Compare commits

...
Author SHA1 Message Date
dh 96755c704e Capture the immutable Recovery hash once after staging 2026-10-05 14:53:57 +02:00
dh d1594e90b3 prepare optional RAW Recovery backend for full KVM NoAVX CI 2026-10-05 13:59:10 +02:00
dh 5f686c5c80 prepare full KVM native CI candidate with verified NoAVX boot injection 2026-10-05 13:29:12 +02:00
dh a356b88ae4 Integrate verified Recovery image repair into full native CI candidate 2026-10-03 22:04:31 +02:00
dh 94a70b2005 ci: patch known Recovery variants and preserve UDIF checksums
PR and Push Build/Test / build-and-test (push) Failing after 18m28s
PR and Push Build/Test / portable-build-and-test (push) Successful in 6m35s
2026-10-03 21:53:38 +02:00
dh 8d2014dcbc Integrate existing-runner KVM profile with full native CI gates 2026-10-03 21:43:04 +02:00
dh 45d7bde71f ci: probe macOS Ventura on existing KVM hardware
PR and Push Build/Test / build-and-test (push) Canceled after 0s
PR and Push Build/Test / portable-build-and-test (push) Canceled after 0s
2026-10-03 20:58:12 +02:00
dh 4606de0696 ci: preserve Apple Recovery daemon during read-only readiness probe
PR and Push Build/Test / build-and-test (push) Canceled after 0s
PR and Push Build/Test / portable-build-and-test (push) Canceled after 0s
2026-10-03 20:17:56 +02:00
dh 9842887e34 Retain installed APFS physical-store mapping in native CI evidence 2026-10-03 18:19:18 +02:00
dh f3bb8411e5 Bound readiness probe overhead and export native screenshots from regular storage 2026-10-03 18:06:04 +02:00
dh cfe8c13f4a ci: require native macOS build and tests after existing runner jobs 2026-10-03 17:12:28 +02:00
dh b19dbd21a7 ci: retain bounded native platform process diagnostics in full candidate 2026-10-03 16:47:45 +02:00
dh 6e58928c1f ci: retain an owned guest monitor screenshot before cleanup 2026-10-03 16:08:26 +02:00
dh 90d86af887 ci: report native progress and require complete unique test evidence 2026-10-03 15:54:58 +02:00
dh 01596e1f52 ci: integrate the experimental native guest candidate with current build fixes 2026-10-03 15:35:42 +02:00
dh db6b1b58e0 ci: add gated native macOS guest build and tests on Ubuntu 2026-10-03 15:32:32 +02:00
13 changed files with 2346 additions and 68 deletions
@@ -1,4 +1,4 @@
name: Native macOS Recovery diagnostic on Ubuntu
name: macOS 13 KVM Cryptex Recovery compatibility diagnostic
on:
workflow_dispatch:
@@ -19,7 +19,7 @@ jobs:
with:
dotnet-version: "10.0.x"
- name: Probe native macOS Recovery with existing Docker resources
- name: Probe macOS 13 Recovery with existing KVM and host CPU
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
- name: Always clean up only this diagnostic's owned resources
+32
View File
@@ -0,0 +1,32 @@
name: Native macOS build and tests on Ubuntu (experimental)
on:
workflow_dispatch:
jobs:
macos-native-full:
runs-on: ubuntu-latest
timeout-minutes: 180
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout exact native CI candidate
uses: actions/checkout@v7
- name: Setup .NET orchestration SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.0.x"
- name: Run owned macOS 13 KVM guest and all native tests
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --recovery-format raw --output artifacts/native-macos-full
- name: Always remove only this run's owned resources
if: always()
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
- name: Retain native build, signatures, TRX and guest receipts
if: always()
uses: actions/upload-artifact@v3
with:
name: native-macos-full
path: artifacts/native-macos-full/
if-no-files-found: error
retention-days: 7
@@ -3,6 +3,10 @@ name: PR and Push Build/Test
on:
pull_request:
push:
# The isolated NoAVX candidate uses the existing manual native workflow.
branches-ignore:
- codex/macos-native-full-kvm-noavx
- codex/macos-native-full-kvm-noavx-raw
workflow_dispatch:
jobs:
@@ -131,6 +135,34 @@ jobs:
find MeetingAssistant.Tests -type d -name TestResults -print || true
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
macos-native-full:
needs: [build-and-test, portable-build-and-test]
runs-on: ubuntu-latest
timeout-minutes: 180
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout exact native CI candidate
uses: actions/checkout@v7
- name: Setup .NET orchestration SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.0.x"
- name: Run owned macOS 13 KVM guest and all native tests
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
- name: Always remove only this run's owned resources
if: always()
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
- name: Retain native build, signatures, TRX and guest receipts
if: always()
uses: actions/upload-artifact@v3
with:
name: native-macos-full
path: artifacts/native-macos-full/
if-no-files-found: error
retention-days: 7
portable-build-and-test:
runs-on: ubuntu-latest
env:
+4 -4
View File
@@ -169,13 +169,13 @@ Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-e
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. No additional runner labels or host devices are required.
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. After both jobs pass, the workflow requires a native macOS Full job on the same existing runner label, using the runner's existing `/dev/kvm` device and Docker daemon.
Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests requiring the native macOS environment report an explicit skip through `MacOsFact`; they must also be run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`. That build compiles and signs the helpers, and the suite checks packaging, helper self-tests, and native image cropping. Real microphone/system-audio capture and privacy permissions still require the operational checks described above.
The Ubuntu managed-test job does not compile the Swift helpers or execute Apple frameworks; its native macOS tests report an explicit skip through `MacOsFact`. The prepared Full job runs an owned macOS 13+ x86_64 guest with KVM, the real host CPU and pinned CryptexFixup on the existing Ubuntu Docker runner. It builds all four native helpers, verifies the audio app's signature, and requires all 577 tests to pass with zero skips, including all five native macOS tests. It has a 180-minute job limit with `always()` steps for retained evidence and ownership-checked cleanup. Recovery compatibility, installed toolchain operation and this CI path remain unqualified until actual remote guests produce every required receipt; .NET 10's binary minimum does not establish vendor support for macOS 13. Native tests can also be run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`; real microphone/system-audio capture and privacy permissions still require the operational checks described above.
[Docker-OSX](https://github.com/sickcodes/Docker-OSX) runs a macOS VM rather than providing a Wine-style compatibility layer. Its launcher supports software emulation with `KVM=accel=tcg`, so KVM is not an absolute requirement. A supported .NET 10 guest needs macOS 14 or later plus the Swift build tools. The documented `auto` build downloads a preinstalled guest disk through `IMAGE_URL`; its documented ready-made tags and disk downloads were unavailable when checked on 2026-10-03. No verified native guest bootstrap is owned by this repository. CI validates source; it does not publish or deploy the workstation application.
The separate manual `.gitea/workflows/macos-native-full.yaml` retains the same Full flow as a diagnostic entry point. CI validates source; it does not publish or deploy the workstation application.
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness through an unprivileged TCG guest on the existing Ubuntu Docker runner. It neither installs macOS nor runs application tests; its result is a prerequisite for a future native test job, not verification of macOS CI support.
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness before qualifying the prepared Full flow. It neither installs macOS nor runs application tests. A green Recovery diagnostic alone does not verify macOS build/test CI support; each Full run repeats Recovery readiness for its own guest and disk.
## Operations And Limitations
+90 -22
View File
@@ -1,45 +1,113 @@
# Native macOS Recovery diagnostic on the existing Ubuntu runner
# macOS 13 KVM/Cryptex/NoAVX diagnostic and prepared Full flow
This manual diagnostic tests the unresolved Recovery startup boundary before adding a native macOS application test job. It does not install macOS, erase a guest disk, install .NET or Apple CLT, or run Meeting Assistant tests. A green diagnostic means only that a real macOS 14+ x86_64 Recovery guest has a working launchd system domain, DiskArbitration and exactly one writable 64-GiB guest disk.
Full RAW run 4219 at `d1594e90b3fa9c6f3bb52f12b754ae933105b8c8` verified RAW sector equality, targeted file-cache eviction, KVM, macOS 13.6/x86_64/root and successful cleanup, but all eight disk-list attempts timed out. Installation and application tests were not reached. The next host-only repair retains the completed Recovery hash once after the existing staging marker; it avoids rereading the immutable 711-MB DMG at every poll. Failed or missing-image captures are not retained as success and can retry. Guest code, assets, CPU, memory and deadlines are unchanged. This repair does not yet prove native readiness or CI success.
The workflow `.gitea/workflows/macos-native-diagnostic.yaml` has only `workflow_dispatch`; it does not run on ordinary pushes or pull requests. It uses the same `ubuntu-latest` label and existing Docker daemon as the current builds. There are no runner changes, extra host devices, privileged containers, added capabilities, published ports, host networking or new secrets. It fails clearly if the existing Docker daemon cannot fit its bounded resource budget.
In readiness mode, this separate manual candidate probes Recovery readiness on the existing Ubuntu Docker daemon with KVM, the real Intel host CPU and macOS 13. It does not install macOS, erase a disk, install .NET or Apple CLT, or run Meeting Assistant. Passing proves only a fresh macOS 13+ x86_64 Recovery guest with root identity, a working launchd system domain, DiskArbitration and exactly one writable 64-GiB guest disk.
## Helper entry point and invocation
Baseline: bootstrap commit `4606de069678e8f95dfe3c7dad1bf5ce5384d30c`; separate branch `codex/macos-ci-kvm-compatibility`. KVM, CPU passthrough, Recovery major version and guest Cryptex staging change together. This is a compatibility experiment, not a causal single-variable A/B test. The TCG/bootstrap experiment remains separate.
The orchestration is a .NET 10 file-based C# app at `tools/ci/MacOsNativeDiagnostic.cs`:
The isolated Full NoAVX candidate starts at `a356b88ae4a0a26d68098460df86038f9831c080` on `codex/macos-native-full-kvm-noavx`. Its Compatibility code, archive/staging/configuration rejection checks, host-memory admission and captured-proof heartbeat are transferred from the offline-verified Recovery candidate `fef676c810cf78b39aabb872546a76e8ac2b29d5`. The additional NoAVX boot kext is the only guest change. Application/tests/specs, Apple bootstrap/installer, payload/SDK pins, owned-disk guards, TRX requirements, CPU/KVM/macOS 13 and guest/container limits are unchanged. Existing phase budgets remain Recovery 40, installation 80, toolchain 30 and tests 25 minutes within the 172-minute host/180-minute job limits. No successful native run is implied by preparation.
```sh
The further isolated branch `codex/macos-native-full-kvm-noavx-raw` starts from that completed Full candidate, `5f686c5c80b6bbb525745de173b57c6393f58bf0`. Its optional `--recovery-format raw` transfers the exact producer, backend selection and existing six producer/three backend fixtures from RAW repair `cae38b014f3278a5b939ff980b0138bff5d6b509`. The manual Full workflow selects RAW; the controller default remains DMG. Against this baseline, the only additional guest variable is the Recovery disk backend. KVM/host CPU, macOS 13, NoAVX/Cryptex, bootstrap/installer, application/tests and every resource/phase limit are retained. The run/validation comparison metadata names this Full baseline; the unchanged boot-assets receipt continues to describe the original NoAVX component comparison.
In RAW mode the existing QEMU converts the same already-patched DMG, requires sector equality and unchanged DMG SHA256, then retains both images and their hash/equality receipt in owned storage. Before RAM admission, two existing GNU `dd` calls synchronize and request removal only of those verified files' caches (`oflag=nocache conv=nocreat,notrunc,fdatasync count=0`); either failure rejects preparation. This avoids the locally reproduced cgroup file-cache admission failure without a package, global cache clearing or larger memory limit. QEMU still attaches the same readonly virtio device and I/O thread with explicit `format=raw`. Offline Full/source validation and the generated real-QEMU fixture can validate preparation; they establish no guest boot, installation or native test result.
Use `dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --recovery-format raw --output artifacts/native-macos-full`. Offline checks use `--validate --full --recovery-format raw --source <pristine-pinned-dockur-checkout> --cryptex-archive <verified-Cryptex-ZIP> --noavx-archive <verified-NoAVX-ZIP> --output <fresh-folder>`. The generated `raw-recovery-real-qemu-fixture.sh` accepts an already-patched disposable writable DMG copy and destination; it does not boot a guest. Cleanup remains the existing `--cleanup --output artifacts/native-macos-full` and removes only the run's owned resources.
The [upstream NoAVX AVXpel 12.6 ZIP](https://raw.githubusercontent.com/dortania/OpenCore-Legacy-Patcher/f40057a5292f4804b51bcfe78d5047c7302a6434/payloads/Kexts/Misc/NoAVXFSCompressionTypeZlib-AVXpel-v12.6.zip) is pinned to OCLP commit `f40057a5292f4804b51bcfe78d5047c7302a6434`, 98,356 bytes and locally verified SHA256 `b5d6319d0a1f335684a92ecf23369bc3deb776be19e92b0a40860021409d20df`. Its two expected bundle files, identity/version and `OSBundleRequired=Root` are verified. After existing Lilu/Cryptex, `Kernel.Add` enables `NoAVXFSCompressionTypeZlib-AVXpel.kext` with `Arch=x86_64`, executable `Contents/MacOS/NoAVXFSCompressionTypeZlib`, plist `Contents/Info.plist`, `MinKernel=22.0.0` and empty `MaxKernel`; both file copies enter the existing SHA256SUMS checks. This is a filesystem-decompression hypothesis, not proof of the current readiness hang's cause.
Memory admission requires the unchanged 4-GiB guest plus 512 MiB QEMU overhead. The copied four fixtures accept run 4204's captured 5,138,696 KiB and reject 4 GiB, missing or invalid availability. This reserves no memory against other host workloads. The existing one-minute heartbeat reads only retained `guest-proof.log`, printing at most its latest two start/completion/result/version markers, capped at 256 characters each; no new guest query or timer is added. Five existing offline fixtures exercise those bounds. Pushes on this candidate branch skip the PR/Push workflow; pull-request and manual triggers remain.
## Reasons and remaining gaps
The existing daemon's Intel Celeron 1037U lacks AVX/AVX2; a separate diagnostic proved KVM enabled/paused state and clean exit. `CPU_MODEL=host` preserves actual instruction availability rather than advertising AVX2 through emulated Skylake. This candidate refuses a TCG or CPU-model fallback.
All four Swift helpers target `x86_64-apple-macos13.0`; the macOS 14 EventKit call has an existing macOS 13 fallback. Inspected native Mach-O files in pinned .NET SDK 10.0.401 x64 declare `minos 12.0`. These source/binary minima are not runtime qualification or vendor support: macOS 13 is outside [Microsoft's current .NET 10 supported-OS policy](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md). This probe does not install that SDK, compile helpers or test calendar/audio permissions.
[Official CryptexFixup 1.0.5](https://github.com/acidanthera/CryptexFixup/blob/1.0.5/CryptexFixup/kern_start.cpp) activates without AVX2 and registers for normal, installer/Recovery and safe-mode boots. It redirects installer/updater ramrod to Apple Silicon's Rosetta Cryptex and bypasses APFS root-hash authentication on Ventura and newer. It does not emulate missing instructions. This kernel patch affects only the owned guest, never a host module.
**Recovery cache gap:** CryptexFixup does not replace an already running Recovery BaseSystem shared cache. Its installer/update selector targets the installed Cryptex, but this readiness-only run invokes no installer. Staging or loading it therefore proves no Recovery userland compatibility. Actual CPU/kernel behavior, guest injection, all native gates and any later installed-Cryptex/build/test behavior remain unqualified until observed.
Apple Recovery uses the pinned public InternetRecovery protocol with board ID and session/asset tokens, without Apple ID or workstation credentials. The macOS 13 selection, downloaded hash and actual guest version are retained; the hook downloads no full installer or SDK.
## Entry point and dependencies
Orchestration/validation remain the .NET 10 file-based app `tools/ci/MacOsNativeDiagnostic.cs`. Bash/Python stay only in the existing pinned Linux/macOS boot integration.
~~~sh
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --help
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/pinned/dockur-clone --output artifacts/native-validation
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/clean/pinned/dockur-clone --cryptex-archive /path/to/CryptexFixup-1.0.5-RELEASE.zip --noavx-archive /path/to/NoAVXFSCompressionTypeZlib-AVXpel-v12.6.zip --output /path/to/fresh/validation
~~~
`--validate` checks result/container contracts without Docker. With `--source` it verifies the actual Cryptex ZIP/bundle, source seams, generated OpenCore configuration and staging/checksum contracts, checks Bash syntax, then exercises four raw/zlib Recovery fixtures and twelve rejection cases with independent C# CRC32 readback. It also checks preservation of a successful resource snapshot after a later failed capture, leaving the supplied source untouched. It does not download/extract the LongQT ISO, verify a complete Apple Recovery image or execute the active-Lilu runtime checks. The ISO checksum is enforced during the later Docker build; active Lilu and EFI-copy checks execute only during container boot. The optional local Cryptex ZIP must match the release size/hash; omitting it downloads only the public 69,703-byte release. Use a fresh output directory. Dependencies are .NET 10, Git, Bash and Python 3 with its standard library; manual execution also requires the existing Linux/x64 Docker daemon and its existing KVM device.
The optional `--noavx-archive` supplies the exact local NoAVX ZIP; omitting it downloads the pinned small archive. The unchanged NoAVX validation checks staged bytes and actual generated `Kernel.Add`, including four invalid archives, two staging failures and five configuration rejections. Use `--validate --full` with the same arguments to exercise the existing Full bootstrap, installer, disk and TRX contracts as well; `MacOsNativeGuest.cs --validate` checks the unchanged guest payload/tar/fresh-test-result contracts. These checks do not install an SDK or execute Apple frameworks.
The manual-only workflow keeps these owned run/cleanup entry points; validation invokes neither:
~~~sh
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
```
~~~
Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docker CLI/socket. The actual execution downloads public Dockur source, upstream build assets, Docker images and Apple Recovery; it does not use workstation credentials. The existing upstream Python UDIF patcher and the Bash hook are retained because they run inside the pinned Linux/macOS boot integration. Independent orchestration and validation remain C#.
## Exact bootasset contract
The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable.
Dockur stays pinned to `16a5b470cdd601bae8b05b02d748d7edfb36c12e`. Original Recovery patcher/staging, Dockerfile, OpenCore script and active config hashes are verified before edits. Both existing QEMU image digests remain pinned; other existing upstream downloads are observed through image identity. `source-hashes.json` includes the generated Recovery patcher, both original/replacement daemon variants and `udif_checksums.py`, staged from `tools/ci/macos-native-udif-checksums.py`. This small Python module belongs to the existing Linux UDIF runtime; C# supplies orchestration, validation fixtures and an independent CRC32 implementation.
The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Native commands have 45-second watchdogs, except the single UID gate's targeted 180-second timing experiment. The ten-minute disk-readiness phase, 40-minute host deadline and 45-minute workflow limit remain unchanged.
Run 4173 at `45d7bde71f9f5a1f7121585fe3ee9fc81f7c585f` failed before QEMU started: the full macOS 14 plist pattern was absent from the macOS 13 download. The original image's hash was not retained. An independently downloaded comparison for the same board `Mac-4B682C642B45593E` is macOS 13.6/22G120, Apple product 042-23155, 710,918,897 bytes, SHA256 `c19bd12f5cb1651b87b74d04f02a636da762ea46b81c7ebc9f205fa2a976d599`. Its Apple chunklist signature and chunks verified before any changes. It is comparison evidence, not the missing run-4173 image identity.
Actual remote run 4155 stopped at the first `sw_vers` with exit 143. Run 4159 then proved native Darwin/x86_64, root identity and guest AVX2, but reached the host deadline before `sw_vers` or the service/disk gates. Its logged command durations included timer cleanup and output copying, so they did not isolate native execution time.
The HFS+ catalog identifies `/System/Library/LaunchDaemons/com.apple.recoveryosd.plist` as file ID 57231, logical size 465 bytes and one 4,096-byte allocated block. Its exact XML SHA256 is `af9d7f6c1948079bd4384d27b6882678d6fb4e338fcf6a8be8f84fceef174ad6`. This variant has `ProcessType=Interactive`; the previous macOS 14 variant has `App`. The patch accepts only these two exact layouts with exactly one daemon label and original `ProgramArguments=[/usr/libexec/recoveryosd]`. It preserves each variant's fields and process type, removes only the XML doctype to fit the wrapper arguments, and pads to the original file size. Unknown, duplicate, malformed or wrong-argument layouts fail before image writes. The early rc.cdrom hook remains mount-only; both the unchanged read-only wrapper and guarded Full wrapper exec the original Apple daemon.
The next probe runs mandatory architecture, root identity and platform gates before optional process/CPU diagnostics. It keeps the proof log open, uses Bash 3.2's timed FIFO reads instead of starting a separate sleep process for every watchdog, and groups output copying and byte-limit checks. Separate markers record fork/exec/wait, timer cleanup and output flush durations. Raw output still fails above 512 KiB per command, proof above 4 MiB fails, and scalar gates reject hidden suffixes or multiline values. A local harmless-command harness verifies all 16 timeout, cancellation, output and scalar cases; this does not qualify macOS Recovery.
The checksum binding validates the original flattened UDIF boundaries and CRC32 values, stages every recompressed chunk before writing, then updates only the changed mish CRC32 and koly data-fork/master CRC32. [libdmg-hfsplus](https://github.com/planetbeing/libdmg-hfsplus/blob/master/dmg/dmglib.c) provides the checksum semantics; an independent C# reader matched all eight mish checksums on the unchanged comparison. Raw and inflated zlib bytes enter logical CRCs in run order; observed IGNORE runs are omitted. Unobserved ZERO runs, other compression/checksum types, overlaps and invalid boundaries are rejected. Base64 characters are replaced within the same metadata region, preserving its whitespace, length, partition tables and trailer offsets; the entire modified image is read again to verify CRCs. Apple chunklist authentication applies exclusively to the unchanged input, not the deliberately modified guest image. CRC integrity proves no Apple authenticity or native runtime gate.
After an initial platform failure the hook collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. Native product version and all original identity/service/disk gates remain required. Optional process and CPU diagnostics run only after a gate fails. The upstream AVX2 warning reads host flags; run 4159 observed AVX2 in the actual guest. No host or guest CPU settings change.
The [original LongQT v0.7 template](https://github.com/LongQT-sea/OpenCore-ISO/releases/download/v0.7/LongQT-OpenCore-v0.7.iso), 15,884,288 bytes, is now Docker-ADD-checksummed to SHA256 `287328995d4198f1b05166f087d85bf7ef66bedafe150d17ad112ac8de60051d`. Runtime copies actual `EFI_RELEASE/EFI/OC/Kexts`, including Lilu 1.7.1, even with official OpenCore DEBUG executables. Active Lilu: executable 526,984 bytes, SHA256 `0c016d93cfe40c7fa3965813175c1b991a76f3d295efd5be66ae712b4a3ffb52`; Info.plist SHA256 `fc885f3319f326e3af60e7965a5216b671772d39d40993ec695758bb43d6ea3a`. Staging checks both hashes and bundle version. Cryptex declares Lilu 1.4.7; [Lilu history](https://github.com/acidanthera/Lilu/blob/master/Changelog.md) includes Ventura/Sonoma installer/Recovery support before 1.7.1. Existing Lilu is kept.
Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. The next diagnostic changes only the UID gate's watchdog to 180 seconds while retaining exit-zero/exact-root checks. This tests whether the measured short limit caused that failure; it does not establish a guest startup or service cause, and it does not qualify native CI. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this new timing experiment or the actual emulated guest.
[CryptexFixup-1.0.5-RELEASE.zip](https://github.com/acidanthera/CryptexFixup/releases/download/1.0.5/CryptexFixup-1.0.5-RELEASE.zip), 69,703 bytes, SHA256 `25041d94a0fe9a0261caf0ba89b36dfcb21682bf3c697a34bcaddc839576ab30`, is checked in C#. Only expected Info.plist/executable files are accepted; identity/version/dependency and individual hashes are recorded. Runtime checks files before/after copying into fresh guest EFI.
## Evidence and cleanup
Active `/assets/config.plist` receives exactly one enabled Cryptex immediately after enabled Lilu, preserving every other kext's order. Entry: `Arch=x86_64`, `BundlePath=CryptexFixup.kext`, `ExecutablePath=Contents/MacOS/CryptexFixup`, `PlistPath=Contents/Info.plist`, `MinKernel=22.0.0`, empty `MaxKernel`. [OpenCore Kernel.Add](https://github.com/acidanthera/OpenCorePkg/blob/1.0.7/Docs/Configuration.tex) requires dependencies first; bounds are Darwin versions. Runtime rechecks order/enabled/paths/architecture/bounds and rejects unverified `/custom.plist`.
Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails.
No new force/beta argument is needed for actual no-AVX2 CPUs. Baseline arguments remain. Validation rejects disabling arguments, `-crypt_allow_hash_validation` (disables the APFS patch) and unexpected Cryptex force/beta overrides. Manifest/profile enter the boot signature; this candidate always rebuilds `boot.img` and accepts no old cache as evidence.
While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success.
## Gates, privileges and cleanup
Run 4159 generated a 6,220,817-byte screenshot file under `/dev/shm`, but `docker cp` could not retrieve it. Screenshots now use the regular container path `/tmp/native-diagnostic-screen-<runToken>.ppm`, avoiding Docker's documented [`/dev`/tmpfs copy limitation](https://docs.docker.com/reference/cli/docker/container/cp/#corner-cases).
The read-only Apple wrapper is byte-identical to baseline: background `/Volumes/installstate/readiness.sh` then `exec /usr/libexec/recoveryosd` under the same launchd job/PID. Full mode uses the separate guarded wrapper described below. Source evidence does not prove Apple's executable ran.
Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup.
Readiness changes only minimum macOS 14 to 13. Validation normalizes that gate to 14 and requires baseline SHA256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. Architecture, UID, services, disk size/writability/uniqueness, retries, proof bounds, timers and native-wait/cleanup/flush metrics remain identical. Limits stay 45 seconds per native command, 180 seconds for UID, ten minutes disk readiness, 40 minutes host and 45 minutes workflow.
The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips.
Container profile: `KVM=Y`, `CPU_MODEL=host`, `VERSION=13`, 4-GiB guest, two guest/host CPUs, 6-GiB memory/swap and 512-MiB shared memory. Fresh anonymous `/storage` holds the 64-GiB disk; evidence reads `/storage/13/setup.dmg`. Existing resource budget checks remain.
Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. Actual run 4155 built that image and started QEMU/XNU successfully, then failed the first native `sw_vers` after its 45-second watchdog. It did not prove native readiness.
Only device mapping: exactly `/dev/kvm:/dev/kvm:rw`. Inspection rejects other devices/permissions, added capabilities, device requests/rules, binds, tmpfs overrides, published ports, host networking, privileged mode, wrong limits, unexpected persistent mounts or changed CPU/OS profile. No host modules, infrastructure, secrets, SSH or app lifecycle actions are involved. Guest slirp networking remains.
Evidence retains run/profile identity, source/assets, EFI staging, container/resources, macOS 13 Recovery hash, native proof/result/outcome and cleanup. `[recovery-original]` logs the exact download's size/SHA256 before modifying it, including when patch failure later deletes the source. `guest-container-resources.last-success.stdout.log` and its timestamp/hash receipt preserve the last successful resource snapshot independently of a later failed stopped-container `docker exec`. Optional final Unix HMP capture includes `info kvm`, `info status` and a bounded PPM exported from `/tmp`; capture success passes no native gate.
Both cleanup paths keep exact token/label/ID checks. `docker rm --force --volumes` removes only the owned container and anonymous volume, then its exact image; no unrelated objects or pruning. Evidence stays seven days. Full native CI still needs a subsequent actual installed remote guest to build/sign helpers and pass the full suite, including five native tests without skips.
## Experimental full guest flow
The prepared `.gitea/workflows/pr-push-build-and-test.yaml` requires `macos-native-full` after both existing Wine and portable jobs succeed, using `ubuntu-latest`, a 180-minute limit and the same checkout/SDK/Full-run/always-cleanup/artifact steps as the separate manual `.gitea/workflows/macos-native-full.yaml`. Both use the existing KVM device, host CPU and macOS 13/Cryptex profile above. Recovery compatibility and the installed build/test path remain unqualified. A Full run must wait for actual remote Recovery qualification, then repeat readiness in its own VM; it cannot accept another run's disk receipt. The full acceptance review follows actual remote build/test verification. The ordinary diagnostic workflow remains read-only.
~~~sh
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --full --source /path/to/clean/pinned/dockur-clone --cryptex-archive /path/to/CryptexFixup-1.0.5-RELEASE.zip --output /path/to/fresh/full-validation
dotnet run --file tools/ci/MacOsNativeGuest.cs -- --validate
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
~~~
The host requires a clean exact Git HEAD, creates its Git/PAX source archive and SHA-256, and downloads macOS/x64 SDK `10.0.401` from Microsoft's release URL with the fixed official SHA-512 recorded in both helpers. Source archive, SDK and helper files enter the image and newly owned anonymous `/storage` volume; no workstation bind mount is introduced. The persistent state is `/storage/13/ci-state` through the existing guest 9p share. An uncommitted integration cannot be qualified through `git archive HEAD`: only an archive of the final reviewed commit binds the actual integrated source.
The Full-only `macos-native-full-bootstrap.sh` waits up to 120 one-second mount attempts for the share's own `run.owner`; a present foreign or invalid owner fails immediately. Before backgrounding the probe, it exclusively creates and validates the complete one-record `probe.started` marker bound to run token and source commit. A subsequent launchd wrapper start with the same complete owned marker starts no second child and always execs the original Apple `recoveryosd`. Foreign, empty, partial, extra-record or unterminated markers and genuine write failures remain errors; markers are preserved. An initial child failure remains a token-bound bootstrap failure, without silent retry. If the share never appears, the wrapper reports a bounded mount failure to stderr without writing to foreign state, then still execs Apple. This contract relies on completing the marker before the wrapper's first Apple exec; it does not prove arbitrary simultaneous installer starts or actual guest 9p atomicity.
Before the only guest `eraseDisk`, C# revalidates the owned Docker boundary, sole anonymous storage mount, exact writable 64-GiB raw image, live QEMU attachment and per-run emulated disk serial. Only after a valid fresh native Recovery receipt does it atomically provide the run/commit/disk permit. The guarded Apple installer rechecks `diskutil` and the corresponding IORegistry serial; missing or ambiguous identity fails. Its exclusive persistent `started` marker binds token, commit and disk. A duplicate child with a complete matching marker exits neutrally, preserving the active installation phase. Foreign or invalid guards and real write errors publish installation failure. The installer child never starts an extra Apple daemon; it terminates while the Full wrapper preserves the original one. The local losing-claim fixture publishes a complete record between checks; the low-level create-before-printf window is not claimed to be a general concurrent-race solution.
With mounted run-owned state, `fail()`, nonzero `startosinstall` and TERM/INT atomically publish a token-bound `installation-failed` phase for the next host poll, preserving the erase guard. The host observes Full bootstrap failure even before the install permit. Upstream `startosinstall`, USR1 bootstrap staging, Setup Assistant/admin packages and byte-for-byte staging checks remain in use. Installer reboots preserve the same QEMU process, disk, NVRAM and share. The read-only Recovery media stays attached. There is no automatic container restart or erase retry.
The existing firstboot LaunchDaemon invokes `macos-native-firstboot.sh` before staging cleanup. This Bash seam is required because the guest has Apple boot tools but no .NET SDK yet. It proves installed APFS `/` maps through one APFS container and physical store to the same owned 64-GiB whole disk, mounts the state share, installs a compatible Apple CLT catalog label through headless `softwareupdate`, and verifies the CLT package/compiler. It records the actual `xcrun` SDK version/path and compiles and runs a macOS 13-targeted smoke program importing AppKit, AVFoundation, ScreenCaptureKit, EventKit and WebKit. CLT compatibility is established by these actual compiler/framework gates, rather than a guessed catalog version. There is no GUI fallback, Apple account or new secret. `macos-native-disk-guard.sh` holds the shared pre-.NET Apple disk/IORegistry check. The pinned upstream Python UDIF patcher remains the image-format runtime binding; exact patch matches and compressed-slot checks fail closed.
After verifying and extracting the SDK on the guest's own APFS work directory, `MacOsNativeGuest.cs` validates payload hashes, safe Git tar paths and PAX commit, then restores/builds/tests `net10.0` with `TZ=Europe/Berlin`. It records the actual SDK/compiler environment and requires installed macOS 13+ x86_64 with guest root identity. It requires fresh outputs for all four Swift helpers, actual Mach-O/x86_64 tools output and strict audio-app codesign verification. Only fresh TRX with 577 total/executed/passed results, zero failures/skips and all five named macOS tests explicitly passed is accepted. TRX SHA-256 uses the same raw-byte snapshot as parsing, including any UTF-8 BOM. Binary minima and local parser fixtures do not demonstrate .NET vendor support or installed runtime compatibility.
The Full outer deadline is 172 minutes; the workflow declares 180 minutes within the existing three-hour server limit, leaving time for evidence and owned-resource cleanup. Independent budgets are Recovery 40 minutes, installer 80, firstboot/CLT 30 and guest checks/restore/build/tests 25; the outer deadline also bounds their combined runtime and preparation. The same 4-GiB/two-CPU guest and 6-GiB container remain, with `ALLOCATE=N`. Full execution checks 32 GiB of existing Docker free space before downloads/boot and 8 GiB of guest free space before toolchain work. Insufficient resources, networking, Apple catalog availability, disk ownership, installer progress or test proof fail without changing infrastructure.
Artifacts include source/SDK hashes, pinned boot patches, installer/Apple/firstboot logs, CLT SDK identity, installed-root/disk identity with APFS mapping plists, native tool logs, guest phase and Full-result receipts, helper hashes and binary-preserved TRX. Polling prints a bounded heartbeat each elapsed minute with phase/time/budget, liveness and readiness. Cleanup retains exact saved resource ID/ownership checks through `finally` and workflow `always()`; only the owned container/image/anonymous volume are removed. Installed files disappear with that volume; retained CI evidence remains outside it. Shared Docker build cache is not pruned.
Local `--validate --full` generates source/fixture evidence and executes the generated installer guard and complete Full wrapper with harmless filesystem/mount, child-process and Apple-exec boundary fixtures. It covers owned/foreign/invalid/write-failed installer and probe markers, restart, first-child failure and delayed/missing/foreign-owner shares, plus all four positive/twelve negative Recovery image cases and retained resource-snapshot checks. Its independent C# CRC32 reader validates fixture readback while the existing pinned Python UDIF binding performs the patch. It starts no Docker or VM, erases no disk, installs no OS/toolchain, builds no application and runs no native test. Bash syntax, synthetic disk parser and receipt tests do not qualify actual Apple exec, guest storage or native CI. Read-only `--compression-chunk` evidence applies only to its retained Recovery chunk, not the new Full wrapper or a different downloaded macOS image. This remains a locally prepared candidate until actual remote guests satisfy every native gate.
File diff suppressed because it is too large Load Diff
+553
View File
@@ -0,0 +1,553 @@
#:property PublishAot=false
using System.Diagnostics;
using System.Formats.Tar;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
// Installed-guest CI slice. --validate never invokes macOS, dotnet build/test, or a VM.
return await NativeGuest.Execute(args);
static class NativeGuest
{
const string SdkVersion = "10.0.401";
const string SdkSha512 = "33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0";
const int ExpectedTests = 577;
const int MaximumLogBytes = 8 * 1024 * 1024;
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
static readonly string[] RequiredNativeTests =
[
"MeetingAssistant.Tests.MacOsMeetingAudioSourceTests.NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant",
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.MacOsCapabilityEndpointReportsEnabledRealProviders",
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures",
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperCropsPngUsingOcrPixelCoordinates",
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied"
];
static readonly string[] NativeNames =
[
"MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture",
"macos-desktop-controls", "macos-meeting-integrations", "macos-meeting-assistant-launcher"
];
public static async Task<int> Execute(string[] args)
{
if (args.Length == 0 || args.SequenceEqual(["--help"]))
{
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeGuest.cs -- --validate [--archive <source.tar> --source-commit <SHA>] | --run --state /Volumes/installstate --work /private/var/tmp/meeting-assistant-native-<runToken>");
return 0;
}
try
{
if (args.SequenceEqual(["--validate"]) || args.Length == 5 && args[0] == "--validate" && args[1] == "--archive" && args[3] == "--source-commit")
{
ValidateContracts();
if (args.Length == 5)
{
if (!Hex(args[4], 40)) throw new ArgumentException("Source commit must be the full lowercase Git SHA.");
using var archive = File.OpenRead(Path.GetFullPath(args[2]));
ValidateArchive(archive, args[4]);
}
Console.WriteLine("Guest payload, safe Git tar, fresh TRX and native receipt contracts passed; no native execution occurred.");
return 0;
}
if (args.Length != 5 || args[0] != "--run" || args[1] != "--state" || args[3] != "--work")
throw new ArgumentException("Choose --validate or --run --state <mounted9pdir> --work <ownedAPFSdir>.");
return await Run(Path.GetFullPath(args[2]), Path.GetFullPath(args[4]));
}
catch (Exception exception)
{
Console.Error.WriteLine(exception.Message);
return 1;
}
}
static async Task<int> Run(string state, string work)
{
if (!OperatingSystem.IsMacOS() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
throw new InvalidOperationException("--run is restricted to the installed macOS x86_64 guest.");
RequireNoLinks(state);
RequireNoLinks(work);
var manifestPath = Path.Combine(state, "payload.json");
RequireNoLinks(manifestPath);
var payload = ReadPayload(File.ReadAllText(manifestPath));
if (work != "/private/var/tmp/meeting-assistant-native-" + payload.RunToken || !Directory.Exists(work))
throw new InvalidOperationException("Guest work directory does not match this run's owned APFS location.");
var owner = Path.Combine(work, "run.owner");
RequireNoLinks(owner);
if (File.ReadAllText(owner).TrimEnd('\r', '\n') != payload.RunToken)
throw new InvalidOperationException("Guest work directory has a different run owner.");
var started = DateTimeOffset.UtcNow;
var summary = new TestSummary(0, 0, 0, 0, 0, []);
var native = new List<NativeArtifact>();
var osVersion = "";
var architecture = "";
var actualSdk = "";
var trxSha256 = "";
var audioCodeSignExit = -1;
var success = false;
var reason = "";
var logs = Path.Combine(state, "guest-logs");
var results = Path.Combine(state, "test-results");
var source = Path.Combine(work, "source");
var dotnet = Path.Combine(work, "dotnet", "dotnet");
// Guest checks/restore/build/tests: 25 minutes within the host's 172-minute total.
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(25));
using var signal = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); });
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
Console.CancelKeyPress += cancelHandler;
void Phase(string phase, string stage) => Save(Path.Combine(state, "guest-phase.json"), new { token = payload.RunToken, phase, stage, updatedUtc = DateTimeOffset.UtcNow });
async Task<CommandResult> Cmd(string executable, string[] arguments, string label, bool requireSuccess = true) =>
await Command(executable, arguments, source, work, logs, label, deadline.Token, requireSuccess);
try
{
RequireAbsent(Path.Combine(state, "full-result.json"));
RequireAbsent(logs);
RequireAbsent(results);
RequireAbsent(source);
Directory.CreateDirectory(logs);
Directory.CreateDirectory(results);
foreach (var directory in new[] { "home", "packages", "tmp" })
{
RequireNoLinks(Path.Combine(work, directory));
Directory.CreateDirectory(Path.Combine(work, directory));
}
Phase("tests-running", "installed-guest-checks");
osVersion = (await Cmd("/usr/bin/sw_vers", ["-productVersion"], "os-version")).Output.Trim();
architecture = (await Cmd("/usr/bin/uname", ["-m"], "architecture")).Output.Trim();
var uid = (await Cmd("/usr/bin/id", ["-u"], "uid")).Output.Trim();
RequirePlatform(osVersion, architecture, uid);
foreach (var volume in new[] { ("/", "system-volume"), (work, "work-volume") })
RequireApfs((await Cmd("/usr/sbin/diskutil", ["info", "-plist", volume.Item1], volume.Item2)).Output);
await Cmd("/usr/bin/xcode-select", ["-p"], "clt-location");
await Cmd("/usr/sbin/pkgutil", ["--pkg-info", "com.apple.pkg.CLTools_Executables"], "clt-package");
await Cmd("/usr/bin/xcrun", ["swiftc", "--version"], "swift-version");
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-version"], "apple-sdk-version");
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], "apple-sdk-path");
RequireNoLinks(dotnet);
actualSdk = (await Cmd(dotnet, ["--version"], "sdk-version")).Output.Trim();
var sdkInfo = (await Cmd(dotnet, ["--info"], "sdk-info")).Output;
if (actualSdk != SdkVersion || !Regex.IsMatch(sdkInfo, @"(?m)^\s*Architecture:\s*x64\s*$"))
throw new InvalidOperationException("Guest .NET SDK is not the pinned 10.0.401/x64 toolchain.");
Phase("tests-running", "payload-verification");
var archive = Path.Combine(state, "source.tar");
var sdkArchive = Path.Combine(state, "sdk.tar.gz");
RequireNoLinks(archive);
RequireNoLinks(sdkArchive);
if (await HashFile(archive, false, deadline.Token) != payload.ArchiveSha256 || await HashFile(sdkArchive, true, deadline.Token) != payload.SdkSha512)
throw new InvalidOperationException("Guest payload hash does not match the pinned manifest.");
using (var stream = File.OpenRead(archive)) ValidateArchive(stream, payload.SourceCommit);
// All members were checked before native tar can write anything; the destination is new.
Directory.CreateDirectory(source);
await Cmd("/usr/bin/tar", ["-xf", archive, "-C", source], "source-extraction");
var project = Path.Combine(source, "MeetingAssistant.Tests", "MeetingAssistant.Tests.csproj");
if (!File.Exists(project)) throw new InvalidOperationException("Source archive lacks the test project.");
var nativeRoot = Path.Combine(source, "MeetingAssistant", "bin", "Release", "net10.0", "Native");
RequireAbsent(nativeRoot);
Phase("tests-running", "restore");
await Cmd(dotnet, ["restore", project, "-p:EnableWindowsTargeting=true", "-p:TargetFramework=net10.0"], "restore");
Phase("tests-running", "build");
var buildStarted = DateTimeOffset.UtcNow;
await Cmd(dotnet, ["build", project, "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true"], "build");
Phase("tests-running", "native-artifacts");
foreach (var name in NativeNames)
{
var path = Path.Combine(nativeRoot, name);
RequireNoLinks(path);
RequireFreshFile(path, buildStarted);
var fileOutput = (await Cmd("/usr/bin/file", ["-b", path], "native-file-" + native.Count)).Output;
var arch = (await Cmd("/usr/bin/xcrun", ["lipo", "-archs", path], "native-architecture-" + native.Count)).Output.Trim();
RequireNativeArtifact(fileOutput, arch);
native.Add(new(name, await HashFile(path, false, deadline.Token), arch));
}
var signing = await Cmd("/usr/bin/codesign", ["--verify", "--deep", "--strict", Path.Combine(nativeRoot, "MeetingAssistantAudioCapture.app")], "audio-code-sign", false);
audioCodeSignExit = signing.ExitCode;
if (audioCodeSignExit != 0) throw new InvalidOperationException("Fresh audio app did not pass strict code-signature verification.");
Phase("tests-running", "test");
var testStarted = DateTimeOffset.UtcNow;
await Cmd(dotnet, ["test", project, "--no-build", "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true", "--logger", "trx;LogFileName=native.trx", "--results-directory", results], "test");
var trxPath = Path.Combine(results, "native.trx");
RequireNoLinks(trxPath);
RequireFreshFile(trxPath, testStarted, 16 * 1024 * 1024);
var trxBytes = File.ReadAllBytes(trxPath);
summary = ValidateTrx(trxBytes, payload.ExpectedTests, testStarted);
trxSha256 = Convert.ToHexStringLower(SHA256.HashData(trxBytes));
success = true;
}
catch (Exception exception)
{
reason = exception is OperationCanceledException ? "The explicit 25-minute guest deadline or cancellation was reached." : exception.Message;
if (reason.Length > 4096) reason = reason[..4096];
Console.Error.WriteLine(reason);
}
finally
{
Console.CancelKeyPress -= cancelHandler;
var result = new FullResult(payload.RunToken, success, payload.SourceCommit, payload.ArchiveSha256, osVersion, architecture, actualSdk, payload.ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, native.ToArray(), audioCodeSignExit, trxSha256, reason, started, DateTimeOffset.UtcNow);
if (success)
{
try { ValidateResult(result, payload); }
catch (InvalidOperationException exception)
{
success = false;
result = result with { Success = false, Reason = exception.Message };
}
}
Save(Path.Combine(state, "full-result.json"), result);
Phase(success ? "tests-passed" : "tests-failed", "complete");
}
Console.WriteLine(success ? "Native macOS guest build, signed helpers and all 577 tests passed without skips." : "Native guest validation failed; full-result.json and bounded logs retain the evidence.");
return success ? 0 : 1;
}
static Payload ReadPayload(string json)
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Object || document.RootElement.EnumerateObject().Select(property => property.Name).Distinct(StringComparer.Ordinal).Count() != document.RootElement.EnumerateObject().Count())
throw new InvalidOperationException("Payload manifest must contain one unambiguous JSON object.");
var payload = JsonSerializer.Deserialize<Payload>(json, JsonOptions) ?? throw new InvalidOperationException("Missing guest payload manifest.");
if (!Hex(payload.RunToken, 32) || !Hex(payload.SourceCommit, 40) || !Hex(payload.ArchiveSha256, 64) || payload.SdkVersion != SdkVersion || payload.SdkSha512 != SdkSha512 || payload.ExpectedTests != ExpectedTests)
throw new InvalidOperationException("Guest payload identity, SDK pin or expected test count is invalid.");
return payload;
}
static void ValidateArchive(Stream stream, string commit)
{
using var reader = new TarReader(stream, leaveOpen: true);
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var commits = new List<string>();
long totalBytes = 0;
while (reader.GetNextEntry() is { } entry)
{
if (entry is PaxGlobalExtendedAttributesTarEntry global)
{
if (global.GlobalExtendedAttributes.TryGetValue("comment", out var value)) commits.Add(value);
if (global.GlobalExtendedAttributes.Keys.Any(key => key is "path" or "linkpath"))
throw new InvalidOperationException("Global tar path overrides are not supported.");
continue;
}
if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory))
throw new InvalidOperationException("Source tar contains a link or unsupported entry type: " + entry.Name);
var name = entry.Name.TrimEnd('/');
if (name.Length == 0 || name.StartsWith('/') || name.Contains('\\') || name.Contains('\0') || name.Split('/').Any(part => part.Length == 0 || part is "." or ".." or ".git" or "bin" or "obj") || !names.Add(name))
throw new InvalidOperationException("Source tar path is unsafe, repeated or contains generated output: " + entry.Name);
totalBytes = checked(totalBytes + entry.Length);
if (names.Count > 100_000 || totalBytes > 2L * 1024 * 1024 * 1024)
throw new InvalidOperationException("Source tar exceeds its explicit entry/size budget.");
}
if (names.Count == 0 || commits.Count != 1 || commits[0] != commit || !names.Contains("MeetingAssistant.Tests/MeetingAssistant.Tests.csproj"))
throw new InvalidOperationException("Source tar does not prove its exact Git commit and test project.");
}
static TestSummary ValidateTrx(string xml, int expected, DateTimeOffset testStarted) => ValidateTrx(Encoding.UTF8.GetBytes(xml), expected, testStarted);
static TestSummary ValidateTrx(byte[] xml, int expected, DateTimeOffset testStarted)
{
var document = ParseXml(xml);
var root = document.Root ?? throw new InvalidOperationException("Empty TRX.");
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
if (root.Name != ns + "TestRun") throw new InvalidOperationException("Unexpected TRX namespace or root.");
var times = root.Element(ns + "Times") ?? throw new InvalidOperationException("TRX lacks execution timestamps.");
if (!DateTimeOffset.TryParse((string?)times.Attribute("start"), out var start) || !DateTimeOffset.TryParse((string?)times.Attribute("finish"), out var finish) || start < testStarted.AddSeconds(-2) || finish < start || finish > DateTimeOffset.UtcNow.AddSeconds(30))
throw new InvalidOperationException("TRX belongs to a stale or invalid test execution.");
var resultSummary = root.Element(ns + "ResultSummary") ?? throw new InvalidOperationException("TRX lacks a final result summary.");
if ((string?)resultSummary.Attribute("outcome") != "Completed") throw new InvalidOperationException("TRX test run did not complete.");
var counters = resultSummary.Element(ns + "Counters") ?? throw new InvalidOperationException("TRX lacks final counters.");
int Count(string name) => int.TryParse((string?)counters.Attribute(name), out var value) && value >= 0 ? value : throw new InvalidOperationException("TRX lacks a valid counter: " + name);
var total = Count("total");
var executed = Count("executed");
var passed = Count("passed");
var failed = Count("failed");
var notExecuted = Count("notExecuted");
foreach (var name in new[] { "error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected", "inProgress", "pending" })
if (counters.Attribute(name) is not null && Count(name) != 0) throw new InvalidOperationException("TRX contains an incomplete or unsuccessful execution: " + name);
if (expected != ExpectedTests || total != expected || executed != expected || passed != expected || failed != 0 || notExecuted != 0)
throw new InvalidOperationException($"Native TRX must prove {expected} total/executed/passed tests, zero failures and zero skips; got {total}/{executed}/{passed}/{failed}/{notExecuted}.");
var definitions = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var unit in root.Element(ns + "TestDefinitions")?.Elements(ns + "UnitTest") ?? [])
{
var method = unit.Element(ns + "TestMethod") ?? throw new InvalidOperationException("TRX test definition lacks its method identity.");
var className = ((string?)method.Attribute("className") ?? "").Split(',')[0].Trim();
var methodName = (string?)method.Attribute("name") ?? "";
var id = (string?)unit.Attribute("id") ?? "";
if (id.Length == 0 || className.Length == 0 || methodName.Length == 0 || !definitions.TryAdd(id, methodName.StartsWith(className + ".", StringComparison.Ordinal) ? methodName : className + "." + methodName))
throw new InvalidOperationException("TRX contains an ambiguous test definition.");
}
var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? [];
var executionIds = new HashSet<string>(StringComparer.Ordinal);
var testIds = new HashSet<string>(StringComparer.Ordinal);
var native = new List<string>();
foreach (var result in results)
{
var id = (string?)result.Attribute("testId") ?? "";
var executionId = (string?)result.Attribute("executionId") ?? "";
if ((string?)result.Attribute("outcome") != "Passed" || executionId.Length == 0 || !executionIds.Add(executionId) || !testIds.Add(id) || !definitions.TryGetValue(id, out var identity))
throw new InvalidOperationException("TRX has a missing, duplicate or non-passed execution.");
if (RequiredNativeTests.Contains(identity, StringComparer.Ordinal)) native.Add(identity);
}
if (definitions.Count != expected || results.Length != expected || native.Count != RequiredNativeTests.Length || !native.Order().SequenceEqual(RequiredNativeTests.Order()))
throw new InvalidOperationException("TRX does not prove every expected test definition exactly once and the five explicit native macOS tests.");
return new(total, executed, passed, failed, notExecuted, native.ToArray());
}
static void ValidateResult(FullResult result, Payload payload)
{
if (result.Token != payload.RunToken || !result.Success || result.SourceCommit != payload.SourceCommit || result.ArchiveSha256 != payload.ArchiveSha256 || !Version.TryParse(result.OsVersion, out var version) || version.Major < 13 || result.Architecture != "x86_64" || result.SdkVersion != SdkVersion || result.ExpectedTests != ExpectedTests || result.Total != ExpectedTests || result.Executed != ExpectedTests || result.Passed != ExpectedTests || result.Failed != 0 || result.NotExecuted != 0 || !result.NativeTests.Order().SequenceEqual(RequiredNativeTests.Order()) || result.AudioCodeSignExit != 0 || !Hex(result.TrxSha256, 64) || result.NativeArtifacts.Length != NativeNames.Length || !result.NativeArtifacts.Select(artifact => artifact.Name).Order().SequenceEqual(NativeNames.Order()) || result.NativeArtifacts.Any(artifact => artifact.Architecture != "x86_64" || !Hex(artifact.Sha256, 64)) || result.CompletedUtc < result.StartedUtc || result.CompletedUtc - result.StartedUtc > TimeSpan.FromMinutes(25).Add(TimeSpan.FromSeconds(15)) || result.CompletedUtc > DateTimeOffset.UtcNow.AddSeconds(30) || result.CompletedUtc < DateTimeOffset.UtcNow.AddMinutes(-1) || result.Reason.Length != 0)
throw new InvalidOperationException("Native guest receipt does not prove this source, toolchain, signed artifacts and all expected tests.");
}
static void RequirePlatform(string version, string architecture, string uid)
{
if (!Version.TryParse(version, out var parsed) || parsed.Major < 13 || architecture != "x86_64" || uid != "0")
throw new InvalidOperationException("Native build requires installed macOS 13+/x86_64 running as root.");
}
static void RequireApfs(string xml)
{
var elements = ParseXml(xml).Root?.Element("dict")?.Elements().ToArray() ?? [];
var type = elements.Select((element, index) => (element, index)).FirstOrDefault(pair => pair.element.Name == "key" && pair.element.Value == "FilesystemType");
if (type.element is null || type.index + 1 >= elements.Length || elements[type.index + 1].Name != "string" || elements[type.index + 1].Value != "apfs")
throw new InvalidOperationException("Native build must run from the installed APFS system and owned APFS work volume.");
}
static void RequireNativeArtifact(string fileOutput, string architecture)
{
if (!fileOutput.Contains("Mach-O", StringComparison.Ordinal) || !fileOutput.Contains("x86_64", StringComparison.Ordinal) || architecture != "x86_64")
throw new InvalidOperationException("Native helper is not a Mach-O executable containing exactly x86_64.");
}
static void RequireAbsent(string path)
{
if (Path.Exists(path) || GetAttributesSafe(path)?.HasFlag(FileAttributes.ReparsePoint) == true)
throw new InvalidOperationException("Fresh guest execution refuses pre-existing output: " + path);
}
static void RequireFreshFile(string path, DateTimeOffset started, long maximumBytes = long.MaxValue)
{
RequireNoLinks(path);
var file = new FileInfo(path);
if (!file.Exists || file.Length == 0 || file.Length > maximumBytes || file.LastWriteTimeUtc < started.UtcDateTime.AddSeconds(-2))
throw new InvalidOperationException("Expected a fresh, nonempty, bounded output from this execution: " + path);
}
static void RequireNoLinks(string path)
{
for (var current = Path.GetFullPath(path); current is not null; current = Path.GetDirectoryName(current))
if (GetAttributesSafe(current)?.HasFlag(FileAttributes.ReparsePoint) == true)
throw new InvalidOperationException("Guest ownership/extraction refuses a symbolic link: " + current);
}
static FileAttributes? GetAttributesSafe(string path)
{
try { return File.GetAttributes(path); }
catch (FileNotFoundException) { return null; }
catch (DirectoryNotFoundException) { return null; }
}
static XDocument ParseXml(string xml) => ParseXml(Encoding.UTF8.GetBytes(xml));
static XDocument ParseXml(byte[] xml)
{
using var stream = new MemoryStream(xml, writable: false);
using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null, MaxCharactersInDocument = 16 * 1024 * 1024 });
return XDocument.Load(reader);
}
static bool Hex(string? value, int length) => value is not null && Regex.IsMatch(value, "^[0-9a-f]{" + length + "}$");
static async Task<string> HashFile(string path, bool sha512, CancellationToken cancellation)
{
using var stream = File.OpenRead(path);
var hash = sha512 ? await SHA512.HashDataAsync(stream, cancellation) : await SHA256.HashDataAsync(stream, cancellation);
return Convert.ToHexStringLower(hash);
}
static void Save(string path, object value)
{
RequireNoLinks(path);
var temporary = path + ".tmp";
RequireNoLinks(temporary);
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
File.Move(temporary, path, overwrite: true);
}
static async Task<CommandResult> Command(string executable, string[] arguments, string source, string work, string logs, string label, CancellationToken cancellation, bool requireSuccess)
{
Console.WriteLine("[native-guest] " + label);
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var token = commandCancellation.Token;
var start = new ProcessStartInfo(executable) { WorkingDirectory = Directory.Exists(source) ? source : work, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
foreach (var argument in arguments) start.ArgumentList.Add(argument);
start.Environment.Clear();
foreach (var pair in new Dictionary<string, string>
{
["PATH"] = Path.Combine(work, "dotnet") + ":/usr/bin:/bin:/usr/sbin:/sbin",
["TMPDIR"] = Path.Combine(work, "tmp"),
["DOTNET_ROOT"] = Path.Combine(work, "dotnet"), ["DOTNET_CLI_HOME"] = Path.Combine(work, "home"),
["NUGET_PACKAGES"] = Path.Combine(work, "packages"), ["TZ"] = "Europe/Berlin",
["LANG"] = "en_US.UTF-8", ["LC_ALL"] = "en_US.UTF-8", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
["DOTNET_NOLOGO"] = "1", ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", ["MSBUILDDISABLENODEREUSE"] = "1"
}) start.Environment[pair.Key] = pair.Value;
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start guest command: " + label);
long capturedBytes = 0;
async Task<string> Read(StreamReader reader, string stream)
{
var captured = new StringBuilder();
var buffer = new char[8192];
using var log = new StreamWriter(Path.Combine(logs, label + "." + stream + ".log"), false, new UTF8Encoding(false));
while (true)
{
var count = await reader.ReadAsync(buffer.AsMemory(), token);
if (count == 0) break;
if (Interlocked.Add(ref capturedBytes, Encoding.UTF8.GetByteCount(buffer.AsSpan(0, count))) > MaximumLogBytes)
{
commandCancellation.Cancel();
throw new InvalidOperationException(label + " exceeded its combined 8-MiB output budget.");
}
captured.Append(buffer, 0, count);
await log.WriteAsync(buffer.AsMemory(0, count), token);
await log.FlushAsync(token);
}
return captured.ToString();
}
var stdout = Read(process.StandardOutput, "stdout");
var stderr = Read(process.StandardError, "stderr");
try
{
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token));
var result = new CommandResult(process.ExitCode, await stdout, await stderr);
if (requireSuccess && result.ExitCode != 0)
throw new InvalidOperationException($"{label} exited {result.ExitCode}: {result.Error[..Math.Min(result.Error.Length, 1500)]}");
return result;
}
catch
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
using var killDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await process.WaitForExitAsync(killDeadline.Token); } catch (OperationCanceledException) { }
throw;
}
}
static void ValidateContracts()
{
var payload = new Payload(new string('a', 32), new string('b', 40), new string('c', 64), SdkVersion, SdkSha512, ExpectedTests);
var json = JsonSerializer.Serialize(payload, JsonOptions);
ReadPayload(json);
Reject(() => ReadPayload(json.Replace(SdkVersion, "10.0.100", StringComparison.Ordinal)));
Reject(() => ReadPayload(json.Replace(SdkSha512, new string('d', 128), StringComparison.Ordinal)));
Reject(() => ReadPayload(json.Replace("577", "572", StringComparison.Ordinal)));
Reject(() => ReadPayload(json.Replace(payload.RunToken, "stale", StringComparison.Ordinal)));
Reject(() => ReadPayload(json.Replace(payload.ArchiveSha256, "invalid", StringComparison.Ordinal)));
RequirePlatform("13.6.1", "x86_64", "0");
Reject(() => RequirePlatform("12.6.1", "x86_64", "0"));
Reject(() => RequirePlatform("14.6.1", "arm64", "0"));
Reject(() => RequirePlatform("14.6.1", "x86_64", "501"));
RequireApfs("<plist><dict><key>FilesystemType</key><string>apfs</string></dict></plist>");
Reject(() => RequireApfs("<plist><dict><key>FilesystemType</key><string>hfs</string></dict></plist>"));
RequireNativeArtifact("Mach-O 64-bit executable x86_64", "x86_64");
Reject(() => RequireNativeArtifact("Mach-O universal binary x86_64 arm64", "x86_64 arm64"));
Reject(() => RequireNativeArtifact("ELF 64-bit executable x86_64", "x86_64"));
using (var archive = FixtureArchive(payload.SourceCommit)) ValidateArchive(archive, payload.SourceCommit);
foreach (var path in new[] { "../escape", "/absolute", "safe/../escape", "safe\\escape", "MeetingAssistant/bin/stale" })
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, path); ValidateArchive(archive, payload.SourceCommit); });
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, "link", true); ValidateArchive(archive, payload.SourceCommit); });
Reject(() => { using var archive = FixtureArchive(new string('d', 40)); ValidateArchive(archive, payload.SourceCommit); });
var started = DateTimeOffset.UtcNow.AddMinutes(-1);
var fixture = FixtureTrx(started);
var summary = ValidateTrx(fixture.ToString(), ExpectedTests, started);
var plainTrx = Encoding.UTF8.GetBytes(fixture.ToString());
var bomTrx = new byte[] { 0xef, 0xbb, 0xbf }.Concat(plainTrx).ToArray();
ValidateTrx(bomTrx, ExpectedTests, started);
if (SHA256.HashData(plainTrx).SequenceEqual(SHA256.HashData(bomTrx))) throw new InvalidOperationException("TRX raw-byte hashing discarded its BOM.");
Reject(() => ValidateTrx(fixture.ToString(), ExpectedTests, started.AddMinutes(2)));
XNamespace ns = fixture.Root!.Name.Namespace;
var skipped = new XDocument(fixture);
skipped.Descendants(ns + "UnitTestResult").First().SetAttributeValue("outcome", "NotExecuted");
Reject(() => ValidateTrx(skipped.ToString(), ExpectedTests, started));
var missingNative = new XDocument(fixture);
missingNative.Descendants(ns + "TestMethod").First().SetAttributeValue("name", "ManagedReplacement");
Reject(() => ValidateTrx(missingNative.ToString(), ExpectedTests, started));
var duplicate = new XDocument(fixture);
duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0");
Reject(() => ValidateTrx(duplicate.ToString(), ExpectedTests, started));
var repeatedManaged = new XDocument(fixture);
repeatedManaged.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("testId", "test-5");
Reject(() => ValidateTrx(repeatedManaged.ToString(), ExpectedTests, started));
var unexecutedDefinition = new XDocument(fixture);
var extraDefinition = new XElement(unexecutedDefinition.Descendants(ns + "UnitTest").Last());
extraDefinition.SetAttributeValue("id", "unexecuted-test");
unexecutedDefinition.Root!.Element(ns + "TestDefinitions")!.Add(extraDefinition);
Reject(() => ValidateTrx(unexecutedDefinition.ToString(), ExpectedTests, started));
var missingDefinition = new XDocument(fixture);
missingDefinition.Descendants(ns + "UnitTest").Last().Remove();
Reject(() => ValidateTrx(missingDefinition.ToString(), ExpectedTests, started));
var theoryRows = new XDocument(fixture);
foreach (var method in theoryRows.Descendants(ns + "TestMethod").Skip(RequiredNativeTests.Length).Take(2))
method.SetAttributeValue("name", "TheoryWithDistinctRowIds");
ValidateTrx(theoryRows.ToString(), ExpectedTests, started);
var counters = new XDocument(fixture);
counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572");
Reject(() => ValidateTrx(counters.ToString(), ExpectedTests, started));
var aborted = new XDocument(fixture);
aborted.Descendants(ns + "ResultSummary").Single().SetAttributeValue("outcome", "Aborted");
Reject(() => ValidateTrx(aborted.ToString(), ExpectedTests, started));
var artifacts = NativeNames.Select(name => new NativeArtifact(name, new string('d', 64), "x86_64")).ToArray();
var result = new FullResult(payload.RunToken, true, payload.SourceCommit, payload.ArchiveSha256, "13.6.1", "x86_64", SdkVersion, ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, artifacts, 0, new string('e', 64), "", started, DateTimeOffset.UtcNow);
ValidateResult(result, payload);
foreach (var invalid in new[] { result with { Token = new string('f', 32) }, result with { Success = false }, result with { SourceCommit = new string('f', 40) }, result with { ArchiveSha256 = new string('f', 64) }, result with { NotExecuted = 5 }, result with { AudioCodeSignExit = 1 }, result with { NativeTests = RequiredNativeTests[..4] }, result with { NativeArtifacts = artifacts[..3] }, result with { NativeArtifacts = [artifacts[0] with { Architecture = "arm64" }, .. artifacts[1..]] }, result with { TrxSha256 = "" }, result with { SdkVersion = "10.0.100" }, result with { OsVersion = "12.6.1" }, result with { StartedUtc = started.AddHours(-1) }, result with { StartedUtc = started.AddHours(-1), CompletedUtc = started.AddHours(-1).AddSeconds(1) } })
Reject(() => ValidateResult(invalid, payload));
var temporary = Path.Combine(OperatingSystem.IsMacOS() ? "/private/tmp" : Path.GetTempPath(), "meeting-assistant-guest-validation-" + Guid.NewGuid().ToString("N"));
try
{
RequireAbsent(temporary);
Directory.CreateDirectory(temporary);
Reject(() => RequireAbsent(temporary));
var file = Path.Combine(temporary, "fresh.trx");
File.WriteAllText(file, "fixture");
RequireFreshFile(file, started);
Reject(() => RequireFreshFile(file, started, 1));
File.SetLastWriteTimeUtc(file, started.UtcDateTime.AddMinutes(-5));
Reject(() => RequireFreshFile(file, started));
File.Delete(file);
}
finally { if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true); }
}
static MemoryStream FixtureArchive(string commit, string? extra = null, bool link = false)
{
var stream = new MemoryStream();
using (var writer = new TarWriter(stream, TarEntryFormat.Pax, leaveOpen: true))
{
writer.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary<string, string> { ["comment"] = commit }));
writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "MeetingAssistant.Tests/MeetingAssistant.Tests.csproj") { DataStream = new MemoryStream(Encoding.UTF8.GetBytes("<Project />")) });
if (extra is not null)
{
var entry = new PaxTarEntry(link ? TarEntryType.SymbolicLink : TarEntryType.RegularFile, extra);
if (link) entry.LinkName = "../outside";
else entry.DataStream = new MemoryStream([1]);
writer.WriteEntry(entry);
}
}
stream.Position = 0;
return stream;
}
static XDocument FixtureTrx(DateTimeOffset started)
{
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
var definitions = new XElement(ns + "TestDefinitions");
var results = new XElement(ns + "Results");
for (var index = 0; index < ExpectedTests; index++)
{
var identity = index < RequiredNativeTests.Length ? RequiredNativeTests[index] : "MeetingAssistant.Tests.ManagedTests.Test" + index;
var separator = identity.LastIndexOf('.');
definitions.Add(new XElement(ns + "UnitTest", new XAttribute("id", "test-" + index), new XElement(ns + "TestMethod", new XAttribute("className", identity[..separator] + ", MeetingAssistant.Tests"), new XAttribute("name", identity[(separator + 1)..]))));
results.Add(new XElement(ns + "UnitTestResult", new XAttribute("testId", "test-" + index), new XAttribute("executionId", "execution-" + index), new XAttribute("outcome", "Passed")));
}
return new XDocument(new XElement(ns + "TestRun", new XElement(ns + "Times", new XAttribute("start", started.ToString("O")), new XAttribute("finish", started.AddSeconds(1).ToString("O"))), definitions, results, new XElement(ns + "ResultSummary", new XAttribute("outcome", "Completed"), new XElement(ns + "Counters", new XAttribute("total", ExpectedTests), new XAttribute("executed", ExpectedTests), new XAttribute("passed", ExpectedTests), new XAttribute("failed", 0), new XAttribute("notExecuted", 0)))));
}
static void Reject(Action action)
{
try { action(); }
catch (InvalidOperationException) { return; }
throw new InvalidOperationException("Contract validation accepted an invalid or stale fixture.");
}
sealed record Payload(string RunToken, string SourceCommit, string ArchiveSha256, string SdkVersion, string SdkSha512, int ExpectedTests);
sealed record NativeArtifact(string Name, string Sha256, string Architecture);
sealed record TestSummary(int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests);
sealed record FullResult(string Token, bool Success, string SourceCommit, string ArchiveSha256, string OsVersion, string Architecture, string SdkVersion, int ExpectedTests, int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests, NativeArtifact[] NativeArtifacts, int AudioCodeSignExit, string TrxSha256, string Reason, DateTimeOffset StartedUtc, DateTimeOffset CompletedUtc);
sealed record CommandResult(int ExitCode, string Output, string Error);
}
+5
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Apple pre-.NET boot seam, sourced in Recovery and on installed first boot.
# A match requires the exact run-owned emulated serial, one whole writable 64-GiB disk.
verify_owned_disk() {
local disk="$1" state="$2" token="$3" info serial matches bytes
[[ "$disk" =~ ^/dev/disk[0-9]+$ && "$token" =~ ^[0-9a-f]{32}$ ]] || return 1
[ "$(cat "$state/run.owner" 2>/dev/null)" = "$token" ] || return 1
serial="${token:0:20}"
/usr/sbin/diskutil list physical > "$state/disk-list-current.log" 2>&1 || return 1
/usr/bin/grep -Eq "^$disk[[:space:]].*physical" "$state/disk-list-current.log" || return 1
/usr/sbin/diskutil info "$disk" > "$state/disk-info-current.log" 2>&1 || return 1
info=$(cat "$state/disk-info-current.log")
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*Whole:[[:space:]]*Yes' || return 1
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes' && return 1
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || return 1
bytes=$(printf '%s\n' "$info" | /usr/bin/sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p')
[ "$bytes" = 68719476736 ] || return 1
/usr/sbin/ioreg -r -c IOBlockStorageDevice -l -w 0 > "$state/disk-ownership-ioreg.log" 2>&1 || return 1
matches=$(/usr/bin/awk -v expected="$serial" -v disk="${disk#/dev/}" '
function finish_root() { if (serialCount == 1 && serial == expected && ownedDisk) found++ }
/^\+-o/ { finish_root(); serial=""; serialCount=0; ownedDisk=0 }
/"Serial Number"[[:space:]]*=[[:space:]]*"/ {
serialCount++; serial=$0; sub(/^.*"Serial Number"[[:space:]]*=[[:space:]]*"/, "", serial); sub(/".*$/, "", serial); sub(/[[:space:]]+$/, "", serial)
}
/"BSD Name"[[:space:]]*=[[:space:]]*"/ {
name=$0; sub(/^.*"BSD Name"[[:space:]]*=[[:space:]]*"/, "", name); sub(/".*$/, "", name)
if (name == disk) ownedDisk=1
}
END { finish_root(); print found+0 }
' "$state/disk-ownership-ioreg.log")
[ "$matches" = 1 ] || return 1
printf '[owned-disk] %s serial=%s bytes=%s\n' "$disk" "$serial" "$bytes"
}
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# Apple LaunchDaemon bootstrap before the guest .NET SDK exists.
# Runs only inside the isolated owned VM; installs CLT and expands the pinned SDK.
set -u
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
PROOF_TOKEN="${1:-}"
STATE_DIR="/Volumes/installstate"
WORK="/private/var/tmp/meeting-assistant-native-$PROOF_TOKEN"
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || exit 1
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || exit 1
exec >> "$STATE_DIR/firstboot.log" 2>&1
phase() {
printf '{"token":"%s","phase":"%s","updatedUtc":"%s"}\n' "$PROOF_TOKEN" "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATE_DIR/guest-phase.json.tmp"
mv -f "$STATE_DIR/guest-phase.json.tmp" "$STATE_DIR/guest-phase.json"
}
fail() { printf '[firstboot] ERROR: %s\n' "$1"; phase bootstrap-failed; exit 1; }
phase toolchain-installing
echo '[firstboot] verifying installed OS, APFS and owned physical store'
sw_vers; uname -a; id
[ "$(id -u)" = 0 ] && [ "$(uname -m)" = x86_64 ] || fail wrong_guest_platform
/usr/sbin/diskutil info -plist / > "$STATE_DIR/installed-root.plist" || fail root_diskutil_failed
[ "$(/usr/libexec/PlistBuddy -c 'Print :FilesystemType' "$STATE_DIR/installed-root.plist")" = apfs ] || fail root_is_not_installed_apfs
container=$(/usr/libexec/PlistBuddy -c 'Print :APFSContainerReference' "$STATE_DIR/installed-root.plist") || fail root_container_missing
/usr/sbin/diskutil apfs list -plist > "$STATE_DIR/apfs-containers.plist" || fail apfs_list_failed
index=0
physical=""
while reference=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:ContainerReference" "$STATE_DIR/apfs-containers.plist" 2>/dev/null); do
if [ "$reference" = "$container" ]; then
[ -z "$physical" ] || fail ambiguous_root_containers
physical=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:0:DeviceIdentifier" "$STATE_DIR/apfs-containers.plist") || fail physical_store_missing
/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:1" "$STATE_DIR/apfs-containers.plist" >/dev/null 2>&1 && fail multiple_physical_stores
fi
index=$((index + 1))
done
[[ "$physical" =~ ^disk[0-9]+s[0-9]+$ ]] || fail invalid_physical_store
/usr/sbin/diskutil info -plist "/dev/$physical" > "$STATE_DIR/physical-store.plist" || fail physical_store_info_failed
whole=$(/usr/libexec/PlistBuddy -c 'Print :ParentWholeDisk' "$STATE_DIR/physical-store.plist") || fail physical_parent_missing
. "$STATE_DIR/macos-native-disk-guard.sh"
verify_owned_disk "/dev/$whole" "$STATE_DIR" "$PROOF_TOKEN" || fail installed_root_is_not_the_owned_64g_disk
# The phase has an independent 30-minute watchdog; host outer deadline is 180 minutes.
parent=$$
(
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
# Toolchain watchdog: 30 minutes, also bounded by the host's 172-minute total.
sleep 1800 & sleeper=$!; wait "$sleeper"; kill -TERM "$parent" 2>/dev/null || :
) & watchdog=$!
clt_marker="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress"
trap 'rm -f "$clt_marker"; kill -TERM "$watchdog" 2>/dev/null || :; wait "$watchdog" 2>/dev/null || :' EXIT
trap 'fail toolchain_deadline_or_cancellation' TERM INT
[ ! -e "$WORK" ] || fail guest_work_directory_already_exists
mkdir -p "$WORK" || fail guest_work_directory_failed
printf '%s\n' "$PROOF_TOKEN" > "$WORK/run.owner" || fail guest_work_owner_failed
free_kib=$(df -Pk "$WORK" | awk 'NR==2 {print $4}')
[[ "$free_kib" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || fail insufficient_existing_guest_free_space
echo '[firstboot] installing a compatible Apple CLT catalog entry without a GUI'
touch "$clt_marker" || fail clt_marker_failed
/usr/sbin/softwareupdate -l > "$STATE_DIR/clt-catalog.log" 2>&1 || fail clt_catalog_failed
label=$(grep -B 1 -E 'Command Line Tools' "$STATE_DIR/clt-catalog.log" | awk -F'*' '/^ *\*/ {print $2}' | sed -e 's/^ *Label: //' -e 's/^ *//' | sort -V | tail -n 1)
[ -n "$label" ] || fail no_compatible_headless_clt_label
printf '[firstboot] selected CLT: %s\n' "$label"
/usr/sbin/softwareupdate -i "$label" > "$STATE_DIR/clt-install.log" 2>&1 || fail clt_install_failed
/usr/bin/xcode-select --switch /Library/Developer/CommandLineTools || fail clt_switch_failed
/usr/sbin/pkgutil --pkg-info=com.apple.pkg.CLTools_Executables || fail clt_receipt_failed
/usr/bin/xcrun --find swiftc || fail swiftc_missing
/usr/bin/xcrun swiftc --version || fail swiftc_version_failed
{
/usr/bin/xcrun --sdk macosx --show-sdk-version &&
/usr/bin/xcrun --sdk macosx --show-sdk-path
} > "$STATE_DIR/clt-sdk.log" 2>&1 || fail clt_sdk_identity_failed
printf 'import AppKit\nimport AVFoundation\nimport ScreenCaptureKit\nimport EventKit\nimport WebKit\nprint("native SDK ready")\n' > "$WORK/toolchain-smoke.swift"
/usr/bin/xcrun swiftc -target x86_64-apple-macos13.0 "$WORK/toolchain-smoke.swift" -o "$WORK/toolchain-smoke" || fail native_framework_compile_failed
"$WORK/toolchain-smoke" || fail native_framework_execution_failed
rm -f "$clt_marker"
echo '[firstboot] validating and expanding the pinned .NET SDK on owned APFS'
expected_sdk=33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0
actual_sdk=$(shasum -a 512 "$STATE_DIR/sdk.tar.gz" | awk '{print $1}')
[ "$actual_sdk" = "$expected_sdk" ] || fail sdk_hash_mismatch
mkdir "$WORK/dotnet" || fail sdk_directory_failed
tar -xzf "$STATE_DIR/sdk.tar.gz" -C "$WORK/dotnet" || fail sdk_extract_failed
[ "$("$WORK/dotnet/dotnet" --version)" = 10.0.401 ] || fail sdk_version_mismatch
# Guest C# owns build/test deadlines from this point; stop the CLT-only watchdog.
kill -TERM "$watchdog" 2>/dev/null || :
wait "$watchdog" 2>/dev/null || :
trap - TERM INT
"$WORK/dotnet/dotnet" run --file "$STATE_DIR/MacOsNativeGuest.cs" -- --run --state "$STATE_DIR" --work "$WORK"
result=$?
(( result == 0 )) || fail native_tests_failed
exit 0
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# Full-only pre-.NET seam. Claim one persistent owned probe before forking;
# launchd then execs the original Apple daemon, including on a real failure.
set -u
PROOF_TOKEN="@@PROOF_TOKEN@@"
STATE_DIR="/Volumes/installstate"
MARKER="$STATE_DIR/probe.started"
bootstrap_failed() {
printf '[full-bootstrap] ERROR: %s\n' "$1" >&2
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
printf '{"token":"%s","phase":"bootstrap-failed","reason":"%s"}\n' "$PROOF_TOKEN" "$1" > "$STATE_DIR/guest-phase.bootstrap.$$.tmp" &&
/bin/mv -f "$STATE_DIR/guest-phase.bootstrap.$$.tmp" "$STATE_DIR/guest-phase.json"
}
wait_for_owned_state() {
local count=0
while :; do
if [ -e "$STATE_DIR/run.owner" ] || [ -L "$STATE_DIR/run.owner" ]; then
[ -f "$STATE_DIR/run.owner" ] && [ ! -L "$STATE_DIR/run.owner" ] &&
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || {
bootstrap_failed foreign_state_owner
return 1
}
return 0
fi
if (( count >= 120 )); then
bootstrap_failed state_share_mount_timeout
return 1
fi
/sbin/mount_9p installstate >/dev/null 2>&1 || :
count=$((count + 1))
sleep 1
done
}
owns_probe_marker() {
local record extra
[ -f "$MARKER" ] && [ ! -L "$MARKER" ] || return 1
{
IFS= read -r record || return 1
if IFS= read -r extra || [ -n "$extra" ]; then return 1; fi
} < "$MARKER"
[ "$record" = "$PROOF_TOKEN:$source_commit" ]
}
claim_probe() {
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || { bootstrap_failed invalid_probe_token; return 1; }
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || { bootstrap_failed foreign_state_owner; return 1; }
source_commit=$(cat "$STATE_DIR/source.commit" 2>/dev/null) || { bootstrap_failed source_commit_missing; return 1; }
[[ "$source_commit" =~ ^[0-9a-f]{40}$ ]] || { bootstrap_failed source_commit_invalid; return 1; }
if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then
owns_probe_marker || { bootstrap_failed foreign_or_invalid_probe_marker; return 1; }
return 2
fi
# Keep partial/failed markers: an incomplete first start is an error, no retry.
if ! ( set -o noclobber; printf '%s:%s\n' "$PROOF_TOKEN" "$source_commit" > "$MARKER" ); then
bootstrap_failed probe_marker_write_failed
return 1
fi
owns_probe_marker || { bootstrap_failed probe_marker_incomplete; return 1; }
return 0
}
if wait_for_owned_state && claim_probe; then
(
/bin/bash "$STATE_DIR/readiness.sh"
child_exit=$?
(( child_exit == 0 )) || bootstrap_failed first_probe_child_failed
) &
fi
exec /usr/libexec/recoveryosd
+1 -1
View File
@@ -200,7 +200,7 @@ run_command version /usr/bin/sw_vers -productVersion
read_scalar || fail_probe product_version_invalid
os_version="$SCALAR"
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid
(( ${os_version%%.*} >= 14 )) || fail_probe unsupported_macos_version
(( ${os_version%%.*} >= 13 )) || fail_probe unsupported_macos_version
flush_outputs || finish false diagnostic_log_budget_exceeded
# Bound readiness independently of the host's 40-minute overall deadline.
+181
View File
@@ -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))