forked from Manuel/meeting-assistant
554 lines
39 KiB
C#
554 lines
39 KiB
C#
#: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 ExpectedTests = 577;
|
|
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",
|
|
"macos-desktop-controls", "macos-meeting-integrations", "macos-meeting-assistant-launcher"
|
|
];
|
|
|
|
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 audioCodeSignExit = -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.");
|
|
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);
|
|
summary = ValidateTrx(trxBytes, payload.ExpectedTests, 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, payload.ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, native.ToArray(), audioCodeSignExit, trxSha256, 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 build, signed helpers and all 577 tests passed without skips." : "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 || payload.ExpectedTests != ExpectedTests)
|
|
throw new InvalidOperationException("Guest payload identity, SDK pin or expected test count 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 TestSummary ValidateTrx(string xml, int expected, DateTimeOffset testStarted) => ValidateTrx(Encoding.UTF8.GetBytes(xml), expected, testStarted);
|
|
static TestSummary ValidateTrx(byte[] xml, int expected, DateTimeOffset testStarted)
|
|
{
|
|
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 (expected != ExpectedTests || total != expected || executed != expected || passed != expected || failed != 0 || notExecuted != 0)
|
|
throw new InvalidOperationException($"Native TRX must prove {expected} total/executed/passed tests, zero failures and zero skips; 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 native = new List<string>();
|
|
foreach (var result in results)
|
|
{
|
|
var id = (string?)result.Attribute("testId") ?? "";
|
|
var executionId = (string?)result.Attribute("executionId") ?? "";
|
|
if ((string?)result.Attribute("outcome") != "Passed" || executionId.Length == 0 || !executionIds.Add(executionId) || !testIds.Add(id) || !definitions.TryGetValue(id, out var identity))
|
|
throw new InvalidOperationException("TRX has a missing, duplicate or non-passed execution.");
|
|
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, notExecuted, 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 < 14 || result.Architecture != "x86_64" || result.SdkVersion != SdkVersion || result.ExpectedTests != ExpectedTests || result.Total != ExpectedTests || result.Executed != ExpectedTests || result.Passed != ExpectedTests || result.Failed != 0 || result.NotExecuted != 0 || !result.NativeTests.Order().SequenceEqual(RequiredNativeTests.Order()) || result.AudioCodeSignExit != 0 || !Hex(result.TrxSha256, 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 < 14 || architecture != "x86_64" || uid != "0")
|
|
throw new InvalidOperationException("Native build requires installed macOS 14+/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, ExpectedTests);
|
|
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("577", "572", StringComparison.Ordinal)));
|
|
Reject(() => ReadPayload(json.Replace(payload.RunToken, "stale", StringComparison.Ordinal)));
|
|
Reject(() => ReadPayload(json.Replace(payload.ArchiveSha256, "invalid", StringComparison.Ordinal)));
|
|
RequirePlatform("14.6.1", "x86_64", "0");
|
|
Reject(() => RequirePlatform("13.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);
|
|
var summary = ValidateTrx(fixture.ToString(), ExpectedTests, started);
|
|
var plainTrx = Encoding.UTF8.GetBytes(fixture.ToString());
|
|
var bomTrx = new byte[] { 0xef, 0xbb, 0xbf }.Concat(plainTrx).ToArray();
|
|
ValidateTrx(bomTrx, ExpectedTests, started);
|
|
if (SHA256.HashData(plainTrx).SequenceEqual(SHA256.HashData(bomTrx))) throw new InvalidOperationException("TRX raw-byte hashing discarded its BOM.");
|
|
Reject(() => ValidateTrx(fixture.ToString(), ExpectedTests, started.AddMinutes(2)));
|
|
XNamespace ns = fixture.Root!.Name.Namespace;
|
|
var skipped = new XDocument(fixture);
|
|
skipped.Descendants(ns + "UnitTestResult").First().SetAttributeValue("outcome", "NotExecuted");
|
|
Reject(() => ValidateTrx(skipped.ToString(), ExpectedTests, started));
|
|
var missingNative = new XDocument(fixture);
|
|
missingNative.Descendants(ns + "TestMethod").First().SetAttributeValue("name", "ManagedReplacement");
|
|
Reject(() => ValidateTrx(missingNative.ToString(), ExpectedTests, started));
|
|
var duplicate = new XDocument(fixture);
|
|
duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0");
|
|
Reject(() => ValidateTrx(duplicate.ToString(), ExpectedTests, started));
|
|
var repeatedManaged = new XDocument(fixture);
|
|
repeatedManaged.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("testId", "test-5");
|
|
Reject(() => ValidateTrx(repeatedManaged.ToString(), ExpectedTests, 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(), ExpectedTests, started));
|
|
var missingDefinition = new XDocument(fixture);
|
|
missingDefinition.Descendants(ns + "UnitTest").Last().Remove();
|
|
Reject(() => ValidateTrx(missingDefinition.ToString(), ExpectedTests, started));
|
|
var theoryRows = new XDocument(fixture);
|
|
foreach (var method in theoryRows.Descendants(ns + "TestMethod").Skip(RequiredNativeTests.Length).Take(2))
|
|
method.SetAttributeValue("name", "TheoryWithDistinctRowIds");
|
|
ValidateTrx(theoryRows.ToString(), ExpectedTests, started);
|
|
var counters = new XDocument(fixture);
|
|
counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572");
|
|
Reject(() => ValidateTrx(counters.ToString(), ExpectedTests, started));
|
|
var aborted = new XDocument(fixture);
|
|
aborted.Descendants(ns + "ResultSummary").Single().SetAttributeValue("outcome", "Aborted");
|
|
Reject(() => ValidateTrx(aborted.ToString(), ExpectedTests, 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, "14.6.1", "x86_64", SdkVersion, ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, artifacts, 0, new string('e', 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 { NativeTests = RequiredNativeTests[..4] }, result with { NativeArtifacts = artifacts[..3] }, result with { NativeArtifacts = [artifacts[0] with { Architecture = "arm64" }, .. artifacts[1..]] }, result with { TrxSha256 = "" }, result with { SdkVersion = "10.0.100" }, result with { OsVersion = "13.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 < ExpectedTests; index++)
|
|
{
|
|
var identity = index < RequiredNativeTests.Length ? RequiredNativeTests[index] : "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)..]))));
|
|
results.Add(new XElement(ns + "UnitTestResult", new XAttribute("testId", "test-" + index), new XAttribute("executionId", "execution-" + index), new XAttribute("outcome", "Passed")));
|
|
}
|
|
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", ExpectedTests), new XAttribute("executed", ExpectedTests), new XAttribute("passed", ExpectedTests), 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, int ExpectedTests);
|
|
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, string TrxSha256, string Reason, DateTimeOffset StartedUtc, DateTimeOffset CompletedUtc);
|
|
sealed record CommandResult(int ExitCode, string Output, string Error);
|
|
}
|