From e770bec382cf848999a7fb27158fa50aa714dedb Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 13:10:42 +0200 Subject: [PATCH 1/4] Fix #3315: decode URI escapes in WPF resource names WPF's build tasks key every Page and Resource item in ".g.resources" by the item's relative path, lower-cased and run through Uri.GetComponents(Path, UriEscaped) - so a folder named "Resource Test" arrives as "resource%20test", and a folder named in any non-ASCII script arrives as a run of UTF-8 percent escapes. Those escapes are not part of the name; sanitizing them turned "resource%20test" into "resource-20test" and any Chinese or umlaut folder into a line of hex. Measured against a WPF assembly built for this: only space, '#', '{', '}' and non-ASCII bytes are ever escaped, and Uri.UnescapeDataString is the exact inverse - anything the escaper leaves alone contains no percent sign, and a literal one arrives as %25. Only the WPF-generated containers are affected, so a percent sign in any other .resources file stays part of the name. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../WholeProjectDecompilerTests.cs | 69 ++++++++++++++++++- .../WholeProjectDecompiler.cs | 21 +++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs index 4d0d25c8e..4deeaa779 100644 --- a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs +++ b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Resources; using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; @@ -28,6 +29,9 @@ using ICSharpCode.Decompiler.CSharp.Transforms; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + using NUnit.Framework; namespace ICSharpCode.Decompiler.Tests.ProjectDecompiler; @@ -134,6 +138,63 @@ public sealed class WholeProjectDecompilerTests } } + /// + /// WPF's build tasks key every Page and Resource item in "<AssemblyName>.g.resources" by its + /// relative path, lower-cased and URI-escaped, so a folder named "My Folder" arrives as + /// "my%20folder". The escapes have to be decoded before the name becomes a file name - + /// sanitizing them instead produces a directory called "my-20folder" (issue #3315). Only the + /// WPF-generated containers are escaped; a percent sign in any other .resources file is part + /// of the name. + /// + [Test] + public void WpfResourceIdsAreUnescapedBeforeSanitizing() + { + 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.de-DE.resources", "my%20folder/window.baml"), + ("Test.resources", "100%25off/window.baml")); + decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter()); + AssertDirectoryDoesntExist(targetDirectory); + + Assert.That(decompiler.WrittenResources, Is.EquivalentTo(new[] { + Path.Combine("my-folder", "window.baml"), + Path.Combine("my-folder", "window.baml"), + // not a WPF container, so "%25" is three characters of the name and not an escaped '%' + Path.Combine("100-25off", "window.baml"), + })); + } + + /// + /// 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. + /// + static Stream CreateAssemblyWithResources(params (string ContainerName, string EntryName)[] resources) + { + var compilation = CSharpCompilation.Create("Test", + new[] { CSharpSyntaxTree.ParseText("[assembly: System.Runtime.Versioning.TargetFramework(\".NETCoreApp,Version=v8.0\")] class C { }") }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + MemoryStream assembly = new(); + var result = compilation.Emit(assembly, manifestResources: resources.Select(r => { + MemoryStream container = new(); + using (ResourceWriter writer = new(container)) + { + writer.AddResource(r.EntryName, new MemoryStream(new byte[] { 1, 2, 3 })); + } + byte[] bytes = container.ToArray(); + return new ResourceDescription(r.ContainerName, () => new MemoryStream(bytes), isPublic: true); + }).ToArray()); + Assert.That(result.Success, Is.True, () => string.Join(Environment.NewLine, result.Diagnostics)); + assembly.Position = 0; + return assembly; + } + sealed class ThrowingAstTransform(string typeName) : IAstTransform { public const string Failure = "Simulated AST transform failure"; @@ -200,9 +261,13 @@ public sealed class WholeProjectDecompilerTests public List WrittenResources { 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; } + protected override IEnumerable WriteResourceFilesInProject(MetadataFile module) { - if (FailResourceWriting) + if (FailResourceWriting || CaptureResources) return base.WriteResourceFilesInProject(module); return FailResourceEnumeration ? Enumerable.Range(0, 1).Select(_ => throw new InvalidOperationException(ResourceFailure)) @@ -213,7 +278,7 @@ public sealed class WholeProjectDecompilerTests // "gave up on the rest of them". protected override IEnumerable WriteResourceToFile(string fileName, string resourceName, Stream entryStream) { - if (WrittenResources.Count == 0) + if (FailResourceWriting && WrittenResources.Count == 0) { WrittenResources.Add(fileName); throw new InvalidOperationException(ResourceFailure); diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs index 01b908530..64eb77814 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs @@ -539,9 +539,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler var resourcesFile = new ResourcesFile(stream); if (resourcesFile.AllEntriesAreStreams()) { + bool entryNamesAreEscaped = IsWpfGeneratedResourceContainer(r.Name); foreach (var (name, value) in resourcesFile) { - string fileName = SanitizeFileName(name); + string fileName = SanitizeFileName(entryNamesAreEscaped ? Uri.UnescapeDataString(name) : name); string? dirName = Path.GetDirectoryName(fileName); if (!string.IsNullOrEmpty(dirName) && directories.Add(dirName)) { @@ -639,6 +640,24 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler return new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) }; } + /// + /// 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 + /// the item's relative path, lower-cased and URI-escaped: a folder named "My Images" becomes + /// "my%20images". Those escapes are not part of the name and have to be decoded before the + /// name is turned into a file name, otherwise the percent sign is sanitized away and + /// "my%20images/logo.png" lands in a directory called "my-20images". + /// + static bool IsWpfGeneratedResourceContainer(string resourceName) + { + const string extension = ".resources"; + if (!resourceName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + return false; + string name = resourceName.Substring(0, resourceName.Length - extension.Length); + return name.EndsWith(".g", StringComparison.OrdinalIgnoreCase) + || name.Substring(0, Math.Max(0, name.LastIndexOf('.'))).EndsWith(".g", StringComparison.OrdinalIgnoreCase); + } + string GetFileNameForResource(string fullName) { // Clean up the name first and ensure the length does not exceed the maximum length From 65092d8bd805b9cac3d36e234b4e065c6a080959 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 13:08:51 +0200 Subject: [PATCH 2/4] #3315: List all decompiled project items in SDK-style projects The SDK-style writer only ever emitted EmbeddedResource items, so every other item type the export produced never reached the project file. XAML recovered from BAML lands in Page items: ILSpy wrote the .xaml files to disk and the project referenced none of them, leaving an exported WPF project that cannot rebuild. Explicit items collide with the SDK's own globs - a UseWPF project globs **/*.xaml into Page, and NETSDK1022 rejects the duplicate - so each include is preceded by a remove of the same item type, the pattern the EmbeddedResource path already uses. Setting EnableDefaultPageItems=false would work as well, but it is WPF-specific and switches off a glob for the whole project, whereas the remove is per item, applies to any item type, and keeps the SDK's item definitions (XamlRuntime) in effect. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../ProjectFileWriterSdkStyleTests.cs | 82 +++++++++++++++++++ .../ProjectFileWriterSdkStyle.cs | 44 ++++++++++ 2 files changed, 126 insertions(+) create mode 100644 ICSharpCode.Decompiler.Tests/ProjectDecompiler/ProjectFileWriterSdkStyleTests.cs diff --git a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/ProjectFileWriterSdkStyleTests.cs b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/ProjectFileWriterSdkStyleTests.cs new file mode 100644 index 000000000..cf6838f2f --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/ProjectFileWriterSdkStyleTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; + +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; +using ICSharpCode.Decompiler.Metadata; + +using NUnit.Framework; + +namespace ICSharpCode.Decompiler.Tests.ProjectDecompiler; + +[TestFixture] +public sealed class ProjectFileWriterSdkStyleTests +{ + /// + /// Everything the export wrote to disk has to be reachable from the project file, or the + /// exported project cannot rebuild. XAML recovered from BAML is the case that hurts: it lands + /// in "Page" items, and a WPF project without them compiles no XAML at all. + /// + [Test] + public void ItemTypesOtherThanEmbeddedResourceAreListed() + { + ProjectItemInfo[] files = [ + new ProjectItemInfo("Page", "Themes/Generic.xaml") + .With("Generator", "MSBuild:Compile") + .With("SubType", "Designer"), + new ProjectItemInfo("Compile", "Program.cs"), + ]; + + string project = WriteProjectFile(files); + + using (Assert.EnterMultipleScope()) + { + Assert.That(project, Does.Contain(@""), + "the XAML file is a project item, metadata included"); + Assert.That(project, Does.Contain(@""), + "the SDK's own **/*.xaml glob would otherwise make the explicit item a duplicate"); + Assert.That(project.IndexOf(@" LanguageVersion.Latest; + public bool CheckForOverflowUnderflow => false; + public Guid ProjectGuid { get; } = Guid.NewGuid(); + public string TargetDirectory { get; } = Environment.CurrentDirectory; + public string StrongNameKeyFile => null; + } +} diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs index e7d75c568..5af87d04c 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs @@ -56,6 +56,18 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler "System.Xaml", }; + /// + /// Item types that must not be listed as project items: source files are covered by the + /// SDK's default Compile glob, the icon and the manifest are written as properties, and + /// embedded resources have their own remove/include phase. + /// + static readonly HashSet ItemTypesWrittenElsewhere = new HashSet { + "ApplicationIcon", + "ApplicationManifest", + "Compile", + "EmbeddedResource", + }; + enum ProjectType { Default, WinForms, Wpf, Web } /// @@ -287,6 +299,38 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler } xml.WriteEndElement(); } + + // Every other item type the export produced, "Page" for XAML recovered from BAML above + // all. Files the project does not list are files the exported project cannot build. + foreach (var group in files.Where(t => !ItemTypesWrittenElsewhere.Contains(t.ItemType)) + .GroupBy(t => t.ItemType).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + var items = group.OrderBy(t => t.FileName, StringComparer.OrdinalIgnoreCase).ToList(); + + // remove phase: an SDK glob may already have claimed these files - a WPF project + // (UseWPF) globs **/*.xaml into Page - and the same file in an item type twice is + // a build error. Removing first, in the same item group, keeps the explicit item + // with its metadata and drops the globbed one. + foreach (var item in items) + { + xml.WriteStartElement(group.Key); + xml.WriteAttributeString("Remove", item.FileName); + xml.WriteEndElement(); + } + + // include phase + foreach (var item in items) + { + xml.WriteStartElement(group.Key); + xml.WriteAttributeString("Include", item.FileName); + if (item.AdditionalProperties != null) + { + foreach (var (key, value) in item.AdditionalProperties) + xml.WriteAttributeString(key, value); + } + xml.WriteEndElement(); + } + } } void WriteReferences(XmlTextWriter xml, MetadataFile module, IProjectInfoProvider project) From c0626ada56b2f9db36759ed0512512069fcd1663 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 13:20:17 +0200 Subject: [PATCH 3/4] #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 From d77a86f5934b0fcec0527607eb512d1d1e60f65b Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 15:17:07 +0200 Subject: [PATCH 4/4] #3315: Give colliding resource names separate files on export Sanitizing a resource name is not injective: "a+b/logo.png", "a&b/logo.png" and "a#b/logo.png" all come out as "a-b/logo.png". The writers create files with FileMode.Create, so every colliding entry but the last was lost, and nothing was written to the error list to say so - an assembly can be built to make that happen to as many entries as it likes. A WPF probe assembly with 20 resource entries exported as 13 files. Uniquifying is enough because the exported item already pins the true name in its LogicalName, so the file on disk has to be unique, not faithful. The suffix search resumes where the previous collision on a name left off, so a crafted pile of collisions stays linear rather than quadratic, and the name is trimmed to keep the segment within the file system's limit. Directory creation moves inside the per-entry error recovery for the same reason: an entry named after a directory another entry needs makes it throw, and that has to cost the one entry rather than the rest of the container. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../WholeProjectDecompilerTests.cs | 44 ++++++++++- .../WholeProjectDecompiler.cs | 74 +++++++++++++++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs index ea54fbd6c..7bf30f574 100644 --- a/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs +++ b/ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs @@ -155,19 +155,59 @@ public sealed class WholeProjectDecompilerTests using var assembly = CreateAssemblyWithResources( ("Test.g.resources", "my%20folder/window.baml"), - ("Test.g.de-DE.resources", "my%20folder/window.baml"), + // satellite assemblies hold the localized pages in ".g..resources" + ("Test.g.de-DE.resources", "my%20folder/other.baml"), ("Test.resources", "100%25off/window.baml")); decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter()); AssertDirectoryDoesntExist(targetDirectory); Assert.That(decompiler.WrittenResources, Is.EquivalentTo(new[] { Path.Combine("my-folder", "window.baml"), - Path.Combine("my-folder", "window.baml"), + Path.Combine("my-folder", "other.baml"), // not a WPF container, so "%25" is three characters of the name and not an escaped '%' Path.Combine("100-25off", "window.baml"), })); } + /// + /// Sanitizing is not injective: "a+b/logo.png", "a&b/logo.png" and "a%23b/logo.png" all end up + /// as "a-b/logo.png", and an assembly can be crafted to aim any number of entries at one name. + /// Colliding entries used to overwrite each other silently, costing the export their contents + /// with nothing written to the error list. Each one now gets a file of its own; the item still + /// carries the true name, so all of them survive a rebuild. + /// + [Test] + public void ResourcesThatSanitizeToTheSameNameGetSeparateFiles() + { + 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", "a+b/logo.png"), + ("Test.g.resources", "a&b/logo.png"), + ("Test.g.resources", "a%23b/logo.png"), + // the same name in a second container is a different resource and needs its own file too + ("Test.g.de-DE.resources", "a+b/logo.png")); + decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter()); + AssertDirectoryDoesntExist(targetDirectory); + + // Entries come back in the order the .resources format stores them, not the order they were + // written in, so which entry draws which suffix is not fixed - only that the four of them + // occupy four files and keep their four names. + using (Assert.EnterMultipleScope()) + { + Assert.That(decompiler.WrittenResources, Is.EquivalentTo(new[] { + Path.Combine("a-b", "logo.png"), + Path.Combine("a-b", "logo_2.png"), + Path.Combine("a-b", "logo_3.png"), + Path.Combine("a-b", "logo_4.png"), + })); + Assert.That(decompiler.ResourceItems.Select(i => i.AdditionalProperties?["LogicalName"]), + Is.EquivalentTo(new[] { "a+b/logo.png", "a&b/logo.png", "a#b/logo.png", "a+b/logo.png" })); + } + } + /// /// 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 diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs index 055ded316..3f5da58e5 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection.Metadata; @@ -151,6 +152,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler // per-run members HashSet directories = new HashSet(Platform.FileNameComparer); + readonly Dictionary resourceFileNames = new Dictionary(Platform.FileNameComparer); readonly List errors = new List(); readonly IProjectFileWriter projectWriter; @@ -256,6 +258,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler { TargetDirectory = targetDirectory; directories.Clear(); + resourceFileNames.Clear(); errors.Clear(); var resources = RecordingErrors(WriteResourceFilesInProject(file), file, "resource files").ToList(); resourceFileCount = resources.Count; @@ -542,16 +545,20 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler bool entryNamesAreEscaped = IsWpfGeneratedResourceContainer(r.Name); foreach (var (name, value) in resourcesFile) { - string fileName = SanitizeFileName(entryNamesAreEscaped ? Uri.UnescapeDataString(name) : name); + string fileName = ReserveResourceFileName(SanitizeFileName(entryNamesAreEscaped ? Uri.UnescapeDataString(name) : name)); string? dirName = Path.GetDirectoryName(fileName); - if (!string.IsNullOrEmpty(dirName) && directories.Add(dirName)) - { - CreateDirectory(Path.Combine(TargetDirectory, dirName)); - } Stream entryStream = (Stream)value!; entryStream.Position = 0; try { + // Inside the recovery, because an entry named after a directory another + // entry needs makes this throw, and that must cost the one entry rather + // than every entry left in the container. + if (!string.IsNullOrEmpty(dirName) && !directories.Contains(dirName)) + { + CreateDirectory(Path.Combine(TargetDirectory, dirName)); + directories.Add(dirName); + } foreach (var item in WriteResourceToFile(fileName, name, entryStream)) { individualResources.Add(entryNamesAreEscaped ? ToWpfProjectItem(item, name) : item); @@ -590,7 +597,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler else { stream.Position = 0; - string fileName = GetFileNameForResource(r.Name); + string fileName = ReserveResourceFileName(GetFileNameForResource(r.Name)); foreach (var entry in WriteResourceToFile(fileName, r.Name, stream)) { yield return entry; @@ -599,7 +606,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler } else { - string fileName = GetFileNameForResource(r.Name); + string fileName = ReserveResourceFileName(GetFileNameForResource(r.Name)); using (FileStream fs = new FileStream(Path.Combine(TargetDirectory, fileName), FileMode.Create, FileAccess.Write)) { stream.Position = 0; @@ -669,6 +676,59 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler return result; } + /// + /// Claims for one resource, appending "_2", "_3", ... until the + /// name is free. Sanitizing is not injective - "a+b/logo.png" and "a&b/logo.png" both come out + /// as "a-b/logo.png" - and the writers create files with FileMode.Create, so without this the + /// entries after the first are lost silently, and an assembly can be built to make that happen + /// to as many of them as it likes. The exported project keeps the true name in the item's + /// LogicalName, so the file on disk only has to be unique, not faithful. + /// + string ReserveResourceFileName(string fileName) + { + if (!resourceFileNames.TryGetValue(fileName, out int lastSuffix)) + { + resourceFileNames.Add(fileName, 1); + return fileName; + } + // Resuming the count where the last collision on this name left off keeps the export + // linear in the number of colliding entries; restarting at 2 each time would make it + // quadratic, which is worth something when the count is the assembly's to choose. + string candidate; + do + { + candidate = AppendFileNameSuffix(fileName, ++lastSuffix); + } + while (resourceFileNames.ContainsKey(candidate)); + resourceFileNames[fileName] = lastSuffix; + resourceFileNames.Add(candidate, 1); + return candidate; + } + + /// + /// Inserts "_" before the extension, trimming the name if the segment + /// would otherwise outgrow what the file system takes - a name already at the limit is an + /// ordinary thing to find in an assembly, and the write would throw. + /// + static string AppendFileNameSuffix(string fileName, int suffix) + { + string directory = Path.GetDirectoryName(fileName) ?? string.Empty; + string name = Path.GetFileNameWithoutExtension(fileName); + string extension = Path.GetExtension(fileName); + string marker = "_" + suffix.ToString(CultureInfo.InvariantCulture); + // Trimming whole characters removes at least as many bytes as it has to, so measuring the + // overflow the way CleanUpName measures a segment cannot leave the result over the limit. + int overflow = SegmentLength(name) + SegmentLength(marker) + SegmentLength(extension) - maxSegmentLength; + if (overflow > 0) + name = name.Substring(0, Math.Max(0, name.Length - overflow)); + return Path.Combine(directory, name + marker + extension); + } + + static int SegmentLength(string text) + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? text.Length : Encoding.UTF8.GetByteCount(text); + } + /// /// 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