From 7dc29290ac622df69471018d03ae460265e0b1c1 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:58:48 -0700 Subject: [PATCH 01/16] Preserve entitlements when signing mach files --- .../AppHost/BinaryUtils.cs | 8 +- .../AppHost/HostWriter.cs | 164 ++-------- .../PlaceHolderNotFoundInAppHostException.cs | 3 + .../Microsoft.NET.HostModel/Bundle/Bundler.cs | 276 +++++++++++++---- .../Bundle/FileEntry.cs | 16 + .../Bundle/Manifest.cs | 24 ++ .../BinaryFormat/Blobs/CodeDirectoryBlob.cs | 19 ++ .../BinaryFormat/Blobs/DerEntitlementsBlob.cs | 35 +++ .../Blobs/EmbeddedSignatureBlob.cs | 90 +++++- .../BinaryFormat/Blobs/EntitlementsBlob.cs | 39 +++ .../MachO/Enums/BlobMagic.cs | 2 + .../MachO/Enums/CodeDirectorySpecialSlot.cs | 2 + .../MachO/MachObjectFile.cs | 110 ++++--- .../ResourceUpdater.cs | 40 ++- .../MachOHostSigningTests.cs | 39 +++ .../MachObjectSigning/MachObjectTests.cs | 99 +++++++ .../MachObjectSigning/SigningTests.cs | 280 +++++++++--------- .../corehost/apphost/static/CMakeLists.txt | 4 + 18 files changed, 847 insertions(+), 403 deletions(-) create mode 100644 src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/DerEntitlementsBlob.cs create mode 100644 src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EntitlementsBlob.cs create mode 100644 src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs index 62597baeaeafc4..e32fe97a554df2 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs @@ -11,7 +11,7 @@ public static class BinaryUtils { internal static unsafe void SearchAndReplace( MemoryMappedViewAccessor accessor, - byte[] searchPattern, + ReadOnlySpan searchPattern, byte[] patternToReplace, bool pad0s = true) { @@ -48,7 +48,7 @@ internal static unsafe void SearchAndReplace( } } - private static unsafe void Pad0(byte[] searchPattern, byte[] patternToReplace, byte* bytes, int offset) + private static unsafe void Pad0(ReadOnlySpan searchPattern, ReadOnlySpan patternToReplace, byte* bytes, int offset) { if (patternToReplace.Length < searchPattern.Length) { @@ -92,7 +92,7 @@ public static unsafe int SearchInFile(string filePath, byte[] searchPattern) } // See: https://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm - private static int[] ComputeKMPFailureFunction(byte[] pattern) + private static int[] ComputeKMPFailureFunction(ReadOnlySpan pattern) { int[] table = new int[pattern.Length]; if (pattern.Length >= 1) @@ -128,7 +128,7 @@ private static int[] ComputeKMPFailureFunction(byte[] pattern) } // See: https://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm - private static unsafe int KMPSearch(byte[] pattern, byte* bytes, long bytesLength) + private static unsafe int KMPSearch(ReadOnlySpan pattern, byte* bytes, long bytesLength) { int m = 0; int i = 0; diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs index 7de2c6cca43872..f0a64226fcb39a 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs @@ -2,11 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.IO.MemoryMappedFiles; using System.Runtime.InteropServices; using System.Text; +using Microsoft.NET.HostModel.Bundle; using Microsoft.NET.HostModel.MachO; namespace Microsoft.NET.HostModel.AppHost @@ -124,8 +126,14 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access if (File.Exists(appHostDestinationFilePath)) File.Delete(appHostDestinationFilePath); - using (FileStream appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.CreateNew, FileAccess.ReadWrite)) + long appHostSourceLength = new FileInfo(appHostSourceFilePath).Length; + string destinationFileName = Path.GetFileName(appHostDestinationFilePath); + long appHostDestinationLength = enableMacOSCodeSign ? + appHostSourceLength + MachObjectFile.GetSignatureSizeEstimate((uint)appHostSourceLength, destinationFileName) + : appHostSourceLength; + using (var appHostDestinationMap = MemoryMappedFile.CreateNew(null, appHostDestinationLength)) { + using (var appHostDestinationStream = appHostDestinationMap.CreateViewStream()) using (FileStream appHostSourceStream = new(appHostSourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1)) { isMachOImage = MachObjectFile.IsMachOImage(appHostSourceStream); @@ -135,42 +143,39 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } appHostSourceStream.CopyTo(appHostDestinationStream); } - // Get the size of the source app host to ensure that we don't write extra data to the destination. - // On Windows, the size of the view accessor is rounded up to the next page boundary. - long appHostLength = appHostDestinationStream.Length; - string destinationFileName = Path.GetFileName(appHostDestinationFilePath); - // On Mac, we need to extend the file size to accommodate the signature. - long appHostTmpCapacity = enableMacOSCodeSign ? - appHostLength + MachObjectFile.GetSignatureSizeEstimate((uint)appHostLength, destinationFileName) - : appHostLength; - using (MemoryMappedFile memoryMappedFile = MemoryMappedFile.CreateFromFile(appHostDestinationStream, null, appHostTmpCapacity, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true)) - using (MemoryMappedViewAccessor memoryMappedViewAccessor = memoryMappedFile.CreateViewAccessor(0, appHostTmpCapacity, MemoryMappedFileAccess.ReadWrite)) + using (var memoryMappedViewAccessor = appHostDestinationMap.CreateViewAccessor()) { + // Get the size of the source app host to ensure that we don't write extra data to the destination. + // On Windows, the size of the view accessor is rounded up to the next page boundary. // Transform the host file in-memory. - RewriteAppHost(memoryMappedFile, memoryMappedViewAccessor); + RewriteAppHost(appHostDestinationMap, memoryMappedViewAccessor); if (isMachOImage) { IMachOFileAccess file = new MemoryMappedMachOViewAccessor(memoryMappedViewAccessor); + MachObjectFile machObjectFile = MachObjectFile.Create(file); if (enableMacOSCodeSign) { - MachObjectFile machObjectFile = MachObjectFile.Create(file); - appHostLength = machObjectFile.AdHocSignFile(file, destinationFileName); + appHostDestinationLength = machObjectFile.AdHocSignFile(file, destinationFileName); } - else if (MachObjectFile.RemoveCodeSignatureIfPresent(file, out long? length)) + else if (machObjectFile.RemoveCodeSignatureIfPresent(file, out long? length)) { - appHostLength = length.Value; + appHostDestinationLength = length.Value; } } } - appHostDestinationStream.SetLength(appHostLength); - if (assemblyToCopyResourcesFrom != null && appHostIsPEImage) { - using var updater = new ResourceUpdater(appHostDestinationStream, true); + using var updater = new ResourceUpdater(appHostDestinationMap, true); updater.AddResourcesFromPEImage(assemblyToCopyResourcesFrom); updater.Update(); } + using (var appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 1)) + using (var appHostAccessor = appHostDestinationMap.CreateViewAccessor(0, appHostDestinationLength, MemoryMappedFileAccess.Read)) + { + // Write the final content to the destination file. + BinaryUtils.WriteToStream(appHostAccessor, appHostDestinationStream, appHostDestinationLength); + } } }); Chmod755(appHostDestinationFilePath); @@ -191,125 +196,6 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } } - /// - /// Set the current AppHost as a single-file bundle. - /// - /// The path of Apphost template, which has the place holder - /// The offset to the location of bundle header - /// Whether to ad-hoc sign the bundle as a Mach-O executable - public static void SetAsBundle( - string appHostPath, - long bundleHeaderOffset, - bool macosCodesign = false) - { - byte[] bundleHeaderPlaceholder = { - // 8 bytes represent the bundle header-offset - // Zero for non-bundle apphosts (default). - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // 32 bytes represent the bundle signature: SHA-256 for ".net core bundle" - 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 - }; - - // Re-write the destination apphost with the proper contents. - RetryUtil.RetryOnIOError(() => - { - string tmpFile = null; - try - { - // MacOS keeps a cache of file signatures. To avoid using the cached value, - // we need to create a new inode with the contents of the old file, sign it, - // and copy it the original file path. - tmpFile = Path.GetTempFileName(); - using (FileStream newBundleStream = new FileStream(tmpFile, FileMode.Create, FileAccess.ReadWrite)) - { - using (FileStream oldBundleStream = new FileStream(appHostPath, FileMode.Open, FileAccess.Read)) - { - oldBundleStream.CopyTo(newBundleStream); - } - - long bundleSize = newBundleStream.Length; - long mmapFileSize = macosCodesign - ? bundleSize + MachObjectFile.GetSignatureSizeEstimate((uint)bundleSize, Path.GetFileName(appHostPath)) - : bundleSize; - using (MemoryMappedFile memoryMappedFile = MemoryMappedFile.CreateFromFile(newBundleStream, null, mmapFileSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: true)) - using (MemoryMappedViewAccessor accessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite)) - { - BinaryUtils.SearchAndReplace(accessor, - bundleHeaderPlaceholder, - BitConverter.GetBytes(bundleHeaderOffset), - pad0s: false); - - var file = new MemoryMappedMachOViewAccessor(accessor); - if (MachObjectFile.IsMachOImage(file)) - { - var machObjectFile = MachObjectFile.Create(file); - if (machObjectFile.HasSignature) - throw new AppHostMachOFormatException(MachOFormatError.SignNotRemoved); - - bool wasBundled = machObjectFile.TryAdjustHeadersForBundle((ulong)bundleSize, file); - if (!wasBundled) - throw new InvalidOperationException("The single-file bundle was unable to be created. This is likely because the bundled content is too large."); - - if (macosCodesign) - bundleSize = machObjectFile.AdHocSignFile(file, Path.GetFileName(appHostPath)); - } - } - newBundleStream.SetLength(bundleSize); - } - File.Copy(tmpFile, appHostPath, overwrite: true); - Chmod755(appHostPath); - } - finally - { - if (tmpFile is not null) - File.Delete(tmpFile); - } - }); - } - - /// - /// Check if the an AppHost is a single-file bundle - /// - /// The path of Apphost to check - /// An out parameter containing the offset of the bundle header (if any) - /// True if the AppHost is a single-file bundle, false otherwise - public static bool IsBundle(string appHostFilePath, out long bundleHeaderOffset) - { - byte[] bundleSignature = { - // 32 bytes represent the bundle signature: SHA-256 for ".net core bundle" - 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 - }; - - long headerOffset = 0; - void FindBundleHeader() - { - using (var memoryMappedFile = MemoryMappedFile.CreateFromFile(appHostFilePath, FileMode.Open, null, 0, MemoryMappedFileAccess.Read)) - { - using (MemoryMappedViewAccessor accessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) - { - int position = BinaryUtils.SearchInFile(accessor, bundleSignature); - if (position == -1) - { - throw new PlaceHolderNotFoundInAppHostException(bundleSignature); - } - - headerOffset = accessor.ReadInt64(position - sizeof(long)); - } - } - } - - RetryUtil.RetryOnIOError(FindBundleHeader); - bundleHeaderOffset = headerOffset; - - return headerOffset != 0; - } - private static byte[] GetSearchOptionBytes(DotNetSearchOptions searchOptions) { if (Path.IsPathRooted(searchOptions.AppRelativeDotNet)) @@ -333,7 +219,7 @@ private static byte[] GetSearchOptionBytes(DotNetSearchOptions searchOptions) return searchOptionsBytes; } - private static void Chmod755(string pathName) + internal static void Chmod755(string pathName) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs index 4268e640154507..b2b21322faf5ac 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs @@ -16,5 +16,8 @@ public PlaceHolderNotFoundInAppHostException(byte[] pattern) { MissingPattern = pattern; } + public PlaceHolderNotFoundInAppHostException(ReadOnlySpan pattern) + { + } } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index bbe567852c922b..acbceae6a83906 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -1,11 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.IO.Compression; +using System.IO.MemoryMappedFiles; using System.Linq; using System.Reflection.PortableExecutable; using System.Runtime.InteropServices; @@ -41,9 +45,9 @@ public Bundler(string hostName, BundleOptions options = BundleOptions.None, OSPlatform? targetOS = null, Architecture? targetArch = null, - Version targetFrameworkVersion = null, + Version? targetFrameworkVersion = null, bool diagnosticOutput = false, - string appAssemblyName = null, + string? appAssemblyName = null, bool macosCodesign = true) { _tracer = new Trace(diagnosticOutput); @@ -93,7 +97,7 @@ private bool ShouldCompress(FileType type) /// startOffset: offset of the start 'file' within 'bundle' /// compressedSize: size of the compressed data, if entry was compressed, otherwise 0 /// - private (long startOffset, long compressedSize) AddToBundle(FileStream bundle, FileStream file, FileType type) + private (long startOffset, long compressedSize) AddToBundle(Stream bundle, FileStream file, FileType type) { long startOffset = bundle.Position; if (ShouldCompress(type)) @@ -181,7 +185,7 @@ private static bool IsAssembly(string path, out bool isPE) try { PEReader peReader = new PEReader(file); - CorHeader corHeader = peReader.PEHeaders.CorHeader; + CorHeader? corHeader = peReader.PEHeaders.CorHeader; isPE = true; // If peReader.PEHeaders doesn't throw, it is a valid PEImage return corHeader != null; @@ -227,6 +231,17 @@ private FileType InferType(FileSpec fileSpec) return FileType.Unknown; } + public static ImmutableArray BundleHeaderPlaceholder = [ + // 8 bytes represent the bundle header-offset + // Zero for non-bundle apphosts (default). + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // 32 bytes represent the bundle signature: SHA-256 for ".net core bundle" + 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 + ]; + /// /// Generate a bundle, given the specification of embedded files /// @@ -250,12 +265,10 @@ public string GenerateBundle(IReadOnlyList fileSpecs) _tracer.Log($"Bundle Version: {BundleManifest.BundleVersion}"); _tracer.Log($"Target Runtime: {_target}"); _tracer.Log($"Bundler Options: {_options}"); - if (fileSpecs.Any(x => !x.IsValid())) { throw new ArgumentException("Invalid input specification: Found entry with empty source-path or bundle-relative-path."); } - string hostSource; try { @@ -266,86 +279,235 @@ public string GenerateBundle(IReadOnlyList fileSpecs) throw new ArgumentException("Invalid input specification: Must specify the host binary"); } + var relativePathToSpec = GetFilteredFileSpecs(fileSpecs); + long bundledFilesSize = 0; + // Conservatively estimate the size of bundled files. + // Assume no compression and worst case alignment for assemblies. + // There's no way to know the exact compressed sizes without reading the entire file, + // which would be expensive. + // We will memory map a larger file than needed, but we'll take that trade-off. + foreach (var (spec, type) in relativePathToSpec) + { + bundledFilesSize += new FileInfo(spec.SourcePath).Length; + if (type == FileType.Assembly) + { + // Alignment could be as much as AssemblyAlignment - 1 bytes. + // Since the files may be compressed when written to the bundle we can't be sure of exactly how much space the padding will require. + // So we'll consvervatively add an additional AssemblyAlignment bytes. + bundledFilesSize += _target.AssemblyAlignment; + } + } + string bundlePath = Path.Combine(_outputDir, _hostName); if (File.Exists(bundlePath)) { _tracer.Log($"Ovewriting existing File {bundlePath}"); } - BinaryUtils.CopyFile(hostSource, bundlePath); - - // Note: We're comparing file paths both on the OS we're running on as well as on the target OS for the app - // We can't really make assumptions about the file systems (even on Linux there can be case insensitive file systems - // and vice versa for Windows). So it's safer to do case sensitive comparison everywhere. - var relativePathToSpec = new Dictionary(StringComparer.Ordinal); - - long headerOffset = 0; - using (FileStream bundle = File.Open(bundlePath, FileMode.Open, FileAccess.ReadWrite)) - using (BinaryWriter writer = new BinaryWriter(bundle, Encoding.Default, leaveOpen: true)) + string destinationDirectory = new FileInfo(bundlePath).Directory!.FullName; + if (!Directory.Exists(destinationDirectory)) { - if (_target.IsOSX) - { - MachObjectFile.RemoveCodeSignatureIfPresent(bundle); - } - bundle.Position = bundle.Length; - foreach (var fileSpec in fileSpecs) + Directory.CreateDirectory(destinationDirectory); + } + var bundleName = Path.GetFileName(bundlePath); + var hostLength = new FileInfo(hostSource).Length; + var bundleManifestLength = BundleManifest.GetManifestLength(BundleManifest.BundleMajorVersion, relativePathToSpec.Select(x => x.Spec.BundleRelativePath)); + long bundleTotalSize = hostLength + bundledFilesSize + bundleManifestLength; + if (_target.IsOSX && _macosCodesign) + bundleTotalSize += MachObjectFile.GetSignatureSizeEstimate((uint)bundleTotalSize, bundleName); + + using (MemoryMappedFile bundleMap = MemoryMappedFile.CreateNew(null, bundleTotalSize, MemoryMappedFileAccess.ReadWrite, MemoryMappedFileOptions.None, HandleInheritability.None)) + { + long endOfHost; + long headerOffset; + using (MemoryMappedViewAccessor accessor = bundleMap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite)) + using (MemoryMappedViewStream bundleStream = bundleMap.CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite)) { - string relativePath = fileSpec.BundleRelativePath; - - if (IsHost(relativePath)) + using (FileStream hostSourceStream = File.OpenRead(hostSource)) { - continue; + hostSourceStream.CopyTo(bundleStream); } + endOfHost = bundleStream.Position; - if (ShouldIgnore(relativePath)) + Debug.Assert(endOfHost == hostLength, $"Host file size on disk does not match bytes written to the bundle. Expected {hostLength}, but got {endOfHost}. This may indicate that the host file is not a valid native binary or that it is not a single-file apphost."); + MachObjectFile? machFile = null; + EmbeddedSignatureBlob? signatureBlob = null; + IMachOFileAccess machFileReader = null!; + if (_target.IsOSX) { - _tracer.Log($"Ignore: {relativePath}"); - continue; + machFileReader = new StreamBasedMachOFile(bundleStream); + machFile = MachObjectFile.Create(machFileReader); + signatureBlob = machFile.EmbeddedSignatureBlob; + if (machFile.RemoveCodeSignatureIfPresent(machFileReader, out long? newEnd)) + { + endOfHost = newEnd!.Value; + } } - - FileType type = InferType(fileSpec); - - if (ShouldExclude(type, relativePath)) + bundleStream.Position = endOfHost; + foreach (var kvp in relativePathToSpec) + { + FileSpec fileSpec = kvp.Spec; + FileType type = kvp.Type; + string relativePath = fileSpec.BundleRelativePath; + using (FileStream file = File.OpenRead(fileSpec.SourcePath)) + { + FileType targetType = _target.TargetSpecificFileType(type); + (long startOffset, long compressedSize) = AddToBundle(bundleStream, file, targetType); + FileEntry entry = BundleManifest.AddEntry(targetType, file, relativePath, startOffset, compressedSize, _target.BundleMajorVersion); + _tracer.Log($"Embed: {entry}"); + } + } + Debug.Assert(bundleStream.Position - endOfHost <= bundledFilesSize, $"Not enough space allocated for bundled files. Allocated {bundledFilesSize}, but written {bundleStream.Position - endOfHost}"); + var endOfBundledFiles = bundleStream.Position; + using (BinaryWriter writer = new BinaryWriter(bundleStream, Encoding.UTF8, leaveOpen: true)) { - _tracer.Log($"Exclude [{type}]: {relativePath}"); - fileSpec.Excluded = true; - continue; + // Write the bundle manifest + headerOffset = BundleManifest.Write(writer); + _tracer.Log($"Header Offset={headerOffset}"); + _tracer.Log($"Meta-data Size={writer.BaseStream.Position - headerOffset}"); + _tracer.Log($"Bundle: Path={bundlePath}, Size={bundleStream.Length}"); } - - if (relativePathToSpec.TryGetValue(fileSpec.BundleRelativePath, out var existingFileSpec)) + ulong endOfBundle = (ulong)bundleStream.Position; + Debug.Assert((long)endOfBundle == endOfBundledFiles + bundleManifestLength, $"Bundle manifest is unexpected size. Expected {bundleManifestLength}, but got {(long)endOfBundle - endOfBundledFiles}"); + BinaryUtils.SearchAndReplace(accessor, + BundleHeaderPlaceholder.AsSpan(), + BitConverter.GetBytes(headerOffset), + pad0s: false); + if (_target.IsOSX && machFile is not null) { - if (!string.Equals(fileSpec.SourcePath, existingFileSpec.SourcePath, StringComparison.Ordinal)) + Debug.Assert(machFileReader is not null, "MachO file reader should not be null if the target is macOS."); + if (!machFile.TryAdjustHeadersForBundle(endOfBundle, machFileReader!)) + { + throw new InvalidOperationException("The single-file bundle was unable to be created. This is likely because the bundled content is too large."); + } + if (_macosCodesign) { - throw new ArgumentException($"Invalid input specification: Found entries '{fileSpec.SourcePath}' and '{existingFileSpec.SourcePath}' with the same BundleRelativePath '{fileSpec.BundleRelativePath}'"); + endOfBundle = (ulong)machFile.AdHocSignFile(machFileReader!, bundleName, signatureBlob); } + } - // Exact duplicate - intentionally skip and don't include a second copy in the bundle - continue; + // MacOS keeps a cache of file signatures, so we must create a new inode to ensure the file signature is properly updated. + if (_macosCodesign && File.Exists(bundlePath)) + { + _tracer.Log($"Removing existing bundle file to clear signature cache: {bundlePath}"); + File.Delete(bundlePath); } - else + using (FileStream bundleOutputStream = File.Open(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None)) { - relativePathToSpec.Add(fileSpec.BundleRelativePath, fileSpec); + BinaryUtils.WriteToStream(accessor, bundleOutputStream, (long)endOfBundle); } + } + } + HostWriter.Chmod755(bundlePath); + return bundlePath; + } + + /// + /// Check if the an AppHost is a single-file bundle + /// + /// The path of Apphost to check + /// An out parameter containing the offset of the bundle header (if any) + /// True if the AppHost is a single-file bundle, false otherwise + public static bool IsBundle(string appHostFilePath, out long bundleHeaderOffset) + { + byte[] bundleSignature = { + // 32 bytes represent the bundle signature: SHA-256 for ".net core bundle" + 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 + }; - using (FileStream file = File.OpenRead(fileSpec.SourcePath)) + long headerOffset = 0; + void FindBundleHeader() + { + using (var memoryMappedFile = MemoryMappedFile.CreateFromFile(appHostFilePath, FileMode.Open, null, 0, MemoryMappedFileAccess.Read)) + { + using (MemoryMappedViewAccessor accessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) { - FileType targetType = _target.TargetSpecificFileType(type); - (long startOffset, long compressedSize) = AddToBundle(bundle, file, targetType); - FileEntry entry = BundleManifest.AddEntry(targetType, file, relativePath, startOffset, compressedSize, _target.BundleMajorVersion); - _tracer.Log($"Embed: {entry}"); + int position = BinaryUtils.SearchInFile(accessor, bundleSignature); + if (position == -1) + { + throw new PlaceHolderNotFoundInAppHostException(bundleSignature); + } + + headerOffset = accessor.ReadInt64(position - sizeof(long)); } } - - // Write the bundle manifest - headerOffset = BundleManifest.Write(writer); - _tracer.Log($"Header Offset={headerOffset}"); - _tracer.Log($"Meta-data Size={writer.BaseStream.Position - headerOffset}"); - _tracer.Log($"Bundle: Path={bundlePath}, Size={bundle.Length}"); } - HostWriter.SetAsBundle(bundlePath, headerOffset, _macosCodesign); + RetryUtil.RetryOnIOError(FindBundleHeader); + bundleHeaderOffset = headerOffset; - return bundlePath; + return headerOffset != 0; + } + + private (FileSpec Spec, FileType Type)[] GetFilteredFileSpecs(IEnumerable fileSpecs) + { + // Note: We're comparing file paths both on the OS we're running on as well as on the target OS for the app + // We can't really make assumptions about the file systems (even on Linux there can be case insensitive file systems + // and vice versa for Windows). So it's safer to do case sensitive comparison everywhere. + var relativePathToSpec = new Dictionary(StringComparer.Ordinal); + foreach (var fileSpec in fileSpecs) + { + string relativePath = fileSpec.BundleRelativePath; + + if (IsHost(relativePath)) + { + continue; + } + + if (ShouldIgnore(relativePath)) + { + _tracer.Log($"Ignore: {relativePath}"); + continue; + } + + FileType type = InferType(fileSpec); + + if (ShouldExclude(type, relativePath)) + { + _tracer.Log($"Exclude [{type}]: {relativePath}"); + fileSpec.Excluded = true; + continue; + } + + if (relativePathToSpec.TryGetValue(fileSpec.BundleRelativePath, out var existingFileSpec)) + { + if (!string.Equals(fileSpec.SourcePath, existingFileSpec.Spec.SourcePath, StringComparison.Ordinal)) + { + throw new ArgumentException($"Invalid input specification: Found entries '{fileSpec.SourcePath}' and '{existingFileSpec.Spec.SourcePath}' with the same BundleRelativePath '{fileSpec.BundleRelativePath}'"); + } + + // Exact duplicate - intentionally skip and don't include a second copy in the bundle + continue; + } + else + { + relativePathToSpec.Add(fileSpec.BundleRelativePath, (fileSpec, type)); + } + } + return relativePathToSpec.Values.ToArray(); + } + + /// + /// Get the length of the string when written to a BinaryWriter. + /// + internal static uint GetBinaryWriterStringLength(string str) + { + // 1 byte for the length prefix + length of the string in bytes + uint stringLength = (uint)Encoding.UTF8.GetByteCount(str); // BundleID with prefixed length + // Prefixed length of bundle ID is 7-bit encoded + // Strings 0-127 chars: 1 byte prefix + // Strings 128-16,383 chars: 2 byte prefix + // Strings 16,384-2,097,151 chars: 3 byte prefix + // Strings 2,097,152-268,435,455 chars: 4 byte prefix + // Strings 268,435,456+ chars: 5 byte prefix + uint lengthPrefixLength = (stringLength < 128) ? 1u : + (stringLength < 16384) ? 2u : + (stringLength < 2097152) ? 3u : + (stringLength < 268435456) ? 4u : 5u; + return lengthPrefixLength + stringLength; } } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs index e71a7faaa45789..8f1b9042b320dc 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.IO; namespace Microsoft.NET.HostModel.Bundle @@ -42,6 +43,7 @@ public FileEntry(FileType fileType, string relativePath, long offset, long size, public void Write(BinaryWriter writer) { + var start = writer.BaseStream.Position; writer.Write(Offset); writer.Write(Size); // compression is used only in version 6.0+ @@ -51,6 +53,20 @@ public void Write(BinaryWriter writer) } writer.Write((byte)Type); writer.Write(RelativePath); + Debug.Assert(writer.BaseStream.Position - start == GetFileEntryLength(BundleMajorVersion, RelativePath), + $"FileEntry size mismatch. Expected: {GetFileEntryLength(BundleMajorVersion, RelativePath)}, Actual: {writer.BaseStream.Position - start}"); + } + + /// + /// Returns the length of the FileEntry in the manifest in bytes. This is not the size of the file itself. + /// + public static uint GetFileEntryLength(uint bundleMajorVersion, string bundleRelativePath) + { + return 8u // Offset + + 8u // Size + + (bundleMajorVersion >= 6 ? 8u : 0u) // CompressedSize + + 1u // Type (FileType) + + Bundler.GetBinaryWriterStringLength(bundleRelativePath); } public override string ToString() => $"{RelativePath} [{Type}] @{Offset} Sz={Size} CompressedSz={CompressedSize}"; diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs index 564da05892fceb..7500aa724c756a 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -165,10 +166,33 @@ public long Write(BinaryWriter writer) { entry.Write(writer); } + Debug.Assert(writer.BaseStream.Position - startOffset == GetManifestLength(BundleMajorVersion, Files.Select(static f => f.RelativePath)), + $"Manifest size mismatch: {writer.BaseStream.Position - startOffset} != {GetManifestLength(BundleMajorVersion, Files.Select(static f => f.RelativePath))}"); return startOffset; } + /// + /// Calculates the length of the manifest in bytes. + /// + public long GetManifestLength(uint bundleMajorVersion, IEnumerable fileSpecs) + { + // Size of the header + long size = sizeof(uint) * 2 + // BundleMajorVersion + BundleMinorVersion + sizeof(int) + // NumEmbeddedFiles + (bundleMajorVersion >= 2 ? (sizeof(long) * 4 + sizeof(ulong)) : 0); // DepsJson and RuntimeConfigJson offsets and sizes, and Flags +#pragma warning disable CA1850 // Prefer static 'System.Security.Cryptography.SHA256.HashData' method over 'ComputeHash' + size += Bundler.GetBinaryWriterStringLength(Convert.ToBase64String(SHA256.Create().ComputeHash([])).Substring(BundleIdLength).Replace('/', '_')); +#pragma warning restore CA1850 + // Size of each FileEntry + foreach (var fileSpec in fileSpecs) + { + size += FileEntry.GetFileEntryLength(bundleMajorVersion, fileSpec); + } + + return size; + } + public bool Contains(string relativePath) { return Files.Any(entry => relativePath.Equals(entry.RelativePath)); diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index 4d95ca62d7b1da..ca307270f38386 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -102,6 +102,8 @@ public static CodeDirectoryBlob Create( long signatureStart, string identifier, RequirementsBlob requirementsBlob, + EntitlementsBlob? entitlementsBlob = null, + DerEntitlementsBlob? derEntitlementsBlob = null, HashType hashType = HashType.SHA256, uint pageSize = MachObjectFile.DefaultPageSize) { @@ -121,12 +123,29 @@ public static CodeDirectoryBlob Create( // Fill in the CodeDirectory hashes // Special slot hashes + // -7 is the der entitlements blob hash + if (derEntitlementsBlob != null) + { + using var derStream = new MemoryStreamWriter((int)derEntitlementsBlob.Size); + derEntitlementsBlob.Write(derStream, 0); + specialSlotHashes[(int)CodeDirectorySpecialSlot.DerEntitlements - 1] = hasher.ComputeHash(derStream.GetBuffer()); + } + + // -5 is the entitlements blob hash + if (entitlementsBlob != null) + { + using var entStream = new MemoryStreamWriter((int)entitlementsBlob.Size); + entitlementsBlob.Write(entStream, 0); + specialSlotHashes[(int)CodeDirectorySpecialSlot.Entitlements - 1] = hasher.ComputeHash(entStream.GetBuffer()); + } + // -2 is the requirements blob hash using (var reqStream = new MemoryStreamWriter((int)requirementsBlob.Size)) { requirementsBlob.Write(reqStream, 0); specialSlotHashes[(int)CodeDirectorySpecialSlot.Requirements - 1] = hasher.ComputeHash(reqStream.GetBuffer()); } + // -1 is the CMS blob hash (which is empty -- nothing to hash) // Reverse special slot hashes diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/DerEntitlementsBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/DerEntitlementsBlob.cs new file mode 100644 index 00000000000000..4b0a13b94252d0 --- /dev/null +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/DerEntitlementsBlob.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace Microsoft.NET.HostModel.MachO; + +internal sealed class DerEntitlementsBlob : IBlob +{ + private SimpleBlob _inner; + + public DerEntitlementsBlob(SimpleBlob blob) + { + _inner = blob; + if (blob.Size > MaxSize) + { + throw new InvalidDataException($"DerEntitlementsBlob size exceeds maximum allowed size: {blob.Data.Length} > {MaxSize}"); + } + if (blob.Magic != BlobMagic.DerEntitlements) + { + throw new InvalidDataException($"Invalid magic for DerEntitlementsBlob: {blob.Magic}"); + } + } + + public static uint MaxSize => 1024; + + /// + public BlobMagic Magic => ((IBlob)_inner).Magic; + + /// + public uint Size => ((IBlob)_inner).Size; + + /// + public int Write(IMachOFileWriter writer, long offset) => ((IBlob)_inner).Write(writer, offset); +} diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs index 529cdc547c3144..d90ec264a34bb8 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Immutable; +using System.Diagnostics; using System.IO; namespace Microsoft.NET.HostModel.MachO; @@ -35,20 +36,38 @@ public EmbeddedSignatureBlob(SuperBlob superBlob) public EmbeddedSignatureBlob( CodeDirectoryBlob codeDirectoryBlob, RequirementsBlob requirementsBlob, - CmsWrapperBlob cmsWrapperBlob) + CmsWrapperBlob cmsWrapperBlob, + EntitlementsBlob? entitlementsBlob = null, + DerEntitlementsBlob? derEntitlementsBlob = null) { - int blobCount = 3; + int blobCount = 3 + (entitlementsBlob is not null ? 1 : 0) + (derEntitlementsBlob is not null ? 1 : 0); var blobs = ImmutableArray.CreateBuilder(blobCount); var blobIndices = ImmutableArray.CreateBuilder(blobCount); - uint expectedOffset = (uint)(sizeof(uint) * 3 + (BlobIndex.Size * blobCount)); + uint nextBlobOffset = (uint)(sizeof(uint) * 3 + (BlobIndex.Size * blobCount)); + blobs.Add(codeDirectoryBlob); - blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.CodeDirectory, expectedOffset)); - expectedOffset += codeDirectoryBlob.Size; + blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.CodeDirectory, nextBlobOffset)); + nextBlobOffset += codeDirectoryBlob.Size; + blobs.Add(requirementsBlob); - blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.Requirements, expectedOffset)); - expectedOffset += requirementsBlob.Size; + blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.Requirements, nextBlobOffset)); + nextBlobOffset += requirementsBlob.Size; + blobs.Add(cmsWrapperBlob); - blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.CmsWrapper, expectedOffset)); + blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.CmsWrapper, nextBlobOffset)); + nextBlobOffset += cmsWrapperBlob.Size; + + if (entitlementsBlob is not null) + { + blobs.Add(entitlementsBlob); + blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.Entitlements, nextBlobOffset)); + nextBlobOffset += entitlementsBlob.Size; + } + if (derEntitlementsBlob is not null) + { + blobs.Add(derEntitlementsBlob); + blobIndices.Add(new BlobIndex(CodeDirectorySpecialSlot.DerEntitlements, nextBlobOffset)); + } _inner = new SuperBlob(BlobMagic.EmbeddedSignature, blobIndices.MoveToImmutable(), blobs.MoveToImmutable()); } @@ -71,6 +90,16 @@ public EmbeddedSignatureBlob( /// public CmsWrapperBlob? CmsWrapperBlob => GetBlob(BlobMagic.CmsWrapper) as CmsWrapperBlob; + /// + /// The EntitlementsBlob. This is only included in created signatures if present in the original signature. + /// + public EntitlementsBlob? EntitlementsBlob => GetBlob(BlobMagic.Entitlements) as EntitlementsBlob; + + /// + /// The DerEntitlementsBlob. This is only included in created signatures if present in the original signature. + /// + public DerEntitlementsBlob? DerEntitlementsBlob => GetBlob(BlobMagic.DerEntitlements) as DerEntitlementsBlob; + public uint GetSpecialSlotHashCount() { uint maxSlot = 0; @@ -84,6 +113,7 @@ public uint GetSpecialSlotHashCount() maxSlot = slot; } } + Debug.Assert((CodeDirectorySpecialSlot)maxSlot is 0 or CodeDirectorySpecialSlot.Requirements or CodeDirectorySpecialSlot.Entitlements or CodeDirectorySpecialSlot.DerEntitlements); return maxSlot; } @@ -104,7 +134,7 @@ public static unsafe long GetLargestSizeEstimate(uint fileSize, string identifie size += sizeof(BlobMagic); size += sizeof(uint); // Blob size size += sizeof(uint); // Blob count - size += sizeof(BlobIndex) * 3; // 3 sub-blobs: CodeDirectory, Requirements, CmsWrapper + size += sizeof(BlobIndex) * 5; // 5 sub-blobs: CodeDirectory, Requirements, CmsWrapper, Entitlements, DerEntitlements // CodeDirectoryBlob size += sizeof(BlobMagic); @@ -112,22 +142,41 @@ public static unsafe long GetLargestSizeEstimate(uint fileSize, string identifie size += sizeof(CodeDirectoryBlob.CodeDirectoryHeader); // CodeDirectory header size += CodeDirectoryBlob.GetIdentifierLength(identifier); // Identifier size += (long)CodeDirectoryBlob.GetCodeSlotCount(fileSize) * usedHashSize; // Code hashes - size += (long)(uint)CodeDirectorySpecialSlot.Requirements * usedHashSize; // Special code hashes + size += (long)(uint)CodeDirectorySpecialSlot.DerEntitlements * usedHashSize; // Special code hashes. The highest special slot is DerEntitlements. size += RequirementsBlob.Empty.Size; // Requirements is always written as an empty blob size += CmsWrapperBlob.Empty.Size; // CMS blob is always written as an empty blob + size += EntitlementsBlob.MaxSize; + size += DerEntitlementsBlob.MaxSize; return size; } /// /// Returns the size of a signature used to replace an existing one. /// If the existing signature is null, it will assume sizing using the default signature, which includes the Requirements and CMS blobs. + /// If the existing signature is not null, it will preserve the Entitlements and DER Entitlements blobs if they exist. /// - internal static unsafe long GetSignatureSize(uint fileSize, string identifier, byte? hashSize = null) + internal static unsafe long GetSignatureSize(uint fileSize, string identifier, EmbeddedSignatureBlob? existingSignature = null, byte? hashSize = null) { byte usedHashSize = hashSize ?? CodeDirectoryBlob.DefaultHashType.GetHashSize(); uint specialCodeSlotCount = (uint)CodeDirectorySpecialSlot.Requirements; uint embeddedSignatureSubBlobCount = 3; // CodeDirectory, Requirements, CMS Wrapper are always present + uint entitlementsBlobSize = 0; + uint derEntitlementsBlobSize = 0; + + if (existingSignature != null) + { + // We preserve Entitlements and DER Entitlements blobs if they exist in the old signature. + // We need to update the relevant sizes and counts to reflect this. + specialCodeSlotCount = Math.Max((uint)CodeDirectorySpecialSlot.Requirements, existingSignature.GetSpecialSlotHashCount()); + entitlementsBlobSize = existingSignature.EntitlementsBlob?.Size ?? 0; + derEntitlementsBlobSize = existingSignature.DerEntitlementsBlob?.Size ?? 0; + // Requirements and CMSWrapper blobs are always overwritten as emtpy, but present. + if (existingSignature.EntitlementsBlob is not null) + embeddedSignatureSubBlobCount += 1; + if (existingSignature.DerEntitlementsBlob is not null) + embeddedSignatureSubBlobCount += 1; + } // Calculate the size of the new signature long size = 0; @@ -137,16 +186,21 @@ internal static unsafe long GetSignatureSize(uint fileSize, string identifier, b size += sizeof(uint); // Blob count size += sizeof(BlobIndex) * embeddedSignatureSubBlobCount; // EmbeddedSignature sub-blobs // CodeDirectory - size += sizeof(BlobMagic); // CD Magic number - size += sizeof(uint); // CD Size field + size += sizeof(BlobMagic); // CodeDirectory Magic number + size += sizeof(uint); // CodeDirectory Size field size += sizeof(CodeDirectoryBlob.CodeDirectoryHeader); // CodeDirectory header size += CodeDirectoryBlob.GetIdentifierLength(identifier); // Identifier size += specialCodeSlotCount * usedHashSize; // Special code hashes size += CodeDirectoryBlob.GetCodeSlotCount(fileSize) * usedHashSize; // Code hashes - // RequirementsBlob + // RequirementsBlob is always empty size += RequirementsBlob.Empty.Size; - // CmsWrapperBlob + // EntitlementsBlob + size += entitlementsBlobSize; + // DER EntitlementsBlob + size += derEntitlementsBlobSize; + // CMSWrapperBlob is always empty size += CmsWrapperBlob.Empty.Size; + return size; } @@ -185,5 +239,11 @@ public static void AssertEquivalent(EmbeddedSignatureBlob? a, EmbeddedSignatureB if (a.CmsWrapperBlob?.Size != b.CmsWrapperBlob?.Size) throw new ArgumentException("CMS Wrapper blobs are not equivalent"); + + if (a.EntitlementsBlob?.Size != b.EntitlementsBlob?.Size) + throw new ArgumentException("Entitlements blobs are not equivalent"); + + if (a.DerEntitlementsBlob?.Size != b.DerEntitlementsBlob?.Size) + throw new ArgumentException("DER Entitlements blobs are not equivalent"); } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EntitlementsBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EntitlementsBlob.cs new file mode 100644 index 00000000000000..fa0f8c0c41329a --- /dev/null +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EntitlementsBlob.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace Microsoft.NET.HostModel.MachO; + +/// +/// See https://github.com/apple-oss-distributions/Security/blob/3dab46a11f45f2ffdbd70e2127cc5a8ce4a1f222/OSX/libsecurity_utilities/lib/blob.h +/// Code signature data is always big endian / network order. +/// +internal sealed class EntitlementsBlob : IBlob +{ + private SimpleBlob _inner; + + public EntitlementsBlob(SimpleBlob blob) + { + _inner = blob; + if (blob.Magic != BlobMagic.Entitlements) + { + throw new InvalidDataException($"Invalid magic for EntitlementsBlob: {blob.Magic}"); + } + if (blob.Size > MaxSize) + { + throw new InvalidDataException($"EntitlementsBlob data exceeds maximum size of {MaxSize} bytes."); + } + } + + public static uint MaxSize => 2048; + + /// + public BlobMagic Magic => ((IBlob)_inner).Magic; + + /// + public uint Size => ((IBlob)_inner).Size; + + /// + public int Write(IMachOFileWriter writer, long offset) => ((IBlob)_inner).Write(writer, offset); +} diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/BlobMagic.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/BlobMagic.cs index 8a709e7c066bea..b2c4f245e418f0 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/BlobMagic.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/BlobMagic.cs @@ -11,5 +11,7 @@ internal enum BlobMagic : uint EmbeddedSignature = 0xfade0cc0, CodeDirectory = 0xfade0c02, Requirements = 0xfade0c01, + Entitlements = 0xfade7171, + DerEntitlements = 0xfade7172, CmsWrapper = 0xfade0b01, } diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/CodeDirectorySpecialSlot.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/CodeDirectorySpecialSlot.cs index 18603dda63c778..231083e272615b 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/CodeDirectorySpecialSlot.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/Enums/CodeDirectorySpecialSlot.cs @@ -10,5 +10,7 @@ internal enum CodeDirectorySpecialSlot { CodeDirectory = 0, Requirements = 2, + Entitlements = 5, + DerEntitlements = 7, CmsWrapper = 0x10000, } diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs index 351468b411b199..6918b3c510dd7d 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs @@ -18,9 +18,8 @@ namespace Microsoft.NET.HostModel.MachO; /// internal unsafe partial class MachObjectFile { - public const uint DefaultPageSize = 0x1000; + internal const uint DefaultPageSize = 0x1000; private const uint CodeSignatureAlignment = 0x10; - private MachHeader _header; private (LinkEditLoadCommand Command, long FileOffset) _codeSignatureLoadCommand; private readonly (Segment64LoadCommand Command, long FileOffset) _textSegment64; @@ -29,13 +28,15 @@ internal unsafe partial class MachObjectFile private EmbeddedSignatureBlob? _codeSignatureBlob; /// - /// The offset of the lowest section in the object file. This is to ensure that additional load commands do not overwrite sections. + /// The offset of the lowest section in the object file. Load commands should not be written past this offset. /// private readonly long _lowestSectionOffset; /// /// The offset in the object file where the next additional load command should be written. /// - private long _nextCommandPtr; + private long NextLoadCommandOffset => _header.SizeOfCommands + sizeof(MachHeader); + + internal EmbeddedSignatureBlob? EmbeddedSignatureBlob => _codeSignatureBlob; private MachObjectFile( MachHeader header, @@ -44,8 +45,7 @@ private MachObjectFile( (Segment64LoadCommand Command, long FileOffset) linkEditSegment64, (SymbolTableLoadCommand Command, long FileOffset) symtabLC, long lowestSection, - EmbeddedSignatureBlob? codeSignatureBlob, - long nextCommandPtr) + EmbeddedSignatureBlob? codeSignatureBlob) { _codeSignatureBlob = codeSignatureBlob; _header = header; @@ -54,7 +54,16 @@ private MachObjectFile( _linkEditSegment64 = linkEditSegment64; _symtabCommand = symtabLC; _lowestSectionOffset = lowestSection; - _nextCommandPtr = nextCommandPtr; + } + + public static MachObjectFile Create(MemoryMappedViewAccessor accessor) + { + return Create(new MemoryMappedMachOViewAccessor(accessor)); + } + + public static MachObjectFile Create(Stream stream) + { + return Create(new StreamBasedMachOFile(stream)); } /// @@ -70,7 +79,7 @@ public static MachObjectFile Create(IMachOFileReader file) if (!header.Is64Bit) throw new AppHostMachOFormatException(MachOFormatError.Not64BitExe); - long nextCommandPtr = ReadCommands( + ReadCommands( file, in header, out (LinkEditLoadCommand Command, long FileOffset) codeSignatureLC, @@ -88,8 +97,7 @@ public static MachObjectFile Create(IMachOFileReader file) linkEditSegment64, symtabCommand, lowestSection, - codeSignatureBlob, - nextCommandPtr); + codeSignatureBlob); } /// @@ -102,35 +110,52 @@ public static MachObjectFile Create(IMachOFileReader file) /// Writes the EmbeddedSignature blob to the file. /// Returns the new size of the file (the end of the signature blob). /// - public long AdHocSignFile(IMachOFileAccess file, string identifier) + /// The file to write the signature to. + /// The identifier to use for the code signature. + /// + /// An optional old signature to preserve entitlements metadata. + /// If not provided, the existing code signature blob will be used. + /// If the existing code signature blob is not present, a new signature will be created without entitlements. + /// + public long AdHocSignFile(IMachOFileAccess file, string identifier, EmbeddedSignatureBlob? oldSignature = null) { - AllocateCodeSignatureLoadCommand(identifier); + oldSignature ??= _codeSignatureBlob; + AllocateCodeSignatureLoadCommand(identifier, oldSignature); _codeSignatureBlob = null; // The code signature includes hashes of the entire file up to the code signature. // In order to calculate the hashes correctly, everything up to the code signature must be written before the signature is built. Write(file); - _codeSignatureBlob = CreateSignature(this, file, identifier); + _codeSignatureBlob = CreateSignature(this, file, identifier, oldSignature); Validate(); _codeSignatureBlob.Write(file, _codeSignatureLoadCommand.Command.GetDataOffset(_header)); return GetFileSize(); } - private static EmbeddedSignatureBlob CreateSignature(MachObjectFile machObject, IMachOFileReader file, string identifier) + + private static EmbeddedSignatureBlob CreateSignature(MachObjectFile machObject, IMachOFileReader file, string identifier, EmbeddedSignatureBlob? oldSignature) { + var oldSignatureBlob = oldSignature; + Debug.Assert(!machObject._codeSignatureLoadCommand.Command.IsDefault); uint signatureStart = machObject._codeSignatureLoadCommand.Command.GetDataOffset(machObject._header); RequirementsBlob requirementsBlob = RequirementsBlob.Empty; CmsWrapperBlob cmsWrapperBlob = CmsWrapperBlob.Empty; + EntitlementsBlob? entitlementsBlob = oldSignatureBlob?.EntitlementsBlob; + DerEntitlementsBlob? derEntitlementsBlob = oldSignatureBlob?.DerEntitlementsBlob; var codeDirectory = CodeDirectoryBlob.Create( file, signatureStart, identifier, - requirementsBlob); + requirementsBlob, + entitlementsBlob, + derEntitlementsBlob); return new EmbeddedSignatureBlob( codeDirectoryBlob: codeDirectory, requirementsBlob: requirementsBlob, - cmsWrapperBlob: cmsWrapperBlob); + cmsWrapperBlob: cmsWrapperBlob, + entitlementsBlob: entitlementsBlob, + derEntitlementsBlob: derEntitlementsBlob); } /// @@ -141,6 +166,11 @@ private static EmbeddedSignatureBlob CreateSignature(MachObjectFile machObject, /// `true` if the headers were adjusted successfully, `false` otherwise. public bool TryAdjustHeadersForBundle(ulong fileSize, IMachOFileWriter file) { + if (_codeSignatureBlob is not null || + !_codeSignatureLoadCommand.Command.IsDefault) + { + throw new InvalidOperationException("Cannot adjust headers for a Mach-O file with an existing code signature."); + } ulong newStringTableSize = fileSize - _symtabCommand.Command.GetStringTableOffset(_header); if (newStringTableSize > uint.MaxValue) { @@ -156,15 +186,15 @@ public bool TryAdjustHeadersForBundle(ulong fileSize, IMachOFileWriter file) return true; } - public static bool IsMachOImage(IMachOFileReader memoryMappedViewAccessor) + public static bool IsMachOImage(IMachOFileReader file) { - memoryMappedViewAccessor.Read(0, out MachMagic magic); + file.Read(0, out MachMagic magic); return magic is MachMagic.MachHeaderCurrentEndian or MachMagic.MachHeaderOppositeEndian or MachMagic.MachHeader64CurrentEndian or MachMagic.MachHeader64OppositeEndian or MachMagic.FatMagicCurrentEndian or MachMagic.FatMagicOppositeEndian; } - public static bool IsMachOImage(FileStream file) + public static bool IsMachOImage(Stream file) { long oldPosition = file.Position; file.Position = 0; @@ -197,22 +227,20 @@ public static bool IsMachOImage(string filePath) /// The file to remove the signature from. /// The new length of the file if the signature is remove and the method returns true /// True if a signature was present and removed, false otherwise - public static bool RemoveCodeSignatureIfPresent(IMachOFileAccess file, out long? newLength) + public bool RemoveCodeSignatureIfPresent(IMachOFileWriter file, out long? newLength) { newLength = null; - if (!IsMachOImage(file)) - return false; - - MachObjectFile machFile = Create(file); + MachObjectFile machFile = this; if (machFile._codeSignatureLoadCommand.Command.IsDefault) { Debug.Assert(machFile._codeSignatureBlob is null); return false; } + LinkEditLoadCommand clearedCommand = default; + file.Write(_codeSignatureLoadCommand.FileOffset, ref clearedCommand); machFile._header.NumberOfCommands -= 1; machFile._header.SizeOfCommands -= (uint)sizeof(LinkEditLoadCommand); - machFile._nextCommandPtr -= (uint)sizeof(LinkEditLoadCommand); machFile._linkEditSegment64.Command.SetFileSize( machFile._linkEditSegment64.Command.GetFileSize(machFile._header) - machFile._codeSignatureLoadCommand.Command.GetFileSize(machFile._header), @@ -228,20 +256,26 @@ public static bool RemoveCodeSignatureIfPresent(IMachOFileAccess file, out long? /// /// Removes the code signature load command and signature, and resizes the file if necessary. /// - public static void RemoveCodeSignatureIfPresent(FileStream bundle) + public static EmbeddedSignatureBlob? RemoveCodeSignatureIfPresent(FileStream bundle) { long? newLength; bool resized; + EmbeddedSignatureBlob? codeSignature = null; // Windows doesn't allow a FileStream to be resized while the file is memory mapped, so we must dispose of the memory mapped file first. using (MemoryMappedFile mmap = MemoryMappedFile.CreateFromFile(bundle, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true)) using (MemoryMappedViewAccessor accessor = mmap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite)) { - resized = RemoveCodeSignatureIfPresent(new MemoryMappedMachOViewAccessor(accessor), out newLength); + var file = new MemoryMappedMachOViewAccessor(accessor); + MachObjectFile machFile = Create(file); + codeSignature = machFile.EmbeddedSignatureBlob; + resized = machFile.RemoveCodeSignatureIfPresent(file, out newLength); } if (resized) { + Debug.Assert(newLength != null); bundle.SetLength(newLength!.Value); } + return codeSignature; } /// @@ -273,6 +307,7 @@ static bool CodeSignatureLCsAreEquivalent((LinkEditLoadCommand Command, long Fil return false; if (a.FileOffset != b.FileOffset) return false; + // Sizes can be different due to identifier differences. return true; } @@ -288,6 +323,10 @@ static bool LinkEditSegmentsAreEquivalent((Segment64LoadCommand Command, long Fi } } + /// + /// Gets the maximum size of additional space required for the code signature to be added to a file of size . + /// Includes the size of the code signature blob and the padding to align the file to the code signature alignment. + /// public static long GetSignatureSizeEstimate(uint fileSize, string identifier) { return EmbeddedSignatureBlob.GetLargestSizeEstimate(fileSize, identifier) + (AlignUp(fileSize, CodeSignatureAlignment) - fileSize); @@ -299,7 +338,7 @@ public static long GetSignatureSizeEstimate(uint fileSize, string identifier) public long Write(IMachOFileWriter file) { if (file.Capacity < GetFileSize()) - throw new ArgumentException("File is too small", nameof(file)); + throw new ArgumentException($"File is too small. File capacity is '{file.Capacity}' bytes, but the Mach-O requires '{GetFileSize()}' bytes. ", nameof(file)); file.Write(0, ref _header); file.Write(_linkEditSegment64.FileOffset, ref _linkEditSegment64.Command); file.Write(_symtabCommand.FileOffset, ref _symtabCommand.Command); @@ -315,7 +354,7 @@ public long Write(IMachOFileWriter file) /// Returns a pointer to the end of the commands list. /// Fills the content of the commands with the corresponding command if present in the file. /// - private static long ReadCommands( + private static void ReadCommands( IMachOFileReader inputFile, in MachHeader header, out (LinkEditLoadCommand Command, long FileOffset) codeSignatureLC, @@ -394,7 +433,7 @@ private static long ReadCommands( // Signature blob should be right after the symbol table except for a few bytes of padding for alignment uint symtabEnd = symtabLC.Command.GetStringTableOffset(header) + symtabLC.Command.GetStringTableSize(header); uint signStart = codeSignatureLC.Command.GetDataOffset(header); - if (symtabEnd > signStart || signStart - symtabEnd > 32) + if (symtabEnd > signStart || signStart - symtabEnd > CodeSignatureAlignment) throw new AppHostMachOFormatException(MachOFormatError.SignDoesntFollowSymtab); // Signature blob should be contained within the LinkEdit segment if (codeSignatureLC.Command.GetDataOffset(header) < linkEditSegment64.Command.GetFileOffset(header) @@ -404,17 +443,17 @@ private static long ReadCommands( throw new AppHostMachOFormatException(MachOFormatError.SignNotInLinkEdit); } } - return commandsPtr; + Debug.Assert(header.SizeOfCommands == commandsPtr - sizeof(MachHeader)); } /// /// Clears the old signature and sets the codeSignatureLC to the proper size and offset for a new signature. /// - private void AllocateCodeSignatureLoadCommand(string identifier) + private void AllocateCodeSignatureLoadCommand(string identifier, EmbeddedSignatureBlob? oldSignature) { uint csOffset = GetSignatureStart(); - uint csPtr = (uint)(_codeSignatureLoadCommand.Command.IsDefault ? _nextCommandPtr : _codeSignatureLoadCommand.FileOffset); - uint csSize = (uint)EmbeddedSignatureBlob.GetSignatureSize(GetSignatureStart(), identifier); + uint csPtr = (uint)(_codeSignatureLoadCommand.Command.IsDefault ? NextLoadCommandOffset : _codeSignatureLoadCommand.FileOffset); + uint csSize = (uint)EmbeddedSignatureBlob.GetSignatureSize(csOffset, identifier, oldSignature); if (_codeSignatureLoadCommand.Command.IsDefault) { @@ -443,6 +482,7 @@ private uint GetSignatureStart() if (!_codeSignatureLoadCommand.Command.IsDefault) { Debug.Assert(_codeSignatureLoadCommand.Command.GetDataOffset(_header) % CodeSignatureAlignment == 0); + Debug.Assert(_codeSignatureLoadCommand.Command.GetDataOffset(_header) + _codeSignatureLoadCommand.Command.GetFileSize(_header) == GetFileSize()); return _codeSignatureLoadCommand.Command.GetDataOffset(_header); } return AlignUp((uint)(_linkEditSegment64.Command.GetFileOffset(_header) + _linkEditSegment64.Command.GetFileSize(_header)), CodeSignatureAlignment); @@ -465,6 +505,8 @@ private void Validate() Debug.Assert(linkEditFileSize <= linkEditVMSize); if (!_codeSignatureLoadCommand.Command.IsDefault) { + Debug.Assert(_symtabCommand.Command.GetStringTableOffset(_header) + _symtabCommand.Command.GetStringTableSize(_header) <= _codeSignatureLoadCommand.Command.GetDataOffset(_header)); + Debug.Assert(_symtabCommand.Command.GetStringTableOffset(_header) + _symtabCommand.Command.GetStringTableSize(_header) <= GetSignatureStart()); var csStart = _codeSignatureLoadCommand.Command.GetDataOffset(_header); var csEnd = csStart + _codeSignatureLoadCommand.Command.GetFileSize(_header); Debug.Assert(_codeSignatureBlob is not null); diff --git a/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs b/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs index e4791a6bb14e99..5bb710f2609438 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + using System; using System.IO; using System.IO.MemoryMappedFiles; @@ -15,9 +17,11 @@ namespace Microsoft.NET.HostModel /// public class ResourceUpdater : IDisposable { - private readonly FileStream stream; + private readonly FileStream? stream; + private readonly MemoryMappedFile? _memoryMappedFile; + private Stream Stream => (Stream?)stream ?? _memoryMappedFile!.CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite); private readonly PEReader _reader; - private ResourceData _resourceData; + private ResourceData? _resourceData; private readonly bool leaveOpen; /// @@ -55,6 +59,23 @@ public ResourceUpdater(FileStream stream, bool leaveOpen = false) } } + public ResourceUpdater(MemoryMappedFile memoryMappedFile, bool leaveOpen = false) + { + this._memoryMappedFile = memoryMappedFile; + this.leaveOpen = leaveOpen; + try + { + _reader = new PEReader(this.Stream, PEStreamOptions.LeaveOpen); + _resourceData = new ResourceData(_reader); + } + catch (Exception) + { + if (!leaveOpen) + this.stream?.Dispose(); + throw; + } + } + /// /// Add all resources from a source PE file. It is assumed /// that the input is a valid PE file. If it is not, an @@ -69,7 +90,7 @@ public ResourceUpdater AddResourcesFromPEImage(string peFile) using var module = new PEReader(File.OpenRead(peFile)); var moduleResources = new ResourceData(module); - _resourceData.CopyResourcesFrom(moduleResources); + _resourceData!.CopyResourcesFrom(moduleResources); return this; } @@ -95,7 +116,7 @@ public ResourceUpdater AddResource(byte[] data, IntPtr lpType, IntPtr lpName) if (_resourceData == null) ThrowExceptionForInvalidUpdate(); - _resourceData.AddResource((ushort)lpName, (ushort)lpType, LangID_LangNeutral_SublangNeutral, data); + _resourceData!.AddResource((ushort)lpName, (ushort)lpType, LangID_LangNeutral_SublangNeutral, data); return this; } @@ -115,7 +136,7 @@ public ResourceUpdater AddResource(byte[] data, string lpType, IntPtr lpName) if (_resourceData == null) ThrowExceptionForInvalidUpdate(); - _resourceData.AddResource((ushort)lpName, lpType, LangID_LangNeutral_SublangNeutral, data); + _resourceData!.AddResource((ushort)lpName, lpType, LangID_LangNeutral_SublangNeutral, data); return this; } @@ -173,7 +194,7 @@ public void Update() } var objectDataBuilder = new ObjectDataBuilder(); - _resourceData.WriteResources(rsrcVirtualAddress, ref objectDataBuilder); + _resourceData!.WriteResources(rsrcVirtualAddress, ref objectDataBuilder); var rsrcSectionData = objectDataBuilder.ToData(); int rsrcSectionDataSize = rsrcSectionData.Length; @@ -185,12 +206,12 @@ public void Update() int trailingSectionVirtualStart = rsrcVirtualAddress + rsrcOriginalVirtualSize; int trailingSectionStart = rsrcPointerToRawData + rsrcOriginalRawDataSize; - int trailingSectionLength = (int)(stream.Length - trailingSectionStart); + int trailingSectionLength = (int)(Stream.Length - trailingSectionStart); bool needsMoveTrailingSections = !isRsrcIsLastSection && delta > 0; long finalImageSize = trailingSectionStart + Math.Max(delta, 0) + trailingSectionLength; - using (var mmap = MemoryMappedFile.CreateFromFile(stream, null, finalImageSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true)) + using (var mmap = _memoryMappedFile ?? MemoryMappedFile.CreateFromFile(stream!, null, finalImageSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true)) using (MemoryMappedViewAccessor accessor = mmap.CreateViewAccessor(0, finalImageSize, MemoryMappedFileAccess.ReadWrite)) { int peSignatureOffset = ReadI32(accessor, PEOffsets.DosStub.PESignatureOffset); @@ -336,7 +357,8 @@ public void Dispose(bool disposing) if (disposing && !leaveOpen) { _reader.Dispose(); - stream.Dispose(); + stream?.Dispose(); + _memoryMappedFile?.Dispose(); } } } diff --git a/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs b/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs index 18752f5f175fa7..f894263d52678f 100644 --- a/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs +++ b/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs @@ -8,6 +8,8 @@ using Microsoft.DotNet.CoreSetup.Test; using Microsoft.DotNet.Cli.Build.Framework; using Microsoft.NET.HostModel.AppHost; +using Microsoft.NET.HostModel.MachO.CodeSign.Tests; +using Microsoft.NET.HostModel.Bundle; namespace HostActivation.Tests { @@ -31,5 +33,42 @@ public void SignedAppHostRuns() .Execute(); executedCommand.Should().ExitWith(Constants.ErrorCode.AppHostExeNotBoundFailure); } + + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public void SigningAppHostPreservesEntitlements() + { + using var testDirectory = TestArtifact.Create(nameof(SignedAppHostRuns)); + var testAppHostPath = Path.Combine(testDirectory.Location, Path.GetFileName(Binaries.AppHost.FilePath)); + File.Copy(Binaries.AppHost.FilePath, testAppHostPath); + long preRemovalSize = new FileInfo(testAppHostPath).Length; + string signedHostPath = testAppHostPath + ".signed"; + + HostWriter.CreateAppHost(testAppHostPath, signedHostPath, testAppHostPath + ".dll", enableMacOSCodeSign: true); + + SigningTests.HasDerEntitlementsBlob(testAppHostPath).Should().BeTrue(); + SigningTests.HasDerEntitlementsBlob(signedHostPath).Should().BeTrue(); + SigningTests.HasEntitlementsBlob(testAppHostPath).Should().BeTrue(); + SigningTests.HasEntitlementsBlob(signedHostPath).Should().BeTrue(); + } + + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public void BundledAppHostHasEntitlements() + { + using var testDirectory = TestArtifact.Create(nameof(BundledAppHostHasEntitlements)); + var testAppHostPath = Path.Combine(testDirectory.Location, Path.GetFileName(Binaries.SingleFileHost.FilePath)); + File.Copy(Binaries.SingleFileHost.FilePath, testAppHostPath); + long preRemovalSize = new FileInfo(testAppHostPath).Length; + string signedHostPath = testAppHostPath + ".signed"; + + HostWriter.CreateAppHost(testAppHostPath, signedHostPath, testAppHostPath + ".dll", enableMacOSCodeSign: true); + var bundlePath = new Bundler(Path.GetFileName(signedHostPath), testAppHostPath + ".bundle").GenerateBundle([new(signedHostPath, Path.GetFileName(signedHostPath))]); + + SigningTests.HasEntitlementsBlob(testAppHostPath).Should().BeTrue(); + SigningTests.HasEntitlementsBlob(bundlePath).Should().BeTrue(); + SigningTests.HasDerEntitlementsBlob(testAppHostPath).Should().BeTrue(); + SigningTests.HasDerEntitlementsBlob(bundlePath).Should().BeTrue(); + } } } diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs new file mode 100644 index 00000000000000..3077a420090d33 --- /dev/null +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -0,0 +1,99 @@ + + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.DotNet.CoreSetup.Test; +using Microsoft.NET.HostModel.MachO; +using Xunit; + +public class MachObjectTests +{ + private readonly List _testArtifacts = new(); + + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(StreamAndMemoryMappedFileAreTheSame))] + public void StreamAndMemoryMappedFileAreTheSame(string filePath, TestArtifact testArtifact) + { + using var testArtifactLocation = testArtifact; + MachObjectFile streamMachOFile; + MachObjectFile memoryMappedMachOFile; + using (FileStream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + streamMachOFile = MachObjectFile.Create(new StreamBasedMachOFile(stream)); + + using (MemoryMappedFile memoryMappedFile = MemoryMappedFile.CreateFromFile(stream, null, 0, MemoryMappedFileAccess.Read, HandleInheritability.None, true)) + using (MemoryMappedViewAccessor memoryMappedViewAccessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) + { + memoryMappedMachOFile = MachObjectFile.Create(new MemoryMappedMachOViewAccessor(memoryMappedViewAccessor)); + } + } + MachObjectFile.AssertEquivalent(streamMachOFile, memoryMappedMachOFile); + } + + + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(RoundTripMachObjectFileIsTheSame))] + void RoundTripMachObjectFileIsTheSame(string filePath, TestArtifact testArtifact) + { + using var testArtifactLocation = testArtifact; + using (var mmap = MemoryMappedFile.CreateFromFile(filePath)) + using (var accessor = mmap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite)) + { + var machFile = new MemoryMappedMachOViewAccessor(accessor); + var machObjectFile = MachObjectFile.Create(machFile); + machObjectFile.Write(machFile); + var rewrittenMachFile = MachObjectFile.Create(machFile); + MachObjectFile.AssertEquivalent(machObjectFile, rewrittenMachFile); + } + } + + static readonly ImmutableArray liveBuiltHosts = ImmutableArray.Create(Binaries.AppHost.FilePath, Binaries.SingleFileHost.FilePath); + public static Object[][] GetTestFilePaths(string testArtifactName) + { + List arguments = []; + List<(string Name, FileInfo File)> testData = TestData.MachObjects.GetAll().ToList(); + foreach ((string name, FileInfo file) in testData) + { + var testArtifact = TestArtifact.Create(testArtifactName + "-" + name); + string newFilePath = Path.Combine(testArtifact.Location, name); + File.Copy(file.FullName, newFilePath, true); + arguments.Add([newFilePath, testArtifact]); + } + + // If we're on mac, we can use the live built binaries to test against + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + foreach (var filePath in liveBuiltHosts) + { + string fileName = Path.GetFileName(filePath); + var testArtifact = TestArtifact.Create(testArtifactName + "-" + fileName); + string testFilePath = Path.Combine(testArtifact.Location, fileName); + File.Copy(filePath, testFilePath); + arguments.Add([testFilePath, testArtifact]); + } + } + + return arguments.ToArray(); + } + + public void Dispose() + { + foreach (var artifact in _testArtifacts) + { + try + { + artifact.Dispose(); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to dispose test artifact: {ex.Message}"); + } + } + _testArtifacts.Clear(); + } +} diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs index 5a20310e5dff46..cc0f4e612d9928 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs @@ -24,133 +24,73 @@ namespace Microsoft.NET.HostModel.MachO.CodeSign.Tests { public class SigningTests { - public static bool IsSigned(string filePath) - { - // Validate the signature if we can, otherwise, at least ensure there is a signature LoadCommand present - using (var appHostSourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1)) - using (var memoryMappedFile = MemoryMappedFile.CreateFromFile(appHostSourceStream, null, 0, MemoryMappedFileAccess.Read, HandleInheritability.None, true)) - using (var managedSignedAccessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.CopyOnWrite)) - { - if (!MachObjectFile.Create(new MemoryMappedMachOViewAccessor(managedSignedAccessor)).HasSignature) - { - return false; - } - } - if (Codesign.IsAvailable && Codesign.Run("--verify", filePath).ExitCode != 0) - { - return false; - } - return true; - } - - public static bool IsMachOImage(string filePath) => MachObjectFile.IsMachOImage(filePath); - - static readonly string[] liveBuiltHosts = new string[] { Binaries.AppHost.FilePath, Binaries.SingleFileHost.FilePath }; - static List GetTestFilePaths(TestArtifact testArtifact) - { - List<(string Name, FileInfo File)> testData = TestData.MachObjects.GetAll().ToList(); - List testFilePaths = new(); - foreach ((string name, FileInfo file) in testData) - { - string newFilePath = Path.Combine(testArtifact.Location, name); - File.Copy(file.FullName, newFilePath, true); - testFilePaths.Add(newFilePath); - } - - // If we're on mac, we can use the live built binaries to test against - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - foreach (var filePath in liveBuiltHosts) - { - string fileName = Path.GetFileName(filePath); - string testFilePath = Path.Combine(testArtifact.Location, fileName); - File.Copy(filePath, testFilePath); - testFilePaths.Add(testFilePath); - } - } - - return testFilePaths; - } - - [Fact] - public void CanSignMachObject() + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(CanSignMachObject))] + public void CanSignMachObject(string filePath, TestArtifact _) { - using var testArtifact = TestArtifact.Create(nameof(CanSignMachObject)); - foreach (var filePath in GetTestFilePaths(testArtifact)) - { - string fileName = Path.GetFileName(filePath); - string originalFilePath = filePath; - string managedSignedPath = filePath + ".signed"; + string fileName = Path.GetFileName(filePath); + string originalFilePath = filePath; + string managedSignedPath = filePath + ".signed"; - // Managed signed file - AdHocSignFile(originalFilePath, managedSignedPath, fileName); - Assert.True(IsSigned(managedSignedPath), $"Failed to sign a copy of {filePath}"); - } + // Managed signed file + AdHocSignFile(originalFilePath, managedSignedPath, fileName); + Assert.True(IsSigned(managedSignedPath), $"Failed to sign a copy of {filePath}"); } - [Fact] - public void CanRemoveSignature() + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(CanRemoveSignature))] + public void CanRemoveSignature(string filePath, TestArtifact _) { - using var testArtifact = TestArtifact.Create(nameof(CanRemoveSignature)); - foreach (var filePath in GetTestFilePaths(testArtifact)) - { - string fileName = Path.GetFileName(filePath); - string originalFilePath = filePath; - string managedSignedPath = filePath + ".signed"; - RemoveSignature(originalFilePath, managedSignedPath); - Assert.False(IsSigned(managedSignedPath), $"Failed to remove signature from {filePath}"); - } + string fileName = Path.GetFileName(filePath); + string originalFilePath = filePath; + string managedSignedPath = filePath + ".signed"; + RemoveSignature(originalFilePath, managedSignedPath); + Assert.False(IsSigned(managedSignedPath), $"Failed to remove signature from {filePath}"); } - [Fact] - public void CanUnsignAndResign() + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(CanUnsignAndResign))] + public void CanUnsignAndResign(string filePath, TestArtifact _) { - using var testArtifact = TestArtifact.Create(nameof(CanUnsignAndResign)); - foreach (var filePath in GetTestFilePaths(testArtifact)) - { - string fileName = Path.GetFileName(filePath); - string originalFilePath = filePath; - string managedSignedPath = filePath + ".signed"; + string fileName = Path.GetFileName(filePath); + string originalFilePath = filePath; + string managedSignedPath = filePath + ".signed"; - // Managed signed file - AdHocSignFile(originalFilePath, managedSignedPath, fileName); - Assert.True(IsSigned(managedSignedPath), $"Failed to sign a copy of {filePath}"); + // Managed signed file + AdHocSignFile(originalFilePath, managedSignedPath, fileName); + Assert.True(IsSigned(managedSignedPath), $"Failed to sign a copy of {filePath}"); - // Remove signature - RemoveSignature(managedSignedPath, managedSignedPath + ".unsigned"); - Assert.False(IsSigned(managedSignedPath + ".unsigned"), $"Failed to remove signature from {filePath}"); + // Remove signature + RemoveSignature(managedSignedPath, managedSignedPath + ".unsigned"); + Assert.False(IsSigned(managedSignedPath + ".unsigned"), $"Failed to remove signature from {filePath}"); - // Resign - AdHocSignFile(managedSignedPath + ".unsigned", managedSignedPath + ".resigned", fileName); - Assert.True(IsSigned(managedSignedPath + ".resigned"), $"Failed to resign {filePath}"); - } + // Resign + AdHocSignFile(managedSignedPath + ".unsigned", managedSignedPath + ".resigned", fileName); + Assert.True(IsSigned(managedSignedPath + ".resigned"), $"Failed to resign {filePath}"); } - [Fact] + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(MatchesCodesignOutput))] [PlatformSpecific(TestPlatforms.OSX)] - void MatchesCodesignOutput() + void MatchesCodesignOutput(string filePath, TestArtifact _) { - using var testArtifact = TestArtifact.Create(nameof(MatchesCodesignOutput)); - foreach (var filePath in GetTestFilePaths(testArtifact)) - { - string fileName = Path.GetFileName(filePath); - string originalFilePath = filePath; - string codesignFilePath = filePath + ".codesigned"; - string managedSignedPath = filePath + ".signed"; - - // Codesigned file - File.Copy(filePath, codesignFilePath); - Assert.True(Codesign.IsAvailable, "Could not find codesign tool"); - Codesign.Run("--remove-signature", codesignFilePath).ExitCode.Should().Be(0, $"'codesign --remove-signature {codesignFilePath}' failed!"); - Codesign.Run("-s - -i " + fileName, codesignFilePath).ExitCode.Should().Be(0, $"'codesign -s - {codesignFilePath}' failed!"); - - // Managed signed file - AdHocSignFile(originalFilePath, managedSignedPath, fileName); - - var check = Codesign.Run("-v", managedSignedPath); - check.ExitCode.Should().Be(0, check.StdErr, $"Failed to sign a copy of '{filePath}'"); - AssertMachFilesAreEquivalent(codesignFilePath, managedSignedPath, fileName); - } + string fileName = Path.GetFileName(filePath); + string originalFilePath = filePath; + string codesignFilePath = filePath + ".codesigned"; + string managedSignedPath = filePath + ".signed"; + + // Codesigned file + File.Copy(filePath, codesignFilePath); + Assert.True(Codesign.IsAvailable, "Could not find codesign tool"); + Codesign.Run("--remove-signature", codesignFilePath).ExitCode.Should().Be(0, $"'codesign --remove-signature {codesignFilePath}' failed!"); + Codesign.Run("-s - -i " + fileName, codesignFilePath).ExitCode.Should().Be(0, $"'codesign -s - {codesignFilePath}' failed!"); + + // Managed signed file + AdHocSignFile(originalFilePath, managedSignedPath, fileName); + + var check = Codesign.Run("-v", managedSignedPath); + check.ExitCode.Should().Be(0, check.StdErr, $"Failed to sign a copy of '{filePath}'"); + AssertMachFilesAreEquivalent(codesignFilePath, managedSignedPath, fileName); } [Fact] @@ -174,42 +114,21 @@ void SignedMachOExecutableRuns() } } - [Fact] - void ReadSignedMachIsTheSameAsReadAndResigned() + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(ReadSignedMachIsTheSameAsReadAndResigned))] + void ReadSignedMachIsTheSameAsReadAndResigned(string filePath, TestArtifact _) { - using var testArtifact = TestArtifact.Create(nameof(ReadSignedMachIsTheSameAsReadAndResigned)); - foreach (var fileName in GetTestFilePaths(testArtifact)) - { - string signedPath = fileName + ".signed"; - - AdHocSignFile(fileName, signedPath, fileName); - using (var mmap = MemoryMappedFile.CreateFromFile(signedPath)) - using (var accessor = mmap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.CopyOnWrite)) - { - var signedMachFile = new MemoryMappedMachOViewAccessor(accessor); - var signedObject = MachObjectFile.Create(signedMachFile); - var resignedObject = MachObjectFile.Create(signedMachFile); - resignedObject.AdHocSignFile(signedMachFile, fileName); - MachObjectFile.AssertEquivalent(signedObject, resignedObject); - } - } - } + string signedPath = filePath + ".signed"; - [Fact] - void RoundTripMachObjectFileIsTheSame() - { - using var testArtifact = TestArtifact.Create(nameof(RoundTripMachObjectFileIsTheSame)); - foreach (var fileName in GetTestFilePaths(testArtifact)) + AdHocSignFile(filePath, signedPath, filePath); + using (var mmap = MemoryMappedFile.CreateFromFile(signedPath)) + using (var accessor = mmap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.CopyOnWrite)) { - using (var mmap = MemoryMappedFile.CreateFromFile(fileName)) - using (var accessor = mmap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite)) - { - var machFile = new MemoryMappedMachOViewAccessor(accessor); - var machObjectFile = MachObjectFile.Create(machFile); - machObjectFile.Write(machFile); - var rewrittenMachFile = MachObjectFile.Create(machFile); - MachObjectFile.AssertEquivalent(machObjectFile, rewrittenMachFile); - } + var signedMachFile = new MemoryMappedMachOViewAccessor(accessor); + var signedObject = MachObjectFile.Create(signedMachFile); + var resignedObject = MachObjectFile.Create(signedMachFile); + resignedObject.AdHocSignFile(signedMachFile, filePath); + MachObjectFile.AssertEquivalent(signedObject, resignedObject); } } @@ -303,5 +222,76 @@ internal static void RemoveSignature(string originalFilePath, string removedSign MachObjectFile.RemoveCodeSignatureIfPresent(appHostDestinationStream); } } + + public static bool IsSigned(string filePath) + { + // Validate the signature if we can, otherwise, at least ensure there is a signature LoadCommand present + using (var appHostSourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1)) + using (var memoryMappedFile = MemoryMappedFile.CreateFromFile(appHostSourceStream, null, 0, MemoryMappedFileAccess.Read, HandleInheritability.None, true)) + using (var managedSignedAccessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.CopyOnWrite)) + { + if (!MachObjectFile.Create(new MemoryMappedMachOViewAccessor(managedSignedAccessor)).HasSignature) + { + return false; + } + } + if (Codesign.IsAvailable && Codesign.Run("--verify", filePath).ExitCode != 0) + { + return false; + } + return true; + } + + public static bool IsMachOImage(string filePath) => MachObjectFile.IsMachOImage(filePath); + + public static bool HasEntitlementsBlob(string filePath) + { + using (MemoryMappedFile memoryMappedFile = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open)) + using (MemoryMappedViewAccessor memoryMappedViewAccessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) + { + var machObjectFile = MachObjectFile.Create(memoryMappedViewAccessor); + return machObjectFile.EmbeddedSignatureBlob?.EntitlementsBlob != null; + } + } + + public static bool HasDerEntitlementsBlob(string filePath) + { + using (MemoryMappedFile memoryMappedFile = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open)) + using (MemoryMappedViewAccessor memoryMappedViewAccessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) + { + var machObjectFile = MachObjectFile.Create(memoryMappedViewAccessor); + return machObjectFile.EmbeddedSignatureBlob?.DerEntitlementsBlob != null; + } + } + + static readonly string[] liveBuiltHosts = new string[] { Binaries.AppHost.FilePath, Binaries.SingleFileHost.FilePath }; + + public static Object[][] GetTestFilePaths(string testArtifactName) + { + List arguments = []; + List<(string Name, FileInfo File)> testData = TestData.MachObjects.GetAll().ToList(); + foreach ((string name, FileInfo file) in testData) + { + var testArtifact = TestArtifact.Create(testArtifactName + "-" + name); + string newFilePath = Path.Combine(testArtifact.Location, name); + File.Copy(file.FullName, newFilePath, true); + arguments.Add([newFilePath, testArtifact]); + } + + // If we're on mac, we can use the live built binaries to test against + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + foreach (var filePath in liveBuiltHosts) + { + string fileName = Path.GetFileName(filePath); + var testArtifact = TestArtifact.Create(testArtifactName + "-" + fileName); + string testFilePath = Path.Combine(testArtifact.Location, fileName); + File.Copy(filePath, testFilePath); + arguments.Add([testFilePath, testArtifact]); + } + } + + return arguments.ToArray(); + } } } diff --git a/src/native/corehost/apphost/static/CMakeLists.txt b/src/native/corehost/apphost/static/CMakeLists.txt index e7103871b0ef7a..30118f679da387 100644 --- a/src/native/corehost/apphost/static/CMakeLists.txt +++ b/src/native/corehost/apphost/static/CMakeLists.txt @@ -303,3 +303,7 @@ target_link_libraries( target_link_libraries(singlefilehost PRIVATE hostmisc) add_sanitizer_runtime_support(singlefilehost) + +if (CLR_CMAKE_HOST_APPLE) + adhoc_sign_with_entitlements(singlefilehost "${CLR_ENG_NATIVE_DIR}/entitlements.plist") +endif() From b705e322e5dd479d682145779cbd24cca7811441 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:07:12 -0700 Subject: [PATCH 02/16] Add tests to verify new inodes are created in apphost signing --- .../tests/AppHost.Bundle.Tests/AppLaunch.cs | 33 ++++++++++++ .../AppHost/CreateAppHost.cs | 50 +++++++++++++++++++ .../tests/TestUtils/SingleFileTestApp.cs | 18 +++++++ 3 files changed, 101 insertions(+) diff --git a/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs b/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs index d859527bc58138..46a84dd32bbf95 100644 --- a/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs +++ b/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs @@ -6,7 +6,9 @@ using System.Runtime.InteropServices; using System.Text; using Microsoft.DotNet.Cli.Build.Framework; +using Microsoft.DotNet.CoreSetup; using Microsoft.DotNet.CoreSetup.Test; +using Microsoft.NET.HostModel.Bundle; using Xunit; namespace AppHost.Bundle.Tests @@ -80,6 +82,37 @@ private void RunApp(bool selfContained) } } + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + private void OverwritingExistingBundleClearsMacOsSignatureCache() + { + // Bundle to a single-file and ensure it is signed + string singleFile = sharedTestState.SelfContainedApp.Bundle(); + Assert.True(Codesign.Run("-v", singleFile).ExitCode == 0); + var firstls = Command.Create("/bin/ls", "-li", singleFile) + .CaptureStdErr() + .CaptureStdOut() + .Execute(); + firstls.Should().Pass(); + var firstInode = firstls.StdOut.Split(' ')[0]; + + // Rebundle to the same location. + // Bundler should create a new inode for the bundle which should clear the MacOS signature cache. + string oldFile = singleFile; + string dir = Path.GetDirectoryName(singleFile); + singleFile = sharedTestState.SelfContainedApp.ReBundle(dir, BundleOptions.BundleAllContent, out var _, new Version(5, 0)); + Assert.True(singleFile == oldFile, "Rebundled app should have a different path than the original single-file app."); + var secondls = Command.Create("/bin/ls", "-li", singleFile) + .CaptureStdErr() + .CaptureStdOut() + .Execute(); + secondls.Should().Pass(); + var secondInode = secondls.StdOut.Split(' ')[0]; + Assert.False(firstInode == secondInode, "not a different inode after rebundle"); + // Ensure the MacOS signature cache is cleared + Assert.True(Codesign.Run("-v", singleFile).ExitCode == 0); + } + [ConditionalTheory(typeof(Binaries.CetCompat), nameof(Binaries.CetCompat.IsSupported))] [InlineData(true)] [InlineData(false)] diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs index 8cccab87aca44b..24958b3de76749 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs @@ -20,6 +20,7 @@ using System.Buffers.Binary; using System.IO.MemoryMappedFiles; using Microsoft.NET.HostModel.MachO.CodeSign.Tests; +using System.ComponentModel; namespace Microsoft.NET.HostModel.AppHost.Tests { @@ -285,6 +286,55 @@ public void CodeSignMachOAppHost(string subdir) } } + [Theory] + [InlineData("")] + [InlineData("dir with spaces")] + [PlatformSpecific(TestPlatforms.OSX)] + public void SigningExistingAppHostCreatesNewInode(string subdir) + { + using (TestArtifact artifact = CreateTestDirectory()) + { + string testDirectory = Path.Combine(artifact.Location, subdir); + Directory.CreateDirectory(testDirectory); + string sourceAppHostMock = Binaries.AppHost.FilePath; + string destinationFilePath = Path.Combine(testDirectory, Binaries.AppHost.FileName); + string appBinaryFilePath = "Test/App/Binary/Path.dll"; + HostWriter.CreateAppHost( + sourceAppHostMock, + destinationFilePath, + appBinaryFilePath, + windowsGraphicalUserInterface: false, + enableMacOSCodeSign: true); + var firstls = Command.Create("/bin/ls", "-li", destinationFilePath) + .CaptureStdErr() + .CaptureStdOut() + .Execute(); + firstls.Should().Pass(); + var firstInode = firstls.StdOut.Split(' ')[0]; + + // Validate that there is a signature present in the apphost Mach file + SigningTests.IsSigned(destinationFilePath).Should().BeTrue(); + + HostWriter.CreateAppHost( + sourceAppHostMock, + destinationFilePath, + appBinaryFilePath, + windowsGraphicalUserInterface: false, + enableMacOSCodeSign: true); + + var secondls = Command.Create("/bin/ls", "-li", destinationFilePath) + .CaptureStdErr() + .CaptureStdOut() + .Execute(); + secondls.Should().Pass(); + var secondInode = secondls.StdOut.Split(' ')[0]; + // Ensure the MacOS signature cache is cleared + Assert.False(firstInode == secondInode, "not a different inode after rebundle"); + + SigningTests.IsSigned(destinationFilePath).Should().BeTrue(); + } + } + [Theory] [InlineData("")] [InlineData("dir with spaces")] diff --git a/src/installer/tests/TestUtils/SingleFileTestApp.cs b/src/installer/tests/TestUtils/SingleFileTestApp.cs index fa4ff517dd23f1..7348d893206732 100644 --- a/src/installer/tests/TestUtils/SingleFileTestApp.cs +++ b/src/installer/tests/TestUtils/SingleFileTestApp.cs @@ -92,6 +92,24 @@ public string Bundle(BundleOptions options = BundleOptions.None, Version? bundle public string Bundle(BundleOptions options, out Manifest manifest, Version? bundleVersion = null) { string bundleDirectory = GetUniqueSubdirectory("bundle"); + return Bundle(options, bundleDirectory, out manifest, bundleVersion); + } + + public string ReBundle(string bundleDirectory, BundleOptions options, out Manifest manifest, Version? bundleVersion = null) + { + // Reuse the existing bundle directory if it exists + if (!Directory.Exists(bundleDirectory)) + { + throw new InvalidOperationException( + $"The bundle directory '{bundleDirectory}' does not exist. " + + "Please ensure the directory is created before rebundling."); + } + + return Bundle(options, bundleDirectory, out manifest, bundleVersion); + } + + private string Bundle(BundleOptions options, string bundleDirectory, out Manifest manifest, Version? bundleVersion = null) + { var bundler = new Bundler( Binaries.GetExeName(AppName), bundleDirectory, From e2ead9780b77113fdf0ebe78f0159e8c7507ec14 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:51:36 -0700 Subject: [PATCH 03/16] Update blob parser and special slot count for new blobs, add tests for Manifest lengths --- .../Microsoft.NET.HostModel/Bundle/Bundler.cs | 7 ++--- .../MachO/BinaryFormat/Blobs/BlobParser.cs | 13 ++++++++- .../BinaryFormat/Blobs/CodeDirectoryBlob.cs | 4 ++- .../Bundle/BundlerConsistencyTests.cs | 29 +++++++++++++++++++ .../MachObjectSigning/MachObjectTests.cs | 21 +++++++++++++- 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index acbceae6a83906..259c0a82882c1a 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -500,13 +500,10 @@ internal static uint GetBinaryWriterStringLength(string str) // Prefixed length of bundle ID is 7-bit encoded // Strings 0-127 chars: 1 byte prefix // Strings 128-16,383 chars: 2 byte prefix - // Strings 16,384-2,097,151 chars: 3 byte prefix - // Strings 2,097,152-268,435,455 chars: 4 byte prefix - // Strings 268,435,456+ chars: 5 byte prefix + // Strings longer than 16,383 bytes are not supported and fail at runtime. uint lengthPrefixLength = (stringLength < 128) ? 1u : (stringLength < 16384) ? 2u : - (stringLength < 2097152) ? 3u : - (stringLength < 268435456) ? 4u : 5u; + throw new ArgumentException("Cannot write strings longer than 16,383 bytes to the bundle."); return lengthPrefixLength + stringLength; } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/BlobParser.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/BlobParser.cs index 41bc2a7b5a75ca..b8157940bd04a1 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/BlobParser.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/BlobParser.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Diagnostics; + namespace Microsoft.NET.HostModel.MachO; /// @@ -23,7 +26,15 @@ public static IBlob ParseBlob(IMachOFileReader reader, long offset) BlobMagic.Requirements => new RequirementsBlob(SuperBlob.Read(reader, offset)), BlobMagic.CmsWrapper => new CmsWrapperBlob(SimpleBlob.Read(reader, offset)), BlobMagic.EmbeddedSignature => new EmbeddedSignatureBlob(SuperBlob.Read(reader, offset)), - _ => SimpleBlob.Read(reader, offset) + BlobMagic.Entitlements => new EntitlementsBlob(SimpleBlob.Read(reader, offset)), + BlobMagic.DerEntitlements => new DerEntitlementsBlob(SimpleBlob.Read(reader, offset)), + _ => CreateUnknownBlob(magic, reader, offset), }; + + static SimpleBlob CreateUnknownBlob(BlobMagic magic, IMachOFileReader reader, long offset) + { + Debug.Assert(!Enum.IsDefined(typeof(BlobMagic), magic), "Blob magic is known but not handled."); + return SimpleBlob.Read(reader, offset); + } } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index ca307270f38386..1b9b4f8462a26d 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -108,7 +108,9 @@ public static CodeDirectoryBlob Create( uint pageSize = MachObjectFile.DefaultPageSize) { uint codeSlotCount = GetCodeSlotCount((uint)signatureStart, pageSize); - uint specialCodeSlotCount = (uint)CodeDirectorySpecialSlot.Requirements; + uint specialCodeSlotCount = (uint)(derEntitlementsBlob != null ? CodeDirectorySpecialSlot.DerEntitlements : + entitlementsBlob != null ? CodeDirectorySpecialSlot.Entitlements : + CodeDirectorySpecialSlot.Requirements); var specialSlotHashes = new byte[specialCodeSlotCount][]; var codeHashes = new byte[codeSlotCount][]; diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs index cb9d3ca634d62d..77e347970826e1 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -314,6 +315,34 @@ public void AssemblyAlignment() Assert.True((file.Type != FileType.Assembly) || (file.Offset % alignment == 0))); } + [Fact] + [Conditional("DEBUG")] // Relies on debug asserts in product code + public void LongFileNames() + { + var app = sharedTestState.App; + List fileSpecs = new List + { + new FileSpec(Binaries.AppHost.FilePath, BundlerHostName), + new FileSpec(app.AppDll, Path.Join( + Path.GetDirectoryName(Path.GetRelativePath(app.Location, app.AppDll)), + Path.GetFileNameWithoutExtension(app.AppDll) + new string('a', 260) + Path.GetExtension(app.AppDll))), + }; + + fileSpecs.AddRange(SingleFileTestApp.GetRuntimeFilesToBundle()); + Bundler bundler = CreateBundlerInstance(); + // Debug asserts in the Manifest and Bundler should catch size calculation issues related to long file names + var bundledPath = bundler.GenerateBundle(fileSpecs); + + fileSpecs.Add(new FileSpec(app.AppDll, Path.Join( + Path.GetDirectoryName(Path.GetRelativePath(app.Location, app.AppDll)), + Path.GetFileNameWithoutExtension(app.AppDll) + new string('a', 16385) + Path.GetExtension(app.AppDll)))); + Assert.Throws(() => + { + // This should throw an exception due to the long file name exceeding the maximum allowed length + bundler.GenerateBundle(fileSpecs); + }); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index 3077a420090d33..eb7566543a9ef2 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -40,7 +40,8 @@ public void StreamAndMemoryMappedFileAreTheSame(string filePath, TestArtifact te [MemberData(nameof(GetTestFilePaths), nameof(RoundTripMachObjectFileIsTheSame))] void RoundTripMachObjectFileIsTheSame(string filePath, TestArtifact testArtifact) { - using var testArtifactLocation = testArtifact; + var backupFilePath = filePath + ".bak"; + File.Copy(filePath, backupFilePath); using (var mmap = MemoryMappedFile.CreateFromFile(filePath)) using (var accessor = mmap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite)) { @@ -50,6 +51,24 @@ void RoundTripMachObjectFileIsTheSame(string filePath, TestArtifact testArtifact var rewrittenMachFile = MachObjectFile.Create(machFile); MachObjectFile.AssertEquivalent(machObjectFile, rewrittenMachFile); } + using (FileStream original = new FileStream(backupFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (FileStream written = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Assert.Equal(original.Length, written.Length); + byte[] originalBuffer = new byte[4096]; + byte[] writtenBuffer = new byte[4096]; + while (true) + { + int bytesReadOriginal = original.Read(originalBuffer, 0, originalBuffer.Length); + int bytesReadWritten = written.Read(writtenBuffer, 0, writtenBuffer.Length); + Assert.Equal(bytesReadOriginal, bytesReadWritten); + + if (bytesReadOriginal == 0) + break; + + Assert.True(originalBuffer.Take(bytesReadOriginal).SequenceEqual(writtenBuffer.Take(bytesReadWritten))); + } + } } static readonly ImmutableArray liveBuiltHosts = ImmutableArray.Create(Binaries.AppHost.FilePath, Binaries.SingleFileHost.FilePath); From 29f6fb8121f0b391b762371086fb5abdfa6c36dc Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:13:14 -0700 Subject: [PATCH 04/16] Add tests to compare EmbeddedSignatureBlob data to codesign output --- .../BinaryFormat/Blobs/CodeDirectoryBlob.cs | 22 +- .../MachObjectSigning/MachObjectTests.cs | 252 +++++++++++++++++- 2 files changed, 257 insertions(+), 17 deletions(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index 1b9b4f8462a26d..c712b17c73dfbe 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -4,6 +4,8 @@ #nullable enable using System; +using System.Collections; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -97,6 +99,21 @@ private CodeDirectoryBlob( + SpecialSlotCount * HashSize + CodeSlotCount * HashSize; + public string Identifier => _identifier; + public CodeDirectoryFlags Flags => (CodeDirectoryFlags)((uint)_cdHeader._flags).ConvertFromBigEndian(); + public CodeDirectoryVersion Version => (CodeDirectoryVersion)((uint)_cdHeader._version).ConvertFromBigEndian(); + public IReadOnlyList> SpecialSlotHashes => _specialSlotHashes; + public IReadOnlyList> CodeHashes => _codeHashes; + public ulong ExecutableSegmentBase => _cdHeader._execSegmentBase.ConvertFromBigEndian(); + public ulong ExecutableSegmentLimit => _cdHeader._execSegmentLimit.ConvertFromBigEndian(); + public ExecutableSegmentFlags ExecutableSegmentFlags => (ExecutableSegmentFlags)((ulong)_cdHeader._execSegmentFlags).ConvertFromBigEndian(); + + private uint SpecialSlotCount => _cdHeader._specialSlotCount.ConvertFromBigEndian(); + private uint CodeSlotCount => _cdHeader._codeSlotCount.ConvertFromBigEndian(); + private byte HashSize => _cdHeader.HashSize; + private uint HashesOffset => _cdHeader._hashesOffset.ConvertFromBigEndian(); + + public static CodeDirectoryBlob Create( IMachOFileReader accessor, long signatureStart, @@ -226,11 +243,6 @@ public CodeDirectoryHeader(string identifier, uint codeSlotCount, uint specialCo } } - public uint HashesOffset => _cdHeader._hashesOffset.ConvertFromBigEndian(); - public uint SpecialSlotCount => _cdHeader._specialSlotCount.ConvertFromBigEndian(); - public uint CodeSlotCount => _cdHeader._codeSlotCount.ConvertFromBigEndian(); - public byte HashSize => _cdHeader.HashSize; - public override bool Equals(object? obj) { if (obj is not CodeDirectoryBlob other) diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index eb7566543a9ef2..8f221639ae6b11 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -7,19 +7,25 @@ using System.IO.MemoryMappedFiles; using System.Linq; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using Microsoft.DotNet.CoreSetup; using Microsoft.DotNet.CoreSetup.Test; using Microsoft.NET.HostModel.MachO; +using Microsoft.NET.HostModel.MachO.CodeSign.Tests; using Xunit; +using Xunit.Abstractions; public class MachObjectTests { - private readonly List _testArtifacts = new(); - + ITestOutputHelper output; + public MachObjectTests(ITestOutputHelper output) + { + this.output = output; + } [Theory] [MemberData(nameof(GetTestFilePaths), nameof(StreamAndMemoryMappedFileAreTheSame))] - public void StreamAndMemoryMappedFileAreTheSame(string filePath, TestArtifact testArtifact) + public void StreamAndMemoryMappedFileAreTheSame(string filePath, TestArtifact _) { - using var testArtifactLocation = testArtifact; MachObjectFile streamMachOFile; MachObjectFile memoryMappedMachOFile; using (FileStream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) @@ -66,7 +72,7 @@ void RoundTripMachObjectFileIsTheSame(string filePath, TestArtifact testArtifact if (bytesReadOriginal == 0) break; - Assert.True(originalBuffer.Take(bytesReadOriginal).SequenceEqual(writtenBuffer.Take(bytesReadWritten))); + Assert.True(originalBuffer.SequenceEqual(writtenBuffer)); } } } @@ -100,19 +106,241 @@ public static Object[][] GetTestFilePaths(string testArtifactName) return arguments.ToArray(); } - public void Dispose() + [Fact] + public void CanParseCodesignOutput() + { + var parsed = CodesignOutputInfo.ParseFromCodeSignOutput(SampleCodesignOutput); + Assert.NotNull(parsed); + output.WriteLine(parsed.ToString()); + var expected = new CodesignOutputInfo + { + Identifier = "singlefilehost-5555494409d4df688bf436b291061028f736b11c", + CodeDirectoryFlags = CodeDirectoryFlags.Adhoc, + CodeDirectoryVersion = CodeDirectoryVersion.SupportsExecSegment, + ExecutableSegmentBase = 0, + ExecutableSegmentLimit = 8949760, + ExecutableSegmentFlags = ExecutableSegmentFlags.MainBinary, + RequirementsSize = 12, + SpecialSlotHashes = [ + [0x4d, 0x8d, 0x4b, 0x9e, 0x41, 0x16, 0xe8, 0xed, 0xd9, 0x96, 0x17, 0x6b, 0x55, 0x53, 0x46, 0x3a, 0xcb, 0x64, 0x28, 0x7b, 0xb6, 0x35, 0xe7, 0xf1, 0x41, 0x15, 0x55, 0x29, 0xe2, 0x04, 0x57, 0xbc], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0xcc, 0xa8, 0xaf, 0xe7, 0x24, 0x25, 0x46, 0x3c, 0x13, 0xb8, 0x13, 0xda, 0x9a, 0xe4, 0x68, 0xae, 0x3b, 0x5f, 0xe2, 0x0f, 0xe5, 0xfe, 0x1d, 0x3f, 0x34, 0x30, 0x2b, 0xa2, 0xf1, 0x57, 0x22, 0xf2], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x98, 0x79, 0x20, 0x90, 0x4e, 0xab, 0x65, 0x0e, 0x75, 0x78, 0x8c, 0x05, 0x4a, 0xa0, 0xb0, 0x52, 0x4e, 0x6a, 0x80, 0xbf, 0xc7, 0x1a, 0xa3, 0x2d, 0xf8, 0xd2, 0x37, 0xa6, 0x17, 0x43, 0xf9, 0x86], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ], + CodeHashes = [ + [0x20, 0x04, 0x29, 0x93, 0x66, 0x56, 0x11, 0xbf, 0x5d, 0x01, 0xd3, 0x5a, 0x46, 0x09, 0x2c, 0x2d, 0x43, 0xa0, 0x78, 0x83, 0xf3, 0x12, 0x47, 0xa0, 0x3b, 0x56, 0x00, 0xc3, 0x01, 0xf5, 0xc0, 0x39], + [0xa9, 0x7f, 0xad, 0x07, 0xcc, 0x9d, 0x6e, 0xab, 0xad, 0x27, 0xa7, 0x7e, 0x32, 0xb6, 0x9c, 0x3d, 0xa5, 0x93, 0x72, 0xfa, 0x79, 0x87, 0xa1, 0x3c, 0x2b, 0x8d, 0x23, 0xf3, 0x78, 0x38, 0x04, 0x76], + [0xad, 0x7f, 0xac, 0xb2, 0x58, 0x6f, 0xc6, 0xe9, 0x66, 0xc0, 0x04, 0xd7, 0xd1, 0xd1, 0x6b, 0x02, 0x4f, 0x58, 0x05, 0xff, 0x7c, 0xb4, 0x7c, 0x7a, 0x85, 0xda, 0xbd, 0x8b, 0x48, 0x89, 0x2c, 0xa7], + [0xad, 0x7f, 0xac, 0xb2, 0x58, 0x6f, 0xc6, 0xe9, 0x66, 0xc0, 0x04, 0xd7, 0xd1, 0xd1, 0x6b, 0x02, 0x4f, 0x58, 0x05, 0xff, 0x7c, 0xb4, 0x7c, 0x7a, 0x85, 0xda, 0xbd, 0x8b, 0x48, 0x89, 0x2c, 0xa7], + [0xad, 0x7f, 0xac, 0xb2, 0x58, 0x6f, 0xc6, 0xe9, 0x66, 0xc0, 0x04, 0xd7, 0xd1, 0xd1, 0x6b, 0x02, 0x4f, 0x58, 0x05, 0xff, 0x7c, 0xb4, 0x7c, 0x7a, 0x85, 0xda, 0xbd, 0x8b, 0x48, 0x89, 0x2c, 0xa7], + [0xb3, 0xd2, 0x30, 0x34, 0x0a, 0xa5, 0xed, 0x09, 0xc7, 0x88, 0xc3, 0x90, 0x81, 0xc2, 0x07, 0xa7, 0x43, 0x0b, 0x83, 0xd2, 0x2c, 0x94, 0x89, 0xd8, 0x4d, 0x4e, 0xde, 0x3e, 0xd3, 0x20, 0xf4, 0x7b], + [0x82, 0x5b, 0x7a, 0xa1, 0x61, 0x70, 0xa9, 0xb7, 0x39, 0xa4, 0x68, 0x9b, 0xa8, 0x87, 0x83, 0x91, 0xbc, 0xae, 0x87, 0xef, 0xd6, 0x3e, 0x3b, 0x17, 0x47, 0x38, 0xc3, 0x82, 0x02, 0x00, 0x31, 0xc1], + [0xe3, 0x60, 0x15, 0x9e, 0xe0, 0xad, 0xae, 0xba, 0x5a, 0xc5, 0xf5, 0x62, 0xc4, 0x5e, 0xc5, 0x51, 0xdb, 0xe8, 0xb7, 0x3f, 0xbc, 0x85, 0x8b, 0xec, 0xa2, 0x98, 0x61, 0x03, 0x12, 0xdf, 0x33, 0xb3], + [0x20, 0x58, 0x5e, 0xf0, 0xbc, 0x02, 0x87, 0xc5, 0xb7, 0xa9, 0xb5, 0x4f, 0x26, 0x69, 0x70, 0x4c, 0xdc, 0x31, 0xce, 0xa7, 0xd7, 0xb1, 0x70, 0x2b, 0x33, 0x6f, 0xcf, 0x93, 0xa9, 0xf0, 0x1c, 0xa2], + [0x41, 0x4a, 0xe6, 0x56, 0x3e, 0x58, 0x81, 0xb2, 0x15, 0xa0, 0x8b, 0xb3, 0x3f, 0xc5, 0x39, 0xfb, 0x0c, 0x90, 0xc3, 0xa5, 0x53, 0x2f, 0x6e, 0x15, 0xa7, 0x26, 0xed, 0x6c, 0xdc, 0x25, 0x55, 0x50], + [0xb6, 0x72, 0xb6, 0x67, 0xeb, 0x31, 0xb4, 0x8d, 0x02, 0x7b, 0xd5, 0xf1, 0xcf, 0x75, 0xba, 0xd5, 0xa8, 0x55, 0x2b, 0x4d, 0x6b, 0x64, 0x9c, 0xbd, 0xae, 0x35, 0x69, 0x91, 0x52, 0xfb, 0x8a, 0x1b] + ], + }; + Assert.Equal(expected, parsed); + } + + // test all the binaries compared to codesinginfo from codesign output + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(EmbeddedSignatureBlobMatchesCodesignInfo +))] + public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifact testArtifact) + { + if(!SigningTests.IsSigned(filePath)) + { + return; + } + using (FileStream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + MachObjectFile machObjectFile = MachObjectFile.Create(new StreamBasedMachOFile(stream)); + Assert.True(machObjectFile.HasSignature, "Expected MachObjectFile to have a signature"); + EmbeddedSignatureBlob? embeddedSignatureBlob = machObjectFile.EmbeddedSignatureBlob; + Assert.NotNull(embeddedSignatureBlob); + var (exitcode, stderr) = Codesign.Run("--display --verbose=8", filePath); + if (exitcode != 0) + { + output.WriteLine($"Codesign command failed with exit code {exitcode}: {stderr}"); + Assert.True(false, "Codesign command failed"); + } + output.WriteLine($"Codesign output for {filePath}:\n{stderr}"); + CodesignOutputInfo codesignInfo = CodesignOutputInfo.ParseFromCodeSignOutput(stderr); + output.WriteLine($"Comparing {filePath} to codesign info: {codesignInfo}"); + output.WriteLine($"specialSlotHashes: {string.Join(", ", codesignInfo.SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}"); + output.WriteLine($"machObjectFile specialSlotHashes: {string.Join(", ", embeddedSignatureBlob.CodeDirectoryBlob.SpecialSlotHashes.Select(h => BitConverter.ToString(h.ToArray()).Replace("-", "")))}"); + AssertEqual(codesignInfo, embeddedSignatureBlob); + } + } + + static void AssertEqual(CodesignOutputInfo csi, EmbeddedSignatureBlob b) + { + Assert.True(csi.Identifier == b.CodeDirectoryBlob.Identifier, "Identifiers do not match"); + Assert.True(csi.CodeDirectoryFlags == b.CodeDirectoryBlob.Flags, "CodeDirectoryFlags do not match"); + Assert.True(csi.CodeDirectoryVersion == b.CodeDirectoryBlob.Version, "CodeDirectoryVersion do not match"); + Assert.True(csi.ExecutableSegmentBase == b.CodeDirectoryBlob.ExecutableSegmentBase, "ExecutableSegmentBase do not match"); + Assert.True(csi.ExecutableSegmentLimit == b.CodeDirectoryBlob.ExecutableSegmentLimit, "ExecutableSegmentLimit do not match"); + Assert.True(csi.ExecutableSegmentFlags == b.CodeDirectoryBlob.ExecutableSegmentFlags, "ExecutableSegmentFlags do not match"); + Assert.True(csi.RequirementsSize == b.RequirementsBlob?.Size, "RequirementsSize do not match"); + + Assert.True(csi.SpecialSlotHashes.Zip(b.CodeDirectoryBlob.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); + Assert.True(csi.CodeHashes.Zip(b.CodeDirectoryBlob.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); + } + + /// + /// Info related to the code signature that can be extracted from the output of the `codesign` command. + /// + internal sealed record CodesignOutputInfo { - foreach (var artifact in _testArtifacts) + public string Identifier { get; init; } + public CodeDirectoryFlags CodeDirectoryFlags { get; init; } + public CodeDirectoryVersion CodeDirectoryVersion { get; init; } + public ulong ExecutableSegmentBase { get; init; } + public ulong ExecutableSegmentLimit { get; init; } + public ExecutableSegmentFlags ExecutableSegmentFlags { get; init; } + public uint RequirementsSize { get; init; } + public byte[][] SpecialSlotHashes { get; init; } + public byte[][] CodeHashes { get; init; } + + public bool Equals(CodesignOutputInfo? obj) + { + if (obj is not CodesignOutputInfo other) + return false; + + return Identifier == other.Identifier && + CodeDirectoryFlags == other.CodeDirectoryFlags && + CodeDirectoryVersion == other.CodeDirectoryVersion && + ExecutableSegmentBase == other.ExecutableSegmentBase && + ExecutableSegmentLimit == other.ExecutableSegmentLimit && + ExecutableSegmentFlags == other.ExecutableSegmentFlags && + RequirementsSize == other.RequirementsSize && + SpecialSlotHashes.Length == other.SpecialSlotHashes.Length && + CodeHashes.Length == other.CodeHashes.Length && + SpecialSlotHashes.Zip(other.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x) && + CodeHashes.Zip(other.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x); + } + public override int GetHashCode() { - try + return HashCode.Combine(Identifier, CodeDirectoryFlags, CodeDirectoryVersion, ExecutableSegmentLimit, ExecutableSegmentFlags, RequirementsSize, SpecialSlotHashes, CodeHashes); + } + + /// + /// Parses the output of the `codesign` command to extract codesign information. + /// + public static CodesignOutputInfo ParseFromCodeSignOutput(string output) + { + var lines = output.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + var Identifier = lines[1].Split('=')[1].Trim(); + var cdInfo = lines[3].Split(' '); + var CodeDirectoryVersion = (CodeDirectoryVersion)Convert.ToUInt32(cdInfo[1].Split('=')[1].Trim(), 16); + var CodeDirectoryFlags = (CodeDirectoryFlags)Convert.ToUInt32(cdInfo[3].Split('=', '(')[1].Trim().TrimStart("0x").ToString(), 16); + Assert.True(lines[13].StartsWith("Executable Segment base="), "Expected 'Executable Segment base=' at line 13"); + Assert.True(lines[14].StartsWith("Executable Segment limit="), "Expected 'Executable Segment limit=' at line 14"); + Assert.True(lines[15].StartsWith("Executable Segment flags="), "Expected 'Executable Segment flags=' at line 15"); + var ExecutableSegmentBase = ulong.Parse(lines[13].Split('=')[1].Trim()); + var ExecutableSegmentLimit = ulong.Parse(lines[14].Split('=')[1].Trim()); + var ExecutableSegmentFlags = (ExecutableSegmentFlags)Convert.ToUInt64(lines[15].Split('=')[1].Trim().TrimStart("0x").ToString(), 16); + Assert.True(lines[16].StartsWith("Page size=4096"), "Expected 'Page size=4096' at line 16"); + var (SpecialSlotHashes, CodeHashes) = ExtractHashes(lines.Skip(17)); + var RequirementsSize = uint.Parse(lines[^1].Split('=')[2].Trim()); + + return new CodesignOutputInfo { - artifact.Dispose(); + Identifier = Identifier, + CodeDirectoryFlags = CodeDirectoryFlags, + CodeDirectoryVersion = CodeDirectoryVersion, + ExecutableSegmentBase = ExecutableSegmentBase, + ExecutableSegmentLimit = ExecutableSegmentLimit, + ExecutableSegmentFlags = ExecutableSegmentFlags, + SpecialSlotHashes = SpecialSlotHashes, + CodeHashes = CodeHashes, + RequirementsSize = RequirementsSize + }; + + static (byte[][] SpecialSlotHashes, byte[][] CodeHashes) ExtractHashes(IEnumerable lines) + { + List specialSlotHashes = []; + List codeHashes = []; + foreach (var line in lines) + { + if (line[0] is not ' ' or '\t') + break; + var hash = line.Split('=')[1].Trim(); + var index = int.Parse(line.Split('=')[0].Trim()); + if (index < 0) + { + // specialSlot + specialSlotHashes.Add(ParseByteArray(hash)); + + } + else + { + // codeHashes + codeHashes.Add(ParseByteArray(hash)); + } + } + return (specialSlotHashes.ToArray(), codeHashes.ToArray()); } - catch (Exception ex) + static byte[] ParseByteArray(string hex) { - Console.WriteLine($"Failed to dispose test artifact: {ex.Message}"); + if (hex.Length % 2 != 0) + throw new ArgumentException("Hex string must have an even length."); + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < hex.Length; i += 2) + { + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + } + return bytes; } } - _testArtifacts.Clear(); } + + string SampleCodesignOutput = """ + Executable=/Users/jacksonschuster/source/runtime3/artifacts/bin/osx-x64.Debug/corehost/singlefilehost + Identifier=singlefilehost-5555494409d4df688bf436b291061028f736b11c + Format=Mach-O thin (x86_64) + CodeDirectory v=20400 size=89264 flags=0x2(adhoc) hashes=2778+7 location=embedded + VersionPlatform=1 + VersionMin=786432 + VersionSDK=984064 + Hash type=sha256 size=32 + CandidateCDHash sha256=6fee638e9fe544a66b0acf9489ebc59e073b3e39 + CandidateCDHashFull sha256=6fee638e9fe544a66b0acf9489ebc59e073b3e39082903247c5413f555ec2857 + Hash choices=sha256 + CMSDigest=6fee638e9fe544a66b0acf9489ebc59e073b3e39082903247c5413f555ec2857 + CMSDigestType=2 + Executable Segment base=0 + Executable Segment limit=8949760 + Executable Segment flags=0x1 + Page size=4096 + -7=4d8d4b9e4116e8edd996176b5553463acb64287bb635e7f141155529e20457bc + -6=0000000000000000000000000000000000000000000000000000000000000000 + -5=cca8afe72425463c13b813da9ae468ae3b5fe20fe5fe1d3f34302ba2f15722f2 + -4=0000000000000000000000000000000000000000000000000000000000000000 + -3=0000000000000000000000000000000000000000000000000000000000000000 + -2=987920904eab650e75788c054aa0b0524e6a80bfc71aa32df8d237a61743f986 + -1=0000000000000000000000000000000000000000000000000000000000000000 + 0=20042993665611bf5d01d35a46092c2d43a07883f31247a03b5600c301f5c039 + 1=a97fad07cc9d6eabad27a77e32b69c3da59372fa7987a13c2b8d23f378380476 + 2=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 + 3=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 + 4=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 + 5=b3d230340aa5ed09c788c39081c207a7430b83d22c9489d84d4ede3ed320f47b + 6=825b7aa16170a9b739a4689ba8878391bcae87efd63e3b174738c382020031c1 + 7=e360159ee0adaeba5ac5f562c45ec551dbe8b73fbc858beca298610312df33b3 + 8=20585ef0bc0287c5b7a9b54f2669704cdc31cea7d7b1702b336fcf93a9f01ca2 + 9=414ae6563e5881b215a08bb33fc539fb0c90c3a5532f6e15a726ed6cdc255550 + 10=b672b667eb31b48d027bd5f1cf75bad5a8552b4d6b649cbdae35699152fb8a1b + CDHash=6fee638e9fe544a66b0acf9489ebc59e073b3e39 + Signature=adhoc + Info.plist=not bound + TeamIdentifier=not set + Sealed Resources=none + Internal requirements count=0 size=12 + """; } From 839c318345508240e98757b95d8031c14cbadfc4 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:13:38 -0700 Subject: [PATCH 05/16] Make test platform specific --- .../MachObjectSigning/MachObjectTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index 8f221639ae6b11..59fe912f84d564 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -149,8 +149,8 @@ public void CanParseCodesignOutput() // test all the binaries compared to codesinginfo from codesign output [Theory] - [MemberData(nameof(GetTestFilePaths), nameof(EmbeddedSignatureBlobMatchesCodesignInfo -))] + [MemberData(nameof(GetTestFilePaths), nameof(EmbeddedSignatureBlobMatchesCodesignInfo))] + [PlatformSpecific(TestPlatforms.OSX)] public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifact testArtifact) { if(!SigningTests.IsSigned(filePath)) From 04664086e5e9a33bc18c49b28bbf1bd97bb72323 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 16 Jun 2025 16:27:31 -0700 Subject: [PATCH 06/16] Remove RequirementsSize from CodesignInfo, preserve entitlements in codesign tests --- .../BinaryFormat/Blobs/CodeDirectoryBlob.cs | 1 - .../ResourceUpdater.cs | 21 +++++--- .../MachObjectSigning/MachObjectTests.cs | 51 +++++++++++-------- .../MachObjectSigning/SigningTests.cs | 7 ++- 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index c712b17c73dfbe..da4504664efbca 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -113,7 +113,6 @@ private CodeDirectoryBlob( private byte HashSize => _cdHeader.HashSize; private uint HashesOffset => _cdHeader._hashesOffset.ConvertFromBigEndian(); - public static CodeDirectoryBlob Create( IMachOFileReader accessor, long signatureStart, diff --git a/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs b/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs index 5bb710f2609438..8476121dc62a23 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs @@ -19,7 +19,7 @@ public class ResourceUpdater : IDisposable { private readonly FileStream? stream; private readonly MemoryMappedFile? _memoryMappedFile; - private Stream Stream => (Stream?)stream ?? _memoryMappedFile!.CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite); + private Stream Stream => stream as Stream ?? _memoryMappedFile!.CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite); private readonly PEReader _reader; private ResourceData? _resourceData; private readonly bool leaveOpen; @@ -71,7 +71,7 @@ public ResourceUpdater(MemoryMappedFile memoryMappedFile, bool leaveOpen = false catch (Exception) { if (!leaveOpen) - this.stream?.Dispose(); + this._memoryMappedFile?.Dispose(); throw; } } @@ -211,12 +211,19 @@ public void Update() bool needsMoveTrailingSections = !isRsrcIsLastSection && delta > 0; long finalImageSize = trailingSectionStart + Math.Max(delta, 0) + trailingSectionLength; - using (var mmap = _memoryMappedFile ?? MemoryMappedFile.CreateFromFile(stream!, null, finalImageSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true)) - using (MemoryMappedViewAccessor accessor = mmap.CreateViewAccessor(0, finalImageSize, MemoryMappedFileAccess.ReadWrite)) + // Create a memory-mapped file if we weren't provided one when constructed + // and make sure we dispose it after use + using var mmap = stream is not null ? + MemoryMappedFile.CreateFromFile(stream!, null, finalImageSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true) + : null; + + // Don't dispose the memory-mapped file if it was provided in the constructor + var mmappedFile = mmap ?? _memoryMappedFile!; + using (MemoryMappedViewAccessor accessor = mmappedFile.CreateViewAccessor(0, finalImageSize, MemoryMappedFileAccess.ReadWrite)) { int peSignatureOffset = ReadI32(accessor, PEOffsets.DosStub.PESignatureOffset); int sectionBase = peSignatureOffset + PEOffsets.PEHeaderSize + - (ushort)_reader.PEHeaders.CoffHeader.SizeOfOptionalHeader; + (ushort)_reader!.PEHeaders.CoffHeader.SizeOfOptionalHeader; if (needsAddSection) { @@ -279,7 +286,7 @@ void PatchRVA(int offset) pointer => pointer >= trailingSectionVirtualStart ? pointer + virtualDelta : pointer); } - int dataDirectoriesOffset = _reader.PEHeaders.PEHeader.Magic == PEMagic.PE32Plus + int dataDirectoriesOffset = _reader.PEHeaders.PEHeader!.Magic == PEMagic.PE32Plus ? peSignatureOffset + PEOffsets.PEHeader.PE64DataDirectories : peSignatureOffset + PEOffsets.PEHeader.PE32DataDirectories; @@ -294,7 +301,7 @@ void PatchRVA(int offset) // fix RVA in DataDirectory for (int i = 0; i < _reader.PEHeaders.PEHeader.NumberOfRvaAndSizes; i++) PatchRVA(dataDirectoriesOffset + i * PEOffsets.DataDirectoryEntrySize + - PEOffsets.DataDirectoryEntry.VirtualAddressOffset); + PEOffsets.DataDirectoryEntry.VirtualAddressOffset); } // update the ResourceTable in DataDirectories diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index 59fe912f84d564..f9ed77d368d0e2 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -7,7 +7,6 @@ using System.IO.MemoryMappedFiles; using System.Linq; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using Microsoft.DotNet.CoreSetup; using Microsoft.DotNet.CoreSetup.Test; using Microsoft.NET.HostModel.MachO; @@ -15,6 +14,8 @@ using Xunit; using Xunit.Abstractions; +namespace Microsoft.NET.HostModel.Tests; + public class MachObjectTests { ITestOutputHelper output; @@ -44,7 +45,7 @@ public void StreamAndMemoryMappedFileAreTheSame(string filePath, TestArtifact _) [Theory] [MemberData(nameof(GetTestFilePaths), nameof(RoundTripMachObjectFileIsTheSame))] - void RoundTripMachObjectFileIsTheSame(string filePath, TestArtifact testArtifact) + void RoundTripMachObjectFileIsTheSame(string filePath, TestArtifact _) { var backupFilePath = filePath + ".bak"; File.Copy(filePath, backupFilePath); @@ -120,7 +121,6 @@ public void CanParseCodesignOutput() ExecutableSegmentBase = 0, ExecutableSegmentLimit = 8949760, ExecutableSegmentFlags = ExecutableSegmentFlags.MainBinary, - RequirementsSize = 12, SpecialSlotHashes = [ [0x4d, 0x8d, 0x4b, 0x9e, 0x41, 0x16, 0xe8, 0xed, 0xd9, 0x96, 0x17, 0x6b, 0x55, 0x53, 0x46, 0x3a, 0xcb, 0x64, 0x28, 0x7b, 0xb6, 0x35, 0xe7, 0xf1, 0x41, 0x15, 0x55, 0x29, 0xe2, 0x04, 0x57, 0xbc], [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], @@ -151,7 +151,7 @@ public void CanParseCodesignOutput() [Theory] [MemberData(nameof(GetTestFilePaths), nameof(EmbeddedSignatureBlobMatchesCodesignInfo))] [PlatformSpecific(TestPlatforms.OSX)] - public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifact testArtifact) + public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifact _) { if(!SigningTests.IsSigned(filePath)) { @@ -167,7 +167,7 @@ public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifa if (exitcode != 0) { output.WriteLine($"Codesign command failed with exit code {exitcode}: {stderr}"); - Assert.True(false, "Codesign command failed"); + Assert.Fail("Codesign command failed"); } output.WriteLine($"Codesign output for {filePath}:\n{stderr}"); CodesignOutputInfo codesignInfo = CodesignOutputInfo.ParseFromCodeSignOutput(stderr); @@ -186,7 +186,6 @@ static void AssertEqual(CodesignOutputInfo csi, EmbeddedSignatureBlob b) Assert.True(csi.ExecutableSegmentBase == b.CodeDirectoryBlob.ExecutableSegmentBase, "ExecutableSegmentBase do not match"); Assert.True(csi.ExecutableSegmentLimit == b.CodeDirectoryBlob.ExecutableSegmentLimit, "ExecutableSegmentLimit do not match"); Assert.True(csi.ExecutableSegmentFlags == b.CodeDirectoryBlob.ExecutableSegmentFlags, "ExecutableSegmentFlags do not match"); - Assert.True(csi.RequirementsSize == b.RequirementsBlob?.Size, "RequirementsSize do not match"); Assert.True(csi.SpecialSlotHashes.Zip(b.CodeDirectoryBlob.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); Assert.True(csi.CodeHashes.Zip(b.CodeDirectoryBlob.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); @@ -203,7 +202,6 @@ internal sealed record CodesignOutputInfo public ulong ExecutableSegmentBase { get; init; } public ulong ExecutableSegmentLimit { get; init; } public ExecutableSegmentFlags ExecutableSegmentFlags { get; init; } - public uint RequirementsSize { get; init; } public byte[][] SpecialSlotHashes { get; init; } public byte[][] CodeHashes { get; init; } @@ -218,15 +216,28 @@ public bool Equals(CodesignOutputInfo? obj) ExecutableSegmentBase == other.ExecutableSegmentBase && ExecutableSegmentLimit == other.ExecutableSegmentLimit && ExecutableSegmentFlags == other.ExecutableSegmentFlags && - RequirementsSize == other.RequirementsSize && SpecialSlotHashes.Length == other.SpecialSlotHashes.Length && CodeHashes.Length == other.CodeHashes.Length && SpecialSlotHashes.Zip(other.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x) && CodeHashes.Zip(other.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x); } - public override int GetHashCode() + + public override string ToString() + { + return $$""" + Identifier: {{Identifier}}, + CodeDirectoryFlags: {{CodeDirectoryFlags}}, + CodeDirectoryVersion: {{CodeDirectoryVersion}}, + ExecutableSegmentBase: {{ExecutableSegmentBase}}, + ExecutableSegmentLimit: {{ExecutableSegmentLimit}}, + ExecutableSegmentFlags: {{ExecutableSegmentFlags}}, + SpecialSlotHashes: [{{string.Join(", ", SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}], + CodeHashes: [{{string.Join(", ", CodeHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}] + """; + } + public override int GetHashCode() { - return HashCode.Combine(Identifier, CodeDirectoryFlags, CodeDirectoryVersion, ExecutableSegmentLimit, ExecutableSegmentFlags, RequirementsSize, SpecialSlotHashes, CodeHashes); + return HashCode.Combine(Identifier, CodeDirectoryFlags, CodeDirectoryVersion, ExecutableSegmentLimit, ExecutableSegmentFlags, SpecialSlotHashes, CodeHashes); } /// @@ -234,20 +245,20 @@ public override int GetHashCode() /// public static CodesignOutputInfo ParseFromCodeSignOutput(string output) { - var lines = output.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - var Identifier = lines[1].Split('=')[1].Trim(); + var splitOptions = StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries; + var lines = output.Split(new[] { '\n', '\r' }, splitOptions); + var Identifier = lines[1].Split('=', splitOptions)[1]; var cdInfo = lines[3].Split(' '); - var CodeDirectoryVersion = (CodeDirectoryVersion)Convert.ToUInt32(cdInfo[1].Split('=')[1].Trim(), 16); - var CodeDirectoryFlags = (CodeDirectoryFlags)Convert.ToUInt32(cdInfo[3].Split('=', '(')[1].Trim().TrimStart("0x").ToString(), 16); + var CodeDirectoryVersion = (CodeDirectoryVersion)Convert.ToUInt32(cdInfo[1].Split('=', splitOptions)[1], 16); + var CodeDirectoryFlags = (CodeDirectoryFlags)Convert.ToUInt32(cdInfo[3].Split(['=', '('], splitOptions)[1].TrimStart("0x").ToString(), 16); Assert.True(lines[13].StartsWith("Executable Segment base="), "Expected 'Executable Segment base=' at line 13"); Assert.True(lines[14].StartsWith("Executable Segment limit="), "Expected 'Executable Segment limit=' at line 14"); Assert.True(lines[15].StartsWith("Executable Segment flags="), "Expected 'Executable Segment flags=' at line 15"); - var ExecutableSegmentBase = ulong.Parse(lines[13].Split('=')[1].Trim()); - var ExecutableSegmentLimit = ulong.Parse(lines[14].Split('=')[1].Trim()); - var ExecutableSegmentFlags = (ExecutableSegmentFlags)Convert.ToUInt64(lines[15].Split('=')[1].Trim().TrimStart("0x").ToString(), 16); + var ExecutableSegmentBase = ulong.Parse(lines[13].Split('=', splitOptions)[1]); + var ExecutableSegmentLimit = ulong.Parse(lines[14].Split('=', splitOptions)[1]); + var ExecutableSegmentFlags = (ExecutableSegmentFlags)Convert.ToUInt64(lines[15].Split('=', splitOptions)[1].TrimStart("0x").ToString(), 16); Assert.True(lines[16].StartsWith("Page size=4096"), "Expected 'Page size=4096' at line 16"); var (SpecialSlotHashes, CodeHashes) = ExtractHashes(lines.Skip(17)); - var RequirementsSize = uint.Parse(lines[^1].Split('=')[2].Trim()); return new CodesignOutputInfo { @@ -259,7 +270,6 @@ public static CodesignOutputInfo ParseFromCodeSignOutput(string output) ExecutableSegmentFlags = ExecutableSegmentFlags, SpecialSlotHashes = SpecialSlotHashes, CodeHashes = CodeHashes, - RequirementsSize = RequirementsSize }; static (byte[][] SpecialSlotHashes, byte[][] CodeHashes) ExtractHashes(IEnumerable lines) @@ -268,7 +278,7 @@ public static CodesignOutputInfo ParseFromCodeSignOutput(string output) List codeHashes = []; foreach (var line in lines) { - if (line[0] is not ' ' or '\t') + if (line[0] is not ('-' or '0' or '1' or '2' or '3' or '4' or '5' or '6' or '7' or '8' or '9')) break; var hash = line.Split('=')[1].Trim(); var index = int.Parse(line.Split('=')[0].Trim()); @@ -276,7 +286,6 @@ public static CodesignOutputInfo ParseFromCodeSignOutput(string output) { // specialSlot specialSlotHashes.Add(ParseByteArray(hash)); - } else { diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs index cc0f4e612d9928..f3ac763b8177aa 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs @@ -82,8 +82,7 @@ void MatchesCodesignOutput(string filePath, TestArtifact _) // Codesigned file File.Copy(filePath, codesignFilePath); Assert.True(Codesign.IsAvailable, "Could not find codesign tool"); - Codesign.Run("--remove-signature", codesignFilePath).ExitCode.Should().Be(0, $"'codesign --remove-signature {codesignFilePath}' failed!"); - Codesign.Run("-s - -i " + fileName, codesignFilePath).ExitCode.Should().Be(0, $"'codesign -s - {codesignFilePath}' failed!"); + Codesign.Run("-s - -f --preserve-metadata=entitlements -i" + fileName, codesignFilePath).ExitCode.Should().Be(0, $"'codesign -s - {codesignFilePath}' failed!"); // Managed signed file AdHocSignFile(originalFilePath, managedSignedPath, fileName); @@ -152,7 +151,7 @@ static void AssertMachFilesAreEquivalent(string codesignedPath, string managedSi /// /// AdHoc sign a test file. This should look similar to HostWriter.CreateAppHost. /// - public static void AdHocSignFile(string originalFilePath, string managedSignedPath, string fileName) + internal static void AdHocSignFile(string originalFilePath, string managedSignedPath, string fileName) { Assert.NotEqual(originalFilePath, managedSignedPath); // Open the source host file. @@ -176,7 +175,7 @@ public static void AdHocSignFile(string originalFilePath, string managedSignedPa } } - public static void AdHocSignFileInPlace(string managedSignedPath) + internal static void AdHocSignFileInPlace(string managedSignedPath) { var tmpFile = Path.GetTempFileName(); var mode = File.GetUnixFileMode(managedSignedPath); From 9d3c8c231c031043ee5c13c64c8b117c4aa83889 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:20:13 -0700 Subject: [PATCH 07/16] Make method public again --- .../MachObjectSigning/SigningTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs index f3ac763b8177aa..a54fd3056d4e01 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs @@ -175,7 +175,9 @@ internal static void AdHocSignFile(string originalFilePath, string managedSigned } } - internal static void AdHocSignFileInPlace(string managedSignedPath) +#pragma warning disable xUnit1013 // Public method should be marked as test + public static void AdHocSignFileInPlace(string managedSignedPath) +#pragma warning restore xUnit1013 // Public method should be marked as test { var tmpFile = Path.GetTempFileName(); var mode = File.GetUnixFileMode(managedSignedPath); From 89a550db01b0ea19a36532eeb7a3e910b15ec4fd Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:55:14 -0700 Subject: [PATCH 08/16] Use FileStream for ResourceUpdater until we can precalculate the size required. --- .../AppHost/HostWriter.cs | 15 ++--- .../ResourceUpdater.cs | 55 +++++-------------- 2 files changed, 21 insertions(+), 49 deletions(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs index f0a64226fcb39a..0e6e399bbb5e66 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs @@ -164,17 +164,18 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } } } - if (assemblyToCopyResourcesFrom != null && appHostIsPEImage) - { - using var updater = new ResourceUpdater(appHostDestinationMap, true); - updater.AddResourcesFromPEImage(assemblyToCopyResourcesFrom); - updater.Update(); - } - using (var appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 1)) + using (var appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 1)) using (var appHostAccessor = appHostDestinationMap.CreateViewAccessor(0, appHostDestinationLength, MemoryMappedFileAccess.Read)) { // Write the final content to the destination file. BinaryUtils.WriteToStream(appHostAccessor, appHostDestinationStream, appHostDestinationLength); + // This could be moved to work on the MemoryMappedFile if we can precalculate the size required. + if (assemblyToCopyResourcesFrom != null && appHostIsPEImage) + { + using var updater = new ResourceUpdater(appHostDestinationStream, leaveOpen: true); + updater.AddResourcesFromPEImage(assemblyToCopyResourcesFrom); + updater.Update(); + } } } }); diff --git a/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs b/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs index 8476121dc62a23..e4791a6bb14e99 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/ResourceUpdater.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable enable - using System; using System.IO; using System.IO.MemoryMappedFiles; @@ -17,11 +15,9 @@ namespace Microsoft.NET.HostModel /// public class ResourceUpdater : IDisposable { - private readonly FileStream? stream; - private readonly MemoryMappedFile? _memoryMappedFile; - private Stream Stream => stream as Stream ?? _memoryMappedFile!.CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite); + private readonly FileStream stream; private readonly PEReader _reader; - private ResourceData? _resourceData; + private ResourceData _resourceData; private readonly bool leaveOpen; /// @@ -59,23 +55,6 @@ public ResourceUpdater(FileStream stream, bool leaveOpen = false) } } - public ResourceUpdater(MemoryMappedFile memoryMappedFile, bool leaveOpen = false) - { - this._memoryMappedFile = memoryMappedFile; - this.leaveOpen = leaveOpen; - try - { - _reader = new PEReader(this.Stream, PEStreamOptions.LeaveOpen); - _resourceData = new ResourceData(_reader); - } - catch (Exception) - { - if (!leaveOpen) - this._memoryMappedFile?.Dispose(); - throw; - } - } - /// /// Add all resources from a source PE file. It is assumed /// that the input is a valid PE file. If it is not, an @@ -90,7 +69,7 @@ public ResourceUpdater AddResourcesFromPEImage(string peFile) using var module = new PEReader(File.OpenRead(peFile)); var moduleResources = new ResourceData(module); - _resourceData!.CopyResourcesFrom(moduleResources); + _resourceData.CopyResourcesFrom(moduleResources); return this; } @@ -116,7 +95,7 @@ public ResourceUpdater AddResource(byte[] data, IntPtr lpType, IntPtr lpName) if (_resourceData == null) ThrowExceptionForInvalidUpdate(); - _resourceData!.AddResource((ushort)lpName, (ushort)lpType, LangID_LangNeutral_SublangNeutral, data); + _resourceData.AddResource((ushort)lpName, (ushort)lpType, LangID_LangNeutral_SublangNeutral, data); return this; } @@ -136,7 +115,7 @@ public ResourceUpdater AddResource(byte[] data, string lpType, IntPtr lpName) if (_resourceData == null) ThrowExceptionForInvalidUpdate(); - _resourceData!.AddResource((ushort)lpName, lpType, LangID_LangNeutral_SublangNeutral, data); + _resourceData.AddResource((ushort)lpName, lpType, LangID_LangNeutral_SublangNeutral, data); return this; } @@ -194,7 +173,7 @@ public void Update() } var objectDataBuilder = new ObjectDataBuilder(); - _resourceData!.WriteResources(rsrcVirtualAddress, ref objectDataBuilder); + _resourceData.WriteResources(rsrcVirtualAddress, ref objectDataBuilder); var rsrcSectionData = objectDataBuilder.ToData(); int rsrcSectionDataSize = rsrcSectionData.Length; @@ -206,24 +185,17 @@ public void Update() int trailingSectionVirtualStart = rsrcVirtualAddress + rsrcOriginalVirtualSize; int trailingSectionStart = rsrcPointerToRawData + rsrcOriginalRawDataSize; - int trailingSectionLength = (int)(Stream.Length - trailingSectionStart); + int trailingSectionLength = (int)(stream.Length - trailingSectionStart); bool needsMoveTrailingSections = !isRsrcIsLastSection && delta > 0; long finalImageSize = trailingSectionStart + Math.Max(delta, 0) + trailingSectionLength; - // Create a memory-mapped file if we weren't provided one when constructed - // and make sure we dispose it after use - using var mmap = stream is not null ? - MemoryMappedFile.CreateFromFile(stream!, null, finalImageSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true) - : null; - - // Don't dispose the memory-mapped file if it was provided in the constructor - var mmappedFile = mmap ?? _memoryMappedFile!; - using (MemoryMappedViewAccessor accessor = mmappedFile.CreateViewAccessor(0, finalImageSize, MemoryMappedFileAccess.ReadWrite)) + using (var mmap = MemoryMappedFile.CreateFromFile(stream, null, finalImageSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true)) + using (MemoryMappedViewAccessor accessor = mmap.CreateViewAccessor(0, finalImageSize, MemoryMappedFileAccess.ReadWrite)) { int peSignatureOffset = ReadI32(accessor, PEOffsets.DosStub.PESignatureOffset); int sectionBase = peSignatureOffset + PEOffsets.PEHeaderSize + - (ushort)_reader!.PEHeaders.CoffHeader.SizeOfOptionalHeader; + (ushort)_reader.PEHeaders.CoffHeader.SizeOfOptionalHeader; if (needsAddSection) { @@ -286,7 +258,7 @@ void PatchRVA(int offset) pointer => pointer >= trailingSectionVirtualStart ? pointer + virtualDelta : pointer); } - int dataDirectoriesOffset = _reader.PEHeaders.PEHeader!.Magic == PEMagic.PE32Plus + int dataDirectoriesOffset = _reader.PEHeaders.PEHeader.Magic == PEMagic.PE32Plus ? peSignatureOffset + PEOffsets.PEHeader.PE64DataDirectories : peSignatureOffset + PEOffsets.PEHeader.PE32DataDirectories; @@ -301,7 +273,7 @@ void PatchRVA(int offset) // fix RVA in DataDirectory for (int i = 0; i < _reader.PEHeaders.PEHeader.NumberOfRvaAndSizes; i++) PatchRVA(dataDirectoriesOffset + i * PEOffsets.DataDirectoryEntrySize + - PEOffsets.DataDirectoryEntry.VirtualAddressOffset); + PEOffsets.DataDirectoryEntry.VirtualAddressOffset); } // update the ResourceTable in DataDirectories @@ -364,8 +336,7 @@ public void Dispose(bool disposing) if (disposing && !leaveOpen) { _reader.Dispose(); - stream?.Dispose(); - _memoryMappedFile?.Dispose(); + stream.Dispose(); } } } From 55ee29bc823c3ed0d733a1a3ed83786bf426152a Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:14:03 -0700 Subject: [PATCH 09/16] Make codesign less verbose to avoid timeout --- .../MachObjectSigning/MachObjectTests.cs | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index f9ed77d368d0e2..76b95711405f32 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -153,29 +153,33 @@ public void CanParseCodesignOutput() [PlatformSpecific(TestPlatforms.OSX)] public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifact _) { - if(!SigningTests.IsSigned(filePath)) + if (!SigningTests.IsSigned(filePath)) { return; } - using (FileStream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + MachObjectFile machObjectFile; + EmbeddedSignatureBlob? embeddedSignatureBlob; + using (var memoryMappedFile = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open, null, 0, MemoryMappedFileAccess.Read)) + using (var memoryMappedViewAccessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) { - MachObjectFile machObjectFile = MachObjectFile.Create(new StreamBasedMachOFile(stream)); + machObjectFile = MachObjectFile.Create(new MemoryMappedMachOViewAccessor(memoryMappedViewAccessor)); Assert.True(machObjectFile.HasSignature, "Expected MachObjectFile to have a signature"); - EmbeddedSignatureBlob? embeddedSignatureBlob = machObjectFile.EmbeddedSignatureBlob; + embeddedSignatureBlob = machObjectFile.EmbeddedSignatureBlob; Assert.NotNull(embeddedSignatureBlob); - var (exitcode, stderr) = Codesign.Run("--display --verbose=8", filePath); - if (exitcode != 0) - { - output.WriteLine($"Codesign command failed with exit code {exitcode}: {stderr}"); - Assert.Fail("Codesign command failed"); - } - output.WriteLine($"Codesign output for {filePath}:\n{stderr}"); - CodesignOutputInfo codesignInfo = CodesignOutputInfo.ParseFromCodeSignOutput(stderr); - output.WriteLine($"Comparing {filePath} to codesign info: {codesignInfo}"); - output.WriteLine($"specialSlotHashes: {string.Join(", ", codesignInfo.SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}"); - output.WriteLine($"machObjectFile specialSlotHashes: {string.Join(", ", embeddedSignatureBlob.CodeDirectoryBlob.SpecialSlotHashes.Select(h => BitConverter.ToString(h.ToArray()).Replace("-", "")))}"); - AssertEqual(codesignInfo, embeddedSignatureBlob); } + + var (exitcode, stderr) = Codesign.Run("--display --verbose=4", filePath); + if (exitcode != 0) + { + output.WriteLine($"Codesign command failed with exit code {exitcode}: {stderr}"); + Assert.Fail("Codesign command failed"); + } + output.WriteLine($"Codesign output for {filePath}:\n{stderr}"); + CodesignOutputInfo codesignInfo = CodesignOutputInfo.ParseFromCodeSignOutput(stderr); + output.WriteLine($"Comparing {filePath} to codesign info: {codesignInfo}"); + output.WriteLine($"specialSlotHashes: {string.Join(", ", codesignInfo.SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}"); + output.WriteLine($"machObjectFile specialSlotHashes: {string.Join(", ", embeddedSignatureBlob.CodeDirectoryBlob.SpecialSlotHashes.Select(h => BitConverter.ToString(h.ToArray()).Replace("-", "")))}"); + AssertEqual(codesignInfo, embeddedSignatureBlob); } static void AssertEqual(CodesignOutputInfo csi, EmbeddedSignatureBlob b) @@ -187,8 +191,8 @@ static void AssertEqual(CodesignOutputInfo csi, EmbeddedSignatureBlob b) Assert.True(csi.ExecutableSegmentLimit == b.CodeDirectoryBlob.ExecutableSegmentLimit, "ExecutableSegmentLimit do not match"); Assert.True(csi.ExecutableSegmentFlags == b.CodeDirectoryBlob.ExecutableSegmentFlags, "ExecutableSegmentFlags do not match"); - Assert.True(csi.SpecialSlotHashes.Zip(b.CodeDirectoryBlob.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); - Assert.True(csi.CodeHashes.Zip(b.CodeDirectoryBlob.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); + // Assert.True(csi.SpecialSlotHashes.Zip(b.CodeDirectoryBlob.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); + // Assert.True(csi.CodeHashes.Zip(b.CodeDirectoryBlob.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); } /// @@ -223,8 +227,8 @@ public bool Equals(CodesignOutputInfo? obj) } public override string ToString() - { - return $$""" + { + return $$""" Identifier: {{Identifier}}, CodeDirectoryFlags: {{CodeDirectoryFlags}}, CodeDirectoryVersion: {{CodeDirectoryVersion}}, @@ -234,8 +238,8 @@ public override string ToString() SpecialSlotHashes: [{{string.Join(", ", SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}], CodeHashes: [{{string.Join(", ", CodeHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}] """; - } - public override int GetHashCode() + } + public override int GetHashCode() { return HashCode.Combine(Identifier, CodeDirectoryFlags, CodeDirectoryVersion, ExecutableSegmentLimit, ExecutableSegmentFlags, SpecialSlotHashes, CodeHashes); } From 7e6d8348f3d153e001f1e5ea30ce4c9499f47b51 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:32:58 -0700 Subject: [PATCH 10/16] Add IO exception retries on test file backups --- .../RegisteredInstallLocationOverride.cs | 10 ++++-- .../tests/TestUtils/TestFileBackup.cs | 33 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/installer/tests/HostActivation.Tests/RegisteredInstallLocationOverride.cs b/src/installer/tests/HostActivation.Tests/RegisteredInstallLocationOverride.cs index 4cd7a495e9401c..bb2fe9f7785bf1 100644 --- a/src/installer/tests/HostActivation.Tests/RegisteredInstallLocationOverride.cs +++ b/src/installer/tests/HostActivation.Tests/RegisteredInstallLocationOverride.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Cli.Build.Framework; +using Microsoft.NET.HostModel; using Microsoft.Win32; using System; using System.Collections.Generic; @@ -98,10 +99,13 @@ public void Dispose() } else { - if (File.Exists(PathValueOverride)) + RetryUtil.RetryOnIOError(() => { - File.Delete(PathValueOverride); - } + if (File.Exists(PathValueOverride)) + { + File.Delete(PathValueOverride); + } + }); } if (_testOnlyProductBehavior != null) diff --git a/src/installer/tests/TestUtils/TestFileBackup.cs b/src/installer/tests/TestUtils/TestFileBackup.cs index ffc7d62946ad80..c17f5d96f7f18f 100644 --- a/src/installer/tests/TestUtils/TestFileBackup.cs +++ b/src/installer/tests/TestUtils/TestFileBackup.cs @@ -68,22 +68,31 @@ public void Backup(string path) public void Dispose() { - if (Directory.Exists(_backupPath)) + RetryOnIOError(() => { - CopyOverDirectory(_backupPath, _basePath); + if (Directory.Exists(_backupPath)) + { + // Copying may fail if the file is still mapped from a process that is exiting + CopyOverDirectory(_backupPath, _basePath); + } + return true; + }, $"Failed to restore files from the backup directory {_backupPath} even after retries"); - // Directory.Delete sometimes fails with error that the directory is not empty. - // This is a known problem where the actual Delete call is not 100% synchronous - // the OS reports a success but the file/folder is not fully removed yet. - // So implement a simple retry with a short timeout. - RetryOnIOError(() => + RetryOnIOError(() => + { + if (Directory.Exists(_backupPath)) { + // Directory.Delete sometimes fails with error that the directory is not empty. + // This is a known problem where the actual Delete call is not 100% synchronous + // the OS reports a success but the file/folder is not fully removed yet. + // So implement a simple retry with a short timeout. Directory.Delete(_backupPath, recursive: true); return !Directory.Exists(_backupPath); - }, - $"Failed to delete the backup folder {_backupPath} even after retries." - ); - } + } + return true; + }, + $"Failed to delete the backup folder {_backupPath} even after retries." + ); } private static void CopyOverDirectory(string source, string destination) @@ -119,7 +128,7 @@ private static void RetryOnIOError(Func action, string errorMessage, int m return; } } - catch (IOException e) + catch (IOException e) { exception = e; } From c1d5d755c71409abb03f2cd652839555fc8399cf Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:51:09 -0700 Subject: [PATCH 11/16] Apply suggestions from code review - use explicit types for "using" statements - Make CodeDirectoryHeader fields private and expose properties that convert them from bigendian - Reenable codesign hash checks in tests - improve readability --- .../AppHost/BinaryUtils.cs | 2 +- .../AppHost/HostWriter.cs | 24 ++--- .../PlaceHolderNotFoundInAppHostException.cs | 1 + .../Microsoft.NET.HostModel/Bundle/Bundler.cs | 28 +++--- .../Bundle/FileEntry.cs | 8 +- .../Bundle/Manifest.cs | 15 ++- .../BinaryFormat/Blobs/CodeDirectoryBlob.cs | 98 ++++++++++++------- .../Blobs/EmbeddedSignatureBlob.cs | 12 ++- .../MachO/MachObjectFile.cs | 27 +++-- .../MachObjectSigning/MachObjectTests.cs | 25 ++++- src/installer/tests/TestUtils/Codesign.cs | 8 +- 11 files changed, 149 insertions(+), 99 deletions(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs index e32fe97a554df2..94e852ee34b71d 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs @@ -74,7 +74,7 @@ public static unsafe void SearchAndReplace( } } - internal static unsafe int SearchInFile(MemoryMappedViewAccessor accessor, byte[] searchPattern) + internal static unsafe int SearchInFile(MemoryMappedViewAccessor accessor, ReadOnlySpan searchPattern) { var safeBuffer = accessor.SafeMemoryMappedViewHandle; return KMPSearch(searchPattern, (byte*)safeBuffer.DangerousGetHandle(), (int)safeBuffer.ByteLength); diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs index 0e6e399bbb5e66..a0fa93553db58e 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs @@ -2,13 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.IO.MemoryMappedFiles; using System.Runtime.InteropServices; using System.Text; -using Microsoft.NET.HostModel.Bundle; using Microsoft.NET.HostModel.MachO; namespace Microsoft.NET.HostModel.AppHost @@ -128,12 +126,14 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access long appHostSourceLength = new FileInfo(appHostSourceFilePath).Length; string destinationFileName = Path.GetFileName(appHostDestinationFilePath); + // Memory-mapped files cannot be resized, so calculate + // the maximum length of the destination file upfront. long appHostDestinationLength = enableMacOSCodeSign ? appHostSourceLength + MachObjectFile.GetSignatureSizeEstimate((uint)appHostSourceLength, destinationFileName) : appHostSourceLength; - using (var appHostDestinationMap = MemoryMappedFile.CreateNew(null, appHostDestinationLength)) + using (MemoryMappedFile appHostDestinationMap = MemoryMappedFile.CreateNew(null, appHostDestinationLength)) { - using (var appHostDestinationStream = appHostDestinationMap.CreateViewStream()) + using (MemoryMappedViewStream appHostDestinationStream = appHostDestinationMap.CreateViewStream()) using (FileStream appHostSourceStream = new(appHostSourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1)) { isMachOImage = MachObjectFile.IsMachOImage(appHostSourceStream); @@ -144,10 +144,8 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access appHostSourceStream.CopyTo(appHostDestinationStream); } - using (var memoryMappedViewAccessor = appHostDestinationMap.CreateViewAccessor()) + using (MemoryMappedViewAccessor memoryMappedViewAccessor = appHostDestinationMap.CreateViewAccessor()) { - // Get the size of the source app host to ensure that we don't write extra data to the destination. - // On Windows, the size of the view accessor is rounded up to the next page boundary. // Transform the host file in-memory. RewriteAppHost(appHostDestinationMap, memoryMappedViewAccessor); if (isMachOImage) @@ -164,15 +162,17 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } } } - using (var appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 1)) - using (var appHostAccessor = appHostDestinationMap.CreateViewAccessor(0, appHostDestinationLength, MemoryMappedFileAccess.Read)) + using (FileStream appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 1)) + using (MemoryMappedViewAccessor appHostAccessor = appHostDestinationMap.CreateViewAccessor(0, appHostDestinationLength, MemoryMappedFileAccess.Read)) { - // Write the final content to the destination file. + // Write the final content to the destination file, only up to the total length of the host, not the entire mapped file. + // On Windows, memory-mapped files are rounded up to the next page size. + // On MacOS, the memory-mapped file is created with a conservative estimate of the size of the signature. BinaryUtils.WriteToStream(appHostAccessor, appHostDestinationStream, appHostDestinationLength); - // This could be moved to work on the MemoryMappedFile if we can precalculate the size required. + // TODO: This could be moved to work on the MemoryMappedFile if we can precalculate the size required. if (assemblyToCopyResourcesFrom != null && appHostIsPEImage) { - using var updater = new ResourceUpdater(appHostDestinationStream, leaveOpen: true); + using ResourceUpdater updater = new ResourceUpdater(appHostDestinationStream, leaveOpen: true); updater.AddResourcesFromPEImage(assemblyToCopyResourcesFrom); updater.Update(); } diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs index b2b21322faf5ac..7988aae7b5dcca 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs @@ -18,6 +18,7 @@ public PlaceHolderNotFoundInAppHostException(byte[] pattern) } public PlaceHolderNotFoundInAppHostException(ReadOnlySpan pattern) { + MissingPattern = pattern.ToArray(); } } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index 259c0a82882c1a..f455e828f719bd 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -50,8 +50,11 @@ public Bundler(string hostName, string? appAssemblyName = null, bool macosCodesign = true) { + if (!string.IsNullOrEmpty(Path.GetDirectoryName(hostName))) + { + throw new ArgumentException("Host name must be a file name, not a path", nameof(hostName)); + } _tracer = new Trace(diagnosticOutput); - _hostName = hostName; _outputDir = Path.GetFullPath(string.IsNullOrEmpty(outputDir) ? Environment.CurrentDirectory : outputDir); _target = new TargetInfo(targetOS, targetArch, targetFrameworkVersion); @@ -242,6 +245,8 @@ private FileType InferType(FileSpec fileSpec) 0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae ]; + public static ReadOnlySpan BundleHeaderSignature => BundleHeaderPlaceholder.AsSpan().Slice(8); + /// /// Generate a bundle, given the specification of embedded files /// @@ -279,7 +284,7 @@ public string GenerateBundle(IReadOnlyList fileSpecs) throw new ArgumentException("Invalid input specification: Must specify the host binary"); } - var relativePathToSpec = GetFilteredFileSpecs(fileSpecs); + (FileSpec Spec, FileType Type)[] relativePathToSpec = GetFilteredFileSpecs(fileSpecs); long bundledFilesSize = 0; // Conservatively estimate the size of bundled files. // Assume no compression and worst case alignment for assemblies. @@ -309,12 +314,11 @@ public string GenerateBundle(IReadOnlyList fileSpecs) { Directory.CreateDirectory(destinationDirectory); } - var bundleName = Path.GetFileName(bundlePath); var hostLength = new FileInfo(hostSource).Length; - var bundleManifestLength = BundleManifest.GetManifestLength(BundleManifest.BundleMajorVersion, relativePathToSpec.Select(x => x.Spec.BundleRelativePath)); + var bundleManifestLength = Manifest.GetManifestLength(BundleManifest.BundleMajorVersion, relativePathToSpec.Select(x => x.Spec.BundleRelativePath)); long bundleTotalSize = hostLength + bundledFilesSize + bundleManifestLength; if (_target.IsOSX && _macosCodesign) - bundleTotalSize += MachObjectFile.GetSignatureSizeEstimate((uint)bundleTotalSize, bundleName); + bundleTotalSize += MachObjectFile.GetSignatureSizeEstimate((uint)bundleTotalSize, _hostName); using (MemoryMappedFile bundleMap = MemoryMappedFile.CreateNew(null, bundleTotalSize, MemoryMappedFileAccess.ReadWrite, MemoryMappedFileOptions.None, HandleInheritability.None)) { @@ -382,7 +386,7 @@ public string GenerateBundle(IReadOnlyList fileSpecs) } if (_macosCodesign) { - endOfBundle = (ulong)machFile.AdHocSignFile(machFileReader!, bundleName, signatureBlob); + endOfBundle = (ulong)machFile.AdHocSignFile(machFileReader!, _hostName, signatureBlob); } } @@ -410,14 +414,6 @@ public string GenerateBundle(IReadOnlyList fileSpecs) /// True if the AppHost is a single-file bundle, false otherwise public static bool IsBundle(string appHostFilePath, out long bundleHeaderOffset) { - byte[] bundleSignature = { - // 32 bytes represent the bundle signature: SHA-256 for ".net core bundle" - 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 - }; - long headerOffset = 0; void FindBundleHeader() { @@ -425,10 +421,10 @@ void FindBundleHeader() { using (MemoryMappedViewAccessor accessor = memoryMappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)) { - int position = BinaryUtils.SearchInFile(accessor, bundleSignature); + int position = BinaryUtils.SearchInFile(accessor, BundleHeaderSignature); if (position == -1) { - throw new PlaceHolderNotFoundInAppHostException(bundleSignature); + throw new PlaceHolderNotFoundInAppHostException(BundleHeaderSignature); } headerOffset = accessor.ReadInt64(position - sizeof(long)); diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs index 8f1b9042b320dc..abed8b4500e5a8 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs @@ -62,10 +62,10 @@ public void Write(BinaryWriter writer) /// public static uint GetFileEntryLength(uint bundleMajorVersion, string bundleRelativePath) { - return 8u // Offset - + 8u // Size - + (bundleMajorVersion >= 6 ? 8u : 0u) // CompressedSize - + 1u // Type (FileType) + return sizeof(long) // Offset + + sizeof(long) // Size + + (bundleMajorVersion >= 6 ? sizeof(long) : 0u) // CompressedSize + + sizeof(FileType) // Type (FileType) + Bundler.GetBinaryWriterStringLength(bundleRelativePath); } diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs index 4ecad05b4d8b10..4228b498ec2dd8 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers.Text; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -68,7 +69,7 @@ private enum HeaderFlags : ulong // with path-names so that the AppHost can use it in // extraction path. public string BundleID { get; private set; } - private const int BundleIdLength = 32; + private const int BundleIdLength = 12; private SHA256 bundleHash = SHA256.Create(); public readonly uint BundleMajorVersion; // The Minor version is currently unused, and is always zero @@ -133,7 +134,11 @@ private string GenerateDeterministicId() byte[] manifestHash = bundleHash.Hash; bundleHash.Dispose(); bundleHash = null; +#if NET + string id = Base64Url.EncodeToString(manifestHash).Substring(0, BundleIdLength); +#else string id = Convert.ToBase64String(manifestHash).Substring(0, BundleIdLength).Replace('/', '_'); +#endif Debug.Assert(id.Length == BundleIdLength); return id; } @@ -175,15 +180,15 @@ public long Write(BinaryWriter writer) /// /// Calculates the length of the manifest in bytes. /// - public long GetManifestLength(uint bundleMajorVersion, IEnumerable fileSpecs) + public static long GetManifestLength(uint bundleMajorVersion, IEnumerable fileSpecs) { + const string dummyBundleId = "FakeBundleID"; + Debug.Assert(dummyBundleId.Length == BundleIdLength); // Size of the header long size = sizeof(uint) * 2 + // BundleMajorVersion + BundleMinorVersion sizeof(int) + // NumEmbeddedFiles (bundleMajorVersion >= 2 ? (sizeof(long) * 4 + sizeof(ulong)) : 0); // DepsJson and RuntimeConfigJson offsets and sizes, and Flags -#pragma warning disable CA1850 // Prefer static 'System.Security.Cryptography.SHA256.HashData' method over 'ComputeHash' - size += Bundler.GetBinaryWriterStringLength(Convert.ToBase64String(SHA256.Create().ComputeHash([])).Substring(BundleIdLength).Replace('/', '_')); -#pragma warning restore CA1850 + size += Bundler.GetBinaryWriterStringLength(dummyBundleId); // Size of each FileEntry foreach (var fileSpec in fileSpecs) { diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index da4504664efbca..ad5917adaa7c0e 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -4,7 +4,6 @@ #nullable enable using System; -using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -32,14 +31,14 @@ public CodeDirectoryBlob(SimpleBlob blob) var data = blob.Data; var cdHeader = MemoryMarshal.Read(data); - int identifierDataOffset = GetDataOffset(cdHeader._identifierOffset.ConvertFromBigEndian()); + int identifierDataOffset = GetDataOffset(cdHeader.IdentifierOffset); int nullTerminatorIndex = data.AsSpan().Slice(identifierDataOffset).IndexOf((byte)0x00); string identifier = Encoding.UTF8.GetString(data, identifierDataOffset, nullTerminatorIndex); - var specialSlotCount = cdHeader._specialSlotCount.ConvertFromBigEndian(); - var codeSlotCount = cdHeader._codeSlotCount.ConvertFromBigEndian(); + var specialSlotCount = cdHeader.SpecialSlotCount; + var codeSlotCount = cdHeader.CodeSlotCount; var hashSize = cdHeader.HashSize; - var hashesDataOffset = GetDataOffset(cdHeader._hashesOffset.ConvertFromBigEndian()); + var hashesDataOffset = GetDataOffset(cdHeader.HashesOffset); var specialSlotHashes = new byte[specialSlotCount][]; var codeHashes = new byte[codeSlotCount][]; @@ -100,18 +99,21 @@ private CodeDirectoryBlob( + CodeSlotCount * HashSize; public string Identifier => _identifier; - public CodeDirectoryFlags Flags => (CodeDirectoryFlags)((uint)_cdHeader._flags).ConvertFromBigEndian(); - public CodeDirectoryVersion Version => (CodeDirectoryVersion)((uint)_cdHeader._version).ConvertFromBigEndian(); + public CodeDirectoryFlags Flags => _cdHeader.Flags; + public CodeDirectoryVersion Version => _cdHeader.Version; public IReadOnlyList> SpecialSlotHashes => _specialSlotHashes; - public IReadOnlyList> CodeHashes => _codeHashes; - public ulong ExecutableSegmentBase => _cdHeader._execSegmentBase.ConvertFromBigEndian(); - public ulong ExecutableSegmentLimit => _cdHeader._execSegmentLimit.ConvertFromBigEndian(); - public ExecutableSegmentFlags ExecutableSegmentFlags => (ExecutableSegmentFlags)((ulong)_cdHeader._execSegmentFlags).ConvertFromBigEndian(); - private uint SpecialSlotCount => _cdHeader._specialSlotCount.ConvertFromBigEndian(); - private uint CodeSlotCount => _cdHeader._codeSlotCount.ConvertFromBigEndian(); + // Fields for test assertions only + internal IReadOnlyList> CodeHashes => _codeHashes; + internal ulong ExecutableSegmentBase => _cdHeader.ExecSegmentBase; + internal ulong ExecutableSegmentLimit => _cdHeader.ExecSegmentLimit; + internal ExecutableSegmentFlags ExecutableSegmentFlags => _cdHeader.ExecSegmentFlags; + + private uint SpecialSlotCount => _cdHeader.SpecialSlotCount; + private uint CodeSlotCount => _cdHeader.CodeSlotCount; private byte HashSize => _cdHeader.HashSize; - private uint HashesOffset => _cdHeader._hashesOffset.ConvertFromBigEndian(); + private uint HashesOffset => _cdHeader.HashesOffset; + public static CodeDirectoryBlob Create( IMachOFileReader accessor, @@ -124,9 +126,11 @@ public static CodeDirectoryBlob Create( uint pageSize = MachObjectFile.DefaultPageSize) { uint codeSlotCount = GetCodeSlotCount((uint)signatureStart, pageSize); - uint specialCodeSlotCount = (uint)(derEntitlementsBlob != null ? CodeDirectorySpecialSlot.DerEntitlements : - entitlementsBlob != null ? CodeDirectorySpecialSlot.Entitlements : - CodeDirectorySpecialSlot.Requirements); + uint specialCodeSlotCount = (uint)(derEntitlementsBlob != null + ? CodeDirectorySpecialSlot.DerEntitlements + : entitlementsBlob != null + ? CodeDirectorySpecialSlot.Entitlements + : CodeDirectorySpecialSlot.Requirements); var specialSlotHashes = new byte[specialCodeSlotCount][]; var codeHashes = new byte[codeSlotCount][]; @@ -197,29 +201,44 @@ public static CodeDirectoryBlob Create( [StructLayout(LayoutKind.Sequential)] internal struct CodeDirectoryHeader { - public CodeDirectoryVersion _version; - public CodeDirectoryFlags _flags; - public uint _hashesOffset; - public uint _identifierOffset; - public uint _specialSlotCount; - public uint _codeSlotCount; - public uint _executableLength; + private CodeDirectoryVersion _version; + private CodeDirectoryFlags _flags; + private uint _hashesOffset; + private uint _identifierOffset; + private uint _specialSlotCount; + private uint _codeSlotCount; + private uint _executableLength; public byte HashSize; public HashType HashType; public byte Platform; public byte Log2PageSize; #pragma warning disable CA1805 // Do not initialize unnecessarily - public readonly uint _reserved = 0; - public readonly uint _scatterOffset = 0; - public readonly uint _teamIdOffset = 0; - public readonly uint _reserved2 = 0; + private readonly uint _reserved = 0; + private readonly uint _scatterOffset = 0; + private readonly uint _teamIdOffset = 0; + private readonly uint _reserved2 = 0; #pragma warning restore CA1805 // Do not initialize unnecessarily - public ulong _codeLimit64; - public ulong _execSegmentBase; - public ulong _execSegmentLimit; - public ExecutableSegmentFlags _execSegmentFlags; + private ulong _codeLimit64; + private ulong _execSegmentBase; + private ulong _execSegmentLimit; + private ExecutableSegmentFlags _execSegmentFlags; public static readonly uint Size = GetSize(); + + public CodeDirectoryVersion Version => (CodeDirectoryVersion)((uint)_version).ConvertFromBigEndian(); + public CodeDirectoryFlags Flags => (CodeDirectoryFlags)((uint)_flags).ConvertFromBigEndian(); + public uint HashesOffset => _hashesOffset.ConvertFromBigEndian(); + public uint IdentifierOffset => _identifierOffset.ConvertFromBigEndian(); + public uint SpecialSlotCount => _specialSlotCount.ConvertFromBigEndian(); + public uint CodeSlotCount => _codeSlotCount.ConvertFromBigEndian(); + public ulong ExecSegmentBase => _execSegmentBase.ConvertFromBigEndian(); + public ulong ExecSegmentLimit + { + get => _execSegmentLimit.ConvertFromBigEndian(); + private set => _execSegmentLimit = value < uint.MaxValue ? 0 : value.ConvertToBigEndian(); + } + public ExecutableSegmentFlags ExecSegmentFlags => (ExecutableSegmentFlags)((ulong)_execSegmentFlags).ConvertFromBigEndian(); + private static unsafe uint GetSize() => (uint)sizeof(CodeDirectoryHeader); public CodeDirectoryHeader(string identifier, uint codeSlotCount, uint specialCodeSlotCount, uint executableLength, byte hashSize, HashType hashType, ulong signatureStart, ulong execSegmentBase, ulong execSegmentLimit, ExecutableSegmentFlags execSegmentFlags) @@ -240,6 +259,14 @@ public CodeDirectoryHeader(string identifier, uint codeSlotCount, uint specialCo _execSegmentLimit = execSegmentLimit.ConvertToBigEndian(); _execSegmentFlags = (ExecutableSegmentFlags)((ulong)execSegmentFlags).ConvertToBigEndian(); } + + public static bool AreEqual(CodeDirectoryHeader first, CodeDirectoryHeader second) + { + // Ignore the exec segment limit for equality checks, as it may differ between codesign and the managed implementation. + first.ExecSegmentLimit = 0; + second.ExecSegmentLimit = 0; + return first.Equals(second); + } } public override bool Equals(object? obj) @@ -252,10 +279,7 @@ public override bool Equals(object? obj) CodeDirectoryHeader thisHeader = _cdHeader; CodeDirectoryHeader otherHeader = other._cdHeader; - // Ignore the exec segment limit for equality checks, as it may differ - thisHeader._execSegmentLimit = 0; - otherHeader._execSegmentLimit = 0; - if (!thisHeader.Equals(otherHeader)) + if (!CodeDirectoryHeader.AreEqual(thisHeader, otherHeader)) { return false; } @@ -299,7 +323,7 @@ public int Write(IMachOFileWriter accessor, long offset) accessor.WriteUInt32BigEndian(offset + sizeof(uint), Size); accessor.Write(offset + sizeof(uint) * 2, ref _cdHeader); var identifierBytes = Encoding.UTF8.GetBytes(_identifier); - Debug.Assert(sizeof(uint) * 2 + CodeDirectoryHeader.Size == _cdHeader._identifierOffset.ConvertFromBigEndian()); + Debug.Assert(sizeof(uint) * 2 + CodeDirectoryHeader.Size == _cdHeader.IdentifierOffset); accessor.WriteExactly(offset + sizeof(uint) * 2 + CodeDirectoryHeader.Size, identifierBytes); accessor.WriteByte(offset + sizeof(uint) * 2 + CodeDirectoryHeader.Size + identifierBytes.Length, 0x00); // null terminator int specialSlotHashesOffset = (int)(offset + sizeof(uint) * 2 + CodeDirectoryHeader.Size + identifierBytes.Length + 1); diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs index d90ec264a34bb8..0d9174d3a203c8 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/EmbeddedSignatureBlob.cs @@ -159,8 +159,9 @@ public static unsafe long GetLargestSizeEstimate(uint fileSize, string identifie internal static unsafe long GetSignatureSize(uint fileSize, string identifier, EmbeddedSignatureBlob? existingSignature = null, byte? hashSize = null) { byte usedHashSize = hashSize ?? CodeDirectoryBlob.DefaultHashType.GetHashSize(); + // CodeDirectory, Requirements, CMS Wrapper are always present uint specialCodeSlotCount = (uint)CodeDirectorySpecialSlot.Requirements; - uint embeddedSignatureSubBlobCount = 3; // CodeDirectory, Requirements, CMS Wrapper are always present + uint embeddedSignatureSubBlobCount = 3; uint entitlementsBlobSize = 0; uint derEntitlementsBlobSize = 0; @@ -169,13 +170,16 @@ internal static unsafe long GetSignatureSize(uint fileSize, string identifier, E // We preserve Entitlements and DER Entitlements blobs if they exist in the old signature. // We need to update the relevant sizes and counts to reflect this. specialCodeSlotCount = Math.Max((uint)CodeDirectorySpecialSlot.Requirements, existingSignature.GetSpecialSlotHashCount()); - entitlementsBlobSize = existingSignature.EntitlementsBlob?.Size ?? 0; - derEntitlementsBlobSize = existingSignature.DerEntitlementsBlob?.Size ?? 0; - // Requirements and CMSWrapper blobs are always overwritten as emtpy, but present. if (existingSignature.EntitlementsBlob is not null) + { + entitlementsBlobSize = existingSignature.EntitlementsBlob.Size; embeddedSignatureSubBlobCount += 1; + } if (existingSignature.DerEntitlementsBlob is not null) + { + derEntitlementsBlobSize = existingSignature.DerEntitlementsBlob.Size; embeddedSignatureSubBlobCount += 1; + } } // Calculate the size of the new signature diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs index 6918b3c510dd7d..ecb7e3d96165c1 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/MachObjectFile.cs @@ -230,26 +230,25 @@ public static bool IsMachOImage(string filePath) public bool RemoveCodeSignatureIfPresent(IMachOFileWriter file, out long? newLength) { newLength = null; - MachObjectFile machFile = this; - if (machFile._codeSignatureLoadCommand.Command.IsDefault) + if (_codeSignatureLoadCommand.Command.IsDefault) { - Debug.Assert(machFile._codeSignatureBlob is null); + Debug.Assert(_codeSignatureBlob is null); return false; } LinkEditLoadCommand clearedCommand = default; file.Write(_codeSignatureLoadCommand.FileOffset, ref clearedCommand); - machFile._header.NumberOfCommands -= 1; - machFile._header.SizeOfCommands -= (uint)sizeof(LinkEditLoadCommand); - machFile._linkEditSegment64.Command.SetFileSize( - machFile._linkEditSegment64.Command.GetFileSize(machFile._header) - - machFile._codeSignatureLoadCommand.Command.GetFileSize(machFile._header), - machFile._header); - newLength = machFile.GetFileSize(); - machFile._codeSignatureLoadCommand = default; - machFile._codeSignatureBlob = null; - machFile.Validate(); - machFile.Write(file); + _header.NumberOfCommands -= 1; + _header.SizeOfCommands -= (uint)sizeof(LinkEditLoadCommand); + _linkEditSegment64.Command.SetFileSize( + _linkEditSegment64.Command.GetFileSize(_header) + - _codeSignatureLoadCommand.Command.GetFileSize(_header), + _header); + newLength = GetFileSize(); + _codeSignatureLoadCommand = default; + _codeSignatureBlob = null; + Validate(); + Write(file); return true; } diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index 76b95711405f32..523b88b0371053 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -1,4 +1,5 @@ - +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; @@ -168,7 +169,8 @@ public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifa Assert.NotNull(embeddedSignatureBlob); } - var (exitcode, stderr) = Codesign.Run("--display --verbose=4", filePath); + var (exitcode, stderr) = Codesign.Run("--display --verbose=6", filePath); +#pragma warning disable CS0162 if (exitcode != 0) { output.WriteLine($"Codesign command failed with exit code {exitcode}: {stderr}"); @@ -191,8 +193,23 @@ static void AssertEqual(CodesignOutputInfo csi, EmbeddedSignatureBlob b) Assert.True(csi.ExecutableSegmentLimit == b.CodeDirectoryBlob.ExecutableSegmentLimit, "ExecutableSegmentLimit do not match"); Assert.True(csi.ExecutableSegmentFlags == b.CodeDirectoryBlob.ExecutableSegmentFlags, "ExecutableSegmentFlags do not match"); - // Assert.True(csi.SpecialSlotHashes.Zip(b.CodeDirectoryBlob.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); - // Assert.True(csi.CodeHashes.Zip(b.CodeDirectoryBlob.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); + AssertEqual(csi.SpecialSlotHashes, b.CodeDirectoryBlob.SpecialSlotHashes); + AssertEqual(csi.CodeHashes, b.CodeDirectoryBlob.CodeHashes); + + static void AssertEqual(byte[][] hashes1, IReadOnlyList> hashes2) + { + Assert.Equal(hashes1.Length, hashes2.Count); + + for (int i = 0; i < hashes1.Length; i++) + { + Assert.Equal(hashes1[i].Length, hashes2[i].Count); + + for(int j = 0; j < hashes1[i].Length; j++) + { + Assert.Equal(hashes1[i][j], hashes2[i][j]); + } + } + } } /// diff --git a/src/installer/tests/TestUtils/Codesign.cs b/src/installer/tests/TestUtils/Codesign.cs index e31f8bc706ad76..15ad429b2f8b5b 100644 --- a/src/installer/tests/TestUtils/Codesign.cs +++ b/src/installer/tests/TestUtils/Codesign.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using System.Threading.Tasks; namespace Microsoft.DotNet.CoreSetup { @@ -30,8 +31,11 @@ public static (int ExitCode, string StdErr) Run(string args, string binaryPath) { if (p == null) return (-1, "Failed to start process"); - p.WaitForExit(); - return (p.ExitCode, p.StandardError.ReadToEnd()); + + var stderrRead = p.StandardError.ReadToEndAsync(); + var processWait = p.WaitForExitAsync(); + Task.WaitAll(stderrRead, processWait); + return (p.ExitCode, stderrRead.Result); } } } From c668a0327ed2446a2ce4f069c0d9f817fa5685b6 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:55:48 -0700 Subject: [PATCH 12/16] Field -> Property --- .../MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index ad5917adaa7c0e..fabe3859c2aa26 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -103,7 +103,7 @@ private CodeDirectoryBlob( public CodeDirectoryVersion Version => _cdHeader.Version; public IReadOnlyList> SpecialSlotHashes => _specialSlotHashes; - // Fields for test assertions only + // Properties for test assertions only internal IReadOnlyList> CodeHashes => _codeHashes; internal ulong ExecutableSegmentBase => _cdHeader.ExecSegmentBase; internal ulong ExecutableSegmentLimit => _cdHeader.ExecSegmentLimit; From 50eb0298387b5b009f41ff44f0e59b115fadf000 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:58:49 -0700 Subject: [PATCH 13/16] Use ReadOnlySpan for header placeholder data --- .../managed/Microsoft.NET.HostModel/Bundle/Bundler.cs | 6 +++--- .../MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index f455e828f719bd..dfc1ea9bec6a7b 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -234,7 +234,7 @@ private FileType InferType(FileSpec fileSpec) return FileType.Unknown; } - public static ImmutableArray BundleHeaderPlaceholder = [ + internal static ReadOnlySpan BundleHeaderPlaceholder => [ // 8 bytes represent the bundle header-offset // Zero for non-bundle apphosts (default). 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -245,7 +245,7 @@ private FileType InferType(FileSpec fileSpec) 0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae ]; - public static ReadOnlySpan BundleHeaderSignature => BundleHeaderPlaceholder.AsSpan().Slice(8); + public static ReadOnlySpan BundleHeaderSignature => BundleHeaderPlaceholder.Slice(8); /// /// Generate a bundle, given the specification of embedded files @@ -374,7 +374,7 @@ public string GenerateBundle(IReadOnlyList fileSpecs) ulong endOfBundle = (ulong)bundleStream.Position; Debug.Assert((long)endOfBundle == endOfBundledFiles + bundleManifestLength, $"Bundle manifest is unexpected size. Expected {bundleManifestLength}, but got {(long)endOfBundle - endOfBundledFiles}"); BinaryUtils.SearchAndReplace(accessor, - BundleHeaderPlaceholder.AsSpan(), + BundleHeaderPlaceholder, BitConverter.GetBytes(headerOffset), pad0s: false); if (_target.IsOSX && machFile is not null) diff --git a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs index fabe3859c2aa26..b56c60b02e70eb 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/MachO/BinaryFormat/Blobs/CodeDirectoryBlob.cs @@ -114,7 +114,6 @@ private CodeDirectoryBlob( private byte HashSize => _cdHeader.HashSize; private uint HashesOffset => _cdHeader.HashesOffset; - public static CodeDirectoryBlob Create( IMachOFileReader accessor, long signatureStart, From b7f05cd78da27007f3dc378696697c0268251a58 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:59:38 -0700 Subject: [PATCH 14/16] Make property internal --- src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index dfc1ea9bec6a7b..0952331b076df1 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -245,7 +245,7 @@ private FileType InferType(FileSpec fileSpec) 0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae ]; - public static ReadOnlySpan BundleHeaderSignature => BundleHeaderPlaceholder.Slice(8); + internal static ReadOnlySpan BundleHeaderSignature => BundleHeaderPlaceholder.Slice(8); /// /// Generate a bundle, given the specification of embedded files From 2a33df4ce26149f8d228b51d418f4051d646f1c4 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:12:41 -0700 Subject: [PATCH 15/16] PR Feedback: Rename ReBundle, move tests to HostModel.Tests, use helper for getting inode --- .../tests/AppHost.Bundle.Tests/AppLaunch.cs | 31 ---- .../MachOHostSigningTests.cs | 37 ---- .../AppHost/CreateAppHost.cs | 20 +- .../MachObjectSigning/CodesignOutputInfo.cs | 172 ++++++++++++++++++ .../MachObjectSigning/MachObjectTests.cs | 164 +---------------- .../MachObjectSigning/SigningTests.cs | 91 ++++++++- src/installer/tests/TestUtils/Inode.cs | 28 +++ .../tests/TestUtils/SingleFileTestApp.cs | 2 +- 8 files changed, 293 insertions(+), 252 deletions(-) create mode 100644 src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/CodesignOutputInfo.cs create mode 100644 src/installer/tests/TestUtils/Inode.cs diff --git a/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs b/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs index 46a84dd32bbf95..1dbd8a40f87532 100644 --- a/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs +++ b/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs @@ -82,37 +82,6 @@ private void RunApp(bool selfContained) } } - [Fact] - [PlatformSpecific(TestPlatforms.OSX)] - private void OverwritingExistingBundleClearsMacOsSignatureCache() - { - // Bundle to a single-file and ensure it is signed - string singleFile = sharedTestState.SelfContainedApp.Bundle(); - Assert.True(Codesign.Run("-v", singleFile).ExitCode == 0); - var firstls = Command.Create("/bin/ls", "-li", singleFile) - .CaptureStdErr() - .CaptureStdOut() - .Execute(); - firstls.Should().Pass(); - var firstInode = firstls.StdOut.Split(' ')[0]; - - // Rebundle to the same location. - // Bundler should create a new inode for the bundle which should clear the MacOS signature cache. - string oldFile = singleFile; - string dir = Path.GetDirectoryName(singleFile); - singleFile = sharedTestState.SelfContainedApp.ReBundle(dir, BundleOptions.BundleAllContent, out var _, new Version(5, 0)); - Assert.True(singleFile == oldFile, "Rebundled app should have a different path than the original single-file app."); - var secondls = Command.Create("/bin/ls", "-li", singleFile) - .CaptureStdErr() - .CaptureStdOut() - .Execute(); - secondls.Should().Pass(); - var secondInode = secondls.StdOut.Split(' ')[0]; - Assert.False(firstInode == secondInode, "not a different inode after rebundle"); - // Ensure the MacOS signature cache is cleared - Assert.True(Codesign.Run("-v", singleFile).ExitCode == 0); - } - [ConditionalTheory(typeof(Binaries.CetCompat), nameof(Binaries.CetCompat.IsSupported))] [InlineData(true)] [InlineData(false)] diff --git a/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs b/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs index f894263d52678f..70bc3b86a51eff 100644 --- a/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs +++ b/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs @@ -33,42 +33,5 @@ public void SignedAppHostRuns() .Execute(); executedCommand.Should().ExitWith(Constants.ErrorCode.AppHostExeNotBoundFailure); } - - [Fact] - [PlatformSpecific(TestPlatforms.OSX)] - public void SigningAppHostPreservesEntitlements() - { - using var testDirectory = TestArtifact.Create(nameof(SignedAppHostRuns)); - var testAppHostPath = Path.Combine(testDirectory.Location, Path.GetFileName(Binaries.AppHost.FilePath)); - File.Copy(Binaries.AppHost.FilePath, testAppHostPath); - long preRemovalSize = new FileInfo(testAppHostPath).Length; - string signedHostPath = testAppHostPath + ".signed"; - - HostWriter.CreateAppHost(testAppHostPath, signedHostPath, testAppHostPath + ".dll", enableMacOSCodeSign: true); - - SigningTests.HasDerEntitlementsBlob(testAppHostPath).Should().BeTrue(); - SigningTests.HasDerEntitlementsBlob(signedHostPath).Should().BeTrue(); - SigningTests.HasEntitlementsBlob(testAppHostPath).Should().BeTrue(); - SigningTests.HasEntitlementsBlob(signedHostPath).Should().BeTrue(); - } - - [Fact] - [PlatformSpecific(TestPlatforms.OSX)] - public void BundledAppHostHasEntitlements() - { - using var testDirectory = TestArtifact.Create(nameof(BundledAppHostHasEntitlements)); - var testAppHostPath = Path.Combine(testDirectory.Location, Path.GetFileName(Binaries.SingleFileHost.FilePath)); - File.Copy(Binaries.SingleFileHost.FilePath, testAppHostPath); - long preRemovalSize = new FileInfo(testAppHostPath).Length; - string signedHostPath = testAppHostPath + ".signed"; - - HostWriter.CreateAppHost(testAppHostPath, signedHostPath, testAppHostPath + ".dll", enableMacOSCodeSign: true); - var bundlePath = new Bundler(Path.GetFileName(signedHostPath), testAppHostPath + ".bundle").GenerateBundle([new(signedHostPath, Path.GetFileName(signedHostPath))]); - - SigningTests.HasEntitlementsBlob(testAppHostPath).Should().BeTrue(); - SigningTests.HasEntitlementsBlob(bundlePath).Should().BeTrue(); - SigningTests.HasDerEntitlementsBlob(testAppHostPath).Should().BeTrue(); - SigningTests.HasDerEntitlementsBlob(bundlePath).Should().BeTrue(); - } } } diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs index 24958b3de76749..e128cd7b6cbec5 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs @@ -305,15 +305,10 @@ public void SigningExistingAppHostCreatesNewInode(string subdir) appBinaryFilePath, windowsGraphicalUserInterface: false, enableMacOSCodeSign: true); - var firstls = Command.Create("/bin/ls", "-li", destinationFilePath) - .CaptureStdErr() - .CaptureStdOut() - .Execute(); - firstls.Should().Pass(); - var firstInode = firstls.StdOut.Split(' ')[0]; + var firstInode = Inode.GetInode(destinationFilePath); // Validate that there is a signature present in the apphost Mach file - SigningTests.IsSigned(destinationFilePath).Should().BeTrue(); + Assert.True(SigningTests.IsSigned(destinationFilePath)); HostWriter.CreateAppHost( sourceAppHostMock, @@ -321,17 +316,12 @@ public void SigningExistingAppHostCreatesNewInode(string subdir) appBinaryFilePath, windowsGraphicalUserInterface: false, enableMacOSCodeSign: true); + var secondInode = Inode.GetInode(destinationFilePath); - var secondls = Command.Create("/bin/ls", "-li", destinationFilePath) - .CaptureStdErr() - .CaptureStdOut() - .Execute(); - secondls.Should().Pass(); - var secondInode = secondls.StdOut.Split(' ')[0]; // Ensure the MacOS signature cache is cleared - Assert.False(firstInode == secondInode, "not a different inode after rebundle"); + Assert.False(firstInode == secondInode, "not a different inode after re-bundling"); - SigningTests.IsSigned(destinationFilePath).Should().BeTrue(); + Assert.True(SigningTests.IsSigned(destinationFilePath)); } } diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/CodesignOutputInfo.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/CodesignOutputInfo.cs new file mode 100644 index 00000000000000..1179fab0d79039 --- /dev/null +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/CodesignOutputInfo.cs @@ -0,0 +1,172 @@ + + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.NET.HostModel.MachO; +using Xunit; + +namespace Microsoft.NET.HostModel.Tests; + +/// +/// Info related to the code signature that can be extracted from the output of the `codesign` command. +/// +internal sealed record CodesignOutputInfo +{ + public string Identifier { get; init; } + public CodeDirectoryFlags CodeDirectoryFlags { get; init; } + public CodeDirectoryVersion CodeDirectoryVersion { get; init; } + public ulong ExecutableSegmentBase { get; init; } + public ulong ExecutableSegmentLimit { get; init; } + public ExecutableSegmentFlags ExecutableSegmentFlags { get; init; } + public byte[][] SpecialSlotHashes { get; init; } + public byte[][] CodeHashes { get; init; } + + public bool Equals(CodesignOutputInfo? obj) + { + if (obj is not CodesignOutputInfo other) + return false; + + return Identifier == other.Identifier && + CodeDirectoryFlags == other.CodeDirectoryFlags && + CodeDirectoryVersion == other.CodeDirectoryVersion && + ExecutableSegmentBase == other.ExecutableSegmentBase && + ExecutableSegmentLimit == other.ExecutableSegmentLimit && + ExecutableSegmentFlags == other.ExecutableSegmentFlags && + SpecialSlotHashes.Length == other.SpecialSlotHashes.Length && + CodeHashes.Length == other.CodeHashes.Length && + SpecialSlotHashes.Zip(other.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x) && + CodeHashes.Zip(other.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x); + } + + public override string ToString() + { + return $$""" + Identifier: {{Identifier}}, + CodeDirectoryFlags: {{CodeDirectoryFlags}}, + CodeDirectoryVersion: {{CodeDirectoryVersion}}, + ExecutableSegmentBase: {{ExecutableSegmentBase}}, + ExecutableSegmentLimit: {{ExecutableSegmentLimit}}, + ExecutableSegmentFlags: {{ExecutableSegmentFlags}}, + SpecialSlotHashes: [{{string.Join(", ", SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}], + CodeHashes: [{{string.Join(", ", CodeHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}] + """; + } + public override int GetHashCode() + { + return HashCode.Combine(Identifier, CodeDirectoryFlags, CodeDirectoryVersion, ExecutableSegmentLimit, ExecutableSegmentFlags, SpecialSlotHashes, CodeHashes); + } + + /// + /// Parses the output of the `codesign` command to extract codesign information. + /// + public static CodesignOutputInfo ParseFromCodeSignOutput(string output) + { + var splitOptions = StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries; + var lines = output.Split(new[] { '\n', '\r' }, splitOptions); + var Identifier = lines[1].Split('=', splitOptions)[1]; + var cdInfo = lines[3].Split(' '); + var CodeDirectoryVersion = (CodeDirectoryVersion)Convert.ToUInt32(cdInfo[1].Split('=', splitOptions)[1], 16); + var CodeDirectoryFlags = (CodeDirectoryFlags)Convert.ToUInt32(cdInfo[3].Split(['=', '('], splitOptions)[1].TrimStart("0x").ToString(), 16); + Assert.True(lines[13].StartsWith("Executable Segment base="), "Expected 'Executable Segment base=' at line 13"); + Assert.True(lines[14].StartsWith("Executable Segment limit="), "Expected 'Executable Segment limit=' at line 14"); + Assert.True(lines[15].StartsWith("Executable Segment flags="), "Expected 'Executable Segment flags=' at line 15"); + var ExecutableSegmentBase = ulong.Parse(lines[13].Split('=', splitOptions)[1]); + var ExecutableSegmentLimit = ulong.Parse(lines[14].Split('=', splitOptions)[1]); + var ExecutableSegmentFlags = (ExecutableSegmentFlags)Convert.ToUInt64(lines[15].Split('=', splitOptions)[1].TrimStart("0x").ToString(), 16); + Assert.True(lines[16].StartsWith("Page size=4096"), "Expected 'Page size=4096' at line 16"); + var (SpecialSlotHashes, CodeHashes) = ExtractHashes(lines.Skip(17)); + + return new CodesignOutputInfo + { + Identifier = Identifier, + CodeDirectoryFlags = CodeDirectoryFlags, + CodeDirectoryVersion = CodeDirectoryVersion, + ExecutableSegmentBase = ExecutableSegmentBase, + ExecutableSegmentLimit = ExecutableSegmentLimit, + ExecutableSegmentFlags = ExecutableSegmentFlags, + SpecialSlotHashes = SpecialSlotHashes, + CodeHashes = CodeHashes, + }; + + static (byte[][] SpecialSlotHashes, byte[][] CodeHashes) ExtractHashes(IEnumerable lines) + { + List specialSlotHashes = []; + List codeHashes = []; + foreach (var line in lines) + { + if (line[0] is not ('-' or '0' or '1' or '2' or '3' or '4' or '5' or '6' or '7' or '8' or '9')) + break; + var hash = line.Split('=')[1].Trim(); + var index = int.Parse(line.Split('=')[0].Trim()); + if (index < 0) + { + // specialSlot + specialSlotHashes.Add(ParseByteArray(hash)); + } + else + { + // codeHashes + codeHashes.Add(ParseByteArray(hash)); + } + } + return (specialSlotHashes.ToArray(), codeHashes.ToArray()); + } + static byte[] ParseByteArray(string hex) + { + if (hex.Length % 2 != 0) + throw new ArgumentException("Hex string must have an even length."); + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < hex.Length; i += 2) + { + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + } + return bytes; + } + + } + + public const string SampleCodesignOutput = """ + Executable=/Users/jacksonschuster/source/runtime3/artifacts/bin/osx-x64.Debug/corehost/singlefilehost + Identifier=singlefilehost-5555494409d4df688bf436b291061028f736b11c + Format=Mach-O thin (x86_64) + CodeDirectory v=20400 size=89264 flags=0x2(adhoc) hashes=2778+7 location=embedded + VersionPlatform=1 + VersionMin=786432 + VersionSDK=984064 + Hash type=sha256 size=32 + CandidateCDHash sha256=6fee638e9fe544a66b0acf9489ebc59e073b3e39 + CandidateCDHashFull sha256=6fee638e9fe544a66b0acf9489ebc59e073b3e39082903247c5413f555ec2857 + Hash choices=sha256 + CMSDigest=6fee638e9fe544a66b0acf9489ebc59e073b3e39082903247c5413f555ec2857 + CMSDigestType=2 + Executable Segment base=0 + Executable Segment limit=8949760 + Executable Segment flags=0x1 + Page size=4096 + -7=4d8d4b9e4116e8edd996176b5553463acb64287bb635e7f141155529e20457bc + -6=0000000000000000000000000000000000000000000000000000000000000000 + -5=cca8afe72425463c13b813da9ae468ae3b5fe20fe5fe1d3f34302ba2f15722f2 + -4=0000000000000000000000000000000000000000000000000000000000000000 + -3=0000000000000000000000000000000000000000000000000000000000000000 + -2=987920904eab650e75788c054aa0b0524e6a80bfc71aa32df8d237a61743f986 + -1=0000000000000000000000000000000000000000000000000000000000000000 + 0=20042993665611bf5d01d35a46092c2d43a07883f31247a03b5600c301f5c039 + 1=a97fad07cc9d6eabad27a77e32b69c3da59372fa7987a13c2b8d23f378380476 + 2=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 + 3=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 + 4=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 + 5=b3d230340aa5ed09c788c39081c207a7430b83d22c9489d84d4ede3ed320f47b + 6=825b7aa16170a9b739a4689ba8878391bcae87efd63e3b174738c382020031c1 + 7=e360159ee0adaeba5ac5f562c45ec551dbe8b73fbc858beca298610312df33b3 + 8=20585ef0bc0287c5b7a9b54f2669704cdc31cea7d7b1702b336fcf93a9f01ca2 + 9=414ae6563e5881b215a08bb33fc539fb0c90c3a5532f6e15a726ed6cdc255550 + 10=b672b667eb31b48d027bd5f1cf75bad5a8552b4d6b649cbdae35699152fb8a1b + CDHash=6fee638e9fe544a66b0acf9489ebc59e073b3e39 + Signature=adhoc + Info.plist=not bound + TeamIdentifier=not set + Sealed Resources=none + Internal requirements count=0 size=12 + """; +} diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs index f9ed77d368d0e2..5211f92f9252ea 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -110,7 +110,7 @@ public static Object[][] GetTestFilePaths(string testArtifactName) [Fact] public void CanParseCodesignOutput() { - var parsed = CodesignOutputInfo.ParseFromCodeSignOutput(SampleCodesignOutput); + var parsed = CodesignOutputInfo.ParseFromCodeSignOutput(CodesignOutputInfo.SampleCodesignOutput); Assert.NotNull(parsed); output.WriteLine(parsed.ToString()); var expected = new CodesignOutputInfo @@ -190,166 +190,4 @@ static void AssertEqual(CodesignOutputInfo csi, EmbeddedSignatureBlob b) Assert.True(csi.SpecialSlotHashes.Zip(b.CodeDirectoryBlob.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); Assert.True(csi.CodeHashes.Zip(b.CodeDirectoryBlob.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x)); } - - /// - /// Info related to the code signature that can be extracted from the output of the `codesign` command. - /// - internal sealed record CodesignOutputInfo - { - public string Identifier { get; init; } - public CodeDirectoryFlags CodeDirectoryFlags { get; init; } - public CodeDirectoryVersion CodeDirectoryVersion { get; init; } - public ulong ExecutableSegmentBase { get; init; } - public ulong ExecutableSegmentLimit { get; init; } - public ExecutableSegmentFlags ExecutableSegmentFlags { get; init; } - public byte[][] SpecialSlotHashes { get; init; } - public byte[][] CodeHashes { get; init; } - - public bool Equals(CodesignOutputInfo? obj) - { - if (obj is not CodesignOutputInfo other) - return false; - - return Identifier == other.Identifier && - CodeDirectoryFlags == other.CodeDirectoryFlags && - CodeDirectoryVersion == other.CodeDirectoryVersion && - ExecutableSegmentBase == other.ExecutableSegmentBase && - ExecutableSegmentLimit == other.ExecutableSegmentLimit && - ExecutableSegmentFlags == other.ExecutableSegmentFlags && - SpecialSlotHashes.Length == other.SpecialSlotHashes.Length && - CodeHashes.Length == other.CodeHashes.Length && - SpecialSlotHashes.Zip(other.SpecialSlotHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x) && - CodeHashes.Zip(other.CodeHashes, static (a, b) => a.SequenceEqual(b)).All(static x => x); - } - - public override string ToString() - { - return $$""" - Identifier: {{Identifier}}, - CodeDirectoryFlags: {{CodeDirectoryFlags}}, - CodeDirectoryVersion: {{CodeDirectoryVersion}}, - ExecutableSegmentBase: {{ExecutableSegmentBase}}, - ExecutableSegmentLimit: {{ExecutableSegmentLimit}}, - ExecutableSegmentFlags: {{ExecutableSegmentFlags}}, - SpecialSlotHashes: [{{string.Join(", ", SpecialSlotHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}], - CodeHashes: [{{string.Join(", ", CodeHashes.Select(h => BitConverter.ToString(h).Replace("-", "")))}}] - """; - } - public override int GetHashCode() - { - return HashCode.Combine(Identifier, CodeDirectoryFlags, CodeDirectoryVersion, ExecutableSegmentLimit, ExecutableSegmentFlags, SpecialSlotHashes, CodeHashes); - } - - /// - /// Parses the output of the `codesign` command to extract codesign information. - /// - public static CodesignOutputInfo ParseFromCodeSignOutput(string output) - { - var splitOptions = StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries; - var lines = output.Split(new[] { '\n', '\r' }, splitOptions); - var Identifier = lines[1].Split('=', splitOptions)[1]; - var cdInfo = lines[3].Split(' '); - var CodeDirectoryVersion = (CodeDirectoryVersion)Convert.ToUInt32(cdInfo[1].Split('=', splitOptions)[1], 16); - var CodeDirectoryFlags = (CodeDirectoryFlags)Convert.ToUInt32(cdInfo[3].Split(['=', '('], splitOptions)[1].TrimStart("0x").ToString(), 16); - Assert.True(lines[13].StartsWith("Executable Segment base="), "Expected 'Executable Segment base=' at line 13"); - Assert.True(lines[14].StartsWith("Executable Segment limit="), "Expected 'Executable Segment limit=' at line 14"); - Assert.True(lines[15].StartsWith("Executable Segment flags="), "Expected 'Executable Segment flags=' at line 15"); - var ExecutableSegmentBase = ulong.Parse(lines[13].Split('=', splitOptions)[1]); - var ExecutableSegmentLimit = ulong.Parse(lines[14].Split('=', splitOptions)[1]); - var ExecutableSegmentFlags = (ExecutableSegmentFlags)Convert.ToUInt64(lines[15].Split('=', splitOptions)[1].TrimStart("0x").ToString(), 16); - Assert.True(lines[16].StartsWith("Page size=4096"), "Expected 'Page size=4096' at line 16"); - var (SpecialSlotHashes, CodeHashes) = ExtractHashes(lines.Skip(17)); - - return new CodesignOutputInfo - { - Identifier = Identifier, - CodeDirectoryFlags = CodeDirectoryFlags, - CodeDirectoryVersion = CodeDirectoryVersion, - ExecutableSegmentBase = ExecutableSegmentBase, - ExecutableSegmentLimit = ExecutableSegmentLimit, - ExecutableSegmentFlags = ExecutableSegmentFlags, - SpecialSlotHashes = SpecialSlotHashes, - CodeHashes = CodeHashes, - }; - - static (byte[][] SpecialSlotHashes, byte[][] CodeHashes) ExtractHashes(IEnumerable lines) - { - List specialSlotHashes = []; - List codeHashes = []; - foreach (var line in lines) - { - if (line[0] is not ('-' or '0' or '1' or '2' or '3' or '4' or '5' or '6' or '7' or '8' or '9')) - break; - var hash = line.Split('=')[1].Trim(); - var index = int.Parse(line.Split('=')[0].Trim()); - if (index < 0) - { - // specialSlot - specialSlotHashes.Add(ParseByteArray(hash)); - } - else - { - // codeHashes - codeHashes.Add(ParseByteArray(hash)); - } - } - return (specialSlotHashes.ToArray(), codeHashes.ToArray()); - } - static byte[] ParseByteArray(string hex) - { - if (hex.Length % 2 != 0) - throw new ArgumentException("Hex string must have an even length."); - byte[] bytes = new byte[hex.Length / 2]; - for (int i = 0; i < hex.Length; i += 2) - { - bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); - } - return bytes; - } - } - } - - string SampleCodesignOutput = """ - Executable=/Users/jacksonschuster/source/runtime3/artifacts/bin/osx-x64.Debug/corehost/singlefilehost - Identifier=singlefilehost-5555494409d4df688bf436b291061028f736b11c - Format=Mach-O thin (x86_64) - CodeDirectory v=20400 size=89264 flags=0x2(adhoc) hashes=2778+7 location=embedded - VersionPlatform=1 - VersionMin=786432 - VersionSDK=984064 - Hash type=sha256 size=32 - CandidateCDHash sha256=6fee638e9fe544a66b0acf9489ebc59e073b3e39 - CandidateCDHashFull sha256=6fee638e9fe544a66b0acf9489ebc59e073b3e39082903247c5413f555ec2857 - Hash choices=sha256 - CMSDigest=6fee638e9fe544a66b0acf9489ebc59e073b3e39082903247c5413f555ec2857 - CMSDigestType=2 - Executable Segment base=0 - Executable Segment limit=8949760 - Executable Segment flags=0x1 - Page size=4096 - -7=4d8d4b9e4116e8edd996176b5553463acb64287bb635e7f141155529e20457bc - -6=0000000000000000000000000000000000000000000000000000000000000000 - -5=cca8afe72425463c13b813da9ae468ae3b5fe20fe5fe1d3f34302ba2f15722f2 - -4=0000000000000000000000000000000000000000000000000000000000000000 - -3=0000000000000000000000000000000000000000000000000000000000000000 - -2=987920904eab650e75788c054aa0b0524e6a80bfc71aa32df8d237a61743f986 - -1=0000000000000000000000000000000000000000000000000000000000000000 - 0=20042993665611bf5d01d35a46092c2d43a07883f31247a03b5600c301f5c039 - 1=a97fad07cc9d6eabad27a77e32b69c3da59372fa7987a13c2b8d23f378380476 - 2=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 - 3=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 - 4=ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 - 5=b3d230340aa5ed09c788c39081c207a7430b83d22c9489d84d4ede3ed320f47b - 6=825b7aa16170a9b739a4689ba8878391bcae87efd63e3b174738c382020031c1 - 7=e360159ee0adaeba5ac5f562c45ec551dbe8b73fbc858beca298610312df33b3 - 8=20585ef0bc0287c5b7a9b54f2669704cdc31cea7d7b1702b336fcf93a9f01ca2 - 9=414ae6563e5881b215a08bb33fc539fb0c90c3a5532f6e15a726ed6cdc255550 - 10=b672b667eb31b48d027bd5f1cf75bad5a8552b4d6b649cbdae35699152fb8a1b - CDHash=6fee638e9fe544a66b0acf9489ebc59e073b3e39 - Signature=adhoc - Info.plist=not bound - TeamIdentifier=not set - Sealed Resources=none - Internal requirements count=0 size=12 - """; } diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs index a54fd3056d4e01..452fe8a8a1b32e 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs @@ -19,11 +19,19 @@ using System.Collections.Generic; using Microsoft.DotNet.Cli.Build.Framework; using System.Security.AccessControl; +using Microsoft.NET.HostModel.Bundle; namespace Microsoft.NET.HostModel.MachO.CodeSign.Tests { - public class SigningTests + public class SigningTests :IClassFixture { + private SharedTestState sharedTestState; + + public SigningTests(SharedTestState fixture) + { + sharedTestState = fixture; + } + [Theory] [MemberData(nameof(GetTestFilePaths), nameof(CanSignMachObject))] public void CanSignMachObject(string filePath, TestArtifact _) @@ -82,13 +90,14 @@ void MatchesCodesignOutput(string filePath, TestArtifact _) // Codesigned file File.Copy(filePath, codesignFilePath); Assert.True(Codesign.IsAvailable, "Could not find codesign tool"); - Codesign.Run("-s - -f --preserve-metadata=entitlements -i" + fileName, codesignFilePath).ExitCode.Should().Be(0, $"'codesign -s - {codesignFilePath}' failed!"); + var (exitCode, stdErr) = Codesign.Run("-s - -f --preserve-metadata=entitlements -i" + fileName, codesignFilePath); + Assert.Equal(0, exitCode); // Managed signed file AdHocSignFile(originalFilePath, managedSignedPath, fileName); - var check = Codesign.Run("-v", managedSignedPath); - check.ExitCode.Should().Be(0, check.StdErr, $"Failed to sign a copy of '{filePath}'"); + (exitCode, stdErr) = Codesign.Run("-v", managedSignedPath); + Assert.Equal(0, exitCode); AssertMachFilesAreEquivalent(codesignFilePath, managedSignedPath, fileName); } @@ -109,7 +118,7 @@ void SignedMachOExecutableRuns() File.SetUnixFileMode(signedPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); var result = Command.Create(signedPath).CaptureStdErr().CaptureStdOut().Execute(); - result.ExitCode.Should().Be(0, result.StdErr); + Assert.Equal(0, result.ExitCode); } } @@ -131,6 +140,78 @@ void ReadSignedMachIsTheSameAsReadAndResigned(string filePath, TestArtifact _) } } + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public void SigningAppHostPreservesEntitlements() + { + using var testDirectory = TestArtifact.Create(nameof(SigningAppHostPreservesEntitlements)); + var testAppHostPath = Path.Combine(testDirectory.Location, Path.GetFileName(Binaries.AppHost.FilePath)); + File.Copy(Binaries.AppHost.FilePath, testAppHostPath); + string signedHostPath = testAppHostPath + ".signed"; + + HostWriter.CreateAppHost(testAppHostPath, signedHostPath, testAppHostPath + ".dll", enableMacOSCodeSign: true); + + Assert.True(SigningTests.HasEntitlementsBlob(testAppHostPath)); + Assert.True(SigningTests.HasEntitlementsBlob(signedHostPath)); + Assert.True(SigningTests.HasDerEntitlementsBlob(testAppHostPath)); + Assert.True(SigningTests.HasDerEntitlementsBlob(signedHostPath)); + } + + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public void BundledAppHostHasEntitlements() + { + using var testDirectory = TestArtifact.Create(nameof(BundledAppHostHasEntitlements)); + var testAppHostPath = Path.Combine(testDirectory.Location, Path.GetFileName(Binaries.SingleFileHost.FilePath)); + File.Copy(Binaries.SingleFileHost.FilePath, testAppHostPath); + string signedHostPath = testAppHostPath + ".signed"; + + HostWriter.CreateAppHost(testAppHostPath, signedHostPath, testAppHostPath + ".dll", enableMacOSCodeSign: true); + var bundlePath = new Bundler(Path.GetFileName(signedHostPath), testAppHostPath + ".bundle").GenerateBundle([new(signedHostPath, Path.GetFileName(signedHostPath))]); + + Assert.True(SigningTests.HasEntitlementsBlob(testAppHostPath)); + Assert.True(SigningTests.HasEntitlementsBlob(bundlePath)); + Assert.True(SigningTests.HasDerEntitlementsBlob(testAppHostPath)); + Assert.True(SigningTests.HasDerEntitlementsBlob(bundlePath)); + } + + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public void OverwritingExistingBundleClearsMacOsSignatureCache() + { + // Bundle to a single-file and ensure it is signed + string singleFile = sharedTestState.SelfContainedApp.Bundle(); + Assert.True(SigningTests.IsSigned(singleFile)); + + var firstInode = Inode.GetInode(singleFile); + + // Rebundle to the same location. + // Bundler should create a new inode for the bundle which should clear the MacOS signature cache. + string oldFile = singleFile; + string dir = Path.GetDirectoryName(singleFile); + singleFile = sharedTestState.SelfContainedApp.Rebundle(dir, BundleOptions.BundleAllContent, out var _, new Version(5, 0)); + Assert.True(singleFile == oldFile, "Rebundled app should have the same path as the original single-file app."); + var secondInode = Inode.GetInode(singleFile); + Assert.False(firstInode == secondInode, "not a different inode after re-bundling"); + // Ensure the MacOS signature cache is cleared + Assert.True(Codesign.Run("-v", singleFile).ExitCode == 0); + } + + public class SharedTestState : IDisposable + { + public SingleFileTestApp SelfContainedApp { get; } + + public SharedTestState() + { + SelfContainedApp = SingleFileTestApp.CreateSelfContained("HelloWorld"); + } + + public void Dispose() + { + SelfContainedApp.Dispose(); + } + } + static void AssertMachFilesAreEquivalent(string codesignedPath, string managedSignedPath, string fileName) { using var managedFileStream = new FileStream(managedSignedPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1); diff --git a/src/installer/tests/TestUtils/Inode.cs b/src/installer/tests/TestUtils/Inode.cs new file mode 100644 index 00000000000000..0969b57362c6ec --- /dev/null +++ b/src/installer/tests/TestUtils/Inode.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.DotNet.Cli.Build.Framework; +using Xunit; + +namespace Microsoft.DotNet.CoreSetup.Test +{ + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + public static class Inode + { + public static string GetInode(string path) + { + var firstls = Command.Create("/bin/ls", "-li", path) + .CaptureStdErr() + .CaptureStdOut() + .Execute(); + firstls.Should().Pass(); + var firstInode = firstls.StdOut.Split(' ')[0]; + return firstInode; + } + } +} diff --git a/src/installer/tests/TestUtils/SingleFileTestApp.cs b/src/installer/tests/TestUtils/SingleFileTestApp.cs index 7348d893206732..74a6df5cdf05da 100644 --- a/src/installer/tests/TestUtils/SingleFileTestApp.cs +++ b/src/installer/tests/TestUtils/SingleFileTestApp.cs @@ -95,7 +95,7 @@ public string Bundle(BundleOptions options, out Manifest manifest, Version? bund return Bundle(options, bundleDirectory, out manifest, bundleVersion); } - public string ReBundle(string bundleDirectory, BundleOptions options, out Manifest manifest, Version? bundleVersion = null) + public string Rebundle(string bundleDirectory, BundleOptions options, out Manifest manifest, Version? bundleVersion = null) { // Reuse the existing bundle directory if it exists if (!Directory.Exists(bundleDirectory)) From edaf00b039f1c85616f07da56aa915a45c576ce5 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:05:03 -0700 Subject: [PATCH 16/16] PR Feedback: - Remove ConditionAttribute on test - Use File.SetUnixFileMode --- .../AppHost/HostWriter.cs | 32 +++------ .../Microsoft.NET.HostModel/Bundle/Bundler.cs | 9 ++- .../Utils/UnixUtils.cs | 69 +++++++++++++++++++ .../Bundle/BundlerConsistencyTests.cs | 1 - 4 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs index a0fa93553db58e..47f64f4669c60c 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs @@ -179,7 +179,14 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } } }); - Chmod755(appHostDestinationFilePath); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // chmod +755 + File.SetUnixFileMode(appHostDestinationFilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } } catch (Exception ex) { @@ -219,28 +226,5 @@ private static byte[] GetSearchOptionBytes(DotNetSearchOptions searchOptions) return searchOptionsBytes; } - - internal static void Chmod755(string pathName) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - var filePermissionOctal = Convert.ToInt32("755", 8); // -rwxr-xr-x - const int EINTR = 4; - int chmodReturnCode; - - do - { - chmodReturnCode = chmod(pathName, filePermissionOctal); - } - while (chmodReturnCode == -1 && Marshal.GetLastWin32Error() == EINTR); - - if (chmodReturnCode == -1) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), $"Could not set file permission {Convert.ToString(filePermissionOctal, 8)} for {pathName}."); - } - } - - [LibraryImport("libc", SetLastError = true)] - private static partial int chmod([MarshalAs(UnmanagedType.LPStr)] string pathname, int mode); } } diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index 0952331b076df1..a5e8b593484198 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -402,7 +402,14 @@ public string GenerateBundle(IReadOnlyList fileSpecs) } } } - HostWriter.Chmod755(bundlePath); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // chmod +755 + File.SetUnixFileMode(bundlePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } return bundlePath; } diff --git a/src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs b/src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs new file mode 100644 index 00000000000000..58306e0bb846ac --- /dev/null +++ b/src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace System.IO; + +#if NETFRAMEWORK + +[Flags] +internal enum UnixFileMode +{ + None = 0, + OtherExecute = 1, + OtherWrite = 2, + OtherRead = 4, + GroupExecute = 8, + GroupWrite = 16, + GroupRead = 32, + UserExecute = 64, + UserWrite = 128, + UserRead = 256, + StickyBit = 512, + SetGroup = 1024, + SetUser = 2048, +} + +internal static class FileExtensions +{ + extension(File) + { + public static void SetUnixFileMode(string path, UnixFileMode mode) + { + int user = ((mode & UnixFileMode.UserRead) != 0 ? 4 : 0) + | ((mode & UnixFileMode.UserWrite) != 0 ? 2 : 0) + | ((mode & UnixFileMode.UserExecute) != 0 ? 1 : 0); + int group = ((mode & UnixFileMode.GroupRead) != 0 ? 4 : 0) + | ((mode & UnixFileMode.GroupWrite) != 0 ? 2 : 0) + | ((mode & UnixFileMode.GroupExecute) != 0 ? 1 : 0); + int other = ((mode & UnixFileMode.OtherRead) != 0 ? 4 : 0) + | ((mode & UnixFileMode.OtherWrite) != 0 ? 2 : 0) + | ((mode & UnixFileMode.OtherExecute) != 0 ? 1 : 0); + int octal = (user << 6) | (group << 3) | other; + + const int EINTR = 4; + int res; + int iterations = 0; + do + { + res = chmod(path, octal); + } while (res == -1 + && Marshal.GetLastWin32Error() == EINTR + && iterations++ < 8); + if (res == -1) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), $"Could not set file permission {Convert.ToString(octal, 8)} for {path}."); + } + } + } + + [DllImport("libc", SetLastError = true)] + private static extern int chmod([MarshalAs(UnmanagedType.LPStr)] string pathname, int mode); +} +#endif diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs index 77e347970826e1..7cce3c76fdd8bb 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/Bundle/BundlerConsistencyTests.cs @@ -316,7 +316,6 @@ public void AssemblyAlignment() } [Fact] - [Conditional("DEBUG")] // Relies on debug asserts in product code public void LongFileNames() { var app = sharedTestState.App;