From 216e0eb2b1bc02511c20fdd8ee717a72def3cb3e Mon Sep 17 00:00:00 2001 From: dbuskariol <32349796+dbuskariol@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:53:18 +1000 Subject: [PATCH] Gate Time Machine helper release readiness --- Delta.xcodeproj/project.pbxproj | 12 +- Documentation/ARCHITECTURE.md | 2 + Documentation/RELEASE_NOTES.md | 1 + Documentation/RELEASING.md | 11 + Documentation/VERIFICATION_REPORT.md | 7 +- Scripts/collect-release-evidence.sh | 20 ++ Scripts/doctor-production-readiness.sh | 15 ++ ...-time-machine-system-support-acceptance.sh | 196 ++++++++++++++++++ Scripts/run-local-acceptance-probe.sh | 13 +- Scripts/verify-ci-workflows.sh | 16 ++ Scripts/verify-ci.sh | 8 + Scripts/verify-production-readiness.sh | 5 + ...fy-time-machine-system-support-evidence.sh | 80 +++++++ Sources/Delta/DeltaApp.swift | 138 ++++++++++++ Sources/Delta/DeltaAppModel.swift | 6 +- .../DeltaCore/TimeMachineSetupProtocol.swift | 23 ++ .../TimeMachineSystemControllerTests.swift | 13 ++ docs/PRODUCTION_READINESS.md | 3 + 18 files changed, 553 insertions(+), 16 deletions(-) create mode 100755 Scripts/run-installed-time-machine-system-support-acceptance.sh create mode 100755 Scripts/verify-time-machine-system-support-evidence.sh diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 54465b1..c730294 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -992,7 +992,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Packaging/Delta.entitlements; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Packaging/Delta.app.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; @@ -1010,7 +1010,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Packaging/Delta.entitlements; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Packaging/Delta.app.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; @@ -1122,7 +1122,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = "$(DELTA_FSKIT_CODE_SIGN_ENTITLEMENTS)"; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DELTA_FSKIT_CODE_SIGN_ENTITLEMENTS = Packaging/DeltaTimeMachineFS.entitlements; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Packaging/DeltaTimeMachineFS.Info.plist; @@ -1238,7 +1238,7 @@ buildSettings = { COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Packaging/DeltaTimeMachineHelper.Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1274,7 +1274,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = "$(DELTA_FSKIT_CODE_SIGN_ENTITLEMENTS)"; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DELTA_FSKIT_CODE_SIGN_ENTITLEMENTS = Packaging/DeltaTimeMachineFS.entitlements; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Packaging/DeltaTimeMachineFS.Info.plist; @@ -1309,7 +1309,7 @@ buildSettings = { COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Packaging/DeltaTimeMachineHelper.Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Documentation/ARCHITECTURE.md b/Documentation/ARCHITECTURE.md index 5f22c87..9711a15 100644 --- a/Documentation/ARCHITECTURE.md +++ b/Documentation/ARCHITECTURE.md @@ -25,6 +25,8 @@ Scheduled-service registration is gated by a fingerprint of the bundled agent an Time Machine system-support registration is likewise bound to a streaming fingerprint of the normalized installed bundle path, user service, privileged helper, property lists, and FSKit extension. Production registration and privileged mutations require the exact `/Applications/Delta.app` installation, so a renamed acceptance copy, DerivedData product, `dist` artifact, or worktree build cannot replace the production Background Items records. Ordinary disconnect remains available from a noncanonical copy so cleanup cannot be stranded. The path witness makes a relocated app detectable even when every embedded byte is unchanged and macOS still retains the earlier bundle location. An idle installed-app update performs the public replacement once, then proves that the exact new helper can launch before the installed fingerprint becomes current. If Service Management still points at an older or moved app, reports an enabled item that cannot run, or launches a stale helper, the proof fails closed and the explicit **Set Up** action re-registers the affected helper and user service through the same public lifecycle. A component newly registered during that request is not torn down merely because its sibling was stale, avoiding a second privileged approval cycle. macOS remains authoritative and may request renewed Login Items approval. Delta never edits launchd or approval databases directly. +Release acceptance uses that same production verifier rather than a test-only launch. On a clean acceptance host, both Time Machine services must begin unregistered, the exact notarized candidate must be installed at `/Applications/Delta.app`, an administrator must approve the launch daemon through System Settings as Apple's `SMAppService` contract requires, and both public registrations must become enabled. Delta then opens the helper's production Mach service and accepts readiness only when the running process returns the Security code hash of the helper embedded in that exact app. The evidence is bound to source commit, version, build, app CDHash, helper CDHash, canonical path, clean initial state, and supported unregister cleanup; production publishing rejects missing, stale, or mismatched evidence. Headless automation cannot substitute direct execution, `launchctl`, private Background Task Management changes, or a synthetic approval for that native administrator boundary. + ## Data flow 1. A native file picker records security-scoped bookmarks for selected sources, destinations, and restore targets. diff --git a/Documentation/RELEASE_NOTES.md b/Documentation/RELEASE_NOTES.md index 3d2fbc6..b05c277 100644 --- a/Documentation/RELEASE_NOTES.md +++ b/Documentation/RELEASE_NOTES.md @@ -28,6 +28,7 @@ Delta 0.4.0 adds native Time Machine-format destinations: - Requires a short authenticated readiness response from the exact embedded privileged helper before recording system support as current or mounting a disk, so an enabled-but-unlaunchable macOS background item fails before remote writes and routes to native Set Up recovery instead of consuming the twelve-minute mutation deadline. - Keeps a partial FSKit/APFS mount in an explicit cleanup-only state: storage telemetry can no longer erase a system-connection failure, and no UI or command surface offers Back Up Now until macOS returns the exact Time Machine destination identity. - Places the privileged setup daemon in Apple's current `SMAppService` executable layout and rejects the obsolete `SMJobBless` helper location, preventing update-time launch constraints from being bound to a legacy bundle structure. +- Adds a fail-closed clean-install release gate for the exact notarized app: after native administrator approval, Delta must launch and authenticate the embedded privileged helper by Security code hash, and candidate-bound evidence must match the app, helper, source commit, immutable build, and canonical `/Applications/Delta.app` path before publishing. This release also restores live Activity output following: diff --git a/Documentation/RELEASING.md b/Documentation/RELEASING.md index 6081f04..527919d 100644 --- a/Documentation/RELEASING.md +++ b/Documentation/RELEASING.md @@ -50,10 +50,21 @@ The Keychain profile name is not product metadata; the existing `Reccy Notary` p Finalization writes public candidates under `dist/updates`, private notarization evidence under `dist/notarization`, and private dSYMs under `dist/symbols`. +Before publishing, install the exact finalized candidate and prove its privileged Time Machine support on a clean acceptance host: + +```sh +Scripts/install-app.sh dist/Delta.app +Scripts/run-installed-time-machine-system-support-acceptance.sh /Applications/Delta.app +``` + +The host must begin with both Delta Time Machine Service Management items unregistered. Apple's public `SMAppService` contract requires an administrator to approve a launch daemon in System Settings before it is eligible to run, so this is deliberately an interactive installed-candidate gate rather than a headless CI simulation. The script waits for the native approval, requires both items to become enabled, authenticates the running helper against the exact embedded helper code hash, unregisters both items through public APIs, and writes candidate-bound evidence under `dist/time-machine-system-support`. It never runs a helper directly, edits Background Task Management or launchd state, or accepts a renamed, `dist`, DerivedData, archive, or worktree app. + ## Publish `Scripts/publish-release.sh` performs the publishing transaction. Before contacting GitHub it re-runs the history/security audit, verifies the complete artifact graph, and requires `Scripts/verify-production-readiness.sh` to pass for the exact installed candidate, current manual acceptance report, and genuine external-backend evidence. It then creates a draft with the six intended public assets, downloads those bytes into a new temporary directory, repeats the full signature/notarization/Gatekeeper/ZIP/DMG/provenance/Sparkle verification, and only then makes the release public and latest. +Production readiness also verifies the clean-install Time Machine system-support report against the exact installed app, source commit, immutable build identity, app CDHash, helper CDHash, enabled registration states, authenticated readiness result, and supported cleanup state. A report from a different candidate or a development machine with retained registration state cannot satisfy the gate. + One marketing-version/build-number pair identifies one immutable signed app. `Scripts/install-app.sh` refuses to replace an installed Delta app with different signed bytes carrying the same `CFBundleShortVersionString` and `CFBundleVersion`; advance `CURRENT_PROJECT_VERSION` before building a replacement. This prevents local build/test churn from presenting macOS Service Management with two helper executables under one release identity and makes installed acceptance match the update topology users receive through Sparkle. ## GitHub Actions diff --git a/Documentation/VERIFICATION_REPORT.md b/Documentation/VERIFICATION_REPORT.md index ba279a7..0be9813 100644 --- a/Documentation/VERIFICATION_REPORT.md +++ b/Documentation/VERIFICATION_REPORT.md @@ -4,13 +4,13 @@ Date: 19 July 2026 Host: macOS 26.5.2 (25F84) -Candidate: Delta 0.4.0 (12) +Candidate: Delta 0.4.0 (13) Status: exact-candidate verification contract ## Outcome -Delta's source, deterministic acceptance harnesses, Developer ID build, stable installer, notarization flow, Sparkle packaging, privacy controls, and production-readiness checks are covered by the release contract below. The source identifies the next candidate as `0.4.0` (12). +Delta's source, deterministic acceptance harnesses, Developer ID build, stable installer, notarization flow, Sparkle packaging, privacy controls, and production-readiness checks are covered by the release contract below. The source identifies the next candidate as `0.4.0` (13). A release must not be merged, tagged, or published until the complete exact-commit gate, separate app and DMG notarization, stapling, Gatekeeper checks, signed-update installation, manual acceptance matrix, and required genuine external-provider evidence have all passed for this version. Generated evidence under `dist/` is authoritative for the commit, installed path, CDHash, notarization IDs, artifact hashes, command results, and acceptance status; this tracked report deliberately does not duplicate volatile identity values or claim that an unverified source commit has passed. @@ -34,6 +34,7 @@ A release must not be merged, tagged, or published until the complete exact-comm - Kept scheduled notification submission inside the short-lived agent lifetime until macOS acknowledges the request, with bounded timeout and failure evidence. - Removed Intel-sensitive `pipefail` handling from bundled-tool validation and made the crash-marker scan portable to clean macOS runners without ripgrep. - Added Delta-managed Time Machine-format destinations backed by a provisioned FSKit extension, bounded local cache, authenticated remote generations, verified recovery-key retention, safe reconnect and removal, and the existing native Permissions guidance. +- Added a clean first-registration Time Machine system-support acceptance gate that uses the production Service Management and authenticated XPC path, binds evidence to the exact notarized installed candidate and helper code hashes, and prevents publishing from treating retained development registration state or a transient build location as release proof. ## Automated verification contract @@ -68,7 +69,7 @@ Finalization requires separate accepted Apple submissions for the app archive an ## Required release handoff -1. Build, install, and complete all exact-commit automated, signing, Apple notarization, stapling, Gatekeeper, and evidence checks for `0.4.0` (12). +1. Build, install, and complete all exact-commit automated, signing, Apple notarization, stapling, Gatekeeper, and evidence checks for `0.4.0` (13). 2. Complete every row of the exact-candidate manual acceptance matrix, including a real signed Sparkle upgrade and system-delivered notifications. 3. Provide and exercise genuine external mounted SMB or NFS, non-local SFTP, and non-local S3-compatible fixtures, plus any additional backend families intended for this release. 4. Obtain a fully passing production-readiness result before merging, tagging `v0.4.0`, or publishing. diff --git a/Scripts/collect-release-evidence.sh b/Scripts/collect-release-evidence.sh index 833e8c9..e7c8c5c 100755 --- a/Scripts/collect-release-evidence.sh +++ b/Scripts/collect-release-evidence.sh @@ -26,6 +26,7 @@ LATEST="$OUTPUT_DIR/latest.md" INFO_PLIST="$APP_PATH/Contents/Info.plist" GATE_STATUS_FILE="$ROOT_DIR/dist/release-evidence/automated-gate-status" MANUAL_ACCEPTANCE_REPORT="${DELTA_MANUAL_ACCEPTANCE_REPORT:-$ROOT_DIR/dist/manual-acceptance/latest.md}" +TIME_MACHINE_SYSTEM_EVIDENCE="${DELTA_TIME_MACHINE_SYSTEM_ACCEPTANCE_EVIDENCE:-$ROOT_DIR/dist/time-machine-system-support/latest.txt}" plist_value() { local key="$1" @@ -141,6 +142,14 @@ if [[ -n "$IDENTITY_ACCEPTANCE_APP_PATH" ]]; then append_command "Service Management Lifecycle Acceptance" "$ROOT_DIR/Scripts/run-installed-service-management-acceptance.sh" "$IDENTITY_ACCEPTANCE_APP_PATH" append_command "Installed Preferences Acceptance" "$ROOT_DIR/Scripts/run-installed-preferences-acceptance.sh" "$IDENTITY_ACCEPTANCE_APP_PATH" append_command "Installed App Smoke Verification" "$ROOT_DIR/Scripts/verify-installed-app.sh" "$INSTALLED_APP_PATH" + append_command "Time Machine System Support Acceptance Verification" \ + "$ROOT_DIR/Scripts/verify-time-machine-system-support-evidence.sh" \ + "$IDENTITY_ACCEPTANCE_APP_PATH" \ + "$TIME_MACHINE_SYSTEM_EVIDENCE" + if [[ -e "$TIME_MACHINE_SYSTEM_EVIDENCE" ]]; then + append_command "Time Machine System Support Acceptance Evidence" \ + /bin/cat "$TIME_MACHINE_SYSTEM_EVIDENCE" + fi EXTERNAL_ACCEPTANCE_APP_PATH="$INSTALLED_APP_PATH" else append_command "Scheduled Backups Acceptance" missing_installed_identity @@ -244,12 +253,22 @@ fi } >>"$OUTPUT" /bin/rm -f "$EXTERNAL_ACCEPTANCE_OUTPUT" +TIME_MACHINE_SYSTEM_ACCEPTANCE_PASSED="No" +if [[ -n "$IDENTITY_ACCEPTANCE_APP_PATH" ]] \ + && "$ROOT_DIR/Scripts/verify-time-machine-system-support-evidence.sh" \ + "$IDENTITY_ACCEPTANCE_APP_PATH" \ + "$TIME_MACHINE_SYSTEM_EVIDENCE" >/dev/null 2>&1 +then + TIME_MACHINE_SYSTEM_ACCEPTANCE_PASSED="Yes" +fi + READY_FOR_EXTERNAL_DISTRIBUTION="No" if [[ "$AUTOMATED_GATE_STATUS" == "Passed" \ && "$AUTOMATED_GATE_APP_HASH_MATCH" == "Yes" \ && "$MANUAL_MATRIX_PASSED" == "Yes" \ && "$MANUAL_REPORT_CURRENT" == "Yes" \ && "$EXTERNAL_ACCEPTANCE_PASSED" == "Yes" \ + && "$TIME_MACHINE_SYSTEM_ACCEPTANCE_PASSED" == "Yes" \ && "$NOTARIZATION_COMPLETE" == "Yes" ]] then READY_FOR_EXTERNAL_DISTRIBUTION="Yes" @@ -269,6 +288,7 @@ Manual matrix passed: $MANUAL_MATRIX_PASSED - Manual report matches git commit: $MANUAL_REPORT_CURRENT - Manual matrix passed: $MANUAL_MATRIX_PASSED - Real external backend acceptance passed: $EXTERNAL_ACCEPTANCE_PASSED +- Clean-install Time Machine system support passed: $TIME_MACHINE_SYSTEM_ACCEPTANCE_PASSED - Developer ID notarization complete: $NOTARIZATION_COMPLETE - Ready for external distribution: $READY_FOR_EXTERNAL_DISTRIBUTION EOF diff --git a/Scripts/doctor-production-readiness.sh b/Scripts/doctor-production-readiness.sh index 62dffd7..5be9dff 100755 --- a/Scripts/doctor-production-readiness.sh +++ b/Scripts/doctor-production-readiness.sh @@ -11,6 +11,7 @@ MANUAL_REPORT="${DELTA_DOCTOR_MANUAL_ACCEPTANCE_REPORT:-$ROOT_DIR/dist/manual-ac RELEASE_EVIDENCE_REPORT="${DELTA_DOCTOR_RELEASE_EVIDENCE_REPORT:-$ROOT_DIR/dist/release-evidence/latest.md}" GATE_STATUS_FILE="$ROOT_DIR/dist/release-evidence/automated-gate-status" NOTARY_OUTPUT_DIR="${DELTA_NOTARY_OUTPUT_DIR:-$ROOT_DIR/dist/notarization}" +TIME_MACHINE_SYSTEM_EVIDENCE="${DELTA_DOCTOR_TIME_MACHINE_SYSTEM_EVIDENCE:-$ROOT_DIR/dist/time-machine-system-support/latest.txt}" blockers=0 warnings=0 @@ -99,6 +100,7 @@ print_next_actions() { DELTA_NOTARY_KEYCHAIN_PROFILE="Reccy Notary" Scripts/release.sh finalize 3. Install the notarized app and refresh release evidence: Scripts/install-app.sh dist/Delta.app + Scripts/run-installed-time-machine-system-support-acceptance.sh /Applications/Delta.app Scripts/collect-release-evidence.sh dist/Delta.app 4. Complete the manual acceptance matrix for the current commit: Scripts/create-manual-acceptance-report.sh @@ -259,6 +261,19 @@ else fi printf "\n## Acceptance Evidence\n\n" +TIME_MACHINE_SYSTEM_DOCTOR_OUTPUT="$(/usr/bin/mktemp -t delta-time-machine-system-evidence-doctor.XXXXXX)" +if [[ -d "$INSTALLED_APP_PATH" ]] \ + && "$ROOT_DIR/Scripts/verify-time-machine-system-support-evidence.sh" \ + "$INSTALLED_APP_PATH" \ + "$TIME_MACHINE_SYSTEM_EVIDENCE" >"$TIME_MACHINE_SYSTEM_DOCTOR_OUTPUT" 2>&1 +then + pass "$(/bin/cat "$TIME_MACHINE_SYSTEM_DOCTOR_OUTPUT")" +else + time_machine_system_output="$(/bin/cat "$TIME_MACHINE_SYSTEM_DOCTOR_OUTPUT" 2>/dev/null || true)" + block "Clean-install authenticated Time Machine system-support evidence is incomplete. ${time_machine_system_output}" +fi +/bin/rm -f "$TIME_MACHINE_SYSTEM_DOCTOR_OUTPUT" + if [[ -f "$RELEASE_EVIDENCE_REPORT" || -L "$RELEASE_EVIDENCE_REPORT" ]]; then release_commit="$(release_evidence_value "Git Commit")" release_ready="$(release_evidence_value "Ready for external distribution")" diff --git a/Scripts/run-installed-time-machine-system-support-acceptance.sh b/Scripts/run-installed-time-machine-system-support-acceptance.sh new file mode 100755 index 0000000..560201a --- /dev/null +++ b/Scripts/run-installed-time-machine-system-support-acceptance.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=Scripts/lib/delta-release.sh +source "$ROOT_DIR/Scripts/lib/delta-release.sh" + +APP_PATH="${1:-/Applications/Delta.app}" +OUTPUT_DIR="${DELTA_TIME_MACHINE_SYSTEM_ACCEPTANCE_DIR:-$ROOT_DIR/dist/time-machine-system-support}" +APPROVAL_TIMEOUT_SECONDS="${DELTA_TIME_MACHINE_APPROVAL_TIMEOUT_SECONDS:-600}" +POLL_INTERVAL_SECONDS=2 + +case "$APPROVAL_TIMEOUT_SECONDS" in + ''|*[!0-9]*) + delta_fail 'DELTA_TIME_MACHINE_APPROVAL_TIMEOUT_SECONDS must be a nonnegative integer' + ;; +esac + +[[ -d "$APP_PATH" ]] || delta_fail "installed app not found: $APP_PATH" +APP_PATH="$(cd "$APP_PATH" && pwd -P)" +[[ "$APP_PATH" == "/Applications/Delta.app" ]] \ + || delta_fail "Time Machine system-support acceptance requires the exact canonical app at /Applications/Delta.app, not $APP_PATH" + +IFS=$'\t' read -r VERSION BUILD < <(delta_assert_release_metadata "$ROOT_DIR") +export DELTA_EXPECTED_RELEASE_VERSION="$VERSION" +export DELTA_EXPECTED_RELEASE_BUILD="$BUILD" +delta_assert_release_app "$APP_PATH" "$DELTA_EXPECTED_TEAM_ID" +delta_assert_notarized_app "$APP_PATH" + +DELTA_EXECUTABLE="$APP_PATH/Contents/MacOS/Delta" +HELPER_EXECUTABLE="$APP_PATH/Contents/MacOS/DeltaTimeMachineHelper" +INFO_PLIST="$APP_PATH/Contents/Info.plist" +[[ -x "$DELTA_EXECUTABLE" ]] || delta_fail "Delta executable is missing: $DELTA_EXECUTABLE" +[[ -x "$HELPER_EXECUTABLE" ]] || delta_fail "Time Machine setup helper is missing: $HELPER_EXECUTABLE" + +support_action() { + DELTA_ENABLE_TIME_MACHINE_SYSTEM_ACCEPTANCE=1 \ + "$DELTA_EXECUTABLE" --acceptance-time-machine-system-support "$1" +} + +status_value() { + local output="$1" + local label="$2" + /usr/bin/awk -F': ' -v label="$label" \ + '$1 == label && value == "" { value = $2 } END { print value }' \ + <<<"$output" +} + +read_statuses() { + local output + output="$(support_action status)" + CURRENT_APP_INSTALLATION="$(status_value "$output" 'Time Machine app installation')" + CURRENT_SERVICE_STATUS="$(status_value "$output" 'Time Machine storage service status')" + CURRENT_HELPER_STATUS="$(status_value "$output" 'Time Machine setup helper status')" + CURRENT_HELPER_EXECUTABLE="$(status_value "$output" 'Time Machine setup helper executable')" + CURRENT_HELPER_CODE_HASH="$(status_value "$output" 'Time Machine setup helper code hash')" +} + +registration_started=0 +cleanup_complete=0 + +cleanup_registrations() { + local attempt + if [[ "$registration_started" != "1" || "$cleanup_complete" == "1" ]]; then + return + fi + support_action unregister >/dev/null 2>&1 || true + for attempt in {1..15}; do + read_statuses 2>/dev/null || true + if [[ "${CURRENT_SERVICE_STATUS:-}" == "notRegistered" \ + && "${CURRENT_HELPER_STATUS:-}" == "notRegistered" ]]; then + cleanup_complete=1 + return + fi + /bin/sleep 1 + done +} + +handle_signal() { + local status="$1" + trap - EXIT INT TERM + cleanup_registrations + exit "$status" +} +trap cleanup_registrations EXIT +trap 'handle_signal 130' INT +trap 'handle_signal 143' TERM + +read_statuses +[[ "$CURRENT_APP_INSTALLATION" == "canonical" ]] \ + || delta_fail "installed app was not reported as canonical: $CURRENT_APP_INSTALLATION" +[[ "$CURRENT_HELPER_EXECUTABLE" == "present" ]] \ + || delta_fail "installed helper was not executable: $CURRENT_HELPER_EXECUTABLE" +[[ "$CURRENT_SERVICE_STATUS" == "notRegistered" \ + && "$CURRENT_HELPER_STATUS" == "notRegistered" ]] \ + || delta_fail "clean first-registration acceptance requires both Time Machine services to begin notRegistered; storage=$CURRENT_SERVICE_STATUS helper=$CURRENT_HELPER_STATUS" + +INITIAL_SERVICE_STATUS="$CURRENT_SERVICE_STATUS" +INITIAL_HELPER_STATUS="$CURRENT_HELPER_STATUS" +registration_started=1 +support_action register >/dev/null + +deadline=$((SECONDS + APPROVAL_TIMEOUT_SECONDS)) +approval_message_printed=0 +while true; do + read_statuses + if [[ "$CURRENT_SERVICE_STATUS" == "enabled" \ + && "$CURRENT_HELPER_STATUS" == "enabled" ]]; then + break + fi + case "$CURRENT_SERVICE_STATUS:$CURRENT_HELPER_STATUS" in + enabled:requiresApproval|requiresApproval:enabled|requiresApproval:requiresApproval) + if [[ "$approval_message_printed" == "0" ]]; then + printf '%s\n' \ + 'macOS registered Delta system support and is waiting for administrator approval in System Settings > General > Login Items & Extensions.' + approval_message_printed=1 + fi + ;; + *) + delta_fail "Time Machine registration entered an invalid state; storage=$CURRENT_SERVICE_STATUS helper=$CURRENT_HELPER_STATUS" + ;; + esac + if (( SECONDS >= deadline )); then + delta_fail "administrator approval did not reach enabled state within ${APPROVAL_TIMEOUT_SECONDS}s; storage=$CURRENT_SERVICE_STATUS helper=$CURRENT_HELPER_STATUS" + fi + /bin/sleep "$POLL_INTERVAL_SECONDS" +done + +REGISTERED_SERVICE_STATUS="$CURRENT_SERVICE_STATUS" +REGISTERED_HELPER_STATUS="$CURRENT_HELPER_STATUS" +VERIFY_OUTPUT="$(support_action verify)" +RUNTIME_READINESS="$(status_value "$VERIFY_OUTPUT" 'Time Machine setup helper readiness')" +RUNTIME_HELPER_CODE_HASH="$(status_value "$VERIFY_OUTPUT" 'Time Machine setup helper code hash')" +[[ "$RUNTIME_READINESS" == "verified" ]] \ + || delta_fail "authenticated setup-helper readiness was not verified: $RUNTIME_READINESS" + +SIGNED_HELPER_CDHASH="$(delta_signature_cdhash "$HELPER_EXECUTABLE")" +[[ -n "$SIGNED_HELPER_CDHASH" && -n "$RUNTIME_HELPER_CODE_HASH" ]] \ + || delta_fail 'the installed or running helper code hash was unavailable' +SIGNED_HELPER_CDHASH_LOWER="$(/usr/bin/tr '[:upper:]' '[:lower:]' <<<"$SIGNED_HELPER_CDHASH")" +RUNTIME_HELPER_CODE_HASH_LOWER="$(/usr/bin/tr '[:upper:]' '[:lower:]' <<<"$RUNTIME_HELPER_CODE_HASH")" +[[ "$SIGNED_HELPER_CDHASH_LOWER" == "$RUNTIME_HELPER_CODE_HASH_LOWER" ]] \ + || delta_fail "the authenticated running helper hash does not match the exact embedded helper; signed=$SIGNED_HELPER_CDHASH running=$RUNTIME_HELPER_CODE_HASH" + +support_action unregister >/dev/null +for _attempt in {1..15}; do + read_statuses + if [[ "$CURRENT_SERVICE_STATUS" == "notRegistered" \ + && "$CURRENT_HELPER_STATUS" == "notRegistered" ]]; then + cleanup_complete=1 + break + fi + /bin/sleep 1 +done +[[ "$cleanup_complete" == "1" ]] \ + || delta_fail "public Service Management cleanup did not complete; storage=$CURRENT_SERVICE_STATUS helper=$CURRENT_HELPER_STATUS" + +APP_CDHASH="$(delta_signature_cdhash "$APP_PATH")" +GIT_COMMIT="$(/usr/bin/git -C "$ROOT_DIR" rev-parse --short HEAD)" +TIMESTAMP="$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ)" +/bin/mkdir -p "$OUTPUT_DIR" +OUTPUT="$OUTPUT_DIR/Delta-time-machine-system-support-${TIMESTAMP//[:]/}.txt" +TEMPORARY="$(/usr/bin/mktemp "$OUTPUT_DIR/.time-machine-system-support.XXXXXX")" +{ + printf 'status=Passed\n' + printf 'recorded_at=%s\n' "$TIMESTAMP" + printf 'git_commit=%s\n' "$GIT_COMMIT" + printf 'app_path=%s\n' "$APP_PATH" + printf 'bundle_id=%s\n' "$(delta_plist_value CFBundleIdentifier "$INFO_PLIST")" + printf 'version=%s\n' "$VERSION" + printf 'build=%s\n' "$BUILD" + printf 'host_macos=%s\n' "$(/usr/bin/sw_vers -productVersion)" + printf 'host_build=%s\n' "$(/usr/bin/sw_vers -buildVersion)" + printf 'team_id=%s\n' "$(delta_signature_team "$APP_PATH")" + printf 'app_cdhash=%s\n' "$APP_CDHASH" + printf 'helper_cdhash=%s\n' "$SIGNED_HELPER_CDHASH" + printf 'runtime_helper_code_hash=%s\n' "$RUNTIME_HELPER_CODE_HASH" + printf 'initial_storage_service_status=%s\n' "$INITIAL_SERVICE_STATUS" + printf 'initial_setup_helper_status=%s\n' "$INITIAL_HELPER_STATUS" + printf 'registered_storage_service_status=%s\n' "$REGISTERED_SERVICE_STATUS" + printf 'registered_setup_helper_status=%s\n' "$REGISTERED_HELPER_STATUS" + printf 'authenticated_helper_readiness=%s\n' "$RUNTIME_READINESS" + printf 'cleanup_storage_service_status=%s\n' "$CURRENT_SERVICE_STATUS" + printf 'cleanup_setup_helper_status=%s\n' "$CURRENT_HELPER_STATUS" +} >"$TEMPORARY" +/bin/mv -f "$TEMPORARY" "$OUTPUT" +LATEST_TEMP="$OUTPUT_DIR/.latest.$$" +/bin/ln -s "$(basename "$OUTPUT")" "$LATEST_TEMP" +/bin/mv -f "$LATEST_TEMP" "$OUTPUT_DIR/latest.txt" +trap - EXIT INT TERM + +printf 'Installed Time Machine system-support acceptance passed.\n' +printf -- '- App: %s\n' "$APP_PATH" +printf -- '- Candidate: %s (%s), commit %s, CDHash %s\n' "$VERSION" "$BUILD" "$GIT_COMMIT" "$APP_CDHASH" +printf -- '- Authenticated helper CDHash: %s\n' "$SIGNED_HELPER_CDHASH" +printf -- '- Evidence: %s\n' "$OUTPUT" diff --git a/Scripts/run-local-acceptance-probe.sh b/Scripts/run-local-acceptance-probe.sh index dbf9573..67db595 100755 --- a/Scripts/run-local-acceptance-probe.sh +++ b/Scripts/run-local-acceptance-probe.sh @@ -8,6 +8,7 @@ source "$ROOT_DIR/Scripts/manual-acceptance-items.sh" APP_PATH="${1:-${DELTA_ACCEPTANCE_APP:-/Applications/Delta.app}}" OUTPUT_DIR="${DELTA_LOCAL_ACCEPTANCE_DIR:-$ROOT_DIR/dist/local-acceptance}" GATE_STATUS_FILE="$ROOT_DIR/dist/release-evidence/automated-gate-status" +TIME_MACHINE_SYSTEM_EVIDENCE="${DELTA_TIME_MACHINE_SYSTEM_ACCEPTANCE_EVIDENCE:-$ROOT_DIR/dist/time-machine-system-support/latest.txt}" mkdir -p "$OUTPUT_DIR" @@ -558,13 +559,21 @@ else else append_row "remote_first_backup_preparation" "$(item_area remote_first_backup_preparation)" "Manual Required" "Remote destination preparation needs a real unprepared remote and an existing remote to avoid false confidence. Configure external backend acceptance to automate the backend lifecycle." "Start backup on new unprepared remote and existing remote; confirm init happens only when needed." fi + time_machine_system_output="$(run_capture time_machine_system_support \ + "$ROOT_DIR/Scripts/verify-time-machine-system-support-evidence.sh" \ + "$APP_PATH" \ + "$TIME_MACHINE_SYSTEM_EVIDENCE")" + time_machine_system_status="$(command_status time_machine_system_support)" if [[ "$automated_gate_status" == "Passed" && "$automated_gate_commit" == "$git_commit" ]] \ + && [[ "$time_machine_system_status" -eq 0 ]] \ && grep -Fq "TimeMachineObjectStoreTests" "$ROOT_DIR/Tests/DeltaCoreTests/TimeMachineObjectStoreTests.swift" \ && grep -Fq "TimeMachineSystemControllerTests" "$ROOT_DIR/Tests/DeltaCoreTests/TimeMachineSystemControllerTests.swift" then - append_row "time_machine_format" "$(item_area time_machine_format)" "Partial" "The automated gate passed the certificate-free Time Machine object, cache, lease, recovery, transport, IPC, controller, packaging, and real local-rclone regression suites for commit $git_commit. It did not activate FSKit, complete a macOS Time Machine backup, restore user data, or exercise a genuine remote provider." "Install the exact stable-signed candidate and complete the full permission, mount, backup, restore, fault, resource, reconnect, update, accessibility, and genuine-provider matrix." + append_row "time_machine_format" "$(item_area time_machine_format)" "Partial" "The automated gate passed the certificate-free Time Machine object, cache, lease, recovery, transport, IPC, controller, packaging, and real local-rclone regression suites for commit $git_commit. Clean first-registration acceptance also proved that the exact notarized /Applications candidate reached enabled public Service Management state and returned its authenticated embedded-helper code hash before registrations were cleaned up. $time_machine_system_output It did not complete a macOS Time Machine backup, restore user data, or exercise a genuine remote provider." "Complete the full permission, FSKit mount, backup, restore, fault, resource, reconnect, update, accessibility, and genuine-provider matrix with the exact stable-signed candidate." + elif [[ -e "$TIME_MACHINE_SYSTEM_EVIDENCE" ]]; then + append_row "time_machine_format" "$(item_area time_machine_format)" "Failed" "Time Machine system-support evidence exists but does not verify against this exact installed candidate: $time_machine_system_output" "Repeat clean first-registration acceptance with the exact notarized candidate, then complete the full Time Machine lifecycle matrix." else - append_row "time_machine_format" "$(item_area time_machine_format)" "Manual Required" "Certificate-free tests cannot activate the provisioned FSKit extension or prove a completed macOS Time Machine backup and restore." "Install the exact stable-signed candidate and complete the full permission, mount, backup, restore, fault, resource, reconnect, update, accessibility, and genuine-provider matrix." + append_row "time_machine_format" "$(item_area time_machine_format)" "Manual Required" "Certificate-free tests cannot prove a modern privileged helper launch because Apple requires administrator approval before an SMAppService launch daemon is eligible to run. Clean first-registration authenticated helper evidence and a completed macOS Time Machine backup and restore remain required." "On a clean acceptance host, run Scripts/run-installed-time-machine-system-support-acceptance.sh against the exact notarized /Applications/Delta.app, approve Login Items in System Settings, then complete the full permission, FSKit mount, backup, restore, fault, resource, reconnect, update, accessibility, and genuine-provider matrix." fi if [[ "$installed_menu_bar_status" -eq 0 ]] then diff --git a/Scripts/verify-ci-workflows.sh b/Scripts/verify-ci-workflows.sh index 14c6c51..47c9da5 100755 --- a/Scripts/verify-ci-workflows.sh +++ b/Scripts/verify-ci-workflows.sh @@ -61,6 +61,8 @@ for script in \ create-release-manifest.sh generate-appcast.sh notarize-release.sh \ package-update.sh publish-release.sh release.sh verify-release-assets.sh \ run-installed-service-management-acceptance.sh \ + run-installed-time-machine-system-support-acceptance.sh \ + verify-time-machine-system-support-evidence.sh \ verify-release-candidate.sh verify-production-readiness.sh \ verify-sparkle-update.sh do @@ -83,6 +85,20 @@ if ! /usr/bin/grep -Fq 'Service Management acceptance requires an app installed printf "Service Management acceptance is missing its installed-identity guard.\n" >&2 exit 1 fi +if ! /usr/bin/grep -Fq 'requires the exact canonical app at /Applications/Delta.app' \ + "$ROOT_DIR/Scripts/run-installed-time-machine-system-support-acceptance.sh" \ + || ! /usr/bin/grep -Fq 'TimeMachineSetupHelperRuntimeVerifier.verify' \ + "$ROOT_DIR/Sources/Delta/DeltaApp.swift" \ + || ! /usr/bin/grep -Fq 'verify-time-machine-system-support-evidence.sh' \ + "$ROOT_DIR/Scripts/verify-production-readiness.sh"; then + printf "Time Machine system-support release acceptance is missing its canonical-path, authenticated-helper, or publishing gate.\n" >&2 + exit 1 +fi +if /usr/bin/grep -Fq 'run-installed-time-machine-system-support-acceptance.sh' \ + "$RELEASE_WORKFLOW"; then + printf "Headless release automation must not pretend it can grant administrator approval to a Time Machine launch daemon.\n" >&2 + exit 1 +fi if ! /usr/bin/grep -Fq 'run-local-acceptance-probe.sh" "$IDENTITY_ACCEPTANCE_APP_PATH"' \ "$ROOT_DIR/Scripts/collect-release-evidence.sh"; then printf "Release evidence can run identity-sensitive acceptance against a transient candidate.\n" >&2 diff --git a/Scripts/verify-ci.sh b/Scripts/verify-ci.sh index 84514f6..f14b7e7 100755 --- a/Scripts/verify-ci.sh +++ b/Scripts/verify-ci.sh @@ -116,6 +116,14 @@ if [[ ! -x "$ROOT_DIR/Scripts/run-installed-rclone-local-acceptance.sh" ]]; then printf "Scripts/run-installed-rclone-local-acceptance.sh must be executable.\n" >&2 exit 1 fi +if [[ ! -x "$ROOT_DIR/Scripts/run-installed-time-machine-system-support-acceptance.sh" ]]; then + printf "Scripts/run-installed-time-machine-system-support-acceptance.sh must be executable.\n" >&2 + exit 1 +fi +if [[ ! -x "$ROOT_DIR/Scripts/verify-time-machine-system-support-evidence.sh" ]]; then + printf "Scripts/verify-time-machine-system-support-evidence.sh must be executable.\n" >&2 + exit 1 +fi "$ROOT_DIR/Scripts/bootstrap-tools.sh" "$ROOT_DIR/Scripts/verify-tools.sh" diff --git a/Scripts/verify-production-readiness.sh b/Scripts/verify-production-readiness.sh index f6abc45..93b3b1b 100755 --- a/Scripts/verify-production-readiness.sh +++ b/Scripts/verify-production-readiness.sh @@ -8,6 +8,7 @@ INSTALLED_APP="${DELTA_PRODUCTION_INSTALLED_APP:-/Applications/Delta.app}" MANUAL_REPORT="${DELTA_MANUAL_ACCEPTANCE_REPORT:-$ROOT_DIR/dist/manual-acceptance/latest.md}" GATE_STATUS_FILE="$ROOT_DIR/dist/release-evidence/automated-gate-status" NOTARY_OUTPUT_DIR="${DELTA_NOTARY_OUTPUT_DIR:-$ROOT_DIR/dist/notarization}" +TIME_MACHINE_SYSTEM_EVIDENCE="${DELTA_TIME_MACHINE_SYSTEM_ACCEPTANCE_EVIDENCE:-$ROOT_DIR/dist/time-machine-system-support/latest.txt}" fail() { printf "Production readiness failed: %s\n" "$1" >&2 @@ -122,6 +123,10 @@ for NOTARY_ARTIFACT in app dmg; do fi done +"$ROOT_DIR/Scripts/verify-time-machine-system-support-evidence.sh" \ + "$INSTALLED_APP" \ + "$TIME_MACHINE_SYSTEM_EVIDENCE" + DELTA_VERIFY_INSTALLED_LAUNCH="${DELTA_PRODUCTION_VERIFY_INSTALLED_LAUNCH:-1}" \ "$ROOT_DIR/Scripts/verify-installed-app.sh" "$INSTALLED_APP" diff --git a/Scripts/verify-time-machine-system-support-evidence.sh b/Scripts/verify-time-machine-system-support-evidence.sh new file mode 100755 index 0000000..4c9fd0e --- /dev/null +++ b/Scripts/verify-time-machine-system-support-evidence.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=Scripts/lib/delta-release.sh +source "$ROOT_DIR/Scripts/lib/delta-release.sh" + +APP_PATH="${1:-/Applications/Delta.app}" +EVIDENCE="${2:-${DELTA_TIME_MACHINE_SYSTEM_ACCEPTANCE_EVIDENCE:-$ROOT_DIR/dist/time-machine-system-support/latest.txt}}" + +[[ -d "$APP_PATH" ]] || delta_fail "installed app not found: $APP_PATH" +APP_PATH="$(cd "$APP_PATH" && pwd -P)" +[[ "$APP_PATH" == "/Applications/Delta.app" ]] \ + || delta_fail "Time Machine system-support evidence requires the exact canonical app at /Applications/Delta.app, not $APP_PATH" +[[ -e "$EVIDENCE" ]] || delta_fail "Time Machine system-support evidence was not found: $EVIDENCE" + +resolve_evidence_path() { + local path="$1" + local target + if [[ -L "$path" ]]; then + target="$(/usr/bin/readlink "$path")" + if [[ "$target" == /* ]]; then + path="$target" + else + path="$(dirname "$path")/$target" + fi + fi + printf '%s/%s\n' "$(cd "$(dirname "$path")" && pwd -P)" "$(basename "$path")" +} + +EVIDENCE="$(resolve_evidence_path "$EVIDENCE")" +[[ -f "$EVIDENCE" ]] || delta_fail "Time Machine system-support evidence is not a regular file: $EVIDENCE" + +evidence_value() { + local key="$1" + local count value + count="$(/usr/bin/awk -F= -v key="$key" '$1 == key { count += 1 } END { print count + 0 }' "$EVIDENCE")" + [[ "$count" == "1" ]] || delta_fail "Time Machine system-support evidence must contain exactly one $key field" + value="$(/usr/bin/awk -F= -v key="$key" '$1 == key { print substr($0, length(key) + 2); exit }' "$EVIDENCE")" + [[ -n "$value" ]] || delta_fail "Time Machine system-support evidence has an empty $key field" + printf '%s\n' "$value" +} + +IFS=$'\t' read -r VERSION BUILD < <(delta_assert_release_metadata "$ROOT_DIR") +export DELTA_EXPECTED_RELEASE_VERSION="$VERSION" +export DELTA_EXPECTED_RELEASE_BUILD="$BUILD" +delta_assert_release_app "$APP_PATH" "$DELTA_EXPECTED_TEAM_ID" +delta_assert_notarized_app "$APP_PATH" + +INFO_PLIST="$APP_PATH/Contents/Info.plist" +HELPER_EXECUTABLE="$APP_PATH/Contents/MacOS/DeltaTimeMachineHelper" +APP_CDHASH="$(delta_signature_cdhash "$APP_PATH")" +HELPER_CDHASH="$(delta_signature_cdhash "$HELPER_EXECUTABLE")" +GIT_COMMIT="$(/usr/bin/git -C "$ROOT_DIR" rev-parse --short HEAD)" +HELPER_CDHASH_LOWER="$(/usr/bin/tr '[:upper:]' '[:lower:]' <<<"$HELPER_CDHASH")" +EVIDENCE_HELPER_CDHASH="$(evidence_value helper_cdhash)" +EVIDENCE_RUNTIME_HELPER_CODE_HASH="$(evidence_value runtime_helper_code_hash)" +EVIDENCE_HELPER_CDHASH_LOWER="$(/usr/bin/tr '[:upper:]' '[:lower:]' <<<"$EVIDENCE_HELPER_CDHASH")" +EVIDENCE_RUNTIME_HELPER_CODE_HASH_LOWER="$(/usr/bin/tr '[:upper:]' '[:lower:]' <<<"$EVIDENCE_RUNTIME_HELPER_CODE_HASH")" + +[[ "$(evidence_value status)" == "Passed" ]] || delta_fail 'Time Machine system-support evidence did not pass' +[[ "$(evidence_value git_commit)" == "$GIT_COMMIT" ]] || delta_fail 'Time Machine system-support evidence belongs to a different source commit' +[[ "$(evidence_value app_path)" == "$APP_PATH" ]] || delta_fail 'Time Machine system-support evidence belongs to a different installed path' +[[ "$(evidence_value bundle_id)" == "$(delta_plist_value CFBundleIdentifier "$INFO_PLIST")" ]] || delta_fail 'Time Machine system-support evidence has a different bundle identifier' +[[ "$(evidence_value version)" == "$VERSION" ]] || delta_fail 'Time Machine system-support evidence has a different version' +[[ "$(evidence_value build)" == "$BUILD" ]] || delta_fail 'Time Machine system-support evidence has a different build' +[[ "$(evidence_value team_id)" == "$DELTA_EXPECTED_TEAM_ID" ]] || delta_fail 'Time Machine system-support evidence has a different signing team' +[[ "$(evidence_value app_cdhash)" == "$APP_CDHASH" ]] || delta_fail 'Time Machine system-support evidence has a different app CDHash' +[[ "$EVIDENCE_HELPER_CDHASH_LOWER" == "$HELPER_CDHASH_LOWER" ]] || delta_fail 'Time Machine system-support evidence has a different helper CDHash' +[[ "$EVIDENCE_RUNTIME_HELPER_CODE_HASH_LOWER" == "$HELPER_CDHASH_LOWER" ]] || delta_fail 'authenticated runtime helper hash does not match the exact embedded helper' +[[ "$(evidence_value initial_storage_service_status)" == "notRegistered" ]] || delta_fail 'acceptance did not begin with a clean storage-service registration' +[[ "$(evidence_value initial_setup_helper_status)" == "notRegistered" ]] || delta_fail 'acceptance did not begin with a clean setup-helper registration' +[[ "$(evidence_value registered_storage_service_status)" == "enabled" ]] || delta_fail 'storage service never became eligible to run' +[[ "$(evidence_value registered_setup_helper_status)" == "enabled" ]] || delta_fail 'setup helper never became eligible to run' +[[ "$(evidence_value authenticated_helper_readiness)" == "verified" ]] || delta_fail 'the exact running helper was not authenticated' +[[ "$(evidence_value cleanup_storage_service_status)" == "notRegistered" ]] || delta_fail 'acceptance did not clean up the storage-service registration' +[[ "$(evidence_value cleanup_setup_helper_status)" == "notRegistered" ]] || delta_fail 'acceptance did not clean up the setup-helper registration' + +printf 'Time Machine system-support evidence verified for Delta %s (%s), commit %s, app CDHash %s, helper CDHash %s.\n' \ + "$VERSION" "$BUILD" "$GIT_COMMIT" "$APP_CDHASH" "$HELPER_CDHASH" diff --git a/Sources/Delta/DeltaApp.swift b/Sources/Delta/DeltaApp.swift index 8eb3002..29d0cc9 100644 --- a/Sources/Delta/DeltaApp.swift +++ b/Sources/Delta/DeltaApp.swift @@ -106,6 +106,10 @@ struct DeltaApp: App { runAcceptanceMenuBarSurface() case "--acceptance-scheduled-service": runAcceptanceScheduledService(arguments: Array(arguments.dropFirst())) + case "--acceptance-time-machine-system-support": + runAcceptanceTimeMachineSystemSupport( + arguments: Array(arguments.dropFirst()) + ) case "--acceptance-seed-scheduled-agent": runAcceptanceSeedScheduledAgent(arguments: Array(arguments.dropFirst())) case "--acceptance-verify-scheduled-agent": @@ -201,6 +205,140 @@ struct DeltaApp: App { exit(0) } + private static func runAcceptanceTimeMachineSystemSupport( + arguments: [String] + ) -> Never { + guard ProcessInfo.processInfo.environment[ + "DELTA_ENABLE_TIME_MACHINE_SYSTEM_ACCEPTANCE" + ] == "1" else { + fputs( + "Delta Time Machine system-support acceptance command is disabled.\n", + stderr + ) + exit(64) + } + guard arguments.count == 1, let action = arguments.first else { + fputs( + "usage: Delta --acceptance-time-machine-system-support \n", + stderr + ) + exit(64) + } + + let bundle = Bundle.main + let isCanonical = TimeMachineInstalledApplicationPolicy + .isCanonicalInstallation(bundleURL: bundle.bundleURL) + var readiness = "not checked" + + do { + switch action { + case "status": + break + case "register": + guard isCanonical else { + throw TimeMachineSetupHelperReadinessError + .noncanonicalInstallation + } + var failures: [String] = [] + do { + try TimeMachineServiceController.register() + } catch { + if !TimeMachineSystemAccessRegistrationPolicy.accepted( + status: TimeMachineServiceController.status() + ) { + failures.append( + "storage service: \(error.localizedDescription)" + ) + } + } + do { + try TimeMachineSetupHelperController.register() + } catch { + if !TimeMachineSystemAccessRegistrationPolicy.accepted( + status: TimeMachineSetupHelperController.status() + ) { + failures.append( + "setup helper: \(error.localizedDescription)" + ) + } + } + if !failures.isEmpty { + throw NSError( + domain: "com.delta.backup.acceptance.time-machine", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: failures.joined( + separator: "; " + ) + ] + ) + } + case "verify": + guard TimeMachineServiceController.status() == .enabled else { + throw TimeMachineSystemAccessRepairError + .registrationIncomplete + } + guard TimeMachineSetupHelperController.status() == .enabled else { + throw TimeMachineSystemAccessRepairError + .registrationIncomplete + } + try TimeMachineSetupHelperRuntimeVerifier.verify(bundle: bundle) + readiness = "verified" + case "unregister": + guard isCanonical else { + throw TimeMachineSetupHelperReadinessError + .noncanonicalInstallation + } + if TimeMachineSetupHelperController.status() + != .notRegistered + { + try TimeMachineSetupHelperController.unregister() + } + if TimeMachineServiceController.status() != .notRegistered { + try TimeMachineServiceController.unregister() + } + default: + fputs( + "usage: Delta --acceptance-time-machine-system-support \n", + stderr + ) + exit(64) + } + } catch { + fputs( + "Delta Time Machine system-support acceptance failed: \(error.localizedDescription)\n", + stderr + ) + exit(1) + } + + let helperURL = bundle.bundleURL.appendingPathComponent( + TimeMachineSetupHelperController.executableRelativePath + ) + let helperCodeHash = (try? TimeMachineSetupHelperController + .installedCodeHash(bundle: bundle))?.map { + String(format: "%02x", $0) + }.joined() + + print( + "Time Machine app installation: \(isCanonical ? "canonical" : "noncanonical")" + ) + print( + "Time Machine storage service status: \(TimeMachineServiceController.status().stableValue)" + ) + print( + "Time Machine setup helper status: \(TimeMachineSetupHelperController.status().stableValue)" + ) + print( + "Time Machine setup helper executable: \(FileManager.default.isExecutableFile(atPath: helperURL.path) ? "present" : "missing")" + ) + print( + "Time Machine setup helper code hash: \(helperCodeHash ?? "unavailable")" + ) + print("Time Machine setup helper readiness: \(readiness)") + exit(0) + } + private static func runDueBackups() -> Never { do { let status = try DeltaAgentRunner.runDueBackups( diff --git a/Sources/Delta/DeltaAppModel.swift b/Sources/Delta/DeltaAppModel.swift index d90a5c5..62fc704 100644 --- a/Sources/Delta/DeltaAppModel.swift +++ b/Sources/Delta/DeltaAppModel.swift @@ -2411,11 +2411,7 @@ final class DeltaAppModel: ObservableObject { private nonisolated static func verifyTimeMachineSetupHelperReadinessSynchronously() throws { - let expectedCodeHash = try TimeMachineSetupHelperController - .installedCodeHash() - try TimeMachineSetupHelperClient().verifyReadiness( - expectedCodeHash: expectedCodeHash - ) + try TimeMachineSetupHelperRuntimeVerifier.verify() } private func verifyTimeMachineSetupHelperReadiness() async throws { diff --git a/Sources/DeltaCore/TimeMachineSetupProtocol.swift b/Sources/DeltaCore/TimeMachineSetupProtocol.swift index a96d085..2422625 100644 --- a/Sources/DeltaCore/TimeMachineSetupProtocol.swift +++ b/Sources/DeltaCore/TimeMachineSetupProtocol.swift @@ -552,6 +552,7 @@ public enum TimeMachineSetupClientError: Error, Equatable, LocalizedError { } public enum TimeMachineSetupHelperReadinessError: Error, Equatable, LocalizedError { + case noncanonicalInstallation case invalidResponse case unavailable(String) case timedOut @@ -559,6 +560,8 @@ public enum TimeMachineSetupHelperReadinessError: Error, Equatable, LocalizedErr public var errorDescription: String? { switch self { + case .noncanonicalInstallation: + TimeMachineInstalledApplicationPolicy.recoveryMessage case .invalidResponse: "Delta's Time Machine setup helper returned an invalid readiness response." case let .unavailable(message): @@ -727,6 +730,26 @@ public struct TimeMachineSetupHelperClient: Sendable { } } +/// One authoritative readiness proof is shared by the interactive app and +/// exact-candidate release acceptance. It never accepts Service Management's +/// registration status by itself: the running helper must return the Security +/// code hash that matches the helper embedded in the canonical installed app. +public enum TimeMachineSetupHelperRuntimeVerifier { + public static func verify(bundle: Bundle = .main) throws { + guard TimeMachineInstalledApplicationPolicy.isCanonicalInstallation( + bundleURL: bundle.bundleURL + ) else { + throw TimeMachineSetupHelperReadinessError + .noncanonicalInstallation + } + let expectedCodeHash = try TimeMachineSetupHelperController + .installedCodeHash(bundle: bundle) + try TimeMachineSetupHelperClient().verifyReadiness( + expectedCodeHash: expectedCodeHash + ) + } +} + private final class LockedSetupReadinessResult: @unchecked Sendable { private let lock = NSLock() private var stored: Result< diff --git a/Tests/DeltaCoreTests/TimeMachineSystemControllerTests.swift b/Tests/DeltaCoreTests/TimeMachineSystemControllerTests.swift index a03d5d8..eb2c74b 100644 --- a/Tests/DeltaCoreTests/TimeMachineSystemControllerTests.swift +++ b/Tests/DeltaCoreTests/TimeMachineSystemControllerTests.swift @@ -673,6 +673,19 @@ final class TimeMachineSystemControllerTests: XCTestCase { ) } + func testSetupHelperRuntimeVerificationRejectsNoncanonicalAppBeforeXPC() { + XCTAssertThrowsError( + try TimeMachineSetupHelperRuntimeVerifier.verify( + bundle: Bundle(for: Self.self) + ) + ) { error in + XCTAssertEqual( + error as? TimeMachineSetupHelperReadinessError, + .noncanonicalInstallation + ) + } + } + func testInstalledComponentLayoutFindsOnlyAnEnclosingApplicationBundle() { XCTAssertEqual( TimeMachineInstalledComponentLayout.applicationBundleURL( diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index d7ef2d5..32a293b 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -35,6 +35,7 @@ The automated gate must pass before any release candidate or public build is shi - Password Access health diagnostics for saved destination passwords and backend credentials - installed Scheduled Backups scheduler acceptance with an isolated due profile, automatic destination preparation, real backup, cached restore point refresh, and source-context log persistence - installed Service Management discovery acceptance that rejects a missing helper, a mismatched `BundleProgram`, and `SMAppService.Status.notFound` +- clean first-registration Time Machine system-support acceptance for the exact notarized `/Applications/Delta.app`: both public registrations must start unregistered, administrator approval must make them enabled, and the running privileged helper must return the exact embedded Security code hash before candidate-bound evidence is recorded and both registrations are removed through public APIs - installed status-menu surface acceptance for ready/running/attention/blocked state text, compact labels, Back Up Now, Run Due Backups, Pause, Stop, Activity, Updates, and forbidden terminology - installed-app diagnostic export with redaction of seeded destination and backend credential values - source access preflight before restic starts or a new destination is prepared @@ -69,6 +70,8 @@ Scripts/run-local-acceptance-probe.sh The probe writes `dist/local-acceptance/latest.md`. Identity-sensitive launch and Service Management checks fail closed unless the exact app is installed directly under `/Applications`; release artifacts under `dist`, archive/export folders, mounted disk images, and temporary extraction paths are never launched as the user's app or allowed to replace the installed app's Login Items/helper registration. The probe runs `Scripts/run-installed-service-management-acceptance.sh` against that installed identity, pauses automatic work, proves the installed launch agent transitions from registered to discoverable-but-unregistered and back through `SMAppService`, then restores the original registration and pause state. It also runs `Scripts/run-installed-keychain-access-acceptance.sh`, which creates a throwaway destination-secret item through the installed Delta app, proves the installed password bridge mode can read it without interaction, then deletes it. It runs `Scripts/run-installed-scheduled-agent-acceptance.sh`, which seeds an isolated due scheduled profile and proves the installed Scheduled Backups scheduler runs one real backup through the installed Delta executable without interactive Keychain prompts. It runs `Scripts/run-installed-diagnostics-acceptance.sh`, which seeds isolated installed-app state, exports diagnostics through the installed app, and proves seeded destination/backend credential values are redacted. It runs `Scripts/run-installed-preferences-acceptance.sh`, which verifies the signed app's shared Settings surface contract, required categories, compact status summary, recommended defaults, unsafe value normalization, custom backup defaults persisted to a new profile, custom restore defaults, diagnostic settings summaries, and restoration of existing preference values. It runs `Scripts/run-installed-menu-bar-surface-acceptance.sh`, which verifies the installed app's shared status-menu contract for ready/running/attention/blocked text, compact labels, Back Up Now, Run Due Backups, Pause, Stop, Activity, Updates, and forbidden terminology. It runs `Scripts/run-installed-run-control-acceptance.sh`, which launches the installed Delta app in run-control acceptance mode and verifies Delta's coordinator, SQLite job store, durable stop-request files, resumable pause state, successful resume, non-resumable cancel state, cleared stop requests, job logs, and restore-point refresh after resume. It runs `Scripts/run-installed-local-backup-acceptance.sh`, which launches the installed Delta app in local lifecycle acceptance mode and verifies Delta's coordinator, SQLite store, Keychain password command, bundled restic, automatic destination preparation, first backup, no-change backup, incremental backup, newest-first restore-point cache, backup browser listing with nested file metadata, full restore, selected folder restore, selected file restore, dry-run restore with no writes, every overwrite policy, destination check, cleanup, post-cleanup check, pruned restore-point cache refresh, and saved backup log source/summary evidence against a temporary encrypted local destination. It runs `Scripts/run-installed-mounted-volume-acceptance.sh`, which creates a temporary APFS volume mounted under `/Volumes` and proves the installed app can prepare, back up, browse, restore, check, clean up, prune, and observe that the mounted destination disappears after unmount. It runs `Scripts/run-installed-local-rest-acceptance.sh`, which starts a temporary local REST backend with bundled `rclone serve restic`, then proves the installed app can use Keychain-backed REST credentials, prepare, back up, browse, restore, check, clean up, and prune through restic's REST backend. It runs `Scripts/run-installed-local-s3-acceptance.sh`, which starts a temporary local S3-compatible endpoint with bundled rclone and proves the installed app can use Keychain-backed AWS credentials, reject missing credentials, prepare, back up, browse, restore, check, clean up, and prune through restic's S3 backend. It runs `Scripts/run-installed-local-sftp-acceptance.sh`, which starts a temporary localhost SFTP server and proves the installed app can use non-interactive key authentication, reject a wrong target, prepare, back up, browse, restore, check, clean up, and prune through restic's SFTP backend. It also runs `Scripts/run-installed-rclone-local-acceptance.sh`, which configures a temporary rclone local remote and proves the installed app can prepare, back up, browse, restore, check, clean up, and prune through restic's `rclone:` backend without depending on cloud credentials. +The separate `Scripts/run-installed-time-machine-system-support-acceptance.sh` gate is intentionally interactive and must run on a clean acceptance host after finalization. Apple documents that a modern `SMAppService` launch daemon will not bootstrap until an administrator approves it in System Settings. Headless CI therefore cannot claim this result. `Scripts/verify-production-readiness.sh` requires the generated report and rejects it unless it matches the exact installed candidate and proves clean initial state, enabled public registration, authenticated helper readiness, and public unregister cleanup. + External backend evidence is opt-in because it needs real infrastructure. Configure these variables before running the local probe when those targets are available: ```sh