forked from Manuel/meeting-assistant
ci: consolidate platform pipelines and test prerequisites on macOS support
This commit is contained in:
1 parent
1b19b08f2e
commit
728e66dd72
76 files changed
+7477
-239
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,602 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
// Installed-guest CI slice. --validate never invokes macOS, dotnet build/test, or a VM.
|
||||
return await NativeGuest.Execute(args);
|
||||
|
||||
static class NativeGuest
|
||||
{
|
||||
const string SdkVersion = "10.0.401";
|
||||
const string SdkSha512 = "33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0";
|
||||
const int FixtureTests = 12;
|
||||
const string CompositionGuard = "MeetingAssistant.Tests.TestTargetCompositionTests.TestAndApplicationTargetsUseMatchingComposition";
|
||||
const int MaximumLogBytes = 8 * 1024 * 1024;
|
||||
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
||||
static readonly string[] RequiredNativeTests =
|
||||
[
|
||||
"MeetingAssistant.Tests.MacOsMeetingAudioSourceTests.NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.MacOsCapabilityEndpointReportsEnabledRealProviders",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperCropsPngUsingOcrPixelCoordinates",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied"
|
||||
];
|
||||
static readonly string[] NativeNames =
|
||||
[
|
||||
"MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture",
|
||||
"MeetingAssistantDesktopControls.app/Contents/MacOS/macos-desktop-controls", "macos-meeting-integrations", "macos-meeting-assistant-launcher"
|
||||
];
|
||||
static readonly string[] WindowsOnlyTests =
|
||||
[
|
||||
"MeetingAssistant.Tests.AzureSpeechStreamingTranscriptionProviderTests.ResolveKeyFallsBackToUserEnvironment",
|
||||
"MeetingAssistant.Tests.TaskbarIconTests.TaskbarIconGlyphsAreVisuallyCentered"
|
||||
];
|
||||
|
||||
public static async Task<int> Execute(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.SequenceEqual(["--help"]))
|
||||
{
|
||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeGuest.cs -- --validate [--archive <source.tar> --source-commit <SHA>] | --run --state /Volumes/installstate --work /private/var/tmp/meeting-assistant-native-<runToken>");
|
||||
return 0;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (args.SequenceEqual(["--validate"]) || args.Length == 5 && args[0] == "--validate" && args[1] == "--archive" && args[3] == "--source-commit")
|
||||
{
|
||||
ValidateContracts();
|
||||
if (args.Length == 5)
|
||||
{
|
||||
if (!Hex(args[4], 40)) throw new ArgumentException("Source commit must be the full lowercase Git SHA.");
|
||||
using var archive = File.OpenRead(Path.GetFullPath(args[2]));
|
||||
ValidateArchive(archive, args[4]);
|
||||
}
|
||||
Console.WriteLine("Guest payload, safe Git tar, fresh TRX and native receipt contracts passed; no native execution occurred.");
|
||||
return 0;
|
||||
}
|
||||
if (args.Length != 5 || args[0] != "--run" || args[1] != "--state" || args[3] != "--work")
|
||||
throw new ArgumentException("Choose --validate or --run --state <mounted9pdir> --work <ownedAPFSdir>.");
|
||||
return await Run(Path.GetFullPath(args[2]), Path.GetFullPath(args[4]));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(exception.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> Run(string state, string work)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
throw new InvalidOperationException("--run is restricted to the installed macOS x86_64 guest.");
|
||||
RequireNoLinks(state);
|
||||
RequireNoLinks(work);
|
||||
var manifestPath = Path.Combine(state, "payload.json");
|
||||
RequireNoLinks(manifestPath);
|
||||
var payload = ReadPayload(File.ReadAllText(manifestPath));
|
||||
if (work != "/private/var/tmp/meeting-assistant-native-" + payload.RunToken || !Directory.Exists(work))
|
||||
throw new InvalidOperationException("Guest work directory does not match this run's owned APFS location.");
|
||||
var owner = Path.Combine(work, "run.owner");
|
||||
RequireNoLinks(owner);
|
||||
if (File.ReadAllText(owner).TrimEnd('\r', '\n') != payload.RunToken)
|
||||
throw new InvalidOperationException("Guest work directory has a different run owner.");
|
||||
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var summary = new TestSummary(0, 0, 0, 0, 0, []);
|
||||
var native = new List<NativeArtifact>();
|
||||
var osVersion = "";
|
||||
var architecture = "";
|
||||
var actualSdk = "";
|
||||
var trxSha256 = "";
|
||||
var discoverySha256 = "";
|
||||
var expectedTests = 0;
|
||||
var audioCodeSignExit = -1;
|
||||
var desktopCodeSignExit = -1;
|
||||
var success = false;
|
||||
var reason = "";
|
||||
var logs = Path.Combine(state, "guest-logs");
|
||||
var results = Path.Combine(state, "test-results");
|
||||
var source = Path.Combine(work, "source");
|
||||
var dotnet = Path.Combine(work, "dotnet", "dotnet");
|
||||
// Guest checks/restore/build/tests: 25 minutes within the host's 172-minute total.
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(25));
|
||||
using var signal = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); });
|
||||
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
void Phase(string phase, string stage) => Save(Path.Combine(state, "guest-phase.json"), new { token = payload.RunToken, phase, stage, updatedUtc = DateTimeOffset.UtcNow });
|
||||
async Task<CommandResult> Cmd(string executable, string[] arguments, string label, bool requireSuccess = true) =>
|
||||
await Command(executable, arguments, source, work, logs, label, deadline.Token, requireSuccess);
|
||||
try
|
||||
{
|
||||
RequireAbsent(Path.Combine(state, "full-result.json"));
|
||||
RequireAbsent(logs);
|
||||
RequireAbsent(results);
|
||||
RequireAbsent(source);
|
||||
Directory.CreateDirectory(logs);
|
||||
Directory.CreateDirectory(results);
|
||||
foreach (var directory in new[] { "home", "packages", "tmp" })
|
||||
{
|
||||
RequireNoLinks(Path.Combine(work, directory));
|
||||
Directory.CreateDirectory(Path.Combine(work, directory));
|
||||
}
|
||||
Phase("tests-running", "installed-guest-checks");
|
||||
osVersion = (await Cmd("/usr/bin/sw_vers", ["-productVersion"], "os-version")).Output.Trim();
|
||||
architecture = (await Cmd("/usr/bin/uname", ["-m"], "architecture")).Output.Trim();
|
||||
var uid = (await Cmd("/usr/bin/id", ["-u"], "uid")).Output.Trim();
|
||||
RequirePlatform(osVersion, architecture, uid);
|
||||
foreach (var volume in new[] { ("/", "system-volume"), (work, "work-volume") })
|
||||
RequireApfs((await Cmd("/usr/sbin/diskutil", ["info", "-plist", volume.Item1], volume.Item2)).Output);
|
||||
await Cmd("/usr/bin/xcode-select", ["-p"], "clt-location");
|
||||
await Cmd("/usr/sbin/pkgutil", ["--pkg-info", "com.apple.pkg.CLTools_Executables"], "clt-package");
|
||||
await Cmd("/usr/bin/xcrun", ["swiftc", "--version"], "swift-version");
|
||||
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-version"], "apple-sdk-version");
|
||||
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], "apple-sdk-path");
|
||||
RequireNoLinks(dotnet);
|
||||
actualSdk = (await Cmd(dotnet, ["--version"], "sdk-version")).Output.Trim();
|
||||
var sdkInfo = (await Cmd(dotnet, ["--info"], "sdk-info")).Output;
|
||||
if (actualSdk != SdkVersion || !Regex.IsMatch(sdkInfo, @"(?m)^\s*Architecture:\s*x64\s*$"))
|
||||
throw new InvalidOperationException("Guest .NET SDK is not the pinned 10.0.401/x64 toolchain.");
|
||||
|
||||
Phase("tests-running", "payload-verification");
|
||||
var archive = Path.Combine(state, "source.tar");
|
||||
var sdkArchive = Path.Combine(state, "sdk.tar.gz");
|
||||
RequireNoLinks(archive);
|
||||
RequireNoLinks(sdkArchive);
|
||||
if (await HashFile(archive, false, deadline.Token) != payload.ArchiveSha256 || await HashFile(sdkArchive, true, deadline.Token) != payload.SdkSha512)
|
||||
throw new InvalidOperationException("Guest payload hash does not match the pinned manifest.");
|
||||
using (var stream = File.OpenRead(archive)) ValidateArchive(stream, payload.SourceCommit);
|
||||
// All members were checked before native tar can write anything; the destination is new.
|
||||
Directory.CreateDirectory(source);
|
||||
await Cmd("/usr/bin/tar", ["-xf", archive, "-C", source], "source-extraction");
|
||||
var project = Path.Combine(source, "MeetingAssistant.Tests", "MeetingAssistant.Tests.csproj");
|
||||
if (!File.Exists(project)) throw new InvalidOperationException("Source archive lacks the test project.");
|
||||
var nativeRoot = Path.Combine(source, "MeetingAssistant", "bin", "Release", "net10.0", "Native");
|
||||
RequireAbsent(nativeRoot);
|
||||
Phase("tests-running", "restore");
|
||||
await Cmd(dotnet, ["restore", project, "-p:EnableWindowsTargeting=true", "-p:TargetFramework=net10.0"], "restore");
|
||||
Phase("tests-running", "build");
|
||||
var buildStarted = DateTimeOffset.UtcNow;
|
||||
await Cmd(dotnet, ["build", project, "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true"], "build");
|
||||
Phase("tests-running", "native-artifacts");
|
||||
foreach (var name in NativeNames)
|
||||
{
|
||||
var path = Path.Combine(nativeRoot, name);
|
||||
RequireNoLinks(path);
|
||||
RequireFreshFile(path, buildStarted);
|
||||
var fileOutput = (await Cmd("/usr/bin/file", ["-b", path], "native-file-" + native.Count)).Output;
|
||||
var arch = (await Cmd("/usr/bin/xcrun", ["lipo", "-archs", path], "native-architecture-" + native.Count)).Output.Trim();
|
||||
RequireNativeArtifact(fileOutput, arch);
|
||||
native.Add(new(name, await HashFile(path, false, deadline.Token), arch));
|
||||
}
|
||||
var signing = await Cmd("/usr/bin/codesign", ["--verify", "--deep", "--strict", Path.Combine(nativeRoot, "MeetingAssistantAudioCapture.app")], "audio-code-sign", false);
|
||||
audioCodeSignExit = signing.ExitCode;
|
||||
if (audioCodeSignExit != 0) throw new InvalidOperationException("Fresh audio app did not pass strict code-signature verification.");
|
||||
desktopCodeSignExit = (await Cmd("/usr/bin/codesign", ["--verify", "--deep", "--strict", Path.Combine(nativeRoot, "MeetingAssistantDesktopControls.app")], "desktop-code-sign", false)).ExitCode;
|
||||
if (desktopCodeSignExit != 0) throw new InvalidOperationException("Fresh desktop app did not pass strict code-signature verification.");
|
||||
Phase("tests-running", "discovery");
|
||||
var discovery = (await Cmd(dotnet, ["test", project, "--no-build", "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true", "--list-tests", "--nologo"], "discovery")).Output;
|
||||
expectedTests = DiscoveredTests(discovery).Length;
|
||||
var discoveryPath = Path.Combine(results, "discovery.txt");
|
||||
File.WriteAllText(discoveryPath, discovery, new UTF8Encoding(false));
|
||||
discoverySha256 = await HashFile(discoveryPath, false, deadline.Token);
|
||||
Phase("tests-running", "test");
|
||||
var testStarted = DateTimeOffset.UtcNow;
|
||||
await Cmd(dotnet, ["test", project, "--no-build", "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true", "--logger", "trx;LogFileName=native.trx", "--results-directory", results], "test");
|
||||
var trxPath = Path.Combine(results, "native.trx");
|
||||
RequireNoLinks(trxPath);
|
||||
RequireFreshFile(trxPath, testStarted, 16 * 1024 * 1024);
|
||||
var trxBytes = File.ReadAllBytes(trxPath);
|
||||
var verifier = Path.Combine(source, "tools", "VerifyPlatformTests.cs");
|
||||
RequireNoLinks(verifier);
|
||||
await Cmd(dotnet, ["run", "--file", verifier, "--", "portable", discoveryPath, trxPath], "platform-test-verification");
|
||||
summary = ValidateTrx(trxBytes, discovery, testStarted);
|
||||
trxSha256 = Convert.ToHexStringLower(SHA256.HashData(trxBytes));
|
||||
success = true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
reason = exception is OperationCanceledException ? "The explicit 25-minute guest deadline or cancellation was reached." : exception.Message;
|
||||
if (reason.Length > 4096) reason = reason[..4096];
|
||||
Console.Error.WriteLine(reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
var result = new FullResult(payload.RunToken, success, payload.SourceCommit, payload.ArchiveSha256, osVersion, architecture, actualSdk, expectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, native.ToArray(), audioCodeSignExit, desktopCodeSignExit, trxSha256, discoverySha256, reason, started, DateTimeOffset.UtcNow);
|
||||
if (success)
|
||||
{
|
||||
try { ValidateResult(result, payload); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
success = false;
|
||||
result = result with { Success = false, Reason = exception.Message };
|
||||
}
|
||||
}
|
||||
Save(Path.Combine(state, "full-result.json"), result);
|
||||
Phase(success ? "tests-passed" : "tests-failed", "complete");
|
||||
}
|
||||
Console.WriteLine(success ? $"Native macOS guest: {summary.Total} discovered/results, {summary.Passed} passed, exactly two reasoned Windows skips; all five native tests passed and both apps signed." : "Native guest validation failed; full-result.json and bounded logs retain the evidence.");
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
|
||||
static Payload ReadPayload(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object || document.RootElement.EnumerateObject().Select(property => property.Name).Distinct(StringComparer.Ordinal).Count() != document.RootElement.EnumerateObject().Count())
|
||||
throw new InvalidOperationException("Payload manifest must contain one unambiguous JSON object.");
|
||||
var payload = JsonSerializer.Deserialize<Payload>(json, JsonOptions) ?? throw new InvalidOperationException("Missing guest payload manifest.");
|
||||
if (!Hex(payload.RunToken, 32) || !Hex(payload.SourceCommit, 40) || !Hex(payload.ArchiveSha256, 64) || payload.SdkVersion != SdkVersion || payload.SdkSha512 != SdkSha512)
|
||||
throw new InvalidOperationException("Guest payload identity or SDK pin is invalid.");
|
||||
return payload;
|
||||
}
|
||||
|
||||
static void ValidateArchive(Stream stream, string commit)
|
||||
{
|
||||
using var reader = new TarReader(stream, leaveOpen: true);
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var commits = new List<string>();
|
||||
long totalBytes = 0;
|
||||
while (reader.GetNextEntry() is { } entry)
|
||||
{
|
||||
if (entry is PaxGlobalExtendedAttributesTarEntry global)
|
||||
{
|
||||
if (global.GlobalExtendedAttributes.TryGetValue("comment", out var value)) commits.Add(value);
|
||||
if (global.GlobalExtendedAttributes.Keys.Any(key => key is "path" or "linkpath"))
|
||||
throw new InvalidOperationException("Global tar path overrides are not supported.");
|
||||
continue;
|
||||
}
|
||||
if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory))
|
||||
throw new InvalidOperationException("Source tar contains a link or unsupported entry type: " + entry.Name);
|
||||
var name = entry.Name.TrimEnd('/');
|
||||
if (name.Length == 0 || name.StartsWith('/') || name.Contains('\\') || name.Contains('\0') || name.Split('/').Any(part => part.Length == 0 || part is "." or ".." or ".git" or "bin" or "obj") || !names.Add(name))
|
||||
throw new InvalidOperationException("Source tar path is unsafe, repeated or contains generated output: " + entry.Name);
|
||||
totalBytes = checked(totalBytes + entry.Length);
|
||||
if (names.Count > 100_000 || totalBytes > 2L * 1024 * 1024 * 1024)
|
||||
throw new InvalidOperationException("Source tar exceeds its explicit entry/size budget.");
|
||||
}
|
||||
if (names.Count == 0 || commits.Count != 1 || commits[0] != commit || !names.Contains("MeetingAssistant.Tests/MeetingAssistant.Tests.csproj"))
|
||||
throw new InvalidOperationException("Source tar does not prove its exact Git commit and test project.");
|
||||
}
|
||||
|
||||
static string[] DiscoveredTests(string discovery)
|
||||
{
|
||||
var names = discovery.Split('\n', StringSplitOptions.TrimEntries).Where(line => line.StartsWith("MeetingAssistant.Tests.", StringComparison.Ordinal)).ToArray();
|
||||
if (names.Length == 0 || names.Distinct(StringComparer.Ordinal).Count() != names.Length || RequiredNativeTests.Concat(WindowsOnlyTests).Append(CompositionGuard).Any(name => !names.Contains(name, StringComparer.Ordinal)))
|
||||
throw new InvalidOperationException("Native discovery must uniquely list the platform guard, five native facts and two Windows-only facts.");
|
||||
return names;
|
||||
}
|
||||
|
||||
static TestSummary ValidateTrx(string xml, string discovery, DateTimeOffset testStarted) => ValidateTrx(Encoding.UTF8.GetBytes(xml), discovery, testStarted);
|
||||
static TestSummary ValidateTrx(byte[] xml, string discovery, DateTimeOffset testStarted)
|
||||
{
|
||||
var discovered = DiscoveredTests(discovery);
|
||||
var expected = discovered.Length;
|
||||
var document = ParseXml(xml);
|
||||
var root = document.Root ?? throw new InvalidOperationException("Empty TRX.");
|
||||
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
if (root.Name != ns + "TestRun") throw new InvalidOperationException("Unexpected TRX namespace or root.");
|
||||
var times = root.Element(ns + "Times") ?? throw new InvalidOperationException("TRX lacks execution timestamps.");
|
||||
if (!DateTimeOffset.TryParse((string?)times.Attribute("start"), out var start) || !DateTimeOffset.TryParse((string?)times.Attribute("finish"), out var finish) || start < testStarted.AddSeconds(-2) || finish < start || finish > DateTimeOffset.UtcNow.AddSeconds(30))
|
||||
throw new InvalidOperationException("TRX belongs to a stale or invalid test execution.");
|
||||
var resultSummary = root.Element(ns + "ResultSummary") ?? throw new InvalidOperationException("TRX lacks a final result summary.");
|
||||
if ((string?)resultSummary.Attribute("outcome") != "Completed") throw new InvalidOperationException("TRX test run did not complete.");
|
||||
var counters = resultSummary.Element(ns + "Counters") ?? throw new InvalidOperationException("TRX lacks final counters.");
|
||||
int Count(string name) => int.TryParse((string?)counters.Attribute(name), out var value) && value >= 0 ? value : throw new InvalidOperationException("TRX lacks a valid counter: " + name);
|
||||
var total = Count("total");
|
||||
var executed = Count("executed");
|
||||
var passed = Count("passed");
|
||||
var failed = Count("failed");
|
||||
var notExecuted = Count("notExecuted");
|
||||
foreach (var name in new[] { "error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected", "inProgress", "pending" })
|
||||
if (counters.Attribute(name) is not null && Count(name) != 0) throw new InvalidOperationException("TRX contains an incomplete or unsuccessful execution: " + name);
|
||||
if (total != expected || executed != expected - WindowsOnlyTests.Length || passed != executed || failed != 0 || notExecuted is not (0 or 2))
|
||||
throw new InvalidOperationException($"Native TRX must match {expected} discovered tests with exactly two Windows-only skips and no failures; got {total}/{executed}/{passed}/{failed}/{notExecuted}.");
|
||||
var definitions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var unit in root.Element(ns + "TestDefinitions")?.Elements(ns + "UnitTest") ?? [])
|
||||
{
|
||||
var method = unit.Element(ns + "TestMethod") ?? throw new InvalidOperationException("TRX test definition lacks its method identity.");
|
||||
var className = ((string?)method.Attribute("className") ?? "").Split(',')[0].Trim();
|
||||
var methodName = (string?)method.Attribute("name") ?? "";
|
||||
var id = (string?)unit.Attribute("id") ?? "";
|
||||
if (id.Length == 0 || className.Length == 0 || methodName.Length == 0 || !definitions.TryAdd(id, methodName.StartsWith(className + ".", StringComparison.Ordinal) ? methodName : className + "." + methodName))
|
||||
throw new InvalidOperationException("TRX contains an ambiguous test definition.");
|
||||
}
|
||||
var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? [];
|
||||
var executionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var testIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var resultNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var native = new List<string>();
|
||||
foreach (var result in results)
|
||||
{
|
||||
var id = (string?)result.Attribute("testId") ?? "";
|
||||
var executionId = (string?)result.Attribute("executionId") ?? "";
|
||||
var name = (string?)result.Attribute("testName") ?? "";
|
||||
if (executionId.Length == 0 || !executionIds.Add(executionId) || !testIds.Add(id) || !definitions.TryGetValue(id, out var identity) || !resultNames.Add(name) || !discovered.Contains(name, StringComparer.Ordinal) || name != identity && !name.StartsWith(identity + "(", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("TRX has a missing, duplicate or undiscovered execution.");
|
||||
var windowsOnly = WindowsOnlyTests.Contains(name, StringComparer.Ordinal);
|
||||
if ((string?)result.Attribute("outcome") != (windowsOnly ? "NotExecuted" : "Passed") || windowsOnly && !result.Descendants().Any(element => element.Name.LocalName is "Message" or "StdOut" && element.Value.Contains("Windows", StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException("Only the two named Windows-only facts may skip, with a Windows capability reason; every other case must pass.");
|
||||
if (name == CompositionGuard && !result.Descendants().Any(element => element.Name.LocalName == "StdOut" && element.Value.Split('\n', StringSplitOptions.TrimEntries).Contains("platform-assurance: target=portable; host=macos")))
|
||||
throw new InvalidOperationException("Native composition guard must report the portable target on macOS.");
|
||||
if (RequiredNativeTests.Contains(identity, StringComparer.Ordinal)) native.Add(identity);
|
||||
}
|
||||
if (definitions.Count != expected || results.Length != expected || native.Count != RequiredNativeTests.Length || !native.Order().SequenceEqual(RequiredNativeTests.Order()))
|
||||
throw new InvalidOperationException("TRX does not prove every expected test definition exactly once and the five explicit native macOS tests.");
|
||||
return new(total, executed, passed, failed, WindowsOnlyTests.Length, native.ToArray());
|
||||
}
|
||||
|
||||
static void ValidateResult(FullResult result, Payload payload)
|
||||
{
|
||||
if (result.Token != payload.RunToken || !result.Success || result.SourceCommit != payload.SourceCommit || result.ArchiveSha256 != payload.ArchiveSha256 || !Version.TryParse(result.OsVersion, out var version) || version.Major < 13 || result.Architecture != "x86_64" || result.SdkVersion != SdkVersion || result.ExpectedTests <= RequiredNativeTests.Length + WindowsOnlyTests.Length || result.Total != result.ExpectedTests || result.Executed != result.Total - WindowsOnlyTests.Length || result.Passed != result.Executed || result.Failed != 0 || result.NotExecuted != WindowsOnlyTests.Length || !result.NativeTests.Order().SequenceEqual(RequiredNativeTests.Order()) || result.AudioCodeSignExit != 0 || result.DesktopCodeSignExit != 0 || !Hex(result.TrxSha256, 64) || !Hex(result.DiscoverySha256, 64) || result.NativeArtifacts.Length != NativeNames.Length || !result.NativeArtifacts.Select(artifact => artifact.Name).Order().SequenceEqual(NativeNames.Order()) || result.NativeArtifacts.Any(artifact => artifact.Architecture != "x86_64" || !Hex(artifact.Sha256, 64)) || result.CompletedUtc < result.StartedUtc || result.CompletedUtc - result.StartedUtc > TimeSpan.FromMinutes(25).Add(TimeSpan.FromSeconds(15)) || result.CompletedUtc > DateTimeOffset.UtcNow.AddSeconds(30) || result.CompletedUtc < DateTimeOffset.UtcNow.AddMinutes(-1) || result.Reason.Length != 0)
|
||||
throw new InvalidOperationException("Native guest receipt does not prove this source, toolchain, signed artifacts and all expected tests.");
|
||||
}
|
||||
|
||||
static void RequirePlatform(string version, string architecture, string uid)
|
||||
{
|
||||
if (!Version.TryParse(version, out var parsed) || parsed.Major < 13 || architecture != "x86_64" || uid != "0")
|
||||
throw new InvalidOperationException("Native build requires installed macOS 13+/x86_64 running as root.");
|
||||
}
|
||||
static void RequireApfs(string xml)
|
||||
{
|
||||
var elements = ParseXml(xml).Root?.Element("dict")?.Elements().ToArray() ?? [];
|
||||
var type = elements.Select((element, index) => (element, index)).FirstOrDefault(pair => pair.element.Name == "key" && pair.element.Value == "FilesystemType");
|
||||
if (type.element is null || type.index + 1 >= elements.Length || elements[type.index + 1].Name != "string" || elements[type.index + 1].Value != "apfs")
|
||||
throw new InvalidOperationException("Native build must run from the installed APFS system and owned APFS work volume.");
|
||||
}
|
||||
static void RequireNativeArtifact(string fileOutput, string architecture)
|
||||
{
|
||||
if (!fileOutput.Contains("Mach-O", StringComparison.Ordinal) || !fileOutput.Contains("x86_64", StringComparison.Ordinal) || architecture != "x86_64")
|
||||
throw new InvalidOperationException("Native helper is not a Mach-O executable containing exactly x86_64.");
|
||||
}
|
||||
static void RequireAbsent(string path)
|
||||
{
|
||||
if (Path.Exists(path) || GetAttributesSafe(path)?.HasFlag(FileAttributes.ReparsePoint) == true)
|
||||
throw new InvalidOperationException("Fresh guest execution refuses pre-existing output: " + path);
|
||||
}
|
||||
static void RequireFreshFile(string path, DateTimeOffset started, long maximumBytes = long.MaxValue)
|
||||
{
|
||||
RequireNoLinks(path);
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists || file.Length == 0 || file.Length > maximumBytes || file.LastWriteTimeUtc < started.UtcDateTime.AddSeconds(-2))
|
||||
throw new InvalidOperationException("Expected a fresh, nonempty, bounded output from this execution: " + path);
|
||||
}
|
||||
static void RequireNoLinks(string path)
|
||||
{
|
||||
for (var current = Path.GetFullPath(path); current is not null; current = Path.GetDirectoryName(current))
|
||||
if (GetAttributesSafe(current)?.HasFlag(FileAttributes.ReparsePoint) == true)
|
||||
throw new InvalidOperationException("Guest ownership/extraction refuses a symbolic link: " + current);
|
||||
}
|
||||
static FileAttributes? GetAttributesSafe(string path)
|
||||
{
|
||||
try { return File.GetAttributes(path); }
|
||||
catch (FileNotFoundException) { return null; }
|
||||
catch (DirectoryNotFoundException) { return null; }
|
||||
}
|
||||
static XDocument ParseXml(string xml) => ParseXml(Encoding.UTF8.GetBytes(xml));
|
||||
static XDocument ParseXml(byte[] xml)
|
||||
{
|
||||
using var stream = new MemoryStream(xml, writable: false);
|
||||
using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null, MaxCharactersInDocument = 16 * 1024 * 1024 });
|
||||
return XDocument.Load(reader);
|
||||
}
|
||||
static bool Hex(string? value, int length) => value is not null && Regex.IsMatch(value, "^[0-9a-f]{" + length + "}$");
|
||||
static async Task<string> HashFile(string path, bool sha512, CancellationToken cancellation)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = sha512 ? await SHA512.HashDataAsync(stream, cancellation) : await SHA256.HashDataAsync(stream, cancellation);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
static void Save(string path, object value)
|
||||
{
|
||||
RequireNoLinks(path);
|
||||
var temporary = path + ".tmp";
|
||||
RequireNoLinks(temporary);
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
|
||||
static async Task<CommandResult> Command(string executable, string[] arguments, string source, string work, string logs, string label, CancellationToken cancellation, bool requireSuccess)
|
||||
{
|
||||
Console.WriteLine("[native-guest] " + label);
|
||||
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var token = commandCancellation.Token;
|
||||
var start = new ProcessStartInfo(executable) { WorkingDirectory = Directory.Exists(source) ? source : work, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
start.Environment.Clear();
|
||||
foreach (var pair in new Dictionary<string, string>
|
||||
{
|
||||
["PATH"] = Path.Combine(work, "dotnet") + ":/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
["TMPDIR"] = Path.Combine(work, "tmp"),
|
||||
["DOTNET_ROOT"] = Path.Combine(work, "dotnet"), ["DOTNET_CLI_HOME"] = Path.Combine(work, "home"),
|
||||
["NUGET_PACKAGES"] = Path.Combine(work, "packages"), ["TZ"] = "Europe/Berlin",
|
||||
["LANG"] = "en_US.UTF-8", ["LC_ALL"] = "en_US.UTF-8", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
|
||||
["DOTNET_NOLOGO"] = "1", ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", ["MSBUILDDISABLENODEREUSE"] = "1"
|
||||
}) start.Environment[pair.Key] = pair.Value;
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start guest command: " + label);
|
||||
long capturedBytes = 0;
|
||||
async Task<string> Read(StreamReader reader, string stream)
|
||||
{
|
||||
var captured = new StringBuilder();
|
||||
var buffer = new char[8192];
|
||||
using var log = new StreamWriter(Path.Combine(logs, label + "." + stream + ".log"), false, new UTF8Encoding(false));
|
||||
while (true)
|
||||
{
|
||||
var count = await reader.ReadAsync(buffer.AsMemory(), token);
|
||||
if (count == 0) break;
|
||||
if (Interlocked.Add(ref capturedBytes, Encoding.UTF8.GetByteCount(buffer.AsSpan(0, count))) > MaximumLogBytes)
|
||||
{
|
||||
commandCancellation.Cancel();
|
||||
throw new InvalidOperationException(label + " exceeded its combined 8-MiB output budget.");
|
||||
}
|
||||
captured.Append(buffer, 0, count);
|
||||
await log.WriteAsync(buffer.AsMemory(0, count), token);
|
||||
await log.FlushAsync(token);
|
||||
}
|
||||
return captured.ToString();
|
||||
}
|
||||
var stdout = Read(process.StandardOutput, "stdout");
|
||||
var stderr = Read(process.StandardError, "stderr");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token));
|
||||
var result = new CommandResult(process.ExitCode, await stdout, await stderr);
|
||||
if (requireSuccess && result.ExitCode != 0)
|
||||
throw new InvalidOperationException($"{label} exited {result.ExitCode}: {result.Error[..Math.Min(result.Error.Length, 1500)]}");
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
|
||||
using var killDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await process.WaitForExitAsync(killDeadline.Token); } catch (OperationCanceledException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static void ValidateContracts()
|
||||
{
|
||||
var payload = new Payload(new string('a', 32), new string('b', 40), new string('c', 64), SdkVersion, SdkSha512);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
ReadPayload(json);
|
||||
Reject(() => ReadPayload(json.Replace(SdkVersion, "10.0.100", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(SdkSha512, new string('d', 128), StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(payload.RunToken, "stale", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(payload.ArchiveSha256, "invalid", StringComparison.Ordinal)));
|
||||
RequirePlatform("13.6.1", "x86_64", "0");
|
||||
Reject(() => RequirePlatform("12.6.1", "x86_64", "0"));
|
||||
Reject(() => RequirePlatform("14.6.1", "arm64", "0"));
|
||||
Reject(() => RequirePlatform("14.6.1", "x86_64", "501"));
|
||||
RequireApfs("<plist><dict><key>FilesystemType</key><string>apfs</string></dict></plist>");
|
||||
Reject(() => RequireApfs("<plist><dict><key>FilesystemType</key><string>hfs</string></dict></plist>"));
|
||||
RequireNativeArtifact("Mach-O 64-bit executable x86_64", "x86_64");
|
||||
Reject(() => RequireNativeArtifact("Mach-O universal binary x86_64 arm64", "x86_64 arm64"));
|
||||
Reject(() => RequireNativeArtifact("ELF 64-bit executable x86_64", "x86_64"));
|
||||
using (var archive = FixtureArchive(payload.SourceCommit)) ValidateArchive(archive, payload.SourceCommit);
|
||||
foreach (var path in new[] { "../escape", "/absolute", "safe/../escape", "safe\\escape", "MeetingAssistant/bin/stale" })
|
||||
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, path); ValidateArchive(archive, payload.SourceCommit); });
|
||||
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, "link", true); ValidateArchive(archive, payload.SourceCommit); });
|
||||
Reject(() => { using var archive = FixtureArchive(new string('d', 40)); ValidateArchive(archive, payload.SourceCommit); });
|
||||
var started = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
var fixture = FixtureTrx(started);
|
||||
XNamespace ns = fixture.Root!.Name.Namespace;
|
||||
var discovery = string.Join('\n', fixture.Descendants(ns + "UnitTestResult").Select(result => (string)result.Attribute("testName")!));
|
||||
var summary = ValidateTrx(fixture.ToString(), discovery, started);
|
||||
var plainTrx = Encoding.UTF8.GetBytes(fixture.ToString());
|
||||
var bomTrx = new byte[] { 0xef, 0xbb, 0xbf }.Concat(plainTrx).ToArray();
|
||||
ValidateTrx(bomTrx, discovery, started);
|
||||
if (SHA256.HashData(plainTrx).SequenceEqual(SHA256.HashData(bomTrx))) throw new InvalidOperationException("TRX raw-byte hashing discarded its BOM.");
|
||||
Reject(() => ValidateTrx(fixture.ToString(), discovery, started.AddMinutes(2)));
|
||||
var skipped = new XDocument(fixture);
|
||||
skipped.Descendants(ns + "UnitTestResult").First().SetAttributeValue("outcome", "NotExecuted");
|
||||
Reject(() => ValidateTrx(skipped.ToString(), discovery, started));
|
||||
var missingNative = new XDocument(fixture);
|
||||
missingNative.Descendants(ns + "TestMethod").First().SetAttributeValue("name", "ManagedReplacement");
|
||||
Reject(() => ValidateTrx(missingNative.ToString(), discovery, started));
|
||||
var duplicate = new XDocument(fixture);
|
||||
duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0");
|
||||
Reject(() => ValidateTrx(duplicate.ToString(), discovery, started));
|
||||
var repeatedManaged = new XDocument(fixture);
|
||||
repeatedManaged.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("testId", "test-5");
|
||||
Reject(() => ValidateTrx(repeatedManaged.ToString(), discovery, started));
|
||||
var unexecutedDefinition = new XDocument(fixture);
|
||||
var extraDefinition = new XElement(unexecutedDefinition.Descendants(ns + "UnitTest").Last());
|
||||
extraDefinition.SetAttributeValue("id", "unexecuted-test");
|
||||
unexecutedDefinition.Root!.Element(ns + "TestDefinitions")!.Add(extraDefinition);
|
||||
Reject(() => ValidateTrx(unexecutedDefinition.ToString(), discovery, started));
|
||||
var missingDefinition = new XDocument(fixture);
|
||||
missingDefinition.Descendants(ns + "UnitTest").Last().Remove();
|
||||
Reject(() => ValidateTrx(missingDefinition.ToString(), discovery, started));
|
||||
var theoryRows = new XDocument(fixture);
|
||||
foreach (var method in theoryRows.Descendants(ns + "TestMethod").Skip(RequiredNativeTests.Length + WindowsOnlyTests.Length + 1).Take(2))
|
||||
method.SetAttributeValue("name", "TheoryWithDistinctRowIds");
|
||||
var theoryRecords = theoryRows.Descendants(ns + "UnitTestResult").Skip(RequiredNativeTests.Length + WindowsOnlyTests.Length + 1).Take(2).ToArray();
|
||||
for (var index = 0; index < theoryRecords.Length; index++) theoryRecords[index].SetAttributeValue("testName", $"MeetingAssistant.Tests.ManagedTests.TheoryWithDistinctRowIds(value: {index})");
|
||||
var theoryDiscovery = string.Join('\n', theoryRows.Descendants(ns + "UnitTestResult").Select(result => (string)result.Attribute("testName")!));
|
||||
ValidateTrx(theoryRows.ToString(), theoryDiscovery, started);
|
||||
var counters = new XDocument(fixture);
|
||||
counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572");
|
||||
Reject(() => ValidateTrx(counters.ToString(), discovery, started));
|
||||
var aborted = new XDocument(fixture);
|
||||
aborted.Descendants(ns + "ResultSummary").Single().SetAttributeValue("outcome", "Aborted");
|
||||
Reject(() => ValidateTrx(aborted.ToString(), discovery, started));
|
||||
Reject(() => ValidateTrx(fixture.ToString(), discovery + "\nMeetingAssistant.Tests.ManagedTests.Omitted", started));
|
||||
Reject(() => ValidateTrx(fixture.ToString(), discovery + "\n" + RequiredNativeTests[0], started));
|
||||
var unexplained = new XDocument(fixture);
|
||||
unexplained.Descendants(ns + "UnitTestResult").First(result => (string?)result.Attribute("outcome") == "NotExecuted").Element(ns + "Output")!.Remove();
|
||||
Reject(() => ValidateTrx(unexplained.ToString(), discovery, started));
|
||||
var wrongHost = new XDocument(fixture);
|
||||
wrongHost.Descendants(ns + "StdOut").Single(item => item.Value.Contains("platform-assurance:")).Value = "platform-assurance: target=portable; host=linux";
|
||||
Reject(() => ValidateTrx(wrongHost.ToString(), discovery, started));
|
||||
var artifacts = NativeNames.Select(name => new NativeArtifact(name, new string('d', 64), "x86_64")).ToArray();
|
||||
var result = new FullResult(payload.RunToken, true, payload.SourceCommit, payload.ArchiveSha256, "13.6.1", "x86_64", SdkVersion, FixtureTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, artifacts, 0, 0, new string('e', 64), new string('f', 64), "", started, DateTimeOffset.UtcNow);
|
||||
ValidateResult(result, payload);
|
||||
foreach (var invalid in new[] { result with { Token = new string('f', 32) }, result with { Success = false }, result with { SourceCommit = new string('f', 40) }, result with { ArchiveSha256 = new string('f', 64) }, result with { NotExecuted = 5 }, result with { AudioCodeSignExit = 1 }, result with { DesktopCodeSignExit = 1 }, result with { NativeTests = RequiredNativeTests[..4] }, result with { NativeArtifacts = artifacts[..3] }, result with { NativeArtifacts = [artifacts[0] with { Architecture = "arm64" }, .. artifacts[1..]] }, result with { TrxSha256 = "" }, result with { DiscoverySha256 = "" }, result with { SdkVersion = "10.0.100" }, result with { OsVersion = "12.6.1" }, result with { StartedUtc = started.AddHours(-1) }, result with { StartedUtc = started.AddHours(-1), CompletedUtc = started.AddHours(-1).AddSeconds(1) } })
|
||||
Reject(() => ValidateResult(invalid, payload));
|
||||
var temporary = Path.Combine(OperatingSystem.IsMacOS() ? "/private/tmp" : Path.GetTempPath(), "meeting-assistant-guest-validation-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
RequireAbsent(temporary);
|
||||
Directory.CreateDirectory(temporary);
|
||||
Reject(() => RequireAbsent(temporary));
|
||||
var file = Path.Combine(temporary, "fresh.trx");
|
||||
File.WriteAllText(file, "fixture");
|
||||
RequireFreshFile(file, started);
|
||||
Reject(() => RequireFreshFile(file, started, 1));
|
||||
File.SetLastWriteTimeUtc(file, started.UtcDateTime.AddMinutes(-5));
|
||||
Reject(() => RequireFreshFile(file, started));
|
||||
File.Delete(file);
|
||||
}
|
||||
finally { if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true); }
|
||||
}
|
||||
|
||||
static MemoryStream FixtureArchive(string commit, string? extra = null, bool link = false)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new TarWriter(stream, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
writer.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary<string, string> { ["comment"] = commit }));
|
||||
writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "MeetingAssistant.Tests/MeetingAssistant.Tests.csproj") { DataStream = new MemoryStream(Encoding.UTF8.GetBytes("<Project />")) });
|
||||
if (extra is not null)
|
||||
{
|
||||
var entry = new PaxTarEntry(link ? TarEntryType.SymbolicLink : TarEntryType.RegularFile, extra);
|
||||
if (link) entry.LinkName = "../outside";
|
||||
else entry.DataStream = new MemoryStream([1]);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
static XDocument FixtureTrx(DateTimeOffset started)
|
||||
{
|
||||
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
var definitions = new XElement(ns + "TestDefinitions");
|
||||
var results = new XElement(ns + "Results");
|
||||
for (var index = 0; index < FixtureTests; index++)
|
||||
{
|
||||
var identity = index < RequiredNativeTests.Length ? RequiredNativeTests[index] : index < RequiredNativeTests.Length + WindowsOnlyTests.Length ? WindowsOnlyTests[index - RequiredNativeTests.Length] : index == RequiredNativeTests.Length + WindowsOnlyTests.Length ? CompositionGuard : "MeetingAssistant.Tests.ManagedTests.Test" + index;
|
||||
var separator = identity.LastIndexOf('.');
|
||||
definitions.Add(new XElement(ns + "UnitTest", new XAttribute("id", "test-" + index), new XElement(ns + "TestMethod", new XAttribute("className", identity[..separator] + ", MeetingAssistant.Tests"), new XAttribute("name", identity[(separator + 1)..]))));
|
||||
var skipped = WindowsOnlyTests.Contains(identity);
|
||||
results.Add(new XElement(ns + "UnitTestResult", new XAttribute("testId", "test-" + index), new XAttribute("testName", identity), new XAttribute("executionId", "execution-" + index), new XAttribute("outcome", skipped ? "NotExecuted" : "Passed"), skipped || identity == CompositionGuard ? new XElement(ns + "Output", new XElement(ns + "StdOut", skipped ? "Requires Windows capabilities; run on Windows." : "platform-assurance: target=portable; host=macos")) : null));
|
||||
}
|
||||
return new XDocument(new XElement(ns + "TestRun", new XElement(ns + "Times", new XAttribute("start", started.ToString("O")), new XAttribute("finish", started.AddSeconds(1).ToString("O"))), definitions, results, new XElement(ns + "ResultSummary", new XAttribute("outcome", "Completed"), new XElement(ns + "Counters", new XAttribute("total", FixtureTests), new XAttribute("executed", FixtureTests - WindowsOnlyTests.Length), new XAttribute("passed", FixtureTests - WindowsOnlyTests.Length), new XAttribute("failed", 0), new XAttribute("notExecuted", 0)))));
|
||||
}
|
||||
static void Reject(Action action)
|
||||
{
|
||||
try { action(); }
|
||||
catch (InvalidOperationException) { return; }
|
||||
throw new InvalidOperationException("Contract validation accepted an invalid or stale fixture.");
|
||||
}
|
||||
sealed record Payload(string RunToken, string SourceCommit, string ArchiveSha256, string SdkVersion, string SdkSha512);
|
||||
sealed record NativeArtifact(string Name, string Sha256, string Architecture);
|
||||
sealed record TestSummary(int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests);
|
||||
sealed record FullResult(string Token, bool Success, string SourceCommit, string ArchiveSha256, string OsVersion, string Architecture, string SdkVersion, int ExpectedTests, int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests, NativeArtifact[] NativeArtifacts, int AudioCodeSignExit, int DesktopCodeSignExit, string TrxSha256, string DiscoverySha256, string Reason, DateTimeOffset StartedUtc, DateTimeOffset CompletedUtc);
|
||||
sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Apple Recovery has Bash before any SDK is installed. Preserve the original
|
||||
# daemon under its launchd label/PID while the unchanged read-only probe runs.
|
||||
/bin/bash /Volumes/installstate/readiness.sh &
|
||||
exec /usr/libexec/recoveryosd
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Apple pre-.NET boot seam, sourced in Recovery and on installed first boot.
|
||||
# A match requires the exact run-owned emulated serial, one whole writable 64-GiB disk.
|
||||
verify_owned_disk() {
|
||||
local disk="$1" state="$2" token="$3" info serial matches bytes
|
||||
[[ "$disk" =~ ^/dev/disk[0-9]+$ && "$token" =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
[ "$(cat "$state/run.owner" 2>/dev/null)" = "$token" ] || return 1
|
||||
serial="${token:0:20}"
|
||||
/usr/sbin/diskutil list physical > "$state/disk-list-current.log" 2>&1 || return 1
|
||||
/usr/bin/grep -Eq "^$disk[[:space:]].*physical" "$state/disk-list-current.log" || return 1
|
||||
/usr/sbin/diskutil info "$disk" > "$state/disk-info-current.log" 2>&1 || return 1
|
||||
info=$(cat "$state/disk-info-current.log")
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*Whole:[[:space:]]*Yes' || return 1
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes' && return 1
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || return 1
|
||||
bytes=$(printf '%s\n' "$info" | /usr/bin/sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p')
|
||||
[ "$bytes" = 68719476736 ] || return 1
|
||||
/usr/sbin/ioreg -r -c IOBlockStorageDevice -l -w 0 > "$state/disk-ownership-ioreg.log" 2>&1 || return 1
|
||||
matches=$(/usr/bin/awk -v expected="$serial" -v disk="${disk#/dev/}" '
|
||||
function finish_root() { if (serialCount == 1 && serial == expected && ownedDisk) found++ }
|
||||
/^\+-o/ { finish_root(); serial=""; serialCount=0; ownedDisk=0 }
|
||||
/"Serial Number"[[:space:]]*=[[:space:]]*"/ {
|
||||
serialCount++; serial=$0; sub(/^.*"Serial Number"[[:space:]]*=[[:space:]]*"/, "", serial); sub(/".*$/, "", serial); sub(/[[:space:]]+$/, "", serial)
|
||||
}
|
||||
/"BSD Name"[[:space:]]*=[[:space:]]*"/ {
|
||||
name=$0; sub(/^.*"BSD Name"[[:space:]]*=[[:space:]]*"/, "", name); sub(/".*$/, "", name)
|
||||
if (name == disk) ownedDisk=1
|
||||
}
|
||||
END { finish_root(); print found+0 }
|
||||
' "$state/disk-ownership-ioreg.log")
|
||||
[ "$matches" = 1 ] || return 1
|
||||
printf '[owned-disk] %s serial=%s bytes=%s\n' "$disk" "$serial" "$bytes"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# Apple LaunchDaemon bootstrap before the guest .NET SDK exists.
|
||||
# Runs only inside the isolated owned VM; installs CLT and expands the pinned SDK.
|
||||
set -u
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
PROOF_TOKEN="${1:-}"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
WORK="/private/var/tmp/meeting-assistant-native-$PROOF_TOKEN"
|
||||
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || exit 1
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || exit 1
|
||||
exec >> "$STATE_DIR/firstboot.log" 2>&1
|
||||
|
||||
phase() {
|
||||
printf '{"token":"%s","phase":"%s","updatedUtc":"%s"}\n' "$PROOF_TOKEN" "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATE_DIR/guest-phase.json.tmp"
|
||||
mv -f "$STATE_DIR/guest-phase.json.tmp" "$STATE_DIR/guest-phase.json"
|
||||
}
|
||||
fail() { printf '[firstboot] ERROR: %s\n' "$1"; phase bootstrap-failed; exit 1; }
|
||||
phase toolchain-installing
|
||||
echo '[firstboot] verifying installed OS, APFS and owned physical store'
|
||||
sw_vers; uname -a; id
|
||||
[ "$(id -u)" = 0 ] && [ "$(uname -m)" = x86_64 ] || fail wrong_guest_platform
|
||||
/usr/sbin/diskutil info -plist / > "$STATE_DIR/installed-root.plist" || fail root_diskutil_failed
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :FilesystemType' "$STATE_DIR/installed-root.plist")" = apfs ] || fail root_is_not_installed_apfs
|
||||
container=$(/usr/libexec/PlistBuddy -c 'Print :APFSContainerReference' "$STATE_DIR/installed-root.plist") || fail root_container_missing
|
||||
/usr/sbin/diskutil apfs list -plist > "$STATE_DIR/apfs-containers.plist" || fail apfs_list_failed
|
||||
index=0
|
||||
physical=""
|
||||
while reference=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:ContainerReference" "$STATE_DIR/apfs-containers.plist" 2>/dev/null); do
|
||||
if [ "$reference" = "$container" ]; then
|
||||
[ -z "$physical" ] || fail ambiguous_root_containers
|
||||
physical=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:0:DeviceIdentifier" "$STATE_DIR/apfs-containers.plist") || fail physical_store_missing
|
||||
/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:1" "$STATE_DIR/apfs-containers.plist" >/dev/null 2>&1 && fail multiple_physical_stores
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
[[ "$physical" =~ ^disk[0-9]+s[0-9]+$ ]] || fail invalid_physical_store
|
||||
/usr/sbin/diskutil info -plist "/dev/$physical" > "$STATE_DIR/physical-store.plist" || fail physical_store_info_failed
|
||||
whole=$(/usr/libexec/PlistBuddy -c 'Print :ParentWholeDisk' "$STATE_DIR/physical-store.plist") || fail physical_parent_missing
|
||||
. "$STATE_DIR/macos-native-disk-guard.sh"
|
||||
verify_owned_disk "/dev/$whole" "$STATE_DIR" "$PROOF_TOKEN" || fail installed_root_is_not_the_owned_64g_disk
|
||||
|
||||
# The phase has an independent 30-minute watchdog; host outer deadline is 180 minutes.
|
||||
parent=$$
|
||||
(
|
||||
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||
# Toolchain watchdog: 30 minutes, also bounded by the host's 172-minute total.
|
||||
sleep 1800 & sleeper=$!; wait "$sleeper"; kill -TERM "$parent" 2>/dev/null || :
|
||||
) & watchdog=$!
|
||||
clt_marker="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress"
|
||||
trap 'rm -f "$clt_marker"; kill -TERM "$watchdog" 2>/dev/null || :; wait "$watchdog" 2>/dev/null || :' EXIT
|
||||
trap 'fail toolchain_deadline_or_cancellation' TERM INT
|
||||
[ ! -e "$WORK" ] || fail guest_work_directory_already_exists
|
||||
mkdir -p "$WORK" || fail guest_work_directory_failed
|
||||
printf '%s\n' "$PROOF_TOKEN" > "$WORK/run.owner" || fail guest_work_owner_failed
|
||||
free_kib=$(df -Pk "$WORK" | awk 'NR==2 {print $4}')
|
||||
[[ "$free_kib" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || fail insufficient_existing_guest_free_space
|
||||
|
||||
echo '[firstboot] installing a compatible Apple CLT catalog entry without a GUI'
|
||||
touch "$clt_marker" || fail clt_marker_failed
|
||||
/usr/sbin/softwareupdate -l > "$STATE_DIR/clt-catalog.log" 2>&1 || fail clt_catalog_failed
|
||||
label=$(grep -B 1 -E 'Command Line Tools' "$STATE_DIR/clt-catalog.log" | awk -F'*' '/^ *\*/ {print $2}' | sed -e 's/^ *Label: //' -e 's/^ *//' | sort -V | tail -n 1)
|
||||
[ -n "$label" ] || fail no_compatible_headless_clt_label
|
||||
printf '[firstboot] selected CLT: %s\n' "$label"
|
||||
/usr/sbin/softwareupdate -i "$label" > "$STATE_DIR/clt-install.log" 2>&1 || fail clt_install_failed
|
||||
/usr/bin/xcode-select --switch /Library/Developer/CommandLineTools || fail clt_switch_failed
|
||||
/usr/sbin/pkgutil --pkg-info=com.apple.pkg.CLTools_Executables || fail clt_receipt_failed
|
||||
/usr/bin/xcrun --find swiftc || fail swiftc_missing
|
||||
/usr/bin/xcrun swiftc --version || fail swiftc_version_failed
|
||||
{
|
||||
/usr/bin/xcrun --sdk macosx --show-sdk-version &&
|
||||
/usr/bin/xcrun --sdk macosx --show-sdk-path
|
||||
} > "$STATE_DIR/clt-sdk.log" 2>&1 || fail clt_sdk_identity_failed
|
||||
printf 'import AppKit\nimport AVFoundation\nimport ScreenCaptureKit\nimport EventKit\nimport WebKit\nprint("native SDK ready")\n' > "$WORK/toolchain-smoke.swift"
|
||||
/usr/bin/xcrun swiftc -target x86_64-apple-macos13.0 "$WORK/toolchain-smoke.swift" -o "$WORK/toolchain-smoke" || fail native_framework_compile_failed
|
||||
"$WORK/toolchain-smoke" || fail native_framework_execution_failed
|
||||
rm -f "$clt_marker"
|
||||
|
||||
echo '[firstboot] validating and expanding the pinned .NET SDK on owned APFS'
|
||||
expected_sdk=33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0
|
||||
actual_sdk=$(shasum -a 512 "$STATE_DIR/sdk.tar.gz" | awk '{print $1}')
|
||||
[ "$actual_sdk" = "$expected_sdk" ] || fail sdk_hash_mismatch
|
||||
mkdir "$WORK/dotnet" || fail sdk_directory_failed
|
||||
tar -xzf "$STATE_DIR/sdk.tar.gz" -C "$WORK/dotnet" || fail sdk_extract_failed
|
||||
[ "$("$WORK/dotnet/dotnet" --version)" = 10.0.401 ] || fail sdk_version_mismatch
|
||||
# Guest C# owns build/test deadlines from this point; stop the CLT-only watchdog.
|
||||
kill -TERM "$watchdog" 2>/dev/null || :
|
||||
wait "$watchdog" 2>/dev/null || :
|
||||
trap - TERM INT
|
||||
"$WORK/dotnet/dotnet" run --file "$STATE_DIR/MacOsNativeGuest.cs" -- --run --state "$STATE_DIR" --work "$WORK"
|
||||
result=$?
|
||||
(( result == 0 )) || fail native_tests_failed
|
||||
exit 0
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Full-only pre-.NET seam. Claim one persistent owned probe before forking;
|
||||
# launchd then execs the original Apple daemon, including on a real failure.
|
||||
set -u
|
||||
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
MARKER="$STATE_DIR/probe.started"
|
||||
|
||||
bootstrap_failed() {
|
||||
printf '[full-bootstrap] ERROR: %s\n' "$1" >&2
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
|
||||
printf '{"token":"%s","phase":"bootstrap-failed","reason":"%s"}\n' "$PROOF_TOKEN" "$1" > "$STATE_DIR/guest-phase.bootstrap.$$.tmp" &&
|
||||
/bin/mv -f "$STATE_DIR/guest-phase.bootstrap.$$.tmp" "$STATE_DIR/guest-phase.json"
|
||||
}
|
||||
|
||||
wait_for_owned_state() {
|
||||
local count=0
|
||||
while :; do
|
||||
if [ -e "$STATE_DIR/run.owner" ] || [ -L "$STATE_DIR/run.owner" ]; then
|
||||
[ -f "$STATE_DIR/run.owner" ] && [ ! -L "$STATE_DIR/run.owner" ] &&
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || {
|
||||
bootstrap_failed foreign_state_owner
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
if (( count >= 120 )); then
|
||||
bootstrap_failed state_share_mount_timeout
|
||||
return 1
|
||||
fi
|
||||
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
owns_probe_marker() {
|
||||
local record extra
|
||||
[ -f "$MARKER" ] && [ ! -L "$MARKER" ] || return 1
|
||||
{
|
||||
IFS= read -r record || return 1
|
||||
if IFS= read -r extra || [ -n "$extra" ]; then return 1; fi
|
||||
} < "$MARKER"
|
||||
[ "$record" = "$PROOF_TOKEN:$source_commit" ]
|
||||
}
|
||||
|
||||
claim_probe() {
|
||||
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || { bootstrap_failed invalid_probe_token; return 1; }
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || { bootstrap_failed foreign_state_owner; return 1; }
|
||||
source_commit=$(cat "$STATE_DIR/source.commit" 2>/dev/null) || { bootstrap_failed source_commit_missing; return 1; }
|
||||
[[ "$source_commit" =~ ^[0-9a-f]{40}$ ]] || { bootstrap_failed source_commit_invalid; return 1; }
|
||||
if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then
|
||||
owns_probe_marker || { bootstrap_failed foreign_or_invalid_probe_marker; return 1; }
|
||||
return 2
|
||||
fi
|
||||
# Keep partial/failed markers: an incomplete first start is an error, no retry.
|
||||
if ! ( set -o noclobber; printf '%s:%s\n' "$PROOF_TOKEN" "$source_commit" > "$MARKER" ); then
|
||||
bootstrap_failed probe_marker_write_failed
|
||||
return 1
|
||||
fi
|
||||
owns_probe_marker || { bootstrap_failed probe_marker_incomplete; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
if wait_for_owned_state && claim_probe; then
|
||||
(
|
||||
/bin/bash "$STATE_DIR/readiness.sh"
|
||||
child_exit=$?
|
||||
(( child_exit == 0 )) || bootstrap_failed first_probe_child_failed
|
||||
) &
|
||||
fi
|
||||
exec /usr/libexec/recoveryosd
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/bash
|
||||
# Existing macOS Recovery/launchd runtime hook; never installs or erases anything.
|
||||
set -u
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
PROOF_LOG="$STATE_DIR/proof.log"
|
||||
RESULT="$STATE_DIR/result.json"
|
||||
EXPECTED_BYTES=68719476736
|
||||
MAX_LOG_BYTES=4194304
|
||||
MAX_OUTPUT_BYTES=524288
|
||||
TIMER_FIFO="/tmp/native-diagnostic-$PROOF_TOKEN-$$.fifo"
|
||||
PENDING_OUTPUTS=()
|
||||
ACTIVE_COMMAND=""
|
||||
ACTIVE_TIMER=""
|
||||
os_version=""
|
||||
architecture=""
|
||||
uid=-1
|
||||
system_exit=-1
|
||||
arbitration_exit=-1
|
||||
recovery_exit=-1
|
||||
disk_list_exit=-1
|
||||
selected_disk=""
|
||||
disk_bytes=0
|
||||
|
||||
count=0
|
||||
while [ ! -d "$STATE_DIR" ] && (( count < 120 )); do
|
||||
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
[ -d "$STATE_DIR" ] || exit 1
|
||||
: > "$PROOF_LOG" || exit 1
|
||||
exec 3>> "$PROOF_LOG" || exit 1
|
||||
rm -f "$RESULT" "$RESULT.tmp"
|
||||
printf '[proof-token] %s\n' "$PROOF_TOKEN" >&3
|
||||
|
||||
finish() {
|
||||
local success="$1" reason="$2"
|
||||
flush_outputs || { success=false; reason=diagnostic_log_budget_exceeded; }
|
||||
printf '[proof-result] %s: %s\n' "$success" "$reason" >&3
|
||||
printf '{"token":"%s","success":%s,"reason":"%s","osVersion":"%s","architecture":"%s","uid":%s,"systemExit":%s,"diskArbitrationExit":%s,"recoveryExit":%s,"diskListExit":%s,"disk":"%s","diskBytes":%s,"readOnly":false}\n' \
|
||||
"$PROOF_TOKEN" "$success" "$reason" "$os_version" "$architecture" "$uid" \
|
||||
"$system_exit" "$arbitration_exit" "$recovery_exit" "$disk_list_exit" \
|
||||
"$selected_disk" "$disk_bytes" > "$RESULT.tmp"
|
||||
/bin/mv -f "$RESULT.tmp" "$RESULT" || exit 1
|
||||
exec 9>&-
|
||||
[ ! -p "$TIMER_FIFO" ] || /bin/rm -f "$TIMER_FIFO"
|
||||
# Keep the service alive for the bounded host diagnostic to capture evidence.
|
||||
while :; do sleep 60; done
|
||||
}
|
||||
|
||||
init_timer_fifo() {
|
||||
# Recovery has Bash 3.2 before any SDK is installed. Its read timeout uses
|
||||
# alarm(), avoiding a separate sleep process for every command and grace period.
|
||||
[ ! -e "$TIMER_FIFO" ] || exit 1
|
||||
/usr/bin/mkfifo -m 600 "$TIMER_FIFO" || exit 1
|
||||
exec 9<> "$TIMER_FIFO" || exit 1
|
||||
}
|
||||
|
||||
flush_outputs() {
|
||||
(( ${#PENDING_OUTPUTS[@]} > 0 )) || return 0
|
||||
local started=$SECONDS sizes="/tmp/native-diagnostic-$$.sizes" proof_size output_size raw_size
|
||||
local raw_count=0 raw_valid=1 pending_count=${#PENDING_OUTPUTS[@]}
|
||||
local bounded="/tmp/native-diagnostic-$$.flush"
|
||||
# One bounded native copy per group, rather than tail/stat startup per command.
|
||||
# Keep native byte-oriented copying: Bash 3.2 read -n would read large outputs
|
||||
# one byte per system call. Small scalar reads below have a separate tight bound.
|
||||
/usr/bin/tail -c "$MAX_OUTPUT_BYTES" "${PENDING_OUTPUTS[@]}" > "$bounded" || return 1
|
||||
/usr/bin/stat -f '%z' "$PROOF_LOG" "$bounded" "${PENDING_OUTPUTS[@]}" > "$sizes" || return 1
|
||||
{
|
||||
IFS= read -r proof_size; IFS= read -r output_size
|
||||
while IFS= read -r raw_size; do
|
||||
raw_count=$((raw_count + 1))
|
||||
[[ "$raw_size" =~ ^[0-9]+$ ]] && (( raw_size <= MAX_OUTPUT_BYTES )) || raw_valid=0
|
||||
done
|
||||
} < "$sizes"
|
||||
PENDING_OUTPUTS=()
|
||||
[[ "$proof_size" =~ ^[0-9]+$ && "$output_size" =~ ^[0-9]+$ ]] || return 1
|
||||
(( raw_valid == 1 && raw_count == pending_count )) || return 1
|
||||
(( proof_size + output_size + 1024 <= MAX_LOG_BYTES )) || return 1
|
||||
/bin/cat "$bounded" >&3 || return 1
|
||||
printf '\n[proof-flush] outputs-bytes=%s elapsed=%ss\n' "$output_size" "$((SECONDS - started))" >&3
|
||||
}
|
||||
|
||||
read_scalar() {
|
||||
local value status
|
||||
# All three values are short native machine/uid/version scalars. Reject excess
|
||||
# content instead of accepting a truncated first line as a successful gate.
|
||||
IFS= read -r -n 65 -d '' value < "$LAST_OUTPUT"; status=$?
|
||||
# EOF is mandatory: the byte bound or a NUL delimiter must never hide a suffix.
|
||||
(( status == 1 && ${#value} < 65 )) || return 1
|
||||
value=${value%$'\n'}
|
||||
[[ "$value" != *$'\n'* ]] || return 1
|
||||
SCALAR="$value"
|
||||
}
|
||||
|
||||
cancel_probe() {
|
||||
trap '' TERM INT
|
||||
if [ -n "$ACTIVE_COMMAND" ]; then
|
||||
kill -TERM "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
wait "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$ACTIVE_TIMER" ] || { kill -TERM "$ACTIVE_TIMER" 2>/dev/null || :; wait "$ACTIVE_TIMER" 2>/dev/null || :; }
|
||||
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||
finish false probe_cancelled
|
||||
}
|
||||
|
||||
run_command() {
|
||||
local name="$1"
|
||||
shift
|
||||
local process timer exit_code started waited command_limit=45
|
||||
# Run 4161: even native uname/ps startup took 34-42s under TCG.
|
||||
# Isolate only the failed UID gate; every other watchdog remains unchanged.
|
||||
[[ "$name" != uid ]] || command_limit=180
|
||||
LAST_OUTPUT="/tmp/native-diagnostic-$name.out"
|
||||
printf '\n[proof-command] %s:' "$name" >&3
|
||||
printf ' %s' "$@" >&3
|
||||
printf '\n' >&3
|
||||
started=$SECONDS
|
||||
"$@" > "$LAST_OUTPUT" 2>&1 &
|
||||
process=$!
|
||||
ACTIVE_COMMAND="$process"
|
||||
printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >&3
|
||||
printf '[proof-limit] %s %ss\n' "$name" "$command_limit" >&3
|
||||
(
|
||||
trap 'exit 0' TERM INT
|
||||
IFS= read -r -t "$command_limit" -u 9 unused || :
|
||||
printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >&3
|
||||
kill -TERM "$process" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$process" 2>/dev/null || :
|
||||
) &
|
||||
timer=$!
|
||||
ACTIVE_TIMER="$timer"
|
||||
wait "$process"
|
||||
exit_code=$?
|
||||
waited=$SECONDS
|
||||
# Includes fork/exec/wait, but excludes timer cleanup and evidence copying.
|
||||
printf '[proof-native-wait] %s child=%s elapsed=%ss exit=%s\n' "$name" "$process" "$((waited - started))" "$exit_code" >&3
|
||||
kill -TERM "$timer" 2>/dev/null || :
|
||||
wait "$timer" 2>/dev/null || :
|
||||
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||
printf '[proof-cleanup] %s child=%s elapsed=%ss total=%ss\n' "$name" "$process" "$((SECONDS - waited))" "$((SECONDS - started))" >&3
|
||||
printf '[proof-exit] %s\n' "$exit_code" >&3
|
||||
LAST_EXIT="$exit_code"
|
||||
PENDING_OUTPUTS+=("$LAST_OUTPUT")
|
||||
return 0
|
||||
}
|
||||
|
||||
diagnose_failure() {
|
||||
run_command kernel /usr/bin/uname -a
|
||||
run_command account /usr/bin/id
|
||||
run_command context /usr/sbin/sysctl kern.bootargs machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features
|
||||
run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm=
|
||||
run_command processes /bin/ps -axo pid,ppid,comm
|
||||
run_command loaded_kexts /usr/bin/kmutil showloaded --list-only
|
||||
}
|
||||
|
||||
fail_probe() {
|
||||
local reason="$1"
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
diagnose_failure
|
||||
finish false "$reason"
|
||||
}
|
||||
|
||||
init_timer_fifo
|
||||
trap cancel_probe TERM INT
|
||||
|
||||
# Test the required native gates before optional process/CPU diagnostics.
|
||||
run_command architecture /usr/bin/uname -m
|
||||
(( LAST_EXIT == 0 )) || fail_probe architecture_probe_failed
|
||||
read_scalar || fail_probe architecture_output_invalid
|
||||
architecture="$SCALAR"
|
||||
[ "$architecture" = x86_64 ] || fail_probe unexpected_guest_architecture
|
||||
run_command uid /usr/bin/id -u
|
||||
(( LAST_EXIT == 0 )) || fail_probe uid_probe_failed
|
||||
read_scalar || fail_probe uid_output_invalid
|
||||
uid="$SCALAR"
|
||||
[ "$uid" = 0 ] || fail_probe recovery_account_not_root
|
||||
run_command platform /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
if (( platform_exit != 0 )); then
|
||||
run_command system /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >&3
|
||||
run_command platform-warm /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
fi
|
||||
(( platform_exit == 0 )) || fail_probe sw_vers_failed
|
||||
run_command version /usr/bin/sw_vers -productVersion
|
||||
(( LAST_EXIT == 0 )) || fail_probe product_version_failed
|
||||
read_scalar || fail_probe product_version_invalid
|
||||
os_version="$SCALAR"
|
||||
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid
|
||||
(( ${os_version%%.*} >= 13 )) || fail_probe unsupported_macos_version
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
|
||||
# Bound readiness independently of the host's 40-minute overall deadline.
|
||||
readiness_start=$SECONDS
|
||||
attempt=0
|
||||
while (( SECONDS - readiness_start < 600 )); do
|
||||
attempt=$((attempt + 1))
|
||||
printf '\n[readiness-attempt] %s\n' "$attempt" >&3
|
||||
run_command disks /usr/sbin/diskutil list physical
|
||||
disk_list_exit="$LAST_EXIT"
|
||||
if (( disk_list_exit == 0 )); then
|
||||
disk_list=$(cat "$LAST_OUTPUT")
|
||||
candidates=0
|
||||
while IFS= read -r disk; do
|
||||
[ -n "$disk" ] || continue
|
||||
run_command "info-$disk" /usr/sbin/diskutil info "/dev/$disk"
|
||||
(( LAST_EXIT == 0 )) || continue
|
||||
info=$(cat "$LAST_OUTPUT")
|
||||
if printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || continue
|
||||
size=$(printf '%s\n' "$info" | sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' | head -n 1)
|
||||
[[ "$size" =~ ^[0-9]+$ ]] || continue
|
||||
(( size == EXPECTED_BYTES )) || continue
|
||||
candidates=$((candidates + 1))
|
||||
selected_disk="/dev/$disk"
|
||||
disk_bytes="$size"
|
||||
printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >&3
|
||||
done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p')
|
||||
(( candidates <= 1 )) || fail_probe ambiguous_writable_64g_disks
|
||||
if (( candidates == 1 )); then
|
||||
# Re-probe live launchd domains after disk readiness, preserving native exits.
|
||||
run_command system_ready /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration_ready /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery_ready /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
(( system_exit == 0 && arbitration_exit == 0 && recovery_exit == 0 )) || fail_probe service_domain_not_ready
|
||||
finish true native_recovery_and_writable_64g_disk_ready
|
||||
fi
|
||||
fi
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
IFS= read -r -t 5 -u 9 unused || :
|
||||
done
|
||||
fail_probe disk_management_or_writable_target_not_ready
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Checksum binding for the pinned Linux Recovery UDIF patcher, not a new CLI.
|
||||
|
||||
Source semantics: planetbeing/libdmg-hfsplus dmg/dmglib.c and dmg/blkx.c.
|
||||
Only flattened, single-segment XML UDIF with CRC32 and raw/zlib data is accepted.
|
||||
The caller plans same-length chunk writes; XML formatting and all offsets remain.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import plistlib
|
||||
import re
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
|
||||
def u32(data, offset):
|
||||
return struct.unpack_from(">I", data, offset)[0]
|
||||
|
||||
|
||||
def u64(data, offset):
|
||||
return struct.unpack_from(">Q", data, offset)[0]
|
||||
|
||||
|
||||
def crc_contract(data, offset):
|
||||
if u32(data, offset) != 2 or u32(data, offset + 4) != 32 or any(data[offset + 12:offset + 136]):
|
||||
raise RuntimeError("Unsupported UDIF checksum type/size/padding")
|
||||
return u32(data, offset + 8)
|
||||
|
||||
|
||||
class ChecksumPlan:
|
||||
def __init__(self, image, koly, plist, xml_offset, xml_length, size):
|
||||
self.image, self.koly, self.plist = image, koly, plist
|
||||
self.xml_offset, self.xml_length, self.size = xml_offset, xml_length, size
|
||||
self.data_offset, self.data_length = u64(koly, 24), u64(koly, 32)
|
||||
if (u32(koly, 4) != 4 or u32(koly, 8) != 512 or u32(koly, 12) != 1
|
||||
or self.data_offset != 0 or u64(koly, 40) or u64(koly, 48)
|
||||
or u32(koly, 60) not in (0, 1) or self.data_length != xml_offset
|
||||
or xml_offset + xml_length > size - 512 or xml_length > 8 * 1024 * 1024):
|
||||
raise RuntimeError("Unsupported or out-of-bounds flattened UDIF layout")
|
||||
crc_contract(koly, 80)
|
||||
crc_contract(koly, 352)
|
||||
image.seek(xml_offset)
|
||||
self.xml = image.read(xml_length)
|
||||
if len(self.xml) != xml_length or not self.xml.lstrip().startswith(b"<?xml"):
|
||||
raise RuntimeError("Unsupported UDIF metadata framing")
|
||||
self.blocks = plist["resource-fork"]["blkx"]
|
||||
self.physical_runs = {}
|
||||
intervals = []
|
||||
for block in self.blocks:
|
||||
mish = block["Data"]
|
||||
if len(mish) < 244 or mish[:4] != b"mish" or (len(mish) - 204) % 40 or u32(mish, 200) != (len(mish) - 204) // 40:
|
||||
raise RuntimeError("Malformed UDIF block table")
|
||||
crc_contract(mish, 64)
|
||||
if u64(mish, 8) + u64(mish, 16) > u64(koly, 492):
|
||||
raise RuntimeError("UDIF partition exceeds logical disk boundary")
|
||||
count = u32(mish, 200)
|
||||
if u32(mish, 204 + (count - 1) * 40) != 0xffffffff:
|
||||
raise RuntimeError("UDIF block table has no final terminator")
|
||||
for kind, offset, length, sectors in self.runs(mish):
|
||||
if kind in (2, 0x7ffffffe, 0xffffffff):
|
||||
if length:
|
||||
raise RuntimeError("Non-data UDIF run has stored bytes")
|
||||
continue
|
||||
if kind not in (1, 0x80000005) or not sectors or length <= 0 or sectors * 512 > 32 * 1024 * 1024:
|
||||
raise RuntimeError("Unsupported UDIF compression/run boundary")
|
||||
if offset < self.data_offset or offset + length > self.data_offset + self.data_length:
|
||||
raise RuntimeError("UDIF data run exceeds data-fork boundary")
|
||||
intervals.append((offset, offset + length))
|
||||
self.physical_runs[offset] = length
|
||||
intervals.sort()
|
||||
if any(left[1] > right[0] for left, right in zip(intervals, intervals[1:])):
|
||||
raise RuntimeError("Overlapping UDIF physical data runs")
|
||||
|
||||
def runs(self, mish):
|
||||
for entry in range(204, len(mish), 40):
|
||||
kind = u32(mish, entry)
|
||||
sector, sectors = u64(mish, entry + 8), u64(mish, entry + 16)
|
||||
if sector + sectors > u64(mish, 16):
|
||||
raise RuntimeError("UDIF run exceeds its partition boundary")
|
||||
offset = self.data_offset + u64(mish, 24) + u64(mish, entry + 24)
|
||||
yield kind, offset, u64(mish, entry + 32), sectors
|
||||
|
||||
def read(self, offset, length):
|
||||
self.image.seek(offset)
|
||||
result = self.image.read(length)
|
||||
if len(result) != length:
|
||||
raise RuntimeError("Short UDIF checksum read")
|
||||
return result
|
||||
|
||||
def logical_crcs(self, mish, replacements):
|
||||
original_crc = patched_crc = 0
|
||||
for kind, offset, length, sectors in self.runs(mish):
|
||||
# IGNORE runs are excluded by the independently verified Apple 13
|
||||
# baseline. Unknown ZERO/compression types are rejected above.
|
||||
if kind not in (1, 0x80000005):
|
||||
continue
|
||||
old = self.read(offset, length)
|
||||
new = replacements.get(offset, old)
|
||||
old_decoded = old if kind == 1 else zlib.decompress(old)
|
||||
new_decoded = new if kind == 1 else zlib.decompress(new)
|
||||
if len(old_decoded) != sectors * 512 or len(new_decoded) != sectors * 512 or len(old) != len(new):
|
||||
raise RuntimeError("UDIF checksum run changed physical/logical extent")
|
||||
original_crc = zlib.crc32(old_decoded, original_crc)
|
||||
patched_crc = zlib.crc32(new_decoded, patched_crc)
|
||||
return original_crc, patched_crc
|
||||
|
||||
def data_crcs(self, replacements):
|
||||
old_crc = new_crc = 0
|
||||
cursor = self.data_offset
|
||||
def same_until(stop):
|
||||
nonlocal cursor, old_crc, new_crc
|
||||
while cursor < stop:
|
||||
data = self.read(cursor, min(1024 * 1024, stop - cursor))
|
||||
old_crc, new_crc = zlib.crc32(data, old_crc), zlib.crc32(data, new_crc)
|
||||
cursor += len(data)
|
||||
for offset, new in sorted(replacements.items()):
|
||||
same_until(offset)
|
||||
old = self.read(offset, len(new))
|
||||
old_crc, new_crc = zlib.crc32(old, old_crc), zlib.crc32(new, new_crc)
|
||||
cursor += len(new)
|
||||
same_until(self.data_offset + self.data_length)
|
||||
return old_crc, new_crc
|
||||
|
||||
def prepare(self, planned):
|
||||
replacements = dict(planned)
|
||||
if len(replacements) != len(planned):
|
||||
raise RuntimeError("Duplicate planned UDIF physical writes")
|
||||
if any(self.physical_runs.get(offset) != len(data) for offset, data in replacements.items()):
|
||||
raise RuntimeError("Planned UDIF write does not preserve an existing data-run boundary")
|
||||
old_master = bytearray()
|
||||
new_master = bytearray()
|
||||
changed = []
|
||||
for block in self.blocks:
|
||||
mish = block["Data"]
|
||||
old_crc, new_crc = self.logical_crcs(mish, replacements)
|
||||
if old_crc != crc_contract(mish, 64):
|
||||
raise RuntimeError("Original UDIF logical CRC32 mismatch")
|
||||
old_master.extend(struct.pack(">I", old_crc))
|
||||
new_master.extend(struct.pack(">I", new_crc))
|
||||
if new_crc != old_crc:
|
||||
new_mish = bytearray(mish)
|
||||
struct.pack_into(">I", new_mish, 72, new_crc)
|
||||
changed.append((mish, bytes(new_mish)))
|
||||
old_data_crc, new_data_crc = self.data_crcs(replacements)
|
||||
if old_data_crc != crc_contract(self.koly, 80) or zlib.crc32(old_master) != crc_contract(self.koly, 352):
|
||||
raise RuntimeError("Original UDIF data-fork/master CRC32 mismatch")
|
||||
xml = self.xml
|
||||
for old_mish, new_mish in changed:
|
||||
matches = [match for match in re.finditer(rb"<data>([\sA-Za-z0-9+/=]*)</data>", xml)
|
||||
if base64.b64decode(match.group(1)) == old_mish]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("UDIF block checksum XML identity is ambiguous")
|
||||
match = matches[0]
|
||||
encoded = iter(base64.b64encode(new_mish))
|
||||
text = bytes(value if chr(value).isspace() else next(encoded) for value in match.group(1))
|
||||
xml = xml[:match.start(1)] + text + xml[match.end(1):]
|
||||
if len(xml) != self.xml_length:
|
||||
raise RuntimeError("UDIF checksum update changed XML region length")
|
||||
new_plist = plistlib.loads(xml)
|
||||
expected = dict(self.plist)
|
||||
expected["resource-fork"] = dict(self.plist["resource-fork"])
|
||||
expected["resource-fork"]["blkx"] = [dict(block, Data=dict(changed).get(block["Data"], block["Data"])) for block in self.blocks]
|
||||
if new_plist != expected:
|
||||
raise RuntimeError("UDIF checksum update changed unrelated metadata")
|
||||
koly = bytearray(self.koly)
|
||||
struct.pack_into(">I", koly, 88, new_data_crc)
|
||||
struct.pack_into(">I", koly, 360, zlib.crc32(new_master))
|
||||
self.receipt = dict(originalDataCrc32=f"{old_data_crc:08x}", patchedDataCrc32=f"{new_data_crc:08x}",
|
||||
originalMasterCrc32=f"{zlib.crc32(old_master):08x}", patchedMasterCrc32=f"{zlib.crc32(new_master):08x}",
|
||||
changedBlockChecksums=len(changed), imageBytes=self.size, xmlOffset=self.xml_offset,
|
||||
xmlBytes=self.xml_length, physicalAndLogicalExtentsPreserved=True)
|
||||
return xml, bytes(koly)
|
||||
|
||||
def verify(self):
|
||||
koly = self.read(self.size - 512, 512)
|
||||
xml = self.read(self.xml_offset, self.xml_length)
|
||||
verifier = ChecksumPlan(self.image, koly, plistlib.loads(xml), self.xml_offset, self.xml_length, self.size)
|
||||
verifier.prepare([])
|
||||
self.image.seek(0, 2)
|
||||
if self.image.tell() != self.size:
|
||||
raise RuntimeError("Patched UDIF image length changed")
|
||||
print("[recovery-udif] " + json.dumps(dict(self.receipt, readBackChecksumsVerified=True), sort_keys=True))
|
||||
Reference in new issue
Block a user