forked from Manuel/meeting-assistant
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147c544496 | ||
|
|
94a70b2005 | ||
|
|
45d7bde71f | ||
|
|
4606de0696 | ||
|
|
40281b57a5 | ||
|
|
c92e62bdf5 | ||
|
|
b40234d3b5 | ||
|
|
0a30a1ca5a | ||
|
|
6b1896660f | ||
|
|
9a91a81992 |
@@ -0,0 +1,36 @@
|
||||
name: macOS 13 KVM Cryptex Recovery compatibility diagnostic
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
macos-native-diagnostic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout diagnostic source
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup .NET for the diagnostic helper
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Probe macOS 13 Recovery with existing KVM and host CPU
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||
|
||||
- name: Always clean up only this diagnostic's owned resources
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||
|
||||
- name: Preserve native diagnostic evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: native-macos-recovery-diagnostic
|
||||
path: artifacts/native-macos/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -112,6 +112,15 @@ jobs:
|
||||
--nologo
|
||||
test -s MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll
|
||||
|
||||
- name: Measure transcript reader sharing on the Windows Wine host
|
||||
run: |
|
||||
mkdir -p artifacts/tests
|
||||
transcript_probe_exit=0
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" run \
|
||||
--file tools/ci/TranscriptFileShareProbe.cs > artifacts/tests/transcript-file-sharing.json || transcript_probe_exit=$?
|
||||
cat artifacts/tests/transcript-file-sharing.json
|
||||
exit "${transcript_probe_exit}"
|
||||
|
||||
- name: Run tests via Wine (Windows dotnet host)
|
||||
run: |
|
||||
rm -f artifacts/tests/wine.trx
|
||||
|
||||
@@ -191,18 +191,21 @@ public sealed class RecordingCoordinatorTests
|
||||
NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(
|
||||
NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var writeProbe = new TranscriptWriteDiagnostic();
|
||||
var transcriptStore = new DiagnosticTranscriptStore(
|
||||
new VaultTranscriptStore(Options.Create(options), NullLogger<VaultTranscriptStore>.Instance),
|
||||
writeProbe);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FixedSegmentStreamingTranscriptionProvider(
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(5),
|
||||
"Guest-1",
|
||||
"Azure returned ***** here."))),
|
||||
new VaultTranscriptStore(
|
||||
Options.Create(options),
|
||||
NullLogger<VaultTranscriptStore>.Instance),
|
||||
new DiagnosticTranscriptionProvider(
|
||||
new FixedSegmentStreamingTranscriptionProvider(
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(5),
|
||||
"Guest-1",
|
||||
"Azure returned ***** here.")), writeProbe)),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
@@ -218,9 +221,45 @@ public sealed class RecordingCoordinatorTests
|
||||
NullLogger<MeetingWorkflowEngine>.Instance));
|
||||
|
||||
var started = await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "Azure returned"));
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
Exception? waitFailure = null;
|
||||
Exception? stopFailure = null;
|
||||
try
|
||||
{
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
writeProbe.Record("test-audio-enqueued");
|
||||
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "Azure returned"));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
waitFailure = exception;
|
||||
writeProbe.Record("test-wait-failed", exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
writeProbe.Record("test-stop-completed");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
stopFailure = exception;
|
||||
writeProbe.Record("test-stop-failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
if (waitFailure is TimeoutException)
|
||||
{
|
||||
throw new TimeoutException($"{waitFailure.Message} {writeProbe.Describe()}", waitFailure);
|
||||
}
|
||||
if (waitFailure is not null)
|
||||
{
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(waitFailure).Throw();
|
||||
}
|
||||
if (stopFailure is not null)
|
||||
{
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(stopFailure).Throw();
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(started.TranscriptPath!);
|
||||
Assert.Contains("[00:00:04] Guest-1: Azure returned [redacted] here.", content);
|
||||
@@ -5212,6 +5251,94 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
// Temporary diagnosis of Run4174. Observes public provider/store boundaries only.
|
||||
private sealed class TranscriptWriteDiagnostic
|
||||
{
|
||||
private readonly System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||
private readonly ConcurrentQueue<string> events = new();
|
||||
|
||||
public void Record(string name, Exception? error = null)
|
||||
{
|
||||
events.Enqueue($"{elapsed.ElapsedMilliseconds}ms:{name}" + (error is null ? "" :
|
||||
$":{error.GetType().FullName}:HResult=0x{error.HResult:X8}:{error.Message}"));
|
||||
}
|
||||
|
||||
public string Describe() => "[DEBUG-transcript-write-4174] " + string.Join(" | ", events);
|
||||
}
|
||||
|
||||
private sealed class DiagnosticTranscriptionProvider(
|
||||
IStreamingTranscriptionProvider inner,
|
||||
TranscriptWriteDiagnostic probe) : IStreamingTranscriptionProvider
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var segment in inner.TranscribeAsync(ObserveAudioAsync(audio, cancellationToken), options, cancellationToken))
|
||||
{
|
||||
probe.Record("fake-segment-yielded");
|
||||
yield return segment;
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> ObserveAudioAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
probe.Record("fake-audio-consumed");
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DiagnosticTranscriptStore(
|
||||
ITranscriptStore inner,
|
||||
TranscriptWriteDiagnostic probe) : ITranscriptStore
|
||||
{
|
||||
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken) =>
|
||||
inner.CreateSessionAsync(cancellationToken);
|
||||
public Task<TranscriptSession> CreateSessionAsync(MeetingAssistantOptions options, DateTimeOffset startedAt, CancellationToken cancellationToken) =>
|
||||
inner.CreateSessionAsync(options, startedAt, cancellationToken);
|
||||
public Task ReplaceLinesAsync(TranscriptSession session, IReadOnlyList<string> replacementLines, CancellationToken cancellationToken) =>
|
||||
inner.ReplaceLinesAsync(session, replacementLines, cancellationToken);
|
||||
public Task UpdateMetadataAsync(TranscriptSession session, MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) =>
|
||||
inner.UpdateMetadataAsync(session, artifacts, meetingNote, cancellationToken);
|
||||
|
||||
public async Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
|
||||
{
|
||||
probe.Record("append-entered");
|
||||
try
|
||||
{
|
||||
var reference = await inner.AppendLineAsync(session, line, cancellationToken);
|
||||
probe.Record("append-completed");
|
||||
return reference;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
probe.Record("append-failed", exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReplaceLineAsync(TranscriptSession session, TranscriptLineReference lineReference, string replacementLine, CancellationToken cancellationToken)
|
||||
{
|
||||
probe.Record("rewrite-entered");
|
||||
try
|
||||
{
|
||||
await inner.ReplaceLineAsync(session, lineReference, replacementLine, cancellationToken);
|
||||
probe.Record("rewrite-completed");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
probe.Record("rewrite-failed", exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly IStreamingTranscriptionProvider provider;
|
||||
|
||||
@@ -175,6 +175,8 @@ Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests req
|
||||
|
||||
[Docker-OSX](https://github.com/sickcodes/Docker-OSX) runs a macOS VM rather than providing a Wine-style compatibility layer. Its launcher supports software emulation with `KVM=accel=tcg`, so KVM is not an absolute requirement. A supported .NET 10 guest needs macOS 14 or later plus the Swift build tools. The documented `auto` build downloads a preinstalled guest disk through `IMAGE_URL`; its documented ready-made tags and disk downloads were unavailable when checked on 2026-10-03. No verified native guest bootstrap is owned by this repository. CI validates source; it does not publish or deploy the workstation application.
|
||||
|
||||
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness through an unprivileged TCG guest on the existing Ubuntu Docker runner. It neither installs macOS nor runs application tests; its result is a prerequisite for a future native test job, not verification of macOS CI support.
|
||||
|
||||
## Operations And Limitations
|
||||
|
||||
- Treat recording, transcription drain, speaker finalization, OCR, and summarization as live user work. Never restart, kill, or clean runtime files until `/recording/status` is idle unless interruption is explicitly intended.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# macOS 13 KVM/Cryptex compatibility 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.
|
||||
|
||||
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.
|
||||
|
||||
## Reasons and remaining gaps
|
||||
|
||||
The existing daemon's Intel Celeron 1037U lacks AVX/AVX2; a separate diagnostic proved KVM enabled/paused state and clean exit. `CPU_MODEL=host` preserves actual instruction availability rather than advertising AVX2 through emulated Skylake. This candidate refuses a TCG or CPU-model fallback.
|
||||
|
||||
All four Swift helpers target `x86_64-apple-macos13.0`; the macOS 14 EventKit call has an existing macOS 13 fallback. Inspected native Mach-O files in pinned .NET SDK 10.0.401 x64 declare `minos 12.0`. These source/binary minima are not runtime qualification or vendor support: macOS 13 is outside [Microsoft's current .NET 10 supported-OS policy](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md). This probe does not install that SDK, compile helpers or test calendar/audio permissions.
|
||||
|
||||
[Official CryptexFixup 1.0.5](https://github.com/acidanthera/CryptexFixup/blob/1.0.5/CryptexFixup/kern_start.cpp) activates without AVX2 and registers for normal, installer/Recovery and safe-mode boots. It redirects installer/updater ramrod to Apple Silicon's Rosetta Cryptex and bypasses APFS root-hash authentication on Ventura and newer. It does not emulate missing instructions. This kernel patch affects only the owned guest, never a host module.
|
||||
|
||||
**Recovery cache gap:** CryptexFixup does not replace an already running Recovery BaseSystem shared cache. Its installer/update selector targets the installed Cryptex, but this readiness-only run invokes no installer. Staging or loading it therefore proves no Recovery userland compatibility. Actual CPU/kernel behavior, guest injection, all native gates and any later installed-Cryptex/build/test behavior remain unqualified until observed.
|
||||
|
||||
Apple Recovery uses the pinned public InternetRecovery protocol with board ID and session/asset tokens, without Apple ID or workstation credentials. The macOS 13 selection, downloaded hash and actual guest version are retained; the hook downloads no full installer or SDK.
|
||||
|
||||
## Entry point and dependencies
|
||||
|
||||
Orchestration/validation remain the .NET 10 file-based app `tools/ci/MacOsNativeDiagnostic.cs`. Bash/Python stay only in the existing pinned Linux/macOS boot integration.
|
||||
|
||||
~~~sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --help
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/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 -- --run --output artifacts/native-macos
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||
~~~
|
||||
|
||||
## Exact bootasset contract
|
||||
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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 [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.
|
||||
|
||||
[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.
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
## Gates, privileges and cleanup
|
||||
|
||||
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.
|
||||
|
||||
Readiness changes only minimum macOS 14 to 13. Validation normalizes that gate to 14 and requires baseline SHA256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. Architecture, UID, services, disk size/writability/uniqueness, retries, proof bounds, timers and native-wait/cleanup/flush metrics remain identical. Limits stay 45 seconds per native command, 180 seconds for UID, ten minutes disk readiness, 40 minutes host and 45 minutes workflow.
|
||||
|
||||
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.
|
||||
|
||||
Only device mapping: exactly `/dev/kvm:/dev/kvm:rw`. Inspection rejects other devices/permissions, added capabilities, device requests/rules, binds, tmpfs overrides, published ports, host networking, privileged mode, wrong limits, unexpected persistent mounts or changed CPU/OS profile. No host modules, infrastructure, secrets, SSH or app lifecycle actions are involved. Guest slirp networking remains.
|
||||
|
||||
Evidence retains run/profile identity, source/assets, EFI staging, container/resources, macOS 13 Recovery hash, native proof/result/outcome and cleanup. `[recovery-original]` logs the exact download's size/SHA256 before modifying it, including when patch failure later deletes the source. `guest-container-resources.last-success.stdout.log` and its timestamp/hash receipt preserve the last successful resource snapshot independently of a later failed stopped-container `docker exec`. Optional final Unix HMP capture includes `info kvm`, `info status` and a bounded PPM exported from `/tmp`; capture success passes no native gate.
|
||||
|
||||
Both cleanup paths keep exact token/label/ID checks. `docker rm --force --volumes` removes only the owned container and anonymous volume, then its exact image; no unrelated objects or pruning. Evidence stays seven days. Full native CI still needs a subsequent actual installed remote guest to build/sign helpers and pass the full suite, including five native tests without skips.
|
||||
@@ -0,0 +1,767 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
|
||||
// .NET 10 file-based CI diagnostic. See docs/macos-native-diagnostic.md.
|
||||
return await NativeDiagnostic.Execute(args);
|
||||
|
||||
static class NativeDiagnostic
|
||||
{
|
||||
const string DockurCommit = "16a5b470cdd601bae8b05b02d748d7edfb36c12e";
|
||||
const string CryptexUrl = "https://github.com/acidanthera/CryptexFixup/releases/download/1.0.5/CryptexFixup-1.0.5-RELEASE.zip";
|
||||
const string CryptexHash = "25041d94a0fe9a0261caf0ba89b36dfcb21682bf3c697a34bcaddc839576ab30";
|
||||
const string OpenCoreTemplateHash = "287328995d4198f1b05166f087d85bf7ef66bedafe150d17ad112ac8de60051d";
|
||||
const string UdifChecksumBindingHash = "6109d04619e800c483fdac363d593cd1cd69f34131d2521417334e11d41c8bfa";
|
||||
const string OwnerLabel = "org.meeting-assistant.native-diagnostic";
|
||||
const long GuestDiskBytes = 64L * 1024 * 1024 * 1024;
|
||||
const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024;
|
||||
const int MaximumCapturedCharacters = 8 * 1024 * 1024;
|
||||
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
||||
const string OriginalBootstrap = "[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m\n";
|
||||
const string MountOnlyBootstrap = "[ ! -e /tmp/m ]&& /sbin/mount_9p installstate >/dev/null 2>&1; : >/tmp/m\n";
|
||||
static readonly string OriginalDaemon = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
\t<key>Label</key>
|
||||
\t<string>com.apple.recoveryosd</string>
|
||||
\t<key>OnDemand</key>
|
||||
\t<false/>
|
||||
\t<key>ProcessType</key>
|
||||
\t<string>App</string>
|
||||
\t<key>EnablePressuredExit</key>
|
||||
\t<false/>
|
||||
\t<key>ProgramArguments</key>
|
||||
\t<array>
|
||||
\t\t<string>/usr/libexec/recoveryosd</string>
|
||||
\t</array>
|
||||
</dict>
|
||||
</plist>
|
||||
""".Replace("\\t", "\t", StringComparison.Ordinal);
|
||||
static readonly string DiagnosticDaemon = (OriginalDaemon + "\n")
|
||||
.Replace("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n", "", StringComparison.Ordinal)
|
||||
.Replace("\t\t<string>/usr/libexec/recoveryosd</string>", "\t\t<string>/bin/bash</string>\n\t\t<string>/Volumes/installstate/launch.sh</string>", StringComparison.Ordinal);
|
||||
// Exact XML framing read from the Apple 13 comparison download, not an assertion
|
||||
// about the unretained bytes downloaded by run 4173.
|
||||
static readonly string OriginalDaemon13 = ReplaceOnce(OriginalDaemon + "\n", "<string>App</string>", "<string>Interactive</string>");
|
||||
static readonly string DiagnosticDaemon13 = ReplaceOnce(DiagnosticDaemon, "<string>App</string>", "<string>Interactive</string>");
|
||||
|
||||
public static async Task<int> Execute(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Contains("--help"))
|
||||
{
|
||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--output artifacts/native-macos] [--source existing-dockur-clone] [--cryptex-archive verified-release.zip]");
|
||||
return 0;
|
||||
}
|
||||
var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos");
|
||||
if (args.Contains("--validate"))
|
||||
{
|
||||
ValidateContracts();
|
||||
if (Option(args, "--source") is { } source)
|
||||
{
|
||||
await PrepareSource(Path.GetFullPath(source), output, "validation", false, Option(args, "--cryptex-archive"), CancellationToken.None);
|
||||
await ValidateResourceRetention(output);
|
||||
await ValidateRecoveryPatch(output);
|
||||
Save(Path.Combine(output, "validation.json"), new { success = true, profile = "kvm-host-ventura-cryptex", helperSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "MacOsNativeDiagnostic.cs"))), udifChecksumBindingSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-udif-checksums.py"))), baselineReadinessNormalized = true, resultNegativeCases = 6, containerNegativeCases = 11, recoveryPositiveCases = 4, recoveryNegativeCases = 12, independentFixtureCrc32Readback = true, resourceSnapshotRetention = true, cryptexArchiveVerified = true, configurationAndStagingContractsVerified = true, templateIsoDownloaded = false, activeLiluRuntimeChecked = false, sourceModified = false, dockerExecuted = false, guestExecuted = false, completedUtc = DateTimeOffset.UtcNow });
|
||||
}
|
||||
Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred.");
|
||||
return 0;
|
||||
}
|
||||
if (args.Contains("--cleanup"))
|
||||
return await Cleanup(output) ? 0 : 1;
|
||||
if (!args.Contains("--run")) throw new ArgumentException("Choose --run, --cleanup or --validate.");
|
||||
Directory.CreateDirectory(output);
|
||||
var statePath = Path.Combine(output, "owned-resources.json");
|
||||
if (File.Exists(statePath)) throw new InvalidOperationException("Output already contains a run identity; choose a fresh directory or clean up its run first.");
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var work = Path.Combine(Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), "meeting-assistant-native-" + token);
|
||||
var state = new OwnedResources(token, "meeting-assistant-native-" + token, "meeting-assistant-native-diagnostic:" + token, work);
|
||||
Save(statePath, state);
|
||||
Directory.CreateDirectory(work);
|
||||
File.WriteAllText(Path.Combine(work, "run.owner"), token);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(40));
|
||||
using var signal = OperatingSystem.IsLinux() ? PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); }) : null;
|
||||
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
var outcome = "failed";
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
throw new InvalidOperationException("This diagnostic runs on the existing Linux/x64 runner only.");
|
||||
ValidateContracts();
|
||||
var sourceCommit = (await Command("git", ["rev-parse", "HEAD"], output, "candidate-commit", deadline.Token)).Output.Trim();
|
||||
Save(Path.Combine(output, "run-metadata.json"), new { token, startedUtc = DateTimeOffset.UtcNow, sourceCommit, dockurCommit = DockurCommit, profile = "kvm-host-ventura-cryptex", causalSingleVariableTest = false, kvm = true, cpuModel = "host", recoveryMajor = 13, cryptexVersion = "1.0.5", liluVersion = "1.7.1", runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), server = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL"), architecture = RuntimeInformation.ProcessArchitecture.ToString(), deadlineMinutes = 40 });
|
||||
var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token);
|
||||
using (var document = JsonDocument.Parse(info.Output))
|
||||
{
|
||||
var data = document.RootElement;
|
||||
if (data.GetProperty("OSType").GetString() != "linux" || data.GetProperty("Architecture").GetString() is not ("x86_64" or "amd64"))
|
||||
throw new InvalidOperationException("The existing Docker daemon is not Linux/x64; this diagnostic does not reconfigure it.");
|
||||
if (data.GetProperty("NCPU").GetInt32() < 2 || data.GetProperty("MemTotal").GetInt64() < ContainerMemoryBytes)
|
||||
throw new InvalidOperationException("Existing Docker resources cannot fit this bounded 2-CPU/6-GiB diagnostic; no infrastructure change was requested.");
|
||||
}
|
||||
await Command("sh", ["-c", "cat /proc/meminfo; printf '\n[cgroup]\n'; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true; printf '\n[workspace disk]\n'; df -Pk ."], output, "runner-resources", deadline.Token);
|
||||
var available = System.Text.RegularExpressions.Regex.Match(File.ReadAllText("/proc/meminfo"), @"(?m)^MemAvailable:\s+(\d+) kB$");
|
||||
if (!available.Success || long.Parse(available.Groups[1].Value) < 5L * 1024 * 1024)
|
||||
throw new InvalidOperationException("Existing runner memory has less than the 5-GiB available diagnostic budget; no infrastructure change was requested.");
|
||||
var source = Path.Combine(work, "dockur");
|
||||
await Command("git", ["clone", "--no-checkout", "https://github.com/dockur/macos.git", source], output, "dockur-clone", deadline.Token);
|
||||
await Command("git", ["-C", source, "checkout", "--detach", DockurCommit], output, "dockur-checkout", deadline.Token);
|
||||
var actualCommit = (await Command("git", ["-C", source, "rev-parse", "HEAD"], output, "dockur-commit", deadline.Token)).Output.Trim();
|
||||
if (actualCommit != DockurCommit) throw new InvalidOperationException("Dockur source pin mismatch.");
|
||||
await PrepareSource(source, output, token, true, Option(args, "--cryptex-archive"), deadline.Token);
|
||||
await Command("docker", ["build", "--platform", "linux/amd64", "--label", OwnerLabel + "=" + token, "--tag", state.ImageTag, source], output, "docker-build", deadline.Token, echo: true);
|
||||
var imageInspect = await Command("docker", ["image", "inspect", state.ImageTag], output, "image-inspect", deadline.Token);
|
||||
using (var image = JsonDocument.Parse(imageInspect.Output))
|
||||
state = state with { ImageId = image.RootElement[0].GetProperty("Id").GetString() };
|
||||
Save(statePath, state);
|
||||
var create = await Command("docker", ["create", "--name", state.ContainerName, "--label", OwnerLabel + "=" + token, "--memory", "6g", "--memory-swap", "6g", "--cpus", "2", "--shm-size", "512m", "--log-opt", "max-size=8m", "--log-opt", "max-file=1", "--device", "/dev/kvm:/dev/kvm:rw", "--env", "KVM=Y", "--env", "CPU_MODEL=host", "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=13", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2", state.ImageTag], output, "docker-create", deadline.Token);
|
||||
var id = create.Output.Trim();
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$")) throw new InvalidOperationException("Docker did not return a container identity.");
|
||||
state = state with { ContainerId = id };
|
||||
Save(statePath, state);
|
||||
await Command("docker", ["inspect", id], output, "container-created", deadline.Token);
|
||||
AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token);
|
||||
await Command("docker", ["start", id], output, "docker-start", deadline.Token);
|
||||
Console.WriteLine("The owned restricted KVM/host-CPU macOS 13 compatibility guest is starting. Success requires native macOS 13+/x86_64 and a writable 64-GiB disk; no installer will run. This is not a single-variable causal test.");
|
||||
var recoveryStarted = Stopwatch.StartNew();
|
||||
var heartbeat = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
deadline.Token.ThrowIfCancellationRequested();
|
||||
await CaptureGuest(id, output, deadline.Token);
|
||||
var resultPath = Path.Combine(output, "guest-result.json");
|
||||
if (File.Exists(resultPath))
|
||||
{
|
||||
var result = File.ReadAllText(resultPath);
|
||||
ValidateResult(result, token);
|
||||
Console.WriteLine("Native Recovery readiness passed. This run has not installed macOS, .NET, CLT, or run Meeting Assistant tests.");
|
||||
outcome = "readiness-passed";
|
||||
break;
|
||||
}
|
||||
var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token);
|
||||
if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result.");
|
||||
if (heartbeat.Elapsed >= TimeSpan.FromSeconds(60))
|
||||
{
|
||||
Console.WriteLine($"[native-diagnostic] phase=recovery; elapsed={recoveryStarted.Elapsed.TotalMinutes:F1} minutes; container=running; readiness=pending");
|
||||
heartbeat.Restart();
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = exception is OperationCanceledException ? "The explicit 40-minute diagnostic deadline or cancellation was reached." : exception.Message;
|
||||
Console.Error.WriteLine(error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
using var captureDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
try { await CaptureGuest(state.ContainerId ?? state.ContainerName, output, captureDeadline.Token, true, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Final evidence capture: " + exception.Message); }
|
||||
try { PrintGuestProof(output, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Native proof output: " + exception.Message); }
|
||||
var clean = await Cleanup(output);
|
||||
if (!clean) { outcome = "failed"; error = (error ?? "") + " Owned-resource cleanup failed; inspect cleanup evidence."; }
|
||||
Save(Path.Combine(output, "outcome.json"), new { token, outcome, error, completedUtc = DateTimeOffset.UtcNow });
|
||||
}
|
||||
return outcome == "readiness-passed" ? 0 : 1;
|
||||
}
|
||||
|
||||
static string? Option(string[] args, string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index < 0 ? null : index + 1 < args.Length ? args[index + 1] : throw new ArgumentException("Missing value for " + name);
|
||||
}
|
||||
|
||||
static void ValidateContracts()
|
||||
{
|
||||
var readiness = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-readiness.sh"));
|
||||
var baseline = ReplaceOnce(readiness, "(( ${os_version%%.*} >= 13 ))", "(( ${os_version%%.*} >= 14 ))");
|
||||
if (Hash(Encoding.UTF8.GetBytes(baseline)) != "4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3")
|
||||
throw new InvalidOperationException("Compatibility readiness may change only the baseline's macOS minimum to 13; identity, services, disk and limits must remain identical.");
|
||||
if (Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"))) != "94f069e116fdc7685a4d233cab6fa50df9f39274386bb82157674061e74fadb5")
|
||||
throw new InvalidOperationException("Compatibility profile must preserve the baseline Apple recoveryosd wrapper.");
|
||||
if (Hash(Encoding.UTF8.GetBytes(OriginalDaemon13)) != "af9d7f6c1948079bd4384d27b6882678d6fb4e338fcf6a8be8f84fceef174ad6") throw new InvalidOperationException("macOS 13 allowlist bytes differ from the independently read comparison plist.");
|
||||
foreach (var variant in new[] { (OriginalDaemon + "\n", DiagnosticDaemon, "App"), (OriginalDaemon13, DiagnosticDaemon13, "Interactive") })
|
||||
{
|
||||
ValidateDaemon(variant.Item1, variant.Item3, false);
|
||||
ValidateDaemon(variant.Item2, variant.Item3, true);
|
||||
if (Encoding.UTF8.GetByteCount(variant.Item2) > Encoding.UTF8.GetByteCount(variant.Item1)) throw new InvalidOperationException("Daemon replacement exceeds original file.");
|
||||
}
|
||||
var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "13.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 });
|
||||
ValidateResult(good, "validation");
|
||||
foreach (var invalid in new[] { good.Replace("13.6.1", "12.6.1"), good.Replace("x86_64", "arm64"), good.Replace("\"readOnly\":false", "\"readOnly\":true"), good.Replace("\"success\":true", "\"success\":false"), good.Replace("68719476736", "17179869184"), good.Replace("validation", "stale") })
|
||||
{
|
||||
try { ValidateResult(invalid, "validation"); } catch (InvalidOperationException) { continue; }
|
||||
throw new InvalidOperationException("Diagnostic validator accepted an invalid/stale result.");
|
||||
}
|
||||
var boundary = """
|
||||
[{"Config":{"Labels":{"org.meeting-assistant.native-diagnostic":"validation"},"Env":["KVM=Y","CPU_MODEL=host","VERSION=13"]},"HostConfig":{"Privileged":false,"NetworkMode":"default","Memory":6442450944,"MemorySwap":6442450944,"NanoCpus":2000000000,"ShmSize":536870912,"CapAdd":null,"DeviceRequests":null,"Binds":null,"PortBindings":{},"DeviceCgroupRules":null,"Tmpfs":null,"Devices":[{"PathOnHost":"/dev/kvm","PathInContainer":"/dev/kvm","CgroupPermissions":"rw"}]},"Mounts":[{"Type":"volume","Destination":"/storage","RW":true}]}]
|
||||
""";
|
||||
AssertContainer(boundary, "validation");
|
||||
foreach (var invalid in new[] { boundary.Replace("\"Privileged\":false", "\"Privileged\":true"), boundary.Replace("\"CgroupPermissions\":\"rw\"", "\"CgroupPermissions\":\"rwm\""), boundary.Replace("/dev/kvm", "/dev/other"), boundary.Replace("KVM=Y", "KVM=N"), boundary.Replace("CPU_MODEL=host", "CPU_MODEL=Skylake-Client-v4"), boundary.Replace("VERSION=13", "VERSION=14"), boundary.Replace("6442450944", "8589934592"), boundary.Replace("\"NetworkMode\":\"default\"", "\"NetworkMode\":\"host\""), boundary.Replace("\"CapAdd\":null", "\"CapAdd\":[\"NET_ADMIN\"]"), boundary.Replace("\"Type\":\"volume\"", "\"Type\":\"bind\""), boundary.Replace("/storage", "/host") })
|
||||
{
|
||||
try { AssertContainer(invalid, "validation"); } catch (InvalidOperationException) { continue; }
|
||||
throw new InvalidOperationException("Diagnostic validator accepted an excessive/wrong-profile container boundary.");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task PrepareSource(string source, string output, string token, bool writeSource, string? cryptexArchive, CancellationToken cancellation)
|
||||
{
|
||||
Directory.CreateDirectory(output);
|
||||
var patchPath = Path.Combine(source, "src/install/recovery/patch.py");
|
||||
var originalPatch = File.ReadAllText(patchPath);
|
||||
if (Hash(Encoding.UTF8.GetBytes(originalPatch)) != "84f13db88c02edbf5ce21a39571fe58f12bebf5b0886c2d012f16ddbaed45323") throw new InvalidOperationException("Pinned Recovery patcher hash mismatch.");
|
||||
var daemon = OriginalDaemon + "\n";
|
||||
var patch = PrepareRecoveryPatch(originalPatch);
|
||||
var checksumBinding = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-udif-checksums.py"));
|
||||
if (Hash(Encoding.UTF8.GetBytes(checksumBinding)) != UdifChecksumBindingHash) throw new InvalidOperationException("Recovery UDIF checksum binding hash mismatch.");
|
||||
File.WriteAllText(Path.Combine(output, "udif_checksums.py"), checksumBinding, new UTF8Encoding(false));
|
||||
var dockerPath = Path.Combine(source, "Dockerfile");
|
||||
if (Hash(File.ReadAllBytes(dockerPath)) != "a0e804235967400eb70e755d63eff8a33a7761922ddd6e9723faa8e828fd8aa3") throw new InvalidOperationException("Pinned Dockerfile hash mismatch.");
|
||||
// The existing runner's BuildKit cannot checksum dangling manpage links during COPY /.
|
||||
// This pinned filesystem image has an empty Config; FROM preserves the same runtime defaults.
|
||||
var dockerfile = ReplaceOnce(File.ReadAllText(dockerPath), "FROM scratch AS base\nCOPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / /\n", "FROM qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df AS base\n");
|
||||
dockerfile = ReplaceAllExact(dockerfile, "--from=qemux/qemu-macos:latest ", "--from=qemux/qemu-macos:latest@sha256:af64297171228f27d5f616249e18f6ad5e2fbc79c1cc517252521e8bcd8eadaa ", 2);
|
||||
dockerfile = ReplaceOnce(dockerfile, "ADD $REPO_KVM_OPENCORE/releases/download/v$VERSION_KVM_OPENCORE/LongQT-OpenCore-v$VERSION_KVM_OPENCORE.iso /opencore.iso", "ADD --checksum=sha256:" + OpenCoreTemplateHash + " $REPO_KVM_OPENCORE/releases/download/v$VERSION_KVM_OPENCORE/LongQT-OpenCore-v$VERSION_KVM_OPENCORE.iso /opencore.iso");
|
||||
var compatibility = await PrepareCompatibility(source, output, cryptexArchive, cancellation);
|
||||
var entryPath = Path.Combine(source, "src/entry.sh");
|
||||
var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n");
|
||||
entry = ReplaceOnce(entry, ". init.sh # Initialize system\n", ". init.sh # Initialize system\n# Fail before Apple downloads if the existing daemon cannot retain this profile.\nenabled \"$KVM\" && [[ \"$CPU_MODEL\" == host && \"$VERSION\" == 13 ]] && grep -Eq '^vendor_id[[:space:]]*:[[:space:]]*GenuineIntel$' /proc/cpuinfo || { error 'Compatibility probe requires existing Intel KVM and the exact host/13 profile.'; exit 1; }\n");
|
||||
entry = ReplaceOnce(entry, "trap - ERR\n", "[[ \"$KVM_OPTS\" == *'accel=kvm'* || \"$KVM_OPTS\" == *'-accel kvm'* ]] && [[ \"$KVM_OPTS\" != *tcg* && \"$CPU_MODEL\" == host ]] || { error 'Compatibility profile refuses a TCG/CPU fallback.'; exit 1; }\ninfo '[compatibility-profile] accelerator=kvm cpu=host recovery=13; actual guest gates still pending'\n\ntrap - ERR\n");
|
||||
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
||||
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
||||
var wrapper = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"));
|
||||
var imagePath = Path.Combine(source, "src", "image.sh");
|
||||
var originalImage = File.ReadAllText(imagePath);
|
||||
if (Hash(Encoding.UTF8.GetBytes(originalImage)) != "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c") throw new InvalidOperationException("Pinned Recovery staging script hash mismatch.");
|
||||
var image = ReplaceOnce(originalImage, " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\"; then\n", " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cp -f \"$IMAGE_TOOLS/recovery/readiness.sh\" \"${script%/*}/readiness.sh\"; then\n");
|
||||
image = ReplaceOnce(image, " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n", " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cmp -s \"$IMAGE_TOOLS/recovery/readiness.sh\" \"$state/readiness.sh\" ||\n");
|
||||
image = ReplaceOnce(image, " if ! result=$(python3 \"$IMAGE_TOOLS/recovery/patch.py\" \"$image\"); then\n", " info \"[recovery-original] bytes=$(stat -c%s -- \"$image\") sha256=$(sha256sum \"$image\" | awk '{print $1}')\"\n if ! result=$(python3 \"$IMAGE_TOOLS/recovery/patch.py\" \"$image\"); then\n");
|
||||
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("recoveryosd-13-original.plist", OriginalDaemon13), ("recoveryosd-13-diagnostic.plist", DiagnosticDaemon13), ("early-bootstrap.sh", MountOnlyBootstrap), ("boot.sh.patched", compatibility.Boot), ("opencore-config.plist", compatibility.Config) })
|
||||
File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false));
|
||||
Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "udif_checksums.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "recoveryosd-13-original.plist" or "recoveryosd-13-diagnostic.plist" or "early-bootstrap.sh" or "boot.sh.patched" or "opencore-config.plist" or "compatibility-boot-assets.json").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
||||
await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "guest-readiness.sh")], output, "guest-readiness-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "image.sh.patched")], output, "guest-staging-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "boot.sh.patched")], output, "boot-staging-syntax", cancellation);
|
||||
if (!writeSource) return;
|
||||
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/udif_checksums.py"), checksumBinding, new UTF8Encoding(false));
|
||||
File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false));
|
||||
File.WriteAllText(entryPath, entry, new UTF8Encoding(false));
|
||||
File.WriteAllText(imagePath, image, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), wrapper, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/readiness.sh"), hook, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/boot.sh"), compatibility.Boot, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "assets/config.plist"), compatibility.Config, new UTF8Encoding(false));
|
||||
var target = Path.Combine(source, "assets", "native-compatibility");
|
||||
if (Directory.Exists(target)) throw new InvalidOperationException("Refusing an existing compatibility asset overlay.");
|
||||
foreach (var file in Directory.GetFiles(compatibility.Assets, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var destination = Path.Combine(target, Path.GetRelativePath(compatibility.Assets, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
File.Copy(file, destination, false);
|
||||
}
|
||||
}
|
||||
|
||||
static void ValidateDaemon(string xml, string processType, bool patched)
|
||||
{
|
||||
var pairs = XDocument.Parse(xml).Root!.Element("dict")!.Elements().ToArray();
|
||||
if (pairs.Length != 10 || !pairs.Where((_, index) => index % 2 == 0).Select(element => element.Value).SequenceEqual(new[] { "Label", "OnDemand", "ProcessType", "EnablePressuredExit", "ProgramArguments" })) throw new InvalidOperationException("Recovery daemon fields changed.");
|
||||
if (pairs[1].Value != "com.apple.recoveryosd" || pairs[3].Name != "false" || pairs[5].Value != processType || pairs[7].Name != "false" || pairs[9].Name != "array" || !pairs[9].Elements().Select(element => element.Value).SequenceEqual(patched ? new[] { "/bin/bash", "/Volumes/installstate/launch.sh" } : new[] { "/usr/libexec/recoveryosd" })) throw new InvalidOperationException("Recovery daemon identity/arguments changed.");
|
||||
}
|
||||
|
||||
static string PrepareRecoveryPatch(string original)
|
||||
{
|
||||
var patch = ReplaceOnce(original, OriginalBootstrap, MountOnlyBootstrap);
|
||||
patch = ReplaceOnce(patch, "import zlib\n", "import zlib\nfrom udif_checksums import ChecksumPlan\n");
|
||||
var constants = "RECOVERY_13_ORIGINAL = b'''" + OriginalDaemon13 + "'''\nRECOVERY_13_REPLACEMENT = b'''" + DiagnosticDaemon13 + "'''.ljust(len(RECOVERY_13_ORIGINAL), b\" \")\nRECOVERY_14_ORIGINAL = b'''" + OriginalDaemon + "\n'''\nRECOVERY_14_REPLACEMENT = b'''" + DiagnosticDaemon + "'''.ljust(len(RECOVERY_14_ORIGINAL), b\" \")\nRECOVERY_LABEL = b'<string>com.apple.recoveryosd</string>'";
|
||||
patch = ReplaceOnce(patch, "RECOVERY_ORIGINAL = b\"/usr/libexec/recoveryosd\"\nRECOVERY_REPLACEMENT = b\"/private/etc/rc.cdrom.sh\"", constants);
|
||||
patch = ReplaceOnce(patch, " if len(RECOVERY_REPLACEMENT) != len(RECOVERY_ORIGINAL):\n raise RuntimeError(\"recoveryosd launch-path replacement length mismatch\")", " if len(RECOVERY_13_REPLACEMENT) != len(RECOVERY_13_ORIGINAL) or len(RECOVERY_14_REPLACEMENT) != len(RECOVERY_14_ORIGINAL):\n raise RuntimeError(\"recoveryosd launch-path replacement length mismatch\")");
|
||||
patch = ReplaceOnce(patch, " (\"recoveryosd launch path\", RECOVERY_ORIGINAL, RECOVERY_REPLACEMENT),", " (\"recoveryosd macOS 13 launch path\", RECOVERY_13_ORIGINAL, RECOVERY_13_REPLACEMENT),\n (\"recoveryosd macOS 14 launch path\", RECOVERY_14_ORIGINAL, RECOVERY_14_REPLACEMENT),");
|
||||
patch = ReplaceOnce(patch, " chunks = {}\n", " chunks = {}\n recovery_label_count = 0\n");
|
||||
patch = ReplaceOnce(patch, " plist = plistlib.loads(image.read(xml_length))\n", " plist = plistlib.loads(image.read(xml_length))\n checksums = ChecksumPlan(image, koly, plist, xml_offset, xml_length, size)\n");
|
||||
patch = ReplaceOnce(patch, " key = (blkx_index, run_index)\n", " recovery_label_count += decoded.count(RECOVERY_LABEL)\n key = (blkx_index, run_index)\n");
|
||||
var variantValidation = """
|
||||
if len(matches[patches[0][0]]) != 1:
|
||||
raise RuntimeError("Expected exactly one rc.cdrom.sh bootstrap")
|
||||
variants = [item for item in patches[1:] if matches[item[0]]]
|
||||
if recovery_label_count != 1 or len(variants) != 1 or len(matches[variants[0][0]]) != 1:
|
||||
raise RuntimeError("Expected exactly one known recoveryosd plist and launch path")
|
||||
patches = (patches[0], variants[0])
|
||||
print("[recovery-daemon] " + variants[0][0])
|
||||
""";
|
||||
patch = ReplaceOnce(patch, " for name, _, _ in patches:\n count = len(matches[name])\n if count != 1:\n raise RuntimeError(f\"Expected exactly one {name}, found {count}\")", IndentPython(variantValidation, 8));
|
||||
// Plan all recompressed chunks before the first image write. A later
|
||||
// compression failure must not leave an earlier chunk patched.
|
||||
patch = ReplaceOnce(patch, " for key, chunk in chunks.items():\n patched = bytearray", " planned = []\n for key, chunk in chunks.items():\n patched = bytearray");
|
||||
patch = ReplaceOnce(patch, " image.seek(chunk[\"physical_offset\"])\n image.write(stored)", " planned.append((chunk[\"physical_offset\"], stored))\n\n checksum_xml, checksum_koly = checksums.prepare(planned)\n for physical_offset, stored in planned:\n image.seek(physical_offset)\n image.write(stored)\n image.seek(xml_offset)\n image.write(checksum_xml)\n image.seek(size - 512)\n image.write(checksum_koly)");
|
||||
patch = ReplaceOnce(patch, " image.flush()\n", " image.flush()\n checksums.verify()\n");
|
||||
return patch;
|
||||
}
|
||||
|
||||
static string IndentPython(string text, int spaces)
|
||||
{
|
||||
var lines = text.Split('\n');
|
||||
var common = lines.Where(line => line.Length > 0).Min(line => line.TakeWhile(character => character == ' ').Count());
|
||||
return string.Join("\n", lines.Select(line => new string(' ', spaces) + (line.Length > 0 ? line[common..] : "")));
|
||||
}
|
||||
|
||||
static async Task<(string Boot, string Config, string Assets)> PrepareCompatibility(string source, string output, string? archivePath, CancellationToken cancellation)
|
||||
{
|
||||
var boot = File.ReadAllText(Path.Combine(source, "src", "boot.sh"));
|
||||
var config = File.ReadAllText(Path.Combine(source, "assets", "config.plist"));
|
||||
if (Hash(Encoding.UTF8.GetBytes(boot)) != "82b56525707a8f586e040f56108b5034c02e7fecfea071f1857e596cba10cbed" || Hash(Encoding.UTF8.GetBytes(config)) != "3b0ec58b693cfa0fadf3e3f952486e87af8c27d504f9545d1e90ae2dc3777096") throw new InvalidOperationException("Pinned OpenCore staging/config hashes mismatch.");
|
||||
byte[] bytes;
|
||||
if (archivePath is not null) bytes = await File.ReadAllBytesAsync(archivePath, cancellation);
|
||||
else
|
||||
{
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30), MaxResponseContentBufferSize = 2 * 1024 * 1024 };
|
||||
bytes = await client.GetByteArrayAsync(CryptexUrl, cancellation);
|
||||
}
|
||||
if (bytes.Length != 69703 || Hash(bytes) != CryptexHash) throw new InvalidOperationException("Official CryptexFixup release size/hash mismatch.");
|
||||
File.WriteAllBytes(Path.Combine(output, "CryptexFixup-1.0.5-RELEASE.zip"), bytes);
|
||||
var assets = Path.Combine(output, "compatibility-assets");
|
||||
if (Directory.Exists(assets)) throw new InvalidOperationException("Compatibility validation requires a fresh output directory.");
|
||||
Directory.CreateDirectory(assets);
|
||||
using var archive = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read);
|
||||
var required = new[] { "CryptexFixup.kext/Contents/Info.plist", "CryptexFixup.kext/Contents/MacOS/CryptexFixup" };
|
||||
var entries = archive.Entries.Where(entry => entry.FullName.StartsWith("CryptexFixup.kext/", StringComparison.Ordinal) && !entry.FullName.EndsWith('/')).ToArray();
|
||||
if (entries.Length != 2 || required.Any(name => entries.Count(entry => entry.FullName == name) != 1)) throw new InvalidOperationException("Cryptex bundle has an unexpected file layout.");
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Length <= 0 || entry.Length > 1024 * 1024) throw new InvalidOperationException("Cryptex bundle file exceeded the staging bound.");
|
||||
var destination = Path.Combine(assets, entry.FullName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
entry.ExtractToFile(destination, false);
|
||||
}
|
||||
var info = XDocument.Load(Path.Combine(assets, required[0])).Root!.Element("dict")!;
|
||||
if (PlistValue(info, "CFBundleIdentifier").Value != "com.khronokernel.CryptexFixup" || PlistValue(info, "CFBundleVersion").Value != "1.0.5" || PlistValue(info, "CFBundleExecutable").Value != "CryptexFixup" || PlistValue(PlistValue(info, "OSBundleLibraries"), "as.vit9696.Lilu").Value != "1.4.7") throw new InvalidOperationException("Cryptex bundle identity/version/Lilu dependency mismatch.");
|
||||
var fileHashes = required.ToDictionary(name => name, name => Hash(File.ReadAllBytes(Path.Combine(assets, name))));
|
||||
File.WriteAllText(Path.Combine(assets, "SHA256SUMS"), string.Concat(fileHashes.Select(pair => pair.Value + " " + pair.Key + "\n")), new UTF8Encoding(false));
|
||||
Save(Path.Combine(output, "compatibility-boot-assets.json"), new { cryptexUrl = CryptexUrl, cryptexSha256 = CryptexHash, cryptexBytes = bytes.Length, cryptexFiles = fileHashes, templateUrl = "https://github.com/LongQT-sea/OpenCore-ISO/releases/download/v0.7/LongQT-OpenCore-v0.7.iso", templateSha256 = OpenCoreTemplateHash, templateBytes = 15884288, liluVersion = "1.7.1", liluBinarySha256 = "0c016d93cfe40c7fa3965813175c1b991a76f3d295efd5be66ae712b4a3ffb52", liluBinaryBytes = 526984, liluInfoSha256 = "fc885f3319f326e3af60e7965a5216b671772d39d40993ec695758bb43d6ea3a", causalSingleVariableTest = false });
|
||||
var document = XDocument.Parse(config, LoadOptions.PreserveWhitespace);
|
||||
var add = PlistValue(PlistValue(document.Root!.Element("dict")!, "Kernel"), "Add");
|
||||
var expected = new[] { "Lilu.kext", "VMHide.kext", "VirtualSMC.kext", "WhateverGreen.kext", "VoodooPS2Controller.kext", "VoodooPS2Controller.kext/Contents/PlugIns/VoodooPS2Keyboard.kext", "AppleMCEReporterDisabler.kext" };
|
||||
if (!add.Elements("dict").Select(dict => PlistValue(dict, "BundlePath").Value).SequenceEqual(expected) || add.Elements("dict").Any(dict => PlistValue(dict, "Enabled").Name != "true")) throw new InvalidOperationException("Pinned Kernel.Add order/enabled contract mismatch.");
|
||||
var cryptex = XElement.Parse("<dict><key>Arch</key><string>x86_64</string><key>BundlePath</key><string>CryptexFixup.kext</string><key>Comment</key><string>Official CryptexFixup 1.0.5; owned compatibility guest only</string><key>Enabled</key><true/><key>ExecutablePath</key><string>Contents/MacOS/CryptexFixup</string><key>MaxKernel</key><string></string><key>MinKernel</key><string>22.0.0</string><key>PlistPath</key><string>Contents/Info.plist</string></dict>");
|
||||
add.Elements("dict").First().AddAfterSelf(cryptex);
|
||||
var bootArguments = PlistValue(PlistValue(PlistValue(PlistValue(document.Root.Element("dict")!, "NVRAM"), "Add"), "7C436110-AB2A-4BBB-A880-FE41995C9F82"), "boot-args").Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (bootArguments.Intersect(new[] { "-cryptoff", "-liluoff", "-crypt_allow_hash_validation", "-crypt_force_avx", "-cryptbeta", "-lilubetaall" }).Any()) throw new InvalidOperationException("Unexpected Cryptex/Lilu disabling or forcing boot argument.");
|
||||
boot = ReplaceOnce(boot, " cp -a \"$template/OC/Resources\" \"$EFI_DIR/OC/\"\n", " cp -a \"$template/OC/Resources\" \"$EFI_DIR/OC/\"\n" + CompatibilityStaging + "\n");
|
||||
boot = ReplaceOnce(boot, " PLIST=\"/assets/config.plist\"\n", " [ ! -e /custom.plist ] || { error 'Compatibility profile refuses an unverified custom OpenCore config!'; exit 12; }\n PLIST=\"/assets/config.plist\"\n");
|
||||
boot = ReplaceOnce(boot, " checkOpenCoreConfig\n addVmHideKext\n", " checkOpenCoreConfig\n" + CompatibilityConfigCheck + "\n addVmHideKext\n");
|
||||
boot = ReplaceOnce(boot, " if [ -s \"$target\" ] && [ \"$previous\" = \"$current\" ]; then\n IMG=\"$target\"\n return 0\n fi\n", " # This owned compatibility probe always rebuilds; never trust a cached boot.img.\n");
|
||||
boot = ReplaceOnce(boot, " echo \"VMHIDE=$vmhide\"\n", " echo \"VMHIDE=$vmhide\"\n echo \"COMPATIBILITY=kvm-host-ventura-cryptex\"\n sha256sum /assets/native-compatibility/SHA256SUMS\n");
|
||||
return (boot, document.ToString(), assets);
|
||||
}
|
||||
|
||||
static XElement PlistValue(XElement dictionary, string key)
|
||||
{
|
||||
var keys = dictionary.Elements("key").Where(element => element.Value == key).ToArray();
|
||||
if (keys.Length != 1 || keys[0].ElementsAfterSelf().FirstOrDefault() is not { } value) throw new InvalidOperationException("Missing/duplicate plist key: " + key);
|
||||
return value;
|
||||
}
|
||||
|
||||
const string CompatibilityStaging = """
|
||||
# Only the freshly extracted, owned guest EFI is changed; never the host.
|
||||
local lilu="$EFI_DIR/OC/Kexts/Lilu.kext/Contents"
|
||||
printf '%s %s\n' \
|
||||
fc885f3319f326e3af60e7965a5216b671772d39d40993ec695758bb43d6ea3a "$lilu/Info.plist" \
|
||||
0c016d93cfe40c7fa3965813175c1b991a76f3d295efd5be66ae712b4a3ffb52 "$lilu/MacOS/Lilu" | sha256sum -c - || { error "Pinned active Lilu files mismatch!"; exit 12; }
|
||||
[ "$(xmlstarlet sel -T -t -v '/plist/dict/key[.="CFBundleVersion"]/following-sibling::string[1]' "$lilu/Info.plist")" = 1.7.1 ] || { error "Active Lilu version mismatch!"; exit 12; }
|
||||
[ ! -e "$EFI_DIR/OC/Kexts/CryptexFixup.kext" ] || { error "Unexpected pre-existing Cryptex kext!"; exit 12; }
|
||||
(cd /assets/native-compatibility && sha256sum -c SHA256SUMS) || { error "Pinned Cryptex staging files mismatch!"; exit 12; }
|
||||
cp -a /assets/native-compatibility/CryptexFixup.kext "$EFI_DIR/OC/Kexts/"
|
||||
(cd "$EFI_DIR/OC/Kexts" && sha256sum -c /assets/native-compatibility/SHA256SUMS) || { error "Active Cryptex copy mismatch!"; exit 12; }
|
||||
info "[compatibility-boot] Lilu=1.7.1 CryptexFixup=1.0.5 files=verified; guest injection and Recovery readiness remain unproved"
|
||||
""";
|
||||
|
||||
const string CompatibilityConfigCheck = """
|
||||
local kernel='/plist/dict/key[.="Kernel"]/following-sibling::dict[1]/key[.="Add"]/following-sibling::array[1]'
|
||||
local actual expected
|
||||
actual=$(xmlstarlet sel -T -t -m "$kernel/dict" -v 'key[.="BundlePath"]/following-sibling::string[1]' -n "$CFG") || exit 12
|
||||
expected=$(printf '%s\n' Lilu.kext CryptexFixup.kext VMHide.kext VirtualSMC.kext WhateverGreen.kext VoodooPS2Controller.kext VoodooPS2Controller.kext/Contents/PlugIns/VoodooPS2Keyboard.kext AppleMCEReporterDisabler.kext)
|
||||
[ "$actual" = "$expected" ] || { error "Active Kernel.Add order mismatch!"; exit 12; }
|
||||
[ "$(xmlstarlet sel -T -t -v "name($kernel/dict[1]/key[.='Enabled']/following-sibling::*[1])" -v "name($kernel/dict[2]/key[.='Enabled']/following-sibling::*[1])" "$CFG")" = truetrue ] || { error "Active Lilu/Cryptex must both be enabled!"; exit 12; }
|
||||
actual=$(xmlstarlet sel -T -t -m "$kernel/dict[2]" -v 'key[.="Arch"]/following-sibling::string[1]' -n -v 'key[.="ExecutablePath"]/following-sibling::string[1]' -n -v 'key[.="PlistPath"]/following-sibling::string[1]' -n -v 'key[.="MinKernel"]/following-sibling::string[1]' -n -v 'key[.="MaxKernel"]/following-sibling::string[1]' "$CFG") || exit 12
|
||||
expected=$(printf '%s\n' x86_64 Contents/MacOS/CryptexFixup Contents/Info.plist 22.0.0 '')
|
||||
[ "$actual" = "$expected" ] || { error "Active Cryptex Kernel.Add paths/architecture/Darwin bounds mismatch!"; exit 12; }
|
||||
info "[compatibility-config] Kernel.Add=Lilu,CryptexFixup before remaining baseline kexts; MinKernel=22.0.0 MaxKernel=empty"
|
||||
""";
|
||||
|
||||
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
||||
static string ReplaceAllExact(string text, string oldValue, string newValue, int expected)
|
||||
{
|
||||
var count = text.Split(oldValue, StringSplitOptions.None).Length - 1;
|
||||
if (count != expected) throw new InvalidOperationException($"Pinned source contract expected {expected} match(es), found {count}: {oldValue.Split('\n')[0]}");
|
||||
return text.Replace(oldValue, newValue, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
static void ValidateResult(string json, string token)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var result = document.RootElement;
|
||||
if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 13 || result.GetProperty("architecture").GetString() != "x86_64" || result.GetProperty("uid").GetInt32() != 0 || !System.Text.RegularExpressions.Regex.IsMatch(result.GetProperty("disk").GetString() ?? "", "^/dev/disk[0-9]+$") || result.GetProperty("diskBytes").GetInt64() != GuestDiskBytes || result.GetProperty("readOnly").GetBoolean() || new[] { "systemExit", "diskArbitrationExit", "recoveryExit", "diskListExit" }.Any(key => result.GetProperty(key).GetInt32() != 0))
|
||||
throw new InvalidOperationException("The fresh guest receipt did not prove native macOS 13+/x86_64, service readiness and the writable 64-GiB disk.");
|
||||
}
|
||||
|
||||
static void AssertContainer(string json, string token)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var container = document.RootElement[0];
|
||||
var config = container.GetProperty("HostConfig");
|
||||
var devices = config.GetProperty("Devices");
|
||||
var mounts = container.GetProperty("Mounts");
|
||||
var environment = container.GetProperty("Config").GetProperty("Env").EnumerateArray().Select(value => value.GetString()).ToArray();
|
||||
if (container.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != token
|
||||
|| config.GetProperty("Privileged").GetBoolean()
|
||||
|| config.GetProperty("NetworkMode").GetString() is not ("default" or "bridge")
|
||||
|| config.GetProperty("Memory").GetInt64() != ContainerMemoryBytes
|
||||
|| config.GetProperty("MemorySwap").GetInt64() != ContainerMemoryBytes
|
||||
|| config.GetProperty("NanoCpus").GetInt64() != 2000000000
|
||||
|| config.GetProperty("ShmSize").GetInt64() != 536870912
|
||||
|| new[] { "CapAdd", "DeviceRequests", "Binds", "PortBindings", "DeviceCgroupRules", "Tmpfs" }.Any(key =>
|
||||
config.TryGetProperty(key, out var value) && value.ValueKind != JsonValueKind.Null
|
||||
&& (value.ValueKind == JsonValueKind.Array ? value.GetArrayLength() != 0 : value.EnumerateObject().Any()))
|
||||
|| devices.GetArrayLength() != 1
|
||||
|| devices[0].GetProperty("PathOnHost").GetString() != "/dev/kvm"
|
||||
|| devices[0].GetProperty("PathInContainer").GetString() != "/dev/kvm"
|
||||
|| devices[0].GetProperty("CgroupPermissions").GetString() != "rw"
|
||||
|| mounts.GetArrayLength() != 1
|
||||
|| mounts[0].GetProperty("Type").GetString() != "volume"
|
||||
|| mounts[0].GetProperty("Destination").GetString() != "/storage"
|
||||
|| !mounts[0].GetProperty("RW").GetBoolean()
|
||||
|| new[] { "KVM=Y", "CPU_MODEL=host", "VERSION=13" }.Any(expected =>
|
||||
environment.Count(value => value is not null && value.StartsWith(expected.Split('=')[0] + "=", StringComparison.Ordinal)) != 1
|
||||
|| !environment.Contains(expected)))
|
||||
throw new InvalidOperationException("Created container exceeds the owned restricted KVM/host-CPU compatibility boundary.");
|
||||
}
|
||||
|
||||
static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool final = false, string? token = null)
|
||||
{
|
||||
if (final && token is not null) await CaptureMonitor(id, output, token, cancellation);
|
||||
var logs = await Command("docker", ["logs", "--tail", "3000", id], output, "container", cancellation, requireSuccess: false);
|
||||
foreach (var file in new[] { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") })
|
||||
{
|
||||
var result = await Command("docker", ["exec", id, "cat", "/dev/shm/installstate/" + file.Item1], output, "capture-" + file.Item1, cancellation, requireSuccess: false);
|
||||
if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output)) File.WriteAllText(Path.Combine(output, file.Item2), result.Output);
|
||||
}
|
||||
await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test ! -f /storage/13/setup.dmg || sha256sum /storage/13/setup.dmg; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false, retainSuccessful: true);
|
||||
}
|
||||
|
||||
static async Task CaptureMonitor(string id, string output, string token, CancellationToken cancellation)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
int? monitorExit = null, copyExit = null;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$") || !System.Text.RegularExpressions.Regex.IsMatch(token, "^[0-9a-f]{32}$")) throw new InvalidOperationException("No saved owned container identity for the optional monitor capture.");
|
||||
var inspection = await Command("docker", ["inspect", id], output, "capture-monitor-container", deadline.Token);
|
||||
AssertContainer(inspection.Output, token);
|
||||
using (var document = JsonDocument.Parse(inspection.Output))
|
||||
if (document.RootElement[0].GetProperty("Id").GetString() != id || !document.RootElement[0].GetProperty("State").GetProperty("Running").GetBoolean()) throw new InvalidOperationException("Owned guest container is no longer running for the optional monitor capture.");
|
||||
var screen = "/tmp/native-diagnostic-screen-" + token + ".ppm";
|
||||
var monitor = await Command("docker", ["exec", id, "sh", "-c", """
|
||||
test -S /run/shm/monitor.sock || exit 1
|
||||
rm -f -- "$1" || exit 1
|
||||
printf 'info kvm\ninfo status\nscreendump %s\n' "$1" | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock
|
||||
monitor_exit=$?
|
||||
printf '\n[monitor-exit] %s\n' "$monitor_exit"
|
||||
[ "$monitor_exit" -eq 0 ] || exit "$monitor_exit"
|
||||
bytes=$(stat -c%s "$1") || exit 1
|
||||
[ "$bytes" -gt 0 ] && [ "$bytes" -le 8388608 ] || exit 1
|
||||
printf '[screen-bytes] %s\n' "$bytes"
|
||||
""", "native-monitor", screen], output, "capture-monitor", deadline.Token, requireSuccess: false);
|
||||
monitorExit = monitor.ExitCode;
|
||||
if (monitorExit != 0) throw new InvalidOperationException("Optional monitor status/screenshot command exited " + monitorExit + ".");
|
||||
var copy = await Command("docker", ["cp", id + ":" + screen, Path.Combine(output, "guest-screen-" + token + ".ppm")], output, "capture-monitor-screen", deadline.Token, requireSuccess: false);
|
||||
copyExit = copy.ExitCode;
|
||||
if (copyExit != 0) throw new InvalidOperationException("Optional monitor screenshot copy exited " + copyExit + ".");
|
||||
}
|
||||
catch (Exception exception) { error = exception.Message; Console.Error.WriteLine("Optional final monitor capture: " + error); }
|
||||
finally { Save(Path.Combine(output, "monitor-capture.json"), new { token, monitorExit, copyExit, success = error is null, error, capturedUtc = DateTimeOffset.UtcNow }); }
|
||||
}
|
||||
|
||||
static async Task<bool> Cleanup(string output)
|
||||
{
|
||||
var path = Path.Combine(output, "owned-resources.json");
|
||||
if (!File.Exists(path)) return true;
|
||||
var state = JsonSerializer.Deserialize<OwnedResources>(File.ReadAllText(path), JsonOptions) ?? throw new InvalidOperationException("Invalid owned-resource receipt.");
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(state.Token, "^[0-9a-f]{32}$") || state.ContainerName != "meeting-assistant-native-" + state.Token || state.ImageTag != "meeting-assistant-native-diagnostic:" + state.Token) throw new InvalidOperationException("Invalid cleanup ownership identity.");
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||
try
|
||||
{
|
||||
foreach (var kind in new[] { "container", "image" })
|
||||
{
|
||||
var name = kind == "container" ? state.ContainerName : state.ImageTag;
|
||||
var inspect = await Command("docker", [kind, "inspect", name], output, "cleanup-" + kind + "-inspect", deadline.Token, requireSuccess: false);
|
||||
if (inspect.ExitCode != 0)
|
||||
{
|
||||
if (inspect.Error.Contains("No such object", StringComparison.Ordinal) || inspect.Error.Contains("No such container", StringComparison.Ordinal) || inspect.Error.Contains("No such image", StringComparison.Ordinal)) continue;
|
||||
throw new InvalidOperationException("Cannot establish owned " + kind + " absence: " + inspect.Error);
|
||||
}
|
||||
using var document = JsonDocument.Parse(inspect.Output);
|
||||
var resource = document.RootElement[0];
|
||||
if (resource.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != state.Token) throw new InvalidOperationException("Cleanup refuses a resource without this run's exact ownership label.");
|
||||
var id = resource.GetProperty("Id").GetString()!;
|
||||
var expectedId = kind == "container" ? state.ContainerId : state.ImageId;
|
||||
if (expectedId is not null && expectedId != id) throw new InvalidOperationException("Cleanup refuses a resource whose ID changed after creation.");
|
||||
await Command("docker", kind == "container" ? ["rm", "--force", "--volumes", id] : ["image", "rm", id], output, "cleanup-" + kind + "-remove", deadline.Token);
|
||||
}
|
||||
if (Path.GetFileName(state.WorkDirectory) == "meeting-assistant-native-" + state.Token && File.Exists(Path.Combine(state.WorkDirectory, "run.owner")) && File.ReadAllText(Path.Combine(state.WorkDirectory, "run.owner")) == state.Token) Directory.Delete(state.WorkDirectory, true);
|
||||
Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = true, completedUtc = DateTimeOffset.UtcNow });
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = false, error = exception.Message, completedUtc = DateTimeOffset.UtcNow });
|
||||
Console.Error.WriteLine("Owned diagnostic cleanup failed: " + exception.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ValidateRecoveryPatch(string output)
|
||||
{
|
||||
if (Crc32(Encoding.ASCII.GetBytes("123456789")) != 0xcbf43926) throw new InvalidOperationException("Independent C# CRC32 known vector failed.");
|
||||
var fixture = Path.Combine(output, "validation-recovery-patch");
|
||||
Directory.CreateDirectory(fixture);
|
||||
var patch = File.ReadAllText(Path.Combine(output, "recovery-patch.py"));
|
||||
const string marker = "SCRIPT_ORIGINAL = b'''";
|
||||
var start = patch.IndexOf(marker, StringComparison.Ordinal) + marker.Length;
|
||||
var end = patch.IndexOf("'''", start, StringComparison.Ordinal);
|
||||
var bootstrap = patch[start..end];
|
||||
var cases = new[] {
|
||||
("13-zlib", OriginalDaemon13, true, true), ("14-zlib", OriginalDaemon + "\n", true, true),
|
||||
("13-raw", OriginalDaemon13, false, true), ("14-raw", OriginalDaemon + "\n", false, true),
|
||||
("unknown", OriginalDaemon13.Replace("Interactive", "Unknown"), true, false),
|
||||
("duplicate", OriginalDaemon13 + OriginalDaemon + "\n", true, false),
|
||||
("duplicate-unknown", OriginalDaemon13 + OriginalDaemon13.Replace("Interactive", "Unknown"), true, false),
|
||||
("malformed", OriginalDaemon13.Replace("</array>", "</broken>"), true, false),
|
||||
("wrong-arguments", OriginalDaemon13.Replace("/usr/libexec/recoveryosd", "/usr/libexec/wrongdaemon"), true, false),
|
||||
("duplicate-arguments", OriginalDaemon13.Replace("</array>", "<string>/usr/libexec/recoveryosd</string></array>"), true, false),
|
||||
("corrupt-data-crc", OriginalDaemon13, true, false), ("unsupported-crc", OriginalDaemon13, true, false),
|
||||
("xml-boundary", OriginalDaemon13, true, false), ("physical-boundary", OriginalDaemon13, true, false),
|
||||
("unknown-zero-run", OriginalDaemon13, true, false), ("logical-boundary", OriginalDaemon13, false, false)
|
||||
};
|
||||
foreach (var item in cases)
|
||||
{
|
||||
var path = Path.Combine(fixture, item.Item1 + ".dmg");
|
||||
CreateRecoveryFixture(path, bootstrap, item.Item2, item.Item3);
|
||||
if (item.Item1 is "corrupt-data-crc" or "unsupported-crc" or "xml-boundary" or "physical-boundary" or "unknown-zero-run" or "logical-boundary")
|
||||
{
|
||||
var corrupt = File.ReadAllBytes(path);
|
||||
var trailer = corrupt.Length - 512;
|
||||
if (item.Item1 == "corrupt-data-crc") corrupt[trailer + 88] ^= 1;
|
||||
else if (item.Item1 == "unsupported-crc") BinaryPrimitives.WriteUInt32BigEndian(corrupt.AsSpan(trailer + 80), 3);
|
||||
else if (item.Item1 == "xml-boundary") BinaryPrimitives.WriteUInt64BigEndian(corrupt.AsSpan(trailer + 224), (ulong)corrupt.Length);
|
||||
else if (item.Item1 == "logical-boundary") BinaryPrimitives.WriteUInt64BigEndian(corrupt.AsSpan(trailer + 492), 1);
|
||||
else
|
||||
{
|
||||
var xmlOffset = checked((int)BinaryPrimitives.ReadUInt64BigEndian(corrupt.AsSpan(trailer + 216)));
|
||||
var xmlLength = checked((int)BinaryPrimitives.ReadUInt64BigEndian(corrupt.AsSpan(trailer + 224)));
|
||||
var xmlText = Encoding.UTF8.GetString(corrupt, xmlOffset, xmlLength);
|
||||
var data = XDocument.Parse(xmlText).Descendants("data").Single().Value;
|
||||
var mish = Convert.FromBase64String(data);
|
||||
if (item.Item1 == "physical-boundary") BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(236), (ulong)corrupt.Length);
|
||||
else BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(204), 0);
|
||||
var replacement = Encoding.UTF8.GetBytes(ReplaceOnce(xmlText, data, Convert.ToBase64String(mish)));
|
||||
replacement.CopyTo(corrupt, xmlOffset);
|
||||
}
|
||||
File.WriteAllBytes(path, corrupt);
|
||||
}
|
||||
var before = File.ReadAllBytes(path);
|
||||
var result = await Command("python3", ["-B", Path.Combine(output, "recovery-patch.py"), path], fixture, item.Item1, CancellationToken.None, requireSuccess: false);
|
||||
var after = File.ReadAllBytes(path);
|
||||
if ((result.ExitCode == 0) != item.Item4) throw new InvalidOperationException("Recovery fixture result mismatch: " + item.Item1 + ": " + result.Error);
|
||||
if (!item.Item4 && !before.SequenceEqual(after)) throw new InvalidOperationException("Rejected Recovery fixture was modified: " + item.Item1);
|
||||
if (item.Item4)
|
||||
{
|
||||
var decoded = DecodeRecoveryFixture(after, item.Item3);
|
||||
var original = Encoding.UTF8.GetBytes(item.Item2);
|
||||
var expectedText = item.Item1.StartsWith("13", StringComparison.Ordinal) ? DiagnosticDaemon13 : DiagnosticDaemon;
|
||||
var expected = Encoding.UTF8.GetBytes(expectedText.PadRight(item.Item2.Length, ' '));
|
||||
if (before.Length != after.Length || !decoded.AsSpan(4096, original.Length).SequenceEqual(expected)) throw new InvalidOperationException("Recovery fixture changed byte extent or daemon fields: " + item.Item1);
|
||||
ValidateDaemon(expectedText, item.Item1.StartsWith("13", StringComparison.Ordinal) ? "Interactive" : "App", true);
|
||||
VerifyRecoveryFixtureChecksums(after, decoded);
|
||||
}
|
||||
}
|
||||
Save(Path.Combine(fixture, "receipt.json"), new { success = true, positiveCases = 4, negativeCases = 12, rejectedImagesUnmodified = true, knownDaemonFieldsPreserved = true, readBackCrc32IndependentlyVerified = true, syntheticUdifFixtures = true, guestExecuted = false });
|
||||
}
|
||||
|
||||
static uint Crc32(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var crc = uint.MaxValue;
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
crc ^= value;
|
||||
for (var bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ ((crc & 1) != 0 ? 0xedb88320u : 0);
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
static void CreateRecoveryFixture(string path, string bootstrap, string daemon, bool compressed)
|
||||
{
|
||||
var decoded = new byte[16384];
|
||||
Encoding.UTF8.GetBytes(bootstrap).CopyTo(decoded, 64);
|
||||
Encoding.UTF8.GetBytes(daemon).CopyTo(decoded, 4096);
|
||||
byte[] stored;
|
||||
if (compressed)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
using (var zipper = new ZLibStream(memory, CompressionLevel.Fastest, true)) zipper.Write(decoded);
|
||||
stored = memory.ToArray();
|
||||
}
|
||||
else stored = decoded;
|
||||
var mish = new byte[284];
|
||||
Encoding.ASCII.GetBytes("mish").CopyTo(mish, 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(4), 1);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(16), 32);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(64), 2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(68), 32);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(72), Crc32(decoded));
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(200), 2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(204), compressed ? 0x80000005u : 1u);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(220), 32);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(mish.AsSpan(236), (ulong)stored.Length);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mish.AsSpan(244), 0xffffffff);
|
||||
var xml = Encoding.UTF8.GetBytes("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>resource-fork</key><dict><key>blkx</key><array><dict><key>Data</key><data>" + Convert.ToBase64String(mish) + "</data></dict></array></dict></dict></plist>\n");
|
||||
var koly = new byte[512];
|
||||
Encoding.ASCII.GetBytes("koly").CopyTo(koly, 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(4), 4);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(8), 512);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(12), 1);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(32), (ulong)stored.Length);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(80), 2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(84), 32);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(88), Crc32(stored));
|
||||
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(216), (ulong)stored.Length);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(224), (ulong)xml.Length);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(352), 2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(356), 32);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(koly.AsSpan(360), Crc32(mish.AsSpan(72, 4)));
|
||||
BinaryPrimitives.WriteUInt64BigEndian(koly.AsSpan(492), 32);
|
||||
File.WriteAllBytes(path, stored.Concat(xml).Concat(koly).ToArray());
|
||||
}
|
||||
|
||||
static byte[] DecodeRecoveryFixture(byte[] image, bool compressed)
|
||||
{
|
||||
var length = checked((int)BinaryPrimitives.ReadUInt64BigEndian(image.AsSpan(image.Length - 512 + 32)));
|
||||
if (!compressed) return image[..length];
|
||||
using var input = new MemoryStream(image, 0, length);
|
||||
using var decoder = new ZLibStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
decoder.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
static void VerifyRecoveryFixtureChecksums(byte[] image, byte[] decoded)
|
||||
{
|
||||
var trailer = image.Length - 512;
|
||||
var length = checked((int)BinaryPrimitives.ReadUInt64BigEndian(image.AsSpan(trailer + 32)));
|
||||
var xmlLength = checked((int)BinaryPrimitives.ReadUInt64BigEndian(image.AsSpan(trailer + 224)));
|
||||
var xml = XDocument.Parse(Encoding.UTF8.GetString(image, length, xmlLength));
|
||||
var mish = Convert.FromBase64String(xml.Descendants("data").Single().Value);
|
||||
if (Crc32(image.AsSpan(0, length)) != BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(trailer + 88)) || Crc32(decoded) != BinaryPrimitives.ReadUInt32BigEndian(mish.AsSpan(72)) || Crc32(mish.AsSpan(72, 4)) != BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(trailer + 360))) throw new InvalidOperationException("Independent C# fixture CRC32 readback failed.");
|
||||
}
|
||||
|
||||
static async Task ValidateResourceRetention(string output)
|
||||
{
|
||||
var fixture = Path.Combine(output, "validation-resource-retention");
|
||||
Directory.CreateDirectory(fixture);
|
||||
const string label = "capture-resource-fixture";
|
||||
const string successful = "Successful snapshot before stopped-container capture.\n";
|
||||
await Command("bash", ["-c", "printf '%s\\n' 'Successful snapshot before stopped-container capture.'"], fixture, label, CancellationToken.None, retainSuccessful: true);
|
||||
await Command("bash", ["-c", "printf '%s\\n' 'Container is not running.' >&2; exit 1"], fixture, label, CancellationToken.None, requireSuccess: false, retainSuccessful: true);
|
||||
var retained = Path.Combine(fixture, label + ".last-success.stdout.log");
|
||||
if (!File.Exists(retained) || File.ReadAllText(retained) != successful || File.ReadAllText(Path.Combine(fixture, label + ".stdout.log")) != "" || !File.ReadAllText(Path.Combine(fixture, label + ".stderr.log")).Contains("Container is not running."))
|
||||
throw new InvalidOperationException("A failed final capture lost the last successful resource snapshot.");
|
||||
using var receipt = JsonDocument.Parse(File.ReadAllText(Path.Combine(fixture, label + ".last-success.json")));
|
||||
if (receipt.RootElement.GetProperty("stdoutSha256").GetString() != Hash(Encoding.UTF8.GetBytes(successful)) || receipt.RootElement.GetProperty("exitCode").GetInt32() != 0) throw new InvalidOperationException("Last successful snapshot receipt does not identify the retained bytes.");
|
||||
}
|
||||
|
||||
static async Task<CommandResult> Command(string executable, string[] arguments, string output, string label, CancellationToken cancellation, bool requireSuccess = true, bool echo = false, bool retainSuccessful = false)
|
||||
{
|
||||
if (!label.StartsWith("capture-", StringComparison.Ordinal) && label is not "container" and not "container-running" and not "guest-container-resources")
|
||||
Console.WriteLine("[native-diagnostic] " + label);
|
||||
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var commandToken = commandCancellation.Token;
|
||||
var start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
start.Environment["GIT_TERMINAL_PROMPT"] = "0";
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start " + executable);
|
||||
async Task<string> Read(StreamReader reader, string stream)
|
||||
{
|
||||
var captured = new StringBuilder();
|
||||
var buffer = new char[8192];
|
||||
using var log = new StreamWriter(Path.Combine(output, label + "." + stream + ".log"), false, new UTF8Encoding(false));
|
||||
while (true)
|
||||
{
|
||||
var count = await reader.ReadAsync(buffer.AsMemory(), commandToken);
|
||||
if (count == 0) break;
|
||||
if (captured.Length + count > MaximumCapturedCharacters)
|
||||
{
|
||||
commandCancellation.Cancel();
|
||||
throw new InvalidOperationException(label + " exceeded its bounded diagnostic log size.");
|
||||
}
|
||||
captured.Append(buffer, 0, count);
|
||||
await log.WriteAsync(buffer.AsMemory(0, count), commandToken);
|
||||
await log.FlushAsync(commandToken);
|
||||
if (echo) Console.Write(new string(buffer, 0, count));
|
||||
}
|
||||
return captured.ToString();
|
||||
}
|
||||
var stdout = Read(process.StandardOutput, "stdout");
|
||||
var stderr = Read(process.StandardError, "stderr");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(commandToken));
|
||||
var result = new CommandResult(process.ExitCode, await stdout, await stderr);
|
||||
if (retainSuccessful && result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output))
|
||||
{
|
||||
File.WriteAllText(Path.Combine(output, label + ".last-success.stdout.log"), result.Output, new UTF8Encoding(false));
|
||||
Save(Path.Combine(output, label + ".last-success.json"), new { exitCode = result.ExitCode, stdoutSha256 = Hash(Encoding.UTF8.GetBytes(result.Output)), capturedUtc = DateTimeOffset.UtcNow });
|
||||
}
|
||||
if (requireSuccess && result.ExitCode != 0) throw new InvalidOperationException($"{label} exited {result.ExitCode}: {result.Error[..Math.Min(result.Error.Length, 1500)]}");
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
|
||||
static void PrintGuestProof(string output, string token)
|
||||
{
|
||||
var path = Path.Combine(output, "guest-proof.log");
|
||||
if (!File.Exists(path)) { Console.WriteLine("[native-diagnostic] No native guest proof was captured."); return; }
|
||||
var proof = File.ReadAllText(path).Replace(token, "<run-id>", StringComparison.Ordinal);
|
||||
const int budget = 512 * 1024;
|
||||
if (proof.Length > budget)
|
||||
proof = proof[..(64 * 1024)] + "\n[native-diagnostic] Middle of proof omitted from CI stdout; complete bounded proof is retained in the artifact.\n" + proof[^((budget - 64 * 1024))..];
|
||||
Console.WriteLine("[native-diagnostic] Final native guest proof:");
|
||||
Console.Write(proof);
|
||||
}
|
||||
static void Save(string path, object value)
|
||||
{
|
||||
var temporary = path + ".tmp";
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
sealed record OwnedResources(string Token, string ContainerName, string ImageTag, string WorkDirectory, string? ContainerId = null, string? ImageId = null);
|
||||
sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#:property PublishAot=false
|
||||
// Local diagnostic only. See TranscriptFileShareProbe.md for the contract and limits.
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
const string original = "---\ntitle: transcript probe\n---\n\n# Meeting Transcript\n";
|
||||
const string written = original + "[00:00:04] Guest-1: Azure returned ***** here.\n";
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-file-share-probe", Guid.NewGuid().ToString("N"));
|
||||
if (Directory.Exists(root))
|
||||
throw new IOException("Probe directory already exists; refusing to reuse it.");
|
||||
Directory.CreateDirectory(root);
|
||||
var cases = new List<WriteObservation>();
|
||||
var cleanupCompleted = false;
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(root, "original-reader.md");
|
||||
await File.WriteAllTextAsync(path, original);
|
||||
using (var reader = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
|
||||
{
|
||||
if (reader.ReadToEnd() != original)
|
||||
throw new InvalidDataException("Original reader did not read the initial fixture.");
|
||||
cases.Add(await ObserveWriteAsync("held-original-reader", path, written));
|
||||
}
|
||||
cases[^1] = cases[^1] with { ContentAfterReaderClosed = await File.ReadAllTextAsync(path) };
|
||||
cases.Add(await ObserveWriteAsync("original-reader-released", path, written));
|
||||
cases[^1] = cases[^1] with { ContentAfterReaderClosed = await File.ReadAllTextAsync(path) };
|
||||
|
||||
path = Path.Combine(root, "compatible-reader.md");
|
||||
await File.WriteAllTextAsync(path, original);
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
|
||||
{
|
||||
if (reader.ReadToEnd() != original)
|
||||
throw new InvalidDataException("Compatible reader did not read the initial fixture.");
|
||||
cases.Add(await ObserveWriteAsync("held-compatible-reader", path, written));
|
||||
}
|
||||
cases[^1] = cases[^1] with { ContentAfterReaderClosed = await File.ReadAllTextAsync(path) };
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
cleanupCompleted = !Directory.Exists(root);
|
||||
}
|
||||
|
||||
var windowsContractMatched = OperatingSystem.IsWindows()
|
||||
? !cases[0].Completed && cases[0].Error is { Type: "System.IO.IOException", NativeCode: 32 }
|
||||
&& cases[0].ContentAfterReaderClosed == original
|
||||
: (bool?)null;
|
||||
var compatibleAndReleasedWritesCompleted = cases.Skip(1).All(result =>
|
||||
result.Completed && result.Error is null && result.ContentAfterReaderClosed == written);
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
Schema = "meeting-assistant-file-share-probe/v1",
|
||||
Runtime = RuntimeInformation.FrameworkDescription,
|
||||
OS = RuntimeInformation.OSDescription,
|
||||
Architecture = RuntimeInformation.ProcessArchitecture.ToString(),
|
||||
ProbeDirectory = root,
|
||||
CleanupCompleted = cleanupCompleted,
|
||||
WindowsContractMatched = windowsContractMatched,
|
||||
CompatibleAndReleasedWritesCompleted = compatibleAndReleasedWritesCompleted,
|
||||
Cases = cases,
|
||||
EvidenceLimit = "Held-reader access-mode probe; it does not reproduce the timing or establish the cause of Run4174."
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return cleanupCompleted && compatibleAndReleasedWritesCompleted && windowsContractMatched != false ? 0 : 1;
|
||||
|
||||
static async Task<WriteObservation> ObserveWriteAsync(string name, string path, string content)
|
||||
{
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
Task? write = null;
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
write = File.WriteAllTextAsync(path, content, deadline.Token);
|
||||
await write;
|
||||
return new(name, true, write.Status.ToString(), elapsed.ElapsedMilliseconds, null, null);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return new(name, false, write?.Status.ToString() ?? "not-returned", elapsed.ElapsedMilliseconds,
|
||||
new(exception.GetType().FullName!, $"0x{exception.HResult:X8}", exception.HResult & 0xffff, exception.Message), null);
|
||||
}
|
||||
}
|
||||
|
||||
sealed record WriteObservation(string Name, bool Completed, string TaskStatus, long ElapsedMilliseconds,
|
||||
WriteError? Error, string? ContentAfterReaderClosed);
|
||||
sealed record WriteError(string Type, string HResult, int NativeCode, string Message);
|
||||
@@ -0,0 +1,25 @@
|
||||
# Transcript file sharing diagnostic
|
||||
|
||||
Purpose: distinguish a writer error from a completed write when a reader with the original test's access mode remains open. This temporary diagnostic does not change the app, its tests, or their 577-case count.
|
||||
|
||||
Entry point: `tools/ci/TranscriptFileShareProbe.cs`, a .NET 10 file-based app with BCL-only dependencies. From this clone:
|
||||
|
||||
```sh
|
||||
/Users/dh/.dotnet/dotnet run --file tools/ci/TranscriptFileShareProbe.cs
|
||||
```
|
||||
|
||||
On another machine use its .NET 10 SDK executable. The file-based app requires an SDK supporting file-based apps; the prepared local run uses SDK 10.0.401. Native AOT is disabled for this diagnostic so its JSON report can use the normal reflection serializer. It prints JSON with runtime/OS, write completion, exception type/HResult/native error code, content after reader disposal, and cleanup status. It creates a unique directory beneath the system temporary directory, writes two small fixture files, and deletes only that directory in `finally`. It starts no Meeting Assistant app, service, network client, container, or VM. The SDK can create its normal file-based build cache. For a fresh CLI profile set `DOTNET_GENERATE_ASPNET_CERTIFICATE=false` and `DOTNET_CLI_TELEMETRY_OPTOUT=1` to disable unrelated certificate/telemetry initialization.
|
||||
|
||||
The optional instrumentation in the existing recording-coordinator test observes public provider and transcript-store boundaries: audio consumed, fake segment yielded, append/rewrite entered, completed, or failed. Timeout output uses `[DEBUG-transcript-write-4174]` and includes elapsed milliseconds, exception type, HResult, and message. The reader, 15-second wait and final redaction assertions are unchanged. `StopAsync` always runs in `finally`; a stop failure does not mask the original timeout. Timestamps distinguish events before and after the failed wait. Instrumentation can affect race timing; a passing run alone does not explain the original failure. This temporary instrumentation should be removed after the actual Wine incident is explained.
|
||||
|
||||
The original-reader case uses the same path-taking `StreamReader` constructor as `File.ReadAllText`. It deliberately holds the reader after reading the fixture so that the overlap is deterministic. The original test normally disposes that reader immediately after `ReadToEnd`; therefore this probe checks compatible access modes, not the historical race's timing. The second write happens after disposing that reader. The compatible case holds `FileAccess.Read` with `FileShare.ReadWrite | FileShare.Delete`. No reader fix is applied to the actual test.
|
||||
|
||||
The existing Wine job runs this probe after its actual Windows SDK build and before the unchanged full test cohort. It writes `artifacts/tests/transcript-file-sharing.json` and prints that report into the CI log. Invocation there uses the already installed Windows SDK through the existing `WINE_BIN`; no runner or infrastructure capability is added. The measured Wine result is pending until this exact workflow executes.
|
||||
|
||||
## Primary-source contract
|
||||
|
||||
In [.NET runtime v10.0.12 File.cs](https://github.com/dotnet/runtime/blob/v10.0.12/src/libraries/System.Private.CoreLib/src/System/IO/File.cs#L572), `ReadAllText` constructs a path-taking `StreamReader`; [`StreamReader.cs`](https://github.com/dotnet/runtime/blob/v10.0.12/src/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs#L203) opens read access sharing only further readers. `WriteAllTextAsync` delegates to a create-mode write; [its writer](https://github.com/dotnet/runtime/blob/v10.0.12/src/libraries/System.Private.CoreLib/src/System/IO/File.cs#L1416) opens write access with reader sharing.
|
||||
|
||||
[Windows CreateFileW documentation](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew#parameters) requires existing access and sharing modes to remain compatible until handle closure. A held reader that does not permit writes therefore prevents that writer from opening; the Windows contract expects an `IOException` with sharing-violation native code 32. Allowing read/write sharing removes that incompatibility. Delete sharing is included for the comparison but this probe does not rename or delete an open file.
|
||||
|
||||
On Windows the CLI asserts that the original held-reader write fails with code 32 and leaves the original bytes, and that both subsequent writes complete with the expected content. On other platforms it reports the original-reader observation without asserting Windows behavior (`WindowsContractMatched: null`), and still checks completed compatible/released writes and cleanup. The Unix/macOS implementation can differ. A local macOS success is not evidence of Wine behavior or the cause of Run4174's timeout.
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Apple Recovery has Bash before any SDK is installed. Preserve the original
|
||||
# daemon under its launchd label/PID while the unchanged read-only probe runs.
|
||||
/bin/bash /Volumes/installstate/readiness.sh &
|
||||
exec /usr/libexec/recoveryosd
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/bin/bash
|
||||
# Existing macOS Recovery/launchd runtime hook; never installs or erases anything.
|
||||
set -u
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
PROOF_LOG="$STATE_DIR/proof.log"
|
||||
RESULT="$STATE_DIR/result.json"
|
||||
EXPECTED_BYTES=68719476736
|
||||
MAX_LOG_BYTES=4194304
|
||||
MAX_OUTPUT_BYTES=524288
|
||||
TIMER_FIFO="/tmp/native-diagnostic-$PROOF_TOKEN-$$.fifo"
|
||||
PENDING_OUTPUTS=()
|
||||
ACTIVE_COMMAND=""
|
||||
ACTIVE_TIMER=""
|
||||
os_version=""
|
||||
architecture=""
|
||||
uid=-1
|
||||
system_exit=-1
|
||||
arbitration_exit=-1
|
||||
recovery_exit=-1
|
||||
disk_list_exit=-1
|
||||
selected_disk=""
|
||||
disk_bytes=0
|
||||
|
||||
count=0
|
||||
while [ ! -d "$STATE_DIR" ] && (( count < 120 )); do
|
||||
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
[ -d "$STATE_DIR" ] || exit 1
|
||||
: > "$PROOF_LOG" || exit 1
|
||||
exec 3>> "$PROOF_LOG" || exit 1
|
||||
rm -f "$RESULT" "$RESULT.tmp"
|
||||
printf '[proof-token] %s\n' "$PROOF_TOKEN" >&3
|
||||
|
||||
finish() {
|
||||
local success="$1" reason="$2"
|
||||
flush_outputs || { success=false; reason=diagnostic_log_budget_exceeded; }
|
||||
printf '[proof-result] %s: %s\n' "$success" "$reason" >&3
|
||||
printf '{"token":"%s","success":%s,"reason":"%s","osVersion":"%s","architecture":"%s","uid":%s,"systemExit":%s,"diskArbitrationExit":%s,"recoveryExit":%s,"diskListExit":%s,"disk":"%s","diskBytes":%s,"readOnly":false}\n' \
|
||||
"$PROOF_TOKEN" "$success" "$reason" "$os_version" "$architecture" "$uid" \
|
||||
"$system_exit" "$arbitration_exit" "$recovery_exit" "$disk_list_exit" \
|
||||
"$selected_disk" "$disk_bytes" > "$RESULT.tmp"
|
||||
/bin/mv -f "$RESULT.tmp" "$RESULT" || exit 1
|
||||
exec 9>&-
|
||||
[ ! -p "$TIMER_FIFO" ] || /bin/rm -f "$TIMER_FIFO"
|
||||
# Keep the service alive for the bounded host diagnostic to capture evidence.
|
||||
while :; do sleep 60; done
|
||||
}
|
||||
|
||||
init_timer_fifo() {
|
||||
# Recovery has Bash 3.2 before any SDK is installed. Its read timeout uses
|
||||
# alarm(), avoiding a separate sleep process for every command and grace period.
|
||||
[ ! -e "$TIMER_FIFO" ] || exit 1
|
||||
/usr/bin/mkfifo -m 600 "$TIMER_FIFO" || exit 1
|
||||
exec 9<> "$TIMER_FIFO" || exit 1
|
||||
}
|
||||
|
||||
flush_outputs() {
|
||||
(( ${#PENDING_OUTPUTS[@]} > 0 )) || return 0
|
||||
local started=$SECONDS sizes="/tmp/native-diagnostic-$$.sizes" proof_size output_size raw_size
|
||||
local raw_count=0 raw_valid=1 pending_count=${#PENDING_OUTPUTS[@]}
|
||||
local bounded="/tmp/native-diagnostic-$$.flush"
|
||||
# One bounded native copy per group, rather than tail/stat startup per command.
|
||||
# Keep native byte-oriented copying: Bash 3.2 read -n would read large outputs
|
||||
# one byte per system call. Small scalar reads below have a separate tight bound.
|
||||
/usr/bin/tail -c "$MAX_OUTPUT_BYTES" "${PENDING_OUTPUTS[@]}" > "$bounded" || return 1
|
||||
/usr/bin/stat -f '%z' "$PROOF_LOG" "$bounded" "${PENDING_OUTPUTS[@]}" > "$sizes" || return 1
|
||||
{
|
||||
IFS= read -r proof_size; IFS= read -r output_size
|
||||
while IFS= read -r raw_size; do
|
||||
raw_count=$((raw_count + 1))
|
||||
[[ "$raw_size" =~ ^[0-9]+$ ]] && (( raw_size <= MAX_OUTPUT_BYTES )) || raw_valid=0
|
||||
done
|
||||
} < "$sizes"
|
||||
PENDING_OUTPUTS=()
|
||||
[[ "$proof_size" =~ ^[0-9]+$ && "$output_size" =~ ^[0-9]+$ ]] || return 1
|
||||
(( raw_valid == 1 && raw_count == pending_count )) || return 1
|
||||
(( proof_size + output_size + 1024 <= MAX_LOG_BYTES )) || return 1
|
||||
/bin/cat "$bounded" >&3 || return 1
|
||||
printf '\n[proof-flush] outputs-bytes=%s elapsed=%ss\n' "$output_size" "$((SECONDS - started))" >&3
|
||||
}
|
||||
|
||||
read_scalar() {
|
||||
local value status
|
||||
# All three values are short native machine/uid/version scalars. Reject excess
|
||||
# content instead of accepting a truncated first line as a successful gate.
|
||||
IFS= read -r -n 65 -d '' value < "$LAST_OUTPUT"; status=$?
|
||||
# EOF is mandatory: the byte bound or a NUL delimiter must never hide a suffix.
|
||||
(( status == 1 && ${#value} < 65 )) || return 1
|
||||
value=${value%$'\n'}
|
||||
[[ "$value" != *$'\n'* ]] || return 1
|
||||
SCALAR="$value"
|
||||
}
|
||||
|
||||
cancel_probe() {
|
||||
trap '' TERM INT
|
||||
if [ -n "$ACTIVE_COMMAND" ]; then
|
||||
kill -TERM "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
wait "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$ACTIVE_TIMER" ] || { kill -TERM "$ACTIVE_TIMER" 2>/dev/null || :; wait "$ACTIVE_TIMER" 2>/dev/null || :; }
|
||||
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||
finish false probe_cancelled
|
||||
}
|
||||
|
||||
run_command() {
|
||||
local name="$1"
|
||||
shift
|
||||
local process timer exit_code started waited command_limit=45
|
||||
# Run 4161: even native uname/ps startup took 34-42s under TCG.
|
||||
# Isolate only the failed UID gate; every other watchdog remains unchanged.
|
||||
[[ "$name" != uid ]] || command_limit=180
|
||||
LAST_OUTPUT="/tmp/native-diagnostic-$name.out"
|
||||
printf '\n[proof-command] %s:' "$name" >&3
|
||||
printf ' %s' "$@" >&3
|
||||
printf '\n' >&3
|
||||
started=$SECONDS
|
||||
"$@" > "$LAST_OUTPUT" 2>&1 &
|
||||
process=$!
|
||||
ACTIVE_COMMAND="$process"
|
||||
printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >&3
|
||||
printf '[proof-limit] %s %ss\n' "$name" "$command_limit" >&3
|
||||
(
|
||||
trap 'exit 0' TERM INT
|
||||
IFS= read -r -t "$command_limit" -u 9 unused || :
|
||||
printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >&3
|
||||
kill -TERM "$process" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$process" 2>/dev/null || :
|
||||
) &
|
||||
timer=$!
|
||||
ACTIVE_TIMER="$timer"
|
||||
wait "$process"
|
||||
exit_code=$?
|
||||
waited=$SECONDS
|
||||
# Includes fork/exec/wait, but excludes timer cleanup and evidence copying.
|
||||
printf '[proof-native-wait] %s child=%s elapsed=%ss exit=%s\n' "$name" "$process" "$((waited - started))" "$exit_code" >&3
|
||||
kill -TERM "$timer" 2>/dev/null || :
|
||||
wait "$timer" 2>/dev/null || :
|
||||
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||
printf '[proof-cleanup] %s child=%s elapsed=%ss total=%ss\n' "$name" "$process" "$((SECONDS - waited))" "$((SECONDS - started))" >&3
|
||||
printf '[proof-exit] %s\n' "$exit_code" >&3
|
||||
LAST_EXIT="$exit_code"
|
||||
PENDING_OUTPUTS+=("$LAST_OUTPUT")
|
||||
return 0
|
||||
}
|
||||
|
||||
diagnose_failure() {
|
||||
run_command kernel /usr/bin/uname -a
|
||||
run_command account /usr/bin/id
|
||||
run_command context /usr/sbin/sysctl kern.bootargs machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features
|
||||
run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm=
|
||||
run_command processes /bin/ps -axo pid,ppid,comm
|
||||
}
|
||||
|
||||
fail_probe() {
|
||||
local reason="$1"
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
diagnose_failure
|
||||
finish false "$reason"
|
||||
}
|
||||
|
||||
init_timer_fifo
|
||||
trap cancel_probe TERM INT
|
||||
|
||||
# Test the required native gates before optional process/CPU diagnostics.
|
||||
run_command architecture /usr/bin/uname -m
|
||||
(( LAST_EXIT == 0 )) || fail_probe architecture_probe_failed
|
||||
read_scalar || fail_probe architecture_output_invalid
|
||||
architecture="$SCALAR"
|
||||
[ "$architecture" = x86_64 ] || fail_probe unexpected_guest_architecture
|
||||
run_command uid /usr/bin/id -u
|
||||
(( LAST_EXIT == 0 )) || fail_probe uid_probe_failed
|
||||
read_scalar || fail_probe uid_output_invalid
|
||||
uid="$SCALAR"
|
||||
[ "$uid" = 0 ] || fail_probe recovery_account_not_root
|
||||
run_command platform /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
if (( platform_exit != 0 )); then
|
||||
run_command system /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >&3
|
||||
run_command platform-warm /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
fi
|
||||
(( platform_exit == 0 )) || fail_probe sw_vers_failed
|
||||
run_command version /usr/bin/sw_vers -productVersion
|
||||
(( LAST_EXIT == 0 )) || fail_probe product_version_failed
|
||||
read_scalar || fail_probe product_version_invalid
|
||||
os_version="$SCALAR"
|
||||
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid
|
||||
(( ${os_version%%.*} >= 13 )) || fail_probe unsupported_macos_version
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
|
||||
# Bound readiness independently of the host's 40-minute overall deadline.
|
||||
readiness_start=$SECONDS
|
||||
attempt=0
|
||||
while (( SECONDS - readiness_start < 600 )); do
|
||||
attempt=$((attempt + 1))
|
||||
printf '\n[readiness-attempt] %s\n' "$attempt" >&3
|
||||
run_command disks /usr/sbin/diskutil list physical
|
||||
disk_list_exit="$LAST_EXIT"
|
||||
if (( disk_list_exit == 0 )); then
|
||||
disk_list=$(cat "$LAST_OUTPUT")
|
||||
candidates=0
|
||||
while IFS= read -r disk; do
|
||||
[ -n "$disk" ] || continue
|
||||
run_command "info-$disk" /usr/sbin/diskutil info "/dev/$disk"
|
||||
(( LAST_EXIT == 0 )) || continue
|
||||
info=$(cat "$LAST_OUTPUT")
|
||||
if printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || continue
|
||||
size=$(printf '%s\n' "$info" | sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' | head -n 1)
|
||||
[[ "$size" =~ ^[0-9]+$ ]] || continue
|
||||
(( size == EXPECTED_BYTES )) || continue
|
||||
candidates=$((candidates + 1))
|
||||
selected_disk="/dev/$disk"
|
||||
disk_bytes="$size"
|
||||
printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >&3
|
||||
done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p')
|
||||
(( candidates <= 1 )) || fail_probe ambiguous_writable_64g_disks
|
||||
if (( candidates == 1 )); then
|
||||
# Re-probe live launchd domains after disk readiness, preserving native exits.
|
||||
run_command system_ready /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration_ready /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery_ready /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
(( system_exit == 0 && arbitration_exit == 0 && recovery_exit == 0 )) || fail_probe service_domain_not_ready
|
||||
finish true native_recovery_and_writable_64g_disk_ready
|
||||
fi
|
||||
fi
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
IFS= read -r -t 5 -u 9 unused || :
|
||||
done
|
||||
fail_probe disk_management_or_writable_target_not_ready
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Checksum binding for the pinned Linux Recovery UDIF patcher, not a new CLI.
|
||||
|
||||
Source semantics: planetbeing/libdmg-hfsplus dmg/dmglib.c and dmg/blkx.c.
|
||||
Only flattened, single-segment XML UDIF with CRC32 and raw/zlib data is accepted.
|
||||
The caller plans same-length chunk writes; XML formatting and all offsets remain.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import plistlib
|
||||
import re
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
|
||||
def u32(data, offset):
|
||||
return struct.unpack_from(">I", data, offset)[0]
|
||||
|
||||
|
||||
def u64(data, offset):
|
||||
return struct.unpack_from(">Q", data, offset)[0]
|
||||
|
||||
|
||||
def crc_contract(data, offset):
|
||||
if u32(data, offset) != 2 or u32(data, offset + 4) != 32 or any(data[offset + 12:offset + 136]):
|
||||
raise RuntimeError("Unsupported UDIF checksum type/size/padding")
|
||||
return u32(data, offset + 8)
|
||||
|
||||
|
||||
class ChecksumPlan:
|
||||
def __init__(self, image, koly, plist, xml_offset, xml_length, size):
|
||||
self.image, self.koly, self.plist = image, koly, plist
|
||||
self.xml_offset, self.xml_length, self.size = xml_offset, xml_length, size
|
||||
self.data_offset, self.data_length = u64(koly, 24), u64(koly, 32)
|
||||
if (u32(koly, 4) != 4 or u32(koly, 8) != 512 or u32(koly, 12) != 1
|
||||
or self.data_offset != 0 or u64(koly, 40) or u64(koly, 48)
|
||||
or u32(koly, 60) not in (0, 1) or self.data_length != xml_offset
|
||||
or xml_offset + xml_length > size - 512 or xml_length > 8 * 1024 * 1024):
|
||||
raise RuntimeError("Unsupported or out-of-bounds flattened UDIF layout")
|
||||
crc_contract(koly, 80)
|
||||
crc_contract(koly, 352)
|
||||
image.seek(xml_offset)
|
||||
self.xml = image.read(xml_length)
|
||||
if len(self.xml) != xml_length or not self.xml.lstrip().startswith(b"<?xml"):
|
||||
raise RuntimeError("Unsupported UDIF metadata framing")
|
||||
self.blocks = plist["resource-fork"]["blkx"]
|
||||
self.physical_runs = {}
|
||||
intervals = []
|
||||
for block in self.blocks:
|
||||
mish = block["Data"]
|
||||
if len(mish) < 244 or mish[:4] != b"mish" or (len(mish) - 204) % 40 or u32(mish, 200) != (len(mish) - 204) // 40:
|
||||
raise RuntimeError("Malformed UDIF block table")
|
||||
crc_contract(mish, 64)
|
||||
if u64(mish, 8) + u64(mish, 16) > u64(koly, 492):
|
||||
raise RuntimeError("UDIF partition exceeds logical disk boundary")
|
||||
count = u32(mish, 200)
|
||||
if u32(mish, 204 + (count - 1) * 40) != 0xffffffff:
|
||||
raise RuntimeError("UDIF block table has no final terminator")
|
||||
for kind, offset, length, sectors in self.runs(mish):
|
||||
if kind in (2, 0x7ffffffe, 0xffffffff):
|
||||
if length:
|
||||
raise RuntimeError("Non-data UDIF run has stored bytes")
|
||||
continue
|
||||
if kind not in (1, 0x80000005) or not sectors or length <= 0 or sectors * 512 > 32 * 1024 * 1024:
|
||||
raise RuntimeError("Unsupported UDIF compression/run boundary")
|
||||
if offset < self.data_offset or offset + length > self.data_offset + self.data_length:
|
||||
raise RuntimeError("UDIF data run exceeds data-fork boundary")
|
||||
intervals.append((offset, offset + length))
|
||||
self.physical_runs[offset] = length
|
||||
intervals.sort()
|
||||
if any(left[1] > right[0] for left, right in zip(intervals, intervals[1:])):
|
||||
raise RuntimeError("Overlapping UDIF physical data runs")
|
||||
|
||||
def runs(self, mish):
|
||||
for entry in range(204, len(mish), 40):
|
||||
kind = u32(mish, entry)
|
||||
sector, sectors = u64(mish, entry + 8), u64(mish, entry + 16)
|
||||
if sector + sectors > u64(mish, 16):
|
||||
raise RuntimeError("UDIF run exceeds its partition boundary")
|
||||
offset = self.data_offset + u64(mish, 24) + u64(mish, entry + 24)
|
||||
yield kind, offset, u64(mish, entry + 32), sectors
|
||||
|
||||
def read(self, offset, length):
|
||||
self.image.seek(offset)
|
||||
result = self.image.read(length)
|
||||
if len(result) != length:
|
||||
raise RuntimeError("Short UDIF checksum read")
|
||||
return result
|
||||
|
||||
def logical_crcs(self, mish, replacements):
|
||||
original_crc = patched_crc = 0
|
||||
for kind, offset, length, sectors in self.runs(mish):
|
||||
# IGNORE runs are excluded by the independently verified Apple 13
|
||||
# baseline. Unknown ZERO/compression types are rejected above.
|
||||
if kind not in (1, 0x80000005):
|
||||
continue
|
||||
old = self.read(offset, length)
|
||||
new = replacements.get(offset, old)
|
||||
old_decoded = old if kind == 1 else zlib.decompress(old)
|
||||
new_decoded = new if kind == 1 else zlib.decompress(new)
|
||||
if len(old_decoded) != sectors * 512 or len(new_decoded) != sectors * 512 or len(old) != len(new):
|
||||
raise RuntimeError("UDIF checksum run changed physical/logical extent")
|
||||
original_crc = zlib.crc32(old_decoded, original_crc)
|
||||
patched_crc = zlib.crc32(new_decoded, patched_crc)
|
||||
return original_crc, patched_crc
|
||||
|
||||
def data_crcs(self, replacements):
|
||||
old_crc = new_crc = 0
|
||||
cursor = self.data_offset
|
||||
def same_until(stop):
|
||||
nonlocal cursor, old_crc, new_crc
|
||||
while cursor < stop:
|
||||
data = self.read(cursor, min(1024 * 1024, stop - cursor))
|
||||
old_crc, new_crc = zlib.crc32(data, old_crc), zlib.crc32(data, new_crc)
|
||||
cursor += len(data)
|
||||
for offset, new in sorted(replacements.items()):
|
||||
same_until(offset)
|
||||
old = self.read(offset, len(new))
|
||||
old_crc, new_crc = zlib.crc32(old, old_crc), zlib.crc32(new, new_crc)
|
||||
cursor += len(new)
|
||||
same_until(self.data_offset + self.data_length)
|
||||
return old_crc, new_crc
|
||||
|
||||
def prepare(self, planned):
|
||||
replacements = dict(planned)
|
||||
if len(replacements) != len(planned):
|
||||
raise RuntimeError("Duplicate planned UDIF physical writes")
|
||||
if any(self.physical_runs.get(offset) != len(data) for offset, data in replacements.items()):
|
||||
raise RuntimeError("Planned UDIF write does not preserve an existing data-run boundary")
|
||||
old_master = bytearray()
|
||||
new_master = bytearray()
|
||||
changed = []
|
||||
for block in self.blocks:
|
||||
mish = block["Data"]
|
||||
old_crc, new_crc = self.logical_crcs(mish, replacements)
|
||||
if old_crc != crc_contract(mish, 64):
|
||||
raise RuntimeError("Original UDIF logical CRC32 mismatch")
|
||||
old_master.extend(struct.pack(">I", old_crc))
|
||||
new_master.extend(struct.pack(">I", new_crc))
|
||||
if new_crc != old_crc:
|
||||
new_mish = bytearray(mish)
|
||||
struct.pack_into(">I", new_mish, 72, new_crc)
|
||||
changed.append((mish, bytes(new_mish)))
|
||||
old_data_crc, new_data_crc = self.data_crcs(replacements)
|
||||
if old_data_crc != crc_contract(self.koly, 80) or zlib.crc32(old_master) != crc_contract(self.koly, 352):
|
||||
raise RuntimeError("Original UDIF data-fork/master CRC32 mismatch")
|
||||
xml = self.xml
|
||||
for old_mish, new_mish in changed:
|
||||
matches = [match for match in re.finditer(rb"<data>([\sA-Za-z0-9+/=]*)</data>", xml)
|
||||
if base64.b64decode(match.group(1)) == old_mish]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("UDIF block checksum XML identity is ambiguous")
|
||||
match = matches[0]
|
||||
encoded = iter(base64.b64encode(new_mish))
|
||||
text = bytes(value if chr(value).isspace() else next(encoded) for value in match.group(1))
|
||||
xml = xml[:match.start(1)] + text + xml[match.end(1):]
|
||||
if len(xml) != self.xml_length:
|
||||
raise RuntimeError("UDIF checksum update changed XML region length")
|
||||
new_plist = plistlib.loads(xml)
|
||||
expected = dict(self.plist)
|
||||
expected["resource-fork"] = dict(self.plist["resource-fork"])
|
||||
expected["resource-fork"]["blkx"] = [dict(block, Data=dict(changed).get(block["Data"], block["Data"])) for block in self.blocks]
|
||||
if new_plist != expected:
|
||||
raise RuntimeError("UDIF checksum update changed unrelated metadata")
|
||||
koly = bytearray(self.koly)
|
||||
struct.pack_into(">I", koly, 88, new_data_crc)
|
||||
struct.pack_into(">I", koly, 360, zlib.crc32(new_master))
|
||||
self.receipt = dict(originalDataCrc32=f"{old_data_crc:08x}", patchedDataCrc32=f"{new_data_crc:08x}",
|
||||
originalMasterCrc32=f"{zlib.crc32(old_master):08x}", patchedMasterCrc32=f"{zlib.crc32(new_master):08x}",
|
||||
changedBlockChecksums=len(changed), imageBytes=self.size, xmlOffset=self.xml_offset,
|
||||
xmlBytes=self.xml_length, physicalAndLogicalExtentsPreserved=True)
|
||||
return xml, bytes(koly)
|
||||
|
||||
def verify(self):
|
||||
koly = self.read(self.size - 512, 512)
|
||||
xml = self.read(self.xml_offset, self.xml_length)
|
||||
verifier = ChecksumPlan(self.image, koly, plistlib.loads(xml), self.xml_offset, self.xml_length, self.size)
|
||||
verifier.prepare([])
|
||||
self.image.seek(0, 2)
|
||||
if self.image.tell() != self.size:
|
||||
raise RuntimeError("Patched UDIF image length changed")
|
||||
print("[recovery-udif] " + json.dumps(dict(self.receipt, readBackChecksumsVerified=True), sort_keys=True))
|
||||
Reference in New Issue
Block a user