Skip to content

Commit 892a25f

Browse files
authored
Add flag and tests for codesigning single-file bundles targeting MacOS (#49697)
Passes the _EnableMacOSCodesign property to single-file bundling task, and validate the default behavior and opt-out behavior.
2 parents d4014b8 + ea284a6 commit 892a25f

File tree

7 files changed

+226
-118
lines changed

7 files changed

+226
-118
lines changed

src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public class GenerateBundle : TaskBase
3030
public bool ShowDiagnosticOutput { get; set; }
3131
[Required]
3232
public bool EnableCompressionInSingleFile { get; set; }
33+
public bool EnableMacOsCodeSign { get; set; } = true;
3334

3435
[Output]
3536
public ITaskItem[] ExcludedFiles { get; set; }
@@ -66,7 +67,8 @@ protected override void ExecuteCore()
6667
targetOS,
6768
targetArch,
6869
version,
69-
ShowDiagnosticOutput);
70+
ShowDiagnosticOutput,
71+
macosCodesign: EnableMacOsCodeSign);
7072

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

src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1138,7 +1138,8 @@ Copyright (c) .NET Foundation. All rights reserved.
11381138
TargetFrameworkVersion="$(_TargetFrameworkVersionWithoutV)"
11391139
RuntimeIdentifier="$(RuntimeIdentifier)"
11401140
OutputDir="$(PublishDir)"
1141-
ShowDiagnosticOutput="$(TraceSingleFileBundler)">
1141+
ShowDiagnosticOutput="$(TraceSingleFileBundler)"
1142+
EnableMacOSCodeSign="$(_EnableMacOSCodeSign)">
11421143
<Output TaskParameter="ExcludedFiles" ItemName="_FilesExcludedFromBundle"/>
11431144
</GenerateBundle>
11441145

test/Microsoft.NET.Build.Tests/AppHostTests.cs

Lines changed: 25 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
#nullable disable
55

6-
using System.Buffers.Binary;
76
using System.Diagnostics;
87
using System.Reflection.PortableExecutable;
98
using System.Text.RegularExpressions;
@@ -80,40 +79,6 @@ public void It_builds_a_runnable_apphost_by_default(string targetFramework)
8079
.HaveStdOutContaining("Hello World!");
8180
}
8281

83-
[PlatformSpecificTheory(TestPlatforms.OSX)]
84-
[InlineData("netcoreapp3.1")]
85-
[InlineData("net5.0")]
86-
[InlineData(ToolsetInfo.CurrentTargetFramework)]
87-
public void It_can_disable_codesign_if_opt_out(string targetFramework)
88-
{
89-
var testAsset = _testAssetsManager
90-
.CopyTestAsset("HelloWorld", identifier: targetFramework)
91-
.WithSource()
92-
.WithTargetFramework(targetFramework);
93-
94-
var buildCommand = new BuildCommand(testAsset);
95-
buildCommand
96-
.Execute(new string[] {
97-
"/p:_EnableMacOSCodeSign=false;ProduceReferenceAssembly=false",
98-
})
99-
.Should()
100-
.Pass();
101-
102-
var outputDirectory = buildCommand.GetOutputDirectory(targetFramework);
103-
var appHostFullPath = Path.Combine(outputDirectory.FullName, "HelloWorld");
104-
105-
// Check that the apphost was not signed
106-
var codesignPath = @"/usr/bin/codesign";
107-
new RunExeCommand(Log, codesignPath, new string[] { "-d", appHostFullPath })
108-
.Execute()
109-
.Should()
110-
.Fail()
111-
.And
112-
.HaveStdErrContaining($"{appHostFullPath}: code object is not signed at all");
113-
114-
outputDirectory.Should().OnlyHaveFiles(GetExpectedFilesFromBuild(testAsset, targetFramework));
115-
}
116-
11782
[PlatformSpecificTheory(TestPlatforms.OSX)]
11883
[InlineData("netcoreapp3.1", "win-x64")]
11984
[InlineData("net5.0", "win-x64")]
@@ -154,20 +119,35 @@ public void It_does_not_try_to_codesign_non_osx_app_hosts(string targetFramework
154119
}
155120

156121
[Theory]
157-
[InlineData("net6.0", "osx-x64")]
158-
[InlineData("net6.0", "osx-arm64")]
159-
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64")]
160-
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64")]
161-
public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid)
122+
[InlineData("net8.0", "osx-x64", true)]
123+
[InlineData("net8.0", "osx-arm64", true)]
124+
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64", true)]
125+
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64", true)]
126+
[InlineData("net8.0", "osx-x64", false)]
127+
[InlineData("net8.0", "osx-arm64", false)]
128+
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64", false)]
129+
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64", false)]
130+
[InlineData("net8.0", "osx-x64", null)]
131+
[InlineData("net8.0", "osx-arm64", null)]
132+
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-x64", null)]
133+
[InlineData(ToolsetInfo.CurrentTargetFramework, "osx-arm64", null)]
134+
public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid, bool? enableMacOSCodesign)
162135
{
136+
const bool CodesignsByDefault = true;
163137
const string testAssetName = "HelloWorld";
164138
var testAsset = _testAssetsManager
165139
.CopyTestAsset(testAssetName, identifier: targetFramework)
166140
.WithSource()
167141
.WithTargetFramework(targetFramework);
168142

169143
var buildCommand = new BuildCommand(testAsset);
144+
170145
var buildArgs = new List<string>() { $"/p:RuntimeIdentifier={rid}" };
146+
if (enableMacOSCodesign.HasValue)
147+
{
148+
buildArgs.Add($"/p:_EnableMacOSCodeSign={enableMacOSCodesign.Value}");
149+
}
150+
171151
buildCommand
172152
.Execute(buildArgs.ToArray())
173153
.Should()
@@ -176,22 +156,14 @@ public void It_codesigns_an_app_targeting_osx(string targetFramework, string rid
176156
var outputDirectory = buildCommand.GetOutputDirectory(targetFramework: targetFramework, runtimeIdentifier: rid);
177157
var appHostFullPath = Path.Combine(outputDirectory.FullName, testAssetName);
178158

179-
// Check that the apphost is signed
180-
HasMachOSignatureLoadCommand(new FileInfo(appHostFullPath)).Should().BeTrue();
181-
// When on a Mac, use the codesign tool to verify the signature as well
159+
// Check that the apphost is signed if expected
160+
var shouldBeSigned = enableMacOSCodesign ?? CodesignsByDefault;
161+
MachOSignature.HasMachOSignatureLoadCommand(new FileInfo(appHostFullPath)).Should().Be(shouldBeSigned, $"The app host should {(shouldBeSigned ? "" : "not ")}have a Mach-O signature load command.");
182162
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
183163
{
184-
var codesignPath = @"/usr/bin/codesign";
185-
new RunExeCommand(Log, codesignPath, ["-s", "-", appHostFullPath])
186-
.Execute()
187-
.Should()
188-
.Fail()
189-
.And
190-
.HaveStdErrContaining($"{appHostFullPath}: is already signed");
191-
new RunExeCommand(Log, codesignPath, ["-v", appHostFullPath])
192-
.Execute()
164+
MachOSignature.HasValidMachOSignature(new FileInfo(appHostFullPath), Log)
193165
.Should()
194-
.Pass();
166+
.Be(shouldBeSigned, $"The app host should have a valid Mach-O signature for {rid}.");
195167
}
196168
}
197169

@@ -487,62 +459,5 @@ private static bool IsPE32(string path)
487459
return reader.PEHeaders.PEHeader.Magic == PEMagic.PE32;
488460
}
489461
}
490-
491-
// Reads the Mach-O load commands and returns true if an LC_CODE_SIGNATURE command is found, otherwise returns false
492-
static bool HasMachOSignatureLoadCommand(FileInfo file)
493-
{
494-
/* Mach-O files have the following structure:
495-
* 32 byte header beginning with a magic number and info about the file and load commands
496-
* A series of load commands with the following structure:
497-
* - 4-byte command type
498-
* - 4-byte command size
499-
* - variable length command-specific data
500-
*/
501-
const uint LC_CODE_SIGNATURE = 0x1D;
502-
using (var stream = file.OpenRead())
503-
{
504-
// Read the MachO magic number to determine endianness
505-
Span<byte> eightByteBuffer = stackalloc byte[8];
506-
stream.ReadExactly(eightByteBuffer);
507-
// Determine if the magic number is in the same or opposite endianness as the runtime
508-
bool reverseEndinanness = BitConverter.ToUInt32(eightByteBuffer.Slice(0, 4)) switch
509-
{
510-
0xFEEDFACF => false,
511-
0xCFFAEDFE => true,
512-
_ => throw new InvalidOperationException("Not a 64-bit Mach-O file")
513-
};
514-
// 4-byte value at offset 16 is the number of load commands
515-
// 4-byte value at offset 20 is the size of the load commands
516-
stream.Position = 16;
517-
ReadUInts(stream, eightByteBuffer, out uint loadCommandsCount, out uint loadCommandsSize);
518-
// Mach-0 64 byte headers are 32 bytes long, and the first load command will be right after
519-
stream.Position = 32;
520-
bool hasSignature = false;
521-
for (int commandIndex = 0; commandIndex < loadCommandsCount; commandIndex++)
522-
{
523-
ReadUInts(stream, eightByteBuffer, out uint commandType, out uint commandSize);
524-
if (commandType == LC_CODE_SIGNATURE)
525-
{
526-
hasSignature = true;
527-
}
528-
stream.Position += commandSize - eightByteBuffer.Length;
529-
}
530-
Debug.Assert(stream.Position == loadCommandsSize + 32);
531-
return hasSignature;
532-
533-
void ReadUInts(Stream stream, Span<byte> buffer, out uint val1, out uint val2)
534-
{
535-
stream.ReadExactly(buffer);
536-
val1 = BitConverter.ToUInt32(buffer.Slice(0, 4));
537-
val2 = BitConverter.ToUInt32(buffer.Slice(4, 4));
538-
if (reverseEndinanness)
539-
{
540-
val1 = BinaryPrimitives.ReverseEndianness(val1);
541-
val2 = BinaryPrimitives.ReverseEndianness(val2);
542-
}
543-
}
544-
}
545-
}
546-
547462
}
548463
}

test/Microsoft.NET.Publish.Tests/GivenThatWeWantToPublishASingleFileApp.cs

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ private string GetNativeDll(string baseName)
8282
RuntimeInformation.RuntimeIdentifier.StartsWith("osx") ? "lib" + baseName + ".dylib" : "lib" + baseName + ".so";
8383
}
8484

85-
private DirectoryInfo GetPublishDirectory(PublishCommand publishCommand, string targetFramework = ToolsetInfo.CurrentTargetFramework)
85+
private DirectoryInfo GetPublishDirectory(PublishCommand publishCommand, string targetFramework = ToolsetInfo.CurrentTargetFramework, string runtimeIdentifier = null)
8686
{
8787
return publishCommand.GetOutputDirectory(targetFramework: targetFramework,
88-
runtimeIdentifier: RuntimeInformation.RuntimeIdentifier);
88+
runtimeIdentifier: runtimeIdentifier ?? RuntimeInformation.RuntimeIdentifier);
8989
}
9090

9191
[Fact]
@@ -416,7 +416,7 @@ public void It_generates_a_single_file_with_all_content_for_self_contained_apps(
416416
}
417417

418418
// https://github.com/dotnet/sdk/issues/49665
419-
// error NETSDK1084: There is no application host available for the specified RuntimeIdentifier 'osx-arm64'.
419+
// error NETSDK1084: There is no application host available for the specified RuntimeIdentifier 'osx-arm64'.
420420
[PlatformSpecificTheory(TestPlatforms.Any & ~TestPlatforms.OSX)]
421421
[InlineData("netcoreapp3.0")]
422422
[InlineData("netcoreapp3.1")]
@@ -756,17 +756,20 @@ public void EnableSingleFile_warns_when_expected_for_not_correctly_multitargeted
756756
testProject.AdditionalProperties["CheckEolTargetFramework"] = "false"; // Silence warning about targeting EOL TFMs
757757
var testAsset = _testAssetsManager.CreateTestProject(testProject, identifier: targetFrameworks)
758758
.WithProjectChanges(AddTargetFrameworkAliases);
759-
759+
760760
var buildCommand = new BuildCommand(testAsset);
761761
var resultAssertion = buildCommand.Execute("/p:CheckEolTargetFramework=false")
762762
.Should().Pass();
763-
if (shouldWarn) {
763+
if (shouldWarn)
764+
{
764765
// Note: can't check for Strings.EnableSingleFileAnalyzerUnsupported because each line of
765766
// the message gets prefixed with a file path by MSBuild.
766767
resultAssertion
767768
.And.HaveStdOutContaining($"warning NETSDK1211")
768769
.And.HaveStdOutContaining($"<EnableSingleFileAnalyzer Condition=\"$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))\">true</EnableSingleFileAnalyzer>");
769-
} else {
770+
}
771+
else
772+
{
770773
resultAssertion.And.NotHaveStdOutContaining($"warning");
771774
}
772775
}
@@ -1138,5 +1141,59 @@ static void VerifyPrepareForBundle(XDocument project)
11381141
new XAttribute("Condition", "'%(FilesToBundle.RelativePath)' == 'SingleFileTest.dll'"))));
11391142
}
11401143
}
1144+
1145+
[Theory]
1146+
[InlineData("osx-x64", true)]
1147+
[InlineData("osx-arm64", true)]
1148+
[InlineData("osx-x64", false)]
1149+
[InlineData("osx-arm64", false)]
1150+
[InlineData("osx-x64", null)]
1151+
[InlineData("osx-arm64", null)]
1152+
public void It_codesigns_an_app_targeting_osx(string rid, bool? enableMacOSCodeSign)
1153+
{
1154+
const bool CodesignsByDefault = true;
1155+
var targetFramework = ToolsetInfo.CurrentTargetFramework;
1156+
var testProject = new TestProject()
1157+
{
1158+
Name = "SingleFileTest",
1159+
TargetFrameworks = targetFramework,
1160+
IsExe = true,
1161+
};
1162+
testProject.AdditionalProperties.Add("SelfContained", "true");
1163+
1164+
var testAsset = _testAssetsManager.CreateTestProject(
1165+
testProject,
1166+
identifier: $"{rid}_{enableMacOSCodeSign}");
1167+
var publishCommand = new PublishCommand(testAsset);
1168+
1169+
List<string> publishArgs = new List<string>(3)
1170+
{
1171+
PublishSingleFile,
1172+
$"/p:RuntimeIdentifier={rid}"
1173+
};
1174+
if (enableMacOSCodeSign.HasValue)
1175+
{
1176+
publishArgs.Add($"/p:_EnableMacOSCodeSign={enableMacOSCodeSign.Value}");
1177+
}
1178+
1179+
publishCommand.Execute(publishArgs)
1180+
.Should()
1181+
.Pass();
1182+
1183+
var publishDir = GetPublishDirectory(publishCommand, targetFramework, runtimeIdentifier: rid).FullName;
1184+
var singleFilePath = Path.Combine(publishDir, testProject.Name);
1185+
1186+
bool shouldBeSigned = enableMacOSCodeSign ?? CodesignsByDefault;
1187+
1188+
MachOSignature.HasMachOSignatureLoadCommand(new FileInfo(singleFilePath))
1189+
.Should()
1190+
.Be(shouldBeSigned, $"The app host should {(shouldBeSigned ? "" : "not ")}have a Mach-O signature load command.");
1191+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
1192+
{
1193+
MachOSignature.HasValidMachOSignature(new FileInfo(singleFilePath), Log)
1194+
.Should()
1195+
.Be(shouldBeSigned, $"The app host should {(shouldBeSigned ? "" : "not ")}have a valid Mach-O signature for {rid}.");
1196+
}
1197+
}
11411198
}
11421199
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.NET.TestFramework.Utilities
5+
{
6+
internal static class BitConverterExtensions
7+
{
8+
extension(BitConverter)
9+
{
10+
public static uint ToUInt32(ReadOnlySpan<byte> value)
11+
{
12+
var buffer = new byte[4];
13+
value.CopyTo(buffer);
14+
return BitConverter.ToUInt32(buffer, 0);
15+
}
16+
}
17+
}
18+
}

0 commit comments

Comments
 (0)