Skip to content

Add flag and tests for codesigning single-file bundles targeting MacOS #49697

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 17, 2025
Merged
4 changes: 3 additions & 1 deletion src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -66,7 +67,8 @@ protected override void ExecuteCore()
targetOS,
targetArch,
version,
ShowDiagnosticOutput);
ShowDiagnosticOutput,
macosCodesign: EnableMacOsCodeSign);

var fileSpec = new List<FileSpec>(FilesToBundle.Length);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,8 @@ Copyright (c) .NET Foundation. All rights reserved.
TargetFrameworkVersion="$(_TargetFrameworkVersionWithoutV)"
RuntimeIdentifier="$(RuntimeIdentifier)"
OutputDir="$(PublishDir)"
ShowDiagnosticOutput="$(TraceSingleFileBundler)">
ShowDiagnosticOutput="$(TraceSingleFileBundler)"
EnableMacOSCodeSign="$(_EnableMacOSCodeSign)">
<Output TaskParameter="ExcludedFiles" ItemName="_FilesExcludedFromBundle"/>
</GenerateBundle>

Expand Down
135 changes: 25 additions & 110 deletions test/Microsoft.NET.Build.Tests/AppHostTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

#nullable disable

using System.Buffers.Binary;
using System.Diagnostics;
using System.Reflection.PortableExecutable;
using System.Text.RegularExpressions;
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -154,20 +119,35 @@ 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)
.WithSource()
.WithTargetFramework(targetFramework);

var buildCommand = new BuildCommand(testAsset);

var buildArgs = new List<string>() { $"/p:RuntimeIdentifier={rid}" };
if (enableMacOSCodesign.HasValue)
{
buildArgs.Add($"/p:_EnableMacOSCodeSign={enableMacOSCodesign.Value}");
}

buildCommand
.Execute(buildArgs.ToArray())
.Should()
Expand All @@ -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}.");
}
}

Expand Down Expand Up @@ -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<byte> 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<byte> 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);
}
}
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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($"<EnableSingleFileAnalyzer Condition=\"$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))\">true</EnableSingleFileAnalyzer>");
} else {
}
else
{
resultAssertion.And.NotHaveStdOutContaining($"warning");
}
}
Expand Down Expand Up @@ -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<string> publishArgs = new List<string>(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}.");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<byte> value)
{
var buffer = new byte[4];
value.CopyTo(buffer);
return BitConverter.ToUInt32(buffer, 0);
}
}
}
}
Loading
Loading