diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3312152..6cc013e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,21 @@ jobs: - name: Public CLI status consumes shared effective-state run: ./scripts/test-cli-status.sh ./build/desktidy-sort + - name: R1B Phase 1A fake-substrate harness + run: | + chmod +x scripts/build-probe.sh scripts/test-phase1a-wiring.sh + ./scripts/test-phase1a-wiring.sh + ./build/desktidy-sort --phase1a-test + ./build/desktidy-sort --phase1a1-test + chmod +x scripts/test-phase1a1-public-boundary.sh + ./scripts/test-phase1a1-public-boundary.sh + ./scripts/build-probe.sh build + test -x ./build/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe + test -x ./build/DeskTidySacrificialProbe.app/Contents/MacOS/SacrificialHelper + test -f ./build/DeskTidySacrificialProbe.app/Contents/Library/LaunchAgents/com.desktidy.sacrificial.plist + codesign -dv ./build/DeskTidySacrificialProbe.app + ./build/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe --plan | grep -q 'read-only\|NON-PRODUCTION\|UNOBSERVED' + - name: R1A read-only confinement grep (app sources contain no mutation calls) run: | for f in app/DeskTidyApp.swift src/EffectiveState.swift; do diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ea930..1b6a98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased (branch r1b/phase1a-smappservice-harness — stacked on Phase 0, non-final) + +- **R1B Phase 1A.1 (measured evidence seal, still no live mutation):** the + sacrificial probe builds `InterlockContext` from measured executable + SHA-256, compiled 40-hex source commit, one-open authorization bytes, + two root/authority observations, and a durable `O_CREAT|O_EXCL` nonce. + A sealed `PreparedMutationGrant` is produced then the probe exits 4 + (`STOP_BEFORE_PRODUCTION_ADAPTER`) with a zero construction/call ledger. + Production adapter methods exist but return fail-closed and do not call + `SMAppService.register` / `.unregister`. Phase 1B still requires a + reviewed connection patch plus separate architect authorization. +- **R1B Phase 1A (fake-substrate only):** sacrificial SMAppService harness — + migration state machine, injectable fake adapter, multi-factor mutation + interlock, simulated transaction/rollback matrix, non-production probe + bundle (ad-hoc, development evidence). No real `SMAppService.register` + or `.unregister` execution. Apple public distribution membership + unresolved. Phase 1B observation is separately authorized. + ## Unreleased (branch r1b/phase0-unified-truth — stacked on R1A, non-final) - **R1B Phase 0 (no live service migration):** one target resolver, one diff --git a/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md b/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md new file mode 100644 index 0000000..6c3854d --- /dev/null +++ b/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md @@ -0,0 +1,139 @@ +# Phase 1B Operator Runbook — Sacrificial SMAppService Observation + +Phase 1A.1 seals measurement and grant preparation only. Authorization +alone is **not** the next step. + +Phase 1B still requires: + +1. a reviewed minimal patch connecting the sealed `PreparedMutationGrant` to + exactly one production adapter call; +2. separate architect authorization for that patch and the observation. + +Do not run a real mutation path until both exist. Fake-substrate tests are +not that observation. + +Token creation must use the **measured probe executable SHA-256** and the +**compiled 40-hex source commit** from the built probe (`--plan` prints the +commit). Nonce reservations are durable and non-reusable under the +sacrificial support root. Foreign/protected-root evidence is measured twice. + +Runtime label, Login Items string, status semantics, FDA/TCC, reboot/login, +and Apple distribution remain **unknown**. No automatic production +self-label widening. + +## Non-claims (Apple membership) + +Developer ID, notarization, TestFlight/App Store, public DMG, and production +profiles are unavailable. Ad-hoc signing is development evidence only. + +Compilation of `SMAppService` is capability, not proof of runtime registration. + +## Identities (hypotheses until observed) + +| Item | Value | Status | +|---|---|---| +| Probe bundle id | `com.desktidy.sacrificial-probe` | coded | +| Embedded plist | `Contents/Library/LaunchAgents/com.desktidy.sacrificial.plist` | coded | +| Hypothesized label | `com.desktidy.sacrificial` | **UNOBSERVED** | +| Production self labels | `com.desktidy.sort`, `com.desktidy.notify` | unchanged | + +Do **not** add any observed label to `ProductIdentity.selfLabels` automatically. + +## Prerequisites + +1. Phase 1A PR reviewed; source commit recorded (40- or 64-hex SHA). +2. Sacrificial probe built with `scripts/build-probe.sh` into a `/tmp` output root. +3. Bundle SHA-256 of the built `.app` recorded. +4. Protected live labels remain `com.sicarii.desktop-autosort` and + `com.sicarii.desktop-autosort-notify`. +5. Architect is present for Login Items / FDA visual readback. + +## Sacrificial root + +Create a directory **outside** `~/Desktop` and not symlink-equivalent to it, +e.g. `/tmp/desktidy-phase1b-`. Never use the live Desktop. + +## Pre-observation inventory + +Record, read-only: + +- `git rev-parse HEAD` +- `shasum -a 256` of the probe bundle executable +- `launchctl print` is **not** required until Phase 1B; if used, print only, + never bootstrap/bootout/kickstart/enable/disable +- confirm personal labels are not mutation targets + +## Authorization + +The architect writes **one** one-time authorization file (strict JSON, schema 1, +exact keys, no duplicate keys) binding: + +- `operation` (`register` then a second file for `unregister`) +- `sacrificialRoot` +- `bundleSHA256` +- `sourceCommit` +- `expiry` +- `nonce` (unique; never reused) + +Phase 1A must not create a valid live file. Phase 1B creates it by hand. + +## Probe command + +Default is read-only: + +```text +DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe --plan +``` + +Mutation (Phase 1B only): + +```text +…/DeskTidySacrificialProbe --register --auth-file /path/to/auth.json +…/DeskTidySacrificialProbe --unregister --auth-file /path/to/unreg.json +``` + +Phase 1A's probe **refuses to invoke** the production mutator even if the +interlock would permit (exit 4). Phase 1B replaces that stop with the single +granted adapter call. + +## Exact readbacks after a real grant + +1. `SMAppService.status` for the embedded plist name +2. `launchctl print gui//` — print only +3. Login Items visible string (human screenshot/notes) +4. Helper heartbeat under the sacrificial app-support root +5. Target confinement: heartbeat/target not on Desktop +6. Migration transaction record fields +7. FDA/TCC: observe whether a prompt appears; do **not** enter credentials + into an agent-driven prompt. Architect operates the UI. +8. Reboot/login is a **later subphase**, not performed here. + +## PASS / BLOCKED / INDETERMINATE / rollback-required + +| Verdict | Meaning | +|---|---| +| PASS | Granted operation ran; status and label readbacks agree; no live Desktop/personal-label involvement; unregister restored absence | +| BLOCKED | Interlock refused, or a protected root/label would have been touched | +| INDETERMINATE | API returned unknown / status unreadable; must not claim success | +| rollback-required | Dual presence or unknown post-state; unregister and stop | + +## Rollback and timeout + +If register is granted and anything is unknown or dual, run the unregister +authorization within a bounded window (architect-chosen, e.g. 15 minutes). +If unregister fails or is unknown, mark rollback-required and stop. + +## Post-run absence proof + +After unregister: status notRegistered/notFound, no Login Item string for the +probe, heartbeat file may remain as a sacrificial artifact. + +## Deletion rules + +Delete only probe-owned `/tmp` sacrificial roots and authorization files. +Do not delete `~/Desktop` contents or personal-mover state. + +## What this runbook does not do + +No merge, tag, release, notarize, Homebrew, self-label widening, reboot, or +FDA grant by an agent. diff --git a/docs/evidence/R1B_PHASE1A1_ABA_NONCE_EXCLUSIVITY.md b/docs/evidence/R1B_PHASE1A1_ABA_NONCE_EXCLUSIVITY.md new file mode 100644 index 0000000..a86597a --- /dev/null +++ b/docs/evidence/R1B_PHASE1A1_ABA_NONCE_EXCLUSIVITY.md @@ -0,0 +1,59 @@ +# R1B Phase 1A.1 A→B→A — durable nonce exclusivity + +Semantic mutation of `src/DurableNonceStore.swift`: drop `O_EXCL` so a +replay or concurrent duplicate can open the same reservation path. + +No production ServiceManagement adapter invocation. Fake/hermetic roots only. + +## A (green) + +- file: `src/DurableNonceStore.swift` +- SHA-256: `6200ce12ae8fd1e2d4cab5c0244a739acc657321d82ff32b22e3338457563bd1` +- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase1a1-aba/desktidy-sort-A && /tmp/desktidy-r1b-phase1a1-aba/desktidy-sort-A --phase1a1-test` +- exit: `0` +- excerpt: + +``` +PASS E08 nonce replay across store instances refused +PASS E09 concurrent nonce reservation has exactly one winner +PASS S11 retained second-precall foreign ID still refused at prepare +PASS S12 retained second-precall target-change ID still refused +PHASE1A1 GATES: 24 passed, 0 failed +``` + +## B (O_EXCL removed) + +- SHA-256: `fba4f0ba4ec0ace5bc09c13262d4d9ed268f0ce63323e0ce41e0cec635962a54` +- rebuild after deleting the previous B binary +- command: `--phase1a1-test` +- exit: `1` +- failing IDs: + +``` +FAIL E08 nonce replay across store instances refused — reserved(...) +FAIL E09 concurrent nonce reservation has exactly one winner — wins=8 losses=0 +PHASE1A1 GATES: 22 passed, 2 failed +``` + +Intended reason: without `O_CREAT|O_EXCL`, the second reservation of +`nonce-rep1` overwrites instead of refusing, and all eight concurrent +openers of `nonce-race1` win. + +Diff (B vs A): + +```diff +- let flags = disableExclusivityForMutationTest ? (O_CREAT | O_WRONLY) : (O_CREAT | O_EXCL | O_WRONLY) ++ let flags = (O_CREAT | O_WRONLY) // B-MUTATION: drop O_EXCL so replay/concurrent can pass +``` + +The test hook `disableExclusivityForMutationTest` is reset to `false` by +`Phase1A1Tests.runAll()`. Mutating only that default is not load-bearing; +the B mutation therefore changes the open flags themselves. + +## Restore + +- SHA-256: `6200ce12ae8fd1e2d4cab5c0244a739acc657321d82ff32b22e3338457563bd1` (equals A) +- `--phase1a1-test` exit 0 — 24/24; E08/E09/S11/S12 PASS + +Retained second-precall transcript: `docs/evidence/R1B_PHASE1A_ABA_SECOND_PRECALL.md`. +S11/S12 re-run on this restore: PASS (phase1a 64/64 and phase1a1 24/24). diff --git a/docs/evidence/R1B_PHASE1A_ABA_SECOND_PRECALL.md b/docs/evidence/R1B_PHASE1A_ABA_SECOND_PRECALL.md new file mode 100644 index 0000000..063fa80 --- /dev/null +++ b/docs/evidence/R1B_PHASE1A_ABA_SECOND_PRECALL.md @@ -0,0 +1,52 @@ +# R1B Phase 1A A→B→A — second pre-call authority/target check + +Semantic mutation of `src/MigrationTransaction.swift`: skip the second +pre-call evidence check so a foreign mover or invalid target appearing +between preflight and mutation is ignored. + +No production ServiceManagement adapter invocation. Fake adapter only. + +## A (green) + +- file: `src/MigrationTransaction.swift` +- SHA-256: `007cef67d03bffce6ac3d5ead179078d7fae25fd6ac0f0eb3c2f9079e87eabc3` +- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase1a-aba/desktidy-sort && /tmp/desktidy-r1b-phase1a-aba/desktidy-sort --phase1a-test` +- exit: `0` +- excerpt: + +``` +PASS S11 transaction refused +PASS S12 transaction refused +PHASE1A GATES: 64 passed, 0 failed +``` + +## B (second pre-call disabled) + +- SHA-256: `f26672bde0cab8b77750c0fa53facb93d24913c4655af47d40f7d32320f2955e` +- rebuild after deleting the previous binary +- command: `--phase1a-test` +- exit: `1` +- failing IDs: + +``` +FAIL S11 transaction refused — got indeterminate regs=1 unregs=0 +FAIL S12 transaction refused — got indeterminate regs=1 unregs=0 +PHASE1A GATES: 62 passed, 2 failed +``` + +Intended reason: without the second check, `requestRegister` ran (`regs=1`) +after preflight had been clean. S11 plants a foreign mover at call time; S12 +invalidates the target at call time. + +Diff (B vs A): + +```diff +- if !skipSecondPreCallCheck { ++ if false && !skipSecondPreCallCheck { // B-MUTATION: skip second pre-call authority/target check +``` + +## Restore + +- SHA-256: `007cef67d03bffce6ac3d5ead179078d7fae25fd6ac0f0eb3c2f9079e87eabc3` (equals A) +- `--phase1a-test` exit 0 — 64/64; S11 PASS +- `--self-test` 17/17; `--state-test` 63/63; `--r0-test` 31/31 diff --git a/probe/HelperMain.swift b/probe/HelperMain.swift new file mode 100644 index 0000000..df6b7d8 --- /dev/null +++ b/probe/HelperMain.swift @@ -0,0 +1,32 @@ +import Foundation + +// Sacrificial helper: no file movement, no network. Writes one heartbeat +// under an explicitly supplied sacrificial app-support root, then exits. +@main +struct SacrificialHelper { + static func main() { + let env = ProcessInfo.processInfo.environment + guard let app = env["DESKTIDY_PROBE_APP_DIR"], !app.isEmpty, + let target = env["DESKTIDY_PROBE_TARGET"], !target.isEmpty else { + fputs("sacrificial helper: refusing — DESKTIDY_PROBE_APP_DIR and DESKTIDY_PROBE_TARGET are required\n", stderr) + exit(2) + } + let desktop = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Desktop").path + if target == desktop || target.hasPrefix(desktop + "/") { + fputs("sacrificial helper: refusing — target must not be the live Desktop\n", stderr) + exit(2) + } + let dir = URL(fileURLWithPath: (app as NSString).expandingTildeInPath, isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let beat = dir.appendingPathComponent("heartbeat.json") + let body = "{\"schema\":1,\"ok\":true,\"targetPresent\":true}\n" + try Data(body.utf8).write(to: beat) + } catch { + fputs("sacrificial helper: heartbeat write failed\n", stderr) + exit(1) + } + exit(0) + } +} diff --git a/probe/ProbeMain.swift b/probe/ProbeMain.swift new file mode 100644 index 0000000..bf03a92 --- /dev/null +++ b/probe/ProbeMain.swift @@ -0,0 +1,109 @@ +import Foundation + +// Sacrificial operator probe. Default is read-only plan/status. +// Phase 1A.1: measure evidence, prepare a sealed grant, then STOP. +// Does not construct ProductionSMAdapter or invoke mutation methods. +@main +struct SacrificialProbe { + static func main() { + let args = CommandLine.arguments + if args.contains("--help") || args.count == 1 || args.contains("--plan") || args.contains("--status") { + print(planText()) + if args.contains("--plan") || args.contains("--status") { print("mode: read-only") } + exit(0) + } + let registering = args.contains("--register") + let unregistering = args.contains("--unregister") + if registering && unregistering { + fputs("probe: specify only one of --register or --unregister\n", stderr) + exit(2) + } + guard registering || unregistering else { + print(planText()) + exit(0) + } + guard let idx = args.firstIndex(of: "--auth-file"), idx + 1 < args.count else { + fputs("probe: mutation requires --auth-file \n", stderr) + exit(2) + } + let authPath = args[idx + 1] + switch SecureAuthFile.openOnce(path: authPath) { + case .refused(let r): + fputs("probe: \(r)\n", stderr) + exit(3) + case .ok(let authBytes): + guard let exe = Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0]) as URL? else { + fputs("probe: cannot locate running executable\n", stderr) + exit(3) + } + switch ProbeIdentity.measureRunning(executableURL: exe, bundle: Bundle.main) { + case .refused(let r): + fputs("probe: \(r)\n", stderr) + exit(3) + case .ok(let identity): + switch ProtectedRootInventory.load() { + case .failed(let r): + fputs("probe: \(r)\n", stderr) + exit(3) + case .ok(let inv): + guard case .ok(let parsed) = MutationInterlock.parseAuthorization(authBytes) else { + fputs("probe: authorization parse failed\n", stderr) + exit(3) + } + let first = ProductionAuthoritySnapshot.live.snapshot(rootPath: parsed.sacrificialRoot) + let second = ProductionAuthoritySnapshot.live.snapshot(rootPath: parsed.sacrificialRoot) + guard case .ok(let a1) = first, case .ok(let a2) = second else { + fputs("probe: sacrificial root/authority observation failed\n", stderr) + exit(3) + } + let home = AuthorityGuard.canonicalize(FileManager.default.homeDirectoryForCurrentUser.path) + switch MutationBoundary.prepare( + authBytes: authBytes, + identity: identity, + compiledSourceCommit: CompiledProbeIdentity.sourceCommit, + operation: registering ? .register : .unregister, + first: a1, second: a2, + desktop: inv.desktop, home: home, + protected: inv.protected, productionTarget: inv.production + ) { + case .refused(let r): + fputs("probe: interlock refused — \(r)\n", stderr) + exit(3) + case .prepared(let grant): + print("GRANT_PREPARED") + print("operation=\(grant.operation.rawValue)") + print("executableSHA256=\(grant.executableSHA256)") + print("sourceCommit=\(grant.sourceCommit)") + print("root=\(grant.rootCanonical)") + print("nonce=\(grant.nonce)") + print("STOP_BEFORE_PRODUCTION_ADAPTER") + print("ledger_constructions=\(ProductionMutationLedger.constructions)") + print("ledger_registers=\(ProductionMutationLedger.registerInvocations)") + exit(4) + } + } + } + } + } + + static func planText() -> String { + """ + DeskTidy sacrificial SMAppService probe (NON-PRODUCTION) + Phase 1A.1 seals measurement and grant preparation only. + Default: read-only plan. No registration in Phase 1A.1. + A future Phase 1B requires a reviewed patch connecting the sealed + grant to exactly one adapter call, plus separate architect authorization. + Hypothesized plist name: \(SacrificialIdentity.hypothesizedPlistName) + Hypothesized label: \(SacrificialIdentity.hypothesizedLabel) (UNOBSERVED) + Bundle id: \(SacrificialIdentity.bundleID) + Compiled source commit: \(CompiledProbeIdentity.sourceCommit) + Ad-hoc signing only — not Developer ID / not notarized. + """ + } +} + +enum SacrificialIdentity { + static let bundleID = "com.desktidy.sacrificial-probe" + static let hypothesizedPlistName = "com.desktidy.sacrificial.plist" + static let hypothesizedLabel = "com.desktidy.sacrificial" +} diff --git a/probe/SMAdapterProduction.swift b/probe/SMAdapterProduction.swift new file mode 100644 index 0000000..5e6d131 --- /dev/null +++ b/probe/SMAdapterProduction.swift @@ -0,0 +1,45 @@ +import Foundation +import ServiceManagement + +// Production adapter. Construction has no side effect. +// register/unregister exist so Phase 1B can call them *only* after the +// MutationInterlock grants. Phase 1A never executes those methods. +final class ProductionSMAdapter: ServiceManagementAdapting { + init() { + ProductionMutationLedger.constructions += 1 + } + + func status(plistName: String) -> Result { + if #available(macOS 13.0, *) { + let service = SMAppService.agent(plistName: plistName) + switch service.status { + case .enabled: return .success(.enabled) + case .requiresApproval: return .success(.requiresApproval) + case .notRegistered: return .success(.notRegistered) + case .notFound: return .success(.notFound) + default: return .success(.unknown(String(describing: service.status))) + } + } + return .failure(.unavailable) + } + + func requestRegister(plistName: String) -> Result { + ProductionMutationLedger.registerInvocations += 1 + return .failure(.failedClosed("Phase 1A.1 sealed: production mutation is not connected")) + } + + func requestRegister(plistName: String, grant: PreparedMutationGrant) -> Result { + _ = grant + return requestRegister(plistName: plistName) + } + + func requestUnregister(plistName: String) -> Result { + ProductionMutationLedger.unregisterInvocations += 1 + return .failure(.failedClosed("Phase 1A.1 sealed: production mutation is not connected")) + } + + func requestUnregister(plistName: String, grant: PreparedMutationGrant) -> Result { + _ = grant + return requestUnregister(plistName: plistName) + } +} diff --git a/probe/com.desktidy.sacrificial.plist b/probe/com.desktidy.sacrificial.plist new file mode 100644 index 0000000..f7b180a --- /dev/null +++ b/probe/com.desktidy.sacrificial.plist @@ -0,0 +1,14 @@ + + + + + Label + com.desktidy.sacrificial + BundleProgram + Contents/MacOS/SacrificialHelper + RunAtLoad + + KeepAlive + + + diff --git a/scripts/build-probe.sh b/scripts/build-probe.sh new file mode 100755 index 0000000..4874956 --- /dev/null +++ b/scripts/build-probe.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Build the NON-PRODUCTION sacrificial SMAppService probe bundle. +# Compiles and ad-hoc signs only. Does not invoke ServiceManagement mutation +# or launchctl load/unload verbs. +# Ad-hoc signing is development evidence, not Developer ID / notarization. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$REPO/build" +INJECTED_COMMIT="" +while [ $# -gt 0 ]; do + case "$1" in + --identity-commit) + INJECTED_COMMIT="${2:-}" + shift 2 ;; + *) + OUT="$1" + shift ;; + esac +done + +if [ -n "$INJECTED_COMMIT" ]; then + COMMIT="$INJECTED_COMMIT" +else + if [ -n "$(git -C "$REPO" status --porcelain)" ]; then + echo "build-probe: refusing dirty worktree (no injected test identity)" >&2 + exit 2 + fi + COMMIT="$(git -C "$REPO" rev-parse HEAD)" +fi +if ! printf '%s' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "build-probe: source commit must be a 40-hex SHA (got '$COMMIT')" >&2 + exit 2 +fi + +APP="$OUT/DeskTidySacrificialProbe.app" +MACOS_MIN="14.0" +GEN="$OUT/GeneratedProbeIdentity.swift" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Library/LaunchAgents" "$APP/Contents/Resources" "$OUT" +cat > "$GEN" < "$APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifiercom.desktidy.sacrificial-probe + CFBundleNameDeskTidySacrificialProbe + CFBundleExecutableDeskTidySacrificialProbe + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.0.0-phase1a + LSMinimumSystemVersion14.0 + LSUIElement + NSHumanReadableCopyrightNON-PRODUCTION sacrificial harness — not for distribution + + +PLIST + +codesign -s - -i com.desktidy.sacrificial-probe --force "$APP" +echo "built (ad-hoc, development evidence only): $APP" diff --git a/scripts/test-phase1a-wiring.sh b/scripts/test-phase1a-wiring.sh new file mode 100755 index 0000000..025fdfb --- /dev/null +++ b/scripts/test-phase1a-wiring.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Structural wiring: automated tests must not construct a production mutator. +# A planted poison in a temp copy must make this script fail. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +fail_if() { + local path="$1" pat="$2" msg="$3" + if sed 's://.*$::' "$path" | grep -nE "$pat"; then + echo "FAIL: $msg ($path)" >&2 + exit 1 + fi +} + +fail_if "$ROOT/src/Phase1ATests.swift" 'ProductionSMAdapter|SMAppService' \ + "test file mentions production ServiceManagement mutator" + +fail_if "$ROOT/src/Phase1A1Tests.swift" 'ProductionSMAdapter|SMAppService' \ + "phase1a1 tests mention production ServiceManagement mutator" + +fail_if "$ROOT/probe/ProbeMain.swift" 'ProductionSMAdapter\(|requestRegister\(|requestUnregister\(' \ + "probe connects prepared grant to production mutator" + +fail_if "$ROOT/scripts/build-probe.sh" 'SMAppService\.register|launchctl (bootstrap|bootout|kickstart)' \ + "probe build script contains registration/launchd mutation" + +if grep -nE '\|\| true' "$ROOT/scripts/build-probe.sh"; then + echo "FAIL: probe build script masks failure" >&2 + exit 1 +fi + +fail_if "$ROOT/probe/HelperMain.swift" 'moveItem|copyItem|removeItem|URLSession|bootstrap|bootout' \ + "helper contains movement/network/launchd symbols" + +# Negative control: plant a production adapter call and prove we detect it. +TMP=$(mktemp) +sed 's://.*$::' "$ROOT/src/Phase1ATests.swift" > "$TMP" +echo 'let _ = ProductionSMAdapter(); try SMAppService.agent(plistName: "x").register()' >> "$TMP" +if sed 's://.*$::' "$TMP" | grep -nE 'ProductionSMAdapter|SMAppService'; then + echo "wiring-poison-control: detected planted production mutator" +else + echo "FAIL: wiring poison control did not fire" >&2 + exit 1 +fi +rm -f "$TMP" +echo "phase1a-wiring: PASS" diff --git a/scripts/test-phase1a1-public-boundary.sh b/scripts/test-phase1a1-public-boundary.sh new file mode 100755 index 0000000..f0ce622 --- /dev/null +++ b/scripts/test-phase1a1-public-boundary.sh @@ -0,0 +1,277 @@ +#!/bin/bash +# Serialized public-boundary suite for the sacrificial probe. +# Builds under /tmp, feeds real authorization files, never invokes production mutation. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$(mktemp -d /tmp/dt-1a1-pub-XXXXXX)" +COMMIT="$(git -C "$ROOT" rev-parse HEAD)" +if ! printf '%s' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "FAIL: git HEAD is not a 40-hex commit" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 +declare -a SEEN=() + +expect() { + local id="$1" want="$2" + shift 2 + local out rc + set +e + out="$("$@" 2>&1)" + rc=$? + set -e + SEEN+=("$id") + if [ "$rc" = "$want" ]; then + echo "PASS $id exit=$rc" + PASS=$((PASS + 1)) + else + echo "FAIL $id want=$want got=$rc" + echo "$out" | tail -5 + FAIL=$((FAIL + 1)) + fi +} + +# Dirty-tree build refusal (no injected identity) +DIRTY="$(mktemp -d /tmp/dt-1a1-dirty-XXXXXX)" +cp -R "$ROOT/." "$DIRTY/" +rm -rf "$DIRTY/.git" +# recreate a git repo that is dirty +git -C "$DIRTY" init -q +git -C "$DIRTY" config user.email t@t +git -C "$DIRTY" config user.name t +git -C "$DIRTY" add -A +git -C "$DIRTY" commit -qm init +echo dirty >> "$DIRTY/README.md" +set +e +"$DIRTY/scripts/build-probe.sh" "$DIRTY/build" >/tmp/dt-dirty-out.txt 2>&1 +DRC=$? +set -e +SEEN+=("P13") +if [ "$DRC" -ne 0 ] && grep -q 'dirty worktree' /tmp/dt-dirty-out.txt; then + echo "PASS P13 dirty-tree build refused" + PASS=$((PASS + 1)) +else + echo "FAIL P13 dirty-tree build not refused (exit=$DRC)" + FAIL=$((FAIL + 1)) +fi + +# Short SHA injection refused +set +e +"$ROOT/scripts/build-probe.sh" "$OUT/short" --identity-commit e14c13f >/tmp/dt-short-out.txt 2>&1 +SRC=$? +set -e +SEEN+=("P14") +if [ "$SRC" -ne 0 ]; then + echo "PASS P14 short SHA identity refused" + PASS=$((PASS + 1)) +else + echo "FAIL P14 short SHA accepted" + FAIL=$((FAIL + 1)) +fi + +"$ROOT/scripts/build-probe.sh" "$OUT" --identity-commit "$COMMIT" +PROBE="$OUT/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe" +HELPER="$OUT/DeskTidySacrificialProbe.app/Contents/MacOS/SacrificialHelper" +test -x "$PROBE" +HASH="$(shasum -a 256 "$PROBE" | awk '{print $1}')" +# Compiled identity readback +"$PROBE" --plan | grep -q "$COMMIT" +SEEN+=("P15") +echo "PASS P15 compiled source commit read back" +PASS=$((PASS + 1)) +SEEN+=("P16") +echo "PASS P16 external executable SHA-256 $HASH" +PASS=$((PASS + 1)) + +write_auth() { + local dest="$1" root="$2" hash="$3" commit="$4" nonce="$5" extra="${6:-}" + local exp + exp="$(date -u -v+1H +%Y-%m-%dT%H:%M:%SZ)" + printf '%s' "{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"$root\",\"bundleSHA256\":\"$hash\",\"sourceCommit\":\"$commit\",\"expiry\":\"$exp\",\"nonce\":\"$nonce\"$extra}" > "$dest" + chmod 600 "$dest" +} + +SAC="$(mktemp -d /tmp/dt-1a1-sac-XXXXXX)" +AUTHDIR="$(mktemp -d /tmp/dt-1a1-auth-XXXXXX)" +ISO_EXP="$(date -u -v+1H +%Y-%m-%dT%H:%M:%SZ)" + +GOOD="$AUTHDIR/good.json" +write_auth "$GOOD" "$SAC" "$HASH" "$COMMIT" "nonce-pub1" +expect P01 4 "$PROBE" --register --auth-file "$GOOD" +# grant prepared, no adapter +# reuse same nonce in a fresh process +expect P32 3 "$PROBE" --register --auth-file "$GOOD" + +expect P02 2 "$PROBE" --register +expect P03 3 "$PROBE" --register --auth-file "$AUTHDIR/missing.json" + +# symlink +ln -s "$GOOD" "$AUTHDIR/link.json" +expect P04 3 "$PROBE" --register --auth-file "$AUTHDIR/link.json" + +# 0644 +WIDE="$AUTHDIR/wide.json" +write_auth "$WIDE" "$SAC" "$HASH" "$COMMIT" "nonce-wide1" +chmod 644 "$WIDE" +expect P05 3 "$PROBE" --register --auth-file "$WIDE" + +# oversized +BIG="$AUTHDIR/big.json" +python3 -c 'open("'"$BIG"'","w").write("a"*5000)' +chmod 600 "$BIG" +expect P06 3 "$PROBE" --register --auth-file "$BIG" + +# malformed +MAL="$AUTHDIR/mal.json" +printf 'not-json' > "$MAL"; chmod 600 "$MAL" +expect P07 3 "$PROBE" --register --auth-file "$MAL" + +# duplicate nonce key +DUP="$AUTHDIR/dup.json" +printf '%s' "{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"$ISO_EXP\",\"nonce\":\"nonce-dup1\",\"nonce\":\"nonce-dup1\"}" > "$DUP" +chmod 600 "$DUP" +expect P08 3 "$PROBE" --register --auth-file "$DUP" + +# extra key +EXT="$AUTHDIR/extra.json" +write_auth "$EXT" "$SAC" "$HASH" "$COMMIT" "nonce-ext1" ",\"extra\":\"x\"" +expect P09 3 "$PROBE" --register --auth-file "$EXT" + +# trailing garbage +TR="$AUTHDIR/trail.json" +write_auth "$TR" "$SAC" "$HASH" "$COMMIT" "nonce-tr1" +printf ' true' >> "$TR" +expect P10 3 "$PROBE" --register --auth-file "$TR" + +# expired +EXP="$AUTHDIR/exp.json" +printf '%s' "{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"2020-01-01T00:00:00Z\",\"nonce\":\"nonce-exp1\"}" > "$EXP" +chmod 600 "$EXP" +expect P11 3 "$PROBE" --register --auth-file "$EXP" + +# wrong operation +UOP="$AUTHDIR/unreg.json" +printf '%s' "{\"schema\":1,\"operation\":\"unregister\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"$ISO_EXP\",\"nonce\":\"nonce-uop1\"}" > "$UOP" +chmod 600 "$UOP" +expect P12 3 "$PROBE" --register --auth-file "$UOP" + +# zero hash +ZH="$AUTHDIR/zero.json" +write_auth "$ZH" "$SAC" "$(printf '0%.0s' {1..64})" "$COMMIT" "nonce-z1" +expect P17 3 "$PROBE" --register --auth-file "$ZH" + +# wrong hash +WH="$AUTHDIR/wrongh.json" +write_auth "$WH" "$SAC" "$(printf 'c%.0s' {1..64})" "$COMMIT" "nonce-wh1" +expect P18 3 "$PROBE" --register --auth-file "$WH" + +# stale Phase 0 commit +ST="$AUTHDIR/stale.json" +write_auth "$ST" "$SAC" "$HASH" "0b11c652e364cf47668ba87b4228a0f4ab7974ec" "nonce-st1" +expect P19 3 "$PROBE" --register --auth-file "$ST" + +# nonexistent root +MISSROOT="$AUTHDIR/missroot.json" +write_auth "$MISSROOT" "/tmp/dt-1a1-does-not-exist-$$" "$HASH" "$COMMIT" "nonce-mr1" +expect P20 3 "$PROBE" --register --auth-file "$MISSROOT" + +# file as root +FROOT="$(mktemp /tmp/dt-1a1-froot-XXXX)" +FR="$AUTHDIR/froot.json" +write_auth "$FR" "$FROOT" "$HASH" "$COMMIT" "nonce-fr1" +expect P21 3 "$PROBE" --register --auth-file "$FR" + +# Desktop +DESK="$HOME/Desktop" +DR="$AUTHDIR/desk.json" +write_auth "$DR" "$DESK" "$HASH" "$COMMIT" "nonce-dk1" +expect P22 3 "$PROBE" --register --auth-file "$DR" + +# helper hash instead of probe +HH="$AUTHDIR/helper.json" +HHASH="$(shasum -a 256 "$HELPER" | awk '{print $1}')" +write_auth "$HH" "$SAC" "$HHASH" "$COMMIT" "nonce-hh1" +expect P23 3 "$PROBE" --register --auth-file "$HH" + +# unreadable +UNR="$AUTHDIR/unreadable.json" +write_auth "$UNR" "$SAC" "$HASH" "$COMMIT" "nonce-unr1" +chmod 000 "$UNR" +expect P25 3 "$PROBE" --register --auth-file "$UNR" +chmod 600 "$UNR" || true + +# missing required key +MISSKEY="$AUTHDIR/misskey.json" +printf '%s' "{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"$ISO_EXP\"}" > "$MISSKEY" +chmod 600 "$MISSKEY" +expect P26 3 "$PROBE" --register --auth-file "$MISSKEY" + +# duplicate sourceCommit +DUPC="$AUTHDIR/dupc.json" +printf '%s' "{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"$ISO_EXP\",\"nonce\":\"nonce-dc1\"}" > "$DUPC" +chmod 600 "$DUPC" +expect P27 3 "$PROBE" --register --auth-file "$DUPC" + +# Desktop child (metadata only; no Desktop write) +CHILD="$HOME/Desktop/Projects" +CH="$AUTHDIR/child.json" +write_auth "$CH" "$CHILD" "$HASH" "$COMMIT" "nonce-ch1" +expect P28 3 "$PROBE" --register --auth-file "$CH" + +# concurrent reservation: exactly one grant, one refuse +RACE="$AUTHDIR/race.json" +write_auth "$RACE" "$SAC" "$HASH" "$COMMIT" "nonce-racep" +set +e +"$PROBE" --register --auth-file "$RACE" >/tmp/dt-race-a.txt 2>&1 & +PA=$! +"$PROBE" --register --auth-file "$RACE" >/tmp/dt-race-b.txt 2>&1 & +PB=$! +wait "$PA"; RA=$? +wait "$PB"; RB=$? +set -e +SEEN+=("P29") +WINS=0 +[ "$RA" = "4" ] && WINS=$((WINS + 1)) +[ "$RB" = "4" ] && WINS=$((WINS + 1)) +if [ "$WINS" -eq 1 ] && { [ "$RA" = "3" ] || [ "$RB" = "3" ]; }; then + echo "PASS P29 concurrent public nonce exactly one winner" + PASS=$((PASS + 1)) +else + echo "FAIL P29 concurrent public nonce wins=$WINS ra=$RA rb=$RB" + FAIL=$((FAIL + 1)) +fi + +# missing auth already P02/P03 +# production ledger: valid grant printed constructions=0 +if "$PROBE" --register --auth-file "$GOOD" 2>/dev/null | grep -q 'STOP_BEFORE_PRODUCTION_ADAPTER'; then + : +fi +# second valid different nonce +GOOD2="$AUTHDIR/good2.json" +write_auth "$GOOD2" "$SAC" "$HASH" "$COMMIT" "nonce-pub2" +OUT2="$("$PROBE" --register --auth-file "$GOOD2" 2>&1 || true)" +SEEN+=("P24") +if echo "$OUT2" | grep -q 'STOP_BEFORE_PRODUCTION_ADAPTER' \ + && echo "$OUT2" | grep -q 'ledger_constructions=0' \ + && echo "$OUT2" | grep -q 'ledger_registers=0'; then + echo "PASS P24 valid grant stops before production adapter" + PASS=$((PASS + 1)) +else + echo "FAIL P24 missing stop/ledger zero" + echo "$OUT2" | tail -8 + FAIL=$((FAIL + 1)) +fi + +# required IDs +REQ="P01 P02 P03 P04 P05 P06 P07 P08 P09 P10 P11 P12 P13 P14 P15 P16 P17 P18 P19 P20 P21 P22 P23 P24 P25 P26 P27 P28 P29 P32" +for id in $REQ; do + echo "${SEEN[*]}" | grep -qw "$id" || { echo "FAIL: missing expected ID $id"; FAIL=$((FAIL + 1)); } +done + +echo "PHASE1A1 PUBLIC: $PASS passed, $FAIL failed, ${#SEEN[@]} cases" +if [ "$PASS" -eq 0 ] || [ "${#SEEN[@]}" -eq 0 ]; then + echo "FAIL: zero cases"; exit 1 +fi +test "$FAIL" -eq 0 diff --git a/src/DeskTidy.swift b/src/DeskTidy.swift index c788100..253fbe7 100644 --- a/src/DeskTidy.swift +++ b/src/DeskTidy.swift @@ -120,6 +120,12 @@ final class DeskTidy { if arguments.contains("--state-test") { return R1ATests(binaryPath: CommandLine.arguments[0]).runAll() ? 0 : 1 } + if arguments.contains("--phase1a-test") { + return Phase1ATests().runAll() ? 0 : 1 + } + if arguments.contains("--phase1a1-test") { + return Phase1A1Tests().runAll() ? 0 : 1 + } if arguments.contains("--history") { return printHistory(arguments: arguments) } diff --git a/src/DurableNonceStore.swift b/src/DurableNonceStore.swift new file mode 100644 index 0000000..b7ccd2c --- /dev/null +++ b/src/DurableNonceStore.swift @@ -0,0 +1,87 @@ +import Darwin +import Foundation + +// ============================================================================ +// Durable atomic one-time nonce reservation under an authorized sacrificial +// support root. O_CREAT|O_EXCL — existing reservation always refuses. +// No global /tmp registry. No cleanup that permits replay. +// ============================================================================ + +struct NonceReservation: Equatable { + var nonce: String + var operation: String + var executableSHA256: String + var sourceCommit: String + var rootCanonical: String + var authorizationDigest: String + var reservedAt: String +} + +enum DurableNonceStore { + /// Test-only hook for A→B→A. Production remains false. + static var disableExclusivityForMutationTest = false + static let maxNonceLen = 64 + static let nonceGrammar = try! NSRegularExpression(pattern: "^[A-Za-z0-9._-]{8,64}$") + + enum Outcome: Equatable { + case reserved(NonceReservation) + case refused(String) + } + + static func normalize(_ nonce: String) -> String? { + let range = NSRange(nonce.startIndex.. URL { + URL(fileURLWithPath: canonicalSacrificial, isDirectory: true) + .appendingPathComponent(".desktidy-probe-support", isDirectory: true) + } + + static func reserve( + canonicalSacrificial: String, + nonce: String, + operation: String, + executableSHA256: String, + sourceCommit: String, + authorizationDigest: String + ) -> Outcome { + guard let safe = normalize(nonce) else { return .refused("nonce grammar invalid") } + let support = supportRoot(canonicalSacrificial: canonicalSacrificial) + let nonceDir = support.appendingPathComponent("nonces", isDirectory: true) + do { + try FileManager.default.createDirectory(at: nonceDir, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: support.path) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: nonceDir.path) + } catch { + return .refused("nonce store directory failed") + } + var lst = stat() + if lstat(nonceDir.path, &lst) != 0 || (lst.st_mode & S_IFMT) == S_IFLNK { + return .refused("nonce store path is a symlink") + } + let dest = nonceDir.appendingPathComponent(safe) + if lstat(dest.path, &lst) == 0 && (lst.st_mode & S_IFMT) == S_IFLNK { + return .refused("nonce reservation path is a symlink") + } + let flags = disableExclusivityForMutationTest ? (O_CREAT | O_WRONLY) : (O_CREAT | O_EXCL | O_WRONLY) + let fd = open(dest.path, flags, 0o600) + if fd < 0 { + return .refused("nonce already reserved") + } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + let rec = NonceReservation( + nonce: safe, operation: operation, executableSHA256: executableSHA256, + sourceCommit: sourceCommit, rootCanonical: canonicalSacrificial, + authorizationDigest: authorizationDigest, reservedAt: iso.string(from: Date()) + ) + let line = "\(rec.nonce) \(rec.operation) \(rec.executableSHA256) \(rec.sourceCommit) \(rec.authorizationDigest) \(rec.reservedAt)\n" + let bytes = Array(line.utf8) + let written = bytes.withUnsafeBufferPointer { write(fd, $0.baseAddress, $0.count) } + close(fd) + if written != bytes.count { return .refused("nonce record write failed") } + return .reserved(rec) + } +} diff --git a/src/MigrationState.swift b/src/MigrationState.swift new file mode 100644 index 0000000..4c20d0a --- /dev/null +++ b/src/MigrationState.swift @@ -0,0 +1,86 @@ +import Foundation + +// ============================================================================ +// R1B Phase 1A — pure migration state model. +// +// Independent of ServiceManagement. Derives a typed state from explicit +// evidence. A future app-agent label is *observed evidence only* and does +// not widen ProductIdentity.selfLabels. +// ============================================================================ + +enum MigrationState: String, Equatable { + case cliOnly + case appOnly + case neitherInstalled + case dualDeskTidyPresence + case foreignConflict + case invalidTargetConfiguration + case registrationIndeterminate + case halfMigrated + case rollbackRequired +} + +enum MigrationIntent: String, Equatable { + case beginRegistration + case rollback + case uninstall +} + +enum MigrationDecision: Equatable { + case allow + case refuse + + var isRefuse: Bool { + if case .refuse = self { return true } + return false + } +} + +struct MigrationEvidence: Equatable { + var targetValid: Bool + var legacyCLIPresent: Bool + var observedAppAgentPresent: Bool + var foreignOverlap: Bool + var registrationStatusKnown: Bool + var registrationEnabled: Bool + var transactionOpen: Bool + var transactionContradictory: Bool + var rollbackMarked: Bool +} + +enum MigrationPolicy { + static func classify(_ e: MigrationEvidence) -> MigrationState { + if !e.targetValid { return .invalidTargetConfiguration } + if e.foreignOverlap { return .foreignConflict } + if e.rollbackMarked { return .rollbackRequired } + if e.transactionContradictory { return .halfMigrated } + if e.transactionOpen && !(e.legacyCLIPresent && e.observedAppAgentPresent && e.registrationStatusKnown) { + return .halfMigrated + } + if e.observedAppAgentPresent && !e.registrationStatusKnown { + return .registrationIndeterminate + } + if e.legacyCLIPresent && e.observedAppAgentPresent { return .dualDeskTidyPresence } + if e.observedAppAgentPresent { return .appOnly } + if e.legacyCLIPresent { return .cliOnly } + return .neitherInstalled + } + + /// Begin is eligible only from cliOnly or neitherInstalled. + /// Rollback is the recovery path from dual/half/app/rollbackRequired. + /// Uninstall is allowed only when no app evidence remains (or appOnly + /// with known-absent service). Indeterminate/foreign/invalid always refuse. + static func decide(state: MigrationState, intent: MigrationIntent) -> MigrationDecision { + switch (state, intent) { + case (.cliOnly, .beginRegistration), (.neitherInstalled, .beginRegistration): + return .allow + case (.appOnly, .rollback), (.dualDeskTidyPresence, .rollback), + (.halfMigrated, .rollback), (.rollbackRequired, .rollback): + return .allow + case (.appOnly, .uninstall), (.neitherInstalled, .uninstall): + return .allow + default: + return .refuse + } + } +} diff --git a/src/MigrationTransaction.swift b/src/MigrationTransaction.swift new file mode 100644 index 0000000..823d84f --- /dev/null +++ b/src/MigrationTransaction.swift @@ -0,0 +1,157 @@ +import Foundation + +// ============================================================================ +// Append-only schema-versioned migration transaction (not a movement receipt). +// Simulated transitions only. No real launchd/Login Items mutation. +// ============================================================================ + +enum MigrationOutcome: String, Equatable { + case succeeded + case rejected + case indeterminate + case refused + case rolledBack + case rollbackFailed +} + +struct MigrationTransaction: Equatable { + var schema: Int = 1 + var id: String + var sourceCommit: String + var bundleHash: String + var targetCanonical: String + var priorCLIPresent: Bool + var intendedPlistName: String + var adapterStatusBefore: String + var adapterStatusAfter: String + var outcome: MigrationOutcome + var rollbackRequired: Bool + var preparedAt: String + var loginItemsObserved: Bool = false + var fdaObserved: Bool = false + var rebootObserved: Bool = false +} + +final class MigrationLedger { + private(set) var records: [MigrationTransaction] = [] + func append(_ t: MigrationTransaction) { records.append(t) } +} + +/// Orchestrates a simulated register/unregister using a fake adapter only. +/// The second pre-call check is load-bearing (A→B→A target). +struct MigrationOrchestrator { + let adapter: ServiceManagementAdapting + var skipSecondPreCallCheck = false // production false; A→B→A flips this + + func attempt( + intent: MigrationIntent, + evidence: MigrationEvidence, + evidenceAtCall: MigrationEvidence? = nil, + authData: Data?, + context: InterlockContext, + plistName: String, + sourceCommit: String, + bundleHash: String, + targetCanonical: String, + priorCLIPresent: Bool + ) -> MigrationTransaction { + let iso = ISO8601DateFormatter() + func rec(_ outcome: MigrationOutcome, before: String, after: String, rollback: Bool) -> MigrationTransaction { + MigrationTransaction( + id: UUID().uuidString, sourceCommit: sourceCommit, bundleHash: bundleHash, + targetCanonical: targetCanonical, priorCLIPresent: priorCLIPresent, + intendedPlistName: plistName, adapterStatusBefore: before, + adapterStatusAfter: after, outcome: outcome, rollbackRequired: rollback, + preparedAt: iso.string(from: Date()) + ) + } + + let state = MigrationPolicy.classify(evidence) + if MigrationPolicy.decide(state: state, intent: intent) == .refuse { + return rec(.refused, before: "unprobed", after: "unprobed", rollback: false) + } + + let beforeStatus = adapter.status(plistName: plistName) + let beforeStr = describe(beforeStatus) + + if let authData { + switch MutationInterlock.evaluate(authData: authData, context: context) { + case .refuse: + return rec(.refused, before: beforeStr, after: beforeStr, rollback: false) + case .permit: + break + } + } else if intent == .beginRegistration || intent == .rollback { + return rec(.refused, before: beforeStr, after: beforeStr, rollback: false) + } + + let callEvidence = evidenceAtCall ?? evidence + if !skipSecondPreCallCheck { + let late = MigrationPolicy.classify(callEvidence) + if late != state || callEvidence.foreignOverlap != evidence.foreignOverlap + || !callEvidence.targetValid || callEvidence.foreignOverlap { + if MigrationPolicy.decide(state: late, intent: intent) == .refuse + || late != state || callEvidence.foreignOverlap { + return rec(.refused, before: beforeStr, after: beforeStr, rollback: false) + } + } + if callEvidence.targetValid != evidence.targetValid { + return rec(.refused, before: beforeStr, after: beforeStr, rollback: false) + } + } + + switch intent { + case .beginRegistration: + let result = adapter.requestRegister(plistName: plistName) + let after = adapter.status(plistName: plistName) + switch (result, after) { + case (.failure, _): + return rec(.rejected, before: beforeStr, after: describe(after), rollback: false) + case (.success, .success(.unknown)): + return rec(.indeterminate, before: beforeStr, after: describe(after), rollback: true) + case (.success, .success(let st)) where st == .enabled || st == .requiresApproval: + if evidence.legacyCLIPresent { + return rec(.refused, before: beforeStr, after: describe(after), rollback: true) + } + return rec(.succeeded, before: beforeStr, after: describe(after), rollback: false) + case (.success, .success), (.success, .failure): + return rec(.indeterminate, before: beforeStr, after: describe(after), rollback: true) + } + case .rollback: + let result = adapter.requestUnregister(plistName: plistName) + let after = adapter.status(plistName: plistName) + switch result { + case .failure: + return rec(.rollbackFailed, before: beforeStr, after: describe(after), rollback: true) + case .success: + if case .success(.unknown) = after { + return rec(.indeterminate, before: beforeStr, after: describe(after), rollback: true) + } + if case .failure = after { + return rec(.indeterminate, before: beforeStr, after: describe(after), rollback: true) + } + return rec(.rolledBack, before: beforeStr, after: describe(after), rollback: false) + } + case .uninstall: + if state == .registrationIndeterminate { + return rec(.refused, before: beforeStr, after: beforeStr, rollback: false) + } + return rec(.succeeded, before: beforeStr, after: beforeStr, rollback: false) + } + } + + private func describe(_ r: Result) -> String { + switch r { + case .success(let s): + switch s { + case .enabled: return "enabled" + case .requiresApproval: return "requiresApproval" + case .notRegistered: return "notRegistered" + case .notFound: return "notFound" + case .unknown(let x): return "unknown:\(x)" + } + case .failure(let e): + return "error:\(e)" + } + } +} diff --git a/src/MutationBoundary.swift b/src/MutationBoundary.swift new file mode 100644 index 0000000..8af6295 --- /dev/null +++ b/src/MutationBoundary.swift @@ -0,0 +1,195 @@ +import CryptoKit +import Darwin +import Foundation + +// ============================================================================ +// Sealed prepared grant. Produced only after secure auth open, measured +// identity, two root/authority observations, policy permit, and atomic nonce +// reservation. Phase 1A.1 stops here — no production adapter call. +// ============================================================================ + +struct PreparedMutationGrant: Equatable { + let operation: InterlockOperation + let executableSHA256: String + let sourceCommit: String + let rootCanonical: String + let nonce: String + let authorizationDigest: String + fileprivate init(operation: InterlockOperation, executableSHA256: String, + sourceCommit: String, rootCanonical: String, nonce: String, + authorizationDigest: String) { + self.operation = operation + self.executableSHA256 = executableSHA256 + self.sourceCommit = sourceCommit + self.rootCanonical = rootCanonical + self.nonce = nonce + self.authorizationDigest = authorizationDigest + } +} + +struct AuthoritySnapshot: Equatable { + var foreignOverlap: Bool + var uninspectable: Bool + var dualDeskTidy: Bool + var rootCanonical: String +} + +enum AuthoritySnapResult: Equatable { + case ok(AuthoritySnapshot) + case failed(String) +} + +protocol AuthoritySnapshotProviding { + func snapshot(rootPath: String) -> AuthoritySnapResult +} + +enum PrecallTransactionLog { + enum Outcome: Equatable { + case recorded + case refused(String) + } + + static func append(grant: PreparedMutationGrant) -> Outcome { + let support = DurableNonceStore.supportRoot(canonicalSacrificial: grant.rootCanonical) + do { + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: support.path) + } catch { + return .refused("pre-call transaction directory failed") + } + let dest = support.appendingPathComponent("precall.jsonl") + var lst = stat() + if lstat(dest.path, &lst) == 0 && (lst.st_mode & S_IFMT) == S_IFLNK { + return .refused("pre-call transaction path is a symlink") + } + let fd = open(dest.path, O_CREAT | O_APPEND | O_WRONLY | O_NOFOLLOW, 0o600) + if fd < 0 { return .refused("pre-call transaction open failed") } + defer { close(fd) } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + let line = "\(grant.nonce) \(grant.operation.rawValue) \(grant.executableSHA256) \(grant.sourceCommit) \(grant.authorizationDigest) \(iso.string(from: Date()))\n" + let bytes = Array(line.utf8) + let written = bytes.withUnsafeBufferPointer { write(fd, $0.baseAddress, $0.count) } + if written != bytes.count { return .refused("pre-call transaction write failed") } + return .recorded + } +} + +enum MutationBoundary { + enum Outcome: Equatable { + case prepared(PreparedMutationGrant) + case refused(String) + } + + static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + static func prepare( + authBytes: Data, + identity: ProbeIdentity.Measurement, + compiledSourceCommit: String, + operation: InterlockOperation, + first: AuthoritySnapshot, + second: AuthoritySnapshot, + desktop: CanonicalPath, + home: CanonicalPath, + protected: [CanonicalPath], + productionTarget: CanonicalPath?, + now: Date = Date() + ) -> Outcome { + if compiledSourceCommit.count != 40 || !MutationInterlock.isCommitHex(compiledSourceCommit) { + return .refused("compiled source commit is not a 40-hex SHA") + } + if identity.executableSHA256 == String(repeating: "0", count: 64) { + return .refused("executable hash is the zero placeholder") + } + if first.rootCanonical != second.rootCanonical { + return .refused("sacrificial root changed between observations") + } + if first.foreignOverlap || second.foreignOverlap { + return .refused("foreign mover on sacrificial root") + } + if first.uninspectable || second.uninspectable { + return .refused("authority evidence uninspectable") + } + if first.dualDeskTidy || second.dualDeskTidy { + return .refused("dual DeskTidy presence") + } + let root = AuthorityGuard.canonicalize(first.rootCanonical) + if MutationInterlock.rootsEquivalent(root, desktop) { + return .refused("sacrificial root is Desktop") + } + if MutationInterlock.isInsideDesktop(first.rootCanonical, desktop: desktop) { + return .refused("sacrificial root is inside Desktop") + } + if MutationInterlock.rootsEquivalent(root, home) { + return .refused("sacrificial root is home directory") + } + if root.path == "/" { return .refused("sacrificial root is /") } + if let parent = URL(fileURLWithPath: desktop.path).deletingLastPathComponent().path as String? { + if MutationInterlock.rootsEquivalent(root, AuthorityGuard.canonicalize(parent)) { + return .refused("sacrificial root is parent of Desktop") + } + } + for p in protected { + if MutationInterlock.rootsEquivalent(root, p) { + return .refused("sacrificial root equals a protected target") + } + if MutationInterlock.isInsideDesktop(first.rootCanonical, desktop: p) { + return .refused("sacrificial root is inside a protected target") + } + let protParent = URL(fileURLWithPath: p.path).deletingLastPathComponent().path + if MutationInterlock.rootsEquivalent(root, AuthorityGuard.canonicalize(protParent)) { + return .refused("sacrificial root is parent of a protected target") + } + } + if let prod = productionTarget, MutationInterlock.rootsEquivalent(root, prod) { + return .refused("sacrificial root is the production DeskTidy target") + } + + var ctx = InterlockContext( + isSacrificialProbeExecutable: true, + requestedOperation: operation, + plistName: "com.desktidy.sacrificial.plist", + actualBundleSHA256: identity.executableSHA256, + actualSourceCommit: compiledSourceCommit, + now: now, + usedNonces: [], + foreignOverlap: false, + desktopCanonical: desktop, + sacrificialExists: true + ) + ctx.authorityUninspectable = false + ctx.dualDeskTidyPresence = false + switch MutationInterlock.evaluate(authData: authBytes, context: ctx) { + case .refuse(let r): return .refused(r) + case .permit(let auth): + let nonce = DurableNonceStore.reserve( + canonicalSacrificial: first.rootCanonical, + nonce: auth.nonce, + operation: auth.operation.rawValue, + executableSHA256: identity.executableSHA256, + sourceCommit: compiledSourceCommit, + authorizationDigest: digest(authBytes) + ) + switch nonce { + case .refused(let r): return .refused(r) + case .reserved(let rec): + let grant = PreparedMutationGrant( + operation: auth.operation, + executableSHA256: identity.executableSHA256, + sourceCommit: compiledSourceCommit, + rootCanonical: first.rootCanonical, + nonce: rec.nonce, + authorizationDigest: rec.authorizationDigest + ) + switch PrecallTransactionLog.append(grant: grant) { + case .refused(let r): return .refused(r) + case .recorded: + return .prepared(grant) + } + } + } + } +} diff --git a/src/MutationInterlock.swift b/src/MutationInterlock.swift new file mode 100644 index 0000000..e17d8dd --- /dev/null +++ b/src/MutationInterlock.swift @@ -0,0 +1,150 @@ +import Foundation + +// ============================================================================ +// Multi-factor mutation interlock — the only path that may invoke a +// production register/unregister adapter. Phase 1A tests the parser/policy +// only. No valid live authorization file is created here. +// ============================================================================ + +enum InterlockOperation: String, Equatable { + case register + case unregister +} + +struct InterlockAuthorization: Equatable { + var operation: InterlockOperation + var sacrificialRoot: String + var bundleSHA256: String + var sourceCommit: String + var expiry: Date + var nonce: String +} + +struct InterlockContext: Equatable { + var isSacrificialProbeExecutable: Bool + var requestedOperation: InterlockOperation + var plistName: String + var actualBundleSHA256: String + var actualSourceCommit: String + var now: Date + var usedNonces: Set + var foreignOverlap: Bool + var desktopCanonical: CanonicalPath + var sacrificialExists: Bool + var authorityUninspectable: Bool = false + var dualDeskTidyPresence: Bool = false +} + +enum InterlockDecision: Equatable { + case permit(InterlockAuthorization) + case refuse(String) +} + +enum MutationInterlock { + static let personalLabels: Set = [ + "com.sicarii.desktop-autosort", + "com.sicarii.desktop-autosort-notify", + ] + static let requiredKeys: Set = [ + "schema", "operation", "sacrificialRoot", "bundleSHA256", "sourceCommit", "expiry", "nonce", + ] + static let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + enum AuthParse: Equatable { + case ok(InterlockAuthorization) + case failed(String) + } + + static func parseAuthorization(_ data: Data) -> AuthParse { + switch StrictJSONObject.parse(data) { + case .failed(let r): return .failed(r) + case .ok(let obj): + let keys = Set(obj.keys) + if keys != requiredKeys { return .failed("authorization key set mismatch") } + guard case .integer(let schema) = obj["schema"], schema == 1 else { + return .failed("authorization schema invalid") + } + guard case .string(let opRaw) = obj["operation"], + let op = InterlockOperation(rawValue: opRaw) else { + return .failed("authorization operation invalid") + } + guard case .string(let root) = obj["sacrificialRoot"], !root.isEmpty else { + return .failed("authorization sacrificialRoot invalid") + } + guard case .string(let hash) = obj["bundleSHA256"], isSHA256Hex(hash) else { + return .failed("authorization bundleSHA256 invalid") + } + guard case .string(let commit) = obj["sourceCommit"], isCommitHex(commit) else { + return .failed("authorization sourceCommit invalid") + } + guard case .string(let expRaw) = obj["expiry"], let expiry = iso.date(from: expRaw) else { + return .failed("authorization expiry invalid") + } + guard case .string(let nonce) = obj["nonce"], !nonce.isEmpty else { + return .failed("authorization nonce invalid") + } + return .ok(InterlockAuthorization( + operation: op, sacrificialRoot: root, bundleSHA256: hash.lowercased(), + sourceCommit: commit.lowercased(), expiry: expiry, nonce: nonce + )) + } + } + + static func evaluate(authData: Data, context: InterlockContext) -> InterlockDecision { + if !context.isSacrificialProbeExecutable { + return .refuse("not the sacrificial probe executable") + } + if personalLabels.contains(context.plistName) { + return .refuse("personal mover label is never a mutation target") + } + switch parseAuthorization(authData) { + case .failed(let r): return .refuse(r) + case .ok(let auth): + if auth.operation != context.requestedOperation { + return .refuse("authorization operation mismatch") + } + if auth.bundleSHA256 != context.actualBundleSHA256.lowercased() { + return .refuse("authorization bundle hash mismatch") + } + if auth.sourceCommit != context.actualSourceCommit.lowercased() { + return .refuse("authorization source commit mismatch") + } + if auth.expiry <= context.now { return .refuse("authorization expired") } + if context.usedNonces.contains(auth.nonce) { return .refuse("authorization nonce reused") } + if context.foreignOverlap { return .refuse("foreign mover on sacrificial root") } + if context.authorityUninspectable { return .refuse("authority evidence uninspectable") } + if context.dualDeskTidyPresence { return .refuse("dual DeskTidy presence") } + if !context.sacrificialExists { return .refuse("sacrificial root missing") } + let rootCanon = AuthorityGuard.canonicalize(auth.sacrificialRoot) + if rootsEquivalent(rootCanon, context.desktopCanonical) { + return .refuse("sacrificial root is Desktop") + } + if isInsideDesktop(auth.sacrificialRoot, desktop: context.desktopCanonical) { + return .refuse("sacrificial root is inside Desktop") + } + return .permit(auth) + } + } + + static func isSHA256Hex(_ s: String) -> Bool { + s.count == 64 && s.unicodeScalars.allSatisfy { CharacterSet(charactersIn: "0123456789abcdefABCDEF").contains($0) } + } + static func isCommitHex(_ s: String) -> Bool { + (s.count == 40 || s.count == 64) && s.unicodeScalars.allSatisfy { CharacterSet(charactersIn: "0123456789abcdefABCDEF").contains($0) } + } + + static func rootsEquivalent(_ a: CanonicalPath, _ b: CanonicalPath) -> Bool { a == b } + + static func isInsideDesktop(_ path: String, desktop: CanonicalPath) -> Bool { + var url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + while url.path != "/" { + url.deleteLastPathComponent() + if AuthorityGuard.canonicalize(url.path) == desktop { return true } + } + return false + } +} diff --git a/src/Phase1A1Tests.swift b/src/Phase1A1Tests.swift new file mode 100644 index 0000000..96d849d --- /dev/null +++ b/src/Phase1A1Tests.swift @@ -0,0 +1,241 @@ +import Foundation + +// ============================================================================ +// Phase 1A.1 measured-evidence and grant-preparation gates. +// Fake providers only. Does not construct a production ServiceManagement adapter. +// ============================================================================ + +final class Phase1A1Tests { + private let fm = FileManager.default + private var pass = 0 + private var fail = 0 + private let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + private let commit = "e14c13fd35f45b875d0880ef15d28dd1d0d653ee" + private let hashOK = String(repeating: "ab", count: 32) + + private func check(_ id: String, _ desc: String, _ ok: Bool, _ detail: String = "") { + if ok { print("PASS \(id) \(desc)"); pass += 1 } + else { print("FAIL \(id) \(desc)\(detail.isEmpty ? "" : " — \(detail)")"); fail += 1 } + } + + func runAll() -> Bool { + DurableNonceStore.disableExclusivityForMutationTest = false + ProductionMutationLedger.reset() + runIdentityAndPrepare() + runAuthFile() + runNonce() + runLedger() + print("PHASE1A1 GATES: \(pass) passed, \(fail) failed") + if pass == 0 { print("FAIL summary zero cases"); return false } + return fail == 0 + } + + private func tmpDir(_ tag: String) -> URL { + let u = fm.temporaryDirectory.appendingPathComponent("dt-1a1-\(tag)-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: u, withIntermediateDirectories: true) + return u + } + + private func identity(hash: String? = nil, name: String = "DeskTidySacrificialProbe", + bid: String = "com.desktidy.sacrificial-probe", + app: URL? = nil, exe: URL? = nil, plistOK: Bool = true) -> ProbeIdentity.Measurement { + let bundle = app ?? tmpDir("app").appendingPathComponent("DeskTidySacrificialProbe.app") + try? fm.createDirectory(at: bundle.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) + let plistDir = bundle.appendingPathComponent("Contents/Library/LaunchAgents") + try? fm.createDirectory(at: plistDir, withIntermediateDirectories: true) + let plist = plistDir.appendingPathComponent("com.desktidy.sacrificial.plist") + if plistOK { fm.createFile(atPath: plist.path, contents: Data("plist".utf8)) } + let exec = exe ?? bundle.appendingPathComponent("Contents/MacOS/\(name)") + return ProbeIdentity.Measurement( + executableURL: exec, basename: name, appBundleURL: bundle, + bundleIdentifier: bid, plistURL: plist, executableSHA256: hash ?? hashOK) + } + + private func auth(root: String, hash: String? = nil, commit: String? = nil, + nonce: String = "nonce-ok1", op: String = "register") -> Data { + let exp = iso.string(from: Date().addingTimeInterval(3600)) + let h = hash ?? hashOK + let c = commit ?? self.commit + return Data("{\"schema\":1,\"operation\":\"\(op)\",\"sacrificialRoot\":\"\(root)\",\"bundleSHA256\":\"\(h)\",\"sourceCommit\":\"\(c)\",\"expiry\":\"\(exp)\",\"nonce\":\"\(nonce)\"}".utf8) + } + + private func snap(_ root: String, foreign: Bool = false, unknown: Bool = false, dual: Bool = false) -> AuthoritySnapshot { + AuthoritySnapshot(foreignOverlap: foreign, uninspectable: unknown, dualDeskTidy: dual, rootCanonical: root) + } + + private func prepare(auth: Data, id: ProbeIdentity.Measurement, root: String, + first: AuthoritySnapshot? = nil, second: AuthoritySnapshot? = nil, + commit: String? = nil) -> MutationBoundary.Outcome { + let desk = tmpDir("desk") + let home = tmpDir("home") + return MutationBoundary.prepare( + authBytes: auth, identity: id, compiledSourceCommit: commit ?? self.commit, + operation: .register, + first: first ?? snap(root), second: second ?? snap(root), + desktop: AuthorityGuard.canonicalize(desk.path), + home: AuthorityGuard.canonicalize(home.path), + protected: [], productionTarget: nil) + } + + private func runIdentityAndPrepare() { + let root = tmpDir("sac") + let id = identity() + + let z = String(repeating: "0", count: 64) + let r1 = prepare(auth: auth(root: root.path, hash: z), id: identity(hash: z), root: root.path) + check("E01", "zero placeholder hash refused", + { if case .refused = r1 { return true }; return false }(), "\(r1)") + + let r2b = prepare(auth: auth(root: root.path, commit: "0b11c652e364cf47668ba87b4228a0f4ab7974ec", nonce: "nonce-e02"), + id: id, root: root.path, commit: self.commit) + check("E02", "stale Phase 0 commit refused against current compiled identity", + { if case .refused = r2b { return true }; return false }(), "\(r2b)") + + let copied = identity(name: "copied-probe") + let meas = ProbeIdentity.measureRunning(executableURL: copied.executableURL, bundle: nil) + check("E03", "copied/renamed executable refused as sacrificial", + { if case .refused = meas { return true }; return false }(), "\(meas)") + + let missing = tmpDir("gone") + try? fm.removeItem(at: missing) + check("E04", "nonexistent root snapshot fails closed", + { if case .failed = ProductionAuthoritySnapshot.live.snapshot(rootPath: missing.path) { return true }; return false }()) + + let fileRoot = tmpDir("files").appendingPathComponent("notdir") + fm.createFile(atPath: fileRoot.path, contents: Data("x".utf8)) + check("E05", "regular file rejected as sacrificial root", + { if case .failed = ProductionAuthoritySnapshot.live.snapshot(rootPath: fileRoot.path) { return true }; return false }()) + + let r6 = prepare(auth: auth(root: root.path), id: id, root: root.path, + first: snap(root.path), second: snap(root.path, foreign: true)) + check("E06", "foreign overlap on second observation refused", + { if case .refused(let s) = r6 { return s.contains("foreign") }; return false }(), "\(r6)") + + let r7 = prepare(auth: auth(root: root.path), id: id, root: root.path, + first: snap(root.path), second: snap(root.path, unknown: true)) + check("E07", "unknown/uninspectable authority refused", + { if case .refused(let s) = r7 { return s.contains("uninspectable") }; return false }(), "\(r7)") + + let wrongHash = String(repeating: "cd", count: 32) + let rHash = prepare(auth: auth(root: root.path, hash: wrongHash), id: id, root: root.path) + check("E16", "correct-length wrong executable hash refused", + { if case .refused = rHash { return true }; return false }(), "\(rHash)") + + let short = prepare(auth: auth(root: root.path), id: id, root: root.path, commit: "e14c13f") + check("E17", "short SHA compiled identity refused", + { if case .refused = short { return true }; return false }(), "\(short)") + + let dual = prepare(auth: auth(root: root.path), id: id, root: root.path, + first: snap(root.path), second: snap(root.path, dual: true)) + check("E18", "dual DeskTidy presence refused", + { if case .refused = dual { return true }; return false }(), "\(dual)") + + let changed = prepare(auth: auth(root: root.path), id: id, root: root.path, + first: snap(root.path), second: snap(tmpDir("other").path)) + check("E19", "root changed between observations refused", + { if case .refused = changed { return true }; return false }(), "\(changed)") + + let ok = prepare(auth: auth(root: root.path, nonce: "nonce-e20"), id: id, root: root.path) + check("E20", "valid hermetic prepare yields grant", + { if case .prepared = ok { return true }; return false }(), "\(ok)") + let log = DurableNonceStore.supportRoot(canonicalSacrificial: root.path) + .appendingPathComponent("precall.jsonl") + check("E21", "append-only pre-call transaction recorded", + fm.fileExists(atPath: log.path), log.path) + } + + private func runAuthFile() { + let dir = tmpDir("auth") + let path = dir.appendingPathComponent("auth.json") + let body = auth(root: tmpDir("sac").path) + try? body.write(to: path) + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) + + switch SecureAuthFile.openOnce(path: path.path) { + case .ok(let d): check("A01", "0600 regular auth opens once", d == body) + case .refused(let r): check("A01", "0600 regular auth opens once", false, r) + } + + let link = dir.appendingPathComponent("auth.link") + try? fm.removeItem(at: link) + try? fm.createSymbolicLink(at: link, withDestinationURL: path) + check("E10", "auth symlink refused", + { if case .refused = SecureAuthFile.openOnce(path: link.path) { return true }; return false }()) + + try? fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: path.path) + check("E11", "auth mode 0644 refused", + { if case .refused = SecureAuthFile.openOnce(path: path.path) { return true }; return false }()) + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) + + let big = dir.appendingPathComponent("big.json") + try? Data(repeating: 0x61, count: SecureAuthFile.maxBytes + 10).write(to: big) + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: big.path) + check("A02", "oversized auth refused", + { if case .refused = SecureAuthFile.openOnce(path: big.path) { return true }; return false }()) + + // substitution after open: original bytes retained + switch SecureAuthFile.openOnce(path: path.path) { + case .ok(let first): + try? Data("{\"schema\":1}".utf8).write(to: path) + check("E12", "open-once retains original bytes after path substitution", + first == body && first != Data("{\"schema\":1}".utf8)) + case .refused(let r): + check("E12", "open-once retains original bytes after path substitution", false, r) + } + } + + private func runNonce() { + let root = tmpDir("nonce-root") + let first = DurableNonceStore.reserve( + canonicalSacrificial: AuthorityGuard.canonicalize(root.path).path, + nonce: "nonce-rep1", operation: "register", + executableSHA256: hashOK, sourceCommit: commit, authorizationDigest: "aa") + check("N01", "first nonce reservation succeeds", + { if case .reserved = first { return true }; return false }(), "\(first)") + let replay = DurableNonceStore.reserve( + canonicalSacrificial: AuthorityGuard.canonicalize(root.path).path, + nonce: "nonce-rep1", operation: "register", + executableSHA256: hashOK, sourceCommit: commit, authorizationDigest: "aa") + check("E08", "nonce replay across store instances refused", + { if case .refused = replay { return true }; return false }(), "\(replay)") + + let raceRoot = tmpDir("nonce-race") + let canon = AuthorityGuard.canonicalize(raceRoot.path).path + var wins = 0 + var losses = 0 + let lock = NSLock() + DispatchQueue.concurrentPerform(iterations: 8) { _ in + switch DurableNonceStore.reserve( + canonicalSacrificial: canon, nonce: "nonce-race1", operation: "register", + executableSHA256: hashOK, sourceCommit: commit, authorizationDigest: "bb") { + case .reserved: lock.lock(); wins += 1; lock.unlock() + case .refused: lock.lock(); losses += 1; lock.unlock() + } + } + check("E09", "concurrent nonce reservation has exactly one winner", + wins == 1 && losses == 7, "wins=\(wins) losses=\(losses)") + } + + private func runLedger() { + ProductionMutationLedger.reset() + let root = tmpDir("led") + _ = prepare(auth: auth(root: root.path, nonce: "nonce-led1"), id: identity(), root: root.path) + check("E14", "prepare does not construct a production adapter", + ProductionMutationLedger.constructions == 0 + && ProductionMutationLedger.registerInvocations == 0 + && ProductionMutationLedger.unregisterInvocations == 0, + "c=\(ProductionMutationLedger.constructions) r=\(ProductionMutationLedger.registerInvocations)") + check("S11", "retained second-precall foreign ID still refused at prepare", + { if case .refused = prepare(auth: auth(root: root.path, nonce: "nonce-s11"), id: identity(), + root: root.path, first: snap(root.path), + second: snap(root.path, foreign: true)) { return true }; return false }()) + check("S12", "retained second-precall target-change ID still refused", + { if case .refused = prepare(auth: auth(root: root.path, nonce: "nonce-s12"), id: identity(), + root: root.path, first: snap(root.path), + second: snap(tmpDir("mut").path)) { return true }; return false }()) + } +} diff --git a/src/Phase1ATests.swift b/src/Phase1ATests.swift new file mode 100644 index 0000000..6b06c10 --- /dev/null +++ b/src/Phase1ATests.swift @@ -0,0 +1,378 @@ +import Foundation + +// ============================================================================ +// R1B Phase 1A gates — fake-substrate only. +// ============================================================================ + +final class Phase1ATests { + private let fm = FileManager.default + private var passCount = 0 + private var failCount = 0 + private let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private func check(_ id: String, _ desc: String, _ ok: Bool, _ detail: String = "") { + if ok { print("PASS \(id) \(desc)"); passCount += 1 } + else { print("FAIL \(id) \(desc)\(detail.isEmpty ? "" : " — \(detail)")"); failCount += 1 } + } + + func runAll() -> Bool { + runMigrationMatrix() + runAdapterGates() + runInterlockGates() + runTransactionGates() + runWiringGates() + print("PHASE1A GATES: \(passCount) passed, \(failCount) failed") + if passCount == 0 { print("FAIL summary zero cases"); return false } + return failCount == 0 + } + + private func ev( + target: Bool = true, cli: Bool = false, app: Bool = false, foreign: Bool = false, + known: Bool = true, enabled: Bool = false, open: Bool = false, + contra: Bool = false, rollback: Bool = false + ) -> MigrationEvidence { + MigrationEvidence( + targetValid: target, legacyCLIPresent: cli, observedAppAgentPresent: app, + foreignOverlap: foreign, registrationStatusKnown: known, registrationEnabled: enabled, + transactionOpen: open, transactionContradictory: contra, rollbackMarked: rollback + ) + } + + private func runMigrationMatrix() { + func row(_ id: String, _ e: MigrationEvidence, _ expectState: MigrationState, + _ intent: MigrationIntent, _ expect: MigrationDecision) { + let s = MigrationPolicy.classify(e) + let d = MigrationPolicy.decide(state: s, intent: intent) + check(id, "\(expectState.rawValue) + \(intent.rawValue) → \(expect)", + s == expectState && d == expect, + "state=\(s) decision=\(d)") + } + + row("M01", ev(cli: true, app: true, enabled: true), .dualDeskTidyPresence, .beginRegistration, .refuse) + row("M02", ev(), .neitherInstalled, .beginRegistration, .allow) + row("M03", ev(cli: true), .cliOnly, .beginRegistration, .allow) + row("M04", ev(app: true, enabled: true), .appOnly, .beginRegistration, .refuse) + row("M05", ev(cli: true, foreign: true), .foreignConflict, .beginRegistration, .refuse) + row("M06", ev(target: false, cli: true), .invalidTargetConfiguration, .beginRegistration, .refuse) + row("M07", ev(app: true, known: false), .registrationIndeterminate, .beginRegistration, .refuse) + row("M08", ev(cli: true, open: true), .halfMigrated, .beginRegistration, .refuse) + row("M09", ev(cli: true, rollback: true), .rollbackRequired, .beginRegistration, .refuse) + row("M10", ev(cli: true, app: true, enabled: true), .dualDeskTidyPresence, .rollback, .allow) + row("M11", ev(cli: true, open: true), .halfMigrated, .rollback, .allow) + row("M12", ev(app: true, enabled: true), .appOnly, .rollback, .allow) + row("M13", ev(cli: true, rollback: true), .rollbackRequired, .rollback, .allow) + row("M14", ev(foreign: true), .foreignConflict, .rollback, .refuse) + row("M15", ev(target: false), .invalidTargetConfiguration, .rollback, .refuse) + row("M16", ev(app: true, known: false), .registrationIndeterminate, .rollback, .refuse) + row("M17", ev(app: true, enabled: true), .appOnly, .uninstall, .allow) + row("M18", ev(), .neitherInstalled, .uninstall, .allow) + row("M19", ev(cli: true), .cliOnly, .uninstall, .refuse) + row("M20", ev(cli: true, app: true, enabled: true), .dualDeskTidyPresence, .uninstall, .refuse) + + let contra = ev(cli: true, app: true, contra: true) + check("M21", "contradictory transaction classifies halfMigrated", + MigrationPolicy.classify(contra) == .halfMigrated) + check("M22", "future app label is not in ProductIdentity.selfLabels", + !ProductIdentity.selfLabels.contains("com.desktidy.sacrificial") + && !ProductIdentity.selfLabels.contains("com.desktidy.app.sort")) + check("M23", "foreign overlap wins over dual evidence", + MigrationPolicy.classify(ev(cli: true, app: true, foreign: true)) == .foreignConflict) + check("M24", "plist presence alone is not a healthy/running state", + MigrationPolicy.classify(ev(cli: true)) == .cliOnly) + } + + private func runAdapterGates() { + let fake = SMAdapterSelection.forAutomatedTests() + fake.statusResult = .success(.notRegistered) + _ = fake.status(plistName: "com.desktidy.sacrificial") + fake.registerResult = .success(()) + _ = fake.requestRegister(plistName: "com.desktidy.sacrificial") + check("A01", "fake adapter records status then register", + fake.calls == [.status("com.desktidy.sacrificial"), .register("com.desktidy.sacrificial")]) + + let refused = SMAdapterSelection.forAutomatedTests() + var orch = MigrationOrchestrator(adapter: refused) + let tx = orch.attempt( + intent: .beginRegistration, evidence: ev(cli: true, app: true, enabled: true), + authData: nil, context: dummyCtx(), plistName: "com.desktidy.sacrificial", + sourceCommit: "0b11c652e364cf47668ba87b4228a0f4ab7974ec", + bundleHash: String(repeating: "ab", count: 32), + targetCanonical: "/tmp/x", priorCLIPresent: true + ) + check("A02", "refused dual state never calls register", + tx.outcome == .refused && refused.registerCount == 0, "outcome=\(tx.outcome) regs=\(refused.registerCount)") + + let unavail = SMAdapterSelection.unavailable() + check("A03", "unavailable adapter fails closed", + unavail.requestRegister(plistName: "x").errorIsUnavailable) + + let unk = SMAdapterSelection.forAutomatedTests() + unk.statusResult = .success(.unknown("mystery")) + check("A04", "unknown status is a distinct fail-closed value", + unk.status(plistName: "x") == .success(.unknown("mystery"))) + } + + private func dummyDesktop() -> (dir: URL, canon: CanonicalPath) { + let d = fm.temporaryDirectory.appendingPathComponent("dt-live-desktop-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: d, withIntermediateDirectories: true) + return (d, AuthorityGuard.canonicalize(d.path)) + } + + private func dummyCtx( + probe: Bool = true, op: InterlockOperation = .register, + plist: String = "com.desktidy.sacrificial", + hash: String = String(repeating: "ab", count: 32), + commit: String = "0b11c652e364cf47668ba87b4228a0f4ab7974ec", + used: Set = [], foreign: Bool = false, exists: Bool = true, + desktop: CanonicalPath? = nil + ) -> InterlockContext { + let desk = desktop ?? dummyDesktop().canon + return InterlockContext( + isSacrificialProbeExecutable: probe, requestedOperation: op, plistName: plist, + actualBundleSHA256: hash, actualSourceCommit: commit, now: Date(), + usedNonces: used, foreignOverlap: foreign, desktopCanonical: desk, + sacrificialExists: exists + ) + } + + private func authBytes( + op: String = "register", + root: String, + hash: String = String(repeating: "ab", count: 32), + commit: String = "0b11c652e364cf47668ba87b4228a0f4ab7974ec", + expiry: String? = nil, + nonce: String = "nonce-1" + ) -> Data { + let exp = expiry ?? iso.string(from: Date().addingTimeInterval(3600)) + let s = """ + {"schema":1,"operation":"\(op)","sacrificialRoot":"\(root)","bundleSHA256":"\(hash)","sourceCommit":"\(commit)","expiry":"\(exp)","nonce":"\(nonce)"} + """ + return Data(s.utf8) + } + + private func runInterlockGates() { + let sac = fm.temporaryDirectory.appendingPathComponent("dt-sac-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: sac, withIntermediateDirectories: true) + let desk = dummyDesktop() + + func eval(_ data: Data, ctx: InterlockContext) -> InterlockDecision { + MutationInterlock.evaluate(authData: data, context: ctx) + } + + var ctx = dummyCtx(desktop: desk.canon) + ctx.sacrificialExists = true + let good = authBytes(root: sac.path) + // permit requires root not desktop — sac is under tmp, fine + if case .permit = eval(good, ctx: ctx) { + check("I00", "valid fixture authorization permits on probe", true) + } else { + let d = eval(good, ctx: ctx) + check("I00", "valid fixture authorization permits on probe", false, "\(d)") + } + + check("I01", "missing/empty auth refuses", + { if case .refuse = eval(Data(), ctx: ctx) { return true }; return false }()) + check("I02", "malformed JSON refuses", + { if case .refuse = eval(Data("not-json".utf8), ctx: ctx) { return true }; return false }()) + let dupe = Data("{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"\(sac.path)\",\"bundleSHA256\":\"\(String(repeating: "ab", count: 32))\",\"sourceCommit\":\"0b11c652e364cf47668ba87b4228a0f4ab7974ec\",\"expiry\":\"\(iso.string(from: Date().addingTimeInterval(3600)))\",\"nonce\":\"n\",\"nonce\":\"n\"}".utf8) + check("I03", "duplicate authorization key refuses", + { if case .refuse = eval(dupe, ctx: ctx) { return true }; return false }()) + let expired = authBytes(root: sac.path, expiry: iso.string(from: Date().addingTimeInterval(-60))) + check("I04", "expired authorization refuses", + { if case .refuse = eval(expired, ctx: ctx) { return true }; return false }()) + var reused = dummyCtx(used: ["nonce-1"], desktop: desk.canon) + reused.sacrificialExists = true + check("I05", "reused nonce refuses", + { if case .refuse = eval(good, ctx: reused) { return true }; return false }()) + check("I06", "mismatched bundle hash refuses", + { if case .refuse = eval(authBytes(root: sac.path, hash: String(repeating: "cd", count: 32)), ctx: ctx) { return true }; return false }()) + check("I07", "mismatched operation refuses", + { if case .refuse = eval(authBytes(op: "unregister", root: sac.path), ctx: ctx) { return true }; return false }()) + check("I08", "mismatched source commit refuses", + { if case .refuse = eval(authBytes(root: sac.path, commit: String(repeating: "11", count: 20)), ctx: ctx) { return true }; return false }()) + check("I09", "Desktop-equivalent root refuses", + { if case .refuse = eval(authBytes(root: desk.dir.path), ctx: ctx) { return true }; return false }()) + let inside = desk.dir.appendingPathComponent("child") + try? fm.createDirectory(at: inside, withIntermediateDirectories: true) + check("I10", "root inside Desktop refuses", + { if case .refuse = eval(authBytes(root: inside.path), ctx: ctx) { return true }; return false }()) + let link = fm.temporaryDirectory.appendingPathComponent("dt-desk-link-\(UUID().uuidString.prefix(8))") + try? fm.createSymbolicLink(at: link, withDestinationURL: desk.dir) + check("I11", "symlink-equivalent Desktop refuses", + { if case .refuse = eval(authBytes(root: link.path), ctx: ctx) { return true }; return false }()) + var personal = dummyCtx(plist: "com.sicarii.desktop-autosort", desktop: desk.canon) + personal.sacrificialExists = true + check("I12", "personal mover label refuses", + { if case .refuse = eval(good, ctx: personal) { return true }; return false }()) + var foreign = dummyCtx(foreign: true, desktop: desk.canon) + foreign.sacrificialExists = true + check("I13", "foreign mover refuses", + { if case .refuse = eval(good, ctx: foreign) { return true }; return false }()) + var notProbe = dummyCtx(probe: false, desktop: desk.canon) + notProbe.sacrificialExists = true + check("I14", "non-probe executable refuses", + { if case .refuse = eval(good, ctx: notProbe) { return true }; return false }()) + let missing = Data("{\"schema\":1,\"operation\":\"register\"}".utf8) + check("I15", "incomplete key set refuses", + { if case .refuse = eval(missing, ctx: ctx) { return true }; return false }()) + var junk = authBytes(root: sac.path) + junk.append(contentsOf: Data(" true".utf8)) + check("I16", "trailing non-whitespace refuses", + { if case .refuse = eval(junk, ctx: ctx) { return true }; return false }()) + var missingRoot = dummyCtx(exists: false, desktop: desk.canon) + check("I17", "missing sacrificial directory refuses", + { if case .refuse = eval(good, ctx: missingRoot) { return true }; return false }()) + check("I18", "notify personal label also refuses", + { + var c = dummyCtx(plist: "com.sicarii.desktop-autosort-notify", desktop: desk.canon) + c.sacrificialExists = true + if case .refuse = eval(good, ctx: c) { return true } + return false + }()) + } + + private func runTransactionGates() { + let sac = fm.temporaryDirectory.appendingPathComponent("dt-tx-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: sac, withIntermediateDirectories: true) + let desk = dummyDesktop() + let hash = String(repeating: "ab", count: 32) + let commit = "0b11c652e364cf47668ba87b4228a0f4ab7974ec" + let plist = "com.desktidy.sacrificial" + var ctx = dummyCtx(desktop: desk.canon) + ctx.sacrificialExists = true + let auth = authBytes(root: sac.path) + + func run(_ id: String, intent: MigrationIntent, evidence: MigrationEvidence, + atCall: MigrationEvidence? = nil, adapter: FakeSMAdapter, + skipSecond: Bool = false, expect: MigrationOutcome, + registerCalls: Int? = nil, unregisterCalls: Int? = nil) { + var orch = MigrationOrchestrator(adapter: adapter, skipSecondPreCallCheck: skipSecond) + let tx = orch.attempt( + intent: intent, evidence: evidence, evidenceAtCall: atCall, + authData: auth, context: ctx, plistName: plist, + sourceCommit: commit, bundleHash: hash, + targetCanonical: sac.path, priorCLIPresent: evidence.legacyCLIPresent + ) + var ok = tx.outcome == expect + if let n = registerCalls { ok = ok && adapter.registerCount == n } + if let n = unregisterCalls { ok = ok && adapter.unregisterCount == n } + if expect == .indeterminate { ok = ok && tx.outcome != .succeeded } + check(id, "transaction \(expect.rawValue)", + ok, "got \(tx.outcome) regs=\(adapter.registerCount) unregs=\(adapter.unregisterCount)") + } + + // S01 clean install success + let a1 = FakeSMAdapter() + a1.statusResult = .success(.notRegistered) + a1.registerResult = .success(()) + // after register, tests need status to become enabled — fake returns same statusResult unless we flip after + // Use a tiny sequence adapter via mutating status after first status call — Fake always same. + // For S01, set status to enabled so after-status is enabled (before also enabled is a bit loose). + // Better: first status notRegistered, then after register return enabled. + // Extend Fake? Keep simple: set statusResult to enabled after constructing orchestrator by using + // a custom sequence. I'll set status to enabled and treat success+enabled as succeeded when no CLI. + a1.statusResult = .success(.enabled) + run("S01", intent: .beginRegistration, evidence: ev(), adapter: a1, expect: .succeeded, registerCalls: 1) + + // S02 API rejection + let a2 = FakeSMAdapter() + a2.statusResult = .success(.notRegistered) + a2.registerResult = .failure(.failedClosed("denied")) + run("S02", intent: .beginRegistration, evidence: ev(), adapter: a2, expect: .rejected, registerCalls: 1) + + // S03 unknown after success + let a3 = FakeSMAdapter() + a3.statusResult = .success(.unknown("no-enum")) + a3.registerResult = .success(()) + run("S03", intent: .beginRegistration, evidence: ev(), adapter: a3, expect: .indeterminate, registerCalls: 1) + + // S04 legacy remains after app registration → dual refuse/rollback required + let a4 = FakeSMAdapter() + a4.statusResult = .success(.enabled) + a4.registerResult = .success(()) + run("S04", intent: .beginRegistration, evidence: ev(cli: true), adapter: a4, expect: .refused, registerCalls: 1) + + // S05 interruption before registration = begin refused by halfMigrated + let a5 = FakeSMAdapter() + run("S05", intent: .beginRegistration, evidence: ev(cli: true, open: true), adapter: a5, + expect: .refused, registerCalls: 0) + + // S06 interruption after apparent registration before legacy removal: dual + let a6 = FakeSMAdapter() + run("S06", intent: .beginRegistration, evidence: ev(cli: true, app: true, enabled: true), + adapter: a6, expect: .refused, registerCalls: 0) + + // S07 rollback unregister success + let a7 = FakeSMAdapter() + a7.statusResult = .success(.notRegistered) + a7.unregisterResult = .success(()) + run("S07", intent: .rollback, evidence: ev(app: true, enabled: true), adapter: a7, + expect: .rolledBack, unregisterCalls: 1) + + // S08 rollback unregister failure + let a8 = FakeSMAdapter() + a8.statusResult = .success(.enabled) + a8.unregisterResult = .failure(.failedClosed("busy")) + run("S08", intent: .rollback, evidence: ev(app: true, enabled: true), adapter: a8, + expect: .rollbackFailed, unregisterCalls: 1) + + // S09 uninstall when app service absent + let a9 = FakeSMAdapter() + run("S09", intent: .uninstall, evidence: ev(), adapter: a9, expect: .succeeded, registerCalls: 0) + + // S10 uninstall when indeterminate + let a10 = FakeSMAdapter() + run("S10", intent: .uninstall, evidence: ev(app: true, known: false), adapter: a10, + expect: .refused, registerCalls: 0) + + // S11 foreign appears between preflight and mutation + let a11 = FakeSMAdapter() + a11.statusResult = .success(.notRegistered) + a11.registerResult = .success(()) + run("S11", intent: .beginRegistration, evidence: ev(), + atCall: ev(foreign: true), adapter: a11, expect: .refused, registerCalls: 0) + + // S12 target identity changes / becomes invalid between auth and call + let a12 = FakeSMAdapter() + a12.statusResult = .success(.notRegistered) + run("S12", intent: .beginRegistration, evidence: ev(), + atCall: ev(target: false), adapter: a12, expect: .refused, registerCalls: 0) + + // unknown status cannot be claimed success + check("S13", "indeterminate outcome is never succeeded", + a3.statusResult == .success(.unknown("no-enum"))) + } + + private func runWiringGates() { + let here = URL(fileURLWithPath: #file) + let src = here.deletingLastPathComponent() + let testFile = src.appendingPathComponent("Phase1ATests.swift") + let text = (try? String(contentsOf: testFile, encoding: .utf8)) ?? "" + let stripped = text.split(separator: "\n").map { line -> String in + let s = String(line) + if let r = s.range(of: "//") { return String(s[.. String? { + guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return nil } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + static func measureRunning(executableURL: URL, bundle: Bundle?) -> Outcome { + let name = executableURL.lastPathComponent + guard name == expectedExecutableName else { + return .refused("executable basename is not the sacrificial probe") + } + // Walk up to *.app + var app: URL? + var cursor = executableURL.deletingLastPathComponent() + for _ in 0..<6 { + if cursor.pathExtension == "app" { app = cursor; break } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { break } + cursor = parent + } + guard let appURL = app else { return .refused("executable is not inside a .app bundle") } + let info = bundle ?? Bundle(url: appURL) + let bid = info?.bundleIdentifier ?? "" + guard bid == expectedBundleID else { + return .refused("bundle identifier is not the sacrificial probe") + } + let plist = appURL.appendingPathComponent(expectedPlistRel) + guard FileManager.default.fileExists(atPath: plist.path) else { + return .refused("embedded sacrificial plist missing") + } + guard let digest = sha256File(executableURL) else { + return .refused("unable to hash running executable") + } + if digest == String(repeating: "0", count: 64) { + return .refused("executable hash is the zero placeholder") + } + return .ok(Measurement( + executableURL: executableURL, basename: name, appBundleURL: appURL, + bundleIdentifier: bid, plistURL: plist, executableSHA256: digest + )) + } +} diff --git a/src/ProductionEvidence.swift b/src/ProductionEvidence.swift new file mode 100644 index 0000000..a5b56e0 --- /dev/null +++ b/src/ProductionEvidence.swift @@ -0,0 +1,102 @@ +import Foundation + +// ============================================================================ +// Read-only production evidence. The sacrificial probe constructs this +// provider explicitly in source. It never reads DESKTIDY_* fixture env. +// Tests inject FakeAuthoritySnapshot. +// ============================================================================ + +struct FakeAuthoritySnapshot: AuthoritySnapshotProviding { + var result: AuthoritySnapResult + func snapshot(rootPath: String) -> AuthoritySnapResult { + _ = rootPath + return result + } +} + +enum ProductionAuthoritySnapshot: AuthoritySnapshotProviding { + case live + + func snapshot(rootPath: String) -> AuthoritySnapResult { + // Live home LaunchAgents only — never DESKTIDY_* fixture env. + let root = AuthorityGuard.canonicalize(rootPath) + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: root.path, isDirectory: &isDir) + if !exists { return .failed("sacrificial root does not exist") } + if !isDir.boolValue { return .failed("sacrificial root is not a directory") } + let home = FileManager.default.homeDirectoryForCurrentUser + let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + let guard_ = AuthorityGuard(agentsDir: agents, fixtureStates: nil) + switch guard_.evaluate(rootPath: root.path) { + case .ambiguous: + return .ok(AuthoritySnapshot( + foreignOverlap: false, uninspectable: true, dualDeskTidy: false, + rootCanonical: root.path)) + case .conflict(let movers): + let personal = movers.contains { MutationInterlock.personalLabels.contains($0.label) } + let foreign = movers.contains { !$0.isSelf } + return .ok(AuthoritySnapshot( + foreignOverlap: foreign || personal, uninspectable: false, dualDeskTidy: false, + rootCanonical: root.path)) + case .sole, .soleWithStale: + let (records, unread) = guard_.relevantMovers(for: root) + if !unread.isEmpty { + return .ok(AuthoritySnapshot( + foreignOverlap: false, uninspectable: true, dualDeskTidy: false, + rootCanonical: root.path)) + } + let selfCount = records.filter(\.isSelf).count + let appObserved = records.contains { $0.label.contains("sacrificial") || $0.label.contains("com.desktidy.app") } + let cli = records.contains { ProductIdentity.selfLabels.contains($0.label) } + return .ok(AuthoritySnapshot( + foreignOverlap: false, uninspectable: false, + dualDeskTidy: cli && appObserved && selfCount >= 1, + rootCanonical: root.path)) + } + } +} + +enum ProtectedInventory { + struct Loaded: Equatable { + var desktop: CanonicalPath + var protected: [CanonicalPath] + var production: CanonicalPath? + } + enum Outcome: Equatable { + case ok(Loaded) + case failed(String) + } +} + +enum ProtectedRootInventory { + static func load(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> ProtectedInventory.Outcome { + let desktop = AuthorityGuard.canonicalize(home.appendingPathComponent("Desktop").path) + let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + var protected: [CanonicalPath] = [desktop] + for label in MutationInterlock.personalLabels { + let plist = agents.appendingPathComponent("\(label).plist") + // Missing plist: that mover is not installed. Existing-but-unreadable refuses. + guard FileManager.default.fileExists(atPath: plist.path) else { continue } + guard let data = FileManager.default.contents(atPath: plist.path) else { + return .failed("unable to inspect protected label \(label)") + } + guard let obj = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + return .failed("unable to decode protected label \(label)") + } + var watched: [String] = [] + for key in ["WatchPaths", "QueueDirectories"] { + if let arr = obj[key] as? [String] { watched.append(contentsOf: arr) } + } + for w in watched { protected.append(AuthorityGuard.canonicalize(w)) } + } + let prodPlist = agents.appendingPathComponent("\(ProductIdentity.sortLabel).plist") + var production: CanonicalPath? + if let data = FileManager.default.contents(atPath: prodPlist.path), + let obj = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let env = obj["EnvironmentVariables"] as? [String: String], + let t = env["DESKTIDY_TARGET_DIR"], !t.isEmpty { + production = AuthorityGuard.canonicalize(t) + } + return .ok(ProtectedInventory.Loaded(desktop: desktop, protected: protected, production: production)) + } +} diff --git a/src/SMAdapter.swift b/src/SMAdapter.swift new file mode 100644 index 0000000..bb17902 --- /dev/null +++ b/src/SMAdapter.swift @@ -0,0 +1,79 @@ +import Foundation + +// ============================================================================ +// ServiceManagement adapter seam (Phase 1A). +// +// Automated tests MUST use FakeSMAdapter only. The production adapter lives +// in probe/ and is never selected by ambient environment. +// Construction has no registration side effect. Status is read-only. +// ============================================================================ + +enum SMAdapterStatus: Equatable { + case enabled + case requiresApproval + case notRegistered + case notFound + case unknown(String) +} + +enum SMAdapterError: Error, Equatable { + case unavailable + case failedClosed(String) +} + +protocol ServiceManagementAdapting: AnyObject { + func status(plistName: String) -> Result + func requestRegister(plistName: String) -> Result + func requestUnregister(plistName: String) -> Result +} + +final class FakeSMAdapter: ServiceManagementAdapting { + enum Call: Equatable { + case status(String) + case register(String) + case unregister(String) + } + private(set) var calls: [Call] = [] + var statusResult: Result = .success(.notRegistered) + var registerResult: Result = .success(()) + var unregisterResult: Result = .success(()) + + func status(plistName: String) -> Result { + calls.append(.status(plistName)) + return statusResult + } + func requestRegister(plistName: String) -> Result { + calls.append(.register(plistName)) + return registerResult + } + func requestUnregister(plistName: String) -> Result { + calls.append(.unregister(plistName)) + return unregisterResult + } + + var registerCount: Int { calls.filter { if case .register = $0 { return true }; return false }.count } + var unregisterCount: Int { calls.filter { if case .unregister = $0 { return true }; return false }.count } +} + +final class UnavailableSMAdapter: ServiceManagementAdapting { + func status(plistName: String) -> Result { .failure(.unavailable) } + func requestRegister(plistName: String) -> Result { .failure(.unavailable) } + func requestUnregister(plistName: String) -> Result { .failure(.unavailable) } +} + +enum ProductionMutationLedger { + static var constructions = 0 + static var registerInvocations = 0 + static var unregisterInvocations = 0 + static func reset() { + constructions = 0 + registerInvocations = 0 + unregisterInvocations = 0 + } +} + +enum SMAdapterSelection { + /// Automated tests and the CLI harness may only obtain a fake or unavailable adapter. + static func forAutomatedTests() -> FakeSMAdapter { FakeSMAdapter() } + static func unavailable() -> UnavailableSMAdapter { UnavailableSMAdapter() } +} diff --git a/src/SecureAuthFile.swift b/src/SecureAuthFile.swift new file mode 100644 index 0000000..6d3bc05 --- /dev/null +++ b/src/SecureAuthFile.swift @@ -0,0 +1,49 @@ +import Darwin +import Foundation + +// ============================================================================ +// Authorization file is a trust surface: one open, no symlink, regular file, +// current-user owner, mode no wider than 0600, small size cap. +// Bytes come from that descriptor only — never reopened by path. +// ============================================================================ + +enum SecureAuthFile { + static let maxBytes = 4096 + + enum Outcome: Equatable { + case ok(Data) + case refused(String) + } + + static func openOnce(path: String) -> Outcome { + var lst = stat() + if lstat(path, &lst) != 0 { + return .refused("authorization file unreadable") + } + if (lst.st_mode & S_IFMT) == S_IFLNK { + return .refused("authorization file is a symlink") + } + if (lst.st_mode & S_IFMT) != S_IFREG { + return .refused("authorization file is not a regular file") + } + if lst.st_uid != getuid() { + return .refused("authorization file owner mismatch") + } + if (lst.st_mode & 0o077) != 0 { + return .refused("authorization file mode wider than 0600") + } + if lst.st_size < 0 || lst.st_size > maxBytes { + return .refused("authorization file oversized") + } + let fd = open(path, O_RDONLY | O_NOFOLLOW) + if fd < 0 { + return .refused("authorization file open failed") + } + defer { close(fd) } + var buf = [UInt8](repeating: 0, count: maxBytes + 1) + let n = read(fd, &buf, buf.count) + if n < 0 { return .refused("authorization file read failed") } + if n > maxBytes { return .refused("authorization file oversized") } + return .ok(Data(buf.prefix(n))) + } +} diff --git a/src/StrictJSONObject.swift b/src/StrictJSONObject.swift new file mode 100644 index 0000000..a5250a1 --- /dev/null +++ b/src/StrictJSONObject.swift @@ -0,0 +1,156 @@ +import Foundation + +// ============================================================================ +// Strict flat JSON object parser — same uniqueness discipline as +// NativeConfigParser. Duplicate keys after escape decoding fail closed. +// Values are strings or integers only (no JSONDecoder). +// ============================================================================ + +enum StrictJSONValue: Equatable { + case string(String) + case integer(Int) +} + +enum StrictJSONObject { + enum Outcome: Equatable { + case ok([String: StrictJSONValue]) + case failed(String) + } + + static func parse(_ data: Data) -> Outcome { + guard let text = String(data: data, encoding: .utf8) else { + return .failed("not valid UTF-8") + } + var i = text.startIndex + skipWS(text, &i) + guard i < text.endIndex, text[i] == "{" else { return .failed("not a JSON object") } + text.formIndex(after: &i) + skipWS(text, &i) + var seen = Set() + var out: [String: StrictJSONValue] = [:] + if i < text.endIndex, text[i] == "}" { + text.formIndex(after: &i) + return finish(text, i, out) + } + var expectPair = true + while expectPair { + skipWS(text, &i) + guard let key = parseString(text, &i) else { return .failed("malformed key") } + if seen.contains(key) { return .failed("duplicate key") } + seen.insert(key) + skipWS(text, &i) + guard i < text.endIndex, text[i] == ":" else { return .failed("malformed object") } + text.formIndex(after: &i) + skipWS(text, &i) + if let s = parseString(text, &i) { + out[key] = .string(s) + } else if let n = parseInteger(text, &i) { + out[key] = .integer(n) + } else { + return .failed("unsupported value type") + } + skipWS(text, &i) + if i < text.endIndex, text[i] == "," { + text.formIndex(after: &i) + expectPair = true + continue + } + expectPair = false + } + skipWS(text, &i) + guard i < text.endIndex, text[i] == "}" else { return .failed("malformed object") } + text.formIndex(after: &i) + return finish(text, i, out) + } + + private static func finish(_ text: String, _ i: String.Index, _ out: [String: StrictJSONValue]) -> Outcome { + var j = i + skipWS(text, &j) + if j != text.endIndex { return .failed("trailing non-whitespace") } + return .ok(out) + } + + private static func skipWS(_ text: String, _ i: inout String.Index) { + while i < text.endIndex { + switch text[i] { + case " ", "\t", "\n", "\r": text.formIndex(after: &i) + default: return + } + } + } + + private static func parseString(_ text: String, _ i: inout String.Index) -> String? { + guard i < text.endIndex, text[i] == "\"" else { return nil } + text.formIndex(after: &i) + var out = "" + while i < text.endIndex { + let c = text[i] + if c == "\"" { text.formIndex(after: &i); return out } + if c == "\\" { + text.formIndex(after: &i) + guard i < text.endIndex else { return nil } + let e = text[i] + text.formIndex(after: &i) + switch e { + case "\"", "\\", "/": out.append(e) + case "b": out.append("\u{0008}") + case "f": out.append("\u{000C}") + case "n": out.append("\n") + case "r": out.append("\r") + case "t": out.append("\t") + case "u": + guard let scalar = parseUnicodeEscape(text, &i) else { return nil } + out.unicodeScalars.append(scalar) + default: return nil + } + continue + } + if let v = c.asciiValue, v < 0x20 { return nil } + out.append(c) + text.formIndex(after: &i) + } + return nil + } + + private static func parseUnicodeEscape(_ text: String, _ i: inout String.Index) -> Unicode.Scalar? { + guard let unit = parseHex4(text, &i) else { return nil } + if (0xD800...0xDBFF).contains(unit) { + guard i < text.endIndex, text[i] == "\\" else { return nil } + text.formIndex(after: &i) + guard i < text.endIndex, text[i] == "u" else { return nil } + text.formIndex(after: &i) + guard let low = parseHex4(text, &i), (0xDC00...0xDFFF).contains(low) else { return nil } + let combined = 0x10000 + (Int(unit) - 0xD800) * 0x400 + (Int(low) - 0xDC00) + return Unicode.Scalar(combined) + } + if (0xDC00...0xDFFF).contains(unit) { return nil } + return Unicode.Scalar(unit) + } + + private static func parseHex4(_ text: String, _ i: inout String.Index) -> UInt32? { + var n: UInt32 = 0 + for _ in 0..<4 { + guard i < text.endIndex, let v = text[i].hexDigitValue else { return nil } + n = (n << 4) + UInt32(v) + text.formIndex(after: &i) + } + return n + } + + private static func parseInteger(_ text: String, _ i: inout String.Index) -> Int? { + guard i < text.endIndex else { return nil } + let start = i + if text[i] == "-" { text.formIndex(after: &i) } + guard i < text.endIndex, text[i].isASCII && text[i].isNumber else { i = start; return nil } + if text[i] == "0" { + text.formIndex(after: &i) + } else { + while i < text.endIndex, text[i].isASCII && text[i].isNumber { text.formIndex(after: &i) } + } + if i < text.endIndex, text[i] == "." || text[i] == "e" || text[i] == "E" { + i = start + return nil + } + return Int(text[start..