forked from Manuel/meeting-assistant
ci: prepare source-bound native macOS build and tests under TCG
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
name: Native macOS build and tests on Ubuntu (experimental)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos-native-full:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||||
|
DOTNET_NOLOGO: "1"
|
||||||
|
steps:
|
||||||
|
- name: Checkout exact native CI candidate
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Setup .NET orchestration SDK
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
- name: Run owned macOS 14 TCG guest and all native tests
|
||||||
|
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||||
|
- name: Always remove only this run's owned resources
|
||||||
|
if: always()
|
||||||
|
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||||
|
- name: Retain native build, signatures, TRX and guest receipts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: native-macos-full
|
||||||
|
path: artifacts/native-macos-full/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -8,6 +8,7 @@ on:
|
|||||||
branches-ignore:
|
branches-ignore:
|
||||||
- codex/macos-ci-kvm-compatibility
|
- codex/macos-ci-kvm-compatibility
|
||||||
- codex/macos-ci-tcg-supported
|
- codex/macos-ci-tcg-supported
|
||||||
|
- codex/macos-native-full-tcg
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -136,6 +137,34 @@ jobs:
|
|||||||
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
||||||
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
||||||
|
|
||||||
|
macos-native-full:
|
||||||
|
needs: [build-and-test, portable-build-and-test]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||||
|
DOTNET_NOLOGO: "1"
|
||||||
|
steps:
|
||||||
|
- name: Checkout exact native CI candidate
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Setup .NET orchestration SDK
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
- name: Run owned macOS 14 TCG guest and all native tests
|
||||||
|
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||||
|
- name: Always remove only this run's owned resources
|
||||||
|
if: always()
|
||||||
|
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||||
|
- name: Retain native build, signatures, TRX and guest receipts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: native-macos-full
|
||||||
|
path: artifacts/native-macos-full/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
portable-build-and-test:
|
portable-build-and-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -169,13 +169,13 @@ Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-e
|
|||||||
|
|
||||||
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
|
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
|
||||||
|
|
||||||
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. No additional runner labels or host devices are required.
|
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. After both jobs pass, the prepared workflow requires a native macOS job on the same existing runner label and Docker daemon, using software CPU emulation without host devices or added capabilities.
|
||||||
|
|
||||||
Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests requiring the native macOS environment report an explicit skip through `MacOsFact`; they must also be run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`. That build compiles and signs the helpers, and the suite checks packaging, helper self-tests, and native image cropping. Real microphone/system-audio capture and privacy permissions still require the operational checks described above.
|
The Ubuntu managed-test job does not compile the Swift helpers or execute Apple frameworks; its native macOS tests report an explicit skip through `MacOsFact`. The prepared native job runs an owned macOS 14+ x86_64 guest under TCG on the existing Ubuntu Docker runner. Before downloading Recovery, the actual pinned QEMU binary must execute an AVX/AVX2 instruction probe. The full flow builds all four native helpers, verifies the audio app's signature, and requires all 577 tests to pass with zero skips, including all five native macOS tests. It has a 180-minute job limit with retained evidence and ownership-checked cleanup. Recovery, installation, toolchain operation and this CI path remain unqualified until actual remote guests produce every required receipt. Native tests can also run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`; real microphone/system-audio capture and privacy permissions still require the operational checks described above.
|
||||||
|
|
||||||
[Docker-OSX](https://github.com/sickcodes/Docker-OSX) runs a macOS VM rather than providing a Wine-style compatibility layer. Its launcher supports software emulation with `KVM=accel=tcg`, so KVM is not an absolute requirement. A supported .NET 10 guest needs macOS 14 or later plus the Swift build tools. The documented `auto` build downloads a preinstalled guest disk through `IMAGE_URL`; its documented ready-made tags and disk downloads were unavailable when checked on 2026-10-03. No verified native guest bootstrap is owned by this repository. CI validates source; it does not publish or deploy the workstation application.
|
The separate manual `.gitea/workflows/macos-native-full.yaml` retains the same Full flow as a diagnostic entry point. CI validates source; it does not publish or deploy the workstation application.
|
||||||
|
|
||||||
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness through an unprivileged TCG guest on the existing Ubuntu Docker runner. It neither installs macOS nor runs application tests; its result is a prerequisite for a future native test job, not verification of macOS CI support.
|
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness before qualifying the prepared Full flow. It neither installs macOS nor runs application tests. A green Recovery diagnostic alone does not verify macOS build/test CI support; each Full run repeats Recovery readiness for its own guest and disk.
|
||||||
|
|
||||||
## Operations And Limitations
|
## Operations And Limitations
|
||||||
|
|
||||||
|
|||||||
@@ -44,3 +44,21 @@ The container retains 6 GiB memory/swap, two-CPU limit, 512 MiB shared memory an
|
|||||||
Evidence retains run/source/profile identity, CPU preflight, original/patched Recovery identity, container/QEMU state, native proof/result and cleanup. Optional bounded before/during/after pressure snapshots record host/cgroup counters. The /storage/14/setup.dmg hash is captured once after staging; successful evidence survives later capture failure. Screenshots/pressure observations pass no gate.
|
Evidence retains run/source/profile identity, CPU preflight, original/patched Recovery identity, container/QEMU state, native proof/result and cleanup. Optional bounded before/during/after pressure snapshots record host/cgroup counters. The /storage/14/setup.dmg hash is captured once after staging; successful evidence survives later capture failure. Screenshots/pressure observations pass no gate.
|
||||||
|
|
||||||
Both cleanup paths verify exact token/label/ID before removing only the owned container, anonymous volume and image. No pruning, host changes, original checkout changes or Meeting Assistant restart occurs. Artifacts remain seven days. Full CI remains unverified until an installed supported guest builds/signs fresh helpers and passes all 577 tests, including the five native macOS tests, with zero skips.
|
Both cleanup paths verify exact token/label/ID before removing only the owned container, anonymous volume and image. No pruning, host changes, original checkout changes or Meeting Assistant restart occurs. Artifacts remain seven days. Full CI remains unverified until an installed supported guest builds/signs fresh helpers and passes all 577 tests, including the five native macOS tests, with zero skips.
|
||||||
|
|
||||||
|
## Prepared full build/test flow
|
||||||
|
|
||||||
|
The separate manual `.gitea/workflows/macos-native-full.yaml` and the prepared required PR job invoke the same full mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --full --source /path/to/clean/pinned/dockur-clone --output /path/to/fresh/full-validation
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||||
|
```
|
||||||
|
|
||||||
|
Full mode repeats CPU preflight and Recovery readiness for its own fresh guest. Before erasing the disposable target, the host verifies the owned container/anonymous volume, raw 64-GiB image, actual QEMU attachment/unique disk serial and state share. A token/source/disk-bound permit authorizes the guest. The guest independently checks whole/writable/size/unique serial before `diskutil eraseDisk`. It never erases a host disk or reuses an unrelated guest volume.
|
||||||
|
|
||||||
|
The installer provisions the owned guest and returns into the prepared firstboot hook. Early firstboot logs and failures enter the state share even before account-package installation or test bootstrap. The installed root must be APFS backed by the exact owned physical store. Apple softwareupdate provisions CLT; a real Swift/SDK smoke build imports the required Apple frameworks. The source archive is bound to clean Git HEAD and SHA256; the pinned macOS x64 .NET SDK 10.0.401 is checked with SHA512. No prebuilt application/helper result counts as this run's evidence.
|
||||||
|
|
||||||
|
`tools/ci/MacOsNativeGuest.cs` restores/builds/tests the source inside the installed guest. Success requires four fresh x86_64 Mach-O helpers, a valid audio-app signature and a fresh source-bound TRX containing exactly 577 distinct passing tests, zero failures/skips and all five named native macOS tests. Archive, SDK, build, signature and TRX receipts are retained. The required PR job follows the existing Wine and portable jobs; all jobs still select `ubuntu-latest`.
|
||||||
|
|
||||||
|
The workflow has 180 minutes; the controller reserves cleanup time with a shared 172-minute total deadline. Recovery, installation, CLT and test caps are 90/80/30/25 minutes under that same total, not additive promises. Actual supported-guest installation/performance and remote test success remain unqualified. Do not start this prepared installer until the separate prerequisite diagnostic passes. CI does not deploy or restart the workstation application.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ static class NativeDiagnostic
|
|||||||
const long GuestDiskBytes = 64L * 1024 * 1024 * 1024;
|
const long GuestDiskBytes = 64L * 1024 * 1024 * 1024;
|
||||||
const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024;
|
const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024;
|
||||||
const int MaximumCapturedCharacters = 8 * 1024 * 1024;
|
const int MaximumCapturedCharacters = 8 * 1024 * 1024;
|
||||||
|
const string SdkVersion = "10.0.401";
|
||||||
|
const string SdkSha512 = "33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0";
|
||||||
|
const string FullState = "/storage/14/ci-state";
|
||||||
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
||||||
const string OriginalBootstrap = "[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m\n";
|
const string OriginalBootstrap = "[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m\n";
|
||||||
const string MountOnlyBootstrap = "[ ! -e /tmp/m ]&& /sbin/mount_9p installstate >/dev/null 2>&1; : >/tmp/m\n";
|
const string MountOnlyBootstrap = "[ ! -e /tmp/m ]&& /sbin/mount_9p installstate >/dev/null 2>&1; : >/tmp/m\n";
|
||||||
@@ -58,22 +61,25 @@ static class NativeDiagnostic
|
|||||||
{
|
{
|
||||||
if (args.Length == 0 || args.Contains("--help"))
|
if (args.Length == 0 || args.Contains("--help"))
|
||||||
{
|
{
|
||||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--output artifacts/native-macos] [--source existing-dockur-clone]");
|
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--full] [--output artifacts/native-macos] [--source existing-dockur-clone] [--compression-chunk readonly-qualified-chunk]");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos");
|
var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos");
|
||||||
|
var full = args.Contains("--full");
|
||||||
if (args.Contains("--validate"))
|
if (args.Contains("--validate"))
|
||||||
{
|
{
|
||||||
ValidateContracts();
|
ValidateContracts();
|
||||||
|
if (full) ValidateFullContracts();
|
||||||
if (Option(args, "--source") is { } source)
|
if (Option(args, "--source") is { } source)
|
||||||
{
|
{
|
||||||
await PrepareSource(Path.GetFullPath(source), output, "validation", false, CancellationToken.None);
|
await PrepareSource(Path.GetFullPath(source), output, full ? new string('0', 32) : "validation", false, CancellationToken.None, full);
|
||||||
await ValidateResourceRetention(output);
|
await ValidateResourceRetention(output);
|
||||||
await ValidateRecoveryPatch(output);
|
await ValidateRecoveryPatch(output);
|
||||||
await ValidateTcgPreflight(output, CancellationToken.None);
|
await ValidateTcgPreflight(output, CancellationToken.None);
|
||||||
|
if (full) await ValidateDiskSerialParser(output);
|
||||||
Save(Path.Combine(output, "validation.json"), new
|
Save(Path.Combine(output, "validation.json"), new
|
||||||
{
|
{
|
||||||
success = true, profile = Profile,
|
success = true, profile = Profile, mode = full ? "full" : "readiness",
|
||||||
helperSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "MacOsNativeDiagnostic.cs"))),
|
helperSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "MacOsNativeDiagnostic.cs"))),
|
||||||
udifChecksumBindingSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-udif-checksums.py"))),
|
udifChecksumBindingSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-udif-checksums.py"))),
|
||||||
baselineReadinessNormalized = true, readinessDiagnosticBlocksExcluded = 7,
|
baselineReadinessNormalized = true, readinessDiagnosticBlocksExcluded = 7,
|
||||||
@@ -82,9 +88,13 @@ static class NativeDiagnostic
|
|||||||
resultNegativeCases = 6, containerNegativeCases = 11, recoveryPositiveCases = 4, recoveryNegativeCases = 12,
|
resultNegativeCases = 6, containerNegativeCases = 11, recoveryPositiveCases = 4, recoveryNegativeCases = 12,
|
||||||
independentFixtureCrc32Readback = true, resourceSnapshotRetention = true, cpuProfileSourceContractsVerified = true,
|
independentFixtureCrc32Readback = true, resourceSnapshotRetention = true, cpuProfileSourceContractsVerified = true,
|
||||||
preflightGateFixtureCases = 8, qemuRuntimePreflightExecuted = false, templateIsoDownloaded = false,
|
preflightGateFixtureCases = 8, qemuRuntimePreflightExecuted = false, templateIsoDownloaded = false,
|
||||||
|
fullResultContractsVerified = full, fullBootstrapFixtureCases = full ? 12 : 0,
|
||||||
|
installerGuardFixtureCases = full ? 10 : 0, firstbootEvidenceFixtureCases = full ? 4 : 0, ownedDiskSerialFixtureCases = full ? 6 : 0,
|
||||||
sourceModified = false, dockerExecuted = false, guestExecuted = false, completedUtc = DateTimeOffset.UtcNow
|
sourceModified = false, dockerExecuted = false, guestExecuted = false, completedUtc = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (full && Option(args, "--source") is null) await ValidateDiskSerialParser(output);
|
||||||
|
if (Option(args, "--compression-chunk") is { } chunk) await ValidateCompression(Path.GetFullPath(chunk), output);
|
||||||
Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred.");
|
Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -100,7 +110,9 @@ static class NativeDiagnostic
|
|||||||
Save(statePath, state);
|
Save(statePath, state);
|
||||||
Directory.CreateDirectory(work);
|
Directory.CreateDirectory(work);
|
||||||
File.WriteAllText(Path.Combine(work, "run.owner"), token);
|
File.WriteAllText(Path.Combine(work, "run.owner"), token);
|
||||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(90));
|
// Full leaves eight minutes within the existing three-hour job for capture/cleanup.
|
||||||
|
var deadlineMinutes = full ? 172 : 90;
|
||||||
|
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(deadlineMinutes));
|
||||||
using var signal = OperatingSystem.IsLinux() ? PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); }) : null;
|
using var signal = OperatingSystem.IsLinux() ? PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); }) : null;
|
||||||
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||||
Console.CancelKeyPress += cancelHandler;
|
Console.CancelKeyPress += cancelHandler;
|
||||||
@@ -112,7 +124,7 @@ static class NativeDiagnostic
|
|||||||
throw new InvalidOperationException("This diagnostic runs on the existing Linux/x64 runner only.");
|
throw new InvalidOperationException("This diagnostic runs on the existing Linux/x64 runner only.");
|
||||||
ValidateContracts();
|
ValidateContracts();
|
||||||
var sourceCommit = (await Command("git", ["rev-parse", "HEAD"], output, "candidate-commit", deadline.Token)).Output.Trim();
|
var sourceCommit = (await Command("git", ["rev-parse", "HEAD"], output, "candidate-commit", deadline.Token)).Output.Trim();
|
||||||
Save(Path.Combine(output, "run-metadata.json"), new { token, startedUtc = DateTimeOffset.UtcNow, sourceCommit, dockurCommit = DockurCommit, profile = Profile, causalSingleVariableTest = false, kvm = false, cpuModel = CpuModel, recoveryMajor = 14, cpuFlags = CpuFlags, runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), server = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL"), architecture = RuntimeInformation.ProcessArchitecture.ToString(), deadlineMinutes = 90 });
|
Save(Path.Combine(output, "run-metadata.json"), new { token, startedUtc = DateTimeOffset.UtcNow, sourceCommit, dockurCommit = DockurCommit, profile = Profile, causalSingleVariableTest = false, kvm = false, cpuModel = CpuModel, recoveryMajor = 14, cpuFlags = CpuFlags, runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), server = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL"), architecture = RuntimeInformation.ProcessArchitecture.ToString(), deadlineMinutes, mode = full ? "full" : "readiness" });
|
||||||
var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token);
|
var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token);
|
||||||
using (var document = JsonDocument.Parse(info.Output))
|
using (var document = JsonDocument.Parse(info.Output))
|
||||||
{
|
{
|
||||||
@@ -131,13 +143,17 @@ static class NativeDiagnostic
|
|||||||
await Command("git", ["-C", source, "checkout", "--detach", DockurCommit], output, "dockur-checkout", deadline.Token);
|
await Command("git", ["-C", source, "checkout", "--detach", DockurCommit], output, "dockur-checkout", deadline.Token);
|
||||||
var actualCommit = (await Command("git", ["-C", source, "rev-parse", "HEAD"], output, "dockur-commit", deadline.Token)).Output.Trim();
|
var actualCommit = (await Command("git", ["-C", source, "rev-parse", "HEAD"], output, "dockur-commit", deadline.Token)).Output.Trim();
|
||||||
if (actualCommit != DockurCommit) throw new InvalidOperationException("Dockur source pin mismatch.");
|
if (actualCommit != DockurCommit) throw new InvalidOperationException("Dockur source pin mismatch.");
|
||||||
await PrepareSource(source, output, token, true, deadline.Token);
|
if (full) await PreparePayload(source, output, token, sourceCommit, deadline.Token);
|
||||||
|
await PrepareSource(source, output, token, true, deadline.Token, full);
|
||||||
await Command("docker", ["build", "--platform", "linux/amd64", "--label", OwnerLabel + "=" + token, "--tag", state.ImageTag, source], output, "docker-build", deadline.Token, echo: true);
|
await Command("docker", ["build", "--platform", "linux/amd64", "--label", OwnerLabel + "=" + token, "--tag", state.ImageTag, source], output, "docker-build", deadline.Token, echo: true);
|
||||||
var imageInspect = await Command("docker", ["image", "inspect", state.ImageTag], output, "image-inspect", deadline.Token);
|
var imageInspect = await Command("docker", ["image", "inspect", state.ImageTag], output, "image-inspect", deadline.Token);
|
||||||
using (var image = JsonDocument.Parse(imageInspect.Output))
|
using (var image = JsonDocument.Parse(imageInspect.Output))
|
||||||
state = state with { ImageId = image.RootElement[0].GetProperty("Id").GetString() };
|
state = state with { ImageId = image.RootElement[0].GetProperty("Id").GetString() };
|
||||||
Save(statePath, state);
|
Save(statePath, state);
|
||||||
var create = await Command("docker", ["create", "--name", state.ContainerName, "--label", OwnerLabel + "=" + token, "--memory", "6g", "--memory-swap", "6g", "--cpus", "2", "--shm-size", "512m", "--log-opt", "max-size=8m", "--log-opt", "max-file=1", "--env", "KVM=N", "--env", "CPU_MODEL=" + CpuModel, "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=14", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2", state.ImageTag], output, "docker-create", deadline.Token);
|
List<string> createArguments = ["create", "--name", state.ContainerName, "--label", OwnerLabel + "=" + token, "--memory", "6g", "--memory-swap", "6g", "--cpus", "2", "--shm-size", "512m", "--log-opt", "max-size=8m", "--log-opt", "max-file=1", "--env", "KVM=N", "--env", "CPU_MODEL=" + CpuModel, "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=14", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2"];
|
||||||
|
if (full) createArguments.AddRange(["--env", "ALLOCATE=N", "--env", "DISK_OPTIONS=serial=" + DiskSerial(token)]);
|
||||||
|
createArguments.Add(state.ImageTag);
|
||||||
|
var create = await Command("docker", createArguments.ToArray(), output, "docker-create", deadline.Token);
|
||||||
var id = create.Output.Trim();
|
var id = create.Output.Trim();
|
||||||
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$")) throw new InvalidOperationException("Docker did not return a container identity.");
|
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$")) throw new InvalidOperationException("Docker did not return a container identity.");
|
||||||
state = state with { ContainerId = id };
|
state = state with { ContainerId = id };
|
||||||
@@ -146,14 +162,19 @@ static class NativeDiagnostic
|
|||||||
AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token);
|
AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token);
|
||||||
await Command("docker", ["start", id], output, "docker-start", deadline.Token);
|
await Command("docker", ["start", id], output, "docker-start", deadline.Token);
|
||||||
await CapturePressure(id, output, "before", deadline.Token);
|
await CapturePressure(id, output, "before", deadline.Token);
|
||||||
Console.WriteLine("The owned unprivileged TCG/Haswell macOS 14 guest is starting. Success requires native macOS 14+/x86_64 and a writable 64-GiB disk; no installer will run. This is not a single-variable causal test.");
|
Console.WriteLine(full ? "The owned unprivileged TCG/Haswell macOS 14 guest is starting. Installation requires fresh native readiness and an owned-disk permit; success requires all 577 tests with zero skips." : "The owned unprivileged TCG/Haswell macOS 14 guest is starting. Success requires native macOS 14+/x86_64 and a writable 64-GiB disk; no installer will run.");
|
||||||
var recoveryStarted = Stopwatch.StartNew();
|
var phaseStarted = Stopwatch.StartNew();
|
||||||
|
var phase = "recovery";
|
||||||
|
var phaseBudget = TimeSpan.FromMinutes(90);
|
||||||
|
var permitted = false;
|
||||||
var heartbeat = Stopwatch.StartNew();
|
var heartbeat = Stopwatch.StartNew();
|
||||||
var diskPressureCaptured = false;
|
var diskPressureCaptured = false;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
deadline.Token.ThrowIfCancellationRequested();
|
deadline.Token.ThrowIfCancellationRequested();
|
||||||
await CaptureGuest(id, output, deadline.Token);
|
await CaptureGuest(id, output, deadline.Token, full);
|
||||||
|
if (full && phaseStarted.Elapsed > phaseBudget)
|
||||||
|
throw new InvalidOperationException("The bounded native " + phase + " phase exceeded " + phaseBudget.TotalMinutes + " minutes.");
|
||||||
var proofPath = Path.Combine(output, "guest-proof.log");
|
var proofPath = Path.Combine(output, "guest-proof.log");
|
||||||
if (!diskPressureCaptured && File.Exists(proofPath) && File.ReadAllText(proofPath).Contains("[proof-start] disks", StringComparison.Ordinal))
|
if (!diskPressureCaptured && File.Exists(proofPath) && File.ReadAllText(proofPath).Contains("[proof-start] disks", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
@@ -161,19 +182,52 @@ static class NativeDiagnostic
|
|||||||
await CapturePressure(id, output, "during", deadline.Token);
|
await CapturePressure(id, output, "during", deadline.Token);
|
||||||
}
|
}
|
||||||
var resultPath = Path.Combine(output, "guest-result.json");
|
var resultPath = Path.Combine(output, "guest-result.json");
|
||||||
if (File.Exists(resultPath))
|
if (File.Exists(resultPath) && !permitted)
|
||||||
{
|
{
|
||||||
var result = File.ReadAllText(resultPath);
|
var result = File.ReadAllText(resultPath);
|
||||||
ValidateResult(result, token);
|
ValidateResult(result, token);
|
||||||
Console.WriteLine("Native Recovery readiness passed. This run has not installed macOS, .NET, CLT, or run Meeting Assistant tests.");
|
if (full)
|
||||||
outcome = "readiness-passed";
|
{
|
||||||
break;
|
await PermitInstallation(id, output, token, sourceCommit, result, deadline.Token);
|
||||||
|
permitted = true;
|
||||||
|
phase = "installation";
|
||||||
|
phaseBudget = TimeSpan.FromMinutes(80);
|
||||||
|
phaseStarted.Restart();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("Native Recovery readiness passed. This run has not installed macOS, .NET, CLT, or run Meeting Assistant tests.");
|
||||||
|
outcome = "readiness-passed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (full)
|
||||||
|
{
|
||||||
|
var phasePath = Path.Combine(output, "guest-phase.json");
|
||||||
|
if (File.Exists(phasePath))
|
||||||
|
{
|
||||||
|
using var nativePhase = JsonDocument.Parse(File.ReadAllText(phasePath));
|
||||||
|
if (nativePhase.RootElement.GetProperty("token").GetString() != token) throw new InvalidOperationException("Stale native phase receipt.");
|
||||||
|
var current = nativePhase.RootElement.GetProperty("phase").GetString();
|
||||||
|
var next = !permitted ? phase : current == "toolchain-installing" ? "toolchain" : current is "tests-running" or "tests-passed" ? "tests" : phase;
|
||||||
|
if (next != phase) { phase = next; phaseBudget = TimeSpan.FromMinutes(next == "toolchain" ? 30 : 25); phaseStarted.Restart(); Console.WriteLine("[native-diagnostic] phase: " + phase); }
|
||||||
|
if (current is "tests-failed" or "bootstrap-failed" or "installation-failed") throw new InvalidOperationException("Guest phase failed: " + current);
|
||||||
|
}
|
||||||
|
var fullResult = Path.Combine(output, "full-result.json");
|
||||||
|
if (permitted && File.Exists(fullResult))
|
||||||
|
{
|
||||||
|
ValidateFullResult(File.ReadAllText(fullResult), token, sourceCommit, File.ReadAllText(Path.Combine(output, "archive.sha256")).Trim());
|
||||||
|
ValidateTrx(File.ReadAllBytes(Path.Combine(output, "native.trx")), File.ReadAllText(fullResult));
|
||||||
|
outcome = "native-tests-passed";
|
||||||
|
Console.WriteLine("Native macOS 577/577 tests passed, including all five native tests, with fresh Mach-O/x86_64 and codesign evidence.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token);
|
var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token);
|
||||||
if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result.");
|
if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result.");
|
||||||
if (heartbeat.Elapsed >= TimeSpan.FromSeconds(60))
|
if (heartbeat.Elapsed >= TimeSpan.FromSeconds(60))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[native-diagnostic] phase=recovery; elapsed={recoveryStarted.Elapsed.TotalMinutes:F1} minutes; container=running; readiness=pending");
|
Console.WriteLine($"[native-diagnostic] phase={phase}; elapsed={phaseStarted.Elapsed.TotalMinutes:F1}/{phaseBudget.TotalMinutes:F0} minutes; container=running; readiness={(permitted ? "passed" : "pending")}");
|
||||||
heartbeat.Restart();
|
heartbeat.Restart();
|
||||||
}
|
}
|
||||||
await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token);
|
await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token);
|
||||||
@@ -181,21 +235,22 @@ static class NativeDiagnostic
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
error = exception is OperationCanceledException ? "The explicit 90-minute diagnostic deadline or cancellation was reached." : exception.Message;
|
error = exception is OperationCanceledException ? $"The explicit {deadlineMinutes}-minute diagnostic deadline or cancellation was reached." : exception.Message;
|
||||||
Console.Error.WriteLine(error);
|
Console.Error.WriteLine(error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Console.CancelKeyPress -= cancelHandler;
|
Console.CancelKeyPress -= cancelHandler;
|
||||||
using var captureDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
using var captureDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||||
try { await CaptureGuest(state.ContainerId ?? state.ContainerName, output, captureDeadline.Token, true, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Final evidence capture: " + exception.Message); }
|
try { await CaptureGuest(state.ContainerId ?? state.ContainerName, output, captureDeadline.Token, full, true, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Final evidence capture: " + exception.Message); }
|
||||||
await CapturePressure(state.ContainerId ?? state.ContainerName, output, "after", captureDeadline.Token);
|
await CapturePressure(state.ContainerId ?? state.ContainerName, output, "after", captureDeadline.Token);
|
||||||
try { PrintGuestProof(output, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Native proof output: " + exception.Message); }
|
try { PrintGuestProof(output, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Native proof output: " + exception.Message); }
|
||||||
|
if (full) try { PrintFullProof(output, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Full native proof output: " + exception.Message); }
|
||||||
var clean = await Cleanup(output);
|
var clean = await Cleanup(output);
|
||||||
if (!clean) { outcome = "failed"; error = (error ?? "") + " Owned-resource cleanup failed; inspect cleanup evidence."; }
|
if (!clean) { outcome = "failed"; error = (error ?? "") + " Owned-resource cleanup failed; inspect cleanup evidence."; }
|
||||||
Save(Path.Combine(output, "outcome.json"), new { token, outcome, error, completedUtc = DateTimeOffset.UtcNow });
|
Save(Path.Combine(output, "outcome.json"), new { token, outcome, error, completedUtc = DateTimeOffset.UtcNow });
|
||||||
}
|
}
|
||||||
return outcome == "readiness-passed" ? 0 : 1;
|
return outcome is "readiness-passed" or "native-tests-passed" ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static string? Option(string[] args, string name)
|
static string? Option(string[] args, string name)
|
||||||
@@ -270,7 +325,7 @@ static class NativeDiagnostic
|
|||||||
return source.Remove(from, to + end.Length - from).Insert(from, originalSequence);
|
return source.Remove(from, to + end.Length - from).Insert(from, originalSequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task PrepareSource(string source, string output, string token, bool writeSource, CancellationToken cancellation)
|
static async Task PrepareSource(string source, string output, string token, bool writeSource, CancellationToken cancellation, bool full = false)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(output);
|
Directory.CreateDirectory(output);
|
||||||
var patchPath = Path.Combine(source, "src/install/recovery/patch.py");
|
var patchPath = Path.Combine(source, "src/install/recovery/patch.py");
|
||||||
@@ -304,13 +359,21 @@ static class NativeDiagnostic
|
|||||||
entry = ReplaceOnce(entry, "trap - ERR\n", "[[ \"$KVM_OPTS\" == ' -accel tcg,thread=multi' && \"$CPU_FLAGS\" == '" + CpuFlags + "' && \"$CPU_OPTS\" == \"-cpu $CPU_FLAGS -smp $SMP\" ]] || { error 'Supported profile refuses a CPU/accelerator fallback.'; exit 1; }\ninfo '[supported-profile] accelerator=tcg cpu=Haswell-noTSX recovery=14; AVX/AVX2 preflight passed; native guest gates still pending'\n\ntrap - ERR\n");
|
entry = ReplaceOnce(entry, "trap - ERR\n", "[[ \"$KVM_OPTS\" == ' -accel tcg,thread=multi' && \"$CPU_FLAGS\" == '" + CpuFlags + "' && \"$CPU_OPTS\" == \"-cpu $CPU_FLAGS -smp $SMP\" ]] || { error 'Supported profile refuses a CPU/accelerator fallback.'; exit 1; }\ninfo '[supported-profile] accelerator=tcg cpu=Haswell-noTSX recovery=14; AVX/AVX2 preflight passed; native guest gates still pending'\n\ntrap - ERR\n");
|
||||||
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
||||||
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
||||||
var wrapper = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"));
|
var wrapper = File.ReadAllText(Path.Combine("tools", "ci", full ? "macos-native-full-bootstrap.sh" : "macos-native-bootstrap.sh"));
|
||||||
|
if (full) wrapper = ReplaceOnce(wrapper, "@@PROOF_TOKEN@@", token);
|
||||||
var imagePath = Path.Combine(source, "src", "image.sh");
|
var imagePath = Path.Combine(source, "src", "image.sh");
|
||||||
var originalImage = File.ReadAllText(imagePath);
|
var originalImage = File.ReadAllText(imagePath);
|
||||||
if (Hash(Encoding.UTF8.GetBytes(originalImage)) != "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c") throw new InvalidOperationException("Pinned Recovery staging script hash mismatch.");
|
if (Hash(Encoding.UTF8.GetBytes(originalImage)) != "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c") throw new InvalidOperationException("Pinned Recovery staging script hash mismatch.");
|
||||||
var image = ReplaceOnce(originalImage, " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\"; then\n", " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cp -f \"$IMAGE_TOOLS/recovery/readiness.sh\" \"${script%/*}/readiness.sh\"; then\n");
|
var image = ReplaceOnce(originalImage, " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\"; then\n", " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cp -f \"$IMAGE_TOOLS/recovery/readiness.sh\" \"${script%/*}/readiness.sh\"; then\n");
|
||||||
image = ReplaceOnce(image, " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n", " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cmp -s \"$IMAGE_TOOLS/recovery/readiness.sh\" \"$state/readiness.sh\" ||\n");
|
image = ReplaceOnce(image, " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n", " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cmp -s \"$IMAGE_TOOLS/recovery/readiness.sh\" \"$state/readiness.sh\" ||\n");
|
||||||
image = ReplaceOnce(image, " if ! result=$(python3 \"$IMAGE_TOOLS/recovery/patch.py\" \"$image\"); then\n", " info \"[recovery-original] bytes=$(stat -c%s -- \"$image\") sha256=$(sha256sum \"$image\" | awk '{print $1}')\"\n if ! result=$(python3 \"$IMAGE_TOOLS/recovery/patch.py\" \"$image\"); then\n");
|
image = ReplaceOnce(image, " if ! result=$(python3 \"$IMAGE_TOOLS/recovery/patch.py\" \"$image\"); then\n", " info \"[recovery-original] bytes=$(stat -c%s -- \"$image\") sha256=$(sha256sum \"$image\" | awk '{print $1}')\"\n if ! result=$(python3 \"$IMAGE_TOOLS/recovery/patch.py\" \"$image\"); then\n");
|
||||||
|
if (full)
|
||||||
|
{
|
||||||
|
await PrepareFullSource(source, output, token, writeSource, cancellation);
|
||||||
|
hook = CreateFullReadiness(hook);
|
||||||
|
dockerfile += "\n# Payload stays inside this image and its owned anonymous storage volume.\nCOPY ci-payload/ /assets/ci-payload/\n";
|
||||||
|
entry = ReplaceOnce(entry, "free_kib >= 8 * 1024 * 1024", "free_kib >= 32 * 1024 * 1024").Replace("8-GiB diagnostic budget", "32-GiB full-run budget", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("recoveryosd-13-original.plist", OriginalDaemon13), ("recoveryosd-13-diagnostic.plist", DiagnosticDaemon13), ("early-bootstrap.sh", MountOnlyBootstrap), ("boot.sh.patched", boot), ("opencore-config.plist", File.ReadAllText(Path.Combine(source, "assets/config.plist"))), ("cpu.sh.patched", cpu), ("ci-cpu-preflight.asm", preflight) })
|
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("recoveryosd-13-original.plist", OriginalDaemon13), ("recoveryosd-13-diagnostic.plist", DiagnosticDaemon13), ("early-bootstrap.sh", MountOnlyBootstrap), ("boot.sh.patched", boot), ("opencore-config.plist", File.ReadAllText(Path.Combine(source, "assets/config.plist"))), ("cpu.sh.patched", cpu), ("ci-cpu-preflight.asm", preflight) })
|
||||||
File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false));
|
File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false));
|
||||||
Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "udif_checksums.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "recoveryosd-13-original.plist" or "recoveryosd-13-diagnostic.plist" or "early-bootstrap.sh" or "boot.sh.patched" or "opencore-config.plist" or "cpu.sh.patched" or "ci-cpu-preflight.asm").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "udif_checksums.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "recoveryosd-13-original.plist" or "recoveryosd-13-diagnostic.plist" or "early-bootstrap.sh" or "boot.sh.patched" or "opencore-config.plist" or "cpu.sh.patched" or "ci-cpu-preflight.asm").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
||||||
@@ -321,6 +384,7 @@ static class NativeDiagnostic
|
|||||||
await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation);
|
await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation);
|
||||||
await Command("bash", ["-n", Path.Combine(output, "boot.sh.patched")], output, "boot-staging-syntax", cancellation);
|
await Command("bash", ["-n", Path.Combine(output, "boot.sh.patched")], output, "boot-staging-syntax", cancellation);
|
||||||
await Command("bash", ["-n", Path.Combine(output, "cpu.sh.patched")], output, "cpu-composition-syntax", cancellation);
|
await Command("bash", ["-n", Path.Combine(output, "cpu.sh.patched")], output, "cpu-composition-syntax", cancellation);
|
||||||
|
if (full && !writeSource) await ValidateFullBootstrap(wrapper, output, token, cancellation);
|
||||||
if (!writeSource) return;
|
if (!writeSource) return;
|
||||||
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
||||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/udif_checksums.py"), checksumBinding, new UTF8Encoding(false));
|
File.WriteAllText(Path.Combine(source, "src/install/recovery/udif_checksums.py"), checksumBinding, new UTF8Encoding(false));
|
||||||
@@ -475,6 +539,531 @@ static class NativeDiagnostic
|
|||||||
}
|
}
|
||||||
|
|
||||||
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
||||||
|
static string DiskSerial(string token) => token[..20];
|
||||||
|
|
||||||
|
static async Task ValidateFullBootstrap(string wrapper, string output, string token, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
// Execute the complete generated shell with only its external filesystem,
|
||||||
|
// Apple daemon and probe-process boundaries mapped to harmless fixtures.
|
||||||
|
const string commit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||||
|
var ownMarker = token + ":" + commit + "\n";
|
||||||
|
var cases = new[]
|
||||||
|
{
|
||||||
|
(Name: "restart", Initial: (string?)null, ChildExit: 0, WriteFailure: false, Children: 1, Failure: false, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "already-owned", Initial: ownMarker, ChildExit: 0, WriteFailure: false, Children: 0, Failure: false, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "foreign-token", Initial: ownMarker.Replace(token, new string('f', 32)), ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "foreign-commit", Initial: ownMarker.Replace(commit, new string('f', 40)), ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "empty", Initial: "", ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "extra-record", Initial: ownMarker + ownMarker, ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "unterminated", Initial: ownMarker.TrimEnd('\n'), ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "write-failure", Initial: (string?)null, ChildExit: 0, WriteFailure: true, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "first-child-failure", Initial: (string?)null, ChildExit: 1, WriteFailure: false, Children: 1, Failure: true, MountAfter: 0, Owner: (string?)token),
|
||||||
|
(Name: "delayed-share", Initial: (string?)null, ChildExit: 0, WriteFailure: false, Children: 1, Failure: false, MountAfter: 3, Owner: (string?)token),
|
||||||
|
(Name: "missing-share", Initial: (string?)null, ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: -1, Owner: (string?)null),
|
||||||
|
(Name: "foreign-owner", Initial: (string?)null, ChildExit: 0, WriteFailure: false, Children: 0, Failure: true, MountAfter: 0, Owner: (string?)new string('f', 32))
|
||||||
|
};
|
||||||
|
foreach (var test in cases)
|
||||||
|
{
|
||||||
|
var state = Path.Combine(output, "full-bootstrap-" + test.Name + "-fixture");
|
||||||
|
if (Directory.Exists(state)) throw new InvalidOperationException("Full bootstrap fixtures require fresh validation output: " + state);
|
||||||
|
Directory.CreateDirectory(state);
|
||||||
|
if (test.MountAfter == 0 && test.Owner is not null) File.WriteAllText(Path.Combine(state, "run.owner"), test.Owner + "\n");
|
||||||
|
File.WriteAllText(Path.Combine(state, "source.commit"), commit + "\n");
|
||||||
|
var active = JsonSerializer.Serialize(new { token, phase = "installation" });
|
||||||
|
File.WriteAllText(Path.Combine(state, "guest-phase.json"), active);
|
||||||
|
var marker = Path.Combine(state, "probe.started");
|
||||||
|
if (test.Initial is not null) File.WriteAllText(marker, test.Initial);
|
||||||
|
var mapped = wrapper.Replace("/Volumes/installstate", state, StringComparison.Ordinal);
|
||||||
|
if (test.WriteFailure) mapped = ReplaceOnce(mapped, "MARKER=\"$STATE_DIR/probe.started\"", "MARKER=\"$STATE_DIR/absent-parent/probe.started\"");
|
||||||
|
var script = """
|
||||||
|
STATE_TEST="$1"; export STATE_TEST
|
||||||
|
CHILD_EXIT="$2"; export CHILD_EXIT
|
||||||
|
MOUNT_AFTER="$3"; MOUNT_OWNER="$4"; MOUNT_COMMIT="$5"
|
||||||
|
/sbin/mount_9p() {
|
||||||
|
local attempts=0
|
||||||
|
[ ! -f "$STATE_TEST/mount-attempts" ] || attempts=$(cat "$STATE_TEST/mount-attempts")
|
||||||
|
attempts=$((attempts + 1)); printf '%s\n' "$attempts" > "$STATE_TEST/mount-attempts"
|
||||||
|
if (( MOUNT_AFTER > 0 && attempts >= MOUNT_AFTER )); then
|
||||||
|
printf '%s\n' "$MOUNT_OWNER" > "$STATE_TEST/run.owner"
|
||||||
|
printf '%s\n' "$MOUNT_COMMIT" > "$STATE_TEST/source.commit"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
sleep() { [ "$1" = 1 ] || return 1; printf 'sleep\n' >> "$STATE_TEST/mount-sleeps.log"; }
|
||||||
|
/bin/bash() { cat "$STATE_TEST/probe.started" >> "$STATE_TEST/child-marker.log"; printf 'child\n' >> "$STATE_TEST/children.log"; return "$CHILD_EXIT"; }
|
||||||
|
exec() { cat "$STATE_TEST/probe.started" >> "$STATE_TEST/apple-marker.log" 2>/dev/null || :; printf '%s\n' "$*" >> "$STATE_TEST/apple-exec.log"; return 0; }
|
||||||
|
""" + "\n" + mapped + "\nwait\n";
|
||||||
|
var errors = "";
|
||||||
|
for (var start = 0; start < 2; start++)
|
||||||
|
errors += (await Command("bash", ["-c", script, "full-bootstrap-contract", state, test.ChildExit.ToString(), test.MountAfter.ToString(), test.Owner ?? "", commit], output, "full-bootstrap-" + test.Name + "-start-" + start, cancellation)).Error;
|
||||||
|
var childLog = Path.Combine(state, "children.log");
|
||||||
|
var children = File.Exists(childLog) ? File.ReadAllLines(childLog).Length : 0;
|
||||||
|
var appleExecutions = File.ReadAllLines(Path.Combine(state, "apple-exec.log"));
|
||||||
|
var phase = File.ReadAllText(Path.Combine(state, "guest-phase.json"));
|
||||||
|
var phaseUnchanged = phase == active;
|
||||||
|
using var receipt = JsonDocument.Parse(phase);
|
||||||
|
var realFailure = receipt.RootElement.GetProperty("token").GetString() == token && receipt.RootElement.GetProperty("phase").GetString() == "bootstrap-failed";
|
||||||
|
var markerPreserved = test.Initial is not null ? File.ReadAllText(marker) == test.Initial : test.Children == 0 ? !File.Exists(marker) : File.Exists(marker) && File.ReadAllText(marker) == ownMarker;
|
||||||
|
var completeBeforeExec = test.Initial is null && test.Children == 1 ? File.Exists(Path.Combine(state, "child-marker.log")) && File.ReadAllText(Path.Combine(state, "apple-marker.log")) == ownMarker + ownMarker && File.ReadAllText(Path.Combine(state, "child-marker.log")) == ownMarker : true;
|
||||||
|
var mountAttemptsPath = Path.Combine(state, "mount-attempts");
|
||||||
|
var mountAttempts = File.Exists(mountAttemptsPath) ? int.Parse(File.ReadAllText(mountAttemptsPath)) : 0;
|
||||||
|
var sleepPath = Path.Combine(state, "mount-sleeps.log");
|
||||||
|
var mountSleeps = File.Exists(sleepPath) ? File.ReadAllLines(sleepPath).Length : 0;
|
||||||
|
var expectedMounts = test.MountAfter < 0 ? 240 : test.MountAfter;
|
||||||
|
var failureReported = test.Owner == token ? realFailure : phaseUnchanged && errors.Contains("[full-bootstrap] ERROR:", StringComparison.Ordinal);
|
||||||
|
Save(Path.Combine(output, "full-bootstrap-" + test.Name + ".json"), new { children, appleExecutions = appleExecutions.Length, phaseUnchanged, realFailure, failureReported, markerPreserved, completeBeforeExec, mountAttempts, mountSleeps });
|
||||||
|
if (children != test.Children || appleExecutions.Length != 2 || appleExecutions.Any(value => value != "/usr/libexec/recoveryosd") || test.Failure && !failureReported || !test.Failure && !phaseUnchanged || !markerPreserved || !completeBeforeExec || mountAttempts != expectedMounts || mountSleeps != expectedMounts)
|
||||||
|
throw new InvalidOperationException($"Full bootstrap contract failed ({test.Name}): children={children}, appleExecutions={appleExecutions.Length}, phaseUnchanged={phaseUnchanged}, failureReported={failureReported}, markerPreserved={markerPreserved}, completeBeforeExec={completeBeforeExec}, mountAttempts={mountAttempts}, mountSleeps={mountSleeps}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task PreparePayload(string source, string output, string token, string commit, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
if (!System.Text.RegularExpressions.Regex.IsMatch(commit, "^[0-9a-f]{40}$")) throw new InvalidOperationException("Candidate commit is not an exact Git SHA.");
|
||||||
|
var status = await Command("git", ["status", "--porcelain", "--untracked-files=all"], output, "source-cleanliness", cancellation);
|
||||||
|
if (!string.IsNullOrEmpty(status.Output)) throw new InvalidOperationException("Full CI requires a clean exact HEAD; commit the reviewable candidate before running it.");
|
||||||
|
var payload = Path.Combine(source, "ci-payload");
|
||||||
|
Directory.CreateDirectory(payload);
|
||||||
|
var archive = Path.Combine(payload, "source.tar");
|
||||||
|
await Command("git", ["archive", "--format=tar", "--output", archive, commit], output, "source-archive", cancellation);
|
||||||
|
var archiveHash = Hash(File.ReadAllBytes(archive));
|
||||||
|
File.WriteAllText(Path.Combine(output, "archive.sha256"), archiveHash + "\n");
|
||||||
|
File.WriteAllText(Path.Combine(payload, "source.commit"), commit + "\n");
|
||||||
|
Save(Path.Combine(payload, "payload.json"), new { runToken = token, sourceCommit = commit, archiveSha256 = archiveHash, sdkVersion = SdkVersion, sdkSha512 = SdkSha512, expectedTests = 577 });
|
||||||
|
File.Copy(Path.Combine(payload, "payload.json"), Path.Combine(output, "payload.json"));
|
||||||
|
foreach (var pair in new[] { ("MacOsNativeGuest.cs", "MacOsNativeGuest.cs"), ("macos-native-firstboot.sh", "native-firstboot-bootstrap.sh"), ("macos-native-disk-guard.sh", "macos-native-disk-guard.sh") })
|
||||||
|
File.Copy(Path.Combine("tools", "ci", pair.Item1), Path.Combine(payload, pair.Item2));
|
||||||
|
Console.WriteLine("[native-diagnostic] pinned-sdk-download");
|
||||||
|
using var downloadDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
|
downloadDeadline.CancelAfter(TimeSpan.FromMinutes(15));
|
||||||
|
using var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
|
||||||
|
using var response = await client.GetAsync($"https://builds.dotnet.microsoft.com/dotnet/Sdk/{SdkVersion}/dotnet-sdk-{SdkVersion}-osx-x64.tar.gz", HttpCompletionOption.ResponseHeadersRead, downloadDeadline.Token);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var sdkPath = Path.Combine(payload, "sdk.tar.gz");
|
||||||
|
await using (var sdk = File.Create(sdkPath))
|
||||||
|
await using (var stream = await response.Content.ReadAsStreamAsync(downloadDeadline.Token))
|
||||||
|
await stream.CopyToAsync(sdk, downloadDeadline.Token);
|
||||||
|
await using (var sdk = File.OpenRead(sdkPath))
|
||||||
|
if (Convert.ToHexStringLower(await SHA512.HashDataAsync(sdk, downloadDeadline.Token)) != SdkSha512) throw new InvalidOperationException("Official macOS/x64 SDK SHA-512 mismatch.");
|
||||||
|
Save(Path.Combine(output, "payload-hashes.json"), Directory.GetFiles(payload).ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ReadPinned(string source, string path, string expected)
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(Path.Combine(source, path));
|
||||||
|
if (Hash(bytes) != expected) throw new InvalidOperationException("Pinned full-install source hash mismatch: " + path);
|
||||||
|
return Encoding.UTF8.GetString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string CreateFullReadiness(string hook) => ReplaceOnce(hook,
|
||||||
|
" # Keep the service alive for the bounded host diagnostic to capture evidence.\n while :; do sleep 60; done",
|
||||||
|
"""
|
||||||
|
# Full mode waits for the host's independently validated fresh owned-disk permit.
|
||||||
|
if [ "$success" = true ]; then
|
||||||
|
permit_start=$SECONDS
|
||||||
|
while (( SECONDS - permit_start < 300 )); do
|
||||||
|
if [ -s "$STATE_DIR/install.permit" ]; then
|
||||||
|
exec /bin/bash "$STATE_DIR/full-install.sh"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
printf '[full-install] host permit was not received in five minutes\n' >> "$PROOF_LOG"
|
||||||
|
fi
|
||||||
|
while :; do sleep 60; done
|
||||||
|
""");
|
||||||
|
|
||||||
|
static async Task PrepareFullSource(string source, string output, string token, bool writeSource, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
var installer = ReadPinned(source, "src/install/recovery/launch.sh", "b44309d1056bbd0321251cc9f04a52cf6ad68209586f263b9619339bf7146b6c");
|
||||||
|
var selectorStart = installer.IndexOf("select_target_disk() {", StringComparison.Ordinal);
|
||||||
|
var selectorEnd = installer.IndexOf("find_startosinstall() {", StringComparison.Ordinal);
|
||||||
|
if (selectorStart < 0 || selectorEnd <= selectorStart) throw new InvalidOperationException("Pinned installer selector boundaries changed.");
|
||||||
|
var selector = """
|
||||||
|
select_target_disk() {
|
||||||
|
local permit_token permit_disk permit_commit extra
|
||||||
|
{ IFS= read -r permit_token; IFS= read -r permit_disk; IFS= read -r permit_commit; IFS= read -r extra || :; } < "$STATE_DIR/install.permit" || return 1
|
||||||
|
[ "$permit_token" = "$PROOF_TOKEN" ] && [ -z "${extra:-}" ] || return 1
|
||||||
|
[ "$permit_commit" = "$(cat "$STATE_DIR/source.commit")" ] || return 1
|
||||||
|
[[ "$permit_commit" =~ ^[0-9a-f]{40}$ ]] || return 1
|
||||||
|
. "$STATE_DIR/macos-native-disk-guard.sh"
|
||||||
|
verify_owned_disk "$permit_disk" "$STATE_DIR" "$PROOF_TOKEN" >&2 || return 1
|
||||||
|
printf '%s\n' "$permit_disk"
|
||||||
|
}
|
||||||
|
|
||||||
|
""" + "\n";
|
||||||
|
installer = ReplaceOnce(installer, installer[selectorStart..selectorEnd], selector);
|
||||||
|
installer = ReplaceOnce(installer, "MIN_TARGET_SIZE=$((16 * 1024 * 1024 * 1024))", "# Target policy is exclusively the own writable 64-GiB emulated disk.");
|
||||||
|
installer = ReplaceOnce(installer, "no writable installation disk of at least 16 GiB was found", "the run-owned writable 64-GiB installation disk was not proved");
|
||||||
|
installer = ReplaceOnce(installer, " local message=\"$1\"\n\n echo \"[log] ERROR: $message\"", " local message=\"$1\"\n\n mark_installation_failed || :\n echo \"[log] ERROR: $message\"");
|
||||||
|
installer = ReplaceOnce(installer, "if (( rc != 0 )); then\n", "if (( rc != 0 )); then\n mark_installation_failed || :\n");
|
||||||
|
installer = ReplaceAllExact(installer, "rm -f \"$STARTED\"", ": # Keep the owned erase guard on failure; never erase again.", 2);
|
||||||
|
installer = ReplaceOnce(installer, "select_target_disk() {", """
|
||||||
|
owns_started_guard() {
|
||||||
|
local permit_token permit_disk permit_commit extra record
|
||||||
|
[ -f "$STARTED" ] && [ ! -L "$STARTED" ] || return 1
|
||||||
|
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
|
||||||
|
{ IFS= read -r permit_token; IFS= read -r permit_disk; IFS= read -r permit_commit; IFS= read -r extra || :; } < "$STATE_DIR/install.permit" || return 1
|
||||||
|
[ "$permit_token" = "$PROOF_TOKEN" ] && [ -z "${extra:-}" ] || return 1
|
||||||
|
[[ "$permit_commit" =~ ^[0-9a-f]{40}$ && "$permit_disk" =~ ^/dev/disk[0-9]+$ ]] || return 1
|
||||||
|
[ "$permit_commit" = "$(cat "$STATE_DIR/source.commit")" ] || return 1
|
||||||
|
[ -z "${TARGET_DISK:-}" ] || [ "$TARGET_DISK" = "$permit_disk" ] || return 1
|
||||||
|
{
|
||||||
|
IFS= read -r record || return 1
|
||||||
|
if IFS= read -r extra || [ -n "$extra" ]; then return 1; fi
|
||||||
|
} < "$STARTED"
|
||||||
|
[ "$record" = "$PROOF_TOKEN:$permit_commit:$permit_disk" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
select_target_disk() {
|
||||||
|
""");
|
||||||
|
installer = ReplaceOnce(installer, " echo \"[log] installation was already started; refusing to erase the target disk again\"\n exec /usr/libexec/recoveryosd\n exit 1", " owns_started_guard || fail \"existing installation guard is not owned by this token, commit and disk\"\n echo \"[log] owned installation is already running; duplicate child exits without changing its phase\"\n exit 0");
|
||||||
|
installer = ReplaceOnce(installer, ": > \"$STARTED\" || fail \"failed to create installation guard\"", """
|
||||||
|
if ! ( set -o noclobber; printf '%s:%s:%s\n' "$PROOF_TOKEN" "$(cat "$STATE_DIR/source.commit")" "$TARGET_DISK" > "$STARTED" ); then
|
||||||
|
if owns_started_guard; then
|
||||||
|
echo "[log] another owned child claimed installation; duplicate exits without changing its phase"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fail "failed to create the exclusive owned installation guard"
|
||||||
|
fi
|
||||||
|
""");
|
||||||
|
// The Full bootstrap wrapper keeps Apple's original daemon running.
|
||||||
|
// An installer child must terminate rather than launch another copy.
|
||||||
|
installer = ReplaceAllExact(installer, " exec /usr/libexec/recoveryosd\n", "", 2);
|
||||||
|
installer = ReplaceOnce(installer, "set -u\n", "set -u\nPROOF_TOKEN=\"" + token + "\"\n" + """
|
||||||
|
mark_installation_failed() {
|
||||||
|
local state="${STATE_DIR:-/Volumes/installstate}" temporary
|
||||||
|
[ "$(cat "$state/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
|
||||||
|
temporary="$state/guest-phase.install.$$.tmp"
|
||||||
|
printf '{"token":"%s","phase":"installation-failed"}\n' "$PROOF_TOKEN" > "$temporary" &&
|
||||||
|
/bin/mv -f "$temporary" "$state/guest-phase.json"
|
||||||
|
}
|
||||||
|
installer_parent=$$
|
||||||
|
# Installer watchdog: 80 minutes, also bounded by the host's 172-minute total.
|
||||||
|
(
|
||||||
|
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||||
|
sleep 4800 & sleeper=$!; wait "$sleeper"; kill -TERM "$installer_parent" 2>/dev/null || :
|
||||||
|
) & install_watchdog=$!
|
||||||
|
trap 'kill -TERM "$install_watchdog" 2>/dev/null || :; wait "$install_watchdog" 2>/dev/null || :' EXIT
|
||||||
|
trap 'kill "${STARTOSINSTALL_PID:-}" "${BOOTSTRAPPER_PID:-}" 2>/dev/null || :; mark_installation_failed || :; exit 1' TERM INT
|
||||||
|
""" + "\n");
|
||||||
|
var firstboot = ReadPinned(source, "src/install/firstboot/launch.sh", "d6b29bb42ffe99edda6b3be3faf6009c4e0b34e5b8bba6eb0855cf24a0c24307");
|
||||||
|
firstboot = ReplaceOnce(firstboot, "LOG=\"/var/log/macos-unattended-firstboot.log\"\n", "LOG=\"/var/log/macos-unattended-firstboot.log\"\n" + FirstbootEvidence.Replace("@@OWNER@@", token, StringComparison.Ordinal) + "\n");
|
||||||
|
firstboot = ReplaceOnce(firstboot, " printf '%s\\n' \"[firstboot] $1\" >> \"$LOG\" 2>/dev/null || :\n", " printf '%s\\n' \"[firstboot] $1\" >> \"$LOG\" 2>/dev/null || :\n publish_firstboot_log || :\n");
|
||||||
|
firstboot = ReplaceOnce(firstboot, "log \"prebuilt account package installed successfully\"\n", "log \"prebuilt account package installed successfully\"\n/bin/bash /Volumes/installstate/native-firstboot-bootstrap.sh \"" + token + "\" || fail \"native CI bootstrap or tests failed\"\n");
|
||||||
|
ReadPinned(source, "src/install/firstboot/com.dockur.macos.firstboot.plist", "29ef05388d962c236bdb87b2911e3b42e08c600035cfccf440d35ba64011c3d3");
|
||||||
|
ReadPinned(source, "src/image.sh", "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c");
|
||||||
|
var initialize = ReadPinned(source, "src/install.sh", "19b4b27187de85148ad1de1c40d75a54738eb9890f02dbb9445155bb5ec44f90");
|
||||||
|
initialize = ReplaceOnce(initialize, "INSTALL_STATE_DIR=\"$QEMU_DIR/installstate\"\nrm -rf \"$INSTALL_STATE_DIR\"", "INSTALL_STATE_DIR=\"$STORAGE/ci-state\"\n# Full CI preserves the own-volume erase guard and evidence across starts.");
|
||||||
|
initialize = ReplaceOnce(initialize, " if ! prepareInstallationState \"$INSTALL_STATE_DIR\"; then\n exit 34\n fi", """
|
||||||
|
if [ -e "$INSTALL_STATE_DIR/run.owner" ]; then
|
||||||
|
[ "$(cat "$INSTALL_STATE_DIR/run.owner")" = "@@OWNER@@" ] || { error "CI storage belongs to another run."; exit 34; }
|
||||||
|
else
|
||||||
|
prepareInstallationState "$INSTALL_STATE_DIR" || exit 34
|
||||||
|
cp -f /assets/ci-payload/* "$INSTALL_STATE_DIR/" || exit 34
|
||||||
|
cp -f "$IMAGE_TOOLS/recovery/full-install.sh" "$INSTALL_STATE_DIR/full-install.sh" || exit 34
|
||||||
|
cmp -s "$IMAGE_TOOLS/recovery/full-install.sh" "$INSTALL_STATE_DIR/full-install.sh" || exit 34
|
||||||
|
for file in /assets/ci-payload/*; do cmp -s "$file" "$INSTALL_STATE_DIR/${file##*/}" || exit 34; done
|
||||||
|
chmod 0755 "$INSTALL_STATE_DIR/full-install.sh" "$INSTALL_STATE_DIR/native-firstboot-bootstrap.sh" || exit 34
|
||||||
|
printf '%s\n' '@@OWNER@@' > "$INSTALL_STATE_DIR/run.owner" || exit 34
|
||||||
|
fi
|
||||||
|
""".Replace("@@OWNER@@", token, StringComparison.Ordinal));
|
||||||
|
foreach (var pair in new[] { ("full-install.sh", installer), ("full-firstboot.sh", firstboot), ("full-state-source.sh", initialize) })
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false));
|
||||||
|
await Command("bash", ["-n", Path.Combine(output, pair.Item1)], output, pair.Item1 + "-syntax", cancellation);
|
||||||
|
}
|
||||||
|
foreach (var name in new[] { "macos-native-firstboot.sh", "macos-native-disk-guard.sh" })
|
||||||
|
await Command("bash", ["-n", Path.Combine("tools", "ci", name)], output, name + "-syntax", cancellation);
|
||||||
|
Save(Path.Combine(output, "full-source-hashes.json"), new Dictionary<string, string> { ["full-install.sh"] = Hash(Encoding.UTF8.GetBytes(installer)), ["full-firstboot.sh"] = Hash(Encoding.UTF8.GetBytes(firstboot)), ["full-state-source.sh"] = Hash(Encoding.UTF8.GetBytes(initialize)), ["MacOsNativeGuest.cs"] = Hash(File.ReadAllBytes("tools/ci/MacOsNativeGuest.cs")), ["macos-native-firstboot.sh"] = Hash(File.ReadAllBytes("tools/ci/macos-native-firstboot.sh")), ["macos-native-disk-guard.sh"] = Hash(File.ReadAllBytes("tools/ci/macos-native-disk-guard.sh")) });
|
||||||
|
if (!writeSource)
|
||||||
|
{
|
||||||
|
await ValidateInstallerFailureReceipt(installer, output, token, cancellation);
|
||||||
|
await ValidateInstallGuardChild(installer, output, token, cancellation);
|
||||||
|
await ValidateFirstbootEvidence(firstboot, output, token, cancellation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/install/recovery/full-install.sh"), installer, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/install/firstboot/launch.sh"), firstboot, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/install.sh"), initialize, new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const string FirstbootEvidence = """
|
||||||
|
# Mount and verify only this run's state before account setup can fail.
|
||||||
|
PROOF_TOKEN="@@OWNER@@"
|
||||||
|
STATE_DIR="/Volumes/installstate"
|
||||||
|
owns_firstboot_state() {
|
||||||
|
[ -f "$STATE_DIR/run.owner" ] && [ ! -L "$STATE_DIR/run.owner" ] &&
|
||||||
|
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ]
|
||||||
|
}
|
||||||
|
publish_firstboot_log() {
|
||||||
|
owns_firstboot_state && [ -f "$LOG" ] || return 1
|
||||||
|
local temporary="$STATE_DIR/unattended-firstboot.$$.tmp"
|
||||||
|
/usr/bin/tail -c 1048576 "$LOG" > "$temporary" &&
|
||||||
|
/bin/mv -f "$temporary" "$STATE_DIR/unattended-firstboot.log"
|
||||||
|
}
|
||||||
|
publish_firstboot_failure() {
|
||||||
|
owns_firstboot_state || return 1
|
||||||
|
local temporary="$STATE_DIR/guest-phase.firstboot.$$.tmp"
|
||||||
|
printf '{"token":"%s","phase":"bootstrap-failed","stage":"account-firstboot"}\n' "$PROOF_TOKEN" > "$temporary" &&
|
||||||
|
/bin/mv -f "$temporary" "$STATE_DIR/guest-phase.json"
|
||||||
|
}
|
||||||
|
trap 'firstboot_status=$?; publish_firstboot_log || :; (( firstboot_status == 0 )) || publish_firstboot_failure || :' EXIT
|
||||||
|
count=0
|
||||||
|
while ! owns_firstboot_state && (( count < 120 )); do
|
||||||
|
if [ -e "$STATE_DIR/run.owner" ] || [ -L "$STATE_DIR/run.owner" ]; then
|
||||||
|
printf '[firstboot] ERROR: foreign CI state owner\n' >> "$LOG"; exit 1
|
||||||
|
fi
|
||||||
|
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||||
|
count=$((count + 1)); sleep 1
|
||||||
|
done
|
||||||
|
owns_firstboot_state || { printf '[firstboot] ERROR: owned CI state share did not mount\n' >> "$LOG"; exit 1; }
|
||||||
|
""";
|
||||||
|
|
||||||
|
static async Task ValidateFirstbootEvidence(string firstboot, string output, string token, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
foreach (var test in new[] { ("missing-package", true, false, false), ("installer-failure", true, true, false), ("foreign-state", false, false, false), ("bounded-log", true, false, true) })
|
||||||
|
{
|
||||||
|
var work = Path.Combine(output, "firstboot-evidence-" + test.Item1);
|
||||||
|
var state = Path.Combine(work, "state");
|
||||||
|
var account = Path.Combine(work, "account");
|
||||||
|
Directory.CreateDirectory(state); Directory.CreateDirectory(account);
|
||||||
|
File.WriteAllText(Path.Combine(state, "run.owner"), test.Item2 ? token : new string('f', 32));
|
||||||
|
var initial = JsonSerializer.Serialize(new { token, phase = "installation" });
|
||||||
|
File.WriteAllText(Path.Combine(state, "guest-phase.json"), initial);
|
||||||
|
if (test.Item3) File.WriteAllText(Path.Combine(account, "admin.pkg"), "owned harmless package fixture");
|
||||||
|
Directory.CreateDirectory(Path.Combine(account, "users"));
|
||||||
|
File.WriteAllText(Path.Combine(account, "admin.plist"), "owned harmless admin fixture");
|
||||||
|
var log = Path.Combine(work, "local-firstboot.log");
|
||||||
|
if (test.Item4) File.WriteAllText(log, new string('x', 1024 * 1024 + 512) + "\n");
|
||||||
|
var mapped = firstboot.Replace("/Volumes/installstate", state, StringComparison.Ordinal)
|
||||||
|
.Replace("/Library/Application Support/macos-unattended", account, StringComparison.Ordinal)
|
||||||
|
.Replace("/private/var/db/dslocal/nodes/Default/users", Path.Combine(account, "users"), StringComparison.Ordinal)
|
||||||
|
.Replace("/private/var/db/dslocal/nodes/Default/groups/admin.plist", Path.Combine(account, "admin.plist"), StringComparison.Ordinal)
|
||||||
|
.Replace("/var/log/macos-unattended-firstboot.log", log, StringComparison.Ordinal)
|
||||||
|
.Replace("[ -x /usr/sbin/installer ]", "true", StringComparison.Ordinal);
|
||||||
|
var fixture = """
|
||||||
|
/sbin/mount_9p() { return 1; }
|
||||||
|
/usr/sbin/installer() { printf '%s\n' 'synthetic owned installer failure'; return 17; }
|
||||||
|
sleep() { return 0; }
|
||||||
|
""" + "\n" + mapped;
|
||||||
|
var path = Path.Combine(work, "fixture.sh");
|
||||||
|
File.WriteAllText(path, fixture, new UTF8Encoding(false));
|
||||||
|
var result = await Command("bash", [path], work, "firstboot", cancellation, requireSuccess: false);
|
||||||
|
var mirror = Path.Combine(state, "unattended-firstboot.log");
|
||||||
|
var evidence = File.Exists(mirror) ? File.ReadAllText(mirror) : "";
|
||||||
|
var phase = File.ReadAllText(Path.Combine(state, "guest-phase.json"));
|
||||||
|
using var receipt = JsonDocument.Parse(phase);
|
||||||
|
var published = receipt.RootElement.GetProperty("token").GetString() == token && receipt.RootElement.GetProperty("phase").GetString() == "bootstrap-failed";
|
||||||
|
var message = test.Item3 ? "synthetic owned installer failure" : "prebuilt account package is missing";
|
||||||
|
var bounded = !File.Exists(mirror) || new FileInfo(mirror).Length <= 1024 * 1024;
|
||||||
|
var success = result.ExitCode != 0 && (test.Item2 ? published && evidence.Contains(message, StringComparison.Ordinal) && bounded : phase == initial && !File.Exists(mirror));
|
||||||
|
Save(Path.Combine(work, "receipt.json"), new { success, result.ExitCode, failurePhasePublished = published, mirroredLogBytes = File.Exists(mirror) ? new FileInfo(mirror).Length : 0, bounded, nativeCommandsExecuted = false });
|
||||||
|
if (!success) throw new InvalidOperationException("Firstboot account-stage evidence fixture failed: " + test.Item1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ValidateInstallerFailureReceipt(string installer, string output, string token, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
const string start = "mark_installation_failed() {";
|
||||||
|
const string end = "\n}\ninstaller_parent=$$";
|
||||||
|
var begin = installer.IndexOf(start, StringComparison.Ordinal);
|
||||||
|
var finish = begin < 0 ? -1 : installer.IndexOf(end, begin, StringComparison.Ordinal);
|
||||||
|
if (begin < 0 || finish < begin || installer.Split("mark_installation_failed || :", StringSplitOptions.None).Length != 4)
|
||||||
|
throw new InvalidOperationException("Pinned installer must publish its terminal failure phase from fail(), nonzero startosinstall and TERM/INT.");
|
||||||
|
var function = installer[begin..(finish + 2)];
|
||||||
|
var state = Path.Combine(output, "installer-failure-fixture");
|
||||||
|
Directory.CreateDirectory(state);
|
||||||
|
File.WriteAllText(Path.Combine(state, "run.owner"), token);
|
||||||
|
var command = "set -u\n" + function + "\nPROOF_TOKEN=\"$1\"; STATE_DIR=\"$2\"; mark_installation_failed";
|
||||||
|
await Command("bash", ["-c", command, "installer-failure-validation", token, state], output, "installer-terminal-failure", cancellation);
|
||||||
|
var receipt = File.ReadAllText(Path.Combine(state, "guest-phase.json"));
|
||||||
|
using var json = JsonDocument.Parse(receipt);
|
||||||
|
if (json.RootElement.GetProperty("token").GetString() != token || json.RootElement.GetProperty("phase").GetString() != "installation-failed" || Directory.GetFiles(state, "*.tmp").Length != 0)
|
||||||
|
throw new InvalidOperationException("Installer failed to publish a complete atomic terminal phase.");
|
||||||
|
File.WriteAllText(Path.Combine(state, "run.owner"), "foreign");
|
||||||
|
var foreign = await Command("bash", ["-c", command, "installer-failure-validation", token, state], output, "installer-foreign-failure", cancellation, requireSuccess: false);
|
||||||
|
if (foreign.ExitCode == 0 || File.ReadAllText(Path.Combine(state, "guest-phase.json")) != receipt)
|
||||||
|
throw new InvalidOperationException("Installer terminal phase overwrote foreign run-owned state.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ValidateInstallGuardChild(string installer, string output, string token, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
// Exercise the generated Recovery shell's actual pre-erase control path.
|
||||||
|
// Apple exec and rolling log snapshots are external boundaries; no VM,
|
||||||
|
// native disk command, installer, daemon or application is invoked here.
|
||||||
|
static string Between(string text, string start, string end)
|
||||||
|
{
|
||||||
|
var first = text.IndexOf(start, StringComparison.Ordinal);
|
||||||
|
var last = first < 0 ? -1 : text.IndexOf(end, first, StringComparison.Ordinal);
|
||||||
|
if (first < 0 || last <= first) throw new InvalidOperationException("Generated installer guard validation boundary changed: " + start);
|
||||||
|
return text[first..last];
|
||||||
|
}
|
||||||
|
var functions = Between(installer, "mark_installation_failed() {", "installer_parent=$$") +
|
||||||
|
Between(installer, "fail() {", "select_target_disk() {");
|
||||||
|
var existing = Between(installer, "if [ -e \"$STARTED\" ]; then", "[ -s \"$ADMIN_PACKAGE\" ]");
|
||||||
|
var claim = Between(installer, "# Everything needed for the unattended install", "if ! /usr/sbin/diskutil eraseDisk");
|
||||||
|
const string commit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||||
|
const string disk = "/dev/disk1";
|
||||||
|
var ownGuard = token + ":" + commit + ":" + disk + "\n";
|
||||||
|
var cases = new[]
|
||||||
|
{
|
||||||
|
(Name: "race", Initial: (string?)null, Race: true, WriteFailure: false, Success: true, Erase: false),
|
||||||
|
(Name: "fresh-winner", Initial: (string?)null, Race: false, WriteFailure: false, Success: true, Erase: true),
|
||||||
|
(Name: "already-owned", Initial: ownGuard, Race: false, WriteFailure: false, Success: true, Erase: false),
|
||||||
|
(Name: "foreign-token", Initial: ownGuard.Replace(token, new string('f', 32)), Race: false, WriteFailure: false, Success: false, Erase: false),
|
||||||
|
(Name: "foreign-commit", Initial: ownGuard.Replace(commit, new string('f', 40)), Race: false, WriteFailure: false, Success: false, Erase: false),
|
||||||
|
(Name: "foreign-disk", Initial: ownGuard.Replace(disk, "/dev/disk2"), Race: false, WriteFailure: false, Success: false, Erase: false),
|
||||||
|
(Name: "empty", Initial: "", Race: false, WriteFailure: false, Success: false, Erase: false),
|
||||||
|
(Name: "extra-record", Initial: ownGuard + ownGuard, Race: false, WriteFailure: false, Success: false, Erase: false),
|
||||||
|
(Name: "unterminated", Initial: ownGuard.TrimEnd('\n'), Race: false, WriteFailure: false, Success: false, Erase: false),
|
||||||
|
(Name: "write-failure", Initial: (string?)null, Race: false, WriteFailure: true, Success: false, Erase: false)
|
||||||
|
};
|
||||||
|
foreach (var test in cases)
|
||||||
|
{
|
||||||
|
var state = Path.Combine(output, "installer-guard-" + test.Name + "-fixture");
|
||||||
|
if (Directory.Exists(state)) throw new InvalidOperationException("Install-guard fixtures require a fresh validation output: " + state);
|
||||||
|
Directory.CreateDirectory(state);
|
||||||
|
File.WriteAllText(Path.Combine(state, "run.owner"), token + "\n");
|
||||||
|
File.WriteAllText(Path.Combine(state, "source.commit"), commit + "\n");
|
||||||
|
File.WriteAllText(Path.Combine(state, "install.permit"), token + "\n" + disk + "\n" + commit + "\n");
|
||||||
|
var active = JsonSerializer.Serialize(new { token, phase = "installation" });
|
||||||
|
File.WriteAllText(Path.Combine(state, "guest-phase.json"), active);
|
||||||
|
var guard = Path.Combine(state, test.WriteFailure ? "absent-parent/started" : "started");
|
||||||
|
if (test.Initial is not null) File.WriteAllText(guard, test.Initial);
|
||||||
|
var script = """
|
||||||
|
set -u
|
||||||
|
STATE_DIR="$1"; PROOF_TOKEN="$2"; TARGET_DISK="$3"
|
||||||
|
STARTED="$4"; LOCAL_LOG="$STATE_DIR/local.log"
|
||||||
|
STATE_LOG="$STATE_DIR/install.log"; STATE_LOG_LIMIT=1024
|
||||||
|
APPLE_INSTALL_LOG="$STATE_DIR/apple.log"; APPLE_INSTALL_LOG_LIMIT=1024
|
||||||
|
snapshot_log() { :; }
|
||||||
|
exec() { printf '%s\n' "$*" >> "$STATE_DIR/apple-exec.log"; return 0; }
|
||||||
|
""" + "\n" + functions + "\n" + existing + "\n" +
|
||||||
|
// Publish another child's complete guard after the initial check.
|
||||||
|
(test.Race ? "printf '%s:%s:%s\\n' \"$PROOF_TOKEN\" \"$(cat \"$STATE_DIR/source.commit\")\" \"$TARGET_DISK\" > \"$STARTED\"\n" : "") + claim +
|
||||||
|
"printf 'guard-acquired\\n' > \"$STATE_DIR/erase-boundary-reached\"\n";
|
||||||
|
var command = await Command("bash", ["-c", script, "generated-install-guard-validation", state, token, disk, guard], output, "installer-guard-" + test.Name, cancellation, requireSuccess: false);
|
||||||
|
var phase = File.ReadAllText(Path.Combine(state, "guest-phase.json"));
|
||||||
|
var phaseUnchanged = phase == active;
|
||||||
|
using var receipt = JsonDocument.Parse(phase);
|
||||||
|
var realFailure = receipt.RootElement.GetProperty("token").GetString() == token && receipt.RootElement.GetProperty("phase").GetString() == "installation-failed";
|
||||||
|
var appleExec = File.Exists(Path.Combine(state, "apple-exec.log"));
|
||||||
|
var eraseReached = File.Exists(Path.Combine(state, "erase-boundary-reached"));
|
||||||
|
var guardPreserved = test.Initial is not null ? File.ReadAllText(guard) == test.Initial : test.WriteFailure ? !File.Exists(guard) : File.ReadAllText(guard) == ownGuard;
|
||||||
|
Save(Path.Combine(output, "installer-guard-" + test.Name + ".json"), new { command.ExitCode, phaseUnchanged, realFailure, appleExec, eraseReached, guardPreserved });
|
||||||
|
if ((command.ExitCode == 0) != test.Success || test.Success && !phaseUnchanged || !test.Success && !realFailure || appleExec || eraseReached != test.Erase || !guardPreserved)
|
||||||
|
throw new InvalidOperationException($"Generated install child contract failed ({test.Name}): exit={command.ExitCode}, phaseUnchanged={phaseUnchanged}, realFailure={realFailure}, appleExec={appleExec}, eraseReached={eraseReached}, guardPreserved={guardPreserved}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task PermitInstallation(string id, string output, string token, string commit, string readiness, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
await Command("docker", ["inspect", id], output, "full-container-boundary", cancellation);
|
||||||
|
AssertContainer(File.ReadAllText(Path.Combine(output, "full-container-boundary.stdout.log")), token);
|
||||||
|
using (var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(output, "full-container-boundary.stdout.log"))))
|
||||||
|
{
|
||||||
|
var mounts = document.RootElement[0].GetProperty("Mounts").EnumerateArray().ToArray();
|
||||||
|
if (mounts.Length != 1 || mounts[0].GetProperty("Type").GetString() != "volume" || mounts[0].GetProperty("Destination").GetString() != "/storage" || !mounts[0].GetProperty("RW").GetBoolean()) throw new InvalidOperationException("Full installer requires only the newly owned anonymous /storage volume.");
|
||||||
|
}
|
||||||
|
var drive = await Command("docker", ["exec", id, "qemu-img", "info", "--force-share", "--output=json", "/storage/14/data.img"], output, "owned-raw-disk", cancellation);
|
||||||
|
using (var document = JsonDocument.Parse(drive.Output))
|
||||||
|
if (document.RootElement.GetProperty("format").GetString() != "raw" || document.RootElement.GetProperty("virtual-size").GetInt64() != GuestDiskBytes) throw new InvalidOperationException("Owned VM raw-disk capacity/format mismatch.");
|
||||||
|
var attachment = await Command("docker", ["exec", id, "sh", "-c", "qemu_pid=$(cat /dev/shm/qemu.pid); tr '\\0' '\\n' < /proc/\"$qemu_pid\"/cmdline | sed -n '/^file=\\/storage\\/13\\/data\\.img,/p; /^ide-hd,drive=data3,/p; /^local,id=installstatefs,/p'"], output, "owned-disk-attachment", cancellation);
|
||||||
|
var lines = attachment.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (lines.Length != 3 || !lines.Any(line => line.StartsWith("file=/storage/14/data.img,id=data3,format=raw,", StringComparison.Ordinal) && !line.Contains("readonly=on", StringComparison.Ordinal)) || !lines.Any(line => line.StartsWith("ide-hd,drive=data3,", StringComparison.Ordinal) && line.Contains("serial=" + DiskSerial(token), StringComparison.Ordinal)) || !lines.Contains("local,id=installstatefs,path=" + FullState + ",security_model=none")) throw new InvalidOperationException("QEMU did not attach the exact owned raw disk/serial and persistent state share.");
|
||||||
|
var owner = await Command("docker", ["exec", id, "cat", FullState + "/run.owner"], output, "full-share-owner", cancellation);
|
||||||
|
if (owner.Output.Trim() != token) throw new InvalidOperationException("Native state owner mismatch.");
|
||||||
|
using var receipt = JsonDocument.Parse(readiness);
|
||||||
|
var disk = receipt.RootElement.GetProperty("disk").GetString();
|
||||||
|
var permit = Path.Combine(output, "install.permit");
|
||||||
|
File.WriteAllText(permit, token + "\n" + disk + "\n" + commit + "\n");
|
||||||
|
await Command("docker", ["cp", permit, id + ":" + FullState + "/install.permit.tmp"], output, "stage-owned-install-permit", cancellation);
|
||||||
|
await Command("docker", ["exec", id, "mv", FullState + "/install.permit.tmp", FullState + "/install.permit"], output, "authorize-owned-guest-installation", cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly string[] NativeFacts = [
|
||||||
|
"MeetingAssistant.Tests.MacOsMeetingAudioSourceTests.NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant",
|
||||||
|
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.MacOsCapabilityEndpointReportsEnabledRealProviders",
|
||||||
|
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures",
|
||||||
|
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperCropsPngUsingOcrPixelCoordinates",
|
||||||
|
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied"];
|
||||||
|
static readonly string[] NativeArtifacts = ["MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture", "macos-desktop-controls", "macos-meeting-integrations", "macos-meeting-assistant-launcher"];
|
||||||
|
|
||||||
|
static void ValidateFullResult(string json, string token, string commit, string archiveHash)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var result = document.RootElement;
|
||||||
|
if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || result.GetProperty("sourceCommit").GetString() != commit || result.GetProperty("archiveSha256").GetString() != archiveHash || result.GetProperty("sdkVersion").GetString() != SdkVersion || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 14 || result.GetProperty("architecture").GetString() != "x86_64" || new[] { "expectedTests", "total", "executed", "passed" }.Any(key => result.GetProperty(key).GetInt32() != 577) || new[] { "failed", "notExecuted", "audioCodeSignExit" }.Any(key => result.GetProperty(key).GetInt32() != 0) || !result.GetProperty("nativeTests").EnumerateArray().Select(value => value.GetString()).Order().SequenceEqual(NativeFacts.Order())) throw new InvalidOperationException("Native full receipt did not prove exact-source 577/577 with all five native tests and zero skips.");
|
||||||
|
var artifacts = result.GetProperty("nativeArtifacts").EnumerateArray().ToArray();
|
||||||
|
if (artifacts.Length != 4 || !artifacts.Select(item => item.GetProperty("name").GetString()).Order().SequenceEqual(NativeArtifacts.Order()) || artifacts.Any(item => item.GetProperty("architecture").GetString() != "x86_64" || !System.Text.RegularExpressions.Regex.IsMatch(item.GetProperty("sha256").GetString() ?? "", "^[0-9a-f]{64}$"))) throw new InvalidOperationException("Native Mach-O/x86_64 helper manifest is incomplete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ValidateTrx(byte[] bytes, string json)
|
||||||
|
{
|
||||||
|
using var receipt = JsonDocument.Parse(json);
|
||||||
|
if (Hash(bytes) != receipt.RootElement.GetProperty("trxSha256").GetString()) throw new InvalidOperationException("Returned native TRX SHA-256 mismatch.");
|
||||||
|
var document = XDocument.Load(new MemoryStream(bytes));
|
||||||
|
var counters = document.Descendants().Single(item => item.Name.LocalName == "Counters");
|
||||||
|
foreach (var key in new[] { "total", "executed", "passed" }) if ((int?)counters.Attribute(key) != 577) throw new InvalidOperationException("Native TRX count mismatch: " + key);
|
||||||
|
foreach (var key in new[] { "failed", "notExecuted" }) if ((int?)counters.Attribute(key) != 0) throw new InvalidOperationException("Native TRX failure/skip counter: " + key);
|
||||||
|
var results = document.Descendants().Where(item => item.Name.LocalName == "UnitTestResult").ToArray();
|
||||||
|
if (results.Length != 577 || results.Any(item => (string?)item.Attribute("outcome") != "Passed")) throw new InvalidOperationException("Native TRX contains missing, failed or skipped results.");
|
||||||
|
var identities = document.Descendants().Where(item => item.Name.LocalName == "UnitTest").ToDictionary(item => (string)item.Attribute("id")!, item => { var method = item.Elements().Single(child => child.Name.LocalName == "TestMethod"); var className = ((string?)method.Attribute("className"))?.Split(',')[0].Trim() ?? ""; var name = (string?)method.Attribute("name") ?? ""; return name.StartsWith(className + ".", StringComparison.Ordinal) ? name : className + "." + name; });
|
||||||
|
var passed = results.Select(item => identities[(string)item.Attribute("testId")!]).ToHashSet();
|
||||||
|
if (NativeFacts.Any(name => !passed.Contains(name))) throw new InvalidOperationException("Native TRX does not explicitly pass every required macOS fact.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ValidateFullContracts()
|
||||||
|
{
|
||||||
|
var token = new string('a', 32); var commit = new string('b', 40); var hash = new string('c', 64);
|
||||||
|
var trx = "<TestRun><TestDefinitions>" + string.Concat(Enumerable.Range(0, 577).Select(index => { var identity = index < 5 ? NativeFacts[index] : "MeetingAssistant.Tests.Validation.Test" + index; var split = identity.LastIndexOf('.'); return $"<UnitTest id='t{index}'><TestMethod className='{identity[..split]}' name='{identity[(split + 1)..]}' /></UnitTest>"; })) + "</TestDefinitions><Results>" + string.Concat(Enumerable.Range(0, 577).Select(index => $"<UnitTestResult testId='t{index}' outcome='Passed' />")) + "</Results><ResultSummary><Counters total='577' executed='577' passed='577' failed='0' notExecuted='0' /></ResultSummary></TestRun>";
|
||||||
|
var good = JsonSerializer.Serialize(new { token, success = true, sourceCommit = commit, archiveSha256 = hash, sdkVersion = SdkVersion, osVersion = "14.6.1", architecture = "x86_64", expectedTests = 577, total = 577, executed = 577, passed = 577, failed = 0, notExecuted = 0, nativeTests = NativeFacts, nativeArtifacts = NativeArtifacts.Select(name => new { name, sha256 = hash, architecture = "x86_64" }), audioCodeSignExit = 0, trxSha256 = Hash(Encoding.UTF8.GetBytes(trx)) });
|
||||||
|
ValidateFullResult(good, token, commit, hash); ValidateTrx(Encoding.UTF8.GetBytes(trx), good);
|
||||||
|
byte[] bomTrx = [0xef, 0xbb, 0xbf, .. Encoding.UTF8.GetBytes(trx)];
|
||||||
|
ValidateTrx(bomTrx, good.Replace(Hash(Encoding.UTF8.GetBytes(trx)), Hash(bomTrx), StringComparison.Ordinal));
|
||||||
|
var qualifiedTrx = trx;
|
||||||
|
foreach (var name in NativeFacts) qualifiedTrx = qualifiedTrx.Replace("name='" + name[(name.LastIndexOf('.') + 1)..] + "'", "name='" + name + "'", StringComparison.Ordinal);
|
||||||
|
ValidateTrx(Encoding.UTF8.GetBytes(qualifiedTrx), good.Replace(Hash(Encoding.UTF8.GetBytes(trx)), Hash(Encoding.UTF8.GetBytes(qualifiedTrx)), StringComparison.Ordinal));
|
||||||
|
foreach (var invalid in new[] { good.Replace("\"success\":true", "\"success\":false"), good.Replace("\"passed\":577", "\"passed\":572"), good.Replace("\"notExecuted\":0", "\"notExecuted\":5"), good.Replace("\"audioCodeSignExit\":0", "\"audioCodeSignExit\":1"), good.Replace("14.6.1", "13.6.1"), good.Replace("x86_64", "arm64"), good.Replace(token, new string('d', 32)), good.Replace(commit, new string('e', 40)), good.Replace("NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures", "UnrelatedTest") })
|
||||||
|
{
|
||||||
|
try { ValidateFullResult(invalid, token, commit, hash); } catch (InvalidOperationException) { continue; }
|
||||||
|
throw new InvalidOperationException("Full native validator accepted an incomplete or stale proof.");
|
||||||
|
}
|
||||||
|
try { ValidateTrx(Encoding.UTF8.GetBytes(trx.Replace("outcome='Passed'", "outcome='NotExecuted'")), good); } catch (InvalidOperationException) { return; }
|
||||||
|
throw new InvalidOperationException("Full native validator accepted changed TRX bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ValidateCompression(string chunk, string output)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(output);
|
||||||
|
var bytes = File.ReadAllBytes(chunk);
|
||||||
|
if (Hash(bytes) != "2770f06fe51f19ddc040cfad4ab870d7227f540d1ba4190a9ce631145c12fae0") throw new InvalidOperationException("Readonly retained Recovery qualification chunk hash mismatch.");
|
||||||
|
var text = Encoding.Latin1.GetString(bytes);
|
||||||
|
if (text.Split(DiagnosticDaemon, StringSplitOptions.None).Length != 2 || text.Split(MountOnlyBootstrap, StringSplitOptions.None).Length != 2) throw new InvalidOperationException("Qualification chunk does not contain this exact Recovery LaunchDaemon and mount-only patch.");
|
||||||
|
// Upstream UDIF recompression uses Python zlib; .NET's compressor differs even on baseline.
|
||||||
|
await Command("python3", ["-c", "import json,pathlib,sys,zlib; b=pathlib.Path(sys.argv[1]).read_bytes(); n=len(zlib.compress(b,9)); print(json.dumps({'runtime':zlib.ZLIB_RUNTIME_VERSION,'compressedBytes':n,'slotBytes':43266,'fits':n<=43266})); sys.exit(0 if n<=43266 else 1)", chunk], output, "readonly-recovery-compression", CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ValidateDiskSerialParser(string output)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(output);
|
||||||
|
var guard = File.ReadAllText("tools/ci/macos-native-disk-guard.sh");
|
||||||
|
const string start = "-v disk=\"${disk#/dev/}\" '\n";
|
||||||
|
const string end = " ' \"$state/disk-ownership-ioreg.log\")";
|
||||||
|
var program = guard[(guard.IndexOf(start, StringComparison.Ordinal) + start.Length)..guard.IndexOf(end, StringComparison.Ordinal)];
|
||||||
|
var serial = new string('0', 20);
|
||||||
|
var own = "+-o QEMU HARDDISK <class IOAHCIBlockStorageDevice>\n | \"Device Characteristics\" = {\"Serial Number\"=\"" + serial + "\"}\n +-o Media <class IOMedia>\n \"BSD Name\" = \"disk1\"\n";
|
||||||
|
var reversed = "+-o QEMU HARDDISK <class IOAHCIBlockStorageDevice>\n +-o Media <class IOMedia>\n \"BSD Name\" = \"disk1\"\n | \"Device Characteristics\" = {\"Serial Number\"=\"" + serial + "\"}\n";
|
||||||
|
var splitRoots = "+-o QEMU HARDDISK <class IOAHCIBlockStorageDevice>\n | \"Device Characteristics\" = {\"Serial Number\"=\"" + serial + "\"}\n+-o Other <class IOAHCIBlockStorageDevice>\n +-o Media <class IOMedia>\n \"BSD Name\" = \"disk1\"\n";
|
||||||
|
foreach (var test in new[] { ("own", own, "1"), ("reversed-properties", reversed, "1"), ("split-roots", splitRoots, "0"), ("foreign-serial", own.Replace(serial, new string('a', 20)), "0"), ("foreign-disk", own.Replace("disk1", "disk2"), "0"), ("ambiguous", own + own, "2") })
|
||||||
|
{
|
||||||
|
var path = Path.Combine(output, "ioreg-" + test.Item1 + ".fixture");
|
||||||
|
File.WriteAllText(path, test.Item2);
|
||||||
|
var result = await Command("awk", ["-v", "expected=" + serial, "-v", "disk=disk1", program, path], output, "disk-serial-" + test.Item1, CancellationToken.None);
|
||||||
|
if (result.Output.Trim() != test.Item3) throw new InvalidOperationException("Actual boot-seam IORegistry parser fixture failed: " + test.Item1);
|
||||||
|
}
|
||||||
|
}
|
||||||
static string ReplaceAllExact(string text, string oldValue, string newValue, int expected)
|
static string ReplaceAllExact(string text, string oldValue, string newValue, int expected)
|
||||||
{
|
{
|
||||||
var count = text.Split(oldValue, StringSplitOptions.None).Length - 1;
|
var count = text.Split(oldValue, StringSplitOptions.None).Length - 1;
|
||||||
@@ -519,15 +1108,28 @@ static class NativeDiagnostic
|
|||||||
throw new InvalidOperationException("Created container exceeds the owned unprivileged TCG/Haswell/macOS 14 boundary.");
|
throw new InvalidOperationException("Created container exceeds the owned unprivileged TCG/Haswell/macOS 14 boundary.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool final = false, string? token = null)
|
static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool full = false, bool final = false, string? token = null)
|
||||||
{
|
{
|
||||||
if (final && token is not null) await CaptureMonitor(id, output, token, cancellation);
|
if (final && token is not null) await CaptureMonitor(id, output, token, cancellation);
|
||||||
var logs = await Command("docker", ["logs", "--tail", "3000", id], output, "container", cancellation, requireSuccess: false);
|
var logs = await Command("docker", ["logs", "--tail", "3000", id], output, "container", cancellation, requireSuccess: false);
|
||||||
foreach (var file in new[] { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") })
|
ReportCpuPreflight(output, logs.Output);
|
||||||
|
var files = new List<(string, string)> { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") };
|
||||||
|
if (full) files.AddRange([("guest-phase.json", "guest-phase.json"), ("full-result.json", "full-result.json"), ("firstboot.log", "firstboot.log"), ("unattended-firstboot.log", "unattended-firstboot.log"), ("install.log", "install.log"), ("apple.log", "apple.log"), ("disk-ownership-ioreg.log", "disk-ownership-ioreg.log"), ("installed-root.plist", "installed-root.plist"), ("apfs-containers.plist", "apfs-containers.plist"), ("physical-store.plist", "physical-store.plist"), ("clt-catalog.log", "clt-catalog.log"), ("clt-install.log", "clt-install.log"), ("clt-sdk.log", "clt-sdk.log")]);
|
||||||
|
foreach (var file in files)
|
||||||
{
|
{
|
||||||
var result = await Command("docker", ["exec", id, "cat", "/dev/shm/installstate/" + file.Item1], output, "capture-" + file.Item1, cancellation, requireSuccess: false);
|
var result = await Command("docker", ["exec", id, "cat", (full ? FullState : "/dev/shm/installstate") + "/" + file.Item1], output, "capture-" + file.Item1.Replace('/', '-'), cancellation, requireSuccess: false);
|
||||||
if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output)) File.WriteAllText(Path.Combine(output, file.Item2), result.Output);
|
if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output)) File.WriteAllText(Path.Combine(output, file.Item2), result.Output);
|
||||||
}
|
}
|
||||||
|
if (full)
|
||||||
|
{
|
||||||
|
var trx = await Command("docker", ["cp", id + ":" + FullState + "/test-results/native.trx", Path.Combine(output, "native.trx.tmp")], output, "capture-native-trx", cancellation, requireSuccess: false);
|
||||||
|
if (trx.ExitCode == 0) File.Move(Path.Combine(output, "native.trx.tmp"), Path.Combine(output, "native.trx"), overwrite: true);
|
||||||
|
if (final || File.Exists(Path.Combine(output, "full-result.json")))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(output, "guest-logs"));
|
||||||
|
await Command("docker", ["cp", id + ":" + FullState + "/guest-logs/.", Path.Combine(output, "guest-logs")], output, "capture-guest-logs", cancellation, requireSuccess: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
// The immutable Recovery image is complete only after this staging marker.
|
// The immutable Recovery image is complete only after this staging marker.
|
||||||
// Hash it once instead of rereading the image on every twenty-second poll.
|
// Hash it once instead of rereading the image on every twenty-second poll.
|
||||||
if (logs.Output.Contains("[supported-profile] accelerator=tcg", StringComparison.Ordinal)
|
if (logs.Output.Contains("[supported-profile] accelerator=tcg", StringComparison.Ordinal)
|
||||||
@@ -535,6 +1137,23 @@ static class NativeDiagnostic
|
|||||||
await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test -f /storage/14/setup.dmg && sha256sum /storage/14/setup.dmg || exit 1; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false, retainSuccessful: true);
|
await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test -f /storage/14/setup.dmg && sha256sum /storage/14/setup.dmg || exit 1; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false, retainSuccessful: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void ReportCpuPreflight(string output, string logs)
|
||||||
|
{
|
||||||
|
var receipt = Path.Combine(output, "cpu-preflight-runtime.json");
|
||||||
|
if (File.Exists(receipt)) return;
|
||||||
|
var expectedSuffix = " cpu=" + CpuFlags + "; actual AVX/AVX2 executed before Apple download";
|
||||||
|
foreach (var line in logs.Split('\n'))
|
||||||
|
{
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(line, @"\[cpu-preflight\] positive=33 negative=([0-9]{1,3})");
|
||||||
|
if (!match.Success || match.Groups[1].Value == "33" || !line[(match.Index + match.Length)..].StartsWith(expectedSuffix, StringComparison.Ordinal)) continue;
|
||||||
|
var sourceMarker = match.Value + expectedSuffix;
|
||||||
|
var marker = "[cpu-preflight] positive=33 negative=" + match.Groups[1].Value + " accelerator=tcg cpu=" + CpuModel + " instructions=AVX/AVX2";
|
||||||
|
Console.WriteLine(marker);
|
||||||
|
Save(receipt, new { marker, markerSha256 = Hash(Encoding.UTF8.GetBytes(sourceMarker)), capturedUtc = DateTimeOffset.UtcNow, readinessGateSatisfied = false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async Task CapturePressure(string id, string output, string phase, CancellationToken cancellation)
|
static async Task CapturePressure(string id, string output, string phase, CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
using var snapshotDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
using var snapshotDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
@@ -860,6 +1479,18 @@ static class NativeDiagnostic
|
|||||||
Console.WriteLine("[native-diagnostic] Final native guest proof:");
|
Console.WriteLine("[native-diagnostic] Final native guest proof:");
|
||||||
Console.Write(proof);
|
Console.Write(proof);
|
||||||
}
|
}
|
||||||
|
static void PrintFullProof(string output, string token)
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { "full-result.json", "firstboot.log", "guest-logs/build.stdout.log", "guest-logs/build.stderr.log", "guest-logs/test.stdout.log", "guest-logs/test.stderr.log" })
|
||||||
|
{
|
||||||
|
var path = Path.Combine(output, name);
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
var proof = File.ReadAllText(path).Replace(token, "<run-id>", StringComparison.Ordinal);
|
||||||
|
if (proof.Length > 64 * 1024) proof = "[earlier output retained in artifact]\n" + proof[^(64 * 1024)..];
|
||||||
|
Console.WriteLine("[native-diagnostic] Final native evidence: " + name);
|
||||||
|
Console.WriteLine(proof);
|
||||||
|
}
|
||||||
|
}
|
||||||
static void Save(string path, object value)
|
static void Save(string path, object value)
|
||||||
{
|
{
|
||||||
var temporary = path + ".tmp";
|
var temporary = path + ".tmp";
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
#: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);
|
||||||
|
}
|
||||||
@@ -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,100 @@
|
|||||||
|
#!/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; }
|
||||||
|
require_installed_macos_version() {
|
||||||
|
# Numeric sw_vers product release with a major version of at least 14.
|
||||||
|
[[ "${1:-}" =~ ^(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,})\.[0-9]+(\.[0-9]+)?$ ]]
|
||||||
|
}
|
||||||
|
phase toolchain-installing
|
||||||
|
echo '[firstboot] verifying installed OS, APFS and owned physical store'
|
||||||
|
uname -a; id
|
||||||
|
installed_version=$(/usr/bin/sw_vers -productVersion) || fail installed_os_version_failed
|
||||||
|
require_installed_macos_version "$installed_version" || fail installed_macos_14_or_newer_required
|
||||||
|
printf '[firstboot] installed macOS version=%s\n' "$installed_version"
|
||||||
|
[ "$(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
|
||||||
Reference in New Issue
Block a user