forked from Manuel/meeting-assistant
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b19b08f2e | ||
|
|
05e23857dd | ||
|
|
83726a2233 | ||
|
|
4840b8e3be |
@@ -1,36 +0,0 @@
|
||||
name: Native macOS Recovery diagnostic on Ubuntu
|
||||
|
||||
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 native macOS Recovery with existing Docker resources
|
||||
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
|
||||
@@ -24,27 +24,23 @@ jobs:
|
||||
|
||||
- name: Install Wine
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends wine64 wine32 winbind unzip
|
||||
if [ -d /usr/lib/wine ]; then
|
||||
echo "/usr/lib/wine" >> "${GITHUB_PATH}"
|
||||
export PATH="${PATH}:/usr/lib/wine"
|
||||
fi
|
||||
if command -v wine >/dev/null 2>&1; then
|
||||
WINE_BIN="$(command -v wine)"
|
||||
elif command -v wine64 >/dev/null 2>&1; then
|
||||
WINE_BIN="$(command -v wine64)"
|
||||
elif [ -x /usr/lib/wine/wine64 ]; then
|
||||
WINE_BIN="/usr/lib/wine/wine64"
|
||||
elif [ -x /usr/lib/wine/wine ]; then
|
||||
WINE_BIN="/usr/lib/wine/wine"
|
||||
else
|
||||
echo "No wine binary found after installation."
|
||||
ls -la /usr/lib/wine || true
|
||||
exit 1
|
||||
fi
|
||||
sudo apt-get install -y --no-install-recommends ca-certificates curl gnupg winbind unzip
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/winehq-archive.key >/dev/null
|
||||
. /etc/os-release
|
||||
curl -fsSL "https://dl.winehq.org/wine-builds/ubuntu/dists/${VERSION_CODENAME}/winehq-${VERSION_CODENAME}.sources" \
|
||||
| sudo tee /etc/apt/sources.list.d/winehq.sources >/dev/null
|
||||
sudo apt-get update
|
||||
# Wine 9 cannot enumerate the DOS_DOT patterns used by .NET 10's SDK pack lookup.
|
||||
sudo apt-get install -y --no-install-recommends "winehq-stable=11.0.0.0~${VERSION_CODENAME}-1"
|
||||
WINE_BIN="/opt/wine-stable/bin/wine"
|
||||
test -x "${WINE_BIN}"
|
||||
echo "WINE_BIN=${WINE_BIN}" >> "${GITHUB_ENV}"
|
||||
"${WINE_BIN}" --version
|
||||
|
||||
@@ -77,6 +73,34 @@ jobs:
|
||||
echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}"
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info
|
||||
|
||||
- name: Provision NuGet signature trust inside Wine
|
||||
run: |
|
||||
SDK_VERSION="$(dotnet --version)"
|
||||
SDK_TRUST_ROOT="${DOTNET_ROOT}/sdk/${SDK_VERSION}/trustedroots"
|
||||
dotnet publish scripts/wine-sdk-trust.cs \
|
||||
-c Release \
|
||||
-p:UseAppHost=false \
|
||||
-p:PublishAot=false \
|
||||
-o "${RUNNER_TEMP}/wine-sdk-trust" \
|
||||
--nologo
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" \
|
||||
"Z:${RUNNER_TEMP}/wine-sdk-trust/WineSdkTrust.dll" --import \
|
||||
"Z:${SDK_TRUST_ROOT}/codesignctl.pem" \
|
||||
"Z:${SDK_TRUST_ROOT}/timestampctl.pem"
|
||||
|
||||
- name: Diagnose Windows SDK targeting-pack resolution
|
||||
run: |
|
||||
find "${WIN_DOTNET_DIR}/packs" -maxdepth 4 -name PackageOverrides.txt -print
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
-p:TargetFramework=net10.0 \
|
||||
-getProperty:NetCoreRoot,NetCoreTargetingPackRoot,PrunePackageDataRoot,PrunePackageTargetingPackRoots,MSBuildSDKsPath,DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
-p:TargetFramework=net10.0 \
|
||||
-t:AddPrunePackageReferences \
|
||||
-v:normal
|
||||
|
||||
- name: Build Windows desktop target via Wine
|
||||
run: |
|
||||
rm -f MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
|
||||
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
|
||||
<PackageReference Include="H.NotifyIcon" Version="2.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -175,8 +175,6 @@ 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.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Native macOS Recovery diagnostic on the existing Ubuntu runner
|
||||
|
||||
This manual diagnostic tests the unresolved Recovery startup boundary before adding a native macOS application test job. It does not install macOS, erase a guest disk, install .NET or Apple CLT, or run Meeting Assistant tests. A green diagnostic means only that a real macOS 14+ x86_64 Recovery guest has a working launchd system domain, DiskArbitration and exactly one writable 64-GiB guest disk.
|
||||
|
||||
The workflow `.gitea/workflows/macos-native-diagnostic.yaml` has only `workflow_dispatch`; it does not run on ordinary pushes or pull requests. It uses the same `ubuntu-latest` label and existing Docker daemon as the current builds. There are no runner changes, extra host devices, privileged containers, added capabilities, published ports, host networking or new secrets. It fails clearly if the existing Docker daemon cannot fit its bounded resource budget.
|
||||
|
||||
## Helper entry point and invocation
|
||||
|
||||
The orchestration is a .NET 10 file-based C# app at `tools/ci/MacOsNativeDiagnostic.cs`:
|
||||
|
||||
```sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --help
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/pinned/dockur-clone --output artifacts/native-validation
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||
```
|
||||
|
||||
Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docker CLI/socket. The actual execution downloads public Dockur source, upstream build assets, Docker images and Apple Recovery; it does not use workstation credentials. The existing upstream Python UDIF patcher and the Bash hook are retained because they run inside the pinned Linux/macOS boot integration. Independent orchestration and validation remain C#.
|
||||
|
||||
The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable.
|
||||
|
||||
The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Its own native commands have per-command watchdogs and a ten-minute readiness phase; the host orchestrator has a 40-minute deadline and the workflow a 45-minute limit.
|
||||
|
||||
## Evidence and cleanup
|
||||
|
||||
Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails.
|
||||
|
||||
Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup.
|
||||
|
||||
The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips.
|
||||
|
||||
Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. The next actual remote build must verify this compatibility change; it does not claim native readiness.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Wine SDK certificate trust
|
||||
|
||||
`scripts/wine-sdk-trust.cs` is a .NET 10 file-based helper for validating the public root-certificate bundles distributed with the Microsoft SDK and importing them into a disposable Wine prefix's Windows `CurrentUser Root` store. It does not disable NuGet signature verification or certificate checks and does not import private keys.
|
||||
|
||||
```sh
|
||||
dotnet publish scripts/wine-sdk-trust.cs -c Release -p:UseAppHost=false -p:PublishAot=false -o artifacts/wine-sdk-trust
|
||||
dotnet artifacts/wine-sdk-trust/WineSdkTrust.dll --validate /path/to/sdk/trustedroots/codesignctl.pem /path/to/sdk/trustedroots/timestampctl.pem
|
||||
```
|
||||
|
||||
Validation is read-only on every OS. Both files must exist and contain public PEM certificates. Explicit non-CA certificates are rejected. Historical self-issued SDK roots without `BasicConstraints` remain valid; certificate expiry is not filtered because trusted bundles also support historical signatures. The helper logs each bundle's certificate count and SHA-256, then the unique certificate count. Use bundles from the verified Microsoft SDK archive and retain those hashes with the SDK provenance.
|
||||
|
||||
For the Windows SDK installed inside a disposable Wine prefix, invoke the published DLL with `--import` and the two authoritative Linux SDK bundle paths, for example:
|
||||
|
||||
```sh
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" artifacts/wine-sdk-trust/WineSdkTrust.dll --import \
|
||||
"Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/codesignctl.pem" \
|
||||
"Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/timestampctl.pem"
|
||||
```
|
||||
|
||||
The import mode first requires `OperatingSystem.IsWindows()` and the Wine-specific `wine_get_version` export from `ntdll.dll`, located with `NativeLibrary.TryLoad` and `TryGetExport`. Native Windows is rejected as well as macOS/Linux. Only after both bundles validate does it open `CurrentUser Root` for writing and call `AddRange`. It closes the store, reopens it read-only and asserts that every imported thumbprint is present. There is no localized shell command, interactive prompt or GUI automation. Missing/unknown arguments and import attempts outside Wine return `2`; validation/import failures return `1`; successful validation or verified import returns `0`.
|
||||
|
||||
The write side effect is confined to the selected Wine prefix's current-user root store. Repeated imports are safe. Run it only against the disposable CI prefix. Native validation never opens any certificate store, and native import attempts are rejected before parsing bundles or constructing a store. The normal PR workflow publishes this DLL and imports the bundles before the Windows desktop restore/build. This repair still requires a real Wine restore/build result to establish that it fixes the observed trust failure.
|
||||
|
||||
[Microsoft documents](https://learn.microsoft.com/en-us/dotnet/core/tools/nuget-signed-package-verification) that these SDK bundles originate from its Trusted Root Program and provide code-signing and timestamping roots. The Windows NuGet restore remains responsible for checking actual package signatures; this helper populates the Wine store used for those checks.
|
||||
|
||||
Local qualification on 2026-10-03 used SDK 10.0.203 to publish the DLL and the authoritative bundles from SDK 10.0.401 to validate it on macOS/.NET 10.0.7. Both bundles passed: codesign contained 307 certificates including seven historical roots without constraints; timestamp contained 327 including two such roots; the union contained 372 unique thumbprints. Their SHA-256 hashes were `AAB671F52E5229906B2100727370007EF4B6D2E360B23F2CF12A6D87773BE611` and `5CCB03367B52F047099F07E1653160E8578A83711CB18560C979FEBB65F8CB5D`, respectively. A native `--import` invocation with those same paths returned `2` before parsing bundles or opening a store; unknown arguments returned `2`, and an empty input returned `1`. No local store import was performed. This is helper qualification, not a passing Wine/NuGet result.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Windows desktop build under Wine
|
||||
|
||||
The PR workflow uses the existing `ubuntu-latest` Gitea runner. It builds `net10.0-windows10.0.19041.0` with the Windows .NET SDK under Wine, then runs the portable `net10.0` test suite through that Windows host. The desktop target retains WPF, `WinExe`, x64, and the Windows recording, hotkey, Outlook, screenshot, tray, and notification implementations.
|
||||
|
||||
## Observed failures
|
||||
|
||||
On 2026-10-03, run 4154 at commit `05e23857ddf9e6990b76660e2ef5c1b429bfe66b` used Wine 11 and successfully imported and read back all 372 unique thumbprints from the Microsoft SDK's code-signing and timestamp trust bundles. The Windows SDK targeting-pack diagnostic succeeded, and the Windows desktop restore completed. NuGet signature verification was not disabled. See [the trust helper documentation](wine-sdk-trust.md).
|
||||
|
||||
The desktop build then failed in `WinAppSdkExpandPriContent` from `Microsoft.Windows.SDK.BuildTools.MSIX` 1.7.20250829.1. MakePRI reported `PRI175: 0x80004001 - Dump`, `MakePri failed with error: Not implemented`, and `PRI222`. The following `Root element is missing` exception came from loading the missing dump output; it was a secondary failure. These logs establish a MakePRI compatibility failure under Wine, but do not identify the particular unimplemented Wine API.
|
||||
|
||||
## Dependency correction
|
||||
|
||||
The resolved Windows graph was:
|
||||
|
||||
```text
|
||||
H.NotifyIcon.Uno.WinUI 2.4.1
|
||||
-> H.NotifyIcon 2.4.1
|
||||
-> Microsoft.WindowsAppSDK 1.8.251106002
|
||||
-> Microsoft.WindowsAppSDK.Base 1.8.250831001
|
||||
-> Microsoft.Windows.SDK.BuildTools.MSIX 1.7.20250829.1
|
||||
```
|
||||
|
||||
`UnoTaskbarIconService.Windows.cs` uses `H.NotifyIcon.Core.TrayIconWithContextMenu`, `PopupMenu`, `PopupMenuItem`, `PopupMenuSeparator`, and `PopupSubMenu`. These types come from the already-selected `H.NotifyIcon` 2.4.1 assembly. The application does not use the wrapper's WinUI/Uno XAML controls. The Windows package reference therefore selects `H.NotifyIcon` 2.4.1 directly. This keeps the same core assembly and tray APIs while removing the unused WinUI wrapper and its Windows App SDK graph. `CommunityToolkit.WinUI.Notifications` 7.1.2 remains available for toast notifications and does not introduce Windows App SDK in the selected Windows target.
|
||||
|
||||
The package author's [core-package documentation](https://www.nuget.org/packages/H.NotifyIcon/2.4.1) supports using `H.NotifyIcon` directly, including in console applications. Its `net10.0` dependency group contains `H.GeneratedIcons.System.Drawing` 2.4.1. The [wrapper package](https://www.nuget.org/packages/H.NotifyIcon.Uno.WinUI/2.4.1) adds Windows App SDK for its Windows target.
|
||||
|
||||
PRI expansion discovers referenced asset files that are absent from normal project outputs and adds them to copy-local output. Disabling that target would risk losing real WinUI resource payloads. This correction removes an unused application dependency instead of disabling PRI generation/expansion, creating a dummy PRI file, suppressing signature checks, or changing the desktop target.
|
||||
|
||||
## Qualification and remaining CI evidence
|
||||
|
||||
Local qualification on 2026-10-03 used the existing `mcr.microsoft.com/dotnet/sdk:10.0-noble` container image with SDK 10.0.401. The existing NuGet package cache was a read-only fallback; new package/cache writes went to a separate temporary directory. This command inside the container compiled the complete Windows desktop target:
|
||||
|
||||
```sh
|
||||
dotnet build MeetingAssistant/MeetingAssistant.csproj \
|
||||
-c Release -f net10.0-windows10.0.19041.0 -r win-x64 \
|
||||
-p:EnableWindowsTargeting=true -p:RestoreFallbackFolders=/host-nuget --nologo
|
||||
```
|
||||
|
||||
The command returned zero with `Build succeeded`, zero errors, and four existing NAudio deprecation warnings. The produced runtime configuration retains `Microsoft.WindowsDesktop.App` alongside the .NET and ASP.NET frameworks. The regenerated assets graph contains `H.NotifyIcon/2.4.1`, no `Microsoft.WindowsAppSDK*` packages, and no `Microsoft.Windows.SDK.BuildTools.MSIX`; generated package imports contain no Windows App SDK or MakePRI entry. The output `H.NotifyIcon.dll` is byte-identical to the previously selected cached core assembly, with SHA-256 `0027e443a6121af8fb616c0200d3c63bf4abb72e3c548e119fee1eae321a8368`. No application source or target suppression was required. This Linux cross-build does not establish that Windows `dotnet.exe` succeeds under Wine.
|
||||
|
||||
Completion requires a new Gitea run at the corrected commit: successful Windows desktop build under Wine with a freshly produced `MeetingAssistant.dll`, followed by actual Windows-host test execution with a fresh `wine.trx`, nonzero executed tests, zero failed tests, and a successful job. The workflow removes the previous DLL/TRX at the expected paths before these commands and requires nonempty replacements. The Wine test project targets `net10.0`, so these tests do not prove execution of Windows-TFM-only or WPF/Outlook UI behavior. Native macOS tests report their explicit platform skip outside macOS.
|
||||
@@ -0,0 +1,150 @@
|
||||
#:property PublishAot=false
|
||||
#:property UseAppHost=false
|
||||
#:property AssemblyName=WineSdkTrust
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
if (args.Length != 3 || (args[0] != "--validate" && args[0] != "--import"))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: WineSdkTrust <--validate|--import> <codesignctl.pem> <timestampctl.pem>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var import = args[0] == "--import";
|
||||
if (import && (!OperatingSystem.IsWindows() || !IsWine()))
|
||||
{
|
||||
Console.Error.WriteLine("REFUSED: --import requires Windows under Wine (ntdll.dll!wine_get_version). No certificate store was opened.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
|
||||
Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
|
||||
Console.WriteLine($"Mode: {args[0]}");
|
||||
var certificates = new X509Certificate2Collection();
|
||||
try
|
||||
{
|
||||
foreach (var path in args.Skip(1))
|
||||
{
|
||||
if (!File.Exists(path) || new FileInfo(path).Length == 0)
|
||||
{
|
||||
throw new InvalidDataException($"The SDK certificate bundle is missing or empty: {path}");
|
||||
}
|
||||
|
||||
var bundle = new X509Certificate2Collection();
|
||||
try
|
||||
{
|
||||
bundle.ImportFromPemFile(path);
|
||||
if (bundle.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException($"The SDK bundle contains no PEM certificates: {path}");
|
||||
}
|
||||
|
||||
var legacyRoots = 0;
|
||||
foreach (var certificate in bundle)
|
||||
{
|
||||
if (certificate.HasPrivateKey)
|
||||
{
|
||||
throw new InvalidDataException($"The SDK bundle must contain public certificates only: {certificate.Thumbprint}");
|
||||
}
|
||||
|
||||
var constraints = certificate.Extensions.OfType<X509BasicConstraintsExtension>().SingleOrDefault();
|
||||
if (constraints is { CertificateAuthority: false })
|
||||
{
|
||||
throw new InvalidDataException($"The SDK bundle contains a non-CA certificate: {certificate.Thumbprint}");
|
||||
}
|
||||
if (constraints is null)
|
||||
{
|
||||
// Microsoft also ships historical roots without the BasicConstraints extension.
|
||||
if (!certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData))
|
||||
{
|
||||
throw new InvalidDataException($"A certificate without CA constraints is not self-issued: {certificate.Thumbprint}");
|
||||
}
|
||||
legacyRoots++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Bundle: {Path.GetFullPath(path)}");
|
||||
Console.WriteLine($" SHA256: {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))}");
|
||||
Console.WriteLine($" Certificates: {bundle.Count}; historical self-issued roots without BasicConstraints: {legacyRoots}");
|
||||
certificates.AddRange(bundle);
|
||||
bundle.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var certificate in bundle)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var thumbprints = certificates.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Console.WriteLine($"Unique SDK certificate thumbprints: {thumbprints.Count}");
|
||||
if (!import)
|
||||
{
|
||||
Console.WriteLine("PASS: both SDK bundles validated; no certificate store was opened.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
|
||||
{
|
||||
store.Open(OpenFlags.ReadWrite);
|
||||
store.AddRange(certificates);
|
||||
}
|
||||
|
||||
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var installed = store.Certificates;
|
||||
try
|
||||
{
|
||||
var installedThumbprints = installed.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missing = thumbprints.Except(installedThumbprints).ToArray();
|
||||
if (missing.Length != 0)
|
||||
{
|
||||
throw new CryptographicException($"SDK certificates missing after import: {string.Join(", ", missing)}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var certificate in installed)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"PASS: all {thumbprints.Count} SDK certificate thumbprints verified in CurrentUser Root.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"FAIL: {exception.GetType().Name}: {exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var certificate in certificates)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsWine()
|
||||
{
|
||||
if (!NativeLibrary.TryLoad("ntdll.dll", out var library))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return NativeLibrary.TryGetExport(library, "wine_get_version", out _);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeLibrary.Free(library);
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
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 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);
|
||||
|
||||
public static async Task<int> Execute(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Contains("--help"))
|
||||
{
|
||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--output artifacts/native-macos] [--source existing-dockur-clone]");
|
||||
return 0;
|
||||
}
|
||||
var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos");
|
||||
if (args.Contains("--validate"))
|
||||
{
|
||||
ValidateContracts();
|
||||
if (Option(args, "--source") is { } source)
|
||||
await PrepareSource(Path.GetFullPath(source), output, "validation", false, CancellationToken.None);
|
||||
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, 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, 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", "--env", "KVM=N", "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=14", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2", state.ImageTag], output, "docker-create", deadline.Token);
|
||||
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 unprivileged TCG guest is starting. Success requires native macOS 14+/x86_64 and a writable 64-GiB disk; no installer will run.");
|
||||
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.");
|
||||
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.ContainerName, output, captureDeadline.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()
|
||||
{
|
||||
XDocument.Parse(DiagnosticDaemon);
|
||||
if (Encoding.UTF8.GetByteCount(DiagnosticDaemon) > Encoding.UTF8.GetByteCount(OriginalDaemon + "\n")) throw new InvalidOperationException("Daemon replacement exceeds original file.");
|
||||
var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "14.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 });
|
||||
ValidateResult(good, "validation");
|
||||
foreach (var invalid in new[] { good.Replace("14.6.1", "13.6.1"), good.Replace("x86_64", "arm64"), good.Replace("\"readOnly\":false", "\"readOnly\":true"), good.Replace("\"success\":true", "\"success\":false"), good.Replace("68719476736", "17179869184"), good.Replace("validation", "stale") })
|
||||
{
|
||||
try { ValidateResult(invalid, "validation"); } catch (InvalidOperationException) { continue; }
|
||||
throw new InvalidOperationException("Diagnostic validator accepted an invalid/stale result.");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task PrepareSource(string source, string output, string token, bool writeSource, CancellationToken cancellation)
|
||||
{
|
||||
Directory.CreateDirectory(output);
|
||||
var patchPath = Path.Combine(source, "src/install/recovery/patch.py");
|
||||
var originalPatch = File.ReadAllText(patchPath);
|
||||
if (Hash(Encoding.UTF8.GetBytes(originalPatch)) != "84f13db88c02edbf5ce21a39571fe58f12bebf5b0886c2d012f16ddbaed45323") throw new InvalidOperationException("Pinned Recovery patcher hash mismatch.");
|
||||
var patch = ReplaceOnce(originalPatch, OriginalBootstrap, MountOnlyBootstrap);
|
||||
var oldConstants = "RECOVERY_ORIGINAL = b\"/usr/libexec/recoveryosd\"\nRECOVERY_REPLACEMENT = b\"/private/etc/rc.cdrom.sh\"";
|
||||
var daemon = OriginalDaemon + "\n";
|
||||
var constants = "RECOVERY_ORIGINAL = b'''" + daemon + "'''\nRECOVERY_REPLACEMENT = b'''" + DiagnosticDaemon + "'''.ljust(len(RECOVERY_ORIGINAL), b\" \")";
|
||||
patch = ReplaceOnce(patch, oldConstants, constants);
|
||||
var dockerPath = Path.Combine(source, "Dockerfile");
|
||||
// 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);
|
||||
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");
|
||||
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
||||
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
||||
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", hook), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) })
|
||||
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 "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").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, "container-entry.sh")], output, "entry-syntax", cancellation);
|
||||
if (!writeSource) return;
|
||||
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
||||
File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false));
|
||||
File.WriteAllText(entryPath, entry, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), hook, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
||||
static string ReplaceAllExact(string text, string oldValue, string newValue, int expected)
|
||||
{
|
||||
var count = text.Split(oldValue, StringSplitOptions.None).Length - 1;
|
||||
if (count != expected) throw new InvalidOperationException($"Pinned source contract expected {expected} match(es), found {count}: {oldValue.Split('\n')[0]}");
|
||||
return text.Replace(oldValue, newValue, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
static void ValidateResult(string json, string token)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var result = document.RootElement;
|
||||
if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 14 || result.GetProperty("architecture").GetString() != "x86_64" || result.GetProperty("uid").GetInt32() != 0 || !System.Text.RegularExpressions.Regex.IsMatch(result.GetProperty("disk").GetString() ?? "", "^/dev/disk[0-9]+$") || result.GetProperty("diskBytes").GetInt64() != GuestDiskBytes || result.GetProperty("readOnly").GetBoolean() || new[] { "systemExit", "diskArbitrationExit", "recoveryExit", "diskListExit" }.Any(key => result.GetProperty(key).GetInt32() != 0))
|
||||
throw new InvalidOperationException("The fresh guest receipt did not prove native macOS 14+/x86_64, service readiness and the writable 64-GiB disk.");
|
||||
}
|
||||
|
||||
static void AssertContainer(string json, string token)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var container = document.RootElement[0];
|
||||
var config = container.GetProperty("HostConfig");
|
||||
if (container.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != token || config.GetProperty("Privileged").GetBoolean() || config.GetProperty("NetworkMode").GetString() != "default" && config.GetProperty("NetworkMode").GetString() != "bridge" || config.GetProperty("Memory").GetInt64() != ContainerMemoryBytes || new[] { "CapAdd", "Devices", "DeviceRequests", "Binds", "PortBindings" }.Any(key => config.TryGetProperty(key, out var value) && value.ValueKind != JsonValueKind.Null && (value.ValueKind == JsonValueKind.Array ? value.GetArrayLength() != 0 : value.EnumerateObject().Any())))
|
||||
throw new InvalidOperationException("Created container exceeds the owned/unprivileged diagnostic boundary.");
|
||||
}
|
||||
|
||||
static async Task CaptureGuest(string id, string output, CancellationToken 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/14/setup.dmg || sha256sum /storage/14/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);
|
||||
}
|
||||
|
||||
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<CommandResult> Command(string executable, string[] arguments, string output, string label, CancellationToken cancellation, bool requireSuccess = true, bool echo = 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 (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);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/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
|
||||
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
|
||||
rm -f "$RESULT" "$RESULT.tmp"
|
||||
printf '[proof-token] %s\n' "$PROOF_TOKEN" >> "$PROOF_LOG"
|
||||
|
||||
finish() {
|
||||
local success="$1" reason="$2"
|
||||
printf '[proof-result] %s: %s\n' "$success" "$reason" >> "$PROOF_LOG"
|
||||
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
|
||||
# Keep the service alive for the bounded host diagnostic to capture evidence.
|
||||
while :; do sleep 60; done
|
||||
}
|
||||
|
||||
run_command() {
|
||||
local name="$1"
|
||||
shift
|
||||
local process timer sleeper exit_code
|
||||
LAST_OUTPUT="/tmp/native-diagnostic-$name.out"
|
||||
printf '\n[proof-command] %s:' "$name" >> "$PROOF_LOG"
|
||||
printf ' %s' "$@" >> "$PROOF_LOG"
|
||||
printf '\n' >> "$PROOF_LOG"
|
||||
"$@" > "$LAST_OUTPUT" 2>&1 &
|
||||
process=$!
|
||||
(
|
||||
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||
sleep 45 &
|
||||
sleeper=$!
|
||||
wait "$sleeper"
|
||||
kill -TERM "$process" 2>/dev/null || :
|
||||
sleep 2
|
||||
kill -KILL "$process" 2>/dev/null || :
|
||||
) &
|
||||
timer=$!
|
||||
wait "$process"
|
||||
exit_code=$?
|
||||
kill -TERM "$timer" 2>/dev/null || :
|
||||
wait "$timer" 2>/dev/null || :
|
||||
/usr/bin/tail -c 524288 "$LAST_OUTPUT" >> "$PROOF_LOG"
|
||||
printf '\n[proof-exit] %s\n' "$exit_code" >> "$PROOF_LOG"
|
||||
LAST_EXIT="$exit_code"
|
||||
local size
|
||||
size=$(/usr/bin/stat -f '%z' "$PROOF_LOG" 2>/dev/null || printf '0')
|
||||
(( size <= MAX_LOG_BYTES )) || finish false diagnostic_log_budget_exceeded
|
||||
return 0
|
||||
}
|
||||
|
||||
run_command platform /usr/bin/sw_vers
|
||||
(( LAST_EXIT == 0 )) || finish false sw_vers_failed
|
||||
run_command version /usr/bin/sw_vers -productVersion
|
||||
(( LAST_EXIT == 0 )) || finish false product_version_failed
|
||||
os_version=$(cat "$LAST_OUTPUT")
|
||||
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || finish false product_version_invalid
|
||||
(( ${os_version%%.*} >= 14 )) || finish false unsupported_macos_version
|
||||
run_command kernel /usr/bin/uname -a
|
||||
(( LAST_EXIT == 0 )) || finish false uname_failed
|
||||
run_command architecture /usr/bin/uname -m
|
||||
(( LAST_EXIT == 0 )) || finish false architecture_probe_failed
|
||||
architecture=$(cat "$LAST_OUTPUT")
|
||||
[ "$architecture" = x86_64 ] || finish false unexpected_guest_architecture
|
||||
run_command account /usr/bin/id
|
||||
run_command uid /usr/bin/id -u
|
||||
(( LAST_EXIT == 0 )) || finish false uid_probe_failed
|
||||
uid=$(cat "$LAST_OUTPUT")
|
||||
[ "$uid" = 0 ] || finish false recovery_account_not_root
|
||||
run_command bootargs /usr/sbin/sysctl kern.bootargs
|
||||
run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm=
|
||||
run_command processes /bin/ps -axo pid,ppid,comm
|
||||
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"
|
||||
|
||||
# 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" >> "$PROOF_LOG"
|
||||
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" >> "$PROOF_LOG"
|
||||
done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p')
|
||||
(( candidates <= 1 )) || finish false 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 )) || finish false service_domain_not_ready
|
||||
finish true native_recovery_and_writable_64g_disk_ready
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
finish false disk_management_or_writable_target_not_ready
|
||||
Reference in New Issue
Block a user