forked from Manuel/meeting-assistant
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6122be2cef | ||
|
|
9735db1cdc | ||
|
|
17fb74dd74 | ||
|
|
c01ae13185 |
@@ -1,4 +1,4 @@
|
||||
name: macOS 13 KVM Cryptex Recovery compatibility diagnostic
|
||||
name: macOS 14 TCG Recovery prerequisite diagnostic
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
macos-native-diagnostic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 95
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Probe macOS 13 Recovery with existing KVM and host CPU
|
||||
- name: Verify actual AVX2 emulation then probe macOS 14 Recovery
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||
|
||||
- name: Always clean up only this diagnostic's owned resources
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Native macOS build and tests on Ubuntu (experimental)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
macos-native-full:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout exact native CI candidate
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup .NET orchestration SDK
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
- name: Run owned macOS 14 TCG guest and all native tests
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||
- name: Always remove only this run's owned resources
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||
- name: Retain native build, signatures, TRX and guest receipts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: native-macos-full
|
||||
path: artifacts/native-macos-full/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
# Its manual workflow provides that evidence; the PR branch still runs all jobs.
|
||||
branches-ignore:
|
||||
- codex/macos-ci-kvm-compatibility
|
||||
- codex/macos-ci-tcg-supported
|
||||
- codex/macos-native-full-tcg
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -135,6 +137,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 14 TCG guest and all native tests
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||
- name: Always remove only this run's owned resources
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||
- name: Retain native build, signatures, TRX and guest receipts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: native-macos-full
|
||||
path: artifacts/native-macos-full/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
portable-build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -169,13 +169,13 @@ Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-e
|
||||
|
||||
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
|
||||
|
||||
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. No additional runner labels or host devices are required.
|
||||
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. After both jobs pass, the prepared workflow requires a native macOS job on the same existing runner label and Docker daemon, using software CPU emulation without host devices or added capabilities.
|
||||
|
||||
Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests requiring the native macOS environment report an explicit skip through `MacOsFact`; they must also be run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`. That build compiles and signs the helpers, and the suite checks packaging, helper self-tests, and native image cropping. Real microphone/system-audio capture and privacy permissions still require the operational checks described above.
|
||||
The Ubuntu managed-test job does not compile the Swift helpers or execute Apple frameworks; its native macOS tests report an explicit skip through `MacOsFact`. The prepared native job runs an owned macOS 14+ x86_64 guest under TCG on the existing Ubuntu Docker runner. Before downloading Recovery, the actual pinned QEMU binary must execute an AVX/AVX2 instruction probe. The full flow builds all four native helpers, verifies the audio app's signature, and requires all 577 tests to pass with zero skips, including all five native macOS tests. It has a 180-minute job limit with retained evidence and ownership-checked cleanup. Recovery, installation, toolchain operation and this CI path remain unqualified until actual remote guests produce every required receipt. Native tests can also run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`; real microphone/system-audio capture and privacy permissions still require the operational checks described above.
|
||||
|
||||
[Docker-OSX](https://github.com/sickcodes/Docker-OSX) runs a macOS VM rather than providing a Wine-style compatibility layer. Its launcher supports software emulation with `KVM=accel=tcg`, so KVM is not an absolute requirement. A supported .NET 10 guest needs macOS 14 or later plus the Swift build tools. The documented `auto` build downloads a preinstalled guest disk through `IMAGE_URL`; its documented ready-made tags and disk downloads were unavailable when checked on 2026-10-03. No verified native guest bootstrap is owned by this repository. CI validates source; it does not publish or deploy the workstation application.
|
||||
The separate manual `.gitea/workflows/macos-native-full.yaml` retains the same Full flow as a diagnostic entry point. CI validates source; it does not publish or deploy the workstation application.
|
||||
|
||||
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness through an unprivileged TCG guest on the existing Ubuntu Docker runner. It neither installs macOS nor runs application tests; its result is a prerequisite for a future native test job, not verification of macOS CI support.
|
||||
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) can isolate macOS startup and disk readiness. It neither installs macOS nor runs application tests. A green Recovery diagnostic alone does not verify macOS build/test CI support; each Full run requires fresh Recovery readiness for its own guest and disk before permitting installation.
|
||||
|
||||
## Operations And Limitations
|
||||
|
||||
|
||||
@@ -1,78 +1,72 @@
|
||||
# macOS 13 KVM/Cryptex compatibility diagnostic
|
||||
# macOS 14 TCG prerequisite diagnostic
|
||||
|
||||
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.
|
||||
This manual candidate uses the existing Ubuntu/x64 Docker runner. Before downloading Apple Recovery it tests actual AVX/AVX2 instruction execution in the pinned QEMU binary, then probes a fresh macOS 14+ Recovery guest. It does not install macOS, erase a disk, provision .NET/CLT or run Meeting Assistant tests. Readiness is only a prerequisite for full native CI.
|
||||
|
||||
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.
|
||||
## Profile and evidence
|
||||
|
||||
## Reasons and remaining gaps
|
||||
The existing Intel Celeron 1037U has neither AVX nor AVX2. KVM run 4187 at `720a431` reached macOS 13.6/x86_64/root and visible whole writable 64-GiB media. Diskutil timed out after 122 seconds; the sampler produced no report after 61 seconds. The screen remained at the Apple boot progress bar. CPU throttling, memory-limit/OOM events and container swap were zero; memory peaked at 2.67 GB. Host paging occurred. No unsupported-instruction crash or particular IPC wait is proved.
|
||||
|
||||
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.
|
||||
[CryptexFixup 1.0.5](https://github.com/acidanthera/CryptexFixup/blob/1.0.5/kern_start.cpp) selects the installed/updated Rosetta Cryptex and patches APFS hash checking; it does not replace the running Recovery cache or emulate instructions. macOS 13 is outside the [.NET 10 supported-OS policy](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md). This candidate therefore uses macOS 14 and software CPU emulation without Cryptex. It changes the compatibility profile, not one isolated causal variable; actual success must be measured.
|
||||
|
||||
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.
|
||||
Earlier TCG run 4159 observed guest AVX2 with the upstream-selected Skylake model. Runs 4161/4163 measured slow native startup and reached the 40-minute host limit before readiness. They predated the UDIF CRC repair at `94a70b2`, reuse of successful sw_vers output and capturing the large Recovery hash only once. They do not qualify this candidate. Host/workflow limits for the read-only mode are 90/95 minutes; a readiness pass does not establish that full installation/build/tests fit the pipeline.
|
||||
|
||||
[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.
|
||||
Run 4188 with Haswell recorded a boot loop; first-reset run 4189 retained repeated supervisor instruction-fetch pagefaults at RIP/CR2 `0x24b0` before native readiness. Run 4190 at `3aaab45` restored `Skylake-Client-v4` and the upstream TCG `-spec-ctrl` mask. The actual AVX/AVX2 ROM passed (exit 33; AVX2-disabled control exit 0), and macOS 14's Darwin 23.6.0 kernel identified the Skylake CPU. It retained one kernel handoff, a running VM and later userspace execution without the earlier reset, but reached the 20-minute diagnostic deadline without the readiness hook. These observations do not identify Haswell as the original cause or qualify native tests.
|
||||
|
||||
**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.
|
||||
Run 4190 used synchronous kernel serial output and QEMU interrupt/register tracing to preserve the failure context. Its kernel explicitly warned that synchronous output impacts performance. The current full candidate uses normal upstream boot arguments and only the existing iothread QEMU argument; it retains actual CPU/staging receipts independently of Docker log rotation. Its existing fresh-readiness gate precedes every installation permit. This allows one bounded full qualification to test boot performance and, only after readiness, installation/build/tests without duplicating the guest startup. No remote full result has qualified this candidate yet.
|
||||
|
||||
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.
|
||||
Full run 4191 at `9735db1` reached native Recovery: x86_64/root, Darwin 23.6.0 and successful launchd service queries. Both `sw_vers` attempts were stopped by the existing 45-second watchdog at about 50 seconds. Other successful commands took 24–47 seconds, and small log-copy batches took 85–181 seconds. Thus this run proves broad native startup latency and a probe-imposed abort, without proving a permanent `sw_vers` hang. Disk enumeration, installation and application tests were not reached. The next candidate obtains the version from the current guest's SystemVersion plist to reduce process launches; it does not claim that `sw_vers` has become functional.
|
||||
|
||||
## Entry point and dependencies
|
||||
## Entry points 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.
|
||||
Orchestration remains the .NET 10 file-based app `tools/ci/MacOsNativeDiagnostic.cs`. Existing Bash/Python boot integration is necessary before a guest SDK exists. NASM assembles the CPU probe in the disposable image build, without host/runner installation. No new runner, device, capability, secret or service is used.
|
||||
|
||||
~~~sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --help
|
||||
```sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
|
||||
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 --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 manual-only workflow keeps these owned run/cleanup entry points; validation invokes neither:
|
||||
|
||||
~~~sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/clean/pinned/dockur-clone --output /path/to/fresh/validation
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||
~~~
|
||||
```
|
||||
|
||||
## Exact bootasset contract
|
||||
The native diagnostic workflow is manual only. Temporary diagnostic branches are excluded from ordinary push jobs to avoid repeating unchanged Wine/portable jobs. Remove this routing when integrating qualified CI into the actual PR.
|
||||
|
||||
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.
|
||||
## Before Apple downloads
|
||||
|
||||
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.
|
||||
The existing daemon must be Linux/x64 with two CPUs and 6 GiB memory; the runner must have 5 GiB available memory and the Docker filesystem 8 GiB free. These checks do not reconfigure resources. Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, both imported QEMU image digests and original source seams remain pinned.
|
||||
|
||||
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; the unchanged wrapper runs the Apple daemon.
|
||||
Actual `Skylake-Client-v4` CPU flags under TCG use `enforce=on` to reject unsupported requests. The CPU preflight uses that same composed flag list and QEMU binary before Recovery download/boot. `tools/ci/macos-tcg-cpu-preflight.asm` enables long mode/YMM state, executes AVX and AVX2 integer arithmetic, and checks an Int32 from the upper 128-bit lane. Only the correct result reaches [QEMU debug-exit](https://github.com/qemu/qemu/blob/v11.1.1/hw/misc/debugexit.c) code 33. No disks/network attach; failure/timeout fails preflight. This tests that instruction chain, not the complete ISA or macOS.
|
||||
|
||||
The 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.
|
||||
The locally assembled NASM 2.16.03 ROM is 65,536 bytes, SHA256 `c32746122cc68f3ed642aa46c21b677f803c58f0d4ff665841723fcc5625f549`. Assembly/static review does not prove remote execution.
|
||||
|
||||
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.
|
||||
## Native gates, bounds and cleanup
|
||||
|
||||
[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.
|
||||
The original Apple recoveryosd runs under its existing job/PID beside the read-only probe. Exact known macOS 13/14 plist layouts and same-length replacements retain their allowlist. The patcher validates UDIF boundaries, updates changed mish/koly CRCs and reads back the image. Four raw/zlib positive and twelve rejection fixtures use an independent C# CRC32 reader. Apple chunklist authentication applies to the input, not the deliberately modified image.
|
||||
|
||||
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`.
|
||||
Native readiness requires x86_64, UID 0, macOS 14+, successful launchd service queries and exactly one writable whole 64-GiB disk. The guest's existing Bash runtime reads its own `/System/Library/CoreServices/SystemVersion.plist` with a 4-KiB bound and mandatory EOF. Apple documents this path as the [system-version source](https://developer.apple.com/documentation/installer_js/system/1812284-version). The existing C# controller parses the captured XML with external resolution disabled, requires a flat string-valued dictionary with exactly one valid direct `ProductVersion` string and macOS 14+, and returns a token-bound answer to that guest. Missing, binary, oversized, ambiguous or malformed content fails. Before installation, the readiness receipt's version must match this current-file evidence. No configured `VERSION` or host OS value serves as proof. The actual native diskutil query remains mandatory.
|
||||
|
||||
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.
|
||||
The raw file, exact source path, length, SHA256 and parsing receipt are retained. The guest exchange uses its existing Bash before any SDK exists; the XML logic stays in C#/.NET. Its reply wait has a 180-second bound and requires the current token, bounded complete content and a valid version. This method establishes the current guest version, not successful execution of `sw_vers`.
|
||||
|
||||
## Gates, privileges and cleanup
|
||||
Required commands retain 45 seconds, UID 180 seconds and the single disk query 120 seconds. The owned observer uses `/bin/ps -M -p <diskutil-child>` with a separate 60-second limit and two-second TERM/KILL grace. It avoids stack symbolication; thread waiting states do not identify an IPC endpoint. Observation failure passes no gate. Owned children are stopped on completion/cancellation; output remains 512 KiB per command and 4 MiB proof.
|
||||
|
||||
The Apple wrapper is byte-identical to baseline: background `/Volumes/installstate/readiness.sh` then `exec /usr/libexec/recoveryosd` under the same launchd job/PID. Source evidence does not prove Apple's executable ran.
|
||||
The container retains 6 GiB memory/swap, two-CPU limit, 512 MiB shared memory and a 4-GiB/two-vCPU guest. One fresh anonymous /storage volume holds the sparse 64-GiB target. Inspection rejects devices, capabilities, binds, ports, host networking and privileged mode. KVM is disabled with no /dev/kvm mapping; guest networking stays slirp.
|
||||
|
||||
The disk IPC continuation contains seven explicitly marked diagnostic blocks and limits disk enumeration to one attempt. Its disk query receives 120 seconds so the owned sample can finish while the query is still running. Validation removes only those marked blocks, including that command-budget exception, restores the former attempt condition and normalizes macOS 13 to 14 before requiring baseline SHA256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. Two separate named version marker pairs exclude the new parser and explicitly restore the original three-line `sw_vers -productVersion` sequence for this baseline comparison. The receipt records this version-source exception, the 1,024-byte parser bound and all existing diagnostic budget exceptions. Architecture, UID, native service exits, minimum macOS version, disk size/writability/uniqueness, proof bounds and native-wait/cleanup/flush metrics remain identical. All other required native commands retain 45 seconds, UID retains 180 seconds, and outer limits remain ten minutes maximum disk readiness, 40 minutes host and 45 minutes workflow.
|
||||
Evidence retains run/source/profile identity, CPU preflight, original/patched Recovery identity, container/QEMU state, native proof/result and cleanup. Sparse kernel-handoff lines are retained separately; two handoffs before readiness/installation permission fail early. After permission, normal installer reboots remain allowed. Optional bounded before/during/after pressure snapshots record host/cgroup counters. The /storage/14/setup.dmg hash is captured once after staging; successful evidence survives later capture failure. Screenshots/pressure observations pass no gate.
|
||||
|
||||
Run 4186 at `227884723a25e703ec1be39f8600eeaae2085c4f` completed native `sw_vers` successfully after 43 seconds, reporting macOS 13.6 / 22G120. The redundant `sw_vers -productVersion` then timed out after 48 seconds before any disk query. The continuation extracts exactly one `ProductVersion` field from the entire already-successful `platform` or `platform-warm` output. It requires EOF within 1,024 bytes, rejects NUL delimiters, duplicate/missing fields and malformed version suffixes, and accepts only two or three numeric version components separated by dots with native tab/space padding. The existing macOS 13 minimum remains mandatory; no native timeout is relaxed by this reuse.
|
||||
Both cleanup paths verify exact token/label/ID before removing only the owned container, anonymous volume and image. No pruning, host changes, original checkout changes or Meeting Assistant restart occurs. Artifacts remain seven days. Full CI remains unverified until an installed supported guest builds/signs fresh helpers and passes all 577 tests, including the five native macOS tests, with zero skips.
|
||||
|
||||
Run 4175 at `94a70b200508d3ba295124896d923fbb785d1658` reached macOS 13.6, x86_64 and UID 0 with KVM enabled; its nine `diskutil list physical` attempts timed out. Run 4185 at `25989cf0eb7205f30ac0bb44eb279aa3b463f12c` proved the whole writable 64-GiB target as IOMedia `disk2`; it measured approximately 14 seconds for the final native process listing and 33 seconds for IOMedia. Both Apple disk jobs were running; DiskManagement's endpoint was still inactive. The former eight-second observer allowance was shorter than observed native startup, so its killed sample did not establish an IPC wait point. A missing target is ruled out for that run; service initialization, IPC or resource delays remain unresolved.
|
||||
## Prepared full build/test flow
|
||||
|
||||
Before the only disk attempt the continuation records bounded `launchctl print` output for `com.apple.diskarbitrationd` and `com.apple.diskmanagementd`, plus `ioreg -r -c IOMedia -l -w 0`. Its observer starts only `/usr/bin/sample <owned-diskutil-child-pid> 3 100 -file <owned-output>`, with no preceding process list or additional service query. Three seconds at a 100-millisecond interval reduces sampling overhead. The sample has 60 seconds for startup/reporting plus the existing two-second TERM/KILL grace; its separate report is flushed into the proof alongside command output. Missing sample tooling or an already completed diskutil is reported explicitly; nonzero observation exits are logged and cannot satisfy any native gate.
|
||||
The separate manual `.gitea/workflows/macos-native-full.yaml` and the prepared required PR job invoke the same full mode:
|
||||
|
||||
The observer owns its command/timer PIDs and is stopped when the disk query completes or the probe is canceled. Its output enters the existing 512-KiB per-output and 4-MiB proof budgets. The hook only reads media/service/process state and writes its existing diagnostic files: it does not load, restart, erase or modify any service or disk. Bash remains necessary because Apple Recovery runs this hook before a .NET SDK is installed. The CPU, Recovery, QEMU, Apple wrapper and container profile are unchanged. These observations are prepared diagnostics, not a new successful guest or full native CI receipt.
|
||||
```sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --full --source /path/to/clean/pinned/dockur-clone --output /path/to/fresh/full-validation
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||
```
|
||||
|
||||
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.
|
||||
Full mode repeats CPU preflight and Recovery readiness for its own fresh guest. Before erasing the disposable target, the host verifies the owned container/anonymous volume, raw 64-GiB image, actual QEMU attachment/unique disk serial and state share. A token/source/disk-bound permit authorizes the guest. The guest independently checks whole/writable/size/unique serial before `diskutil eraseDisk`. It never erases a host disk or reuses an unrelated guest volume.
|
||||
|
||||
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.
|
||||
The installer provisions the owned guest and returns into the prepared firstboot hook. Early firstboot logs and failures enter the state share even before account-package installation or test bootstrap. The installed root must be APFS backed by the exact owned physical store. Apple softwareupdate provisions CLT; a real Swift/SDK smoke build imports the required Apple frameworks. The source archive is bound to clean Git HEAD and SHA256; the pinned macOS x64 .NET SDK 10.0.401 is checked with SHA512. No prebuilt application/helper result counts as this run's evidence.
|
||||
|
||||
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.
|
||||
`tools/ci/MacOsNativeGuest.cs` restores/builds/tests the source inside the installed guest. Success requires four fresh x86_64 Mach-O helpers, a valid audio-app signature and a fresh source-bound TRX containing exactly 577 distinct passing tests, zero failures/skips and all five named native macOS tests. Archive, SDK, build, signature and TRX receipts are retained. The required PR job follows the existing Wine and portable jobs; all jobs still select `ubuntu-latest`.
|
||||
|
||||
Optional 20-second resource snapshots before, during and after the guest probe retain cgroup CPU usage/throttling/pressure, memory events/pressure/statistics and host page-fault/swap counters. These observations test resource contention as a hypothesis; no resource failure is established by the existing guest timing alone. The large Recovery image hash is captured once after compatibility-profile staging is observed, with its successful receipt retained, instead of repeatedly hashing the image while collecting guest progress. Snapshot or hash observation failure cannot satisfy a native readiness 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.
|
||||
The workflow has 180 minutes; the controller reserves cleanup time with a shared 172-minute total deadline. Recovery, installation, CLT and test caps are 90/80/30/25 minutes under that same total, not additive promises. Actual supported-guest installation/performance and remote test success remain unqualified. The full run's own mandatory fresh-readiness and owned-disk gates prevent installation until that guest passes its prerequisites. CI does not deploy or restart the workstation application.
|
||||
|
||||
+1074
-122
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
// Installed-guest CI slice. --validate never invokes macOS, dotnet build/test, or a VM.
|
||||
return await NativeGuest.Execute(args);
|
||||
|
||||
static class NativeGuest
|
||||
{
|
||||
const string SdkVersion = "10.0.401";
|
||||
const string SdkSha512 = "33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0";
|
||||
const int ExpectedTests = 577;
|
||||
const int MaximumLogBytes = 8 * 1024 * 1024;
|
||||
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
||||
static readonly string[] RequiredNativeTests =
|
||||
[
|
||||
"MeetingAssistant.Tests.MacOsMeetingAudioSourceTests.NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.MacOsCapabilityEndpointReportsEnabledRealProviders",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperCropsPngUsingOcrPixelCoordinates",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied"
|
||||
];
|
||||
static readonly string[] NativeNames =
|
||||
[
|
||||
"MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture",
|
||||
"macos-desktop-controls", "macos-meeting-integrations", "macos-meeting-assistant-launcher"
|
||||
];
|
||||
|
||||
public static async Task<int> Execute(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.SequenceEqual(["--help"]))
|
||||
{
|
||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeGuest.cs -- --validate [--archive <source.tar> --source-commit <SHA>] | --run --state /Volumes/installstate --work /private/var/tmp/meeting-assistant-native-<runToken>");
|
||||
return 0;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (args.SequenceEqual(["--validate"]) || args.Length == 5 && args[0] == "--validate" && args[1] == "--archive" && args[3] == "--source-commit")
|
||||
{
|
||||
ValidateContracts();
|
||||
if (args.Length == 5)
|
||||
{
|
||||
if (!Hex(args[4], 40)) throw new ArgumentException("Source commit must be the full lowercase Git SHA.");
|
||||
using var archive = File.OpenRead(Path.GetFullPath(args[2]));
|
||||
ValidateArchive(archive, args[4]);
|
||||
}
|
||||
Console.WriteLine("Guest payload, safe Git tar, fresh TRX and native receipt contracts passed; no native execution occurred.");
|
||||
return 0;
|
||||
}
|
||||
if (args.Length != 5 || args[0] != "--run" || args[1] != "--state" || args[3] != "--work")
|
||||
throw new ArgumentException("Choose --validate or --run --state <mounted9pdir> --work <ownedAPFSdir>.");
|
||||
return await Run(Path.GetFullPath(args[2]), Path.GetFullPath(args[4]));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(exception.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> Run(string state, string work)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
throw new InvalidOperationException("--run is restricted to the installed macOS x86_64 guest.");
|
||||
RequireNoLinks(state);
|
||||
RequireNoLinks(work);
|
||||
var manifestPath = Path.Combine(state, "payload.json");
|
||||
RequireNoLinks(manifestPath);
|
||||
var payload = ReadPayload(File.ReadAllText(manifestPath));
|
||||
if (work != "/private/var/tmp/meeting-assistant-native-" + payload.RunToken || !Directory.Exists(work))
|
||||
throw new InvalidOperationException("Guest work directory does not match this run's owned APFS location.");
|
||||
var owner = Path.Combine(work, "run.owner");
|
||||
RequireNoLinks(owner);
|
||||
if (File.ReadAllText(owner).TrimEnd('\r', '\n') != payload.RunToken)
|
||||
throw new InvalidOperationException("Guest work directory has a different run owner.");
|
||||
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var summary = new TestSummary(0, 0, 0, 0, 0, []);
|
||||
var native = new List<NativeArtifact>();
|
||||
var osVersion = "";
|
||||
var architecture = "";
|
||||
var actualSdk = "";
|
||||
var trxSha256 = "";
|
||||
var audioCodeSignExit = -1;
|
||||
var success = false;
|
||||
var reason = "";
|
||||
var logs = Path.Combine(state, "guest-logs");
|
||||
var results = Path.Combine(state, "test-results");
|
||||
var source = Path.Combine(work, "source");
|
||||
var dotnet = Path.Combine(work, "dotnet", "dotnet");
|
||||
// Guest checks/restore/build/tests: 25 minutes within the host's 172-minute total.
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(25));
|
||||
using var signal = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); });
|
||||
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
void Phase(string phase, string stage) => Save(Path.Combine(state, "guest-phase.json"), new { token = payload.RunToken, phase, stage, updatedUtc = DateTimeOffset.UtcNow });
|
||||
async Task<CommandResult> Cmd(string executable, string[] arguments, string label, bool requireSuccess = true) =>
|
||||
await Command(executable, arguments, source, work, logs, label, deadline.Token, requireSuccess);
|
||||
try
|
||||
{
|
||||
RequireAbsent(Path.Combine(state, "full-result.json"));
|
||||
RequireAbsent(logs);
|
||||
RequireAbsent(results);
|
||||
RequireAbsent(source);
|
||||
Directory.CreateDirectory(logs);
|
||||
Directory.CreateDirectory(results);
|
||||
foreach (var directory in new[] { "home", "packages", "tmp" })
|
||||
{
|
||||
RequireNoLinks(Path.Combine(work, directory));
|
||||
Directory.CreateDirectory(Path.Combine(work, directory));
|
||||
}
|
||||
Phase("tests-running", "installed-guest-checks");
|
||||
osVersion = (await Cmd("/usr/bin/sw_vers", ["-productVersion"], "os-version")).Output.Trim();
|
||||
architecture = (await Cmd("/usr/bin/uname", ["-m"], "architecture")).Output.Trim();
|
||||
var uid = (await Cmd("/usr/bin/id", ["-u"], "uid")).Output.Trim();
|
||||
RequirePlatform(osVersion, architecture, uid);
|
||||
foreach (var volume in new[] { ("/", "system-volume"), (work, "work-volume") })
|
||||
RequireApfs((await Cmd("/usr/sbin/diskutil", ["info", "-plist", volume.Item1], volume.Item2)).Output);
|
||||
await Cmd("/usr/bin/xcode-select", ["-p"], "clt-location");
|
||||
await Cmd("/usr/sbin/pkgutil", ["--pkg-info", "com.apple.pkg.CLTools_Executables"], "clt-package");
|
||||
await Cmd("/usr/bin/xcrun", ["swiftc", "--version"], "swift-version");
|
||||
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-version"], "apple-sdk-version");
|
||||
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], "apple-sdk-path");
|
||||
RequireNoLinks(dotnet);
|
||||
actualSdk = (await Cmd(dotnet, ["--version"], "sdk-version")).Output.Trim();
|
||||
var sdkInfo = (await Cmd(dotnet, ["--info"], "sdk-info")).Output;
|
||||
if (actualSdk != SdkVersion || !Regex.IsMatch(sdkInfo, @"(?m)^\s*Architecture:\s*x64\s*$"))
|
||||
throw new InvalidOperationException("Guest .NET SDK is not the pinned 10.0.401/x64 toolchain.");
|
||||
|
||||
Phase("tests-running", "payload-verification");
|
||||
var archive = Path.Combine(state, "source.tar");
|
||||
var sdkArchive = Path.Combine(state, "sdk.tar.gz");
|
||||
RequireNoLinks(archive);
|
||||
RequireNoLinks(sdkArchive);
|
||||
if (await HashFile(archive, false, deadline.Token) != payload.ArchiveSha256 || await HashFile(sdkArchive, true, deadline.Token) != payload.SdkSha512)
|
||||
throw new InvalidOperationException("Guest payload hash does not match the pinned manifest.");
|
||||
using (var stream = File.OpenRead(archive)) ValidateArchive(stream, payload.SourceCommit);
|
||||
// All members were checked before native tar can write anything; the destination is new.
|
||||
Directory.CreateDirectory(source);
|
||||
await Cmd("/usr/bin/tar", ["-xf", archive, "-C", source], "source-extraction");
|
||||
var project = Path.Combine(source, "MeetingAssistant.Tests", "MeetingAssistant.Tests.csproj");
|
||||
if (!File.Exists(project)) throw new InvalidOperationException("Source archive lacks the test project.");
|
||||
var nativeRoot = Path.Combine(source, "MeetingAssistant", "bin", "Release", "net10.0", "Native");
|
||||
RequireAbsent(nativeRoot);
|
||||
Phase("tests-running", "restore");
|
||||
await Cmd(dotnet, ["restore", project, "-p:EnableWindowsTargeting=true", "-p:TargetFramework=net10.0"], "restore");
|
||||
Phase("tests-running", "build");
|
||||
var buildStarted = DateTimeOffset.UtcNow;
|
||||
await Cmd(dotnet, ["build", project, "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true"], "build");
|
||||
Phase("tests-running", "native-artifacts");
|
||||
foreach (var name in NativeNames)
|
||||
{
|
||||
var path = Path.Combine(nativeRoot, name);
|
||||
RequireNoLinks(path);
|
||||
RequireFreshFile(path, buildStarted);
|
||||
var fileOutput = (await Cmd("/usr/bin/file", ["-b", path], "native-file-" + native.Count)).Output;
|
||||
var arch = (await Cmd("/usr/bin/xcrun", ["lipo", "-archs", path], "native-architecture-" + native.Count)).Output.Trim();
|
||||
RequireNativeArtifact(fileOutput, arch);
|
||||
native.Add(new(name, await HashFile(path, false, deadline.Token), arch));
|
||||
}
|
||||
var signing = await Cmd("/usr/bin/codesign", ["--verify", "--deep", "--strict", Path.Combine(nativeRoot, "MeetingAssistantAudioCapture.app")], "audio-code-sign", false);
|
||||
audioCodeSignExit = signing.ExitCode;
|
||||
if (audioCodeSignExit != 0) throw new InvalidOperationException("Fresh audio app did not pass strict code-signature verification.");
|
||||
Phase("tests-running", "test");
|
||||
var testStarted = DateTimeOffset.UtcNow;
|
||||
await Cmd(dotnet, ["test", project, "--no-build", "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true", "--logger", "trx;LogFileName=native.trx", "--results-directory", results], "test");
|
||||
var trxPath = Path.Combine(results, "native.trx");
|
||||
RequireNoLinks(trxPath);
|
||||
RequireFreshFile(trxPath, testStarted, 16 * 1024 * 1024);
|
||||
var trxBytes = File.ReadAllBytes(trxPath);
|
||||
summary = ValidateTrx(trxBytes, payload.ExpectedTests, testStarted);
|
||||
trxSha256 = Convert.ToHexStringLower(SHA256.HashData(trxBytes));
|
||||
success = true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
reason = exception is OperationCanceledException ? "The explicit 25-minute guest deadline or cancellation was reached." : exception.Message;
|
||||
if (reason.Length > 4096) reason = reason[..4096];
|
||||
Console.Error.WriteLine(reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
var result = new FullResult(payload.RunToken, success, payload.SourceCommit, payload.ArchiveSha256, osVersion, architecture, actualSdk, payload.ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, native.ToArray(), audioCodeSignExit, trxSha256, reason, started, DateTimeOffset.UtcNow);
|
||||
if (success)
|
||||
{
|
||||
try { ValidateResult(result, payload); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
success = false;
|
||||
result = result with { Success = false, Reason = exception.Message };
|
||||
}
|
||||
}
|
||||
Save(Path.Combine(state, "full-result.json"), result);
|
||||
Phase(success ? "tests-passed" : "tests-failed", "complete");
|
||||
}
|
||||
Console.WriteLine(success ? "Native macOS guest build, signed helpers and all 577 tests passed without skips." : "Native guest validation failed; full-result.json and bounded logs retain the evidence.");
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
|
||||
static Payload ReadPayload(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object || document.RootElement.EnumerateObject().Select(property => property.Name).Distinct(StringComparer.Ordinal).Count() != document.RootElement.EnumerateObject().Count())
|
||||
throw new InvalidOperationException("Payload manifest must contain one unambiguous JSON object.");
|
||||
var payload = JsonSerializer.Deserialize<Payload>(json, JsonOptions) ?? throw new InvalidOperationException("Missing guest payload manifest.");
|
||||
if (!Hex(payload.RunToken, 32) || !Hex(payload.SourceCommit, 40) || !Hex(payload.ArchiveSha256, 64) || payload.SdkVersion != SdkVersion || payload.SdkSha512 != SdkSha512 || payload.ExpectedTests != ExpectedTests)
|
||||
throw new InvalidOperationException("Guest payload identity, SDK pin or expected test count is invalid.");
|
||||
return payload;
|
||||
}
|
||||
|
||||
static void ValidateArchive(Stream stream, string commit)
|
||||
{
|
||||
using var reader = new TarReader(stream, leaveOpen: true);
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var commits = new List<string>();
|
||||
long totalBytes = 0;
|
||||
while (reader.GetNextEntry() is { } entry)
|
||||
{
|
||||
if (entry is PaxGlobalExtendedAttributesTarEntry global)
|
||||
{
|
||||
if (global.GlobalExtendedAttributes.TryGetValue("comment", out var value)) commits.Add(value);
|
||||
if (global.GlobalExtendedAttributes.Keys.Any(key => key is "path" or "linkpath"))
|
||||
throw new InvalidOperationException("Global tar path overrides are not supported.");
|
||||
continue;
|
||||
}
|
||||
if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory))
|
||||
throw new InvalidOperationException("Source tar contains a link or unsupported entry type: " + entry.Name);
|
||||
var name = entry.Name.TrimEnd('/');
|
||||
if (name.Length == 0 || name.StartsWith('/') || name.Contains('\\') || name.Contains('\0') || name.Split('/').Any(part => part.Length == 0 || part is "." or ".." or ".git" or "bin" or "obj") || !names.Add(name))
|
||||
throw new InvalidOperationException("Source tar path is unsafe, repeated or contains generated output: " + entry.Name);
|
||||
totalBytes = checked(totalBytes + entry.Length);
|
||||
if (names.Count > 100_000 || totalBytes > 2L * 1024 * 1024 * 1024)
|
||||
throw new InvalidOperationException("Source tar exceeds its explicit entry/size budget.");
|
||||
}
|
||||
if (names.Count == 0 || commits.Count != 1 || commits[0] != commit || !names.Contains("MeetingAssistant.Tests/MeetingAssistant.Tests.csproj"))
|
||||
throw new InvalidOperationException("Source tar does not prove its exact Git commit and test project.");
|
||||
}
|
||||
|
||||
static TestSummary ValidateTrx(string xml, int expected, DateTimeOffset testStarted) => ValidateTrx(Encoding.UTF8.GetBytes(xml), expected, testStarted);
|
||||
static TestSummary ValidateTrx(byte[] xml, int expected, DateTimeOffset testStarted)
|
||||
{
|
||||
var document = ParseXml(xml);
|
||||
var root = document.Root ?? throw new InvalidOperationException("Empty TRX.");
|
||||
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
if (root.Name != ns + "TestRun") throw new InvalidOperationException("Unexpected TRX namespace or root.");
|
||||
var times = root.Element(ns + "Times") ?? throw new InvalidOperationException("TRX lacks execution timestamps.");
|
||||
if (!DateTimeOffset.TryParse((string?)times.Attribute("start"), out var start) || !DateTimeOffset.TryParse((string?)times.Attribute("finish"), out var finish) || start < testStarted.AddSeconds(-2) || finish < start || finish > DateTimeOffset.UtcNow.AddSeconds(30))
|
||||
throw new InvalidOperationException("TRX belongs to a stale or invalid test execution.");
|
||||
var resultSummary = root.Element(ns + "ResultSummary") ?? throw new InvalidOperationException("TRX lacks a final result summary.");
|
||||
if ((string?)resultSummary.Attribute("outcome") != "Completed") throw new InvalidOperationException("TRX test run did not complete.");
|
||||
var counters = resultSummary.Element(ns + "Counters") ?? throw new InvalidOperationException("TRX lacks final counters.");
|
||||
int Count(string name) => int.TryParse((string?)counters.Attribute(name), out var value) && value >= 0 ? value : throw new InvalidOperationException("TRX lacks a valid counter: " + name);
|
||||
var total = Count("total");
|
||||
var executed = Count("executed");
|
||||
var passed = Count("passed");
|
||||
var failed = Count("failed");
|
||||
var notExecuted = Count("notExecuted");
|
||||
foreach (var name in new[] { "error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected", "inProgress", "pending" })
|
||||
if (counters.Attribute(name) is not null && Count(name) != 0) throw new InvalidOperationException("TRX contains an incomplete or unsuccessful execution: " + name);
|
||||
if (expected != ExpectedTests || total != expected || executed != expected || passed != expected || failed != 0 || notExecuted != 0)
|
||||
throw new InvalidOperationException($"Native TRX must prove {expected} total/executed/passed tests, zero failures and zero skips; got {total}/{executed}/{passed}/{failed}/{notExecuted}.");
|
||||
var definitions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var unit in root.Element(ns + "TestDefinitions")?.Elements(ns + "UnitTest") ?? [])
|
||||
{
|
||||
var method = unit.Element(ns + "TestMethod") ?? throw new InvalidOperationException("TRX test definition lacks its method identity.");
|
||||
var className = ((string?)method.Attribute("className") ?? "").Split(',')[0].Trim();
|
||||
var methodName = (string?)method.Attribute("name") ?? "";
|
||||
var id = (string?)unit.Attribute("id") ?? "";
|
||||
if (id.Length == 0 || className.Length == 0 || methodName.Length == 0 || !definitions.TryAdd(id, methodName.StartsWith(className + ".", StringComparison.Ordinal) ? methodName : className + "." + methodName))
|
||||
throw new InvalidOperationException("TRX contains an ambiguous test definition.");
|
||||
}
|
||||
var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? [];
|
||||
var executionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var testIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var native = new List<string>();
|
||||
foreach (var result in results)
|
||||
{
|
||||
var id = (string?)result.Attribute("testId") ?? "";
|
||||
var executionId = (string?)result.Attribute("executionId") ?? "";
|
||||
if ((string?)result.Attribute("outcome") != "Passed" || executionId.Length == 0 || !executionIds.Add(executionId) || !testIds.Add(id) || !definitions.TryGetValue(id, out var identity))
|
||||
throw new InvalidOperationException("TRX has a missing, duplicate or non-passed execution.");
|
||||
if (RequiredNativeTests.Contains(identity, StringComparer.Ordinal)) native.Add(identity);
|
||||
}
|
||||
if (definitions.Count != expected || results.Length != expected || native.Count != RequiredNativeTests.Length || !native.Order().SequenceEqual(RequiredNativeTests.Order()))
|
||||
throw new InvalidOperationException("TRX does not prove every expected test definition exactly once and the five explicit native macOS tests.");
|
||||
return new(total, executed, passed, failed, notExecuted, native.ToArray());
|
||||
}
|
||||
|
||||
static void ValidateResult(FullResult result, Payload payload)
|
||||
{
|
||||
if (result.Token != payload.RunToken || !result.Success || result.SourceCommit != payload.SourceCommit || result.ArchiveSha256 != payload.ArchiveSha256 || !Version.TryParse(result.OsVersion, out var version) || version.Major < 14 || result.Architecture != "x86_64" || result.SdkVersion != SdkVersion || result.ExpectedTests != ExpectedTests || result.Total != ExpectedTests || result.Executed != ExpectedTests || result.Passed != ExpectedTests || result.Failed != 0 || result.NotExecuted != 0 || !result.NativeTests.Order().SequenceEqual(RequiredNativeTests.Order()) || result.AudioCodeSignExit != 0 || !Hex(result.TrxSha256, 64) || result.NativeArtifacts.Length != NativeNames.Length || !result.NativeArtifacts.Select(artifact => artifact.Name).Order().SequenceEqual(NativeNames.Order()) || result.NativeArtifacts.Any(artifact => artifact.Architecture != "x86_64" || !Hex(artifact.Sha256, 64)) || result.CompletedUtc < result.StartedUtc || result.CompletedUtc - result.StartedUtc > TimeSpan.FromMinutes(25).Add(TimeSpan.FromSeconds(15)) || result.CompletedUtc > DateTimeOffset.UtcNow.AddSeconds(30) || result.CompletedUtc < DateTimeOffset.UtcNow.AddMinutes(-1) || result.Reason.Length != 0)
|
||||
throw new InvalidOperationException("Native guest receipt does not prove this source, toolchain, signed artifacts and all expected tests.");
|
||||
}
|
||||
|
||||
static void RequirePlatform(string version, string architecture, string uid)
|
||||
{
|
||||
if (!Version.TryParse(version, out var parsed) || parsed.Major < 14 || architecture != "x86_64" || uid != "0")
|
||||
throw new InvalidOperationException("Native build requires installed macOS 14+/x86_64 running as root.");
|
||||
}
|
||||
static void RequireApfs(string xml)
|
||||
{
|
||||
var elements = ParseXml(xml).Root?.Element("dict")?.Elements().ToArray() ?? [];
|
||||
var type = elements.Select((element, index) => (element, index)).FirstOrDefault(pair => pair.element.Name == "key" && pair.element.Value == "FilesystemType");
|
||||
if (type.element is null || type.index + 1 >= elements.Length || elements[type.index + 1].Name != "string" || elements[type.index + 1].Value != "apfs")
|
||||
throw new InvalidOperationException("Native build must run from the installed APFS system and owned APFS work volume.");
|
||||
}
|
||||
static void RequireNativeArtifact(string fileOutput, string architecture)
|
||||
{
|
||||
if (!fileOutput.Contains("Mach-O", StringComparison.Ordinal) || !fileOutput.Contains("x86_64", StringComparison.Ordinal) || architecture != "x86_64")
|
||||
throw new InvalidOperationException("Native helper is not a Mach-O executable containing exactly x86_64.");
|
||||
}
|
||||
static void RequireAbsent(string path)
|
||||
{
|
||||
if (Path.Exists(path) || GetAttributesSafe(path)?.HasFlag(FileAttributes.ReparsePoint) == true)
|
||||
throw new InvalidOperationException("Fresh guest execution refuses pre-existing output: " + path);
|
||||
}
|
||||
static void RequireFreshFile(string path, DateTimeOffset started, long maximumBytes = long.MaxValue)
|
||||
{
|
||||
RequireNoLinks(path);
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists || file.Length == 0 || file.Length > maximumBytes || file.LastWriteTimeUtc < started.UtcDateTime.AddSeconds(-2))
|
||||
throw new InvalidOperationException("Expected a fresh, nonempty, bounded output from this execution: " + path);
|
||||
}
|
||||
static void RequireNoLinks(string path)
|
||||
{
|
||||
for (var current = Path.GetFullPath(path); current is not null; current = Path.GetDirectoryName(current))
|
||||
if (GetAttributesSafe(current)?.HasFlag(FileAttributes.ReparsePoint) == true)
|
||||
throw new InvalidOperationException("Guest ownership/extraction refuses a symbolic link: " + current);
|
||||
}
|
||||
static FileAttributes? GetAttributesSafe(string path)
|
||||
{
|
||||
try { return File.GetAttributes(path); }
|
||||
catch (FileNotFoundException) { return null; }
|
||||
catch (DirectoryNotFoundException) { return null; }
|
||||
}
|
||||
static XDocument ParseXml(string xml) => ParseXml(Encoding.UTF8.GetBytes(xml));
|
||||
static XDocument ParseXml(byte[] xml)
|
||||
{
|
||||
using var stream = new MemoryStream(xml, writable: false);
|
||||
using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null, MaxCharactersInDocument = 16 * 1024 * 1024 });
|
||||
return XDocument.Load(reader);
|
||||
}
|
||||
static bool Hex(string? value, int length) => value is not null && Regex.IsMatch(value, "^[0-9a-f]{" + length + "}$");
|
||||
static async Task<string> HashFile(string path, bool sha512, CancellationToken cancellation)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = sha512 ? await SHA512.HashDataAsync(stream, cancellation) : await SHA256.HashDataAsync(stream, cancellation);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
static void Save(string path, object value)
|
||||
{
|
||||
RequireNoLinks(path);
|
||||
var temporary = path + ".tmp";
|
||||
RequireNoLinks(temporary);
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
|
||||
static async Task<CommandResult> Command(string executable, string[] arguments, string source, string work, string logs, string label, CancellationToken cancellation, bool requireSuccess)
|
||||
{
|
||||
Console.WriteLine("[native-guest] " + label);
|
||||
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var token = commandCancellation.Token;
|
||||
var start = new ProcessStartInfo(executable) { WorkingDirectory = Directory.Exists(source) ? source : work, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
start.Environment.Clear();
|
||||
foreach (var pair in new Dictionary<string, string>
|
||||
{
|
||||
["PATH"] = Path.Combine(work, "dotnet") + ":/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
["TMPDIR"] = Path.Combine(work, "tmp"),
|
||||
["DOTNET_ROOT"] = Path.Combine(work, "dotnet"), ["DOTNET_CLI_HOME"] = Path.Combine(work, "home"),
|
||||
["NUGET_PACKAGES"] = Path.Combine(work, "packages"), ["TZ"] = "Europe/Berlin",
|
||||
["LANG"] = "en_US.UTF-8", ["LC_ALL"] = "en_US.UTF-8", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
|
||||
["DOTNET_NOLOGO"] = "1", ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", ["MSBUILDDISABLENODEREUSE"] = "1"
|
||||
}) start.Environment[pair.Key] = pair.Value;
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start guest command: " + label);
|
||||
long capturedBytes = 0;
|
||||
async Task<string> Read(StreamReader reader, string stream)
|
||||
{
|
||||
var captured = new StringBuilder();
|
||||
var buffer = new char[8192];
|
||||
using var log = new StreamWriter(Path.Combine(logs, label + "." + stream + ".log"), false, new UTF8Encoding(false));
|
||||
while (true)
|
||||
{
|
||||
var count = await reader.ReadAsync(buffer.AsMemory(), token);
|
||||
if (count == 0) break;
|
||||
if (Interlocked.Add(ref capturedBytes, Encoding.UTF8.GetByteCount(buffer.AsSpan(0, count))) > MaximumLogBytes)
|
||||
{
|
||||
commandCancellation.Cancel();
|
||||
throw new InvalidOperationException(label + " exceeded its combined 8-MiB output budget.");
|
||||
}
|
||||
captured.Append(buffer, 0, count);
|
||||
await log.WriteAsync(buffer.AsMemory(0, count), token);
|
||||
await log.FlushAsync(token);
|
||||
}
|
||||
return captured.ToString();
|
||||
}
|
||||
var stdout = Read(process.StandardOutput, "stdout");
|
||||
var stderr = Read(process.StandardError, "stderr");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token));
|
||||
var result = new CommandResult(process.ExitCode, await stdout, await stderr);
|
||||
if (requireSuccess && result.ExitCode != 0)
|
||||
throw new InvalidOperationException($"{label} exited {result.ExitCode}: {result.Error[..Math.Min(result.Error.Length, 1500)]}");
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
|
||||
using var killDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await process.WaitForExitAsync(killDeadline.Token); } catch (OperationCanceledException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static void ValidateContracts()
|
||||
{
|
||||
var payload = new Payload(new string('a', 32), new string('b', 40), new string('c', 64), SdkVersion, SdkSha512, ExpectedTests);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
ReadPayload(json);
|
||||
Reject(() => ReadPayload(json.Replace(SdkVersion, "10.0.100", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(SdkSha512, new string('d', 128), StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace("577", "572", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(payload.RunToken, "stale", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(payload.ArchiveSha256, "invalid", StringComparison.Ordinal)));
|
||||
RequirePlatform("14.6.1", "x86_64", "0");
|
||||
Reject(() => RequirePlatform("13.6.1", "x86_64", "0"));
|
||||
Reject(() => RequirePlatform("14.6.1", "arm64", "0"));
|
||||
Reject(() => RequirePlatform("14.6.1", "x86_64", "501"));
|
||||
RequireApfs("<plist><dict><key>FilesystemType</key><string>apfs</string></dict></plist>");
|
||||
Reject(() => RequireApfs("<plist><dict><key>FilesystemType</key><string>hfs</string></dict></plist>"));
|
||||
RequireNativeArtifact("Mach-O 64-bit executable x86_64", "x86_64");
|
||||
Reject(() => RequireNativeArtifact("Mach-O universal binary x86_64 arm64", "x86_64 arm64"));
|
||||
Reject(() => RequireNativeArtifact("ELF 64-bit executable x86_64", "x86_64"));
|
||||
using (var archive = FixtureArchive(payload.SourceCommit)) ValidateArchive(archive, payload.SourceCommit);
|
||||
foreach (var path in new[] { "../escape", "/absolute", "safe/../escape", "safe\\escape", "MeetingAssistant/bin/stale" })
|
||||
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, path); ValidateArchive(archive, payload.SourceCommit); });
|
||||
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, "link", true); ValidateArchive(archive, payload.SourceCommit); });
|
||||
Reject(() => { using var archive = FixtureArchive(new string('d', 40)); ValidateArchive(archive, payload.SourceCommit); });
|
||||
var started = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
var fixture = FixtureTrx(started);
|
||||
var summary = ValidateTrx(fixture.ToString(), ExpectedTests, started);
|
||||
var plainTrx = Encoding.UTF8.GetBytes(fixture.ToString());
|
||||
var bomTrx = new byte[] { 0xef, 0xbb, 0xbf }.Concat(plainTrx).ToArray();
|
||||
ValidateTrx(bomTrx, ExpectedTests, started);
|
||||
if (SHA256.HashData(plainTrx).SequenceEqual(SHA256.HashData(bomTrx))) throw new InvalidOperationException("TRX raw-byte hashing discarded its BOM.");
|
||||
Reject(() => ValidateTrx(fixture.ToString(), ExpectedTests, started.AddMinutes(2)));
|
||||
XNamespace ns = fixture.Root!.Name.Namespace;
|
||||
var skipped = new XDocument(fixture);
|
||||
skipped.Descendants(ns + "UnitTestResult").First().SetAttributeValue("outcome", "NotExecuted");
|
||||
Reject(() => ValidateTrx(skipped.ToString(), ExpectedTests, started));
|
||||
var missingNative = new XDocument(fixture);
|
||||
missingNative.Descendants(ns + "TestMethod").First().SetAttributeValue("name", "ManagedReplacement");
|
||||
Reject(() => ValidateTrx(missingNative.ToString(), ExpectedTests, started));
|
||||
var duplicate = new XDocument(fixture);
|
||||
duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0");
|
||||
Reject(() => ValidateTrx(duplicate.ToString(), ExpectedTests, started));
|
||||
var repeatedManaged = new XDocument(fixture);
|
||||
repeatedManaged.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("testId", "test-5");
|
||||
Reject(() => ValidateTrx(repeatedManaged.ToString(), ExpectedTests, started));
|
||||
var unexecutedDefinition = new XDocument(fixture);
|
||||
var extraDefinition = new XElement(unexecutedDefinition.Descendants(ns + "UnitTest").Last());
|
||||
extraDefinition.SetAttributeValue("id", "unexecuted-test");
|
||||
unexecutedDefinition.Root!.Element(ns + "TestDefinitions")!.Add(extraDefinition);
|
||||
Reject(() => ValidateTrx(unexecutedDefinition.ToString(), ExpectedTests, started));
|
||||
var missingDefinition = new XDocument(fixture);
|
||||
missingDefinition.Descendants(ns + "UnitTest").Last().Remove();
|
||||
Reject(() => ValidateTrx(missingDefinition.ToString(), ExpectedTests, started));
|
||||
var theoryRows = new XDocument(fixture);
|
||||
foreach (var method in theoryRows.Descendants(ns + "TestMethod").Skip(RequiredNativeTests.Length).Take(2))
|
||||
method.SetAttributeValue("name", "TheoryWithDistinctRowIds");
|
||||
ValidateTrx(theoryRows.ToString(), ExpectedTests, started);
|
||||
var counters = new XDocument(fixture);
|
||||
counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572");
|
||||
Reject(() => ValidateTrx(counters.ToString(), ExpectedTests, started));
|
||||
var aborted = new XDocument(fixture);
|
||||
aborted.Descendants(ns + "ResultSummary").Single().SetAttributeValue("outcome", "Aborted");
|
||||
Reject(() => ValidateTrx(aborted.ToString(), ExpectedTests, started));
|
||||
var artifacts = NativeNames.Select(name => new NativeArtifact(name, new string('d', 64), "x86_64")).ToArray();
|
||||
var result = new FullResult(payload.RunToken, true, payload.SourceCommit, payload.ArchiveSha256, "14.6.1", "x86_64", SdkVersion, ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, artifacts, 0, new string('e', 64), "", started, DateTimeOffset.UtcNow);
|
||||
ValidateResult(result, payload);
|
||||
foreach (var invalid in new[] { result with { Token = new string('f', 32) }, result with { Success = false }, result with { SourceCommit = new string('f', 40) }, result with { ArchiveSha256 = new string('f', 64) }, result with { NotExecuted = 5 }, result with { AudioCodeSignExit = 1 }, result with { NativeTests = RequiredNativeTests[..4] }, result with { NativeArtifacts = artifacts[..3] }, result with { NativeArtifacts = [artifacts[0] with { Architecture = "arm64" }, .. artifacts[1..]] }, result with { TrxSha256 = "" }, result with { SdkVersion = "10.0.100" }, result with { OsVersion = "13.6.1" }, result with { StartedUtc = started.AddHours(-1) }, result with { StartedUtc = started.AddHours(-1), CompletedUtc = started.AddHours(-1).AddSeconds(1) } })
|
||||
Reject(() => ValidateResult(invalid, payload));
|
||||
var temporary = Path.Combine(OperatingSystem.IsMacOS() ? "/private/tmp" : Path.GetTempPath(), "meeting-assistant-guest-validation-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
RequireAbsent(temporary);
|
||||
Directory.CreateDirectory(temporary);
|
||||
Reject(() => RequireAbsent(temporary));
|
||||
var file = Path.Combine(temporary, "fresh.trx");
|
||||
File.WriteAllText(file, "fixture");
|
||||
RequireFreshFile(file, started);
|
||||
Reject(() => RequireFreshFile(file, started, 1));
|
||||
File.SetLastWriteTimeUtc(file, started.UtcDateTime.AddMinutes(-5));
|
||||
Reject(() => RequireFreshFile(file, started));
|
||||
File.Delete(file);
|
||||
}
|
||||
finally { if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true); }
|
||||
}
|
||||
|
||||
static MemoryStream FixtureArchive(string commit, string? extra = null, bool link = false)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new TarWriter(stream, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
writer.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary<string, string> { ["comment"] = commit }));
|
||||
writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "MeetingAssistant.Tests/MeetingAssistant.Tests.csproj") { DataStream = new MemoryStream(Encoding.UTF8.GetBytes("<Project />")) });
|
||||
if (extra is not null)
|
||||
{
|
||||
var entry = new PaxTarEntry(link ? TarEntryType.SymbolicLink : TarEntryType.RegularFile, extra);
|
||||
if (link) entry.LinkName = "../outside";
|
||||
else entry.DataStream = new MemoryStream([1]);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
static XDocument FixtureTrx(DateTimeOffset started)
|
||||
{
|
||||
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
var definitions = new XElement(ns + "TestDefinitions");
|
||||
var results = new XElement(ns + "Results");
|
||||
for (var index = 0; index < ExpectedTests; index++)
|
||||
{
|
||||
var identity = index < RequiredNativeTests.Length ? RequiredNativeTests[index] : "MeetingAssistant.Tests.ManagedTests.Test" + index;
|
||||
var separator = identity.LastIndexOf('.');
|
||||
definitions.Add(new XElement(ns + "UnitTest", new XAttribute("id", "test-" + index), new XElement(ns + "TestMethod", new XAttribute("className", identity[..separator] + ", MeetingAssistant.Tests"), new XAttribute("name", identity[(separator + 1)..]))));
|
||||
results.Add(new XElement(ns + "UnitTestResult", new XAttribute("testId", "test-" + index), new XAttribute("executionId", "execution-" + index), new XAttribute("outcome", "Passed")));
|
||||
}
|
||||
return new XDocument(new XElement(ns + "TestRun", new XElement(ns + "Times", new XAttribute("start", started.ToString("O")), new XAttribute("finish", started.AddSeconds(1).ToString("O"))), definitions, results, new XElement(ns + "ResultSummary", new XAttribute("outcome", "Completed"), new XElement(ns + "Counters", new XAttribute("total", ExpectedTests), new XAttribute("executed", ExpectedTests), new XAttribute("passed", ExpectedTests), new XAttribute("failed", 0), new XAttribute("notExecuted", 0)))));
|
||||
}
|
||||
static void Reject(Action action)
|
||||
{
|
||||
try { action(); }
|
||||
catch (InvalidOperationException) { return; }
|
||||
throw new InvalidOperationException("Contract validation accepted an invalid or stale fixture.");
|
||||
}
|
||||
sealed record Payload(string RunToken, string SourceCommit, string ArchiveSha256, string SdkVersion, string SdkSha512, int ExpectedTests);
|
||||
sealed record NativeArtifact(string Name, string Sha256, string Architecture);
|
||||
sealed record TestSummary(int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests);
|
||||
sealed record FullResult(string Token, bool Success, string SourceCommit, string ArchiveSha256, string OsVersion, string Architecture, string SdkVersion, int ExpectedTests, int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests, NativeArtifact[] NativeArtifacts, int AudioCodeSignExit, string TrxSha256, string Reason, DateTimeOffset StartedUtc, DateTimeOffset CompletedUtc);
|
||||
sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Apple pre-.NET boot seam, sourced in Recovery and on installed first boot.
|
||||
# A match requires the exact run-owned emulated serial, one whole writable 64-GiB disk.
|
||||
verify_owned_disk() {
|
||||
local disk="$1" state="$2" token="$3" info serial matches bytes
|
||||
[[ "$disk" =~ ^/dev/disk[0-9]+$ && "$token" =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
[ "$(cat "$state/run.owner" 2>/dev/null)" = "$token" ] || return 1
|
||||
serial="${token:0:20}"
|
||||
/usr/sbin/diskutil list physical > "$state/disk-list-current.log" 2>&1 || return 1
|
||||
/usr/bin/grep -Eq "^$disk[[:space:]].*physical" "$state/disk-list-current.log" || return 1
|
||||
/usr/sbin/diskutil info "$disk" > "$state/disk-info-current.log" 2>&1 || return 1
|
||||
info=$(cat "$state/disk-info-current.log")
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*Whole:[[:space:]]*Yes' || return 1
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes' && return 1
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || return 1
|
||||
bytes=$(printf '%s\n' "$info" | /usr/bin/sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p')
|
||||
[ "$bytes" = 68719476736 ] || return 1
|
||||
/usr/sbin/ioreg -r -c IOBlockStorageDevice -l -w 0 > "$state/disk-ownership-ioreg.log" 2>&1 || return 1
|
||||
matches=$(/usr/bin/awk -v expected="$serial" -v disk="${disk#/dev/}" '
|
||||
function finish_root() { if (serialCount == 1 && serial == expected && ownedDisk) found++ }
|
||||
/^\+-o/ { finish_root(); serial=""; serialCount=0; ownedDisk=0 }
|
||||
/"Serial Number"[[:space:]]*=[[:space:]]*"/ {
|
||||
serialCount++; serial=$0; sub(/^.*"Serial Number"[[:space:]]*=[[:space:]]*"/, "", serial); sub(/".*$/, "", serial); sub(/[[:space:]]+$/, "", serial)
|
||||
}
|
||||
/"BSD Name"[[:space:]]*=[[:space:]]*"/ {
|
||||
name=$0; sub(/^.*"BSD Name"[[:space:]]*=[[:space:]]*"/, "", name); sub(/".*$/, "", name)
|
||||
if (name == disk) ownedDisk=1
|
||||
}
|
||||
END { finish_root(); print found+0 }
|
||||
' "$state/disk-ownership-ioreg.log")
|
||||
[ "$matches" = 1 ] || return 1
|
||||
printf '[owned-disk] %s serial=%s bytes=%s\n' "$disk" "$serial" "$bytes"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
# Apple LaunchDaemon bootstrap before the guest .NET SDK exists.
|
||||
# Runs only inside the isolated owned VM; installs CLT and expands the pinned SDK.
|
||||
set -u
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
PROOF_TOKEN="${1:-}"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
WORK="/private/var/tmp/meeting-assistant-native-$PROOF_TOKEN"
|
||||
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || exit 1
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || exit 1
|
||||
exec >> "$STATE_DIR/firstboot.log" 2>&1
|
||||
|
||||
phase() {
|
||||
printf '{"token":"%s","phase":"%s","updatedUtc":"%s"}\n' "$PROOF_TOKEN" "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATE_DIR/guest-phase.json.tmp"
|
||||
mv -f "$STATE_DIR/guest-phase.json.tmp" "$STATE_DIR/guest-phase.json"
|
||||
}
|
||||
fail() { printf '[firstboot] ERROR: %s\n' "$1"; phase bootstrap-failed; exit 1; }
|
||||
require_installed_macos_version() {
|
||||
# Numeric sw_vers product release with a major version of at least 14.
|
||||
[[ "${1:-}" =~ ^(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,})\.[0-9]+(\.[0-9]+)?$ ]]
|
||||
}
|
||||
phase toolchain-installing
|
||||
echo '[firstboot] verifying installed OS, APFS and owned physical store'
|
||||
uname -a; id
|
||||
installed_version=$(/usr/bin/sw_vers -productVersion) || fail installed_os_version_failed
|
||||
require_installed_macos_version "$installed_version" || fail installed_macos_14_or_newer_required
|
||||
printf '[firstboot] installed macOS version=%s\n' "$installed_version"
|
||||
[ "$(id -u)" = 0 ] && [ "$(uname -m)" = x86_64 ] || fail wrong_guest_platform
|
||||
/usr/sbin/diskutil info -plist / > "$STATE_DIR/installed-root.plist" || fail root_diskutil_failed
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :FilesystemType' "$STATE_DIR/installed-root.plist")" = apfs ] || fail root_is_not_installed_apfs
|
||||
container=$(/usr/libexec/PlistBuddy -c 'Print :APFSContainerReference' "$STATE_DIR/installed-root.plist") || fail root_container_missing
|
||||
/usr/sbin/diskutil apfs list -plist > "$STATE_DIR/apfs-containers.plist" || fail apfs_list_failed
|
||||
index=0
|
||||
physical=""
|
||||
while reference=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:ContainerReference" "$STATE_DIR/apfs-containers.plist" 2>/dev/null); do
|
||||
if [ "$reference" = "$container" ]; then
|
||||
[ -z "$physical" ] || fail ambiguous_root_containers
|
||||
physical=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:0:DeviceIdentifier" "$STATE_DIR/apfs-containers.plist") || fail physical_store_missing
|
||||
/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:1" "$STATE_DIR/apfs-containers.plist" >/dev/null 2>&1 && fail multiple_physical_stores
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
[[ "$physical" =~ ^disk[0-9]+s[0-9]+$ ]] || fail invalid_physical_store
|
||||
/usr/sbin/diskutil info -plist "/dev/$physical" > "$STATE_DIR/physical-store.plist" || fail physical_store_info_failed
|
||||
whole=$(/usr/libexec/PlistBuddy -c 'Print :ParentWholeDisk' "$STATE_DIR/physical-store.plist") || fail physical_parent_missing
|
||||
. "$STATE_DIR/macos-native-disk-guard.sh"
|
||||
verify_owned_disk "/dev/$whole" "$STATE_DIR" "$PROOF_TOKEN" || fail installed_root_is_not_the_owned_64g_disk
|
||||
|
||||
# The phase has an independent 30-minute watchdog; host outer deadline is 180 minutes.
|
||||
parent=$$
|
||||
(
|
||||
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||
# Toolchain watchdog: 30 minutes, also bounded by the host's 172-minute total.
|
||||
sleep 1800 & sleeper=$!; wait "$sleeper"; kill -TERM "$parent" 2>/dev/null || :
|
||||
) & watchdog=$!
|
||||
clt_marker="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress"
|
||||
trap 'rm -f "$clt_marker"; kill -TERM "$watchdog" 2>/dev/null || :; wait "$watchdog" 2>/dev/null || :' EXIT
|
||||
trap 'fail toolchain_deadline_or_cancellation' TERM INT
|
||||
[ ! -e "$WORK" ] || fail guest_work_directory_already_exists
|
||||
mkdir -p "$WORK" || fail guest_work_directory_failed
|
||||
printf '%s\n' "$PROOF_TOKEN" > "$WORK/run.owner" || fail guest_work_owner_failed
|
||||
free_kib=$(df -Pk "$WORK" | awk 'NR==2 {print $4}')
|
||||
[[ "$free_kib" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || fail insufficient_existing_guest_free_space
|
||||
|
||||
echo '[firstboot] installing a compatible Apple CLT catalog entry without a GUI'
|
||||
touch "$clt_marker" || fail clt_marker_failed
|
||||
/usr/sbin/softwareupdate -l > "$STATE_DIR/clt-catalog.log" 2>&1 || fail clt_catalog_failed
|
||||
label=$(grep -B 1 -E 'Command Line Tools' "$STATE_DIR/clt-catalog.log" | awk -F'*' '/^ *\*/ {print $2}' | sed -e 's/^ *Label: //' -e 's/^ *//' | sort -V | tail -n 1)
|
||||
[ -n "$label" ] || fail no_compatible_headless_clt_label
|
||||
printf '[firstboot] selected CLT: %s\n' "$label"
|
||||
/usr/sbin/softwareupdate -i "$label" > "$STATE_DIR/clt-install.log" 2>&1 || fail clt_install_failed
|
||||
/usr/bin/xcode-select --switch /Library/Developer/CommandLineTools || fail clt_switch_failed
|
||||
/usr/sbin/pkgutil --pkg-info=com.apple.pkg.CLTools_Executables || fail clt_receipt_failed
|
||||
/usr/bin/xcrun --find swiftc || fail swiftc_missing
|
||||
/usr/bin/xcrun swiftc --version || fail swiftc_version_failed
|
||||
{
|
||||
/usr/bin/xcrun --sdk macosx --show-sdk-version &&
|
||||
/usr/bin/xcrun --sdk macosx --show-sdk-path
|
||||
} > "$STATE_DIR/clt-sdk.log" 2>&1 || fail clt_sdk_identity_failed
|
||||
printf 'import AppKit\nimport AVFoundation\nimport ScreenCaptureKit\nimport EventKit\nimport WebKit\nprint("native SDK ready")\n' > "$WORK/toolchain-smoke.swift"
|
||||
/usr/bin/xcrun swiftc -target x86_64-apple-macos13.0 "$WORK/toolchain-smoke.swift" -o "$WORK/toolchain-smoke" || fail native_framework_compile_failed
|
||||
"$WORK/toolchain-smoke" || fail native_framework_execution_failed
|
||||
rm -f "$clt_marker"
|
||||
|
||||
echo '[firstboot] validating and expanding the pinned .NET SDK on owned APFS'
|
||||
expected_sdk=33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0
|
||||
actual_sdk=$(shasum -a 512 "$STATE_DIR/sdk.tar.gz" | awk '{print $1}')
|
||||
[ "$actual_sdk" = "$expected_sdk" ] || fail sdk_hash_mismatch
|
||||
mkdir "$WORK/dotnet" || fail sdk_directory_failed
|
||||
tar -xzf "$STATE_DIR/sdk.tar.gz" -C "$WORK/dotnet" || fail sdk_extract_failed
|
||||
[ "$("$WORK/dotnet/dotnet" --version)" = 10.0.401 ] || fail sdk_version_mismatch
|
||||
# Guest C# owns build/test deadlines from this point; stop the CLT-only watchdog.
|
||||
kill -TERM "$watchdog" 2>/dev/null || :
|
||||
wait "$watchdog" 2>/dev/null || :
|
||||
trap - TERM INT
|
||||
"$WORK/dotnet/dotnet" run --file "$STATE_DIR/MacOsNativeGuest.cs" -- --run --state "$STATE_DIR" --work "$WORK"
|
||||
result=$?
|
||||
(( result == 0 )) || fail native_tests_failed
|
||||
exit 0
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Full-only pre-.NET seam. Claim one persistent owned probe before forking;
|
||||
# launchd then execs the original Apple daemon, including on a real failure.
|
||||
set -u
|
||||
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
MARKER="$STATE_DIR/probe.started"
|
||||
|
||||
bootstrap_failed() {
|
||||
printf '[full-bootstrap] ERROR: %s\n' "$1" >&2
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
|
||||
printf '{"token":"%s","phase":"bootstrap-failed","reason":"%s"}\n' "$PROOF_TOKEN" "$1" > "$STATE_DIR/guest-phase.bootstrap.$$.tmp" &&
|
||||
/bin/mv -f "$STATE_DIR/guest-phase.bootstrap.$$.tmp" "$STATE_DIR/guest-phase.json"
|
||||
}
|
||||
|
||||
wait_for_owned_state() {
|
||||
local count=0
|
||||
while :; do
|
||||
if [ -e "$STATE_DIR/run.owner" ] || [ -L "$STATE_DIR/run.owner" ]; then
|
||||
[ -f "$STATE_DIR/run.owner" ] && [ ! -L "$STATE_DIR/run.owner" ] &&
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || {
|
||||
bootstrap_failed foreign_state_owner
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
if (( count >= 120 )); then
|
||||
bootstrap_failed state_share_mount_timeout
|
||||
return 1
|
||||
fi
|
||||
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
owns_probe_marker() {
|
||||
local record extra
|
||||
[ -f "$MARKER" ] && [ ! -L "$MARKER" ] || return 1
|
||||
{
|
||||
IFS= read -r record || return 1
|
||||
if IFS= read -r extra || [ -n "$extra" ]; then return 1; fi
|
||||
} < "$MARKER"
|
||||
[ "$record" = "$PROOF_TOKEN:$source_commit" ]
|
||||
}
|
||||
|
||||
claim_probe() {
|
||||
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || { bootstrap_failed invalid_probe_token; return 1; }
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || { bootstrap_failed foreign_state_owner; return 1; }
|
||||
source_commit=$(cat "$STATE_DIR/source.commit" 2>/dev/null) || { bootstrap_failed source_commit_missing; return 1; }
|
||||
[[ "$source_commit" =~ ^[0-9a-f]{40}$ ]] || { bootstrap_failed source_commit_invalid; return 1; }
|
||||
if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then
|
||||
owns_probe_marker || { bootstrap_failed foreign_or_invalid_probe_marker; return 1; }
|
||||
return 2
|
||||
fi
|
||||
# Keep partial/failed markers: an incomplete first start is an error, no retry.
|
||||
if ! ( set -o noclobber; printf '%s:%s\n' "$PROOF_TOKEN" "$source_commit" > "$MARKER" ); then
|
||||
bootstrap_failed probe_marker_write_failed
|
||||
return 1
|
||||
fi
|
||||
owns_probe_marker || { bootstrap_failed probe_marker_incomplete; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
if wait_for_owned_state && claim_probe; then
|
||||
(
|
||||
/bin/bash "$STATE_DIR/readiness.sh"
|
||||
child_exit=$?
|
||||
(( child_exit == 0 )) || bootstrap_failed first_probe_child_failed
|
||||
) &
|
||||
fi
|
||||
exec /usr/libexec/recoveryosd
|
||||
@@ -99,31 +99,61 @@ read_scalar() {
|
||||
SCALAR="$value"
|
||||
}
|
||||
|
||||
# BEGIN successful sw_vers version parser
|
||||
read_product_version() {
|
||||
local value status line version="" fields=0
|
||||
# Read the entire successful native output. EOF is mandatory; a NUL delimiter
|
||||
# or reaching the 1025-byte sentinel must never hide a suffix.
|
||||
LC_ALL=C IFS= read -r -n 1025 -d '' value < "$LAST_OUTPUT"; status=$?
|
||||
(( status == 1 && ${#value} <= 1024 )) || return 1
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
if [[ "$line" =~ ^[[:blank:]]*ProductVersion: ]]; then
|
||||
fields=$((fields + 1))
|
||||
(( fields == 1 )) || return 1
|
||||
[[ "$line" =~ ^[[:blank:]]*ProductVersion:[[:blank:]]*([0-9]+\.[0-9]+(\.[0-9]+)?)[[:blank:]]*$ ]] || return 1
|
||||
version="${BASH_REMATCH[1]}"
|
||||
# BEGIN native SystemVersion plist request helpers
|
||||
read_native_system_version() {
|
||||
# Recovery has Bash 3.2 before .NET. Read bytes here; XML is parsed by the
|
||||
# existing owned host controller, never by shell or VERSION metadata.
|
||||
local LC_ALL=C source="/System/Library/CoreServices/SystemVersion.plist"
|
||||
local value status owner reply started=$SECONDS invalid_bytes
|
||||
local raw="$STATE_DIR/native-system-version.plist"
|
||||
local ready="$STATE_DIR/native-system-version.request"
|
||||
local response="$STATE_DIR/native-system-version.reply"
|
||||
NATIVE_VERSION_ERROR=native_system_version_read_failed
|
||||
printf '[native-version-start] method=guest-file/host-xml source=%s seconds=%s\n' "$source" "$started" >&3
|
||||
IFS= read -r -n 81 -d '' owner < "$STATE_DIR/run.owner"; status=$?
|
||||
(( status == 1 && ${#owner} <= 80 )) &&
|
||||
[ "$owner" = "$PROOF_TOKEN"$'\n' ] || { NATIVE_VERSION_ERROR=native_system_version_foreign_owner; return 1; }
|
||||
[ ! -e "$ready" ] && [ ! -L "$ready" ] &&
|
||||
[ ! -e "$raw" ] && [ ! -L "$raw" ] &&
|
||||
[ ! -e "$response" ] && [ ! -L "$response" ] || { NATIVE_VERSION_ERROR=native_system_version_stale_exchange; return 1; }
|
||||
[ -f "$source" ] || return 1
|
||||
IFS= read -r -n 4097 -d '' value < "$source"; status=$?
|
||||
# EOF is mandatory. NUL stops read with status 0; 4097 is the overbound sentinel.
|
||||
(( status == 1 && ${#value} > 0 && ${#value} <= 4096 )) || { NATIVE_VERSION_ERROR=native_system_version_invalid_bytes; return 1; }
|
||||
invalid_bytes=${value//$'\t'/}
|
||||
invalid_bytes=${invalid_bytes//$'\r'/}
|
||||
invalid_bytes=${invalid_bytes//$'\n'/}
|
||||
[[ "$invalid_bytes" =~ [[:cntrl:]] ]] && { NATIVE_VERSION_ERROR=native_system_version_binary; return 1; }
|
||||
printf '%s' "$value" > "$raw" || return 1
|
||||
# Publish this final marker only after the complete raw write has closed.
|
||||
printf '%s\n%s\n%s\nready:%s\n' "$PROOF_TOKEN" "$source" "${#value}" "$PROOF_TOKEN" > "$ready" || return 1
|
||||
printf '[native-version-request] method=guest-file/host-xml source=%s bytes=%s seconds=%s\n' "$source" "${#value}" "$SECONDS" >&3
|
||||
while (( SECONDS - started < 180 )); do
|
||||
if [ -e "$response" ] || [ -L "$response" ]; then
|
||||
[ -f "$response" ] && [ ! -L "$response" ] || { NATIVE_VERSION_ERROR=native_system_version_invalid_reply; return 1; }
|
||||
IFS= read -r -n 81 -d '' reply < "$response"; status=$?
|
||||
(( status == 1 && ${#reply} <= 80 )) && [[ "$reply" = *$'\n' ]] || { NATIVE_VERSION_ERROR=native_system_version_invalid_reply; return 1; }
|
||||
reply=${reply%$'\n'}
|
||||
if [[ "$reply" =~ ^([0-9a-f]{32}):([0-9]+\.[0-9]+(\.[0-9]+)?)$ ]] && [ "${BASH_REMATCH[1]}" = "$PROOF_TOKEN" ]; then
|
||||
SCALAR="${BASH_REMATCH[2]}"
|
||||
NATIVE_VERSION_ERROR=""
|
||||
printf '[native-version-result] method=guest-file/host-xml source=%s version=%s elapsed=%ss\n' "$source" "$SCALAR" "$((SECONDS - started))" >&3
|
||||
return 0
|
||||
fi
|
||||
NATIVE_VERSION_ERROR=native_system_version_rejected_reply
|
||||
return 1
|
||||
fi
|
||||
done <<< "$value"
|
||||
(( fields == 1 )) || return 1
|
||||
SCALAR="$version"
|
||||
IFS= read -r -t 1 -u 9 unused || :
|
||||
done
|
||||
NATIVE_VERSION_ERROR=native_system_version_reply_timeout
|
||||
return 1
|
||||
}
|
||||
|
||||
# END successful sw_vers version parser
|
||||
# END native SystemVersion plist request helpers
|
||||
# BEGIN disk IPC diagnostic
|
||||
# Optional observations have their own child/timer ownership. No service is
|
||||
# loaded, restarted or changed, and samples target only this probe's diskutil.
|
||||
# Optional observations have their own child/timer ownership. Thread state/time
|
||||
# targets only this probe's diskutil and does not request stack symbolication.
|
||||
observe_disk_query() {
|
||||
local disk_process="$1" output="$2" sample_output="$3" observation_child="" observation_timer=""
|
||||
local disk_process="$1" output="$2" observation_child="" observation_timer=""
|
||||
cancel_observation() {
|
||||
trap '' TERM INT
|
||||
if [ -n "$observation_child" ]; then
|
||||
@@ -161,14 +191,14 @@ observe_disk_query() {
|
||||
}
|
||||
trap cancel_observation TERM INT
|
||||
printf '[disk-observation] owned-diskutil-child=%s parent-shell=%s\n' "$disk_process" "$$" >> "$output"
|
||||
if [ -x /usr/bin/sample ]; then
|
||||
if [ -x /bin/ps ]; then
|
||||
if kill -0 "$disk_process" 2>/dev/null; then
|
||||
observe_command diskutil-sample /usr/bin/sample "$disk_process" 3 100 -file "$sample_output"
|
||||
observe_command diskutil-threads /bin/ps -M -p "$disk_process"
|
||||
else
|
||||
printf '[disk-observation-unavailable] diskutil already exited before sample\n' >> "$output"
|
||||
printf '[disk-observation-unavailable] diskutil already exited before thread observation\n' >> "$output"
|
||||
fi
|
||||
else
|
||||
printf '[disk-observation-unavailable] /usr/bin/sample is unavailable\n' >> "$output"
|
||||
printf '[disk-observation-unavailable] /bin/ps is unavailable\n' >> "$output"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -229,13 +259,11 @@ run_command() {
|
||||
# BEGIN disk IPC diagnostic
|
||||
if [[ "$name" == disks && "$attempt" == 1 ]]; then
|
||||
local observation_output="/tmp/native-diagnostic-disk-observation.out"
|
||||
local sample_output="/tmp/native-diagnostic-disk-sample.out"
|
||||
: > "$observation_output"
|
||||
: > "$sample_output"
|
||||
observe_disk_query "$process" "$observation_output" "$sample_output" &
|
||||
observe_disk_query "$process" "$observation_output" &
|
||||
ACTIVE_OBSERVER=$!
|
||||
printf '[disk-observation-start] observer=%s owned-diskutil-child=%s\n' "$ACTIVE_OBSERVER" "$process" >&3
|
||||
PENDING_OUTPUTS+=("$observation_output" "$sample_output")
|
||||
PENDING_OUTPUTS+=("$observation_output")
|
||||
fi
|
||||
# END disk IPC diagnostic
|
||||
wait "$process"
|
||||
@@ -285,27 +313,12 @@ run_command uid /usr/bin/id -u
|
||||
read_scalar || fail_probe uid_output_invalid
|
||||
uid="$SCALAR"
|
||||
[ "$uid" = 0 ] || fail_probe recovery_account_not_root
|
||||
run_command platform /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
if (( platform_exit != 0 )); then
|
||||
run_command system /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >&3
|
||||
run_command platform-warm /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
fi
|
||||
(( platform_exit == 0 )) || fail_probe sw_vers_failed
|
||||
# BEGIN successful sw_vers version extraction
|
||||
read_product_version || fail_probe product_version_invalid
|
||||
# END successful sw_vers version extraction
|
||||
# BEGIN native SystemVersion plist getter
|
||||
read_native_system_version || fail_probe "$NATIVE_VERSION_ERROR"
|
||||
# END native SystemVersion plist getter
|
||||
os_version="$SCALAR"
|
||||
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid
|
||||
(( ${os_version%%.*} >= 13 )) || fail_probe unsupported_macos_version
|
||||
(( ${os_version%%.*} >= 14 )) || fail_probe unsupported_macos_version
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
|
||||
# BEGIN disk IPC diagnostic
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
; Bare 64-KiB BIOS for the existing Linux QEMU binary, not macOS firmware.
|
||||
; Assemble: nasm -f bin -o ci-cpu-preflight.bin macos-tcg-cpu-preflight.asm
|
||||
; No disks/network. isa-debug-exit returns 33 only after AVX + AVX2 execute
|
||||
; and the upper 128-bit lane contains the expected arithmetic result.
|
||||
; Unsupported instructions/triple faults cannot produce the success code.
|
||||
BITS 16
|
||||
ORG 0
|
||||
start:
|
||||
cli
|
||||
cld
|
||||
xor ax, ax
|
||||
mov ds, ax
|
||||
mov es, ax
|
||||
mov ss, ax
|
||||
mov sp, 0x8000
|
||||
|
||||
; QEMU zeroes fresh RAM. Identity-map the first 2 MiB through three tables.
|
||||
mov dword [0x1000], 0x2003
|
||||
mov dword [0x2000], 0x3003
|
||||
mov dword [0x3000], 0x0083
|
||||
lgdt [cs:gdt_descriptor]
|
||||
mov eax, 0x40620 ; PAE, OSFXSR, OSXMMEXCPT, OSXSAVE
|
||||
mov cr4, eax
|
||||
mov eax, 0x1000
|
||||
mov cr3, eax
|
||||
mov ecx, 0xc0000080 ; EFER.LME
|
||||
rdmsr
|
||||
or eax, 0x100
|
||||
wrmsr
|
||||
mov eax, cr0
|
||||
and eax, ~0x0c ; clear EM and TS before vector instructions
|
||||
or eax, 0x80000003 ; paging, protected mode, monitor coprocessor
|
||||
mov cr0, eax
|
||||
jmp dword 0x08:(0xf0000 + long_mode)
|
||||
|
||||
ALIGN 8
|
||||
gdt:
|
||||
dq 0
|
||||
dq 0x00af9a000000ffff ; ring-0 long-mode code, base 0
|
||||
dq 0x00cf92000000ffff ; ring-0 data, base 0
|
||||
gdt_descriptor:
|
||||
dw gdt_descriptor - gdt - 1
|
||||
dd 0xf0000 + gdt
|
||||
|
||||
BITS 64
|
||||
long_mode:
|
||||
mov ax, 0x10
|
||||
mov ds, ax
|
||||
mov es, ax
|
||||
mov ss, ax
|
||||
mov rsp, 0x8000
|
||||
xor ecx, ecx
|
||||
mov eax, 7 ; XCR0 enables x87, SSE and AVX state
|
||||
xor edx, edx
|
||||
xsetbv
|
||||
vxorps ymm0, ymm0, ymm0 ; AVX, including the upper YMM lane
|
||||
vpcmpeqd ymm1, ymm1, ymm1 ; AVX2: all eight int32 lanes become -1
|
||||
vpsrld ymm1, ymm1, 31 ; AVX2: all lanes become 1
|
||||
vpaddd ymm2, ymm1, ymm1 ; AVX2: all lanes become 2
|
||||
vextracti128 xmm3, ymm2, 1 ; AVX2: inspect the upper half, not only SSE
|
||||
vmovd eax, xmm3
|
||||
cmp eax, 2
|
||||
jne fail
|
||||
vzeroupper
|
||||
mov eax, 0x10 ; QEMU debugexit computes (value << 1) | 1
|
||||
jmp exit_qemu
|
||||
fail:
|
||||
mov eax, 0x11
|
||||
exit_qemu:
|
||||
mov dx, 0xf4
|
||||
out dx, eax
|
||||
hlt
|
||||
jmp $
|
||||
|
||||
; CPU reset starts at the last 16 bytes; reload the real-mode CS base.
|
||||
BITS 16
|
||||
TIMES 0xfff0 - ($ - $$) db 0xff
|
||||
jmp 0xf000:start
|
||||
TIMES 0x10000 - ($ - $$) db 0xff
|
||||
Reference in New Issue
Block a user