Skip to content

Commit 4683f06

Browse files
sunnamed434claude
andcommitted
Rewrite WPF BAML to rename XAML-referenced type names (#212)
Previously XAML-referenced types were only excluded (kept) so WPF apps wouldn't break. Now, when WpfBamlRewrite is enabled (default), FullRenamer also renames those type names and rewrites the BAML (TypeInfoRecord) to match, so XAML types get obfuscated too. Kept deliberately safe: - Only type *names* are renamed; their members are always kept, because binding paths / event-handler wiring reference members as plain strings in BAML (renaming them would break bindings silently). - A type is only renamed if its name appears in no BAML string value (which would be an {x:Type}/TargetType reference we don't rewrite). - Namespaces are kept; renamed type names are dot-free so BAML full-name resolution still works. - If any BAML doc can't be parsed, no rewrite happens (falls back to exclude). WpfBamlContext (parse + analyze + write back) is shared by BamlCriticalAnalyzer and FullRenamer via WpfBamlContextAccessor. Verified on a real net9.0 WPF app: MainWindow/MyControl are renamed, BAML updated, members (incl. the bound Greeting property) kept, and the app loads. WpfBamlRewrite=false restores the keep-only behavior. Full solution builds, all 160 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f49df65 commit 4683f06

10 files changed

Lines changed: 396 additions & 223 deletions

File tree

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
using System.IO;
2+
using BitMono.Core.Renaming;
3+
#if !NETSTANDARD
4+
using System.Resources;
5+
using AsmResolver;
6+
#endif
7+
8+
namespace BitMono.Core.Analyzing.Baml;
9+
10+
/// <summary>
11+
/// Per-module view of the assembly's compiled WPF XAML (BAML in <c>&lt;assembly&gt;.g.resources</c>).
12+
/// <para>
13+
/// It tells the renamer which types/members XAML references (so they aren't broken), and, when
14+
/// rewriting is enabled, renames the XAML-referenced type names and updates the BAML to match.
15+
/// Members of XAML types are always kept (binding paths and the like live in BAML as plain strings),
16+
/// and a type is only renamed when its name appears nowhere as a string value (which would be an
17+
/// <c>{x:Type}</c>/<c>TargetType</c> reference we don't rewrite).
18+
/// </para>
19+
/// </summary>
20+
public sealed class WpfBamlContext
21+
{
22+
/// <summary>All in-module types referenced by XAML. Their members are always kept.</summary>
23+
public HashSet<TypeDefinition> XamlTypes { get; } = new();
24+
25+
/// <summary>Subset of <see cref="XamlTypes"/> whose name can be safely renamed (BAML updated).</summary>
26+
public HashSet<TypeDefinition> RenamableTypes { get; } = new();
27+
28+
#if !NETSTANDARD
29+
private readonly List<(TypeInfoRecord Record, TypeDefinition Type)> _typeEdits = new();
30+
private readonly List<ResourceRewrite> _rewrites = new();
31+
private bool _canRewrite = true;
32+
33+
private sealed class ResourceRewrite
34+
{
35+
public ManifestResource Resource = null!;
36+
public Dictionary<string, BamlDocument> Documents = new();
37+
}
38+
#endif
39+
40+
public static WpfBamlContext Build(ModuleDefinition module, bool rewriteEnabled)
41+
{
42+
var context = new WpfBamlContext();
43+
#if !NETSTANDARD
44+
var assemblyName = module.Assembly?.Name?.Value;
45+
if (string.IsNullOrEmpty(assemblyName))
46+
{
47+
return context;
48+
}
49+
var typesByFullName = new Dictionary<string, TypeDefinition>(StringComparer.Ordinal);
50+
foreach (var type in module.GetAllTypes())
51+
{
52+
var fullName = type.FullName;
53+
if (!string.IsNullOrEmpty(fullName))
54+
{
55+
typesByFullName[fullName] = type;
56+
}
57+
}
58+
59+
foreach (var resource in module.Resources)
60+
{
61+
if (!resource.IsEmbedded)
62+
{
63+
continue;
64+
}
65+
var name = resource.Name?.Value;
66+
if (name == null || !name.EndsWith(".g.resources", StringComparison.OrdinalIgnoreCase))
67+
{
68+
continue;
69+
}
70+
byte[]? data;
71+
try
72+
{
73+
data = resource.GetData();
74+
}
75+
catch
76+
{
77+
continue;
78+
}
79+
if (data == null)
80+
{
81+
continue;
82+
}
83+
try
84+
{
85+
context.ProcessResource(resource, data, assemblyName!, typesByFullName);
86+
}
87+
catch
88+
{
89+
// A resource we can't read just means we exclude/rewrite nothing for it.
90+
context._canRewrite = false;
91+
}
92+
}
93+
94+
context.ComputeRenamable(rewriteEnabled);
95+
#endif
96+
return context;
97+
}
98+
99+
#if !NETSTANDARD
100+
private void ProcessResource(ManifestResource resource, byte[] resourcesData, string assemblyName,
101+
Dictionary<string, TypeDefinition> typesByFullName)
102+
{
103+
var rewrite = new ResourceRewrite { Resource = resource };
104+
using var stream = new MemoryStream(resourcesData, writable: false);
105+
using var reader = new ResourceReader(stream);
106+
var enumerator = reader.GetEnumerator();
107+
while (enumerator.MoveNext())
108+
{
109+
if (enumerator.Key is not string key
110+
|| !key.EndsWith(".baml", StringComparison.OrdinalIgnoreCase))
111+
{
112+
continue;
113+
}
114+
reader.GetResourceData(key, out _, out var rawData);
115+
if (rawData == null || rawData.Length <= 4)
116+
{
117+
continue;
118+
}
119+
var baml = new byte[rawData.Length - 4];
120+
Array.Copy(rawData, 4, baml, 0, baml.Length);
121+
122+
BamlDocument document;
123+
try
124+
{
125+
document = BamlReader.ReadDocument(new MemoryStream(baml, writable: false));
126+
}
127+
catch
128+
{
129+
_canRewrite = false;
130+
continue;
131+
}
132+
rewrite.Documents[key] = document;
133+
CollectFromDocument(document, assemblyName, typesByFullName);
134+
}
135+
if (rewrite.Documents.Count > 0)
136+
{
137+
_rewrites.Add(rewrite);
138+
}
139+
}
140+
141+
private void CollectFromDocument(BamlDocument document, string assemblyName,
142+
Dictionary<string, TypeDefinition> typesByFullName)
143+
{
144+
var assemblies = new Dictionary<ushort, string>();
145+
var typeRecords = new Dictionary<ushort, TypeInfoRecord>();
146+
foreach (var record in document)
147+
{
148+
switch (record)
149+
{
150+
case AssemblyInfoRecord assembly:
151+
assemblies[assembly.AssemblyId] = assembly.AssemblyFullName;
152+
break;
153+
case TypeInfoRecord type:
154+
typeRecords[type.TypeId] = type;
155+
break;
156+
}
157+
}
158+
159+
bool IsLocal(ushort assemblyId)
160+
{
161+
if (!assemblies.TryGetValue(assemblyId, out var full) || full == null)
162+
{
163+
return false;
164+
}
165+
var simple = full;
166+
var comma = simple.IndexOf(',');
167+
if (comma >= 0)
168+
{
169+
simple = simple.Substring(0, comma);
170+
}
171+
return string.Equals(simple.Trim(), assemblyName, StringComparison.OrdinalIgnoreCase);
172+
}
173+
174+
TypeDefinition? Resolve(TypeInfoRecord record)
175+
{
176+
if (!IsLocal(record.AssemblyId))
177+
{
178+
return null;
179+
}
180+
return typesByFullName.TryGetValue(record.TypeFullName ?? string.Empty, out var def) ? def : null;
181+
}
182+
183+
foreach (var record in typeRecords.Values)
184+
{
185+
var def = Resolve(record);
186+
if (def != null)
187+
{
188+
XamlTypes.Add(def);
189+
_typeEdits.Add((record, def));
190+
}
191+
}
192+
foreach (var record in document)
193+
{
194+
if (record is AttributeInfoRecord attribute
195+
&& typeRecords.TryGetValue(attribute.OwnerTypeId, out var ownerRecord)
196+
&& Resolve(ownerRecord) is { } owner)
197+
{
198+
XamlTypes.Add(owner);
199+
}
200+
}
201+
}
202+
203+
private void ComputeRenamable(bool rewriteEnabled)
204+
{
205+
if (!rewriteEnabled || !_canRewrite)
206+
{
207+
return;
208+
}
209+
// A type whose name appears in any BAML string value is referenced as text (e.g. {x:Type},
210+
// TargetType, a binding path), which we don't rewrite - keep those names. Only types
211+
// referenced purely as elements/attributes are safe to rename.
212+
var stringValues = new List<string>();
213+
foreach (var rewrite in _rewrites)
214+
{
215+
foreach (var document in rewrite.Documents.Values)
216+
{
217+
foreach (var record in document)
218+
{
219+
var value = record switch
220+
{
221+
PropertyRecord p => p.Value,
222+
TextRecord t => t.Value,
223+
StringInfoRecord s => s.Value,
224+
DefAttributeRecord d => d.Value,
225+
_ => null
226+
};
227+
if (!string.IsNullOrEmpty(value))
228+
{
229+
stringValues.Add(value!);
230+
}
231+
}
232+
}
233+
}
234+
235+
foreach (var type in XamlTypes)
236+
{
237+
var simpleName = type.Name?.Value;
238+
if (string.IsNullOrEmpty(simpleName))
239+
{
240+
continue;
241+
}
242+
var mentioned = false;
243+
foreach (var value in stringValues)
244+
{
245+
if (value.IndexOf(simpleName!, StringComparison.Ordinal) >= 0)
246+
{
247+
mentioned = true;
248+
break;
249+
}
250+
}
251+
if (!mentioned)
252+
{
253+
RenamableTypes.Add(type);
254+
}
255+
}
256+
}
257+
#endif
258+
259+
/// <summary>
260+
/// Renames the renamable XAML type names (keeping their namespace) and rewrites the BAML so it
261+
/// still points at them. Members are untouched. No-op when rewriting is disabled/unsafe.
262+
/// </summary>
263+
public void ApplyRewrite(ModuleDefinition module, Renamer renamer)
264+
{
265+
#if !NETSTANDARD
266+
if (RenamableTypes.Count == 0)
267+
{
268+
return;
269+
}
270+
foreach (var type in RenamableTypes)
271+
{
272+
if (!renamer.CanRename(type))
273+
{
274+
continue;
275+
}
276+
// BAML resolves a type by its full name (namespace.name), so the new name must not
277+
// contain '.' - swap the renamer's dots for spaces, which are valid in type names.
278+
type.Name = renamer.RenameUnsafely().Replace('.', ' ');
279+
}
280+
foreach (var (record, type) in _typeEdits)
281+
{
282+
record.TypeFullName = type.FullName;
283+
}
284+
foreach (var rewrite in _rewrites)
285+
{
286+
WriteBack(module, rewrite);
287+
}
288+
#endif
289+
}
290+
291+
#if !NETSTANDARD
292+
private static void WriteBack(ModuleDefinition module, ResourceRewrite rewrite)
293+
{
294+
var original = rewrite.Resource.GetData();
295+
if (original == null)
296+
{
297+
return;
298+
}
299+
using var output = new MemoryStream();
300+
using (var writer = new ResourceWriter(output))
301+
using (var reader = new ResourceReader(new MemoryStream(original, writable: false)))
302+
{
303+
var enumerator = reader.GetEnumerator();
304+
while (enumerator.MoveNext())
305+
{
306+
var key = (string)enumerator.Key;
307+
reader.GetResourceData(key, out var typeName, out var resourceData);
308+
if (rewrite.Documents.TryGetValue(key, out var document))
309+
{
310+
using var bamlStream = new MemoryStream();
311+
bamlStream.Position = 4;
312+
BamlWriter.WriteDocument(document, bamlStream);
313+
var length = (int)bamlStream.Length - 4;
314+
bamlStream.Position = 0;
315+
bamlStream.Write(BitConverter.GetBytes(length), 0, 4);
316+
resourceData = bamlStream.ToArray();
317+
}
318+
writer.AddResourceData(key, typeName, resourceData);
319+
}
320+
writer.Generate();
321+
}
322+
var bytes = output.ToArray();
323+
var index = module.Resources.IndexOf(rewrite.Resource);
324+
if (index >= 0)
325+
{
326+
module.Resources[index] = new ManifestResource(rewrite.Resource.Name,
327+
rewrite.Resource.Attributes, new DataSegment(bytes));
328+
}
329+
}
330+
#endif
331+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using BitMono.Core.Services;
2+
3+
namespace BitMono.Core.Analyzing.Baml;
4+
5+
/// <summary>
6+
/// Builds and caches the <see cref="WpfBamlContext"/> for the module being obfuscated, so the
7+
/// critical analyzer (which decides what to keep) and the renamer (which performs the BAML rewrite)
8+
/// share one parse.
9+
/// </summary>
10+
public class WpfBamlContextAccessor
11+
{
12+
private readonly IEngineContextAccessor _engineContextAccessor;
13+
private readonly ObfuscationSettings _obfuscationSettings;
14+
private ModuleDefinition? _module;
15+
private WpfBamlContext? _context;
16+
17+
public WpfBamlContextAccessor(IEngineContextAccessor engineContextAccessor, ObfuscationSettings obfuscationSettings)
18+
{
19+
_engineContextAccessor = engineContextAccessor;
20+
_obfuscationSettings = obfuscationSettings;
21+
}
22+
23+
public WpfBamlContext? GetContext()
24+
{
25+
var module = _engineContextAccessor.Instance?.Module;
26+
if (module == null)
27+
{
28+
return null;
29+
}
30+
if (!ReferenceEquals(_module, module) || _context == null)
31+
{
32+
_module = module;
33+
_context = WpfBamlContext.Build(module, _obfuscationSettings.WpfBamlRewrite);
34+
}
35+
return _context;
36+
}
37+
}

0 commit comments

Comments
 (0)