mirror of https://github.com/icsharpcode/ILSpy.git
5 changed files with 499 additions and 15 deletions
@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
|
||||
// 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.Collections.Generic; |
||||
using System.IO; |
||||
using System.Text; |
||||
using System.Threading.Tasks; |
||||
|
||||
using ICSharpCode.Decompiler; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
using static ICSharpCode.ILSpyCmd.Tests.CliTestRunner; |
||||
|
||||
namespace ICSharpCode.ILSpyCmd.Tests |
||||
{ |
||||
/// <summary>
|
||||
/// A single-file bundle is a host executable with a payload appended to it: the embedded
|
||||
/// files, a manifest, and - as the last bytes of the file - the manifest offset followed by
|
||||
/// the bundle signature. Only that trailer and the manifest are read to detect a bundle and
|
||||
/// find its entries, so these tests build the payload around a real assembly and leave the
|
||||
/// host stub empty, rather than publishing a self-contained app at test time.
|
||||
/// </summary>
|
||||
[TestFixture] |
||||
public class ILSpyCmdBundleEntryOptionTests |
||||
{ |
||||
static readonly string testAssemblyPath = typeof(ILSpyCmdBundleEntryOptionTests).Assembly.Location; |
||||
|
||||
// The 32-byte bundle signature, as written by the .NET bundler.
|
||||
static readonly byte[] bundleSignature = { |
||||
0x8b, 0x12, 0x02, 0xb9, 0x6a, 0x61, 0x20, 0x38, |
||||
0x72, 0x7b, 0x93, 0x02, 0x14, 0xd7, 0xa0, 0x32, |
||||
0x13, 0xf5, 0xb9, 0xe6, 0xef, 0xae, 0x33, 0x18, |
||||
0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae |
||||
}; |
||||
|
||||
string tempDirectory; |
||||
string bundlePath; |
||||
string bundleWithoutRuntimeConfigPath; |
||||
|
||||
[OneTimeSetUp] |
||||
public void CreateBundles() |
||||
{ |
||||
tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); |
||||
Directory.CreateDirectory(tempDirectory); |
||||
bundlePath = Path.Combine(tempDirectory, "SampleApp.exe"); |
||||
bundleWithoutRuntimeConfigPath = Path.Combine(tempDirectory, "SampleAppV1.exe"); |
||||
WriteBundle(bundlePath, withRuntimeConfig: true); |
||||
WriteBundle(bundleWithoutRuntimeConfigPath, withRuntimeConfig: false); |
||||
} |
||||
|
||||
[OneTimeTearDown] |
||||
public void DeleteBundles() |
||||
{ |
||||
if (tempDirectory != null && Directory.Exists(tempDirectory)) |
||||
Directory.Delete(tempDirectory, recursive: true); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Writes a bundle containing two managed assemblies (both copies of this test assembly)
|
||||
/// and, optionally, the runtime-config entry that identifies "Sample.dll" as the app.
|
||||
/// </summary>
|
||||
static void WriteBundle(string path, bool withRuntimeConfig) |
||||
{ |
||||
byte[] assemblyBytes = File.ReadAllBytes(testAssemblyPath); |
||||
var entries = new List<(string Name, SingleFileBundle.FileType Type, long Offset, long Size)>(); |
||||
|
||||
using var stream = File.Create(path); |
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); |
||||
|
||||
// Stand-in for the host executable the payload is appended to. Bundle detection only
|
||||
// requires that the signature is not within the first eight bytes of the file.
|
||||
writer.Write(new byte[64]); |
||||
|
||||
void WriteEntry(string name, SingleFileBundle.FileType type, byte[] contents) |
||||
{ |
||||
long offset = stream.Position; |
||||
writer.Write(contents); |
||||
entries.Add((name, type, offset, contents.Length)); |
||||
} |
||||
|
||||
WriteEntry("Sample.dll", SingleFileBundle.FileType.Assembly, assemblyBytes); |
||||
WriteEntry("Helper.dll", SingleFileBundle.FileType.Assembly, assemblyBytes); |
||||
if (withRuntimeConfig) |
||||
{ |
||||
WriteEntry("Sample.runtimeconfig.json", SingleFileBundle.FileType.RuntimeConfigJson, |
||||
Encoding.UTF8.GetBytes("{ \"runtimeOptions\": { } }")); |
||||
} |
||||
|
||||
long headerOffset = stream.Position; |
||||
writer.Write((uint)6); // MajorVersion: entries carry a compressed size
|
||||
writer.Write((uint)0); // MinorVersion
|
||||
writer.Write(entries.Count); |
||||
writer.Write("bundle-id"); |
||||
writer.Write(0L); // DepsJsonOffset
|
||||
writer.Write(0L); // DepsJsonSize
|
||||
writer.Write(0L); // RuntimeConfigJsonOffset
|
||||
writer.Write(0L); // RuntimeConfigJsonSize
|
||||
writer.Write(0UL); // Flags
|
||||
foreach (var entry in entries) |
||||
{ |
||||
writer.Write(entry.Offset); |
||||
writer.Write(entry.Size); |
||||
writer.Write(0L); // CompressedSize: entries are stored uncompressed
|
||||
writer.Write((byte)entry.Type); |
||||
writer.Write(entry.Name); |
||||
} |
||||
|
||||
writer.Write(headerOffset); |
||||
writer.Write(bundleSignature); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task BundleWithoutEntryListsManagedEntries() |
||||
{ |
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_USAGE)); |
||||
Assert.That(result.Error, Does.Contain("--bundle-entry")); |
||||
Assert.That(result.Error, Does.Contain("Sample.dll")); |
||||
Assert.That(result.Error, Does.Contain("Helper.dll")); |
||||
// The manifest entry that is not an assembly is not something to decompile.
|
||||
Assert.That(result.Error, Does.Not.Contain("Sample.runtimeconfig.json")); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task BundleWithoutEntryMarksEntryPoint() |
||||
{ |
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_USAGE)); |
||||
Assert.That(result.Error, Does.Contain("Sample.dll (entry point)")); |
||||
Assert.That(result.Error, Does.Not.Contain("Helper.dll (entry point)")); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Without a runtime-config entry the app assembly cannot be derived. The listing is
|
||||
/// still the answer to the question asked, it just carries no annotation.
|
||||
/// </summary>
|
||||
[Test] |
||||
public async Task BundleWithoutRuntimeConfigListsEntriesUnmarked() |
||||
{ |
||||
var result = await RunAsync(bundleWithoutRuntimeConfigPath, "--disable-updatecheck"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_USAGE)); |
||||
Assert.That(result.Error, Does.Contain("Sample.dll")); |
||||
Assert.That(result.Error, Does.Contain("Helper.dll")); |
||||
Assert.That(result.Error, Does.Not.Contain("(entry point)")); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task NamedEntryIsDecompiled() |
||||
{ |
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck", "--bundle-entry", "Sample.dll", |
||||
"-m", "M:ICSharpCode.ILSpyCmd.Tests.MemberOptionSample.Add(System.Int32,System.Int32)"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); |
||||
Assert.That(result.Output, Does.Contain("int Add(int a, int b)")); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task EntryNameIsMatchedCaseInsensitively() |
||||
{ |
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck", "--bundle-entry", "sample.DLL", |
||||
"-m", "M:ICSharpCode.ILSpyCmd.Tests.MemberOptionSample.Add(System.Int32,System.Int32)"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); |
||||
Assert.That(result.Output, Does.Contain("int Add(int a, int b)")); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// The five call sites that loaded the input file separately are the reason every mode
|
||||
/// failed on a bundle; -il is one of the modes that never reaches the default path.
|
||||
/// </summary>
|
||||
[Test] |
||||
public async Task NamedEntryWorksInILMode() |
||||
{ |
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck", "--bundle-entry", "Sample.dll", "-il"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); |
||||
Assert.That(result.Output, Does.Contain(".class")); |
||||
Assert.That(result.Output, Does.Contain("MemberOptionSample")); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task UnknownEntryNameListsValidEntries() |
||||
{ |
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck", "--bundle-entry", "NotThere.dll"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_DATAERR)); |
||||
Assert.That(result.Error, Does.Contain("NotThere.dll")); |
||||
Assert.That(result.Error, Does.Contain("Sample.dll")); |
||||
Assert.That(result.Error, Does.Contain("Helper.dll")); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task DumpPackageStillWorksWithoutBundleEntry() |
||||
{ |
||||
string outputDir = Path.Combine(tempDirectory, Path.GetRandomFileName()); |
||||
|
||||
var result = await RunAsync(bundlePath, "--disable-updatecheck", "-d", "-o", outputDir); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); |
||||
Assert.That(File.Exists(Path.Combine(outputDir, "Sample.dll")), Is.True); |
||||
Assert.That(File.Exists(Path.Combine(outputDir, "Helper.dll")), Is.True); |
||||
} |
||||
|
||||
[Test] |
||||
public async Task OrdinaryAssemblyIsUnaffected() |
||||
{ |
||||
var result = await RunAsync(testAssemblyPath, "--disable-updatecheck", |
||||
"-m", "M:ICSharpCode.ILSpyCmd.Tests.MemberOptionSample.Add(System.Int32,System.Int32)"); |
||||
|
||||
Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); |
||||
Assert.That(result.Output, Does.Contain("int Add(int a, int b)")); |
||||
Assert.That(result.Error, Does.Not.Contain("--bundle-entry")); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
|
||||
// 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.Collections.Generic; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using System.Reflection.Metadata; |
||||
using System.Reflection.PortableExecutable; |
||||
using System.Text; |
||||
|
||||
using ICSharpCode.Decompiler; |
||||
using ICSharpCode.Decompiler.Metadata; |
||||
using ICSharpCode.ILSpyX; |
||||
using ICSharpCode.ILSpyX.FileLoaders; |
||||
|
||||
namespace ICSharpCode.ILSpyCmd |
||||
{ |
||||
/// <summary>
|
||||
/// Thrown when the input file is a package (a single-file bundle, an archive) and the entry
|
||||
/// to work on was either not named or names nothing in the package. The message is the
|
||||
/// complete text to print, including the entries to choose from.
|
||||
/// </summary>
|
||||
public sealed class PackageEntryRequiredException : Exception |
||||
{ |
||||
public PackageEntryRequiredException(string message, int exitCode) |
||||
: base(message) |
||||
{ |
||||
this.ExitCode = exitCode; |
||||
} |
||||
|
||||
public int ExitCode { get; } |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Turns an input file name into the module to work on. Every mode of the tool loads its
|
||||
/// input through here, so a package is recognized the same way in all of them.
|
||||
/// </summary>
|
||||
static class InputFileLoader |
||||
{ |
||||
static readonly FileLoaderRegistry loaders = new FileLoaderRegistry(); |
||||
|
||||
/// <summary>
|
||||
/// Loads <paramref name="fileName"/>, or the entry named by <paramref name="entryName"/>
|
||||
/// if the file turns out to be a package.
|
||||
/// </summary>
|
||||
/// <exception cref="PackageEntryRequiredException">
|
||||
/// The file is a package and <paramref name="entryName"/> is null or unknown.
|
||||
/// </exception>
|
||||
public static PEFile Load(string fileName, string entryName, bool applyWinRTProjections = true) |
||||
{ |
||||
var context = new FileLoadContext(applyWinRTProjections, null); |
||||
var result = LoadFile(fileName, context); |
||||
if (result?.Package is { } package) |
||||
{ |
||||
return LoadPackageEntry(package, entryName, context); |
||||
} |
||||
if (result?.MetadataFile is PEFile module) |
||||
{ |
||||
return module; |
||||
} |
||||
// Not a file any loader recognized: let PEFile report what is wrong with it, which is
|
||||
// the error the tool has always produced for such input.
|
||||
return new PEFile(fileName, metadataOptions: MetadataOptions(context)); |
||||
} |
||||
|
||||
static LoadResult LoadFile(string fileName, FileLoadContext context) |
||||
{ |
||||
using var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read); |
||||
foreach (var loader in loaders.RegisteredLoaders) |
||||
{ |
||||
stream.Position = 0; |
||||
LoadResult result; |
||||
try |
||||
{ |
||||
// The loaders are synchronous in fact; only their signature is not.
|
||||
result = loader.Load(fileName, stream, context).GetAwaiter().GetResult(); |
||||
} |
||||
catch (Exception) |
||||
{ |
||||
// A loader that chokes on a file it does not own says nothing about the file.
|
||||
// If no other loader claims it either, the caller reloads it as a PE file and
|
||||
// reports the failure from there.
|
||||
continue; |
||||
} |
||||
if (result?.IsSuccess == true) |
||||
return result; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
static PEFile LoadPackageEntry(LoadedPackage package, string entryName, FileLoadContext context) |
||||
{ |
||||
var managedEntries = GetManagedEntries(package); |
||||
string kind = package.Kind == LoadedPackage.PackageKind.Bundle ? "single-file bundle" : "package"; |
||||
|
||||
if (string.IsNullOrEmpty(entryName)) |
||||
{ |
||||
throw new PackageEntryRequiredException( |
||||
BuildListing($"error: {kind}; name an entry with --bundle-entry <name>.", package, managedEntries), |
||||
ProgramExitCodes.EX_USAGE); |
||||
} |
||||
|
||||
var entry = managedEntries.FirstOrDefault(e => string.Equals(entryName, e.Name, StringComparison.OrdinalIgnoreCase)); |
||||
if (entry == null) |
||||
{ |
||||
throw new PackageEntryRequiredException( |
||||
BuildListing($"error: '{entryName}' is not a managed entry of this {kind}.", package, managedEntries), |
||||
ProgramExitCodes.EX_DATAERR); |
||||
} |
||||
|
||||
using var stream = entry.TryOpenStream(); |
||||
if (stream == null) |
||||
{ |
||||
throw new PackageEntryRequiredException( |
||||
$"error: entry '{entry.Name}' could not be read from the {kind}.", |
||||
ProgramExitCodes.EX_DATAERR); |
||||
} |
||||
stream.Position = 0; |
||||
return new PEFile(entry.Name, stream, |
||||
PEStreamOptions.PrefetchEntireImage | PEStreamOptions.LeaveOpen, |
||||
MetadataOptions(context)); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// The entries worth decompiling. A bundle manifest states the type of each entry; an
|
||||
/// archive - and a bundle whose entries are all typed as unknown - is filtered by
|
||||
/// extension instead.
|
||||
/// </summary>
|
||||
static IReadOnlyList<PackageEntry> GetManagedEntries(LoadedPackage package) |
||||
{ |
||||
var assemblyNames = GetAssemblyEntryNames(package); |
||||
if (assemblyNames.Count > 0) |
||||
{ |
||||
return package.Entries.Where(e => assemblyNames.Contains(e.Name)).ToList(); |
||||
} |
||||
return package.Entries |
||||
.Where(e => e.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) |
||||
|| e.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) |
||||
.ToList(); |
||||
} |
||||
|
||||
static HashSet<string> GetAssemblyEntryNames(LoadedPackage package) |
||||
{ |
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
||||
var entries = package.BundleHeader.Entries; |
||||
if (!entries.IsDefaultOrEmpty) |
||||
{ |
||||
foreach (var entry in entries) |
||||
{ |
||||
if (entry.Type == SingleFileBundle.FileType.Assembly) |
||||
names.Add(entry.RelativePath); |
||||
} |
||||
} |
||||
return names; |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// The name of the application assembly, or null if it cannot be determined. The manifest
|
||||
/// carries no entry-point marker, so it is derived the way the host does it: from the
|
||||
/// runtime-config entry, whose name is the application name plus ".runtimeconfig.json".
|
||||
/// </summary>
|
||||
static string FindEntryPoint(LoadedPackage package, IReadOnlyList<PackageEntry> managedEntries) |
||||
{ |
||||
const string suffix = ".runtimeconfig.json"; |
||||
var entries = package.BundleHeader.Entries; |
||||
if (entries.IsDefaultOrEmpty) |
||||
return null; |
||||
foreach (var entry in entries) |
||||
{ |
||||
if (entry.Type != SingleFileBundle.FileType.RuntimeConfigJson) |
||||
continue; |
||||
if (!entry.RelativePath.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) |
||||
continue; |
||||
string candidate = entry.RelativePath[..^suffix.Length] + ".dll"; |
||||
var match = managedEntries.FirstOrDefault(e => string.Equals(candidate, e.Name, StringComparison.OrdinalIgnoreCase)); |
||||
if (match != null) |
||||
return match.Name; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
static string BuildListing(string message, LoadedPackage package, IReadOnlyList<PackageEntry> managedEntries) |
||||
{ |
||||
var text = new StringBuilder(message); |
||||
if (managedEntries.Count == 0) |
||||
{ |
||||
text.Append(" It contains no managed entries."); |
||||
return text.ToString(); |
||||
} |
||||
string entryPoint = FindEntryPoint(package, managedEntries); |
||||
text.AppendLine(" Managed entries:"); |
||||
foreach (var entry in managedEntries) |
||||
{ |
||||
text.Append(" ").Append(entry.Name); |
||||
if (entry.Name == entryPoint) |
||||
text.Append(" (entry point)"); |
||||
text.AppendLine(); |
||||
} |
||||
return text.ToString().TrimEnd(); |
||||
} |
||||
|
||||
static MetadataReaderOptions MetadataOptions(FileLoadContext context) |
||||
{ |
||||
return context.ApplyWinRTProjections |
||||
? MetadataReaderOptions.ApplyWindowsRuntimeProjections |
||||
: MetadataReaderOptions.None; |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue