From c0626ada56b2f9db36759ed0512512069fcd1663 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 13:20:17 +0200 Subject: [PATCH] #3315: Pin WPF resource IDs when exporting a project A rebuilt WPF project only resolves its own pack URIs when every entry of ".g.resources" comes back under the resource ID it had before. The file on disk cannot carry that ID: it is sanitized for the file system, and the ID itself is escaped. Verified against a WPF assembly built for this: the WPF build tasks re-escape whatever LogicalName they are given, so the item has to hand them the decoded name, and an entry left as EmbeddedResource rebuilds into a manifest resource of its own instead of into ".g.resources". The adjustment is made where the items are collected, so it covers the base class and both hosts that plug their own BAML handling into it without widening the WriteResourceToFile or IResourceFileHandler contracts. The Resource build action this gives them is also what #2253 asks for. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../WholeProjectDecompilerTests.cs | 68 ++++++++++++++++--- .../WholeProjectDecompiler.cs | 33 ++++++++- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs index 4deeaa779..ea54fbd6c 100644 --- a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs +++ b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs @@ -169,9 +169,41 @@ public sealed class WholeProjectDecompilerTests } /// - /// Emits an assembly carrying one embedded .resources container per entry in - /// , each holding a single stream-valued entry. Streams are what - /// makes the export write the entries out as individual files. + /// A rebuilt WPF project only resolves its own pack URIs when every entry of + /// "<AssemblyName>.g.resources" comes back under the resource ID it had before. The file on + /// disk cannot carry that ID - it is sanitized, and the ID is escaped - so each item pins it + /// with a LogicalName holding the decoded name, which is what the WPF build tasks escape again. + /// Entries no handler claimed have to be Resource items as well: as EmbeddedResource they would + /// rebuild into a manifest resource of their own instead of landing in ".g.resources". + /// + [Test] + public void WpfResourceEntriesCarryTheirOriginalResourceIdAsLogicalName() + { + string targetDirectory = Path.Combine(Environment.CurrentDirectory, Path.GetRandomFileName()); + TestFriendlyProjectDecompiler decompiler = new(new UniversalAssemblyResolver(null, false, null)); + decompiler.CaptureResources = true; + + using var assembly = CreateAssemblyWithResources( + ("Test.g.resources", "my%20folder/window.baml"), + ("Test.g.resources", "resource%20test/logo.png"), + ("Test.resources", "plain%25folder/logo.png")); + decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter()); + AssertDirectoryDoesntExist(targetDirectory); + + Assert.That(decompiler.ResourceItems.Select(i => (i.ItemType, i.FileName, i.AdditionalProperties?["LogicalName"])), + Is.EquivalentTo(new[] { + // the build re-derives the .baml extension from the Page item type + ("Page", Path.Combine("my-folder", "window.xaml"), "my folder/window.xaml"), + ("Resource", Path.Combine("resource-test", "logo.png"), "resource test/logo.png"), + // not a WPF container, so the name is neither escaped nor a Resource item + ("EmbeddedResource", Path.Combine("plain-25folder", "logo.png"), "plain%25folder/logo.png"), + })); + } + + /// + /// Emits an assembly carrying one embedded .resources container per distinct container name in + /// , each holding the stream-valued entries named for it. Streams + /// are what makes the export write the entries out as individual files. /// static Stream CreateAssemblyWithResources(params (string ContainerName, string EntryName)[] resources) { @@ -181,14 +213,17 @@ public sealed class WholeProjectDecompilerTests new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); MemoryStream assembly = new(); - var result = compilation.Emit(assembly, manifestResources: resources.Select(r => { - MemoryStream container = new(); - using (ResourceWriter writer = new(container)) + var result = compilation.Emit(assembly, manifestResources: resources.GroupBy(r => r.ContainerName).Select(container => { + MemoryStream contents = new(); + using (ResourceWriter writer = new(contents)) { - writer.AddResource(r.EntryName, new MemoryStream(new byte[] { 1, 2, 3 })); + foreach (var entry in container) + { + writer.AddResource(entry.EntryName, new MemoryStream(new byte[] { 1, 2, 3 })); + } } - byte[] bytes = container.ToArray(); - return new ResourceDescription(r.ContainerName, () => new MemoryStream(bytes), isPublic: true); + byte[] bytes = contents.ToArray(); + return new ResourceDescription(container.Key, () => new MemoryStream(bytes), isPublic: true); }).ToArray()); Assert.That(result.Success, Is.True, () => string.Join(Environment.NewLine, result.Diagnostics)); assembly.Position = 0; @@ -261,6 +296,8 @@ public sealed class WholeProjectDecompilerTests public List WrittenResources { get; } = []; + public List ResourceItems { get; } = []; + // Resources are skipped unless a test asks for them, so the tests that only care about // source files neither touch the disk nor pay for decoding them. public bool CaptureResources { get; set; } @@ -268,7 +305,10 @@ public sealed class WholeProjectDecompilerTests protected override IEnumerable WriteResourceFilesInProject(MetadataFile module) { if (FailResourceWriting || CaptureResources) - return base.WriteResourceFilesInProject(module); + { + ResourceItems.AddRange(base.WriteResourceFilesInProject(module)); + return ResourceItems; + } return FailResourceEnumeration ? Enumerable.Range(0, 1).Select(_ => throw new InvalidOperationException(ResourceFailure)) : []; @@ -284,7 +324,13 @@ public sealed class WholeProjectDecompilerTests throw new InvalidOperationException(ResourceFailure); } WrittenResources.Add(fileName); - return new[] { new ProjectItemInfo("EmbeddedResource", fileName) }; + // Stands in for the BAML resource-file handlers the real hosts plug in: a .baml entry + // is decompiled into a .xaml file referenced by a item, and those handlers + // attach no LogicalName. Everything else keeps the base class' behaviour of naming the + // resource entry as it is stored in the assembly. + return fileName.EndsWith(".baml", StringComparison.OrdinalIgnoreCase) + ? new[] { new ProjectItemInfo("Page", Path.ChangeExtension(fileName, ".xaml")) } + : new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) }; } } } diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs index 64eb77814..055ded316 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs @@ -552,8 +552,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler entryStream.Position = 0; try { - individualResources.AddRange( - WriteResourceToFile(fileName, name, entryStream)); + foreach (var item in WriteResourceToFile(fileName, name, entryStream)) + { + individualResources.Add(entryNamesAreEscaped ? ToWpfProjectItem(item, name) : item); + } } catch (Exception ex) when (!(ex is OperationCanceledException)) { @@ -640,6 +642,33 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler return new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) }; } + /// + /// Adjusts a project item produced from an entry of a WPF-generated ".g.resources" container + /// so that rebuilding the exported project puts the entry back under its original resource + /// ID. The file on disk cannot carry that ID - it is sanitized, and the ID is escaped - so + /// the item pins it with a LogicalName holding the decoded name: the WPF build tasks + /// lower-case and escape the LogicalName again, arriving back at the original ID. Entries + /// that no handler turned into some other item type are WPF Resource items; leaving them as + /// EmbeddedResource would rebuild them into a manifest resource of their own instead of + /// putting them into ".g.resources". + /// + static ProjectItemInfo ToWpfProjectItem(ProjectItemInfo item, string escapedEntryName) + { + string logicalName = Uri.UnescapeDataString(escapedEntryName); + if (item.FileName.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase)) + { + // A decompiled BAML page is written as .xaml, and the build derives the .baml + // extension of the resource ID from the Page item type, not from the LogicalName. + logicalName = Path.ChangeExtension(logicalName, ".xaml"); + } + var result = item with { + ItemType = item.ItemType == "EmbeddedResource" ? "Resource" : item.ItemType + }; + result.AdditionalProperties ??= new Dictionary(); + result.AdditionalProperties["LogicalName"] = logicalName; + return result; + } + /// /// WPF's build tasks put every Page and Resource item into "<AssemblyName>.g.resources" /// (and into "<AssemblyName>.g.<culture>.resources" in satellite assemblies), keyed by