mirror of
https://github.com/icsharpcode/ILSpy.git
synced 2026-10-12 02:15:58 +00:00
#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
This commit is contained in:
@@ -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 "<AssemblyName>.g.<culture>.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"),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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" }));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -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<string> directories = new HashSet<string>(Platform.FileNameComparer);
|
||||
readonly Dictionary<string, int> resourceFileNames = new Dictionary<string, int>(Platform.FileNameComparer);
|
||||
readonly List<DecompilerException> errors = new List<DecompilerException>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims <paramref name="fileName"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts "_<paramref name="suffix"/>" 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user