Browse Source

#3315: Pin WPF resource IDs when exporting a project

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 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
pull/4102/head
Siegfried Pammer 2 weeks ago
parent
commit
c0626ada56
  1. 68
      ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs
  2. 33
      ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs

68
ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs

@ -169,9 +169,41 @@ public sealed class WholeProjectDecompilerTests
} }
/// <summary> /// <summary>
/// Emits an assembly carrying one embedded .resources container per entry in /// A rebuilt WPF project only resolves its own pack URIs when every entry of
/// <paramref name="resources"/>, each holding a single stream-valued entry. Streams are what /// "&lt;AssemblyName&gt;.g.resources" comes back under the resource ID it had before. The file on
/// makes the export write the entries out as individual files. /// 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".
/// </summary>
[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"),
}));
}
/// <summary>
/// Emits an assembly carrying one embedded .resources container per distinct container name in
/// <paramref name="resources"/>, each holding the stream-valued entries named for it. Streams
/// are what makes the export write the entries out as individual files.
/// </summary> /// </summary>
static Stream CreateAssemblyWithResources(params (string ContainerName, string EntryName)[] resources) static Stream CreateAssemblyWithResources(params (string ContainerName, string EntryName)[] resources)
{ {
@ -181,14 +213,17 @@ public sealed class WholeProjectDecompilerTests
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
MemoryStream assembly = new(); MemoryStream assembly = new();
var result = compilation.Emit(assembly, manifestResources: resources.Select(r => { var result = compilation.Emit(assembly, manifestResources: resources.GroupBy(r => r.ContainerName).Select(container => {
MemoryStream container = new(); MemoryStream contents = new();
using (ResourceWriter writer = new(container)) 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(); byte[] bytes = contents.ToArray();
return new ResourceDescription(r.ContainerName, () => new MemoryStream(bytes), isPublic: true); return new ResourceDescription(container.Key, () => new MemoryStream(bytes), isPublic: true);
}).ToArray()); }).ToArray());
Assert.That(result.Success, Is.True, () => string.Join(Environment.NewLine, result.Diagnostics)); Assert.That(result.Success, Is.True, () => string.Join(Environment.NewLine, result.Diagnostics));
assembly.Position = 0; assembly.Position = 0;
@ -261,6 +296,8 @@ public sealed class WholeProjectDecompilerTests
public List<string> WrittenResources { get; } = []; public List<string> WrittenResources { get; } = [];
public List<ProjectItemInfo> ResourceItems { get; } = [];
// Resources are skipped unless a test asks for them, so the tests that only care about // 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. // source files neither touch the disk nor pay for decoding them.
public bool CaptureResources { get; set; } public bool CaptureResources { get; set; }
@ -268,7 +305,10 @@ public sealed class WholeProjectDecompilerTests
protected override IEnumerable<ProjectItemInfo> WriteResourceFilesInProject(MetadataFile module) protected override IEnumerable<ProjectItemInfo> WriteResourceFilesInProject(MetadataFile module)
{ {
if (FailResourceWriting || CaptureResources) if (FailResourceWriting || CaptureResources)
return base.WriteResourceFilesInProject(module); {
ResourceItems.AddRange(base.WriteResourceFilesInProject(module));
return ResourceItems;
}
return FailResourceEnumeration return FailResourceEnumeration
? Enumerable.Range(0, 1).Select<int, ProjectItemInfo>(_ => throw new InvalidOperationException(ResourceFailure)) ? Enumerable.Range(0, 1).Select<int, ProjectItemInfo>(_ => throw new InvalidOperationException(ResourceFailure))
: []; : [];
@ -284,7 +324,13 @@ public sealed class WholeProjectDecompilerTests
throw new InvalidOperationException(ResourceFailure); throw new InvalidOperationException(ResourceFailure);
} }
WrittenResources.Add(fileName); 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 <Page> 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) };
} }
} }
} }

33
ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs

@ -552,8 +552,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
entryStream.Position = 0; entryStream.Position = 0;
try try
{ {
individualResources.AddRange( foreach (var item in WriteResourceToFile(fileName, name, entryStream))
WriteResourceToFile(fileName, name, entryStream)); {
individualResources.Add(entryNamesAreEscaped ? ToWpfProjectItem(item, name) : item);
}
} }
catch (Exception ex) when (!(ex is OperationCanceledException)) 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) }; return new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) };
} }
/// <summary>
/// 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".
/// </summary>
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<string, string>();
result.AdditionalProperties["LogicalName"] = logicalName;
return result;
}
/// <summary> /// <summary>
/// WPF's build tasks put every Page and Resource item into "&lt;AssemblyName&gt;.g.resources" /// WPF's build tasks put every Page and Resource item into "&lt;AssemblyName&gt;.g.resources"
/// (and into "&lt;AssemblyName&gt;.g.&lt;culture&gt;.resources" in satellite assemblies), keyed by /// (and into "&lt;AssemblyName&gt;.g.&lt;culture&gt;.resources" in satellite assemblies), keyed by

Loading…
Cancel
Save