Browse Source

Fix #3315: decode URI escapes in WPF resource names

WPF's build tasks key every Page and Resource item in
"<AssemblyName>.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
pull/4102/head
Siegfried Pammer 2 weeks ago
parent
commit
e770bec382
  1. 69
      ICSharpCode.Decompiler.Tests/ProjectDecompiler/WholeProjectDecompilerTests.cs
  2. 21
      ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs

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

@ -20,6 +20,7 @@ using System; @@ -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; @@ -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 @@ -134,6 +138,63 @@ public sealed class WholeProjectDecompilerTests
}
}
/// <summary>
/// WPF's build tasks key every Page and Resource item in "&lt;AssemblyName&gt;.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.
/// </summary>
[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"),
}));
}
/// <summary>
/// Emits an assembly carrying one embedded .resources container per entry in
/// <paramref name="resources"/>, each holding a single stream-valued entry. Streams are what
/// makes the export write the entries out as individual files.
/// </summary>
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 @@ -200,9 +261,13 @@ public sealed class WholeProjectDecompilerTests
public List<string> 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<ProjectItemInfo> WriteResourceFilesInProject(MetadataFile module)
{
if (FailResourceWriting)
if (FailResourceWriting || CaptureResources)
return base.WriteResourceFilesInProject(module);
return FailResourceEnumeration
? Enumerable.Range(0, 1).Select<int, ProjectItemInfo>(_ => throw new InvalidOperationException(ResourceFailure))
@ -213,7 +278,7 @@ public sealed class WholeProjectDecompilerTests @@ -213,7 +278,7 @@ public sealed class WholeProjectDecompilerTests
// "gave up on the rest of them".
protected override IEnumerable<ProjectItemInfo> WriteResourceToFile(string fileName, string resourceName, Stream entryStream)
{
if (WrittenResources.Count == 0)
if (FailResourceWriting && WrittenResources.Count == 0)
{
WrittenResources.Add(fileName);
throw new InvalidOperationException(ResourceFailure);

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

@ -539,9 +539,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler @@ -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 @@ -639,6 +640,24 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
return new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) };
}
/// <summary>
/// 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
/// 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".
/// </summary>
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

Loading…
Cancel
Save