diff --git a/docs/dotnet/bundles.rst b/docs/dotnet/bundles.rst new file mode 100644 index 000000000..670b9eb3d --- /dev/null +++ b/docs/dotnet/bundles.rst @@ -0,0 +1,176 @@ +AppHost / SingleFileHost Bundles +================================ + +Since the release of .NET Core 3.1, it is possible to deploy .NET assemblies as a single binary. These files are executables that do not contain a traditional .NET metadata header, and run natively on the underlying operating system via a platform-specific application host bootstrapper. + +AsmResolver supports extracting the embedded files from these types of binaries. Additionally, given an application host template provided by the .NET SDK, AsmResolver also supports constructing new bundles as well. All relevant code is found in the following namespace: + +.. code-block:: csharp + + using AsmResolver.DotNet.Bundles; + + +Creating Bundles +---------------- + +.NET bundles are represented using the ``BundleManifest`` class. Creating new bundles can be done using any of the constructors: + +.. code-block:: csharp + + var manifest = new BundleManifest(majorVersionNumber: 6); + + +The major version number refers to the file format that should be used when saving the manifest. Below an overview of the values that are recognized by the CLR: + ++----------------------+----------------------------+ +| .NET Version Number | Bundle File Format Version | ++======================+============================+ +| .NET Core 3.1 | 1 | ++----------------------+----------------------------+ +| .NET 5.0 | 2 | ++----------------------+----------------------------+ +| .NET 6.0 | 6 | ++----------------------+----------------------------+ + +To create a new bundle with a specific bundle identifier, use the overloaded constructor + +.. code-block:: csharp + + var manifest = new BundleManifest(6, "MyBundleID"); + + +It is also possible to change the version number as well as the bundle ID later, since these values are exposed as mutable properties ``MajorVersion`` and ``BundleID`` + +.. code-block:: csharp + + manifest.MajorVersion = 6; + manifest.BundleID = manifest.GenerateDeterministicBundleID(); + +.. note:: + + If ``BundleID`` is left unset (``null``), it will be automatically assigned a new one using ``GenerateDeterministicBundleID`` upon writing. + + +Reading Bundles +--------------- + +Reading and extracting existing bundle manifests from an executable can be done by using one of the ``FromXXX`` methods: + +.. code-block:: csharp + + var manifest = BundleManifest.FromFile(@"C:\Path\To\Executable.exe"); + +.. code-block:: csharp + + byte[] contents = ... + var manifest = BundleManifest.FromBytes(contents); + +.. code-block:: csharp + + IDataSource contents = ... + var manifest = BundleManifest.FromDataSource(contents); + + +Similar to the official .NET bundler and extractor, the methods above locate the bundle in the file by looking for a specific signature first. However, official implementations of the application hosting program itself actually do not verify or use this signature in any shape or form. This means that a third party can replace or remove this signature, or write their own implementation of an application host that does not adhere to this standard, and thus throw off static analysis of the file. + +AsmResolver does not provide built-in alternative heuristics for finding the right start address of the bundle header. However, it is possible to implement one yourself and provide the resulting start address in one of the overloads of the ``FromXXX`` methods: + +.. code-block:: csharp + + byte[] contents = ... + ulong bundleAddress = ... + var manifest = BundleManifest.FromBytes(contents, bundleAddress); + +.. code-block:: csharp + + IDataSource contents = ... + ulong bundleAddress = ... + var manifest = BundleManifest.FromDataSource(contents, bundleAddress); + + +Writing Bundles +--------------- + +Constructing new bundled executable files requires a template file that AsmResolver can base the final output on. This is similar how .NET compilers themselves do this as well. By default, the .NET SDK installs template binaries in one of the following directories: + +- ``/sdk//AppHostTemplate`` +- ``/packs/Microsoft.NETCore.App.Host.//runtimes//native`` + +Using this template file, it is then possible to write a new bundled executable file using ``WriteUsingTemplate``: + +.. code-block:: csharp + + BundleManifest manifest = ... + manifest.WriteUsingTemplate( + @"C:\Path\To\Output\File.exe", + new BundlerParameters( + appHostTemplatePath: @"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64\6.0.0\runtimes\win-x64\native\apphost.exe", + appBinaryPath: @"HelloWorld.dll")); + + +Typically on Windows, use an ``apphost.exe`` template if you want to construct a native binary that is framework dependent, and ``singlefilehost.exe`` for a fully self-contained binary. On Linux, use the ``apphost`` and ``singlefilehost`` ELF equivalents. + +For bundle executable files targeting Windows, it may be required to copy over some values from the original PE file into the final bundle executable file. Usually these values include fields from the PE headers (such as the executable's sub-system target) and Win32 resources (such as application icons and version information). AsmResolver can automatically update these headers by specifying a source image to pull this data from in the ``BundlerParameters``: + +.. code-block:: csharp + + BundleManifest manifest = ... + manifest.WriteUsingTemplate( + @"C:\Path\To\Output\File.exe", + new BundlerParameters( + appHostTemplatePath: @"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64\6.0.0\runtimes\win-x64\native\apphost.exe", + appBinaryPath: @"HelloWorld.dll", + imagePathToCopyHeadersFrom: @"C:\Path\To\Original\HelloWorld.exe")); + +``BundleManifest`` also defines other ```WriteUsingTemplate`` overloads taking ``byte[]``, ``IDataSource`` or ``IPEImage`` instances instead of paths. + + +Managing Files +-------------- + +Files in a bundle are represented using the ``BundleFile`` class, and are exposed by the ``BundleManifest.Files`` property. Both the class as well as the list itself is fully mutable, and thus can be used to add, remove or modify files in the bundle. + +Creating a new file can be done using the constructors: + +.. code-block:: csharp + + var newFile = new BundleFile( + relativePath: "HelloWorld.dll", + type: BundleFileType.Assembly, + contents: System.IO.File.ReadAllBytes(@"C:\Binaries\HelloWorld.dll")); + + manifest.Files.Add(newFile); + + +It is also possible to iterate over all files and inspect their contents using ``GetData``: + +.. code-block:: csharp + + foreach (var file in manifest.Files) + { + string path = file.RelativePath; + byte[] contents = file.GetData(); + + Console.WriteLine($"Extracting {path}..."); + System.IO.File.WriteAllBytes(path, contents); + } + + +Changing the contents of an existing file can be done using the ``Contents`` property. + +.. code-block:: csharp + + BundleFile file = ... + file.Contents = new DataSegment(new byte[] { 1, 2, 3, 4 }); + + +If the bundle manifest is put into a single-file host template (e.g. ``singlefilehost.exe``), then files can also be compressed or decompressed: + +.. code-block:: csharp + + file.Compress(); + // file.Contents now contains the compressed version of the data and file.IsCompressed = true + + file.Decompress(); + // file.Contents now contains the decompressed version of the data and file.IsCompressed = false + diff --git a/docs/index.rst b/docs/index.rst index 6e56f82d4..89f3b0aeb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,4 +62,5 @@ Table of Contents: dotnet/cloning dotnet/token-allocation dotnet/type-memory-layout + dotnet/bundles dotnet/advanced-pe-image-building.rst diff --git a/src/AsmResolver.DotNet/Bundles/BundleFile.cs b/src/AsmResolver.DotNet/Bundles/BundleFile.cs new file mode 100644 index 000000000..958eeba16 --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/BundleFile.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.IO.Compression; +using AsmResolver.Collections; +using AsmResolver.IO; + +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Represents a single file in a .NET bundle manifest. + /// + public class BundleFile : IOwnedCollectionElement + { + private readonly LazyVariable _contents; + + /// + /// Creates a new empty bundle file. + /// + /// The path of the file, relative to the root of the bundle. + public BundleFile(string relativePath) + { + RelativePath = relativePath; + _contents = new LazyVariable(GetContents); + } + + /// + /// Creates a new bundle file. + /// + /// The path of the file, relative to the root of the bundle. + /// The type of the file. + /// The contents of the file. + public BundleFile(string relativePath, BundleFileType type, byte[] contents) + : this(relativePath, type, new DataSegment(contents)) + { + } + + /// + /// Creates a new empty bundle file. + /// + /// The path of the file, relative to the root of the bundle. + /// The type of the file. + /// The contents of the file. + public BundleFile(string relativePath, BundleFileType type, ISegment contents) + { + RelativePath = relativePath; + Type = type; + _contents = new LazyVariable(contents); + } + + /// + /// Gets the parent manifest this file was added to. + /// + public BundleManifest? ParentManifest + { + get; + private set; + } + + /// + BundleManifest? IOwnedCollectionElement.Owner + { + get => ParentManifest; + set => ParentManifest = value; + } + + /// + /// Gets or sets the path to the file, relative to the root directory of the bundle. + /// + public string RelativePath + { + get; + set; + } + + /// + /// Gets or sets the type of the file. + /// + public BundleFileType Type + { + get; + set; + } + + /// + /// Gets or sets a value indicating whether the data stored in is compressed or not. + /// + /// + /// The default implementation of the application host by Microsoft only supports compressing files if it is + /// a fully self-contained binary and the file is not the .deps.json nor the .runtmeconfig.json + /// file. This property does not do validation on any of these conditions. As such, if the file is supposed to be + /// compressed with any of these conditions not met, a custom application host template needs to be provided + /// upon serializing the bundle for it to be runnable. + /// + public bool IsCompressed + { + get; + set; + } + + /// + /// Gets or sets the raw contents of the file. + /// + public ISegment Contents + { + get => _contents.Value; + set => _contents.Value = value; + } + + /// + /// Gets a value whether the contents of the file can be read using a . + /// + public bool CanRead => Contents is IReadableSegment; + + /// + /// Obtains the raw contents of the file. + /// + /// The contents. + /// + /// This method is called upon initialization of the property. + /// + protected virtual ISegment? GetContents() => null; + + /// + /// Attempts to create a that points to the start of the raw contents of the file. + /// + /// The reader. + /// true if the reader was constructed successfully, false otherwise. + public bool TryGetReader(out BinaryStreamReader reader) + { + if (Contents is IReadableSegment segment) + { + reader = segment.CreateReader(); + return true; + } + + reader = default; + return false; + } + + /// + /// Reads (and decompresses if necessary) the contents of the file. + /// + /// The contents. + public byte[] GetData() => GetData(true); + + /// + /// Reads the contents of the file. + /// + /// true if the contents should be decompressed or not when necessary. + /// The contents. + public byte[] GetData(bool decompressIfRequired) + { + if (TryGetReader(out var reader)) + { + byte[] contents = reader.ReadToEnd(); + if (decompressIfRequired && IsCompressed) + { + using var outputStream = new MemoryStream(); + + using var inputStream = new MemoryStream(contents); + using (var deflate = new DeflateStream(inputStream, CompressionMode.Decompress)) + { + deflate.CopyTo(outputStream); + } + + contents = outputStream.ToArray(); + } + + return contents; + } + + throw new InvalidOperationException("Contents of file is not readable."); + } + + /// + /// Marks the file as compressed, compresses the file contents, and replaces the value of + /// with the result. + /// + /// Occurs when the file was already compressed. + /// + /// The default implementation of the application host by Microsoft only supports compressing files if it is + /// a fully self-contained binary and the file is not the .deps.json nor the .runtmeconfig.json + /// file. This method does not do validation on any of these conditions. As such, if the file is supposed to be + /// compressed with any of these conditions not met, a custom application host template needs to be provided + /// upon serializing the bundle for it to be runnable. + /// + public void Compress() + { + if (IsCompressed) + throw new InvalidOperationException("File is already compressed."); + + using var inputStream = new MemoryStream(GetData()); + + using var outputStream = new MemoryStream(); + using (var deflate = new DeflateStream(outputStream, CompressionLevel.Optimal)) + { + inputStream.CopyTo(deflate); + } + + Contents = new DataSegment(outputStream.ToArray()); + IsCompressed = true; + } + + /// + /// Marks the file as uncompressed, decompresses the file contents, and replaces the value of + /// with the result. + /// + /// Occurs when the file was not compressed. + public void Decompress() + { + if (!IsCompressed) + throw new InvalidOperationException("File is not compressed."); + + Contents = new DataSegment(GetData(true)); + IsCompressed = false; + } + + /// + public override string ToString() => RelativePath; + } +} diff --git a/src/AsmResolver.DotNet/Bundles/BundleFileType.cs b/src/AsmResolver.DotNet/Bundles/BundleFileType.cs new file mode 100644 index 000000000..8ac16b290 --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/BundleFileType.cs @@ -0,0 +1,38 @@ +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Provides members defining all possible file types that can be stored in a bundled .NET application. + /// + public enum BundleFileType + { + /// + /// Indicates the file type is unknown. + /// + Unknown, + + /// + /// Indicates the file is a .NET assembly. + /// + Assembly, + + /// + /// Indicates the file is a native binary. + /// + NativeBinary, + + /// + /// Indicates the file is the deps.json file associated to a .NET assembly. + /// + DepsJson, + + /// + /// Indicates the file is the runtimeconfig.json file associated to a .NET assembly. + /// + RuntimeConfigJson, + + /// + /// Indicates the file contains symbols. + /// + Symbols + } +} diff --git a/src/AsmResolver.DotNet/Bundles/BundleManifest.cs b/src/AsmResolver.DotNet/Bundles/BundleManifest.cs new file mode 100644 index 000000000..bd8e6f80b --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/BundleManifest.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using AsmResolver.Collections; +using AsmResolver.IO; +using AsmResolver.PE.File; +using AsmResolver.PE.File.Headers; +using AsmResolver.PE.Win32Resources.Builder; + +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Represents a set of bundled files embedded in a .NET application host or single-file host. + /// + public class BundleManifest + { + private const int DefaultBundleIDLength = 12; + + private 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 + }; + + private static readonly byte[] AppBinaryPathPlaceholder = + Encoding.UTF8.GetBytes("c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2"); + + private IList? _files; + + /// + /// Initializes an empty bundle manifest. + /// + protected BundleManifest() + { + } + + /// + /// Creates a new bundle manifest. + /// + /// The file format version. + public BundleManifest(uint majorVersionNumber) + { + MajorVersion = majorVersionNumber; + MinorVersion = 0; + } + + /// + /// Creates a new bundle manifest with a specific bundle identifier. + /// + /// The file format version. + /// The unique bundle manifest identifier. + public BundleManifest(uint majorVersionNumber, string bundleId) + { + MajorVersion = majorVersionNumber; + MinorVersion = 0; + BundleID = bundleId; + } + + /// + /// Gets or sets the major file format version of the bundle. + /// + /// + /// Version numbers recognized by the CLR are: + /// + /// 1 for .NET Core 3.1 + /// 2 for .NET 5.0 + /// 6 for .NET 6.0 + /// + /// + public uint MajorVersion + { + get; + set; + } + + /// + /// Gets or sets the minor file format version of the bundle. + /// + /// + /// This value is ignored by the CLR and should be set to 0. + /// + public uint MinorVersion + { + get; + set; + } + + /// + /// Gets or sets the unique identifier for the bundle manifest. + /// + /// + /// When this property is set to null, the bundle identifier will be generated upon writing the manifest + /// based on the contents of the manifest. + /// + public string? BundleID + { + get; + set; + } + + /// + /// Gets or sets flags associated to the bundle. + /// + public BundleManifestFlags Flags + { + get; + set; + } + + /// + /// Gets a collection of files stored in the bundle. + /// + public IList Files + { + get + { + if (_files is null) + Interlocked.CompareExchange(ref _files, GetFiles(), null); + return _files; + } + } + + /// + /// Attempts to automatically locate and parse the bundle header in the provided file. + /// + /// The path to the file to read. + /// The read manifest. + public static BundleManifest FromFile(string filePath) + { + return FromBytes(File.ReadAllBytes(filePath)); + } + + /// + /// Attempts to automatically locate and parse the bundle header in the provided file. + /// + /// The raw contents of the file to read. + /// The read manifest. + public static BundleManifest FromBytes(byte[] data) + { + return FromDataSource(new ByteArrayDataSource(data)); + } + + /// + /// Parses the bundle header in the provided file at the provided address. + /// + /// The raw contents of the file to read. + /// The address within the file to start reading the bundle at. + /// The read manifest. + public static BundleManifest FromBytes(byte[] data, ulong offset) + { + return FromDataSource(new ByteArrayDataSource(data), offset); + } + + /// + /// Attempts to automatically locate and parse the bundle header in the provided file. + /// + /// The raw contents of the file to read. + /// The read manifest. + public static BundleManifest FromDataSource(IDataSource source) + { + long address = FindBundleManifestAddress(source); + if (address == -1) + throw new BadImageFormatException("File does not contain an AppHost bundle signature."); + + return FromDataSource(source, (ulong) address); + } + + /// + /// Parses the bundle header in the provided file at the provided address. + /// + /// The raw contents of the file to read. + /// The address within the file to start reading the bundle at. + /// The read manifest. + public static BundleManifest FromDataSource(IDataSource source, ulong offset) + { + var reader = new BinaryStreamReader(source, 0, 0, (uint) source.Length) + { + Offset = offset + }; + + return FromReader(reader); + } + + /// + /// Parses the bundle header from the provided input stream. + /// + /// The input stream pointing to the start of the bundle to read. + /// The read manifest. + public static BundleManifest FromReader(BinaryStreamReader reader) => new SerializedBundleManifest(reader); + + private static long FindInFile(IDataSource source, byte[] data) + { + // Note: For performance reasons, we read data from the data source in blocks, such that we avoid + // virtual-dispatch calls and do the searching directly on a byte array instead. + + byte[] buffer = new byte[0x1000]; + + ulong start = 0; + while (start < source.Length) + { + int read = source.ReadBytes(start, buffer, 0, buffer.Length); + + for (int i = sizeof(ulong); i < read - data.Length; i++) + { + bool fullMatch = true; + for (int j = 0; fullMatch && j < data.Length; j++) + { + if (buffer[i + j] != data[j]) + fullMatch = false; + } + + if (fullMatch) + return (long) start + i; + } + + start += (ulong) read; + } + + return -1; + } + + private static long ReadBundleManifestAddress(IDataSource source, long signatureAddress) + { + var reader = new BinaryStreamReader(source, (ulong) signatureAddress - sizeof(ulong), 0, 8); + ulong manifestAddress = reader.ReadUInt64(); + + return source.IsValidAddress(manifestAddress) + ? (long) manifestAddress + : -1; + } + + /// + /// Attempts to find the start of the bundle header in the provided file. + /// + /// The file to locate the bundle header in. + /// The offset, or -1 if none was found. + public static long FindBundleManifestAddress(IDataSource source) + { + long signatureAddress = FindInFile(source, BundleSignature); + if (signatureAddress == -1) + return -1; + + return ReadBundleManifestAddress(source, signatureAddress); + } + + /// + /// Gets a value indicating whether the provided data source contains a conventional bundled assembly signature. + /// + /// The file to locate the bundle header in. + /// true if a bundle signature was found, false otherwise. + public static bool IsBundledAssembly(IDataSource source) => FindBundleManifestAddress(source) != -1; + + /// + /// Obtains the list of files stored in the bundle. + /// + /// The files + /// + /// This method is called upon initialization of the property. + /// + protected virtual IList GetFiles() => new OwnedCollection(this); + + /// + /// Generates a bundle identifier based on the SHA-256 hashes of all files in the manifest. + /// + /// The generated bundle identifier. + public string GenerateDeterministicBundleID() + { + using var manifestHasher = SHA256.Create(); + + for (int i = 0; i < Files.Count; i++) + { + var file = Files[i]; + using var fileHasher = SHA256.Create(); + byte[] fileHash = fileHasher.ComputeHash(file.GetData()); + manifestHasher.TransformBlock(fileHash, 0, fileHash.Length, fileHash, 0); + } + + manifestHasher.TransformFinalBlock(Array.Empty(), 0, 0); + byte[] manifestHash = manifestHasher.Hash; + + return Convert.ToBase64String(manifestHash) + .Substring(DefaultBundleIDLength) + .Replace('/', '_'); + } + + /// + /// Constructs a new application host file based on the bundle manifest. + /// + /// The path of the file to write to. + /// The parameters to use for bundling all files into a single executable. + public void WriteUsingTemplate(string outputPath, in BundlerParameters parameters) + { + using var fs = File.Create(outputPath); + WriteUsingTemplate(fs, parameters); + } + + /// + /// Constructs a new application host file based on the bundle manifest. + /// + /// The output stream to write to. + /// The parameters to use for bundling all files into a single executable. + public void WriteUsingTemplate(Stream outputStream, in BundlerParameters parameters) + { + WriteUsingTemplate(new BinaryStreamWriter(outputStream), parameters); + } + + /// + /// Constructs a new application host file based on the bundle manifest. + /// + /// The output stream to write to. + /// The parameters to use for bundling all files into a single executable. + public void WriteUsingTemplate(IBinaryStreamWriter writer, BundlerParameters parameters) + { + var appBinaryEntry = Files.FirstOrDefault(f => f.RelativePath == parameters.ApplicationBinaryPath); + if (appBinaryEntry is null) + throw new ArgumentException($"Application {parameters.ApplicationBinaryPath} does not exist within the bundle."); + + byte[] appBinaryPathBytes = Encoding.UTF8.GetBytes(parameters.ApplicationBinaryPath); + if (appBinaryPathBytes.Length > 1024) + throw new ArgumentException("Application binary path cannot exceed 1024 bytes."); + + if (!parameters.IsArm64Linux) + EnsureAppHostPEHeadersAreUpToDate(ref parameters); + + var appHostTemplateSource = new ByteArrayDataSource(parameters.ApplicationHostTemplate); + long signatureAddress = FindInFile(appHostTemplateSource, BundleSignature); + if (signatureAddress == -1) + throw new ArgumentException("AppHost template does not contain the bundle signature."); + + long appBinaryPathAddress = FindInFile(appHostTemplateSource, AppBinaryPathPlaceholder); + if (appBinaryPathAddress == -1) + throw new ArgumentException("AppHost template does not contain the application binary path placeholder."); + + writer.WriteBytes(parameters.ApplicationHostTemplate); + writer.Offset = writer.Length; + ulong headerAddress = WriteManifest(writer, parameters.IsArm64Linux); + + writer.Offset = (ulong) signatureAddress - sizeof(ulong); + writer.WriteUInt64(headerAddress); + + writer.Offset = (ulong) appBinaryPathAddress; + writer.WriteBytes(appBinaryPathBytes); + if (AppBinaryPathPlaceholder.Length > appBinaryPathBytes.Length) + writer.WriteZeroes(AppBinaryPathPlaceholder.Length - appBinaryPathBytes.Length); + } + + private static void EnsureAppHostPEHeadersAreUpToDate(ref BundlerParameters parameters) + { + PEFile file; + try + { + file = PEFile.FromBytes(parameters.ApplicationHostTemplate); + } + catch (BadImageFormatException) + { + // Template is not a PE file. + return; + } + + bool changed = false; + + // Ensure same Windows subsystem is used (typically required for GUI applications). + if (file.OptionalHeader.SubSystem != parameters.SubSystem) + { + file.OptionalHeader.SubSystem = parameters.SubSystem; + changed = true; + } + + // If the app binary has resources (such as an icon or version info), we need to copy it into the + // AppHost template so that they are also visible from the final packed executable. + if (parameters.Resources is { } directory) + { + // Put original resource directory in a new .rsrc section. + var buffer = new ResourceDirectoryBuffer(); + buffer.AddDirectory(directory); + var rsrc = new PESection(".rsrc", SectionFlags.MemoryRead | SectionFlags.ContentInitializedData); + rsrc.Contents = buffer; + + // Find .reloc section, and insert .rsrc before it if it is present. Otherwise just append to the end. + int sectionIndex = file.Sections.Count - 1; + for (int i = file.Sections.Count - 1; i >= 0; i--) + { + if (file.Sections[i].Name == ".reloc") + { + sectionIndex = i; + break; + } + } + + file.Sections.Insert(sectionIndex, rsrc); + + // Update resource data directory va + size. + file.AlignSections(); + file.OptionalHeader.DataDirectories[(int) DataDirectoryIndex.ResourceDirectory] = new DataDirectory( + buffer.Rva, + buffer.GetPhysicalSize()); + + changed = true; + } + + // Rebuild AppHost PE file if necessary. + if (changed) + { + using var stream = new MemoryStream(); + file.Write(stream); + parameters.ApplicationHostTemplate = stream.ToArray(); + } + } + + /// + /// Writes the manifest to an output stream. + /// + /// The output stream to write to. + /// true if the application host is a Linux ELF binary targeting ARM64. + /// The address of the bundle header. + /// + /// This does not necessarily produce a working executable file, it only writes the contents of the entire manifest, + /// without a host application that invokes the manifest. If you want to produce a runnable executable, use one + /// of the WriteUsingTemplate methods instead. + /// + public ulong WriteManifest(IBinaryStreamWriter writer, bool isArm64Linux) + { + WriteFileContents(writer, isArm64Linux + ? 4096u + : 16u); + + ulong headerAddress = writer.Offset; + WriteManifestHeader(writer); + + return headerAddress; + } + + private void WriteFileContents(IBinaryStreamWriter writer, uint alignment) + { + for (int i = 0; i < Files.Count; i++) + { + var file = Files[i]; + + if (file.Type == BundleFileType.Assembly) + writer.Align(alignment); + + file.Contents.UpdateOffsets(writer.Offset, (uint) writer.Offset); + file.Contents.Write(writer); + } + } + + private void WriteManifestHeader(IBinaryStreamWriter writer) + { + writer.WriteUInt32(MajorVersion); + writer.WriteUInt32(MinorVersion); + writer.WriteInt32(Files.Count); + + BundleID ??= GenerateDeterministicBundleID(); + writer.WriteBinaryFormatterString(BundleID); + + if (MajorVersion >= 2) + { + WriteFileOffsetSizePair(writer, Files.FirstOrDefault(f => f.Type == BundleFileType.DepsJson)); + WriteFileOffsetSizePair(writer, Files.FirstOrDefault(f => f.Type == BundleFileType.RuntimeConfigJson)); + writer.WriteUInt64((ulong) Flags); + } + + WriteFileHeaders(writer); + } + + private void WriteFileHeaders(IBinaryStreamWriter writer) + { + for (int i = 0; i < Files.Count; i++) + { + var file = Files[i]; + + WriteFileOffsetSizePair(writer, file); + + if (MajorVersion >= 6) + writer.WriteUInt64(file.IsCompressed ? file.Contents.GetPhysicalSize() : 0); + + writer.WriteByte((byte) file.Type); + writer.WriteBinaryFormatterString(file.RelativePath); + } + } + + private static void WriteFileOffsetSizePair(IBinaryStreamWriter writer, BundleFile? file) + { + if (file is not null) + { + writer.WriteUInt64(file.Contents.Offset); + writer.WriteUInt64((ulong) file.GetData().Length); + } + else + { + writer.WriteUInt64(0); + writer.WriteUInt64(0); + } + } + + } +} diff --git a/src/AsmResolver.DotNet/Bundles/BundleManifestFlags.cs b/src/AsmResolver.DotNet/Bundles/BundleManifestFlags.cs new file mode 100644 index 000000000..0a2a9a277 --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/BundleManifestFlags.cs @@ -0,0 +1,21 @@ +using System; + +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Provides members defining all flags that can be assigned to a bundle manifest. + /// + [Flags] + public enum BundleManifestFlags : ulong + { + /// + /// Indicates no flags were assigned. + /// + None = 0, + + /// + /// Indicates the bundle was compiled in .NET Core 3 compatibility mode. + /// + NetCoreApp3CompatibilityMode = 1 + } +} diff --git a/src/AsmResolver.DotNet/Bundles/BundlerParameters.cs b/src/AsmResolver.DotNet/Bundles/BundlerParameters.cs new file mode 100644 index 000000000..2e83c3c16 --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/BundlerParameters.cs @@ -0,0 +1,221 @@ +using System.IO; +using AsmResolver.IO; +using AsmResolver.PE; +using AsmResolver.PE.File.Headers; +using AsmResolver.PE.Win32Resources; + +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Defines parameters for the .NET application bundler. + /// + public struct BundlerParameters + { + /// + /// Initializes new bundler parameters. + /// + /// + /// The path to the application host file template to use. By default this is stored in + /// <DOTNET-INSTALLATION-PATH>/sdk/<version>/AppHostTemplate or + /// <DOTNET-INSTALLATION-PATH>/packs/Microsoft.NETCore.App.Host.<runtime-identifier>/<version>/runtimes/<runtime-identifier>/native. + /// + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + public BundlerParameters(string appHostTemplatePath, string appBinaryPath) + : this(File.ReadAllBytes(appHostTemplatePath), appBinaryPath) + { + } + + /// + /// Initializes new bundler parameters. + /// + /// The application host template file to use. + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + public BundlerParameters(byte[] appHostTemplate, string appBinaryPath) + { + ApplicationHostTemplate = appHostTemplate; + ApplicationBinaryPath = appBinaryPath; + IsArm64Linux = false; + Resources = null; + SubSystem = SubSystem.WindowsCui; + } + + /// + /// Initializes new bundler parameters. + /// + /// + /// The path to the application host file template to use. By default this is stored in + /// <DOTNET-INSTALLATION-PATH>/sdk/<version>/AppHostTemplate or + /// <DOTNET-INSTALLATION-PATH>/packs/Microsoft.NETCore.App.Host.<runtime-identifier>/<version>/runtimes/<runtime-identifier>/native. + /// + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + /// + /// The path to copy the PE headers and Win32 resources from. This is typically the original native executable + /// file that hosts the CLR, or the original AppHost file the bundle was extracted from. + /// + public BundlerParameters(string appHostTemplatePath, string appBinaryPath, string? imagePathToCopyHeadersFrom) + : this( + File.ReadAllBytes(appHostTemplatePath), + appBinaryPath, + !string.IsNullOrEmpty(imagePathToCopyHeadersFrom) + ? PEImage.FromFile(imagePathToCopyHeadersFrom!) + : null + ) + { + } + + /// + /// Initializes new bundler parameters. + /// + /// The application host template file to use. + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + /// + /// The binary to copy the PE headers and Win32 resources from. This is typically the original native executable + /// file that hosts the CLR, or the original AppHost file the bundle was extracted from. + /// + public BundlerParameters(byte[] appHostTemplate, string appBinaryPath, byte[]? imageToCopyHeadersFrom) + : this( + appHostTemplate, + appBinaryPath, + imageToCopyHeadersFrom is not null + ? PEImage.FromBytes(imageToCopyHeadersFrom) + : null + ) + { + } + + /// + /// Initializes new bundler parameters. + /// + /// The application host template file to use. + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + /// + /// The binary to copy the PE headers and Win32 resources from. This is typically the original native executable + /// file that hosts the CLR, or the original AppHost file the bundle was extracted from. + /// + public BundlerParameters(byte[] appHostTemplate, string appBinaryPath, IDataSource? imageToCopyHeadersFrom) + : this( + appHostTemplate, + appBinaryPath, + imageToCopyHeadersFrom is not null + ? PEImage.FromDataSource(imageToCopyHeadersFrom) + : null + ) + { + } + + /// + /// Initializes new bundler parameters. + /// + /// The application host template file to use. + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + /// + /// The PE image to copy the headers and Win32 resources from. This is typically the original native executable + /// file that hosts the CLR, or the original AppHost file the bundle was extracted from. + /// + public BundlerParameters(byte[] appHostTemplate, string appBinaryPath, IPEImage? imageToCopyHeadersFrom) + : this( + appHostTemplate, + appBinaryPath, + imageToCopyHeadersFrom?.SubSystem ?? SubSystem.WindowsCui, + imageToCopyHeadersFrom?.Resources + ) + { + } + + /// + /// Initializes new bundler parameters. + /// + /// The application host template file to use. + /// + /// The name of the file in the bundle that contains the entry point of the application. + /// + /// The subsystem to use in the final Windows PE binary. + /// The resources to copy into the final Windows PE binary. + public BundlerParameters( + byte[] appHostTemplate, + string appBinaryPath, + SubSystem subSystem, + IResourceDirectory? resources) + { + ApplicationHostTemplate = appHostTemplate; + ApplicationBinaryPath = appBinaryPath; + IsArm64Linux = false; + SubSystem = subSystem; + Resources = resources; + } + + /// + /// Gets or sets the template application hosting binary. + /// + /// + /// By default, the official implementations of the application host can be found in one of the following + /// installation directories: + /// + /// <DOTNET-INSTALLATION-PATH>/sdk/<version>/AppHostTemplate + /// <DOTNET-INSTALLATION-PATH>/packs/Microsoft.NETCore.App.Host.<runtime-identifier>/<version>/runtimes/<runtime-identifier>/native + /// + /// It is therefore recommended to use the contents of one of these templates to ensure compatibility. + /// + public byte[] ApplicationHostTemplate + { + get; + set; + } + + /// + /// Gets or sets the path to the binary within the bundle that contains the application's entry point. + /// + public string ApplicationBinaryPath + { + get; + set; + } + + /// + /// Gets a value indicating whether the bundled executable targets the Linux operating system on ARM64. + /// + public bool IsArm64Linux + { + get; + set; + } + + /// + /// Gets or sets the Win32 resources directory to copy into the final PE executable. + /// + /// + /// This field is ignored if is set to true, or + /// does not contain a proper PE image. + /// + public IResourceDirectory? Resources + { + get; + set; + } + + /// + /// Gets or sets the Windows subsystem the final PE executable should target. + /// + /// + /// This field is ignored if is set to true, or + /// does not contain a proper PE image. + /// + public SubSystem SubSystem + { + get; + set; + } + } +} diff --git a/src/AsmResolver.DotNet/Bundles/SerializedBundleFile.cs b/src/AsmResolver.DotNet/Bundles/SerializedBundleFile.cs new file mode 100644 index 000000000..79581a262 --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/SerializedBundleFile.cs @@ -0,0 +1,42 @@ +using AsmResolver.IO; + +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Represents a lazily initialized implementation of that is read from an existing file. + /// + public class SerializedBundleFile : BundleFile + { + private readonly BinaryStreamReader _contentsReader; + + /// + /// Reads a bundle file entry from the provided input stream. + /// + /// The input stream. + /// The file format version of the bundle. + public SerializedBundleFile(ref BinaryStreamReader reader, uint bundleVersionFormat) + : base(string.Empty) + { + ulong offset = reader.ReadUInt64(); + ulong size = reader.ReadUInt64(); + + if (bundleVersionFormat >= 6) + { + ulong compressedSize = reader.ReadUInt64(); + if (compressedSize != 0) + { + size = compressedSize; + IsCompressed = true; + } + } + + Type = (BundleFileType) reader.ReadByte(); + RelativePath = reader.ReadBinaryFormatterString(); + + _contentsReader = reader.ForkAbsolute(offset, (uint) size); + } + + /// + protected override ISegment GetContents() => _contentsReader.ReadSegment(_contentsReader.Length); + } +} diff --git a/src/AsmResolver.DotNet/Bundles/SerializedBundleManifest.cs b/src/AsmResolver.DotNet/Bundles/SerializedBundleManifest.cs new file mode 100644 index 000000000..2fce34fae --- /dev/null +++ b/src/AsmResolver.DotNet/Bundles/SerializedBundleManifest.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using AsmResolver.Collections; +using AsmResolver.IO; + +namespace AsmResolver.DotNet.Bundles +{ + /// + /// Represents a lazily initialized implementation of that is read from an existing file. + /// + public class SerializedBundleManifest : BundleManifest + { + private readonly uint _originalMajorVersion; + private readonly BinaryStreamReader _fileEntriesReader; + private readonly int _originalFileCount; + + /// + /// Reads a bundle manifest from the provided input stream. + /// + /// The input stream. + public SerializedBundleManifest(BinaryStreamReader reader) + { + MajorVersion = _originalMajorVersion = reader.ReadUInt32(); + MinorVersion = reader.ReadUInt32(); + _originalFileCount = reader.ReadInt32(); + BundleID = reader.ReadBinaryFormatterString(); + + if (MajorVersion >= 2) + { + reader.Offset += 4 * sizeof(ulong); + Flags = (BundleManifestFlags) reader.ReadUInt64(); + } + + _fileEntriesReader = reader; + } + + /// + protected override IList GetFiles() + { + var reader = _fileEntriesReader; + var result = new OwnedCollection(this); + + for (int i = 0; i < _originalFileCount; i++) + result.Add(new SerializedBundleFile(ref reader, _originalMajorVersion)); + + return result; + } + } +} diff --git a/test/AsmResolver.Benchmarks/DotNetBundleBenchmark.cs b/test/AsmResolver.Benchmarks/DotNetBundleBenchmark.cs new file mode 100644 index 000000000..00acd4fc9 --- /dev/null +++ b/test/AsmResolver.Benchmarks/DotNetBundleBenchmark.cs @@ -0,0 +1,19 @@ +using System.IO; +using AsmResolver.DotNet.Bundles; +using BenchmarkDotNet.Attributes; + +namespace AsmResolver.Benchmarks +{ + [MemoryDiagnoser] + public class DotNetBundleBenchmark + { + private static readonly byte[] HelloWorldSingleFileV6 = Properties.Resources.HelloWorld_SingleFile_V6; + private readonly MemoryStream _outputStream = new(); + + [Benchmark] + public void ReadBundleManifestV6() + { + _ = BundleManifest.FromBytes(HelloWorldSingleFileV6); + } + } +} diff --git a/test/AsmResolver.Benchmarks/Properties/Resources.Designer.cs b/test/AsmResolver.Benchmarks/Properties/Resources.Designer.cs index 86ed39706..ee7805eca 100644 --- a/test/AsmResolver.Benchmarks/Properties/Resources.Designer.cs +++ b/test/AsmResolver.Benchmarks/Properties/Resources.Designer.cs @@ -99,5 +99,15 @@ public static byte[] HelloWorld_ManyMethods { return ((byte[])(obj)); } } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + public static byte[] HelloWorld_SingleFile_V6 { + get { + object obj = ResourceManager.GetObject("HelloWorld_SingleFile_V6", resourceCulture); + return ((byte[])(obj)); + } + } } } diff --git a/test/AsmResolver.Benchmarks/Properties/Resources.resx b/test/AsmResolver.Benchmarks/Properties/Resources.resx index 83f78bc25..585e32796 100644 --- a/test/AsmResolver.Benchmarks/Properties/Resources.resx +++ b/test/AsmResolver.Benchmarks/Properties/Resources.resx @@ -130,4 +130,7 @@ ..\Resources\HelloWorld.ManyMethods.deflate;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + ..\Resources\HelloWorld.SingleFile.v6.exe;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + diff --git a/test/AsmResolver.Benchmarks/Resources/HelloWorld.SingleFile.v6.exe b/test/AsmResolver.Benchmarks/Resources/HelloWorld.SingleFile.v6.exe new file mode 100644 index 000000000..e980f978e Binary files /dev/null and b/test/AsmResolver.Benchmarks/Resources/HelloWorld.SingleFile.v6.exe differ diff --git a/test/AsmResolver.DotNet.Tests/AsmResolver.DotNet.Tests.csproj b/test/AsmResolver.DotNet.Tests/AsmResolver.DotNet.Tests.csproj index 30de93753..a29c5c0d5 100644 --- a/test/AsmResolver.DotNet.Tests/AsmResolver.DotNet.Tests.csproj +++ b/test/AsmResolver.DotNet.Tests/AsmResolver.DotNet.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/test/AsmResolver.DotNet.Tests/Bundles/BundleFileTest.cs b/test/AsmResolver.DotNet.Tests/Bundles/BundleFileTest.cs new file mode 100644 index 000000000..cf0bddfb0 --- /dev/null +++ b/test/AsmResolver.DotNet.Tests/Bundles/BundleFileTest.cs @@ -0,0 +1,44 @@ +using System.Linq; +using System.Text; +using AsmResolver.DotNet.Bundles; +using AsmResolver.PE.DotNet.Metadata.Strings; +using AsmResolver.PE.DotNet.Metadata.Tables; +using AsmResolver.PE.DotNet.Metadata.Tables.Rows; +using Xunit; + +namespace AsmResolver.DotNet.Tests.Bundles +{ + public class BundleFileTest + { + [Fact] + public void ReadUncompressedStringContents() + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6); + var file = manifest.Files.First(f => f.Type == BundleFileType.RuntimeConfigJson); + string contents = Encoding.UTF8.GetString(file.GetData()); + + Assert.Equal(@"{ + ""runtimeOptions"": { + ""tfm"": ""net6.0"", + ""framework"": { + ""name"": ""Microsoft.NETCore.App"", + ""version"": ""6.0.0"" + }, + ""configProperties"": { + ""System.Reflection.Metadata.MetadataUpdater.IsSupported"": false + } + } +}", contents); + } + + [Fact] + public void ReadUncompressedAssemblyContents() + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6); + var bundleFile = manifest.Files.First(f => f.RelativePath == "HelloWorld.dll"); + + var embeddedImage = ModuleDefinition.FromBytes(bundleFile.GetData()); + Assert.Equal("HelloWorld.dll", embeddedImage.Name); + } + } +} diff --git a/test/AsmResolver.DotNet.Tests/Bundles/BundleManifestTest.cs b/test/AsmResolver.DotNet.Tests/Bundles/BundleManifestTest.cs new file mode 100644 index 000000000..0bed69a5a --- /dev/null +++ b/test/AsmResolver.DotNet.Tests/Bundles/BundleManifestTest.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using AsmResolver.DotNet.Bundles; +using AsmResolver.IO; +using AsmResolver.PE; +using AsmResolver.PE.File; +using AsmResolver.PE.File.Headers; +using AsmResolver.PE.Win32Resources.Version; +using AsmResolver.Tests.Runners; +using Xunit; + +namespace AsmResolver.DotNet.Tests.Bundles +{ + public class BundleManifestTest : IClassFixture + { + private readonly TemporaryDirectoryFixture _fixture; + + public BundleManifestTest(TemporaryDirectoryFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void ReadBundleManifestHeaderV1() + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V1); + Assert.Equal(1u, manifest.MajorVersion); + Assert.Equal("j7LK4is5ipe1CCtiafaTb8uhSOR7JhI=", manifest.BundleID); + Assert.Equal(new[] + { + "HelloWorld.dll", "HelloWorld.deps.json", "HelloWorld.runtimeconfig.json" + }, manifest.Files.Select(f => f.RelativePath)); + } + + [Fact] + public void ReadBundleManifestHeaderV2() + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V2); + Assert.Equal(2u, manifest.MajorVersion); + Assert.Equal("poUQ+RBCefcEL4xrSAXdE2I5M+5D_Pk=", manifest.BundleID); + Assert.Equal(new[] + { + "HelloWorld.dll", "HelloWorld.deps.json", "HelloWorld.runtimeconfig.json" + }, manifest.Files.Select(f => f.RelativePath)); + } + + [Fact] + public void ReadBundleManifestHeaderV6() + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6); + Assert.Equal(6u, manifest.MajorVersion); + Assert.Equal("lc43r48XAQNxN7Cx8QQvO9JgZI5lqPA=", manifest.BundleID); + Assert.Equal(new[] + { + "HelloWorld.dll", "HelloWorld.deps.json", "HelloWorld.runtimeconfig.json" + }, manifest.Files.Select(f => f.RelativePath)); + } + + [SkippableFact] + public void WriteBundleManifestV1Windows() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + AssertWriteManifestWindowsPreservesOutput( + BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V1), + "3.1", + "HelloWorld.dll", + $"Hello, World!{Environment.NewLine}"); + } + + [SkippableFact] + public void WriteBundleManifestV2Windows() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + AssertWriteManifestWindowsPreservesOutput( + BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V2), + "5.0", + "HelloWorld.dll", + $"Hello, World!{Environment.NewLine}"); + } + + [SkippableFact] + public void WriteBundleManifestV6Windows() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + AssertWriteManifestWindowsPreservesOutput( + BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6), + "6.0", + "HelloWorld.dll", + $"Hello, World!{Environment.NewLine}"); + } + + [SkippableFact] + public void MarkFilesAsCompressed() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6); + manifest.Files.First(f => f.RelativePath == "HelloWorld.dll").Compress(); + + using var stream = new MemoryStream(); + ulong address = manifest.WriteManifest(new BinaryStreamWriter(stream), false); + + var reader = ByteArrayDataSource.CreateReader(stream.ToArray()); + reader.Offset = address; + var newManifest = BundleManifest.FromReader(reader); + AssertBundlesAreEqual(manifest, newManifest); + } + + [Theory] + [InlineData(SubSystem.WindowsCui)] + [InlineData(SubSystem.WindowsGui)] + public void WriteWithSubSystem(SubSystem subSystem) + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6); + string appHostTemplatePath = FindAppHostTemplate("6.0"); + + using var stream = new MemoryStream(); + manifest.WriteUsingTemplate(stream, new BundlerParameters(appHostTemplatePath, "HelloWorld.dll") + { + SubSystem = subSystem + }); + + var newFile = PEFile.FromBytes(stream.ToArray()); + Assert.Equal(subSystem, newFile.OptionalHeader.SubSystem); + } + + [SkippableFact] + public void WriteWithWin32Resources() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6_WithResources); + string appHostTemplatePath = FindAppHostTemplate("6.0"); + + // Obtain expected version info. + var oldImage = PEImage.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6_WithResources); + var versionInfo = VersionInfoResource.FromDirectory(oldImage.Resources!)!; + + // Bundle with PE image as template for PE headers and resources. + using var stream = new MemoryStream(); + manifest.WriteUsingTemplate(stream, new BundlerParameters( + File.ReadAllBytes(appHostTemplatePath), + "HelloWorld.dll", + oldImage)); + + // Verify new file still runs as expected. + string output = _fixture + .GetRunner() + .RunAndCaptureOutput("HelloWorld.exe", stream.ToArray()); + + Assert.Equal($"Hello, World!{Environment.NewLine}", output); + + // Verify that resources were added properly. + var newImage = PEImage.FromBytes(stream.ToArray()); + Assert.NotNull(newImage.Resources); + var newVersionInfo = VersionInfoResource.FromDirectory(newImage.Resources); + Assert.NotNull(newVersionInfo); + Assert.Equal(versionInfo.FixedVersionInfo.FileVersion, newVersionInfo.FixedVersionInfo.FileVersion); + } + + [Fact] + public void NewManifestShouldGenerateBundleIdIfUnset() + { + var manifest = new BundleManifest(6); + + manifest.Files.Add(new BundleFile("HelloWorld.dll", BundleFileType.Assembly, + Properties.Resources.HelloWorld_NetCore)); + manifest.Files.Add(new BundleFile("HelloWorld.runtimeconfig.json", BundleFileType.RuntimeConfigJson, + Encoding.UTF8.GetBytes(@"{ + ""runtimeOptions"": { + ""tfm"": ""net6.0"", + ""includedFrameworks"": [ + { + ""name"": ""Microsoft.NETCore.App"", + ""version"": ""6.0.0"" + } + ] + } +}"))); + + Assert.Null(manifest.BundleID); + + using var stream = new MemoryStream(); + manifest.WriteUsingTemplate(stream, new BundlerParameters( + FindAppHostTemplate("6.0"), + "HelloWorld.dll")); + + Assert.NotNull(manifest.BundleID); + } + + [Fact] + public void SameManifestContentsShouldResultInSameBundleID() + { + var manifest = BundleManifest.FromBytes(Properties.Resources.HelloWorld_SingleFile_V6); + + var newManifest = new BundleManifest(manifest.MajorVersion); + foreach (var file in manifest.Files) + newManifest.Files.Add(new BundleFile(file.RelativePath, file.Type, file.GetData())); + + Assert.Equal(manifest.BundleID, newManifest.GenerateDeterministicBundleID()); + } + + private void AssertWriteManifestWindowsPreservesOutput( + BundleManifest manifest, + string sdkVersion, + string fileName, + string expectedOutput, + [CallerFilePath] string className = "File", + [CallerMemberName] string methodName = "Method") + { + string appHostTemplatePath = FindAppHostTemplate(sdkVersion); + + using var stream = new MemoryStream(); + manifest.WriteUsingTemplate(stream, new BundlerParameters(appHostTemplatePath, fileName)); + + var newManifest = BundleManifest.FromBytes(stream.ToArray()); + AssertBundlesAreEqual(manifest, newManifest); + + string output = _fixture + .GetRunner() + .RunAndCaptureOutput( + Path.ChangeExtension(fileName, ".exe"), + stream.ToArray(), + null, + 5000, + className, + methodName); + + Assert.Equal(expectedOutput, output); + } + + private static string FindAppHostTemplate(string sdkVersion) + { + string sdkPath = Path.Combine(DotNetCorePathProvider.DefaultInstallationPath!, "sdk"); + string? sdkVersionPath = null; + foreach (string dir in Directory.GetDirectories(sdkPath)) + { + if (Path.GetFileName(dir).StartsWith(sdkVersion)) + { + sdkVersionPath = Path.Combine(dir); + break; + } + } + + if (string.IsNullOrEmpty(sdkVersionPath)) + { + throw new InvalidOperationException( + $"Could not find the apphost template for .NET SDK version {sdkVersion}. This is an indication that the test environment does not have this SDK installed."); + } + + return Path.Combine(sdkVersionPath, "AppHostTemplate", "apphost.exe"); + } + + private static void AssertBundlesAreEqual(BundleManifest manifest, BundleManifest newManifest) + { + Assert.Equal(manifest.MajorVersion, newManifest.MajorVersion); + Assert.Equal(manifest.MinorVersion, newManifest.MinorVersion); + Assert.Equal(manifest.BundleID, newManifest.BundleID); + + Assert.Equal(manifest.Files.Count, newManifest.Files.Count); + for (int i = 0; i < manifest.Files.Count; i++) + { + var file = manifest.Files[i]; + var newFile = newManifest.Files[i]; + Assert.Equal(file.Type, newFile.Type); + Assert.Equal(file.RelativePath, newFile.RelativePath); + Assert.Equal(file.IsCompressed, newFile.IsCompressed); + Assert.Equal(file.GetData(), newFile.GetData()); + } + } + } +} diff --git a/test/AsmResolver.DotNet.Tests/Properties/Resources.Designer.cs b/test/AsmResolver.DotNet.Tests/Properties/Resources.Designer.cs index 141a67ba8..c2e4010ea 100644 --- a/test/AsmResolver.DotNet.Tests/Properties/Resources.Designer.cs +++ b/test/AsmResolver.DotNet.Tests/Properties/Resources.Designer.cs @@ -136,6 +136,34 @@ public static byte[] HelloWorld_WithAttribute { } } + public static byte[] HelloWorld_SingleFile_V1 { + get { + object obj = ResourceManager.GetObject("HelloWorld_SingleFile_V1", resourceCulture); + return ((byte[])(obj)); + } + } + + public static byte[] HelloWorld_SingleFile_V2 { + get { + object obj = ResourceManager.GetObject("HelloWorld_SingleFile_V2", resourceCulture); + return ((byte[])(obj)); + } + } + + public static byte[] HelloWorld_SingleFile_V6 { + get { + object obj = ResourceManager.GetObject("HelloWorld_SingleFile_V6", resourceCulture); + return ((byte[])(obj)); + } + } + + public static byte[] HelloWorld_SingleFile_V6_WithResources { + get { + object obj = ResourceManager.GetObject("HelloWorld_SingleFile_V6_WithResources", resourceCulture); + return ((byte[])(obj)); + } + } + public static byte[] Assembly1_Forwarder { get { object obj = ResourceManager.GetObject("Assembly1_Forwarder", resourceCulture); diff --git a/test/AsmResolver.DotNet.Tests/Properties/Resources.resx b/test/AsmResolver.DotNet.Tests/Properties/Resources.resx index c6ab516aa..5e722a4a9 100644 --- a/test/AsmResolver.DotNet.Tests/Properties/Resources.resx +++ b/test/AsmResolver.DotNet.Tests/Properties/Resources.resx @@ -57,6 +57,18 @@ ..\Resources\HelloWorld.WithAttribute.dll;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + ..\Resources\HelloWorld.SingleFile.v1.exe;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ..\Resources\HelloWorld.SingleFile.v2.exe;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ..\Resources\HelloWorld.SingleFile.v6.exe;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ..\Resources\HelloWorld.SingleFile.v6.WithResources.exe;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + ..\Resources\Assembly1_Forwarder.dll;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 diff --git a/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v1.exe b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v1.exe new file mode 100644 index 000000000..67e3b49a2 Binary files /dev/null and b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v1.exe differ diff --git a/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v2.exe b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v2.exe new file mode 100644 index 000000000..99c1be95e Binary files /dev/null and b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v2.exe differ diff --git a/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v6.WithResources.exe b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v6.WithResources.exe new file mode 100644 index 000000000..9da59aba9 Binary files /dev/null and b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v6.WithResources.exe differ diff --git a/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v6.exe b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v6.exe new file mode 100644 index 000000000..e980f978e Binary files /dev/null and b/test/AsmResolver.DotNet.Tests/Resources/HelloWorld.SingleFile.v6.exe differ diff --git a/test/AsmResolver.Tests/Runners/PERunner.cs b/test/AsmResolver.Tests/Runners/PERunner.cs index 3f5a674fb..c2e4ff405 100644 --- a/test/AsmResolver.Tests/Runners/PERunner.cs +++ b/test/AsmResolver.Tests/Runners/PERunner.cs @@ -58,6 +58,17 @@ public string Rebuild(PEFile peFile, string fileName, string testClass, string t return fullPath; } + public string RunAndCaptureOutput(string fileName, byte[] contents, string[]? arguments = null, + int timeout = 5000, + [CallerFilePath] string testClass = "File", + [CallerMemberName] string testMethod = "Test") + { + testClass = Path.GetFileNameWithoutExtension(testClass); + string testExecutablePath = GetTestExecutablePath(testClass, testMethod, fileName); + File.WriteAllBytes(testExecutablePath, contents); + return RunAndCaptureOutput(testExecutablePath, arguments, timeout); + } + public string RunAndCaptureOutput(string filePath, string[]? arguments = null, int timeout = 5000) { var info = GetStartInfo(filePath, arguments);