diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs index 767409d10998..63facae1dbee 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs @@ -30,6 +30,7 @@ public class GenerateBundle : TaskBase public bool ShowDiagnosticOutput { get; set; } [Required] public bool EnableCompressionInSingleFile { get; set; } + public bool EnableMacOsCodeSign { get; set; } = true; [Output] public ITaskItem[] ExcludedFiles { get; set; } @@ -66,7 +67,8 @@ protected override void ExecuteCore() targetOS, targetArch, version, - ShowDiagnosticOutput); + ShowDiagnosticOutput, + macosCodesign: EnableMacOsCodeSign); var fileSpec = new List(FilesToBundle.Length); diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets index a30b7df69f01..1b78b5c95653 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets @@ -1138,7 +1138,8 @@ Copyright (c) .NET Foundation. All rights reserved. TargetFrameworkVersion="$(_TargetFrameworkVersionWithoutV)" RuntimeIdentifier="$(RuntimeIdentifier)" OutputDir="$(PublishDir)" - ShowDiagnosticOutput="$(TraceSingleFileBundler)"> + ShowDiagnosticOutput="$(TraceSingleFileBundler)" + EnableMacOSCodeSign="$(_EnableMacOSCodeSign)"> diff --git a/test/Microsoft.NET.Build.Tests/AppHostTests.cs b/test/Microsoft.NET.Build.Tests/AppHostTests.cs index 86b8425d15e9..1ae4ea8f5bd0 100644 --- a/test/Microsoft.NET.Build.Tests/AppHostTests.cs +++ b/test/Microsoft.NET.Build.Tests/AppHostTests.cs @@ -3,7 +3,6 @@ #nullable disable -using System.Buffers.Binary; using System.Diagnostics; using System.Reflection.PortableExecutable; using System.Text.RegularExpressions; @@ -80,40 +79,6 @@ public void It_builds_a_runnable_apphost_by_default(string targetFramework) .HaveStdOutContaining("Hello World!"); } - [PlatformSpecificTheory(TestPlatforms.OSX)] - [InlineData("netcoreapp3.1")] - [InlineData("net5.0")] - [InlineData(ToolsetInfo.CurrentTargetFramework)] - public void It_can_disable_codesign_if_opt_out(string targetFramework) - { - var testAsset = _testAssetsManager - .CopyTestAsset("HelloWorld", identifier: targetFramework) - .WithSource() - .WithTargetFramework(targetFramework); - - var buildCommand = new BuildCommand(testAsset); - buildCommand - .Execute(new string[] { - "/p:_EnableMacOSCodeSign=false;ProduceReferenceAssembly=false", - }) - .Should() - .Pass(); - - var outputDirectory = buildCommand.GetOutputDirectory(targetFramework); - var appHostFullPath = Path.Combine(outputDirectory.FullName, "HelloWorld"); - - // Check that the apphost was not signed - var codesignPath = @"/usr/bin/codesign"; - new RunExeCommand(Log, codesignPath, new string[] { "-d", appHostFullPath }) - .Execute() - .Should() - .Fail() - .And - .HaveStdErrContaining($"{appHostFullPath}: code object is not signed at all"); - - outputDirectory.Should().OnlyHaveFiles(GetExpectedFilesFromBuild(testAsset, targetFramework)); - } - [PlatformSpecificTheory(TestPlatforms.OSX)] [InlineData("netcoreapp3.1", "win-x64")] [InlineData("net5.0", "win-x64")] @@ -154,12 +119,21 @@ public void It_does_not_try_to_codesign_non_osx_app_hosts(string targetFramework } [Theory] - [InlineData("net6.0", "osx-x64")] - [InlineData("net6.0", "osx-arm64")] - [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64")] - [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64")] - public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid) + [InlineData("net8.0", "osx-x64", true)] + [InlineData("net8.0", "osx-arm64", true)] + [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64", true)] + [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64", true)] + [InlineData("net8.0", "osx-x64", false)] + [InlineData("net8.0", "osx-arm64", false)] + [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64", false)] + [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64", false)] + [InlineData("net8.0", "osx-x64", null)] + [InlineData("net8.0", "osx-arm64", null)] + [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64", null)] + [InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64", null)] + public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid, bool? enableMacOSCodesign) { + const bool CodesignsByDefault = true; const string testAssetName = "HelloWorld"; var testAsset = _testAssetsManager .CopyTestAsset(testAssetName, identifier: targetFramework) @@ -167,7 +141,13 @@ public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid .WithTargetFramework(targetFramework); var buildCommand = new BuildCommand(testAsset); + var buildArgs = new List() { $"/p:RuntimeIdentifier={rid}" }; + if (enableMacOSCodesign.HasValue) + { + buildArgs.Add($"/p:_EnableMacOSCodeSign={enableMacOSCodesign.Value}"); + } + buildCommand .Execute(buildArgs.ToArray()) .Should() @@ -176,22 +156,14 @@ public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid var outputDirectory = buildCommand.GetOutputDirectory(targetFramework: targetFramework, runtimeIdentifier: rid); var appHostFullPath = Path.Combine(outputDirectory.FullName, testAssetName); - // Check that the apphost is signed - HasMachOSignatureLoadCommand(new FileInfo(appHostFullPath)).Should().BeTrue(); - // When on a Mac, use the codesign tool to verify the signature as well + // Check that the apphost is signed if expected + var shouldBeSigned = enableMacOSCodesign ?? CodesignsByDefault; + MachOSignature.HasMachOSignatureLoadCommand(new FileInfo(appHostFullPath)).Should().Be(shouldBeSigned, $"The app host should {(shouldBeSigned ? "" : "not ")}have a Mach-O signature load command."); if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - var codesignPath = @"/usr/bin/codesign"; - new RunExeCommand(Log, codesignPath, ["-s", "-", appHostFullPath]) - .Execute() - .Should() - .Fail() - .And - .HaveStdErrContaining($"{appHostFullPath}: is already signed"); - new RunExeCommand(Log, codesignPath, ["-v", appHostFullPath]) - .Execute() + MachOSignature.HasValidMachOSignature(new FileInfo(appHostFullPath), Log) .Should() - .Pass(); + .Be(shouldBeSigned, $"The app host should have a valid Mach-O signature for {rid}."); } } @@ -487,62 +459,5 @@ private static bool IsPE32(string path) return reader.PEHeaders.PEHeader.Magic == PEMagic.PE32; } } - - // Reads the Mach-O load commands and returns true if an LC_CODE_SIGNATURE command is found, otherwise returns false - static bool HasMachOSignatureLoadCommand(FileInfo file) - { - /* Mach-O files have the following structure: - * 32 byte header beginning with a magic number and info about the file and load commands - * A series of load commands with the following structure: - * - 4-byte command type - * - 4-byte command size - * - variable length command-specific data - */ - const uint LC_CODE_SIGNATURE = 0x1D; - using (var stream = file.OpenRead()) - { - // Read the MachO magic number to determine endianness - Span eightByteBuffer = stackalloc byte[8]; - stream.ReadExactly(eightByteBuffer); - // Determine if the magic number is in the same or opposite endianness as the runtime - bool reverseEndinanness = BitConverter.ToUInt32(eightByteBuffer.Slice(0, 4)) switch - { - 0xFEEDFACF => false, - 0xCFFAEDFE => true, - _ => throw new InvalidOperationException("Not a 64-bit Mach-O file") - }; - // 4-byte value at offset 16 is the number of load commands - // 4-byte value at offset 20 is the size of the load commands - stream.Position = 16; - ReadUInts(stream, eightByteBuffer, out uint loadCommandsCount, out uint loadCommandsSize); - // Mach-0 64 byte headers are 32 bytes long, and the first load command will be right after - stream.Position = 32; - bool hasSignature = false; - for (int commandIndex = 0; commandIndex < loadCommandsCount; commandIndex++) - { - ReadUInts(stream, eightByteBuffer, out uint commandType, out uint commandSize); - if (commandType == LC_CODE_SIGNATURE) - { - hasSignature = true; - } - stream.Position += commandSize - eightByteBuffer.Length; - } - Debug.Assert(stream.Position == loadCommandsSize + 32); - return hasSignature; - - void ReadUInts(Stream stream, Span buffer, out uint val1, out uint val2) - { - stream.ReadExactly(buffer); - val1 = BitConverter.ToUInt32(buffer.Slice(0, 4)); - val2 = BitConverter.ToUInt32(buffer.Slice(4, 4)); - if (reverseEndinanness) - { - val1 = BinaryPrimitives.ReverseEndianness(val1); - val2 = BinaryPrimitives.ReverseEndianness(val2); - } - } - } - } - } } diff --git a/test/Microsoft.NET.Publish.Tests/GivenThatWeWantToPublishASingleFileApp.cs b/test/Microsoft.NET.Publish.Tests/GivenThatWeWantToPublishASingleFileApp.cs index ed1826a2737f..940be00e85d6 100644 --- a/test/Microsoft.NET.Publish.Tests/GivenThatWeWantToPublishASingleFileApp.cs +++ b/test/Microsoft.NET.Publish.Tests/GivenThatWeWantToPublishASingleFileApp.cs @@ -82,10 +82,10 @@ private string GetNativeDll(string baseName) RuntimeInformation.RuntimeIdentifier.StartsWith("osx") ? "lib" + baseName + ".dylib" : "lib" + baseName + ".so"; } - private DirectoryInfo GetPublishDirectory(PublishCommand publishCommand, string targetFramework = ToolsetInfo.CurrentTargetFramework) + private DirectoryInfo GetPublishDirectory(PublishCommand publishCommand, string targetFramework = ToolsetInfo.CurrentTargetFramework, string runtimeIdentifier = null) { return publishCommand.GetOutputDirectory(targetFramework: targetFramework, - runtimeIdentifier: RuntimeInformation.RuntimeIdentifier); + runtimeIdentifier: runtimeIdentifier ?? RuntimeInformation.RuntimeIdentifier); } [Fact] @@ -416,7 +416,7 @@ public void It_generates_a_single_file_with_all_content_for_self_contained_apps( } // https://github.com/dotnet/sdk/issues/49665 - // error NETSDK1084: There is no application host available for the specified RuntimeIdentifier 'osx-arm64'. + // error NETSDK1084: There is no application host available for the specified RuntimeIdentifier 'osx-arm64'. [PlatformSpecificTheory(TestPlatforms.Any & ~TestPlatforms.OSX)] [InlineData("netcoreapp3.0")] [InlineData("netcoreapp3.1")] @@ -756,17 +756,20 @@ public void EnableSingleFile_warns_when_expected_for_not_correctly_multitargeted testProject.AdditionalProperties["CheckEolTargetFramework"] = "false"; // Silence warning about targeting EOL TFMs var testAsset = _testAssetsManager.CreateTestProject(testProject, identifier: targetFrameworks) .WithProjectChanges(AddTargetFrameworkAliases); - + var buildCommand = new BuildCommand(testAsset); var resultAssertion = buildCommand.Execute("/p:CheckEolTargetFramework=false") .Should().Pass(); - if (shouldWarn) { + if (shouldWarn) + { // Note: can't check for Strings.EnableSingleFileAnalyzerUnsupported because each line of // the message gets prefixed with a file path by MSBuild. resultAssertion .And.HaveStdOutContaining($"warning NETSDK1211") .And.HaveStdOutContaining($"true"); - } else { + } + else + { resultAssertion.And.NotHaveStdOutContaining($"warning"); } } @@ -1138,5 +1141,59 @@ static void VerifyPrepareForBundle(XDocument project) new XAttribute("Condition", "'%(FilesToBundle.RelativePath)' == 'SingleFileTest.dll'")))); } } + + [Theory] + [InlineData("osx-x64", true)] + [InlineData("osx-arm64", true)] + [InlineData("osx-x64", false)] + [InlineData("osx-arm64", false)] + [InlineData("osx-x64", null)] + [InlineData("osx-arm64", null)] + public void It_codesigns_an_app_targeting_osx(string rid, bool? enableMacOSCodeSign) + { + const bool CodesignsByDefault = true; + var targetFramework = ToolsetInfo.CurrentTargetFramework; + var testProject = new TestProject() + { + Name = "SingleFileTest", + TargetFrameworks = targetFramework, + IsExe = true, + }; + testProject.AdditionalProperties.Add("SelfContained", "true"); + + var testAsset = _testAssetsManager.CreateTestProject( + testProject, + identifier: $"{rid}_{enableMacOSCodeSign}"); + var publishCommand = new PublishCommand(testAsset); + + List publishArgs = new List(3) + { + PublishSingleFile, + $"/p:RuntimeIdentifier={rid}" + }; + if (enableMacOSCodeSign.HasValue) + { + publishArgs.Add($"/p:_EnableMacOSCodeSign={enableMacOSCodeSign.Value}"); + } + + publishCommand.Execute(publishArgs) + .Should() + .Pass(); + + var publishDir = GetPublishDirectory(publishCommand, targetFramework, runtimeIdentifier: rid).FullName; + var singleFilePath = Path.Combine(publishDir, testProject.Name); + + bool shouldBeSigned = enableMacOSCodeSign ?? CodesignsByDefault; + + MachOSignature.HasMachOSignatureLoadCommand(new FileInfo(singleFilePath)) + .Should() + .Be(shouldBeSigned, $"The app host should {(shouldBeSigned ? "" : "not ")}have a Mach-O signature load command."); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + MachOSignature.HasValidMachOSignature(new FileInfo(singleFilePath), Log) + .Should() + .Be(shouldBeSigned, $"The app host should {(shouldBeSigned ? "" : "not ")}have a valid Mach-O signature for {rid}."); + } + } } } diff --git a/test/Microsoft.NET.TestFramework/Utilities/BitConverterExtensions.cs b/test/Microsoft.NET.TestFramework/Utilities/BitConverterExtensions.cs new file mode 100644 index 000000000000..9f465e11c18a --- /dev/null +++ b/test/Microsoft.NET.TestFramework/Utilities/BitConverterExtensions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.NET.TestFramework.Utilities +{ + internal static class BitConverterExtensions + { + extension(BitConverter) + { + public static uint ToUInt32(ReadOnlySpan value) + { + var buffer = new byte[4]; + value.CopyTo(buffer); + return BitConverter.ToUInt32(buffer, 0); + } + } + } +} diff --git a/test/Microsoft.NET.TestFramework/Utilities/MachOSignature.cs b/test/Microsoft.NET.TestFramework/Utilities/MachOSignature.cs new file mode 100644 index 000000000000..b4f34ed1b1a5 --- /dev/null +++ b/test/Microsoft.NET.TestFramework/Utilities/MachOSignature.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; +using System.Buffers.Binary; +using System.Diagnostics; + +namespace Microsoft.NET.TestFramework.Utilities +{ + public static class MachOSignature + { + /// + /// Calls the 'codesign' utility to verify if the file has a valid Mach-O signature. + /// + /// The Mach Object file to check. + /// The output helper for logging. + /// True if the file has a valid Mach-O signature, otherwise false. +#if NET + [SupportedOSPlatform("osx")] +#endif + public static bool HasValidMachOSignature(FileInfo file, ITestOutputHelper log) + { + var codesignPath = @"/usr/bin/codesign"; + return new RunExeCommand(log, codesignPath, "-v", file.FullName) + .Execute().ExitCode == 0; + } + + /// + /// Reads the Mach-O load commands and returns true if an LC_CODE_SIGNATURE command is found, otherwise returns false. Does not validate the signature. + /// + /// The Mach Object file to check. + /// True if the file has a Mach-O signature load command, otherwise false. + public static bool HasMachOSignatureLoadCommand(FileInfo file) + { + /* Mach-O files have the following structure: + * 32 byte header beginning with a magic number and info about the file and load commands + * A series of load commands with the following structure: + * - 4-byte command type + * - 4-byte command size + * - variable length command-specific data + */ + const uint LC_CODE_SIGNATURE = 0x0000001D; + using (var stream = file.OpenRead()) + { + // Read the MachO magic number to determine endianness + Span eightByteBuffer = stackalloc byte[8]; + stream.ReadExactly(eightByteBuffer); + // Determine if the magic number is in the same or opposite endianness as the runtime + bool reverseEndinanness = BitConverter.ToUInt32(eightByteBuffer.Slice(0, 4)) switch + { + 0xFEEDFACF => false, + 0xCFFAEDFE => true, + _ => throw new InvalidOperationException("Not a 64-bit Mach-O file") + }; + // 4-byte value at offset 16 is the number of load commands + // 4-byte value at offset 20 is the size of the load commands + stream.Position = 16; + ReadUInts(stream, eightByteBuffer, out uint loadCommandsCount, out uint loadCommandsSize); + // Mach-0 64 byte headers are 32 bytes long, and the first load command will be right after + stream.Position = 32; + bool hasSignature = false; + for (int commandIndex = 0; commandIndex < loadCommandsCount; commandIndex++) + { + ReadUInts(stream, eightByteBuffer, out uint commandType, out uint commandSize); + if (commandType == LC_CODE_SIGNATURE) + { + hasSignature = true; + } + stream.Position += commandSize - eightByteBuffer.Length; + } + Debug.Assert(stream.Position == loadCommandsSize + 32); + return hasSignature; + + void ReadUInts(Stream stream, Span buffer, out uint val1, out uint val2) + { + stream.ReadExactly(buffer); + val1 = BitConverter.ToUInt32(buffer.Slice(0, 4)); + val2 = BitConverter.ToUInt32(buffer.Slice(4, 4)); + if (reverseEndinanness) + { + val1 = BinaryPrimitives.ReverseEndianness(val1); + val2 = BinaryPrimitives.ReverseEndianness(val2); + } + } + } + } + } +} diff --git a/test/Microsoft.NET.TestFramework/Utilities/StreamExtensions.cs b/test/Microsoft.NET.TestFramework/Utilities/StreamExtensions.cs new file mode 100644 index 000000000000..0bff668eaaeb --- /dev/null +++ b/test/Microsoft.NET.TestFramework/Utilities/StreamExtensions.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.NET.TestFramework.Utilities +{ + internal static class StreamExtensions + { + extension(Stream stream) + { + public void ReadExactly(Span buffer) + { + int bytesRead = 0; + byte[] arrayBuffer = new byte[buffer.Length]; + while (bytesRead < buffer.Length) + { + int read = stream.Read(arrayBuffer, bytesRead, buffer.Length - bytesRead); + if (read == 0) + { + throw new EndOfStreamException("Unexpected end of stream while reading Mach-O file."); + } + bytesRead += read; + } + arrayBuffer.CopyTo(buffer); + } + } + } +}