forked from Manuel/meeting-assistant
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aaab45b46 | ||
|
|
efc3fdc681 | ||
|
|
c01ae13185 | ||
|
|
720a43158c | ||
|
|
227884723a | ||
|
|
25989cf0eb | ||
|
|
94a70b2005 | ||
|
|
45d7bde71f | ||
|
|
4606de0696 | ||
|
|
40281b57a5 | ||
|
|
c92e62bdf5 | ||
|
|
b40234d3b5 | ||
|
|
0a30a1ca5a | ||
|
|
6b1896660f | ||
|
|
9a91a81992 |
@@ -0,0 +1,36 @@
|
|||||||
|
name: macOS 14 TCG Recovery prerequisite diagnostic
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos-native-diagnostic:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||||
|
DOTNET_NOLOGO: "1"
|
||||||
|
steps:
|
||||||
|
- name: Checkout diagnostic source
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Setup .NET for the diagnostic helper
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Verify actual AVX2 emulation then probe macOS 14 Recovery
|
||||||
|
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||||
|
|
||||||
|
- name: Always clean up only this diagnostic's owned resources
|
||||||
|
if: always()
|
||||||
|
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||||
|
|
||||||
|
- name: Preserve native diagnostic evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: native-macos-recovery-diagnostic
|
||||||
|
path: artifacts/native-macos/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -3,6 +3,11 @@ name: PR and Push Build/Test
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
|
# This temporary branch changes only the native prerequisite diagnostic.
|
||||||
|
# Its manual workflow provides that evidence; the PR branch still runs all jobs.
|
||||||
|
branches-ignore:
|
||||||
|
- codex/macos-ci-kvm-compatibility
|
||||||
|
- codex/macos-ci-tcg-supported
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests req
|
|||||||
|
|
||||||
[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.
|
[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 [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.
|
||||||
|
|
||||||
## Operations And Limitations
|
## Operations And Limitations
|
||||||
|
|
||||||
- Treat recording, transcription drain, speaker finalization, OCR, and summarization as live user work. Never restart, kill, or clean runtime files until `/recording/status` is idle unless interruption is explicitly intended.
|
- Treat recording, transcription drain, speaker finalization, OCR, and summarization as live user work. Never restart, kill, or clean runtime files until `/recording/status` is idle unless interruption is explicitly intended.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# macOS 14 TCG prerequisite diagnostic
|
||||||
|
|
||||||
|
This manual candidate uses the existing Ubuntu/x64 Docker runner. Before downloading Apple Recovery it tests actual AVX/AVX2 instruction execution in the pinned QEMU binary, then probes a fresh macOS 14+ Recovery guest. It does not install macOS, erase a disk, provision .NET/CLT or run Meeting Assistant tests. Readiness is only a prerequisite for full native CI.
|
||||||
|
|
||||||
|
## Profile and evidence
|
||||||
|
|
||||||
|
The existing Intel Celeron 1037U has neither AVX nor AVX2. KVM run 4187 at `720a431` reached macOS 13.6/x86_64/root and visible whole writable 64-GiB media. Diskutil timed out after 122 seconds; the sampler produced no report after 61 seconds. The screen remained at the Apple boot progress bar. CPU throttling, memory-limit/OOM events and container swap were zero; memory peaked at 2.67 GB. Host paging occurred. No unsupported-instruction crash or particular IPC wait is proved.
|
||||||
|
|
||||||
|
[CryptexFixup 1.0.5](https://github.com/acidanthera/CryptexFixup/blob/1.0.5/kern_start.cpp) selects the installed/updated Rosetta Cryptex and patches APFS hash checking; it does not replace the running Recovery cache or emulate instructions. macOS 13 is outside the [.NET 10 supported-OS policy](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md). This candidate therefore uses macOS 14 and software CPU emulation without Cryptex. It changes the compatibility profile, not one isolated causal variable; actual success must be measured.
|
||||||
|
|
||||||
|
Earlier TCG run 4159 observed guest AVX2 with the upstream-selected Skylake model. Runs 4161/4163 measured slow native startup and reached the 40-minute host limit before readiness. They predated the UDIF CRC repair at `94a70b2`, reuse of successful sw_vers output and capturing the large Recovery hash only once. They do not qualify this candidate.
|
||||||
|
|
||||||
|
Run 4188 at `c01ae13` passed the actual AVX/AVX2 ROM test (positive exit 33, negative exit 0), then recorded 93 UEFI starts and 87 XNU handoffs before its 90-minute deadline. Container restart count, CPU throttling and OOM events were zero; no guest hook proof appeared. This establishes a guest boot loop, without identifying its cause. The next diagnostic preserves the same CPU/OS profile and has host/workflow limits of 20/25 minutes. Its purpose is to capture the first failure, not qualify full-run performance.
|
||||||
|
|
||||||
|
Kernel arguments add `-v debug=0x108 serial=5 msgbuf=1048576` while preserving the other pinned arguments, following [OpenCore 1.0.7](https://raw.githubusercontent.com/acidanthera/OpenCorePkg/1.0.7/Docs/Configuration.tex). Actual VM arguments add `-no-reboot -no-shutdown` and `-d int,cpu_reset,guest_errors,unimp`. The [QEMU reset policy](https://github.com/qemu/qemu/blob/v11.1.1/system/runstate.c) pauses the VM after a requested reset so monitor/framebuffer evidence survives. Exception output uses two bounded 4-MiB Docker log files. CPU/staging markers and sparse kernel-handoff lines are retained separately; repeated handoff or halted VM status fails immediately. These diagnostics do not prove a particular panic, CPU deficiency, or completed native test.
|
||||||
|
|
||||||
|
Run 4189 at `efc3fdc` stopped the first reset within about six minutes of container startup, with `VM status: paused (shutdown)`, one handoff and successful cleanup. Its retained trace started at exception 9517 and showed repeated supervisor instruction-fetch pagefaults at RIP/CR2 `0x24b0`; the preceding cause was missing. The current producer therefore retains the first 2 MiB after the kernel handoff before Docker rotation, while passing all output onward. This small AWK filter is part of the existing container boot integration and runs before a guest SDK exists; orchestration remains C#/.NET. Final capture retrieves the available Docker log files, the separate first-context file and monitor register/stack state.
|
||||||
|
|
||||||
|
The current candidate restores `Skylake-Client-v4` and the upstream TCG `-spec-ctrl` mask used by run 4159. `enforce=on`, actual instruction preflight, macOS 14, resource budgets and Readiness gates remain. This tests a previously booted source-bound profile; it does not assert that Haswell caused the earlier fault.
|
||||||
|
|
||||||
|
## Entry points and dependencies
|
||||||
|
|
||||||
|
Orchestration remains the .NET 10 file-based app `tools/ci/MacOsNativeDiagnostic.cs`. Existing Bash/Python boot integration is necessary before a guest SDK exists. NASM assembles the CPU probe in the disposable image build, without host/runner installation. No new runner, device, capability, secret or service is used.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/clean/pinned/dockur-clone --output /path/to/fresh/validation
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||||
|
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||||
|
```
|
||||||
|
|
||||||
|
The native diagnostic workflow is manual only. Temporary diagnostic branches are excluded from ordinary push jobs to avoid repeating unchanged Wine/portable jobs. Remove this routing when integrating qualified CI into the actual PR.
|
||||||
|
|
||||||
|
## Before Apple downloads
|
||||||
|
|
||||||
|
The existing daemon must be Linux/x64 with two CPUs and 6 GiB memory; the runner must have 5 GiB available memory and the Docker filesystem 8 GiB free. These checks do not reconfigure resources. Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, both imported QEMU image digests and original source seams remain pinned.
|
||||||
|
|
||||||
|
Actual `Skylake-Client-v4` CPU flags under TCG use `enforce=on` to reject unsupported requests. The CPU preflight uses that same composed flag list and QEMU binary before Recovery download/boot. `tools/ci/macos-tcg-cpu-preflight.asm` enables long mode/YMM state, executes AVX and AVX2 integer arithmetic, and checks an Int32 from the upper 128-bit lane. Only the correct result reaches [QEMU debug-exit](https://github.com/qemu/qemu/blob/v11.1.1/hw/misc/debugexit.c) code 33. No disks/network attach; failure/timeout fails preflight. This tests that instruction chain, not the complete ISA or macOS.
|
||||||
|
|
||||||
|
The locally assembled NASM 2.16.03 ROM is 65,536 bytes, SHA256 `c32746122cc68f3ed642aa46c21b677f803c58f0d4ff665841723fcc5625f549`. Assembly/static review does not prove remote execution.
|
||||||
|
|
||||||
|
## Native gates, bounds and cleanup
|
||||||
|
|
||||||
|
The original Apple recoveryosd runs under its existing job/PID beside the read-only probe. Exact known macOS 13/14 plist layouts and same-length replacements retain their allowlist. The patcher validates UDIF boundaries, updates changed mish/koly CRCs and reads back the image. Four raw/zlib positive and twelve rejection fixtures use an independent C# CRC32 reader. Apple chunklist authentication applies to the input, not the deliberately modified image.
|
||||||
|
|
||||||
|
Native readiness requires x86_64, UID 0, macOS 14+, successful launchd service queries and exactly one writable whole 64-GiB disk. The complete successful sw_vers output must contain one valid ProductVersion field and EOF within 1,024 bytes. The actual native diskutil query remains mandatory.
|
||||||
|
|
||||||
|
Required commands retain 45 seconds, UID 180 seconds and the single disk query 120 seconds. The owned observer uses `/bin/ps -M -p <diskutil-child>` with a separate 60-second limit and two-second TERM/KILL grace. It avoids stack symbolication; thread waiting states do not identify an IPC endpoint. Observation failure passes no gate. Owned children are stopped on completion/cancellation; output remains 512 KiB per command and 4 MiB proof.
|
||||||
|
|
||||||
|
The container retains 6 GiB memory/swap, two-CPU limit, 512 MiB shared memory and a 4-GiB/two-vCPU guest. One fresh anonymous /storage volume holds the sparse 64-GiB target. Inspection rejects devices, capabilities, binds, ports, host networking and privileged mode. KVM is disabled with no /dev/kvm mapping; guest networking stays slirp.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
#:property PublishAot=false
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
// .NET 10 file-based CI diagnostic. See docs/macos-native-diagnostic.md.
|
||||||
|
return await NativeDiagnostic.Execute(args);
|
||||||
|
|
||||||
|
static class NativeDiagnostic
|
||||||
|
{
|
||||||
|
const string DockurCommit = "16a5b470cdd601bae8b05b02d748d7edfb36c12e";
|
||||||
|
const string Profile = "tcg-skylake-sonoma";
|
||||||
|
const string CpuModel = "Skylake-Client-v4";
|
||||||
|
const int DiagnosticMinutes = 20;
|
||||||
|
const string DiagnosticArguments = "-object iothread,id=io2 -no-reboot -no-shutdown -d int,cpu_reset,guest_errors,unimp";
|
||||||
|
const string CpuFlags = "Skylake-Client-v4,l3-cache=on,+hypervisor,vendor=GenuineIntel,vmx=off,vmware-cpuid-freq=on,-pdpe1gb,-spec-ctrl,-pcid,-invpcid,-tsc-deadline,-xsavec,-xsaves,+ssse3,+sse4.2,+popcnt,+avx,+avx2,+aes,+fma,+bmi1,+bmi2,+smep,+xsave,+xsaveopt,+xgetbv1,+movbe,+rdrand,enforce=on";
|
||||||
|
const string OpenCoreTemplateHash = "287328995d4198f1b05166f087d85bf7ef66bedafe150d17ad112ac8de60051d";
|
||||||
|
const string UdifChecksumBindingHash = "6109d04619e800c483fdac363d593cd1cd69f34131d2521417334e11d41c8bfa";
|
||||||
|
const string OwnerLabel = "org.meeting-assistant.native-diagnostic";
|
||||||
|
const long GuestDiskBytes = 64L * 1024 * 1024 * 1024;
|
||||||
|
const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024;
|
||||||
|
const int MaximumCapturedCharacters = 8 * 1024 * 1024;
|
||||||
|
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 MountOnlyBootstrap = "[ ! -e /tmp/m ]&& /sbin/mount_9p installstate >/dev/null 2>&1; : >/tmp/m\n";
|
||||||
|
static readonly string OriginalDaemon = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
\t<key>Label</key>
|
||||||
|
\t<string>com.apple.recoveryosd</string>
|
||||||
|
\t<key>OnDemand</key>
|
||||||
|
\t<false/>
|
||||||
|
\t<key>ProcessType</key>
|
||||||
|
\t<string>App</string>
|
||||||
|
\t<key>EnablePressuredExit</key>
|
||||||
|
\t<false/>
|
||||||
|
\t<key>ProgramArguments</key>
|
||||||
|
\t<array>
|
||||||
|
\t\t<string>/usr/libexec/recoveryosd</string>
|
||||||
|
\t</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
""".Replace("\\t", "\t", StringComparison.Ordinal);
|
||||||
|
static readonly string DiagnosticDaemon = (OriginalDaemon + "\n")
|
||||||
|
.Replace("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n", "", StringComparison.Ordinal)
|
||||||
|
.Replace("\t\t<string>/usr/libexec/recoveryosd</string>", "\t\t<string>/bin/bash</string>\n\t\t<string>/Volumes/installstate/launch.sh</string>", StringComparison.Ordinal);
|
||||||
|
// Exact XML framing read from the Apple 13 comparison download, not an assertion
|
||||||
|
// about the unretained bytes downloaded by run 4173.
|
||||||
|
static readonly string OriginalDaemon13 = ReplaceOnce(OriginalDaemon + "\n", "<string>App</string>", "<string>Interactive</string>");
|
||||||
|
static readonly string DiagnosticDaemon13 = ReplaceOnce(DiagnosticDaemon, "<string>App</string>", "<string>Interactive</string>");
|
||||||
|
|
||||||
|
public static async Task<int> Execute(string[] args)
|
||||||
|
{
|
||||||
|
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]");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos");
|
||||||
|
if (args.Contains("--validate"))
|
||||||
|
{
|
||||||
|
ValidateContracts();
|
||||||
|
if (Option(args, "--source") is { } source)
|
||||||
|
{
|
||||||
|
await PrepareSource(Path.GetFullPath(source), output, "validation", false, CancellationToken.None);
|
||||||
|
await ValidateResourceRetention(output);
|
||||||
|
await ValidateRecoveryPatch(output);
|
||||||
|
await ValidateTcgPreflight(output, CancellationToken.None);
|
||||||
|
Save(Path.Combine(output, "validation.json"), new
|
||||||
|
{
|
||||||
|
success = true, profile = Profile,
|
||||||
|
helperSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "MacOsNativeDiagnostic.cs"))),
|
||||||
|
udifChecksumBindingSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-udif-checksums.py"))),
|
||||||
|
baselineReadinessNormalized = true, readinessDiagnosticBlocksExcluded = 7,
|
||||||
|
productVersionParserBlockExcluded = true, productVersionSequenceRestored = true, productVersionMaximumBytes = 1024, nativeProductVersionCommandRemoved = true,
|
||||||
|
diskReadinessAttemptLimit = 1, diskCommandLimitSeconds = 120, diskThreadObservationLimitSeconds = 60, stackSamplingUsed = false,
|
||||||
|
resultNegativeCases = 6, containerNegativeCases = 11, recoveryPositiveCases = 4, recoveryNegativeCases = 12,
|
||||||
|
independentFixtureCrc32Readback = true, resourceSnapshotRetention = true, cpuProfileSourceContractsVerified = true,
|
||||||
|
preflightGateFixtureCases = 8, qemuRuntimePreflightExecuted = false, templateIsoDownloaded = false,
|
||||||
|
sourceModified = false, dockerExecuted = false, guestExecuted = false, completedUtc = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (args.Contains("--cleanup"))
|
||||||
|
return await Cleanup(output) ? 0 : 1;
|
||||||
|
if (!args.Contains("--run")) throw new ArgumentException("Choose --run, --cleanup or --validate.");
|
||||||
|
Directory.CreateDirectory(output);
|
||||||
|
var statePath = Path.Combine(output, "owned-resources.json");
|
||||||
|
if (File.Exists(statePath)) throw new InvalidOperationException("Output already contains a run identity; choose a fresh directory or clean up its run first.");
|
||||||
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
var work = Path.Combine(Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), "meeting-assistant-native-" + token);
|
||||||
|
var state = new OwnedResources(token, "meeting-assistant-native-" + token, "meeting-assistant-native-diagnostic:" + token, work);
|
||||||
|
Save(statePath, state);
|
||||||
|
Directory.CreateDirectory(work);
|
||||||
|
File.WriteAllText(Path.Combine(work, "run.owner"), token);
|
||||||
|
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(DiagnosticMinutes));
|
||||||
|
using var signal = OperatingSystem.IsLinux() ? PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); }) : null;
|
||||||
|
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||||
|
Console.CancelKeyPress += cancelHandler;
|
||||||
|
var outcome = "failed";
|
||||||
|
string? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||||
|
throw new InvalidOperationException("This diagnostic runs on the existing Linux/x64 runner only.");
|
||||||
|
ValidateContracts();
|
||||||
|
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 = DiagnosticMinutes });
|
||||||
|
var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token);
|
||||||
|
using (var document = JsonDocument.Parse(info.Output))
|
||||||
|
{
|
||||||
|
var data = document.RootElement;
|
||||||
|
if (data.GetProperty("OSType").GetString() != "linux" || data.GetProperty("Architecture").GetString() is not ("x86_64" or "amd64"))
|
||||||
|
throw new InvalidOperationException("The existing Docker daemon is not Linux/x64; this diagnostic does not reconfigure it.");
|
||||||
|
if (data.GetProperty("NCPU").GetInt32() < 2 || data.GetProperty("MemTotal").GetInt64() < ContainerMemoryBytes)
|
||||||
|
throw new InvalidOperationException("Existing Docker resources cannot fit this bounded 2-CPU/6-GiB diagnostic; no infrastructure change was requested.");
|
||||||
|
}
|
||||||
|
await Command("sh", ["-c", "cat /proc/meminfo; printf '\n[cgroup]\n'; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true; printf '\n[workspace disk]\n'; df -Pk ."], output, "runner-resources", deadline.Token);
|
||||||
|
var available = System.Text.RegularExpressions.Regex.Match(File.ReadAllText("/proc/meminfo"), @"(?m)^MemAvailable:\s+(\d+) kB$");
|
||||||
|
if (!available.Success || long.Parse(available.Groups[1].Value) < 5L * 1024 * 1024)
|
||||||
|
throw new InvalidOperationException("Existing runner memory has less than the 5-GiB available diagnostic budget; no infrastructure change was requested.");
|
||||||
|
var source = Path.Combine(work, "dockur");
|
||||||
|
await Command("git", ["clone", "--no-checkout", "https://github.com/dockur/macos.git", source], output, "dockur-clone", 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();
|
||||||
|
if (actualCommit != DockurCommit) throw new InvalidOperationException("Dockur source pin mismatch.");
|
||||||
|
await PrepareSource(source, output, token, true, deadline.Token);
|
||||||
|
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);
|
||||||
|
using (var image = JsonDocument.Parse(imageInspect.Output))
|
||||||
|
state = state with { ImageId = image.RootElement[0].GetProperty("Id").GetString() };
|
||||||
|
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=4m", "--log-opt", "max-file=2", "--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=" + DiagnosticArguments, state.ImageTag], output, "docker-create", deadline.Token);
|
||||||
|
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.");
|
||||||
|
state = state with { ContainerId = id };
|
||||||
|
Save(statePath, state);
|
||||||
|
await Command("docker", ["inspect", id], output, "container-created", deadline.Token);
|
||||||
|
AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token);
|
||||||
|
await Command("docker", ["start", id], output, "docker-start", deadline.Token);
|
||||||
|
await CapturePressure(id, output, "before", deadline.Token);
|
||||||
|
Console.WriteLine("The owned unprivileged TCG/Skylake 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.");
|
||||||
|
var recoveryStarted = Stopwatch.StartNew();
|
||||||
|
var heartbeat = Stopwatch.StartNew();
|
||||||
|
var diskPressureCaptured = false;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
deadline.Token.ThrowIfCancellationRequested();
|
||||||
|
await CaptureGuest(id, output, deadline.Token);
|
||||||
|
await CheckRecoveryBootProgress(id, output, deadline.Token);
|
||||||
|
var proofPath = Path.Combine(output, "guest-proof.log");
|
||||||
|
if (!diskPressureCaptured && File.Exists(proofPath) && File.ReadAllText(proofPath).Contains("[proof-start] disks", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
diskPressureCaptured = true;
|
||||||
|
await CapturePressure(id, output, "during", deadline.Token);
|
||||||
|
}
|
||||||
|
var resultPath = Path.Combine(output, "guest-result.json");
|
||||||
|
if (File.Exists(resultPath))
|
||||||
|
{
|
||||||
|
var result = File.ReadAllText(resultPath);
|
||||||
|
ValidateResult(result, token);
|
||||||
|
Console.WriteLine("Native Recovery readiness passed. This run has not installed macOS, .NET, CLT, or run Meeting Assistant tests.");
|
||||||
|
outcome = "readiness-passed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
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 (heartbeat.Elapsed >= TimeSpan.FromSeconds(60))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[native-diagnostic] phase=recovery; elapsed={recoveryStarted.Elapsed.TotalMinutes:F1} minutes; container=running; readiness=pending");
|
||||||
|
heartbeat.Restart();
|
||||||
|
}
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
error = exception is OperationCanceledException ? $"The explicit {DiagnosticMinutes}-minute diagnostic deadline or cancellation was reached." : exception.Message;
|
||||||
|
Console.Error.WriteLine(error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.CancelKeyPress -= cancelHandler;
|
||||||
|
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); }
|
||||||
|
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); }
|
||||||
|
var clean = await Cleanup(output);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
return outcome == "readiness-passed" ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? Option(string[] args, string name)
|
||||||
|
{
|
||||||
|
var index = Array.IndexOf(args, name);
|
||||||
|
return index < 0 ? null : index + 1 < args.Length ? args[index + 1] : throw new ArgumentException("Missing value for " + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ValidateContracts()
|
||||||
|
{
|
||||||
|
var readiness = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-readiness.sh"));
|
||||||
|
var baseline = NormalizeReadinessDiagnostics(readiness);
|
||||||
|
baseline = ReplaceOnce(baseline, "while (( attempt < 1 && SECONDS - readiness_start < 600 )); do", "while (( SECONDS - readiness_start < 600 )); do");
|
||||||
|
if (Hash(Encoding.UTF8.GetBytes(baseline)) != "4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3")
|
||||||
|
throw new InvalidOperationException("Outside seven explicit diagnostic blocks, the successful sw_vers version parser/sequence and one-attempt limit, baseline identity/service/disk gates and watchdogs must remain identical.");
|
||||||
|
if (Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"))) != "94f069e116fdc7685a4d233cab6fa50df9f39274386bb82157674061e74fadb5")
|
||||||
|
throw new InvalidOperationException("Compatibility profile must preserve the baseline Apple recoveryosd wrapper.");
|
||||||
|
if (Hash(Encoding.UTF8.GetBytes(OriginalDaemon13)) != "af9d7f6c1948079bd4384d27b6882678d6fb4e338fcf6a8be8f84fceef174ad6") throw new InvalidOperationException("macOS 13 allowlist bytes differ from the independently read comparison plist.");
|
||||||
|
foreach (var variant in new[] { (OriginalDaemon + "\n", DiagnosticDaemon, "App"), (OriginalDaemon13, DiagnosticDaemon13, "Interactive") })
|
||||||
|
{
|
||||||
|
ValidateDaemon(variant.Item1, variant.Item3, false);
|
||||||
|
ValidateDaemon(variant.Item2, variant.Item3, true);
|
||||||
|
if (Encoding.UTF8.GetByteCount(variant.Item2) > Encoding.UTF8.GetByteCount(variant.Item1)) throw new InvalidOperationException("Daemon replacement exceeds original file.");
|
||||||
|
}
|
||||||
|
var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "14.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 });
|
||||||
|
ValidateResult(good, "validation");
|
||||||
|
ValidateBootProgress();
|
||||||
|
foreach (var invalid in new[] { good.Replace("14.6.1", "13.6.1"), good.Replace("x86_64", "arm64"), good.Replace("\"readOnly\":false", "\"readOnly\":true"), good.Replace("\"success\":true", "\"success\":false"), good.Replace("68719476736", "17179869184"), good.Replace("validation", "stale") })
|
||||||
|
{
|
||||||
|
try { ValidateResult(invalid, "validation"); } catch (InvalidOperationException) { continue; }
|
||||||
|
throw new InvalidOperationException("Diagnostic validator accepted an invalid/stale result.");
|
||||||
|
}
|
||||||
|
var boundary = """
|
||||||
|
[{"Config":{"Labels":{"org.meeting-assistant.native-diagnostic":"validation"},"Env":["KVM=N","CPU_MODEL=Skylake-Client-v4","VERSION=14"]},"HostConfig":{"Privileged":false,"NetworkMode":"default","Memory":6442450944,"MemorySwap":6442450944,"NanoCpus":2000000000,"ShmSize":536870912,"CapAdd":null,"DeviceRequests":null,"Binds":null,"PortBindings":{},"DeviceCgroupRules":null,"Tmpfs":null,"Devices":[]},"Mounts":[{"Type":"volume","Destination":"/storage","RW":true}]}]
|
||||||
|
""";
|
||||||
|
AssertContainer(boundary, "validation");
|
||||||
|
foreach (var invalid in new[] { boundary.Replace("\"Privileged\":false", "\"Privileged\":true"), boundary.Replace("\"Devices\":[]", "\"Devices\":[{\"PathOnHost\":\"/dev/kvm\",\"PathInContainer\":\"/dev/kvm\",\"CgroupPermissions\":\"rw\"}]"), boundary.Replace("KVM=N", "KVM=Y"), boundary.Replace("CPU_MODEL=Skylake-Client-v4", "CPU_MODEL=host"), boundary.Replace("VERSION=14", "VERSION=13"), boundary.Replace("6442450944", "8589934592"), boundary.Replace("\"NetworkMode\":\"default\"", "\"NetworkMode\":\"host\""), boundary.Replace("\"CapAdd\":null", "\"CapAdd\":[\"NET_ADMIN\"]"), boundary.Replace("\"Type\":\"volume\"", "\"Type\":\"bind\""), boundary.Replace("/storage", "/host"), boundary.Replace("\"NanoCpus\":2000000000", "\"NanoCpus\":4000000000") })
|
||||||
|
{
|
||||||
|
try { AssertContainer(invalid, "validation"); } catch (InvalidOperationException) { continue; }
|
||||||
|
throw new InvalidOperationException("Diagnostic validator accepted an excessive/wrong-profile container boundary.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NormalizeReadinessDiagnostics(string source)
|
||||||
|
{
|
||||||
|
const string start = "# BEGIN disk IPC diagnostic\n";
|
||||||
|
const string end = "# END disk IPC diagnostic\n";
|
||||||
|
var blocks = 0;
|
||||||
|
while (source.IndexOf(start, StringComparison.Ordinal) is var from && from >= 0)
|
||||||
|
{
|
||||||
|
var to = source.IndexOf(end, from + start.Length, StringComparison.Ordinal);
|
||||||
|
if (to < 0 || source.IndexOf(start, from + start.Length, to - from - start.Length, StringComparison.Ordinal) >= 0)
|
||||||
|
throw new InvalidOperationException("Readiness diagnostic blocks are unbalanced or nested.");
|
||||||
|
source = source.Remove(from, to + end.Length - from);
|
||||||
|
blocks++;
|
||||||
|
}
|
||||||
|
if (blocks != 7 || source.Contains(end, StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException("Readiness must contain exactly seven explicit disk IPC diagnostic blocks.");
|
||||||
|
source = RestoreVersionBlock(source, "successful sw_vers version parser", "");
|
||||||
|
source = RestoreVersionBlock(source, "successful sw_vers version extraction", "run_command version /usr/bin/sw_vers -productVersion\n(( LAST_EXIT == 0 )) || fail_probe product_version_failed\nread_scalar || fail_probe product_version_invalid\n");
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string RestoreVersionBlock(string source, string name, string originalSequence)
|
||||||
|
{
|
||||||
|
var start = "# BEGIN " + name + "\n";
|
||||||
|
var end = "# END " + name + "\n";
|
||||||
|
if (source.Split(start, StringSplitOptions.None).Length != 2 || source.Split(end, StringSplitOptions.None).Length != 2)
|
||||||
|
throw new InvalidOperationException("Readiness requires exactly one named version marker pair: " + name);
|
||||||
|
var from = source.IndexOf(start, StringComparison.Ordinal);
|
||||||
|
var to = source.IndexOf(end, StringComparison.Ordinal);
|
||||||
|
if (to < from + start.Length) throw new InvalidOperationException("Readiness version markers are reversed: " + name);
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(output);
|
||||||
|
var patchPath = Path.Combine(source, "src/install/recovery/patch.py");
|
||||||
|
var originalPatch = File.ReadAllText(patchPath);
|
||||||
|
if (Hash(Encoding.UTF8.GetBytes(originalPatch)) != "84f13db88c02edbf5ce21a39571fe58f12bebf5b0886c2d012f16ddbaed45323") throw new InvalidOperationException("Pinned Recovery patcher hash mismatch.");
|
||||||
|
var daemon = OriginalDaemon + "\n";
|
||||||
|
var patch = PrepareRecoveryPatch(originalPatch);
|
||||||
|
var checksumBinding = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-udif-checksums.py"));
|
||||||
|
if (Hash(Encoding.UTF8.GetBytes(checksumBinding)) != UdifChecksumBindingHash) throw new InvalidOperationException("Recovery UDIF checksum binding hash mismatch.");
|
||||||
|
File.WriteAllText(Path.Combine(output, "udif_checksums.py"), checksumBinding, new UTF8Encoding(false));
|
||||||
|
var dockerPath = Path.Combine(source, "Dockerfile");
|
||||||
|
if (Hash(File.ReadAllBytes(dockerPath)) != "a0e804235967400eb70e755d63eff8a33a7761922ddd6e9723faa8e828fd8aa3") throw new InvalidOperationException("Pinned Dockerfile hash mismatch.");
|
||||||
|
// The existing runner's BuildKit cannot checksum dangling manpage links during COPY /.
|
||||||
|
// This pinned filesystem image has an empty Config; FROM preserves the same runtime defaults.
|
||||||
|
var dockerfile = ReplaceOnce(File.ReadAllText(dockerPath), "FROM scratch AS base\nCOPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / /\n", "FROM qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df AS base\n");
|
||||||
|
dockerfile = ReplaceAllExact(dockerfile, "--from=qemux/qemu-macos:latest ", "--from=qemux/qemu-macos:latest@sha256:af64297171228f27d5f616249e18f6ad5e2fbc79c1cc517252521e8bcd8eadaa ", 2);
|
||||||
|
dockerfile = ReplaceOnce(dockerfile, "ADD $REPO_KVM_OPENCORE/releases/download/v$VERSION_KVM_OPENCORE/LongQT-OpenCore-v$VERSION_KVM_OPENCORE.iso /opencore.iso", "ADD --checksum=sha256:" + OpenCoreTemplateHash + " $REPO_KVM_OPENCORE/releases/download/v$VERSION_KVM_OPENCORE/LongQT-OpenCore-v$VERSION_KVM_OPENCORE.iso /opencore.iso");
|
||||||
|
dockerfile = ReplaceOnce(dockerfile, " gzip \\\n", " gzip \\\n nasm \\\n");
|
||||||
|
dockerfile = ReplaceOnce(dockerfile, "COPY --chmod=755 ./assets /assets/\n", "COPY --chmod=755 ./assets /assets/\nRUN nasm -f bin /assets/ci-cpu-preflight.asm -o /assets/ci-cpu-preflight.bin && test \"$(stat -c%s /assets/ci-cpu-preflight.bin)\" = 65536\n");
|
||||||
|
var boot = PrepareTcgBoot(source);
|
||||||
|
var cpuPath = Path.Combine(source, "src/cpu.sh");
|
||||||
|
var cpu = File.ReadAllText(cpuPath);
|
||||||
|
if (Hash(Encoding.UTF8.GetBytes(cpu)) != "0f3e4b4e1c3e17743d3a8d27b77a76424ceebb576b269283d612c489bc70993e") throw new InvalidOperationException("Pinned CPU composition script hash mismatch.");
|
||||||
|
cpu = ReplaceOnce(cpu, ",+movbe,+rdrand,check\"", ",+movbe,+rdrand,enforce=on\"");
|
||||||
|
// Explicit CPU_MODEL bypasses upstream selection, so restore its TCG mitigation mask.
|
||||||
|
cpu = ReplaceOnce(cpu, " DEFAULT_FLAGS+=\",-pcid,-invpcid,-tsc-deadline,-xsavec,-xsaves\"", " DEFAULT_FLAGS+=\",-spec-ctrl,-pcid,-invpcid,-tsc-deadline,-xsavec,-xsaves\"");
|
||||||
|
var preflight = File.ReadAllText(Path.Combine("tools", "ci", "macos-tcg-cpu-preflight.asm"));
|
||||||
|
var entryPath = Path.Combine(source, "src/entry.sh");
|
||||||
|
var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n");
|
||||||
|
entry = ReplaceOnce(entry, ". cpu.sh # Configure CPU model\n", "");
|
||||||
|
entry = ReplaceOnce(entry, ". proc.sh # Initialize processor\n", "");
|
||||||
|
entry = ReplaceOnce(entry, ". init.sh # Initialize system\n", ". init.sh # Initialize system\n. cpu.sh # Compose the exact guest CPU before any Apple download\n. proc.sh # Compose the actual accelerator/CPU_FLAGS once\n" + TcgPreflight + "\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=Skylake-Client-v4 recovery=14; AVX/AVX2 preflight passed; native guest gates still pending'\n\ntrap - ERR\n");
|
||||||
|
entry = ReplaceOnce(entry, "\ntrap - ERR\n", "\nprintf '%s\\n' '[supported-profile] accelerator=tcg cpu=Skylake-Client-v4 recovery=14; AVX/AVX2 preflight passed; native guest gates still pending' >> \"$QEMU_DIR/native-stage.log\"\ntrap - ERR\n");
|
||||||
|
// Preserve sparse boot markers independently of the bounded, verbose Docker trace.
|
||||||
|
entry = ReplaceOnce(entry, " -e 's/failed to load Boot/skipped Boot/g' \\\n", " -e 's/failed to load Boot/skipped Boot/g' \\\n -e '/^#\\[EB|LOG:HANDOFF TO XNU\\] /w /run/shm/kernel-handoffs.log' \\\n");
|
||||||
|
// The producer must retain the first kernel context before Docker can rotate it.
|
||||||
|
// This tiny existing-runtime filter runs inside the VM container before any .NET SDK.
|
||||||
|
const string contextCapture = """
|
||||||
|
<"$pipe" | LC_ALL=C awk '
|
||||||
|
/^#\[EB\|LOG:HANDOFF TO XNU\] / { kernel_started=1 }
|
||||||
|
{
|
||||||
|
if (kernel_started && context_bytes < 2097152) {
|
||||||
|
remaining=2097152-context_bytes
|
||||||
|
piece=substr($0 "\n", 1, remaining)
|
||||||
|
printf "%s", piece > "/run/shm/first-kernel-context.log"
|
||||||
|
context_bytes+=length(piece)
|
||||||
|
fflush("/run/shm/first-kernel-context.log")
|
||||||
|
}
|
||||||
|
print
|
||||||
|
fflush()
|
||||||
|
}
|
||||||
|
' &
|
||||||
|
""";
|
||||||
|
entry = ReplaceOnce(entry, " <\"$pipe\" &", contextCapture);
|
||||||
|
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
||||||
|
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
||||||
|
var wrapper = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"));
|
||||||
|
var imagePath = Path.Combine(source, "src", "image.sh");
|
||||||
|
var originalImage = File.ReadAllText(imagePath);
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
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));
|
||||||
|
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, "cpu-preflight-source.json"), new { profile = Profile, cpuModel = CpuModel, cpuFlags = CpuFlags, expectedExitCode = 33, instructionProbeExecuted = false, qemuBinaryExecuted = false, sourceSha256 = Hash(Encoding.UTF8.GetBytes(preflight)) });
|
||||||
|
await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-syntax", cancellation);
|
||||||
|
await Command("bash", ["-n", Path.Combine(output, "guest-readiness.sh")], output, "guest-readiness-syntax", cancellation);
|
||||||
|
await Command("bash", ["-n", Path.Combine(output, "image.sh.patched")], output, "guest-staging-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, "cpu.sh.patched")], output, "cpu-composition-syntax", cancellation);
|
||||||
|
if (!writeSource) return;
|
||||||
|
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/install/recovery/udif_checksums.py"), checksumBinding, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(entryPath, entry, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(imagePath, image, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), wrapper, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/install/recovery/readiness.sh"), hook, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "src/boot.sh"), boot, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(cpuPath, cpu, new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(source, "assets/ci-cpu-preflight.asm"), preflight, new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ValidateDaemon(string xml, string processType, bool patched)
|
||||||
|
{
|
||||||
|
var pairs = XDocument.Parse(xml).Root!.Element("dict")!.Elements().ToArray();
|
||||||
|
if (pairs.Length != 10 || !pairs.Where((_, index) => index % 2 == 0).Select(element => element.Value).SequenceEqual(new[] { "Label", "OnDemand", "ProcessType", "EnablePressuredExit", "ProgramArguments" })) throw new InvalidOperationException("Recovery daemon fields changed.");
|
||||||
|
if (pairs[1].Value != "com.apple.recoveryosd" || pairs[3].Name != "false" || pairs[5].Value != processType || pairs[7].Name != "false" || pairs[9].Name != "array" || !pairs[9].Elements().Select(element => element.Value).SequenceEqual(patched ? new[] { "/bin/bash", "/Volumes/installstate/launch.sh" } : new[] { "/usr/libexec/recoveryosd" })) throw new InvalidOperationException("Recovery daemon identity/arguments changed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string PrepareRecoveryPatch(string original)
|
||||||
|
{
|
||||||
|
var patch = ReplaceOnce(original, OriginalBootstrap, MountOnlyBootstrap);
|
||||||
|
patch = ReplaceOnce(patch, "import zlib\n", "import zlib\nfrom udif_checksums import ChecksumPlan\n");
|
||||||
|
var constants = "RECOVERY_13_ORIGINAL = b'''" + OriginalDaemon13 + "'''\nRECOVERY_13_REPLACEMENT = b'''" + DiagnosticDaemon13 + "'''.ljust(len(RECOVERY_13_ORIGINAL), b\" \")\nRECOVERY_14_ORIGINAL = b'''" + OriginalDaemon + "\n'''\nRECOVERY_14_REPLACEMENT = b'''" + DiagnosticDaemon + "'''.ljust(len(RECOVERY_14_ORIGINAL), b\" \")\nRECOVERY_LABEL = b'<string>com.apple.recoveryosd</string>'";
|
||||||
|
patch = ReplaceOnce(patch, "RECOVERY_ORIGINAL = b\"/usr/libexec/recoveryosd\"\nRECOVERY_REPLACEMENT = b\"/private/etc/rc.cdrom.sh\"", constants);
|
||||||
|
patch = ReplaceOnce(patch, " if len(RECOVERY_REPLACEMENT) != len(RECOVERY_ORIGINAL):\n raise RuntimeError(\"recoveryosd launch-path replacement length mismatch\")", " if len(RECOVERY_13_REPLACEMENT) != len(RECOVERY_13_ORIGINAL) or len(RECOVERY_14_REPLACEMENT) != len(RECOVERY_14_ORIGINAL):\n raise RuntimeError(\"recoveryosd launch-path replacement length mismatch\")");
|
||||||
|
patch = ReplaceOnce(patch, " (\"recoveryosd launch path\", RECOVERY_ORIGINAL, RECOVERY_REPLACEMENT),", " (\"recoveryosd macOS 13 launch path\", RECOVERY_13_ORIGINAL, RECOVERY_13_REPLACEMENT),\n (\"recoveryosd macOS 14 launch path\", RECOVERY_14_ORIGINAL, RECOVERY_14_REPLACEMENT),");
|
||||||
|
patch = ReplaceOnce(patch, " chunks = {}\n", " chunks = {}\n recovery_label_count = 0\n");
|
||||||
|
patch = ReplaceOnce(patch, " plist = plistlib.loads(image.read(xml_length))\n", " plist = plistlib.loads(image.read(xml_length))\n checksums = ChecksumPlan(image, koly, plist, xml_offset, xml_length, size)\n");
|
||||||
|
patch = ReplaceOnce(patch, " key = (blkx_index, run_index)\n", " recovery_label_count += decoded.count(RECOVERY_LABEL)\n key = (blkx_index, run_index)\n");
|
||||||
|
var variantValidation = """
|
||||||
|
if len(matches[patches[0][0]]) != 1:
|
||||||
|
raise RuntimeError("Expected exactly one rc.cdrom.sh bootstrap")
|
||||||
|
variants = [item for item in patches[1:] if matches[item[0]]]
|
||||||
|
if recovery_label_count != 1 or len(variants) != 1 or len(matches[variants[0][0]]) != 1:
|
||||||
|
raise RuntimeError("Expected exactly one known recoveryosd plist and launch path")
|
||||||
|
patches = (patches[0], variants[0])
|
||||||
|
print("[recovery-daemon] " + variants[0][0])
|
||||||
|
""";
|
||||||
|
patch = ReplaceOnce(patch, " for name, _, _ in patches:\n count = len(matches[name])\n if count != 1:\n raise RuntimeError(f\"Expected exactly one {name}, found {count}\")", IndentPython(variantValidation, 8));
|
||||||
|
// Plan all recompressed chunks before the first image write. A later
|
||||||
|
// compression failure must not leave an earlier chunk patched.
|
||||||
|
patch = ReplaceOnce(patch, " for key, chunk in chunks.items():\n patched = bytearray", " planned = []\n for key, chunk in chunks.items():\n patched = bytearray");
|
||||||
|
patch = ReplaceOnce(patch, " image.seek(chunk[\"physical_offset\"])\n image.write(stored)", " planned.append((chunk[\"physical_offset\"], stored))\n\n checksum_xml, checksum_koly = checksums.prepare(planned)\n for physical_offset, stored in planned:\n image.seek(physical_offset)\n image.write(stored)\n image.seek(xml_offset)\n image.write(checksum_xml)\n image.seek(size - 512)\n image.write(checksum_koly)");
|
||||||
|
patch = ReplaceOnce(patch, " image.flush()\n", " image.flush()\n checksums.verify()\n");
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string IndentPython(string text, int spaces)
|
||||||
|
{
|
||||||
|
var lines = text.Split('\n');
|
||||||
|
var common = lines.Where(line => line.Length > 0).Min(line => line.TakeWhile(character => character == ' ').Count());
|
||||||
|
return string.Join("\n", lines.Select(line => new string(' ', spaces) + (line.Length > 0 ? line[common..] : "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string PrepareTcgBoot(string source)
|
||||||
|
{
|
||||||
|
var boot = File.ReadAllText(Path.Combine(source, "src", "boot.sh"));
|
||||||
|
var config = File.ReadAllText(Path.Combine(source, "assets", "config.plist"));
|
||||||
|
if (Hash(Encoding.UTF8.GetBytes(boot)) != "82b56525707a8f586e040f56108b5034c02e7fecfea071f1857e596cba10cbed" || Hash(Encoding.UTF8.GetBytes(config)) != "3b0ec58b693cfa0fadf3e3f952486e87af8c27d504f9545d1e90ae2dc3777096") throw new InvalidOperationException("Pinned OpenCore staging/config hashes mismatch.");
|
||||||
|
var document = XDocument.Parse(config, LoadOptions.PreserveWhitespace);
|
||||||
|
var add = PlistValue(PlistValue(document.Root!.Element("dict")!, "Kernel"), "Add");
|
||||||
|
var expected = new[] { "Lilu.kext", "VMHide.kext", "VirtualSMC.kext", "WhateverGreen.kext", "VoodooPS2Controller.kext", "VoodooPS2Controller.kext/Contents/PlugIns/VoodooPS2Keyboard.kext", "AppleMCEReporterDisabler.kext" };
|
||||||
|
if (!add.Elements("dict").Select(dict => PlistValue(dict, "BundlePath").Value).SequenceEqual(expected) || add.Elements("dict").Any(dict => PlistValue(dict, "Enabled").Name != "true")) throw new InvalidOperationException("Pinned Kernel.Add order/enabled contract mismatch.");
|
||||||
|
var nvram = PlistValue(PlistValue(PlistValue(document.Root!.Element("dict")!, "NVRAM"), "Add"), "7C436110-AB2A-4BBB-A880-FE41995C9F82");
|
||||||
|
if (PlistValue(nvram, "boot-args").Value != "keepsyms=1 debug=0x100 -lilubeta -wegbeta vmhState=enabled") throw new InvalidOperationException("Pinned kernel diagnostic arguments differ.");
|
||||||
|
const string diagnosticBoot = """
|
||||||
|
local diagnostic_boot='-v keepsyms=1 debug=0x108 serial=5 msgbuf=1048576 -lilubeta -wegbeta vmhState=enabled'
|
||||||
|
local boot_args="/plist/dict/key[.='NVRAM']/following-sibling::dict[1]/key[.='Add']/following-sibling::dict[1]/key[.='7C436110-AB2A-4BBB-A880-FE41995C9F82']/following-sibling::dict[1]/key[.='boot-args']/following-sibling::string[1]"
|
||||||
|
xmlstarlet ed -P -L -u "$boot_args" -v "$diagnostic_boot" "$CFG" || exit 12
|
||||||
|
[ "$(xmlstarlet sel -t -v "$boot_args" "$CFG")" = "$diagnostic_boot" ] || { error 'Kernel diagnostic arguments were not installed.'; exit 12; }
|
||||||
|
|
||||||
|
""";
|
||||||
|
boot = ReplaceOnce(boot, " # DEBUG logging goes only to the OpenCore log file on the EFI partition.\n", diagnosticBoot + " # DEBUG logging goes only to the OpenCore log file on the EFI partition.\n");
|
||||||
|
boot = ReplaceOnce(boot, " PLIST=\"/assets/config.plist\"\n", " [ ! -e /custom.plist ] || { error 'Supported profile refuses an unverified custom OpenCore config!'; exit 12; }\n PLIST=\"/assets/config.plist\"\n");
|
||||||
|
boot = ReplaceOnce(boot, " if [ -s \"$target\" ] && [ \"$previous\" = \"$current\" ]; then\n IMG=\"$target\"\n return 0\n fi\n", " # This owned compatibility probe always rebuilds; never trust a cached boot.img.\n");
|
||||||
|
boot = ReplaceOnce(boot, " echo \"VMHIDE=$vmhide\"\n", " echo \"VMHIDE=$vmhide\"\n echo \"PROFILE=tcg-skylake-sonoma\"\n");
|
||||||
|
return boot;
|
||||||
|
}
|
||||||
|
|
||||||
|
static XElement PlistValue(XElement dictionary, string key)
|
||||||
|
{
|
||||||
|
var keys = dictionary.Elements("key").Where(element => element.Value == key).ToArray();
|
||||||
|
if (keys.Length != 1 || keys[0].ElementsAfterSelf().FirstOrDefault() is not { } value) throw new InvalidOperationException("Missing/duplicate plist key: " + key);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly string TcgPreflight = """
|
||||||
|
# Realize this exact TCG model and execute AVX/AVX2 before Apple downloads.
|
||||||
|
disabled "$KVM" && [[ "$ARCH" == amd64 && "$CPU_MODEL" == Skylake-Client-v4 && "$VERSION" == 14 && "$CPU_FLAGS" == '@@CPU_FLAGS@@' ]] || { error 'Supported probe requires the exact TCG/Skylake/macOS 14 profile.'; exit 1; }
|
||||||
|
[[ "$(qemu-system-x86_64 --version | head -n 1)" == 'QEMU emulator version 11.1.1 (Reims 11.1.3)' ]] || { error 'Pinned QEMU runtime version mismatch.'; exit 1; }
|
||||||
|
printf '%s %s\n' c32746122cc68f3ed642aa46c21b677f803c58f0d4ff665841723fcc5625f549 /assets/ci-cpu-preflight.bin | sha256sum -c - || { error 'Compiled AVX/AVX2 preflight ROM hash mismatch.'; exit 1; }
|
||||||
|
probeTcgInstructions() {
|
||||||
|
/usr/bin/timeout --signal=TERM --kill-after=2 10 qemu-system-x86_64 \
|
||||||
|
-machine q35 -accel tcg,thread=multi -cpu "$1" -smp 2 -m 64 -bios /assets/ci-cpu-preflight.bin \
|
||||||
|
-nodefaults -display none -serial none -monitor none -nographic -no-reboot \
|
||||||
|
-device isa-debug-exit,iobase=0xf4,iosize=0x04
|
||||||
|
}
|
||||||
|
if probeTcgInstructions "$CPU_FLAGS" > "$QEMU_DIR/cpu-preflight-positive.log" 2>&1; then positive=0; else positive=$?; fi
|
||||||
|
cat "$QEMU_DIR/cpu-preflight-positive.log"
|
||||||
|
(( positive == 33 )) || { error "Actual TCG AVX/AVX2 instruction preflight failed: exit=$positive"; exit 1; }
|
||||||
|
if probeTcgInstructions "$CPU_FLAGS,-avx2" > "$QEMU_DIR/cpu-preflight-negative.log" 2>&1; then negative=0; else negative=$?; fi
|
||||||
|
cat "$QEMU_DIR/cpu-preflight-negative.log"
|
||||||
|
(( negative != 33 )) || { error 'AVX2-disabled negative control unexpectedly passed.'; exit 1; }
|
||||||
|
info "[cpu-preflight] positive=$positive negative=$negative cpu=$CPU_FLAGS; actual AVX/AVX2 executed before Apple download"
|
||||||
|
printf '[cpu-preflight] positive=%s negative=%s cpu=%s; actual AVX/AVX2 executed before Apple download\n' "$positive" "$negative" "$CPU_FLAGS" > "$QEMU_DIR/native-stage.log"
|
||||||
|
""".Replace("@@CPU_FLAGS@@", CpuFlags, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
static async Task ValidateTcgPreflight(string output, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
var fixture = Path.Combine(output, "validation-cpu-preflight-gate");
|
||||||
|
Directory.CreateDirectory(fixture);
|
||||||
|
var entry = File.ReadAllText(Path.Combine(output, "container-entry.sh"));
|
||||||
|
if (entry.IndexOf(TcgPreflight, StringComparison.Ordinal) >= entry.IndexOf(". download.sh", StringComparison.Ordinal)
|
||||||
|
|| entry.Split(". cpu.sh", StringSplitOptions.None).Length != 2
|
||||||
|
|| entry.Split(". proc.sh", StringSplitOptions.None).Length != 2)
|
||||||
|
throw new InvalidOperationException("Actual composed CPU preflight must execute once before any Apple download.");
|
||||||
|
var cases = new[]
|
||||||
|
{
|
||||||
|
("positive-negative-exit", 33, 0, "14", CpuFlags, true, true),
|
||||||
|
("positive-negative-timeout", 33, 124, "14", CpuFlags, true, true),
|
||||||
|
("positive-normal-exit", 0, 0, "14", CpuFlags, true, false),
|
||||||
|
("positive-timeout", 124, 0, "14", CpuFlags, true, false),
|
||||||
|
("negative-passed", 33, 33, "14", CpuFlags, true, false),
|
||||||
|
("wrong-recovery", 33, 0, "13", CpuFlags, true, false),
|
||||||
|
("wrong-cpu-flags", 33, 0, "14", CpuFlags.Replace("enforce=on", "check"), true, false),
|
||||||
|
("wrong-rom-hash", 33, 0, "14", CpuFlags, false, false)
|
||||||
|
};
|
||||||
|
foreach (var item in cases)
|
||||||
|
{
|
||||||
|
var work = Path.Combine(fixture, item.Item1);
|
||||||
|
Directory.CreateDirectory(work);
|
||||||
|
var script = """
|
||||||
|
set -euo pipefail
|
||||||
|
disabled() { [ "$1" = N ]; }
|
||||||
|
error() { printf '%s\n' "$*" >&2; }
|
||||||
|
info() { printf '%s\n' "$*"; }
|
||||||
|
qemu-system-x86_64() { printf '%s\n' 'QEMU emulator version 11.1.1 (Reims 11.1.3)'; }
|
||||||
|
sha256sum() { cat >/dev/null; return "@@HASH_EXIT@@"; }
|
||||||
|
mock_timeout() {
|
||||||
|
printf '[fixture-command] %s\n' "$*"
|
||||||
|
case "$*" in *,-avx2*) return @@NEGATIVE@@ ;; *) return @@POSITIVE@@ ;; esac
|
||||||
|
}
|
||||||
|
KVM=N; ARCH=amd64; CPU_MODEL=Skylake-Client-v4; VERSION='@@VERSION@@'
|
||||||
|
CPU_FLAGS='@@FLAGS@@'; QEMU_DIR='@@WORK@@'
|
||||||
|
""".Replace("@@HASH_EXIT@@", item.Item6 ? "0" : "1", StringComparison.Ordinal)
|
||||||
|
.Replace("@@NEGATIVE@@", item.Item3.ToString(), StringComparison.Ordinal)
|
||||||
|
.Replace("@@POSITIVE@@", item.Item2.ToString(), StringComparison.Ordinal)
|
||||||
|
.Replace("@@VERSION@@", item.Item4, StringComparison.Ordinal)
|
||||||
|
.Replace("@@FLAGS@@", item.Item5, StringComparison.Ordinal)
|
||||||
|
.Replace("@@WORK@@", work.Replace("'", "'\\''", StringComparison.Ordinal), StringComparison.Ordinal)
|
||||||
|
+ "\n" + TcgPreflight.Replace("/usr/bin/timeout", "mock_timeout", StringComparison.Ordinal)
|
||||||
|
+ "\nprintf '[fixture] Apple download reached after gate\\n'\n";
|
||||||
|
var path = Path.Combine(work, "fixture.sh");
|
||||||
|
File.WriteAllText(path, script, new UTF8Encoding(false));
|
||||||
|
var result = await Command("bash", [path], work, "gate", cancellation, requireSuccess: false);
|
||||||
|
var reached = result.Output.Contains("Apple download reached after gate", StringComparison.Ordinal);
|
||||||
|
Save(Path.Combine(work, "receipt.json"), new { success = (result.ExitCode == 0) == item.Item7 && reached == item.Item7, result.ExitCode, reachedAppleDownloadSeam = reached, qemuExecuted = false });
|
||||||
|
if ((result.ExitCode == 0) != item.Item7 || reached != item.Item7)
|
||||||
|
throw new InvalidOperationException("Actual TCG preflight gate fixture failed: " + item.Item1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
||||||
|
static string ReplaceAllExact(string text, string oldValue, string newValue, int expected)
|
||||||
|
{
|
||||||
|
var count = text.Split(oldValue, StringSplitOptions.None).Length - 1;
|
||||||
|
if (count != expected) throw new InvalidOperationException($"Pinned source contract expected {expected} match(es), found {count}: {oldValue.Split('\n')[0]}");
|
||||||
|
return text.Replace(oldValue, newValue, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ValidateResult(string json, string token)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var result = document.RootElement;
|
||||||
|
if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 14 || result.GetProperty("architecture").GetString() != "x86_64" || result.GetProperty("uid").GetInt32() != 0 || !System.Text.RegularExpressions.Regex.IsMatch(result.GetProperty("disk").GetString() ?? "", "^/dev/disk[0-9]+$") || result.GetProperty("diskBytes").GetInt64() != GuestDiskBytes || result.GetProperty("readOnly").GetBoolean() || new[] { "systemExit", "diskArbitrationExit", "recoveryExit", "diskListExit" }.Any(key => result.GetProperty(key).GetInt32() != 0))
|
||||||
|
throw new InvalidOperationException("The fresh guest receipt did not prove native macOS 14+/x86_64, service readiness and the writable 64-GiB disk.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AssertContainer(string json, string token)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var container = document.RootElement[0];
|
||||||
|
var config = container.GetProperty("HostConfig");
|
||||||
|
var devices = config.GetProperty("Devices");
|
||||||
|
var mounts = container.GetProperty("Mounts");
|
||||||
|
var environment = container.GetProperty("Config").GetProperty("Env").EnumerateArray().Select(value => value.GetString()).ToArray();
|
||||||
|
if (container.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != token
|
||||||
|
|| config.GetProperty("Privileged").GetBoolean()
|
||||||
|
|| config.GetProperty("NetworkMode").GetString() is not ("default" or "bridge")
|
||||||
|
|| config.GetProperty("Memory").GetInt64() != ContainerMemoryBytes
|
||||||
|
|| config.GetProperty("MemorySwap").GetInt64() != ContainerMemoryBytes
|
||||||
|
|| config.GetProperty("NanoCpus").GetInt64() != 2000000000
|
||||||
|
|| config.GetProperty("ShmSize").GetInt64() != 536870912
|
||||||
|
|| new[] { "CapAdd", "DeviceRequests", "Binds", "PortBindings", "DeviceCgroupRules", "Tmpfs" }.Any(key =>
|
||||||
|
config.TryGetProperty(key, out var value) && value.ValueKind != JsonValueKind.Null
|
||||||
|
&& (value.ValueKind == JsonValueKind.Array ? value.GetArrayLength() != 0 : value.EnumerateObject().Any()))
|
||||||
|
|| devices.GetArrayLength() != 0
|
||||||
|
|| mounts.GetArrayLength() != 1
|
||||||
|
|| mounts[0].GetProperty("Type").GetString() != "volume"
|
||||||
|
|| mounts[0].GetProperty("Destination").GetString() != "/storage"
|
||||||
|
|| !mounts[0].GetProperty("RW").GetBoolean()
|
||||||
|
|| new[] { "KVM=N", "CPU_MODEL=" + CpuModel, "VERSION=14" }.Any(expected =>
|
||||||
|
environment.Count(value => value is not null && value.StartsWith(expected.Split('=')[0] + "=", StringComparison.Ordinal)) != 1
|
||||||
|
|| !environment.Contains(expected)))
|
||||||
|
throw new InvalidOperationException("Created container exceeds the owned unprivileged TCG/Skylake/macOS 14 boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool final = false, string? token = null)
|
||||||
|
{
|
||||||
|
if (final && token is not null) await CaptureMonitor(id, output, token, cancellation);
|
||||||
|
var logs = await Command("docker", ["logs", "--tail", final ? "all" : "3000", id], output, final ? "container-final" : "container", cancellation, requireSuccess: false);
|
||||||
|
if (final) await Command("docker", ["exec", id, "head", "-c", "2097152", "/run/shm/first-kernel-context.log"], output, "first-kernel-context", cancellation, requireSuccess: false, retainSuccessful: true);
|
||||||
|
var stage = await Command("docker", ["exec", id, "cat", "/run/shm/native-stage.log"], output, "capture-native-stage", cancellation, requireSuccess: false);
|
||||||
|
ReportCpuPreflight(output, stage.ExitCode == 0 ? stage.Output : logs.Output);
|
||||||
|
await Command("docker", ["exec", id, "head", "-c", "4096", "/run/shm/kernel-handoffs.log"], output, "capture-kernel-handoffs", cancellation, requireSuccess: false, retainSuccessful: true);
|
||||||
|
foreach (var file in new[] { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") })
|
||||||
|
{
|
||||||
|
var result = await Command("docker", ["exec", id, "cat", "/dev/shm/installstate/" + file.Item1], output, "capture-" + file.Item1, cancellation, requireSuccess: false);
|
||||||
|
if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output)) File.WriteAllText(Path.Combine(output, file.Item2), result.Output);
|
||||||
|
}
|
||||||
|
// The immutable Recovery image is complete only after this staging marker.
|
||||||
|
// Hash it once instead of rereading the image on every twenty-second poll.
|
||||||
|
if ((logs.Output + stage.Output).Contains("[supported-profile] accelerator=tcg", StringComparison.Ordinal)
|
||||||
|
&& !File.Exists(Path.Combine(output, "guest-container-resources.last-success.json")))
|
||||||
|
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 int KernelHandoffs(string logs) => logs.Split('\n').Count(line => line.Trim().StartsWith("#[EB|LOG:HANDOFF TO XNU] ", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
static void ValidateBootProgress()
|
||||||
|
{
|
||||||
|
const string handoff = "#[EB|LOG:HANDOFF TO XNU] _\r\n";
|
||||||
|
foreach (var (logs, count) in new[] { ("", 0), ("BdsDxe: starting Boot0002\n", 0), (handoff, 1), (handoff + handoff, 2), ("source says \"" + handoff, 0), ("#[EB|LOG:HANDOFF TO XNU-ish] _\n", 0) })
|
||||||
|
if (KernelHandoffs(logs) != count) throw new InvalidOperationException("Recovery boot-progress parser accepted missing, quoted or malformed markers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task CheckRecoveryBootProgress(string id, string output, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
var retained = Path.Combine(output, "capture-kernel-handoffs.last-success.stdout.log");
|
||||||
|
var count = KernelHandoffs(File.ReadAllText(File.Exists(retained) ? retained : Path.Combine(output, "container.stdout.log")));
|
||||||
|
Save(Path.Combine(output, "recovery-boot-progress.json"), new { kernelHandoffs = count, unexpectedRepeat = count >= 2, capturedUtc = DateTimeOffset.UtcNow });
|
||||||
|
if (count >= 2) throw new InvalidOperationException("Recovery returned to kernel boot before readiness; repeated handoff detected. See serial/exception logs; this does not identify the reset cause.");
|
||||||
|
if (!File.ReadAllText(Path.Combine(output, "capture-native-stage.stdout.log")).Contains("[supported-profile] accelerator=tcg", StringComparison.Ordinal)) return;
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
|
deadline.CancelAfter(TimeSpan.FromSeconds(8));
|
||||||
|
var monitor = await Command("docker", ["exec", id, "sh", "-c", "test -S /run/shm/monitor.sock || exit 1; printf 'info status\\n' | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock"], output, "recovery-vm-status", deadline.Token, requireSuccess: false);
|
||||||
|
if (monitor.ExitCode == 0 && System.Text.RegularExpressions.Regex.IsMatch(monitor.Output, @"(?m)^VM status: (shutdown|paused|internal-error|guest-panicked)(?:\s+\([^\r\n]*\))?\r?$"))
|
||||||
|
throw new InvalidOperationException("Recovery VM halted before readiness. The first reset was retained for exception/monitor evidence.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task CapturePressure(string id, string output, string phase, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
using var snapshotDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
|
snapshotDeadline.CancelAfter(TimeSpan.FromSeconds(20));
|
||||||
|
const string snapshot = """
|
||||||
|
printf '[snapshot UTC]\n'; date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||||
|
for path in /proc/meminfo /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io \
|
||||||
|
/sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.stat /sys/fs/cgroup/cpu.pressure \
|
||||||
|
/sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.peak \
|
||||||
|
/sys/fs/cgroup/memory.events /sys/fs/cgroup/memory.stat /sys/fs/cgroup/memory.pressure \
|
||||||
|
/sys/fs/cgroup/memory.swap.current; do
|
||||||
|
printf '\n[%s]\n' "$path"
|
||||||
|
if [ -r "$path" ]; then cat "$path"; else printf 'unavailable\n'; fi
|
||||||
|
done
|
||||||
|
printf '\n[host paging counters]\n'
|
||||||
|
awk '/^(pgmajfault|pswpin|pswpout) / {print}' /proc/vmstat
|
||||||
|
""";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Command("docker", ["exec", id, "sh", "-c", snapshot], output, "capture-pressure-" + phase, snapshotDeadline.Token, requireSuccess: false, retainSuccessful: true);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Optional evidence must not replace the guest outcome or prevent cleanup.
|
||||||
|
try { Save(Path.Combine(output, "capture-pressure-" + phase + ".unavailable.json"), new { phase, error = exception.Message, capturedUtc = DateTimeOffset.UtcNow }); }
|
||||||
|
catch (Exception evidenceError) { Console.Error.WriteLine("Optional pressure evidence: " + evidenceError.Message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task CaptureMonitor(string id, string output, string token, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
|
deadline.CancelAfter(TimeSpan.FromSeconds(10));
|
||||||
|
int? monitorExit = null, copyExit = null;
|
||||||
|
string? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$") || !System.Text.RegularExpressions.Regex.IsMatch(token, "^[0-9a-f]{32}$")) throw new InvalidOperationException("No saved owned container identity for the optional monitor capture.");
|
||||||
|
var inspection = await Command("docker", ["inspect", id], output, "capture-monitor-container", deadline.Token);
|
||||||
|
AssertContainer(inspection.Output, token);
|
||||||
|
using (var document = JsonDocument.Parse(inspection.Output))
|
||||||
|
if (document.RootElement[0].GetProperty("Id").GetString() != id || !document.RootElement[0].GetProperty("State").GetProperty("Running").GetBoolean()) throw new InvalidOperationException("Owned guest container is no longer running for the optional monitor capture.");
|
||||||
|
var screen = "/tmp/native-diagnostic-screen-" + token + ".ppm";
|
||||||
|
var monitor = await Command("docker", ["exec", id, "sh", "-c", """
|
||||||
|
test -S /run/shm/monitor.sock || exit 1
|
||||||
|
rm -f -- "$1" || exit 1
|
||||||
|
printf 'info kvm\ninfo status\ninfo registers -a\nx/16gx $rsp\nscreendump %s\n' "$1" | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock
|
||||||
|
monitor_exit=$?
|
||||||
|
printf '\n[monitor-exit] %s\n' "$monitor_exit"
|
||||||
|
[ "$monitor_exit" -eq 0 ] || exit "$monitor_exit"
|
||||||
|
bytes=$(stat -c%s "$1") || exit 1
|
||||||
|
[ "$bytes" -gt 0 ] && [ "$bytes" -le 8388608 ] || exit 1
|
||||||
|
printf '[screen-bytes] %s\n' "$bytes"
|
||||||
|
""", "native-monitor", screen], output, "capture-monitor", deadline.Token, requireSuccess: false);
|
||||||
|
monitorExit = monitor.ExitCode;
|
||||||
|
if (monitorExit != 0) throw new InvalidOperationException("Optional monitor status/screenshot command exited " + monitorExit + ".");
|
||||||
|
var copy = await Command("docker", ["cp", id + ":" + screen, Path.Combine(output, "guest-screen-" + token + ".ppm")], output, "capture-monitor-screen", deadline.Token, requireSuccess: false);
|
||||||
|
copyExit = copy.ExitCode;
|
||||||
|
if (copyExit != 0) throw new InvalidOperationException("Optional monitor screenshot copy exited " + copyExit + ".");
|
||||||
|
}
|
||||||
|
catch (Exception exception) { error = exception.Message; Console.Error.WriteLine("Optional final monitor capture: " + error); }
|
||||||
|
finally { Save(Path.Combine(output, "monitor-capture.json"), new { token, monitorExit, copyExit, success = error is null, error, capturedUtc = DateTimeOffset.UtcNow }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<bool> Cleanup(string output)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(output, "owned-resources.json");
|
||||||
|
if (!File.Exists(path)) return true;
|
||||||
|
var state = JsonSerializer.Deserialize<OwnedResources>(File.ReadAllText(path), JsonOptions) ?? throw new InvalidOperationException("Invalid owned-resource receipt.");
|
||||||
|
if (!System.Text.RegularExpressions.Regex.IsMatch(state.Token, "^[0-9a-f]{32}$") || state.ContainerName != "meeting-assistant-native-" + state.Token || state.ImageTag != "meeting-assistant-native-diagnostic:" + state.Token) throw new InvalidOperationException("Invalid cleanup ownership identity.");
|
||||||
|
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var kind in new[] { "container", "image" })
|
||||||
|
{
|
||||||
|
var name = kind == "container" ? state.ContainerName : state.ImageTag;
|
||||||
|
var inspect = await Command("docker", [kind, "inspect", name], output, "cleanup-" + kind + "-inspect", deadline.Token, requireSuccess: false);
|
||||||
|
if (inspect.ExitCode != 0)
|
||||||
|
{
|
||||||
|
if (inspect.Error.Contains("No such object", StringComparison.Ordinal) || inspect.Error.Contains("No such container", StringComparison.Ordinal) || inspect.Error.Contains("No such image", StringComparison.Ordinal)) continue;
|
||||||
|
throw new InvalidOperationException("Cannot establish owned " + kind + " absence: " + inspect.Error);
|
||||||
|
}
|
||||||
|
using var document = JsonDocument.Parse(inspect.Output);
|
||||||
|
var resource = document.RootElement[0];
|
||||||
|
if (resource.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != state.Token) throw new InvalidOperationException("Cleanup refuses a resource without this run's exact ownership label.");
|
||||||
|
var id = resource.GetProperty("Id").GetString()!;
|
||||||
|
var expectedId = kind == "container" ? state.ContainerId : state.ImageId;
|
||||||
|
if (expectedId is not null && expectedId != id) throw new InvalidOperationException("Cleanup refuses a resource whose ID changed after creation.");
|
||||||
|
await Command("docker", kind == "container" ? ["rm", "--force", "--volumes", id] : ["image", "rm", id], output, "cleanup-" + kind + "-remove", deadline.Token);
|
||||||
|
}
|
||||||
|
if (Path.GetFileName(state.WorkDirectory) == "meeting-assistant-native-" + state.Token && File.Exists(Path.Combine(state.WorkDirectory, "run.owner")) && File.ReadAllText(Path.Combine(state.WorkDirectory, "run.owner")) == state.Token) Directory.Delete(state.WorkDirectory, true);
|
||||||
|
Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = true, completedUtc = DateTimeOffset.UtcNow });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = false, error = exception.Message, completedUtc = DateTimeOffset.UtcNow });
|
||||||
|
Console.Error.WriteLine("Owned diagnostic cleanup failed: " + exception.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ValidateRecoveryPatch(string output)
|
||||||
|
{
|
||||||
|
if (Crc32(Encoding.ASCII.GetBytes("123456789")) != 0xcbf43926) throw new InvalidOperationException("Independent C# CRC32 known vector failed.");
|
||||||
|
var fixture = Path.Combine(output, "validation-recovery-patch");
|
||||||
|
Directory.CreateDirectory(fixture);
|
||||||
|
var patch = File.ReadAllText(Path.Combine(output, "recovery-patch.py"));
|
||||||
|
const string marker = "SCRIPT_ORIGINAL = b'''";
|
||||||
|
var start = patch.IndexOf(marker, StringComparison.Ordinal) + marker.Length;
|
||||||
|
var end = patch.IndexOf("'''", start, StringComparison.Ordinal);
|
||||||
|
var bootstrap = patch[start..end];
|
||||||
|
var cases = new[] {
|
||||||
|
("13-zlib", OriginalDaemon13, true, true), ("14-zlib", OriginalDaemon + "\n", true, true),
|
||||||
|
("13-raw", OriginalDaemon13, false, true), ("14-raw", OriginalDaemon + "\n", false, true),
|
||||||
|
("unknown", OriginalDaemon13.Replace("Interactive", "Unknown"), true, false),
|
||||||
|
("duplicate", OriginalDaemon13 + OriginalDaemon + "\n", true, false),
|
||||||
|
("duplicate-unknown", OriginalDaemon13 + OriginalDaemon13.Replace("Interactive", "Unknown"), true, false),
|
||||||
|
("malformed", OriginalDaemon13.Replace("</array>", "</broken>"), true, false),
|
||||||
|
("wrong-arguments", OriginalDaemon13.Replace("/usr/libexec/recoveryosd", "/usr/libexec/wrongdaemon"), true, false),
|
||||||
|
("duplicate-arguments", OriginalDaemon13.Replace("</array>", "<string>/usr/libexec/recoveryosd</string></array>"), true, false),
|
||||||
|
("corrupt-data-crc", OriginalDaemon13, true, false), ("unsupported-crc", OriginalDaemon13, true, false),
|
||||||
|
("xml-boundary", OriginalDaemon13, true, false), ("physical-boundary", OriginalDaemon13, true, false),
|
||||||
|
("unknown-zero-run", OriginalDaemon13, true, false), ("logical-boundary", OriginalDaemon13, false, false)
|
||||||
|
};
|
||||||
|
foreach (var item in cases)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(fixture, item.Item1 + ".dmg");
|
||||||
|
CreateRecoveryFixture(path, bootstrap, item.Item2, item.Item3);
|
||||||
|
if (item.Item1 is "corrupt-data-crc" or "unsupported-crc" or "xml-boundary" or "physical-boundary" or "unknown-zero-run" or "logical-boundary")
|
||||||
|
{
|
||||||
|
var corrupt = File.ReadAllBytes(path);
|
||||||
|
var trailer = corrupt.Length - 512;
|
||||||
|
if (item.Item1 == "corrupt-data-crc") corrupt[trailer + 88] ^= 1;
|
||||||
|
else if (item.Item1 == "unsupported-crc") BinaryPrimitives.WriteUInt32BigEndian(corrupt.AsSpan(trailer + 80), 3);
|
||||||
|
else if (item.Item1 == "xml-boundary") BinaryPrimitives.WriteUInt64BigEndian(corrupt.AsSpan(trailer + 224), (ulong)corrupt.Length);
|
||||||
|
else if (item.Item1 == "logical-boundary") BinaryPrimitives.WriteUInt64BigEndian(corrupt.AsSpan(trailer + 492), 1);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var xmlOffset = checked((int)BinaryPrimitives.ReadUInt64BigEndian(corrupt.AsSpan(trailer + 216)));
|
||||||
|
var xmlLength = checked((int)BinaryPrimitives.ReadUInt64BigEndian(corrupt.AsSpan(trailer + 224)));
|
||||||
|
var xmlText = Encoding.UTF8.GetString(corrupt, xmlOffset, xmlLength);
|
||||||
|
var data = XDocument.Parse(xmlText).Descendants("data").Single().Value;
|
||||||
|
var mish = Convert.FromBase64String(data);
|
||||||
|
if (item.Item1 == "physical-boundary") BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(236), (ulong)corrupt.Length);
|
||||||
|
else BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(204), 0);
|
||||||
|
var replacement = Encoding.UTF8.GetBytes(ReplaceOnce(xmlText, data, Convert.ToBase64String(mish)));
|
||||||
|
replacement.CopyTo(corrupt, xmlOffset);
|
||||||
|
}
|
||||||
|
File.WriteAllBytes(path, corrupt);
|
||||||
|
}
|
||||||
|
var before = File.ReadAllBytes(path);
|
||||||
|
var result = await Command("python3", ["-B", Path.Combine(output, "recovery-patch.py"), path], fixture, item.Item1, CancellationToken.None, requireSuccess: false);
|
||||||
|
var after = File.ReadAllBytes(path);
|
||||||
|
if ((result.ExitCode == 0) != item.Item4) throw new InvalidOperationException("Recovery fixture result mismatch: " + item.Item1 + ": " + result.Error);
|
||||||
|
if (!item.Item4 && !before.SequenceEqual(after)) throw new InvalidOperationException("Rejected Recovery fixture was modified: " + item.Item1);
|
||||||
|
if (item.Item4)
|
||||||
|
{
|
||||||
|
var decoded = DecodeRecoveryFixture(after, item.Item3);
|
||||||
|
var original = Encoding.UTF8.GetBytes(item.Item2);
|
||||||
|
var expectedText = item.Item1.StartsWith("13", StringComparison.Ordinal) ? DiagnosticDaemon13 : DiagnosticDaemon;
|
||||||
|
var expected = Encoding.UTF8.GetBytes(expectedText.PadRight(item.Item2.Length, ' '));
|
||||||
|
if (before.Length != after.Length || !decoded.AsSpan(4096, original.Length).SequenceEqual(expected)) throw new InvalidOperationException("Recovery fixture changed byte extent or daemon fields: " + item.Item1);
|
||||||
|
ValidateDaemon(expectedText, item.Item1.StartsWith("13", StringComparison.Ordinal) ? "Interactive" : "App", true);
|
||||||
|
VerifyRecoveryFixtureChecksums(after, decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Save(Path.Combine(fixture, "receipt.json"), new { success = true, positiveCases = 4, negativeCases = 12, rejectedImagesUnmodified = true, knownDaemonFieldsPreserved = true, readBackCrc32IndependentlyVerified = true, syntheticUdifFixtures = true, guestExecuted = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint Crc32(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
var crc = uint.MaxValue;
|
||||||
|
foreach (var value in bytes)
|
||||||
|
{
|
||||||
|
crc ^= value;
|
||||||
|
for (var bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ ((crc & 1) != 0 ? 0xedb88320u : 0);
|
||||||
|
}
|
||||||
|
return ~crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void CreateRecoveryFixture(string path, string bootstrap, string daemon, bool compressed)
|
||||||
|
{
|
||||||
|
var decoded = new byte[16384];
|
||||||
|
Encoding.UTF8.GetBytes(bootstrap).CopyTo(decoded, 64);
|
||||||
|
Encoding.UTF8.GetBytes(daemon).CopyTo(decoded, 4096);
|
||||||
|
byte[] stored;
|
||||||
|
if (compressed)
|
||||||
|
{
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
using (var zipper = new ZLibStream(memory, CompressionLevel.Fastest, true)) zipper.Write(decoded);
|
||||||
|
stored = memory.ToArray();
|
||||||
|
}
|
||||||
|
else stored = decoded;
|
||||||
|
var mish = new byte[284];
|
||||||
|
Encoding.ASCII.GetBytes("mish").CopyTo(mish, 0);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(4), 1);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(16), 32);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(64), 2);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(68), 32);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(72), Crc32(decoded));
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(200), 2);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(204), compressed ? 0x80000005u : 1u);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(220), 32);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(236), (ulong)stored.Length);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(244), 0xffffffff);
|
||||||
|
var xml = Encoding.UTF8.GetBytes("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>resource-fork</key><dict><key>blkx</key><array><dict><key>Data</key><data>" + Convert.ToBase64String(mish) + "</data></dict></array></dict></dict></plist>\n");
|
||||||
|
var koly = new byte[512];
|
||||||
|
Encoding.ASCII.GetBytes("koly").CopyTo(koly, 0);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(4), 4);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(8), 512);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(12), 1);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(32), (ulong)stored.Length);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(80), 2);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(84), 32);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(88), Crc32(stored));
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(216), (ulong)stored.Length);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(224), (ulong)xml.Length);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(352), 2);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(356), 32);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(360), Crc32(mish.AsSpan(72, 4)));
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(492), 32);
|
||||||
|
File.WriteAllBytes(path, stored.Concat(xml).Concat(koly).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] DecodeRecoveryFixture(byte[] image, bool compressed)
|
||||||
|
{
|
||||||
|
var length = checked((int)BinaryPrimitives.ReadUInt64BigEndian(image.AsSpan(image.Length - 512 + 32)));
|
||||||
|
if (!compressed) return image[..length];
|
||||||
|
using var input = new MemoryStream(image, 0, length);
|
||||||
|
using var decoder = new ZLibStream(input, CompressionMode.Decompress);
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
decoder.CopyTo(output);
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void VerifyRecoveryFixtureChecksums(byte[] image, byte[] decoded)
|
||||||
|
{
|
||||||
|
var trailer = image.Length - 512;
|
||||||
|
var length = checked((int)BinaryPrimitives.ReadUInt64BigEndian(image.AsSpan(trailer + 32)));
|
||||||
|
var xmlLength = checked((int)BinaryPrimitives.ReadUInt64BigEndian(image.AsSpan(trailer + 224)));
|
||||||
|
var xml = XDocument.Parse(Encoding.UTF8.GetString(image, length, xmlLength));
|
||||||
|
var mish = Convert.FromBase64String(xml.Descendants("data").Single().Value);
|
||||||
|
if (Crc32(image.AsSpan(0, length)) != BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(trailer + 88)) || Crc32(decoded) != BinaryPrimitives.ReadUInt32BigEndian(mish.AsSpan(72)) || Crc32(mish.AsSpan(72, 4)) != BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(trailer + 360))) throw new InvalidOperationException("Independent C# fixture CRC32 readback failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ValidateResourceRetention(string output)
|
||||||
|
{
|
||||||
|
var fixture = Path.Combine(output, "validation-resource-retention");
|
||||||
|
Directory.CreateDirectory(fixture);
|
||||||
|
const string label = "capture-resource-fixture";
|
||||||
|
const string successful = "Successful snapshot before stopped-container capture.\n";
|
||||||
|
await Command("bash", ["-c", "printf '%s\\n' 'Successful snapshot before stopped-container capture.'"], fixture, label, CancellationToken.None, retainSuccessful: true);
|
||||||
|
await Command("bash", ["-c", "printf '%s\\n' 'Container is not running.' >&2; exit 1"], fixture, label, CancellationToken.None, requireSuccess: false, retainSuccessful: true);
|
||||||
|
var retained = Path.Combine(fixture, label + ".last-success.stdout.log");
|
||||||
|
if (!File.Exists(retained) || File.ReadAllText(retained) != successful || File.ReadAllText(Path.Combine(fixture, label + ".stdout.log")) != "" || !File.ReadAllText(Path.Combine(fixture, label + ".stderr.log")).Contains("Container is not running."))
|
||||||
|
throw new InvalidOperationException("A failed final capture lost the last successful resource snapshot.");
|
||||||
|
using var receipt = JsonDocument.Parse(File.ReadAllText(Path.Combine(fixture, label + ".last-success.json")));
|
||||||
|
if (receipt.RootElement.GetProperty("stdoutSha256").GetString() != Hash(Encoding.UTF8.GetBytes(successful)) || receipt.RootElement.GetProperty("exitCode").GetInt32() != 0) throw new InvalidOperationException("Last successful snapshot receipt does not identify the retained bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<CommandResult> Command(string executable, string[] arguments, string output, string label, CancellationToken cancellation, bool requireSuccess = true, bool echo = false, bool retainSuccessful = false)
|
||||||
|
{
|
||||||
|
if (!label.StartsWith("capture-", StringComparison.Ordinal) && label is not "container" and not "container-running" and not "guest-container-resources")
|
||||||
|
Console.WriteLine("[native-diagnostic] " + label);
|
||||||
|
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
|
var commandToken = commandCancellation.Token;
|
||||||
|
var start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||||
|
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||||
|
start.Environment["GIT_TERMINAL_PROMPT"] = "0";
|
||||||
|
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start " + executable);
|
||||||
|
async Task<string> Read(StreamReader reader, string stream)
|
||||||
|
{
|
||||||
|
var captured = new StringBuilder();
|
||||||
|
var buffer = new char[8192];
|
||||||
|
using var log = new StreamWriter(Path.Combine(output, label + "." + stream + ".log"), false, new UTF8Encoding(false));
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var count = await reader.ReadAsync(buffer.AsMemory(), commandToken);
|
||||||
|
if (count == 0) break;
|
||||||
|
if (captured.Length + count > MaximumCapturedCharacters)
|
||||||
|
{
|
||||||
|
commandCancellation.Cancel();
|
||||||
|
throw new InvalidOperationException(label + " exceeded its bounded diagnostic log size.");
|
||||||
|
}
|
||||||
|
captured.Append(buffer, 0, count);
|
||||||
|
await log.WriteAsync(buffer.AsMemory(0, count), commandToken);
|
||||||
|
await log.FlushAsync(commandToken);
|
||||||
|
if (echo) Console.Write(new string(buffer, 0, count));
|
||||||
|
}
|
||||||
|
return captured.ToString();
|
||||||
|
}
|
||||||
|
var stdout = Read(process.StandardOutput, "stdout");
|
||||||
|
var stderr = Read(process.StandardError, "stderr");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(commandToken));
|
||||||
|
var result = new CommandResult(process.ExitCode, await stdout, await stderr);
|
||||||
|
if (retainSuccessful && result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output))
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(output, label + ".last-success.stdout.log"), result.Output, new UTF8Encoding(false));
|
||||||
|
Save(Path.Combine(output, label + ".last-success.json"), new { exitCode = result.ExitCode, stdoutSha256 = Hash(Encoding.UTF8.GetBytes(result.Output)), capturedUtc = DateTimeOffset.UtcNow });
|
||||||
|
}
|
||||||
|
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) { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
|
||||||
|
static void PrintGuestProof(string output, string token)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(output, "guest-proof.log");
|
||||||
|
if (!File.Exists(path)) { Console.WriteLine("[native-diagnostic] No native guest proof was captured."); return; }
|
||||||
|
var proof = File.ReadAllText(path).Replace(token, "<run-id>", StringComparison.Ordinal);
|
||||||
|
const int budget = 512 * 1024;
|
||||||
|
if (proof.Length > budget)
|
||||||
|
proof = proof[..(64 * 1024)] + "\n[native-diagnostic] Middle of proof omitted from CI stdout; complete bounded proof is retained in the artifact.\n" + proof[^((budget - 64 * 1024))..];
|
||||||
|
Console.WriteLine("[native-diagnostic] Final native guest proof:");
|
||||||
|
Console.Write(proof);
|
||||||
|
}
|
||||||
|
static void Save(string path, object value)
|
||||||
|
{
|
||||||
|
var temporary = path + ".tmp";
|
||||||
|
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
|
||||||
|
File.Move(temporary, path, overwrite: true);
|
||||||
|
}
|
||||||
|
sealed record OwnedResources(string Token, string ContainerName, string ImageTag, string WorkDirectory, string? ContainerId = null, string? ImageId = null);
|
||||||
|
sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Apple Recovery has Bash before any SDK is installed. Preserve the original
|
||||||
|
# daemon under its launchd label/PID while the unchanged read-only probe runs.
|
||||||
|
/bin/bash /Volumes/installstate/readiness.sh &
|
||||||
|
exec /usr/libexec/recoveryosd
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Existing macOS Recovery/launchd runtime hook; never installs or erases anything.
|
||||||
|
set -u
|
||||||
|
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
export PATH
|
||||||
|
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||||
|
STATE_DIR="/Volumes/installstate"
|
||||||
|
PROOF_LOG="$STATE_DIR/proof.log"
|
||||||
|
RESULT="$STATE_DIR/result.json"
|
||||||
|
EXPECTED_BYTES=68719476736
|
||||||
|
MAX_LOG_BYTES=4194304
|
||||||
|
MAX_OUTPUT_BYTES=524288
|
||||||
|
TIMER_FIFO="/tmp/native-diagnostic-$PROOF_TOKEN-$$.fifo"
|
||||||
|
PENDING_OUTPUTS=()
|
||||||
|
ACTIVE_COMMAND=""
|
||||||
|
ACTIVE_TIMER=""
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
ACTIVE_OBSERVER=""
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
os_version=""
|
||||||
|
architecture=""
|
||||||
|
uid=-1
|
||||||
|
system_exit=-1
|
||||||
|
arbitration_exit=-1
|
||||||
|
recovery_exit=-1
|
||||||
|
disk_list_exit=-1
|
||||||
|
selected_disk=""
|
||||||
|
disk_bytes=0
|
||||||
|
|
||||||
|
count=0
|
||||||
|
while [ ! -d "$STATE_DIR" ] && (( count < 120 )); do
|
||||||
|
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||||
|
count=$((count + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -d "$STATE_DIR" ] || exit 1
|
||||||
|
: > "$PROOF_LOG" || exit 1
|
||||||
|
exec 3>> "$PROOF_LOG" || exit 1
|
||||||
|
rm -f "$RESULT" "$RESULT.tmp"
|
||||||
|
printf '[proof-token] %s\n' "$PROOF_TOKEN" >&3
|
||||||
|
|
||||||
|
finish() {
|
||||||
|
local success="$1" reason="$2"
|
||||||
|
flush_outputs || { success=false; reason=diagnostic_log_budget_exceeded; }
|
||||||
|
printf '[proof-result] %s: %s\n' "$success" "$reason" >&3
|
||||||
|
printf '{"token":"%s","success":%s,"reason":"%s","osVersion":"%s","architecture":"%s","uid":%s,"systemExit":%s,"diskArbitrationExit":%s,"recoveryExit":%s,"diskListExit":%s,"disk":"%s","diskBytes":%s,"readOnly":false}\n' \
|
||||||
|
"$PROOF_TOKEN" "$success" "$reason" "$os_version" "$architecture" "$uid" \
|
||||||
|
"$system_exit" "$arbitration_exit" "$recovery_exit" "$disk_list_exit" \
|
||||||
|
"$selected_disk" "$disk_bytes" > "$RESULT.tmp"
|
||||||
|
/bin/mv -f "$RESULT.tmp" "$RESULT" || exit 1
|
||||||
|
exec 9>&-
|
||||||
|
[ ! -p "$TIMER_FIFO" ] || /bin/rm -f "$TIMER_FIFO"
|
||||||
|
# Keep the service alive for the bounded host diagnostic to capture evidence.
|
||||||
|
while :; do sleep 60; done
|
||||||
|
}
|
||||||
|
|
||||||
|
init_timer_fifo() {
|
||||||
|
# Recovery has Bash 3.2 before any SDK is installed. Its read timeout uses
|
||||||
|
# alarm(), avoiding a separate sleep process for every command and grace period.
|
||||||
|
[ ! -e "$TIMER_FIFO" ] || exit 1
|
||||||
|
/usr/bin/mkfifo -m 600 "$TIMER_FIFO" || exit 1
|
||||||
|
exec 9<> "$TIMER_FIFO" || exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
flush_outputs() {
|
||||||
|
(( ${#PENDING_OUTPUTS[@]} > 0 )) || return 0
|
||||||
|
local started=$SECONDS sizes="/tmp/native-diagnostic-$$.sizes" proof_size output_size raw_size
|
||||||
|
local raw_count=0 raw_valid=1 pending_count=${#PENDING_OUTPUTS[@]}
|
||||||
|
local bounded="/tmp/native-diagnostic-$$.flush"
|
||||||
|
# One bounded native copy per group, rather than tail/stat startup per command.
|
||||||
|
# Keep native byte-oriented copying: Bash 3.2 read -n would read large outputs
|
||||||
|
# one byte per system call. Small scalar reads below have a separate tight bound.
|
||||||
|
/usr/bin/tail -c "$MAX_OUTPUT_BYTES" "${PENDING_OUTPUTS[@]}" > "$bounded" || return 1
|
||||||
|
/usr/bin/stat -f '%z' "$PROOF_LOG" "$bounded" "${PENDING_OUTPUTS[@]}" > "$sizes" || return 1
|
||||||
|
{
|
||||||
|
IFS= read -r proof_size; IFS= read -r output_size
|
||||||
|
while IFS= read -r raw_size; do
|
||||||
|
raw_count=$((raw_count + 1))
|
||||||
|
[[ "$raw_size" =~ ^[0-9]+$ ]] && (( raw_size <= MAX_OUTPUT_BYTES )) || raw_valid=0
|
||||||
|
done
|
||||||
|
} < "$sizes"
|
||||||
|
PENDING_OUTPUTS=()
|
||||||
|
[[ "$proof_size" =~ ^[0-9]+$ && "$output_size" =~ ^[0-9]+$ ]] || return 1
|
||||||
|
(( raw_valid == 1 && raw_count == pending_count )) || return 1
|
||||||
|
(( proof_size + output_size + 1024 <= MAX_LOG_BYTES )) || return 1
|
||||||
|
/bin/cat "$bounded" >&3 || return 1
|
||||||
|
printf '\n[proof-flush] outputs-bytes=%s elapsed=%ss\n' "$output_size" "$((SECONDS - started))" >&3
|
||||||
|
}
|
||||||
|
|
||||||
|
read_scalar() {
|
||||||
|
local value status
|
||||||
|
# All three values are short native machine/uid/version scalars. Reject excess
|
||||||
|
# content instead of accepting a truncated first line as a successful gate.
|
||||||
|
IFS= read -r -n 65 -d '' value < "$LAST_OUTPUT"; status=$?
|
||||||
|
# EOF is mandatory: the byte bound or a NUL delimiter must never hide a suffix.
|
||||||
|
(( status == 1 && ${#value} < 65 )) || return 1
|
||||||
|
value=${value%$'\n'}
|
||||||
|
[[ "$value" != *$'\n'* ]] || return 1
|
||||||
|
SCALAR="$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# BEGIN successful sw_vers version parser
|
||||||
|
read_product_version() {
|
||||||
|
local value status line version="" fields=0
|
||||||
|
# Read the entire successful native output. EOF is mandatory; a NUL delimiter
|
||||||
|
# or reaching the 1025-byte sentinel must never hide a suffix.
|
||||||
|
LC_ALL=C IFS= read -r -n 1025 -d '' value < "$LAST_OUTPUT"; status=$?
|
||||||
|
(( status == 1 && ${#value} <= 1024 )) || return 1
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
if [[ "$line" =~ ^[[:blank:]]*ProductVersion: ]]; then
|
||||||
|
fields=$((fields + 1))
|
||||||
|
(( fields == 1 )) || return 1
|
||||||
|
[[ "$line" =~ ^[[:blank:]]*ProductVersion:[[:blank:]]*([0-9]+\.[0-9]+(\.[0-9]+)?)[[:blank:]]*$ ]] || return 1
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
done <<< "$value"
|
||||||
|
(( fields == 1 )) || return 1
|
||||||
|
SCALAR="$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# END successful sw_vers version parser
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
# Optional observations have their own child/timer ownership. Thread state/time
|
||||||
|
# targets only this probe's diskutil and does not request stack symbolication.
|
||||||
|
observe_disk_query() {
|
||||||
|
local disk_process="$1" output="$2" observation_child="" observation_timer=""
|
||||||
|
cancel_observation() {
|
||||||
|
trap '' TERM INT
|
||||||
|
if [ -n "$observation_child" ]; then
|
||||||
|
kill -TERM "$observation_child" 2>/dev/null || :
|
||||||
|
IFS= read -r -t 2 -u 9 unused || :
|
||||||
|
kill -KILL "$observation_child" 2>/dev/null || :
|
||||||
|
wait "$observation_child" 2>/dev/null || :
|
||||||
|
fi
|
||||||
|
[ -z "$observation_timer" ] || { kill -TERM "$observation_timer" 2>/dev/null || :; wait "$observation_timer" 2>/dev/null || :; }
|
||||||
|
printf '[disk-observation] stopped after the owned disk query\n' >> "$output"
|
||||||
|
exit 143
|
||||||
|
}
|
||||||
|
observe_command() {
|
||||||
|
local name="$1" status started=$SECONDS
|
||||||
|
shift
|
||||||
|
printf '\n[disk-observation-command] %s:' "$name" >> "$output"
|
||||||
|
printf ' %s' "$@" >> "$output"
|
||||||
|
printf '\n' >> "$output"
|
||||||
|
"$@" >> "$output" 2>&1 &
|
||||||
|
observation_child=$!
|
||||||
|
(
|
||||||
|
trap 'exit 0' TERM INT
|
||||||
|
IFS= read -r -t 60 -u 9 unused || :
|
||||||
|
printf '[disk-observation-timeout] %s child=%s limit=60s\n' "$name" "$observation_child" >> "$output"
|
||||||
|
kill -TERM "$observation_child" 2>/dev/null || :
|
||||||
|
IFS= read -r -t 2 -u 9 unused || :
|
||||||
|
kill -KILL "$observation_child" 2>/dev/null || :
|
||||||
|
) &
|
||||||
|
observation_timer=$!
|
||||||
|
wait "$observation_child"; status=$?
|
||||||
|
kill -TERM "$observation_timer" 2>/dev/null || :
|
||||||
|
wait "$observation_timer" 2>/dev/null || :
|
||||||
|
printf '[disk-observation-exit] %s status=%s elapsed=%ss\n' "$name" "$status" "$((SECONDS - started))" >> "$output"
|
||||||
|
observation_child=""; observation_timer=""
|
||||||
|
}
|
||||||
|
trap cancel_observation TERM INT
|
||||||
|
printf '[disk-observation] owned-diskutil-child=%s parent-shell=%s\n' "$disk_process" "$$" >> "$output"
|
||||||
|
if [ -x /bin/ps ]; then
|
||||||
|
if kill -0 "$disk_process" 2>/dev/null; then
|
||||||
|
observe_command diskutil-threads /bin/ps -M -p "$disk_process"
|
||||||
|
else
|
||||||
|
printf '[disk-observation-unavailable] diskutil already exited before thread observation\n' >> "$output"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf '[disk-observation-unavailable] /bin/ps is unavailable\n' >> "$output"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_disk_observation() {
|
||||||
|
[ -n "$ACTIVE_OBSERVER" ] || return 0
|
||||||
|
kill -TERM "$ACTIVE_OBSERVER" 2>/dev/null || :
|
||||||
|
wait "$ACTIVE_OBSERVER" 2>/dev/null || :
|
||||||
|
ACTIVE_OBSERVER=""
|
||||||
|
}
|
||||||
|
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
cancel_probe() {
|
||||||
|
trap '' TERM INT
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
stop_disk_observation
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
if [ -n "$ACTIVE_COMMAND" ]; then
|
||||||
|
kill -TERM "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||||
|
IFS= read -r -t 2 -u 9 unused || :
|
||||||
|
kill -KILL "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||||
|
wait "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||||
|
fi
|
||||||
|
[ -z "$ACTIVE_TIMER" ] || { kill -TERM "$ACTIVE_TIMER" 2>/dev/null || :; wait "$ACTIVE_TIMER" 2>/dev/null || :; }
|
||||||
|
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||||
|
finish false probe_cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
run_command() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local process timer exit_code started waited command_limit=45
|
||||||
|
# Run 4161: even native uname/ps startup took 34-42s under TCG.
|
||||||
|
# Isolate only the failed UID gate; every other watchdog remains unchanged.
|
||||||
|
[[ "$name" != uid ]] || command_limit=180
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
if [[ "$name" == disks && "${attempt:-0}" == 1 ]]; then command_limit=120; fi
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
LAST_OUTPUT="/tmp/native-diagnostic-$name.out"
|
||||||
|
printf '\n[proof-command] %s:' "$name" >&3
|
||||||
|
printf ' %s' "$@" >&3
|
||||||
|
printf '\n' >&3
|
||||||
|
started=$SECONDS
|
||||||
|
"$@" > "$LAST_OUTPUT" 2>&1 &
|
||||||
|
process=$!
|
||||||
|
ACTIVE_COMMAND="$process"
|
||||||
|
printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >&3
|
||||||
|
printf '[proof-limit] %s %ss\n' "$name" "$command_limit" >&3
|
||||||
|
(
|
||||||
|
trap 'exit 0' TERM INT
|
||||||
|
IFS= read -r -t "$command_limit" -u 9 unused || :
|
||||||
|
printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >&3
|
||||||
|
kill -TERM "$process" 2>/dev/null || :
|
||||||
|
IFS= read -r -t 2 -u 9 unused || :
|
||||||
|
kill -KILL "$process" 2>/dev/null || :
|
||||||
|
) &
|
||||||
|
timer=$!
|
||||||
|
ACTIVE_TIMER="$timer"
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
if [[ "$name" == disks && "$attempt" == 1 ]]; then
|
||||||
|
local observation_output="/tmp/native-diagnostic-disk-observation.out"
|
||||||
|
: > "$observation_output"
|
||||||
|
observe_disk_query "$process" "$observation_output" &
|
||||||
|
ACTIVE_OBSERVER=$!
|
||||||
|
printf '[disk-observation-start] observer=%s owned-diskutil-child=%s\n' "$ACTIVE_OBSERVER" "$process" >&3
|
||||||
|
PENDING_OUTPUTS+=("$observation_output")
|
||||||
|
fi
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
wait "$process"
|
||||||
|
exit_code=$?
|
||||||
|
waited=$SECONDS
|
||||||
|
# Includes fork/exec/wait, but excludes timer cleanup and evidence copying.
|
||||||
|
printf '[proof-native-wait] %s child=%s elapsed=%ss exit=%s\n' "$name" "$process" "$((waited - started))" "$exit_code" >&3
|
||||||
|
kill -TERM "$timer" 2>/dev/null || :
|
||||||
|
wait "$timer" 2>/dev/null || :
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
stop_disk_observation
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||||
|
printf '[proof-cleanup] %s child=%s elapsed=%ss total=%ss\n' "$name" "$process" "$((SECONDS - waited))" "$((SECONDS - started))" >&3
|
||||||
|
printf '[proof-exit] %s\n' "$exit_code" >&3
|
||||||
|
LAST_EXIT="$exit_code"
|
||||||
|
PENDING_OUTPUTS+=("$LAST_OUTPUT")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnose_failure() {
|
||||||
|
run_command kernel /usr/bin/uname -a
|
||||||
|
run_command account /usr/bin/id
|
||||||
|
run_command context /usr/sbin/sysctl kern.bootargs machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features
|
||||||
|
run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm=
|
||||||
|
run_command processes /bin/ps -axo pid,ppid,comm
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_probe() {
|
||||||
|
local reason="$1"
|
||||||
|
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||||
|
diagnose_failure
|
||||||
|
finish false "$reason"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_timer_fifo
|
||||||
|
trap cancel_probe TERM INT
|
||||||
|
|
||||||
|
# Test the required native gates before optional process/CPU diagnostics.
|
||||||
|
run_command architecture /usr/bin/uname -m
|
||||||
|
(( LAST_EXIT == 0 )) || fail_probe architecture_probe_failed
|
||||||
|
read_scalar || fail_probe architecture_output_invalid
|
||||||
|
architecture="$SCALAR"
|
||||||
|
[ "$architecture" = x86_64 ] || fail_probe unexpected_guest_architecture
|
||||||
|
run_command uid /usr/bin/id -u
|
||||||
|
(( LAST_EXIT == 0 )) || fail_probe uid_probe_failed
|
||||||
|
read_scalar || fail_probe uid_output_invalid
|
||||||
|
uid="$SCALAR"
|
||||||
|
[ "$uid" = 0 ] || fail_probe recovery_account_not_root
|
||||||
|
run_command platform /usr/bin/sw_vers
|
||||||
|
platform_exit="$LAST_EXIT"
|
||||||
|
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||||
|
if (( platform_exit != 0 )); then
|
||||||
|
run_command system /bin/launchctl print system
|
||||||
|
system_exit="$LAST_EXIT"
|
||||||
|
run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd
|
||||||
|
arbitration_exit="$LAST_EXIT"
|
||||||
|
run_command recovery /bin/launchctl print system/com.apple.recoveryosd
|
||||||
|
recovery_exit="$LAST_EXIT"
|
||||||
|
printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >&3
|
||||||
|
run_command platform-warm /usr/bin/sw_vers
|
||||||
|
platform_exit="$LAST_EXIT"
|
||||||
|
fi
|
||||||
|
(( platform_exit == 0 )) || fail_probe sw_vers_failed
|
||||||
|
# BEGIN successful sw_vers version extraction
|
||||||
|
read_product_version || fail_probe product_version_invalid
|
||||||
|
# END successful sw_vers version extraction
|
||||||
|
os_version="$SCALAR"
|
||||||
|
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid
|
||||||
|
(( ${os_version%%.*} >= 14 )) || fail_probe unsupported_macos_version
|
||||||
|
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||||
|
|
||||||
|
# BEGIN disk IPC diagnostic
|
||||||
|
run_command arbitration_before /bin/launchctl print system/com.apple.diskarbitrationd
|
||||||
|
run_command management_before /bin/launchctl print system/com.apple.diskmanagementd
|
||||||
|
run_command media_before /usr/sbin/ioreg -r -c IOMedia -l -w 0
|
||||||
|
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||||
|
|
||||||
|
# END disk IPC diagnostic
|
||||||
|
# Bound readiness independently of the host's 40-minute overall deadline.
|
||||||
|
readiness_start=$SECONDS
|
||||||
|
attempt=0
|
||||||
|
while (( attempt < 1 && SECONDS - readiness_start < 600 )); do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
printf '\n[readiness-attempt] %s\n' "$attempt" >&3
|
||||||
|
run_command disks /usr/sbin/diskutil list physical
|
||||||
|
disk_list_exit="$LAST_EXIT"
|
||||||
|
if (( disk_list_exit == 0 )); then
|
||||||
|
disk_list=$(cat "$LAST_OUTPUT")
|
||||||
|
candidates=0
|
||||||
|
while IFS= read -r disk; do
|
||||||
|
[ -n "$disk" ] || continue
|
||||||
|
run_command "info-$disk" /usr/sbin/diskutil info "/dev/$disk"
|
||||||
|
(( LAST_EXIT == 0 )) || continue
|
||||||
|
info=$(cat "$LAST_OUTPUT")
|
||||||
|
if printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || continue
|
||||||
|
size=$(printf '%s\n' "$info" | sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' | head -n 1)
|
||||||
|
[[ "$size" =~ ^[0-9]+$ ]] || continue
|
||||||
|
(( size == EXPECTED_BYTES )) || continue
|
||||||
|
candidates=$((candidates + 1))
|
||||||
|
selected_disk="/dev/$disk"
|
||||||
|
disk_bytes="$size"
|
||||||
|
printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >&3
|
||||||
|
done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p')
|
||||||
|
(( candidates <= 1 )) || fail_probe ambiguous_writable_64g_disks
|
||||||
|
if (( candidates == 1 )); then
|
||||||
|
# Re-probe live launchd domains after disk readiness, preserving native exits.
|
||||||
|
run_command system_ready /bin/launchctl print system
|
||||||
|
system_exit="$LAST_EXIT"
|
||||||
|
run_command arbitration_ready /bin/launchctl print system/com.apple.diskarbitrationd
|
||||||
|
arbitration_exit="$LAST_EXIT"
|
||||||
|
run_command recovery_ready /bin/launchctl print system/com.apple.recoveryosd
|
||||||
|
recovery_exit="$LAST_EXIT"
|
||||||
|
(( system_exit == 0 && arbitration_exit == 0 && recovery_exit == 0 )) || fail_probe service_domain_not_ready
|
||||||
|
finish true native_recovery_and_writable_64g_disk_ready
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||||
|
IFS= read -r -t 5 -u 9 unused || :
|
||||||
|
done
|
||||||
|
fail_probe disk_management_or_writable_target_not_ready
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Checksum binding for the pinned Linux Recovery UDIF patcher, not a new CLI.
|
||||||
|
|
||||||
|
Source semantics: planetbeing/libdmg-hfsplus dmg/dmglib.c and dmg/blkx.c.
|
||||||
|
Only flattened, single-segment XML UDIF with CRC32 and raw/zlib data is accepted.
|
||||||
|
The caller plans same-length chunk writes; XML formatting and all offsets remain.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import plistlib
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
|
||||||
|
def u32(data, offset):
|
||||||
|
return struct.unpack_from(">I", data, offset)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def u64(data, offset):
|
||||||
|
return struct.unpack_from(">Q", data, offset)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def crc_contract(data, offset):
|
||||||
|
if u32(data, offset) != 2 or u32(data, offset + 4) != 32 or any(data[offset + 12:offset + 136]):
|
||||||
|
raise RuntimeError("Unsupported UDIF checksum type/size/padding")
|
||||||
|
return u32(data, offset + 8)
|
||||||
|
|
||||||
|
|
||||||
|
class ChecksumPlan:
|
||||||
|
def __init__(self, image, koly, plist, xml_offset, xml_length, size):
|
||||||
|
self.image, self.koly, self.plist = image, koly, plist
|
||||||
|
self.xml_offset, self.xml_length, self.size = xml_offset, xml_length, size
|
||||||
|
self.data_offset, self.data_length = u64(koly, 24), u64(koly, 32)
|
||||||
|
if (u32(koly, 4) != 4 or u32(koly, 8) != 512 or u32(koly, 12) != 1
|
||||||
|
or self.data_offset != 0 or u64(koly, 40) or u64(koly, 48)
|
||||||
|
or u32(koly, 60) not in (0, 1) or self.data_length != xml_offset
|
||||||
|
or xml_offset + xml_length > size - 512 or xml_length > 8 * 1024 * 1024):
|
||||||
|
raise RuntimeError("Unsupported or out-of-bounds flattened UDIF layout")
|
||||||
|
crc_contract(koly, 80)
|
||||||
|
crc_contract(koly, 352)
|
||||||
|
image.seek(xml_offset)
|
||||||
|
self.xml = image.read(xml_length)
|
||||||
|
if len(self.xml) != xml_length or not self.xml.lstrip().startswith(b"<?xml"):
|
||||||
|
raise RuntimeError("Unsupported UDIF metadata framing")
|
||||||
|
self.blocks = plist["resource-fork"]["blkx"]
|
||||||
|
self.physical_runs = {}
|
||||||
|
intervals = []
|
||||||
|
for block in self.blocks:
|
||||||
|
mish = block["Data"]
|
||||||
|
if len(mish) < 244 or mish[:4] != b"mish" or (len(mish) - 204) % 40 or u32(mish, 200) != (len(mish) - 204) // 40:
|
||||||
|
raise RuntimeError("Malformed UDIF block table")
|
||||||
|
crc_contract(mish, 64)
|
||||||
|
if u64(mish, 8) + u64(mish, 16) > u64(koly, 492):
|
||||||
|
raise RuntimeError("UDIF partition exceeds logical disk boundary")
|
||||||
|
count = u32(mish, 200)
|
||||||
|
if u32(mish, 204 + (count - 1) * 40) != 0xffffffff:
|
||||||
|
raise RuntimeError("UDIF block table has no final terminator")
|
||||||
|
for kind, offset, length, sectors in self.runs(mish):
|
||||||
|
if kind in (2, 0x7ffffffe, 0xffffffff):
|
||||||
|
if length:
|
||||||
|
raise RuntimeError("Non-data UDIF run has stored bytes")
|
||||||
|
continue
|
||||||
|
if kind not in (1, 0x80000005) or not sectors or length <= 0 or sectors * 512 > 32 * 1024 * 1024:
|
||||||
|
raise RuntimeError("Unsupported UDIF compression/run boundary")
|
||||||
|
if offset < self.data_offset or offset + length > self.data_offset + self.data_length:
|
||||||
|
raise RuntimeError("UDIF data run exceeds data-fork boundary")
|
||||||
|
intervals.append((offset, offset + length))
|
||||||
|
self.physical_runs[offset] = length
|
||||||
|
intervals.sort()
|
||||||
|
if any(left[1] > right[0] for left, right in zip(intervals, intervals[1:])):
|
||||||
|
raise RuntimeError("Overlapping UDIF physical data runs")
|
||||||
|
|
||||||
|
def runs(self, mish):
|
||||||
|
for entry in range(204, len(mish), 40):
|
||||||
|
kind = u32(mish, entry)
|
||||||
|
sector, sectors = u64(mish, entry + 8), u64(mish, entry + 16)
|
||||||
|
if sector + sectors > u64(mish, 16):
|
||||||
|
raise RuntimeError("UDIF run exceeds its partition boundary")
|
||||||
|
offset = self.data_offset + u64(mish, 24) + u64(mish, entry + 24)
|
||||||
|
yield kind, offset, u64(mish, entry + 32), sectors
|
||||||
|
|
||||||
|
def read(self, offset, length):
|
||||||
|
self.image.seek(offset)
|
||||||
|
result = self.image.read(length)
|
||||||
|
if len(result) != length:
|
||||||
|
raise RuntimeError("Short UDIF checksum read")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def logical_crcs(self, mish, replacements):
|
||||||
|
original_crc = patched_crc = 0
|
||||||
|
for kind, offset, length, sectors in self.runs(mish):
|
||||||
|
# IGNORE runs are excluded by the independently verified Apple 13
|
||||||
|
# baseline. Unknown ZERO/compression types are rejected above.
|
||||||
|
if kind not in (1, 0x80000005):
|
||||||
|
continue
|
||||||
|
old = self.read(offset, length)
|
||||||
|
new = replacements.get(offset, old)
|
||||||
|
old_decoded = old if kind == 1 else zlib.decompress(old)
|
||||||
|
new_decoded = new if kind == 1 else zlib.decompress(new)
|
||||||
|
if len(old_decoded) != sectors * 512 or len(new_decoded) != sectors * 512 or len(old) != len(new):
|
||||||
|
raise RuntimeError("UDIF checksum run changed physical/logical extent")
|
||||||
|
original_crc = zlib.crc32(old_decoded, original_crc)
|
||||||
|
patched_crc = zlib.crc32(new_decoded, patched_crc)
|
||||||
|
return original_crc, patched_crc
|
||||||
|
|
||||||
|
def data_crcs(self, replacements):
|
||||||
|
old_crc = new_crc = 0
|
||||||
|
cursor = self.data_offset
|
||||||
|
def same_until(stop):
|
||||||
|
nonlocal cursor, old_crc, new_crc
|
||||||
|
while cursor < stop:
|
||||||
|
data = self.read(cursor, min(1024 * 1024, stop - cursor))
|
||||||
|
old_crc, new_crc = zlib.crc32(data, old_crc), zlib.crc32(data, new_crc)
|
||||||
|
cursor += len(data)
|
||||||
|
for offset, new in sorted(replacements.items()):
|
||||||
|
same_until(offset)
|
||||||
|
old = self.read(offset, len(new))
|
||||||
|
old_crc, new_crc = zlib.crc32(old, old_crc), zlib.crc32(new, new_crc)
|
||||||
|
cursor += len(new)
|
||||||
|
same_until(self.data_offset + self.data_length)
|
||||||
|
return old_crc, new_crc
|
||||||
|
|
||||||
|
def prepare(self, planned):
|
||||||
|
replacements = dict(planned)
|
||||||
|
if len(replacements) != len(planned):
|
||||||
|
raise RuntimeError("Duplicate planned UDIF physical writes")
|
||||||
|
if any(self.physical_runs.get(offset) != len(data) for offset, data in replacements.items()):
|
||||||
|
raise RuntimeError("Planned UDIF write does not preserve an existing data-run boundary")
|
||||||
|
old_master = bytearray()
|
||||||
|
new_master = bytearray()
|
||||||
|
changed = []
|
||||||
|
for block in self.blocks:
|
||||||
|
mish = block["Data"]
|
||||||
|
old_crc, new_crc = self.logical_crcs(mish, replacements)
|
||||||
|
if old_crc != crc_contract(mish, 64):
|
||||||
|
raise RuntimeError("Original UDIF logical CRC32 mismatch")
|
||||||
|
old_master.extend(struct.pack(">I", old_crc))
|
||||||
|
new_master.extend(struct.pack(">I", new_crc))
|
||||||
|
if new_crc != old_crc:
|
||||||
|
new_mish = bytearray(mish)
|
||||||
|
struct.pack_into(">I", new_mish, 72, new_crc)
|
||||||
|
changed.append((mish, bytes(new_mish)))
|
||||||
|
old_data_crc, new_data_crc = self.data_crcs(replacements)
|
||||||
|
if old_data_crc != crc_contract(self.koly, 80) or zlib.crc32(old_master) != crc_contract(self.koly, 352):
|
||||||
|
raise RuntimeError("Original UDIF data-fork/master CRC32 mismatch")
|
||||||
|
xml = self.xml
|
||||||
|
for old_mish, new_mish in changed:
|
||||||
|
matches = [match for match in re.finditer(rb"<data>([\sA-Za-z0-9+/=]*)</data>", xml)
|
||||||
|
if base64.b64decode(match.group(1)) == old_mish]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise RuntimeError("UDIF block checksum XML identity is ambiguous")
|
||||||
|
match = matches[0]
|
||||||
|
encoded = iter(base64.b64encode(new_mish))
|
||||||
|
text = bytes(value if chr(value).isspace() else next(encoded) for value in match.group(1))
|
||||||
|
xml = xml[:match.start(1)] + text + xml[match.end(1):]
|
||||||
|
if len(xml) != self.xml_length:
|
||||||
|
raise RuntimeError("UDIF checksum update changed XML region length")
|
||||||
|
new_plist = plistlib.loads(xml)
|
||||||
|
expected = dict(self.plist)
|
||||||
|
expected["resource-fork"] = dict(self.plist["resource-fork"])
|
||||||
|
expected["resource-fork"]["blkx"] = [dict(block, Data=dict(changed).get(block["Data"], block["Data"])) for block in self.blocks]
|
||||||
|
if new_plist != expected:
|
||||||
|
raise RuntimeError("UDIF checksum update changed unrelated metadata")
|
||||||
|
koly = bytearray(self.koly)
|
||||||
|
struct.pack_into(">I", koly, 88, new_data_crc)
|
||||||
|
struct.pack_into(">I", koly, 360, zlib.crc32(new_master))
|
||||||
|
self.receipt = dict(originalDataCrc32=f"{old_data_crc:08x}", patchedDataCrc32=f"{new_data_crc:08x}",
|
||||||
|
originalMasterCrc32=f"{zlib.crc32(old_master):08x}", patchedMasterCrc32=f"{zlib.crc32(new_master):08x}",
|
||||||
|
changedBlockChecksums=len(changed), imageBytes=self.size, xmlOffset=self.xml_offset,
|
||||||
|
xmlBytes=self.xml_length, physicalAndLogicalExtentsPreserved=True)
|
||||||
|
return xml, bytes(koly)
|
||||||
|
|
||||||
|
def verify(self):
|
||||||
|
koly = self.read(self.size - 512, 512)
|
||||||
|
xml = self.read(self.xml_offset, self.xml_length)
|
||||||
|
verifier = ChecksumPlan(self.image, koly, plistlib.loads(xml), self.xml_offset, self.xml_length, self.size)
|
||||||
|
verifier.prepare([])
|
||||||
|
self.image.seek(0, 2)
|
||||||
|
if self.image.tell() != self.size:
|
||||||
|
raise RuntimeError("Patched UDIF image length changed")
|
||||||
|
print("[recovery-udif] " + json.dumps(dict(self.receipt, readBackChecksumsVerified=True), sort_keys=True))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
; Bare 64-KiB BIOS for the existing Linux QEMU binary, not macOS firmware.
|
||||||
|
; Assemble: nasm -f bin -o ci-cpu-preflight.bin macos-tcg-cpu-preflight.asm
|
||||||
|
; No disks/network. isa-debug-exit returns 33 only after AVX + AVX2 execute
|
||||||
|
; and the upper 128-bit lane contains the expected arithmetic result.
|
||||||
|
; Unsupported instructions/triple faults cannot produce the success code.
|
||||||
|
BITS 16
|
||||||
|
ORG 0
|
||||||
|
start:
|
||||||
|
cli
|
||||||
|
cld
|
||||||
|
xor ax, ax
|
||||||
|
mov ds, ax
|
||||||
|
mov es, ax
|
||||||
|
mov ss, ax
|
||||||
|
mov sp, 0x8000
|
||||||
|
|
||||||
|
; QEMU zeroes fresh RAM. Identity-map the first 2 MiB through three tables.
|
||||||
|
mov dword [0x1000], 0x2003
|
||||||
|
mov dword [0x2000], 0x3003
|
||||||
|
mov dword [0x3000], 0x0083
|
||||||
|
lgdt [cs:gdt_descriptor]
|
||||||
|
mov eax, 0x40620 ; PAE, OSFXSR, OSXMMEXCPT, OSXSAVE
|
||||||
|
mov cr4, eax
|
||||||
|
mov eax, 0x1000
|
||||||
|
mov cr3, eax
|
||||||
|
mov ecx, 0xc0000080 ; EFER.LME
|
||||||
|
rdmsr
|
||||||
|
or eax, 0x100
|
||||||
|
wrmsr
|
||||||
|
mov eax, cr0
|
||||||
|
and eax, ~0x0c ; clear EM and TS before vector instructions
|
||||||
|
or eax, 0x80000003 ; paging, protected mode, monitor coprocessor
|
||||||
|
mov cr0, eax
|
||||||
|
jmp dword 0x08:(0xf0000 + long_mode)
|
||||||
|
|
||||||
|
ALIGN 8
|
||||||
|
gdt:
|
||||||
|
dq 0
|
||||||
|
dq 0x00af9a000000ffff ; ring-0 long-mode code, base 0
|
||||||
|
dq 0x00cf92000000ffff ; ring-0 data, base 0
|
||||||
|
gdt_descriptor:
|
||||||
|
dw gdt_descriptor - gdt - 1
|
||||||
|
dd 0xf0000 + gdt
|
||||||
|
|
||||||
|
BITS 64
|
||||||
|
long_mode:
|
||||||
|
mov ax, 0x10
|
||||||
|
mov ds, ax
|
||||||
|
mov es, ax
|
||||||
|
mov ss, ax
|
||||||
|
mov rsp, 0x8000
|
||||||
|
xor ecx, ecx
|
||||||
|
mov eax, 7 ; XCR0 enables x87, SSE and AVX state
|
||||||
|
xor edx, edx
|
||||||
|
xsetbv
|
||||||
|
vxorps ymm0, ymm0, ymm0 ; AVX, including the upper YMM lane
|
||||||
|
vpcmpeqd ymm1, ymm1, ymm1 ; AVX2: all eight int32 lanes become -1
|
||||||
|
vpsrld ymm1, ymm1, 31 ; AVX2: all lanes become 1
|
||||||
|
vpaddd ymm2, ymm1, ymm1 ; AVX2: all lanes become 2
|
||||||
|
vextracti128 xmm3, ymm2, 1 ; AVX2: inspect the upper half, not only SSE
|
||||||
|
vmovd eax, xmm3
|
||||||
|
cmp eax, 2
|
||||||
|
jne fail
|
||||||
|
vzeroupper
|
||||||
|
mov eax, 0x10 ; QEMU debugexit computes (value << 1) | 1
|
||||||
|
jmp exit_qemu
|
||||||
|
fail:
|
||||||
|
mov eax, 0x11
|
||||||
|
exit_qemu:
|
||||||
|
mov dx, 0xf4
|
||||||
|
out dx, eax
|
||||||
|
hlt
|
||||||
|
jmp $
|
||||||
|
|
||||||
|
; CPU reset starts at the last 16 bytes; reload the real-mode CS base.
|
||||||
|
BITS 16
|
||||||
|
TIMES 0xfff0 - ($ - $$) db 0xff
|
||||||
|
jmp 0xf000:start
|
||||||
|
TIMES 0x10000 - ($ - $$) db 0xff
|
||||||
Reference in New Issue
Block a user