diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/BinaryUtils.cs index 62597baeaeafc4..94e852ee34b71d 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) { @@ -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); @@ -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..47f64f4669c60c 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs @@ -124,8 +124,16 @@ 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); + // 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 (MemoryMappedFile appHostDestinationMap = MemoryMappedFile.CreateNew(null, appHostDestinationLength)) { + using (MemoryMappedViewStream appHostDestinationStream = appHostDestinationMap.CreateViewStream()) using (FileStream appHostSourceStream = new(appHostSourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1)) { isMachOImage = MachObjectFile.IsMachOImage(appHostSourceStream); @@ -135,45 +143,50 @@ 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 (MemoryMappedViewAccessor memoryMappedViewAccessor = appHostDestinationMap.CreateViewAccessor()) { // 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 (FileStream appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 1)) + using (MemoryMappedViewAccessor appHostAccessor = appHostDestinationMap.CreateViewAccessor(0, appHostDestinationLength, MemoryMappedFileAccess.Read)) { - using var updater = new ResourceUpdater(appHostDestinationStream, true); - updater.AddResourcesFromPEImage(assemblyToCopyResourcesFrom); - updater.Update(); + // 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); + // TODO: This could be moved to work on the MemoryMappedFile if we can precalculate the size required. + if (assemblyToCopyResourcesFrom != null && appHostIsPEImage) + { + using ResourceUpdater updater = new ResourceUpdater(appHostDestinationStream, leaveOpen: true); + updater.AddResourcesFromPEImage(assemblyToCopyResourcesFrom); + updater.Update(); + } } } }); - 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) { @@ -191,125 +204,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)) @@ -332,28 +226,5 @@ private static byte[] GetSearchOptionBytes(DotNetSearchOptions searchOptions) return searchOptionsBytes; } - - private 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/AppHost/PlaceHolderNotFoundInAppHostException.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs index 4268e640154507..7988aae7b5dcca 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/PlaceHolderNotFoundInAppHostException.cs @@ -16,5 +16,9 @@ public PlaceHolderNotFoundInAppHostException(byte[] pattern) { MissingPattern = 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 bbe567852c922b..a5e8b593484198 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,13 +45,16 @@ 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) { + 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); @@ -93,7 +100,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 +188,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 +234,19 @@ private FileType InferType(FileSpec fileSpec) return FileType.Unknown; } + 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, + // 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 + ]; + + internal static ReadOnlySpan BundleHeaderSignature => BundleHeaderPlaceholder.Slice(8); + /// /// Generate a bundle, given the specification of embedded files /// @@ -250,12 +270,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 +284,230 @@ public string GenerateBundle(IReadOnlyList fileSpecs) throw new ArgumentException("Invalid input specification: Must specify the host binary"); } + (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. + // 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); + string destinationDirectory = new FileInfo(bundlePath).Directory!.FullName; + if (!Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + var hostLength = new FileInfo(hostSource).Length; + 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, _hostName); - long headerOffset = 0; - using (FileStream bundle = File.Open(bundlePath, FileMode.Open, FileAccess.ReadWrite)) - using (BinaryWriter writer = new BinaryWriter(bundle, Encoding.Default, leaveOpen: true)) + using (MemoryMappedFile bundleMap = MemoryMappedFile.CreateNew(null, bundleTotalSize, MemoryMappedFileAccess.ReadWrite, MemoryMappedFileOptions.None, HandleInheritability.None)) { - if (_target.IsOSX) - { - MachObjectFile.RemoveCodeSignatureIfPresent(bundle); - } - bundle.Position = bundle.Length; - foreach (var fileSpec in fileSpecs) + 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) { - _tracer.Log($"Exclude [{type}]: {relativePath}"); - fileSpec.Excluded = true; - continue; + 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}"); + } } - - if (relativePathToSpec.TryGetValue(fileSpec.BundleRelativePath, out var existingFileSpec)) + 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)) + { + // 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}"); + } + 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, + 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 ArgumentException($"Invalid input specification: Found entries '{fileSpec.SourcePath}' and '{existingFileSpec.SourcePath}' with the same BundleRelativePath '{fileSpec.BundleRelativePath}'"); + throw new InvalidOperationException("The single-file bundle was unable to be created. This is likely because the bundled content is too large."); } + if (_macosCodesign) + { + endOfBundle = (ulong)machFile.AdHocSignFile(machFileReader!, _hostName, 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); } + } + } + 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; + } - using (FileStream file = File.OpenRead(fileSpec.SourcePath)) + /// + /// 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) + { + 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, BundleHeaderSignature); + if (position == -1) + { + throw new PlaceHolderNotFoundInAppHostException(BundleHeaderSignature); + } + + 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 longer than 16,383 bytes are not supported and fail at runtime. + uint lengthPrefixLength = (stringLength < 128) ? 1u : + (stringLength < 16384) ? 2u : + 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/Bundle/FileEntry.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs index e71a7faaa45789..abed8b4500e5a8 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 sizeof(long) // Offset + + sizeof(long) // Size + + (bundleMajorVersion >= 6 ? sizeof(long) : 0u) // CompressedSize + + sizeof(FileType) // 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 04609e07ed5c15..fdc51c4381fe7c 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; @@ -172,10 +173,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 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 + size += Bundler.GetBinaryWriterStringLength(dummyBundleId); + // 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/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 4d95ca62d7b1da..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 @@ -4,6 +4,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -30,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][]; @@ -97,16 +98,38 @@ private CodeDirectoryBlob( + SpecialSlotCount * HashSize + CodeSlotCount * HashSize; + public string Identifier => _identifier; + public CodeDirectoryFlags Flags => _cdHeader.Flags; + public CodeDirectoryVersion Version => _cdHeader.Version; + public IReadOnlyList> SpecialSlotHashes => _specialSlotHashes; + + // Properties 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; + public static CodeDirectoryBlob Create( IMachOFileReader accessor, long signatureStart, string identifier, RequirementsBlob requirementsBlob, + EntitlementsBlob? entitlementsBlob = null, + DerEntitlementsBlob? derEntitlementsBlob = null, HashType hashType = HashType.SHA256, 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][]; @@ -121,12 +144,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 @@ -160,29 +200,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) @@ -203,12 +258,15 @@ public CodeDirectoryHeader(string identifier, uint codeSlotCount, uint specialCo _execSegmentLimit = execSegmentLimit.ConvertToBigEndian(); _execSegmentFlags = (ExecutableSegmentFlags)((ulong)execSegmentFlags).ConvertToBigEndian(); } - } - public uint HashesOffset => _cdHeader._hashesOffset.ConvertFromBigEndian(); - public uint SpecialSlotCount => _cdHeader._specialSlotCount.ConvertFromBigEndian(); - public uint CodeSlotCount => _cdHeader._codeSlotCount.ConvertFromBigEndian(); - public byte HashSize => _cdHeader.HashSize; + 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) { @@ -220,10 +278,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; } @@ -267,7 +322,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/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..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 @@ -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,45 @@ 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(); + // 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; + + 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()); + 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 long size = 0; @@ -137,16 +190,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 +243,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..ecb7e3d96165c1 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,51 +227,54 @@ 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); - if (machFile._codeSignatureLoadCommand.Command.IsDefault) + if (_codeSignatureLoadCommand.Command.IsDefault) { - Debug.Assert(machFile._codeSignatureBlob is null); + Debug.Assert(_codeSignatureBlob is null); return false; } - 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), - machFile._header); - newLength = machFile.GetFileSize(); - machFile._codeSignatureLoadCommand = default; - machFile._codeSignatureBlob = null; - machFile.Validate(); - machFile.Write(file); + LinkEditLoadCommand clearedCommand = default; + file.Write(_codeSignatureLoadCommand.FileOffset, ref clearedCommand); + _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; } /// /// 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 +306,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 +322,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 +337,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 +353,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 +432,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 +442,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 +481,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 +504,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/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/AppHost.Bundle.Tests/AppLaunch.cs b/src/installer/tests/AppHost.Bundle.Tests/AppLaunch.cs index 57b1f1f9ab5d7f..c1ccb708ad6719 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 diff --git a/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs b/src/installer/tests/HostActivation.Tests/MachOHostSigningTests.cs index 18752f5f175fa7..70bc3b86a51eff 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 { 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/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs index 8cccab87aca44b..e128cd7b6cbec5 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,45 @@ 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 firstInode = Inode.GetInode(destinationFilePath); + + // Validate that there is a signature present in the apphost Mach file + Assert.True(SigningTests.IsSigned(destinationFilePath)); + + HostWriter.CreateAppHost( + sourceAppHostMock, + destinationFilePath, + appBinaryFilePath, + windowsGraphicalUserInterface: false, + enableMacOSCodeSign: true); + var secondInode = Inode.GetInode(destinationFilePath); + + // Ensure the MacOS signature cache is cleared + Assert.False(firstInode == secondInode, "not a different inode after re-bundling"); + + Assert.True(SigningTests.IsSigned(destinationFilePath)); + } + } + [Theory] [InlineData("")] [InlineData("dir with spaces")] 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..7cce3c76fdd8bb 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,33 @@ public void AssemblyAlignment() Assert.True((file.Type != FileType.Assembly) || (file.Offset % alignment == 0))); } + [Fact] + 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/CodesignOutputInfo.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/CodesignOutputInfo.cs new file mode 100644 index 00000000000000..81c6df4248b120 --- /dev/null +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/CodesignOutputInfo.cs @@ -0,0 +1,171 @@ + + +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 new file mode 100644 index 00000000000000..49aee13bebd87e --- /dev/null +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/MachObjectTests.cs @@ -0,0 +1,214 @@ +// 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.Collections.Immutable; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Linq; +using System.Runtime.InteropServices; +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; + +namespace Microsoft.NET.HostModel.Tests; + +public class MachObjectTests +{ + ITestOutputHelper output; + public MachObjectTests(ITestOutputHelper output) + { + this.output = output; + } + [Theory] + [MemberData(nameof(GetTestFilePaths), nameof(StreamAndMemoryMappedFileAreTheSame))] + public void StreamAndMemoryMappedFileAreTheSame(string filePath, 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 _) + { + var backupFilePath = filePath + ".bak"; + File.Copy(filePath, backupFilePath); + 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); + } + 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.SequenceEqual(writtenBuffer)); + } + } + } + + 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(); + } + + [Fact] + public void CanParseCodesignOutput() + { + var parsed = CodesignOutputInfo.ParseFromCodeSignOutput(CodesignOutputInfo.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, + 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))] + [PlatformSpecific(TestPlatforms.OSX)] + public void EmbeddedSignatureBlobMatchesCodesignInfo(string filePath, TestArtifact _) + { + if (!SigningTests.IsSigned(filePath)) + { + return; + } + 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.Create(new MemoryMappedMachOViewAccessor(memoryMappedViewAccessor)); + Assert.True(machObjectFile.HasSignature, "Expected MachObjectFile to have a signature"); + embeddedSignatureBlob = machObjectFile.EmbeddedSignatureBlob; + Assert.NotNull(embeddedSignatureBlob); + } + + 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}"); + 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) + { + 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"); + + 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/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/MachObjectSigning/SigningTests.cs index 5a20310e5dff46..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,138 +19,86 @@ 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 { - 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); + private SharedTestState sharedTestState; - static readonly string[] liveBuiltHosts = new string[] { Binaries.AppHost.FilePath, Binaries.SingleFileHost.FilePath }; - static List GetTestFilePaths(TestArtifact testArtifact) + public SigningTests(SharedTestState fixture) { - 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; + sharedTestState = fixture; } - [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"); + var (exitCode, stdErr) = Codesign.Run("-s - -f --preserve-metadata=entitlements -i" + fileName, codesignFilePath); + Assert.Equal(0, exitCode); + + // Managed signed file + AdHocSignFile(originalFilePath, managedSignedPath, fileName); + + (exitCode, stdErr) = Codesign.Run("-v", managedSignedPath); + Assert.Equal(0, exitCode); + AssertMachFilesAreEquivalent(codesignFilePath, managedSignedPath, fileName); } [Fact] @@ -170,46 +118,97 @@ 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); } } - [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"; + string signedPath = filePath + ".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); - } + AdHocSignFile(filePath, signedPath, filePath); + 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, filePath); + MachObjectFile.AssertEquivalent(signedObject, resignedObject); } } [Fact] - void RoundTripMachObjectFileIsTheSame() + [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() { - using var testArtifact = TestArtifact.Create(nameof(RoundTripMachObjectFileIsTheSame)); - foreach (var fileName in GetTestFilePaths(testArtifact)) + // 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() { - 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); - } + SelfContainedApp = SingleFileTestApp.CreateSelfContained("HelloWorld"); + } + + public void Dispose() + { + SelfContainedApp.Dispose(); } } @@ -233,7 +232,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. @@ -257,7 +256,9 @@ public static void AdHocSignFile(string originalFilePath, string managedSignedPa } } +#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); @@ -303,5 +304,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/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); } } } 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 fa4ff517dd23f1..74a6df5cdf05da 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, 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; } 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()