From 51b122ca490860a9bd823eb33c4888be11cfb533 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:44:07 +0800 Subject: [PATCH 01/11] fix: complete safe release hardening --- .github/workflows/ci.yml | 18 +- .github/workflows/release.yml | 142 ++++- .github/workflows/sentry-issues.yml | 195 ++---- README.md | 16 +- SECURITY.md | 38 +- TELEMETRY.md | 25 +- docs/macos-signing.md | 81 ++- macos/Resources/Info.plist | 4 +- .../zh-Hans.lproj/Localizable.strings | 4 +- .../zh-Hant.lproj/Localizable.strings | 4 +- macos/Sources/CleanView.swift | 103 ++-- macos/Sources/CleanupAuthorization.swift | 202 ++++++ macos/Sources/CrashReporter.swift | 50 +- .../ExternalSparkleUpdateSession.swift | 192 ++++++ macos/Sources/LaunchDiagnostics.swift | 35 +- macos/Sources/MCP.swift | 6 +- macos/Sources/MoleCLI.swift | 86 ++- macos/Sources/OperationFlow.swift | 73 ++- macos/Sources/PopupView.swift | 15 +- macos/Sources/PortsView.swift | 21 +- macos/Sources/PrivilegeBroker.swift | 51 +- macos/Sources/PrivilegedExecution.swift | 273 +++++++++ macos/Sources/ProcessActions.swift | 178 +++++- macos/Sources/QueryServer.swift | 282 +++++++-- macos/Sources/SettingsView.swift | 3 +- macos/Sources/SoftwareView.swift | 71 ++- macos/Sources/StatusView.swift | 30 +- macos/Sources/Store.swift | 17 +- macos/Sources/TuneUp.swift | 15 + macos/Sources/TuneUpView.swift | 81 ++- macos/Sources/UpdateSources.swift | 22 + macos/Sources/UpdateWorkflow.swift | 579 ++++++++++++++++++ macos/Sources/UpdatesView.swift | 571 ++++++++++++++--- macos/Tests/CrashReporterPolicyTests.swift | 71 +++ .../Tests/Fixtures/SentrySensitiveEvent.json | 53 ++ macos/Tests/MCPEnvelopeTests.swift | 6 +- macos/Tests/MoleCLITests.swift | 57 +- macos/Tests/OperationFlowTests.swift | 32 +- macos/Tests/PrivilegeBrokerTests.swift | 59 +- macos/Tests/PrivilegedSecurityTests.swift | 199 ++++++ macos/Tests/ProcessActionsTests.swift | 160 +++++ macos/Tests/QueryEventsTests.swift | 21 +- macos/Tests/QueryServerTests.swift | 81 ++- macos/Tests/SoftwareModelTests.swift | 187 ++++++ macos/Tests/StoreTests.swift | 24 + macos/Tests/TuneUpTests.swift | 15 + macos/Tests/UpdateWorkflowTests.swift | 210 +++++++ macos/Tests/UpdatesModelTests.swift | 179 ++++++ macos/project.yml | 16 +- macos/vendor/burrow-engine | 2 +- scripts/fetch-sentry-cli.sh | 29 + scripts/fetch-sentry.sh | 9 +- scripts/fetch-sparkle.sh | 9 +- scripts/fetch-xcodegen.sh | 32 + scripts/release-input.py | 35 ++ scripts/release-inputs.json | 28 + scripts/release.sh | 7 +- scripts/sentry_public_summary.py | 130 ++++ .../fixtures/sentry-sensitive-event.json | 34 + .../fixtures/sentry-sensitive-issue.json | 12 + scripts/tests/test_helper_version_sync.py | 54 +- scripts/tests/test_release_workflows.py | 113 ++++ scripts/tests/test_sentry_issues_workflow.py | 71 ++- scripts/tests/test_validate_release_notes.py | 12 +- scripts/verify-dsym-uuids.sh | 37 ++ scripts/verify-macos-release.sh | 52 ++ scripts/verify-project-generation.py | 110 ++++ windows/BURROW_WINDOWS_ALIGNMENT.md | 4 +- windows/Models/BurrowSettings.cs | 24 + .../JsonApplicationSettingsService.cs | 56 +- windows/Services/LocalMcpServerService.cs | 172 +++++- .../JsonApplicationSettingsServiceTests.cs | 22 + .../LocalMcpServerServiceTests.cs | 80 ++- windows/Tools/McpStdioBridge/Program.cs | 22 +- windows/ViewModels/SettingsViewModel.cs | 5 +- windows/run-local.ps1 | 6 +- 76 files changed, 5285 insertions(+), 735 deletions(-) create mode 100644 macos/Sources/CleanupAuthorization.swift create mode 100644 macos/Sources/ExternalSparkleUpdateSession.swift create mode 100644 macos/Sources/PrivilegedExecution.swift create mode 100644 macos/Sources/UpdateWorkflow.swift create mode 100644 macos/Tests/Fixtures/SentrySensitiveEvent.json create mode 100644 macos/Tests/PrivilegedSecurityTests.swift create mode 100644 macos/Tests/ProcessActionsTests.swift create mode 100644 macos/Tests/SoftwareModelTests.swift create mode 100644 macos/Tests/UpdateWorkflowTests.swift create mode 100644 macos/Tests/UpdatesModelTests.swift create mode 100755 scripts/fetch-sentry-cli.sh create mode 100755 scripts/fetch-xcodegen.sh create mode 100755 scripts/release-input.py create mode 100644 scripts/release-inputs.json create mode 100755 scripts/sentry_public_summary.py create mode 100644 scripts/tests/fixtures/sentry-sensitive-event.json create mode 100644 scripts/tests/fixtures/sentry-sensitive-issue.json create mode 100755 scripts/verify-dsym-uuids.sh create mode 100755 scripts/verify-macos-release.sh create mode 100755 scripts/verify-project-generation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db98ba6..66ab2c2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,8 +56,10 @@ jobs: sudo xcode-select -s "$XCODE" xcodebuild -version - - name: Install xcodegen - run: brew install xcodegen + - name: Install checksum-pinned XcodeGen + run: | + bash ../scripts/fetch-xcodegen.sh "$RUNNER_TEMP/xcodegen" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" # Sentry is a vendored local framework (not SPM — its binary download # hangs xcodebuild). Fetch it before generating the project, or xcodegen @@ -71,8 +73,10 @@ jobs: - name: Fetch vendored Sparkle framework run: bash ../scripts/fetch-sparkle.sh - - name: Generate project - run: xcodegen generate + - name: Verify deterministic generated metadata + run: | + python3 ../scripts/verify-project-generation.py \ + --xcodegen "$(command -v xcodegen)" --check-git - name: Test run: | @@ -99,8 +103,10 @@ jobs: sw_vers xcodebuild -version - - name: Install xcodegen - run: brew install xcodegen + - name: Install checksum-pinned XcodeGen + run: | + bash ../scripts/fetch-xcodegen.sh "$RUNNER_TEMP/xcodegen" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" - name: Fetch vendored Sentry framework run: bash ../scripts/fetch-sentry.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7580103..c465da08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ name: release # AC_API_KEY_P8 base64 of the AuthKey_XXXX.p8 # SPARKLE_ED_PRIVATE_KEY private Sparkle seed exported by generate_keys -x # TAP_PAT fine-grained token for caezium/homebrew-tap +# SENTRY_AUTH_TOKEN token with project:releases for mandatory dSYM upload # # Optional telemetry secrets — release.sh reads these from the environment # (locally it sources the gitignored scripts/release.env instead). Without @@ -50,7 +51,9 @@ jobs: # The build is capped at 38 minutes and Apple's synchronous notarization # wait at 60 minutes. Keep bounded headroom for setup, signing, stapling, # packaging, publication, and the external tap update. - timeout-minutes: 120 + timeout-minutes: 150 + env: + EXPECTED_TEAM_ID: YGSM2722TZ steps: # Pinned to a commit SHA, not a floating tag: this job holds # contents:write, the signing cert, and TAP_PAT, so a moved tag must @@ -69,6 +72,7 @@ jobs: AC_KEY_P8: ${{ secrets.AC_API_KEY_P8 }} SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} TAP_TOKEN: ${{ secrets.TAP_PAT }} + SENTRY_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: | missing=() [ -n "$CERT_P12" ] || missing+=(MACOS_CERT_P12) @@ -79,6 +83,7 @@ jobs: [ -n "$AC_KEY_P8" ] || missing+=(AC_API_KEY_P8) [ -n "$SPARKLE_KEY" ] || missing+=(SPARKLE_ED_PRIVATE_KEY) [ -n "$TAP_TOKEN" ] || missing+=(TAP_PAT) + [ -n "$SENTRY_TOKEN" ] || missing+=(SENTRY_AUTH_TOKEN) if [ "${#missing[@]}" -ne 0 ]; then echo "::error::Release blocked: missing required secret(s): ${missing[*]}" exit 1 @@ -90,6 +95,13 @@ jobs: exit 1 ;; esac + case "$SIGN_IDENTITY" in + *"($EXPECTED_TEAM_ID)") ;; + *) + echo "::error::MACOS_SIGN_IDENTITY must preserve Developer ID team $EXPECTED_TEAM_ID." + exit 1 + ;; + esac - name: Verify Homebrew tap write access env: @@ -173,8 +185,30 @@ jobs: - name: Build fclones sidecar (MIT, universal) run: bash scripts/build-fclones.sh - - name: Install xcodegen - run: brew install xcodegen + - name: Install checksum-pinned XcodeGen + run: | + bash scripts/fetch-xcodegen.sh "$RUNNER_TEMP/xcodegen" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" + + - name: Test exact tagged commit + timeout-minutes: 30 + run: | + ACTUAL_SHA="$(git rev-parse HEAD)" + ACTUAL_TAG="$(git describe --exact-match --tags "$ACTUAL_SHA")" + [ "$ACTUAL_SHA" = "$GITHUB_SHA" ] \ + || { echo "::error::checkout $ACTUAL_SHA does not match triggering commit $GITHUB_SHA"; exit 1; } + [ "$ACTUAL_TAG" = "$GITHUB_REF_NAME" ] \ + || { echo "::error::commit is tagged $ACTUAL_TAG, expected $GITHUB_REF_NAME"; exit 1; } + + bash scripts/fetch-sentry.sh + bash scripts/fetch-sparkle.sh + python3 scripts/verify-project-generation.py \ + --xcodegen "$(command -v xcodegen)" --check-git + python3 -m unittest discover -s scripts/tests -p 'test_*.py' + node --test scripts/tests/test_site_analytics.mjs + xcodebuild test -project macos/Burrow.xcodeproj -scheme Burrow \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO - name: Build (Release) id: build @@ -275,30 +309,26 @@ jobs: # Burrow frames arrive as ("debug information file was # missing") — exactly what made the 0.6.7 "App Hanging" reports show only # system frames. Runs after the tag/version check so only a real - # release's symbols ship; skipped (with a warning) when no token is set. - # Signing and notarization remain mandatory and fail closed independently. - - name: Upload dSYMs to Sentry — skipped if no token + # release's symbols ship. The token, dSYM, and exact app/dSYM UUID match + # are release requirements: publishing an unsymbolicatable build is blocked. + - name: Verify and upload release dSYM env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: henry-zhang-r7 SENTRY_PROJECT: burrow run: | - if [ -z "$SENTRY_AUTH_TOKEN" ]; then - echo "::warning::No SENTRY_AUTH_TOKEN — skipping dSYM upload; Sentry frames for ${{ steps.build.outputs.version }} stay unsymbolicated." - exit 0 - fi + APP="${{ steps.build.outputs.app }}" DSYM="build_dist/Build/Products/Release/Burrow.app.dSYM" - if [ ! -d "$DSYM" ]; then - echo "::warning::No dSYM at $DSYM — nothing to upload (Release build didn't emit one?)." - exit 0 - fi - # From Homebrew, consistent with the xcodegen install above — this - # job is SHA-pinned for Actions, so sentry-cli comes from brew rather - # than a piped remote installer. - brew install sentry-cli + [ -d "$DSYM" ] \ + || { echo "::error::Release blocked: dSYM is missing at $DSYM"; exit 1; } + bash scripts/verify-dsym-uuids.sh "$APP" "$DSYM" + bash scripts/fetch-sentry-cli.sh "$RUNNER_TEMP/sentry-cli" # Debug symbols only (names + line maps); no --include-sources, so no # source files are ever shipped to Sentry. - sentry-cli debug-files upload "$DSYM" + "$RUNNER_TEMP/sentry-cli" debug-files check \ + "$DSYM/Contents/Resources/DWARF/Burrow" + "$RUNNER_TEMP/sentry-cli" debug-files upload \ + --no-sources --wait "$DSYM" - name: Code-sign every executable (Developer ID) env: @@ -405,9 +435,9 @@ jobs: exit 1 fi xcrun stapler staple "$APP" - xcrun stapler validate "$APP" - codesign --verify --deep --strict --verbose=2 "$APP" - spctl --assess --type execute --verbose=4 "$APP" + bash scripts/verify-macos-release.sh \ + "$APP" "$EXPECTED_TEAM_ID" \ + "${{ steps.build.outputs.version }}" "${{ steps.build.outputs.build_number }}" - name: Package (zip + sha256) id: pkg @@ -462,28 +492,74 @@ jobs: "$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - "$APPCAST" echo "Signed Sparkle archive + feed verified; release publication is now allowed." - - name: Publish GitHub release (idempotent) + - name: Upload GitHub release draft env: GH_TOKEN: ${{ github.token }} run: | ZIP="dist/Burrow-${{ steps.build.outputs.version }}.zip" APPCAST="dist/appcast.xml" if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - # Release already exists (e.g. re-run after adding TAP_PAT) — - # replace the archive first and the signed feed last. During that - # narrow window, a mismatch fails signature validation closed. - gh release upload "$GITHUB_REF_NAME" "$ZIP" --clobber - gh release upload "$GITHUB_REF_NAME" "$APPCAST" --clobber + IS_DRAFT="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" + if [ "$IS_DRAFT" != "true" ]; then + echo "::error::Release $GITHUB_REF_NAME is already public; immutable published assets will not be replaced." + exit 1 + fi else - # Keep the release draft until both assets have uploaded; a - # partial upload must never become Sparkle's latest feed. - gh release create "$GITHUB_REF_NAME" "$ZIP" "$APPCAST" \ + gh release create "$GITHUB_REF_NAME" \ --title "Burrow ${{ steps.build.outputs.version }}" \ --notes-file RELEASES.md \ --draft fi - # Also publish a draft left by a previously interrupted attempt. - # Reaching this line means both freshly verified assets now exist. + gh release upload "$GITHUB_REF_NAME" "$ZIP" "$APPCAST" --clobber + + - name: Verify downloaded release artifact + env: + GH_TOKEN: ${{ github.token }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + VERSION="${{ steps.build.outputs.version }}" + BUILD_NUMBER="${{ steps.build.outputs.build_number }}" + ZIP_NAME="Burrow-${VERSION}.zip" + DOWNLOAD_DIR="$(mktemp -d)" + EXTRACT_DIR="$(mktemp -d)" + gh release download "$GITHUB_REF_NAME" \ + --pattern "$ZIP_NAME" --pattern appcast.xml --dir "$DOWNLOAD_DIR" + DOWNLOADED_ZIP="$DOWNLOAD_DIR/$ZIP_NAME" + DOWNLOADED_APPCAST="$DOWNLOAD_DIR/appcast.xml" + [ -f "$DOWNLOADED_ZIP" ] && [ -f "$DOWNLOADED_APPCAST" ] \ + || { echo "::error::Draft release did not return both expected assets."; exit 1; } + + DOWNLOADED_SHA="$(shasum -a 256 "$DOWNLOADED_ZIP" | awk '{print $1}')" + [ "$DOWNLOADED_SHA" = "${{ steps.pkg.outputs.sha }}" ] \ + || { echo "::error::Downloaded release archive differs from the verified package."; exit 1; } + ditto -x -k "$DOWNLOADED_ZIP" "$EXTRACT_DIR" + DOWNLOADED_APP="$EXTRACT_DIR/Burrow.app" + bash scripts/verify-macos-release.sh \ + "$DOWNLOADED_APP" "$EXPECTED_TEAM_ID" "$VERSION" "$BUILD_NUMBER" + bash scripts/verify-dsym-uuids.sh \ + "$DOWNLOADED_APP" "build_dist/Build/Products/Release/Burrow.app.dSYM" + + ARCHIVE_URL="https://github.com/caezium/Burrow/releases/download/${GITHUB_REF_NAME}/${ZIP_NAME}" + python3 scripts/verify-sparkle-appcast.py "$DOWNLOADED_APPCAST" \ + --archive "$DOWNLOADED_ZIP" \ + --version "$VERSION" \ + --build "$BUILD_NUMBER" \ + --url "$ARCHIVE_URL" \ + --release-notes RELEASES.md \ + --signature-output "$RUNNER_TEMP/downloaded-archive-signature.txt" + printf '%s' "$SPARKLE_PRIVATE_KEY" | \ + "$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - \ + "$DOWNLOADED_ZIP" "$(< "$RUNNER_TEMP/downloaded-archive-signature.txt")" + printf '%s' "$SPARKLE_PRIVATE_KEY" | \ + "$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - "$DOWNLOADED_APPCAST" + + - name: Publish verified GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + IS_DRAFT="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" + [ "$IS_DRAFT" = "true" ] \ + || { echo "::error::Release draft state changed before publication."; exit 1; } gh release edit "$GITHUB_REF_NAME" --draft=false - name: Bump Homebrew cask in caezium/homebrew-tap diff --git a/.github/workflows/sentry-issues.yml b/.github/workflows/sentry-issues.yml index bc264eee..d2a95467 100644 --- a/.github/workflows/sentry-issues.yml +++ b/.github/workflows/sentry-issues.yml @@ -9,15 +9,15 @@ name: sentry-issues # isn't on. This workflow needs only a read-only Sentry auth token and the # free plan's API. # -# Each filed issue also embeds the most recent event's stack trace (fetched -# from the events/latest API) in a collapsed
block, so triage rarely -# needs a trip to the Sentry dashboard. Best-effort: if the trace can't be -# fetched/parsed the issue is still filed, just without the trace block. +# Public issues contain only a small allowlist of bounded operational fields. +# Stack traces, paths, arguments, usernames, and event payloads stay in the +# restricted Sentry project and must cross a human privacy-review boundary +# before anyone copies a diagnosis into GitHub. # # App-Hang groups are collected into one rolling weekly digest instead of # opening one GitHub issue per sampled top frame. The digest still carries -# every Sentry short-id, release/build tags, and a bounded stack trace, so hangs -# stay visible without flooding the tracker. +# every Sentry short-id and bounded release/build tags, so hangs stay visible +# without flooding the tracker or publishing diagnostic payloads. # # Dedup is by a hidden marker line ("sentry-id: ") in the body of # every issue or digest we file. Before filing, we read back the short-ids of all @@ -71,10 +71,9 @@ jobs: LOOKBACK_HOURS: ${{ github.event.inputs.lookback_hours || '168' }} # Cap creations per run so a backlog can't open hundreds of issues at once. MAX_PER_RUN: "25" - # Bound each App-Hang batch so its table, traces, and markers fit inside + # Bound each App-Hang batch so its table and markers fit inside # GitHub's 65,536-character issue-body limit with room to append safely. MAX_HANG_GROUPS_PER_RUN: "20" - MAX_HANG_TRACE_CHARS: "1200" MAX_ISSUE_BODY_BYTES: "60000" # Defensive ceiling against a malformed cursor loop. Normal runs exhaust # the Sentry result set long before this (100 pages × 50 groups). @@ -83,6 +82,9 @@ jobs: GH_REPO: ${{ github.repository }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} steps: + - name: Checkout privacy filter + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Poll Sentry and file GitHub issues run: | set -euo pipefail @@ -102,56 +104,9 @@ jobs: | grep -oE 'sentry-id: [^ <]+' | awk '{print $2}' | sort -u > "$seen" || true echo "Already filed: $(wc -l < "$seen" | tr -d ' ') Sentry issue(s)." hang_rows="$(mktemp)" - hang_details="$(mktemp)" hang_markers="$(mktemp)" hang_count=0 - # jq program that distils a Sentry event into a compact stack trace. - # Prefers the crashed/current thread (the right one for App Hang/ANR - # events, where the main thread is what stalled), falling back to the - # exception's own stacktrace. Frames arrive oldest-first, so reverse - # to put the offending frame on top; cap at 30. In-app frames are - # marked with "* ". Emits "" when there are no frames to show. - read -r -d '' TRACE_JQ <<'JQ' || true - def fmtframe: - (if (.inApp // false) then "* " else " " end) - + (.function // .symbol // "") - + (if .filename then " (" + .filename + (if .lineNo then ":" + (.lineNo|tostring) else "" end) + ")" - elif .package then " [" + (.package | sub(".*/";"")) + "]" - else "" end); - ( [ .entries[]? | select(.type=="threads") | .data.values[]? - | select((.crashed // false) or (.current // false)) - | .stacktrace.frames ] | map(select(. != null)) | .[0] ) as $tf - | ( [ .entries[]? | select(.type=="exception") | .data.values[]? - | .stacktrace.frames ] | map(select(. != null)) | .[0] ) as $ef - | ( ($tf // $ef) // [] ) - | reverse | .[0:30] | map(fmtframe) | join("\n") - JQ - - # Populate four globals from a latest-event payload. Burrow's - # beforeSend policy already bounds these fields; sanitize again - # before copying them into a public GitHub issue. - populate_event_context() { - local event_json="${1:-{}}" - release=$(jq -r ' - if (.release | type) == "object" then (.release.version // "unknown") - elif (.release | type) == "string" then .release - else "unknown" - end - ' <<<"$event_json" 2>/dev/null || echo unknown) - osBuild=$(jq -r '[.tags[]? | select(.key=="os_build") | .value][0] // "unknown"' <<<"$event_json" 2>/dev/null || echo unknown) - launchPhase=$(jq -r '[.tags[]? | select(.key=="launch_phase") | .value][0] // "unknown"' <<<"$event_json" 2>/dev/null || echo unknown) - statusItem=$(jq -r '[.tags[]? | select(.key=="status_item_state") | .value][0] // "unknown"' <<<"$event_json" 2>/dev/null || echo unknown) - release=$(printf '%s' "$release" | tr -cd 'A-Za-z0-9._:+-' | cut -c1-80) - osBuild=$(printf '%s' "$osBuild" | tr -cd 'A-Za-z0-9._:+-' | cut -c1-80) - launchPhase=$(printf '%s' "$launchPhase" | tr -cd 'A-Za-z0-9._:+-' | cut -c1-80) - statusItem=$(printf '%s' "$statusItem" | tr -cd 'A-Za-z0-9._:+-' | cut -c1-80) - [ -n "$release" ] || release=unknown - [ -n "$osBuild" ] || osBuild=unknown - [ -n "$launchPhase" ] || launchPhase=unknown - [ -n "$statusItem" ] || statusItem=unknown - } - filed=0 for project in $SENTRY_PROJECTS; do # $q below is jq syntax, not a shell variable. @@ -178,7 +133,7 @@ jobs: page_fetched=$(jq 'if type=="array" then length else -1 end' "$page" 2>/dev/null || echo -1) if [ "$page_fetched" = "-1" ]; then - echo "::warning::Sentry returned a non-array response for '${project}' page $((page_count + 1)); processing pages already fetched. Head: $(head -c 160 "$page")" + echo "::warning::Sentry returned a non-array response for '${project}' page $((page_count + 1)); processing pages already fetched without logging the payload." break fi jq -c '.[]' "$page" >> "$response_rows" @@ -209,10 +164,50 @@ jobs: skipped_seen=0 skipped_old=0 - while read -r issue; do + while IFS= read -r issue; do [ -z "$issue" ] && continue - shortId=$(jq -r '.shortId // empty' <<<"$issue") - [ -z "$shortId" ] && continue + + # Raw Sentry objects remain in temporary files and are reduced by + # a fail-closed allowlist before any value can reach GitHub. + issue_json="$(mktemp)" + event_json="$(mktemp)" + summary_json="$(mktemp)" + printf '%s\n' "$issue" > "$issue_json" + printf '{}\n' > "$event_json" + + issueId=$(jq -r '.id // empty' "$issue_json" 2>/dev/null || true) + if [[ "$issueId" =~ ^[0-9]{1,20}$ ]]; then + if ! curl -fsS -o "$event_json" \ + -H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \ + "${SENTRY_HOST}/api/0/organizations/${SENTRY_ORG}/issues/${issueId}/events/latest/"; then + printf '{}\n' > "$event_json" + echo "::warning::Latest event unavailable; filing only the allowlisted issue summary." + fi + fi + + if ! python3 scripts/sentry_public_summary.py \ + --issue-file "$issue_json" \ + --event-file "$event_json" \ + --org "$SENTRY_ORG" \ + --project "$project" > "$summary_json"; then + echo "::warning::Refusing to publish a Sentry issue that did not pass the public-summary policy." + rm -f "$issue_json" "$event_json" "$summary_json" + continue + fi + rm -f "$issue_json" "$event_json" + + shortId=$(jq -r '.shortId' "$summary_json") + permalink=$(jq -r '.sentryUrl' "$summary_json") + level=$(jq -r '.level' "$summary_json") + count=$(jq -r '.count' "$summary_json") + release=$(jq -r '.release' "$summary_json") + osBuild=$(jq -r '.osBuild' "$summary_json") + launchPhase=$(jq -r '.launchPhase' "$summary_json") + statusItem=$(jq -r '.statusItem' "$summary_json") + firstSeen=$(jq -r '.firstSeen' "$summary_json") + lastSeen=$(jq -r '.lastSeen' "$summary_json") + isAppHang=$(jq -r '.isAppHang' "$summary_json") + rm -f "$summary_json" # Already filed? if grep -qxF "$shortId" "$seen"; then @@ -228,48 +223,19 @@ jobs: continue fi - # A single sampled stall may fan out across several Sentry groups - # when their top frames differ. Keep every group visible, but add - # it to one weekly digest instead of opening one GitHub issue per - # short-id. Match on issueType/title so this survives wording - # changes between Sentry Cocoa SDK versions. - issueType=$(jq -r '.issueType // empty' <<<"$issue") - titleProbe=$(jq -r '.title // .metadata.value // empty' <<<"$issue") - if printf '%s %s' "$issueType" "$titleProbe" | grep -qiE 'app[ _-]?hang|hanging'; then + # The reducer classifies hangs internally; neither the raw title + # nor any frame used by Sentry to group a hang reaches GitHub. + if [ "$isAppHang" = "true" ]; then if [ "$hang_count" -ge "$MAX_HANG_GROUPS_PER_RUN" ]; then echo "Deferring App-Hang ${shortId}: this run's bounded digest batch is full." continue fi - count=$(jq -r '.count // "?"' <<<"$issue") - lastSeen=$(jq -r '.lastSeen // "?"' <<<"$issue") - permalink=$(jq -r '.permalink // empty' <<<"$issue") - issueId=$(jq -r '.id // empty' <<<"$issue") - ev="" - trace="" - if [ -n "$issueId" ]; then - ev=$(curl -fsS -H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \ - "${SENTRY_HOST}/api/0/organizations/${SENTRY_ORG}/issues/${issueId}/events/latest/" 2>/dev/null) || ev="" - [ -n "$ev" ] && trace=$(jq -r "$TRACE_JQ" <<<"$ev" 2>/dev/null || true) - fi - if [ "${#trace}" -gt "$MAX_HANG_TRACE_CHARS" ]; then - trace="$(printf '%s' "$trace" | cut -c1-"$MAX_HANG_TRACE_CHARS") - … trace truncated by the Sentry bridge" - fi - - populate_event_context "$ev" # Backticks are literal Markdown delimiters. # shellcheck disable=SC2016 printf '| [%s](%s) | `%s` | %s | `%s` | `%s` | `%s` | %s | %s |\n' \ "$shortId" "$permalink" "$project" "$count" "$release" "$osBuild" \ "$launchPhase" "$statusItem" "$lastSeen" >> "$hang_rows" - if [ -n "$trace" ]; then - { - printf '\n
\n%s latest stack trace\n\n```\n' "$shortId" - printf '%s\n' "$trace" - printf '```\n\n
\n' - } >> "$hang_details" - fi printf 'sentry-id: %s — managed marker, do not edit or remove.\n' "$shortId" >> "$hang_markers" echo "$shortId" >> "$seen" hang_count=$((hang_count + 1)) @@ -282,29 +248,9 @@ jobs: break 2 fi - title=$(jq -r '.title // .metadata.value // .culprit // "Unknown error"' <<<"$issue") - level=$(jq -r '.level // "error"' <<<"$issue") - count=$(jq -r '.count // "?"' <<<"$issue") - lastSeen=$(jq -r '.lastSeen // "?"' <<<"$issue") - permalink=$(jq -r '.permalink // empty' <<<"$issue") - - # Fetch the most recent event so the GitHub issue carries a real - # stack trace — saves a manual Sentry dive on every triage. Pure - # best-effort: a failed request (event:read missing, message-only - # issue, network blip) or an empty jq result just files the issue - # without a trace block, exactly as before. - issueId=$(jq -r '.id // empty' <<<"$issue") - ev="" - trace="" - if [ -n "$issueId" ]; then - ev=$(curl -fsS -H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \ - "${SENTRY_HOST}/api/0/organizations/${SENTRY_ORG}/issues/${issueId}/events/latest/" 2>/dev/null) || ev="" - [ -n "$ev" ] && trace=$(jq -r "$TRACE_JQ" <<<"$ev" 2>/dev/null || true) - fi - populate_event_context "$ev" - printf -v body '%s\n' \ - "Auto-filed from Sentry by the \`sentry-issues\` workflow." \ + "Auto-filed from Sentry by the \`sentry-issues\` workflow. This public issue contains only the workflow's allowlisted operational summary." \ + "Detailed diagnostics stay in restricted Sentry. Review them there; copy only a minimal, privacy-reviewed diagnosis into this public issue." \ "" \ "| | |" \ "|---|---|" \ @@ -319,29 +265,16 @@ jobs: "| **First seen** | ${firstSeen} |" \ "| **Last seen** | ${lastSeen} |" - if [ -n "$trace" ]; then - body="${body} -
- Stack trace (most recent event) - - \`\`\` - ${trace} - \`\`\` - -
- " - fi - # Keep the managed marker LAST: the dedup read greps for # 'sentry-id:' across all sentry-labelled issues. body="${body} sentry-id: ${shortId} — managed marker, do not edit or remove (prevents duplicate filings). - " + " if gh issue create --repo "$GH_REPO" \ - --title "[Sentry] ${shortId}: ${title}" \ + --title "[Sentry] ${shortId}: restricted diagnostic" \ --label sentry \ --body "$body" >/dev/null; then - echo "Filed ${shortId}: ${title}" + echo "Filed allowlisted public summary for ${shortId}." echo "$shortId" >> "$seen" # dedup within this run too filed=$((filed + 1)) else @@ -360,7 +293,6 @@ jobs: printf '| Sentry | Project | Events | Release | OS build | Launch phase | Status item | Last seen |\n' printf '|---|---|---:|---|---|---|---|---|\n' cat "$hang_rows" - cat "$hang_details" printf '\n' cat "$hang_markers" } > "$digest_section" @@ -382,7 +314,7 @@ jobs: done # If the latest open part still has room, append there. Otherwise, - # create the next numbered part. The per-run group/trace bounds + # create the next numbered part. The per-run group bounds # above guarantee a fresh part stays below the body limit. if [ -n "$digest_number" ]; then digest_body="$(mktemp)" @@ -407,6 +339,7 @@ jobs: # shellcheck disable=SC2016 printf '%s\n\n' 'Auto-filed from Sentry by the `sentry-issues` workflow.' printf '%s\n' 'App-Hang samples can split into several Sentry groups when their top frames differ. This weekly digest keeps every group visible without opening one GitHub issue per sampled frame.' + printf '%s\n' 'Detailed diagnostics stay in restricted Sentry. Review them there; copy only a minimal, privacy-reviewed diagnosis into this public issue.' cat "$digest_section" } > "$digest_body" gh issue create --repo "$GH_REPO" --title "$digest_title" --label sentry \ diff --git a/README.md b/README.md index 8a3d2c0f..cdbc5e77 100644 --- a/README.md +++ b/README.md @@ -498,9 +498,19 @@ Windows also keeps `burrow_uninstall(action=...)` as a compatibility tool for list, leftover-preview, and confirmed vendor-uninstaller launch workflows. There's also an optional localhost REST API (`127.0.0.1:9277` — `/health`, -`/info`, `/snapshot`, `/metrics`) for dashboards or curl. On Windows, disabling -REST in Settings does not close the loopback listener because the stdio MCP -bridge still posts to `/mcp`. +`/info`, `/snapshot`, `/metrics`) for dashboards or curl. Loopback is a network +boundary, not authentication: every request needs the random per-install bearer +credential, an exact localhost `Host`, no browser `Origin`, and stays inside the +request-size/rate limits. On macOS, read the credential locally with +`defaults read dev.caezium.Burrow query_auth_token`; on Windows it lives in the +current user's `%LOCALAPPDATA%\BurrowWin\settings.json`, and the stdio bridge +adds it automatically. Disabling Windows REST does not close the listener +because the authenticated stdio bridge still posts to `/mcp`. + +```bash +BURROW_HTTP_TOKEN="$(defaults read dev.caezium.Burrow query_auth_token)" +curl -H "Authorization: Bearer $BURROW_HTTP_TOKEN" http://127.0.0.1:9277/health +``` ## Develop & test diff --git a/SECURITY.md b/SECURITY.md index e5abed63..5c6430a3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,13 +13,17 @@ bundled engine (MIT, © tw93 for the original); audit it too. The tag-release workflow fails closed unless it can sign the app and every bundled executable with a **Developer ID Application** certificate, enable the hardened runtime, obtain secure timestamps, receive an accepted notarization -result, staple the ticket, and pass Gatekeeper assessment. The external +result, staple the ticket, preserve the designated requirement's bundle/Team +identity, and pass Gatekeeper assessment. CI uploads only to a draft, downloads +the ZIP again, matches its SHA and dSYM UUIDs, and repeats strict signing, +notarization-ticket, designated-requirement, Gatekeeper, and Sparkle checks +before publication. An existing public release is immutable. The external Homebrew cask is updated only after that verified artifact exists, and its live -0.11.1 definition preserves quarantine so Gatekeeper can verify the stapled +0.11.2 definition preserves quarantine so Gatekeeper can verify the stapled ticket. Maintainer setup and release verification are in the [macOS signing runbook](docs/macos-signing.md). -The published 0.11.1 app was independently checked after download: its bundle +The published 0.11.2 app was independently checked after download: its bundle identifier is `dev.caezium.Burrow`, its Developer ID Team ID is `YGSM2722TZ`, the hardened runtime and secure timestamp are present, `stapler validate` succeeds, and Gatekeeper reports `source=Notarized Developer ID`. @@ -27,8 +31,8 @@ succeeds, and Gatekeeper reports `source=Notarized Developer ID`. The release also embeds Sparkle 2.9.4 with a checked-in Ed25519 public key. After notarization, CI signs the update ZIP and `appcast.xml`, verifies both signatures and the private/public key match, and keeps a new GitHub release in -draft until both assets exist. Sparkle verifies the signed feed and archive -again on the Mac before installing. +draft until both downloaded assets pass the distribution checks. Sparkle +verifies the signed feed and archive again on the Mac before installing. Version 0.11.0 was the first Sparkle-enabled release. The first real successor test then updated the installed signed app from 0.11.0 build 21 to 0.11.1 build @@ -128,9 +132,16 @@ This is the part people rightly scrutinize in cleaners. Burrow's model: spans/profiles. PostHog sizes, counts, and durations are **bucketed into ranges**. **What's never sent:** screenshots, screen recordings, file names, file contents, user paths, URLs, your metrics/history, or any account - identity. Crash events remove path-bearing image/frame fields; profiling is - limited to apps under `/Applications` because profile envelopes bypass that - event scrubber. Automatic network/file tracing is disabled. **Your IP isn't + identity. Crash events explicitly disable Sentry stack-memory introspection; + remove exception values, raw registers, source/package/context/variable data, + and every path-bearing image/frame field; and retain only bounded diagnostic + labels, UUIDs, and addresses. Profiling is limited to apps under + `/Applications` because profile envelopes bypass that event scrubber. + Automatic network/file tracing is disabled. The public GitHub bridge exposes + only bounded release/OS/launch/count fields plus a restricted Sentry link; + titles, frames, paths, usernames, arguments, and event payloads remain in + Sentry until a maintainer writes a minimal privacy-reviewed diagnosis. + **Your IP isn't stored** — PostHog events carry `$ip = "0"` (and the project discards client IPs), and Sentry sets `sendDefaultPii = false`. It's **on by default**; turn it off in **Settings → Anonymous usage** and both pipelines stop. The exact @@ -168,10 +179,13 @@ This is the part people rightly scrutinize in cleaners. Burrow's model: telemetry switch is on. - **Local-only surfaces:** - The MCP **HTTP query server** binds `127.0.0.1:9277` (loopback only; **on - by default**). It serves your local metrics to local MCP clients; it is not - reachable off-device, and it sends no CORS grant, so web pages in your - browser can't read it either. In the Windows preview, the Settings toggle - disables REST endpoints but keeps the loopback `/mcp` route bound so the + by default**). It serves local metrics only after a per-install bearer + credential, exact localhost `Host`, non-browser request check, method/body + policy, and bounded request-rate check all pass. The server sends no CORS + grant and rejects `Origin`, `Referer`, and browser fetch-metadata headers, + so DNS rebinding and hostile web callers fail closed even if they target + loopback. In the Windows preview, the Settings toggle disables REST + endpoints but keeps the authenticated loopback `/mcp` route bound so the stdio bridge can continue to work. - The **stdio MCP server** (`Burrow --mcp`) is a local subprocess. - History is a local **SQLite** file under diff --git a/TELEMETRY.md b/TELEMETRY.md index ab78aa91..8b06adba 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -63,10 +63,14 @@ own project instead. Opting out leaves those ids and any Sentry cache on disk; deleting the app's Application Support and Caches folders removes them. - **No PII, ever.** `DiagnosticPrivacy.sanitize()` drops sensitive keys (paths, - file names, contents, URLs, tokens, email, username, identifiers, …), accepts - only primitive values, and replaces complete path-like strings. Sentry event - frames keep symbols/modules/debug IDs while all package, source-file, and - binary-image path fields are removed. + file names, contents, URLs, tokens, email, username, identifiers, arguments, + headers, payloads, …), accepts only primitive values, and replaces complete + path-like strings. Sentry stack-memory introspection is explicitly disabled. + Before transport, exception values and mechanism metadata are removed, + contexts are recursively sanitized, raw registers are cleared, and debug/frame + labels, UUIDs, and addresses must pass strict bounded formats. Package, + source-file, source-context, variable, and binary-image path fields are + removed. - **PostHog sizes/counts/durations are bucketed**, never raw — see `bytesBucket`, `countBucket`, `secondsBucket`. Sentry's sampled performance traces necessarily contain precise span timing, but every span has a fixed @@ -125,7 +129,7 @@ for about 1% of launches overall; profiling is disabled from Downloads, home directories, mounted volumes, and other relocated paths because profile envelopes contain binary-image paths. Pre-main profiling is always disabled. Burrow adds at most 50 fixed-name manual breadcrumbs and fixed-name -warning/error logs. Automatic network breadcrumbs, failed-request capture, +warning/error logs. Stack-memory introspection, automatic network breadcrumbs, failed-request capture, file I/O tracing, Core Data tracing, UI tracing, screenshots, and view-hierarchy capture remain disabled. @@ -135,10 +139,13 @@ fixed `move_to_applications` recovery, ordinary network download failures stay in PostHog with `sparkle_scheduled_retry`, and user cancellations are recorded as completed cycles. Configuration, signature/validation, installation, and otherwise unknown failures still create a scrubbed Sentry diagnostic. The -GitHub bridge includes bounded release, OS-build, launch-phase, status-item, -count, and stack information on every new issue. App-Hang groups are aggregated -into a weekly digest instead of being silently skipped or opening one issue per -sampled frame. +public GitHub bridge includes only the Sentry short ID/restricted link and +bounded project, release, OS-build, launch-phase, status-item, level, count, and +time fields. It never copies raw Sentry titles, frames, paths, usernames, +arguments, or payloads. Maintainers review detailed diagnostics in restricted +Sentry and copy only a minimal privacy-reviewed diagnosis into the public issue. +App-Hang groups are aggregated into a weekly digest instead of being silently +skipped or opening one issue per sampled frame. Automatic Sparkle startup begins only after the status item has remained responsive for 30 seconds, then receives a separate 30-second durable diff --git a/docs/macos-signing.md b/docs/macos-signing.md index bc5dd9f2..47287bf6 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -190,18 +190,26 @@ renders those comments as visible text. Full site history belongs in The workflow order is: -1. Require all Apple, Sparkle, and external-tap secrets, then verify that - `TAP_PAT` can write `caezium/homebrew-tap`. -2. Fetch and checksum-validate the official Sentry and Sparkle frameworks, - then build and confirm the bundled conductor, engine, and fclones sidecar. -3. Require the Sparkle private seed to match the public key embedded in the app. -4. Sign every executable and the outer app with Developer ID. -5. Require Apple’s notarization result to be `Accepted`. -6. Staple and validate the ticket, then require Gatekeeper acceptance. -7. Package the exact verified app, generate the signed appcast, and - cryptographically verify the ZIP and feed before publishing either asset. -8. Keep a new release draft until both assets exist, then publish it. -9. Update `caezium/homebrew-tap`: normalize `auto_updates true` beside the +1. Require all Apple, Sparkle, Sentry-symbol, and external-tap secrets, then + verify that `TAP_PAT` can write `caezium/homebrew-tap`. +2. Fetch the checksum-locked release inputs and prove that XcodeGen produces + identical metadata twice from the checked-in version/build source. +3. Require the triggering tag to name the checked-out SHA exactly, then run the + release-helper, website, and complete macOS unit suites on that commit. +4. Build and confirm the bundled conductor, engine, and fclones sidecar. +5. Require the app binary UUIDs to exactly match its dSYM, validate the dSYM, + and upload it without source files before release publication can continue. +6. Require the Sparkle private seed to match the public key embedded in the app. +7. Sign every executable and the outer app with Developer ID, preserving bundle + ID `dev.caezium.Burrow` and Team ID `YGSM2722TZ` in the designated requirement. +8. Require Apple’s notarization result to be `Accepted`, staple the ticket, then + require strict code-signature, designated-requirement, and Gatekeeper checks. +9. Package the exact verified app, generate the signed appcast, and + cryptographically verify the ZIP and feed before uploading either to a draft. +10. Download both draft assets again, match the ZIP SHA and dSYM UUIDs, extract + the app, and repeat Developer ID, requirement, ticket, Gatekeeper, and + Sparkle checks before making the release public. +11. Update `caezium/homebrew-tap`: normalize `auto_updates true` beside the homepage stanza and fail if the legacy quarantine bypass, unsigned warning, or stale security note ever reappears. @@ -218,11 +226,16 @@ Download the release asset and verify the distributed copy: ditto -x -k Burrow-VERSION.zip verified-release codesign --verify --deep --strict --verbose=2 verified-release/Burrow.app codesign -d --verbose=4 verified-release/Burrow.app +codesign -d -r- verified-release/Burrow.app xcrun stapler validate verified-release/Burrow.app spctl --assess --type execute --verbose=4 verified-release/Burrow.app ``` The final `spctl` result must identify the source as `Notarized Developer ID`. +The designated requirement must contain `identifier "dev.caezium.Burrow"`, +`anchor apple generic`, and Team ID `YGSM2722TZ`; changing any of those values +can break upgrade and Full Disk Access continuity even when a signature is +otherwise valid. Check that Homebrew’s live `Casks/burrow.rb` no longer contains `postflight`, `xattr -cr`, or an unsigned-build caveat, and that it contains `auto_updates true`. Confirm the release has both `Burrow-VERSION.zip` and @@ -230,7 +243,28 @@ Check that Homebrew’s live `Casks/burrow.rb` no longer contains `postflight`, `https://github.com/caezium/Burrow/releases/latest/download/appcast.xml` resolves to that feed. -### 0.11.1 current trust-chain baseline +### 0.11.2 current release and symbol baseline + +The current release was re-verified from its downloaded GitHub asset on August +8, 2026. Tag `v0.11.2` points to +`c08de9714d51b6f6aa580ce9e9b71bebf4fb86be`; the ZIP has SHA-256 +`2a51a87d541be50cd3b2ab9418dfcaf20927d16499a265298b53bbc100d4eca5`, +and `appcast.xml` has SHA-256 +`04ac0ea7efcade3d0ea87455a1ab849ebd853c9a288c09b545d3d59bfc04feaa`. +The extracted app reports version 0.11.2, build 23, bundle ID +`dev.caezium.Burrow`, and Team ID `YGSM2722TZ`; strict nested-signature, +designated-requirement, stapler, and Gatekeeper checks all pass. + +The distributed binary UUIDs are +`324C0B80-A09E-346C-8153-7BDCCB37A24C` (arm64) and +`B0DF1FAF-0575-3865-8C7F-E2CBFFD141CC` (x86_64). The +[tag workflow log](https://github.com/caezium/Burrow/actions/runs/30934193823) +records those exact two UUIDs as uploaded Sentry debug companions, proving that +the 0.11.2 dSYM matches the public release binary. That evidence does not +recover BURROW-9E by itself: the older event still needs restricted Sentry data +and an actionable frame, so the trigger below remains in force. + +### 0.11.1 trust-chain baseline The current release was verified on August 3, 2026. Tag `v0.11.1` points to `d482544e415d10cf9cb0c606c8a8ce149ddad99d`; the published ZIP has SHA-256 @@ -276,6 +310,27 @@ existing FDA grant. [#319](https://github.com/caezium/Burrow/issues/319) remains open until an affected macOS 27 Beta 4 user verifies the notarized compatibility build. +The next signed successor test must start with Full Disk Access enabled for the +currently installed release, update through Sparkle, and prove the relaunched +copy retains protected-folder access without another grant. The release job now +enforces the stable designated requirement on both its local app and the +downloaded draft, but the 0.11.0 → 0.11.1 test cannot supply this external TCC +evidence because Full Disk Access was off before that update. + +### Symbolication incident trigger + +The public report for BURROW-9E ([#306](https://github.com/caezium/Burrow/issues/306)) +contains only an unknown top frame, so it does not establish a failing function +or reproduction path. The next actionable trigger is the first matching event +from a tag produced after the mandatory UUID-verified dSYM upload gate above. +In restricted Sentry, compare that event's debug ID with the UUIDs printed by +`verify-dsym-uuids.sh`; if they match, record the first symbolicated in-app frame +and the smallest reproducible launch/update condition. If the frame remains +unknown, retain Sentry's processing error and mismatched/missing debug ID there +and block the next tag until the upload is corrected. Public GitHub issues get +only a minimal privacy-reviewed diagnosis and the restricted Sentry link, never +the raw event, stack, local paths, arguments, or usernames. + ### 0.11.0 historical first-signed baseline The first signed release was verified on August 1, 2026. Tag `v0.11.0` points diff --git a/macos/Resources/Info.plist b/macos/Resources/Info.plist index 78ba4496..37a6c1c9 100644 --- a/macos/Resources/Info.plist +++ b/macos/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.12.0 + $(MARKETING_VERSION) CFBundleVersion - 24 + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSUIElement diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 5845d2b0..826ac99f 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -429,7 +429,9 @@ "Use Settings › Engine › Update external engine, then try again." = "前往“设置 › 引擎 › 更新外部引擎”,然后重试。"; "Reinstall Burrow to restore the bundled engine." = "重新安装 Burrow 以恢复捆绑引擎。"; "Lets `sudo` and admin prompts accept your fingerprint instead of a password, where macOS supports it. Configured via `mo touchid`; turning it on or off needs your password once." = "在 macOS 支持的情况下,让 `sudo` 和管理员提示接受你的指纹而非密码。通过 `mo touchid` 配置;开启或关闭需要输入一次密码。"; -"Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch." = "供仪表盘或 curl 使用的可选 REST 接口:在本机提供 /health、/info、/snapshot、/metrics。与上面的 MCP stdio 服务器相互独立;开关与端口更改在重新启动后生效。"; +"Authentication" = "身份验证"; +"Bearer token required" = "需要 Bearer 令牌"; +"Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Every request needs the per-install token; retrieve it locally with `defaults read dev.caezium.Burrow query_auth_token`. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch." = "供仪表盘或 curl 使用的可选 REST 接口:在本机提供 /health、/info、/snapshot、/metrics。每个请求都需要该安装实例的令牌;可在本机运行 `defaults read dev.caezium.Burrow query_auth_token` 获取。它与上面的 MCP stdio 服务器相互独立;开关与端口更改会在重新启动后生效。"; "Applies immediately. When off, Burrow shows a Dock icon instead so it stays reachable — a Dock click reopens the window." = "立即生效。关闭后,Burrow 会改为显示 Dock 图标以便随时可达 — 点击 Dock 图标即可重新打开窗口。"; /* Onboarding / Full Disk Access */ diff --git a/macos/Resources/zh-Hant.lproj/Localizable.strings b/macos/Resources/zh-Hant.lproj/Localizable.strings index f6a6311a..b9fddadd 100644 --- a/macos/Resources/zh-Hant.lproj/Localizable.strings +++ b/macos/Resources/zh-Hant.lproj/Localizable.strings @@ -429,7 +429,9 @@ "Use Settings › Engine › Update external engine, then try again." = "前往「設定 › 引擎 › 更新外引擎」,然後再試一次。"; "Reinstall Burrow to restore the bundled engine." = "重新安裝 Burrow 以還原內建引擎。"; "Lets `sudo` and admin prompts accept your fingerprint instead of a password, where macOS supports it. Configured via `mo touchid`; turning it on or off needs your password once." = "在 macOS 支援的情況下,讓 `sudo` 與管理者提示可以使用指紋代替密碼。透過 `mo touchid` 設定;開啟或關閉需要輸入一次密碼。"; -"Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch." = "供儀表板或 curl 使用的選用 REST 介面:在 localhost 提供 /health、/info、/snapshot、/metrics。與上方的 MCP stdio 伺服器各自獨立;開關與連接埠變更會在重新啟動後生效。"; +"Authentication" = "身分驗證"; +"Bearer token required" = "需要 Bearer 權杖"; +"Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Every request needs the per-install token; retrieve it locally with `defaults read dev.caezium.Burrow query_auth_token`. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch." = "供儀表板或 curl 使用的選用 REST 介面:在 localhost 提供 /health、/info、/snapshot、/metrics。每個請求都需要此安裝項目的權杖;可在本機執行 `defaults read dev.caezium.Burrow query_auth_token` 取得。它與上方的 MCP stdio 伺服器各自獨立;開關與連接埠變更會在重新啟動後生效。"; "Applies immediately. When off, Burrow shows a Dock icon instead so it stays reachable — a Dock click reopens the window." = "立即生效。關閉後,Burrow 會改為顯示 Dock 圖示以便隨時開啟 — 點一下 Dock 圖示即可重新打開視窗。"; /* Onboarding / Full Disk Access */ diff --git a/macos/Sources/CleanView.swift b/macos/Sources/CleanView.swift index 8976446c..40708dfc 100644 --- a/macos/Sources/CleanView.swift +++ b/macos/Sources/CleanView.swift @@ -37,6 +37,7 @@ struct CleanView: View { @State private var screen: Screen = .hero /// Parsed clean-list.txt + locked map, loaded when entering review. @State private var reviewList: CleanList? + @State private var reviewSnapshot: CleanupSnapshot? @State private var reviewLocked: [String: CleanSelection.LockReason] = [:] /// When the dry-run finished — the review goes stale after a few /// minutes (TOCTOU: caches appear between preview and run). @@ -129,7 +130,6 @@ struct CleanView: View { private var idleHero: some View { ToolHero(tool: .clean, title: "Clean", subtitle: Tool.clean.tagline) { PillButton(title: "Scan your Mac") { startDry() } - PillButton(title: "Clean Now", filled: false) { confirmDirectClean() } } } @@ -179,10 +179,6 @@ struct CleanView: View { HStack(spacing: 12) { if reviewAvailable { PillButton(title: "Review results") { enterReview() } - } else if dryFlow.report?.summary != nil { - // clean-list.txt didn't parse (format drift) — fail - // soft to the direct path with the engine's total. - PillButton(title: "Clean Now") { confirmDirectClean() } } PillButton(title: "Rescan", filled: false) { startDry() } Button { dryFlow.reset(); screen = .hero } label: { @@ -228,6 +224,18 @@ struct CleanView: View { private func enterReview() { guard let list = CleanList.loadLive() else { return } + do { + let user = try InvokingUserIdentity.current() + reviewSnapshot = try CleanupSnapshot.capture( + list: list, approvedRootURLs: CleanupSnapshot.approvedRoots(for: user)) + } catch { + let alert = NSAlert() + alert.messageText = NSLocalizedString("This preview can't be cleaned safely", comment: "") + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.runModalQuiet() + return + } reviewList = list reviewLocked = CleanLock.lockedPaths(in: list, running: CleanLock.runningApps()) screen = .review @@ -237,6 +245,7 @@ struct CleanView: View { /// caches that appeared after the scan would be cleaned unreviewed) /// force a rescan instead of a run. private func confirmClean(_ selection: CleanSelection) { + guard let snapshot = reviewSnapshot else { return } if let finished = scanFinishedAt, Date().timeIntervalSince(finished) > Self.reviewFreshSeconds { let alert = NSAlert() alert.messageText = NSLocalizedString("This preview is stale", comment: "") @@ -247,9 +256,9 @@ struct CleanView: View { return } if Store.cacheRemovalMode == .trash { - trashTicked(selection) + trashTicked(selection, snapshot: snapshot) } else { - runRealClean(selection) + runRealClean(selection, snapshot: snapshot) } } @@ -260,56 +269,25 @@ struct CleanView: View { // MARK: - The real run (permanent mode) - private func runRealClean(_ selection: CleanSelection) { - // Unticked paths ride a fenced whitelist session for exactly this - // run. All-ticked writes nothing — the engine's history stays - // canonical for the common case. + private func runRealClean(_ selection: CleanSelection, snapshot: CleanupSnapshot) { + let paths = selection.list.categories.flatMap(\.items).map(\.path) + .filter { selection.isTicked($0) } + let plan: CleanupExecutionPlan do { - try MoleWhitelist.live.beginSession(excluding: selection.excludedPaths) + plan = try snapshot.plan(selectedPaths: paths) } catch { let alert = NSAlert() - alert.messageText = NSLocalizedString("Couldn't protect deselected items", comment: "") - alert.informativeText = String(format: NSLocalizedString("Writing the whitelist failed (%@), so the engine would clean everything it found. Nothing was cleaned.", comment: ""), error.localizedDescription) + alert.messageText = NSLocalizedString("The reviewed files changed", comment: "") + alert.informativeText = String(format: NSLocalizedString("Nothing was cleaned. Rescan before trying again. (%@)", comment: ""), error.localizedDescription) alert.alertStyle = .warning alert.runModalQuiet() return } screen = .hero - realFlow.start(.moleStream(["clean"], elevated: true, - label: NSLocalizedString("Cleaning caches", comment: ""), - notifyOnEnd: true)) - // Restore is owned by the RUN, not the view: this watcher ends the - // fenced session however the flow finishes, even if the user - // navigates away mid-clean (a view-attached onChange would never - // fire then, leaving the block to skip those paths until the next - // launch sweep). endSession is idempotent; the startup sweep still - // covers a crash. - let flow = realFlow - Task { @MainActor in - for await state in flow.$state.values { - if case .finished = state { - try? MoleWhitelist.live.endSession() - break - } - } - } - } - - /// The pre-review direct path ("Clean Now" on the hero) — everything - /// the engine decides, no session. Kept because the review needs a - /// parseable clean-list and this path must survive format drift. - private func confirmDirectClean() { - let alert = NSAlert() - alert.messageText = NSLocalizedString("Clean caches for real?", comment: "") - alert.informativeText = NSLocalizedString("Burrow will run `mo clean` with administrator rights. Cache files are removed permanently; Mole's whitelist and safety rules still apply.", comment: "") - alert.alertStyle = .warning - alert.addButton(withTitle: NSLocalizedString("Clean", comment: "")) - alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) - guard alert.runModalQuiet() == .alertFirstButtonReturn else { return } - screen = .hero - realFlow.start(.moleStream(["clean"], elevated: true, - label: NSLocalizedString("Cleaning caches", comment: ""), - notifyOnEnd: true)) + realFlow.start(ToolOperation( + label: NSLocalizedString("Cleaning reviewed caches", comment: ""), + executable: .path("/usr/bin/find"), arguments: [], elevated: true, + cleanupPlan: plan, reduce: { parseTaskReport($0) }, notifyOnEnd: true)) } // MARK: - Trash mode @@ -319,7 +297,7 @@ struct CleanView: View { /// engine's own dry-run enumeration. Trade-off (stated in Settings): /// space frees when Trash empties, and the run isn't in `mo history` /// — it lands in Burrow's Activity log instead. - private func trashTicked(_ selection: CleanSelection) { + private func trashTicked(_ selection: CleanSelection, snapshot: CleanupSnapshot) { let paths = selection.list.categories.flatMap(\.items).map(\.path) .filter { selection.isTicked($0) } // Refuse anything that didn't come from the dry-run enumeration. @@ -332,20 +310,25 @@ struct CleanView: View { alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) guard alert.runModalQuiet() == .alertFirstButtonReturn else { return } + let plan: CleanupExecutionPlan + do { + plan = try snapshot.plan(selectedPaths: paths) + } catch { + let changed = NSAlert() + changed.messageText = NSLocalizedString("The reviewed files changed", comment: "") + changed.informativeText = NSLocalizedString("Nothing was moved. Rescan before trying again.", comment: "") + changed.alertStyle = .warning + changed.runModalQuiet() + return + } + screen = .hero let opID = UUID() OperationCenter.shared.begin(opID, label: NSLocalizedString("Moving caches to Trash", comment: ""), notifiesOnEnd: true) DispatchQueue.global(qos: .userInitiated).async { - var moved = 0, failed = 0 - for path in paths { - do { - try FileManager.default.trashItem(at: URL(fileURLWithPath: path), resultingItemURL: nil) - moved += 1 - } catch { - failed += 1 - } - } + let result = CleanupExecutor.moveToTrash(plan) + let moved = result.moved, failed = result.failed DispatchQueue.main.async { OperationCenter.shared.end(opID, success: failed == 0, detail: String(format: NSLocalizedString("%d moved · %d failed", comment: ""), moved, failed)) @@ -421,6 +404,8 @@ struct CleanView: View { private func startDry() { trashResult = nil + reviewList = nil + reviewSnapshot = nil screen = .hero dryFlow.reset() dryFlow.start(dryOperation()) diff --git a/macos/Sources/CleanupAuthorization.swift b/macos/Sources/CleanupAuthorization.swift new file mode 100644 index 00000000..10c96a3f --- /dev/null +++ b/macos/Sources/CleanupAuthorization.swift @@ -0,0 +1,202 @@ +// +// CleanupAuthorization.swift +// Burrow +// +// The dry-run file is untrusted input. A review is backed by one immutable, +// short-lived snapshot of canonical allow roots and lstat identities. The +// confirmation renders that snapshot and execution consumes a sealed subset +// of the same value; it never re-reads clean-list.txt for authority. +// + +import Foundation +import CryptoKit +import Darwin + +struct CleanupExecutionPlan: Sendable, Equatable { + struct Item: Sendable, Equatable { + let identity: PinnedFileIdentity + } + + let snapshotID: UUID + let createdAt: Date + let expiresAt: Date + let approvedRoots: [PinnedFileIdentity] + let items: [Item] + private let seal: Data + + fileprivate init(snapshotID: UUID, createdAt: Date, expiresAt: Date, + approvedRoots: [PinnedFileIdentity], items: [Item]) { + self.snapshotID = snapshotID + self.createdAt = createdAt + self.expiresAt = expiresAt + self.approvedRoots = approvedRoots + self.items = items + self.seal = Self.makeSeal(snapshotID: snapshotID, createdAt: createdAt, + expiresAt: expiresAt, roots: approvedRoots, items: items) + } + + func validateForLaunch(now: Date = Date()) -> Bool { + guard now <= expiresAt, !items.isEmpty, + seal == Self.makeSeal(snapshotID: snapshotID, createdAt: createdAt, + expiresAt: expiresAt, roots: approvedRoots, items: items), + approvedRoots.allSatisfy({ $0.matchesCurrent() }), + items.allSatisfy({ $0.identity.matchesCurrent() }) else { return false } + return items.allSatisfy { item in + approvedRoots.contains { root in + item.identity.device == root.device && + item.identity.path != root.path && + item.identity.path.hasPrefix(root.path.hasSuffix("/") ? root.path : root.path + "/") + } + } + } + + /// Checks serialized into the administrator shell. They are intentionally + /// repeated after the password dialog because that dialog is an unbounded + /// attacker-controlled delay. + func executionBoundaryChecks() -> [String] { + let expiry = Int(expiresAt.timeIntervalSince1970) + let identities = approvedRoots + items.map(\.identity) + return ["[ \"$(/bin/date +%s)\" -le \(expiry) ]"] + identities.map { identity in + let path = MoleCLI.shellQuote(identity.path) + let token = MoleCLI.shellQuote(identity.shellStatToken) + return "[ \"$(/usr/bin/stat -f '%d:%i:%u:%p' -- \(path) 2>/dev/null)\" = \(token) ]" + } + } + + private static func makeSeal(snapshotID: UUID, createdAt: Date, expiresAt: Date, + roots: [PinnedFileIdentity], items: [Item]) -> Data { + func line(_ i: PinnedFileIdentity) -> String { + "\(i.path.utf8.count):\(i.path)|\(i.shellStatToken)" + } + let payload = ([snapshotID.uuidString, + String(createdAt.timeIntervalSince1970.bitPattern), + String(expiresAt.timeIntervalSince1970.bitPattern)] + + roots.map(line) + ["--items--"] + items.map { line($0.identity) }) + .joined(separator: "\n") + return Data(SHA256.hash(data: Data(payload.utf8))) + } +} + +struct CleanupSnapshot: Sendable, Equatable { + enum SnapshotError: LocalizedError, Equatable { + case noApprovedRoots + case malformedPath(String) + case symbolicLink(String) + case outsideApprovedRoots(String) + case unexpectedVolume(String) + case missingPath(String) + case selectionMismatch + case staleOrChanged + + var errorDescription: String? { + switch self { + case .noApprovedRoots: return "No canonical cleanup roots are available." + case .malformedPath(let p): return "The cleanup preview contained a malformed path: \(p)" + case .symbolicLink(let p): return "The cleanup preview contained a symbolic link: \(p)" + case .outsideApprovedRoots(let p): return "The cleanup preview escaped its approved roots: \(p)" + case .unexpectedVolume(let p): return "The cleanup preview crossed onto an unexpected volume: \(p)" + case .missingPath(let p): return "A reviewed cleanup item no longer exists: \(p)" + case .selectionMismatch: return "The cleanup selection no longer matches the reviewed preview." + case .staleOrChanged: return "The cleanup preview is stale or changed." + } + } + } + + static let lifetime: TimeInterval = 300 + + let id: UUID + let createdAt: Date + let expiresAt: Date + let list: CleanList + let approvedRoots: [PinnedFileIdentity] + let items: [CleanupExecutionPlan.Item] + + static func capture(list: CleanList, + approvedRootURLs: [URL], + now: Date = Date()) throws -> Self { + let roots = try approvedRootURLs.compactMap { raw -> PinnedFileIdentity? in + guard let canonical = InvokingUserIdentity.canonicalPath(raw.path) else { return nil } + let identity = try PinnedFileIdentity.capture(canonical) + guard identity.isDirectory else { return nil } + return identity + } + guard !roots.isEmpty else { throw SnapshotError.noApprovedRoots } + + var seen = Set() + var captured: [CleanupExecutionPlan.Item] = [] + for item in list.categories.flatMap(\.items) { + let raw = item.path + guard !raw.isEmpty, raw.hasPrefix("/"), + !raw.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + !raw.hasPrefix("{"), !raw.hasPrefix("[") else { + throw SnapshotError.malformedPath(raw) + } + var lst = stat() + guard lstat(raw, &lst) == 0 else { throw SnapshotError.missingPath(raw) } + guard (lst.st_mode & S_IFMT) != S_IFLNK else { throw SnapshotError.symbolicLink(raw) } + guard let canonical = InvokingUserIdentity.canonicalPath(raw), canonical == raw else { + throw SnapshotError.symbolicLink(raw) + } + guard seen.insert(canonical).inserted else { continue } + let identity = try PinnedFileIdentity.capture(canonical) + _ = try approvedRoot(for: canonical, device: identity.device, roots: roots) + captured.append(.init(identity: identity)) + } + guard !captured.isEmpty else { throw SnapshotError.selectionMismatch } + return Self(id: UUID(), createdAt: now, expiresAt: now.addingTimeInterval(lifetime), + list: list, approvedRoots: roots, items: captured) + } + + static func approvedRoot(for path: String, device: UInt64, + roots: [PinnedFileIdentity]) throws -> PinnedFileIdentity { + guard let root = roots.first(where: { + path != $0.path && path.hasPrefix($0.path.hasSuffix("/") ? $0.path : $0.path + "/") + }) else { throw SnapshotError.outsideApprovedRoots(path) } + guard device == root.device else { throw SnapshotError.unexpectedVolume(path) } + return root + } + + func plan(selectedPaths: [String], now: Date = Date()) throws -> CleanupExecutionPlan { + guard now <= expiresAt else { throw SnapshotError.staleOrChanged } + let selected = Set(selectedPaths) + let byPath = Dictionary(uniqueKeysWithValues: items.map { ($0.identity.path, $0) }) + guard !selected.isEmpty, selected.count == selectedPaths.count, + selected.allSatisfy({ byPath[$0] != nil }) else { + throw SnapshotError.selectionMismatch + } + let ordered = items.filter { selected.contains($0.identity.path) } + let result = CleanupExecutionPlan(snapshotID: id, createdAt: createdAt, + expiresAt: expiresAt, + approvedRoots: approvedRoots, items: ordered) + guard result.validateForLaunch(now: now) else { throw SnapshotError.staleOrChanged } + return result + } + + static func approvedRoots(for user: InvokingUserIdentity) -> [URL] { + [URL(fileURLWithPath: user.canonicalHome, isDirectory: true), + URL(fileURLWithPath: "/Library/Caches", isDirectory: true), + URL(fileURLWithPath: "/Library/Logs", isDirectory: true), + URL(fileURLWithPath: "/private/var/folders", isDirectory: true)] + } +} + +enum CleanupExecutor { + struct Result: Sendable, Equatable { let moved: Int; let failed: Int } + + static func moveToTrash(_ plan: CleanupExecutionPlan, + fileManager: FileManager = .default) -> Result { + guard plan.validateForLaunch() else { return Result(moved: 0, failed: plan.items.count) } + var moved = 0, failed = 0 + for item in plan.items { + guard item.identity.matchesCurrent() else { failed += 1; continue } + do { + try fileManager.trashItem(at: URL(fileURLWithPath: item.identity.path), + resultingItemURL: nil) + moved += 1 + } catch { + failed += 1 + } + } + return Result(moved: moved, failed: failed) + } +} diff --git a/macos/Sources/CrashReporter.swift b/macos/Sources/CrashReporter.swift index 50cf771d..aa854b4a 100644 --- a/macos/Sources/CrashReporter.swift +++ b/macos/Sources/CrashReporter.swift @@ -152,6 +152,12 @@ enum CrashReporter { options.dsn = dsn options.environment = "production" options.releaseName = release + // Sentry 9.24+ can inspect stack-adjacent memory after a crash and + // promote discovered Objective-C/C strings into the event. Keep + // this explicit even though the SDK now defaults it off: those + // bytes can contain file contents, credentials, or user input and + // never belong in Burrow diagnostics. + options.enableMemoryIntrospection = false // Custom, fixed-name startup/update spans only. Automatic network, // file, Core Data, and UI tracing remains disabled so URLs and // local paths cannot enter performance events. @@ -386,11 +392,13 @@ enum CrashReporter { let stacktraces = (event.exceptions?.compactMap { $0.stacktrace } ?? []) + (event.threads?.compactMap { $0.stacktrace } ?? []) let frames = stacktraces.flatMap(\.frames).reversed() - guard let function = frames.first(where: { frame in + guard let rawFunction = frames.first(where: { frame in frame.inApp?.boolValue == true || frame.module?.localizedCaseInsensitiveContains("Burrow") == true || frame.package?.localizedCaseInsensitiveContains("Burrow.app") == true - })?.function else { return nil } + })?.function, + let function = DiagnosticPrivacy.safeDiagnosticLabel(rawFunction) + else { return nil } let allowed = function.unicodeScalars.filter { CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_.$:<>-")).contains($0) } @@ -417,7 +425,7 @@ enum CrashReporter { /// module names, symbols, addresses, and line numbers, which are enough to /// symbolicate Burrow without transmitting exception prose, source text, /// request data, or where its bundle lived on the user's Mac. - private static func scrubForTransport(_ event: Event) { + static func scrubForTransport(_ event: Event) { event.message = nil event.error = nil event.request = nil @@ -427,7 +435,15 @@ enum CrashReporter { event.serverName = nil event.logger = nil event.transaction = nil - if event.fingerprint?.first != "burrow-app-hang" { + if let fingerprint = event.fingerprint, + fingerprint.count == 3, + fingerprint.first == "burrow-app-hang", + let phase = DiagnosticPrivacy.safeDiagnosticLabel(fingerprint[1]), + let frame = DiagnosticPrivacy.safeDiagnosticLabel(fingerprint[2]), + phase.utf8.count <= 80, + frame.utf8.count <= 120 { + event.fingerprint = ["burrow-app-hang", phase, frame] + } else { event.fingerprint = nil } @@ -464,9 +480,14 @@ enum CrashReporter { // text, and uncaught exception reasons). The exception type and // symbolicated stack retain the actionable diagnosis. exception.value = "" - exception.module = exception.module.map(DiagnosticPrivacy.redact) + exception.type = DiagnosticPrivacy.safeDiagnosticLabel(exception.type) + exception.module = DiagnosticPrivacy.safeDiagnosticLabel(exception.module) exception.mechanism?.desc = nil exception.mechanism?.helpLink = nil + exception.mechanism?.meta = nil + if let mechanism = exception.mechanism { + mechanism.type = DiagnosticPrivacy.safeDiagnosticLabel(mechanism.type) ?? "unknown" + } if let data = exception.mechanism?.data { exception.mechanism?.data = DiagnosticPrivacy.sanitize(data) } @@ -484,13 +505,30 @@ enum CrashReporter { return true } - event.debugMeta?.forEach { $0.codeFile = nil } + event.debugMeta?.forEach { meta in + meta.codeFile = nil + meta.debugID = DiagnosticPrivacy.safeDebugID(meta.debugID) + meta.type = ["apple", "macho"].contains(meta.type ?? "") ? meta.type : nil + meta.imageAddress = DiagnosticPrivacy.safeHexAddress(meta.imageAddress) + meta.imageVmAddress = DiagnosticPrivacy.safeHexAddress(meta.imageVmAddress) + } let stacktraces = (event.exceptions?.compactMap { $0.stacktrace } ?? []) + (event.threads?.compactMap { $0.stacktrace } ?? []) + + [event.stacktrace].compactMap { $0 } for st in stacktraces { + // Raw register contents are unnecessary once frame instruction and + // image addresses are retained, and can point into user memory. + st.registers = [:] for frame in st.frames { frame.package = nil frame.fileName = nil + frame.function = DiagnosticPrivacy.safeDiagnosticLabel(frame.function) + frame.module = DiagnosticPrivacy.safeDiagnosticLabel(frame.module) + frame.platform = ["cocoa", "native"].contains(frame.platform ?? "") + ? frame.platform : nil + frame.symbolAddress = DiagnosticPrivacy.safeHexAddress(frame.symbolAddress) + frame.imageAddress = DiagnosticPrivacy.safeHexAddress(frame.imageAddress) + frame.instructionAddress = DiagnosticPrivacy.safeHexAddress(frame.instructionAddress) frame.contextLine = nil frame.preContext = nil frame.postContext = nil diff --git a/macos/Sources/ExternalSparkleUpdateSession.swift b/macos/Sources/ExternalSparkleUpdateSession.swift new file mode 100644 index 00000000..43b7b604 --- /dev/null +++ b/macos/Sources/ExternalSparkleUpdateSession.swift @@ -0,0 +1,192 @@ +// +// ExternalSparkleUpdateSession.swift +// Burrow +// +// Drives Sparkle against another installed app bundle. Sparkle remains the +// authority for appcast selection, archive signatures, code-signing checks, +// authorization, replacement, and relaunch; Burrow only mirrors lifecycle +// state into the unified Updates list. +// + +import Foundation +import Sparkle + +@MainActor +final class ExternalSparkleUpdateSession: NSObject, SPUUpdaterDelegate { + private var userDriver: SPUStandardUserDriver! + private var updater: SPUUpdater! + private let onPhase: (UpdatePhase) -> Void + private let onMetadata: (String, URL?) -> Void + private let onFinish: () -> Void + private var lastPhase: UpdatePhase = .checking + private var didFindUpdate = false + private var finished = false + + init?( + appPath: String, + onPhase: @escaping (UpdatePhase) -> Void, + onMetadata: @escaping (String, URL?) -> Void, + onFinish: @escaping () -> Void + ) { + guard let bundle = Bundle(path: appPath) else { return nil } + self.onPhase = onPhase + self.onMetadata = onMetadata + self.onFinish = onFinish + super.init() + userDriver = SPUStandardUserDriver(hostBundle: bundle, delegate: nil) + updater = SPUUpdater( + hostBundle: bundle, + applicationBundle: bundle, + userDriver: userDriver, + delegate: self + ) + } + + func begin() -> UpdateFailure? { + transition(.checking) + do { + try updater.start() + updater.checkForUpdates() + return nil + } catch { + let failure = UpdateFailure.unsupported(error.localizedDescription) + transition(.failed(failure)) + finishOnce() + return failure + } + } + + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + didFindUpdate = true + onMetadata(item.displayVersionString, item.releaseNotesURL ?? item.infoURL) + transition(.available) + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + transition(.completed) + } + + func updater( + _ updater: SPUUpdater, + willDownloadUpdate item: SUAppcastItem, + with request: NSMutableURLRequest + ) { + transition(.downloading(progress: nil)) + } + + func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + transition(.verifying) + } + + func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + transition(.failed(Self.failure(from: error))) + } + + func userDidCancelDownload(_ updater: SPUUpdater) { + transition(.failed(.cancelled)) + } + + func updater(_ updater: SPUUpdater, willExtractUpdate item: SUAppcastItem) { + transition(.verifying) + } + + func updater(_ updater: SPUUpdater, didExtractUpdate item: SUAppcastItem) { + // Sparkle validates the archive and replacement bundle before this + // callback. Its native window owns the final install/relaunch choice. + transition(.readyToInstall) + } + + func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { + transition(.installing) + } + + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + transition(.waitingForRestart) + } + + func updater( + _ updater: SPUUpdater, + didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, + error: Error? + ) { + if let error { + if UpdateFailurePolicy.classify(error as NSError).category == .noUpdate { + transition(.completed) + finishOnce() + return + } + let failure = Self.failure(from: error) + if failure != .cancelled { + transition(.failed(failure)) + } else if !didFindUpdate { + transition(.failed(.cancelled)) + } + } else { + switch lastPhase { + case .installing, .waitingForRestart: + transition(.completed) + case .checking where !didFindUpdate: + transition(.completed) + default: + break + } + } + finishOnce() + } + + private func transition(_ phase: UpdatePhase) { + lastPhase = phase + onPhase(phase) + } + + private func finishOnce() { + guard !finished else { return } + finished = true + onFinish() + } + + private static func failure(from error: Error) -> UpdateFailure { + let nsError = error as NSError + let underlying = deepestUnderlyingError(in: nsError) + let urlError = [underlying, nsError].compactMap { candidate -> URLError? in + guard candidate.domain == NSURLErrorDomain else { return nil } + return URLError(URLError.Code(rawValue: candidate.code)) + }.first + if let urlError { + switch urlError.code { + case .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .dataNotAllowed: + return .offline + case .timedOut: + return .timeout + case .cancelled: + return .cancelled + default: + return .network(code: urlError.errorCode) + } + } + switch UpdateFailurePolicy.classify(nsError).category { + case .noUpdate: + return .cancelled + case .cancelled: + return .cancelled + case .transientDownload: + return .network(code: nsError.code) + case .signatureValidation: + return .verification(.invalidSignature) + case .appTranslocation, .configuration: + return .unsupported(nsError.localizedDescription) + case .installation, .other: + return .installation(nsError.localizedDescription) + } + } + + private static func deepestUnderlyingError(in error: NSError) -> NSError { + var current = error + var seen = Set() + while let next = current.userInfo[NSUnderlyingErrorKey] as? NSError, + seen.insert(ObjectIdentifier(next)).inserted { + current = next + } + return current + } +} diff --git a/macos/Sources/LaunchDiagnostics.swift b/macos/Sources/LaunchDiagnostics.swift index 3e36b996..b20c5044 100644 --- a/macos/Sources/LaunchDiagnostics.swift +++ b/macos/Sources/LaunchDiagnostics.swift @@ -365,7 +365,9 @@ enum DiagnosticPrivacy { "api_key", "token", "authorization", "password", "secret", "file_path", "path", "url", "home", "home_dir", "username", "user", "email", "clipboard", "file_name", "contents", - "run_id", "distinct_id", "device_id" + "run_id", "distinct_id", "device_id", "account", "apikey", "argv", + "argument", "arguments", "command", "command_line", "cookie", "headers", + "payload", "session" ] private static let urlPattern = try! NSRegularExpression( @@ -424,6 +426,37 @@ enum DiagnosticPrivacy { return sanitized } + /// Preserve only bounded symbol/type labels. Path syntax, email/URL + /// redactions, control characters, and credential-shaped text fail closed. + static func safeDiagnosticLabel(_ value: String?) -> String? { + guard let value, !value.isEmpty, value.utf8.count <= 240, + redact(value) == value else { return nil } + let lowered = value.lowercased() + let sensitiveMarkers = [ + "api_key", "apikey", "authorization", "bearer ", "password", + "secret", "sk-live", "token=", "token:", "x-api-key", + ] + guard !sensitiveMarkers.contains(where: lowered.contains) else { return nil } + let allowed = CharacterSet.alphanumerics.union( + CharacterSet(charactersIn: " _.$:+<>()[]{}?!,@#&'`~-=") + ) + guard value.unicodeScalars.allSatisfy(allowed.contains) else { return nil } + return value + } + + static func safeHexAddress(_ value: String?) -> String? { + guard let value, + value.range(of: #"^0x[0-9a-fA-F]{1,16}$"#, options: .regularExpression) != nil + else { return nil } + return value + } + + static func safeDebugID(_ value: String?) -> String? { + guard let value else { return nil } + let pattern = #"^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})$"# + return value.range(of: pattern, options: .regularExpression) == nil ? nil : value + } + static func isSafeIdentifier(_ value: String) -> Bool { guard !value.isEmpty, value.count <= 80 else { return false } return value.range(of: #"^[a-z0-9_.-]+$"#, options: .regularExpression) != nil diff --git a/macos/Sources/MCP.swift b/macos/Sources/MCP.swift index f6426088..c3c674b7 100644 --- a/macos/Sources/MCP.swift +++ b/macos/Sources/MCP.swift @@ -71,10 +71,12 @@ final class MCPServer { private let dec = JSONDecoder() private let enc = JSONEncoder() private let catalog: ToolCatalog + private let serverVersion: String - init(db: DB) { + init(db: DB, serverVersion: String = RuntimeEnvironment.current.appVersion) { self.db = db self.catalog = ToolCatalog(db: db) + self.serverVersion = serverVersion self.enc.outputFormatting = [.withoutEscapingSlashes] } @@ -147,7 +149,7 @@ final class MCPServer { "capabilities": ["tools": [String: Any]()], "serverInfo": [ "name": "burrow", - "version": "0.3.0", + "version": self.serverVersion, ], ], ] diff --git a/macos/Sources/MoleCLI.swift b/macos/Sources/MoleCLI.swift index 04595caa..9cc2f19b 100644 --- a/macos/Sources/MoleCLI.swift +++ b/macos/Sources/MoleCLI.swift @@ -92,23 +92,13 @@ enum MoleCLI { return FileManager.default.isExecutableFile(atPath: url.path) ? url.path : nil } - /// The known trusted locations ONLY — no PATH lookup. ELEVATED runs must resolve through - /// this: accepting a user-writable PATH entry would hand root to whatever binary shadowed - /// the engine first. Order: bundled engine → installed `burrow-engine` (the MIT fork) → - /// legacy upstream `mo` (back-compat for existing installs). + /// The only engine Burrow may run as root. Homebrew prefixes are normally + /// owned by the signed-in user, and `mo` is a shell program that sources + /// adjacent files; checking `/opt/homebrew/bin/mo` once and executing it + /// later would elevate replaceable code. Unprivileged discovery retains + /// the external fallbacks, but elevation requires the sealed bundle. static func trustedExecutable() -> String? { - if let bundled = bundledExecutable() { return bundled } - let candidates = [ - "/opt/homebrew/bin/burrow-engine", // MIT fork, if installed - "/usr/local/bin/burrow-engine", - "/opt/homebrew/bin/mo", // legacy upstream (back-compat) - "/usr/local/bin/mo", - "/usr/bin/mo", - ] - for path in candidates where FileManager.default.isExecutableFile(atPath: path) { - return path - } - return nil + bundledExecutable() } /// Build the `do shell script` source for one elevated invocation: @@ -133,13 +123,65 @@ enum MoleCLI { /// next — re-prompting per run is OS policy, not a Burrow bug. /// Pooling them would take a resident privileged helper /// (SMAppService daemon + XPC), a deliberate non-goal for now. - static func elevatedScript(executable: String, args: [String], - redirectTo logPath: String? = nil) -> String { - func shQuote(_ s: String) -> String { - "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Compose a root command from values already captured before elevation. + /// The preamble rechecks every pinned inode and the app's resource seal at + /// the execution boundary, then supplies the invoking user's canonical + /// identity explicitly instead of inheriting root's HOME/USER. + static func elevatedScript(command: ValidatedElevatedCommand, args: [String], + logSink: PrivilegedLogSink? = nil, + cleanupPlan: CleanupExecutionPlan? = nil) -> String { + func identityCheck(_ identity: PinnedFileIdentity) -> String { + let stat = "/usr/bin/stat -f '%d:%i:%u:%p' -- \(shellQuote(identity.path)) 2>/dev/null" + return "[ \"$(\(stat))\" = \(shellQuote(identity.shellStatToken)) ] || exit 126" + } + + var statements = command.components.map(identityCheck) + statements.append(identityCheck(command.executable)) + if let bundle = command.signedBundlePath { + statements.append("/usr/bin/codesign --verify --strict -- \(shellQuote(bundle)) >/dev/null 2>&1 || exit 126") + } + + let user = command.invokingUser + let environment = [ + "HOME=\(user.canonicalHome)", + "USER=\(user.username)", + "LOGNAME=\(user.username)", + "SUDO_USER=\(user.username)", + "SUDO_UID=\(user.uid)", + ] + let run: String + if let cleanupPlan { + statements.append(contentsOf: cleanupPlan.executionBoundaryChecks().map { $0 + " || exit 124" }) + let quotedPaths = cleanupPlan.items.map { shellQuote($0.identity.path) }.joined(separator: " ") + // BSD find defaults to -P (do not follow symlinks); -x prevents a + // reviewed directory from crossing into a later-mounted volume. + run = "failed=0; for p in \(quotedPaths); do /usr/bin/find -x \"$p\" -depth -delete || failed=1; done; [ \"$failed\" -eq 0 ]" + } else { + run = (["/usr/bin/env"] + environment + [command.executable.path] + args) + .map(shellQuote).joined(separator: " ") + } + + if let sink = logSink { + let dir = shellQuote(sink.directoryPath), file = shellQuote(sink.filePath) + statements.append("umask 022") + statements.append("/bin/mkdir -m 0755 -- \(dir) || exit 125") + statements.append("cleanup_burrow_log() { /bin/rm -f -- \(file); /bin/rmdir -- \(dir); }") + statements.append("trap cleanup_burrow_log EXIT HUP INT TERM") + statements.append("( set -C; : > \(file) ) || exit 125") + statements.append("( \(run) ) > \(file) 2>&1") + statements.append("burrow_status=$?") + // Let the app obtain a no-follow descriptor. The root shell then + // unlinks the sink; an open descriptor remains readable. + statements.append("/bin/sleep 0.35") + statements.append("exit $burrow_status") + } else { + statements.append(cleanupPlan == nil ? "exec \(run)" : run) } - var raw = ([executable] + args).map(shQuote).joined(separator: " ") - if let logPath { raw += " > \(shQuote(logPath)) 2>&1" } + let raw = statements.joined(separator: "; ") let inner = raw.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") return "do shell script \"\(inner)\" with administrator privileges" diff --git a/macos/Sources/OperationFlow.swift b/macos/Sources/OperationFlow.swift index a5a53570..3f49bff0 100644 --- a/macos/Sources/OperationFlow.swift +++ b/macos/Sources/OperationFlow.swift @@ -28,6 +28,9 @@ struct ProcessSpec: Sendable, Equatable { var stdin: String? var elevated: Bool var timeout: TimeInterval? + var invokingUser: InvokingUserIdentity? = nil + var requiresCurrentBundle: Bool = false + var cleanupPlan: CleanupExecutionPlan? = nil } enum ProcessEvent: Sendable { @@ -64,6 +67,7 @@ struct ToolOperation { var gate: Gate = .none var elevated: Bool = false var timeout: TimeInterval? = nil + var cleanupPlan: CleanupExecutionPlan? = nil var reduce: @Sendable ([String]) -> Report /// Optional line → HUD detail mapping (clean/optimize use /// TaskReportText.line); nil shows the raw line. @@ -130,6 +134,7 @@ final class OperationFlow: ObservableObject { /// Resolves the mo executable; elevated runs use trusted locations only /// (never a PATH lookup a user-writable directory could shadow). private let resolveMo: (_ elevated: Bool) -> String? + private let resolveInvokingUser: () throws -> InvokingUserIdentity private let center: OperationCenter private var task: Task? @@ -162,10 +167,12 @@ final class OperationFlow: ObservableObject { resolveMo: @escaping (_ elevated: Bool) -> String? = { $0 ? MoleCLI.trustedExecutable() : MoleCLI.findExecutable() }, + resolveInvokingUser: @escaping () throws -> InvokingUserIdentity = InvokingUserIdentity.current, center: OperationCenter = .shared) { self.process = process self.hasFullDiskAccess = hasFullDiskAccess self.resolveMo = resolveMo + self.resolveInvokingUser = resolveInvokingUser self.center = center } @@ -199,8 +206,24 @@ final class OperationFlow: ObservableObject { return } + let invokingUser: InvokingUserIdentity? + if op.elevated { + do { invokingUser = try resolveInvokingUser() } + catch { + state = .finished(.failed(error.localizedDescription)) + return + } + } else { + invokingUser = nil + } + let requiresCurrentBundle: Bool + if case .mo = op.executable { requiresCurrentBundle = op.elevated } + else { requiresCurrentBundle = false } let spec = ProcessSpec(executable: executable, arguments: arguments, - stdin: op.stdin, elevated: op.elevated, timeout: op.timeout) + stdin: op.stdin, elevated: op.elevated, timeout: op.timeout, + invokingUser: invokingUser, + requiresCurrentBundle: requiresCurrentBundle, + cleanupPlan: op.cleanupPlan) state = .running report = nil lastReportAt = .distantPast @@ -359,10 +382,10 @@ struct SystemProcessPort: ProcessPort { func emit(_ s: String) { // streamQ only for line in splitter.ingest(Ansi.strip(s)) { cont.yield(.line(line)) } } - func finish(_ code: Int32) { // streamQ only + func finish(_ code: Int32, appleScriptStderr: String = "") { // streamQ only for line in splitter.flush() { cont.yield(.line(line)) } cont.yield(Self.finalEvent(exitCode: code, elevated: spec.elevated, - sawOutput: splitter.sawAnyLine)) + appleScriptStderr: appleScriptStderr)) cont.finish() } @@ -375,20 +398,27 @@ struct SystemProcessPort: ProcessPort { // assert so the unsupported combo fails loudly rather than // silently dropping the input if someone wires it up later. assert(spec.stdin == nil, "elevated runs don't support stdin") - let safe = spec.arguments.map { $0.filter(\.isLetter) }.joined(separator: "-") - let logPath = NSTemporaryDirectory() + "burrow-op-\(safe).log" - FileManager.default.createFile(atPath: logPath, contents: Data()) - let script = MoleCLI.elevatedScript(executable: spec.executable, - args: spec.arguments, redirectTo: logPath) + guard let invokingUser = spec.invokingUser, + let command = try? ValidatedElevatedCommand.prepare( + executable: spec.executable, invokingUser: invokingUser, + requireCurrentBundle: spec.requiresCurrentBundle), + let logSink = try? PrivilegedLogSink.make(), + spec.cleanupPlan?.validateForLaunch() != false else { + streamQ.async { finish(126) } + return + } + let script = MoleCLI.elevatedScript(command: command, + args: spec.arguments, + logSink: logSink, + cleanupPlan: spec.cleanupPlan) t.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") t.arguments = ["-e", script] t.standardOutput = outPipe t.standardError = errPipe - let handle = FileHandle(forReadingAtPath: logPath) - logHandle = handle - let timer = Timer(timeInterval: 0.3, repeats: true) { _ in - guard let h = handle else { return } + let timer = Timer(timeInterval: 0.05, repeats: true) { _ in + if logHandle == nil { logHandle = logSink.openForReading() } + guard let h = logHandle else { return } let data = h.readDataToEndOfFile() guard !data.isEmpty, let s = String(data: data, encoding: .utf8) else { return } streamQ.async { emit(s) } @@ -405,7 +435,11 @@ struct SystemProcessPort: ProcessPort { if !data.isEmpty, let s = String(data: data, encoding: .utf8) { emit(s) } try? h.close() } - finish(proc.terminationStatus) + let stderr = String( + decoding: errPipe.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self) + _ = outPipe.fileHandleForReading.readDataToEndOfFile() + finish(proc.terminationStatus, appleScriptStderr: stderr) } } } else { @@ -475,13 +509,12 @@ struct SystemProcessPort: ProcessPort { } } - /// The one final-event rule (issue #48's error taxonomy): an elevated - /// run that exits nonzero having produced NOTHING is a dismissed auth - /// prompt, not a command failure. The predicate itself lives in - /// `AuthCancel` so the streaming runner and the one-shot - /// `SystemPrivilegeBroker` share one source of truth. Pure → table-tested. - static func finalEvent(exitCode: Int32, elevated: Bool, sawOutput: Bool) -> ProcessEvent { - if AuthCancel.isAuthCancelled(elevated: elevated, exitCode: exitCode, sawOutput: sawOutput) { + /// Both streaming and one-shot elevation require AppleScript's canonical + /// -128 diagnostic. A silent nonzero root command remains a real failure. + static func finalEvent(exitCode: Int32, elevated: Bool, + appleScriptStderr: String) -> ProcessEvent { + if AuthCancel.isAuthCancelled(elevated: elevated, exitCode: exitCode, + appleScriptStderr: appleScriptStderr) { return .authCancelled } return .exited(exitCode) diff --git a/macos/Sources/PopupView.swift b/macos/Sources/PopupView.swift index af095c15..56cb61f6 100644 --- a/macos/Sources/PopupView.swift +++ b/macos/Sources/PopupView.swift @@ -467,7 +467,8 @@ struct PopupView: View { private func topProcesses(_ s: MoleStatus) -> some View { VStack(alignment: .leading, spacing: 5) { Eyebrow(text: "Top processes", glyph: "list.bullet", color: Brand.textSecondary) - ForEach(Array((s.topProcesses ?? []).prefix(4).enumerated()), id: \.offset) { _, p in + ForEach(Array((s.topProcesses ?? []).filter { !model.invalidatedProcessIDs.contains($0.pid) } + .prefix(4).enumerated()), id: \.offset) { _, p in HStack(spacing: 8) { Image(nsImage: AppIcon.image(for: p) ?? PopupView.blankIcon) .resizable().frame(width: 15, height: 15) @@ -503,7 +504,11 @@ struct PopupView: View { if ProcessActions.isOwnProcess(pid: p.pid) { Divider() Button(NSLocalizedString("Quit…", comment: ""), role: .destructive) { - if ProcessActions.quit(pid: p.pid) == false { NSSound.beep() } + ProcessActions.confirmTermination( + pid: p.pid, + displayName: p.name, + onRefresh: { model.invalidateProcess(pid: p.pid) } + ) } } } label: { @@ -669,6 +674,7 @@ final class HUDModel: ObservableObject { /// Camera/mic in-use (opt-in indicator). False unless the toggle is on. @Published var cameraActive = false @Published var micActive = false + @Published private(set) var invalidatedProcessIDs: Set = [] private let db: DB private let live: LiveFeed @@ -692,6 +698,7 @@ final class HUDModel: ObservableObject { /// Latest snapshot + freshness, off the 1 s `snapshot.live` pump. func subscribeSnapshot() async { for await v in feeds.liveSnapshot(live).subscribeValues() { + invalidatedProcessIDs.removeAll() snap = v.snap // Live 1 s sparklines for the cpu/mem/gpu tiles — appended each tick // so the popover charts actually move (the 15 s pump now only feeds @@ -709,6 +716,10 @@ final class HUDModel: ObservableObject { } } + func invalidateProcess(pid: Int) { + invalidatedProcessIDs.insert(pid) + } + /// Sparklines + top drain, off the 15 s `metrics.sparklines.30m` pump. func subscribeSparklines() async { for await v in feeds.metricSparklines(db: db).subscribeValues() { diff --git a/macos/Sources/PortsView.swift b/macos/Sources/PortsView.swift index 9598da07..a2af3302 100644 --- a/macos/Sources/PortsView.swift +++ b/macos/Sources/PortsView.swift @@ -31,7 +31,6 @@ struct PortsView: View { @State private var sortAsc = true @State private var resolveDNS = true @State private var expandedID: String? - @State private var killTarget: ListeningPort? @State private var loaded = false @State private var loading = false private let uid = Int(getuid()) @@ -62,18 +61,6 @@ struct PortsView: View { } .onAppear { if isActive { reload() } } .onChange(of: isActive) { _, now in if now { reload() } } - .confirmationDialog( - NSLocalizedString("Quit this process?", comment: ""), - isPresented: Binding(get: { killTarget != nil }, set: { if !$0 { killTarget = nil } }), - presenting: killTarget - ) { p in - Button(NSLocalizedString("Quit", comment: ""), role: .destructive) { - _ = kill(pid_t(p.pid), SIGTERM); reload() - } - Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} - } message: { p in - Text("\(p.process) (pid \(p.pid)) — port \(p.port)") - } } // MARK: Toolbar @@ -272,7 +259,13 @@ struct PortsView: View { copyButton(NSLocalizedString("Copy kill", comment: ""), "kill \(p.pid)") Spacer() if PortInspector.isKillable(p, currentUID: uid) { - Button(NSLocalizedString("Quit", comment: "")) { killTarget = p } + Button(NSLocalizedString("Quit…", comment: "")) { + ProcessActions.confirmTermination( + pid: p.pid, + displayName: p.process, + onRefresh: reload + ) + } .buttonStyle(.plain) .font(Brand.sans(11, .semibold)).foregroundStyle(Brand.red) } diff --git a/macos/Sources/PrivilegeBroker.swift b/macos/Sources/PrivilegeBroker.swift index 31159dae..d0bde4aa 100644 --- a/macos/Sources/PrivilegeBroker.swift +++ b/macos/Sources/PrivilegeBroker.swift @@ -76,14 +76,21 @@ protocol PrivilegeBroker: Sendable { /// body — only the result classification is new (auth-cancel is now named, /// not folded into a bare nonzero exit). struct SystemPrivilegeBroker: PrivilegeBroker { - /// osascript's exit status when the user dismisses the auth dialog: it - /// surfaces AppleScript's `userCanceledErr` (-128) as a process exit of - /// 1, but the canonical signal is the error number. We classify on - /// "produced no output" the way the streaming path does, so a genuine - /// command failure (which DID run and print) is never mistaken for a - /// cancel. func openElevated(executable: String, args: [String]) -> ElevatedOutcome { - let script = MoleCLI.elevatedScript(executable: executable, args: args) + let command: ValidatedElevatedCommand + do { + let user = try InvokingUserIdentity.current() + let bundle = InvokingUserIdentity.canonicalPath(Bundle.main.bundleURL.path) + let canonicalExecutable = InvokingUserIdentity.canonicalPath(executable) + let isBundled = bundle.flatMap { root in + canonicalExecutable.map { $0.hasPrefix(root + "/") } + } ?? false + command = try ValidatedElevatedCommand.prepare( + executable: executable, invokingUser: user, requireCurrentBundle: isBundled) + } catch { + return .launchFailed + } + let script = MoleCLI.elevatedScript(command: command, args: args) let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") task.arguments = ["-e", script] @@ -94,12 +101,12 @@ struct SystemPrivilegeBroker: PrivilegeBroker { try task.run() // Drain both pipes to EOF before reaping so neither can fill and // wedge osascript; small output, so a blocking read is fine. - let out = outPipe.fileHandleForReading.readDataToEndOfFile() + _ = outPipe.fileHandleForReading.readDataToEndOfFile() let err = errPipe.fileHandleForReading.readDataToEndOfFile() task.waitUntilExit() let code = task.terminationStatus - let sawOutput = !out.isEmpty || !err.isEmpty - return AuthCancel.outcome(exitCode: code, sawOutput: sawOutput) + let stderr = String(decoding: err, as: UTF8.self) + return AuthCancel.outcome(exitCode: code, appleScriptStderr: stderr) } catch { return .launchFailed } @@ -108,10 +115,10 @@ struct SystemPrivilegeBroker: PrivilegeBroker { // MARK: - Auth-cancel classification (the one engine rule) -/// The single auth-cancel rule, shared by every elevated path (issue #48's -/// "one error taxonomy"). An elevated run that exits nonzero having produced -/// NOTHING is a dismissed auth prompt, not a command failure — output proves -/// the command actually ran under root. Pure → exhaustively table-tested. +/// The single auth-cancel rule, shared by every elevated path. osascript's +/// process status is only `1`; the canonical AppleScript signal is the +/// `userCanceledErr` number -128 in its diagnostic. Silence is not evidence +/// of cancellation: a root command can fail without printing anything. /// /// `SystemProcessPort.finalEvent` (the streaming runner) and /// `SystemPrivilegeBroker.openElevated` (the one-shot runner) both route @@ -121,13 +128,21 @@ enum AuthCancel { /// `elevated` is always true at the one-shot call site (every run here is /// elevated) but kept as a parameter so the streaming path — which spawns /// plain runs too — shares the exact same predicate. - static func isAuthCancelled(elevated: Bool, exitCode: Int32, sawOutput: Bool) -> Bool { - elevated && exitCode != 0 && !sawOutput + static func isAuthCancelled(elevated: Bool, exitCode: Int32, + appleScriptStderr: String) -> Bool { + guard elevated, exitCode != 0 else { return false } + return appleScriptStderr + .components(separatedBy: .newlines) + .contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasSuffix("(-128)") + } } /// Classify a one-shot elevated result (always elevated here). - static func outcome(exitCode: Int32, sawOutput: Bool) -> ElevatedOutcome { - if isAuthCancelled(elevated: true, exitCode: exitCode, sawOutput: sawOutput) { + static func outcome(exitCode: Int32, appleScriptStderr: String) -> ElevatedOutcome { + if isAuthCancelled(elevated: true, exitCode: exitCode, + appleScriptStderr: appleScriptStderr) { return .authCancelled } return .exited(exitCode) diff --git a/macos/Sources/PrivilegedExecution.swift b/macos/Sources/PrivilegedExecution.swift new file mode 100644 index 00000000..828ce3e5 --- /dev/null +++ b/macos/Sources/PrivilegedExecution.swift @@ -0,0 +1,273 @@ +// +// PrivilegedExecution.swift +// Burrow +// +// Values crossing Burrow's administrator boundary are resolved and pinned +// while the app is still running as the invoking user. The privileged +// shell then revalidates the same filesystem identities immediately before +// execution. A path string by itself is never authority to run code as +// root. +// + +import Foundation +import Darwin + +struct InvokingUserIdentity: Sendable, Equatable { + struct Account: Sendable, Equatable { + let uid: uid_t + let name: String + let home: String + } + + enum ResolutionError: LocalizedError, Equatable { + case rootProcess + case missingAccount(uid_t) + case mismatchedAccount(expected: uid_t, actual: uid_t) + case mismatchedHomeOwner(expected: uid_t, actual: uid_t) + case invalidName + case invalidHome + + var errorDescription: String? { + switch self { + case .rootProcess: + return "Burrow must be launched by a signed-in user, not root." + case .missingAccount(let uid): + return "The account for user id \(uid) no longer exists." + case .mismatchedAccount: + return "The invoking account changed before authorization." + case .mismatchedHomeOwner: + return "The invoking account does not own its resolved home directory." + case .invalidName: + return "The invoking account name is invalid." + case .invalidHome: + return "The invoking account home directory is invalid." + } + } + } + + let uid: uid_t + let username: String + /// `realpath(3)` result. This is intentionally captured before the + /// administrator dialog; root's HOME (/var/root) is never consulted. + let canonicalHome: String + + static func current() throws -> Self { + let invokingUID = getuid() + guard invokingUID != 0 else { throw ResolutionError.rootProcess } + guard let pwd = getpwuid(invokingUID) else { + throw ResolutionError.missingAccount(invokingUID) + } + let account = Account(uid: pwd.pointee.pw_uid, + name: String(cString: pwd.pointee.pw_name), + home: String(cString: pwd.pointee.pw_dir)) + return try resolve(invokingUID: invokingUID, accounts: [account]) + } + + /// Deterministic resolver used by tests to cover missing users and hosts + /// with several signed-in/local accounts. Selection is by the process's + /// numeric uid, never by a mutable USER/SUDO_USER environment variable. + static func resolve(invokingUID: uid_t, + accounts: [Account], + canonicalize: (String) -> String? = Self.canonicalPath) throws -> Self { + guard invokingUID != 0 else { throw ResolutionError.rootProcess } + guard let account = accounts.first(where: { $0.uid == invokingUID }) else { + throw ResolutionError.missingAccount(invokingUID) + } + guard account.uid == invokingUID else { + throw ResolutionError.mismatchedAccount(expected: invokingUID, actual: account.uid) + } + guard account.name != "root", !account.name.isEmpty, + !account.name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw ResolutionError.invalidName + } + guard account.home.hasPrefix("/"), + !account.home.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let home = canonicalize(account.home), home.hasPrefix("/"), home != "/var/root" else { + throw ResolutionError.invalidHome + } + var st = stat() + guard lstat(home, &st) == 0, (st.st_mode & S_IFMT) == S_IFDIR else { + throw ResolutionError.invalidHome + } + guard st.st_uid == invokingUID else { + throw ResolutionError.mismatchedHomeOwner(expected: invokingUID, actual: st.st_uid) + } + return Self(uid: invokingUID, username: account.name, canonicalHome: home) + } + + static func canonicalPath(_ path: String) -> String? { + guard let resolved = realpath(path, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } +} + +/// An lstat identity. Matching device+inode+type means a symlink swap or +/// replacement cannot silently turn a reviewed/validated object into another +/// one while the administrator sheet is open. +struct PinnedFileIdentity: Sendable, Equatable, Codable { + enum IdentityError: LocalizedError { + case missing(String) + case symlink(String) + case wrongType(String) + case mutableComponent(String) + + var errorDescription: String? { + switch self { + case .missing(let p): return "A required path no longer exists: \(p)" + case .symlink(let p): return "A symbolic link is not allowed here: \(p)" + case .wrongType(let p): return "A required path has the wrong type: \(p)" + case .mutableComponent(let p): return "A privileged executable can be replaced by the current user: \(p)" + } + } + } + + let path: String + let device: UInt64 + let inode: UInt64 + let owner: UInt32 + let mode: UInt16 + + var isDirectory: Bool { (mode_t(mode) & S_IFMT) == S_IFDIR } + var isRegular: Bool { (mode_t(mode) & S_IFMT) == S_IFREG } + + static func capture(_ path: String, rejectSymlink: Bool = true) throws -> Self { + var st = stat() + guard lstat(path, &st) == 0 else { throw IdentityError.missing(path) } + if rejectSymlink, (st.st_mode & S_IFMT) == S_IFLNK { throw IdentityError.symlink(path) } + return Self(path: path, device: UInt64(st.st_dev), inode: UInt64(st.st_ino), + owner: UInt32(st.st_uid), mode: UInt16(st.st_mode)) + } + + func matchesCurrent() -> Bool { + guard let current = try? Self.capture(path) else { return false } + return current.device == device && current.inode == inode && + current.owner == owner && current.mode == mode + } + + /// BSD stat's `%p` is the complete mode in octal (for example 100755). + var shellStatToken: String { + "\(device):\(inode):\(owner):\(String(mode, radix: 8))" + } +} + +struct ValidatedElevatedCommand: Sendable, Equatable { + enum ValidationError: LocalizedError { + case nonCanonicalExecutable + case executableNotRegular + case executableNotRootOwned + case executableMutable(String) + case unsignedBundle + + var errorDescription: String? { + switch self { + case .nonCanonicalExecutable: return "The privileged executable path is not canonical." + case .executableNotRegular: return "The privileged executable is not a regular file." + case .executableNotRootOwned: return "The privileged executable is not owned by root." + case .executableMutable(let p): return "The privileged executable can be replaced at \(p)." + case .unsignedBundle: return "The bundled cleanup engine could not be verified." + } + } + } + + let executable: PinnedFileIdentity + /// Every ancestor, including `/`, pinned and proven root-owned and not + /// group/world-writable. Once this holds, an unprivileged process cannot + /// win a check/exec rename race. + let components: [PinnedFileIdentity] + let invokingUser: InvokingUserIdentity + let signedBundlePath: String? + + static func prepare(executable rawPath: String, + invokingUser: InvokingUserIdentity, + requireCurrentBundle: Bool, + requiredOwner: uid_t = 0) throws -> Self { + guard let canonical = InvokingUserIdentity.canonicalPath(rawPath), canonical == rawPath else { + throw ValidationError.nonCanonicalExecutable + } + let executable = try PinnedFileIdentity.capture(canonical) + guard executable.isRegular else { throw ValidationError.executableNotRegular } + guard executable.owner == requiredOwner else { throw ValidationError.executableNotRootOwned } + + let url = URL(fileURLWithPath: canonical) + var componentPaths = ["/"] + var cursor = "" + for component in url.pathComponents.dropFirst().dropLast() { + cursor += "/" + component + componentPaths.append(cursor) + } + var components: [PinnedFileIdentity] = [] + for path in componentPaths { + let identity = try PinnedFileIdentity.capture(path) + guard identity.isDirectory, identity.owner == requiredOwner, + (mode_t(identity.mode) & 0o022) == 0 else { + throw ValidationError.executableMutable(path) + } + components.append(identity) + } + guard (mode_t(executable.mode) & 0o022) == 0 else { + throw ValidationError.executableMutable(canonical) + } + + var signedBundle: String? + if requireCurrentBundle { + guard let bundle = InvokingUserIdentity.canonicalPath(Bundle.main.bundleURL.path), + canonical.hasPrefix(bundle + "/") else { + throw ValidationError.unsignedBundle + } + signedBundle = bundle + } + return Self(executable: executable, components: components, + invokingUser: invokingUser, signedBundlePath: signedBundle) + } + + func matchesCurrentFilesystem() -> Bool { + executable.matchesCurrent() && components.allSatisfy { $0.matchesCurrent() } + } +} + +/// Streaming elevated commands write into a root-created directory beneath +/// /private/var/tmp. The random name is chosen before elevation, while mkdir +/// supplies O_EXCL-like collision semantics at the privileged boundary. The +/// directory remains root-owned for its entire lifetime, so another process +/// cannot replace the log with a symlink between creation and redirection. +struct PrivilegedLogSink: Sendable, Equatable { + enum SinkError: LocalizedError { case invalidParent, invalidToken } + + let directoryPath: String + let filePath: String + + static func make(parent: String = "/private/var/tmp", + token: String = UUID().uuidString) throws -> Self { + guard InvokingUserIdentity.canonicalPath(parent) == "/private/var/tmp" else { + throw SinkError.invalidParent + } + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") + guard token.count >= 32, token.unicodeScalars.allSatisfy(allowed.contains) else { + throw SinkError.invalidToken + } + let directory = parent + "/dev.caezium.burrow.operation-" + token + return Self(directoryPath: directory, filePath: directory + "/output.log") + } + + /// Open only the root-created regular file. Holding this descriptor lets + /// the privileged shell unlink its sink safely at exit while the app drains + /// the final bytes without any path-based cleanup race. + func openForReading() -> FileHandle? { + let fd = Darwin.open(filePath, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { return nil } + var st = stat() + guard fstat(fd, &st) == 0, (st.st_mode & S_IFMT) == S_IFREG, st.st_uid == 0 else { + Darwin.close(fd) + return nil + } + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } + + var exclusiveCreationShell: String { + let dir = MoleCLI.shellQuote(directoryPath) + let file = MoleCLI.shellQuote(filePath) + return "umask 022; /bin/mkdir -m 0755 -- \(dir) || exit 125; " + + "( set -C; : > \(file) ) || exit 125" + } +} diff --git a/macos/Sources/ProcessActions.swift b/macos/Sources/ProcessActions.swift index 1d11185d..554a003e 100644 --- a/macos/Sources/ProcessActions.swift +++ b/macos/Sources/ProcessActions.swift @@ -22,6 +22,36 @@ import AppKit import Darwin enum ProcessActions { + struct Identity: Equatable, Sendable { + let pid: Int + let ownerUID: uid_t + let startSeconds: UInt64 + let startMicroseconds: UInt64 + let executablePath: String? + } + + struct TerminationTarget: Equatable, Sendable { + let displayName: String + let identity: Identity + + /// Text shown before confirmation comes from the captured snapshot, + /// not a mutable live row. PID + owner + process birth time identify + /// the process instance; the path also catches an in-place `exec`. + var confirmationDetails: String { + let started = String(format: "%llu.%06llu", identity.startSeconds, identity.startMicroseconds) + let path = identity.executablePath ?? NSLocalizedString("executable path unavailable", comment: "") + return "\(displayName) · PID \(identity.pid) · user \(identity.ownerUID) · started \(started)\n\(path)" + } + } + + enum TerminationResult: Equatable { + case sent + case cancelled + case stale + case notOwned + case signalFailed + } + /// Cumulative billed energy for a pid, in nanojoules. nil when the /// kernel won't say (permission, exited, or platform). Flavor 4 is /// the first rusage_info with ri_energy_billed — pinned numerically @@ -48,11 +78,7 @@ enum ProcessActions { /// Whether this process belongs to the current user — the /// requirement for Quit / Force Kill. Root-owned rows are read-only. static func isOwnProcess(pid: Int) -> Bool { - var info = proc_bsdinfo() - let size = Int32(MemoryLayout.size) - let got = proc_pidinfo(Int32(pid), PROC_PIDTBSDINFO, 0, &info, size) - guard got == size else { return false } - return info.pbi_uid == getuid() + identity(pid: pid)?.ownerUID == getuid() } /// Executable path for reveal-in-Finder. nil for system stubs. @@ -63,13 +89,151 @@ enum ProcessActions { return String(cString: buffer) } + /// Immutable process instance captured before a destructive confirmation. + /// A PID alone is unsafe because macOS can reuse it after the old process + /// exits while the alert is still open. + static func identity(pid: Int) -> Identity? { + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + let got = proc_pidinfo(Int32(pid), PROC_PIDTBSDINFO, 0, &info, size) + guard got == size else { return nil } + return Identity( + pid: pid, + ownerUID: info.pbi_uid, + startSeconds: info.pbi_start_tvsec, + startMicroseconds: info.pbi_start_tvusec, + executablePath: executablePath(pid: pid) + ) + } + + static func terminationTarget(pid: Int, displayName: String) -> TerminationTarget? { + guard let identity = identity(pid: pid), identity.ownerUID == getuid() else { return nil } + return TerminationTarget(displayName: displayName, identity: identity) + } + + /// Re-read the owner and immutable identity immediately before signaling. + /// Any exit, PID reuse, owner change, or `exec` fails closed. + static func terminate(_ target: TerminationTarget, force: Bool) -> TerminationResult { + terminate( + target, + force: force, + currentUID: getuid(), + readIdentity: identity(pid:), + sendSignal: { Darwin.kill($0, $1) } + ) + } + + static func terminate( + _ target: TerminationTarget, + force: Bool, + currentUID: uid_t, + readIdentity: (Int) -> Identity?, + sendSignal: (Int32, Int32) -> Int32 + ) -> TerminationResult { + guard target.identity.ownerUID == currentUID else { return .notOwned } + guard let current = readIdentity(target.identity.pid) else { return .stale } + guard current.ownerUID == currentUID else { return .notOwned } + guard current == target.identity else { return .stale } + let signal = force ? SIGKILL : SIGTERM + return sendSignal(Int32(target.identity.pid), signal) == 0 ? .sent : .signalFailed + } + + static func terminateIfConfirmed( + _ target: TerminationTarget, + force: Bool, + confirmed: Bool, + currentUID: uid_t, + readIdentity: (Int) -> Identity?, + sendSignal: (Int32, Int32) -> Int32 + ) -> TerminationResult { + guard confirmed else { return .cancelled } + return terminate( + target, + force: force, + currentUID: currentUID, + readIdentity: readIdentity, + sendSignal: sendSignal + ) + } + + /// One confirmation path for every visible Quit… / Force Kill… action. + /// `onRefresh` runs after a confirmed attempt, including stale failures, + /// so callers discard the row that led to the action. + @MainActor @discardableResult + static func confirmTermination( + pid: Int, + displayName: String, + force: Bool = false, + onRefresh: @escaping () -> Void = {} + ) -> TerminationResult? { + guard let target = terminationTarget(pid: pid, displayName: displayName) else { + presentTerminationFailure(.stale) + onRefresh() + return .stale + } + + let alert = NSAlert() + alert.messageText = force + ? String(format: NSLocalizedString("Force kill %@?", comment: ""), displayName) + : String(format: NSLocalizedString("Quit %@?", comment: ""), displayName) + let consequence = force + ? NSLocalizedString("SIGKILL ends it immediately, so unsaved work in this process is lost.", comment: "") + : NSLocalizedString("SIGTERM asks the process to quit. It may save and exit, or ignore the request.", comment: "") + alert.informativeText = "\(target.confirmationDetails)\n\n\(consequence)" + alert.alertStyle = .warning + alert.addButton(withTitle: force + ? NSLocalizedString("Force Kill", comment: "") + : NSLocalizedString("Quit Process", comment: "")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) + guard alert.runModalQuiet() == .alertFirstButtonReturn else { return nil } + + let result = terminate(target, force: force) + if result != .sent { presentTerminationFailure(result) } + onRefresh() + return result + } + + @MainActor + private static func presentTerminationFailure(_ result: TerminationResult) { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Process wasn't quit", comment: "") + switch result { + case .stale: + alert.informativeText = NSLocalizedString( + "The process exited or its identity changed while the confirmation was open. The process list was refreshed and no signal was sent.", + comment: "" + ) + case .notOwned: + alert.informativeText = NSLocalizedString( + "The process is no longer owned by your user account. The process list was refreshed and no signal was sent.", + comment: "" + ) + case .signalFailed: + alert.informativeText = NSLocalizedString( + "macOS refused the signal or the process exited at the last moment. The process list was refreshed.", + comment: "" + ) + case .sent, .cancelled: + return + } + alert.alertStyle = .warning + alert.addButton(withTitle: NSLocalizedString("OK", comment: "")) + alert.runModalQuiet() + } + /// SIGTERM — the polite ask. Caller confirms first. @discardableResult - static func quit(pid: Int) -> Bool { kill(Int32(pid), SIGTERM) == 0 } + static func quit(pid: Int) -> Bool { + guard let target = terminationTarget(pid: pid, displayName: "pid \(pid)") else { return false } + return terminate(target, force: false) == .sent + } /// SIGKILL — the hammer. Caller double-confirms first. @discardableResult - static func forceKill(pid: Int) -> Bool { kill(Int32(pid), SIGKILL) == 0 } + static func forceKill(pid: Int) -> Bool { + guard let target = terminationTarget(pid: pid, displayName: "pid \(pid)") else { return false } + return terminate(target, force: true) == .sent + } /// SIGSTOP — pause a process (freeze it without killing). Reversible via /// `resume`; own-user processes only (PRD §α Process Inspector). diff --git a/macos/Sources/QueryServer.swift b/macos/Sources/QueryServer.swift index ec3a6a15..a9e02498 100644 --- a/macos/Sources/QueryServer.swift +++ b/macos/Sources/QueryServer.swift @@ -4,10 +4,10 @@ // // Localhost JSON HTTP server. The MCP server for Claude Code points // at this and a curl-from-the-terminal user can hit the same endpoints. -// Bound to 127.0.0.1 only and double-checks the peer address at accept -// time — there's no scenario where Burrow should accept off-host -// traffic, so this is belt-and-braces against a future NWParameters -// default change. +// Bound to 127.0.0.1 only, double-checks the peer address at accept time, +// and requires a per-install bearer credential plus an exact local Host. +// Loopback is reachable by browsers and unrelated local processes, so it is +// transport scoping rather than an authentication boundary. // // Endpoints: // GET /health → { ok, app, port } @@ -26,17 +26,50 @@ import Foundation import Network +/// A small global limiter for the loopback surface. Loopback does not mean +/// trusted: every browser and local process can reach it, so bound accepted +/// authenticated work before it reaches SQLite or snapshot decoding. +final class QueryRateLimiter { + private let limit: Int + private let window: TimeInterval + private var acceptedAt: [TimeInterval] = [] + private let lock = NSLock() + + init(limit: Int = 120, window: TimeInterval = 60) { + self.limit = max(1, limit) + self.window = max(1, window) + } + + func allow(at date: Date = Date()) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + let now = date.timeIntervalSince1970 + let cutoff = now - self.window + self.acceptedAt.removeAll { $0 <= cutoff } + guard self.acceptedAt.count < self.limit else { return false } + self.acceptedAt.append(now) + return true + } +} + final class QueryServer { static let defaultPort: UInt16 = 9277 // Stats's MCP uses 9276; +1 to coexist private let db: DB private let port: UInt16 + private let authToken: String + private let rateLimiter: QueryRateLimiter private var listener: NWListener? private let queue = DispatchQueue(label: "dev.caezium.burrow.queryserver") - init(db: DB, port: UInt16 = QueryServer.defaultPort) { + init(db: DB, + port: UInt16 = QueryServer.defaultPort, + authToken: String = Store.queryAuthToken, + rateLimiter: QueryRateLimiter = QueryRateLimiter()) { self.db = db self.port = port + self.authToken = authToken + self.rateLimiter = rateLimiter } func start() { @@ -130,6 +163,103 @@ final class QueryServer { return isComplete ? .drop : .keepReading } + enum AuthorizationResult: Equatable { + case allowed + case unauthorized + case forbidden + case malformed + } + + struct Response { + let statusCode: Int + let body: String + let contentType: String + var headers: [String] = [] + } + + private struct Request { + let method: String + let target: String + let version: String + let headers: [String: String] + let body: String + } + + /// Parse the intentionally tiny HTTP subset without accepting duplicate + /// headers or absolute-form targets. Rejecting ambiguous input avoids Host + /// and Authorization smuggling through this hand-rolled boundary. + private static func parseRequest(_ raw: String) -> Request? { + guard raw.utf8.count <= Self.maxRequestBytes else { return nil } + let sections = raw.components(separatedBy: "\r\n\r\n") + guard sections.count <= 2 else { return nil } + var lines = sections[0].components(separatedBy: "\r\n") + guard !lines.isEmpty else { return nil } + let requestLine = lines.removeFirst().split(separator: " ", omittingEmptySubsequences: true) + guard requestLine.count == 3 else { return nil } + let method = String(requestLine[0]) + let target = String(requestLine[1]) + let version = String(requestLine[2]) + guard target.hasPrefix("/"), version == "HTTP/1.1" else { return nil } + + var headers: [String: String] = [:] + for line in lines { + guard !line.isEmpty, let colon = line.firstIndex(of: ":") else { return nil } + let name = line[.. Bool { + let a = Array(lhs.utf8) + let b = Array(rhs.utf8) + guard a.count == b.count, !a.isEmpty else { return false } + var difference: UInt8 = 0 + for index in a.indices { difference |= a[index] ^ b[index] } + return difference == 0 + } + + /// Shared by REST and SSE. A credential in the URL is deliberately + /// ignored because URLs leak into shell history, browser history, and + /// diagnostics; callers must use `Authorization: Bearer …`. + static func authorize(_ raw: String, token: String, port: UInt16) -> AuthorizationResult { + guard let request = Self.parseRequest(raw) else { return .malformed } + + let allowedHosts = ["127.0.0.1:\(port)", "localhost:\(port)"] + guard let host = request.headers["host"]?.lowercased(), allowedHosts.contains(host) else { + return .forbidden + } + if request.headers["origin"] != nil + || request.headers["referer"] != nil + || request.headers.keys.contains(where: { $0.hasPrefix("sec-fetch-") }) { + return .forbidden + } + + guard let authorization = request.headers["authorization"] else { return .unauthorized } + let pieces = authorization.split(separator: " ", omittingEmptySubsequences: true) + guard pieces.count == 2, + pieces[0].lowercased() == "bearer", + Self.timingSafeEqual(String(pieces[1]), token) + else { return .unauthorized } + return .allowed + } + + private static func requestBodyIsAllowed(_ request: Request) -> Bool { + guard request.headers["transfer-encoding"] == nil else { return false } + if let rawLength = request.headers["content-length"] { + guard let length = Int(rawLength), length >= 0, + length == request.body.utf8.count else { return false } + return length == 0 + } + return request.body.isEmpty + } + private func receive(_ conn: NWConnection, accumulated: Data) { conn.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, err in guard let self else { conn.cancel(); return } @@ -149,60 +279,86 @@ final class QueryServer { } } - /// Response head for the one shape we ever send (200 + JSON + close). /// Deliberately NO CORS header: the user's browser is also a loopback - /// client, and an allow-all grant would let any web page read /snapshot - /// (hostname, process command lines) cross-origin. The real clients — - /// curl and the stdio MCP bridge — don't need CORS at all. + /// client. Real callers authenticate out of band and don't need CORS. static let jsonContentType = "application/json; charset=utf-8" /// Prometheus text exposition format, version 0.0.4 — the de-facto scrape /// content type. Served only by `/metrics?format=prometheus`. static let prometheusContentType = "text/plain; version=0.0.4; charset=utf-8" - static func httpHead(contentLength: Int, contentType: String = jsonContentType) -> String { - return "HTTP/1.1 200 OK\r\n" + static func httpHead(contentLength: Int, + contentType: String = jsonContentType, + statusCode: Int = 200, + extraHeaders: [String] = []) -> String { + let reason: String + switch statusCode { + case 200: reason = "OK" + case 400: reason = "Bad Request" + case 401: reason = "Unauthorized" + case 403: reason = "Forbidden" + case 404: reason = "Not Found" + case 405: reason = "Method Not Allowed" + case 415: reason = "Unsupported Media Type" + case 429: reason = "Too Many Requests" + default: reason = "Error" + } + var head = "HTTP/1.1 \(statusCode) \(reason)\r\n" + "Content-Type: \(contentType)\r\n" + "Content-Length: \(contentLength)\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n" - + "\r\n" + for header in extraHeaders { head += header + "\r\n" } + return head + "\r\n" } - private func send(_ response: (body: String, contentType: String), on conn: NWConnection) { + private func send(_ response: Response, on conn: NWConnection) { let body = Data(response.body.utf8) - var payload = Data(Self.httpHead(contentLength: body.count, contentType: response.contentType).utf8) + var payload = Data(Self.httpHead(contentLength: body.count, + contentType: response.contentType, + statusCode: response.statusCode, + extraHeaders: response.headers).utf8) payload.append(body) conn.send(content: payload, completion: .contentProcessed { _ in conn.cancel() }) } // MARK: - SSE /events (B.6) - /// Parse the `token` query param from a request target. Static + pure so - /// the auth gate is unit-tested without a socket. - static func eventsToken(from target: String) -> String { - let parts = target.split(separator: "?", maxSplits: 1) - guard parts.count > 1 else { return "" } - for kv in parts[1].split(separator: "&") { - let p = kv.split(separator: "=", maxSplits: 1) - if p.count == 2, p[0] == "token" { return String(p[1]) } - } - return "" - } - - /// Handle `GET /events`: a token-gated SSE stream. Returns true if it took + /// Handle `GET /events`: a bearer-gated SSE stream. Returns true if it took /// ownership of the connection (streaming, or 401'd it), false to fall - /// through to the normal one-shot router. The server binds loopback only, - /// so the token just keeps other local processes/pages from subscribing. + /// through to the normal one-shot router. It uses the same bearer, Host, + /// browser-origin, body, and rate policy as the one-shot routes. private func tryServeEvents(_ header: String, on conn: NWConnection) -> Bool { - let line = header.split(separator: "\r\n", maxSplits: 1).first.map(String.init) ?? "" - let parts = line.split(separator: " ") - guard parts.count >= 2, parts[0] == "GET" else { return false } - let target = String(parts[1]) - guard target.split(separator: "?", maxSplits: 1).first.map(String.init) == "/events" else { return false } - - guard !Store.queryAuthToken.isEmpty, Self.eventsToken(from: target) == Store.queryAuthToken else { - let resp = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - conn.send(content: Data(resp.utf8), completion: .contentProcessed { _ in conn.cancel() }) + guard let request = Self.parseRequest(header) else { return false } + let path = request.target.split(separator: "?", maxSplits: 1).first.map(String.init) ?? "" + guard path == "/events" else { return false } + + let rejection: Response? + switch Self.authorize(header, token: self.authToken, port: self.port) { + case .allowed: rejection = nil + case .unauthorized: + rejection = Self.errorResponse(401, "valid bearer credential required", + headers: ["WWW-Authenticate: Bearer"]) + case .forbidden: + rejection = Self.errorResponse(403, "request host or browser origin rejected") + case .malformed: + rejection = Self.errorResponse(400, "malformed request") + } + if let rejection { + self.send(rejection, on: conn) + return true + } + guard request.method == "GET" else { + self.send(Self.errorResponse(405, "only GET supported", + headers: ["Allow: GET"]), on: conn) + return true + } + guard Self.requestBodyIsAllowed(request) else { + self.send(Self.errorResponse(400, "GET requests cannot include a body"), on: conn) + return true + } + guard self.rateLimiter.allow() else { + self.send(Self.errorResponse(429, "rate limit exceeded", + headers: ["Retry-After: 60"]), on: conn) return true } let head = "HTTP/1.1 200 OK\r\n" @@ -217,17 +373,36 @@ final class QueryServer { /// Returns the response body and its content type. Everything is JSON /// except `/metrics?format=prometheus`, which is text exposition. - func route(_ raw: String) -> (body: String, contentType: String) { - func json(_ s: String) -> (body: String, contentType: String) { (s, Self.jsonContentType) } + func route(_ raw: String) -> Response { + func json(_ s: String, status: Int = 200) -> Response { + Response(statusCode: status, body: s, contentType: Self.jsonContentType) + } - guard let first = raw.split(separator: "\r\n", maxSplits: 1).first else { - return json(Self.errorJSON("malformed request")) + let authorization = Self.authorize(raw, token: self.authToken, port: self.port) + switch authorization { + case .unauthorized: + return Self.errorResponse(401, "valid bearer credential required", + headers: ["WWW-Authenticate: Bearer"]) + case .forbidden: + return Self.errorResponse(403, "request host or browser origin rejected") + case .malformed: + return Self.errorResponse(400, "malformed request") + case .allowed: + break } - let parts = first.split(separator: " ") - guard parts.count >= 2, parts[0] == "GET" else { - return json(Self.errorJSON("only GET supported")) + guard let request = Self.parseRequest(raw) else { + return Self.errorResponse(400, "malformed request") } - let target = String(parts[1]) + guard request.method == "GET" else { + return Self.errorResponse(405, "only GET supported", headers: ["Allow: GET"]) + } + guard Self.requestBodyIsAllowed(request) else { + return Self.errorResponse(400, "GET requests cannot include a body") + } + guard self.rateLimiter.allow() else { + return Self.errorResponse(429, "rate limit exceeded", headers: ["Retry-After: 60"]) + } + let target = request.target let split = target.split(separator: "?", maxSplits: 1) let path = String(split[0]) let query = QueryServer.parseQuery(split.count == 2 ? String(split[1]) : "") @@ -244,12 +419,14 @@ final class QueryServer { case "/metrics": if query["format"] == "prometheus" { - return (self.routeMetricsPrometheus(), Self.prometheusContentType) + return Response(statusCode: 200, + body: self.routeMetricsPrometheus(), + contentType: Self.prometheusContentType) } return json(self.routeMetrics(query: query)) default: - return json(Self.errorJSON("unknown route")) + return json(Self.errorJSON("unknown route"), status: 404) } } @@ -344,6 +521,15 @@ final class QueryServer { return "{\"error\":\"\(msg.replacingOccurrences(of: "\"", with: "\\\""))\"}" } + private static func errorResponse(_ statusCode: Int, + _ message: String, + headers: [String] = []) -> Response { + Response(statusCode: statusCode, + body: Self.errorJSON(message), + contentType: Self.jsonContentType, + headers: headers) + } + private static func jsonString(_ object: Any) -> String { guard let data = try? JSONSerialization.data(withJSONObject: object, options: []) else { return errorJSON("serialization failed") diff --git a/macos/Sources/SettingsView.swift b/macos/Sources/SettingsView.swift index 8812c72f..5d79d95f 100644 --- a/macos/Sources/SettingsView.swift +++ b/macos/Sources/SettingsView.swift @@ -625,7 +625,8 @@ struct SettingsView: View { section("Local HTTP query server", "antenna.radiowaves.left.and.right") { toggleRow("Enable HTTP query server", isOn: $queryServerEnabled) { Store.queryServerEnabled = $0 } infoRow("Endpoint", "127.0.0.1:\(Store.queryServerPort)") - footnote("Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch.") + infoRow("Authentication", "Bearer token required") + footnote("Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Every request needs the per-install token; retrieve it locally with `defaults read dev.caezium.Burrow query_auth_token`. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch.") } section("Explain (AI) — experimental", "sparkles") { diff --git a/macos/Sources/SoftwareView.swift b/macos/Sources/SoftwareView.swift index 93f4fe2f..359e385d 100644 --- a/macos/Sources/SoftwareView.swift +++ b/macos/Sources/SoftwareView.swift @@ -536,6 +536,27 @@ final class SoftwareModel: ObservableObject { @Published var previewLoading: Set = [] @Published var pathSelections: [String: Set] = [:] private var started = false + private let appsLoader: () -> [InstalledApp] + private let recentDateLoader: (String) -> Date? + private let previewLoader: (String) -> UninstallPreview + private var loadGeneration = 0 + private var recentGeneration = 0 + + init( + loadApps: (() -> [InstalledApp])? = nil, + lastUsedDate: ((String) -> Date?)? = nil, + loadPreview: ((String) -> UninstallPreview)? = nil + ) { + appsLoader = loadApps ?? { Self.fetch() } + recentDateLoader = lastUsedDate ?? { Self.lastUsedDate($0) } + previewLoader = loadPreview ?? { name in + let result = try? MoEngine.shared.capture( + MoCommand(target: .mo, args: ["uninstall", "--dry-run", name], stdin: "y\n", timeout: 120) + ) + let text = Ansi.strip((result?.stdout ?? "") + "\n" + (result?.stderr ?? "")) + return UninstallPreview.parse(text.components(separatedBy: "\n")) + } + } /// `id` → lowercased name, rebuilt once per `apps` load. The search field /// filters on every keystroke, so doing the ICU case-folding @@ -637,14 +658,15 @@ final class SoftwareModel: ObservableObject { guard previews[app.id] == nil, !previewLoading.contains(app.id) else { return } previewLoading.insert(app.id) let name = app.uninstallName + let path = app.path + let generation = loadGeneration + let loader = previewLoader DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // EOF after the prompt makes --dry-run print the enumeration and exit. - let res = try? MoEngine.shared.capture( - MoCommand(target: .mo, args: ["uninstall", "--dry-run", name], stdin: "y\n", timeout: 120)) - let text = Ansi.strip((res?.stdout ?? "") + "\n" + (res?.stderr ?? "")) - let preview = UninstallPreview.parse(text.components(separatedBy: "\n")) + let preview = loader(name) Task { @MainActor in - guard let self else { return } + guard let self, + generation == self.loadGeneration, + self.apps.contains(where: { $0.id == app.id && $0.path == path }) else { return } self.previewLoading.remove(app.id) self.previews[app.id] = preview // Default ticks: the auto-selected kinds. @@ -663,34 +685,55 @@ final class SoftwareModel: ObservableObject { guard !recentLoaded, !apps.isEmpty else { return } recentLoaded = true let snapshot = apps + recentGeneration &+= 1 + let generation = recentGeneration + let inventoryGeneration = loadGeneration + let dateLoader = recentDateLoader DispatchQueue.global(qos: .userInitiated).async { - let dated = snapshot.map { a in - InstalledApp(id: a.id, name: a.name, bundleId: a.bundleId, source: a.source, - uninstallName: a.uninstallName, path: a.path, sizeStr: a.sizeStr, - sizeBytes: a.sizeBytes, lastUsed: Self.lastUsedDate(a.path)) + let dates = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.id, dateLoader($0.path)) }) + Task { @MainActor in + guard generation == self.recentGeneration, + inventoryGeneration == self.loadGeneration else { return } + // The inventory may have refreshed metadata while dates were + // loading. Merge onto the live rows by stable app identity so + // a late date pass never resurrects an old snapshot. + self.apps = self.apps.map { app in + guard let date = dates[app.id] else { return app } + return InstalledApp( + id: app.id, name: app.name, bundleId: app.bundleId, source: app.source, + uninstallName: app.uninstallName, path: app.path, sizeStr: app.sizeStr, + sizeBytes: app.sizeBytes, lastUsed: date + ) + } } - Task { @MainActor in self.apps = dated } } } func load() { + loadGeneration &+= 1 + recentGeneration &+= 1 + let generation = loadGeneration + let loader = appsLoader loading = true error = nil DispatchQueue.global(qos: .userInitiated).async { - let parsed = Self.fetch() + let parsed = loader() Task { @MainActor in + guard generation == self.loadGeneration else { return } self.apps = parsed self.loading = false self.recentLoaded = false self.previews = [:] + self.previewLoading = [] self.pathSelections = [:] self.expandedAppID = nil + self.selected.formIntersection(parsed.map(\.id)) if self.sort == .recent { self.ensureRecentDates() } } } } - private static func fetch() -> [InstalledApp] { + private nonisolated static func fetch() -> [InstalledApp] { // `mo uninstall --list` computes a size for every installed app, which can // take a while on a full /Applications — the client gives it room. let apps = MoleClient.listApps() @@ -707,7 +750,7 @@ final class SoftwareModel: ObservableObject { /// querying metadata for every installed app woke `mds`/`mdworker` and spiked /// CPU/energy. Filesystem dates are close enough for the Recent sort and cost /// nothing — no metadata server, no indexing. - private static func lastUsedDate(_ path: String) -> Date? { + private nonisolated static func lastUsedDate(_ path: String) -> Date? { let url = URL(fileURLWithPath: path) if let vals = try? url.resourceValues(forKeys: [.contentAccessDateKey, .contentModificationDateKey]) { return vals.contentAccessDate ?? vals.contentModificationDate diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index 6e8c66f2..1809e16c 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -607,7 +607,8 @@ struct ProcessCard: View { ProcRow(p: p, pinned: model.pinned.contains(p.pid), energy: model.energies[p.pid], - onInspect: { inspecting = ProcessInspectTarget(proc: p) }) { + onInspect: { inspecting = ProcessInspectTarget(proc: p) }, + onTermination: { model.invalidateProcess($0) }) { model.togglePin(p.pid) } } @@ -728,6 +729,7 @@ struct ProcRow: View { /// Cumulative billed energy (nJ) — nil renders "—", never estimated. var energy: UInt64? = nil var onInspect: () -> Void = {} + var onTermination: (Int) -> Void = { _ in } let onPin: () -> Void @State private var hover = false @@ -834,18 +836,12 @@ struct ProcRow: View { } private func confirmQuit(force: Bool) { - let alert = NSAlert() - alert.messageText = force - ? String(format: NSLocalizedString("Force kill %@?", comment: ""), p.name) - : String(format: NSLocalizedString("Quit %@?", comment: ""), p.name) - alert.informativeText = force - ? NSLocalizedString("SIGKILL ends it immediately — unsaved work in this process is lost.", comment: "") - : NSLocalizedString("Sends a polite quit (SIGTERM). The process may save and exit, or ignore it.", comment: "") - alert.alertStyle = .warning - alert.addButton(withTitle: force ? NSLocalizedString("Force Kill", comment: "") : NSLocalizedString("Quit Process", comment: "")) - alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) - guard alert.runModalQuiet() == .alertFirstButtonReturn else { return } - if force { ProcessActions.forceKill(pid: p.pid) } else { ProcessActions.quit(pid: p.pid) } + ProcessActions.confirmTermination( + pid: p.pid, + displayName: p.name, + force: force, + onRefresh: { onTermination(p.pid) } + ) } private var cpuBar: some View { @@ -996,6 +992,7 @@ final class StatusModel: ObservableObject { /// Typed predicate filter over the table (PRD §α), e.g. "cpu > 20" or /// "name ~ chrome". Empty = no filter. Parsed once per change, not per row. @Published var filterText: String = "" + private var invalidatedProcessIDs: Set = [] let db: DB private let live: LiveFeed @@ -1069,6 +1066,7 @@ final class StatusModel: ObservableObject { }.value } for await v in feed.subscribeValues() { + invalidatedProcessIDs.removeAll() // Empty pass (spawn failure) keeps the table on the snapshot's // engine top five — recomputeSortedRows() falls back when empty. processes = v.processes @@ -1098,6 +1096,11 @@ final class StatusModel: ObservableObject { recomputeSortedRows() } + func invalidateProcess(_ pid: Int) { + invalidatedProcessIDs.insert(pid) + recomputeSortedRows() + } + /// Re-sort the table from the current inputs and publish the result into /// `sortedRows`. O(n log n) over a few hundred rows, but run once per /// real change instead of once per `ProcessCard.body` evaluation — the @@ -1105,6 +1108,7 @@ final class StatusModel: ObservableObject { /// mutates a `@Published`); every caller already does. func recomputeSortedRows() { var procs = processes.isEmpty ? (snap?.topProcesses ?? []) : processes + procs.removeAll { invalidatedProcessIDs.contains($0.pid) } if let pred = ProcessFilter.parse(filterText) { procs = procs.filter { ProcessFilter.matches(ProcessFilter.Record( diff --git a/macos/Sources/Store.swift b/macos/Sources/Store.swift index 8d877676..de0a902f 100644 --- a/macos/Sources/Store.swift +++ b/macos/Sources/Store.swift @@ -569,12 +569,19 @@ enum Store { set { write(min(100, max(50, newValue)), "mem_alert_threshold") } } - /// Bearer token for the query server's SSE /events stream (B.6). Generated - /// once and persisted; agents pass it as `?token=…`. The server is loopback- - /// only, so this just stops other local processes/pages from subscribing. + /// Per-install bearer credential for every HTTP query-server route, + /// including SSE. Two UUIDv4 payloads provide more than 256 random bits; + /// strip punctuation so the value is safe to paste into an HTTP header. + /// URLs are never accepted as credential transport because they leak into + /// browser history, shell history, and diagnostics. static var queryAuthToken: String { - if let t = d.string(forKey: "query_auth_token"), !t.isEmpty { return t } - let t = UUID().uuidString + if let t = d.string(forKey: "query_auth_token"), + t.utf8.count >= 43, + t.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_" }) { + return t + } + let t = (UUID().uuidString + UUID().uuidString) + .replacingOccurrences(of: "-", with: "") write(t, "query_auth_token") return t } diff --git a/macos/Sources/TuneUp.swift b/macos/Sources/TuneUp.swift index dcc1008a..648755eb 100644 --- a/macos/Sources/TuneUp.swift +++ b/macos/Sources/TuneUp.swift @@ -13,6 +13,21 @@ import Foundation enum TuneUp { + struct ConfirmationPolicy: Equatable { + let includesClean: Bool + + var requiresIrreversibleConsent: Bool { includesClean } + var notice: String { + includesClean + ? "Clean caches & junk permanently deletes the reviewed cache files. They do not go to the Trash and cannot be recovered. Each elevated step asks for your password separately." + : "Maintenance does not run the cache-deletion step. Each elevated step asks for your password separately." + } + + func permitsRun(irreversibleConsent: Bool) -> Bool { + !requiresIrreversibleConsent || irreversibleConsent + } + } + enum Kind: String, Equatable { case brewCleanup, freeCache, updateApp // safe to auto-run case uninstallUnused, disableStartupItem // destructive — review only diff --git a/macos/Sources/TuneUpView.swift b/macos/Sources/TuneUpView.swift index cab6867d..13b6bf9b 100644 --- a/macos/Sources/TuneUpView.swift +++ b/macos/Sources/TuneUpView.swift @@ -35,7 +35,7 @@ struct TuneUpView: View { var isActive: Bool = true private enum Phase { case scanning, results, running, done } - private enum SafeStep { case clean, optimize } + private enum SafeStep { case clean(CleanupExecutionPlan), optimize } @State private var phase: Phase = .scanning @State private var includeClean = true @@ -44,6 +44,8 @@ struct TuneUpView: View { @State private var runIndex = 0 @State private var stepSummaries: [String] = [] @State private var showPlan = false + @State private var irreversibleConsent = false + @State private var pendingCleanupPlan: CleanupExecutionPlan? private var accent: Color { Tool.tuneup.accent } @@ -187,7 +189,7 @@ struct TuneUpView: View { GlassCard { VStack(alignment: .leading, spacing: 12) { Eyebrow(text: "Run the safe set", glyph: "wand.and.stars", color: accent) - Text(NSLocalizedString("Reclaim space and refresh maintenance in a single pass — everything here is reversible.", comment: "")) + Text(NSLocalizedString("Reclaim space and refresh maintenance in a single pass. Cache cleaning permanently deletes the reviewed cache files; maintenance runs separately.", comment: "")) .font(Brand.sans(12.5)).foregroundStyle(Brand.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -201,7 +203,7 @@ struct TuneUpView: View { : String(format: NSLocalizedString("%d areas", comment: ""), snap.optimizeAreas.count)) HStack(spacing: 12) { - PillButton(title: "Run the safe set") { showPlan = true } + PillButton(title: "Review run") { prepareRunReview() } .disabled(!canRun(snap)) .opacity(canRun(snap) ? 1 : 0.4) Text(NSLocalizedString("Each step asks for your password.", comment: "")) @@ -376,7 +378,7 @@ struct TuneUpView: View { guard runIndex < runSteps.count else { return NSLocalizedString("Working…", comment: "") } let pos = runIndex + 1, n = runSteps.count switch runSteps[runIndex] { - case .clean: return String(format: NSLocalizedString("Cleaning… (%d of %d)", comment: ""), pos, n) + case .clean(_): return String(format: NSLocalizedString("Cleaning… (%d of %d)", comment: ""), pos, n) case .optimize: return String(format: NSLocalizedString("Running maintenance… (%d of %d)", comment: ""), pos, n) } case .done: @@ -405,7 +407,10 @@ struct TuneUpView: View { private func runSafeSet() { guard let snap = model.snapshot else { return } var steps: [SafeStep] = [] - if includeClean, !snap.cleanableText.isEmpty { steps.append(.clean) } + if includeClean, !snap.cleanableText.isEmpty { + guard let pendingCleanupPlan, pendingCleanupPlan.validateForLaunch() else { return } + steps.append(.clean(pendingCleanupPlan)) + } if includeOptimize, !snap.optimizeAreas.isEmpty { steps.append(.optimize) } guard !steps.isEmpty else { return } runSteps = steps @@ -417,10 +422,11 @@ struct TuneUpView: View { private func startStep(_ step: SafeStep) { switch step { - case .clean: - flow.start(.moleStream(["clean"], elevated: true, - label: NSLocalizedString("Tune-Up: cleaning", comment: ""), - notifyOnEnd: true)) + case .clean(let plan): + flow.start(ToolOperation( + label: NSLocalizedString("Tune-Up: cleaning reviewed caches", comment: ""), + executable: .path("/usr/bin/find"), arguments: [], elevated: true, + cleanupPlan: plan, reduce: { parseTaskReport($0) }, notifyOnEnd: true)) case .optimize: flow.start(.moleStream(["optimize"], elevated: true, label: NSLocalizedString("Tune-Up: optimizing", comment: ""), @@ -494,6 +500,7 @@ struct TuneUpView: View { let snap = model.snapshot let willClean = includeClean && !(snap?.cleanableText.isEmpty ?? true) let willOptimize = includeOptimize && !(snap?.optimizeAreas.isEmpty ?? true) + let policy = TuneUp.ConfirmationPolicy(includesClean: willClean) return VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text(NSLocalizedString("Tune-up plan", comment: "")) @@ -505,20 +512,46 @@ struct TuneUpView: View { if willClean { planRow(glyph: "sparkles", title: NSLocalizedString("Clean caches & junk", comment: ""), value: snap?.cleanableText ?? "") + if let plan = pendingCleanupPlan { + VStack(alignment: .leading, spacing: 3) { + ForEach(plan.items.prefix(6), id: \.identity.path) { item in + Text(item.identity.path) + .font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) + .lineLimit(1).truncationMode(.middle) + } + if plan.items.count > 6 { + Text(String(format: NSLocalizedString("and %d more reviewed paths", comment: ""), plan.items.count - 6)) + .font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) + } + } + .padding(.horizontal, 12) + } } if willOptimize { planRow(glyph: "wand.and.stars", title: NSLocalizedString("Run maintenance", comment: ""), value: String(format: NSLocalizedString("%d areas", comment: ""), snap?.optimizeAreas.count ?? 0)) } } - Text(NSLocalizedString("Files go to the Trash, not deleted. Each elevated step asks for your password separately.", comment: "")) + Text(NSLocalizedString(policy.notice, comment: "")) .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) .fixedSize(horizontal: false, vertical: true) + if policy.requiresIrreversibleConsent { + Toggle(isOn: $irreversibleConsent) { + Text(NSLocalizedString("I understand the clean step permanently deletes these cache files.", comment: "")) + .font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + } + .toggleStyle(.checkbox) + } HStack(spacing: 12) { Spacer() Button(NSLocalizedString("Cancel", comment: "")) { showPlan = false } .buttonStyle(.plain).foregroundStyle(Brand.textSecondary) - PillButton(title: "Run") { showPlan = false; runSafeSet() } + PillButton(title: willClean ? "Permanently clean and run" : "Run") { + showPlan = false + runSafeSet() + } + .disabled(!policy.permitsRun(irreversibleConsent: irreversibleConsent)) + .opacity(policy.permitsRun(irreversibleConsent: irreversibleConsent) ? 1 : 0.4) } } .padding(24) @@ -527,6 +560,32 @@ struct TuneUpView: View { .environment(\.colorScheme, .dark) } + private func prepareRunReview() { + irreversibleConsent = false + pendingCleanupPlan = nil + if includeClean, !(model.snapshot?.cleanableText.isEmpty ?? true) { + do { + guard let list = CleanList.loadLive() else { + throw NSError(domain: "dev.caezium.burrow.cleanup", code: 1, + userInfo: [NSLocalizedDescriptionKey: "The engine did not produce a valid itemized cleanup preview."]) + } + let user = try InvokingUserIdentity.current() + let snapshot = try CleanupSnapshot.capture( + list: list, approvedRootURLs: CleanupSnapshot.approvedRoots(for: user)) + let paths = snapshot.items.map(\.identity.path) + pendingCleanupPlan = try snapshot.plan(selectedPaths: paths) + } catch { + let alert = NSAlert() + alert.messageText = NSLocalizedString("The cleanup preview can't be authorized", comment: "") + alert.informativeText = String(format: NSLocalizedString("Nothing will run until you rescan. (%@)", comment: ""), error.localizedDescription) + alert.alertStyle = .warning + alert.runModalQuiet() + return + } + } + showPlan = true + } + private func planRow(glyph: String, title: String, value: String) -> some View { HStack(spacing: 10) { Image(systemName: glyph).font(.system(size: 13)).foregroundStyle(accent).frame(width: 18) diff --git a/macos/Sources/UpdateSources.swift b/macos/Sources/UpdateSources.swift index 9e72ee29..4428c3c7 100644 --- a/macos/Sources/UpdateSources.swift +++ b/macos/Sources/UpdateSources.swift @@ -12,6 +12,7 @@ import Foundation import AppKit +import Darwin enum UpdateSources { enum Source: String { @@ -49,6 +50,27 @@ enum UpdateSources { return nil } + /// Files whose identity/content metadata can change update-source + /// detection without changing InstalledApp's stable id or path. `lstat` + /// keeps this fingerprint cheap and does not follow a planted symlink. + static func detectionFingerprint(appPath: String) -> String { + let contents = (appPath as NSString).appendingPathComponent("Contents") + let paths = [ + (contents as NSString).appendingPathComponent("Info.plist"), + (contents as NSString).appendingPathComponent("_MASReceipt/receipt"), + (contents as NSString).appendingPathComponent("Frameworks/Electron Framework.framework"), + (contents as NSString).appendingPathComponent("Resources/app-update.yml"), + ] + return paths.map(fileFingerprint).joined(separator: "|") + } + + private static func fileFingerprint(_ path: String) -> String { + var info = stat() + guard lstat(path, &info) == 0 else { return "missing" } + return "\(info.st_dev):\(info.st_ino):\(info.st_mode):\(info.st_size):" + + "\(info.st_mtimespec.tv_sec):\(info.st_mtimespec.tv_nsec)" + } + /// The app's Sparkle feed URL, when it advertises one. static func feedURL(appPath: String) -> URL? { let plist = (appPath as NSString).appendingPathComponent("Contents/Info.plist") diff --git a/macos/Sources/UpdateWorkflow.swift b/macos/Sources/UpdateWorkflow.swift new file mode 100644 index 00000000..d6cb1a18 --- /dev/null +++ b/macos/Sources/UpdateWorkflow.swift @@ -0,0 +1,579 @@ +// +// UpdateWorkflow.swift +// Burrow +// +// Shared state and verification primitives for third-party app updates. +// The UI owns orchestration; this file keeps network failure classification, +// Electron feed parsing, and replacement identity checks small and testable. +// + +import Foundation +import AppKit +import CryptoKit +import Security +import Darwin + +enum BundleVerificationFailure: String, Equatable, Sendable { + case bundleIdentityChanged + case signingIdentityChanged + case invalidSignature + + var message: String { + switch self { + case .bundleIdentityChanged: + return NSLocalizedString("The downloaded app has a different bundle identifier.", comment: "") + case .signingIdentityChanged: + return NSLocalizedString("The downloaded app is signed by a different developer.", comment: "") + case .invalidSignature: + return NSLocalizedString("macOS could not validate the downloaded app's code signature.", comment: "") + } + } +} + +enum UpdateFailure: Error, Equatable, Sendable { + case offline + case timeout + case network(code: Int) + case http(status: Int, retryable: Bool) + case invalidResponse + case decoding + case verification(BundleVerificationFailure) + case installation(String) + case unsupported(String) + case cancelled + + var canRetry: Bool { + switch self { + case .offline, .timeout, .network, .decoding: + return true + case let .http(_, retryable): + return retryable + case .installation: + return true + case .invalidResponse, .verification, .unsupported, .cancelled: + return false + } + } + + var message: String { + switch self { + case .offline: + return NSLocalizedString("Offline — reconnect and retry.", comment: "") + case .timeout: + return NSLocalizedString("The update server timed out. Retry when the connection is stable.", comment: "") + case let .network(code): + return String(format: NSLocalizedString("The network request failed (%d).", comment: ""), code) + case let .http(status, retryable): + if retryable { + return String(format: NSLocalizedString("The update server failed (HTTP %d). You can retry.", comment: ""), status) + } + return String(format: NSLocalizedString("The update server refused the request (HTTP %d).", comment: ""), status) + case .invalidResponse: + return NSLocalizedString("The update server returned an invalid response.", comment: "") + case .decoding: + return NSLocalizedString("The update metadata was malformed.", comment: "") + case let .verification(reason): + return reason.message + case let .installation(message), let .unsupported(message): + return message + case .cancelled: + return NSLocalizedString("Update cancelled.", comment: "") + } + } +} + +enum UpdatePhase: Equatable, Sendable { + case idle + case checking + case available + case downloading(progress: Double?) + case verifying + case readyToInstall + case installing + case waitingForRestart + case completed + case handedOff(String) + case failed(UpdateFailure) + + var canRetry: Bool { + if case let .failed(failure) = self { return failure.canRetry } + return false + } + + var isBusy: Bool { + switch self { + case .checking, .downloading, .verifying, .installing, .waitingForRestart: + return true + default: + return false + } + } + + var accessibilityValue: String { + switch self { + case .idle: + return NSLocalizedString("Not checked", comment: "") + case .checking: + return NSLocalizedString("Checking for updates", comment: "") + case .available: + return NSLocalizedString("Update available", comment: "") + case let .downloading(progress): + guard let progress else { return NSLocalizedString("Downloading", comment: "") } + return String(format: NSLocalizedString("Downloading, %.0f percent", comment: ""), progress * 100) + case .verifying: + return NSLocalizedString("Verifying downloaded update", comment: "") + case .readyToInstall: + return NSLocalizedString("Ready to install and restart", comment: "") + case .installing: + return NSLocalizedString("Installing update", comment: "") + case .waitingForRestart: + return NSLocalizedString("Waiting for the app to restart", comment: "") + case .completed: + return NSLocalizedString("Update installed", comment: "") + case let .handedOff(destination): + return String(format: NSLocalizedString("Continue in %@", comment: ""), destination) + case let .failed(failure): + return failure.message + } + } +} + +enum UpdateHTTPOutcome: Equatable, Sendable { + case success(Data) + case failure(UpdateFailure) +} + +enum UpdateHTTP { + static func classify(data: Data?, response: URLResponse?, error: Error?) -> UpdateHTTPOutcome { + if let urlError = error as? URLError { + switch urlError.code { + case .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .dataNotAllowed: + return .failure(.offline) + case .timedOut: + return .failure(.timeout) + case .cancelled: + return .failure(.cancelled) + default: + return .failure(.network(code: urlError.errorCode)) + } + } + if let error = error as NSError? { + return .failure(.network(code: error.code)) + } + guard let http = response as? HTTPURLResponse else { + return .failure(.invalidResponse) + } + guard (200...299).contains(http.statusCode) else { + let retryable = http.statusCode == 408 || http.statusCode == 429 || (500...599).contains(http.statusCode) + return .failure(.http(status: http.statusCode, retryable: retryable)) + } + guard let data, !data.isEmpty else { return .failure(.decoding) } + return .success(data) + } + + static func fetch(_ url: URL, timeout: TimeInterval = 15) async -> UpdateHTTPOutcome { + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalCacheData + do { + let (data, response) = try await URLSession.shared.data(for: request) + if url.scheme?.lowercased() == "https", + response.url?.scheme?.lowercased() != "https" { + return .failure(.invalidResponse) + } + return classify(data: data, response: response, error: nil) + } catch { + return classify(data: nil, response: nil, error: error) + } + } +} + +struct ElectronFeedConfiguration: Equatable, Sendable { + let latestYAMLURL: URL + + /// electron-builder's generic provider is the only direct replacement + /// format supported here. GitHub/S3/private providers hand off to the + /// app's own updater because reconstructing their authentication and + /// channel rules would be unsafe. + static func parse(_ data: Data) -> ElectronFeedConfiguration? { + guard let text = String(data: data, encoding: .utf8), + scalar("provider", in: text)?.lowercased() == "generic", + let rawURL = scalar("url", in: text), + let base = URL(string: rawURL), + base.scheme?.lowercased() == "https" else { return nil } + return ElectronFeedConfiguration(latestYAMLURL: base.appendingPathComponent("latest-mac.yml")) + } + + static func read(appPath: String) -> ElectronFeedConfiguration? { + let path = (appPath as NSString).appendingPathComponent("Contents/Resources/app-update.yml") + guard let data = FileManager.default.contents(atPath: path) else { return nil } + return parse(data) + } + + fileprivate static func scalar(_ key: String, in yaml: String) -> String? { + for rawLine in yaml.split(whereSeparator: { $0.isNewline }) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard !line.hasPrefix("#"), let colon = line.firstIndex(of: ":") else { continue } + let candidate = line[.. String { + guard value.count >= 2, + let first = value.first, + let last = value.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") else { return value } + return String(value.dropFirst().dropLast()) + } +} + +struct ElectronUpdateDescriptor: Equatable, Sendable { + let version: String + let archiveURL: URL + let sha512: Data + + static func parse(_ data: Data, relativeTo metadataURL: URL) -> ElectronUpdateDescriptor? { + guard let text = String(data: data, encoding: .utf8), + let version = ElectronFeedConfiguration.scalar("version", in: text), + !version.isEmpty else { return nil } + + var archivePath: String? + var digest: String? + for rawLine in text.split(whereSeparator: { $0.isNewline }) { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + if archivePath == nil, trimmed.hasPrefix("- url:") { + archivePath = ElectronFeedConfiguration.unquote( + String(trimmed.dropFirst("- url:".count)).trimmingCharacters(in: .whitespaces) + ) + } else if archivePath == nil, trimmed.hasPrefix("path:") { + archivePath = ElectronFeedConfiguration.unquote( + String(trimmed.dropFirst("path:".count)).trimmingCharacters(in: .whitespaces) + ) + } + if digest == nil, trimmed.hasPrefix("sha512:") { + digest = ElectronFeedConfiguration.unquote( + String(trimmed.dropFirst("sha512:".count)).trimmingCharacters(in: .whitespaces) + ) + } + } + + guard let archivePath, let digest, let sha512 = Data(base64Encoded: digest) else { return nil } + let archiveURL: URL? + if let absolute = URL(string: archivePath), absolute.scheme != nil { + archiveURL = absolute + } else { + archiveURL = URL(string: archivePath, relativeTo: metadataURL.deletingLastPathComponent())?.absoluteURL + } + guard let archiveURL, + archiveURL.scheme?.lowercased() == "https", + archiveURL.pathExtension.lowercased() == "zip" else { return nil } + return ElectronUpdateDescriptor(version: version, archiveURL: archiveURL, sha512: sha512) + } +} + +struct BundleUpdateIdentity: Equatable, Sendable { + let bundleID: String + let signingIdentifier: String? + let teamIdentifier: String? + let signatureValid: Bool + + func verificationFailure(comparedWith candidate: BundleUpdateIdentity) -> BundleVerificationFailure? { + guard candidate.signatureValid else { return .invalidSignature } + guard bundleID == candidate.bundleID else { return .bundleIdentityChanged } + guard signatureValid, + let signingIdentifier, let teamIdentifier, + !signingIdentifier.isEmpty, !teamIdentifier.isEmpty, + signingIdentifier == candidate.signingIdentifier, + teamIdentifier == candidate.teamIdentifier else { return .signingIdentityChanged } + return nil + } + + static func read(appURL: URL) -> BundleUpdateIdentity? { + guard let bundle = Bundle(url: appURL), let bundleID = bundle.bundleIdentifier else { return nil } + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, + let staticCode else { return nil } + // Validate every architecture in a universal app. Checking only the + // host architecture could otherwise admit a tampered alternate slice. + let checkAllArchitectures = SecCSFlags(rawValue: 1 << 0) + let valid = SecStaticCodeCheckValidity(staticCode, checkAllArchitectures, nil) == errSecSuccess + var information: CFDictionary? + let signingInformation: UInt32 = 0x2 + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: signingInformation), + &information + ) == errSecSuccess, + let values = information as? [String: Any] else { + return BundleUpdateIdentity( + bundleID: bundleID, + signingIdentifier: nil, + teamIdentifier: nil, + signatureValid: valid + ) + } + return BundleUpdateIdentity( + bundleID: bundleID, + signingIdentifier: values[kSecCodeInfoIdentifier as String] as? String, + teamIdentifier: values[kSecCodeInfoTeamIdentifier as String] as? String, + signatureValid: valid + ) + } +} + +struct StagedElectronUpdate: Sendable { + let targetURL: URL + let candidateURL: URL + let stagingDirectory: URL + let expectedIdentity: BundleUpdateIdentity + + func discard() { + try? FileManager.default.removeItem(at: stagingDirectory) + } +} + +enum PrivateUpdateDirectory { + /// `mkdtemp` creates the directory atomically, so concurrent update runs + /// cannot select the same path or follow a pre-planted symlink. Tighten + /// its permissions before any downloaded data is written. + static func create(in parent: URL = FileManager.default.temporaryDirectory) throws -> URL { + var template = parent + .appendingPathComponent("BurrowUpdate.XXXXXX", isDirectory: true) + .path + .utf8CString + let created = template.withUnsafeMutableBufferPointer { buffer in + mkdtemp(buffer.baseAddress!) + } + guard created != nil else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))] + ) + } + let path = template.withUnsafeBufferPointer { buffer in + String(cString: buffer.baseAddress!) + } + let url = URL(fileURLWithPath: path, isDirectory: true) + guard chmod(url.path, S_IRWXU) == 0 else { + let code = errno + try? FileManager.default.removeItem(at: url) + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(code))] + ) + } + return url + } +} + +enum ElectronStageOutcome: Sendable { + case ready(StagedElectronUpdate) + case failure(UpdateFailure) +} + +enum ElectronInstallOutcome: Sendable { + case installed + case failure(UpdateFailure) +} + +enum ElectronReplacementInstaller { + static func boundaryVerificationFailure( + for staged: StagedElectronUpdate, + readIdentity: (URL) -> BundleUpdateIdentity? = { BundleUpdateIdentity.read(appURL: $0) } + ) -> UpdateFailure? { + guard let targetIdentity = readIdentity(staged.targetURL) else { + return .verification(.bundleIdentityChanged) + } + if let failure = staged.expectedIdentity.verificationFailure(comparedWith: targetIdentity) { + return .verification(failure) + } + guard let candidateIdentity = readIdentity(staged.candidateURL) else { + return .verification(.invalidSignature) + } + if let failure = staged.expectedIdentity.verificationFailure(comparedWith: candidateIdentity) { + return .verification(failure) + } + return nil + } + + static func stage( + appPath: String, + descriptor: ElectronUpdateDescriptor + ) async -> ElectronStageOutcome { + let targetURL = URL(fileURLWithPath: appPath, isDirectory: true) + guard let expectedIdentity = BundleUpdateIdentity.read(appURL: targetURL), + expectedIdentity.verificationFailure(comparedWith: expectedIdentity) == nil else { + return .failure(.unsupported(NSLocalizedString( + "This Electron app does not expose a verifiable signing identity, so Burrow opened its own updater instead.", + comment: "" + ))) + } + + let root: URL + do { + root = try PrivateUpdateDirectory.create() + } catch { + return .failure(.installation(error.localizedDescription)) + } + + let archive = root.appendingPathComponent("update.zip") + let extracted = root.appendingPathComponent("Extracted", isDirectory: true) + do { + let (temporaryDownload, response) = try await URLSession.shared.download(from: descriptor.archiveURL) + guard let http = response as? HTTPURLResponse, + response.url?.scheme?.lowercased() == "https" else { + throw UpdateFailure.invalidResponse + } + guard (200...299).contains(http.statusCode) else { + throw UpdateFailure.http( + status: http.statusCode, + retryable: http.statusCode == 408 || http.statusCode == 429 || (500...599).contains(http.statusCode) + ) + } + try FileManager.default.moveItem(at: temporaryDownload, to: archive) + guard try sha512(of: archive) == descriptor.sha512 else { + throw UpdateFailure.verification(.invalidSignature) + } + try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true) + let extraction = try MoEngine.shared.capture( + MoCommand( + target: .executable("/usr/bin/ditto"), + args: ["-x", "-k", archive.path, extracted.path], + timeout: 120 + ) + ) + guard extraction.exitCode == 0 else { + throw UpdateFailure.installation(NSLocalizedString("The downloaded update could not be extracted.", comment: "")) + } + guard let candidate = firstApplication(in: extracted), + let candidateIdentity = BundleUpdateIdentity.read(appURL: candidate) else { + throw UpdateFailure.verification(.invalidSignature) + } + if let failure = expectedIdentity.verificationFailure(comparedWith: candidateIdentity) { + throw UpdateFailure.verification(failure) + } + return .ready(StagedElectronUpdate( + targetURL: targetURL, + candidateURL: candidate, + stagingDirectory: root, + expectedIdentity: expectedIdentity + )) + } catch let failure as UpdateFailure { + try? FileManager.default.removeItem(at: root) + return .failure(failure) + } catch let error as URLError { + try? FileManager.default.removeItem(at: root) + let failure: UpdateFailure + switch error.code { + case .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .dataNotAllowed: + failure = .offline + case .timedOut: + failure = .timeout + case .cancelled: + failure = .cancelled + default: + failure = .network(code: error.errorCode) + } + return .failure(failure) + } catch { + try? FileManager.default.removeItem(at: root) + return .failure(.installation(error.localizedDescription)) + } + } + + @MainActor + static func install(_ staged: StagedElectronUpdate) async -> ElectronInstallOutcome { + defer { staged.discard() } + guard let currentIdentity = BundleUpdateIdentity.read(appURL: staged.targetURL), + staged.expectedIdentity.verificationFailure(comparedWith: currentIdentity) == nil else { + return .failure(.verification(.bundleIdentityChanged)) + } + guard FileManager.default.isWritableFile(atPath: staged.targetURL.deletingLastPathComponent().path) else { + return .failure(.unsupported(NSLocalizedString( + "Burrow cannot safely replace this app in its current folder. Open the app and use its own updater.", + comment: "" + ))) + } + + let running = NSRunningApplication.runningApplications( + withBundleIdentifier: staged.expectedIdentity.bundleID + ) + for app in running { app.terminate() } + let deadline = Date().addingTimeInterval(8) + while running.contains(where: { !$0.isTerminated }), Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } + guard !running.contains(where: { !$0.isTerminated }) else { + return .failure(.installation(NSLocalizedString( + "The app did not quit, so Burrow left the existing installation unchanged.", + comment: "" + ))) + } + + // Revalidate again after waiting for termination. An app or another + // updater may have replaced the target while the quit request ran. + // Both bundles have remained on disk while the target app quit. + // Re-read them at the replacement boundary so a stale target or + // swapped candidate fails closed. + if let failure = boundaryVerificationFailure(for: staged) { + return .failure(failure) + } + + let backupName = ".Burrow Update Backup \(UUID().uuidString).app" + let backupURL = staged.targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + do { + _ = try FileManager.default.replaceItemAt( + staged.targetURL, + withItemAt: staged.candidateURL, + backupItemName: backupName, + options: [] + ) + guard let installedIdentity = BundleUpdateIdentity.read(appURL: staged.targetURL), + staged.expectedIdentity.verificationFailure(comparedWith: installedIdentity) == nil else { + if FileManager.default.fileExists(atPath: backupURL.path) { + _ = try? FileManager.default.replaceItemAt(staged.targetURL, withItemAt: backupURL) + } + return .failure(.verification(.signingIdentityChanged)) + } + try? FileManager.default.removeItem(at: backupURL) + let configuration = NSWorkspace.OpenConfiguration() + _ = try? await NSWorkspace.shared.openApplication( + at: staged.targetURL, + configuration: configuration + ) + return .installed + } catch { + return .failure(.installation(error.localizedDescription)) + } + } + + private static func sha512(of url: URL) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA512() + while true { + let data = try handle.read(upToCount: 1_048_576) ?? Data() + if data.isEmpty { break } + hasher.update(data: data) + } + return Data(hasher.finalize()) + } + + private static func firstApplication(in directory: URL) -> URL? { + let keys: [URLResourceKey] = [.isDirectoryKey] + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { return nil } + for case let url as URL in enumerator where url.pathExtension.lowercased() == "app" { + return url + } + return nil + } +} diff --git a/macos/Sources/UpdatesView.swift b/macos/Sources/UpdatesView.swift index d8db32c8..d217751b 100644 --- a/macos/Sources/UpdatesView.swift +++ b/macos/Sources/UpdatesView.swift @@ -10,9 +10,11 @@ // ONLY when the user clicks Check — never silently (the network story // in SECURITY.md depends on this). // -// v1 actions are deep-links: Sparkle/Electron apps open themselves -// (their own updater takes it from there), App Store opens the product -// page, Homebrew upgrades inline as before. +// Supported Sparkle apps update through Sparkle's signed installer; +// generic electron-builder feeds stage a SHA-512 checked, code-signature +// matched replacement; Homebrew runs serially in-process; App Store and +// unsupported vendor mechanisms are explicit handoffs and never claim an +// install Burrow did not perform. // import SwiftUI @@ -37,6 +39,7 @@ struct AppUpdateItem: Identifiable { let source: UpdateSources.Source var latestVersion: String? var pageURL: URL? + var releaseNotesURL: URL? var lastUsed: Date? /// App Store: the macOS this update requires (from the iTunes lookup). var minimumOS: String? @@ -68,7 +71,7 @@ struct UpdatesView: View { } } .onAppear { model.prepare(apps: apps); model.autoSurface() } - .onChange(of: apps.count) { _, _ in model.prepare(apps: apps) } + .onChange(of: apps) { _, latest in model.prepare(apps: latest) } } private var header: some View { @@ -94,9 +97,20 @@ struct UpdatesView: View { model.checkNow() } .keyboardShortcut("r", modifiers: .command) - if model.checked, !model.brewItems.isEmpty { - PillButton(title: model.upgrading.isEmpty ? "Update all brews" : "Updating…", filled: false) { - model.upgradeAll() + if model.updateAllRunning { + Text(verbatim: "\(model.updateAllCompleted)/\(model.updateAllTotal)") + .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + .accessibilityLabel(String( + format: NSLocalizedString("%d of %d updates complete", comment: ""), + model.updateAllCompleted, + model.updateAllTotal + )) + PillButton(title: "Stop after current", filled: false) { + model.cancelUpdateAll() + } + } else if model.checked, model.availableItems.count + model.brewItems.count > 1 { + PillButton(title: "Update All", filled: false) { + model.updateAll() } } } @@ -106,6 +120,12 @@ struct UpdatesView: View { private var list: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { + if let error = model.error { + Text(error) + .font(Brand.mono(10)).foregroundStyle(Brand.amber) + .padding(.horizontal, 14).padding(.vertical, 8) + .accessibilityLabel(error) + } if model.checked, model.availableItems.isEmpty, model.brewItems.isEmpty { VStack(spacing: 10) { Image(systemName: "checkmark.seal.fill").font(.system(size: 30)).foregroundStyle(Brand.green) @@ -153,7 +173,8 @@ struct UpdatesView: View { // MARK: Rows private func appRow(_ item: AppUpdateItem) -> some View { - HStack(spacing: 12) { + let phase = model.phase(for: item.id) + return HStack(spacing: 12) { Image(nsImage: SoftwareIcons.icon(item.path)).resizable().frame(width: 28, height: 28) VStack(alignment: .leading, spacing: 1) { HStack(spacing: 7) { @@ -162,22 +183,77 @@ struct UpdatesView: View { } metaLine(version: item.installedVersion, latest: item.latestVersion, size: item.sizeStr, lastUsed: item.lastUsed) + if phase != .idle, phase != .available, phase != .completed { + Text(phase.accessibilityValue) + .font(Brand.mono(9)) + .foregroundStyle(phaseColor(phase)) + .lineLimit(2) + } } Spacer(minLength: 8) - if item.updateAvailable { - Button { model.update(item) } label: { - Text("Update").font(Brand.sans(11, .semibold)).foregroundStyle(.white) - .padding(.horizontal, 12).padding(.vertical, 5) - .background(Capsule().fill(Tool.apps.accent)) - }.buttonStyle(.plain) + if item.releaseNotesURL != nil || item.pageURL != nil { + Button("Release notes") { model.openReleaseNotes(item) } + .buttonStyle(.plain) + .font(Brand.mono(9)).foregroundStyle(Brand.textSecondary) } + appAction(item, phase: phase) } .padding(.horizontal, 10).padding(.vertical, 7) .accessibilityElement(children: .combine) + .accessibilityValue(phase.accessibilityValue) + } + + @ViewBuilder + private func appAction(_ item: AppUpdateItem, phase: UpdatePhase) -> some View { + switch phase { + case .readyToInstall where item.source == .electron: + updateButton("Install & Restart") { model.installReady(item) } + case let .failed(failure) where failure.canRetry: + updateButton("Retry") { model.retry(item) } + case .failed(.unsupported(_)) where item.source == .electron: + updateButton("Open updater", filled: false) { model.update(item) } + case .handedOff(_) where item.source == .appStore: + updateButton("Open App Store", filled: false) { model.update(item) } + case .handedOff(_): + updateButton("Open updater", filled: false) { model.update(item) } + case .downloading(_) where item.source == .electron: + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Button("Cancel") { model.cancel(item) } + .buttonStyle(.plain).font(Brand.mono(9)).foregroundStyle(Brand.textSecondary) + } + case .checking, .downloading(_), .verifying, .installing, .waitingForRestart: + ProgressView().controlSize(.small).frame(width: 64) + default: + if item.updateAvailable { + updateButton("Update") { model.update(item) } + } + } + } + + private func updateButton( + _ title: String, + filled: Bool = true, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title).font(Brand.sans(11, .semibold)) + .foregroundStyle(filled ? Color.white : Tool.apps.accent) + .padding(.horizontal, 12).padding(.vertical, 5) + .background(Capsule().fill(filled ? Tool.apps.accent : Tool.apps.accent.opacity(0.12))) + } + .buttonStyle(.plain) + } + + private func phaseColor(_ phase: UpdatePhase) -> Color { + if case .failed = phase { return Brand.red } + if case .handedOff = phase { return Brand.amber } + return Tool.apps.accent } private func brewRow(_ item: OutdatedItem) -> some View { - HStack(spacing: 12) { + let phase = model.phase(for: item.id) + return HStack(spacing: 12) { Image(systemName: item.kind == "cask" ? "macwindow" : "shippingbox") .font(.system(size: 14)).foregroundStyle(Tool.apps.accent).frame(width: 28) VStack(alignment: .leading, spacing: 1) { @@ -192,6 +268,9 @@ struct UpdatesView: View { Text(verbatim: "\(item.installed) → \(item.latest)") .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) } + if case let .failed(failure) = phase { + Text(failure.message).font(Brand.mono(9)).foregroundStyle(Brand.red).lineLimit(2) + } } Spacer(minLength: 8) if model.upgrading.contains(item.id) { @@ -205,6 +284,8 @@ struct UpdatesView: View { } } .padding(.horizontal, 10).padding(.vertical, 7) + .accessibilityElement(children: .combine) + .accessibilityValue(phase.accessibilityValue) } private func plainRow(_ app: InstalledApp) -> some View { @@ -265,37 +346,119 @@ final class UpdatesModel: ObservableObject { @Published var checked = false @Published var error: String? @Published var upgrading: Set = [] + @Published private(set) var phases: [String: UpdatePhase] = [:] + @Published private(set) var updateAllRunning = false + @Published private(set) var updateAllCompleted = 0 + @Published private(set) var updateAllTotal = 0 /// Live brew step during an upgrade (H: brew-upgrade streaming). @Published var brewPhrase: String = "" /// True while the on-open `brew outdated` surface is running (shows a spinner). @Published var brewSurfacing = false - private var preparedCount = -1 + private struct InventoryFingerprint: Equatable { + let id: String + let bundleID: String + let path: String + let source: String + let detectionMetadata: String + } + + private var preparedInventory: [InventoryFingerprint] = [] + private var prepareGeneration = 0 + private var checkGeneration = 0 + private let sourceDetector: (InstalledApp) -> UpdateSources.Source? + private let sourceFingerprinter: (InstalledApp) -> String private var brewSurfaced = false + private var electronDescriptors: [String: ElectronUpdateDescriptor] = [:] + private var stagedElectronUpdates: [String: StagedElectronUpdate] = [:] + private var sparkleSessions: [String: ExternalSparkleUpdateSession] = [:] + private var appTasks: [String: Task] = [:] + private var updateAllTask: Task? + private var cancelUpdateAllAfterCurrent = false + + init( + detectSource: ((InstalledApp) -> UpdateSources.Source?)? = nil, + sourceFingerprint: ((InstalledApp) -> String)? = nil + ) { + sourceDetector = detectSource ?? { UpdateSources.detect(appPath: $0.path) } + sourceFingerprinter = sourceFingerprint ?? { UpdateSources.detectionFingerprint(appPath: $0.path) } + } var availableItems: [AppUpdateItem] { appItems.filter(\.updateAvailable) } var upToDateItems: [AppUpdateItem] { appItems.filter { !$0.updateAvailable && $0.latestVersion != nil } } + func phase(for id: String) -> UpdatePhase { phases[id] ?? .idle } + /// Local-only pass: detect each app's update mechanism from bundle /// shape. No network. func prepare(apps: [InstalledApp]) { - guard apps.count != preparedCount else { return } - preparedCount = apps.count + let fingerprinter = sourceFingerprinter + let inventory = apps.map { app in + InventoryFingerprint( + id: app.id, + bundleID: app.bundleId, + path: app.path, + source: app.source, + detectionMetadata: fingerprinter(app) + ) + }.sorted { + ($0.id, $0.path, $0.bundleID, $0.source) < ($1.id, $1.path, $1.bundleID, $1.source) + } + guard inventory != preparedInventory else { return } + + let previousByID = Dictionary(grouping: preparedInventory, by: \.id).compactMapValues(\.first) + let nextByID = Dictionary(grouping: inventory, by: \.id).compactMapValues(\.first) + let unchangedIDs = Set(nextByID.compactMap { id, fingerprint in + previousByID[id] == fingerprint ? id : nil + }) + let nextIDs = Set(nextByID.keys) + let invalidatedIDs = Set(previousByID.keys).subtracting(unchangedIDs) + preparedInventory = inventory + prepareGeneration &+= 1 + checkGeneration &+= 1 + checking = false + let generation = prepareGeneration + let detector = sourceDetector + let preserved = Dictionary(uniqueKeysWithValues: appItems + .filter { unchangedIDs.contains($0.id) } + .map { ($0.id, $0) }) + + // Stop showing rows whose app disappeared or whose bundle/source + // metadata changed while the new local inspection is in flight. + appItems.removeAll { !unchangedIDs.contains($0.id) } + uncheckableApps.removeAll { !unchangedIDs.contains($0.id) } + phases = phases.filter { nextIDs.contains($0.key) && unchangedIDs.contains($0.key) } + electronDescriptors = electronDescriptors.filter { unchangedIDs.contains($0.key) } + for id in invalidatedIDs { + stagedElectronUpdates.removeValue(forKey: id)?.discard() + appTasks.removeValue(forKey: id)?.cancel() + } + + guard !apps.isEmpty else { return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in var detected: [AppUpdateItem] = [] var unknown: [InstalledApp] = [] for app in apps { - if let source = UpdateSources.detect(appPath: app.path) { - detected.append(AppUpdateItem( - id: app.id, name: app.name, path: app.path, bundleId: app.bundleId, app: app, source: source)) + if let source = detector(app) { + var item = AppUpdateItem( + id: app.id, name: app.name, path: app.path, bundleId: app.bundleId, app: app, source: source) + if let old = preserved[app.id] { + item.latestVersion = old.latestVersion + item.pageURL = old.pageURL + item.releaseNotesURL = old.releaseNotesURL + item.minimumOS = old.minimumOS + item.lastUsed = old.lastUsed + } + detected.append(item) } else { unknown.append(app) } } Task { @MainActor in - self?.appItems = detected - self?.uncheckableApps = unknown + guard let self, generation == self.prepareGeneration else { return } + self.appItems = detected + self.uncheckableApps = unknown } } } @@ -320,13 +483,16 @@ final class UpdatesModel: ObservableObject { /// The manual check: Sparkle appcasts + iTunes lookups + brew /// outdated, bounded concurrency. func checkNow() { - guard !checking else { return } + guard !checking, !updateAllRunning, appTasks.isEmpty, sparkleSessions.isEmpty, upgrading.isEmpty else { return } checking = true error = nil + checkGeneration &+= 1 + let generation = checkGeneration let items = appItems + for item in items { phases[item.id] = .checking } Task { - var updated: [AppUpdateItem] = [] - await withTaskGroup(of: AppUpdateItem.self) { group in + var results: [CheckResult] = [] + await withTaskGroup(of: CheckResult.self) { group in var iterator = items.makeIterator() var inFlight = 0 func enqueue(_ item: AppUpdateItem) { @@ -334,20 +500,36 @@ final class UpdatesModel: ObservableObject { } while inFlight < 6, let next = iterator.next() { enqueue(next); inFlight += 1 } for await result in group { - updated.append(result) + results.append(result) if let next = iterator.next() { enqueue(next) } } } let brews = await Self.brewOutdated() - // Sort off the main actor — `localizedCaseInsensitiveCompare` is - // ICU work, and the only thing the hop needs to publish is the - // already-ordered array. - let sortedItems = updated.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } await MainActor.run { - self.appItems = sortedItems + guard generation == self.checkGeneration else { return } + let byID = Dictionary(uniqueKeysWithValues: results.map { ($0.id, $0) }) + self.appItems = self.appItems.map { live in + guard let result = byID[live.id] else { return live } + var copy = live + // A failed check never erases a previously known update. + if let latest = result.latestVersion { copy.latestVersion = latest } + if let pageURL = result.pageURL { copy.pageURL = pageURL } + if let releaseNotesURL = result.releaseNotesURL { copy.releaseNotesURL = releaseNotesURL } + if let minimumOS = result.minimumOS { copy.minimumOS = minimumOS } + if let descriptor = result.electronDescriptor { + self.electronDescriptors[live.id] = descriptor + } + self.phases[live.id] = result.phase + return copy + }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } self.brewItems = brews self.checking = false self.checked = true + let failures = results.filter { if case .failed = $0.phase { return true }; return false }.count + self.error = failures == 0 ? nil : String( + format: NSLocalizedString("%d update checks failed. Known versions were preserved.", comment: ""), + failures + ) } } // Recency for the meta line, cheap filesystem dates. @@ -360,92 +542,312 @@ final class UpdatesModel: ObservableObject { } } Task { @MainActor in - guard let self else { return } + guard let self, generation == self.checkGeneration else { return } self.appItems = self.appItems.map { item in var copy = item - copy.lastUsed = dates[item.id] + if let date = dates[item.id] { copy.lastUsed = date } return copy } } } } - private static func check(_ item: AppUpdateItem) async -> AppUpdateItem { - var result = item + private struct CheckResult: Sendable { + let id: String + var latestVersion: String? + var pageURL: URL? + var releaseNotesURL: URL? + var minimumOS: String? + var electronDescriptor: ElectronUpdateDescriptor? + var phase: UpdatePhase + } + + private static func check(_ item: AppUpdateItem) async -> CheckResult { + var result = CheckResult( + id: item.id, + latestVersion: nil, + pageURL: nil, + releaseNotesURL: nil, + minimumOS: nil, + electronDescriptor: nil, + phase: .idle + ) switch item.source { case .sparkle: - guard let feed = UpdateSources.feedURL(appPath: item.path), - let data = await fetch(feed) else { return result } - result.latestVersion = UpdateSources.parseAppcast(data) + guard let feed = UpdateSources.feedURL(appPath: item.path) else { + result.phase = .failed(.unsupported(NSLocalizedString("This app's Sparkle feed URL is invalid.", comment: ""))) + return result + } + switch await UpdateHTTP.fetch(feed) { + case let .success(data): + guard let latest = UpdateSources.parseAppcast(data) else { + result.phase = .failed(.decoding) + return result + } + result.latestVersion = latest + result.phase = UpdateCheck.isNewer(latest, than: item.installedVersion) ? .available : .completed + case let .failure(failure): + result.phase = .failed(failure) + } case .appStore: - guard !item.bundleID.isEmpty, - let data = await fetch(UpdateSources.itunesLookupURL(bundleID: item.bundleID)), - let lookup = UpdateSources.parseITunesLookup(data) else { return result } - result.latestVersion = lookup.version - result.pageURL = lookup.pageURL - result.minimumOS = lookup.minimumOsVersion - case .electron, .homebrew: - break // v1: badge only; their own updaters handle it + guard !item.bundleID.isEmpty else { + result.phase = .failed(.decoding) + return result + } + switch await UpdateHTTP.fetch(UpdateSources.itunesLookupURL(bundleID: item.bundleID)) { + case let .success(data): + guard let lookup = UpdateSources.parseITunesLookup(data) else { + result.phase = .failed(.decoding) + return result + } + result.latestVersion = lookup.version + result.pageURL = lookup.pageURL + result.minimumOS = lookup.minimumOsVersion + let newer = UpdateCheck.isNewer(lookup.version, than: item.installedVersion) + let installable = OSUpdateGate.isInstallable( + minimumOS: lookup.minimumOsVersion, + running: Self.runningOSVersion + ) + result.phase = newer && installable ? .available : .completed + case let .failure(failure): + result.phase = .failed(failure) + } + case .electron: + guard let feed = ElectronFeedConfiguration.read(appPath: item.path) else { + result.phase = .failed(.unsupported(NSLocalizedString( + "This Electron updater uses a provider Burrow cannot safely replace. Use the app's own updater.", + comment: "" + ))) + return result + } + switch await UpdateHTTP.fetch(feed.latestYAMLURL) { + case let .success(data): + guard let descriptor = ElectronUpdateDescriptor.parse(data, relativeTo: feed.latestYAMLURL) else { + result.phase = .failed(.decoding) + return result + } + result.electronDescriptor = descriptor + result.latestVersion = descriptor.version + result.phase = UpdateCheck.isNewer(descriptor.version, than: item.installedVersion) ? .available : .completed + case let .failure(failure): + result.phase = .failed(failure) + } + case .homebrew: + result.phase = .completed } return result } - private static func fetch(_ url: URL) async -> Data? { - var request = URLRequest(url: url) - request.timeoutInterval = 10 - request.cachePolicy = .reloadIgnoringLocalCacheData // manual check = fresh metadata (PRD §Software) - return try? await URLSession.shared.data(for: request).0 + private nonisolated static var runningOSVersion: String { + let version = Foundation.ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" } - /// v1 update action per source: deep-link the right updater. func update(_ item: AppUpdateItem) { + guard appTasks[item.id] == nil, !phase(for: item.id).isBusy else { return } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performUpdate(item, installElectronImmediately: false) + self.appTasks.removeValue(forKey: item.id) + } + appTasks[item.id] = task + } + + func retry(_ item: AppUpdateItem) { + if item.updateAvailable { update(item) } + else { checkNow() } + } + + func installReady(_ item: AppUpdateItem) { + guard let staged = stagedElectronUpdates.removeValue(forKey: item.id) else { return } + appTasks[item.id]?.cancel() + let task = Task { @MainActor [weak self] in + guard let self else { staged.discard(); return } + await self.install(staged, for: item.id) + self.appTasks.removeValue(forKey: item.id) + } + appTasks[item.id] = task + } + + func cancel(_ item: AppUpdateItem) { + appTasks.removeValue(forKey: item.id)?.cancel() + stagedElectronUpdates.removeValue(forKey: item.id)?.discard() + phases[item.id] = .failed(.cancelled) + } + + func openReleaseNotes(_ item: AppUpdateItem) { + if let url = item.releaseNotesURL ?? item.pageURL { NSWorkspace.shared.open(url) } + } + + private func performUpdate(_ item: AppUpdateItem, installElectronImmediately: Bool) async { switch item.source { - case .sparkle, .electron: - NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: item.path), - configuration: NSWorkspace.OpenConfiguration()) + case .sparkle: + await runSparkle(item) + case .electron: + guard let descriptor = electronDescriptors[item.id] else { + handOffToOwnUpdater(item) + return + } + phases[item.id] = .downloading(progress: nil) + let outcome = await ElectronReplacementInstaller.stage(appPath: item.path, descriptor: descriptor) + guard !Task.isCancelled else { + if case let .ready(staged) = outcome { staged.discard() } + phases[item.id] = .failed(.cancelled) + return + } + switch outcome { + case let .ready(staged): + stagedElectronUpdates[item.id]?.discard() + stagedElectronUpdates[item.id] = staged + phases[item.id] = .readyToInstall + if installElectronImmediately { + stagedElectronUpdates.removeValue(forKey: item.id) + await install(staged, for: item.id) + } + case let .failure(failure): + phases[item.id] = .failed(failure) + if case .unsupported = failure { handOffToOwnUpdater(item) } + } case .appStore: if let page = item.pageURL { NSWorkspace.shared.open(page) } - else { NSWorkspace.shared.open(URL(string: "macappstore://showUpdatesPage")!) } + else if let updates = URL(string: "macappstore://showUpdatesPage") { NSWorkspace.shared.open(updates) } + phases[item.id] = .handedOff(NSLocalizedString("App Store", comment: "")) case .homebrew: break } } + private func runSparkle(_ item: AppUpdateItem) async { + await withCheckedContinuation { continuation in + guard let session = ExternalSparkleUpdateSession( + appPath: item.path, + onPhase: { [weak self] phase in + guard let self, self.appItems.contains(where: { $0.id == item.id }) else { return } + self.phases[item.id] = phase + }, + onMetadata: { [weak self] version, releaseNotesURL in + guard let self else { return } + self.appItems = self.appItems.map { live in + guard live.id == item.id else { return live } + var copy = live + copy.latestVersion = version + copy.releaseNotesURL = releaseNotesURL + return copy + } + }, + onFinish: { [weak self] in + self?.sparkleSessions.removeValue(forKey: item.id) + continuation.resume() + } + ) else { + phases[item.id] = .failed(.unsupported(NSLocalizedString( + "Sparkle could not open this app bundle. Use the app's own updater.", + comment: "" + ))) + handOffToOwnUpdater(item) + continuation.resume() + return + } + sparkleSessions[item.id] = session + if session.begin() != nil { + sparkleSessions.removeValue(forKey: item.id) + handOffToOwnUpdater(item) + } + } + } + + private func install(_ staged: StagedElectronUpdate, for id: String) async { + phases[id] = .installing + switch await ElectronReplacementInstaller.install(staged) { + case .installed: + phases[id] = .completed + case let .failure(failure): + phases[id] = .failed(failure) + } + } + + private func handOffToOwnUpdater(_ item: AppUpdateItem) { + NSWorkspace.shared.openApplication( + at: URL(fileURLWithPath: item.path), + configuration: NSWorkspace.OpenConfiguration() + ) + phases[item.id] = .handedOff(NSLocalizedString("the app's updater", comment: "")) + } + // MARK: Homebrew (the existing flow) func upgrade(_ item: OutdatedItem) { - guard let brew = Self.brewPath() else { return } - // Already upgrading (this row, or an upgrade-all in flight): a - // second concurrent `brew` just trips over brew's own lock. - guard !upgrading.contains(item.id) else { return } - upgrading.insert(item.id) - DispatchQueue.global(qos: .userInitiated).async { - Self.runBrewStreaming(brew, ["upgrade", item.name], timeout: 1800) { line in - if let phrase = BrewProgress.phrase(line) { Task { @MainActor in self.brewPhrase = phrase } } + guard upgrading.isEmpty, !updateAllRunning else { return } + Task { @MainActor [weak self] in + guard let self else { return } + await self.performBrewUpdate(item) + self.brewItems = await Self.brewOutdated() + } + } + + /// Updates every available item serially. Cancellation is deliberately a + /// boundary between apps: interrupting a package replacement or `brew` + /// transaction halfway through is less safe than finishing the current + /// item and stopping before the next one. + func updateAll() { + guard !updateAllRunning, upgrading.isEmpty else { return } + let apps = availableItems + let brews = brewItems + guard !apps.isEmpty || !brews.isEmpty else { return } + cancelUpdateAllAfterCurrent = false + updateAllRunning = true + updateAllCompleted = 0 + updateAllTotal = apps.count + brews.count + updateAllTask = Task { @MainActor [weak self] in + guard let self else { return } + for item in apps { + if self.cancelUpdateAllAfterCurrent { break } + await self.performUpdate(item, installElectronImmediately: true) + self.updateAllCompleted += 1 } - Task { @MainActor in - self.brewPhrase = "" - self.upgrading.remove(item.id) - self.brewItems = await Self.brewOutdated() + for item in brews { + if self.cancelUpdateAllAfterCurrent { break } + await self.performBrewUpdate(item) + self.updateAllCompleted += 1 } + self.brewItems = await Self.brewOutdated() + self.updateAllRunning = false + self.updateAllTask = nil + self.cancelUpdateAllAfterCurrent = false } } + func cancelUpdateAll() { + guard updateAllRunning else { return } + cancelUpdateAllAfterCurrent = true + } + func upgradeAll() { - guard let brew = Self.brewPath() else { return } + updateAll() + } + + private func performBrewUpdate(_ item: OutdatedItem) async { + guard let brew = Self.brewPath() else { + phases[item.id] = .failed(.unsupported(NSLocalizedString("Homebrew is no longer available.", comment: ""))) + return + } guard upgrading.isEmpty else { return } - let ids = Set(brewItems.map(\.id)) - upgrading.formUnion(ids) - DispatchQueue.global(qos: .userInitiated).async { - Self.runBrewStreaming(brew, ["upgrade"], timeout: 3600) { line in - if let phrase = BrewProgress.phrase(line) { Task { @MainActor in self.brewPhrase = phrase } } - } - Task { @MainActor in - self.brewPhrase = "" - self.upgrading.subtract(ids) - self.brewItems = await Self.brewOutdated() + upgrading.insert(item.id) + phases[item.id] = .installing + let code = await Task.detached(priority: .userInitiated) { + Self.runBrewStreaming(brew, ["upgrade", item.name], timeout: 1800) { line in + guard let phrase = BrewProgress.phrase(line) else { return } + Task { @MainActor [weak self] in self?.brewPhrase = phrase } } - } + }.value + brewPhrase = "" + upgrading.remove(item.id) + phases[item.id] = code == 0 + ? .completed + : .failed(.installation(String( + format: NSLocalizedString("Homebrew exited with status %d. Retry after resolving its message.", comment: ""), + code + ))) } private static func brewOutdated() async -> [OutdatedItem] { @@ -483,7 +885,7 @@ final class UpdatesModel: ObservableObject { /// a work item terminates on timeout. private nonisolated static func runBrewStreaming(_ brew: String, _ args: [String], timeout: TimeInterval, - onLine: @escaping (String) -> Void) { + onLine: @escaping (String) -> Void) -> Int32 { var env = Foundation.ProcessInfo.processInfo.environment let dir = (brew as NSString).deletingLastPathComponent env["PATH"] = "\(dir):/usr/bin:/bin:/usr/sbin:/sbin:" + (env["PATH"] ?? "") @@ -508,10 +910,11 @@ final class UpdatesModel: ObservableObject { } let killer = DispatchWorkItem { if p.isRunning { p.terminate() } } DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: killer) - do { try p.run() } catch { handle.readabilityHandler = nil; return } + do { try p.run() } catch { handle.readabilityHandler = nil; return -1 } p.waitUntilExit() killer.cancel() handle.readabilityHandler = nil + return p.terminationStatus } /// Pure parser for `brew outdated --json=v2` — unit-tested against captured @@ -541,6 +944,6 @@ private extension AppUpdateItem { self.init(id: id, name: name, path: path, bundleID: bundleId, installedVersion: SoftwareIcons.version(path) ?? "0", sizeStr: app.sizeStr, source: source, - latestVersion: nil, pageURL: nil, lastUsed: nil) + latestVersion: nil, pageURL: nil, releaseNotesURL: nil, lastUsed: nil) } } diff --git a/macos/Tests/CrashReporterPolicyTests.swift b/macos/Tests/CrashReporterPolicyTests.swift index 55bc1539..68245ac3 100644 --- a/macos/Tests/CrashReporterPolicyTests.swift +++ b/macos/Tests/CrashReporterPolicyTests.swift @@ -4,9 +4,80 @@ // import XCTest +import Sentry @testable import Burrow final class CrashReporterPolicyTests: XCTestCase { + func testSensitiveCapturedEventIsScrubbedBeforeTransport() throws { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/SentrySensitiveEvent.json") + let fixture = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) + as? [String: Any] + ) + let exceptionFixture = try XCTUnwrap(fixture["exception"] as? [String: Any]) + let frameFixture = try XCTUnwrap(fixture["frame"] as? [String: Any]) + let debugFixture = try XCTUnwrap(fixture["debugMeta"] as? [String: Any]) + + let frame = Frame() + frame.function = frameFixture["function"] as? String + frame.module = frameFixture["module"] as? String + frame.package = frameFixture["package"] as? String + frame.fileName = frameFixture["fileName"] as? String + frame.contextLine = frameFixture["contextLine"] as? String + frame.preContext = ["sk-live-SECRET"] + frame.postContext = ["/Users/alice/Documents/customer.txt"] + frame.imageAddress = frameFixture["imageAddress"] as? String + frame.instructionAddress = frameFixture["instructionAddress"] as? String + frame.symbolAddress = frameFixture["symbolAddress"] as? String + frame.vars = frameFixture["vars"] as? [String: Any] + let stacktrace = SentryStacktrace( + frames: [frame], + registers: ["x0": "sk-live-SECRET", "sp": "0x100001234"] + ) + + let mechanism = Mechanism(type: exceptionFixture["mechanismType"] as! String) + mechanism.desc = exceptionFixture["mechanismDescription"] as? String + mechanism.helpLink = exceptionFixture["mechanismHelp"] as? String + mechanism.data = exceptionFixture["mechanismData"] as? [String: Any] + let exception = Exception( + value: exceptionFixture["value"] as? String, + type: exceptionFixture["type"] as? String + ) + exception.module = exceptionFixture["module"] as? String + exception.mechanism = mechanism + exception.stacktrace = stacktrace + + let debugMeta = DebugMeta() + debugMeta.debugID = debugFixture["debugID"] as? String + debugMeta.type = debugFixture["type"] as? String + debugMeta.codeFile = debugFixture["codeFile"] as? String + debugMeta.imageAddress = debugFixture["imageAddress"] as? String + debugMeta.imageVmAddress = debugFixture["imageVmAddress"] as? String + + let event = Event() + event.exceptions = [exception] + event.stacktrace = stacktrace + event.context = fixture["contexts"] as? [String: [String: Any]] + event.debugMeta = [debugMeta] + event.fingerprint = ["burrow-app-hang", "launch_started", "/Users/alice/Secret.swift"] + + CrashReporter.scrubForTransport(event) + let serialized = try JSONSerialization.data(withJSONObject: event.serialize()) + let outbound = try XCTUnwrap(String(data: serialized, encoding: .utf8)) + + for sensitive in [ + "sk-live-SECRET", "alice", "customer.txt", "/Users/", "Downloads", + "--upload", "example.com/private", + ] { + XCTAssertFalse(outbound.localizedCaseInsensitiveContains(sensitive), outbound) + } + XCTAssertTrue(outbound.contains("0.11.2"), outbound) + XCTAssertTrue(outbound.contains("B7C38183-66CD-4C76-895A-150D17B4E2D7"), outbound) + XCTAssertTrue(outbound.contains("0x100001234"), outbound) + } + func testAppHangLimiterKeepsFirstAndRateLimitsOnlyRepeats() { var limiter = AppHangRateLimiter(minimumInterval: 60) diff --git a/macos/Tests/Fixtures/SentrySensitiveEvent.json b/macos/Tests/Fixtures/SentrySensitiveEvent.json new file mode 100644 index 00000000..26f9b32e --- /dev/null +++ b/macos/Tests/Fixtures/SentrySensitiveEvent.json @@ -0,0 +1,53 @@ +{ + "exception": { + "value": "crash near sk-live-SECRET for alice@example.com in /Users/alice/Documents/customer.txt", + "type": "Fatal /Users/alice/Secret.swift", + "module": "/Users/alice/Downloads/Burrow.app/Contents/MacOS/Burrow", + "mechanismType": "signal /Users/alice", + "mechanismDescription": "argv --upload /Users/alice/customer.txt --token SECRET", + "mechanismHelp": "https://example.com/private?token=SECRET", + "mechanismData": { + "api_key": "sk-live-SECRET", + "command_arguments": "--upload /Users/alice/customer.txt", + "error_code": 17 + } + }, + "contexts": { + "burrow_runtime": { + "app_version": "0.11.2", + "app_build": "23", + "username": "alice", + "file_path": "/Users/alice/Documents/customer.txt" + }, + "diagnostic": { + "phase": "launch_started", + "api_key": "sk-live-SECRET", + "command_arguments": "--upload /Users/alice/customer.txt" + }, + "device": { + "name": "Alice's Mac", + "free_memory": 42 + } + }, + "debugMeta": { + "debugID": "B7C38183-66CD-4C76-895A-150D17B4E2D7", + "type": "macho", + "codeFile": "/Users/alice/Downloads/Burrow.app/Contents/MacOS/Burrow", + "imageAddress": "0x100000000", + "imageVmAddress": "0x100000000" + }, + "frame": { + "function": "Burrow.Secret=/Users/alice/Documents/customer.txt", + "module": "/Users/alice/Downloads/Burrow.app/Contents/MacOS/Burrow", + "package": "/Users/alice/Downloads/Burrow.app", + "fileName": "customer.txt", + "contextLine": "let token = sk-live-SECRET", + "imageAddress": "0x100000000", + "instructionAddress": "0x100001234", + "symbolAddress": "0x100001000", + "vars": { + "api_key": "sk-live-SECRET", + "path": "/Users/alice/Documents/customer.txt" + } + } +} diff --git a/macos/Tests/MCPEnvelopeTests.swift b/macos/Tests/MCPEnvelopeTests.swift index f2db95ae..b25f418e 100644 --- a/macos/Tests/MCPEnvelopeTests.swift +++ b/macos/Tests/MCPEnvelopeTests.swift @@ -19,7 +19,10 @@ final class MCPEnvelopeTests: XCTestCase { tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("burrow-envelope-test-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - server = MCPServer(db: try DB(at: tempDir.appendingPathComponent("burrow.db"))) + server = MCPServer( + db: try DB(at: tempDir.appendingPathComponent("burrow.db")), + serverVersion: "9.8.7" + ) } override func tearDown() { @@ -60,6 +63,7 @@ final class MCPEnvelopeTests: XCTestCase { XCTAssertNotNil(result["protocolVersion"] as? String) let info = try XCTUnwrap(result["serverInfo"] as? [String: Any]) XCTAssertEqual(info["name"] as? String, "burrow") + XCTAssertEqual(info["version"] as? String, "9.8.7") } func testToolsList_returnsTheCatalog() throws { diff --git a/macos/Tests/MoleCLITests.swift b/macos/Tests/MoleCLITests.swift index a0890c6a..4975941d 100644 --- a/macos/Tests/MoleCLITests.swift +++ b/macos/Tests/MoleCLITests.swift @@ -8,6 +8,7 @@ // import XCTest +import Darwin @testable import Burrow final class MoleCLITests: XCTestCase { @@ -106,60 +107,54 @@ final class MoleCLITests: XCTestCase { // the shell, then backslash/quote escaping for the AppleScript literal. func testElevatedScript_shellQuotesEveryArgument() { - let s = MoleCLI.elevatedScript(executable: "/tmp/m o/mo", args: ["clean", "--dry-run"]) + let s = MoleCLI.elevatedScript(command: fakeCommand("/tmp/m o/mo"), + args: ["clean", "--dry-run"]) XCTAssertTrue(s.hasPrefix("do shell script \"")) XCTAssertTrue(s.hasSuffix("\" with administrator privileges")) XCTAssertTrue(s.contains("'/tmp/m o/mo' 'clean' '--dry-run'")) } func testElevatedScript_neutralizesShellMetacharacters() { - let s = MoleCLI.elevatedScript(executable: "/tmp/$(reboot)/mo", args: ["a;b", "`x`"]) + let s = MoleCLI.elevatedScript(command: fakeCommand("/tmp/$(reboot)/mo"), + args: ["a;b", "`x`"]) XCTAssertTrue(s.contains("'/tmp/$(reboot)/mo' 'a;b' '`x`'"), "metacharacters must ride inert inside single quotes") } func testElevatedScript_escapesAppleScriptLiteralBreakers() { // A double quote in a path must not terminate the AppleScript string. - let s = MoleCLI.elevatedScript(executable: #"/tmp/he said "hi"/mo"#, args: []) + let s = MoleCLI.elevatedScript(command: fakeCommand(#"/tmp/he said "hi"/mo"#), args: []) XCTAssertFalse(s.contains(#"said "hi""#), "raw quote would break out of the literal") XCTAssertTrue(s.contains(#"said \"hi\""#)) // A single quote goes through the shell's '\'' dance, whose // backslash must itself be AppleScript-escaped. - let s2 = MoleCLI.elevatedScript(executable: "/tmp/a'b/mo", args: []) + let s2 = MoleCLI.elevatedScript(command: fakeCommand("/tmp/a'b/mo"), args: []) XCTAssertTrue(s2.contains(#"'/tmp/a'\\''b/mo'"#)) } func testElevatedScript_redirectsThroughQuotedLogPath() { - let s = MoleCLI.elevatedScript(executable: "/usr/local/bin/mo", args: ["clean"], - redirectTo: "/tmp/my log.txt") - XCTAssertTrue(s.contains("> '/tmp/my log.txt' 2>&1")) - } - - /// The invariant: an elevated run resolves its binary from the app bundle - /// or from a fixed absolute location, NEVER from a PATH lookup a - /// user-writable directory could shadow. - /// - /// The old version of this test listed only the three `mo` paths, so it - /// passed purely because CI and dev checkouts had no bundled engine. With - /// the engine actually bundled — which is what every release ships — - /// `trustedExecutable()` correctly returns the in-bundle copy first and - /// the assertion failed. It was checking a stale list, not the invariant. + let sink = try! PrivilegedLogSink.make(token: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + let s = MoleCLI.elevatedScript(command: fakeCommand("/usr/local/bin/mo"), args: ["clean"], + logSink: sink) + XCTAssertTrue(s.contains("> '\(sink.filePath)' 2>&1")) + } + + /// Elevation may execute only the engine sealed inside Burrow.app. + /// Homebrew prefixes and PATH entries are mutable by the invoking user. func testTrustedExecutable_onlyEverReturnsKnownLocations() { - guard let resolved = MoleCLI.trustedExecutable() else { - return // nothing installed in a trusted spot is a valid outcome + if let p = MoleCLI.trustedExecutable() { + XCTAssertEqual(p, MoleCLI.bundledExecutable(), + "elevation must never use a Homebrew/user-mutable engine") } + } - let fixedLocations = [ - "/opt/homebrew/bin/burrow-engine", "/usr/local/bin/burrow-engine", - "/opt/homebrew/bin/mo", "/usr/local/bin/mo", "/usr/bin/mo", - ] - if fixedLocations.contains(resolved) { return } - - // Otherwise it must be the engine sealed inside our own app bundle. - XCTAssertEqual(resolved, MoleCLI.bundledExecutable(), - "trusted lookup must be the bundled engine or a fixed absolute path, never PATH") - XCTAssertTrue(resolved.contains("/Burrow.app/Contents/Resources/engine/"), - "the bundled engine must live inside the signed app bundle") + private func fakeCommand(_ path: String) -> ValidatedElevatedCommand { + let identity = PinnedFileIdentity(path: path, device: 1, inode: 2, + owner: 0, mode: UInt16(S_IFREG | 0o755)) + let user = InvokingUserIdentity(uid: 501, username: "test", + canonicalHome: "/Users/test") + return ValidatedElevatedCommand(executable: identity, components: [], + invokingUser: user, signedBundlePath: nil) } func testRun_timesOutInsteadOfHanging() throws { diff --git a/macos/Tests/OperationFlowTests.swift b/macos/Tests/OperationFlowTests.swift index 39363d73..73410f09 100644 --- a/macos/Tests/OperationFlowTests.swift +++ b/macos/Tests/OperationFlowTests.swift @@ -199,6 +199,18 @@ final class OperationFlowTests: XCTestCase { guard case .finished(.failed) = flow.state else { return XCTFail("expected failed") } XCTAssertTrue(port.specs.isEmpty) } + + func testElevatedRunFailsBeforeSpawnWhenInvokingAccountCannotBeResolved() { + let port = FakeProcessPort(script: [.exited(0)]) + let flow = OperationFlow( + process: port, hasFullDiskAccess: { true }, resolveMo: { _ in "/usr/bin/true" }, + resolveInvokingUser: { + throw InvokingUserIdentity.ResolutionError.missingAccount(501) + }, center: OperationCenter()) + flow.start(Self.cleanOp(elevated: true)) + guard case .finished(.failed) = flow.state else { return XCTFail("expected fail closed") } + XCTAssertTrue(port.specs.isEmpty, "identity targets are derived before elevation") + } } // MARK: - Production adapter against real (tiny) processes @@ -249,27 +261,27 @@ final class SystemProcessPortTests: XCTestCase { // MARK: Auth-cancel classification — an engine rule, not view folklore - /// "osascript exits nonzero having produced nothing" used to be an - /// OperationFlow heuristic; the runner owns it now, as a pure rule. + /// Only AppleScript's canonical userCanceledErr is a cancellation. func testFinalEvent_classifiesAuthCancel() { - // The previously-untestable path: elevated, failed, silent. guard case .authCancelled = SystemProcessPort.finalEvent( - exitCode: 1, elevated: true, sawOutput: false) else { - return XCTFail("elevated + nonzero + no output = dismissed auth prompt") + exitCode: 1, elevated: true, + appleScriptStderr: "execution error: User canceled. (-128)") else { + return XCTFail("canonical -128 diagnostic = dismissed auth prompt") } - // Output means the run really happened — a real failure, not cancel. guard case .exited(1) = SystemProcessPort.finalEvent( - exitCode: 1, elevated: true, sawOutput: true) else { - return XCTFail("an elevated run that produced output failed on its own terms") + exitCode: 1, elevated: true, appleScriptStderr: "") else { + return XCTFail("silent root command failures remain failures") } // Un-elevated runs have no auth prompt to cancel. guard case .exited(2) = SystemProcessPort.finalEvent( - exitCode: 2, elevated: false, sawOutput: false) else { + exitCode: 2, elevated: false, + appleScriptStderr: "execution error: User canceled. (-128)") else { return XCTFail("no elevation, no auth-cancel") } // Success is success even when silent. guard case .exited(0) = SystemProcessPort.finalEvent( - exitCode: 0, elevated: true, sawOutput: false) else { + exitCode: 0, elevated: true, + appleScriptStderr: "execution error: User canceled. (-128)") else { return XCTFail("exit 0 is never a cancel") } } diff --git a/macos/Tests/PrivilegeBrokerTests.swift b/macos/Tests/PrivilegeBrokerTests.swift index 9f826640..a9bd2f10 100644 --- a/macos/Tests/PrivilegeBrokerTests.swift +++ b/macos/Tests/PrivilegeBrokerTests.swift @@ -24,6 +24,7 @@ // import XCTest +import Darwin @testable import Burrow final class PrivilegeBrokerTests: XCTestCase { @@ -36,55 +37,57 @@ final class PrivilegeBrokerTests: XCTestCase { // MARK: - Auth-cancel rule (the one engine taxonomy, exhaustive table) // - // "elevated + nonzero exit + produced nothing = dismissed prompt." Output - // proves the command actually ran under root, so it's a real failure, not - // a cancel. All four cells of elevated × output, plus exit-code edges. + private static let cancelError = "execution error: User canceled. (-128)\n" func testAuthCancel_classifiesDismissedPrompt() { - // elevated, failed, silent → cancel. - XCTAssertTrue(AuthCancel.isAuthCancelled(elevated: true, exitCode: 1, sawOutput: false)) - XCTAssertTrue(AuthCancel.isAuthCancelled(elevated: true, exitCode: -128, sawOutput: false)) + XCTAssertTrue(AuthCancel.isAuthCancelled(elevated: true, exitCode: 1, + appleScriptStderr: Self.cancelError)) + XCTAssertTrue(AuthCancel.isAuthCancelled(elevated: true, exitCode: 1, + appleScriptStderr: "执行错误:用户已取消。 (-128)")) } - func testAuthCancel_outputMeansRealFailure() { - // The command printed → it ran; a nonzero exit is its own failure. - XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: true, exitCode: 1, sawOutput: true)) + func testAuthCancel_silentFailureIsNotCancellation() { + XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: true, exitCode: 1, + appleScriptStderr: "")) + XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: true, exitCode: 1, + appleScriptStderr: "execution error: failed (1)")) } func testAuthCancel_unelevatedNeverCancels() { // No elevation = no auth prompt to dismiss. - XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: false, exitCode: 1, sawOutput: false)) - XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: false, exitCode: 1, sawOutput: true)) + XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: false, exitCode: 1, + appleScriptStderr: Self.cancelError)) } func testAuthCancel_successIsNeverCancel() { // Exit 0 is success even when silent. - XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: true, exitCode: 0, sawOutput: false)) - XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: true, exitCode: 0, sawOutput: true)) + XCTAssertFalse(AuthCancel.isAuthCancelled(elevated: true, exitCode: 0, + appleScriptStderr: Self.cancelError)) } func testAuthCancel_outcomeMapsThePredicate() { // The one-shot helper folds the predicate into the named outcome. - XCTAssertEqual(AuthCancel.outcome(exitCode: 1, sawOutput: false), .authCancelled) - XCTAssertEqual(AuthCancel.outcome(exitCode: 1, sawOutput: true), .exited(1)) - XCTAssertEqual(AuthCancel.outcome(exitCode: 0, sawOutput: false), .exited(0)) - XCTAssertEqual(AuthCancel.outcome(exitCode: 5, sawOutput: true), .exited(5)) + XCTAssertEqual(AuthCancel.outcome(exitCode: 1, appleScriptStderr: Self.cancelError), .authCancelled) + XCTAssertEqual(AuthCancel.outcome(exitCode: 1, appleScriptStderr: ""), .exited(1)) + XCTAssertEqual(AuthCancel.outcome(exitCode: 0, appleScriptStderr: Self.cancelError), .exited(0)) } /// The streaming runner and the one-shot broker must agree on the rule — /// they share `AuthCancel`, so the same inputs land the same way through /// both surfaces. Guards against the two paths drifting apart again. func testAuthCancel_streamingAndOneShotAgree() { - for (code, output) in [(Int32(1), false), (Int32(1), true), (Int32(0), false), (Int32(2), true)] { - let stream = SystemProcessPort.finalEvent(exitCode: code, elevated: true, sawOutput: output) - let oneShot = AuthCancel.outcome(exitCode: code, sawOutput: output) + for (code, error) in [(Int32(1), Self.cancelError), (Int32(1), ""), + (Int32(0), Self.cancelError), (Int32(2), "failure")] { + let stream = SystemProcessPort.finalEvent(exitCode: code, elevated: true, + appleScriptStderr: error) + let oneShot = AuthCancel.outcome(exitCode: code, appleScriptStderr: error) switch (stream, oneShot) { case (.authCancelled, .authCancelled): break case (.exited(let a), .exited(let b)): XCTAssertEqual(a, b) default: - XCTFail("streaming and one-shot disagreed for exit \(code), output \(output)") + XCTFail("streaming and one-shot disagreed for exit \(code)") } } } @@ -113,7 +116,8 @@ final class PrivilegeBrokerTests: XCTestCase { func testElevatedScript_brokerComposesInertRootInvocation() { // A path with spaces + args with shell metacharacters: every element // single-quoted, the whole thing AppleScript-escaped. - let script = MoleCLI.elevatedScript(executable: "/opt/home brew/bin/mo", + let command = fakeCommand(path: "/opt/home brew/bin/mo") + let script = MoleCLI.elevatedScript(command: command, args: ["clean", "path with 'quotes'", "$(rm -rf /)"]) XCTAssertTrue(script.hasPrefix("do shell script \"")) XCTAssertTrue(script.hasSuffix("\" with administrator privileges")) @@ -126,10 +130,19 @@ final class PrivilegeBrokerTests: XCTestCase { } func testElevatedScript_neutralizesNewlineAndBacktick() { - let script = MoleCLI.elevatedScript(executable: "/usr/local/bin/mo", + let script = MoleCLI.elevatedScript(command: fakeCommand(path: "/usr/local/bin/mo"), args: ["uninstall", "a\nb", "`whoami`"]) XCTAssertTrue(script.contains("'`whoami`'"), "backticks inert in single quotes") // A newline survives inside the single-quoted arg (no statement break). XCTAssertTrue(script.contains("'a\nb'")) } + + private func fakeCommand(path: String) -> ValidatedElevatedCommand { + let identity = PinnedFileIdentity(path: path, device: 1, inode: 2, + owner: 0, mode: UInt16(S_IFREG | 0o755)) + let user = InvokingUserIdentity(uid: 501, username: "test user", + canonicalHome: "/Users/test user") + return ValidatedElevatedCommand(executable: identity, components: [], + invokingUser: user, signedBundlePath: nil) + } } diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift new file mode 100644 index 00000000..e6c3ddfe --- /dev/null +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -0,0 +1,199 @@ +import XCTest +import Darwin +@testable import Burrow + +final class PrivilegedIdentityTests: XCTestCase { + private var temp: URL! + + override func setUpWithError() throws { + temp = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow identity \(UUID().uuidString)") + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + temp = URL(fileURLWithPath: try XCTUnwrap(InvokingUserIdentity.canonicalPath(temp.path))) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temp) + } + + func testInvokingUser_selectsNumericUIDAmongSeveralAccountsAndPreservesSpaces() throws { + let accounts = [ + InvokingUserIdentity.Account(uid: 502, name: "other", home: "/Users/other"), + InvokingUserIdentity.Account(uid: 501, name: "henry", home: temp.path), + ] + let user = try InvokingUserIdentity.resolve(invokingUID: 501, accounts: accounts) + XCTAssertEqual(user.uid, 501) + XCTAssertEqual(user.username, "henry") + XCTAssertEqual(user.canonicalHome, temp.path) + } + + func testInvokingUser_canonicalizesSymlinkedHomeBeforeElevation() throws { + let link = temp.deletingLastPathComponent().appendingPathComponent("home-link-\(UUID().uuidString)") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: temp) + defer { try? FileManager.default.removeItem(at: link) } + + let user = try InvokingUserIdentity.resolve( + invokingUID: 501, + accounts: [.init(uid: 501, name: "henry", home: link.path)]) + XCTAssertEqual(user.canonicalHome, temp.path) + } + + func testInvokingUser_refusesMissingRootAndVarRootMismatch() { + XCTAssertThrowsError(try InvokingUserIdentity.resolve(invokingUID: 501, accounts: [])) + XCTAssertThrowsError(try InvokingUserIdentity.resolve( + invokingUID: 0, accounts: [.init(uid: 0, name: "root", home: "/var/root")])) + XCTAssertThrowsError(try InvokingUserIdentity.resolve( + invokingUID: 501, + accounts: [.init(uid: 501, name: "root", home: "/var/root")], + canonicalize: { $0 })) + } + + func testInvokingUser_refusesHomeOwnedByAnotherAccount() { + let otherUID = getuid() &+ 1 + XCTAssertThrowsError(try InvokingUserIdentity.resolve( + invokingUID: otherUID, + accounts: [.init(uid: otherUID, name: "other", home: temp.path)])) { error in + guard case InvokingUserIdentity.ResolutionError.mismatchedHomeOwner( + expected: otherUID, actual: getuid()) = error else { + return XCTFail("expected mismatched home ownership, got \(error)") + } + } + } + + func testElevatedScriptPinsInvokingIdentityRatherThanRootHome() { + let executable = PinnedFileIdentity(path: "/usr/bin/true", device: 1, inode: 2, + owner: 0, mode: UInt16(S_IFREG | 0o755)) + let user = InvokingUserIdentity(uid: 501, username: "name with space", + canonicalHome: "/Users/name with space") + let command = ValidatedElevatedCommand(executable: executable, components: [], + invokingUser: user, signedBundlePath: nil) + let script = MoleCLI.elevatedScript(command: command, args: []) + XCTAssertTrue(script.contains("'HOME=/Users/name with space'")) + XCTAssertTrue(script.contains("'SUDO_UID=501'")) + XCTAssertFalse(script.contains("HOME=/var/root")) + } + + func testUserMutableExecutableIsRejectedAndReplacementBreaksPinnedIdentity() throws { + let executable = temp.appendingPathComponent("mo") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let user = InvokingUserIdentity(uid: getuid(), username: NSUserName(), canonicalHome: NSHomeDirectory()) + XCTAssertThrowsError(try ValidatedElevatedCommand.prepare( + executable: executable.path, invokingUser: user, requireCurrentBundle: false)) + + let pinned = try PinnedFileIdentity.capture(executable.path) + try FileManager.default.removeItem(at: executable) + try Data("#!/bin/sh\nexit 9\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + XCTAssertFalse(pinned.matchesCurrent(), "a hostile same-path replacement must fail its inode check") + } +} + +final class CleanupAuthorizationTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-clean-auth-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + root = URL(fileURLWithPath: try XCTUnwrap(InvokingUserIdentity.canonicalPath(root.path))) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + private func list(_ paths: [String]) -> CleanList { + CleanList(categories: [.init(name: "Test", items: paths.map { + .init(path: $0, sizeBytes: 1, sizeText: "1B", itemCount: nil) + })], summaryTotalText: "1B", summaryItemCount: paths.count) + } + + func testSnapshotAcceptsCanonicalPathsWithSpacesAndPinsReviewedIdentity() throws { + let item = root.appendingPathComponent("cache with spaces") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + XCTAssertTrue(plan.validateForLaunch()) + XCTAssertEqual(plan.items.map(\.identity.path), [item.path]) + } + + func testSnapshotRejectsControlsNULJSONRelativeSymlinkAndOutsideRoots() throws { + for malformed in ["relative/cache", "{\"path\":\"/tmp/x\"}", + "/tmp/bad\npath", "/tmp/bad\0suffix"] { + XCTAssertThrowsError(try CleanupSnapshot.capture( + list: list([malformed]), approvedRootURLs: [root]), malformed) + } + + let outside = FileManager.default.temporaryDirectory + .appendingPathComponent("outside-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: outside) } + XCTAssertThrowsError(try CleanupSnapshot.capture(list: list([outside.path]), + approvedRootURLs: [root])) + + let real = root.appendingPathComponent("real") + let link = root.appendingPathComponent("link") + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: false) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + XCTAssertThrowsError(try CleanupSnapshot.capture(list: list([link.path]), + approvedRootURLs: [root])) + } + + func testApprovedRootRejectsUnexpectedVolumeIdentity() throws { + let rootIdentity = try PinnedFileIdentity.capture(root.path) + XCTAssertThrowsError(try CleanupSnapshot.approvedRoot( + for: root.path + "/cache", device: rootIdentity.device + 1, roots: [rootIdentity])) { + XCTAssertEqual($0 as? CleanupSnapshot.SnapshotError, + .unexpectedVolume(root.path + "/cache")) + } + } + + func testPlanFailsClosedWhenStaleOrSymlinkSwapped() throws { + let item = root.appendingPathComponent("cache") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + let old = Date(timeIntervalSince1970: 1_000) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root], now: old) + XCTAssertThrowsError(try snapshot.plan(selectedPaths: [item.path], + now: old.addingTimeInterval(301))) + + let moved = root.appendingPathComponent("moved") + try FileManager.default.moveItem(at: item, to: moved) + try FileManager.default.createSymbolicLink(at: item, withDestinationURL: moved) + XCTAssertThrowsError(try snapshot.plan(selectedPaths: [item.path], now: old)) + XCTAssertTrue(FileManager.default.fileExists(atPath: moved.path), + "malformed/swapped directories are refused, never auto-deleted") + } +} + +final class PrivilegedLogSinkTests: XCTestCase { + func testNamesAreUnguessableAndDistinct() throws { + let a = try PrivilegedLogSink.make(), b = try PrivilegedLogSink.make() + XCTAssertNotEqual(a.directoryPath, b.directoryPath) + XCTAssertTrue(a.directoryPath.hasPrefix("/private/var/tmp/dev.caezium.burrow.operation-")) + } + + func testHostileSymlinkCollisionFailsWithoutFollowingOrDeletingIt() throws { + let token = "TESTCOLLISION" + String(repeating: "A", count: 32) + let sink = try PrivilegedLogSink.make(token: token) + let target = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-log-target-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: false) + try FileManager.default.createSymbolicLink(atPath: sink.directoryPath, + withDestinationPath: target.path) + defer { + try? FileManager.default.removeItem(atPath: sink.directoryPath) + try? FileManager.default.removeItem(at: target) + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/sh") + task.arguments = ["-c", sink.exclusiveCreationShell] + try task.run(); task.waitUntilExit() + XCTAssertNotEqual(task.terminationStatus, 0) + XCTAssertTrue(FileManager.default.fileExists(atPath: sink.directoryPath)) + XCTAssertFalse(FileManager.default.fileExists(atPath: target.appendingPathComponent("output.log").path)) + } +} diff --git a/macos/Tests/ProcessActionsTests.swift b/macos/Tests/ProcessActionsTests.swift new file mode 100644 index 00000000..ead09a8c --- /dev/null +++ b/macos/Tests/ProcessActionsTests.swift @@ -0,0 +1,160 @@ +// +// ProcessActionsTests.swift +// BurrowTests +// + +import XCTest +import Darwin +@testable import Burrow + +final class ProcessActionsTests: XCTestCase { + func testTerminateFailsClosedWhenPIDWasReused() { + let expected = target(identity: identity(start: 10)) + let signals = LockedSignals() + + let result = ProcessActions.terminate( + expected, + force: false, + currentUID: 501, + readIdentity: { _ in self.identity(start: 11) }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .stale) + XCTAssertTrue(signals.values.isEmpty) + } + + func testTerminateFailsClosedWhenExecutableChangedAfterConfirmation() { + let expected = target(identity: identity(path: "/Applications/Before.app/Contents/MacOS/Before")) + let signals = LockedSignals() + + let result = ProcessActions.terminate( + expected, + force: false, + currentUID: 501, + readIdentity: { _ in self.identity(path: "/Applications/After.app/Contents/MacOS/After") }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .stale) + XCTAssertTrue(signals.values.isEmpty) + } + + func testTerminateFailsClosedWhenOwnerChanged() { + let expected = target(identity: identity(owner: 501)) + let signals = LockedSignals() + + let result = ProcessActions.terminate( + expected, + force: false, + currentUID: 501, + readIdentity: { _ in self.identity(owner: 0) }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .notOwned) + XCTAssertTrue(signals.values.isEmpty) + } + + func testTerminateSignalsOnlyAnExactRevalidatedIdentity() { + let identity = identity() + let expected = target(identity: identity) + let signals = LockedSignals() + + let result = ProcessActions.terminate( + expected, + force: false, + currentUID: 501, + readIdentity: { _ in identity }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .sent) + XCTAssertEqual(signals.values, [.init(pid: 42, signal: SIGTERM)]) + } + + func testForceKillUsesTheSameRevalidationBeforeSIGKILL() { + let identity = identity() + let expected = target(identity: identity) + let signals = LockedSignals() + + let result = ProcessActions.terminate( + expected, + force: true, + currentUID: 501, + readIdentity: { _ in identity }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .sent) + XCTAssertEqual(signals.values, [.init(pid: 42, signal: SIGKILL)]) + } + + func testCancellingConfirmationNeverReadsOrSignalsTheProcess() { + let expected = target(identity: identity()) + let signals = LockedSignals() + var identityReads = 0 + + let result = ProcessActions.terminateIfConfirmed( + expected, + force: false, + confirmed: false, + currentUID: 501, + readIdentity: { _ in identityReads += 1; return self.identity() }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .cancelled) + XCTAssertEqual(identityReads, 0) + XCTAssertTrue(signals.values.isEmpty) + } + + func testConfirmationDetailsExposeTheCapturedImmutableIdentity() { + let target = target(identity: identity()) + + XCTAssertTrue(target.confirmationDetails.contains("PID 42")) + XCTAssertTrue(target.confirmationDetails.contains("user 501")) + XCTAssertTrue(target.confirmationDetails.contains("started 10.000020")) + XCTAssertTrue(target.confirmationDetails.contains("/Applications/Test.app/Contents/MacOS/Test")) + } + + private func identity( + owner: uid_t = 501, + start: UInt64 = 10, + path: String? = "/Applications/Test.app/Contents/MacOS/Test" + ) -> ProcessActions.Identity { + ProcessActions.Identity( + pid: 42, + ownerUID: owner, + startSeconds: start, + startMicroseconds: 20, + executablePath: path + ) + } + + private func target(identity: ProcessActions.Identity) -> ProcessActions.TerminationTarget { + ProcessActions.TerminationTarget(displayName: "Test", identity: identity) + } +} + +private final class LockedSignals: @unchecked Sendable { + struct Value: Equatable { + let pid: Int32 + let signal: Int32 + } + + private let lock = NSLock() + private var storage: [Value] = [] + + var values: [Value] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(pid: Int32, signal: Int32) { + lock.lock() + storage.append(.init(pid: pid, signal: signal)) + lock.unlock() + } +} diff --git a/macos/Tests/QueryEventsTests.swift b/macos/Tests/QueryEventsTests.swift index a33c7cae..13daadec 100644 --- a/macos/Tests/QueryEventsTests.swift +++ b/macos/Tests/QueryEventsTests.swift @@ -2,21 +2,26 @@ // QueryEventsTests.swift // BurrowTests // -// The SSE /events auth gate (roadmap B.6): the token parse is pure, so it's -// unit-tested without a socket. (The streaming itself is exercised by hand.) +// The SSE /events stream shares the query server's HTTP bearer gate. The +// streaming socket stays thin; these tests exercise the request boundary. // import XCTest @testable import Burrow final class QueryEventsTests: XCTestCase { - func testEventsToken_parsesTokenParam() { - XCTAssertEqual(QueryServer.eventsToken(from: "/events?token=abc123"), "abc123") - XCTAssertEqual(QueryServer.eventsToken(from: "/events?foo=1&token=xy&bar=2"), "xy") + func testEventsRejectsCredentialInQueryString() throws { + let request = "GET /events?token=abc123 HTTP/1.1\r\nHost: 127.0.0.1:9277\r\n\r\n" + XCTAssertEqual(QueryServer.authorize(request, token: "abc123", port: 9277), .unauthorized) } - func testEventsToken_absent_isEmpty() { - XCTAssertEqual(QueryServer.eventsToken(from: "/events"), "") - XCTAssertEqual(QueryServer.eventsToken(from: "/events?foo=1"), "") + func testEventsAcceptsBearerHeaderFromLocalClient() throws { + let request = "GET /events HTTP/1.1\r\nHost: localhost:9277\r\nAuthorization: Bearer abc123\r\n\r\n" + XCTAssertEqual(QueryServer.authorize(request, token: "abc123", port: 9277), .allowed) + } + + func testEventsRejectsBrowserRequestEvenWithCredential() throws { + let request = "GET /events HTTP/1.1\r\nHost: localhost:9277\r\nAuthorization: Bearer abc123\r\nOrigin: http://localhost:3000\r\n\r\n" + XCTAssertEqual(QueryServer.authorize(request, token: "abc123", port: 9277), .forbidden) } } diff --git a/macos/Tests/QueryServerTests.swift b/macos/Tests/QueryServerTests.swift index 712e553b..ddfffa3e 100644 --- a/macos/Tests/QueryServerTests.swift +++ b/macos/Tests/QueryServerTests.swift @@ -12,6 +12,7 @@ import XCTest @testable import Burrow final class QueryServerTests: XCTestCase { + private let token = "test-only-query-credential" private var tempDir: URL! private var db: DB! private var server: QueryServer! @@ -21,7 +22,7 @@ final class QueryServerTests: XCTestCase { .appendingPathComponent("burrow-qs-test-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) db = try DB(at: tempDir.appendingPathComponent("burrow.db")) - server = QueryServer(db: db, port: 9277) + server = QueryServer(db: db, port: 9277, authToken: token) } override func tearDown() { @@ -80,46 +81,102 @@ final class QueryServerTests: XCTestCase { // MARK: - Routing + private func request(_ target: String, + method: String = "GET", + host: String? = "127.0.0.1:9277", + credential: String? = "test-only-query-credential", + extraHeaders: [String] = []) -> String { + var lines = ["\(method) \(target) HTTP/1.1"] + if let host { lines.append("Host: \(host)") } + if let credential { lines.append("Authorization: Bearer \(credential)") } + lines.append(contentsOf: extraHeaders) + return lines.joined(separator: "\r\n") + "\r\n\r\n" + } + + func testRoute_requiresValidBearerCredential() { + let missing = server.route(request("/health", credential: nil)) + XCTAssertEqual(missing.statusCode, 401) + XCTAssertFalse(missing.body.contains("\"ok\":true")) + + let wrong = server.route(request("/health", credential: "wrong")) + XCTAssertEqual(wrong.statusCode, 401) + + let queryStringLeak = server.route(request("/health?token=\(token)", credential: nil)) + XCTAssertEqual(queryStringLeak.statusCode, 401, + "credentials in URLs must not bypass the Authorization header gate") + + XCTAssertEqual(server.route(request("/health")).statusCode, 200) + } + + func testRoute_rejectsDNSRebindingAndBrowserOrigins() { + XCTAssertEqual(server.route(request("/snapshot", host: "attacker.example")).statusCode, 403) + XCTAssertEqual(server.route(request("/snapshot", host: nil)).statusCode, 403) + XCTAssertEqual(server.route(request("/snapshot", extraHeaders: ["Origin: https://attacker.example"])).statusCode, 403) + XCTAssertEqual(server.route(request("/snapshot", extraHeaders: ["Origin: null"])).statusCode, 403) + XCTAssertEqual(server.route(request("/snapshot", extraHeaders: ["Sec-Fetch-Site: cross-site"])).statusCode, 403) + } + + func testRoute_constrainsMethodAndRequestBody() { + XCTAssertEqual(server.route(request("/health", method: "POST", + extraHeaders: ["Content-Type: application/json", "Content-Length: 2"]) + "{}").statusCode, + 405) + XCTAssertEqual(server.route(request("/health", extraHeaders: ["Content-Length: 2"]) + "{}").statusCode, + 400) + XCTAssertEqual(server.route(request("/health", extraHeaders: ["Transfer-Encoding: chunked"])).statusCode, + 400) + } + + func testRoute_rateLimitsAuthenticatedClients() { + let limited = QueryServer(db: db, port: 9277, authToken: token, + rateLimiter: QueryRateLimiter(limit: 2, window: 60)) + XCTAssertEqual(limited.route(request("/health")).statusCode, 200) + XCTAssertEqual(limited.route(request("/health")).statusCode, 200) + XCTAssertEqual(limited.route(request("/health")).statusCode, 429) + } + func testRoute_health() { - let res = server.route("GET /health HTTP/1.1\r\n\r\n") + let res = server.route(request("/health")) XCTAssertTrue(res.body.contains("\"ok\":true")) XCTAssertTrue(res.body.contains("9277")) XCTAssertEqual(res.contentType, QueryServer.jsonContentType) } func testRoute_rejectsNonGET() { - let res = server.route("POST /health HTTP/1.1\r\n\r\n") + let res = server.route(request("/health", method: "POST")) XCTAssertTrue(res.body.contains("error")) XCTAssertTrue(res.body.contains("only GET")) + XCTAssertEqual(res.statusCode, 405) } func testRoute_unknownPathIsError() { - let res = server.route("GET /admin HTTP/1.1\r\n\r\n") + let res = server.route(request("/admin")) XCTAssertTrue(res.body.contains("unknown route")) + XCTAssertEqual(res.statusCode, 404) } func testRoute_malformedRequestIsError() { XCTAssertTrue(server.route("").body.contains("error")) XCTAssertTrue(server.route("\r\n\r\n").body.contains("error")) + XCTAssertEqual(server.route("").statusCode, 400) } func testRoute_snapshotReturnsLatestSeededRow() throws { let now = Int(Date().timeIntervalSince1970) try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now - 60, json: "{\"old\":true}") try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now, json: "{\"new\":true}") - let res = server.route("GET /snapshot HTTP/1.1\r\n\r\n") + let res = server.route(request("/snapshot")) XCTAssertTrue(res.body.contains("\"new\":true"), "should embed the most recent row verbatim") XCTAssertFalse(res.body.contains("\"old\":true")) XCTAssertTrue(res.body.contains("\"ts\":\(now)")) } func testRoute_snapshotWithEmptyDBIsError() { - let res = server.route("GET /snapshot HTTP/1.1\r\n\r\n") + let res = server.route(request("/snapshot")) XCTAssertTrue(res.body.contains("no snapshot yet")) } func testRoute_metricsRequiresPrefix() { - let res = server.route("GET /metrics HTTP/1.1\r\n\r\n") + let res = server.route(request("/metrics")) XCTAssertTrue(res.body.contains("missing 'prefix'")) } @@ -128,7 +185,7 @@ final class QueryServerTests: XCTestCase { try db.insert(prefix: "cpu", ts: now - 10, json: "{\"v\":1}") try db.insert(prefix: "cpu", ts: now - 5, json: "{\"v\":2}") try db.insert(prefix: "other", ts: now - 5, json: "{\"v\":9}") - let res = server.route("GET /metrics?prefix=cpu&since=0&until=\(now + 1) HTTP/1.1\r\n\r\n") + let res = server.route(request("/metrics?prefix=cpu&since=0&until=\(now + 1)")) XCTAssertTrue(res.body.contains("{\"v\":1}")) XCTAssertTrue(res.body.contains("{\"v\":2}")) XCTAssertFalse(res.body.contains("{\"v\":9}"), "other prefixes must not bleed into the slice") @@ -148,14 +205,14 @@ final class QueryServerTests: XCTestCase { "disk_io":{"read_rate":0,"write_rate":0},"top_processes":[]} """ try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now, json: snap) - let res = server.route("GET /metrics?format=prometheus HTTP/1.1\r\n\r\n") + let res = server.route(request("/metrics?format=prometheus")) XCTAssertEqual(res.contentType, QueryServer.prometheusContentType) XCTAssertTrue(res.body.contains("\nburrow_cpu_usage_percent 42\n"), res.body) XCTAssertTrue(res.body.contains("# TYPE burrow_health_score gauge"), res.body) } func testRoute_metricsPrometheusWithEmptyDBYieldsComment() { - let res = server.route("GET /metrics?format=prometheus HTTP/1.1\r\n\r\n") + let res = server.route(request("/metrics?format=prometheus")) XCTAssertEqual(res.contentType, QueryServer.prometheusContentType) XCTAssertTrue(res.body.hasPrefix("#"), "scrapers tolerate an empty target; emit a comment, not error JSON") } @@ -164,7 +221,7 @@ final class QueryServerTests: XCTestCase { // visible cause an agent (or curl) can see. func testRoute_infoSurfacesDriftCounters() throws { MetricsStore.resetDriftCounters() - let clean = server.route("GET /info HTTP/1.1\r\n\r\n") + let clean = server.route(request("/info")) let cleanObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(clean.body.utf8)) as? [String: Any]) XCTAssertEqual(cleanObj["decode_skipped_total"] as? Int, 0) XCTAssertTrue(cleanObj["last_drift"] is NSNull, "no drift yet → explicit null") @@ -173,7 +230,7 @@ final class QueryServerTests: XCTestCase { try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now, json: "not valid json") _ = MetricsStore(db: db).snapshots(.init(since: 0, until: now + 1)) - let drifted = server.route("GET /info HTTP/1.1\r\n\r\n") + let drifted = server.route(request("/info")) let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(drifted.body.utf8)) as? [String: Any]) XCTAssertEqual(obj["decode_skipped_total"] as? Int, 1) let last = try XCTUnwrap(obj["last_drift"] as? [String: Any]) diff --git a/macos/Tests/SoftwareModelTests.swift b/macos/Tests/SoftwareModelTests.swift new file mode 100644 index 00000000..8ea8a923 --- /dev/null +++ b/macos/Tests/SoftwareModelTests.swift @@ -0,0 +1,187 @@ +// +// SoftwareModelTests.swift +// BurrowTests +// + +import XCTest +@testable import Burrow + +@MainActor +final class SoftwareModelTests: XCTestCase { + func testNewestLoadWinsWhenAnOlderLoadFinishesLast() async { + let firstStarted = expectation(description: "first load started") + let releaseFirst = DispatchSemaphore(value: 0) + let calls = LockedCounter() + let older = app(id: "same", name: "Older", path: "/Applications/Older.app") + let newer = app(id: "same", name: "Newer", path: "/Applications/Newer.app") + let model = SoftwareModel(loadApps: { + if calls.next() == 1 { + firstStarted.fulfill() + releaseFirst.wait() + return [older] + } + return [newer] + }) + + model.load() + await fulfillment(of: [firstStarted], timeout: 1) + model.load() + await eventually { model.apps == [newer] && !model.loading } + + releaseFirst.signal() + await settle() + + XCTAssertEqual(model.apps, [newer]) + XCTAssertFalse(model.loading) + } + + func testRecentDatesMergeOntoCurrentMetadataByStableIdentity() async { + let dateStarted = expectation(description: "date pass started") + let releaseDate = DispatchSemaphore(value: 0) + let date = Date(timeIntervalSince1970: 123) + let model = SoftwareModel(lastUsedDate: { _ in + dateStarted.fulfill() + releaseDate.wait() + return date + }) + model.apps = [app(id: "stable", name: "Before", path: "/Applications/App.app")] + + model.setSort(.recent) + await fulfillment(of: [dateStarted], timeout: 1) + model.apps = [app(id: "stable", name: "After", path: "/Applications/App.app", size: 42)] + releaseDate.signal() + await eventually { model.apps.first?.lastUsed == date } + + XCTAssertEqual(model.apps.first?.name, "After") + XCTAssertEqual(model.apps.first?.sizeBytes, 42) + } + + func testRecentPassFromAnOlderInventoryCannotOverwriteTheNewestLoad() async { + let oldDateStarted = expectation(description: "old date pass started") + let releaseOldDate = DispatchSemaphore(value: 0) + let calls = LockedCounter() + let old = app(id: "old", name: "Old", path: "/Applications/Old.app") + let fresh = app(id: "fresh", name: "Fresh", path: "/Applications/Fresh.app") + let freshDate = Date(timeIntervalSince1970: 456) + let model = SoftwareModel( + loadApps: { calls.next() == 1 ? [old] : [fresh] }, + lastUsedDate: { path in + if path == old.path { + oldDateStarted.fulfill() + releaseOldDate.wait() + return Date(timeIntervalSince1970: 1) + } + return freshDate + } + ) + + model.load() + await eventually { model.apps == [old] && !model.loading } + model.setSort(.recent) + await fulfillment(of: [oldDateStarted], timeout: 1) + model.load() + await eventually { model.apps.first?.id == fresh.id && model.apps.first?.lastUsed == freshDate } + + releaseOldDate.signal() + await settle() + + XCTAssertEqual(model.apps.first?.id, fresh.id) + XCTAssertEqual(model.apps.first?.lastUsed, freshDate) + } + + func testPreviewFromAnOlderInventoryCannotPublishAfterReload() async { + let previewStarted = expectation(description: "old preview started") + let releasePreview = DispatchSemaphore(value: 0) + let calls = LockedCounter() + let old = app(id: "old", name: "Old", path: "/Applications/Old.app") + let fresh = app(id: "fresh", name: "Fresh", path: "/Applications/Fresh.app") + let model = SoftwareModel( + loadApps: { calls.next() == 1 ? [old] : [fresh] }, + loadPreview: { _ in + previewStarted.fulfill() + releasePreview.wait() + return UninstallPreview( + appName: "Old", + totalText: "1 B", + entries: [.init(path: old.path, kind: .application)] + ) + } + ) + + model.load() + await eventually { model.apps == [old] } + model.toggleExpansion(old) + await fulfillment(of: [previewStarted], timeout: 1) + model.load() + await eventually { model.apps == [fresh] && !model.loading } + + releasePreview.signal() + await settle() + + XCTAssertTrue(model.previews.isEmpty) + XCTAssertTrue(model.previewLoading.isEmpty) + XCTAssertNil(model.expandedAppID) + } + + func testReloadDropsSelectionsThatNoLongerExist() async { + let calls = LockedCounter() + let old = app(id: "old", name: "Old", path: "/Applications/Old.app") + let fresh = app(id: "fresh", name: "Fresh", path: "/Applications/Fresh.app") + let model = SoftwareModel(loadApps: { calls.next() == 1 ? [old] : [fresh] }) + + model.load() + await eventually { model.apps == [old] } + model.toggle(old.id) + model.load() + await eventually { model.apps == [fresh] } + + XCTAssertTrue(model.selected.isEmpty) + } + + private func app( + id: String, + name: String, + path: String, + size: Int64 = 1, + lastUsed: Date? = nil + ) -> InstalledApp { + InstalledApp( + id: id, + name: name, + bundleId: "dev.test.\(id)", + source: "App", + uninstallName: name, + path: path, + sizeStr: "\(size) B", + sizeBytes: size, + lastUsed: lastUsed + ) + } + + private func eventually( + timeout: TimeInterval = 2, + _ condition: @escaping @MainActor () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(condition()) + } + + private func settle() async { + try? await Task.sleep(nanoseconds: 50_000_000) + } +} + +private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } +} diff --git a/macos/Tests/StoreTests.swift b/macos/Tests/StoreTests.swift index 3342a5d3..4854e91b 100644 --- a/macos/Tests/StoreTests.swift +++ b/macos/Tests/StoreTests.swift @@ -161,6 +161,30 @@ final class StoreTests: XCTestCase { XCTAssertEqual(Store.queryServerPort, Int(QueryServer.defaultPort)) } + func testQueryServerCredential_isRandomLookingAndStable() { + let first = Store.queryAuthToken + let second = Store.queryAuthToken + XCTAssertEqual(first, second) + XCTAssertGreaterThanOrEqual(first.utf8.count, 43, + "the loopback credential needs at least 256 random bits") + XCTAssertFalse(first.contains("/"), "credential must be safe in an HTTP header") + XCTAssertFalse(first.contains("+"), "credential must be safe in an HTTP header") + } + + func testQueryServerCredential_rotatesLegacyShortToken() { + Store.d.set("legacy-short-token", forKey: "query_auth_token") + let migrated = Store.queryAuthToken + XCTAssertNotEqual(migrated, "legacy-short-token") + XCTAssertGreaterThanOrEqual(migrated.utf8.count, 43) + } + + func testQueryServerCredential_rotatesHeaderUnsafeToken() { + Store.d.set(String(repeating: "a", count: 63) + "\n", forKey: "query_auth_token") + let migrated = Store.queryAuthToken + XCTAssertFalse(migrated.contains("\n")) + XCTAssertGreaterThanOrEqual(migrated.utf8.count, 43) + } + func testLastHistoryRangeMinutes_defaultsToOneHour() { XCTAssertEqual(Store.lastHistoryRangeMinutes, 60) } diff --git a/macos/Tests/TuneUpTests.swift b/macos/Tests/TuneUpTests.swift index 3cbaa1f5..c26df68e 100644 --- a/macos/Tests/TuneUpTests.swift +++ b/macos/Tests/TuneUpTests.swift @@ -30,4 +30,19 @@ final class TuneUpTests: XCTestCase { func testReclaimable_sumsAllBytes() { XCTAssertEqual(TuneUp.reclaimable(recs), 3_500_000_000) } + + func testConfirmationCopyMatchesPermanentCleanupAndRequiresSeparateConsent() { + let policy = TuneUp.ConfirmationPolicy(includesClean: true) + XCTAssertTrue(policy.notice.contains("permanently deletes")) + XCTAssertTrue(policy.notice.contains("do not go to the Trash")) + XCTAssertTrue(policy.notice.contains("cannot be recovered")) + XCTAssertFalse(policy.permitsRun(irreversibleConsent: false)) + XCTAssertTrue(policy.permitsRun(irreversibleConsent: true)) + } + + func testMaintenanceOnlyDoesNotRequireIrreversibleConsent() { + let policy = TuneUp.ConfirmationPolicy(includesClean: false) + XCTAssertFalse(policy.requiresIrreversibleConsent) + XCTAssertTrue(policy.permitsRun(irreversibleConsent: false)) + } } diff --git a/macos/Tests/UpdateWorkflowTests.swift b/macos/Tests/UpdateWorkflowTests.swift new file mode 100644 index 00000000..a44fb255 --- /dev/null +++ b/macos/Tests/UpdateWorkflowTests.swift @@ -0,0 +1,210 @@ +// +// UpdateWorkflowTests.swift +// BurrowTests +// + +import XCTest +import Foundation +import Darwin +@testable import Burrow + +final class UpdateWorkflowTests: XCTestCase { + func testHTTPFailuresRemainDistinctAndRetryableOnlyWhenRecoveryCanSucceed() { + let offline = UpdateHTTP.classify( + data: nil, + response: nil, + error: URLError(.notConnectedToInternet) + ) + let timeout = UpdateHTTP.classify( + data: nil, + response: nil, + error: URLError(.timedOut) + ) + let forbidden = UpdateHTTP.classify( + data: Data(), + response: response(status: 403), + error: nil + ) + let server = UpdateHTTP.classify( + data: Data(), + response: response(status: 500), + error: nil + ) + + XCTAssertEqual(offline, .failure(.offline)) + XCTAssertEqual(timeout, .failure(.timeout)) + XCTAssertEqual(forbidden, .failure(.http(status: 403, retryable: false))) + XCTAssertEqual(server, .failure(.http(status: 500, retryable: true))) + } + + func testNonHTTPAndEmptySuccessfulResponsesAreRejected() { + XCTAssertEqual( + UpdateHTTP.classify( + data: Data("body".utf8), + response: URLResponse( + url: URL(string: "file:///tmp/update")!, + mimeType: nil, + expectedContentLength: 4, + textEncodingName: nil + ), + error: nil + ), + .failure(.invalidResponse) + ) + XCTAssertEqual( + UpdateHTTP.classify(data: Data(), response: response(status: 200), error: nil), + .failure(.decoding) + ) + } + + func testElectronLatestYAMLRequiresVersionArchiveAndSHA512() { + let yaml = """ + version: 4.2.0 + files: + - url: Example-4.2.0-mac.zip + sha512: YWJjZA== + releaseDate: '2026-08-08T01:02:03.000Z' + """ + + let descriptor = ElectronUpdateDescriptor.parse( + Data(yaml.utf8), + relativeTo: URL(string: "https://updates.example.com/mac/latest-mac.yml")! + ) + + XCTAssertEqual(descriptor?.version, "4.2.0") + XCTAssertEqual(descriptor?.archiveURL.absoluteString, "https://updates.example.com/mac/Example-4.2.0-mac.zip") + XCTAssertEqual(descriptor?.sha512, Data("abcd".utf8)) + XCTAssertNil(ElectronUpdateDescriptor.parse(Data("version: 4.2.0".utf8), relativeTo: URL(string: "https://example.com/latest-mac.yml")!)) + } + + func testElectronGenericFeedConfigurationBuildsLatestMacURL() { + let config = """ + provider: generic + url: https://updates.example.com/releases/ + channel: latest + """ + + XCTAssertEqual( + ElectronFeedConfiguration.parse(Data(config.utf8))?.latestYAMLURL.absoluteString, + "https://updates.example.com/releases/latest-mac.yml" + ) + XCTAssertNil(ElectronFeedConfiguration.parse(Data("provider: github\nowner: acme".utf8))) + XCTAssertNil(ElectronFeedConfiguration.parse(Data("provider: generic\nurl: http://updates.example.com".utf8))) + } + + func testElectronDescriptorRejectsPlainHTTPArchive() { + let yaml = """ + version: 4.2.0 + path: http://updates.example.com/Example-4.2.0-mac.zip + sha512: YWJjZA== + """ + + XCTAssertNil(ElectronUpdateDescriptor.parse( + Data(yaml.utf8), + relativeTo: URL(string: "https://updates.example.com/latest-mac.yml")! + )) + } + + func testPrivateStagingDirectoriesAreExclusiveUniqueAndOwnerOnly() throws { + let first = try PrivateUpdateDirectory.create() + let second = try PrivateUpdateDirectory.create() + defer { + try? FileManager.default.removeItem(at: first) + try? FileManager.default.removeItem(at: second) + } + + XCTAssertNotEqual(first, second) + XCTAssertFalse(first.lastPathComponent.contains("UUID().uuidString")) + XCTAssertFalse(second.lastPathComponent.contains("UUID().uuidString")) + var firstStat = stat() + var secondStat = stat() + XCTAssertEqual(lstat(first.path, &firstStat), 0) + XCTAssertEqual(lstat(second.path, &secondStat), 0) + XCTAssertEqual(firstStat.st_mode & mode_t(0o777), mode_t(0o700)) + XCTAssertEqual(secondStat.st_mode & mode_t(0o777), mode_t(0o700)) + } + + func testReplacementIdentityRejectsBundleOrSigningIdentityChanges() { + let expected = BundleUpdateIdentity( + bundleID: "com.example.App", + signingIdentifier: "com.example.App", + teamIdentifier: "TEAM123", + signatureValid: true + ) + + XCTAssertEqual(expected.verificationFailure(comparedWith: expected), nil) + XCTAssertEqual( + expected.verificationFailure(comparedWith: .init( + bundleID: "com.attacker.App", + signingIdentifier: "com.attacker.App", + teamIdentifier: "TEAM123", + signatureValid: true + )), + .bundleIdentityChanged + ) + XCTAssertEqual( + expected.verificationFailure(comparedWith: .init( + bundleID: "com.example.App", + signingIdentifier: "com.example.App", + teamIdentifier: "OTHER", + signatureValid: true + )), + .signingIdentityChanged + ) + XCTAssertEqual( + expected.verificationFailure(comparedWith: .init( + bundleID: "com.example.App", + signingIdentifier: "com.example.App", + teamIdentifier: "TEAM123", + signatureValid: false + )), + .invalidSignature + ) + } + + func testReplacementBoundaryRereadsAndRejectsSwappedCandidate() { + let expected = BundleUpdateIdentity( + bundleID: "com.example.App", + signingIdentifier: "com.example.App", + teamIdentifier: "TEAM123", + signatureValid: true + ) + let staged = StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Example.app"), + candidateURL: URL(fileURLWithPath: "/tmp/private/Candidate.app"), + stagingDirectory: URL(fileURLWithPath: "/tmp/private"), + expectedIdentity: expected + ) + var reads: [URL] = [] + + let failure = ElectronReplacementInstaller.boundaryVerificationFailure(for: staged) { url in + reads.append(url) + if url == staged.targetURL { return expected } + return BundleUpdateIdentity( + bundleID: expected.bundleID, + signingIdentifier: expected.signingIdentifier, + teamIdentifier: "ATTACKER", + signatureValid: true + ) + } + + XCTAssertEqual(reads, [staged.targetURL, staged.candidateURL]) + XCTAssertEqual(failure, .verification(.signingIdentityChanged)) + } + + func testUpdatePhaseExposesRecoveryAndAccessibleProgress() { + XCTAssertTrue(UpdatePhase.failed(.offline).canRetry) + XCTAssertEqual(UpdatePhase.downloading(progress: 0.42).accessibilityValue, "Downloading, 42 percent") + XCTAssertEqual(UpdatePhase.readyToInstall.accessibilityValue, "Ready to install and restart") + XCTAssertFalse(UpdatePhase.failed(.verification(.invalidSignature)).canRetry) + } + + private func response(status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "https://updates.example.com")!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + } +} diff --git a/macos/Tests/UpdatesModelTests.swift b/macos/Tests/UpdatesModelTests.swift new file mode 100644 index 00000000..82ad7fbf --- /dev/null +++ b/macos/Tests/UpdatesModelTests.swift @@ -0,0 +1,179 @@ +// +// UpdatesModelTests.swift +// BurrowTests +// + +import XCTest +@testable import Burrow + +@MainActor +final class UpdatesModelTests: XCTestCase { + func testSameCountInventoryChangeRepreparesSourceMetadata() async { + let detections = LockedStringRecorder() + let model = UpdatesModel(detectSource: { app in + detections.append(app.path) + return app.path.contains("Store") ? .appStore : .sparkle + }) + + model.prepare(apps: [app(id: "one", name: "Vendor", path: "/Applications/Vendor.app")]) + await eventually { model.appItems.first?.source == .sparkle } + + model.prepare(apps: [app(id: "one", name: "Store", path: "/Applications/Store.app")]) + await eventually { model.appItems.first?.source == .appStore } + + XCTAssertEqual(detections.values, ["/Applications/Vendor.app", "/Applications/Store.app"]) + XCTAssertEqual(model.appItems.first?.name, "Store") + } + + func testNewestPrepareWinsWhenOlderDetectionFinishesLast() async { + let oldStarted = expectation(description: "old detection started") + let releaseOld = DispatchSemaphore(value: 0) + let model = UpdatesModel(detectSource: { app in + if app.id == "old" { + oldStarted.fulfill() + releaseOld.wait() + return .sparkle + } + return .appStore + }) + + model.prepare(apps: [app(id: "old", name: "Old", path: "/Applications/Old.app")]) + await fulfillment(of: [oldStarted], timeout: 1) + model.prepare(apps: [app(id: "new", name: "New", path: "/Applications/New.app")]) + await eventually { model.appItems.map(\.id) == ["new"] } + + releaseOld.signal() + await settle() + + XCTAssertEqual(model.appItems.map(\.id), ["new"]) + XCTAssertTrue(model.uncheckableApps.isEmpty) + } + + func testRemovedAppsDisappearBeforeReplacementDetectionCompletes() async { + let replacementStarted = expectation(description: "replacement detection started") + let releaseReplacement = DispatchSemaphore(value: 0) + let model = UpdatesModel(detectSource: { app in + if app.name == "Replacement" { + replacementStarted.fulfill() + releaseReplacement.wait() + } + return .sparkle + }) + let keep = app(id: "keep", name: "Keep", path: "/Applications/Keep.app") + let remove = app(id: "remove", name: "Remove", path: "/Applications/Remove.app") + + model.prepare(apps: [keep, remove]) + await eventually { Set(model.appItems.map(\.id)) == ["keep", "remove"] } + let replacement = app(id: "keep", name: "Replacement", path: "/Applications/Keep.app") + model.prepare(apps: [replacement]) + await fulfillment(of: [replacementStarted], timeout: 1) + + XCTAssertFalse(model.appItems.contains { $0.id == "remove" }) + XCTAssertFalse(model.uncheckableApps.contains { $0.id == "remove" }) + + releaseReplacement.signal() + await eventually { model.appItems.first?.name == "Replacement" } + } + + func testSameInstalledAppIdentityRepreparesWhenBundleDetectionMetadataChanges() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("UpdatesModelTests-\(UUID().uuidString)", isDirectory: true) + let appURL = root.appendingPathComponent("Mutable.app", isDirectory: true) + let contents = appURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try writePlist( + ["CFBundleIdentifier": "dev.test.mutable", "SUFeedURL": "https://updates.example.com/appcast.xml"], + to: contents.appendingPathComponent("Info.plist") + ) + let installed = app(id: "stable", name: "Mutable", path: appURL.path) + let model = UpdatesModel() + + model.prepare(apps: [installed]) + await eventually { model.appItems.first?.source == .sparkle } + + try writePlist( + ["CFBundleIdentifier": "dev.test.mutable"], + to: contents.appendingPathComponent("Info.plist") + ) + try FileManager.default.createDirectory( + at: contents.appendingPathComponent("Frameworks/Electron Framework.framework", isDirectory: true), + withIntermediateDirectories: true + ) + model.prepare(apps: [installed]) + await eventually { model.appItems.first?.source == .electron } + + XCTAssertEqual(model.appItems.map(\.id), ["stable"]) + } + + func testDetectionFingerprintIncludesElectronAppUpdateConfiguration() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("UpdatesFingerprintTests-\(UUID().uuidString)", isDirectory: true) + let resources = root.appendingPathComponent("Mutable.app/Contents/Resources", isDirectory: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let config = resources.appendingPathComponent("app-update.yml") + try Data("provider: generic\nurl: https://one.example.com".utf8).write(to: config, options: .atomic) + let before = UpdateSources.detectionFingerprint(appPath: root.appendingPathComponent("Mutable.app").path) + + try Data("provider: generic\nurl: https://two.example.com".utf8).write(to: config, options: .atomic) + let after = UpdateSources.detectionFingerprint(appPath: root.appendingPathComponent("Mutable.app").path) + + XCTAssertNotEqual(before, after) + } + + private func app(id: String, name: String, path: String) -> InstalledApp { + InstalledApp( + id: id, + name: name, + bundleId: "dev.test.\(id)", + source: "App", + uninstallName: name, + path: path, + sizeStr: "1 B", + sizeBytes: 1, + lastUsed: nil + ) + } + + private func writePlist(_ values: [String: Any], to url: URL) throws { + let data = try PropertyListSerialization.data( + fromPropertyList: values, + format: .xml, + options: 0 + ) + try data.write(to: url, options: .atomic) + } + + private func eventually( + timeout: TimeInterval = 2, + _ condition: @escaping @MainActor () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(condition()) + } + + private func settle() async { + try? await Task.sleep(nanoseconds: 50_000_000) + } +} + +private final class LockedStringRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ value: String) { + lock.lock() + storage.append(value) + lock.unlock() + } +} diff --git a/macos/project.yml b/macos/project.yml index fe48b098..1ccb3576 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -13,6 +13,10 @@ settings: base: SWIFT_VERSION: "5.9" MACOSX_DEPLOYMENT_TARGET: "14.0" + # The only app/build version declarations. The generated Info.plist keeps + # these build-setting references, and Xcode expands them in the product. + MARKETING_VERSION: "0.12.0" + CURRENT_PROJECT_VERSION: "24" ENABLE_HARDENED_RUNTIME: YES # Xcode leaves signing off for reproducible builds. Local scripts apply a # coherent ad-hoc signature; the tag workflow applies Developer ID after @@ -61,8 +65,8 @@ targets: properties: LSUIElement: true # menu-bar agent, no Dock icon CFBundleDisplayName: Burrow - CFBundleShortVersionString: "0.12.0" - CFBundleVersion: "24" + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" NSHumanReadableCopyright: "MIT License. © 2026 Henry Zhang." # Burrow uses only exempt encryption supplied by macOS (for example, # URLSession HTTPS); it contains no proprietary or non-exempt crypto. @@ -199,11 +203,9 @@ targets: # bundle happens to surround it after an update. CREATE_INFOPLIST_SECTION_IN_BINARY: YES GENERATE_INFOPLIST_FILE: YES - # Must track the app's CFBundleVersion above — a mismatch is exactly - # what HelperVersionSkew refuses, so a stale value here disables the - # helper rather than shipping a subtly wrong one. - CURRENT_PROJECT_VERSION: "24" - MARKETING_VERSION: "0.12.0" + # Version/build inherit the repository-wide settings above. The app + # plist also references those settings, so helper/app skew cannot be + # introduced by changing a second declaration. SKIP_INSTALL: YES BurrowTests: diff --git a/macos/vendor/burrow-engine b/macos/vendor/burrow-engine index a6d1a983..4e008973 160000 --- a/macos/vendor/burrow-engine +++ b/macos/vendor/burrow-engine @@ -1 +1 @@ -Subproject commit a6d1a9837f0938600619e6c16505bf4e62d95904 +Subproject commit 4e00897382c35f5ec827c767d04075041a7aa159 diff --git a/scripts/fetch-sentry-cli.sh b/scripts/fetch-sentry-cli.sh new file mode 100755 index 00000000..67310036 --- /dev/null +++ b/scripts/fetch-sentry-cli.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Install the exact checksum-pinned universal sentry-cli used for dSYM upload. +set -euo pipefail +cd "$(dirname "$0")/.." + +[ "$#" -eq 1 ] && [ -n "$1" ] \ + || { echo "usage: $0 " >&2; exit 2; } +DEST="$1" +[ ! -e "$DEST" ] \ + || { echo "error: sentry-cli destination already exists: $DEST" >&2; exit 2; } + +VERSION="$(python3 scripts/release-input.py tools.sentry-cli.version)" +URL="$(python3 scripts/release-input.py tools.sentry-cli.url)" +SHA256="$(python3 scripts/release-input.py tools.sentry-cli.sha256)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +curl -fSL --retry 5 --retry-delay 3 --retry-all-errors \ + --connect-timeout 30 --max-time 300 -o "$TMP/sentry-cli" "$URL" +GOT="$(shasum -a 256 "$TMP/sentry-cli" | awk '{print $1}')" +[ "$GOT" = "$SHA256" ] \ + || { echo "error: sentry-cli checksum mismatch (got $GOT, want $SHA256)" >&2; exit 1; } +chmod 0755 "$TMP/sentry-cli" +ACTUAL="$("$TMP/sentry-cli" --version | awk '{print $NF}')" +[ "$ACTUAL" = "$VERSION" ] \ + || { echo "error: sentry-cli binary is $ACTUAL, expected $VERSION" >&2; exit 1; } +mkdir -p "$(dirname "$DEST")" +mv "$TMP/sentry-cli" "$DEST" +echo "==> installed checksum-pinned sentry-cli $VERSION at $DEST" diff --git a/scripts/fetch-sentry.sh b/scripts/fetch-sentry.sh index 4244b7f7..4db9c116 100755 --- a/scripts/fetch-sentry.sh +++ b/scripts/fetch-sentry.sh @@ -16,11 +16,10 @@ set -euo pipefail cd "$(dirname "$0")/.." -VERSION="9.16.1" -# sha256 of Sentry.xcframework.zip — matches the checksum sentry-cocoa pins for -# the `Sentry` (static) binaryTarget at this version. Bump both together. -SHA256="7e3966c543697a8d51f337dc357cfcf99e80942c6931c6457e0a49c113133cd4" -URL="https://github.com/getsentry/sentry-cocoa/releases/download/${VERSION}/Sentry.xcframework.zip" +VERSION="$(python3 scripts/release-input.py frameworks.sentry.version)" +# Matches the checksum sentry-cocoa pins for the static Sentry binary target. +SHA256="$(python3 scripts/release-input.py frameworks.sentry.sha256)" +URL="$(python3 scripts/release-input.py frameworks.sentry.url)" DEST="macos/vendor/Sentry.xcframework" STAMP="macos/vendor/.sentry-${VERSION}-${SHA256:0:12}.ok" diff --git a/scripts/fetch-sparkle.sh b/scripts/fetch-sparkle.sh index be261d72..eb473b1a 100755 --- a/scripts/fetch-sparkle.sh +++ b/scripts/fetch-sparkle.sh @@ -19,11 +19,10 @@ set -euo pipefail cd "$(dirname "$0")/.." -VERSION="2.9.4" -# sha256 of Sparkle-2.9.4.tar.xz from the official GitHub release. Framework -# and appcast tools both come through this single pin. -SHA256="ce89daf967db1e1893ed3ebd67575ed82d3902563e3191ca92aaec9164fbdef9" -URL="https://github.com/sparkle-project/Sparkle/releases/download/${VERSION}/Sparkle-${VERSION}.tar.xz" +VERSION="$(python3 scripts/release-input.py frameworks.sparkle.version)" +# Framework and appcast tools both come through this single content pin. +SHA256="$(python3 scripts/release-input.py frameworks.sparkle.sha256)" +URL="$(python3 scripts/release-input.py frameworks.sparkle.url)" DEST="macos/vendor/Sparkle.framework" CACHE_DIR="macos/vendor/.sparkle-cache" ARCHIVE="$CACHE_DIR/Sparkle-${VERSION}.tar.xz" diff --git a/scripts/fetch-xcodegen.sh b/scripts/fetch-xcodegen.sh new file mode 100755 index 00000000..158fee19 --- /dev/null +++ b/scripts/fetch-xcodegen.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Install the exact checksum-pinned XcodeGen release used by CI and releases. +set -euo pipefail +cd "$(dirname "$0")/.." + +[ "$#" -eq 1 ] && [ -n "$1" ] \ + || { echo "usage: $0 " >&2; exit 2; } +DEST="$1" +[ ! -e "$DEST" ] \ + || { echo "error: XcodeGen destination already exists: $DEST" >&2; exit 2; } + +VERSION="$(python3 scripts/release-input.py tools.xcodegen.version)" +URL="$(python3 scripts/release-input.py tools.xcodegen.url)" +SHA256="$(python3 scripts/release-input.py tools.xcodegen.sha256)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +curl -fSL --retry 5 --retry-delay 3 --retry-all-errors \ + --connect-timeout 30 --max-time 300 -o "$TMP/xcodegen.zip" "$URL" +GOT="$(shasum -a 256 "$TMP/xcodegen.zip" | awk '{print $1}')" +[ "$GOT" = "$SHA256" ] \ + || { echo "error: XcodeGen checksum mismatch (got $GOT, want $SHA256)" >&2; exit 1; } + +unzip -q "$TMP/xcodegen.zip" -d "$TMP/unpacked" +[ -x "$TMP/unpacked/xcodegen/bin/xcodegen" ] \ + || { echo "error: XcodeGen archive is missing bin/xcodegen" >&2; exit 1; } +ACTUAL="$("$TMP/unpacked/xcodegen/bin/xcodegen" --version | awk '{print $NF}')" +[ "$ACTUAL" = "$VERSION" ] \ + || { echo "error: XcodeGen binary is $ACTUAL, expected $VERSION" >&2; exit 1; } +mkdir -p "$(dirname "$DEST")" +mv "$TMP/unpacked/xcodegen" "$DEST" +echo "==> installed checksum-pinned XcodeGen $VERSION at $DEST" diff --git a/scripts/release-input.py b/scripts/release-input.py new file mode 100755 index 00000000..6b3ee065 --- /dev/null +++ b/scripts/release-input.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Read one scalar from the committed release input lock.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +LOCK = Path(__file__).with_name("release-inputs.json") + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {Path(sys.argv[0]).name} dotted.key", file=sys.stderr) + return 2 + value: object = json.loads(LOCK.read_text(encoding="utf-8")) + try: + for component in sys.argv[1].split("."): + if not isinstance(value, dict): + raise KeyError(component) + value = value[component] + except KeyError: + print(f"error: no release input named {sys.argv[1]!r}", file=sys.stderr) + return 1 + if not isinstance(value, (str, int, float)): + print(f"error: release input {sys.argv[1]!r} is not a scalar", file=sys.stderr) + return 1 + print(value) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release-inputs.json b/scripts/release-inputs.json new file mode 100644 index 00000000..ef1c6c1c --- /dev/null +++ b/scripts/release-inputs.json @@ -0,0 +1,28 @@ +{ + "formatVersion": 1, + "swiftPackages": [], + "frameworks": { + "sentry": { + "version": "9.24.0", + "url": "https://github.com/getsentry/sentry-cocoa/releases/download/9.24.0/Sentry.xcframework.zip", + "sha256": "c530edd27b20f7c151e73d84a34ee03474e3d5ddab65ffe9d30366f80149668a" + }, + "sparkle": { + "version": "2.9.4", + "url": "https://github.com/sparkle-project/Sparkle/releases/download/2.9.4/Sparkle-2.9.4.tar.xz", + "sha256": "ce89daf967db1e1893ed3ebd67575ed82d3902563e3191ca92aaec9164fbdef9" + } + }, + "tools": { + "xcodegen": { + "version": "2.46.0", + "url": "https://github.com/yonaskolb/XcodeGen/releases/download/2.46.0/xcodegen.zip", + "sha256": "4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806" + }, + "sentry-cli": { + "version": "3.6.2", + "url": "https://github.com/getsentry/sentry-cli/releases/download/3.6.2/sentry-cli-Darwin-universal", + "sha256": "d1339bc39b2c681496d70fc0cb5263a6f4ff93939de21b1f34cffb310643ef1c" + } + } +} diff --git a/scripts/release.sh b/scripts/release.sh index b7f87e64..07630905 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -9,7 +9,10 @@ set -euo pipefail cd "$(dirname "$0")/.." -command -v xcodegen >/dev/null 2>&1 || { echo "need xcodegen — brew install xcodegen"; exit 1; } +TOOLS_TMP="$(mktemp -d)" +trap 'rm -rf "$TOOLS_TMP"' EXIT +bash scripts/fetch-xcodegen.sh "$TOOLS_TMP/xcodegen" +XCODEGEN="$TOOLS_TMP/xcodegen/bin/xcodegen" echo "==> fetching vendored Sentry.xcframework" # Sentry is a local framework, not an SPM package (SPM's binary-artifact @@ -24,7 +27,7 @@ bash scripts/fetch-sparkle.sh echo "==> xcodegen generate" # The macOS app lives under macos/ (monorepo: macos/ + windows/). Generate the # project there; build artifacts still land at the repo root (build_dist/, dist/). -( cd macos && xcodegen generate >/dev/null ) +( cd macos && "$XCODEGEN" generate >/dev/null ) # Telemetry keys (optional). Sourced from the gitignored scripts/release.env so # secrets never hit the repo. Absent → an honest no-telemetry release: empty diff --git a/scripts/sentry_public_summary.py b/scripts/sentry_public_summary.py new file mode 100755 index 00000000..8724b589 --- /dev/null +++ b/scripts/sentry_public_summary.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Reduce Sentry issue/event JSON to fields approved for a public tracker.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +SLUG = re.compile(r"^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$") +SHORT_ID = re.compile(r"^(?=.{3,80}$)[A-Z0-9]+(?:-[A-Z0-9]+)+$") +ISSUE_ID = re.compile(r"^[0-9]{1,20}$") +BOUNDED_VALUE = re.compile(r"^[A-Za-z0-9._:+@-]{1,80}$") +TIMESTAMP = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]{8,32}Z?$") +LEVELS = {"debug", "info", "warning", "error", "fatal"} + + +def load_object(path: Path) -> dict[str, Any]: + if path.stat().st_size > 10 * 1024 * 1024: + raise ValueError(f"input is unexpectedly large: {path}") + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"input is not a JSON object: {path}") + return value + + +def bounded(value: Any, default: str = "unknown") -> str: + if isinstance(value, str) and BOUNDED_VALUE.fullmatch(value): + return value + return default + + +def timestamp(value: Any) -> str: + if isinstance(value, str) and TIMESTAMP.fullmatch(value): + return value + return "unknown" + + +def event_tag(event: dict[str, Any], key: str) -> str: + tags = event.get("tags") + if not isinstance(tags, list): + return "unknown" + for tag in tags[:200]: + if isinstance(tag, dict) and tag.get("key") == key: + return bounded(tag.get("value")) + return "unknown" + + +def release_value(event: dict[str, Any]) -> str: + release = event.get("release") + if isinstance(release, dict): + release = release.get("version") + return bounded(release) + + +def make_summary( + issue: dict[str, Any], event: dict[str, Any], org: str, project: str +) -> dict[str, Any]: + if not SLUG.fullmatch(org) or not SLUG.fullmatch(project): + raise ValueError("organization and project must be fixed Sentry slugs") + issue_id = issue.get("id") + short_id = issue.get("shortId") + if not isinstance(issue_id, str) or not ISSUE_ID.fullmatch(issue_id): + raise ValueError("Sentry issue has no safe numeric id") + if not isinstance(short_id, str) or not SHORT_ID.fullmatch(short_id): + raise ValueError("Sentry issue has no safe short id") + + count = issue.get("count") + if isinstance(count, int) and count >= 0: + public_count = str(count) + elif ( + isinstance(count, str) + and 1 <= len(count) <= 20 + and count.isascii() + and count.isdigit() + ): + public_count = str(int(count)) + else: + public_count = "unknown" + + level = issue.get("level") + public_level = level if isinstance(level, str) and level in LEVELS else "unknown" + issue_type = issue.get("issueType") if isinstance(issue.get("issueType"), str) else "" + title = issue.get("title") if isinstance(issue.get("title"), str) else "" + hang_probe = f"{issue_type} {title}".lower() + + return { + "shortId": short_id, + "sentryUrl": f"https://sentry.io/organizations/{org}/issues/{issue_id}/", + "project": project, + "level": public_level, + "count": public_count, + "release": release_value(event), + "osBuild": event_tag(event, "os_build"), + "launchPhase": event_tag(event, "launch_phase"), + "statusItem": event_tag(event, "status_item_state"), + "firstSeen": timestamp(issue.get("firstSeen")), + "lastSeen": timestamp(issue.get("lastSeen")), + "isAppHang": bool(re.search(r"app[ _-]?hang|hanging", hang_probe)), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--issue-file", required=True, type=Path) + parser.add_argument("--event-file", required=True, type=Path) + parser.add_argument("--org", required=True) + parser.add_argument("--project", required=True) + args = parser.parse_args() + try: + summary = make_summary( + load_object(args.issue_file), + load_object(args.event_file), + args.org, + args.project, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"error: refusing unsafe Sentry summary: {error}", file=sys.stderr) + return 1 + json.dump(summary, sys.stdout, sort_keys=True, separators=(",", ":")) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/fixtures/sentry-sensitive-event.json b/scripts/tests/fixtures/sentry-sensitive-event.json new file mode 100644 index 00000000..ffecd246 --- /dev/null +++ b/scripts/tests/fixtures/sentry-sensitive-event.json @@ -0,0 +1,34 @@ +{ + "release": { + "version": "dev.caezium.Burrow@0.11.2+23" + }, + "tags": [ + {"key": "os_build", "value": "25A123"}, + {"key": "launch_phase", "value": "launch_started"}, + {"key": "status_item_state", "value": "stable"}, + {"key": "username", "value": "alice"}, + {"key": "command_arguments", "value": "--upload /Users/alice/customer.txt"} + ], + "entries": [ + { + "type": "threads", + "data": { + "values": [ + { + "name": "alice-private-worker", + "stacktrace": { + "frames": [ + { + "function": "Burrow.run(secret: sk-live-SECRET)", + "filename": "/Users/alice/customer.txt", + "package": "/Users/alice/Downloads/Burrow.app", + "vars": {"argv": ["--token", "SECRET"]} + } + ] + } + } + ] + } + } + ] +} diff --git a/scripts/tests/fixtures/sentry-sensitive-issue.json b/scripts/tests/fixtures/sentry-sensitive-issue.json new file mode 100644 index 00000000..f43e4db9 --- /dev/null +++ b/scripts/tests/fixtures/sentry-sensitive-issue.json @@ -0,0 +1,12 @@ +{ + "id": "7639318005", + "shortId": "BURROW-WINDOWS-2", + "title": "sk-live-SECRET failed for alice in /Users/alice/customer.txt --token SECRET", + "culprit": "Burrow.run(args: [--upload, /Users/alice/customer.txt])", + "permalink": "https://evil.example/alice?token=SECRET", + "issueType": "error", + "level": "error", + "count": "3", + "firstSeen": "2026-07-29T02:45:36Z", + "lastSeen": "2026-07-29T03:03:34Z" +} diff --git a/scripts/tests/test_helper_version_sync.py b/scripts/tests/test_helper_version_sync.py index aab05e40..5343e793 100644 --- a/scripts/tests/test_helper_version_sync.py +++ b/scripts/tests/test_helper_version_sync.py @@ -1,20 +1,4 @@ -"""The app and the privileged helper must ship the same version. - -The helper reports its own build over XPC and the client refuses to use a -helper whose build doesn't match the app's — that check is deliberate, because -a registered daemon outlives the app that installed it and a stale root helper -is exactly the drift worth refusing. - -The cost of that design is a coupling: `macos/project.yml` spells the version -twice, once for the app target and once for the helper target, and Xcode has -no way to derive one from the other. Bump the app for a release and forget the -helper and nothing fails loudly — the helper simply stops being used, every -user silently falls back to the password-only prompt, and the feature dies -quietly. - -That failure is invisible in the build, invisible in the tests, and invisible -in the release artifact. So it gets caught here instead. -""" +"""The app and privileged helper inherit one version/build declaration.""" import re import unittest @@ -35,36 +19,22 @@ class HelperVersionSyncTests(unittest.TestCase): def test_project_file_exists(self) -> None: self.assertTrue(PROJECT.is_file(), f"missing {PROJECT}") - def test_build_number_matches_between_app_and_helper(self) -> None: - app = _scalar("CFBundleVersion") - helper = _scalar("CURRENT_PROJECT_VERSION") - - self.assertEqual(len(app), 1, "expected exactly one app CFBundleVersion") - self.assertEqual(len(helper), 1, "expected exactly one helper CURRENT_PROJECT_VERSION") - self.assertEqual( - app[0], - helper[0], - "app CFBundleVersion and helper CURRENT_PROJECT_VERSION must match, " - "or HelperVersionSkew refuses the helper at runtime and every user " - "silently falls back to the password prompt", - ) + def test_one_repository_wide_build_number(self) -> None: + self.assertEqual(len(_scalar("CURRENT_PROJECT_VERSION")), 1, + "app and helper must inherit one build-number declaration") - def test_marketing_version_matches_between_app_and_helper(self) -> None: - app = _scalar("CFBundleShortVersionString") - helper = _scalar("MARKETING_VERSION") + def test_one_repository_wide_marketing_version(self) -> None: + self.assertEqual(len(_scalar("MARKETING_VERSION")), 1, + "app and helper must inherit one marketing-version declaration") - self.assertEqual(len(app), 1, "expected exactly one app CFBundleShortVersionString") - self.assertEqual(len(helper), 1, "expected exactly one helper MARKETING_VERSION") - self.assertEqual( - app[0], - helper[0], - "app and helper marketing versions must match", - ) + def test_app_plist_references_shared_build_settings(self) -> None: + self.assertEqual(_scalar("CFBundleVersion"), ["$(CURRENT_PROJECT_VERSION)"]) + self.assertEqual(_scalar("CFBundleShortVersionString"), ["$(MARKETING_VERSION)"]) def test_versions_are_plausible(self) -> None: """Guards against the regex silently matching nothing useful.""" - build = _scalar("CFBundleVersion")[0] - marketing = _scalar("CFBundleShortVersionString")[0] + build = _scalar("CURRENT_PROJECT_VERSION")[0] + marketing = _scalar("MARKETING_VERSION")[0] self.assertTrue(build.isdigit(), f"build number should be an integer, got {build!r}") self.assertRegex(marketing, r"^\d+\.\d+(\.\d+)?$") diff --git a/scripts/tests/test_release_workflows.py b/scripts/tests/test_release_workflows.py index 555abac8..5d629f4e 100644 --- a/scripts/tests/test_release_workflows.py +++ b/scripts/tests/test_release_workflows.py @@ -1,4 +1,7 @@ import unittest +import json +import plistlib +import re from pathlib import Path @@ -7,6 +10,116 @@ class ReleaseWorkflowTests(unittest.TestCase): + def test_app_version_has_one_source_and_generated_metadata_is_checked(self) -> None: + project = (ROOT / "macos" / "project.yml").read_text(encoding="utf-8") + with (ROOT / "macos" / "Resources" / "Info.plist").open("rb") as stream: + info = plistlib.load(stream) + ci = (WORKFLOWS / "ci.yml").read_text(encoding="utf-8") + mcp = (ROOT / "macos" / "Sources" / "MCP.swift").read_text(encoding="utf-8") + + self.assertEqual( + len(re.findall(r'^\s+MARKETING_VERSION: "[0-9]+\.[0-9]+\.[0-9]+"$', project, re.MULTILINE)), + 1, + ) + self.assertEqual( + len(re.findall(r'^\s+CURRENT_PROJECT_VERSION: "[1-9][0-9]*"$', project, re.MULTILINE)), + 1, + ) + self.assertIn('CFBundleShortVersionString: "$(MARKETING_VERSION)"', project) + self.assertIn('CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"', project) + self.assertEqual(info["CFBundleShortVersionString"], "$(MARKETING_VERSION)") + self.assertEqual(info["CFBundleVersion"], "$(CURRENT_PROJECT_VERSION)") + self.assertIn("verify-project-generation.py", ci) + self.assertIn("--check-git", ci) + self.assertNotIn('"version": "0.3.0"', mcp) + + def test_release_sources_and_tools_are_content_locked(self) -> None: + lock = json.loads( + (ROOT / "scripts" / "release-inputs.json").read_text(encoding="utf-8") + ) + ci = (WORKFLOWS / "ci.yml").read_text(encoding="utf-8") + release = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + + self.assertEqual(lock["swiftPackages"], []) + for group in ("frameworks", "tools"): + for dependency in lock[group].values(): + self.assertRegex(dependency["version"], r"^[0-9]+\.[0-9]+\.[0-9]+$") + self.assertRegex(dependency["sha256"], r"^[0-9a-f]{64}$") + self.assertTrue(dependency["url"].startswith("https://github.com/")) + + self.assertNotIn("brew install xcodegen", ci) + self.assertNotIn("brew install xcodegen", release) + self.assertNotIn("brew install sentry-cli", release) + self.assertIn("fetch-xcodegen.sh", ci) + self.assertIn("fetch-xcodegen.sh", release) + self.assertIn("fetch-sentry-cli.sh", release) + + def test_exact_tag_runs_required_tests_before_release_build_and_publish(self) -> None: + workflow = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + + test_gate = workflow.index("- name: Test exact tagged commit") + release_build = workflow.index("- name: Build (Release)") + publish = workflow.index("- name: Publish verified GitHub release") + gate = workflow[test_gate:release_build] + + self.assertLess(test_gate, release_build) + self.assertLess(release_build, publish) + self.assertIn('git rev-parse HEAD', gate) + self.assertIn('"$GITHUB_SHA"', gate) + self.assertIn("verify-project-generation.py", gate) + self.assertIn("--check-git", gate) + self.assertIn("python3 -m unittest discover", gate) + self.assertIn("node --test scripts/tests/test_site_analytics.mjs", gate) + self.assertIn("xcodebuild test", gate) + + def test_symbols_are_required_and_match_the_distributed_binary(self) -> None: + workflow = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + + credentials = workflow.index("- name: Require release credentials") + upload = workflow.index("- name: Verify and upload release dSYM") + download = workflow.index("- name: Verify downloaded release artifact") + publish = workflow.index("- name: Publish verified GitHub release") + + self.assertIn("SENTRY_AUTH_TOKEN", workflow[credentials:upload]) + self.assertNotIn("skipped if no token", workflow) + self.assertIn("verify-dsym-uuids.sh", workflow[upload:publish]) + self.assertIn("debug-files check", workflow[upload:download]) + self.assertIn("debug-files upload", workflow[upload:download]) + self.assertLess(upload, download) + self.assertLess(download, publish) + + def test_release_stays_draft_until_downloaded_artifact_passes_trust_checks(self) -> None: + workflow = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + + upload = workflow.index("- name: Upload GitHub release draft") + verify = workflow.index("- name: Verify downloaded release artifact") + publish = workflow.index("- name: Publish verified GitHub release") + homebrew = workflow.index("- name: Bump Homebrew cask") + verification = workflow[verify:publish] + + self.assertLess(upload, verify) + self.assertLess(verify, publish) + self.assertLess(publish, homebrew) + self.assertIn('if [ "$IS_DRAFT" != "true" ]', workflow[upload:verify]) + self.assertIn("gh release download", verification) + self.assertIn('steps.pkg.outputs.sha', verification) + self.assertIn("verify-macos-release.sh", verification) + self.assertIn("verify-dsym-uuids.sh", verification) + self.assertIn('"$EXPECTED_TEAM_ID"', verification) + self.assertIn('gh release edit "$GITHUB_REF_NAME" --draft=false', workflow[publish:homebrew]) + + def test_macos_release_verifier_pins_the_designated_requirement(self) -> None: + verifier = (ROOT / "scripts" / "verify-macos-release.sh").read_text( + encoding="utf-8" + ) + + self.assertIn("codesign --verify --deep --strict", verifier) + self.assertIn("codesign -d -r-", verifier) + self.assertIn("anchor apple generic", verifier) + self.assertIn("subject\\.OU", verifier) + self.assertIn("xcrun stapler validate", verifier) + self.assertIn("spctl --assess --type execute", verifier) + def test_tap_permission_check_runs_before_the_release_build(self) -> None: workflow = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") diff --git a/scripts/tests/test_sentry_issues_workflow.py b/scripts/tests/test_sentry_issues_workflow.py index 9b2cdd0c..6d932746 100644 --- a/scripts/tests/test_sentry_issues_workflow.py +++ b/scripts/tests/test_sentry_issues_workflow.py @@ -1,12 +1,65 @@ import unittest +import json +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[2] WORKFLOW = ROOT / ".github" / "workflows" / "sentry-issues.yml" +SUMMARY = ROOT / "scripts" / "sentry_public_summary.py" +FIXTURES = ROOT / "scripts" / "tests" / "fixtures" class SentryIssuesWorkflowTests(unittest.TestCase): + def test_sensitive_event_fixture_emits_only_reviewed_public_fields(self) -> None: + result = subprocess.run( + [ + "python3", + str(SUMMARY), + "--issue-file", + str(FIXTURES / "sentry-sensitive-issue.json"), + "--event-file", + str(FIXTURES / "sentry-sensitive-event.json"), + "--org", + "henry-zhang-r7", + "--project", + "burrow-windows", + ], + check=True, + capture_output=True, + text=True, + ) + summary = json.loads(result.stdout) + serialized = json.dumps(summary, sort_keys=True) + + self.assertEqual(summary["shortId"], "BURROW-WINDOWS-2") + self.assertEqual(summary["release"], "dev.caezium.Burrow@0.11.2+23") + self.assertEqual( + summary["sentryUrl"], + "https://sentry.io/organizations/henry-zhang-r7/issues/7639318005/", + ) + for sensitive in ( + "sk-live-SECRET", + "alice", + "/Users/", + "customer.txt", + "--upload", + "argv", + "evil.example", + "frames", + ): + self.assertNotIn(sensitive, serialized) + + def test_workflow_never_copies_raw_sentry_payloads_to_public_issues(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("scripts/sentry_public_summary.py", workflow) + self.assertIn("Detailed diagnostics stay in restricted Sentry", workflow) + self.assertNotIn("TRACE_JQ", workflow) + self.assertNotIn("latest stack trace", workflow) + self.assertNotIn("Stack trace (most recent event)", workflow) + self.assertNotIn("${title}", workflow) + def test_app_hangs_are_aggregated_instead_of_silently_skipped(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") @@ -19,8 +72,8 @@ def test_hang_digests_are_bounded_and_roll_into_numbered_parts(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") self.assertIn('MAX_HANG_GROUPS_PER_RUN: "20"', workflow) - self.assertIn('MAX_HANG_TRACE_CHARS: "1200"', workflow) self.assertIn('MAX_ISSUE_BODY_BYTES: "60000"', workflow) + self.assertNotIn("MAX_HANG_TRACE_CHARS", workflow) self.assertIn('digest_title="${digest_base_title} — part ${digest_part}"', workflow) self.assertIn("existing_bytes + section_bytes", workflow) @@ -34,12 +87,12 @@ def test_sentry_issue_poll_follows_cursor_pagination(self) -> None: self.assertIn('done < "$response_rows"', workflow) def test_hang_digest_carries_bounded_release_and_launch_context(self) -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") + summary_filter = SUMMARY.read_text(encoding="utf-8") - self.assertIn('select(.key=="os_build")', workflow) - self.assertIn('select(.key=="launch_phase")', workflow) - self.assertIn('select(.key=="status_item_state")', workflow) - self.assertIn(".release.version", workflow) + self.assertIn('event_tag(event, "os_build")', summary_filter) + self.assertIn('event_tag(event, "launch_phase")', summary_filter) + self.assertIn('event_tag(event, "status_item_state")', summary_filter) + self.assertIn("release.get(\"version\")", summary_filter) def test_regular_issues_carry_the_same_launch_context(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") @@ -50,10 +103,10 @@ def test_regular_issues_carry_the_same_launch_context(self) -> None: self.assertIn(r'| **Status item** | \`${statusItem}\` |', workflow) def test_release_shape_is_checked_before_reading_its_version(self) -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") + summary_filter = SUMMARY.read_text(encoding="utf-8") - self.assertIn('if (.release | type) == "object"', workflow) - self.assertIn('elif (.release | type) == "string"', workflow) + self.assertIn("if isinstance(release, dict)", summary_filter) + self.assertIn("return bounded(release)", summary_filter) if __name__ == "__main__": diff --git a/scripts/tests/test_validate_release_notes.py b/scripts/tests/test_validate_release_notes.py index 4306cf8b..968b32ab 100644 --- a/scripts/tests/test_validate_release_notes.py +++ b/scripts/tests/test_validate_release_notes.py @@ -1,4 +1,4 @@ -import plistlib +import re import subprocess import tempfile import unittest @@ -52,8 +52,14 @@ def test_rejects_accumulated_top_level_release_sections(self) -> None: self.assertIn("exactly one top-level heading", result.stderr) def test_checked_in_release_notes_are_safe_for_sparkle(self) -> None: - with (ROOT / "macos" / "Resources" / "Info.plist").open("rb") as stream: - version = plistlib.load(stream)["CFBundleShortVersionString"] + project = (ROOT / "macos" / "project.yml").read_text(encoding="utf-8") + versions = re.findall( + r'^\s+MARKETING_VERSION: "([0-9]+\.[0-9]+\.[0-9]+)"$', + project, + re.MULTILINE, + ) + self.assertEqual(len(versions), 1) + version = versions[0] result = subprocess.run( [ "python3", diff --git a/scripts/verify-dsym-uuids.sh b/scripts/verify-dsym-uuids.sh new file mode 100755 index 00000000..ec84e497 --- /dev/null +++ b/scripts/verify-dsym-uuids.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Prove that a release app binary and its dSYM describe exactly the same slices. +set -euo pipefail + +[ "$#" -eq 2 ] \ + || { echo "usage: $0 " >&2; exit 2; } + +APP="$1" +DSYM="$2" +BINARY="$APP/Contents/MacOS/Burrow" +DEBUG_BINARY="$DSYM/Contents/Resources/DWARF/Burrow" + +[ -f "$BINARY" ] \ + || { echo "error: release binary is missing: $BINARY" >&2; exit 1; } +[ -f "$DEBUG_BINARY" ] \ + || { echo "error: release dSYM is missing its DWARF binary: $DEBUG_BINARY" >&2; exit 1; } + +uuids() { + dwarfdump --uuid "$1" \ + | awk '/^UUID: / { print toupper($2) " " $3 }' \ + | LC_ALL=C sort -u +} + +APP_UUIDS="$(uuids "$BINARY")" +DSYM_UUIDS="$(uuids "$DEBUG_BINARY")" +[ -n "$APP_UUIDS" ] \ + || { echo "error: release binary has no Mach-O UUID" >&2; exit 1; } +[ -n "$DSYM_UUIDS" ] \ + || { echo "error: release dSYM has no UUID" >&2; exit 1; } + +if [ "$APP_UUIDS" != "$DSYM_UUIDS" ]; then + echo "error: release binary and dSYM UUIDs differ" >&2 + printf 'app:\n%s\ndSYM:\n%s\n' "$APP_UUIDS" "$DSYM_UUIDS" >&2 + exit 1 +fi + +printf 'release binary/dSYM UUIDs match:\n%s\n' "$APP_UUIDS" diff --git a/scripts/verify-macos-release.sh b/scripts/verify-macos-release.sh new file mode 100755 index 00000000..e8492eed --- /dev/null +++ b/scripts/verify-macos-release.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Fail closed unless an extracted Burrow release preserves its distribution identity. +set -euo pipefail + +[ "$#" -eq 4 ] \ + || { echo "usage: $0 " >&2; exit 2; } + +APP="$1" +EXPECTED_TEAM="$2" +EXPECTED_VERSION="$3" +EXPECTED_BUILD="$4" +EXPECTED_IDENTIFIER="dev.caezium.Burrow" + +[ -d "$APP" ] || { echo "error: app bundle is missing: $APP" >&2; exit 1; } +[[ "$EXPECTED_TEAM" =~ ^[A-Z0-9]{10}$ ]] \ + || { echo "error: expected Developer ID team is invalid" >&2; exit 2; } + +codesign --verify --deep --strict --verbose=2 "$APP" +DETAILS="$(codesign -d --verbose=4 "$APP" 2>&1)" +grep -qxF "Identifier=$EXPECTED_IDENTIFIER" <<< "$DETAILS" \ + || { echo "error: release bundle identifier changed" >&2; exit 1; } +grep -qxF "TeamIdentifier=$EXPECTED_TEAM" <<< "$DETAILS" \ + || { echo "error: release Developer ID team changed" >&2; exit 1; } +grep -q '^Authority=Developer ID Application:' <<< "$DETAILS" \ + || { echo "error: release is not signed with Developer ID Application" >&2; exit 1; } +grep -q '^Runtime Version=' <<< "$DETAILS" \ + || { echo "error: release is missing hardened runtime" >&2; exit 1; } +grep -q '^Timestamp=' <<< "$DETAILS" \ + || { echo "error: release is missing a secure timestamp" >&2; exit 1; } + +REQUIREMENT="$(codesign -d -r- "$APP" 2>&1)" +grep -Fq "identifier \"$EXPECTED_IDENTIFIER\"" <<< "$REQUIREMENT" \ + || { echo "error: designated requirement has the wrong identifier" >&2; exit 1; } +grep -Fq "anchor apple generic" <<< "$REQUIREMENT" \ + || { echo "error: designated requirement is not anchored to Apple" >&2; exit 1; } +grep -Eq 'certificate leaf\[subject\.OU\] = "?'"$EXPECTED_TEAM"'"?' <<< "$REQUIREMENT" \ + || { echo "error: designated requirement does not pin the expected team" >&2; exit 1; } + +INFO="$APP/Contents/Info.plist" +actual_plist_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "$INFO" +} +[ "$(actual_plist_value CFBundleIdentifier)" = "$EXPECTED_IDENTIFIER" ] \ + || { echo "error: Info.plist bundle identifier changed" >&2; exit 1; } +[ "$(actual_plist_value CFBundleShortVersionString)" = "$EXPECTED_VERSION" ] \ + || { echo "error: downloaded app version does not match the tag build" >&2; exit 1; } +[ "$(actual_plist_value CFBundleVersion)" = "$EXPECTED_BUILD" ] \ + || { echo "error: downloaded app build does not match the tag build" >&2; exit 1; } + +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=4 "$APP" +echo "Verified notarized Developer ID release identity for $EXPECTED_IDENTIFIER $EXPECTED_VERSION ($EXPECTED_BUILD), team $EXPECTED_TEAM." diff --git a/scripts/verify-project-generation.py b/scripts/verify-project-generation.py new file mode 100755 index 00000000..4b522fd4 --- /dev/null +++ b/scripts/verify-project-generation.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Prove XcodeGen output is deterministic and tracked metadata is current.""" + +from __future__ import annotations + +import argparse +import hashlib +import plistlib +import re +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MACOS = ROOT / "macos" +GENERATED = ( + MACOS / "Resources" / "Info.plist", + MACOS / "Tests" / "Info.plist", + MACOS / "Burrow.xcodeproj" / "project.pbxproj", + MACOS + / "Burrow.xcodeproj" + / "xcshareddata" + / "xcschemes" + / "Burrow.xcscheme", +) +TRACKED_METADATA = ( + "macos/Resources/Info.plist", + "macos/Tests/Info.plist", +) + + +def digest_generated_files() -> dict[str, str]: + missing = [str(path.relative_to(ROOT)) for path in GENERATED if not path.is_file()] + if missing: + raise RuntimeError(f"XcodeGen did not produce: {', '.join(missing)}") + return { + str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in GENERATED + } + + +def generate(xcodegen: Path) -> None: + subprocess.run([str(xcodegen), "generate"], cwd=MACOS, check=True) + + +def verify_version_contract() -> None: + project = (MACOS / "project.yml").read_text(encoding="utf-8") + marketing_versions = re.findall( + r'^\s+MARKETING_VERSION: "([0-9]+\.[0-9]+\.[0-9]+)"$', project, re.MULTILINE + ) + build_numbers = re.findall( + r'^\s+CURRENT_PROJECT_VERSION: "([1-9][0-9]*)"$', project, re.MULTILINE + ) + if len(marketing_versions) != 1: + raise RuntimeError("project.yml must declare the marketing version exactly once") + if len(build_numbers) != 1: + raise RuntimeError("project.yml must declare the build number exactly once") + + with (MACOS / "Resources" / "Info.plist").open("rb") as stream: + info = plistlib.load(stream) + expected = { + "CFBundleShortVersionString": "$(MARKETING_VERSION)", + "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", + } + for key, value in expected.items(): + if info.get(key) != value: + raise RuntimeError(f"generated Info.plist has drifted: {key}={info.get(key)!r}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--xcodegen", required=True, type=Path) + parser.add_argument( + "--check-git", + action="store_true", + help="also fail when generation changes tracked plist metadata", + ) + args = parser.parse_args() + + if not args.xcodegen.is_file(): + parser.error(f"xcodegen not found: {args.xcodegen}") + + try: + generate(args.xcodegen) + first = digest_generated_files() + generate(args.xcodegen) + second = digest_generated_files() + if first != second: + changed = sorted(path for path in first if first[path] != second[path]) + raise RuntimeError( + "XcodeGen output changed across identical runs: " + ", ".join(changed) + ) + verify_version_contract() + if args.check_git: + subprocess.run( + ["git", "diff", "--exit-code", "--", *TRACKED_METADATA], + cwd=ROOT, + check=True, + ) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + print("XcodeGen output is deterministic and version metadata is aligned.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows/BURROW_WINDOWS_ALIGNMENT.md b/windows/BURROW_WINDOWS_ALIGNMENT.md index 526525a3..e57270a7 100644 --- a/windows/BURROW_WINDOWS_ALIGNMENT.md +++ b/windows/BURROW_WINDOWS_ALIGNMENT.md @@ -40,7 +40,7 @@ Current Windows-specific adaptations: - History and Activity are persisted locally and surfaced in the GUI and MCP/HTTP paths. Mole command executions, Windows uninstall actions, and BurrowWin native fallback preview/removal flows now write into the same operation history. - Mole command history summaries are normalized before storage so GUI Activity, History, tray HUD, HTTP, and MCP surfaces do not show ANSI terminal escapes or CLI icon placeholders. - History now renders Burrow-style trend cards for CPU, memory, disk, and network, has selectable 5m/1h/6h/24h/7d/30d/90d ranges, and shows a Top CPU Processes table backed by the same filtered telemetry history. -- MCP is local-only: HTTP binds to loopback and stdio uses the published bridge executable. The REST surface exposes `/health`, `/info`, `/snapshot`, and `/metrics`; disabling REST keeps `/mcp` available for the stdio bridge. +- MCP is local-only and authenticated: HTTP binds to loopback, requires the per-install bearer credential plus an exact localhost Host, rejects browser Origin/fetch metadata, and bounds methods, bodies, and request rate. The published stdio bridge reads the user-private credential from Settings automatically. The REST surface exposes `/health`, `/info`, `/snapshot`, and `/metrics`; disabling REST keeps only the authenticated `/mcp` route available for the stdio bridge. - MCP now includes the upstream read-only tool shape: `burrow_snapshot`, `burrow_history`, `burrow_top_processes`, `burrow_process_usage`, `burrow_info`, `burrow_list_apps`, `burrow_purge`, and `burrow_installer`. Windows process usage can rank by `peak_cpu`, `avg_cpu`, `cpu_time`, `peak_mem`, or `avg_mem` using locally recorded telemetry, while purge and installer MCP calls remain preview-only. - GUI startup now activates the main WinUI window before hosted background services finish, records startup phases to `%LOCALAPPDATA%\BurrowWin\startup.log`, and treats tray/background failures as diagnostics instead of blocking the first visible window. - The Windows tray now provides a Burrow icon with live tooltip text, a left-click Burrow-style HUD window, a right-click status menu, quick navigation to Status, History, Activity, Clean, Optimize, and Settings, and a safe Exit Burrow command. @@ -80,7 +80,7 @@ Current Windows-specific adaptations: - `dotnet .\Tools\McpStdioBridge\bin\Debug\net8.0\burrow-mcp-stdio.dll` responds to `tools/list` and exposes the `metric` input on `burrow_top_processes`. - History chart, time-range, and Top CPU Process UI changes compile through WinUI XAML generation in the x64 Debug build. Range resolution, read-limit estimation, and sample filtering are covered by unit tests. - Tray HUD/menu changes compile through the x64 Debug build; tray menu and tray HUD status formatter coverage are included in the test suite. -- Runtime smoke verified the x64 Debug app starts, `http://127.0.0.1:9277/health` responds, `/snapshot` returns live Windows telemetry, `/metrics?limit=2` returns recorded samples, and `Assets\Mcp\burrow-mcp-stdio.exe` can call `burrow_snapshot`, `burrow_info`, `burrow_top_processes`, and `burrow_process_usage`. +- Runtime smoke verified the x64 Debug app starts, authenticated requests to `http://127.0.0.1:9277/health`, `/snapshot`, and `/metrics?limit=2` return live local telemetry, and `Assets\Mcp\burrow-mcp-stdio.exe` reads the per-install credential and can call `burrow_snapshot`, `burrow_info`, `burrow_top_processes`, and `burrow_process_usage`. - Runtime smoke after the tray HUD/menu work launched `bin\x64\Debug\...\BurrowWin.exe` and confirmed `/health` returned `ok: true`, engine availability, and a fresh `latest_sample_at`. - Latest tray HUD screenshot smoke launched `bin\x64\Debug\...\BurrowWin.exe` with `BURROWWIN_SHOW_TRAY_HUD=1`, captured `artifacts\burrowwin-tray-hud-smoke.png`, and visually confirmed the HUD window, status cards, activity card, top CPU process rows, and quick navigation buttons render without clipping. - `.\scripts\build-release.ps1` restores, builds Release x64, runs the current test suite, publishes the portable WinUI payload, creates `Burrow-v0.1.0-preview.1-win-x64-setup.exe`, creates `Burrow-v0.1.0-preview.1-win-x64.zip`, writes `SHA256SUMS.txt`, writes WinGet manifests, and copies release docs into the payload. diff --git a/windows/Models/BurrowSettings.cs b/windows/Models/BurrowSettings.cs index 50c43604..36a6f569 100644 --- a/windows/Models/BurrowSettings.cs +++ b/windows/Models/BurrowSettings.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; + namespace BurrowWin.Models; public sealed class BurrowSettings @@ -14,6 +16,11 @@ public sealed class BurrowSettings public int HttpServerPort { get; set; } = DefaultHttpServerPort; + /// Per-install bearer credential for the loopback HTTP/MCP surface. The + /// stdio bridge reads the same user-private settings file and sends it in + /// an Authorization header; browsers and unrelated callers do not know it. + public string HttpServerAuthToken { get; set; } = CreateHttpServerAuthToken(); + public bool TrayIconEnabled { get; set; } = true; public bool McpDestructiveActionsEnabled { get; set; } @@ -31,9 +38,26 @@ public static BurrowSettings Normalize(BurrowSettings? settings) HistoryRetentionDays = Math.Clamp(settings.HistoryRetentionDays, 1, 365), HttpServerEnabled = settings.HttpServerEnabled, HttpServerPort = Math.Clamp(settings.HttpServerPort, 1024, 65535), + HttpServerAuthToken = IsValidHttpServerAuthToken(settings.HttpServerAuthToken) + ? settings.HttpServerAuthToken + : CreateHttpServerAuthToken(), TrayIconEnabled = settings.TrayIconEnabled, McpDestructiveActionsEnabled = settings.McpDestructiveActionsEnabled, TelemetryEnabled = settings.TelemetryEnabled }; } + + internal static string CreateHttpServerAuthToken() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + internal static bool IsValidHttpServerAuthToken(string? token) + { + return token is { Length: >= 43 } && token.All(character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); + } } diff --git a/windows/Services/JsonApplicationSettingsService.cs b/windows/Services/JsonApplicationSettingsService.cs index f1ecea06..922c0b25 100644 --- a/windows/Services/JsonApplicationSettingsService.cs +++ b/windows/Services/JsonApplicationSettingsService.cs @@ -23,7 +23,12 @@ public JsonApplicationSettingsService() public JsonApplicationSettingsService(string settingsFilePath) { SettingsFilePath = settingsFilePath; - Current = ReadFromDisk(); + var (settings, shouldPersist) = ReadFromDisk(); + Current = settings; + if (shouldPersist) + { + TryWriteToDisk(settings); + } } public string SettingsFilePath { get; } @@ -36,6 +41,10 @@ public async Task SaveAsync( BurrowSettings settings, CancellationToken cancellationToken = default) { + // Settings callers commonly construct a fresh model. Preserve the + // per-install credential across ordinary saves instead of silently + // rotating it and breaking a running stdio bridge. + settings.HttpServerAuthToken = Current.HttpServerAuthToken; var normalized = BurrowSettings.Normalize(settings); var directory = Path.GetDirectoryName(SettingsFilePath); if (!string.IsNullOrWhiteSpace(directory)) @@ -57,7 +66,11 @@ public async Task SaveAsync( public BurrowSettings Reload() { - var settings = ReadFromDisk(); + var (settings, shouldPersist) = ReadFromDisk(); + if (shouldPersist) + { + TryWriteToDisk(settings); + } lock (_sync) { Current = settings; @@ -67,21 +80,52 @@ public BurrowSettings Reload() return settings; } - private BurrowSettings ReadFromDisk() + private (BurrowSettings Settings, bool ShouldPersist) ReadFromDisk() { if (!File.Exists(SettingsFilePath)) { - return BurrowSettings.Normalize(null); + return (BurrowSettings.Normalize(null), true); } try { var json = File.ReadAllText(SettingsFilePath); - return BurrowSettings.Normalize(JsonSerializer.Deserialize(json, SerializerOptions)); + using var document = JsonDocument.Parse(json); + var hasPersistedCredential = document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("HttpServerAuthToken", out var credentialElement) + && credentialElement.ValueKind == JsonValueKind.String + && BurrowSettings.IsValidHttpServerAuthToken(credentialElement.GetString()); + var decoded = JsonSerializer.Deserialize(json, SerializerOptions); + return (BurrowSettings.Normalize(decoded), !hasPersistedCredential); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { - return BurrowSettings.Normalize(null); + // Do not overwrite a malformed or inaccessible user file. The app + // can run with safe defaults, and a later explicit Save repairs it. + return (BurrowSettings.Normalize(null), false); + } + } + + private void WriteToDisk(BurrowSettings settings) + { + var directory = Path.GetDirectoryName(SettingsFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + File.WriteAllText(SettingsFilePath, JsonSerializer.Serialize(settings, SerializerOptions)); + } + + private void TryWriteToDisk(BurrowSettings settings) + { + try + { + WriteToDisk(settings); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The listener remains fail-closed with its in-memory credential; + // an explicit Save can surface and repair the storage problem. } } } diff --git a/windows/Services/LocalMcpServerService.cs b/windows/Services/LocalMcpServerService.cs index 2ff2387a..49b46a77 100644 --- a/windows/Services/LocalMcpServerService.cs +++ b/windows/Services/LocalMcpServerService.cs @@ -1,5 +1,6 @@ using System.Net; using System.Diagnostics; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -8,10 +9,58 @@ namespace BurrowWin.Services; +internal enum LocalRequestDecision +{ + Allowed, + BadRequest, + Unauthorized, + Forbidden, + MethodNotAllowed, + PayloadTooLarge, + UnsupportedMediaType, + RateLimited +} + +internal sealed class LocalRequestRateLimiter +{ + private readonly int _limit; + private readonly TimeSpan _window; + private readonly Queue _acceptedAt = new(); + private readonly object _sync = new(); + + public LocalRequestRateLimiter(int limit = 120, TimeSpan? window = null) + { + _limit = Math.Max(1, limit); + _window = window is { } configured && configured > TimeSpan.Zero + ? configured + : TimeSpan.FromMinutes(1); + } + + public bool Allow(DateTimeOffset? at = null) + { + lock (_sync) + { + var now = at ?? DateTimeOffset.UtcNow; + var cutoff = now - _window; + while (_acceptedAt.TryPeek(out var accepted) && accepted <= cutoff) + { + _acceptedAt.Dequeue(); + } + if (_acceptedAt.Count >= _limit) + { + return false; + } + _acceptedAt.Enqueue(now); + return true; + } + } +} + public sealed class LocalMcpServerService : BackgroundService { public const int DefaultPort = 9277; private const string ProtocolVersion = "2025-11-25"; + private const long MaxRequestBodyBytes = 64 * 1024; private readonly IMoleEngineService _moleEngineService; private readonly IDiskAnalyzerService _diskAnalyzerService; @@ -22,6 +71,7 @@ public sealed class LocalMcpServerService : BackgroundService private readonly IInstallerCleanupService _installerCleanupService; private readonly IOperationHistoryService _operationHistoryService; private readonly IApplicationSettingsService _settingsService; + private readonly LocalRequestRateLimiter _requestRateLimiter = new(); private readonly SemaphoreSlim _listenerGate = new(1, 1); private HttpListener? _listener; private Task? _listenerTask; @@ -203,14 +253,48 @@ private async Task ListenAsync(HttpListener listener, CancellationToken stopping private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) { - if (!IsRequestAllowed(context.Request.RemoteEndPoint?.Address, context.Request.Headers["Origin"])) - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; + var path = context.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? string.Empty; + var decision = EvaluateRequest( + context.Request.RemoteEndPoint?.Address, + context.Request.UserHostName, + context.Request.Headers["Origin"] ?? context.Request.Headers["Referer"], + context.Request.Headers["Sec-Fetch-Site"], + context.Request.Headers["Authorization"], + _settingsService.Current.HttpServerAuthToken, + _activePort, + path, + context.Request.HttpMethod, + context.Request.ContentType, + context.Request.ContentLength64); + if (decision == LocalRequestDecision.Allowed && !_requestRateLimiter.Allow()) + { + decision = LocalRequestDecision.RateLimited; + } + if (decision != LocalRequestDecision.Allowed) + { + context.Response.StatusCode = decision switch + { + LocalRequestDecision.BadRequest => StatusCodes.Status400BadRequest, + LocalRequestDecision.Unauthorized => StatusCodes.Status401Unauthorized, + LocalRequestDecision.Forbidden => StatusCodes.Status403Forbidden, + LocalRequestDecision.MethodNotAllowed => StatusCodes.Status405MethodNotAllowed, + LocalRequestDecision.PayloadTooLarge => StatusCodes.Status413PayloadTooLarge, + LocalRequestDecision.UnsupportedMediaType => StatusCodes.Status415UnsupportedMediaType, + LocalRequestDecision.RateLimited => StatusCodes.Status429TooManyRequests, + _ => StatusCodes.Status400BadRequest + }; + if (decision == LocalRequestDecision.Unauthorized) + { + context.Response.Headers["WWW-Authenticate"] = "Bearer"; + } + if (decision == LocalRequestDecision.RateLimited) + { + context.Response.Headers["Retry-After"] = "60"; + } context.Response.Close(); return; } - var path = context.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? string.Empty; var response = ShouldBlockRestEndpoint(_settingsService.Current.HttpServerEnabled, path, context.Request.HttpMethod) ? new JsonObject { @@ -1294,9 +1378,56 @@ private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode response.Close(); } - internal static bool IsRequestAllowed(IPAddress? remoteAddress, string? origin) + internal static LocalRequestDecision EvaluateRequest( + IPAddress? remoteAddress, + string? host, + string? origin, + string? secFetchSite, + string? authorization, + string expectedToken, + int expectedPort, + string path, + string method, + string? contentType, + long contentLength) { - return IsLoopback(remoteAddress) && IsAllowedOrigin(origin); + if (!IsLoopback(remoteAddress) || !IsAllowedHost(host, expectedPort) || origin is not null || secFetchSite is not null) + { + return LocalRequestDecision.Forbidden; + } + if (!HasValidBearerCredential(authorization, expectedToken)) + { + return LocalRequestDecision.Unauthorized; + } + if (contentLength > MaxRequestBodyBytes) + { + return LocalRequestDecision.PayloadTooLarge; + } + + var isGet = method.Equals("GET", StringComparison.OrdinalIgnoreCase); + var isPost = method.Equals("POST", StringComparison.OrdinalIgnoreCase); + var postRoute = path is "/mcp" or "/tools/call"; + if ((!isGet && !isPost) || (isPost && !postRoute) || (isGet && postRoute)) + { + return LocalRequestDecision.MethodNotAllowed; + } + if (isGet && contentLength != 0) + { + return LocalRequestDecision.BadRequest; + } + if (isPost) + { + if (contentLength < 0) + { + return LocalRequestDecision.BadRequest; + } + var mediaType = contentType?.Split(';', 2)[0].Trim(); + if (!string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase)) + { + return LocalRequestDecision.UnsupportedMediaType; + } + } + return LocalRequestDecision.Allowed; } internal static bool ShouldBlockRestEndpoint(bool httpRestEnabled, string path, string method) @@ -1314,26 +1445,37 @@ private static bool IsLoopback(IPAddress? address) return address is not null && IPAddress.IsLoopback(address); } - private static bool IsAllowedOrigin(string? origin) + private static bool IsAllowedHost(string? host, int expectedPort) { - if (string.IsNullOrWhiteSpace(origin)) + return string.Equals(host, $"127.0.0.1:{expectedPort}", StringComparison.OrdinalIgnoreCase) + || string.Equals(host, $"localhost:{expectedPort}", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasValidBearerCredential(string? authorization, string expectedToken) + { + if (string.IsNullOrWhiteSpace(authorization) || string.IsNullOrWhiteSpace(expectedToken)) { - return true; + return false; } - - if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) + var pieces = authorization.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (pieces.Length != 2 || !pieces[0].Equals("Bearer", StringComparison.OrdinalIgnoreCase)) { return false; } - - return uri.IsLoopback && - (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || - uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)); + var supplied = Encoding.UTF8.GetBytes(pieces[1]); + var expected = Encoding.UTF8.GetBytes(expectedToken); + return supplied.Length == expected.Length && CryptographicOperations.FixedTimeEquals(supplied, expected); } private static class StatusCodes { public const int Status200OK = 200; + public const int Status400BadRequest = 400; + public const int Status401Unauthorized = 401; public const int Status403Forbidden = 403; + public const int Status405MethodNotAllowed = 405; + public const int Status413PayloadTooLarge = 413; + public const int Status415UnsupportedMediaType = 415; + public const int Status429TooManyRequests = 429; } } diff --git a/windows/Tests/BurrowWin.Tests/JsonApplicationSettingsServiceTests.cs b/windows/Tests/BurrowWin.Tests/JsonApplicationSettingsServiceTests.cs index 98293251..8d4769f3 100644 --- a/windows/Tests/BurrowWin.Tests/JsonApplicationSettingsServiceTests.cs +++ b/windows/Tests/BurrowWin.Tests/JsonApplicationSettingsServiceTests.cs @@ -26,6 +26,12 @@ public void Constructor_UsesNormalizedDefaults_WhenFileIsMissing() Assert.True(service.Current.HttpServerEnabled); Assert.True(service.Current.TrayIconEnabled); Assert.False(service.Current.McpDestructiveActionsEnabled); + Assert.True(service.Current.HttpServerAuthToken.Length >= 43); + Assert.True(File.Exists(_settingsPath), + "first-run credentials must be persisted for the stdio bridge"); + + var reloaded = new JsonApplicationSettingsService(_settingsPath); + Assert.Equal(service.Current.HttpServerAuthToken, reloaded.Current.HttpServerAuthToken); } [Fact] @@ -41,6 +47,7 @@ public async Task SaveAsync_NormalizesPersistsAndRaisesChangedEvent() HistoryRetentionDays = 1000, HttpServerEnabled = false, HttpServerPort = 10, + HttpServerAuthToken = service.Current.HttpServerAuthToken, TrayIconEnabled = false, McpDestructiveActionsEnabled = true }); @@ -57,9 +64,24 @@ public async Task SaveAsync_NormalizesPersistsAndRaisesChangedEvent() Assert.Equal(saved.SamplingIntervalSeconds, reloaded.Current.SamplingIntervalSeconds); Assert.Equal(saved.HistoryRetentionDays, reloaded.Current.HistoryRetentionDays); Assert.Equal(saved.HttpServerPort, reloaded.Current.HttpServerPort); + Assert.Equal(saved.HttpServerAuthToken, reloaded.Current.HttpServerAuthToken); Assert.False(reloaded.Current.HttpServerEnabled); } + [Fact] + public void Constructor_MigratesMissingCredentialWithoutChangingOtherSettings() + { + File.WriteAllText(_settingsPath, + "{\"SamplingIntervalSeconds\":42,\"HttpServerPort\":9444,\"HttpServerAuthToken\":\"\"}"); + + var service = new JsonApplicationSettingsService(_settingsPath); + + Assert.Equal(42, service.Current.SamplingIntervalSeconds); + Assert.Equal(9444, service.Current.HttpServerPort); + Assert.True(service.Current.HttpServerAuthToken.Length >= 43); + Assert.Contains(service.Current.HttpServerAuthToken, File.ReadAllText(_settingsPath)); + } + public void Dispose() { if (Directory.Exists(_tempRoot)) diff --git a/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs b/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs index 5302606b..719d5268 100644 --- a/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs +++ b/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs @@ -10,15 +10,50 @@ namespace BurrowWin.Tests; public sealed class LocalMcpServerServiceTests { [Fact] - public void RequestGate_AllowsOnlyLoopbackAndLocalOrigins() + public void RequestGate_RequiresCredentialAndRejectsHostOrBrowserRequests() { - Assert.True(LocalMcpServerService.IsRequestAllowed(IPAddress.Loopback, null)); - Assert.True(LocalMcpServerService.IsRequestAllowed(IPAddress.IPv6Loopback, "http://localhost:3000")); - Assert.True(LocalMcpServerService.IsRequestAllowed(IPAddress.Loopback, "http://127.0.0.1:9277")); + const string token = "test-query-token"; + Assert.Equal(LocalRequestDecision.Allowed, EvaluateRequest(token)); + Assert.Equal(LocalRequestDecision.Unauthorized, EvaluateRequest(token, includeCredential: false)); + Assert.Equal(LocalRequestDecision.Unauthorized, EvaluateRequest(token, authorization: "Bearer wrong")); + Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, host: "attacker.example")); + Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, host: null)); + Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, origin: "http://localhost:3000")); + Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, origin: "https://example.com")); + Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, secFetchSite: "cross-site")); + Assert.Equal(LocalRequestDecision.Forbidden, + EvaluateRequest(token, remoteAddress: IPAddress.Parse("192.168.1.10"))); + } + + [Fact] + public void RequestGate_ConstrainsMethodsContentTypesAndPayloadSize() + { + const string token = "test-query-token"; + Assert.Equal(LocalRequestDecision.Allowed, + EvaluateRequest(token, path: "/mcp", method: "POST", + contentType: "application/json; charset=utf-8", contentLength: 512)); + Assert.Equal(LocalRequestDecision.MethodNotAllowed, + EvaluateRequest(token, path: "/health", method: "POST", + contentType: "application/json", contentLength: 2)); + Assert.Equal(LocalRequestDecision.UnsupportedMediaType, + EvaluateRequest(token, path: "/mcp", method: "POST", + contentType: "text/plain", contentLength: 2)); + Assert.Equal(LocalRequestDecision.PayloadTooLarge, + EvaluateRequest(token, path: "/mcp", method: "POST", + contentType: "application/json", contentLength: 64 * 1024 + 1)); + Assert.Equal(LocalRequestDecision.BadRequest, + EvaluateRequest(token, path: "/health", method: "GET", contentLength: 1)); + } - Assert.False(LocalMcpServerService.IsRequestAllowed(IPAddress.Parse("192.168.1.10"), null)); - Assert.False(LocalMcpServerService.IsRequestAllowed(IPAddress.Loopback, "https://example.com")); - Assert.False(LocalMcpServerService.IsRequestAllowed(IPAddress.Loopback, "not a uri")); + [Fact] + public void RequestRateLimiter_RejectsBurstPastLimitAndRecovers() + { + var limiter = new LocalRequestRateLimiter(limit: 2, window: TimeSpan.FromSeconds(60)); + var start = DateTimeOffset.Parse("2026-08-08T00:00:00Z"); + Assert.True(limiter.Allow(start)); + Assert.True(limiter.Allow(start.AddSeconds(1))); + Assert.False(limiter.Allow(start.AddSeconds(2))); + Assert.True(limiter.Allow(start.AddSeconds(61))); } [Fact] @@ -156,6 +191,37 @@ private static LocalMcpServerService BuildService( settingsService ?? new FakeApplicationSettingsService()); } + private static LocalRequestDecision EvaluateRequest( + string expectedToken, + IPAddress? remoteAddress = null, + string? host = "127.0.0.1:9277", + string? origin = null, + string? secFetchSite = null, + bool includeCredential = true, + string? authorization = null, + string path = "/health", + string method = "GET", + string? contentType = null, + long contentLength = 0) + { + if (includeCredential && authorization is null) + { + authorization = $"Bearer {expectedToken}"; + } + return LocalMcpServerService.EvaluateRequest( + remoteAddress ?? IPAddress.Loopback, + host, + origin, + secFetchSite, + authorization, + expectedToken, + LocalMcpServerService.DefaultPort, + path, + method, + contentType, + contentLength); + } + private static async Task ExecuteToolAsync( LocalMcpServerService service, string name, diff --git a/windows/Tools/McpStdioBridge/Program.cs b/windows/Tools/McpStdioBridge/Program.cs index 165d8f70..adeed5a0 100644 --- a/windows/Tools/McpStdioBridge/Program.cs +++ b/windows/Tools/McpStdioBridge/Program.cs @@ -17,16 +17,27 @@ internal static class Program private static async Task Main() { + var stored = ReadConnectionSettings(); var endpoint = Environment.GetEnvironmentVariable("BURROWWIN_MCP_ENDPOINT"); if (string.IsNullOrWhiteSpace(endpoint)) { - endpoint = ReadEndpointFromSettings() ?? DefaultEndpoint; + endpoint = stored.Endpoint ?? DefaultEndpoint; + } + var authToken = Environment.GetEnvironmentVariable("BURROWWIN_MCP_TOKEN"); + if (string.IsNullOrWhiteSpace(authToken)) + { + authToken = stored.AuthToken; } using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + if (!string.IsNullOrWhiteSpace(authToken)) + { + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", authToken); + } string? line; while ((line = await Console.In.ReadLineAsync().ConfigureAwait(false)) is not null) @@ -48,7 +59,7 @@ private static async Task Main() return 0; } - private static string? ReadEndpointFromSettings() + private static (string? Endpoint, string? AuthToken) ReadConnectionSettings() { var path = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -57,7 +68,7 @@ private static async Task Main() if (!File.Exists(path)) { - return null; + return (null, null); } try @@ -65,11 +76,12 @@ private static async Task Main() var root = JsonNode.Parse(File.ReadAllText(path))?.AsObject(); var port = root?["HttpServerPort"]?.GetValue() ?? 9277; port = Math.Clamp(port, 1024, 65535); - return $"http://127.0.0.1:{port}/mcp"; + var authToken = root?["HttpServerAuthToken"]?.GetValue(); + return ($"http://127.0.0.1:{port}/mcp", authToken); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException) { - return null; + return (null, null); } } diff --git a/windows/ViewModels/SettingsViewModel.cs b/windows/ViewModels/SettingsViewModel.cs index 4a20fe79..cae2fecc 100644 --- a/windows/ViewModels/SettingsViewModel.cs +++ b/windows/ViewModels/SettingsViewModel.cs @@ -106,6 +106,7 @@ public async Task SaveSettingsAsync() HistoryRetentionDays = ParseInt(HistoryRetentionDays, current.HistoryRetentionDays), HttpServerEnabled = HttpServerEnabled, HttpServerPort = ParseInt(HttpServerPort, current.HttpServerPort), + HttpServerAuthToken = current.HttpServerAuthToken, TrayIconEnabled = TrayIconEnabled, McpDestructiveActionsEnabled = McpDestructiveActionsEnabled, TelemetryEnabled = TelemetryEnabled @@ -145,8 +146,8 @@ private void ApplySettings(BurrowSettings settings) McpDestructiveActionsEnabled = settings.McpDestructiveActionsEnabled; TelemetryEnabled = settings.TelemetryEnabled; McpEndpoint = settings.HttpServerEnabled - ? $"REST + MCP on http://127.0.0.1:{settings.HttpServerPort}" - : $"REST disabled; local MCP bridge remains on http://127.0.0.1:{settings.HttpServerPort}/mcp"; + ? $"Bearer-authenticated REST + MCP on http://127.0.0.1:{settings.HttpServerPort}" + : $"REST disabled; authenticated local MCP bridge remains on http://127.0.0.1:{settings.HttpServerPort}/mcp"; } private static int ParseInt(string value, int fallback) diff --git a/windows/run-local.ps1 b/windows/run-local.ps1 index 1fdacd28..36a1a488 100644 --- a/windows/run-local.ps1 +++ b/windows/run-local.ps1 @@ -23,6 +23,7 @@ $root = Split-Path -Parent $MyInvocation.MyCommand.Path $project = Join-Path $root "BurrowWin.csproj" $exe = Join-Path $root "bin\x64\Debug\net8.0-windows10.0.26100.0\win-x64\BurrowWin.exe" $startupLog = Join-Path $env:LOCALAPPDATA "BurrowWin\startup.log" +$settingsPath = Join-Path $env:LOCALAPPDATA "BurrowWin\settings.json" function Stop-ExistingBurrow { param([string]$ExpectedPath) @@ -357,7 +358,10 @@ try { if ($null -eq $health) { try { - $health = Invoke-RestMethod -Uri "http://127.0.0.1:9277/health" -TimeoutSec 2 + $settings = Get-Content -Raw -Path $settingsPath | ConvertFrom-Json + $port = [Math]::Min(65535, [Math]::Max(1024, [int]$settings.HttpServerPort)) + $headers = @{ Authorization = "Bearer $($settings.HttpServerAuthToken)" } + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$port/health" -Headers $headers -TimeoutSec 2 } catch { $health = $null } From 6e04dbeb2fa7a39c215356f7e999027bb2b0a1ca Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:05:35 +0800 Subject: [PATCH 02/11] fix: bind helper operations to invoking user --- macos/HelperSources/HelperService.swift | 140 +++++++++++++-- .../PrivilegedHelper/HelperContract.swift | 156 +++++++++++++++- macos/Sources/PrivilegedHelperClient.swift | 28 ++- macos/Tests/HelperContractTests.swift | 168 +++++++++++++++++- macos/vendor/burrow-engine | 2 +- 5 files changed, 462 insertions(+), 32 deletions(-) diff --git a/macos/HelperSources/HelperService.swift b/macos/HelperSources/HelperService.swift index df9a004f..9b0184bb 100644 --- a/macos/HelperSources/HelperService.swift +++ b/macos/HelperSources/HelperService.swift @@ -30,6 +30,7 @@ // import Foundation +import Darwin import Security import os @@ -84,6 +85,85 @@ func helperTrace(_ message: String) { try? helperTraceHandle.write(contentsOf: Data("[\(stamp)] \(message)\n".utf8)) } +// MARK: - Invoking identity + +/// Reconstructs the invoking account entirely inside the daemon. The request's +/// uid/home are comparison values only; getpwuid_r and descriptor-backed stat +/// facts are the authority used to build the child environment. +enum HelperDaemonIdentityResolver { + static func resolve(peerUID: uid_t, + claim: HelperInvokingUserClaim) throws -> HelperResolvedInvokingUser { + let account = try account(for: peerUID) + return try HelperInvokingUserResolver.resolve( + peerUID: UInt32(peerUID), + claim: claim, + accounts: [account], + inspectHome: inspectHome) + } + + private static func account(for uid: uid_t) throws -> HelperInvokingUserAccount { + var record = passwd() + var result: UnsafeMutablePointer? + let configured = sysconf(_SC_GETPW_R_SIZE_MAX) + let capacity = configured > 0 ? Int(configured) : 16_384 + var buffer = [CChar](repeating: 0, count: capacity) + let status = buffer.withUnsafeMutableBufferPointer { bytes in + getpwuid_r(uid, &record, bytes.baseAddress, bytes.count, &result) + } + guard status == 0, result != nil, + let name = record.pw_name, let home = record.pw_dir else { + throw HelperInvokingUserResolutionError.missingAccount + } + return HelperInvokingUserAccount(uid: UInt32(record.pw_uid), + username: String(cString: name), + homeDirectory: String(cString: home)) + } + + private static func inspectHome(_ rawPath: String) -> HelperHomeInspection { + var before = stat() + guard lstat(rawPath, &before) == 0 else { + return HelperHomeInspection(kind: .missing, canonicalPath: nil, ownerUID: nil) + } + switch before.st_mode & S_IFMT { + case S_IFLNK: + return HelperHomeInspection(kind: .symbolicLink, canonicalPath: nil, + ownerUID: UInt32(before.st_uid)) + case S_IFDIR: + break + default: + return HelperHomeInspection(kind: .other, canonicalPath: nil, + ownerUID: UInt32(before.st_uid)) + } + + guard let firstCanonical = canonicalPath(rawPath) else { + return HelperHomeInspection(kind: .missing, canonicalPath: nil, ownerUID: nil) + } + let descriptor = Darwin.open(rawPath, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { + return HelperHomeInspection(kind: .missing, canonicalPath: nil, ownerUID: nil) + } + defer { Darwin.close(descriptor) } + + var opened = stat() + guard fstat(descriptor, &opened) == 0, + (opened.st_mode & S_IFMT) == S_IFDIR, + opened.st_dev == before.st_dev, + opened.st_ino == before.st_ino, + canonicalPath(rawPath) == firstCanonical else { + return HelperHomeInspection(kind: .other, canonicalPath: nil, ownerUID: nil) + } + return HelperHomeInspection(kind: .directory, + canonicalPath: firstCanonical, + ownerUID: UInt32(opened.st_uid)) + } + + private static func canonicalPath(_ path: String) -> String? { + guard let resolved = realpath(path, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } +} + // MARK: - Engine resolution enum HelperEngine { @@ -195,13 +275,18 @@ final class HelperOperationRunner: @unchecked Sendable { func run(operation: HelperOperation, operationID: String, interface: String?, - enginePath: String, + enginePath: String?, + invokingUser: HelperResolvedInvokingUser, emit: @escaping (String) -> Void) -> Int32 { var last: Int32 = 0 for step in operation.steps(interface: interface) { let path: String switch step.executable { case .bundledEngine: + guard let enginePath else { + helperTrace("refused: bundled engine step has no verified engine") + return 127 + } path = enginePath case .system(let systemPath): // Re-check against the closed set at the moment of use, not @@ -215,7 +300,8 @@ final class HelperOperationRunner: @unchecked Sendable { path = systemPath } last = runOne(path: path, arguments: step.arguments, - operationID: operationID, emit: emit) + operationID: operationID, environment: invokingUser.childEnvironment, + emit: emit) guard last == 0 else { break } } return last @@ -224,6 +310,7 @@ final class HelperOperationRunner: @unchecked Sendable { private func runOne(path: String, arguments: [String], operationID: String, + environment: [String: String], emit: @escaping (String) -> Void) -> Int32 { let process = Process() process.executableURL = URL(fileURLWithPath: path) @@ -236,11 +323,7 @@ final class HelperOperationRunner: @unchecked Sendable { // inherited from the launchd context that could redirect a lookup // (PATH, DYLD_*, the engine's own overrides) is dropped rather than // passed through. - process.environment = [ - "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", - "HOME": NSHomeDirectory(), - "LC_ALL": "C", - ] + process.environment = environment let outPipe = Pipe(), errPipe = Pipe() process.standardOutput = outPipe @@ -324,10 +407,6 @@ final class HelperService: NSObject, BurrowHelperProtocol { private let runner = HelperOperationRunner() private let teamID: String? - /// The client callback for the connection currently being served. Set by - /// the listener delegate per connection. - weak var currentConnection: NSXPCConnection? - init(teamID: String?) { self.teamID = teamID super.init() @@ -381,6 +460,14 @@ final class HelperService: NSObject, BurrowHelperProtocol { reply(encoded) } + // Capture the connection for THIS invocation. A listener-wide mutable + // `currentConnection` lets a second client race the first and receive + // its output; Foundation binds current() to the dispatching XPC call. + guard let connection = NSXPCConnection.current() else { + helperTrace("request refused: no current XPC connection") + return respond(.rejected(.invalidInvokingUser)) + } + // Gate 2 — shape. An unknown operation cannot survive decoding. guard let request = try? JSONDecoder().decode(HelperRequest.self, from: requestData) else { helperTrace("request refused: malformed payload") @@ -396,6 +483,19 @@ final class HelperService: NSObject, BurrowHelperProtocol { return respond(.rejected(rejection)) } + let invokingUser: HelperResolvedInvokingUser + do { + invokingUser = try HelperDaemonIdentityResolver.resolve( + peerUID: connection.effectiveUserIdentifier, + claim: request.invokingUser) + } catch { + // Numeric uid is useful for auditing account-switch/mismatch + // failures. Never log the account name, home, or claim text. + helperTrace("request refused: invoking identity mismatch for uid \(connection.effectiveUserIdentifier)") + return respond(.rejected(.invalidInvokingUser)) + } + helperTrace("invoking identity accepted for uid \(invokingUser.uid); canonical home matched") + // Gate 3 — freshness. One authorization buys exactly one operation, so // a captured payload cannot be replayed for a second root run. guard replayGuard.admit(request.operationID) else { @@ -418,23 +518,28 @@ final class HelperService: NSObject, BurrowHelperProtocol { // Gate 5 — execution. Our own signed engine, or a system tool from the // closed set; fixed argv either way. - guard let enginePath = HelperEngine.bundledEnginePath() else { - helperTrace("engine unavailable: no bundled engine at Contents/Resources/engine/mole") - return respond(.engineUnavailable) - } guard HelperEngine.verifyContainingBundle(teamID: teamID) else { helperTrace("engine unavailable: containing app bundle failed signature verification") return respond(.engineUnavailable) } + var enginePath: String? + if request.operation.engineArguments != nil { + guard let verifiedPath = HelperEngine.bundledEnginePath() else { + helperTrace("engine unavailable: no bundled engine at Contents/Resources/engine/mole") + return respond(.engineUnavailable) + } + enginePath = verifiedPath + } helperTrace("running \(request.operation.rawValue) (mutating: \(request.operation.mutatesDisk))") - let client = currentConnection?.remoteObjectProxy as? BurrowHelperClientProtocol + let client = connection.remoteObjectProxy as? BurrowHelperClientProtocol let operationID = request.operationID let code = runner.run(operation: request.operation, operationID: operationID, interface: request.networkInterface, - enginePath: enginePath) { line in + enginePath: enginePath, + invokingUser: invokingUser) { line in client?.helperDidEmit(line: line, operationID: operationID) } helperTrace("operation finished with status \(code)") @@ -461,7 +566,6 @@ final class HelperListenerDelegate: NSObject, NSXPCListenerDelegate { connection.exportedInterface = HelperInterface.daemon() connection.exportedObject = service connection.remoteObjectInterface = HelperInterface.client() - service.currentConnection = connection connection.resume() helperTrace("connection accepted from a verified Burrow client") return true diff --git a/macos/Sources/PrivilegedHelper/HelperContract.swift b/macos/Sources/PrivilegedHelper/HelperContract.swift index 25968c35..c08b40e9 100644 --- a/macos/Sources/PrivilegedHelper/HelperContract.swift +++ b/macos/Sources/PrivilegedHelper/HelperContract.swift @@ -169,6 +169,130 @@ struct HelperStep: Equatable, Sendable { let arguments: [String] } +// MARK: - Invoking user + +/// The non-privileged app's statement of who initiated the operation. This is +/// a consistency claim, never authority: the daemon binds it to the XPC +/// peer's effective uid and reconstructs the account from its own user +/// database before using either value. +struct HelperInvokingUserClaim: Codable, Equatable, Sendable { + let uid: UInt32 + let canonicalHome: String +} + +/// One account record from the daemon's user database. Kept as data so the +/// selection rule can be tested with several signed-in/local accounts without +/// consulting the test runner's real account. +struct HelperInvokingUserAccount: Equatable, Sendable { + let uid: UInt32 + let username: String + let homeDirectory: String +} + +/// The daemon's descriptor-based inspection of the home named by getpwuid. +/// A path supplied by the client never creates this value. +struct HelperHomeInspection: Equatable, Sendable { + enum Kind: Equatable, Sendable { + case directory + case symbolicLink + case other + case missing + } + + let kind: Kind + let canonicalPath: String? + let ownerUID: UInt32? +} + +struct HelperResolvedInvokingUser: Equatable, Sendable { + let uid: UInt32 + let username: String + let canonicalHome: String + + /// A complete, deterministic environment for every root child. Nothing is + /// inherited from launchd and no client-provided string is copied here. + var childEnvironment: [String: String] { + [ + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": canonicalHome, + "USER": username, + "LOGNAME": username, + "SUDO_USER": username, + "SUDO_UID": String(uid), + "LC_ALL": "C", + ] + } +} + +enum HelperInvokingUserResolutionError: Error, Equatable, Sendable { + case rootPeer + case claimUIDMismatch + case missingAccount + case invalidUsername + case invalidAccountHome + case missingHome + case symbolicLinkHome + case homeNotDirectory + case homeOwnerMismatch + case canonicalHomeMismatch +} + +/// The fail-closed identity rule shared by the app tests and daemon. The +/// daemon supplies getpwuid data and a no-follow filesystem inspection; this +/// function decides whether those authoritative facts agree with the XPC peer +/// and the app's pre-authorization claim. +enum HelperInvokingUserResolver { + static func resolve( + peerUID: UInt32, + claim: HelperInvokingUserClaim, + accounts: [HelperInvokingUserAccount], + inspectHome: (String) -> HelperHomeInspection + ) throws -> HelperResolvedInvokingUser { + guard peerUID != 0 else { throw HelperInvokingUserResolutionError.rootPeer } + guard claim.uid == peerUID else { throw HelperInvokingUserResolutionError.claimUIDMismatch } + guard let account = accounts.first(where: { $0.uid == peerUID }) else { + throw HelperInvokingUserResolutionError.missingAccount + } + guard isSafeEnvironmentValue(account.username), account.username != "root" else { + throw HelperInvokingUserResolutionError.invalidUsername + } + guard account.homeDirectory.hasPrefix("/"), + isSafeEnvironmentValue(account.homeDirectory) else { + throw HelperInvokingUserResolutionError.invalidAccountHome + } + + let home = inspectHome(account.homeDirectory) + switch home.kind { + case .missing: throw HelperInvokingUserResolutionError.missingHome + case .symbolicLink: throw HelperInvokingUserResolutionError.symbolicLinkHome + case .other: throw HelperInvokingUserResolutionError.homeNotDirectory + case .directory: break + } + guard let canonicalHome = home.canonicalPath, + canonicalHome.hasPrefix("/"), canonicalHome != "/", + canonicalHome != "/var/root", canonicalHome != "/private/var/root", + isSafeEnvironmentValue(canonicalHome) else { + throw HelperInvokingUserResolutionError.invalidAccountHome + } + guard home.ownerUID == peerUID else { + throw HelperInvokingUserResolutionError.homeOwnerMismatch + } + guard claim.canonicalHome == canonicalHome else { + throw HelperInvokingUserResolutionError.canonicalHomeMismatch + } + + return HelperResolvedInvokingUser(uid: peerUID, + username: account.username, + canonicalHome: canonicalHome) + } + + private static func isSafeEnvironmentValue(_ value: String) -> Bool { + !value.isEmpty && !value.unicodeScalars.contains { + CharacterSet.controlCharacters.contains($0) + } + } +} + // MARK: - Request /// Why a request never reached the authorization step. Named so the GUI can @@ -186,10 +310,13 @@ enum HelperRequestRejection: String, Codable, Equatable, Sendable { /// The interface name was missing, malformed, or not a real interface on /// this machine — or was supplied for an operation that takes none. case invalidInterface + /// The invoking-user claim was malformed or did not match the XPC peer, + /// daemon account database, and inspected home directory. + case invalidInvokingUser } -/// One privileged operation, fully described. Three fields, none of which can -/// carry a command. +/// One privileged operation, fully described. None of its fields can carry a +/// command; the identity fields are consistency claims checked by the daemon. struct HelperRequest: Codable, Equatable, Sendable { let operation: HelperOperation @@ -206,6 +333,11 @@ struct HelperRequest: Codable, Equatable, Sendable { /// of what `clean` does is exactly the drift worth refusing. let clientBuild: String + /// The uid and canonical home resolved by the app before authentication. + /// This is mandatory context for every privileged action, but only a + /// claim: the daemon independently resolves and verifies both values. + let invokingUser: HelperInvokingUserClaim + /// The network interface for `renewDHCP`, and nil for everything else. /// /// This is the ONLY caller-supplied value that reaches a child process's @@ -214,6 +346,18 @@ struct HelperRequest: Codable, Equatable, Sendable { /// A name that isn't a real interface is refused rather than passed on. var networkInterface: String? = nil + init(operation: HelperOperation, + operationID: String, + clientBuild: String, + invokingUser: HelperInvokingUserClaim, + networkInterface: String? = nil) { + self.operation = operation + self.operationID = operationID + self.clientBuild = clientBuild + self.invokingUser = invokingUser + self.networkInterface = networkInterface + } + /// `nil` when the request is well formed. Runs on the PRIVILEGED side — /// the client's own validation is a courtesy, this one is the boundary. /// @@ -225,6 +369,14 @@ struct HelperRequest: Codable, Equatable, Sendable { guard HelperVersionSkew.evaluate(appBuild: expectedBuild, helperBuild: clientBuild) == .matched else { return .buildMismatch } + guard invokingUser.uid != 0, + invokingUser.canonicalHome.hasPrefix("/"), + invokingUser.canonicalHome != "/", + invokingUser.canonicalHome != "/var/root", + invokingUser.canonicalHome != "/private/var/root", + !invokingUser.canonicalHome.unicodeScalars.contains(where: { + CharacterSet.controlCharacters.contains($0) + }) else { return .invalidInvokingUser } if operation.needsInterface { guard let name = networkInterface, diff --git a/macos/Sources/PrivilegedHelperClient.swift b/macos/Sources/PrivilegedHelperClient.swift index 9b616703..d7d9afd8 100644 --- a/macos/Sources/PrivilegedHelperClient.swift +++ b/macos/Sources/PrivilegedHelperClient.swift @@ -233,7 +233,19 @@ final class PrivilegedHelperClient: @unchecked Sendable { /// takes at the authentication prompt plus as long as the operation runs. func run(operation: HelperOperation, interface: String? = nil, + invokingUser suppliedIdentity: InvokingUserIdentity? = nil, onLine: @escaping (String) -> Void) -> ElevatedOutcome { + // Resolve while still running as the caller and before showing an auth + // prompt. The daemon treats this as a claim and independently binds it + // to the XPC peer's effective uid and getpwuid record. + let invokingUser: InvokingUserIdentity + do { + invokingUser = try suppliedIdentity ?? InvokingUserIdentity.current() + } catch { + helperClientLog.notice("invoking identity unavailable; privileged request refused locally") + return .launchFailed + } + // Ask the user to authenticate. This is the prompt — raised here, in a // real session, so SecurityAgent can offer Touch ID. The daemon then // verifies the resulting reference without prompting. @@ -258,7 +270,7 @@ final class PrivilegedHelperClient: @unchecked Sendable { // not a guarantee. return withExtendedLifetime(granted) { send(payload: granted.externalForm, operation: operation, - interface: interface, onLine: onLine) + interface: interface, invokingUser: invokingUser, onLine: onLine) } } @@ -266,10 +278,13 @@ final class PrivilegedHelperClient: @unchecked Sendable { /// transcript rather than a live stream (reading the Login Items dump). /// /// Blocking — call off the main thread. - func capture(operation: HelperOperation, interface: String? = nil) -> (outcome: ElevatedOutcome, output: String) { + func capture(operation: HelperOperation, + interface: String? = nil, + invokingUser: InvokingUserIdentity? = nil) -> (outcome: ElevatedOutcome, output: String) { var lines: [String] = [] let lock = NSLock() - let outcome = run(operation: operation, interface: interface) { line in + let outcome = run(operation: operation, interface: interface, + invokingUser: invokingUser) { line in lock.lock(); lines.append(line); lock.unlock() } lock.lock(); let joined = lines.joined(separator: "\n"); lock.unlock() @@ -288,10 +303,14 @@ final class PrivilegedHelperClient: @unchecked Sendable { private func send(payload authorization: Data, operation: HelperOperation, interface: String?, + invokingUser: InvokingUserIdentity, onLine: @escaping (String) -> Void) -> ElevatedOutcome { let request = HelperRequest(operation: operation, operationID: UUID().uuidString, clientBuild: Self.appBuild, + invokingUser: HelperInvokingUserClaim( + uid: UInt32(invokingUser.uid), + canonicalHome: invokingUser.canonicalHome), networkInterface: interface) guard let payload = try? JSONEncoder().encode(request) else { return .launchFailed } @@ -355,7 +374,8 @@ struct HelperAwareProcessPort: ProcessPort { switch client.route(for: spec.arguments) { case .helper(let operation): var sawOutput = false - let outcome = client.run(operation: operation) { line in + let outcome = client.run(operation: operation, + invokingUser: spec.invokingUser) { line in sawOutput = true continuation.yield(.line(line)) } diff --git a/macos/Tests/HelperContractTests.swift b/macos/Tests/HelperContractTests.swift index 9298956f..d9fd7074 100644 --- a/macos/Tests/HelperContractTests.swift +++ b/macos/Tests/HelperContractTests.swift @@ -25,6 +25,136 @@ import XCTest final class HelperContractTests: XCTestCase { + private var validInvokingUserClaim: HelperInvokingUserClaim { + HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/test") + } + + // MARK: - Invoking identity + + func testInvokingUserResolver_bindsTheClaimToThePeerUIDAndDaemonAccount() throws { + let claim = HelperInvokingUserClaim(uid: 502, canonicalHome: "/Users/Jane Doe") + let accounts = [ + HelperInvokingUserAccount(uid: 501, username: "other", homeDirectory: "/Users/other"), + HelperInvokingUserAccount(uid: 502, username: "jane doe", homeDirectory: "/Users/Jane Doe"), + ] + + let resolved = try HelperInvokingUserResolver.resolve( + peerUID: 502, + claim: claim, + accounts: accounts, + inspectHome: { path in + XCTAssertEqual(path, "/Users/Jane Doe") + return HelperHomeInspection(kind: .directory, + canonicalPath: "/Users/Jane Doe", + ownerUID: 502) + }) + + XCTAssertEqual(resolved.uid, 502) + XCTAssertEqual(resolved.username, "jane doe") + XCTAssertEqual(resolved.canonicalHome, "/Users/Jane Doe") + XCTAssertEqual(resolved.childEnvironment["HOME"], "/Users/Jane Doe") + XCTAssertEqual(resolved.childEnvironment["USER"], "jane doe") + XCTAssertEqual(resolved.childEnvironment["LOGNAME"], "jane doe") + XCTAssertEqual(resolved.childEnvironment["SUDO_USER"], "jane doe") + XCTAssertEqual(resolved.childEnvironment["SUDO_UID"], "502") + XCTAssertFalse(resolved.childEnvironment.values.contains("/var/root")) + } + + func testRequestCarriesAnExplicitInvokingUserClaimAndRejectsItsAbsence() throws { + let claim = HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/henry") + let request = HelperRequest(operation: .clean, + operationID: UUID().uuidString, + clientBuild: "24", + invokingUser: claim) + + let roundTripped = try JSONDecoder().decode( + HelperRequest.self, from: JSONEncoder().encode(request)) + XCTAssertEqual(roundTripped.invokingUser, claim) + + let missingClaim = #"{"operation":"clean","operationID":"00000000-0000-0000-0000-000000000001","clientBuild":"24"}"# + XCTAssertThrowsError(try JSONDecoder().decode(HelperRequest.self, + from: Data(missingClaim.utf8))) + } + + func testInvokingUserResolver_refusesRootAndAClaimForAnotherPeer() { + let account = HelperInvokingUserAccount(uid: 501, username: "user", + homeDirectory: "/Users/user") + let inspection = HelperHomeInspection(kind: .directory, + canonicalPath: "/Users/user", ownerUID: 501) + + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 0, + claim: HelperInvokingUserClaim(uid: 0, canonicalHome: "/var/root"), + accounts: [], inspectHome: { _ in inspection })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .rootPeer) + } + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, + claim: HelperInvokingUserClaim(uid: 502, canonicalHome: "/Users/user"), + accounts: [account], inspectHome: { _ in inspection })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .claimUIDMismatch) + } + } + + func testInvokingUserResolver_refusesMissingOrSymlinkedHomes() { + let account = HelperInvokingUserAccount(uid: 501, username: "user", + homeDirectory: "/Users/user") + let claim = HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/user") + + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, claim: claim, accounts: [account], + inspectHome: { _ in HelperHomeInspection(kind: .missing, + canonicalPath: nil, ownerUID: nil) })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .missingHome) + } + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, claim: claim, accounts: [account], + inspectHome: { _ in HelperHomeInspection(kind: .symbolicLink, + canonicalPath: nil, ownerUID: 501) })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .symbolicLinkHome) + } + } + + func testInvokingUserResolver_refusesWrongOwnerAndCanonicalHomeMismatch() { + let account = HelperInvokingUserAccount(uid: 501, username: "user", + homeDirectory: "/Users/user") + let claim = HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/user") + + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, claim: claim, accounts: [account], + inspectHome: { _ in HelperHomeInspection(kind: .directory, + canonicalPath: "/Users/user", ownerUID: 502) })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .homeOwnerMismatch) + } + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, claim: claim, accounts: [account], + inspectHome: { _ in HelperHomeInspection(kind: .directory, + canonicalPath: "/Users/renamed", ownerUID: 501) })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .canonicalHomeMismatch) + } + } + + func testInvokingUserResolver_refusesMissingAccountAndRootHome() { + let claim = HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/user") + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, claim: claim, accounts: [], + inspectHome: { _ in HelperHomeInspection(kind: .directory, + canonicalPath: "/Users/user", ownerUID: 501) })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .missingAccount) + } + + let rootHomeAccount = HelperInvokingUserAccount(uid: 501, username: "user", + homeDirectory: "/var/root") + XCTAssertThrowsError(try HelperInvokingUserResolver.resolve( + peerUID: 501, + claim: HelperInvokingUserClaim(uid: 501, canonicalHome: "/private/var/root"), + accounts: [rootHomeAccount], + inspectHome: { _ in HelperHomeInspection(kind: .directory, + canonicalPath: "/private/var/root", ownerUID: 501) })) { error in + XCTAssertEqual(error as? HelperInvokingUserResolutionError, .invalidAccountHome) + } + } + // MARK: - The closed operation set // // The approved scope is exactly: privileged scan, clean, optimize. Not @@ -124,7 +254,8 @@ final class HelperContractTests: XCTestCase { func testValidate_renewDHCPRequiresARealInterface() { func request(_ name: String?) -> HelperRequest { HelperRequest(operation: .renewDHCP, operationID: UUID().uuidString, - clientBuild: "23", networkInterface: name) + clientBuild: "23", invokingUser: validInvokingUserClaim, + networkInterface: name) } // Well-formed AND present on the machine. XCTAssertNil(request("en0").validate(expectedBuild: "23", liveInterfaces: ["en0", "lo0"])) @@ -145,7 +276,8 @@ final class HelperContractTests: XCTestCase { for operation in [HelperOperation.clean, .optimize, .scan, .optimizeScan, .flushDNS, .readLoginItems] { let request = HelperRequest(operation: operation, operationID: UUID().uuidString, - clientBuild: "23", networkInterface: "en0") + clientBuild: "23", invokingUser: validInvokingUserClaim, + networkInterface: "en0") XCTAssertEqual(request.validate(expectedBuild: "23", liveInterfaces: ["en0"]), .invalidInterface, "\(operation) takes no interface") } @@ -193,7 +325,8 @@ final class HelperContractTests: XCTestCase { func testDecoding_roundTripsEveryOperation() throws { for op in HelperOperation.allCases { - let request = HelperRequest(operation: op, operationID: UUID().uuidString, clientBuild: "23") + let request = HelperRequest(operation: op, operationID: UUID().uuidString, + clientBuild: "23", invokingUser: validInvokingUserClaim) let data = try JSONEncoder().encode(request) XCTAssertEqual(try JSONDecoder().decode(HelperRequest.self, from: data), request) } @@ -202,16 +335,34 @@ final class HelperContractTests: XCTestCase { // MARK: - Validation (named rejections, never a partial run) func testValidate_acceptsAWellFormedRequest() { - let request = HelperRequest(operation: .clean, operationID: UUID().uuidString, clientBuild: "23") + let request = HelperRequest(operation: .clean, operationID: UUID().uuidString, + clientBuild: "23", invokingUser: validInvokingUserClaim) XCTAssertNil(request.validate(expectedBuild: "23")) } + func testValidate_rejectsMalformedInvokingUserClaims() { + let claims = [ + HelperInvokingUserClaim(uid: 0, canonicalHome: "/var/root"), + HelperInvokingUserClaim(uid: 501, canonicalHome: "/private/var/root"), + HelperInvokingUserClaim(uid: 501, canonicalHome: "Users/test"), + HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/test\nother"), + ] + for claim in claims { + let request = HelperRequest(operation: .clean, + operationID: UUID().uuidString, + clientBuild: "23", + invokingUser: claim) + XCTAssertEqual(request.validate(expectedBuild: "23"), .invalidInvokingUser) + } + } + /// A non-UUID operation ID is rejected outright. The ID is the replay key, /// so a client-chosen constant ("1") would let a single authorization be /// reused; requiring a UUID makes every request distinguishable. func testValidate_rejectsNonUUIDOperationID() { for bad in ["", "1", "not-a-uuid", String(repeating: "a", count: 400)] { - let request = HelperRequest(operation: .clean, operationID: bad, clientBuild: "23") + let request = HelperRequest(operation: .clean, operationID: bad, + clientBuild: "23", invokingUser: validInvokingUserClaim) XCTAssertEqual(request.validate(expectedBuild: "23"), .malformedOperationID, "operation ID \(bad.prefix(12)) must be rejected") } @@ -222,12 +373,14 @@ final class HelperContractTests: XCTestCase { /// runs as root — so the mismatch stops the operation and the GUI /// re-registers instead. func testValidate_rejectsBuildMismatch() { - let request = HelperRequest(operation: .clean, operationID: UUID().uuidString, clientBuild: "22") + let request = HelperRequest(operation: .clean, operationID: UUID().uuidString, + clientBuild: "22", invokingUser: validInvokingUserClaim) XCTAssertEqual(request.validate(expectedBuild: "23"), .buildMismatch) } func testValidate_rejectsEmptyBuild() { - let request = HelperRequest(operation: .clean, operationID: UUID().uuidString, clientBuild: "") + let request = HelperRequest(operation: .clean, operationID: UUID().uuidString, + clientBuild: "", invokingUser: validInvokingUserClaim) XCTAssertEqual(request.validate(expectedBuild: "23"), .buildMismatch) } @@ -280,6 +433,7 @@ final class HelperContractTests: XCTestCase { .authorizationDenied, .rejected(.buildMismatch), .rejected(.replayedOperationID), + .rejected(.invalidInvokingUser), .engineUnavailable, ] for outcome in outcomes { diff --git a/macos/vendor/burrow-engine b/macos/vendor/burrow-engine index 4e008973..b3e788e2 160000 --- a/macos/vendor/burrow-engine +++ b/macos/vendor/burrow-engine @@ -1 +1 @@ -Subproject commit 4e00897382c35f5ec827c767d04075041a7aa159 +Subproject commit b3e788e25550dbbaa0cfa5df729cf01e2745aa27 From 802f0900c226ba3a24e5a21fb23a7f8bc6c0ffba Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:05:35 +0800 Subject: [PATCH 03/11] docs: record 0.12 release verification --- SECURITY.md | 6 +++--- docs/macos-signing.md | 35 +++++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 5c6430a3..4e229ff8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,11 +19,11 @@ the ZIP again, matches its SHA and dSYM UUIDs, and repeats strict signing, notarization-ticket, designated-requirement, Gatekeeper, and Sparkle checks before publication. An existing public release is immutable. The external Homebrew cask is updated only after that verified artifact exists, and its live -0.11.2 definition preserves quarantine so Gatekeeper can verify the stapled +0.12.0 definition preserves quarantine so Gatekeeper can verify the stapled ticket. Maintainer setup and release verification are in the [macOS signing runbook](docs/macos-signing.md). -The published 0.11.2 app was independently checked after download: its bundle +The published 0.12.0 app was independently checked after download: its bundle identifier is `dev.caezium.Burrow`, its Developer ID Team ID is `YGSM2722TZ`, the hardened runtime and secure timestamp are present, `stapler validate` succeeds, and Gatekeeper reports `source=Notarized Developer ID`. @@ -48,7 +48,7 @@ copies still use an ad-hoc signature so macOS can bind Full Disk Access to a coherent development identity, but that is not a substitute for Developer ID or notarization and changes between builds. -## Privileged (admin) operations — no background helper +## Privileged (admin) operations This is the part people rightly scrutinize in cleaners. Burrow's model: diff --git a/docs/macos-signing.md b/docs/macos-signing.md index 47287bf6..d1306ce8 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -243,30 +243,41 @@ Check that Homebrew’s live `Casks/burrow.rb` no longer contains `postflight`, `https://github.com/caezium/Burrow/releases/latest/download/appcast.xml` resolves to that feed. -### 0.11.2 current release and symbol baseline +### 0.12.0 current release and symbol baseline The current release was re-verified from its downloaded GitHub asset on August -8, 2026. Tag `v0.11.2` points to -`c08de9714d51b6f6aa580ce9e9b71bebf4fb86be`; the ZIP has SHA-256 -`2a51a87d541be50cd3b2ab9418dfcaf20927d16499a265298b53bbc100d4eca5`, +8, 2026. Tag `v0.12.0` points to +`adf9f89b676d79be23bcc4e930952adb81942dc7`; the ZIP has SHA-256 +`367ab1f8b6129f4154fb6c5e90bfcbe5d86909e6c71dd9b2cb24afb5ec455e6d`, and `appcast.xml` has SHA-256 -`04ac0ea7efcade3d0ea87455a1ab849ebd853c9a288c09b545d3d59bfc04feaa`. -The extracted app reports version 0.11.2, build 23, bundle ID +`7b7732d91085be4dbf5eecbf9663739e8414bf71c9db05454167dc8c79c16621`. +The extracted app reports version 0.12.0, build 24, bundle ID `dev.caezium.Burrow`, and Team ID `YGSM2722TZ`; strict nested-signature, designated-requirement, stapler, and Gatekeeper checks all pass. The distributed binary UUIDs are +`9DBA7960-5620-3924-8140-1A48AC6A1A19` (arm64) and +`A99B795D-2FAC-3452-AEE8-4F52260A9493` (x86_64). The +[tag workflow log](https://github.com/caezium/Burrow/actions/runs/31248528339) +records those exact two UUIDs as uploaded Sentry debug companions, proving that +the 0.12.0 dSYM matches the public release binary. The live Homebrew cask is +also 0.12.0 with the same ZIP SHA, preserves quarantine, and contains no +`postflight`, `xattr -cr`, or unsigned-build caveat. + +### 0.11.2 BURROW-9E symbol baseline + +The downloaded 0.11.2 binary UUIDs are `324C0B80-A09E-346C-8153-7BDCCB37A24C` (arm64) and `B0DF1FAF-0575-3865-8C7F-E2CBFFD141CC` (x86_64). The -[tag workflow log](https://github.com/caezium/Burrow/actions/runs/30934193823) -records those exact two UUIDs as uploaded Sentry debug companions, proving that -the 0.11.2 dSYM matches the public release binary. That evidence does not -recover BURROW-9E by itself: the older event still needs restricted Sentry data -and an actionable frame, so the trigger below remains in force. +[0.11.2 tag workflow log](https://github.com/caezium/Burrow/actions/runs/30934193823) +records those exact two UUIDs as uploaded Sentry debug companions. That proves +the matching symbols were uploaded, but it does not recover BURROW-9E by +itself: the older event still needs restricted Sentry data and an actionable +frame, so the trigger below remains in force. ### 0.11.1 trust-chain baseline -The current release was verified on August 3, 2026. Tag `v0.11.1` points to +That release was verified on August 3, 2026. Tag `v0.11.1` points to `d482544e415d10cf9cb0c606c8a8ce149ddad99d`; the published ZIP has SHA-256 `d9b2267cce68ff091d898bdfca30e0b0f861a411ee92c4a4b60b70bcf0b8bceb`, and the signed `appcast.xml` asset has SHA-256 From 093230d3f55eca75e9ddcef05586796402806dba Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:13:31 +0800 Subject: [PATCH 04/11] fix: close safe release review gaps --- .github/workflows/sentry-issues.yml | 1 - SECURITY.md | 13 +- macos/HelperSources/HelperService.swift | 83 +- macos/Sources/CleanView.swift | 10 +- macos/Sources/CleanupAuthorization.swift | 320 +++++- macos/Sources/MoleCLI.swift | 21 +- macos/Sources/OperationFlow.swift | 57 +- macos/Sources/PrivilegedExecution.swift | 6 +- .../HelperCodeRequirement.swift | 144 +++ macos/Sources/ProcessActions.swift | 31 +- macos/Sources/ProcessWatchdog.swift | 78 +- macos/Sources/StatusView.swift | 4 +- macos/Sources/TuneUpView.swift | 7 +- macos/Sources/UpdateWorkflow.swift | 848 +++++++++++++++- macos/Sources/UpdatesView.swift | 413 ++++++-- macos/Tests/HelperCodeRequirementTests.swift | 100 ++ macos/Tests/MoleCLITests.swift | 9 + macos/Tests/OperationFlowTests.swift | 33 + macos/Tests/PrivilegedSecurityTests.swift | 194 ++++ macos/Tests/ProcessActionsTests.swift | 16 + macos/Tests/ProcessWatchdogTests.swift | 95 ++ macos/Tests/UpdateSeenStoreTests.swift | 15 + macos/Tests/UpdateWorkflowTests.swift | 925 +++++++++++++++++- macos/Tests/UpdatesModelTests.swift | 678 +++++++++++++ macos/vendor/burrow-engine | 2 +- scripts/tests/test_sentry_issues_workflow.py | 5 + windows/BURROW_WINDOWS_ALIGNMENT.md | 4 +- 27 files changed, 3884 insertions(+), 228 deletions(-) create mode 100644 macos/Tests/ProcessWatchdogTests.swift diff --git a/.github/workflows/sentry-issues.yml b/.github/workflows/sentry-issues.yml index d2a95467..d21c1951 100644 --- a/.github/workflows/sentry-issues.yml +++ b/.github/workflows/sentry-issues.yml @@ -215,7 +215,6 @@ jobs: continue fi - firstSeen=$(jq -r '.firstSeen // empty' <<<"$issue") fsEpoch=$(date -u -d "$firstSeen" +%s 2>/dev/null || echo 0) if [ "$fsEpoch" -lt "$cutoff" ]; then echo "Skipping ${shortId}: firstSeen ${firstSeen} is outside the ${LOOKBACK_HOURS}h lookback." diff --git a/SECURITY.md b/SECURITY.md index 4e229ff8..5b7f360a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -101,8 +101,17 @@ This is the part people rightly scrutinize in cleaners. Burrow's model: - **It runs only your engine, or those four Apple tools.** The engine it executes is the copy inside the app bundle, resolved relative to the helper's own path, never through `PATH` or an environment variable. Before - running anything the helper verifies the whole app bundle against its own - signing team, which seals the engine and every library the engine loads. + running anything the helper copies the bundle below a fresh root-only + directory, then verifies that exact snapshot against Burrow's bundle + identifier, the helper's build number, and its signing team. The launched + bytes are therefore the verified bytes, including the sealed engine and + every library it loads. + - **The elevated environment is explicit.** An approved administrator probe + on 2026-08-08 observed UID 0 with the invoking user's `HOME`, but also an + inherited user-controlled `PATH`. Burrow therefore ignores that ambient + environment: both elevation paths use the daemon-validated canonical home + and fixed `/usr/bin:/bin:/usr/sbin:/sbin` path. The audit records only the + numeric uid and canonical-match decision, not the username or home path. - **You can remove it** from Settings, or from System Settings ▸ General ▸ Login Items & Extensions. - Code: `macos/Sources/PrivilegedHelper/`, `macos/HelperSources/`. diff --git a/macos/HelperSources/HelperService.swift b/macos/HelperSources/HelperService.swift index 9b0184bb..73f98b07 100644 --- a/macos/HelperSources/HelperService.swift +++ b/macos/HelperSources/HelperService.swift @@ -167,35 +167,6 @@ enum HelperDaemonIdentityResolver { // MARK: - Engine resolution enum HelperEngine { - - /// The signed engine inside the app bundle that contains this helper, - /// resolved RELATIVE TO OUR OWN EXECUTABLE: - /// - /// …/Burrow.app/Contents/MacOS/BurrowHelper ← us - /// …/Burrow.app/Contents/Resources/engine/mole ← the engine - /// - /// Never `PATH`, never an environment variable, never a caller-supplied - /// path. A root process that resolves its executable through any of those - /// hands root to whoever wins the race to shadow the name — which is the - /// exact reason `MoleCLI.trustedExecutable()` already refuses PATH on the - /// osascript path. - static func bundledEnginePath() -> String? { - guard let executable = Bundle.main.executableURL?.resolvingSymlinksInPath() else { return nil } - let contents = executable // …/Contents/MacOS/BurrowHelper - .deletingLastPathComponent() // …/Contents/MacOS - .deletingLastPathComponent() // …/Contents - let engine = contents - .appendingPathComponent("Resources/engine/mole") - .standardizedFileURL - - // Belt and braces: after standardizing, the engine must still sit - // inside our own Contents directory. A symlink pointing out of the - // bundle would otherwise be followed as root. - guard engine.path.hasPrefix(contents.standardizedFileURL.path + "/") else { return nil } - guard FileManager.default.isExecutableFile(atPath: engine.path) else { return nil } - return engine.path - } - /// The app bundle containing this helper. static func appBundleURL() -> URL? { guard let executable = Bundle.main.executableURL?.resolvingSymlinksInPath() else { return nil } @@ -224,12 +195,44 @@ enum HelperEngine { /// check is skipped and the fact is logged. Release builds always have /// one, and the release gate refuses to ship a helper without it. static func verifyContainingBundle(teamID: String?) -> Bool { + guard let bundle = appBundleURL() else { return false } + return verifyBundle(at: bundle, teamID: teamID) + } + + /// Clone first, then validate the clone that will actually be executed. + /// The snapshot's 0700 root-owned parent removes the signature-check/path- + /// exec window without relying on another best-effort stat immediately + /// before `Process.run()`. + static func executableSnapshot(teamID: String?) -> HelperExecutableSnapshot? { + guard let bundle = appBundleURL() else { return nil } + do { + return try HelperExecutableSnapshot.prepare( + appBundleURL: bundle, + expectedBundleID: HelperNames.clientBundleID, + expectedBuild: HelperService.build) { copiedBundle in + verifyBundle(at: copiedBundle, teamID: teamID) + } + } catch { + helperTrace("bundle execution snapshot could not be prepared") + return nil + } + } + + private static func verifyBundle(at bundle: URL, teamID: String?) -> Bool { + guard HelperExecutableSnapshot.matchesSealedMetadata( + at: bundle, + expectedBundleID: HelperNames.clientBundleID, + expectedBuild: HelperService.build) else { + helperTrace("bundle verification failed: identity or build mismatch") + return false + } guard let teamID else { helperTrace("bundle signature check skipped: helper is ad-hoc signed (development build)") return true } - guard let requirement = HelperCodeRequirement.sameTeam(teamID: teamID), - let bundle = appBundleURL() else { return false } + let requirement = HelperCodeRequirement.string(bundleID: HelperNames.clientBundleID, + teamID: teamID) + guard requirement != HelperCodeRequirement.unsatisfiable else { return false } var staticCode: SecStaticCode? guard SecStaticCodeCreateWithPath(bundle as CFURL, [], &staticCode) == errSecSuccess, @@ -518,17 +521,18 @@ final class HelperService: NSObject, BurrowHelperProtocol { // Gate 5 — execution. Our own signed engine, or a system tool from the // closed set; fixed argv either way. - guard HelperEngine.verifyContainingBundle(teamID: teamID) else { - helperTrace("engine unavailable: containing app bundle failed signature verification") - return respond(.engineUnavailable) - } + var engineSnapshot: HelperExecutableSnapshot? var enginePath: String? if request.operation.engineArguments != nil { - guard let verifiedPath = HelperEngine.bundledEnginePath() else { - helperTrace("engine unavailable: no bundled engine at Contents/Resources/engine/mole") + guard let snapshot = HelperEngine.executableSnapshot(teamID: teamID) else { + helperTrace("engine unavailable: signed execution snapshot could not be prepared") return respond(.engineUnavailable) } - enginePath = verifiedPath + engineSnapshot = snapshot + enginePath = snapshot.executableURL.path + } else if !HelperEngine.verifyContainingBundle(teamID: teamID) { + helperTrace("engine unavailable: containing app bundle failed signature verification") + return respond(.engineUnavailable) } helperTrace("running \(request.operation.rawValue) (mutating: \(request.operation.mutatesDisk))") @@ -542,6 +546,9 @@ final class HelperService: NSObject, BurrowHelperProtocol { invokingUser: invokingUser) { line in client?.helperDidEmit(line: line, operationID: operationID) } + // Keep the validated clone alive until Process has exited and every + // output pipe has drained; deinit then removes the private snapshot. + withExtendedLifetime(engineSnapshot) {} helperTrace("operation finished with status \(code)") respond(.exited(code)) } diff --git a/macos/Sources/CleanView.swift b/macos/Sources/CleanView.swift index 40708dfc..4ccc8e14 100644 --- a/macos/Sources/CleanView.swift +++ b/macos/Sources/CleanView.swift @@ -201,7 +201,7 @@ struct CleanView: View { } private var reviewAvailable: Bool { - if case .finished(.done) = dryFlow.state { return CleanList.loadLive() != nil } + if case .finished(.done(exit: 0)) = dryFlow.state { return CleanList.loadLive() != nil } return false } @@ -286,7 +286,7 @@ struct CleanView: View { screen = .hero realFlow.start(ToolOperation( label: NSLocalizedString("Cleaning reviewed caches", comment: ""), - executable: .path("/usr/bin/find"), arguments: [], elevated: true, + executable: .path("/bin/sh"), arguments: [], elevated: true, cleanupPlan: plan, reduce: { parseTaskReport($0) }, notifyOnEnd: true)) } @@ -361,7 +361,7 @@ struct CleanView: View { } .padding(.horizontal, 18).padding(.top, 4).padding(.bottom, 12) Rectangle().fill(Brand.hairline).frame(height: 1) - if case .finished(.done) = realFlow.state { + if case .finished(.done(exit: 0)) = realFlow.state { DoneBanner(accent: Tool.clean.accent, title: "Cleaned", detail: cleanedDetail) .task { await loadLifetime() } } @@ -378,7 +378,9 @@ struct CleanView: View { private var realStatusText: String { switch realFlow.state { case .running: return NSLocalizedString("Cleaning… don't quit.", comment: "") - case .finished(.done): return NSLocalizedString("Done — caches cleared.", comment: "") + case .finished(.done(exit: 0)): return NSLocalizedString("Done — caches cleared.", comment: "") + case .finished(.done(let code)): + return String(format: NSLocalizedString("Failed: cleanup exited with status %d.", comment: ""), code) case .finished(.cancelled): return NSLocalizedString("Stopped.", comment: "") case .finished(.failed(let m)): return String(format: NSLocalizedString("Failed: %@", comment: ""), m) case .idle, .gated: return "" diff --git a/macos/Sources/CleanupAuthorization.swift b/macos/Sources/CleanupAuthorization.swift index 10c96a3f..2cbb7950 100644 --- a/macos/Sources/CleanupAuthorization.swift +++ b/macos/Sources/CleanupAuthorization.swift @@ -15,6 +15,11 @@ import Darwin struct CleanupExecutionPlan: Sendable, Equatable { struct Item: Sendable, Equatable { let identity: PinnedFileIdentity + /// Every name below `identity`, captured without following symlinks. + /// Irreversible execution consumes this exact set deepest-first; it + /// never recursively deletes whatever happens to be below the top + /// directory later. + let descendants: [PinnedFileIdentity] } let snapshotID: UUID @@ -40,7 +45,10 @@ struct CleanupExecutionPlan: Sendable, Equatable { seal == Self.makeSeal(snapshotID: snapshotID, createdAt: createdAt, expiresAt: expiresAt, roots: approvedRoots, items: items), approvedRoots.allSatisfy({ $0.matchesCurrent() }), - items.allSatisfy({ $0.identity.matchesCurrent() }) else { return false } + items.allSatisfy({ item in + item.identity.matchesCurrent() && + item.descendants.allSatisfy { $0.matchesCurrent() } + }) else { return false } return items.allSatisfy { item in approvedRoots.contains { root in item.identity.device == root.device && @@ -63,20 +71,232 @@ struct CleanupExecutionPlan: Sendable, Equatable { } } + /// Materialize the exact reviewed tree into a digest-pinned manifest. + /// The privileged shell copies and verifies that manifest beneath its + /// private root before consuming it, so the command stays bounded even + /// when a cache contains thousands of reviewed descendants. + func prepareIrreversibleCleanup() throws -> CleanupIrreversibleExecution { + try prepareIrreversibleCleanup(preCaptureDelaySeconds: 0, + postCaptureDelaySeconds: 0) + } + +#if DEBUG + func prepareIrreversibleCleanupForTesting(preCaptureDelaySeconds: UInt = 0, + postCaptureDelaySeconds: UInt = 0) throws + -> CleanupIrreversibleExecution { + try prepareIrreversibleCleanup(preCaptureDelaySeconds: preCaptureDelaySeconds, + postCaptureDelaySeconds: postCaptureDelaySeconds) + } +#endif + + private func prepareIrreversibleCleanup(preCaptureDelaySeconds: UInt, + postCaptureDelaySeconds: UInt) throws + -> CleanupIrreversibleExecution { + guard preCaptureDelaySeconds <= 5, postCaptureDelaySeconds <= 5, + validateForLaunch() else { + throw CleanupIrreversibleExecution.ExecutionError.invalidPlan + } + var identitiesByPath: [String: PinnedFileIdentity] = [:] + for item in items { + for identity in item.descendants + [item.identity] { + if let existing = identitiesByPath[identity.path], existing != identity { + throw CleanupIrreversibleExecution.ExecutionError.invalidPlan + } + identitiesByPath[identity.path] = identity + } + } + let identities = identitiesByPath.values.sorted { lhs, rhs in + let lhsDepth = lhs.path.reduce(into: 0) { if $1 == "/" { $0 += 1 } } + let rhsDepth = rhs.path.reduce(into: 0) { if $1 == "/" { $0 += 1 } } + return lhsDepth == rhsDepth ? lhs.path < rhs.path : lhsDepth > rhsDepth + } + return try CleanupIrreversibleExecution(snapshotID: snapshotID, + identities: identities, + preCaptureDelaySeconds: preCaptureDelaySeconds, + postCaptureDelaySeconds: postCaptureDelaySeconds) + } + private static func makeSeal(snapshotID: UUID, createdAt: Date, expiresAt: Date, roots: [PinnedFileIdentity], items: [Item]) -> Data { func line(_ i: PinnedFileIdentity) -> String { "\(i.path.utf8.count):\(i.path)|\(i.shellStatToken)" } + var itemLines: [String] = [] + for item in items { + itemLines.append("item:\(line(item.identity))") + itemLines.append(contentsOf: item.descendants.map { "descendant:\(line($0))" }) + itemLines.append("--") + } let payload = ([snapshotID.uuidString, String(createdAt.timeIntervalSince1970.bitPattern), String(expiresAt.timeIntervalSince1970.bitPattern)] - + roots.map(line) + ["--items--"] + items.map { line($0.identity) }) + + roots.map { "root:\(line($0))" } + ["--items--"] + itemLines) .joined(separator: "\n") return Data(SHA256.hash(data: Data(payload.utf8))) } } +/// Owns the unprivileged source manifest until the elevated process exits. +/// The root shell never trusts this path directly: it copies the bytes into a +/// fresh 0700 directory and validates their SHA-256 digest before parsing. +final class CleanupIrreversibleExecution: @unchecked Sendable { + enum ExecutionError: Error { + case invalidPlan + case cannotCreateManifest(Int32) + case cannotWriteManifest(Int32) + } + + let snapshotID: UUID + let shell: String + let quarantineRootPath: String + + private let manifestPath: String + private let lock = NSLock() + private var removed = false + + fileprivate init(snapshotID: UUID, + identities: [PinnedFileIdentity], + preCaptureDelaySeconds: UInt, + postCaptureDelaySeconds: UInt) throws { + guard !identities.isEmpty, + identities.allSatisfy({ identity in + !identity.path.unicodeScalars.contains { + CharacterSet.controlCharacters.contains($0) + } + }) else { throw ExecutionError.invalidPlan } + + let manifest = identities.map { identity in + let kind = identity.isDirectory ? "d" : "n" + return "\(identity.shellStatToken)\t\(kind)\t\(identity.path)\n" + }.joined() + let data = Data(manifest.utf8) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + let writtenManifest = try Self.writeManifest(data) + let privateRoot = "/private/var/tmp/dev.caezium.burrow.cleanup-\(snapshotID.uuidString)" + manifestPath = writtenManifest.path + self.snapshotID = snapshotID + quarantineRootPath = privateRoot + shell = Self.makeShell(manifestPath: writtenManifest.path, + manifestIdentity: writtenManifest.identity, + digest: digest, + quarantineRootPath: privateRoot, + preCaptureDelaySeconds: preCaptureDelaySeconds, + postCaptureDelaySeconds: postCaptureDelaySeconds) + } + + func remove() { + lock.lock() + guard !removed else { lock.unlock(); return } + removed = true + lock.unlock() + _ = Darwin.unlink(manifestPath) + } + + deinit { remove() } + + private struct WrittenManifest { + let path: String + let identity: PinnedFileIdentity + } + + private static func writeManifest(_ data: Data) throws -> WrittenManifest { + var template = Array("/private/var/tmp/dev.caezium.burrow.cleanup-manifest.XXXXXX".utf8CString) + let descriptor = template.withUnsafeMutableBufferPointer { bytes in + mkstemp(bytes.baseAddress) + } + guard descriptor >= 0 else { throw ExecutionError.cannotCreateManifest(errno) } + let path = String(cString: template) + var keep = false + defer { + Darwin.close(descriptor) + if !keep { _ = Darwin.unlink(path) } + } + guard fchmod(descriptor, 0o600) == 0 else { + throw ExecutionError.cannotWriteManifest(errno) + } + let wroteAll = data.withUnsafeBytes { raw -> Bool in + guard let base = raw.baseAddress else { return false } + var offset = 0 + while offset < raw.count { + let written = Darwin.write(descriptor, base.advanced(by: offset), raw.count - offset) + if written < 0 { + if errno == EINTR { continue } + return false + } + offset += written + } + return true + } + var fileStat = stat() + guard wroteAll, fsync(descriptor) == 0, + fstat(descriptor, &fileStat) == 0, + (fileStat.st_mode & S_IFMT) == S_IFREG else { + throw ExecutionError.cannotWriteManifest(errno) + } + keep = true + return WrittenManifest( + path: path, + identity: PinnedFileIdentity(path: path, + device: UInt64(fileStat.st_dev), + inode: UInt64(fileStat.st_ino), + owner: UInt32(fileStat.st_uid), + mode: UInt16(fileStat.st_mode))) + } + + private static func makeShell(manifestPath: String, + manifestIdentity: PinnedFileIdentity, + digest: String, + quarantineRootPath: String, + preCaptureDelaySeconds: UInt, + postCaptureDelaySeconds: UInt) -> String { + let source = MoleCLI.shellQuote(manifestPath) + let root = MoleCLI.shellQuote(quarantineRootPath) + let expectedDigest = MoleCLI.shellQuote(digest) + var statements = [ + "burrow_root=\(root)", + "burrow_source=\(source)", + "burrow_source_capture=\"$burrow_root/source-manifest\"", + "burrow_manifest=\"$burrow_root/manifest\"", + "burrow_abort() { /bin/rm -f -- \"$burrow_manifest\" 2>/dev/null || true; /bin/rmdir -- \"$burrow_root\" 2>/dev/null || true; exit 124; }", + "burrow_source_mismatch() { if [ ! -e \"$burrow_source\" ] && [ ! -L \"$burrow_source\" ]; then /bin/mv -n -- \"$burrow_source_capture\" \"$burrow_source\" 2>/dev/null || true; fi; burrow_abort; }", + "burrow_verified_source_abort() { /bin/rm -f -- \"$burrow_source_capture\" 2>/dev/null || true; burrow_abort; }", + "burrow_restore_abort() { if [ ! -e \"$burrow_original\" ] && [ ! -L \"$burrow_original\" ]; then /bin/mv -n -- \"$burrow_capture\" \"$burrow_original\" 2>/dev/null || true; fi; /bin/rmdir -- \"$burrow_node\" 2>/dev/null || true; burrow_abort; }", + "/bin/mkdir -m 0700 -- \"$burrow_root\" || exit 124", + "/bin/mv -- \"$burrow_source\" \"$burrow_source_capture\" || burrow_abort", + "[ \"$(/usr/bin/stat -f '%d:%i:%u:%p' -- \"$burrow_source_capture\" 2>/dev/null)\" = '\(manifestIdentity.shellStatToken)' ] || burrow_source_mismatch", + "/bin/cp -- \"$burrow_source_capture\" \"$burrow_manifest\" || burrow_verified_source_abort", + "[ \"$(/usr/bin/shasum -a 256 -- \"$burrow_manifest\" | /usr/bin/cut -d ' ' -f 1)\" = \(expectedDigest) ] || burrow_verified_source_abort", + "/bin/rm -f -- \"$burrow_source_capture\" || burrow_verified_source_abort", + "burrow_index=0", + "while IFS=\"$(/usr/bin/printf '\\t')\" read -r burrow_expected burrow_kind burrow_original; do [ -n \"$burrow_expected\" ] && [ -n \"$burrow_original\" ] || burrow_abort", + "burrow_node=\"$burrow_root/node-$burrow_index\"", + "burrow_capture=\"$burrow_node/captured\"", + "/bin/mkdir -m 0700 -- \"$burrow_node\" || burrow_abort", + "burrow_device=${burrow_expected%%:*}", + "[ \"$(/usr/bin/stat -f '%d' -- \"$burrow_node\" 2>/dev/null)\" = \"$burrow_device\" ] || { /bin/rmdir -- \"$burrow_node\" 2>/dev/null || true; burrow_abort; }", + ] + if preCaptureDelaySeconds > 0 { + statements.append("/bin/sleep \(preCaptureDelaySeconds)") + } + statements += [ + "/bin/mv -- \"$burrow_original\" \"$burrow_capture\" || { /bin/rmdir -- \"$burrow_node\" 2>/dev/null || true; burrow_abort; }", + "[ \"$(/usr/bin/stat -f '%d:%i:%u:%p' -- \"$burrow_capture\" 2>/dev/null)\" = \"$burrow_expected\" ] || burrow_restore_abort", + ] + if postCaptureDelaySeconds > 0 { + statements.append("/bin/sleep \(postCaptureDelaySeconds)") + } + statements += [ + "if [ \"$burrow_kind\" = d ]; then /bin/rmdir -- \"$burrow_capture\" || burrow_restore_abort; else /bin/rm -f -- \"$burrow_capture\" || burrow_restore_abort; fi", + "/bin/rmdir -- \"$burrow_node\" || burrow_abort", + "burrow_index=$((burrow_index + 1))", + "done < \"$burrow_manifest\"", + "/bin/rm -f -- \"$burrow_manifest\" || exit 124", + "/bin/rmdir -- \"$burrow_root\" || exit 124", + ] + return statements.joined(separator: "; ") + } +} + struct CleanupSnapshot: Sendable, Equatable { enum SnapshotError: LocalizedError, Equatable { case noApprovedRoots @@ -140,13 +360,57 @@ struct CleanupSnapshot: Sendable, Equatable { guard seen.insert(canonical).inserted else { continue } let identity = try PinnedFileIdentity.capture(canonical) _ = try approvedRoot(for: canonical, device: identity.device, roots: roots) - captured.append(.init(identity: identity)) + let descendants = try captureDescendants(of: identity) + captured.append(.init(identity: identity, descendants: descendants)) } guard !captured.isEmpty else { throw SnapshotError.selectionMismatch } return Self(id: UUID(), createdAt: now, expiresAt: now.addingTimeInterval(lifetime), list: list, approvedRoots: roots, items: captured) } + /// Enumerate without following symlinks and then revalidate every captured + /// identity. The enumeration need not be an atomic filesystem snapshot: + /// anything missed by a concurrent addition is absent from the execution + /// manifest, so the later `rmdir` fails instead of deleting it. + private static func captureDescendants(of root: PinnedFileIdentity) throws + -> [PinnedFileIdentity] { + guard root.isDirectory else { return [] } + let rootURL = URL(fileURLWithPath: root.path, isDirectory: true) + var enumerationFailed = false + guard let enumerator = FileManager.default.enumerator( + at: rootURL, includingPropertiesForKeys: nil, options: [], + errorHandler: { _, _ in enumerationFailed = true; return false }) else { + throw SnapshotError.staleOrChanged + } + + var descendants: [PinnedFileIdentity] = [] + while let value = enumerator.nextObject() as? URL { + if enumerationFailed { throw SnapshotError.staleOrChanged } + let path = value.path + guard path.hasPrefix(root.path + "/"), + !path.unicodeScalars.contains(where: { + CharacterSet.controlCharacters.contains($0) + }) else { throw SnapshotError.malformedPath(path) } + let identity: PinnedFileIdentity + do { + identity = try PinnedFileIdentity.capture(path, rejectSymlink: false) + } catch { + throw SnapshotError.staleOrChanged + } + guard identity.device == root.device else { + enumerator.skipDescendants() + throw SnapshotError.unexpectedVolume(path) + } + if identity.isSymbolicLink { enumerator.skipDescendants() } + descendants.append(identity) + } + guard !enumerationFailed, root.matchesCurrent(), + descendants.allSatisfy({ $0.matchesCurrent() }) else { + throw SnapshotError.staleOrChanged + } + return descendants + } + static func approvedRoot(for path: String, device: UInt64, roots: [PinnedFileIdentity]) throws -> PinnedFileIdentity { guard let root = roots.first(where: { @@ -184,14 +448,43 @@ enum CleanupExecutor { struct Result: Sendable, Equatable { let moved: Int; let failed: Int } static func moveToTrash(_ plan: CleanupExecutionPlan, - fileManager: FileManager = .default) -> Result { + move: (URL) throws -> URL = systemTrashMove) -> Result { guard plan.validateForLaunch() else { return Result(moved: 0, failed: plan.items.count) } var moved = 0, failed = 0 for item in plan.items { guard item.identity.matchesCurrent() else { failed += 1; continue } + let flags = O_RDONLY | O_NOFOLLOW | O_CLOEXEC | + (item.identity.isDirectory ? O_DIRECTORY : 0) + let descriptor = Darwin.open(item.identity.path, flags) + guard descriptor >= 0 else { failed += 1; continue } + defer { Darwin.close(descriptor) } + var opened = stat() + guard fstat(descriptor, &opened) == 0, + UInt64(opened.st_dev) == item.identity.device, + UInt64(opened.st_ino) == item.identity.inode, + UInt32(opened.st_uid) == item.identity.owner, + UInt16(opened.st_mode) == item.identity.mode else { + failed += 1 + continue + } do { - try fileManager.trashItem(at: URL(fileURLWithPath: item.identity.path), - resultingItemURL: nil) + let source = URL(fileURLWithPath: item.identity.path) + let destination = try move(source) + guard let captured = try? PinnedFileIdentity.capture(destination.path), + captured.device == item.identity.device, + captured.inode == item.identity.inode, + captured.owner == item.identity.owner, + captured.mode == item.identity.mode else { + // FileManager's Trash operation is an atomic rename on the + // source volume, but its API is path-based. If another + // process swapped the name between our check and that + // rename, the returned destination contains the wrong + // vnode. Put that recoverable object back when possible; + // if its name was occupied again, leave it in Trash. + restoreUnreviewedItem(at: destination, to: source) + failed += 1 + continue + } moved += 1 } catch { failed += 1 @@ -199,4 +492,19 @@ enum CleanupExecutor { } return Result(moved: moved, failed: failed) } + + private static func systemTrashMove(_ source: URL) throws -> URL { + var destination: NSURL? + try FileManager.default.trashItem(at: source, resultingItemURL: &destination) + guard let destination else { throw CocoaError(.fileWriteUnknown) } + return destination as URL + } + + private static func restoreUnreviewedItem(at captured: URL, to original: URL) { + var current = stat() + guard lstat(original.path, ¤t) != 0, errno == ENOENT else { return } + // moveItem refuses an occupied destination, so a second race can only + // make restoration fail closed and leave the object recoverable. + try? FileManager.default.moveItem(at: captured, to: original) + } } diff --git a/macos/Sources/MoleCLI.swift b/macos/Sources/MoleCLI.swift index 9cc2f19b..a1a4e61e 100644 --- a/macos/Sources/MoleCLI.swift +++ b/macos/Sources/MoleCLI.swift @@ -133,7 +133,8 @@ enum MoleCLI { /// identity explicitly instead of inheriting root's HOME/USER. static func elevatedScript(command: ValidatedElevatedCommand, args: [String], logSink: PrivilegedLogSink? = nil, - cleanupPlan: CleanupExecutionPlan? = nil) -> String { + cleanupPlan: CleanupExecutionPlan? = nil, + cleanupExecution: CleanupIrreversibleExecution? = nil) -> String { func identityCheck(_ identity: PinnedFileIdentity) -> String { let stat = "/usr/bin/stat -f '%d:%i:%u:%p' -- \(shellQuote(identity.path)) 2>/dev/null" return "[ \"$(\(stat))\" = \(shellQuote(identity.shellStatToken)) ] || exit 126" @@ -147,21 +148,29 @@ enum MoleCLI { let user = command.invokingUser let environment = [ + "PATH=/usr/bin:/bin:/usr/sbin:/sbin", "HOME=\(user.canonicalHome)", "USER=\(user.username)", "LOGNAME=\(user.username)", "SUDO_USER=\(user.username)", "SUDO_UID=\(user.uid)", + "LC_ALL=C", ] + let isolatedEnvironment = ["/usr/bin/env", "-i"] + environment let run: String if let cleanupPlan { statements.append(contentsOf: cleanupPlan.executionBoundaryChecks().map { $0 + " || exit 124" }) - let quotedPaths = cleanupPlan.items.map { shellQuote($0.identity.path) }.joined(separator: " ") - // BSD find defaults to -P (do not follow symlinks); -x prevents a - // reviewed directory from crossing into a later-mounted volume. - run = "failed=0; for p in \(quotedPaths); do /usr/bin/find -x \"$p\" -depth -delete || failed=1; done; [ \"$failed\" -eq 0 ]" + let cleanupShell = cleanupExecution?.snapshotID == cleanupPlan.snapshotID + ? cleanupExecution?.shell ?? "exit 124" + : "exit 124" + run = (isolatedEnvironment + ["/bin/sh", "-c", cleanupShell]) + .map(shellQuote).joined(separator: " ") } else { - run = (["/usr/bin/env"] + environment + [command.executable.path] + args) + // `do shell script … with administrator privileges` inherits the + // caller's PATH on current macOS releases. Start from an empty + // environment so no user-writable tool can be resolved by the + // root engine or one of its child scripts. + run = (isolatedEnvironment + [command.executable.path] + args) .map(shellQuote).joined(separator: " ") } diff --git a/macos/Sources/OperationFlow.swift b/macos/Sources/OperationFlow.swift index 3f49bff0..52aaa7a8 100644 --- a/macos/Sources/OperationFlow.swift +++ b/macos/Sources/OperationFlow.swift @@ -272,17 +272,29 @@ final class OperationFlow: ObservableObject { case .exited(let code): guard !self.cancelRequested else { return } self.reactivateIfElevated(op) // backstop: no-output runs - self.report = op.reduce(lines) self.rawLog = lines.joined(separator: "\n") - self.state = .finished(.done(exit: code)) - if op.label != nil { - // Replace the last streamed line with the parsed - // result line where the op provides one — that's - // what a completion notification shows. - let detail = self.report.map { op.finalDetail?($0) ?? "" } ?? "" - self.center.end(id, success: code == 0, detail: detail) + if code == 0 { + self.report = op.reduce(lines) + self.state = .finished(.done(exit: code)) + if op.label != nil { + // Replace the last streamed line with the parsed + // result line where the op provides one — that's + // what a completion notification shows. + let detail = self.report.map { op.finalDetail?($0) ?? "" } ?? "" + self.center.end(id, success: true, detail: detail) + } + self.captureTelemetryCompletion(result: "succeeded") + } else { + // A cleanup report may contain the preview's optimistic + // summary even though the exact-tree guard stopped the + // deletion. Do not render or notify with that summary. + self.report = op.cleanupPlan == nil ? op.reduce(lines) : nil + let message = Self.failureMessage(exitCode: code, + isCleanup: op.cleanupPlan != nil) + self.state = .finished(.failed(message)) + if op.label != nil { self.center.end(id, success: false, detail: message) } + self.captureTelemetryCompletion(result: "failed") } - self.captureTelemetryCompletion(result: code == 0 ? "succeeded" : "failed") case .authCancelled: // Auth-cancel is classified by the runner now (#48 taxonomy), // not by a view-level "elevated + nonzero + no output" guess. @@ -321,6 +333,18 @@ final class OperationFlow: ObservableObject { return command } + private static func failureMessage(exitCode: Int32, isCleanup: Bool) -> String { + if isCleanup { + return String( + format: NSLocalizedString( + "Cleanup stopped because the reviewed files changed or could not be secured (exit %d). Rescan before trying again.", + comment: ""), + exitCode) + } + return String(format: NSLocalizedString("Operation failed with exit status %d.", comment: ""), + exitCode) + } + private func captureTelemetryCompletion(result: String) { guard let telemetryFeature else { return } let duration = telemetryStartedAt.map { Date().timeIntervalSince($0) } ?? 0 @@ -390,6 +414,12 @@ struct SystemProcessPort: ProcessPort { } let outPipe = Pipe(), errPipe = Pipe() + let cleanupExecution: CleanupIrreversibleExecution? + if spec.elevated, let plan = spec.cleanupPlan { + cleanupExecution = try? plan.prepareIrreversibleCleanup() + } else { + cleanupExecution = nil + } if spec.elevated { // The osascript `do shell script` wrapper has no stdin channel, @@ -403,14 +433,17 @@ struct SystemProcessPort: ProcessPort { executable: spec.executable, invokingUser: invokingUser, requireCurrentBundle: spec.requiresCurrentBundle), let logSink = try? PrivilegedLogSink.make(), - spec.cleanupPlan?.validateForLaunch() != false else { + spec.cleanupPlan?.validateForLaunch() != false, + spec.cleanupPlan == nil || cleanupExecution != nil else { + cleanupExecution?.remove() streamQ.async { finish(126) } return } let script = MoleCLI.elevatedScript(command: command, args: spec.arguments, logSink: logSink, - cleanupPlan: spec.cleanupPlan) + cleanupPlan: spec.cleanupPlan, + cleanupExecution: cleanupExecution) t.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") t.arguments = ["-e", script] t.standardOutput = outPipe @@ -428,6 +461,7 @@ struct SystemProcessPort: ProcessPort { t.terminationHandler = { proc in killTimer?.cancel() + cleanupExecution?.remove() DispatchQueue.main.async { tailTimer?.invalidate() } streamQ.async { if let h = logHandle { // last tail of the log @@ -504,6 +538,7 @@ struct SystemProcessPort: ProcessPort { } } } catch { + cleanupExecution?.remove() streamQ.async { finish(127) } } } diff --git a/macos/Sources/PrivilegedExecution.swift b/macos/Sources/PrivilegedExecution.swift index 828ce3e5..5c2df0b2 100644 --- a/macos/Sources/PrivilegedExecution.swift +++ b/macos/Sources/PrivilegedExecution.swift @@ -130,6 +130,7 @@ struct PinnedFileIdentity: Sendable, Equatable, Codable { var isDirectory: Bool { (mode_t(mode) & S_IFMT) == S_IFDIR } var isRegular: Bool { (mode_t(mode) & S_IFMT) == S_IFREG } + var isSymbolicLink: Bool { (mode_t(mode) & S_IFMT) == S_IFLNK } static func capture(_ path: String, rejectSymlink: Bool = true) throws -> Self { var st = stat() @@ -140,7 +141,10 @@ struct PinnedFileIdentity: Sendable, Equatable, Codable { } func matchesCurrent() -> Bool { - guard let current = try? Self.capture(path) else { return false } + // A reviewed descendant may itself be a symlink. Capture the link's + // identity without following it; a regular-file-to-symlink swap still + // fails because the inode and complete mode are pinned. + guard let current = try? Self.capture(path, rejectSymlink: false) else { return false } return current.device == device && current.inode == inode && current.owner == owner && current.mode == mode } diff --git a/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift b/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift index 412f4e7c..97626407 100644 --- a/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift +++ b/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift @@ -40,6 +40,7 @@ import Foundation import Security +import Darwin enum HelperCodeRequirement { @@ -153,3 +154,146 @@ enum HelperCodeRequirement { return #"anchor apple generic and certificate leaf[subject.OU] = "\#(team)""# } } + +/// A private copy of the app whose resource seal has been verified in place. +/// +/// `SecStaticCodeCheckValidity` accepts a path, and `Process` also launches a +/// path. Validating the installed bundle and later launching from it leaves a +/// rename window between those two operations. The helper closes that window +/// by cloning the bundle beneath a fresh 0700 root-owned directory, validating +/// that clone, and retaining it until the child exits. No unprivileged process +/// can replace anything below `rootURL`, so the validated bytes and launched +/// bytes are the same filesystem objects. +final class HelperExecutableSnapshot { + enum SnapshotError: Error { + case invalidParent + case cannotCreateRoot(Int32) + case copyFailed(Int32) + case verificationFailed + } + + let rootURL: URL + let appBundleURL: URL + let executableURL: URL + + private let lock = NSLock() + private var removed = false + + private init(rootURL: URL, appBundleURL: URL, executableURL: URL) { + self.rootURL = rootURL + self.appBundleURL = appBundleURL + self.executableURL = executableURL + } + + /// The verifier is injectable only so unit tests can exercise the + /// filesystem boundary with a tiny fixture. The daemon supplies the real + /// Security-framework resource-seal check. + static func prepare(appBundleURL source: URL, + parentDirectory: URL = URL(fileURLWithPath: "/private/var/tmp", + isDirectory: true), + expectedOwner: uid_t = 0, + expectedBundleID: String, + expectedBuild: String, + verify: (URL) -> Bool) throws -> HelperExecutableSnapshot { + guard let canonicalParent = canonicalPath(parentDirectory.path) else { + throw SnapshotError.invalidParent + } + let parent = URL(fileURLWithPath: canonicalParent, isDirectory: true) + var parentStat = stat() + guard lstat(parent.path, &parentStat) == 0, + (parentStat.st_mode & S_IFMT) == S_IFDIR, + parentStat.st_uid == expectedOwner else { + throw SnapshotError.invalidParent + } + // A writable parent is safe only when sticky: /private/var/tmp lets a + // user create their own entries but not replace a root-owned one. + let writable = parentStat.st_mode & 0o022 != 0 + guard !writable || parentStat.st_mode & S_ISVTX != 0 else { + throw SnapshotError.invalidParent + } + + var template = Array((parent.path + "/dev.caezium.burrow.engine.XXXXXX").utf8CString) + let created: String? = template.withUnsafeMutableBufferPointer { bytes in + guard let path = mkdtemp(bytes.baseAddress) else { return nil } + return String(cString: path) + } + guard let rootPath = created else { throw SnapshotError.cannotCreateRoot(errno) } + let root = URL(fileURLWithPath: rootPath, isDirectory: true) + var keep = false + defer { + if !keep { try? FileManager.default.removeItem(at: root) } + } + guard chmod(rootPath, 0o700) == 0 else { + throw SnapshotError.cannotCreateRoot(errno) + } + + let copiedApp = root.appendingPathComponent("Burrow.app", isDirectory: true) + let cloneFlags = copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE) + var copyResult = copyfile(source.path, copiedApp.path, nil, cloneFlags) + if copyResult != 0 { + let firstError = errno + try? FileManager.default.removeItem(at: copiedApp) + let fallbackFlags = copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE) + copyResult = copyfile(source.path, copiedApp.path, nil, fallbackFlags) + guard copyResult == 0 else { + throw SnapshotError.copyFailed(errno == 0 ? firstError : errno) + } + } + + let contents = copiedApp.appendingPathComponent("Contents", isDirectory: true) + let resources = contents.appendingPathComponent("Resources", isDirectory: true) + let engineDirectory = resources.appendingPathComponent("engine", isDirectory: true) + let executable = engineDirectory.appendingPathComponent("mole", isDirectory: false) + guard [copiedApp, contents, resources, engineDirectory].allSatisfy(isRealDirectory), + isExecutableRegularFile(executable), + verify(copiedApp), + matchesSealedMetadata(at: copiedApp, + expectedBundleID: expectedBundleID, + expectedBuild: expectedBuild) else { + throw SnapshotError.verificationFailed + } + + keep = true + return HelperExecutableSnapshot(rootURL: root, appBundleURL: copiedApp, + executableURL: executable) + } + + func remove() { + lock.lock() + guard !removed else { lock.unlock(); return } + removed = true + lock.unlock() + try? FileManager.default.removeItem(at: rootURL) + } + + deinit { remove() } + + private static func canonicalPath(_ path: String) -> String? { + guard let resolved = realpath(path, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } + + private static func isRealDirectory(_ url: URL) -> Bool { + var value = stat() + return lstat(url.path, &value) == 0 && (value.st_mode & S_IFMT) == S_IFDIR + } + + private static func isExecutableRegularFile(_ url: URL) -> Bool { + var value = stat() + return lstat(url.path, &value) == 0 && + (value.st_mode & S_IFMT) == S_IFREG && value.st_mode & 0o111 != 0 + } + + static func matchesSealedMetadata(at appBundleURL: URL, + expectedBundleID: String, + expectedBuild: String) -> Bool { + let infoURL = appBundleURL.appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: infoURL), + let value = try? PropertyListSerialization.propertyList(from: data, + format: nil), + let info = value as? [String: Any] else { return false } + return info["CFBundleIdentifier"] as? String == expectedBundleID && + info["CFBundleVersion"] as? String == expectedBuild + } +} diff --git a/macos/Sources/ProcessActions.swift b/macos/Sources/ProcessActions.swift index 554a003e..d6bd1be7 100644 --- a/macos/Sources/ProcessActions.swift +++ b/macos/Sources/ProcessActions.swift @@ -172,10 +172,23 @@ enum ProcessActions { return .stale } + return confirmTermination(target, force: force, onRefresh: onRefresh) + } + + /// Present and act on one immutable candidate. Callers that discover a + /// process asynchronously can pass the exact PID/owner/start/path snapshot + /// they intend to show; the same value is then re-read and compared before + /// any signal is sent. + @MainActor @discardableResult + static func confirmTermination( + _ target: TerminationTarget, + force: Bool = false, + onRefresh: @escaping () -> Void = {} + ) -> TerminationResult? { let alert = NSAlert() alert.messageText = force - ? String(format: NSLocalizedString("Force kill %@?", comment: ""), displayName) - : String(format: NSLocalizedString("Quit %@?", comment: ""), displayName) + ? String(format: NSLocalizedString("Force kill %@?", comment: ""), target.displayName) + : String(format: NSLocalizedString("Quit %@?", comment: ""), target.displayName) let consequence = force ? NSLocalizedString("SIGKILL ends it immediately, so unsaved work in this process is lost.", comment: "") : NSLocalizedString("SIGTERM asks the process to quit. It may save and exit, or ignore the request.", comment: "") @@ -221,20 +234,6 @@ enum ProcessActions { alert.runModalQuiet() } - /// SIGTERM — the polite ask. Caller confirms first. - @discardableResult - static func quit(pid: Int) -> Bool { - guard let target = terminationTarget(pid: pid, displayName: "pid \(pid)") else { return false } - return terminate(target, force: false) == .sent - } - - /// SIGKILL — the hammer. Caller double-confirms first. - @discardableResult - static func forceKill(pid: Int) -> Bool { - guard let target = terminationTarget(pid: pid, displayName: "pid \(pid)") else { return false } - return terminate(target, force: true) == .sent - } - /// SIGSTOP — pause a process (freeze it without killing). Reversible via /// `resume`; own-user processes only (PRD §α Process Inspector). @discardableResult diff --git a/macos/Sources/ProcessWatchdog.swift b/macos/Sources/ProcessWatchdog.swift index df36a3d3..da03dd5d 100644 --- a/macos/Sources/ProcessWatchdog.swift +++ b/macos/Sources/ProcessWatchdog.swift @@ -6,17 +6,66 @@ // buffer fed by the Status process pump, evaluates the opt-in watchdog rule // each tick (pure ProcessRule.fires), and dispatches the configured action — // notify / suspend / quit. Disabled by default (Store.processWatchdogEnabled); -// when off it clears its buffers and does nothing. Suspend/quit only touch -// own-user processes; notify always posts. +// when off it clears its buffers and does nothing. Suspend only touches an +// own-user process; quit captures PID/owner/start/path and requires the user +// to confirm that exact candidate before ProcessActions can signal it. // import Foundation import UserNotifications final class ProcessWatchdog { + struct ProcessControl { + let isOwnProcess: (Int) -> Bool + let suspend: (Int) -> Void + let terminationTarget: (Int, String) -> ProcessActions.TerminationTarget? + let confirmTermination: @MainActor ( + ProcessActions.TerminationTarget, + @escaping () -> Void + ) -> ProcessActions.TerminationResult? + let notify: (Int, String, Double, String?) -> Void + + init( + isOwnProcess: @escaping (Int) -> Bool, + suspend: @escaping (Int) -> Void, + terminationTarget: @escaping (Int, String) -> ProcessActions.TerminationTarget?, + confirmTermination: @escaping @MainActor ( + ProcessActions.TerminationTarget, + @escaping () -> Void + ) -> ProcessActions.TerminationResult?, + notify: @escaping (Int, String, Double, String?) -> Void = { _, _, _, _ in } + ) { + self.isOwnProcess = isOwnProcess + self.suspend = suspend + self.terminationTarget = terminationTarget + self.confirmTermination = confirmTermination + self.notify = notify + } + + static let live = ProcessControl( + isOwnProcess: ProcessActions.isOwnProcess(pid:), + suspend: { _ = ProcessActions.suspend(pid: $0) }, + terminationTarget: { ProcessActions.terminationTarget(pid: $0, displayName: $1) }, + confirmTermination: { ProcessActions.confirmTermination($0, onRefresh: $1) }, + notify: { ProcessWatchdog.notify(pid: $0, name: $1, threshold: $2, suffix: $3) } + ) + } + private var samples: [Int: [Double]] = [:] // pid → recent cpu%, oldest→newest private var fired: Set = [] // pids already actioned (dedup until they calm) private let cap = 64 + private let processControl: ProcessControl + private let configuredAction: () -> ProcessRule.Action + + init( + processControl: ProcessControl = .live, + configuredAction: @escaping () -> ProcessRule.Action = { + ProcessRule.Action(rawValue: Store.processWatchdogAction) ?? .notify + } + ) { + self.processControl = processControl + self.configuredAction = configuredAction + } /// Feed one process tick. Returns the processes that NEWLY fired the rule so /// the caller can dispatch. The pump cadence is ~2s, so the rule's @@ -53,24 +102,31 @@ final class ProcessWatchdog { } /// Dispatch the configured action for a fired process. - func dispatch(pid: Int, name: String) { + @MainActor + func dispatch(pid: Int, name: String, onRefresh: @escaping () -> Void = {}) { let threshold = Store.processWatchdogCPU switch action { case .notify: - Self.notify(pid: pid, name: name, threshold: threshold) + processControl.notify(pid, name, threshold, nil) case .suspend: - if ProcessActions.isOwnProcess(pid: pid) { ProcessActions.suspend(pid: pid) } - Self.notify(pid: pid, name: name, threshold: threshold, - suffix: NSLocalizedString("suspended", comment: "")) + if processControl.isOwnProcess(pid) { processControl.suspend(pid) } + processControl.notify(pid, name, threshold, NSLocalizedString("suspended", comment: "")) case .quit: - if ProcessActions.isOwnProcess(pid: pid) { ProcessActions.quit(pid: pid) } - Self.notify(pid: pid, name: name, threshold: threshold, - suffix: NSLocalizedString("asked to quit", comment: "")) + guard let target = processControl.terminationTarget(pid, name) else { + onRefresh() + processControl.notify(pid, name, threshold, + NSLocalizedString("identity changed; no action taken", comment: "")) + return + } + if processControl.confirmTermination(target, onRefresh) == .sent { + processControl.notify(pid, name, threshold, + NSLocalizedString("asked to quit", comment: "")) + } } } private var action: ProcessRule.Action { - ProcessRule.Action(rawValue: Store.processWatchdogAction) ?? .notify + configuredAction() } private static func notify(pid: Int, name: String, threshold: Double, suffix: String? = nil) { diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index 1809e16c..426176d9 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -1074,7 +1074,9 @@ final class StatusModel: ObservableObject { recomputeSortedRows() // Opt-in watchdog: evaluate this tick, dispatch any new firings. for f in watchdog.step(processes: v.processes, cadenceSeconds: 2) { - watchdog.dispatch(pid: f.pid, name: f.name) + watchdog.dispatch(pid: f.pid, name: f.name) { [weak self] in + self?.invalidateProcess(f.pid) + } } } } diff --git a/macos/Sources/TuneUpView.swift b/macos/Sources/TuneUpView.swift index 13b6bf9b..b67390ad 100644 --- a/macos/Sources/TuneUpView.swift +++ b/macos/Sources/TuneUpView.swift @@ -425,7 +425,7 @@ struct TuneUpView: View { case .clean(let plan): flow.start(ToolOperation( label: NSLocalizedString("Tune-Up: cleaning reviewed caches", comment: ""), - executable: .path("/usr/bin/find"), arguments: [], elevated: true, + executable: .path("/bin/sh"), arguments: [], elevated: true, cleanupPlan: plan, reduce: { parseTaskReport($0) }, notifyOnEnd: true)) case .optimize: flow.start(.moleStream(["optimize"], elevated: true, @@ -455,14 +455,13 @@ struct TuneUpView: View { private func handleFlowChange() { guard phase == .running else { return } switch flow.state { - case .finished(.done): + case .finished(.done(exit: 0)): if let line = flow.report?.summary?.completionLine, !line.isEmpty { stepSummaries.append(line) } advance() - case .finished(.failed), .finished(.cancelled): + case .finished(.done), .finished(.failed), .finished(.cancelled): phase = .done - recordRun() default: break } diff --git a/macos/Sources/UpdateWorkflow.swift b/macos/Sources/UpdateWorkflow.swift index d6cb1a18..e35b9482 100644 --- a/macos/Sources/UpdateWorkflow.swift +++ b/macos/Sources/UpdateWorkflow.swift @@ -13,10 +13,19 @@ import CryptoKit import Security import Darwin +@_silgen_name("removefileat") +private func descriptorRelativeRemoveFile( + _ directoryFD: Int32, + _ path: UnsafePointer, + _ state: OpaquePointer?, + _ flags: UInt32 +) -> Int32 + enum BundleVerificationFailure: String, Equatable, Sendable { case bundleIdentityChanged case signingIdentityChanged case invalidSignature + case artifactIdentityChanged var message: String { switch self { @@ -26,6 +35,8 @@ enum BundleVerificationFailure: String, Equatable, Sendable { return NSLocalizedString("The downloaded app is signed by a different developer.", comment: "") case .invalidSignature: return NSLocalizedString("macOS could not validate the downloaded app's code signature.", comment: "") + case .artifactIdentityChanged: + return NSLocalizedString("The app changed after Burrow verified it. Check for updates again.", comment: "") } } } @@ -274,11 +285,40 @@ struct ElectronUpdateDescriptor: Equatable, Sendable { } } +struct BundleFileIdentity: Equatable, Sendable { + let device: UInt64 + let inode: UInt64 +} + struct BundleUpdateIdentity: Equatable, Sendable { let bundleID: String let signingIdentifier: String? let teamIdentifier: String? let signatureValid: Bool + let version: String? + let build: String? + let codeDirectoryHash: Data? + let fileIdentity: BundleFileIdentity? + + init( + bundleID: String, + signingIdentifier: String?, + teamIdentifier: String?, + signatureValid: Bool, + version: String? = nil, + build: String? = nil, + codeDirectoryHash: Data? = nil, + fileIdentity: BundleFileIdentity? = nil + ) { + self.bundleID = bundleID + self.signingIdentifier = signingIdentifier + self.teamIdentifier = teamIdentifier + self.signatureValid = signatureValid + self.version = version + self.build = build + self.codeDirectoryHash = codeDirectoryHash + self.fileIdentity = fileIdentity + } func verificationFailure(comparedWith candidate: BundleUpdateIdentity) -> BundleVerificationFailure? { guard candidate.signatureValid else { return .invalidSignature } @@ -291,8 +331,23 @@ struct BundleUpdateIdentity: Equatable, Sendable { return nil } + func artifactVerificationFailure( + comparedWith current: BundleUpdateIdentity, + requireSameFile: Bool = true + ) -> BundleVerificationFailure? { + if let failure = verificationFailure(comparedWith: current) { return failure } + guard let version, let build, let codeDirectoryHash, let fileIdentity, + current.version == version, + current.build == build, + current.codeDirectoryHash == codeDirectoryHash, + !codeDirectoryHash.isEmpty else { return .artifactIdentityChanged } + if requireSameFile, current.fileIdentity != fileIdentity { + return .artifactIdentityChanged + } + return nil + } + static func read(appURL: URL) -> BundleUpdateIdentity? { - guard let bundle = Bundle(url: appURL), let bundleID = bundle.bundleIdentifier else { return nil } var staticCode: SecStaticCode? guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, let staticCode else { return nil } @@ -307,19 +362,27 @@ struct BundleUpdateIdentity: Equatable, Sendable { SecCSFlags(rawValue: signingInformation), &information ) == errSecSuccess, - let values = information as? [String: Any] else { - return BundleUpdateIdentity( - bundleID: bundleID, - signingIdentifier: nil, - teamIdentifier: nil, - signatureValid: valid - ) - } + let values = information as? [String: Any], + let sealedInfo = values[kSecCodeInfoPList as String] as? [String: Any], + let bundleID = sealedInfo[kCFBundleIdentifierKey as String] as? String, + let version = sealedInfo["CFBundleShortVersionString"] as? String, + let build = sealedInfo[kCFBundleVersionKey as String] as? String, + let codeDirectoryHash = values[kSecCodeInfoUnique as String] as? Data, + !codeDirectoryHash.isEmpty else { return nil } + var status = stat() + guard lstat(appURL.path, &status) == 0 else { return nil } return BundleUpdateIdentity( bundleID: bundleID, signingIdentifier: values[kSecCodeInfoIdentifier as String] as? String, teamIdentifier: values[kSecCodeInfoTeamIdentifier as String] as? String, - signatureValid: valid + signatureValid: valid, + version: version, + build: build, + codeDirectoryHash: codeDirectoryHash, + fileIdentity: BundleFileIdentity( + device: UInt64(status.st_dev), + inode: UInt64(status.st_ino) + ) ) } } @@ -328,10 +391,38 @@ struct StagedElectronUpdate: Sendable { let targetURL: URL let candidateURL: URL let stagingDirectory: URL + let stagingDirectoryIdentity: BundleFileIdentity + let canonicalStagingDirectoryURL: URL let expectedIdentity: BundleUpdateIdentity + let expectedCandidateIdentity: BundleUpdateIdentity? + let descriptorVersion: String? + + init( + targetURL: URL, + candidateURL: URL, + stagingDirectory: URL, + stagingDirectoryIdentity: BundleFileIdentity, + canonicalStagingDirectoryURL: URL, + expectedIdentity: BundleUpdateIdentity, + expectedCandidateIdentity: BundleUpdateIdentity? = nil, + descriptorVersion: String? = nil + ) { + self.targetURL = targetURL + self.candidateURL = candidateURL + self.stagingDirectory = stagingDirectory + self.stagingDirectoryIdentity = stagingDirectoryIdentity + self.canonicalStagingDirectoryURL = canonicalStagingDirectoryURL + self.expectedIdentity = expectedIdentity + self.expectedCandidateIdentity = expectedCandidateIdentity + self.descriptorVersion = descriptorVersion + } func discard() { - try? FileManager.default.removeItem(at: stagingDirectory) + PrivateUpdateDirectory.discard( + at: stagingDirectory, + expectedIdentity: stagingDirectoryIdentity, + expectedCanonicalURL: canonicalStagingDirectoryURL + ) } } @@ -360,7 +451,9 @@ enum PrivateUpdateDirectory { let url = URL(fileURLWithPath: path, isDirectory: true) guard chmod(url.path, S_IRWXU) == 0 else { let code = errno - try? FileManager.default.removeItem(at: url) + // The path has not been pinned yet. Never recurse through a name + // that another process could have replaced after mkdtemp. + _ = rmdir(url.path) throw NSError( domain: NSPOSIXErrorDomain, code: Int(code), @@ -369,6 +462,139 @@ enum PrivateUpdateDirectory { } return url } + + static func discard( + at directoryURL: URL, + expectedIdentity: BundleFileIdentity, + expectedCanonicalURL: URL, + afterOpeningPinnedRoot: ((URL) -> Void)? = nil + ) { + let sourceURL = directoryURL.standardizedFileURL + guard sourceURL.resolvingSymlinksInPath().standardizedFileURL + == expectedCanonicalURL.standardizedFileURL else { return } + + let parentURL = sourceURL.deletingLastPathComponent() + let parentFD = open(parentURL.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard parentFD >= 0 else { return } + defer { close(parentFD) } + + let sourceName = sourceURL.lastPathComponent + let quarantineName = ".Burrow Discard \(UUID().uuidString)" + let quarantineURL = parentURL.appendingPathComponent(quarantineName, isDirectory: true) + do { + try moveExclusively( + in: parentFD, + from: sourceName, + to: quarantineName + ) + } catch { + return + } + + let rootFD = openat(parentFD, quarantineName, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard rootFD >= 0 else { + try? moveExclusively(in: parentFD, from: quarantineName, to: sourceName) + return + } + defer { close(rootFD) } + + var rootStatus = stat() + guard fstat(rootFD, &rootStatus) == 0, + directoryIdentity(from: rootStatus) == expectedIdentity else { + // We captured a replacement tree, not Burrow's pinned staging + // root. Put it back only if its original name is still vacant; + // otherwise leave the quarantined tree intact for its owner. + try? moveExclusively(in: parentFD, from: quarantineName, to: sourceName) + return + } + + afterOpeningPinnedRoot?(quarantineURL) + + // removefileat traverses relative to the verified, held root FD. KEEP_PARENT + // leaves that exact directory in place until the parent-relative unlink + // below; symlink entries are removed rather than followed. + let recursive = UInt32(1 << 0) + let keepParent = UInt32(1 << 1) + let recursiveSlim = UInt32(1 << 11) + let removalResult = ".".withCString { + descriptorRelativeRemoveFile( + rootFD, + $0, + nil, + recursive | keepParent | recursiveSlim + ) + } + guard removalResult == 0, + fstat(rootFD, &rootStatus) == 0, + directoryIdentity(from: rootStatus) == expectedIdentity else { return } + + // Recheck the parent entry before unlinking. If the pathname was + // replaced, AT_REMOVEDIR cannot remove a nonempty substitution; an + // identity mismatch is left untouched even when it is empty. + var namedStatus = stat() + let namedIdentity: BundleFileIdentity? = quarantineName.withCString { name in + guard fstatat(parentFD, name, &namedStatus, AT_SYMLINK_NOFOLLOW) == 0 else { return nil } + return directoryIdentity(from: namedStatus) + } + guard namedIdentity == expectedIdentity else { return } + _ = quarantineName.withCString { name in + unlinkat(parentFD, name, AT_REMOVEDIR) + } + } + + static func directoryIdentity(at url: URL) -> BundleFileIdentity? { + var status = stat() + guard lstat(url.path, &status) == 0, + status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } + return directoryIdentity(from: status) + } + + private static func directoryIdentity(from status: stat) -> BundleFileIdentity? { + guard status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } + return BundleFileIdentity(device: UInt64(status.st_dev), inode: UInt64(status.st_ino)) + } + + static func moveExclusively(from sourceURL: URL, to destinationURL: URL) throws { + let result = sourceURL.path.withCString { sourcePath in + destinationURL.path.withCString { destinationPath in + renamex_np(sourcePath, destinationPath, UInt32(RENAME_EXCL)) + } + } + guard result == 0 else { + let code = errno + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(code))] + ) + } + } + + private static func moveExclusively( + in parentFD: Int32, + from sourceName: String, + to destinationName: String + ) throws { + let result = sourceName.withCString { sourcePath in + destinationName.withCString { destinationPath in + renameatx_np( + parentFD, + sourcePath, + parentFD, + destinationPath, + UInt32(RENAME_EXCL) + ) + } + } + guard result == 0 else { + let code = errno + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(code))] + ) + } + } } enum ElectronStageOutcome: Sendable { @@ -381,33 +607,532 @@ enum ElectronInstallOutcome: Sendable { case failure(UpdateFailure) } +enum ElectronPostReplacementDecision: Equatable, Sendable { + case accept + case restore( + installedIdentity: BundleUpdateIdentity?, + backupIdentity: BundleUpdateIdentity, + failure: UpdateFailure + ) + case fail(UpdateFailure) +} + enum ElectronReplacementInstaller { + static func stagingDirectoryIdentity(at url: URL) -> BundleFileIdentity? { + PrivateUpdateDirectory.directoryIdentity(at: url) + } + + static func candidateLocationVerificationFailure( + candidateURL: URL, + stagingDirectory: URL, + expectedStagingIdentity: BundleFileIdentity, + expectedCanonicalStagingDirectory: URL + ) -> UpdateFailure? { + let lexicalRoot = stagingDirectory.standardizedFileURL + let lexicalCandidate = candidateURL.standardizedFileURL + guard stagingDirectoryIdentity(at: lexicalRoot) == expectedStagingIdentity else { + return .verification(.artifactIdentityChanged) + } + + let currentCanonicalRoot = lexicalRoot.resolvingSymlinksInPath().standardizedFileURL + let canonicalCandidate = lexicalCandidate.resolvingSymlinksInPath().standardizedFileURL + guard currentCanonicalRoot == expectedCanonicalStagingDirectory.standardizedFileURL, + isStrictDescendant(canonicalCandidate, of: currentCanonicalRoot), + isStrictDescendant(lexicalCandidate, of: lexicalRoot) else { + return .verification(.artifactIdentityChanged) + } + + // Walk the root-relative descriptor path with lstat. Canonical + // containment alone would accept an intermediate symlink that happens + // to resolve back inside the staging tree, while the installed + // artifact would still be a mutable link rather than a pinned bundle. + let relativeComponents = lexicalCandidate.pathComponents.dropFirst(lexicalRoot.pathComponents.count) + var currentURL = lexicalRoot + for component in relativeComponents { + currentURL.appendPathComponent(component) + guard stagingDirectoryIdentity(at: currentURL) != nil else { + return .verification(.artifactIdentityChanged) + } + } + return nil + } + + private static func isStrictDescendant(_ candidate: URL, of root: URL) -> Bool { + let candidateComponents = candidate.standardizedFileURL.pathComponents + let rootComponents = root.standardizedFileURL.pathComponents + return candidateComponents.count > rootComponents.count + && candidateComponents.prefix(rootComponents.count).elementsEqual(rootComponents) + } + + static func stagingVerificationFailure( + expectedIdentity: BundleUpdateIdentity, + candidateIdentity: BundleUpdateIdentity, + descriptorVersion: String + ) -> UpdateFailure? { + if let failure = expectedIdentity.artifactVerificationFailure(comparedWith: expectedIdentity) { + return .verification(failure) + } + if let failure = expectedIdentity.verificationFailure(comparedWith: candidateIdentity) { + return .verification(failure) + } + if let failure = candidateIdentity.artifactVerificationFailure(comparedWith: candidateIdentity) { + return .verification(failure) + } + guard candidateIdentity.version == descriptorVersion else { + return .verification(.artifactIdentityChanged) + } + return nil + } + static func boundaryVerificationFailure( for staged: StagedElectronUpdate, + validateCandidateLocation: (StagedElectronUpdate) -> UpdateFailure? = { + candidateLocationVerificationFailure( + candidateURL: $0.candidateURL, + stagingDirectory: $0.stagingDirectory, + expectedStagingIdentity: $0.stagingDirectoryIdentity, + expectedCanonicalStagingDirectory: $0.canonicalStagingDirectoryURL + ) + }, readIdentity: (URL) -> BundleUpdateIdentity? = { BundleUpdateIdentity.read(appURL: $0) } ) -> UpdateFailure? { + if let failure = validateCandidateLocation(staged) { return failure } guard let targetIdentity = readIdentity(staged.targetURL) else { return .verification(.bundleIdentityChanged) } - if let failure = staged.expectedIdentity.verificationFailure(comparedWith: targetIdentity) { + if let failure = staged.expectedIdentity.artifactVerificationFailure(comparedWith: targetIdentity) { return .verification(failure) } + guard let expectedCandidateIdentity = staged.expectedCandidateIdentity, + let descriptorVersion = staged.descriptorVersion, + expectedCandidateIdentity.version == descriptorVersion else { + return .verification(.artifactIdentityChanged) + } guard let candidateIdentity = readIdentity(staged.candidateURL) else { return .verification(.invalidSignature) } - if let failure = staged.expectedIdentity.verificationFailure(comparedWith: candidateIdentity) { + if let failure = expectedCandidateIdentity.artifactVerificationFailure(comparedWith: candidateIdentity) { return .verification(failure) } return nil } + static func postReplacementDecision( + for staged: StagedElectronUpdate, + backupURL: URL, + readIdentity: (URL) -> BundleUpdateIdentity? = { BundleUpdateIdentity.read(appURL: $0) } + ) -> ElectronPostReplacementDecision { + // Read both moved artifacts even if the installed candidate is bad. + // A valid backup is the only safe recovery path, and selecting it + // based on existence alone would let a path swap drive rollback. + let installedIdentity = readIdentity(staged.targetURL) + let backupIdentity = readIdentity(backupURL) + + let installedFailure: UpdateFailure? + if let expectedCandidateIdentity = staged.expectedCandidateIdentity, + let descriptorVersion = staged.descriptorVersion, + expectedCandidateIdentity.version == descriptorVersion, + let installedIdentity { + installedFailure = expectedCandidateIdentity + .artifactVerificationFailure(comparedWith: installedIdentity) + .map(UpdateFailure.verification) + } else { + installedFailure = .verification(.artifactIdentityChanged) + } + + guard let backupIdentity, + isEligibleRollbackIdentity(backupIdentity, for: staged) else { + return .fail(recoveryFailure()) + } + + let backupIsPinnedTarget = staged.expectedIdentity + .artifactVerificationFailure(comparedWith: backupIdentity) == nil + if installedFailure == nil, backupIsPinnedTarget { + return .accept + } + + return .restore( + installedIdentity: installedIdentity, + backupIdentity: backupIdentity, + failure: installedFailure ?? .verification(.artifactIdentityChanged) + ) + } + + static func postReplacementVerificationFailure( + for staged: StagedElectronUpdate, + backupURL: URL, + readIdentity: (URL) -> BundleUpdateIdentity? = { BundleUpdateIdentity.read(appURL: $0) } + ) -> UpdateFailure? { + switch postReplacementDecision(for: staged, backupURL: backupURL, readIdentity: readIdentity) { + case .accept: + return nil + case let .restore(_, _, failure), let .fail(failure): + return failure + } + } + + static func replacePreservingBackup( + targetURL: URL, + candidateURL: URL, + backupName: String, + fileManager: FileManager = .default + ) throws { + _ = try fileManager.replaceItemAt( + targetURL, + withItemAt: candidateURL, + backupItemName: backupName, + options: [.withoutDeletingBackupItem] + ) + } + + static func removePreservedBackup( + at backupURL: URL, + fileManager: FileManager = .default + ) throws { + try fileManager.removeItem(at: backupURL) + } + + static func moveItemExclusively( + from sourceURL: URL, + to destinationURL: URL + ) throws { + try PrivateUpdateDirectory.moveExclusively(from: sourceURL, to: destinationURL) + } + + static func pathEntryExists(at url: URL) -> Bool { + var status = stat() + return lstat(url.path, &status) == 0 + } + + static func restoreVerifiedBackup( + for staged: StagedElectronUpdate, + backupURL: URL, + capturedInstalledIdentity: BundleUpdateIdentity?, + capturedBackupIdentity: BundleUpdateIdentity, + replaceItem: (URL, URL, String) throws -> Void = { targetURL, replacementURL, backupName in + try replacePreservingBackup( + targetURL: targetURL, + candidateURL: replacementURL, + backupName: backupName + ) + }, + moveItemExclusively: (URL, URL) throws -> Void = { sourceURL, destinationURL in + try moveItemExclusively(from: sourceURL, to: destinationURL) + }, + pathExists: (URL) -> Bool = { pathEntryExists(at: $0) }, + readIdentity: (URL) -> BundleUpdateIdentity? = { BundleUpdateIdentity.read(appURL: $0) } + ) -> UpdateFailure? { + guard isEligibleRollbackIdentity(capturedBackupIdentity, for: staged) else { + return recoveryFailure() + } + + // Revalidate both paths immediately before consuming either one. The + // decision was made earlier, so a modal event loop or another updater + // could otherwise swap the target or backup before rollback begins. + let currentInstalledIdentity = readIdentity(staged.targetURL) + let currentBackupIdentity = readIdentity(backupURL) + guard currentInstalledIdentity == capturedInstalledIdentity, + currentBackupIdentity == capturedBackupIdentity else { + return recoveryFailure() + } + + // `replaceItemAt` requires the target to exist. If the moved-in app + // vanished entirely, use an exclusive rename so a target appearing + // concurrently is never overwritten by the recovery operation. + if capturedInstalledIdentity == nil, !pathExists(staged.targetURL) { + do { + try moveItemExclusively(backupURL, staged.targetURL) + } catch { + return recoveryFailure(error) + } + guard readIdentity(staged.targetURL) == capturedBackupIdentity else { + return recoveryFailure() + } + return nil + } + + let displacedName = ".Burrow Displaced Candidate \(UUID().uuidString).app" + let displacedURL = staged.targetURL.deletingLastPathComponent().appendingPathComponent(displacedName) + var replacementError: Error? + do { + try replaceItem(staged.targetURL, backupURL, displacedName) + } catch { + replacementError = error + } + + // Verify both sides of the replacement before deleting anything. A + // newer app can race into the target after the precheck and will then + // be the item preserved at `displacedURL`. + let restoredIdentity = readIdentity(staged.targetURL) + let displacedIdentity = readIdentity(displacedURL) + if restoredIdentity == capturedBackupIdentity, + displacedIdentity == capturedInstalledIdentity { + if capturedInstalledIdentity != nil { + try? removePreservedBackup(at: displacedURL) + } + return nil + } + + if let capturedInstalledIdentity, + displacedIdentity == capturedInstalledIdentity, + isPinnedExpectedCandidate(capturedInstalledIdentity, for: staged), + replaceAndVerifyPreservedArtifact( + staged: staged, + sourceURL: displacedURL, + desiredIdentity: capturedInstalledIdentity, + capturedCurrentTargetIdentity: restoredIdentity, + replaceItem: replaceItem, + readIdentity: readIdentity + ) { + return recoveryFailure(replacementError) + } + + if let displacedIdentity, + shouldPreferDisplacedIdentity( + displacedIdentity, + over: capturedBackupIdentity, + for: staged + ), + replaceAndVerifyPreservedArtifact( + staged: staged, + sourceURL: displacedURL, + desiredIdentity: displacedIdentity, + capturedCurrentTargetIdentity: restoredIdentity, + replaceItem: replaceItem, + readIdentity: readIdentity + ) { + return nil + } + + return recoveryFailure(replacementError) + } + + private static func replaceAndVerifyPreservedArtifact( + staged: StagedElectronUpdate, + sourceURL: URL, + desiredIdentity: BundleUpdateIdentity, + capturedCurrentTargetIdentity: BundleUpdateIdentity?, + replaceItem: (URL, URL, String) throws -> Void, + readIdentity: (URL) -> BundleUpdateIdentity? + ) -> Bool { + let currentTargetIdentity = readIdentity(staged.targetURL) + let currentSourceIdentity = readIdentity(sourceURL) + guard currentTargetIdentity == capturedCurrentTargetIdentity, + currentSourceIdentity == desiredIdentity else { return false } + + let quarantineName = ".Burrow Failed Rollback \(UUID().uuidString).app" + let quarantineURL = staged.targetURL.deletingLastPathComponent().appendingPathComponent(quarantineName) + do { + try replaceItem(staged.targetURL, sourceURL, quarantineName) + } catch { + return false + } + let installedIdentity = readIdentity(staged.targetURL) + let quarantinedIdentity = readIdentity(quarantineURL) + guard installedIdentity == desiredIdentity, + quarantinedIdentity == capturedCurrentTargetIdentity else { return false } + if capturedCurrentTargetIdentity != nil { + try? removePreservedBackup(at: quarantineURL) + } + return true + } + + private static func isPinnedExpectedCandidate( + _ identity: BundleUpdateIdentity, + for staged: StagedElectronUpdate + ) -> Bool { + guard let expectedCandidateIdentity = staged.expectedCandidateIdentity, + expectedCandidateIdentity.version == staged.descriptorVersion else { return false } + return expectedCandidateIdentity.artifactVerificationFailure(comparedWith: identity) == nil + } + + private static func shouldPreferDisplacedIdentity( + _ displacedIdentity: BundleUpdateIdentity, + over restoredIdentity: BundleUpdateIdentity, + for staged: StagedElectronUpdate + ) -> Bool { + guard displacedIdentity != restoredIdentity, + isEligibleRollbackIdentity(displacedIdentity, for: staged) else { return false } + return isProvablyNotOlder(displacedIdentity, than: restoredIdentity) + } + + private static func isEligibleRollbackIdentity( + _ backupIdentity: BundleUpdateIdentity, + for staged: StagedElectronUpdate + ) -> Bool { + guard staged.expectedIdentity.verificationFailure(comparedWith: backupIdentity) == nil, + backupIdentity.artifactVerificationFailure(comparedWith: backupIdentity) == nil else { + return false + } + if staged.expectedIdentity.artifactVerificationFailure(comparedWith: backupIdentity) == nil { + return true + } + guard let expectedCandidateIdentity = staged.expectedCandidateIdentity else { return false } + return isProvablyNotOlder(backupIdentity, than: expectedCandidateIdentity) + } + + private static func isProvablyNotOlder( + _ backupIdentity: BundleUpdateIdentity, + than candidateIdentity: BundleUpdateIdentity + ) -> Bool { + guard let backupVersion = backupIdentity.version, + let backupBuild = backupIdentity.build, + let candidateVersion = candidateIdentity.version, + let candidateBuild = candidateIdentity.build, + let versionComparison = semanticVersionComparison(backupVersion, candidateVersion) + ?? numericVersionComparison(backupVersion, candidateVersion) else { + return false + } + if versionComparison == .orderedDescending { return true } + if versionComparison == .orderedAscending { return false } + guard let buildComparison = numericVersionComparison(backupBuild, candidateBuild) else { return false } + if buildComparison == .orderedDescending { return true } + if buildComparison == .orderedAscending { return false } + // Equal advertised versions are only ordered safely when the sealed + // code identity is also identical. An arbitrary same-version build + // is not evidence that it is at least as new. + return backupIdentity.codeDirectoryHash == candidateIdentity.codeDirectoryHash + } + + private enum SemanticVersionIdentifier: Equatable { + case numeric(UInt64) + case text(String) + } + + private struct SemanticVersion { + let core: [UInt64] + let prerelease: [SemanticVersionIdentifier]? + } + + private static func semanticVersionComparison( + _ lhs: String, + _ rhs: String + ) -> ComparisonResult? { + guard let lhsVersion = parseSemanticVersion(lhs), + let rhsVersion = parseSemanticVersion(rhs) else { return nil } + for index in 0..<3 where lhsVersion.core[index] != rhsVersion.core[index] { + return lhsVersion.core[index] < rhsVersion.core[index] ? .orderedAscending : .orderedDescending + } + switch (lhsVersion.prerelease, rhsVersion.prerelease) { + case (nil, nil): + return .orderedSame + case (nil, .some): + return .orderedDescending + case (.some, nil): + return .orderedAscending + case let (.some(lhsIdentifiers), .some(rhsIdentifiers)): + for index in 0.. SemanticVersion? { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("v") || value.hasPrefix("V") { value.removeFirst() } + let withoutBuildMetadata = value.split( + separator: "+", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard !withoutBuildMetadata[0].isEmpty, + withoutBuildMetadata.count == 1 || !withoutBuildMetadata[1].isEmpty else { return nil } + let versionAndPrerelease = withoutBuildMetadata[0].split( + separator: "-", + maxSplits: 1, + omittingEmptySubsequences: false + ) + let coreParts = versionAndPrerelease[0].split(separator: ".", omittingEmptySubsequences: false) + guard coreParts.count == 3 else { return nil } + var core: [UInt64] = [] + for part in coreParts { + guard !part.isEmpty, + (part.count == 1 || part.first != "0"), + let number = UInt64(part) else { return nil } + core.append(number) + } + + var prerelease: [SemanticVersionIdentifier]? + if versionAndPrerelease.count == 2 { + let parts = versionAndPrerelease[1].split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty else { return nil } + var identifiers: [SemanticVersionIdentifier] = [] + for part in parts { + guard !part.isEmpty, + part.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) else { + return nil + } + if part.allSatisfy(\.isNumber) { + guard (part.count == 1 || part.first != "0"), let number = UInt64(part) else { return nil } + identifiers.append(.numeric(number)) + } else { + identifiers.append(.text(String(part))) + } + } + prerelease = identifiers + } + return SemanticVersion(core: core, prerelease: prerelease) + } + + private static func numericVersionComparison( + _ lhs: String, + _ rhs: String + ) -> ComparisonResult? { + func components(_ value: String) -> [UInt64]? { + var value = value.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("v") || value.hasPrefix("V") { value.removeFirst() } + let rawComponents = value.split(separator: ".", omittingEmptySubsequences: false) + guard !rawComponents.isEmpty else { return nil } + var result: [UInt64] = [] + for component in rawComponents { + guard !component.isEmpty, let number = UInt64(component) else { return nil } + result.append(number) + } + return result + } + guard let lhsComponents = components(lhs), let rhsComponents = components(rhs) else { return nil } + for index in 0.. rhsComponent { return .orderedDescending } + } + return .orderedSame + } + + private static func recoveryFailure(_ error: Error? = nil) -> UpdateFailure { + var message = NSLocalizedString( + "Burrow could not verify or safely restore the previous app after replacement. Reinstall it from its developer before retrying.", + comment: "" + ) + if let error { message += " \(error.localizedDescription)" } + return .installation(message) + } + static func stage( appPath: String, descriptor: ElectronUpdateDescriptor ) async -> ElectronStageOutcome { let targetURL = URL(fileURLWithPath: appPath, isDirectory: true) guard let expectedIdentity = BundleUpdateIdentity.read(appURL: targetURL), - expectedIdentity.verificationFailure(comparedWith: expectedIdentity) == nil else { + expectedIdentity.artifactVerificationFailure(comparedWith: expectedIdentity) == nil else { return .failure(.unsupported(NSLocalizedString( "This Electron app does not expose a verifiable signing identity, so Burrow opened its own updater instead.", comment: "" @@ -420,6 +1145,18 @@ enum ElectronReplacementInstaller { } catch { return .failure(.installation(error.localizedDescription)) } + guard let stagingIdentity = stagingDirectoryIdentity(at: root) else { + _ = rmdir(root.path) + return .failure(.verification(.artifactIdentityChanged)) + } + let canonicalStagingDirectory = root.resolvingSymlinksInPath().standardizedFileURL + let discardRoot = { + PrivateUpdateDirectory.discard( + at: root, + expectedIdentity: stagingIdentity, + expectedCanonicalURL: canonicalStagingDirectory + ) + } let archive = root.appendingPathComponent("update.zip") let extracted = root.appendingPathComponent("Extracted", isDirectory: true) @@ -450,24 +1187,42 @@ enum ElectronReplacementInstaller { guard extraction.exitCode == 0 else { throw UpdateFailure.installation(NSLocalizedString("The downloaded update could not be extracted.", comment: "")) } - guard let candidate = firstApplication(in: extracted), - let candidateIdentity = BundleUpdateIdentity.read(appURL: candidate) else { + guard let candidate = firstApplication(in: extracted) else { throw UpdateFailure.verification(.invalidSignature) } - if let failure = expectedIdentity.verificationFailure(comparedWith: candidateIdentity) { - throw UpdateFailure.verification(failure) + if let failure = candidateLocationVerificationFailure( + candidateURL: candidate, + stagingDirectory: root, + expectedStagingIdentity: stagingIdentity, + expectedCanonicalStagingDirectory: canonicalStagingDirectory + ) { + throw failure + } + guard let candidateIdentity = BundleUpdateIdentity.read(appURL: candidate) else { + throw UpdateFailure.verification(.invalidSignature) + } + if let failure = stagingVerificationFailure( + expectedIdentity: expectedIdentity, + candidateIdentity: candidateIdentity, + descriptorVersion: descriptor.version + ) { + throw failure } return .ready(StagedElectronUpdate( targetURL: targetURL, candidateURL: candidate, stagingDirectory: root, - expectedIdentity: expectedIdentity + stagingDirectoryIdentity: stagingIdentity, + canonicalStagingDirectoryURL: canonicalStagingDirectory, + expectedIdentity: expectedIdentity, + expectedCandidateIdentity: candidateIdentity, + descriptorVersion: descriptor.version )) } catch let failure as UpdateFailure { - try? FileManager.default.removeItem(at: root) + discardRoot() return .failure(failure) } catch let error as URLError { - try? FileManager.default.removeItem(at: root) + discardRoot() let failure: UpdateFailure switch error.code { case .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .dataNotAllowed: @@ -481,7 +1236,7 @@ enum ElectronReplacementInstaller { } return .failure(failure) } catch { - try? FileManager.default.removeItem(at: root) + discardRoot() return .failure(.installation(error.localizedDescription)) } } @@ -489,9 +1244,8 @@ enum ElectronReplacementInstaller { @MainActor static func install(_ staged: StagedElectronUpdate) async -> ElectronInstallOutcome { defer { staged.discard() } - guard let currentIdentity = BundleUpdateIdentity.read(appURL: staged.targetURL), - staged.expectedIdentity.verificationFailure(comparedWith: currentIdentity) == nil else { - return .failure(.verification(.bundleIdentityChanged)) + if let failure = boundaryVerificationFailure(for: staged) { + return .failure(failure) } guard FileManager.default.isWritableFile(atPath: staged.targetURL.deletingLastPathComponent().path) else { return .failure(.unsupported(NSLocalizedString( @@ -515,32 +1269,36 @@ enum ElectronReplacementInstaller { ))) } - // Revalidate again after waiting for termination. An app or another - // updater may have replaced the target while the quit request ran. - // Both bundles have remained on disk while the target app quit. - // Re-read them at the replacement boundary so a stale target or - // swapped candidate fails closed. + let backupName = ".Burrow Update Backup \(UUID().uuidString).app" + let backupURL = staged.targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + // No suspension is allowed between this exact target/candidate check + // and replacement. Post-replace verification below also pins the + // moved inodes, closing a path-swap race in this final check/use gap. if let failure = boundaryVerificationFailure(for: staged) { return .failure(failure) } - - let backupName = ".Burrow Update Backup \(UUID().uuidString).app" - let backupURL = staged.targetURL.deletingLastPathComponent().appendingPathComponent(backupName) do { - _ = try FileManager.default.replaceItemAt( - staged.targetURL, - withItemAt: staged.candidateURL, - backupItemName: backupName, - options: [] + try replacePreservingBackup( + targetURL: staged.targetURL, + candidateURL: staged.candidateURL, + backupName: backupName ) - guard let installedIdentity = BundleUpdateIdentity.read(appURL: staged.targetURL), - staged.expectedIdentity.verificationFailure(comparedWith: installedIdentity) == nil else { - if FileManager.default.fileExists(atPath: backupURL.path) { - _ = try? FileManager.default.replaceItemAt(staged.targetURL, withItemAt: backupURL) + switch postReplacementDecision(for: staged, backupURL: backupURL) { + case .accept: + try? removePreservedBackup(at: backupURL) + case let .restore(installedIdentity, backupIdentity, failure): + if let recoveryFailure = restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: installedIdentity, + capturedBackupIdentity: backupIdentity + ) { + return .failure(recoveryFailure) } - return .failure(.verification(.signingIdentityChanged)) + return .failure(failure) + case let .fail(failure): + return .failure(failure) } - try? FileManager.default.removeItem(at: backupURL) let configuration = NSWorkspace.OpenConfiguration() _ = try? await NSWorkspace.shared.openApplication( at: staged.targetURL, diff --git a/macos/Sources/UpdatesView.swift b/macos/Sources/UpdatesView.swift index d217751b..a46e790a 100644 --- a/macos/Sources/UpdatesView.swift +++ b/macos/Sources/UpdatesView.swift @@ -54,6 +54,59 @@ struct AppUpdateItem: Identifiable { } } +/// One source-check response. Keeping this value independent from the view +/// model makes forced responses deterministic in tests and keeps retries from +/// smuggling partial state into the main actor. +struct AppUpdateCheckResult: Equatable, Sendable { + let id: String + var latestVersion: String? + var pageURL: URL? + var releaseNotesURL: URL? + var minimumOS: String? + var electronDescriptor: ElectronUpdateDescriptor? + var phase: UpdatePhase + + static func failure(id: String, failure: UpdateFailure) -> Self { + Self( + id: id, + latestVersion: nil, + pageURL: nil, + releaseNotesURL: nil, + minimumOS: nil, + electronDescriptor: nil, + phase: .failed(failure) + ) + } + + static func available( + id: String, + version: String, + electronDescriptor: ElectronUpdateDescriptor? = nil + ) -> Self { + Self( + id: id, + latestVersion: version, + pageURL: nil, + releaseNotesURL: nil, + minimumOS: nil, + electronDescriptor: electronDescriptor, + phase: .available + ) + } + + static func completed(id: String, version: String? = nil) -> Self { + Self( + id: id, + latestVersion: version, + pageURL: nil, + releaseNotesURL: nil, + minimumOS: nil, + electronDescriptor: nil, + phase: .completed + ) + } +} + struct UpdatesView: View { @ObservedObject var model: UpdatesModel var apps: [InstalledApp] = [] @@ -101,7 +154,7 @@ struct UpdatesView: View { Text(verbatim: "\(model.updateAllCompleted)/\(model.updateAllTotal)") .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) .accessibilityLabel(String( - format: NSLocalizedString("%d of %d updates complete", comment: ""), + format: NSLocalizedString("%d of %d update steps processed", comment: ""), model.updateAllCompleted, model.updateAllTotal )) @@ -367,20 +420,56 @@ final class UpdatesModel: ObservableObject { private var checkGeneration = 0 private let sourceDetector: (InstalledApp) -> UpdateSources.Source? private let sourceFingerprinter: (InstalledApp) -> String + private let checkItem: (AppUpdateItem) async -> AppUpdateCheckResult + private let retrySleep: (UInt64) async -> Void + private let retryDelayNanoseconds: UInt64 + private let loadBrewOutdated: () async -> [OutdatedItem] + private let stageElectron: (String, ElectronUpdateDescriptor) async -> ElectronStageOutcome + private let installElectron: @MainActor (StagedElectronUpdate) async -> ElectronInstallOutcome + private let confirmRestart: @MainActor (AppUpdateItem) -> Bool private var brewSurfaced = false private var electronDescriptors: [String: ElectronUpdateDescriptor] = [:] private var stagedElectronUpdates: [String: StagedElectronUpdate] = [:] - private var sparkleSessions: [String: ExternalSparkleUpdateSession] = [:] - private var appTasks: [String: Task] = [:] + private var checkFailureIDs: Set = [] + private struct TrackedSparkleSession { + let generation: UInt64 + let session: ExternalSparkleUpdateSession + } + private var sparkleSessions: [String: TrackedSparkleSession] = [:] + private var sparkleSessionGeneration: UInt64 = 0 + private struct TrackedAppTask { + let generation: UInt64 + let task: Task + } + private enum AppUpdateOwner { + case row(UInt64) + case updateAll(inventoryGeneration: Int) + } + private var appTasks: [String: TrackedAppTask] = [:] + private var appTaskGeneration: UInt64 = 0 private var updateAllTask: Task? private var cancelUpdateAllAfterCurrent = false init( detectSource: ((InstalledApp) -> UpdateSources.Source?)? = nil, - sourceFingerprint: ((InstalledApp) -> String)? = nil + sourceFingerprint: ((InstalledApp) -> String)? = nil, + checkItem: ((AppUpdateItem) async -> AppUpdateCheckResult)? = nil, + retrySleep: ((UInt64) async -> Void)? = nil, + retryDelayNanoseconds: UInt64 = 750_000_000, + loadBrewOutdated: (() async -> [OutdatedItem])? = nil, + stageElectron: ((String, ElectronUpdateDescriptor) async -> ElectronStageOutcome)? = nil, + installElectron: (@MainActor (StagedElectronUpdate) async -> ElectronInstallOutcome)? = nil, + confirmRestart: (@MainActor (AppUpdateItem) -> Bool)? = nil ) { sourceDetector = detectSource ?? { UpdateSources.detect(appPath: $0.path) } sourceFingerprinter = sourceFingerprint ?? { UpdateSources.detectionFingerprint(appPath: $0.path) } + self.checkItem = checkItem ?? { await Self.check($0) } + self.retrySleep = retrySleep ?? { delay in try? await Task.sleep(nanoseconds: delay) } + self.retryDelayNanoseconds = min(retryDelayNanoseconds, 2_000_000_000) + self.loadBrewOutdated = loadBrewOutdated ?? { await Self.brewOutdated() } + self.stageElectron = stageElectron ?? { await ElectronReplacementInstaller.stage(appPath: $0, descriptor: $1) } + self.installElectron = installElectron ?? { await ElectronReplacementInstaller.install($0) } + self.confirmRestart = confirmRestart ?? { Self.confirmRestartBeforeInstalling($0) } } var availableItems: [AppUpdateItem] { appItems.filter(\.updateAvailable) } @@ -429,10 +518,11 @@ final class UpdatesModel: ObservableObject { appItems.removeAll { !unchangedIDs.contains($0.id) } uncheckableApps.removeAll { !unchangedIDs.contains($0.id) } phases = phases.filter { nextIDs.contains($0.key) && unchangedIDs.contains($0.key) } + checkFailureIDs.formIntersection(unchangedIDs) electronDescriptors = electronDescriptors.filter { unchangedIDs.contains($0.key) } for id in invalidatedIDs { stagedElectronUpdates.removeValue(forKey: id)?.discard() - appTasks.removeValue(forKey: id)?.cancel() + cancelAppTask(for: id) } guard !apps.isEmpty else { return } @@ -472,7 +562,7 @@ final class UpdatesModel: ObservableObject { brewSurfaced = true brewSurfacing = true Task { - let brews = await Self.brewOutdated() + let brews = await loadBrewOutdated() await MainActor.run { if self.brewItems.isEmpty { self.brewItems = brews } self.brewSurfacing = false @@ -489,14 +579,28 @@ final class UpdatesModel: ObservableObject { checkGeneration &+= 1 let generation = checkGeneration let items = appItems - for item in items { phases[item.id] = .checking } + let checkItem = self.checkItem + let retrySleep = self.retrySleep + let retryDelayNanoseconds = self.retryDelayNanoseconds + let loadBrewOutdated = self.loadBrewOutdated + for item in items { + phases[item.id] = .checking + checkFailureIDs.remove(item.id) + } Task { - var results: [CheckResult] = [] - await withTaskGroup(of: CheckResult.self) { group in + var results: [AppUpdateCheckResult] = [] + await withTaskGroup(of: AppUpdateCheckResult.self) { group in var iterator = items.makeIterator() var inFlight = 0 func enqueue(_ item: AppUpdateItem) { - group.addTask { await Self.check(item) } + group.addTask { + await Self.checkWithRetry( + item, + checkItem: checkItem, + retrySleep: retrySleep, + retryDelayNanoseconds: retryDelayNanoseconds + ) + } } while inFlight < 6, let next = iterator.next() { enqueue(next); inFlight += 1 } for await result in group { @@ -504,22 +608,44 @@ final class UpdatesModel: ObservableObject { if let next = iterator.next() { enqueue(next) } } } - let brews = await Self.brewOutdated() + let brews = await loadBrewOutdated() await MainActor.run { guard generation == self.checkGeneration else { return } let byID = Dictionary(uniqueKeysWithValues: results.map { ($0.id, $0) }) self.appItems = self.appItems.map { live in guard let result = byID[live.id] else { return live } var copy = live - // A failed check never erases a previously known update. - if let latest = result.latestVersion { copy.latestVersion = latest } - if let pageURL = result.pageURL { copy.pageURL = pageURL } - if let releaseNotesURL = result.releaseNotesURL { copy.releaseNotesURL = releaseNotesURL } - if let minimumOS = result.minimumOS { copy.minimumOS = minimumOS } - if let descriptor = result.electronDescriptor { - self.electronDescriptors[live.id] = descriptor + // A failed, cached, or otherwise older response never + // erases a release we already confirmed. Apply metadata as + // one versioned unit so an older Electron descriptor cannot + // be paired with the preserved newer version. + let acceptsResultMetadata: Bool + if let latest = result.latestVersion { + if let known = live.latestVersion, + UpdateCheck.isNewer(known, than: latest) { + acceptsResultMetadata = false + } else { + copy.latestVersion = latest + acceptsResultMetadata = true + } + } else { + acceptsResultMetadata = false + } + if acceptsResultMetadata { + if let pageURL = result.pageURL { copy.pageURL = pageURL } + if let releaseNotesURL = result.releaseNotesURL { copy.releaseNotesURL = releaseNotesURL } + if let minimumOS = result.minimumOS { copy.minimumOS = minimumOS } + if let descriptor = result.electronDescriptor { + self.electronDescriptors[live.id] = descriptor + } + } + if case .failed = result.phase { + self.checkFailureIDs.insert(live.id) + self.phases[live.id] = result.phase + } else { + self.checkFailureIDs.remove(live.id) + self.phases[live.id] = copy.updateAvailable ? .available : result.phase } - self.phases[live.id] = result.phase return copy }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } self.brewItems = brews @@ -552,18 +678,21 @@ final class UpdatesModel: ObservableObject { } } - private struct CheckResult: Sendable { - let id: String - var latestVersion: String? - var pageURL: URL? - var releaseNotesURL: URL? - var minimumOS: String? - var electronDescriptor: ElectronUpdateDescriptor? - var phase: UpdatePhase + private static func checkWithRetry( + _ item: AppUpdateItem, + checkItem: (AppUpdateItem) async -> AppUpdateCheckResult, + retrySleep: (UInt64) async -> Void, + retryDelayNanoseconds: UInt64 + ) async -> AppUpdateCheckResult { + let first = await checkItem(item) + guard case let .failed(failure) = first.phase, failure.canRetry else { return first } + await retrySleep(retryDelayNanoseconds) + guard !Task.isCancelled else { return first } + return await checkItem(item) } - private static func check(_ item: AppUpdateItem) async -> CheckResult { - var result = CheckResult( + private static func check(_ item: AppUpdateItem) async -> AppUpdateCheckResult { + var result = AppUpdateCheckResult( id: item.id, latestVersion: nil, pageURL: nil, @@ -644,33 +773,137 @@ final class UpdatesModel: ObservableObject { } func update(_ item: AppUpdateItem) { - guard appTasks[item.id] == nil, !phase(for: item.id).isBusy else { return } + guard !updateAllRunning, + updateAllTask == nil, + appTasks[item.id] == nil, + !phase(for: item.id).isBusy else { return } + let generation = nextAppTaskGeneration() let task = Task { @MainActor [weak self] in guard let self else { return } - await self.performUpdate(item, installElectronImmediately: false) - self.appTasks.removeValue(forKey: item.id) + await self.performUpdate(item, owner: .row(generation)) + self.finishAppTask(for: item.id, generation: generation) } - appTasks[item.id] = task + appTasks[item.id] = TrackedAppTask(generation: generation, task: task) } func retry(_ item: AppUpdateItem) { - if item.updateAvailable { update(item) } + if checkFailureIDs.contains(item.id) { checkNow() } + else if item.updateAvailable { update(item) } else { checkNow() } } func installReady(_ item: AppUpdateItem) { - guard let staged = stagedElectronUpdates.removeValue(forKey: item.id) else { return } - appTasks[item.id]?.cancel() + guard !updateAllRunning, updateAllTask == nil else { return } + let inventoryGeneration = prepareGeneration + let capturedFingerprint = inventoryFingerprint(for: item) + guard isLiveUpdateIdentity(item), + let staged = stagedElectronUpdates[item.id], + confirmRestart(item) else { return } + // The restart alert runs a modal event loop. Any inventory/fingerprint + // change while it is open invalidates this consent, even if the same ID + // is detected and republished before the user returns. + guard prepareGeneration == inventoryGeneration, + inventoryFingerprint(for: item) == capturedFingerprint, + isLiveUpdateIdentity(item) else { return } + guard let currentStaged = stagedElectronUpdates[item.id], + isSameStagedUpdate(currentStaged, staged) else { + staged.discard() + return + } + stagedElectronUpdates.removeValue(forKey: item.id) + cancelAppTask(for: item.id) + let generation = nextAppTaskGeneration() let task = Task { @MainActor [weak self] in guard let self else { staged.discard(); return } - await self.install(staged, for: item.id) - self.appTasks.removeValue(forKey: item.id) + await self.install(staged, for: item.id, owner: .row(generation)) + self.finishAppTask(for: item.id, generation: generation) } - appTasks[item.id] = task + appTasks[item.id] = TrackedAppTask(generation: generation, task: task) + } + + private func nextAppTaskGeneration() -> UInt64 { + appTaskGeneration &+= 1 + return appTaskGeneration + } + + private func finishAppTask(for id: String, generation: UInt64) { + guard appTasks[id]?.generation == generation else { return } + appTasks.removeValue(forKey: id) + } + + private func isCurrentUpdateAll(inventoryGeneration: Int) -> Bool { + updateAllRunning && prepareGeneration == inventoryGeneration + } + + private func isCurrentAppUpdate(for id: String, owner: AppUpdateOwner) -> Bool { + switch owner { + case let .row(generation): + return appTasks[id]?.generation == generation + && appItems.contains(where: { $0.id == id }) + case let .updateAll(inventoryGeneration): + return isCurrentUpdateAll(inventoryGeneration: inventoryGeneration) + && appItems.contains(where: { $0.id == id }) + } + } + + private func cancelAppTask(for id: String) { + appTasks.removeValue(forKey: id)?.task.cancel() + } + + private func inventoryFingerprint(for item: AppUpdateItem) -> InventoryFingerprint? { + preparedInventory.first { + $0.id == item.id && $0.bundleID == item.bundleID && $0.path == item.path + } + } + + private func isLiveUpdateIdentity(_ expected: AppUpdateItem) -> Bool { + appItems.contains { + $0.id == expected.id + && $0.path == expected.path + && $0.bundleID == expected.bundleID + && $0.installedVersion == expected.installedVersion + && $0.source == expected.source + } + } + + private func isSameStagedUpdate( + _ lhs: StagedElectronUpdate, + _ rhs: StagedElectronUpdate + ) -> Bool { + lhs.targetURL.standardizedFileURL == rhs.targetURL.standardizedFileURL + && lhs.candidateURL.standardizedFileURL == rhs.candidateURL.standardizedFileURL + && lhs.stagingDirectory.standardizedFileURL == rhs.stagingDirectory.standardizedFileURL + } + + private func confirmedStagedUpdate(for item: AppUpdateItem) -> StagedElectronUpdate? { + guard let staged = stagedElectronUpdates[item.id], confirmRestart(item) else { return nil } + stagedElectronUpdates.removeValue(forKey: item.id) + return staged + } + + private static func confirmRestartBeforeInstalling(_ item: AppUpdateItem) -> Bool { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + format: NSLocalizedString("Install and restart %@?", comment: ""), + item.name + ) + alert.informativeText = String( + format: NSLocalizedString( + "Save any work in %@ before continuing. Burrow will ask the app to quit, replace its verified app bundle, and reopen it. Choosing Not Yet leaves the verified update ready to install.", + comment: "" + ), + item.name + ) + alert.addButton(withTitle: NSLocalizedString("Install & Restart", comment: "")) + alert.addButton(withTitle: NSLocalizedString("Not Yet", comment: "")) + NSApp.activate(ignoringOtherApps: true) + return alert.runModalQuiet() == .alertFirstButtonReturn } func cancel(_ item: AppUpdateItem) { - appTasks.removeValue(forKey: item.id)?.cancel() + guard !updateAllRunning, updateAllTask == nil else { return } + cancelAppTask(for: item.id) stagedElectronUpdates.removeValue(forKey: item.id)?.discard() phases[item.id] = .failed(.cancelled) } @@ -679,17 +912,22 @@ final class UpdatesModel: ObservableObject { if let url = item.releaseNotesURL ?? item.pageURL { NSWorkspace.shared.open(url) } } - private func performUpdate(_ item: AppUpdateItem, installElectronImmediately: Bool) async { + private func performUpdate(_ item: AppUpdateItem, owner: AppUpdateOwner) async { + guard isCurrentAppUpdate(for: item.id, owner: owner) else { return } switch item.source { case .sparkle: - await runSparkle(item) + await runSparkle(item, owner: owner) case .electron: guard let descriptor = electronDescriptors[item.id] else { handOffToOwnUpdater(item) return } phases[item.id] = .downloading(progress: nil) - let outcome = await ElectronReplacementInstaller.stage(appPath: item.path, descriptor: descriptor) + let outcome = await stageElectron(item.path, descriptor) + guard isCurrentAppUpdate(for: item.id, owner: owner) else { + if case let .ready(staged) = outcome { staged.discard() } + return + } guard !Task.isCancelled else { if case let .ready(staged) = outcome { staged.discard() } phases[item.id] = .failed(.cancelled) @@ -700,10 +938,6 @@ final class UpdatesModel: ObservableObject { stagedElectronUpdates[item.id]?.discard() stagedElectronUpdates[item.id] = staged phases[item.id] = .readyToInstall - if installElectronImmediately { - stagedElectronUpdates.removeValue(forKey: item.id) - await install(staged, for: item.id) - } case let .failure(failure): phases[item.id] = .failed(failure) if case .unsupported = failure { handOffToOwnUpdater(item) } @@ -717,16 +951,24 @@ final class UpdatesModel: ObservableObject { } } - private func runSparkle(_ item: AppUpdateItem) async { + private func runSparkle(_ item: AppUpdateItem, owner: AppUpdateOwner) async { + guard isCurrentAppUpdate(for: item.id, owner: owner) else { return } + sparkleSessionGeneration &+= 1 + let sessionGeneration = sparkleSessionGeneration await withCheckedContinuation { continuation in guard let session = ExternalSparkleUpdateSession( appPath: item.path, onPhase: { [weak self] phase in - guard let self, self.appItems.contains(where: { $0.id == item.id }) else { return } + guard let self, + self.isCurrentSparkleSession(for: item.id, generation: sessionGeneration), + self.isCurrentAppUpdate(for: item.id, owner: owner), + self.appItems.contains(where: { $0.id == item.id }) else { return } self.phases[item.id] = phase }, onMetadata: { [weak self] version, releaseNotesURL in - guard let self else { return } + guard let self, + self.isCurrentSparkleSession(for: item.id, generation: sessionGeneration), + self.isCurrentAppUpdate(for: item.id, owner: owner) else { return } self.appItems = self.appItems.map { live in guard live.id == item.id else { return live } var copy = live @@ -736,7 +978,7 @@ final class UpdatesModel: ObservableObject { } }, onFinish: { [weak self] in - self?.sparkleSessions.removeValue(forKey: item.id) + self?.finishSparkleSession(for: item.id, generation: sessionGeneration) continuation.resume() } ) else { @@ -748,17 +990,42 @@ final class UpdatesModel: ObservableObject { continuation.resume() return } - sparkleSessions[item.id] = session + sparkleSessions[item.id] = TrackedSparkleSession( + generation: sessionGeneration, + session: session + ) if session.begin() != nil { - sparkleSessions.removeValue(forKey: item.id) + finishSparkleSession(for: item.id, generation: sessionGeneration) handOffToOwnUpdater(item) } } } - private func install(_ staged: StagedElectronUpdate, for id: String) async { + private func isCurrentSparkleSession(for id: String, generation: UInt64) -> Bool { + sparkleSessions[id]?.generation == generation + } + + private func finishSparkleSession(for id: String, generation: UInt64) { + guard isCurrentSparkleSession(for: id, generation: generation) else { return } + sparkleSessions.removeValue(forKey: id) + } + + private func install( + _ staged: StagedElectronUpdate, + for id: String, + owner: AppUpdateOwner + ) async { + guard isCurrentAppUpdate(for: id, owner: owner) else { + staged.discard() + return + } phases[id] = .installing - switch await ElectronReplacementInstaller.install(staged) { + let outcome = await installElectron(staged) + guard isCurrentAppUpdate(for: id, owner: owner) else { + staged.discard() + return + } + switch outcome { case .installed: phases[id] = .completed case let .failure(failure): @@ -781,19 +1048,27 @@ final class UpdatesModel: ObservableObject { Task { @MainActor [weak self] in guard let self else { return } await self.performBrewUpdate(item) - self.brewItems = await Self.brewOutdated() + self.brewItems = await self.loadBrewOutdated() } } - /// Updates every available item serially. Cancellation is deliberately a - /// boundary between apps: interrupting a package replacement or `brew` - /// transaction halfway through is less safe than finishing the current - /// item and stopping before the next one. + /// Processes every available item serially. Electron replacements stop at + /// the visible ready-to-install boundary; only an explicit Install & + /// Restart confirmation may let the batch or row quit and replace it. + /// Cancellation is deliberately a boundary between apps: interrupting a + /// download or `brew` transaction halfway through is less safe than + /// finishing the current item and stopping before the next one. func updateAll() { - guard !updateAllRunning, upgrading.isEmpty else { return } + guard !updateAllRunning, + updateAllTask == nil, + upgrading.isEmpty, + appTasks.isEmpty, + sparkleSessions.isEmpty else { return } let apps = availableItems let brews = brewItems guard !apps.isEmpty || !brews.isEmpty else { return } + let inventoryGeneration = prepareGeneration + let owner = AppUpdateOwner.updateAll(inventoryGeneration: inventoryGeneration) cancelUpdateAllAfterCurrent = false updateAllRunning = true updateAllCompleted = 0 @@ -801,16 +1076,24 @@ final class UpdatesModel: ObservableObject { updateAllTask = Task { @MainActor [weak self] in guard let self else { return } for item in apps { - if self.cancelUpdateAllAfterCurrent { break } - await self.performUpdate(item, installElectronImmediately: true) + guard !self.cancelUpdateAllAfterCurrent, + self.isCurrentAppUpdate(for: item.id, owner: owner) else { break } + await self.performUpdate(item, owner: owner) + guard self.isCurrentAppUpdate(for: item.id, owner: owner) else { break } + if let staged = self.confirmedStagedUpdate(for: item) { + await self.install(staged, for: item.id, owner: owner) + guard self.isCurrentAppUpdate(for: item.id, owner: owner) else { break } + } self.updateAllCompleted += 1 } for item in brews { - if self.cancelUpdateAllAfterCurrent { break } + guard !self.cancelUpdateAllAfterCurrent, + self.isCurrentUpdateAll(inventoryGeneration: inventoryGeneration) else { break } await self.performBrewUpdate(item) + guard self.isCurrentUpdateAll(inventoryGeneration: inventoryGeneration) else { break } self.updateAllCompleted += 1 } - self.brewItems = await Self.brewOutdated() + self.brewItems = await self.loadBrewOutdated() self.updateAllRunning = false self.updateAllTask = nil self.cancelUpdateAllAfterCurrent = false diff --git a/macos/Tests/HelperCodeRequirementTests.swift b/macos/Tests/HelperCodeRequirementTests.swift index b73c1ba0..06fa9f23 100644 --- a/macos/Tests/HelperCodeRequirementTests.swift +++ b/macos/Tests/HelperCodeRequirementTests.swift @@ -18,6 +18,7 @@ // import XCTest +import Darwin @testable import Burrow final class HelperCodeRequirementTests: XCTestCase { @@ -126,6 +127,105 @@ final class HelperCodeRequirementTests: XCTestCase { XCTAssertNil(HelperCodeRequirement.validated(identifier: HelperCodeRequirement.unsatisfiable)) } + // MARK: - Exact executable snapshot + + func testExecutableSnapshotIsTheVerifiedCopyNotTheLaterSourcePath() throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-helper-snapshot-\(UUID().uuidString)") + let app = temp.appendingPathComponent("Source.app") + let engineDirectory = app.appendingPathComponent("Contents/Resources/engine") + let engine = engineDirectory.appendingPathComponent("mole") + try FileManager.default.createDirectory(at: engineDirectory, + withIntermediateDirectories: true) + try writeInfoPlist(to: app, build: "24") + try Data("#!/bin/sh\nprintf original\\n".utf8).write(to: engine) + try FileManager.default.setAttributes([.posixPermissions: 0o755], + ofItemAtPath: engine.path) + defer { try? FileManager.default.removeItem(at: temp) } + + var verifiedURL: URL? + let snapshot = try HelperExecutableSnapshot.prepare( + appBundleURL: app, + parentDirectory: temp.resolvingSymlinksInPath(), + expectedOwner: geteuid(), + expectedBundleID: HelperNames.clientBundleID, + expectedBuild: "24", + verify: { copiedApp in + verifiedURL = copiedApp + return true + }) + + try FileManager.default.removeItem(at: engine) + try Data("#!/bin/sh\nprintf replaced\\n".utf8).write(to: engine) + try FileManager.default.setAttributes([.posixPermissions: 0o755], + ofItemAtPath: engine.path) + + XCTAssertEqual(verifiedURL, snapshot.appBundleURL) + XCTAssertTrue(snapshot.executableURL.path.hasPrefix(snapshot.rootURL.path + "/")) + XCTAssertTrue(try String(contentsOf: snapshot.executableURL).contains("original"), + "the exact verified copy must be what the helper later executes") + } + + func testExecutableSnapshotRejectsASymlinkedEngine() throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-helper-snapshot-link-\(UUID().uuidString)") + let app = temp.appendingPathComponent("Source.app") + let engineDirectory = app.appendingPathComponent("Contents/Resources/engine") + let outside = temp.appendingPathComponent("outside") + try FileManager.default.createDirectory(at: engineDirectory, + withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: outside) + try FileManager.default.createSymbolicLink( + at: engineDirectory.appendingPathComponent("mole"), withDestinationURL: outside) + defer { try? FileManager.default.removeItem(at: temp) } + + XCTAssertThrowsError(try HelperExecutableSnapshot.prepare( + appBundleURL: app, + parentDirectory: temp.resolvingSymlinksInPath(), + expectedOwner: geteuid(), + expectedBundleID: HelperNames.clientBundleID, + expectedBuild: "24", + verify: { _ in true })) + } + + func testExecutableSnapshotRejectsAnOlderSameTeamBundle() throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-helper-snapshot-old-\(UUID().uuidString)") + let app = temp.appendingPathComponent("Source.app") + let engine = app.appendingPathComponent("Contents/Resources/engine/mole") + try FileManager.default.createDirectory(at: engine.deletingLastPathComponent(), + withIntermediateDirectories: true) + try writeInfoPlist(to: app, build: "23") + try Data("#!/bin/sh\n".utf8).write(to: engine) + try FileManager.default.setAttributes([.posixPermissions: 0o755], + ofItemAtPath: engine.path) + defer { try? FileManager.default.removeItem(at: temp) } + + var sameTeamSignatureAccepted = false + XCTAssertThrowsError(try HelperExecutableSnapshot.prepare( + appBundleURL: app, + parentDirectory: temp.resolvingSymlinksInPath(), + expectedOwner: geteuid(), + expectedBundleID: HelperNames.clientBundleID, + expectedBuild: "24", + verify: { _ in + sameTeamSignatureAccepted = true + return true + })) + XCTAssertTrue(sameTeamSignatureAccepted, + "the regression must reach the same-team-pass/build-mismatch boundary") + } + + private func writeInfoPlist(to app: URL, build: String) throws { + let info: [String: Any] = [ + "CFBundleIdentifier": HelperNames.clientBundleID, + "CFBundleVersion": build, + ] + let data = try PropertyListSerialization.data(fromPropertyList: info, + format: .binary, options: 0) + try data.write(to: app.appendingPathComponent("Contents/Info.plist")) + } + // MARK: - Version skew between app and helper // // The installed helper outlives the app that installed it: Sparkle can diff --git a/macos/Tests/MoleCLITests.swift b/macos/Tests/MoleCLITests.swift index 4975941d..3cb6b1ba 100644 --- a/macos/Tests/MoleCLITests.swift +++ b/macos/Tests/MoleCLITests.swift @@ -114,6 +114,15 @@ final class MoleCLITests: XCTestCase { XCTAssertTrue(s.contains("'/tmp/m o/mo' 'clean' '--dry-run'")) } + func testElevatedScript_replacesTheAmbientRootEnvironment() { + let s = MoleCLI.elevatedScript(command: fakeCommand("/tmp/mo"), args: ["clean"]) + + XCTAssertTrue(s.contains("'/usr/bin/env' '-i'")) + XCTAssertTrue(s.contains("'PATH=/usr/bin:/bin:/usr/sbin:/sbin'")) + XCTAssertTrue(s.contains("'HOME=/Users/test'")) + XCTAssertTrue(s.contains("'LC_ALL=C'")) + } + func testElevatedScript_neutralizesShellMetacharacters() { let s = MoleCLI.elevatedScript(command: fakeCommand("/tmp/$(reboot)/mo"), args: ["a;b", "`x`"]) diff --git a/macos/Tests/OperationFlowTests.swift b/macos/Tests/OperationFlowTests.swift index 73410f09..47ff3f24 100644 --- a/macos/Tests/OperationFlowTests.swift +++ b/macos/Tests/OperationFlowTests.swift @@ -191,6 +191,39 @@ final class OperationFlowTests: XCTestCase { guard case .finished(.failed) = flow.state else { return XCTFail("expected failed") } } + func testNonzeroPrivilegedCleanupFailsVisiblyAndNeverCompletesTheHUD() async throws { + var parent = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-operation-flow-cleanup-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + parent = URL(fileURLWithPath: try XCTUnwrap( + InvokingUserIdentity.canonicalPath(parent.path)), isDirectory: true) + let item = parent.appendingPathComponent("reviewed-cache") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: parent) } + let snapshot = try CleanupSnapshot.capture( + list: CleanList(categories: [ + .init(name: "Test", items: [ + .init(path: item.path, sizeBytes: 1, sizeText: "1B", itemCount: nil), + ]), + ], summaryTotalText: "1B", summaryItemCount: 1), + approvedRootURLs: [parent]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + + let port = FakeProcessPort(script: [.exited(124)]) + let center = OperationCenter() + let flow = makeFlow(port, center: center) + var operation = Self.cleanOp(elevated: true) + operation.cleanupPlan = plan + flow.start(operation) + await settle(flow) + + guard case .finished(.failed(let message)) = flow.state else { + return XCTFail("a fail-closed cleanup exit must not become done") + } + XCTAssertTrue(message.contains("124")) + XCTAssertEqual(center.ops.first?.phase, .failed) + } + func testMissingExecutableFailsBeforeSpawn() { let port = FakeProcessPort(script: []) let flow = OperationFlow(process: port, hasFullDiskAccess: { true }, diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift index e6c3ddfe..c5d01f13 100644 --- a/macos/Tests/PrivilegedSecurityTests.swift +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -166,6 +166,200 @@ final class CleanupAuthorizationTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: moved.path), "malformed/swapped directories are refused, never auto-deleted") } + + func testTrashMoveRestoresAnUnreviewedObjectCapturedByAPathRace() throws { + let reviewed = root.appendingPathComponent("reviewed") + let original = root.appendingPathComponent("original-reviewed") + let fakeTrash = root.appendingPathComponent("fake-trash") + try FileManager.default.createDirectory(at: reviewed, withIntermediateDirectories: false) + try Data("reviewed".utf8).write(to: reviewed.appendingPathComponent("marker")) + let snapshot = try CleanupSnapshot.capture(list: list([reviewed.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [reviewed.path]) + + let result = CleanupExecutor.moveToTrash(plan) { source in + // Simulate a same-user process replacing the reviewed name in the + // instant after launch validation but before the Trash rename. + try FileManager.default.moveItem(at: source, to: original) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: false) + try Data("unreviewed".utf8).write(to: source.appendingPathComponent("marker")) + try FileManager.default.moveItem(at: source, to: fakeTrash) + return fakeTrash + } + + XCTAssertEqual(result, .init(moved: 0, failed: 1)) + XCTAssertEqual(try String(contentsOf: reviewed.appendingPathComponent("marker")), + "unreviewed", "the raced object must be restored, not deleted") + XCTAssertEqual(try String(contentsOf: original.appendingPathComponent("marker")), + "reviewed", "the reviewed inode remains untouched when its name changes") + XCTAssertFalse(FileManager.default.fileExists(atPath: fakeTrash.path)) + } + + func testIrreversibleCleanupCapturesAndVerifiesBeforeDeleting() throws { + let item = root.appendingPathComponent("permanent cache") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + + let execution = try plan.prepareIrreversibleCleanupForTesting() + let shell = execution.shell + XCTAssertTrue(shell.contains("/bin/mv -- \"$burrow_original\" \"$burrow_capture\"")) + XCTAssertTrue(shell.contains("/usr/bin/stat -f '%d:%i:%u:%p'")) + XCTAssertTrue(shell.contains("/bin/rmdir -- \"$burrow_capture\"")) + XCTAssertFalse(shell.contains("/usr/bin/find"), + "no live recursive traversal may reach irreversible delete") + + let executable = PinnedFileIdentity(path: "/usr/bin/true", device: 1, inode: 2, + owner: 0, mode: UInt16(S_IFREG | 0o755)) + let user = InvokingUserIdentity(uid: 501, username: "review user", + canonicalHome: "/Users/review user") + let command = ValidatedElevatedCommand(executable: executable, components: [], + invokingUser: user, signedBundlePath: nil) + let elevated = MoleCLI.elevatedScript(command: command, args: [], cleanupPlan: plan, + cleanupExecution: execution) + XCTAssertTrue(elevated.contains("'/usr/bin/env' '-i'")) + XCTAssertTrue(elevated.contains("'PATH=/usr/bin:/bin:/usr/sbin:/sbin'")) + XCTAssertTrue(elevated.contains("'HOME=/Users/review user'")) + XCTAssertTrue(elevated.contains("'/bin/sh' '-c'")) + + XCTAssertEqual(try runCleanupShell(shell), 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: item.path), + "an unchanged exact reviewed tree is removed successfully") + XCTAssertFalse(FileManager.default.fileExists(atPath: execution.quarantineRootPath)) + } + + func testIrreversibleCleanupPreservesAReplacementCapturedByTheRace() throws { + let item = root.appendingPathComponent("permanent-race") + let original = root.appendingPathComponent("original-permanent-race") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + try Data("reviewed".utf8).write(to: item.appendingPathComponent("marker")) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + let execution = try plan.prepareIrreversibleCleanupForTesting(preCaptureDelaySeconds: 1) + let quarantine = execution.quarantineRootPath + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", execution.shell] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + + let deadline = Date().addingTimeInterval(2) + while !FileManager.default.fileExists(atPath: quarantine), Date() < deadline { + usleep(10_000) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: quarantine), + "the command must reach its protected capture boundary") + + try FileManager.default.moveItem(at: item, to: original) + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + try Data("unreviewed".utf8).write(to: item.appendingPathComponent("marker")) + + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 124) + XCTAssertEqual(try String(contentsOf: item.appendingPathComponent("marker")), + "unreviewed", "the substituted inode must be restored intact") + XCTAssertEqual(try String(contentsOf: original.appendingPathComponent("marker")), + "reviewed", "the reviewed inode must survive at its raced location") + XCTAssertFalse(FileManager.default.fileExists(atPath: quarantine)) + } + + func testIrreversibleCleanupPreservesDescendantAddedAfterReview() throws { + let item = root.appendingPathComponent("added-descendant") + let reviewed = item.appendingPathComponent("reviewed") + let added = item.appendingPathComponent("added-later") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + try Data("reviewed".utf8).write(to: reviewed) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + let execution = try plan.prepareIrreversibleCleanupForTesting() + + try Data("unreviewed".utf8).write(to: added) + let status = try runCleanupShell(execution.shell) + + XCTAssertEqual(status, 124) + XCTAssertEqual(try String(contentsOf: added), "unreviewed", + "a descendant added after preview must never be deleted") + } + + func testIrreversibleCleanupPreservesMovedDescendantAndItsReplacement() throws { + let item = root.appendingPathComponent("moved-descendant") + let reviewed = item.appendingPathComponent("reviewed") + let moved = root.appendingPathComponent("reviewed-moved-away") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + try Data("reviewed".utf8).write(to: reviewed) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + let execution = try plan.prepareIrreversibleCleanupForTesting() + + try FileManager.default.moveItem(at: reviewed, to: moved) + try Data("unreviewed".utf8).write(to: reviewed) + let status = try runCleanupShell(execution.shell) + + XCTAssertEqual(status, 124) + XCTAssertEqual(try String(contentsOf: reviewed), "unreviewed") + XCTAssertEqual(try String(contentsOf: moved), "reviewed") + } + + func testIrreversibleCleanupOpenDirectoryFDMutationFailsClosed() throws { + let item = root.appendingPathComponent("open-fd-descendant") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + let execution = try plan.prepareIrreversibleCleanupForTesting( + postCaptureDelaySeconds: 1) + + let directoryFD = Darwin.open(item.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + XCTAssertGreaterThanOrEqual(directoryFD, 0) + defer { if directoryFD >= 0 { Darwin.close(directoryFD) } } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", execution.shell] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + + let captured = execution.quarantineRootPath + "/node-0/captured" + let deadline = Date().addingTimeInterval(2) + while !FileManager.default.fileExists(atPath: captured), Date() < deadline { + usleep(10_000) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: captured)) + + let lateFD = Darwin.openat(directoryFD, "added-through-open-fd", + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) + XCTAssertGreaterThanOrEqual(lateFD, 0) + if lateFD >= 0 { + _ = "unreviewed".withCString { bytes in + Darwin.write(lateFD, bytes, strlen(bytes)) + } + Darwin.close(lateFD) + } + + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 124) + let added = item.appendingPathComponent("added-through-open-fd") + XCTAssertEqual(try String(contentsOf: added), "unreviewed", + "an entry added through a pre-opened directory must survive") + } + + private func runCleanupShell(_ shell: String) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", shell] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus + } } final class PrivilegedLogSinkTests: XCTestCase { diff --git a/macos/Tests/ProcessActionsTests.swift b/macos/Tests/ProcessActionsTests.swift index ead09a8c..551e9851 100644 --- a/macos/Tests/ProcessActionsTests.swift +++ b/macos/Tests/ProcessActionsTests.swift @@ -24,6 +24,22 @@ final class ProcessActionsTests: XCTestCase { XCTAssertTrue(signals.values.isEmpty) } + func testTerminateFailsClosedWhenProcessExitedBeforeTheSignal() { + let expected = target(identity: identity()) + let signals = LockedSignals() + + let result = ProcessActions.terminate( + expected, + force: false, + currentUID: 501, + readIdentity: { _ in nil }, + sendSignal: { pid, signal in signals.append(pid: pid, signal: signal); return 0 } + ) + + XCTAssertEqual(result, .stale) + XCTAssertTrue(signals.values.isEmpty) + } + func testTerminateFailsClosedWhenExecutableChangedAfterConfirmation() { let expected = target(identity: identity(path: "/Applications/Before.app/Contents/MacOS/Before")) let signals = LockedSignals() diff --git a/macos/Tests/ProcessWatchdogTests.swift b/macos/Tests/ProcessWatchdogTests.swift new file mode 100644 index 00000000..d2f47c36 --- /dev/null +++ b/macos/Tests/ProcessWatchdogTests.swift @@ -0,0 +1,95 @@ +// +// ProcessWatchdogTests.swift +// BurrowTests +// + +import XCTest +@testable import Burrow + +@MainActor +final class ProcessWatchdogTests: XCTestCase { + func testQuitRoutesTheExactCapturedIdentityThroughConfirmation() { + let target = ProcessActions.TerminationTarget( + displayName: "Example", + identity: .init( + pid: 42, + ownerUID: 501, + startSeconds: 100, + startMicroseconds: 200, + executablePath: "/Applications/Example.app/Contents/MacOS/Example" + ) + ) + var shownTarget: ProcessActions.TerminationTarget? + + let watchdog = ProcessWatchdog( + processControl: .init( + isOwnProcess: { _ in XCTFail("quit must not use the suspend ownership path"); return true }, + suspend: { _ in XCTFail("quit must not suspend directly") }, + terminationTarget: { pid, name in + XCTAssertEqual(pid, 42) + XCTAssertEqual(name, "Example") + return target + }, + confirmTermination: { candidate, _ in + shownTarget = candidate + return nil + } + ), + configuredAction: { .quit } + ) + + watchdog.dispatch(pid: 42, name: "Example") + + XCTAssertEqual(shownTarget, target) + XCTAssertTrue(shownTarget?.confirmationDetails.contains("PID 42") == true) + XCTAssertTrue(shownTarget?.confirmationDetails.contains("user 501") == true) + XCTAssertTrue(shownTarget?.confirmationDetails.contains("started 100.000200") == true) + XCTAssertTrue(shownTarget?.confirmationDetails.contains( + "/Applications/Example.app/Contents/MacOS/Example" + ) == true) + } + + func testQuitFailsClosedWhenNoOwnedIdentityCanBeCaptured() { + var confirmationCount = 0 + var refreshCount = 0 + let watchdog = ProcessWatchdog( + processControl: .init( + isOwnProcess: { _ in true }, + suspend: { _ in XCTFail("quit must not suspend directly") }, + terminationTarget: { _, _ in nil }, + confirmTermination: { _, _ in confirmationCount += 1; return .sent } + ), + configuredAction: { .quit } + ) + + watchdog.dispatch(pid: 42, name: "Exited") { refreshCount += 1 } + + XCTAssertEqual(confirmationCount, 0) + XCTAssertEqual(refreshCount, 1) + } + + func testQuitFailureRefreshesTheProcessList() { + let target = ProcessActions.TerminationTarget( + displayName: "Exited", + identity: .init(pid: 42, ownerUID: 501, startSeconds: 100, + startMicroseconds: 200, executablePath: "/tmp/exited") + ) + var refreshCount = 0 + let watchdog = ProcessWatchdog( + processControl: .init( + isOwnProcess: { _ in true }, + suspend: { _ in XCTFail("quit must not suspend directly") }, + terminationTarget: { _, _ in target }, + confirmTermination: { _, refresh in + refresh() + return .stale + } + ), + configuredAction: { .quit } + ) + + watchdog.dispatch(pid: 42, name: "Exited") { refreshCount += 1 } + + XCTAssertEqual(refreshCount, 1) + } +} diff --git a/macos/Tests/UpdateSeenStoreTests.swift b/macos/Tests/UpdateSeenStoreTests.swift index c50827b8..1075462e 100644 --- a/macos/Tests/UpdateSeenStoreTests.swift +++ b/macos/Tests/UpdateSeenStoreTests.swift @@ -14,4 +14,19 @@ final class UpdateSeenStoreTests: XCTestCase { XCTAssertEqual(UpdateSeenStore.unseenCount(available: [(bundleID: "a", version: "1.1")], seen: seen), 1) XCTAssertEqual(UpdateSeenStore.unseenCount(available: [(bundleID: "a", version: "1.0")], seen: seen), 0) } + + func testDismissedVersionRemainsAcknowledgedWhenTheSameVersionIsForcedAgain() { + let dismissed = UpdateSeenStore.markAllSeen( + available: [(bundleID: "a", version: "2.0")], + seen: [] + ) + + XCTAssertEqual( + UpdateSeenStore.unseenCount( + available: [(bundleID: "a", version: "2.0")], + seen: dismissed + ), + 0 + ) + } } diff --git a/macos/Tests/UpdateWorkflowTests.swift b/macos/Tests/UpdateWorkflowTests.swift index a44fb255..32d6614c 100644 --- a/macos/Tests/UpdateWorkflowTests.swift +++ b/macos/Tests/UpdateWorkflowTests.swift @@ -124,6 +124,109 @@ final class UpdateWorkflowTests: XCTestCase { XCTAssertEqual(secondStat.st_mode & mode_t(0o777), mode_t(0o700)) } + func testStagedDiscardRemovesOnlyThePinnedStagingRoot() throws { + let root = try PrivateUpdateDirectory.create() + let identity = try XCTUnwrap(ElectronReplacementInstaller.stagingDirectoryIdentity(at: root)) + let marker = root.appendingPathComponent("marker") + try Data("owned".utf8).write(to: marker) + let staged = stagedUpdateForCleanup(root: root, identity: identity) + + staged.discard() + + XCTAssertFalse(ElectronReplacementInstaller.pathEntryExists(at: root)) + } + + func testStagedDiscardPreservesReplacementTreeAfterRootSwap() throws { + let root = try PrivateUpdateDirectory.create() + let movedRoot = root.deletingLastPathComponent().appendingPathComponent("Moved Cleanup \(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: movedRoot) + } + let identity = try XCTUnwrap(ElectronReplacementInstaller.stagingDirectoryIdentity(at: root)) + let staged = stagedUpdateForCleanup(root: root, identity: identity) + try FileManager.default.moveItem(at: root, to: movedRoot) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let replacementMarker = root.appendingPathComponent("replacement-marker") + try Data("must survive".utf8).write(to: replacementMarker) + + staged.discard() + + XCTAssertTrue(FileManager.default.fileExists(atPath: replacementMarker.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: movedRoot.path)) + } + + func testDescriptorBoundDiscardPreservesTreeSwappedAfterRootFstat() throws { + let root = try PrivateUpdateDirectory.create() + let capturedRoot = root.deletingLastPathComponent().appendingPathComponent( + "Captured Cleanup \(UUID().uuidString)" + ) + var replacementQuarantine: URL? + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: capturedRoot) + if let replacementQuarantine { + try? FileManager.default.removeItem(at: replacementQuarantine) + } + } + let identity = try XCTUnwrap(ElectronReplacementInstaller.stagingDirectoryIdentity(at: root)) + let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Nested"), + withIntermediateDirectories: false + ) + try Data("owned".utf8).write(to: root.appendingPathComponent("Nested/file")) + + PrivateUpdateDirectory.discard( + at: root, + expectedIdentity: identity, + expectedCanonicalURL: canonicalRoot, + afterOpeningPinnedRoot: { quarantineURL in + replacementQuarantine = quarantineURL + try! FileManager.default.moveItem(at: quarantineURL, to: capturedRoot) + try! FileManager.default.createDirectory(at: quarantineURL, withIntermediateDirectories: false) + try! Data("must survive".utf8).write( + to: quarantineURL.appendingPathComponent("replacement-marker") + ) + } + ) + + let quarantineURL = try XCTUnwrap(replacementQuarantine) + XCTAssertEqual( + try Data(contentsOf: quarantineURL.appendingPathComponent("replacement-marker")), + Data("must survive".utf8) + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: capturedRoot.path)) + } + + func testDescriptorBoundDiscardRemovesNestedTreeWithoutFollowingSymlinks() throws { + let root = try PrivateUpdateDirectory.create() + let outside = try PrivateUpdateDirectory.create() + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outside) + } + let identity = try XCTUnwrap(ElectronReplacementInstaller.stagingDirectoryIdentity(at: root)) + let nested = root.appendingPathComponent("One/Two", isDirectory: true) + let outsideMarker = outside.appendingPathComponent("outside-marker") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data("inside".utf8).write(to: nested.appendingPathComponent("inside-file")) + try Data("outside".utf8).write(to: outsideMarker) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent("outside-link"), + withDestinationURL: outside + ) + + PrivateUpdateDirectory.discard( + at: root, + expectedIdentity: identity, + expectedCanonicalURL: root.resolvingSymlinksInPath().standardizedFileURL + ) + + XCTAssertFalse(ElectronReplacementInstaller.pathEntryExists(at: root)) + XCTAssertEqual(try Data(contentsOf: outsideMarker), Data("outside".utf8)) + } + func testReplacementIdentityRejectsBundleOrSigningIdentityChanges() { let expected = BundleUpdateIdentity( bundleID: "com.example.App", @@ -163,28 +266,26 @@ final class UpdateWorkflowTests: XCTestCase { } func testReplacementBoundaryRereadsAndRejectsSwappedCandidate() { - let expected = BundleUpdateIdentity( - bundleID: "com.example.App", - signingIdentifier: "com.example.App", - teamIdentifier: "TEAM123", - signatureValid: true - ) - let staged = StagedElectronUpdate( - targetURL: URL(fileURLWithPath: "/Applications/Example.app"), - candidateURL: URL(fileURLWithPath: "/tmp/private/Candidate.app"), - stagingDirectory: URL(fileURLWithPath: "/tmp/private"), - expectedIdentity: expected - ) + let expected = pinnedIdentity(version: "1.0.0", build: "100", hash: "target", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate", inode: 20) + let staged = stagedUpdate(target: expected, candidate: candidate) var reads: [URL] = [] - let failure = ElectronReplacementInstaller.boundaryVerificationFailure(for: staged) { url in + let failure = ElectronReplacementInstaller.boundaryVerificationFailure( + for: staged, + validateCandidateLocation: { _ in nil } + ) { url in reads.append(url) if url == staged.targetURL { return expected } return BundleUpdateIdentity( bundleID: expected.bundleID, signingIdentifier: expected.signingIdentifier, teamIdentifier: "ATTACKER", - signatureValid: true + signatureValid: true, + version: candidate.version, + build: candidate.build, + codeDirectoryHash: candidate.codeDirectoryHash, + fileIdentity: candidate.fileIdentity ) } @@ -192,6 +293,749 @@ final class UpdateWorkflowTests: XCTestCase { XCTAssertEqual(failure, .verification(.signingIdentityChanged)) } + func testReplacementBoundaryRejectsTargetChangedToNewerSameSignerBuild() { + let target = BundleUpdateIdentity( + bundleID: "com.example.App", + signingIdentifier: "com.example.App", + teamIdentifier: "TEAM123", + signatureValid: true, + version: "1.0.0", + build: "100", + codeDirectoryHash: Data("target-v1".utf8), + fileIdentity: .init(device: 1, inode: 10) + ) + let candidate = BundleUpdateIdentity( + bundleID: target.bundleID, + signingIdentifier: target.signingIdentifier, + teamIdentifier: target.teamIdentifier, + signatureValid: true, + version: "2.0.0", + build: "200", + codeDirectoryHash: Data("candidate-v2".utf8), + fileIdentity: .init(device: 1, inode: 20) + ) + let staged = stagedUpdate(target: target, candidate: candidate) + let newerTarget = BundleUpdateIdentity( + bundleID: target.bundleID, + signingIdentifier: target.signingIdentifier, + teamIdentifier: target.teamIdentifier, + signatureValid: true, + version: "3.0.0", + build: "300", + codeDirectoryHash: Data("target-v3".utf8), + fileIdentity: .init(device: 1, inode: 30) + ) + var reads: [URL] = [] + + let failure = ElectronReplacementInstaller.boundaryVerificationFailure( + for: staged, + validateCandidateLocation: { _ in nil } + ) { url in + reads.append(url) + return url == staged.targetURL ? newerTarget : candidate + } + + XCTAssertEqual(reads, [staged.targetURL]) + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testReplacementBoundaryRejectsCandidateChangedToOlderSameSignerBuild() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let olderCandidate = pinnedIdentity( + version: "1.5.0", + build: "150", + hash: "candidate-v1.5", + inode: 30 + ) + var reads: [URL] = [] + + let failure = ElectronReplacementInstaller.boundaryVerificationFailure( + for: staged, + validateCandidateLocation: { _ in nil } + ) { url in + reads.append(url) + return url == staged.targetURL ? target : olderCandidate + } + + XCTAssertEqual(reads, [staged.targetURL, staged.candidateURL]) + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testReplacementBoundaryRejectsByteIdenticalCandidateAtDifferentFileIdentity() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let replacementFile = pinnedIdentity( + version: "2.0.0", + build: "200", + hash: "candidate-v2", + inode: 21 + ) + + let failure = ElectronReplacementInstaller.boundaryVerificationFailure( + for: staged, + validateCandidateLocation: { _ in nil } + ) { url in + url == staged.targetURL ? target : replacementFile + } + + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testStagingCandidateLocationRejectsSymlinksAndNonDirectoryAppCandidates() throws { + let root = try PrivateUpdateDirectory.create() + let outside = try PrivateUpdateDirectory.create() + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outside) + } + let extracted = root.appendingPathComponent("Extracted", isDirectory: true) + let outsideApp = outside.appendingPathComponent("External.app", isDirectory: true) + try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: outsideApp, withIntermediateDirectories: false) + let finalLink = extracted.appendingPathComponent("Linked.app") + try FileManager.default.createSymbolicLink(at: finalLink, withDestinationURL: outsideApp) + let intermediateLink = root.appendingPathComponent("Linked Directory") + try FileManager.default.createSymbolicLink(at: intermediateLink, withDestinationURL: outside) + let throughIntermediateLink = intermediateLink.appendingPathComponent("External.app") + let nonDirectoryApp = extracted.appendingPathComponent("Payload.app") + try Data("not a bundle directory".utf8).write(to: nonDirectoryApp) + let rootIdentity = try XCTUnwrap(ElectronReplacementInstaller.stagingDirectoryIdentity(at: root)) + let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL + + for candidateURL in [finalLink, throughIntermediateLink, nonDirectoryApp] { + XCTAssertEqual( + ElectronReplacementInstaller.candidateLocationVerificationFailure( + candidateURL: candidateURL, + stagingDirectory: root, + expectedStagingIdentity: rootIdentity, + expectedCanonicalStagingDirectory: canonicalRoot + ), + .verification(.artifactIdentityChanged) + ) + } + } + + func testReplacementBoundaryRejectsReplacedStagingRootBeforeReadingBundles() throws { + let root = try PrivateUpdateDirectory.create() + let movedRoot = root.deletingLastPathComponent().appendingPathComponent("Moved \(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: movedRoot) + } + let candidateURL = root.appendingPathComponent("Extracted/Example.app", isDirectory: true) + try FileManager.default.createDirectory(at: candidateURL, withIntermediateDirectories: true) + let rootIdentity = try XCTUnwrap(ElectronReplacementInstaller.stagingDirectoryIdentity(at: root)) + let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Example.app"), + candidateURL: candidateURL, + stagingDirectory: root, + stagingDirectoryIdentity: rootIdentity, + canonicalStagingDirectoryURL: canonicalRoot, + expectedIdentity: target, + expectedCandidateIdentity: candidate, + descriptorVersion: candidate.version! + ) + try FileManager.default.moveItem(at: root, to: movedRoot) + try FileManager.default.createDirectory(at: candidateURL, withIntermediateDirectories: true) + var didReadBundleIdentity = false + + let failure = ElectronReplacementInstaller.boundaryVerificationFailure( + for: staged, + readIdentity: { _ in + didReadBundleIdentity = true + return target + } + ) + + XCTAssertFalse(didReadBundleIdentity) + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testStagingRejectsCandidateVersionThatDoesNotMatchDescriptor() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "1.5.0", build: "150", hash: "candidate-v1.5", inode: 20) + + let failure = ElectronReplacementInstaller.stagingVerificationFailure( + expectedIdentity: target, + candidateIdentity: candidate, + descriptorVersion: "2.0.0" + ) + + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testPostReplacementRejectsCandidateSwappedAfterBoundaryCheck() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let swappedCandidate = pinnedIdentity( + version: "1.5.0", + build: "150", + hash: "candidate-v1.5", + inode: 30 + ) + + let failure = ElectronReplacementInstaller.postReplacementVerificationFailure( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? swappedCandidate : target } + ) + + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testPostReplacementRejectsBackupThatIsNotPinnedTarget() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let newerTarget = pinnedIdentity(version: "3.0.0", build: "300", hash: "target-v3", inode: 30) + + let failure = ElectronReplacementInstaller.postReplacementVerificationFailure( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? candidate : newerTarget } + ) + + XCTAssertEqual(failure, .verification(.artifactIdentityChanged)) + } + + func testPostReplacementReadsBackupAfterCandidateMismatchAndSelectsPinnedRollback() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let swappedCandidate = pinnedIdentity( + version: "1.5.0", + build: "150", + hash: "candidate-v1.5", + inode: 30 + ) + var reads: [URL] = [] + + let decision = ElectronReplacementInstaller.postReplacementDecision( + for: staged, + backupURL: backupURL, + readIdentity: { url in + reads.append(url) + return url == staged.targetURL ? swappedCandidate : target + } + ) + + XCTAssertEqual(reads, [staged.targetURL, backupURL]) + XCTAssertEqual( + decision, + .restore( + installedIdentity: swappedCandidate, + backupIdentity: target, + failure: .verification(.artifactIdentityChanged) + ) + ) + } + + func testPostReplacementDoesNotSelectUntrustedBackupForRollback() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let untrustedBackup = BundleUpdateIdentity( + bundleID: target.bundleID, + signingIdentifier: target.signingIdentifier, + teamIdentifier: "ATTACKER", + signatureValid: true, + version: "3.0.0", + build: "300", + codeDirectoryHash: Data("attacker-v3".utf8), + fileIdentity: .init(device: 1, inode: 30) + ) + + let decision = ElectronReplacementInstaller.postReplacementDecision( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? candidate : untrustedBackup } + ) + + guard case let .fail(.installation(message)) = decision else { + return XCTFail("An untrusted backup must fail closed without being selected for rollback") + } + XCTAssertTrue(message.localizedCaseInsensitiveContains("restore")) + } + + func testPostReplacementDoesNotSelectAuthenticOlderSwappedBackup() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let authenticOlderBackup = pinnedIdentity( + version: "0.9.0", + build: "90", + hash: "authentic-v0.9", + inode: 30 + ) + + let decision = ElectronReplacementInstaller.postReplacementDecision( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? candidate : authenticOlderBackup } + ) + + guard case .fail(.installation(_)) = decision else { + return XCTFail("An older backup that is not the pinned original must not be restored") + } + } + + func testPostReplacementSelectsPinnedBackupWhenInstalledTargetIsUnreadable() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + + let decision = ElectronReplacementInstaller.postReplacementDecision( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? nil : target } + ) + + XCTAssertEqual( + decision, + .restore( + installedIdentity: nil, + backupIdentity: target, + failure: .verification(.artifactIdentityChanged) + ) + ) + } + + func testPostReplacementSelectsAuthenticatedNewerBackupInsteadOfDowngradingIt() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let newerBackup = pinnedIdentity(version: "3.0.0", build: "300", hash: "target-v3", inode: 30) + + let decision = ElectronReplacementInstaller.postReplacementDecision( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? candidate : newerBackup } + ) + + XCTAssertEqual( + decision, + .restore( + installedIdentity: candidate, + backupIdentity: newerBackup, + failure: .verification(.artifactIdentityChanged) + ) + ) + } + + func testPostReplacementTreatsNewerElectronPrereleaseAsSafeRollback() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity( + version: "2.0.0-beta.1", + build: "200", + hash: "candidate-beta1", + inode: 20 + ) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let newerPrerelease = pinnedIdentity( + version: "2.0.0-beta.2", + build: "200", + hash: "candidate-beta2", + inode: 30 + ) + + let decision = ElectronReplacementInstaller.postReplacementDecision( + for: staged, + backupURL: backupURL, + readIdentity: { url in url == staged.targetURL ? candidate : newerPrerelease } + ) + + XCTAssertEqual( + decision, + .restore( + installedIdentity: candidate, + backupIdentity: newerPrerelease, + failure: .verification(.artifactIdentityChanged) + ) + ) + } + + func testRestoreVerifiedBackupReplacesAndReverifiesCapturedIdentity() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + var replacements: [(target: URL, backup: URL, backupName: String)] = [] + var reads: [URL] = [] + var didReplace = false + var displacedURL: URL? + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: candidate, + capturedBackupIdentity: target, + replaceItem: { targetURL, backupURL, backupName in + replacements.append((targetURL, backupURL, backupName)) + displacedURL = targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + didReplace = true + }, + readIdentity: { url in + reads.append(url) + if url == backupURL { return target } + if url == displacedURL { return candidate } + return didReplace ? target : candidate + } + ) + + XCTAssertNil(failure) + XCTAssertEqual(replacements.count, 1) + XCTAssertEqual(replacements.first?.target, staged.targetURL) + XCTAssertEqual(replacements.first?.backup, backupURL) + XCTAssertFalse(replacements.first?.backupName.isEmpty ?? true) + XCTAssertEqual(reads, [staged.targetURL, backupURL, staged.targetURL, displacedURL!]) + } + + func testRestoreVerifiedBackupDoesNotReplaceWhenBackupPathChangedAfterDecision() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let swappedBackup = pinnedIdentity( + version: target.version!, + build: target.build!, + hash: "target-v1", + inode: 11 + ) + var didReplace = false + var reads: [URL] = [] + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: candidate, + capturedBackupIdentity: target, + replaceItem: { _, _, _ in didReplace = true }, + readIdentity: { url in + reads.append(url) + return url == staged.targetURL ? candidate : swappedBackup + } + ) + + XCTAssertFalse(didReplace) + XCTAssertEqual(reads, [staged.targetURL, backupURL]) + guard case .installation = failure else { + return XCTFail("A swapped backup must fail before replacement") + } + } + + func testRestoreVerifiedBackupDoesNotReplaceWhenTargetChangedAfterDecision() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let newerTarget = pinnedIdentity(version: "3.0.0", build: "300", hash: "target-v3", inode: 30) + var didReplace = false + var reads: [URL] = [] + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: candidate, + capturedBackupIdentity: target, + replaceItem: { _, _, _ in didReplace = true }, + readIdentity: { url in + reads.append(url) + return url == staged.targetURL ? newerTarget : target + } + ) + + XCTAssertFalse(didReplace) + XCTAssertEqual(reads, [staged.targetURL, backupURL]) + guard case .installation = failure else { + return XCTFail("A changed target must fail before rollback") + } + } + + func testRestoreVerifiedBackupRestoresPinnedBackupOverStillUnreadableTarget() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + var didReplace = false + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: nil, + capturedBackupIdentity: target, + replaceItem: { _, _, _ in didReplace = true }, + moveItemExclusively: { _, _ in didReplace = true }, + pathExists: { _ in false }, + readIdentity: { url in + if url == backupURL { return target } + return didReplace ? target : nil + } + ) + + XCTAssertTrue(didReplace) + XCTAssertNil(failure) + } + + func testRestoreVerifiedBackupFailsWhenRestoredArtifactDoesNotMatchCapturedBackup() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let swappedDuringRestore = pinnedIdentity( + version: target.version!, + build: target.build!, + hash: "target-v1", + inode: 11 + ) + var replacementCount = 0 + var displacedURL: URL? + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: candidate, + capturedBackupIdentity: target, + replaceItem: { targetURL, _, backupName in + replacementCount += 1 + displacedURL = targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + }, + readIdentity: { url in + if url == backupURL { return target } + if url == staged.targetURL { return replacementCount == 0 ? candidate : swappedDuringRestore } + if url == displacedURL { return candidate } + return swappedDuringRestore + } + ) + + guard case let .installation(message) = failure else { + return XCTFail("A changed restored artifact must surface an explicit recovery failure") + } + XCTAssertTrue(message.localizedCaseInsensitiveContains("restore")) + XCTAssertEqual(replacementCount, 2) + } + + func testRestoreVerifiedBackupRecoversPreservedCandidateAfterPostUseMismatch() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let swappedBackup = pinnedIdentity(version: "0.9.0", build: "90", hash: "swapped-v0.9", inode: 30) + var identities: [URL: BundleUpdateIdentity] = [ + staged.targetURL: candidate, + backupURL: target, + ] + var replacementCount = 0 + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: candidate, + capturedBackupIdentity: target, + replaceItem: { targetURL, replacementURL, backupName in + replacementCount += 1 + let displacedURL = targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + identities[displacedURL] = identities[targetURL] + if replacementCount == 1 { + // Model a swap after the immediate backup check but before + // the atomic replacement consumes the path. + identities[targetURL] = swappedBackup + } else { + identities[targetURL] = identities[replacementURL] + } + identities[replacementURL] = nil + }, + readIdentity: { identities[$0] } + ) + + XCTAssertEqual(replacementCount, 2) + XCTAssertEqual(identities[staged.targetURL], candidate) + guard case .installation = failure else { + return XCTFail("A post-use mismatch must restore the preserved known candidate and report recovery") + } + } + + func testRestoreVerifiedBackupRestoresNewerTargetDisplacedAfterPrecheck() { + let original = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: original, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let capturedBackup = pinnedIdentity(version: "3.0.0", build: "300", hash: "target-v3", inode: 30) + let racedTarget = pinnedIdentity(version: "4.0.0", build: "400", hash: "target-v4", inode: 40) + var identities: [URL: BundleUpdateIdentity] = [ + staged.targetURL: candidate, + backupURL: capturedBackup, + ] + var replacementCount = 0 + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: candidate, + capturedBackupIdentity: capturedBackup, + replaceItem: { targetURL, replacementURL, backupName in + replacementCount += 1 + let displacedURL = targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + identities[displacedURL] = replacementCount == 1 ? racedTarget : identities[targetURL] + identities[targetURL] = identities[replacementURL] + identities[replacementURL] = nil + }, + readIdentity: { identities[$0] } + ) + + XCTAssertNil(failure) + XCTAssertEqual(replacementCount, 2) + XCTAssertEqual(identities[staged.targetURL], racedTarget) + } + + func testRestoreVerifiedBackupNeverRecoversUnpinnedInstalledArtifact() { + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = stagedUpdate(target: target, candidate: candidate) + let backupURL = URL(fileURLWithPath: "/Applications/.Burrow Backup.app") + let wrongInstalled = pinnedIdentity(version: "1.5.0", build: "150", hash: "wrong-v1.5", inode: 25) + let swappedDuringRollback = pinnedIdentity( + version: "0.9.0", + build: "90", + hash: "swapped-v0.9", + inode: 30 + ) + var identities: [URL: BundleUpdateIdentity] = [ + staged.targetURL: wrongInstalled, + backupURL: target, + ] + var replacementCount = 0 + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: wrongInstalled, + capturedBackupIdentity: target, + replaceItem: { targetURL, replacementURL, backupName in + replacementCount += 1 + let displacedURL = targetURL.deletingLastPathComponent().appendingPathComponent(backupName) + identities[displacedURL] = identities[targetURL] + identities[targetURL] = swappedDuringRollback + identities[replacementURL] = nil + }, + readIdentity: { identities[$0] } + ) + + XCTAssertEqual(replacementCount, 1) + XCTAssertNotEqual(identities[staged.targetURL], wrongInstalled) + guard case .installation = failure else { + return XCTFail("An unpinned installed artifact must never be restored during recovery") + } + } + + func testReplacementPreservesBackupUntilExplicitCleanup() throws { + let root = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: root) } + let targetURL = root.appendingPathComponent("Example.app", isDirectory: true) + let candidateURL = root.appendingPathComponent("Candidate.app", isDirectory: true) + let backupName = "Backup.app" + let backupURL = root.appendingPathComponent(backupName, isDirectory: true) + try FileManager.default.createDirectory(at: targetURL, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: candidateURL, withIntermediateDirectories: false) + try Data("old".utf8).write(to: targetURL.appendingPathComponent("payload")) + try Data("new".utf8).write(to: candidateURL.appendingPathComponent("payload")) + + try ElectronReplacementInstaller.replacePreservingBackup( + targetURL: targetURL, + candidateURL: candidateURL, + backupName: backupName + ) + + XCTAssertEqual(try Data(contentsOf: targetURL.appendingPathComponent("payload")), Data("new".utf8)) + XCTAssertEqual(try Data(contentsOf: backupURL.appendingPathComponent("payload")), Data("old".utf8)) + + try ElectronReplacementInstaller.removePreservedBackup(at: backupURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: backupURL.path)) + } + + func testExclusiveMoveRestoresMissingTargetAndNeverOverwritesCompetitor() throws { + let root = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: root) } + let sourceURL = root.appendingPathComponent("Verified Backup.app", isDirectory: true) + let targetURL = root.appendingPathComponent("Example.app", isDirectory: true) + try FileManager.default.createDirectory(at: sourceURL, withIntermediateDirectories: false) + try Data("verified".utf8).write(to: sourceURL.appendingPathComponent("payload")) + + try ElectronReplacementInstaller.moveItemExclusively(from: sourceURL, to: targetURL) + + XCTAssertFalse(FileManager.default.fileExists(atPath: sourceURL.path)) + XCTAssertEqual(try Data(contentsOf: targetURL.appendingPathComponent("payload")), Data("verified".utf8)) + + let competingSourceURL = root.appendingPathComponent("Second Backup.app", isDirectory: true) + try FileManager.default.createDirectory(at: competingSourceURL, withIntermediateDirectories: false) + try Data("second".utf8).write(to: competingSourceURL.appendingPathComponent("payload")) + + XCTAssertThrowsError( + try ElectronReplacementInstaller.moveItemExclusively(from: competingSourceURL, to: targetURL) + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: competingSourceURL.path)) + XCTAssertEqual(try Data(contentsOf: targetURL.appendingPathComponent("payload")), Data("verified".utf8)) + } + + func testUnreadableDanglingSymlinkUsesQuarantineReplacementInsteadOfMissingPathMove() throws { + let root = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: root) } + let targetURL = root.appendingPathComponent("Example.app") + let backupURL = root.appendingPathComponent("Verified Backup.app") + let missingDestination = root.appendingPathComponent("Missing.app") + try FileManager.default.createSymbolicLink(at: targetURL, withDestinationURL: missingDestination) + XCTAssertFalse(FileManager.default.fileExists(atPath: targetURL.path)) + + let target = pinnedIdentity(version: "1.0.0", build: "100", hash: "target-v1", inode: 10) + let candidate = pinnedIdentity(version: "2.0.0", build: "200", hash: "candidate-v2", inode: 20) + let staged = StagedElectronUpdate( + targetURL: targetURL, + candidateURL: root.appendingPathComponent("Candidate.app"), + stagingDirectory: root, + stagingDirectoryIdentity: try XCTUnwrap( + ElectronReplacementInstaller.stagingDirectoryIdentity(at: root) + ), + canonicalStagingDirectoryURL: root.resolvingSymlinksInPath().standardizedFileURL, + expectedIdentity: target, + expectedCandidateIdentity: candidate, + descriptorVersion: candidate.version! + ) + var usedQuarantineReplacement = false + var usedMissingPathMove = false + + let failure = ElectronReplacementInstaller.restoreVerifiedBackup( + for: staged, + backupURL: backupURL, + capturedInstalledIdentity: nil, + capturedBackupIdentity: target, + replaceItem: { _, _, _ in usedQuarantineReplacement = true }, + moveItemExclusively: { _, _ in usedMissingPathMove = true }, + readIdentity: { url in + if url == backupURL { return target } + if url == staged.targetURL { return usedQuarantineReplacement ? target : nil } + return nil + } + ) + + XCTAssertNil(failure) + XCTAssertTrue(usedQuarantineReplacement) + XCTAssertFalse(usedMissingPathMove) + } + func testUpdatePhaseExposesRecoveryAndAccessibleProgress() { XCTAssertTrue(UpdatePhase.failed(.offline).canRetry) XCTAssertEqual(UpdatePhase.downloading(progress: 0.42).accessibilityValue, "Downloading, 42 percent") @@ -207,4 +1051,57 @@ final class UpdateWorkflowTests: XCTestCase { headerFields: nil )! } + + private func pinnedIdentity( + version: String, + build: String, + hash: String, + inode: UInt64 + ) -> BundleUpdateIdentity { + BundleUpdateIdentity( + bundleID: "com.example.App", + signingIdentifier: "com.example.App", + teamIdentifier: "TEAM123", + signatureValid: true, + version: version, + build: build, + codeDirectoryHash: Data(hash.utf8), + fileIdentity: .init(device: 1, inode: inode) + ) + } + + private func stagedUpdate( + target: BundleUpdateIdentity, + candidate: BundleUpdateIdentity + ) -> StagedElectronUpdate { + StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Example.app"), + candidateURL: URL(fileURLWithPath: "/tmp/private/Candidate.app"), + stagingDirectory: URL(fileURLWithPath: "/tmp/private"), + stagingDirectoryIdentity: .init(device: 1, inode: 1), + canonicalStagingDirectoryURL: URL(fileURLWithPath: "/tmp/private"), + expectedIdentity: target, + expectedCandidateIdentity: candidate, + descriptorVersion: candidate.version! + ) + } + + private func stagedUpdateForCleanup( + root: URL, + identity: BundleFileIdentity + ) -> StagedElectronUpdate { + StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Example.app"), + candidateURL: root.appendingPathComponent("Candidate.app"), + stagingDirectory: root, + stagingDirectoryIdentity: identity, + canonicalStagingDirectoryURL: root.resolvingSymlinksInPath().standardizedFileURL, + expectedIdentity: pinnedIdentity( + version: "1.0.0", + build: "100", + hash: "target-v1", + inode: 10 + ) + ) + } } diff --git a/macos/Tests/UpdatesModelTests.swift b/macos/Tests/UpdatesModelTests.swift index 82ad7fbf..175cd1db 100644 --- a/macos/Tests/UpdatesModelTests.swift +++ b/macos/Tests/UpdatesModelTests.swift @@ -122,6 +122,472 @@ final class UpdatesModelTests: XCTestCase { XCTAssertNotEqual(before, after) } + func testTransientCheckAutomaticallyRetriesOnceAndPublishesTheRecoveredUpdate() async { + let responses = LockedCheckResponseQueue([ + .failure(id: "electron", failure: .offline), + .available(id: "electron", version: "2.0.0"), + ]) + let retryDelays = LockedUInt64Recorder() + let model = UpdatesModel( + checkItem: { item in responses.next(for: item.id) }, + retrySleep: { delay in retryDelays.append(delay) }, + loadBrewOutdated: { [] } + ) + model.appItems = [updateItem(id: "electron", installed: "1.0.0", source: .electron)] + + model.checkNow() + await eventually { model.checked } + + XCTAssertEqual(responses.count, 2) + XCTAssertEqual(retryDelays.values.count, 1) + XCTAssertLessThanOrEqual(retryDelays.values[0], 2_000_000_000) + XCTAssertEqual(model.appItems.first?.latestVersion, "2.0.0") + XCTAssertEqual(model.phase(for: "electron"), .available) + } + + func testAutomaticRetryIsBoundedAndPreservesTheLastConfirmedUpdate() async { + let responses = LockedCheckResponseQueue([ + .failure(id: "electron", failure: .decoding), + .failure(id: "electron", failure: .decoding), + .available(id: "electron", version: "9.9.9"), + ]) + let retryDelays = LockedUInt64Recorder() + let model = UpdatesModel( + checkItem: { item in responses.next(for: item.id) }, + retrySleep: { delay in retryDelays.append(delay) }, + loadBrewOutdated: { [] } + ) + model.appItems = [updateItem( + id: "electron", + installed: "1.0.0", + source: .electron, + latest: "2.0.0" + )] + + model.checkNow() + await eventually { model.checked } + + XCTAssertEqual(responses.count, 2) + XCTAssertEqual(retryDelays.values.count, 1) + XCTAssertEqual(model.appItems.first?.latestVersion, "2.0.0") + XCTAssertEqual(model.phase(for: "electron"), .failed(.decoding)) + } + + func testRetryAfterCheckFailureRerunsTheCheckEvenWhenAKnownUpdateWasPreserved() async { + let responses = LockedCheckResponseQueue([ + .failure(id: "electron", failure: .offline), + .failure(id: "electron", failure: .offline), + .available(id: "electron", version: "2.1.0"), + ]) + let model = UpdatesModel( + checkItem: { item in responses.next(for: item.id) }, + retrySleep: { _ in }, + loadBrewOutdated: { [] } + ) + model.appItems = [updateItem( + id: "electron", + installed: "1.0.0", + source: .electron, + latest: "2.0.0" + )] + + model.checkNow() + await eventually { model.checked && model.phase(for: "electron") == .failed(.offline) } + model.retry(model.appItems[0]) + await eventually { !model.checking && model.appItems.first?.latestVersion == "2.1.0" } + + XCTAssertEqual(responses.count, 3) + XCTAssertEqual(model.phase(for: "electron"), .available) + } + + func testOlderForcedResponsePreservesPreviouslyConfirmedAvailability() async { + let responses = LockedCheckResponseQueue([ + .completed(id: "electron", version: "0.9.0"), + ]) + let model = UpdatesModel( + checkItem: { item in responses.next(for: item.id) }, + loadBrewOutdated: { [] } + ) + model.appItems = [updateItem( + id: "electron", + installed: "1.0.0", + source: .electron, + latest: "2.0.0" + )] + + model.checkNow() + await eventually { model.checked } + + XCTAssertEqual(responses.count, 1) + XCTAssertEqual(model.appItems.first?.latestVersion, "2.0.0") + XCTAssertEqual(model.availableItems.map(\.id), ["electron"]) + XCTAssertEqual(model.phase(for: "electron"), .available) + } + + func testOlderForcedCheckCannotOverwriteANewerGeneration() async { + let oldStarted = expectation(description: "older check started") + let responder = OutOfOrderCheckResponder(onOldStart: { oldStarted.fulfill() }) + let model = UpdatesModel( + detectSource: { _ in .electron }, + sourceFingerprint: { $0.path }, + checkItem: { item in await responder.response(for: item.id) }, + loadBrewOutdated: { [] } + ) + + model.prepare(apps: [app(id: "old", name: "Old", path: "/Applications/Old.app")]) + await eventually { model.appItems.map(\.id) == ["old"] } + model.checkNow() + await fulfillment(of: [oldStarted], timeout: 1) + + model.prepare(apps: [app(id: "new", name: "New", path: "/Applications/New.app")]) + await eventually { model.appItems.map(\.id) == ["new"] } + model.checkNow() + await eventually { model.checked && model.appItems.first?.latestVersion == "3.0.0" } + + await responder.finishOldResponse() + await settle() + + XCTAssertEqual(model.appItems.map(\.id), ["new"]) + XCTAssertEqual(model.appItems.first?.latestVersion, "3.0.0") + XCTAssertEqual(model.phase(for: "new"), .available) + XCTAssertEqual(model.phase(for: "old"), .idle) + } + + func testUpdateAllDoesNotStartWhileARowUpdateIsRunning() async { + let rowStageStarted = expectation(description: "row stage started") + let staging = FirstBlockingElectronStage(onFirstStart: { rowStageStarted.fulfill() }) + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let model = UpdatesModel( + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in await staging.stage() } + ) + model.appItems = [updateItem(id: "electron", installed: "1.0.0", source: .electron)] + model.checkNow() + await eventually { model.checked } + + model.update(model.appItems[0]) + await fulfillment(of: [rowStageStarted], timeout: 1) + model.updateAll() + + XCTAssertFalse(model.updateAllRunning) + await staging.finishFirst(with: .failure(.cancelled)) + await eventually { model.phase(for: "electron") == .failed(.cancelled) } + let callCount = await staging.callCount + XCTAssertEqual(callCount, 1) + } + + func testRowUpdateDoesNotStartWhileUpdateAllIsRunning() async { + let batchStageStarted = expectation(description: "batch stage started") + let staging = FirstBlockingElectronStage(onFirstStart: { batchStageStarted.fulfill() }) + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let model = UpdatesModel( + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in await staging.stage() } + ) + model.appItems = [updateItem(id: "electron", installed: "1.0.0", source: .electron)] + model.checkNow() + await eventually { model.checked } + + model.updateAll() + model.update(model.appItems[0]) + await fulfillment(of: [batchStageStarted], timeout: 1) + await staging.finishFirst(with: .failure(.cancelled)) + await eventually { !model.updateAllRunning } + await settle() + + let callCount = await staging.callCount + XCTAssertEqual(callCount, 1) + } + + func testRemovedAppCannotBeResurrectedWhenUpdateAllStageFinishesLate() async throws { + let stagingRoot = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: stagingRoot) } + let stageStarted = expectation(description: "batch stage started") + let staging = FirstBlockingElectronStage(onFirstStart: { stageStarted.fulfill() }) + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let staged = stagedElectronUpdate(in: stagingRoot) + var confirmationCount = 0 + var installCount = 0 + let installed = app( + id: "electron", + name: "Electron", + path: "/Applications/Electron.app" + ) + let model = UpdatesModel( + detectSource: { _ in .electron }, + sourceFingerprint: { $0.path }, + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in await staging.stage() }, + installElectron: { _ in + installCount += 1 + return .installed + }, + confirmRestart: { _ in + confirmationCount += 1 + return true + } + ) + + model.prepare(apps: [installed]) + await eventually { model.appItems.map(\.id) == ["electron"] } + model.checkNow() + await eventually { model.checked && model.availableItems.map(\.id) == ["electron"] } + + model.updateAll() + await fulfillment(of: [stageStarted], timeout: 1) + model.prepare(apps: []) + XCTAssertTrue(model.appItems.isEmpty) + + await staging.finishFirst(with: .ready(staged)) + await eventually { + !model.updateAllRunning && !FileManager.default.fileExists(atPath: stagingRoot.path) + } + + XCTAssertTrue(model.appItems.isEmpty) + XCTAssertEqual(model.phase(for: "electron"), .idle) + XCTAssertEqual(model.updateAllCompleted, 0) + XCTAssertEqual(confirmationCount, 0) + XCTAssertEqual(installCount, 0) + } + + func testRestartConsentCannotInstallAnAppRemovedWhileTheModalIsOpen() async throws { + let stagingRoot = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: stagingRoot) } + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let staged = stagedElectronUpdate(in: stagingRoot) + let installed = app( + id: "electron", + name: "Electron", + path: "/Applications/Electron.app" + ) + var reentrantModel: UpdatesModel? + var installCount = 0 + let model = UpdatesModel( + detectSource: { _ in .electron }, + sourceFingerprint: { $0.path }, + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in .ready(staged) }, + installElectron: { _ in + installCount += 1 + return .installed + }, + confirmRestart: { _ in + reentrantModel?.prepare(apps: []) + return true + } + ) + reentrantModel = model + + model.prepare(apps: [installed]) + await eventually { model.appItems.map(\.id) == ["electron"] } + model.checkNow() + await eventually { model.checked && model.availableItems.map(\.id) == ["electron"] } + model.update(model.appItems[0]) + await eventually { model.phase(for: "electron") == .readyToInstall } + + model.installReady(model.appItems[0]) + await settle() + + XCTAssertTrue(model.appItems.isEmpty) + XCTAssertEqual(model.phase(for: "electron"), .idle) + XCTAssertEqual(installCount, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: stagingRoot.path)) + } + + func testCancelledRowTaskCannotRemoveTrackingForItsReplacement() async throws { + let stagingRoot = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: stagingRoot) } + let firstStageStarted = expectation(description: "first row stage started") + let replacementStageStarted = expectation(description: "replacement row stage started") + let staging = SequencedBlockingElectronStages(onStart: { call in + if call == 1 { firstStageStarted.fulfill() } + if call == 2 { replacementStageStarted.fulfill() } + }) + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let cancelledStage = StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Electron.app"), + candidateURL: stagingRoot.appendingPathComponent("Electron.app"), + stagingDirectory: stagingRoot, + stagingDirectoryIdentity: try XCTUnwrap( + ElectronReplacementInstaller.stagingDirectoryIdentity(at: stagingRoot) + ), + canonicalStagingDirectoryURL: stagingRoot.resolvingSymlinksInPath().standardizedFileURL, + expectedIdentity: BundleUpdateIdentity( + bundleID: "dev.test.electron", + signingIdentifier: "dev.test.electron", + teamIdentifier: "TEAM123", + signatureValid: true + ) + ) + let model = UpdatesModel( + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in await staging.stage() } + ) + model.appItems = [updateItem(id: "electron", installed: "1.0.0", source: .electron)] + model.checkNow() + await eventually { model.checked } + + model.update(model.appItems[0]) + await fulfillment(of: [firstStageStarted], timeout: 1) + model.cancel(model.appItems[0]) + model.update(model.appItems[0]) + await fulfillment(of: [replacementStageStarted], timeout: 1) + + await staging.finish(call: 1, with: .ready(cancelledStage)) + await eventually { !FileManager.default.fileExists(atPath: stagingRoot.path) } + model.updateAll() + + XCTAssertFalse(model.updateAllRunning) + await staging.finish(call: 2, with: .failure(.offline)) + await eventually { model.phase(for: "electron") == .failed(.offline) } + let callCount = await staging.callCount + XCTAssertEqual(callCount, 2) + } + + func testCancelledRowTaskFinishingAfterReplacementCannotOverwriteReplacementState() async throws { + let cancelledRoot = try PrivateUpdateDirectory.create() + let replacementRoot = try PrivateUpdateDirectory.create() + defer { + try? FileManager.default.removeItem(at: cancelledRoot) + try? FileManager.default.removeItem(at: replacementRoot) + } + let firstStageStarted = expectation(description: "first row stage started") + let replacementStageStarted = expectation(description: "replacement row stage started") + let staging = SequencedBlockingElectronStages(onStart: { call in + if call == 1 { firstStageStarted.fulfill() } + if call == 2 { replacementStageStarted.fulfill() } + }) + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let cancelledStage = stagedElectronUpdate(in: cancelledRoot) + let replacementStage = stagedElectronUpdate(in: replacementRoot) + var installedCandidate: URL? + let model = UpdatesModel( + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in await staging.stage() }, + installElectron: { staged in + installedCandidate = staged.candidateURL + return .installed + }, + confirmRestart: { _ in true } + ) + model.appItems = [updateItem(id: "electron", installed: "1.0.0", source: .electron)] + model.checkNow() + await eventually { model.checked } + + model.update(model.appItems[0]) + await fulfillment(of: [firstStageStarted], timeout: 1) + model.cancel(model.appItems[0]) + model.update(model.appItems[0]) + await fulfillment(of: [replacementStageStarted], timeout: 1) + + await staging.finish(call: 2, with: .ready(replacementStage)) + await eventually { model.phase(for: "electron") == .readyToInstall } + await staging.finish(call: 1, with: .ready(cancelledStage)) + await eventually { !FileManager.default.fileExists(atPath: cancelledRoot.path) } + + XCTAssertEqual(model.phase(for: "electron"), .readyToInstall) + XCTAssertTrue(FileManager.default.fileExists(atPath: replacementRoot.path)) + model.installReady(model.appItems[0]) + await eventually { model.phase(for: "electron") == .completed } + XCTAssertEqual(installedCandidate, replacementStage.candidateURL) + } + + func testUpdateAllStagesElectronThenRequiresExplicitRestartConsent() async throws { + let stagingRoot = try PrivateUpdateDirectory.create() + defer { try? FileManager.default.removeItem(at: stagingRoot) } + let descriptor = ElectronUpdateDescriptor( + version: "2.0.0", + archiveURL: URL(string: "https://updates.example.com/Electron.zip")!, + sha512: Data("digest".utf8) + ) + let staged = StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Electron.app"), + candidateURL: stagingRoot.appendingPathComponent("Electron.app"), + stagingDirectory: stagingRoot, + stagingDirectoryIdentity: try XCTUnwrap( + ElectronReplacementInstaller.stagingDirectoryIdentity(at: stagingRoot) + ), + canonicalStagingDirectoryURL: stagingRoot.resolvingSymlinksInPath().standardizedFileURL, + expectedIdentity: BundleUpdateIdentity( + bundleID: "dev.test.electron", + signingIdentifier: "dev.test.electron", + teamIdentifier: "TEAM123", + signatureValid: true + ) + ) + let workflow = LockedElectronWorkflow(staged: staged) + let model = UpdatesModel( + checkItem: { item in + .available(id: item.id, version: descriptor.version, electronDescriptor: descriptor) + }, + loadBrewOutdated: { [] }, + stageElectron: { _, _ in workflow.stage() }, + installElectron: { staged in workflow.install(staged) }, + confirmRestart: { item in workflow.confirmRestart(for: item.name) } + ) + model.appItems = [updateItem(id: "electron", installed: "1.0.0", source: .electron)] + model.checkNow() + await eventually { model.checked } + + model.updateAll() + await eventually { !model.updateAllRunning && model.phase(for: "electron") == .readyToInstall } + + XCTAssertEqual(workflow.stageCount, 1) + XCTAssertEqual(workflow.installCount, 0) + XCTAssertEqual(workflow.confirmedNames, ["Electron"]) + XCTAssertEqual(workflow.events, ["stage", "confirm:Electron"]) + + workflow.allowRestart() + model.installReady(model.appItems[0]) + await eventually { model.phase(for: "electron") == .completed } + + XCTAssertEqual(workflow.installCount, 1) + XCTAssertEqual(workflow.confirmedNames, ["Electron", "Electron"]) + XCTAssertEqual(workflow.events, ["stage", "confirm:Electron", "confirm:Electron", "install"]) + } + private func app(id: String, name: String, path: String) -> InstalledApp { InstalledApp( id: id, @@ -136,6 +602,45 @@ final class UpdatesModelTests: XCTestCase { ) } + private func updateItem( + id: String, + installed: String, + source: UpdateSources.Source, + latest: String? = nil + ) -> AppUpdateItem { + AppUpdateItem( + id: id, + name: id.capitalized, + path: "/Applications/\(id.capitalized).app", + bundleID: "dev.test.\(id)", + installedVersion: installed, + sizeStr: "1 B", + source: source, + latestVersion: latest, + pageURL: nil, + releaseNotesURL: nil, + lastUsed: nil, + minimumOS: nil + ) + } + + private func stagedElectronUpdate(in root: URL) -> StagedElectronUpdate { + StagedElectronUpdate( + targetURL: URL(fileURLWithPath: "/Applications/Electron.app"), + candidateURL: root.appendingPathComponent("Electron.app"), + stagingDirectory: root, + stagingDirectoryIdentity: ElectronReplacementInstaller.stagingDirectoryIdentity(at: root) + ?? .init(device: 0, inode: 0), + canonicalStagingDirectoryURL: root.resolvingSymlinksInPath().standardizedFileURL, + expectedIdentity: BundleUpdateIdentity( + bundleID: "dev.test.electron", + signingIdentifier: "dev.test.electron", + teamIdentifier: "TEAM123", + signatureValid: true + ) + ) + } + private func writePlist(_ values: [String: Any], to url: URL) throws { let data = try PropertyListSerialization.data( fromPropertyList: values, @@ -161,6 +666,179 @@ final class UpdatesModelTests: XCTestCase { } } +private final class LockedCheckResponseQueue: @unchecked Sendable { + private let lock = NSLock() + private var responses: [AppUpdateCheckResult] + private var calls = 0 + + init(_ responses: [AppUpdateCheckResult]) { + self.responses = responses + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return calls + } + + func next(for id: String) -> AppUpdateCheckResult { + lock.lock() + defer { lock.unlock() } + calls += 1 + guard !responses.isEmpty else { + return .failure(id: id, failure: .invalidResponse) + } + return responses.removeFirst() + } +} + +private final class LockedUInt64Recorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [UInt64] = [] + + var values: [UInt64] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ value: UInt64) { + lock.lock() + storage.append(value) + lock.unlock() + } +} + +private actor OutOfOrderCheckResponder { + private var oldContinuation: CheckedContinuation? + private let onOldStart: () -> Void + + init(onOldStart: @escaping () -> Void) { + self.onOldStart = onOldStart + } + + func response(for id: String) async -> AppUpdateCheckResult { + if id == "old" { + onOldStart() + await withCheckedContinuation { continuation in + oldContinuation = continuation + } + return .available(id: id, version: "2.0.0") + } + return .available(id: id, version: "3.0.0") + } + + func finishOldResponse() { + oldContinuation?.resume() + oldContinuation = nil + } +} + +private actor FirstBlockingElectronStage { + private var calls = 0 + private var firstContinuation: CheckedContinuation? + private let onFirstStart: () -> Void + + init(onFirstStart: @escaping () -> Void) { + self.onFirstStart = onFirstStart + } + + var callCount: Int { calls } + + func stage() async -> ElectronStageOutcome { + calls += 1 + guard calls == 1 else { return .failure(.cancelled) } + onFirstStart() + return await withCheckedContinuation { continuation in + firstContinuation = continuation + } + } + + func finishFirst(with outcome: ElectronStageOutcome) { + firstContinuation?.resume(returning: outcome) + firstContinuation = nil + } +} + +private actor SequencedBlockingElectronStages { + private var calls = 0 + private var continuations: [Int: CheckedContinuation] = [:] + private let onStart: (Int) -> Void + + init(onStart: @escaping (Int) -> Void) { + self.onStart = onStart + } + + var callCount: Int { calls } + + func stage() async -> ElectronStageOutcome { + calls += 1 + let call = calls + guard call <= 2 else { return .failure(.cancelled) } + onStart(call) + return await withCheckedContinuation { continuation in + continuations[call] = continuation + } + } + + func finish(call: Int, with outcome: ElectronStageOutcome) { + continuations.removeValue(forKey: call)?.resume(returning: outcome) + } +} + +private final class LockedElectronWorkflow: @unchecked Sendable { + private let lock = NSLock() + private let stagedUpdate: StagedElectronUpdate + private var restartAllowed = false + private var stages = 0 + private var installs = 0 + private var names: [String] = [] + private var recordedEvents: [String] = [] + + init(staged: StagedElectronUpdate) { + stagedUpdate = staged + } + + var stageCount: Int { locked { stages } } + var installCount: Int { locked { installs } } + var confirmedNames: [String] { locked { names } } + var events: [String] { locked { recordedEvents } } + + func stage() -> ElectronStageOutcome { + locked { + stages += 1 + recordedEvents.append("stage") + } + return .ready(stagedUpdate) + } + + func install(_ staged: StagedElectronUpdate) -> ElectronInstallOutcome { + locked { + installs += 1 + recordedEvents.append("install") + } + return .installed + } + + func confirmRestart(for name: String) -> Bool { + locked { + names.append(name) + recordedEvents.append("confirm:\(name)") + return restartAllowed + } + } + + func allowRestart() { + locked { restartAllowed = true } + } + + private func locked(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } +} + private final class LockedStringRecorder: @unchecked Sendable { private let lock = NSLock() private var storage: [String] = [] diff --git a/macos/vendor/burrow-engine b/macos/vendor/burrow-engine index b3e788e2..9d5a102d 160000 --- a/macos/vendor/burrow-engine +++ b/macos/vendor/burrow-engine @@ -1 +1 @@ -Subproject commit b3e788e25550dbbaa0cfa5df729cf01e2745aa27 +Subproject commit 9d5a102d73302f6404b96fc89565f1a03848ae9a diff --git a/scripts/tests/test_sentry_issues_workflow.py b/scripts/tests/test_sentry_issues_workflow.py index 6d932746..072a5d14 100644 --- a/scripts/tests/test_sentry_issues_workflow.py +++ b/scripts/tests/test_sentry_issues_workflow.py @@ -59,6 +59,11 @@ def test_workflow_never_copies_raw_sentry_payloads_to_public_issues(self) -> Non self.assertNotIn("latest stack trace", workflow) self.assertNotIn("Stack trace (most recent event)", workflow) self.assertNotIn("${title}", workflow) + self.assertNotIn("<<<\"$issue\"", workflow) + self.assertEqual( + workflow.count("firstSeen=$(jq -r '.firstSeen' \"$summary_json\")"), + 1, + ) def test_app_hangs_are_aggregated_instead_of_silently_skipped(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") diff --git a/windows/BURROW_WINDOWS_ALIGNMENT.md b/windows/BURROW_WINDOWS_ALIGNMENT.md index e57270a7..cf9ddc2c 100644 --- a/windows/BURROW_WINDOWS_ALIGNMENT.md +++ b/windows/BURROW_WINDOWS_ALIGNMENT.md @@ -55,7 +55,7 @@ Current Windows-specific adaptations: - `dotnet build BurrowWin.csproj -p:Platform=x64 -nr:false -v:minimal` succeeds with 0 warnings and 0 errors. - `dotnet build Tests\BurrowWin.Tests\BurrowWin.Tests.csproj -nr:false -v:minimal` succeeds with 0 warnings and 0 errors. -- The pre-BUR-10 baseline passed its then-current Windows test suite. BUR-10 expands the current source to 130 xUnit cases (114 facts plus 16 inline theory cases); its exact build/test result must be taken from Windows CI because the implementation environment did not include the .NET/Windows SDK. +- The current source is 137 xUnit cases (121 facts plus 16 inline theory cases). The exact build/test result must be taken from Windows CI, because the implementation environment has no .NET/Windows SDK — the last Windows-host figure quoted here (66 tests) predates the authenticated loopback work and no longer describes this suite. - Mole command history normalization is covered by `ExecuteCommandAsync_RecordsAnsiFreeHistorySummary`, including ANSI color removal, control character removal, and CLI icon placeholder removal. - HTTP runtime settings changes are covered by `HttpServerSettingsPlannerTests`, including no-op, start, stop, restart, and disabled-stays-disabled decisions. - `.\run-local.ps1 -NoBuild -SmokeTest -Restart -RequireHealth -TimeoutSeconds 45` starts the x64 Debug GUI, confirms the `BurrowWin` main window is visible, confirms `/health` returns `ok: true`, and writes startup diagnostics to `%LOCALAPPDATA%\BurrowWin\startup.log`. @@ -83,7 +83,7 @@ Current Windows-specific adaptations: - Runtime smoke verified the x64 Debug app starts, authenticated requests to `http://127.0.0.1:9277/health`, `/snapshot`, and `/metrics?limit=2` return live local telemetry, and `Assets\Mcp\burrow-mcp-stdio.exe` reads the per-install credential and can call `burrow_snapshot`, `burrow_info`, `burrow_top_processes`, and `burrow_process_usage`. - Runtime smoke after the tray HUD/menu work launched `bin\x64\Debug\...\BurrowWin.exe` and confirmed `/health` returned `ok: true`, engine availability, and a fresh `latest_sample_at`. - Latest tray HUD screenshot smoke launched `bin\x64\Debug\...\BurrowWin.exe` with `BURROWWIN_SHOW_TRAY_HUD=1`, captured `artifacts\burrowwin-tray-hud-smoke.png`, and visually confirmed the HUD window, status cards, activity card, top CPU process rows, and quick navigation buttons render without clipping. -- `.\scripts\build-release.ps1` restores, builds Release x64, runs the current test suite, publishes the portable WinUI payload, creates `Burrow-v0.1.0-preview.1-win-x64-setup.exe`, creates `Burrow-v0.1.0-preview.1-win-x64.zip`, writes `SHA256SUMS.txt`, writes WinGet manifests, and copies release docs into the payload. +- `.\scripts\build-release.ps1` restores, builds Release x64, runs the Windows test suite, publishes the portable WinUI payload, creates `Burrow-v0.1.0-preview.1-win-x64-setup.exe`, creates `Burrow-v0.1.0-preview.1-win-x64.zip`, writes `SHA256SUMS.txt`, writes WinGet manifests, and copies release docs into the payload. - The generated installer and ZIP contain `BurrowWin.exe`, `Assets\Mole\mo.exe`, `Assets\Mcp\burrow-mcp-stdio.exe`, README, LICENSE, release notes, Windows alignment notes, and Mole gap notes. - The generated installer and ZIP hashes verify against `artifacts\release\SHA256SUMS.txt`. - The generated WinGet manifest targets `Caezium.Burrow`, package name `Burrow`, `InstallerType: inno`, `Scope: user`, x64 architecture, and the GitHub Release setup exe URL. From 39c719980c92f3634af688854da1a97204b67876 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:05:53 -0700 Subject: [PATCH 05/11] fix(macos): report what the cleanup actually did Reverts the manifest-based irreversible cleanup added in 9f3208f and restores `find -x -depth -delete` behind the existing boundary checks. The manifest walked every reviewed tree in the GUI to enumerate descendants, then spawned one root process per node; on a large Xcode cache that is tens of thousands of spawns, and any entry whose volume differed from the quarantine root aborted the whole run. The deletes now live next to the plan that authorizes them, as `CleanupExecutionPlan.irreversibleCleanupShell()`, so a test can execute the real shell rather than a reimplementation of it. Two reporting bugs went with it: * `find -delete` is documented to always return true. It exits 0 having printed "Permission denied" and removed nothing, so its exit status was never a success signal -- a clean that freed nothing reported success. Each entry is now re-checked after the delete and the shell fails on any survivor. * One failing entry aborted the remaining entries and the run was reported as if nothing had happened. Entries are independent now, and `failureMessage` distinguishes "nothing ran" from "ran and partly failed" instead of telling users to rescan after a partial delete. Wrapper exit codes are named in `ElevatedExitCode` rather than bare 124/125/126/127 spread across two files, and a refusal before the auth prompt now carries its reason into the transcript instead of surfacing as an unexplained 126. --- macos/Sources/CleanView.swift | 2 +- macos/Sources/CleanupAuthorization.swift | 325 ++++------------------ macos/Sources/MoleCLI.swift | 22 +- macos/Sources/OperationFlow.swift | 100 +++++-- macos/Sources/TuneUpView.swift | 2 +- macos/Tests/OperationFlowTests.swift | 42 ++- macos/Tests/PrivilegedSecurityTests.swift | 189 +++++-------- 7 files changed, 258 insertions(+), 424 deletions(-) diff --git a/macos/Sources/CleanView.swift b/macos/Sources/CleanView.swift index 4ccc8e14..361625e8 100644 --- a/macos/Sources/CleanView.swift +++ b/macos/Sources/CleanView.swift @@ -286,7 +286,7 @@ struct CleanView: View { screen = .hero realFlow.start(ToolOperation( label: NSLocalizedString("Cleaning reviewed caches", comment: ""), - executable: .path("/bin/sh"), arguments: [], elevated: true, + executable: .path("/usr/bin/find"), arguments: [], elevated: true, cleanupPlan: plan, reduce: { parseTaskReport($0) }, notifyOnEnd: true)) } diff --git a/macos/Sources/CleanupAuthorization.swift b/macos/Sources/CleanupAuthorization.swift index 2cbb7950..19256886 100644 --- a/macos/Sources/CleanupAuthorization.swift +++ b/macos/Sources/CleanupAuthorization.swift @@ -14,12 +14,11 @@ import Darwin struct CleanupExecutionPlan: Sendable, Equatable { struct Item: Sendable, Equatable { + /// The reviewed entry itself — the unit the preview actually showed + /// the user, with its size and item count. Execution deletes the tree + /// rooted at THIS pinned inode; it does not enumerate and pin every + /// descendant, because the review never presented them individually. let identity: PinnedFileIdentity - /// Every name below `identity`, captured without following symlinks. - /// Irreversible execution consumes this exact set deepest-first; it - /// never recursively deletes whatever happens to be below the top - /// directory later. - let descendants: [PinnedFileIdentity] } let snapshotID: UUID @@ -45,15 +44,15 @@ struct CleanupExecutionPlan: Sendable, Equatable { seal == Self.makeSeal(snapshotID: snapshotID, createdAt: createdAt, expiresAt: expiresAt, roots: approvedRoots, items: items), approvedRoots.allSatisfy({ $0.matchesCurrent() }), - items.allSatisfy({ item in - item.identity.matchesCurrent() && - item.descendants.allSatisfy { $0.matchesCurrent() } - }) else { return false } + items.allSatisfy({ $0.identity.matchesCurrent() }) else { return false } + let rootPrefixes: [(device: UInt64, path: String, prefix: String)] = approvedRoots.map { + ($0.device, $0.path, $0.path.hasSuffix("/") ? $0.path : $0.path + "/") + } return items.allSatisfy { item in - approvedRoots.contains { root in - item.identity.device == root.device && - item.identity.path != root.path && - item.identity.path.hasPrefix(root.path.hasSuffix("/") ? root.path : root.path + "/") + let path = item.identity.path + let device = item.identity.device + return rootPrefixes.contains { root in + device == root.device && path != root.path && path.hasPrefix(root.prefix) } } } @@ -71,49 +70,53 @@ struct CleanupExecutionPlan: Sendable, Equatable { } } - /// Materialize the exact reviewed tree into a digest-pinned manifest. - /// The privileged shell copies and verifies that manifest beneath its - /// private root before consuming it, so the command stays bounded even - /// when a cache contains thousands of reviewed descendants. - func prepareIrreversibleCleanup() throws -> CleanupIrreversibleExecution { - try prepareIrreversibleCleanup(preCaptureDelaySeconds: 0, - postCaptureDelaySeconds: 0) - } - -#if DEBUG - func prepareIrreversibleCleanupForTesting(preCaptureDelaySeconds: UInt = 0, - postCaptureDelaySeconds: UInt = 0) throws - -> CleanupIrreversibleExecution { - try prepareIrreversibleCleanup(preCaptureDelaySeconds: preCaptureDelaySeconds, - postCaptureDelaySeconds: postCaptureDelaySeconds) + /// The shell-quoted reviewed paths, deepest-first, for the delete loop. + /// Ordering is cosmetic here — each entry is deleted as a whole tree — but + /// it keeps the transcript readable when nested caches are both ticked. + func quotedReviewedPaths() -> [String] { + func depth(_ path: String) -> Int { path.filter { $0 == "/" }.count } + let ordered = items.map(\.identity.path).sorted { lhs, rhs in + let l = depth(lhs), r = depth(rhs) + return l == r ? lhs < rhs : l > r + } + return ordered.map { MoleCLI.shellQuote($0) } } -#endif - private func prepareIrreversibleCleanup(preCaptureDelaySeconds: UInt, - postCaptureDelaySeconds: UInt) throws - -> CleanupIrreversibleExecution { - guard preCaptureDelaySeconds <= 5, postCaptureDelaySeconds <= 5, - validateForLaunch() else { - throw CleanupIrreversibleExecution.ExecutionError.invalidPlan - } - var identitiesByPath: [String: PinnedFileIdentity] = [:] - for item in items { - for identity in item.descendants + [item.identity] { - if let existing = identitiesByPath[identity.path], existing != identity { - throw CleanupIrreversibleExecution.ExecutionError.invalidPlan - } - identitiesByPath[identity.path] = identity - } - } - let identities = identitiesByPath.values.sorted { lhs, rhs in - let lhsDepth = lhs.path.reduce(into: 0) { if $1 == "/" { $0 += 1 } } - let rhsDepth = rhs.path.reduce(into: 0) { if $1 == "/" { $0 += 1 } } - return lhsDepth == rhsDepth ? lhs.path < rhs.path : lhsDepth > rhsDepth + /// The complete irreversible cleanup: boundary checks, then the deletes. + /// + /// This lives beside the plan that authorizes it rather than inside the + /// AppleScript builder, so a test can execute exactly the semantics that + /// ship instead of reconstructing them from a quoted wrapper. + /// + /// `find -delete` is doing real safety work here, not just recursion. + /// BSD `find` chdir's as it descends, so every unlink is relative to the + /// directory it is standing in rather than a re-resolved path, and it + /// refuses to delete any name whose path relative to "." contains a "/" — + /// which is precisely the mid-walk directory-swap case. Following + /// symlinks is incompatible with `-delete`, so it cannot be redirected out + /// of the tree, and `-x` holds it to the volume the boundary check pinned. + /// + /// A failing entry does not abandon the rest: the loop records it and the + /// aggregate status reports it once. `find` names each failing path on + /// stderr, which the caller redirects into the run log, so the transcript + /// says which entries survived. + /// + /// Success is decided by the POSTCONDITION, never by find's exit status. + /// BSD `-delete` is documented to "always return true", so `find` exits 0 + /// having printed `unlink(...): Permission denied` and deleted nothing — + /// trusting its status is how a cleanup that removed nothing reports + /// "Done — caches cleared". Asking whether the entry is actually gone also + /// covers the cases find never reports at all. + func irreversibleCleanupShell() -> String { + let checks = executionBoundaryChecks().map { + $0 + " || exit \(ElevatedExitCode.boundaryCheckFailed)" } - return try CleanupIrreversibleExecution(snapshotID: snapshotID, - identities: identities, - preCaptureDelaySeconds: preCaptureDelaySeconds, - postCaptureDelaySeconds: postCaptureDelaySeconds) + let paths = quotedReviewedPaths().joined(separator: " ") + let loop = "failed=0; for p in \(paths); do " + + "/usr/bin/find -x \"$p\" -depth -delete; " + + "if [ -e \"$p\" ] || [ -L \"$p\" ]; then failed=1; fi; " + + "done; [ \"$failed\" -eq 0 ]" + return (checks + [loop]).joined(separator: "; ") } private static func makeSeal(snapshotID: UUID, createdAt: Date, expiresAt: Date, @@ -121,182 +124,16 @@ struct CleanupExecutionPlan: Sendable, Equatable { func line(_ i: PinnedFileIdentity) -> String { "\(i.path.utf8.count):\(i.path)|\(i.shellStatToken)" } - var itemLines: [String] = [] - for item in items { - itemLines.append("item:\(line(item.identity))") - itemLines.append(contentsOf: item.descendants.map { "descendant:\(line($0))" }) - itemLines.append("--") - } let payload = ([snapshotID.uuidString, String(createdAt.timeIntervalSince1970.bitPattern), String(expiresAt.timeIntervalSince1970.bitPattern)] - + roots.map { "root:\(line($0))" } + ["--items--"] + itemLines) + + roots.map { "root:\(line($0))" } + ["--items--"] + + items.map { "item:\(line($0.identity))" }) .joined(separator: "\n") return Data(SHA256.hash(data: Data(payload.utf8))) } } -/// Owns the unprivileged source manifest until the elevated process exits. -/// The root shell never trusts this path directly: it copies the bytes into a -/// fresh 0700 directory and validates their SHA-256 digest before parsing. -final class CleanupIrreversibleExecution: @unchecked Sendable { - enum ExecutionError: Error { - case invalidPlan - case cannotCreateManifest(Int32) - case cannotWriteManifest(Int32) - } - - let snapshotID: UUID - let shell: String - let quarantineRootPath: String - - private let manifestPath: String - private let lock = NSLock() - private var removed = false - - fileprivate init(snapshotID: UUID, - identities: [PinnedFileIdentity], - preCaptureDelaySeconds: UInt, - postCaptureDelaySeconds: UInt) throws { - guard !identities.isEmpty, - identities.allSatisfy({ identity in - !identity.path.unicodeScalars.contains { - CharacterSet.controlCharacters.contains($0) - } - }) else { throw ExecutionError.invalidPlan } - - let manifest = identities.map { identity in - let kind = identity.isDirectory ? "d" : "n" - return "\(identity.shellStatToken)\t\(kind)\t\(identity.path)\n" - }.joined() - let data = Data(manifest.utf8) - let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - let writtenManifest = try Self.writeManifest(data) - let privateRoot = "/private/var/tmp/dev.caezium.burrow.cleanup-\(snapshotID.uuidString)" - manifestPath = writtenManifest.path - self.snapshotID = snapshotID - quarantineRootPath = privateRoot - shell = Self.makeShell(manifestPath: writtenManifest.path, - manifestIdentity: writtenManifest.identity, - digest: digest, - quarantineRootPath: privateRoot, - preCaptureDelaySeconds: preCaptureDelaySeconds, - postCaptureDelaySeconds: postCaptureDelaySeconds) - } - - func remove() { - lock.lock() - guard !removed else { lock.unlock(); return } - removed = true - lock.unlock() - _ = Darwin.unlink(manifestPath) - } - - deinit { remove() } - - private struct WrittenManifest { - let path: String - let identity: PinnedFileIdentity - } - - private static func writeManifest(_ data: Data) throws -> WrittenManifest { - var template = Array("/private/var/tmp/dev.caezium.burrow.cleanup-manifest.XXXXXX".utf8CString) - let descriptor = template.withUnsafeMutableBufferPointer { bytes in - mkstemp(bytes.baseAddress) - } - guard descriptor >= 0 else { throw ExecutionError.cannotCreateManifest(errno) } - let path = String(cString: template) - var keep = false - defer { - Darwin.close(descriptor) - if !keep { _ = Darwin.unlink(path) } - } - guard fchmod(descriptor, 0o600) == 0 else { - throw ExecutionError.cannotWriteManifest(errno) - } - let wroteAll = data.withUnsafeBytes { raw -> Bool in - guard let base = raw.baseAddress else { return false } - var offset = 0 - while offset < raw.count { - let written = Darwin.write(descriptor, base.advanced(by: offset), raw.count - offset) - if written < 0 { - if errno == EINTR { continue } - return false - } - offset += written - } - return true - } - var fileStat = stat() - guard wroteAll, fsync(descriptor) == 0, - fstat(descriptor, &fileStat) == 0, - (fileStat.st_mode & S_IFMT) == S_IFREG else { - throw ExecutionError.cannotWriteManifest(errno) - } - keep = true - return WrittenManifest( - path: path, - identity: PinnedFileIdentity(path: path, - device: UInt64(fileStat.st_dev), - inode: UInt64(fileStat.st_ino), - owner: UInt32(fileStat.st_uid), - mode: UInt16(fileStat.st_mode))) - } - - private static func makeShell(manifestPath: String, - manifestIdentity: PinnedFileIdentity, - digest: String, - quarantineRootPath: String, - preCaptureDelaySeconds: UInt, - postCaptureDelaySeconds: UInt) -> String { - let source = MoleCLI.shellQuote(manifestPath) - let root = MoleCLI.shellQuote(quarantineRootPath) - let expectedDigest = MoleCLI.shellQuote(digest) - var statements = [ - "burrow_root=\(root)", - "burrow_source=\(source)", - "burrow_source_capture=\"$burrow_root/source-manifest\"", - "burrow_manifest=\"$burrow_root/manifest\"", - "burrow_abort() { /bin/rm -f -- \"$burrow_manifest\" 2>/dev/null || true; /bin/rmdir -- \"$burrow_root\" 2>/dev/null || true; exit 124; }", - "burrow_source_mismatch() { if [ ! -e \"$burrow_source\" ] && [ ! -L \"$burrow_source\" ]; then /bin/mv -n -- \"$burrow_source_capture\" \"$burrow_source\" 2>/dev/null || true; fi; burrow_abort; }", - "burrow_verified_source_abort() { /bin/rm -f -- \"$burrow_source_capture\" 2>/dev/null || true; burrow_abort; }", - "burrow_restore_abort() { if [ ! -e \"$burrow_original\" ] && [ ! -L \"$burrow_original\" ]; then /bin/mv -n -- \"$burrow_capture\" \"$burrow_original\" 2>/dev/null || true; fi; /bin/rmdir -- \"$burrow_node\" 2>/dev/null || true; burrow_abort; }", - "/bin/mkdir -m 0700 -- \"$burrow_root\" || exit 124", - "/bin/mv -- \"$burrow_source\" \"$burrow_source_capture\" || burrow_abort", - "[ \"$(/usr/bin/stat -f '%d:%i:%u:%p' -- \"$burrow_source_capture\" 2>/dev/null)\" = '\(manifestIdentity.shellStatToken)' ] || burrow_source_mismatch", - "/bin/cp -- \"$burrow_source_capture\" \"$burrow_manifest\" || burrow_verified_source_abort", - "[ \"$(/usr/bin/shasum -a 256 -- \"$burrow_manifest\" | /usr/bin/cut -d ' ' -f 1)\" = \(expectedDigest) ] || burrow_verified_source_abort", - "/bin/rm -f -- \"$burrow_source_capture\" || burrow_verified_source_abort", - "burrow_index=0", - "while IFS=\"$(/usr/bin/printf '\\t')\" read -r burrow_expected burrow_kind burrow_original; do [ -n \"$burrow_expected\" ] && [ -n \"$burrow_original\" ] || burrow_abort", - "burrow_node=\"$burrow_root/node-$burrow_index\"", - "burrow_capture=\"$burrow_node/captured\"", - "/bin/mkdir -m 0700 -- \"$burrow_node\" || burrow_abort", - "burrow_device=${burrow_expected%%:*}", - "[ \"$(/usr/bin/stat -f '%d' -- \"$burrow_node\" 2>/dev/null)\" = \"$burrow_device\" ] || { /bin/rmdir -- \"$burrow_node\" 2>/dev/null || true; burrow_abort; }", - ] - if preCaptureDelaySeconds > 0 { - statements.append("/bin/sleep \(preCaptureDelaySeconds)") - } - statements += [ - "/bin/mv -- \"$burrow_original\" \"$burrow_capture\" || { /bin/rmdir -- \"$burrow_node\" 2>/dev/null || true; burrow_abort; }", - "[ \"$(/usr/bin/stat -f '%d:%i:%u:%p' -- \"$burrow_capture\" 2>/dev/null)\" = \"$burrow_expected\" ] || burrow_restore_abort", - ] - if postCaptureDelaySeconds > 0 { - statements.append("/bin/sleep \(postCaptureDelaySeconds)") - } - statements += [ - "if [ \"$burrow_kind\" = d ]; then /bin/rmdir -- \"$burrow_capture\" || burrow_restore_abort; else /bin/rm -f -- \"$burrow_capture\" || burrow_restore_abort; fi", - "/bin/rmdir -- \"$burrow_node\" || burrow_abort", - "burrow_index=$((burrow_index + 1))", - "done < \"$burrow_manifest\"", - "/bin/rm -f -- \"$burrow_manifest\" || exit 124", - "/bin/rmdir -- \"$burrow_root\" || exit 124", - ] - return statements.joined(separator: "; ") - } -} - struct CleanupSnapshot: Sendable, Equatable { enum SnapshotError: LocalizedError, Equatable { case noApprovedRoots @@ -360,57 +197,13 @@ struct CleanupSnapshot: Sendable, Equatable { guard seen.insert(canonical).inserted else { continue } let identity = try PinnedFileIdentity.capture(canonical) _ = try approvedRoot(for: canonical, device: identity.device, roots: roots) - let descendants = try captureDescendants(of: identity) - captured.append(.init(identity: identity, descendants: descendants)) + captured.append(.init(identity: identity)) } guard !captured.isEmpty else { throw SnapshotError.selectionMismatch } return Self(id: UUID(), createdAt: now, expiresAt: now.addingTimeInterval(lifetime), list: list, approvedRoots: roots, items: captured) } - /// Enumerate without following symlinks and then revalidate every captured - /// identity. The enumeration need not be an atomic filesystem snapshot: - /// anything missed by a concurrent addition is absent from the execution - /// manifest, so the later `rmdir` fails instead of deleting it. - private static func captureDescendants(of root: PinnedFileIdentity) throws - -> [PinnedFileIdentity] { - guard root.isDirectory else { return [] } - let rootURL = URL(fileURLWithPath: root.path, isDirectory: true) - var enumerationFailed = false - guard let enumerator = FileManager.default.enumerator( - at: rootURL, includingPropertiesForKeys: nil, options: [], - errorHandler: { _, _ in enumerationFailed = true; return false }) else { - throw SnapshotError.staleOrChanged - } - - var descendants: [PinnedFileIdentity] = [] - while let value = enumerator.nextObject() as? URL { - if enumerationFailed { throw SnapshotError.staleOrChanged } - let path = value.path - guard path.hasPrefix(root.path + "/"), - !path.unicodeScalars.contains(where: { - CharacterSet.controlCharacters.contains($0) - }) else { throw SnapshotError.malformedPath(path) } - let identity: PinnedFileIdentity - do { - identity = try PinnedFileIdentity.capture(path, rejectSymlink: false) - } catch { - throw SnapshotError.staleOrChanged - } - guard identity.device == root.device else { - enumerator.skipDescendants() - throw SnapshotError.unexpectedVolume(path) - } - if identity.isSymbolicLink { enumerator.skipDescendants() } - descendants.append(identity) - } - guard !enumerationFailed, root.matchesCurrent(), - descendants.allSatisfy({ $0.matchesCurrent() }) else { - throw SnapshotError.staleOrChanged - } - return descendants - } - static func approvedRoot(for path: String, device: UInt64, roots: [PinnedFileIdentity]) throws -> PinnedFileIdentity { guard let root = roots.first(where: { diff --git a/macos/Sources/MoleCLI.swift b/macos/Sources/MoleCLI.swift index a1a4e61e..5f0c725d 100644 --- a/macos/Sources/MoleCLI.swift +++ b/macos/Sources/MoleCLI.swift @@ -133,17 +133,16 @@ enum MoleCLI { /// identity explicitly instead of inheriting root's HOME/USER. static func elevatedScript(command: ValidatedElevatedCommand, args: [String], logSink: PrivilegedLogSink? = nil, - cleanupPlan: CleanupExecutionPlan? = nil, - cleanupExecution: CleanupIrreversibleExecution? = nil) -> String { + cleanupPlan: CleanupExecutionPlan? = nil) -> String { func identityCheck(_ identity: PinnedFileIdentity) -> String { let stat = "/usr/bin/stat -f '%d:%i:%u:%p' -- \(shellQuote(identity.path)) 2>/dev/null" - return "[ \"$(\(stat))\" = \(shellQuote(identity.shellStatToken)) ] || exit 126" + return "[ \"$(\(stat))\" = \(shellQuote(identity.shellStatToken)) ] || exit \(ElevatedExitCode.executableRefused)" } var statements = command.components.map(identityCheck) statements.append(identityCheck(command.executable)) if let bundle = command.signedBundlePath { - statements.append("/usr/bin/codesign --verify --strict -- \(shellQuote(bundle)) >/dev/null 2>&1 || exit 126") + statements.append("/usr/bin/codesign --verify --strict -- \(shellQuote(bundle)) >/dev/null 2>&1 || exit \(ElevatedExitCode.executableRefused)") } let user = command.invokingUser @@ -159,12 +158,11 @@ enum MoleCLI { let isolatedEnvironment = ["/usr/bin/env", "-i"] + environment let run: String if let cleanupPlan { - statements.append(contentsOf: cleanupPlan.executionBoundaryChecks().map { $0 + " || exit 124" }) - let cleanupShell = cleanupExecution?.snapshotID == cleanupPlan.snapshotID - ? cleanupExecution?.shell ?? "exit 124" - : "exit 124" - run = (isolatedEnvironment + ["/bin/sh", "-c", cleanupShell]) - .map(shellQuote).joined(separator: " ") + // Boundary checks and deletes both come from the plan, so the + // authorization and what it authorizes cannot drift apart. Running + // them inside the redirect means a refused check explains itself in + // the run log rather than vanishing into osascript's stderr. + run = cleanupPlan.irreversibleCleanupShell() } else { // `do shell script … with administrator privileges` inherits the // caller's PATH on current macOS releases. Start from an empty @@ -177,10 +175,10 @@ enum MoleCLI { if let sink = logSink { let dir = shellQuote(sink.directoryPath), file = shellQuote(sink.filePath) statements.append("umask 022") - statements.append("/bin/mkdir -m 0755 -- \(dir) || exit 125") + statements.append("/bin/mkdir -m 0755 -- \(dir) || exit \(ElevatedExitCode.logSinkUnavailable)") statements.append("cleanup_burrow_log() { /bin/rm -f -- \(file); /bin/rmdir -- \(dir); }") statements.append("trap cleanup_burrow_log EXIT HUP INT TERM") - statements.append("( set -C; : > \(file) ) || exit 125") + statements.append("( set -C; : > \(file) ) || exit \(ElevatedExitCode.logSinkUnavailable)") statements.append("( \(run) ) > \(file) 2>&1") statements.append("burrow_status=$?") // Let the app obtain a no-follow descriptor. The root shell then diff --git a/macos/Sources/OperationFlow.swift b/macos/Sources/OperationFlow.swift index 52aaa7a8..8272a0b1 100644 --- a/macos/Sources/OperationFlow.swift +++ b/macos/Sources/OperationFlow.swift @@ -333,16 +333,34 @@ final class OperationFlow: ObservableObject { return command } + /// Turn an exit status into something a person can act on. The wrapper's + /// own refusals (124–127) all mean NOTHING ran, which is the opposite of a + /// partial delete, so they must never share wording with a command that + /// ran and failed partway. private static func failureMessage(exitCode: Int32, isCleanup: Bool) -> String { - if isCleanup { - return String( - format: NSLocalizedString( - "Cleanup stopped because the reviewed files changed or could not be secured (exit %d). Rescan before trying again.", - comment: ""), - exitCode) + switch exitCode { + case ElevatedExitCode.boundaryCheckFailed: + return isCleanup + ? NSLocalizedString( + "Nothing was cleaned: the reviewed items changed before the run started. Rescan before trying again.", + comment: "") + : NSLocalizedString( + "Nothing ran: the files Burrow verified changed before the operation started.", + comment: "") + case ElevatedExitCode.logSinkUnavailable, + ElevatedExitCode.executableRefused, + ElevatedExitCode.launchFailed: + return NSLocalizedString( + "Nothing ran: Burrow could not verify the program it was about to run as an administrator.", + comment: "") + default: + return isCleanup + ? String(format: NSLocalizedString( + "Some reviewed items could not be removed (exit %d). The run log lists each one.", + comment: ""), exitCode) + : String(format: NSLocalizedString("Operation failed with exit status %d.", comment: ""), + exitCode) } - return String(format: NSLocalizedString("Operation failed with exit status %d.", comment: ""), - exitCode) } private func captureTelemetryCompletion(result: String) { @@ -381,6 +399,23 @@ extension ToolOperation where Report == TaskRunReport { // MARK: - Production adapter +/// Why an elevated run never reached the authentication prompt. Distinct from +/// `ValidatedElevatedCommand.ValidationError` because these two are decided by +/// the caller's own state rather than by the filesystem. +enum ElevatedSetupError: LocalizedError, Equatable { + case noInvokingUser + case staleCleanupPlan + + var errorDescription: String? { + switch self { + case .noInvokingUser: + return "Burrow could not confirm which signed-in account started this operation." + case .staleCleanupPlan: + return "The reviewed items changed before the run started, so nothing was cleaned." + } + } +} + /// The streaming-op spawn mechanics: plain runs stream /// stdout+stderr through pipes; elevated runs go through ONE osascript auth /// prompt with output tailed from a temp log (`do shell script` doesn't @@ -414,12 +449,6 @@ struct SystemProcessPort: ProcessPort { } let outPipe = Pipe(), errPipe = Pipe() - let cleanupExecution: CleanupIrreversibleExecution? - if spec.elevated, let plan = spec.cleanupPlan { - cleanupExecution = try? plan.prepareIrreversibleCleanup() - } else { - cleanupExecution = nil - } if spec.elevated { // The osascript `do shell script` wrapper has no stdin channel, @@ -428,22 +457,42 @@ struct SystemProcessPort: ProcessPort { // assert so the unsupported combo fails loudly rather than // silently dropping the input if someone wires it up later. assert(spec.stdin == nil, "elevated runs don't support stdin") - guard let invokingUser = spec.invokingUser, - let command = try? ValidatedElevatedCommand.prepare( + // A refusal here is the single most confusing failure Burrow + // can produce — nothing runs, no prompt appears, and the exit + // status alone ("126") tells the user nothing about which + // check said no. Carry the reason into the transcript. + let command: ValidatedElevatedCommand + let logSink: PrivilegedLogSink + do { + guard let invokingUser = spec.invokingUser else { + throw ElevatedSetupError.noInvokingUser + } + command = try ValidatedElevatedCommand.prepare( executable: spec.executable, invokingUser: invokingUser, - requireCurrentBundle: spec.requiresCurrentBundle), - let logSink = try? PrivilegedLogSink.make(), - spec.cleanupPlan?.validateForLaunch() != false, - spec.cleanupPlan == nil || cleanupExecution != nil else { - cleanupExecution?.remove() - streamQ.async { finish(126) } + requireCurrentBundle: spec.requiresCurrentBundle) + guard spec.cleanupPlan?.validateForLaunch() != false else { + throw ElevatedSetupError.staleCleanupPlan + } + logSink = try PrivilegedLogSink.make() + } catch { + let reason = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + // A stale plan is a changed review, not a program we + // couldn't verify; reporting it as the latter would send + // the user looking for a signing problem they don't have. + let code = (error as? ElevatedSetupError) == .staleCleanupPlan + ? ElevatedExitCode.boundaryCheckFailed + : ElevatedExitCode.executableRefused + streamQ.async { + emit(reason + "\n") + finish(code) + } return } let script = MoleCLI.elevatedScript(command: command, args: spec.arguments, logSink: logSink, - cleanupPlan: spec.cleanupPlan, - cleanupExecution: cleanupExecution) + cleanupPlan: spec.cleanupPlan) t.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") t.arguments = ["-e", script] t.standardOutput = outPipe @@ -461,8 +510,6 @@ struct SystemProcessPort: ProcessPort { t.terminationHandler = { proc in killTimer?.cancel() - cleanupExecution?.remove() - DispatchQueue.main.async { tailTimer?.invalidate() } streamQ.async { if let h = logHandle { // last tail of the log let data = h.readDataToEndOfFile() @@ -538,7 +585,6 @@ struct SystemProcessPort: ProcessPort { } } } catch { - cleanupExecution?.remove() streamQ.async { finish(127) } } } diff --git a/macos/Sources/TuneUpView.swift b/macos/Sources/TuneUpView.swift index b67390ad..49debf5c 100644 --- a/macos/Sources/TuneUpView.swift +++ b/macos/Sources/TuneUpView.swift @@ -425,7 +425,7 @@ struct TuneUpView: View { case .clean(let plan): flow.start(ToolOperation( label: NSLocalizedString("Tune-Up: cleaning reviewed caches", comment: ""), - executable: .path("/bin/sh"), arguments: [], elevated: true, + executable: .path("/usr/bin/find"), arguments: [], elevated: true, cleanupPlan: plan, reduce: { parseTaskReport($0) }, notifyOnEnd: true)) case .optimize: flow.start(.moleStream(["optimize"], elevated: true, diff --git a/macos/Tests/OperationFlowTests.swift b/macos/Tests/OperationFlowTests.swift index 47ff3f24..6874024e 100644 --- a/macos/Tests/OperationFlowTests.swift +++ b/macos/Tests/OperationFlowTests.swift @@ -209,7 +209,7 @@ final class OperationFlowTests: XCTestCase { approvedRootURLs: [parent]) let plan = try snapshot.plan(selectedPaths: [item.path]) - let port = FakeProcessPort(script: [.exited(124)]) + let port = FakeProcessPort(script: [.exited(ElevatedExitCode.boundaryCheckFailed)]) let center = OperationCenter() let flow = makeFlow(port, center: center) var operation = Self.cleanOp(elevated: true) @@ -220,10 +220,48 @@ final class OperationFlowTests: XCTestCase { guard case .finished(.failed(let message)) = flow.state else { return XCTFail("a fail-closed cleanup exit must not become done") } - XCTAssertTrue(message.contains("124")) + // A refused boundary check means NOTHING was deleted, so the message + // must say so rather than implying a partial run. + XCTAssertTrue(message.contains("Nothing was cleaned"), message) + XCTAssertTrue(message.contains("Rescan"), message) + XCTAssertNil(flow.report, "a refused cleanup must not render the preview's summary") XCTAssertEqual(center.ops.first?.phase, .failed) } + /// A cleanup that ran and could not remove everything is a DIFFERENT + /// outcome from one that was refused before it started, and the two must + /// not share wording — one leaves the caches in place, the other doesn't. + func testPartialCleanupFailureReadsAsPartialNotRefused() async throws { + let parent = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-flow-partial-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parent) } + let canonicalParent = URL(fileURLWithPath: try XCTUnwrap( + InvokingUserIdentity.canonicalPath(parent.path))) + let item = canonicalParent.appendingPathComponent("reviewed-cache") + try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + let snapshot = try CleanupSnapshot.capture( + list: CleanList(categories: [ + .init(name: "Test", items: [ + .init(path: item.path, sizeBytes: 1, sizeText: "1B", itemCount: nil), + ]), + ], summaryTotalText: "1B", summaryItemCount: 1), + approvedRootURLs: [canonicalParent]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + + let flow = makeFlow(FakeProcessPort(script: [.exited(1)])) + var operation = Self.cleanOp(elevated: true) + operation.cleanupPlan = plan + flow.start(operation) + await settle(flow) + + guard case .finished(.failed(let message)) = flow.state else { + return XCTFail("a nonzero cleanup exit must not become done") + } + XCTAssertTrue(message.contains("could not be removed"), message) + XCTAssertFalse(message.contains("Nothing was cleaned"), message) + } + func testMissingExecutableFailsBeforeSpawn() { let port = FakeProcessPort(script: []) let flow = OperationFlow(process: port, hasFullDiskAccess: { true }, diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift index c5d01f13..296f0cb8 100644 --- a/macos/Tests/PrivilegedSecurityTests.swift +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -195,161 +195,120 @@ final class CleanupAuthorizationTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fakeTrash.path)) } - func testIrreversibleCleanupCapturesAndVerifiesBeforeDeleting() throws { + func testIrreversibleCleanupDeletesTheReviewedTreeAndReportsSuccess() throws { let item = root.appendingPathComponent("permanent cache") - try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) + let nested = item.appendingPathComponent("nested") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data("cache".utf8).write(to: nested.appendingPathComponent("blob")) let snapshot = try CleanupSnapshot.capture(list: list([item.path]), approvedRootURLs: [root]) let plan = try snapshot.plan(selectedPaths: [item.path]) - let execution = try plan.prepareIrreversibleCleanupForTesting() - let shell = execution.shell - XCTAssertTrue(shell.contains("/bin/mv -- \"$burrow_original\" \"$burrow_capture\"")) - XCTAssertTrue(shell.contains("/usr/bin/stat -f '%d:%i:%u:%p'")) - XCTAssertTrue(shell.contains("/bin/rmdir -- \"$burrow_capture\"")) - XCTAssertFalse(shell.contains("/usr/bin/find"), - "no live recursive traversal may reach irreversible delete") - - let executable = PinnedFileIdentity(path: "/usr/bin/true", device: 1, inode: 2, - owner: 0, mode: UInt16(S_IFREG | 0o755)) - let user = InvokingUserIdentity(uid: 501, username: "review user", - canonicalHome: "/Users/review user") - let command = ValidatedElevatedCommand(executable: executable, components: [], - invokingUser: user, signedBundlePath: nil) - let elevated = MoleCLI.elevatedScript(command: command, args: [], cleanupPlan: plan, - cleanupExecution: execution) - XCTAssertTrue(elevated.contains("'/usr/bin/env' '-i'")) - XCTAssertTrue(elevated.contains("'PATH=/usr/bin:/bin:/usr/sbin:/sbin'")) - XCTAssertTrue(elevated.contains("'HOME=/Users/review user'")) - XCTAssertTrue(elevated.contains("'/bin/sh' '-c'")) + let shell = plan.irreversibleCleanupShell() + XCTAssertTrue(shell.contains("/usr/bin/find -x")) + XCTAssertTrue(shell.contains("-depth -delete")) + XCTAssertTrue(shell.contains("/usr/bin/stat -f '%d:%i:%u:%p'"), + "the reviewed identity must still be re-checked at the boundary") XCTAssertEqual(try runCleanupShell(shell), 0) - XCTAssertFalse(FileManager.default.fileExists(atPath: item.path), - "an unchanged exact reviewed tree is removed successfully") - XCTAssertFalse(FileManager.default.fileExists(atPath: execution.quarantineRootPath)) + XCTAssertFalse(FileManager.default.fileExists(atPath: item.path)) } - func testIrreversibleCleanupPreservesAReplacementCapturedByTheRace() throws { - let item = root.appendingPathComponent("permanent-race") - let original = root.appendingPathComponent("original-permanent-race") + func testIrreversibleCleanupRefusesWhenTheReviewedInodeWasSwapped() throws { + let item = root.appendingPathComponent("swapped") + let moved = root.appendingPathComponent("moved-away") try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) try Data("reviewed".utf8).write(to: item.appendingPathComponent("marker")) let snapshot = try CleanupSnapshot.capture(list: list([item.path]), approvedRootURLs: [root]) let plan = try snapshot.plan(selectedPaths: [item.path]) - let execution = try plan.prepareIrreversibleCleanupForTesting(preCaptureDelaySeconds: 1) - let quarantine = execution.quarantineRootPath - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", execution.shell] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() - - let deadline = Date().addingTimeInterval(2) - while !FileManager.default.fileExists(atPath: quarantine), Date() < deadline { - usleep(10_000) - } - XCTAssertTrue(FileManager.default.fileExists(atPath: quarantine), - "the command must reach its protected capture boundary") - - try FileManager.default.moveItem(at: item, to: original) + // Substitute a different directory at the reviewed NAME after review. + try FileManager.default.moveItem(at: item, to: moved) try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) try Data("unreviewed".utf8).write(to: item.appendingPathComponent("marker")) - process.waitUntilExit() - XCTAssertEqual(process.terminationStatus, 124) + XCTAssertEqual(try runCleanupShell(plan.irreversibleCleanupShell()), + ElevatedExitCode.boundaryCheckFailed) XCTAssertEqual(try String(contentsOf: item.appendingPathComponent("marker")), - "unreviewed", "the substituted inode must be restored intact") - XCTAssertEqual(try String(contentsOf: original.appendingPathComponent("marker")), - "reviewed", "the reviewed inode must survive at its raced location") - XCTAssertFalse(FileManager.default.fileExists(atPath: quarantine)) + "unreviewed", "the substituted inode must not be deleted") + XCTAssertEqual(try String(contentsOf: moved.appendingPathComponent("marker")), + "reviewed", "the reviewed inode survives at its new name") } - func testIrreversibleCleanupPreservesDescendantAddedAfterReview() throws { - let item = root.appendingPathComponent("added-descendant") - let reviewed = item.appendingPathComponent("reviewed") - let added = item.appendingPathComponent("added-later") + /// The deliberate trade behind deleting the tree rooted at the reviewed + /// inode rather than an enumerated set of descendants pinned at review + /// time. The preview presents a cache ENTRY with a size, not a file list, + /// so "everything under this exact directory" is what the user approved — + /// and pinning the full set instead made a clean abort whenever the owning + /// app wrote to its own cache between the preview and the confirmation. + func testIrreversibleCleanupRemovesContentAddedUnderTheReviewedEntryAfterReview() throws { + let item = root.appendingPathComponent("live cache") try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) - try Data("reviewed".utf8).write(to: reviewed) let snapshot = try CleanupSnapshot.capture(list: list([item.path]), approvedRootURLs: [root]) let plan = try snapshot.plan(selectedPaths: [item.path]) - let execution = try plan.prepareIrreversibleCleanupForTesting() - try Data("unreviewed".utf8).write(to: added) - let status = try runCleanupShell(execution.shell) + try Data("written after review".utf8) + .write(to: item.appendingPathComponent("added-later")) - XCTAssertEqual(status, 124) - XCTAssertEqual(try String(contentsOf: added), "unreviewed", - "a descendant added after preview must never be deleted") + XCTAssertEqual(try runCleanupShell(plan.irreversibleCleanupShell()), 0, + "a cache written to between preview and confirmation still cleans") + XCTAssertFalse(FileManager.default.fileExists(atPath: item.path)) } - func testIrreversibleCleanupPreservesMovedDescendantAndItsReplacement() throws { - let item = root.appendingPathComponent("moved-descendant") - let reviewed = item.appendingPathComponent("reviewed") - let moved = root.appendingPathComponent("reviewed-moved-away") + func testIrreversibleCleanupDeletesTheLinkNotItsTargetOutsideTheTree() throws { + let item = root.appendingPathComponent("with-symlink") + let outside = root.appendingPathComponent("outside-the-reviewed-tree") try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) - try Data("reviewed".utf8).write(to: reviewed) - let snapshot = try CleanupSnapshot.capture(list: list([item.path]), - approvedRootURLs: [root]) - let plan = try snapshot.plan(selectedPaths: [item.path]) - let execution = try plan.prepareIrreversibleCleanupForTesting() - - try FileManager.default.moveItem(at: reviewed, to: moved) - try Data("unreviewed".utf8).write(to: reviewed) - let status = try runCleanupShell(execution.shell) - - XCTAssertEqual(status, 124) - XCTAssertEqual(try String(contentsOf: reviewed), "unreviewed") - XCTAssertEqual(try String(contentsOf: moved), "reviewed") - } + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: false) + try Data("precious".utf8).write(to: outside.appendingPathComponent("keep")) + try FileManager.default.createSymbolicLink( + at: item.appendingPathComponent("escape"), withDestinationURL: outside) - func testIrreversibleCleanupOpenDirectoryFDMutationFailsClosed() throws { - let item = root.appendingPathComponent("open-fd-descendant") - try FileManager.default.createDirectory(at: item, withIntermediateDirectories: false) let snapshot = try CleanupSnapshot.capture(list: list([item.path]), approvedRootURLs: [root]) let plan = try snapshot.plan(selectedPaths: [item.path]) - let execution = try plan.prepareIrreversibleCleanupForTesting( - postCaptureDelaySeconds: 1) - - let directoryFD = Darwin.open(item.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) - XCTAssertGreaterThanOrEqual(directoryFD, 0) - defer { if directoryFD >= 0 { Darwin.close(directoryFD) } } - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", execution.shell] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() + XCTAssertEqual(try runCleanupShell(plan.irreversibleCleanupShell()), 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: item.path)) + XCTAssertEqual(try String(contentsOf: outside.appendingPathComponent("keep")), + "precious", "-delete must never follow a symlink out of the tree") + } - let captured = execution.quarantineRootPath + "/node-0/captured" - let deadline = Date().addingTimeInterval(2) - while !FileManager.default.fileExists(atPath: captured), Date() < deadline { - usleep(10_000) - } - XCTAssertTrue(FileManager.default.fileExists(atPath: captured)) - - let lateFD = Darwin.openat(directoryFD, "added-through-open-fd", - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) - XCTAssertGreaterThanOrEqual(lateFD, 0) - if lateFD >= 0 { - _ = "unreviewed".withCString { bytes in - Darwin.write(lateFD, bytes, strlen(bytes)) - } - Darwin.close(lateFD) + func testIrreversibleCleanupContinuesPastOneFailingEntryAndReportsFailure() throws { + let good = root.appendingPathComponent("removable") + let blocked = root.appendingPathComponent("blocked") + let locked = blocked.appendingPathComponent("locked") + let child = locked.appendingPathComponent("undeletable") + try FileManager.default.createDirectory(at: good, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: locked, withIntermediateDirectories: true) + try Data("stuck".utf8).write(to: child) + // Clear the write bit on the NESTED directory so its entry cannot be + // unlinked. This happens before capture: mutating a reviewed entry's + // own mode afterwards would change its pinned identity and the + // boundary check would refuse the whole run — a different outcome. + try FileManager.default.setAttributes([.posixPermissions: 0o500], + ofItemAtPath: locked.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], + ofItemAtPath: locked.path) } - process.waitUntilExit() - XCTAssertEqual(process.terminationStatus, 124) - let added = item.appendingPathComponent("added-through-open-fd") - XCTAssertEqual(try String(contentsOf: added), "unreviewed", - "an entry added through a pre-opened directory must survive") + let snapshot = try CleanupSnapshot.capture(list: list([good.path, blocked.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [good.path, blocked.path]) + + let status = try runCleanupShell(plan.irreversibleCleanupShell()) + XCTAssertNotEqual(status, 0, "an entry that could not be removed must report failure") + XCTAssertNotEqual(status, ElevatedExitCode.boundaryCheckFailed, + "a partial removal is not the same refusal as a changed review") + XCTAssertFalse(FileManager.default.fileExists(atPath: good.path), + "one failing entry must not abandon the others") + XCTAssertTrue(FileManager.default.fileExists(atPath: child.path)) } + private func runCleanupShell(_ shell: String) throws -> Int32 { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") From 84426e29ede573a66778b96ed245641fb9482bb8 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:06:12 -0700 Subject: [PATCH 06/11] fix(macos): stop refusing the engine on every real installation `ValidatedElevatedCommand.prepare` required root ownership of the privileged executable and of every ancestor directory, with no group-writable component anywhere. No shipped copy of Burrow can satisfy that. `/Applications` is root:admin mode 0775 on stock macOS, and an app dragged there -- or installed by the Homebrew cask -- is owned by the account that installed it. Optimize therefore refused before raising a prompt on any install that wasn't running from a root-owned build directory, which is every install except a developer's. Ownership is now a policy rather than one rule. System tools outside the bundle keep the strict version. Inside our own signed bundle, ownership may be root or the invoking user and only world-writable components are refused -- group-writable is how macOS ships /Applications, so refusing it refuses the platform. What pays for the relaxation is the resource seal: `codesign --verify --strict` already runs as root at the execution boundary, and every ancestor stays pinned and re-checked there regardless of who owns it. The tests build the real /Applications layout -- group-writable parent, user-owned bundle -- which is the coverage whose absence let a rule that cannot hold in production ship as a hardening change. --- macos/Sources/PrivilegedExecution.swift | 108 ++++++++++++++++++---- macos/Tests/PrivilegedSecurityTests.swift | 66 +++++++++++++ 2 files changed, 156 insertions(+), 18 deletions(-) diff --git a/macos/Sources/PrivilegedExecution.swift b/macos/Sources/PrivilegedExecution.swift index 5c2df0b2..ba5a762d 100644 --- a/macos/Sources/PrivilegedExecution.swift +++ b/macos/Sources/PrivilegedExecution.swift @@ -12,6 +12,22 @@ import Foundation import Darwin +/// Exit statuses the elevated *wrapper* produces, as opposed to anything the +/// elevated command itself can return. Spelled once here because the shell +/// that emits them and the UI that has to explain them live in different +/// files, and a drifting number turns a precise refusal into a bare code. +enum ElevatedExitCode { + /// A pinned identity, or the plan's expiry, failed its check at the + /// execution boundary. Nothing ran. + static let boundaryCheckFailed: Int32 = 124 + /// The root-owned log sink could not be created. Nothing ran. + static let logSinkUnavailable: Int32 = 125 + /// The executable failed validation before launch. Nothing ran. + static let executableRefused: Int32 = 126 + /// The elevated process could not be spawned at all. + static let launchFailed: Int32 = 127 +} + struct InvokingUserIdentity: Sendable, Equatable { struct Account: Sendable, Equatable { let uid: uid_t @@ -156,10 +172,50 @@ struct PinnedFileIdentity: Sendable, Equatable, Codable { } struct ValidatedElevatedCommand: Sendable, Equatable { + /// What "this executable cannot be swapped under us" is allowed to mean. + /// + /// These are two genuinely different situations and one rule cannot serve + /// both. A system tool lives in a tree Apple owns, so demanding root + /// ownership all the way up costs nothing. The bundled engine lives + /// inside Burrow.app, and on every ordinary install — drag-to-Applications + /// or a Homebrew cask — the bundle is owned by the account that installed + /// it, beneath `/Applications`, which macOS itself ships as `root:admin` + /// mode 0775. Requiring root ownership there is not a stricter policy, it + /// is an unsatisfiable one: it refuses every real installation. + enum OwnershipPolicy: Equatable, Sendable { + /// Root owns the executable and every ancestor, and nothing along the + /// path is group- or world-writable. + case systemOwned + /// Inside the current signed app bundle. Ownership is whatever the + /// installer left behind, so authority comes from the resource seal — + /// `codesign --verify --strict`, re-run AS ROOT at the execution + /// boundary — plus the pinned inode of the executable and every + /// ancestor. A world-writable component is still refused: that would + /// let an unrelated account do the swapping, which no install layout + /// legitimately requires. + case signedBundle + + /// Owners this policy accepts, in addition to root. + func acceptsOwner(_ owner: UInt32, invokingUser: uid_t) -> Bool { + switch self { + case .systemOwned: return owner == 0 + case .signedBundle: return owner == 0 || owner == UInt32(invokingUser) + } + } + + /// Write bits that disqualify a component. + var forbiddenWriteBits: mode_t { + switch self { + case .systemOwned: return 0o022 // group and other + case .signedBundle: return 0o002 // other only + } + } + } + enum ValidationError: LocalizedError { case nonCanonicalExecutable case executableNotRegular - case executableNotRootOwned + case executableNotRootOwned(String) case executableMutable(String) case unsignedBundle @@ -167,7 +223,8 @@ struct ValidatedElevatedCommand: Sendable, Equatable { switch self { case .nonCanonicalExecutable: return "The privileged executable path is not canonical." case .executableNotRegular: return "The privileged executable is not a regular file." - case .executableNotRootOwned: return "The privileged executable is not owned by root." + case .executableNotRootOwned(let p): + return "The privileged executable is not owned by root or by you at \(p)." case .executableMutable(let p): return "The privileged executable can be replaced at \(p)." case .unsignedBundle: return "The bundled cleanup engine could not be verified." } @@ -182,16 +239,31 @@ struct ValidatedElevatedCommand: Sendable, Equatable { let invokingUser: InvokingUserIdentity let signedBundlePath: String? + /// `requireCurrentBundle` selects the policy rather than adding a second + /// knob: an executable inside our own signed bundle is exactly the case + /// that cannot be root-owned, and it is also the only case that carries a + /// seal to verify instead. Keeping them one decision means a call site + /// cannot ask for the seal and the impossible ownership rule at once. static func prepare(executable rawPath: String, invokingUser: InvokingUserIdentity, requireCurrentBundle: Bool, - requiredOwner: uid_t = 0) throws -> Self { + bundlePath: @autoclosure () -> String? = Bundle.main.bundleURL.path) throws -> Self { guard let canonical = InvokingUserIdentity.canonicalPath(rawPath), canonical == rawPath else { throw ValidationError.nonCanonicalExecutable } let executable = try PinnedFileIdentity.capture(canonical) guard executable.isRegular else { throw ValidationError.executableNotRegular } - guard executable.owner == requiredOwner else { throw ValidationError.executableNotRootOwned } + + var signedBundle: String? + if requireCurrentBundle { + guard let raw = bundlePath(), + let bundle = InvokingUserIdentity.canonicalPath(raw), + canonical.hasPrefix(bundle + "/") else { + throw ValidationError.unsignedBundle + } + signedBundle = bundle + } + let policy: OwnershipPolicy = signedBundle == nil ? .systemOwned : .signedBundle let url = URL(fileURLWithPath: canonical) var componentPaths = ["/"] @@ -200,26 +272,26 @@ struct ValidatedElevatedCommand: Sendable, Equatable { cursor += "/" + component componentPaths.append(cursor) } + // Every ancestor is pinned regardless of policy. Ownership decides + // whether a swap is PLAUSIBLE; the pin is what detects one, and it is + // re-checked as root immediately before exec. var components: [PinnedFileIdentity] = [] for path in componentPaths { let identity = try PinnedFileIdentity.capture(path) - guard identity.isDirectory, identity.owner == requiredOwner, - (mode_t(identity.mode) & 0o022) == 0 else { + guard identity.isDirectory else { throw ValidationError.executableMutable(path) } + guard policy.acceptsOwner(identity.owner, invokingUser: invokingUser.uid) else { + throw ValidationError.executableNotRootOwned(path) + } + guard (mode_t(identity.mode) & policy.forbiddenWriteBits) == 0 else { throw ValidationError.executableMutable(path) } components.append(identity) } - guard (mode_t(executable.mode) & 0o022) == 0 else { - throw ValidationError.executableMutable(canonical) + guard policy.acceptsOwner(executable.owner, invokingUser: invokingUser.uid) else { + throw ValidationError.executableNotRootOwned(canonical) } - - var signedBundle: String? - if requireCurrentBundle { - guard let bundle = InvokingUserIdentity.canonicalPath(Bundle.main.bundleURL.path), - canonical.hasPrefix(bundle + "/") else { - throw ValidationError.unsignedBundle - } - signedBundle = bundle + guard (mode_t(executable.mode) & policy.forbiddenWriteBits) == 0 else { + throw ValidationError.executableMutable(canonical) } return Self(executable: executable, components: components, invokingUser: invokingUser, signedBundlePath: signedBundle) @@ -271,7 +343,7 @@ struct PrivilegedLogSink: Sendable, Equatable { var exclusiveCreationShell: String { let dir = MoleCLI.shellQuote(directoryPath) let file = MoleCLI.shellQuote(filePath) - return "umask 022; /bin/mkdir -m 0755 -- \(dir) || exit 125; " + - "( set -C; : > \(file) ) || exit 125" + return "umask 022; /bin/mkdir -m 0755 -- \(dir) || exit \(ElevatedExitCode.logSinkUnavailable); " + + "( set -C; : > \(file) ) || exit \(ElevatedExitCode.logSinkUnavailable)" } } diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift index 296f0cb8..7f9fe674 100644 --- a/macos/Tests/PrivilegedSecurityTests.swift +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -73,6 +73,72 @@ final class PrivilegedIdentityTests: XCTestCase { XCTAssertFalse(script.contains("HOME=/var/root")) } + /// The layout every shipped copy of Burrow actually has. + /// + /// `/Applications` is `root:admin` mode 0775 on stock macOS and an app + /// dragged there (or installed by a Homebrew cask) is owned by the account + /// that installed it. A rule demanding root ownership of the engine and + /// every ancestor is therefore unsatisfiable in production, and the only + /// place it shows up is at runtime, as a refusal with no prompt. + private func makeApplicationsLayout() throws -> (bundle: String, engine: String) { + let applications = temp.appendingPathComponent("Applications", isDirectory: true) + let bundle = applications.appendingPathComponent("Burrow.app", isDirectory: true) + let engineDirectory = bundle.appendingPathComponent("Contents/Resources/engine", + isDirectory: true) + try FileManager.default.createDirectory(at: engineDirectory, + withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o775], + ofItemAtPath: applications.path) + let engine = engineDirectory.appendingPathComponent("mole") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: engine) + try FileManager.default.setAttributes([.posixPermissions: 0o755], + ofItemAtPath: engine.path) + return (bundle.path, engine.path) + } + + private var currentUser: InvokingUserIdentity { + InvokingUserIdentity(uid: getuid(), username: NSUserName(), + canonicalHome: NSHomeDirectory()) + } + + func testBundledEngineIsAcceptedUnderAGroupWritableApplicationsDirectory() throws { + let layout = try makeApplicationsLayout() + let command = try ValidatedElevatedCommand.prepare( + executable: layout.engine, invokingUser: currentUser, + requireCurrentBundle: true, bundlePath: layout.bundle) + + XCTAssertEqual(command.signedBundlePath, layout.bundle) + // Ownership stopped being the guarantee, so the resource seal has to + // be checked at the boundary — without it nothing is verifying this. + let script = MoleCLI.elevatedScript(command: command, args: ["optimize"]) + XCTAssertTrue(script.contains("/usr/bin/codesign --verify --strict"), + "the signed-bundle policy is only safe with the seal check") + XCTAssertTrue(script.contains("/usr/bin/stat -f '%d:%i:%u:%p'"), + "every ancestor stays pinned regardless of who owns it") + } + + func testBundledEngineIsRefusedWhenAnAncestorIsWorldWritable() throws { + let layout = try makeApplicationsLayout() + // Group-writable is normal; world-writable would let an unrelated + // account do the swapping, and no install layout needs that. + try FileManager.default.setAttributes( + [.posixPermissions: 0o777], + ofItemAtPath: temp.appendingPathComponent("Applications").path) + + XCTAssertThrowsError(try ValidatedElevatedCommand.prepare( + executable: layout.engine, invokingUser: currentUser, + requireCurrentBundle: true, bundlePath: layout.bundle)) + } + + func testBundledEnginePolicyDoesNotLeakToExecutablesOutsideTheBundle() throws { + let layout = try makeApplicationsLayout() + // The same user-owned file, asked for WITHOUT the bundle seal, must + // still be refused: relaxing ownership is only paid for by the seal. + XCTAssertThrowsError(try ValidatedElevatedCommand.prepare( + executable: layout.engine, invokingUser: currentUser, + requireCurrentBundle: false, bundlePath: layout.bundle)) + } + func testUserMutableExecutableIsRejectedAndReplacementBreaksPinnedIdentity() throws { let executable = temp.appendingPathComponent("mo") try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executable) From 50a4c42169f9cdbdd13eb41859dd6a6f43d7b0c5 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:07:00 -0700 Subject: [PATCH 07/11] feat(macos): restore Touch ID for the reviewed clean Clean was the one elevated operation that never reached the privileged helper. Routing recognises engine argv, and once the permanent clean became a reviewed plan rather than a `mo clean` invocation it had no argv to recognise, so it fell through to osascript -- which authenticates through SecurityAgent's classic mechanism and is password-only by construction. The most consequential operation Burrow performs quietly lost Touch ID while every other one kept it. It cannot simply route to the existing `clean` operation: that runs the engine's own selection rules and discards the entries the user reviewed. So this adds `HelperOperation.cleanReviewed`, the only operation that carries data from the caller beyond a verb. That does not weaken the property the helper rests on. argv is still composed daemon-side, the executable is still a fixed absolute path (/usr/bin/find joins the closed system-tool set), and the paths are operands to a delete rather than anything executed. What makes it safe is that the daemon does not trust the list: it rebuilds the approved roots from its own getpwuid record and re-derives every fact with its own lstat. An entry must exist, not be a symlink, equal its own canonical path -- so no ancestor is a link either -- sit strictly inside the invoking user's home or a system cache, be on that root's volume, and outside the shared system caches belong to the invoking user. That last rule is the one that matters: /private/var/folders holds other accounts' trees, and root deleting those is an escalation the user could not perform themselves. The list is capped and one bad entry refuses the whole request. Success is the postcondition, not find's exit status, for the same reason as on the osascript path. Two daemon fixes in the same files: * Cancellation is bound to the invoking uid. On a Mac with several signed-in users, knowing an operation ID was enough to stop another session's root work. * The output splitter buffers bytes rather than strings. A read ending mid-UTF-8-sequence made `String(data:encoding:)` return nil and the whole chunk was dropped, losing entire lines of a root operation's output -- and paths are exactly where non-ASCII shows up. --- SECURITY.md | 36 +-- macos/HelperSources/HelperService.swift | 195 +++++++++++++--- .../PrivilegedHelper/HelperContract.swift | 162 ++++++++++++- macos/Sources/PrivilegedHelperClient.swift | 53 ++++- macos/Tests/HelperContractTests.swift | 215 +++++++++++++++++- macos/Tests/PrivilegeRouteTests.swift | 38 ++++ 6 files changed, 634 insertions(+), 65 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 5b7f360a..55a636a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -77,20 +77,30 @@ This is the part people rightly scrutinize in cleaners. Burrow's model: other processes, `allow-root: false` stops the root helper satisfying it by itself), and each operation ID is served at most once so a captured request cannot be replayed. - - **It cannot be asked to run anything else.** The helper accepts seven typed - operations — scan, clean, optimize, the optimize preview, flush DNS, - renew DHCP, and reading the Login Items list — and derives every command - line itself. There is no field in its API for a path, a shell string, or - an executable, so a caller that fully controls the message still cannot - express "run this". - - **The only value a caller supplies** is the network interface name for - renew DHCP. It is checked twice: against a strict `en0`-shaped pattern, - and against the interfaces that actually exist on the machine. A - well-formed name for an interface that isn't there is refused. - - **No shell.** The helper runs the bundled engine, plus exactly four + - **It cannot be asked to run anything else.** The helper accepts eight typed + operations — scan, clean, the reviewed clean, optimize, the optimize + preview, flush DNS, renew DHCP, and reading the Login Items list — and + derives every command line itself. There is no field in its API for a shell + string or an executable, so a caller that fully controls the message still + cannot express "run this". + - **The two values a caller supplies** are the network interface name for + renew DHCP, and the list of entries to remove for the reviewed clean. + Neither is ever executed; both are checked by the privileged side against + facts it gathers itself. + - The interface name is checked twice: against a strict `en0`-shaped + pattern, and against the interfaces that actually exist on the machine. + A well-formed name for an interface that isn't there is refused. + - Every entry in a reviewed clean must, according to the helper's own + `lstat` and its own copy of your account record: exist, not be a symbolic + link, match its own canonical path (so no parent directory is a link + either), sit strictly inside your home or a system cache directory, be on + that same volume, and — outside the shared system caches — belong to you. + The list is capped, and one entry that fails any of these refuses the + whole request rather than deleting the rest. + - **No shell.** The helper runs the bundled engine, plus exactly five system tools by absolute path (`/usr/bin/dscacheutil`, `/usr/bin/killall`, - `/usr/sbin/ipconfig`, `/usr/bin/sfltool`), each as a separate process with - fixed arguments. + `/usr/sbin/ipconfig`, `/usr/bin/sfltool`, `/usr/bin/find`), each as a + separate process with fixed arguments. This is stricter than the path it replaces: flushing DNS previously elevated `/bin/sh -c "dscacheutil -flushcache; killall -HUP mDNSResponder"`, handing a command string to a root shell. diff --git a/macos/HelperSources/HelperService.swift b/macos/HelperSources/HelperService.swift index 73f98b07..37fad95c 100644 --- a/macos/HelperSources/HelperService.swift +++ b/macos/HelperSources/HelperService.swift @@ -164,6 +164,68 @@ enum HelperDaemonIdentityResolver { } } +// MARK: - Reviewed cleanup targets + +/// Gathers the facts `HelperReviewedPathPolicy` judges. Everything here is the +/// daemon's own observation; the client's list supplies candidate strings and +/// nothing else. +enum HelperReviewedCleanup { + /// The roots a reviewed clean may touch, rebuilt inside the daemon. + /// + /// The home comes from the account the daemon itself resolved through + /// getpwuid, not from anything the client said. The system cache trees are + /// fixed literals. A root that cannot be stat'd is dropped rather than + /// assumed, so a missing location can only ever narrow what is allowed. + static func approvedRoots(for user: HelperResolvedInvokingUser) -> [HelperReviewedRoot] { + let candidates: [(String, Bool)] = [ + (user.canonicalHome, false), + ("/Library/Caches", true), + ("/Library/Logs", true), + ("/private/var/folders", false), + ] + return candidates.compactMap { path, allowsForeignOwner in + var status = stat() + guard lstat(path, &status) == 0, + (status.st_mode & S_IFMT) == S_IFDIR else { return nil } + return HelperReviewedRoot(path: path, + device: UInt64(status.st_dev), + allowsForeignOwner: allowsForeignOwner) + } + } + + static func inspect(_ path: String) -> HelperReviewedTarget { + var status = stat() + guard lstat(path, &status) == 0 else { + return HelperReviewedTarget(exists: false, isSymbolicLink: false, + canonicalPath: nil, device: 0, ownerUID: 0) + } + let isLink = (status.st_mode & S_IFMT) == S_IFLNK + var canonical: String? + // realpath follows links, so it is only meaningful once we know this + // entry is not one itself; the policy compares it against the literal + // path to prove no ancestor is a link either. + if !isLink, let resolved = realpath(path, nil) { + canonical = String(cString: resolved) + free(resolved) + } + return HelperReviewedTarget(exists: true, + isSymbolicLink: isLink, + canonicalPath: canonical, + device: UInt64(status.st_dev), + ownerUID: UInt32(status.st_uid)) + } + + /// Whether every target is gone. `find -delete` is documented to "always + /// return true", so it exits 0 having printed a permission error and + /// removed nothing — the exit status cannot be the success signal. + static func survivors(among paths: [String]) -> [String] { + paths.filter { path in + var status = stat() + return lstat(path, &status) == 0 + } + } +} + // MARK: - Engine resolution enum HelperEngine { @@ -265,8 +327,16 @@ enum HelperEngine { /// the root child it had spawned, so the streaming flow simply had no safe /// cancel. Here the child is ours to signal and reap. final class HelperOperationRunner: @unchecked Sendable { + private struct Running { + let process: Process + /// Who started it. Cancellation is bound to the same account, so on a + /// machine with several signed-in users one session cannot stop + /// another's root operation just by knowing its ID. + let ownerUID: UInt32 + } + private let lock = NSLock() - private var running: [String: Process] = [:] + private var running: [String: Running] = [:] /// Run every step of `operation` in order, stopping at the first failure, /// and return the exit status of the last step attempted. @@ -278,11 +348,12 @@ final class HelperOperationRunner: @unchecked Sendable { func run(operation: HelperOperation, operationID: String, interface: String?, + reviewedPaths: [String] = [], enginePath: String?, invokingUser: HelperResolvedInvokingUser, emit: @escaping (String) -> Void) -> Int32 { var last: Int32 = 0 - for step in operation.steps(interface: interface) { + for step in operation.steps(interface: interface, reviewedPaths: reviewedPaths) { let path: String switch step.executable { case .bundledEngine: @@ -302,10 +373,15 @@ final class HelperOperationRunner: @unchecked Sendable { } path = systemPath } - last = runOne(path: path, arguments: step.arguments, - operationID: operationID, environment: invokingUser.childEnvironment, - emit: emit) - guard last == 0 else { break } + let status = runOne(path: path, arguments: step.arguments, + operationID: operationID, ownerUID: invokingUser.uid, + environment: invokingUser.childEnvironment, + emit: emit) + if status != 0 { last = status } + // A reviewed clean is a list of independent entries: one that + // can't be removed must not abandon the ones after it. Every other + // operation is a sequence where a failed step invalidates the rest. + guard operation.needsReviewedPaths || status == 0 else { break } } return last } @@ -313,6 +389,7 @@ final class HelperOperationRunner: @unchecked Sendable { private func runOne(path: String, arguments: [String], operationID: String, + ownerUID: UInt32, environment: [String: String], emit: @escaping (String) -> Void) -> Int32 { let process = Process() @@ -340,7 +417,9 @@ final class HelperOperationRunner: @unchecked Sendable { return 127 } - lock.lock(); running[operationID] = process; lock.unlock() + lock.lock() + running[operationID] = Running(process: process, ownerUID: ownerUID) + lock.unlock() defer { lock.lock(); running.removeValue(forKey: operationID); lock.unlock() } try? outPipe.fileHandleForWriting.close() @@ -353,9 +432,13 @@ final class HelperOperationRunner: @unchecked Sendable { for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] { group.enter() DispatchQueue.global(qos: .utility).async { + // Bytes, not strings, across the pipe boundary. A read can end + // mid-UTF-8-sequence — a path with an accent or a CJK filename + // is enough — and decoding each chunk on its own would fail and + // silently drop the ENTIRE chunk, losing whole lines of a root + // operation's output. The splitter carries the partial tail. while case let chunk = handle.availableData, !chunk.isEmpty { - guard let text = String(data: chunk, encoding: .utf8) else { continue } - for line in splitter.ingest(text) { emit(line) } + for line in splitter.ingest(chunk) { emit(line) } } group.leave() } @@ -367,11 +450,14 @@ final class HelperOperationRunner: @unchecked Sendable { return process.terminationStatus } - /// Terminate a running operation. Returns whether anything was running. - func cancel(operationID: String) -> Bool { - lock.lock(); let process = running[operationID]; lock.unlock() - guard let process, process.isRunning else { return false } - process.terminate() + /// Terminate a running operation started by `requestedBy`. Returns whether + /// anything was stopped. An ID owned by a different account is treated + /// exactly like an ID that isn't running: no signal, no acknowledgement + /// that it exists. + func cancel(operationID: String, requestedBy: UInt32) -> Bool { + lock.lock(); let entry = running[operationID]; lock.unlock() + guard let entry, entry.ownerUID == requestedBy, entry.process.isRunning else { return false } + entry.process.terminate() return true } @@ -383,23 +469,36 @@ final class HelperOperationRunner: @unchecked Sendable { /// Buffers partial reads and emits whole lines. Mirrors the GUI's splitter so /// both elevation routes deliver output the same way. +/// +/// Buffering happens in BYTES. Decoding per read and concatenating strings +/// loses any multi-byte character that straddles a chunk boundary, and the +/// paths this daemon reports are exactly where non-ASCII shows up. Decoding is +/// deferred until a whole line is in hand, and uses a lossy decode so one +/// undecodable byte costs a replacement character rather than the line. final class HelperLineSplitter: @unchecked Sendable { - private var buffer = "" + private var buffer = Data() private let lock = NSLock() + private static let newline = UInt8(ascii: "\n") - func ingest(_ text: String) -> [String] { + func ingest(_ chunk: Data) -> [String] { lock.lock(); defer { lock.unlock() } - buffer += text - var parts = buffer.components(separatedBy: "\n") - buffer = parts.removeLast() - return parts + buffer.append(chunk) + var lines: [String] = [] + while let index = buffer.firstIndex(of: Self.newline) { + lines.append(String(decoding: buffer[buffer.startIndex.. [String] { lock.lock(); defer { lock.unlock() } - let rest = buffer - buffer = "" - return rest.isEmpty ? [] : [rest] + guard !buffer.isEmpty else { return [] } + let rest = String(decoding: buffer, as: UTF8.self) + buffer = Data() + return [rest] } } @@ -450,11 +549,14 @@ final class HelperService: NSObject, BurrowHelperProtocol { func cancelOperation(operationID: String, withReply reply: @escaping (Bool) -> Void) { // Cancellation stops work; it never starts any, so it needs no - // authorization of its own. The connection gate has already - // established that the caller is Burrow, and an ID it doesn't know - // simply isn't running. - guard UUID(uuidString: operationID) != nil else { return reply(false) } - reply(runner.cancel(operationID: operationID)) + // authorization of its own. But it is still bound to the invoking + // account: `execute` establishes who owns an operation, and on a Mac + // with several signed-in users a second session must not be able to + // stop the first one's root work just by presenting its ID. + guard UUID(uuidString: operationID) != nil, + let connection = NSXPCConnection.current() else { return reply(false) } + reply(runner.cancel(operationID: operationID, + requestedBy: UInt32(connection.effectiveUserIdentifier))) } func execute(requestData: Data, authorization: Data, withReply reply: @escaping (Data) -> Void) { @@ -499,6 +601,28 @@ final class HelperService: NSObject, BurrowHelperProtocol { } helperTrace("invoking identity accepted for uid \(invokingUser.uid); canonical home matched") + // The reviewed path list is judged HERE, against roots and lstat facts + // the daemon gathered itself, before anything is authorized. A client + // that fully controls the payload still cannot name a target outside + // the invoking user's own trees. + var reviewedPaths: [String] = [] + if request.operation.needsReviewedPaths { + let decision = HelperReviewedPathPolicy.validate( + paths: request.reviewedPaths, + roots: HelperReviewedCleanup.approvedRoots(for: invokingUser), + invokingUID: invokingUser.uid, + inspect: HelperReviewedCleanup.inspect) + switch decision { + case .success(let accepted): + reviewedPaths = accepted + case .failure(let rejection): + // The reason is a closed enum, never a path — this log is + // world-readable. + helperTrace("request refused: reviewed path rejected (\(rejection.rawValue))") + return respond(.rejected(.invalidReviewedPaths)) + } + } + // Gate 3 — freshness. One authorization buys exactly one operation, so // a captured payload cannot be replayed for a second root run. guard replayGuard.admit(request.operationID) else { @@ -539,9 +663,10 @@ final class HelperService: NSObject, BurrowHelperProtocol { let client = connection.remoteObjectProxy as? BurrowHelperClientProtocol let operationID = request.operationID - let code = runner.run(operation: request.operation, + var code = runner.run(operation: request.operation, operationID: operationID, interface: request.networkInterface, + reviewedPaths: reviewedPaths, enginePath: enginePath, invokingUser: invokingUser) { line in client?.helperDidEmit(line: line, operationID: operationID) @@ -549,6 +674,18 @@ final class HelperService: NSObject, BurrowHelperProtocol { // Keep the validated clone alive until Process has exited and every // output pipe has drained; deinit then removes the private snapshot. withExtendedLifetime(engineSnapshot) {} + + // Success is the POSTCONDITION, not find's exit status: `-delete` is + // documented to always return true, so it exits 0 having printed a + // permission error and removed nothing. Reporting that as success is + // how a clean that freed nothing renders as "Done — caches cleared". + if request.operation.needsReviewedPaths { + let survivors = HelperReviewedCleanup.survivors(among: reviewedPaths) + if !survivors.isEmpty { + helperTrace("reviewed cleanup left \(survivors.count) of \(reviewedPaths.count) entries") + if code == 0 { code = 1 } + } + } helperTrace("operation finished with status \(code)") respond(.exited(code)) } diff --git a/macos/Sources/PrivilegedHelper/HelperContract.swift b/macos/Sources/PrivilegedHelper/HelperContract.swift index c08b40e9..ee208781 100644 --- a/macos/Sources/PrivilegedHelper/HelperContract.swift +++ b/macos/Sources/PrivilegedHelper/HelperContract.swift @@ -37,6 +37,20 @@ enum HelperOperation: String, Codable, CaseIterable, Sendable { case scan /// Remove the caches the engine's own rules select. case clean + /// Remove exactly the entries the user reviewed and ticked. + /// + /// This is the only operation that accepts data from the caller beyond a + /// verb, which deserves saying plainly. It does NOT weaken the rule that a + /// compromised client cannot express "run this": the argv is still built + /// here, the executable is still a fixed absolute path, and the paths are + /// values passed to a delete, never anything executed. + /// + /// What stops a compromised client asking root to delete something it + /// shouldn't is that the daemon does not trust the list. It rebuilds the + /// approved roots from its OWN getpwuid record, and re-derives every fact + /// it checks — existence, symlink-ness, canonical form, volume, owner — + /// with its own lstat. The client proposes; the privileged side decides. + case cleanReviewed /// The engine's maintenance pass. case optimize /// Enumerate what an optimize WOULD do. The optimize counterpart of @@ -67,13 +81,18 @@ enum HelperOperation: String, Codable, CaseIterable, Sendable { case .clean: return ["clean"] case .optimize: return ["optimize"] case .optimizeScan: return ["optimize", "--dry-run"] - case .flushDNS, .renewDHCP, .readLoginItems: return nil + case .flushDNS, .renewDHCP, .readLoginItems, .cleanReviewed: return nil } } /// Whether this operation needs a network interface name. var needsInterface: Bool { self == .renewDHCP } + /// Whether this operation is driven by a reviewed path list. Exactly one + /// operation is, and it is required to be non-empty for that one and + /// required to be absent for every other. + var needsReviewedPaths: Bool { self == .cleanReviewed } + /// The exact process steps the daemon runs, in order. /// /// Every executable is an absolute path from a closed set, and every @@ -86,10 +105,21 @@ enum HelperOperation: String, Codable, CaseIterable, Sendable { /// `/bin/sh -c "dscacheutil -flushcache; killall -HUP mDNSResponder"`, /// so a root shell parses a command string. Here the two commands are two /// separate `posix_spawn` calls with fixed argv and no shell in between. - func steps(interface: String?) -> [HelperStep] { + func steps(interface: String?, reviewedPaths: [String] = []) -> [HelperStep] { switch self { case .scan, .clean, .optimize, .optimizeScan: return [HelperStep(executable: .bundledEngine, arguments: engineArguments ?? [])] + case .cleanReviewed: + // One `find` per reviewed entry, each with fixed flags around a + // path the daemon has already validated. `-x` holds it to the + // entry's own volume, `-delete` implies depth-first and refuses to + // follow symlinks, and BSD find chdir's as it descends so each + // unlink is relative to the directory it is standing in rather + // than a re-resolved path. + return reviewedPaths.map { + HelperStep(executable: .system(HelperSystemTool.find), + arguments: ["-x", $0, "-depth", "-delete"]) + } case .flushDNS: return [ HelperStep(executable: .system(HelperSystemTool.dscacheutil), arguments: ["-flushcache"]), @@ -111,7 +141,7 @@ enum HelperOperation: String, Codable, CaseIterable, Sendable { var mutatesDisk: Bool { switch self { case .scan, .optimizeScan: return false - case .clean, .optimize: return true + case .clean, .optimize, .cleanReviewed: return true // These change system state or read privileged data rather than // touching the filesystem, but all run as root, so all authenticate. case .flushDNS, .renewDHCP: return false @@ -148,11 +178,12 @@ enum HelperSystemTool { static let killall = "/usr/bin/killall" static let ipconfig = "/usr/sbin/ipconfig" static let sfltool = "/usr/bin/sfltool" + static let find = "/usr/bin/find" /// Every permitted absolute path. Used by the daemon to re-check an /// executable immediately before spawning it, so a step constructed by /// some future code path still cannot introduce a new binary. - static let all: Set = [dscacheutil, killall, ipconfig, sfltool] + static let all: Set = [dscacheutil, killall, ipconfig, sfltool, find] } /// What a step runs. @@ -293,6 +324,99 @@ enum HelperInvokingUserResolver { } } +// MARK: - Reviewed cleanup targets + +/// One approved root, as the DAEMON derives it. The invoking user's home comes +/// from getpwuid and the rest are fixed system cache locations; a client never +/// contributes to this list. +struct HelperReviewedRoot: Equatable, Sendable { + let path: String + let device: UInt64 + /// System cache trees are root-owned and shared, so their contents + /// legitimately belong to other accounts. A per-user tree is not: deleting + /// another account's files there would be a real escalation, since the + /// invoking user could not do it themselves. + let allowsForeignOwner: Bool + + var prefix: String { path.hasSuffix("/") ? path : path + "/" } +} + +/// The daemon's own lstat of a proposed target. Injected so the rule below is +/// a pure function and can be tested against layouts the test host doesn't have. +struct HelperReviewedTarget: Equatable, Sendable { + let exists: Bool + let isSymbolicLink: Bool + let canonicalPath: String? + let device: UInt64 + let ownerUID: UInt32 +} + +enum HelperReviewedPathRejection: String, Error, Codable, Equatable, Sendable { + case emptySelection + case tooManyTargets + case malformedPath + case missingPath + case symbolicLink + case notCanonical + case outsideApprovedRoots + case foreignVolume + case foreignOwner +} + +/// What root is allowed to delete on a client's say-so. +/// +/// Every rule here is enforced against facts the daemon gathered itself. The +/// client's list is a proposal; nothing in it is taken on trust, including +/// whether a path exists or where it points. +enum HelperReviewedPathPolicy { + /// A reviewed clean is a handful of cache entries. A list far past that is + /// not a user's selection, and an unbounded one is a way to make the root + /// daemon do unbounded work. + static let maximumTargets = 4096 + + static func validate(paths: [String], + roots: [HelperReviewedRoot], + invokingUID: UInt32, + inspect: (String) -> HelperReviewedTarget) + -> Result<[String], HelperReviewedPathRejection> { + guard !paths.isEmpty else { return .failure(.emptySelection) } + guard paths.count <= maximumTargets else { return .failure(.tooManyTargets) } + guard !roots.isEmpty else { return .failure(.outsideApprovedRoots) } + + var accepted: [String] = [] + var seen = Set() + for path in paths { + guard path.hasPrefix("/"), path != "/", !path.hasSuffix("/"), + !path.contains("/../"), !path.hasSuffix("/.."), + !path.unicodeScalars.contains(where: { + CharacterSet.controlCharacters.contains($0) + }) else { return .failure(.malformedPath) } + + let target = inspect(path) + guard target.exists else { return .failure(.missingPath) } + // A symlink would let the delete land on whatever it points at. + guard !target.isSymbolicLink else { return .failure(.symbolicLink) } + // realpath must agree with the literal path, so no component along + // the way is a link either. + guard let canonical = target.canonicalPath, canonical == path else { + return .failure(.notCanonical) + } + // A root itself is never deletable — only things strictly below it. + guard let root = roots.first(where: { + path != $0.path && path.hasPrefix($0.prefix) + }) else { return .failure(.outsideApprovedRoots) } + guard target.device == root.device else { return .failure(.foreignVolume) } + guard root.allowsForeignOwner || target.ownerUID == invokingUID else { + return .failure(.foreignOwner) + } + // Duplicates are dropped rather than refused: the same entry twice + // is harmless, and the second find would just fail on a missing path. + if seen.insert(path).inserted { accepted.append(path) } + } + return .success(accepted) + } +} + // MARK: - Request /// Why a request never reached the authorization step. Named so the GUI can @@ -313,6 +437,9 @@ enum HelperRequestRejection: String, Codable, Equatable, Sendable { /// The invoking-user claim was malformed or did not match the XPC peer, /// daemon account database, and inspected home directory. case invalidInvokingUser + /// The reviewed path list was missing, oversized, or contained an entry + /// the daemon's own inspection refused. See `HelperReviewedPathPolicy`. + case invalidReviewedPaths } /// One privileged operation, fully described. None of its fields can carry a @@ -346,16 +473,28 @@ struct HelperRequest: Codable, Equatable, Sendable { /// A name that isn't a real interface is refused rather than passed on. var networkInterface: String? = nil + /// The entries the user reviewed and ticked, for `cleanReviewed` and + /// nothing else. + /// + /// These reach a child process's argv, but only as operands to a fixed + /// `find … -delete` the daemon composes, and only after the daemon has + /// re-derived every fact it checks about them. Empty for every other + /// operation, and an operation that carries them when it shouldn't is + /// refused rather than having them ignored. + var reviewedPaths: [String] = [] + init(operation: HelperOperation, operationID: String, clientBuild: String, invokingUser: HelperInvokingUserClaim, - networkInterface: String? = nil) { + networkInterface: String? = nil, + reviewedPaths: [String] = []) { self.operation = operation self.operationID = operationID self.clientBuild = clientBuild self.invokingUser = invokingUser self.networkInterface = networkInterface + self.reviewedPaths = reviewedPaths } /// `nil` when the request is well formed. Runs on the PRIVILEGED side — @@ -378,6 +517,19 @@ struct HelperRequest: Codable, Equatable, Sendable { CharacterSet.controlCharacters.contains($0) }) else { return .invalidInvokingUser } + if operation.needsReviewedPaths { + // Shape only. Whether these paths may actually be deleted is + // decided by HelperReviewedPathPolicy against the daemon's own + // lstat — this check just refuses obvious nonsense early. + guard !reviewedPaths.isEmpty, + reviewedPaths.count <= HelperReviewedPathPolicy.maximumTargets + else { return .invalidReviewedPaths } + } else { + // Paths on an operation that takes none means the caller and this + // contract disagree about what is being asked for. + guard reviewedPaths.isEmpty else { return .invalidReviewedPaths } + } + if operation.needsInterface { guard let name = networkInterface, HelperRequest.isPlausibleInterfaceName(name), diff --git a/macos/Sources/PrivilegedHelperClient.swift b/macos/Sources/PrivilegedHelperClient.swift index d7d9afd8..1dfbe74a 100644 --- a/macos/Sources/PrivilegedHelperClient.swift +++ b/macos/Sources/PrivilegedHelperClient.swift @@ -71,9 +71,10 @@ enum PrivilegeRoute: Equatable { case osascript /// The routing rule. The helper is used only when ALL of these hold: - /// * the argv maps onto one of the three typed operations; /// * the daemon is registered and enabled; - /// * its build matches this app's. + /// * its build matches this app's; + /// * the work is either a reviewed cleanup plan, or argv that maps onto + /// one of the typed engine operations. /// /// Any doubt routes to osascript. That is a genuine fallback rather than a /// silent downgrade: the osascript path is the elevation Burrow has always @@ -82,10 +83,15 @@ enum PrivilegeRoute: Equatable { /// cancellation, not the authentication itself. static func decide(arguments: [String], registration: HelperRegistrationStatus, - skew: HelperVersionSkew.Skew) -> PrivilegeRoute { + skew: HelperVersionSkew.Skew, + hasReviewedCleanup: Bool = false) -> PrivilegeRoute { + guard registration == .enabled, skew == .matched else { return .osascript } + // A reviewed cleanup has no engine argv to recognise — it is described + // by its plan, not by a command. Without this it fell through to + // osascript every time, which is how the permanent clean quietly lost + // Touch ID while every other elevated operation kept it. + if hasReviewedCleanup { return .helper(.cleanReviewed) } guard let operation = HelperOperation(engineArguments: arguments) else { return .osascript } - guard registration == .enabled else { return .osascript } - guard skew == .matched else { return .osascript } return .helper(operation) } } @@ -208,16 +214,18 @@ final class PrivilegedHelperClient: @unchecked Sendable { /// The route for an elevated invocation described the way `OperationFlow` /// already describes it. - func route(for arguments: [String]) -> PrivilegeRoute { + func route(for arguments: [String], hasReviewedCleanup: Bool = false) -> PrivilegeRoute { let status = registrationStatus // Don't pay for an XPC round trip to learn the version when the daemon // isn't usable anyway. guard status == .enabled else { helperClientLog.notice("route: osascript (registration \(String(describing: status), privacy: .public))") - return PrivilegeRoute.decide(arguments: arguments, registration: status, skew: .mismatched) + return PrivilegeRoute.decide(arguments: arguments, registration: status, + skew: .mismatched, hasReviewedCleanup: hasReviewedCleanup) } let skew = versionSkew() - let route = PrivilegeRoute.decide(arguments: arguments, registration: status, skew: skew) + let route = PrivilegeRoute.decide(arguments: arguments, registration: status, + skew: skew, hasReviewedCleanup: hasReviewedCleanup) helperClientLog.notice(""" route: \(String(describing: route), privacy: .public) \ (app build \(Self.appBuild, privacy: .public), skew \(String(describing: skew), privacy: .public)) @@ -233,6 +241,7 @@ final class PrivilegedHelperClient: @unchecked Sendable { /// takes at the authentication prompt plus as long as the operation runs. func run(operation: HelperOperation, interface: String? = nil, + reviewedPaths: [String] = [], invokingUser suppliedIdentity: InvokingUserIdentity? = nil, onLine: @escaping (String) -> Void) -> ElevatedOutcome { // Resolve while still running as the caller and before showing an auth @@ -270,7 +279,8 @@ final class PrivilegedHelperClient: @unchecked Sendable { // not a guarantee. return withExtendedLifetime(granted) { send(payload: granted.externalForm, operation: operation, - interface: interface, invokingUser: invokingUser, onLine: onLine) + interface: interface, reviewedPaths: reviewedPaths, + invokingUser: invokingUser, onLine: onLine) } } @@ -303,6 +313,7 @@ final class PrivilegedHelperClient: @unchecked Sendable { private func send(payload authorization: Data, operation: HelperOperation, interface: String?, + reviewedPaths: [String], invokingUser: InvokingUserIdentity, onLine: @escaping (String) -> Void) -> ElevatedOutcome { let request = HelperRequest(operation: operation, @@ -311,7 +322,8 @@ final class PrivilegedHelperClient: @unchecked Sendable { invokingUser: HelperInvokingUserClaim( uid: UInt32(invokingUser.uid), canonicalHome: invokingUser.canonicalHome), - networkInterface: interface) + networkInterface: interface, + reviewedPaths: reviewedPaths) guard let payload = try? JSONEncoder().encode(request) else { return .launchFailed } let connection = makeConnection() @@ -371,10 +383,29 @@ struct HelperAwareProcessPort: ProcessPort { // for as long as the user takes to authenticate. Neither may // happen on the main thread. DispatchQueue.global(qos: .userInitiated).async { - switch client.route(for: spec.arguments) { + switch client.route(for: spec.arguments, + hasReviewedCleanup: spec.cleanupPlan != nil) { case .helper(let operation): var sawOutput = false + // Re-validate the plan on THIS side of the prompt too. The + // daemon checks the paths independently, but a plan that + // already went stale should never raise a prompt at all. + let reviewedPaths: [String] + if operation.needsReviewedPaths { + guard let plan = spec.cleanupPlan, plan.validateForLaunch() else { + continuation.yield(.line(NSLocalizedString( + "The reviewed items changed before the run started, so nothing was cleaned.", + comment: ""))) + continuation.yield(.exited(ElevatedExitCode.boundaryCheckFailed)) + continuation.finish() + return + } + reviewedPaths = plan.items.map(\.identity.path) + } else { + reviewedPaths = [] + } let outcome = client.run(operation: operation, + reviewedPaths: reviewedPaths, invokingUser: spec.invokingUser) { line in sawOutput = true continuation.yield(.line(line)) diff --git a/macos/Tests/HelperContractTests.swift b/macos/Tests/HelperContractTests.swift index d9fd7074..518b6ea7 100644 --- a/macos/Tests/HelperContractTests.swift +++ b/macos/Tests/HelperContractTests.swift @@ -157,17 +157,27 @@ final class HelperContractTests: XCTestCase { // MARK: - The closed operation set // - // The approved scope is exactly: privileged scan, clean, optimize. Not - // "run this binary", not "run this shell string", not "run mo with these - // args". A new case here is a deliberate security decision, so the count - // is pinned — adding one without updating this test is a failing build. + // The approved scope is exactly: privileged scan, clean, optimize, the + // reviewed clean, and three system-state operations. Not "run this binary", + // not "run this shell string", not "run mo with these args". A new case + // here is a deliberate security decision, so the set is pinned — adding one + // without updating this test is a failing build. func testOperationSet_isPinned() { XCTAssertEqual(Set(HelperOperation.allCases.map(\.rawValue)), - ["scan", "clean", "optimize", "optimizeScan", "flushDNS", "renewDHCP", "readLoginItems"], + ["scan", "clean", "cleanReviewed", "optimize", "optimizeScan", + "flushDNS", "renewDHCP", "readLoginItems"], "the helper's operation set is closed; widening it is a security decision") } + /// `cleanReviewed` is the only operation carrying caller data, so the + /// property that matters is that it stays the only one. + func testOnlyTheReviewedCleanAcceptsCallerSuppliedPaths() { + let accepting = HelperOperation.allCases.filter(\.needsReviewedPaths) + XCTAssertEqual(accepting, [.cleanReviewed], + "widening which operations take paths is a security decision") + } + // MARK: - The closed executable set // // Most operations drive the bundled engine. The rest drive system tools, and @@ -184,7 +194,7 @@ final class HelperContractTests: XCTestCase { func testSystemTools_setIsExactlyWhatTheOperationsNeed() { XCTAssertEqual(HelperSystemTool.all, ["/usr/bin/dscacheutil", "/usr/bin/killall", - "/usr/sbin/ipconfig", "/usr/bin/sfltool"]) + "/usr/sbin/ipconfig", "/usr/bin/sfltool", "/usr/bin/find"]) } /// No step may ever name a shell. The path this replaces elevated @@ -304,8 +314,11 @@ final class HelperContractTests: XCTestCase { /// anywhere in here would signal that someone started templating strings /// into a command that runs as root. func testArguments_neverEmptyAndNeverShellMetacharacters() { + // The reviewed clean is driven by its path list, so it is given one — + // and a path the daemon would have had to validate before it got here. + let reviewedPaths = ["/Users/henry/Library/Caches/example"] for op in HelperOperation.allCases { - let steps = op.steps(interface: "en0") + let steps = op.steps(interface: "en0", reviewedPaths: reviewedPaths) XCTAssertFalse(steps.isEmpty, "\(op) must resolve to at least one command") for token in steps.flatMap(\.arguments) { XCTAssertFalse(token.contains(where: { ";|&`$<>\n\0".contains($0) }), @@ -465,3 +478,191 @@ final class HelperContractTests: XCTestCase { XCTAssertEqual(HelperResponse.Outcome.authorizationDenied.elevatedOutcome, .authCancelled) } } + +// MARK: - Reviewed cleanup targets +// +// The one operation that accepts data from the caller beyond a verb. Every +// rule below is enforced against facts the DAEMON gathers, so these tests +// inject the inspection rather than the policy trusting a supplied fact. + +final class HelperReviewedPathPolicyTests: XCTestCase { + private let uid: UInt32 = 501 + private let homeDevice: UInt64 = 1 + private let cacheDevice: UInt64 = 1 + + private var roots: [HelperReviewedRoot] { + [ + HelperReviewedRoot(path: "/Users/henry", device: homeDevice, allowsForeignOwner: false), + HelperReviewedRoot(path: "/Library/Caches", device: cacheDevice, allowsForeignOwner: true), + ] + } + + private func target(device: UInt64? = nil, + owner: UInt32? = nil, + exists: Bool = true, + isSymbolicLink: Bool = false, + canonical: String?) -> HelperReviewedTarget { + HelperReviewedTarget(exists: exists, isSymbolicLink: isSymbolicLink, + canonicalPath: canonical, + device: device ?? homeDevice, ownerUID: owner ?? uid) + } + + private func validate(_ paths: [String], + inspect: @escaping (String) -> HelperReviewedTarget) + -> Result<[String], HelperReviewedPathRejection> { + HelperReviewedPathPolicy.validate(paths: paths, roots: roots, + invokingUID: uid, inspect: inspect) + } + + func testAcceptsOwnedEntriesBelowAnApprovedRoot() throws { + let path = "/Users/henry/Library/Caches/app" + let result = validate([path]) { [unowned self] in self.target(canonical: $0) } + XCTAssertEqual(try result.get(), [path]) + } + + func testRefusesAPathOutsideEveryApprovedRoot() { + let result = validate(["/System/Library/Caches/x"]) { [unowned self] in self.target(canonical: $0) } + XCTAssertEqual(result.failureRejection, .outsideApprovedRoots) + } + + /// A root itself is not a cache entry. Deleting `/Library/Caches` wholesale + /// is never what a reviewed selection meant. + func testRefusesAnApprovedRootItself() { + let result = validate(["/Library/Caches"]) { [unowned self] in self.target(canonical: $0) } + XCTAssertEqual(result.failureRejection, .outsideApprovedRoots) + } + + func testRefusesTraversalOutOfAnApprovedRoot() { + let result = validate(["/Users/henry/../root/.ssh"]) { [unowned self] in self.target(canonical: $0) } + XCTAssertEqual(result.failureRejection, .malformedPath) + } + + /// A symlink would put the delete wherever it points, which is the whole + /// reason the daemon lstats instead of trusting the string. + func testRefusesASymbolicLink() { + let result = validate(["/Users/henry/Library/Caches/link"]) { [unowned self] in + self.target(isSymbolicLink: true, canonical: $0) + } + XCTAssertEqual(result.failureRejection, .symbolicLink) + } + + /// realpath disagreeing with the literal path means some ANCESTOR is a + /// link, so the entry is not where the client says it is. + func testRefusesWhenAnAncestorIsASymbolicLink() { + let result = validate(["/Users/henry/Library/Caches/app"]) { [unowned self] _ in + self.target(canonical: "/Volumes/elsewhere/app") + } + XCTAssertEqual(result.failureRejection, .notCanonical) + } + + func testRefusesAnEntryOnAnotherVolume() { + let result = validate(["/Users/henry/Library/Caches/app"]) { [unowned self] in + self.target(device: 99, canonical: $0) + } + XCTAssertEqual(result.failureRejection, .foreignVolume) + } + + /// The escalation that matters. Per-user trees hold other accounts' files, + /// and root deleting those is something the invoking user could not do + /// themselves — unlike anything in their own home. + func testRefusesAnotherAccountsEntryInAPerUserTree() { + let result = validate(["/Users/henry/Library/Caches/app"]) { [unowned self] in + self.target(owner: 502, canonical: $0) + } + XCTAssertEqual(result.failureRejection, .foreignOwner) + } + + /// System cache trees are root-owned and shared, so foreign ownership + /// there is normal rather than suspicious. + func testAllowsForeignOwnershipUnderSharedSystemCaches() throws { + let path = "/Library/Caches/com.apple.something" + let result = validate([path]) { [unowned self] in self.target(owner: 0, canonical: $0) } + XCTAssertEqual(try result.get(), [path]) + } + + func testRefusesAMissingEntryRatherThanSkippingIt() { + let result = validate(["/Users/henry/Library/Caches/gone"]) { [unowned self] in + self.target(exists: false, canonical: $0) + } + XCTAssertEqual(result.failureRejection, .missingPath) + } + + func testRefusesAnEmptyOrOversizedSelection() { + XCTAssertEqual(validate([]) { [unowned self] in self.target(canonical: $0) }.failureRejection, + .emptySelection) + let many = (0...HelperReviewedPathPolicy.maximumTargets) + .map { "/Users/henry/Library/Caches/app-\($0)" } + XCTAssertEqual(validate(many) { [unowned self] in self.target(canonical: $0) }.failureRejection, + .tooManyTargets) + } + + func testDeduplicatesRepeatedEntries() throws { + let path = "/Users/henry/Library/Caches/app" + let result = validate([path, path]) { [unowned self] in self.target(canonical: $0) } + XCTAssertEqual(try result.get(), [path]) + } +} + +final class HelperReviewedRequestTests: XCTestCase { + private func claim() -> HelperInvokingUserClaim { + HelperInvokingUserClaim(uid: 501, canonicalHome: "/Users/henry") + } + + private func request(_ operation: HelperOperation, paths: [String]) -> HelperRequest { + HelperRequest(operation: operation, operationID: UUID().uuidString, + clientBuild: "1", invokingUser: claim(), reviewedPaths: paths) + } + + func testReviewedPathsRoundTripAcrossTheWire() throws { + let original = request(.cleanReviewed, paths: ["/Users/henry/Library/Caches/a"]) + let decoded = try JSONDecoder().decode( + HelperRequest.self, from: try JSONEncoder().encode(original)) + XCTAssertEqual(decoded, original) + } + + func testCleanReviewedRequiresAtLeastOnePath() { + XCTAssertEqual(request(.cleanReviewed, paths: []).validate(expectedBuild: "1"), + .invalidReviewedPaths) + } + + /// Paths on an operation that takes none means the caller and the contract + /// disagree; refuse rather than silently ignoring them. + func testOtherOperationsRefusePathsRatherThanIgnoringThem() { + XCTAssertEqual(request(.clean, paths: ["/Users/henry/x"]).validate(expectedBuild: "1"), + .invalidReviewedPaths) + } + + /// The recogniser maps engine argv onto operations. A reviewed cleanup has + /// no engine argv, so it must never be reachable that way. + func testReviewedCleanupIsNotReachableFromEngineArguments() { + for operation in HelperOperation.allCases { + if let argv = operation.engineArguments { + XCTAssertNotEqual(HelperOperation(engineArguments: argv), .cleanReviewed) + } + } + XCTAssertNil(HelperOperation.cleanReviewed.engineArguments) + } + + func testStepsAreFixedFindInvocationsOverTheValidatedPaths() { + let paths = ["/Users/henry/Library/Caches/a", "/Library/Caches/b"] + let steps = HelperOperation.cleanReviewed.steps(interface: nil, reviewedPaths: paths) + XCTAssertEqual(steps.count, 2) + for (step, path) in zip(steps, paths) { + XCTAssertEqual(step.executable, .system(HelperSystemTool.find)) + XCTAssertEqual(step.arguments, ["-x", path, "-depth", "-delete"]) + } + XCTAssertTrue(HelperSystemTool.all.contains(HelperSystemTool.find)) + } + + func testNoReviewedPathsMeansNoStepsRatherThanADeleteOfSomethingElse() { + XCTAssertTrue(HelperOperation.cleanReviewed.steps(interface: nil, + reviewedPaths: []).isEmpty) + } +} + +private extension Result where Success == [String], Failure == HelperReviewedPathRejection { + var failureRejection: HelperReviewedPathRejection? { + if case .failure(let rejection) = self { return rejection } + return nil + } +} diff --git a/macos/Tests/PrivilegeRouteTests.swift b/macos/Tests/PrivilegeRouteTests.swift index 2176c058..7c41029f 100644 --- a/macos/Tests/PrivilegeRouteTests.swift +++ b/macos/Tests/PrivilegeRouteTests.swift @@ -131,4 +131,42 @@ final class PrivilegeRouteTests: XCTestCase { XCTAssertFalse(HelperRegistrationStatus.notRegistered.needsUserAction, "never registered is the default state, not a pending decision") } + + // MARK: - The reviewed clean + // + // This is the regression that quietly removed Touch ID from the single + // most consequential operation Burrow has. The permanent clean stopped + // being described by engine argv and became a reviewed PLAN, and because + // routing only ever recognised argv, it fell through to osascript — which + // is password-only by construction — while every other elevated operation + // kept authenticating through the helper. + + func testReviewedCleanupRoutesToTheHelperDespiteHavingNoEngineArguments() { + XCTAssertEqual(PrivilegeRoute.decide(arguments: [], + registration: .enabled, + skew: .matched, + hasReviewedCleanup: true), + .helper(.cleanReviewed)) + } + + func testReviewedCleanupStillFallsBackWhenTheHelperIsUnusable() { + for (registration, skew) in [(HelperRegistrationStatus.notRegistered, HelperVersionSkew.Skew.matched), + (.requiresApproval, .matched), + (.enabled, .mismatched)] { + XCTAssertEqual(PrivilegeRoute.decide(arguments: [], + registration: registration, + skew: skew, + hasReviewedCleanup: true), + .osascript) + } + } + + /// Without a plan there is nothing to delete, so an empty argv must stay + /// unroutable rather than reaching the daemon as a cleanup of nothing. + func testEmptyArgumentsWithoutAPlanDoNotReachTheHelper() { + XCTAssertEqual(PrivilegeRoute.decide(arguments: [], + registration: .enabled, + skew: .matched), + .osascript) + } } From af970d575a2d4437a051e051ae41637eae891daa Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:07:15 -0700 Subject: [PATCH 08/11] fix: close the remaining safe-release gaps Replay guard: eviction was a count-bounded FIFO, so a caller could send `capacity` fresh operation IDs to push an older one out of the set and then replay that older payload. The bound meant to prevent replay was itself the bypass. Eviction is by age now -- an ID is only forgotten once it is far older than any authorization could still be valid for -- with the count cap kept as a backstop that only discards already-expired entries. Update verification: `SecStaticCodeCheckValidity` ran with a nil requirement, which validates against the code's own designated requirement -- something a self-signed bundle satisfies trivially. The only other thing pinned was the team identifier, a string in the certificate subject that a self-signed certificate is free to claim. It now checks `anchor apple generic`, which only Apple-issued chains satisfy, and a requirement that fails to build refuses rather than skipping the check. Rate limiting (macOS and Windows): the limiter ran only after a request had authenticated, leaving rejected requests uncounted. That gave an attacker unlimited token guesses and unlimited parse work from the same loopback socket -- the exact load the limiter exists to cap. Both count before authorization now. Elevated log tail: the poll ran on the main run loop, so a busy or modal UI could miss the short window the root shell allows before its trap unlinks the sink, losing the entire transcript. It runs on the stream queue now, which also puts every access to the log handle on the one queue that owns it -- previously the timer and the termination handler raced to open, read and close it, including reads against a descriptor the other side had already closed. The GUI's decode sites use lossy decoding so a chunk boundary mid-sequence can no longer drop output. Also corrects the helper code-requirement comment, which claimed no team identifier appears anywhere in the repository; the release workflow pins one as a build-time assertion. The runtime check still discovers it. --- macos/Sources/OperationFlow.swift | 54 +++++++++++++++---- .../HelperCodeRequirement.swift | 15 ++++-- .../PrivilegedHelper/HelperContract.swift | 51 ++++++++++++++---- macos/Sources/QueryServer.swift | 21 +++++--- macos/Sources/UpdateWorkflow.swift | 25 ++++++++- macos/Tests/HelperContractTests.swift | 41 ++++++++++---- windows/Services/LocalMcpServerService.cs | 6 ++- 7 files changed, 168 insertions(+), 45 deletions(-) diff --git a/macos/Sources/OperationFlow.swift b/macos/Sources/OperationFlow.swift index 8272a0b1..048e6041 100644 --- a/macos/Sources/OperationFlow.swift +++ b/macos/Sources/OperationFlow.swift @@ -434,9 +434,16 @@ struct SystemProcessPort: ProcessPort { // silently drop lines — an intermittent CI failure that surfaced as // [] or ["a"] instead of ["a","b"].) let streamQ = DispatchQueue(label: "dev.caezium.burrow.opflow.stream") - var tailTimer: Timer? - var logHandle: FileHandle? + var tailTimer: DispatchSourceTimer? var killTimer: DispatchSourceTimer? + // Both of these belong to streamQ ALONE. The tail timer fires on + // the main run loop, so if it opened, read, or closed the handle + // itself it would be racing the termination handler doing the same + // three things — including a read against a descriptor the other + // side had already closed. The timer therefore only schedules work + // onto streamQ; ownership never leaves it. + var logHandle: FileHandle? // streamQ only + var tailFinished = false // streamQ only func emit(_ s: String) { // streamQ only for line in splitter.ingest(Ansi.strip(s)) { cont.yield(.line(line)) } @@ -498,23 +505,49 @@ struct SystemProcessPort: ProcessPort { t.standardOutput = outPipe t.standardError = errPipe - let timer = Timer(timeInterval: 0.05, repeats: true) { _ in + // The tail poll runs on streamQ, NOT the main run loop. + // + // The root shell unlinks its sink from a trap on exit and only + // gives the app a short fixed window to get a descriptor first. + // Polling from the main run loop meant a busy or modal UI could + // miss that window entirely and lose the whole transcript — and + // a clean whose output vanished used to render as a successful + // run that freed nothing. A background queue cannot be starved + // by the UI, and it puts every access to `logHandle` on the one + // queue that owns it. + let tail = DispatchSource.makeTimerSource(queue: streamQ) + tail.schedule(deadline: .now(), repeating: .milliseconds(50)) + tail.setEventHandler { + // A tick can still be in flight after the final tail has + // been read and the handle closed; serving it would read a + // closed descriptor. + guard !tailFinished else { return } if logHandle == nil { logHandle = logSink.openForReading() } guard let h = logHandle else { return } let data = h.readDataToEndOfFile() - guard !data.isEmpty, let s = String(data: data, encoding: .utf8) else { return } - streamQ.async { emit(s) } + guard !data.isEmpty else { return } + // Lossy decode, never the failable initializer: a read can + // end mid-UTF-8-sequence, and `String(data:encoding:)` + // returning nil there used to discard the whole chunk — + // losing entire lines of a privileged run's transcript. + emit(String(decoding: data, as: UTF8.self)) } - RunLoop.main.add(timer, forMode: .common) - tailTimer = timer + tail.resume() + tailTimer = tail t.terminationHandler = { proc in killTimer?.cancel() streamQ.async { + tail.cancel() + // Claim the handle before the final read so a timer + // tick queued behind this block cannot touch it. + tailFinished = true + if logHandle == nil { logHandle = logSink.openForReading() } if let h = logHandle { // last tail of the log let data = h.readDataToEndOfFile() - if !data.isEmpty, let s = String(data: data, encoding: .utf8) { emit(s) } + if !data.isEmpty { emit(String(decoding: data, as: UTF8.self)) } try? h.close() + logHandle = nil } let stderr = String( decoding: errPipe.fileHandleForReading.readDataToEndOfFile(), @@ -537,7 +570,7 @@ struct SystemProcessPort: ProcessPort { } cont.onTermination = { @Sendable _ in - DispatchQueue.main.async { tailTimer?.invalidate() } + tailTimer?.cancel() if t.isRunning { t.terminate() } } @@ -573,7 +606,8 @@ struct SystemProcessPort: ProcessPort { group.enter() DispatchQueue.global(qos: .utility).async { while case let d = fh.availableData, !d.isEmpty { - if let s = String(data: d, encoding: .utf8) { streamQ.sync { emit(s) } } + let s = String(decoding: d, as: UTF8.self) + streamQ.sync { emit(s) } } group.leave() } diff --git a/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift b/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift index 97626407..3ff5052f 100644 --- a/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift +++ b/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift @@ -30,12 +30,19 @@ // the evaluation happens in the kernel against the real peer, with no window // between check and use. // -// ── Nothing personal is hardcoded ─────────────────────────────────────── +// ── The team is discovered, not compiled in ───────────────────────────── // The requirement is assembled at RUNTIME from the daemon's own signing // information: the helper asks what team signed IT, and demands the caller be -// the Burrow app signed by that same team. No team ID, certificate, or -// developer name appears in this repository, and the check keeps working -// across certificate renewals. +// the Burrow app signed by that same team. Nothing here is baked in, so the +// check keeps working across certificate renewals and an ad-hoc local build +// needs no special case. +// +// The release workflow does pin the expected team as `EXPECTED_TEAM_ID` so a +// misconfigured signing identity fails the build rather than shipping. That is +// a build-time assertion about which certificate we meant to use; a team ID is +// public (it is in every signature we ship) and is not a secret. This file +// still learns it at runtime, and must keep doing so — hardcoding it here +// would break the ad-hoc development path and add a second place to update. // import Foundation diff --git a/macos/Sources/PrivilegedHelper/HelperContract.swift b/macos/Sources/PrivilegedHelper/HelperContract.swift index ee208781..29233b9e 100644 --- a/macos/Sources/PrivilegedHelper/HelperContract.swift +++ b/macos/Sources/PrivilegedHelper/HelperContract.swift @@ -613,20 +613,38 @@ struct HelperResponse: Codable, Equatable, Sendable { /// authorization buys exactly one root operation. /// /// Bounded on purpose: a daemon can stay resident for weeks, and an unbounded -/// set would grow with every request a client cared to send. Eviction is -/// oldest-first, which only ever forgets ancient IDs — the practical replay -/// window (seconds, between authenticating and executing) is always covered. +/// set would grow with every request a client cared to send. +/// +/// Eviction is by AGE, not by count. A count-bounded FIFO looks equivalent but +/// isn't: a caller could send `capacity` fresh IDs to push an older one out of +/// the set and then replay that older payload, turning the bound itself into +/// the bypass. Age-based eviction cannot be driven that way — an ID is only +/// forgotten once it is far older than any authorization could still be valid +/// for, so the practical replay window is always covered no matter how much +/// traffic arrives. The count cap remains as a memory backstop, but it only +/// discards entries that are ALREADY expired. final class HelperReplayGuard: @unchecked Sendable { + /// Comfortably longer than the gap between authenticating and executing, + /// and longer than the authorization credential's own ten-second life, so + /// nothing still-usable is ever forgotten. + static let retention: TimeInterval = 3600 + private let capacity: Int - private var order: [String] = [] + private let retention: TimeInterval + private let now: () -> Date + private var order: [(id: String, at: Date)] = [] private var seen: Set = [] private let lock = NSLock() - init(capacity: Int = 512) { + init(capacity: Int = 8192, + retention: TimeInterval = HelperReplayGuard.retention, + now: @escaping () -> Date = Date.init) { self.capacity = max(1, capacity) + self.retention = max(1, retention) + self.now = now } - /// Number of IDs currently remembered. Never exceeds `capacity`. + /// Number of IDs currently remembered. var count: Int { lock.lock(); defer { lock.unlock() } return seen.count @@ -635,14 +653,27 @@ final class HelperReplayGuard: @unchecked Sendable { /// `true` the first time an ID is presented, `false` for every repeat. func admit(_ operationID: String) -> Bool { lock.lock(); defer { lock.unlock() } + let moment = now() + evictExpired(before: moment.addingTimeInterval(-retention)) guard !seen.contains(operationID) else { return false } seen.insert(operationID) - order.append(operationID) - while order.count > capacity { - seen.remove(order.removeFirst()) - } + order.append((operationID, moment)) return true } + + /// Drop only entries older than the retention window. If a flood pushes + /// past `capacity` while every entry is still fresh, the guard keeps them + /// — refusing to forget a replayable ID matters more than the memory, + /// and the entries are short strings. + private func evictExpired(before cutoff: Date) { + guard let first = order.first, first.at <= cutoff || order.count > capacity else { return } + var index = 0 + while index < order.count, order[index].at <= cutoff { + seen.remove(order[index].id) + index += 1 + } + if index > 0 { order.removeFirst(index) } + } } // MARK: - Version skew diff --git a/macos/Sources/QueryServer.swift b/macos/Sources/QueryServer.swift index a9e02498..11e95fdd 100644 --- a/macos/Sources/QueryServer.swift +++ b/macos/Sources/QueryServer.swift @@ -332,6 +332,11 @@ final class QueryServer { let path = request.target.split(separator: "?", maxSplits: 1).first.map(String.init) ?? "" guard path == "/events" else { return false } + guard self.rateLimiter.allow() else { + self.send(Self.errorResponse(429, "rate limit exceeded", + headers: ["Retry-After: 60"]), on: conn) + return true + } let rejection: Response? switch Self.authorize(header, token: self.authToken, port: self.port) { case .allowed: rejection = nil @@ -356,11 +361,6 @@ final class QueryServer { self.send(Self.errorResponse(400, "GET requests cannot include a body"), on: conn) return true } - guard self.rateLimiter.allow() else { - self.send(Self.errorResponse(429, "rate limit exceeded", - headers: ["Retry-After: 60"]), on: conn) - return true - } let head = "HTTP/1.1 200 OK\r\n" + "Content-Type: text/event-stream; charset=utf-8\r\n" + "Cache-Control: no-store\r\nConnection: keep-alive\r\n\r\n" @@ -378,6 +378,14 @@ final class QueryServer { Response(statusCode: status, body: s, contentType: Self.jsonContentType) } + // Counted BEFORE authorization, not after. Limiting only the requests + // that already presented the right credential leaves rejected ones + // unbounded — so an attacker gets unlimited guesses at the token and + // unlimited parse work out of the same socket, which is the load this + // limiter exists to cap. + guard self.rateLimiter.allow() else { + return Self.errorResponse(429, "rate limit exceeded", headers: ["Retry-After: 60"]) + } let authorization = Self.authorize(raw, token: self.authToken, port: self.port) switch authorization { case .unauthorized: @@ -399,9 +407,6 @@ final class QueryServer { guard Self.requestBodyIsAllowed(request) else { return Self.errorResponse(400, "GET requests cannot include a body") } - guard self.rateLimiter.allow() else { - return Self.errorResponse(429, "rate limit exceeded", headers: ["Retry-After: 60"]) - } let target = request.target let split = target.split(separator: "?", maxSplits: 1) let path = String(split[0]) diff --git a/macos/Sources/UpdateWorkflow.swift b/macos/Sources/UpdateWorkflow.swift index e35b9482..4c7a69f6 100644 --- a/macos/Sources/UpdateWorkflow.swift +++ b/macos/Sources/UpdateWorkflow.swift @@ -347,6 +347,25 @@ struct BundleUpdateIdentity: Equatable, Sendable { return nil } + /// Anchored at Apple, so a certificate chain we do not control cannot + /// satisfy the check. + /// + /// Without this, validity is judged against the code's OWN designated + /// requirement, which a self-signed bundle satisfies trivially — and the + /// only other thing pinned here is the team identifier, a plain string in + /// the certificate's subject OU that a self-signed certificate is free to + /// claim. `anchor apple generic` is what makes that string mean something, + /// because only Apple issues chains that satisfy it. `HelperCodeRequirement` + /// already pins its nested engine this way; the updater has to match, or + /// the SHA-512 from the feed is the only thing standing between a swapped + /// app and an install. + static let appleAnchoredRequirement: SecRequirement? = { + var requirement: SecRequirement? + guard SecRequirementCreateWithString("anchor apple generic" as CFString, + [], &requirement) == errSecSuccess else { return nil } + return requirement + }() + static func read(appURL: URL) -> BundleUpdateIdentity? { var staticCode: SecStaticCode? guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, @@ -354,7 +373,11 @@ struct BundleUpdateIdentity: Equatable, Sendable { // Validate every architecture in a universal app. Checking only the // host architecture could otherwise admit a tampered alternate slice. let checkAllArchitectures = SecCSFlags(rawValue: 1 << 0) - let valid = SecStaticCodeCheckValidity(staticCode, checkAllArchitectures, nil) == errSecSuccess + // A requirement we could not build is treated as "refuse", never as + // "skip the check" — the nil-requirement call is the weak one. + let valid = appleAnchoredRequirement.map { + SecStaticCodeCheckValidity(staticCode, checkAllArchitectures, $0) == errSecSuccess + } ?? false var information: CFDictionary? let signingInformation: UInt32 = 0x2 guard SecCodeCopySigningInformation( diff --git a/macos/Tests/HelperContractTests.swift b/macos/Tests/HelperContractTests.swift index 518b6ea7..fe2fabfe 100644 --- a/macos/Tests/HelperContractTests.swift +++ b/macos/Tests/HelperContractTests.swift @@ -418,18 +418,37 @@ final class HelperContractTests: XCTestCase { } } - /// The guard is bounded so a long-lived daemon can't be grown without - /// limit by a client that just keeps sending fresh IDs. Eviction is - /// oldest-first, and the CURRENT id is always remembered — so the replay - /// window can only ever shrink for ancient ids, never for a live one. - func testReplayGuard_isBoundedAndEvictsOldestFirst() { + /// A flood of fresh IDs must NOT be able to push an older one back out of + /// the set. Count-bounded FIFO eviction looks equivalent to age-based + /// eviction until you notice that the attacker chooses the traffic: send + /// `capacity` fresh IDs and the ID you wanted to replay is forgotten, which + /// turns the memory bound into the replay bypass it was meant to prevent. + func testReplayGuard_floodOfFreshIDsCannotEvictAStillReplayableID() { let guardian = HelperReplayGuard(capacity: 3) - let ids = (0..<4).map { _ in UUID().uuidString } - for id in ids { XCTAssertTrue(guardian.admit(id)) } - - XCTAssertTrue(guardian.admit(ids[0]), "the oldest ID was evicted once capacity was exceeded") - XCTAssertFalse(guardian.admit(ids[3]), "the newest ID is still remembered") - XCTAssertEqual(guardian.count, 3, "the guard never grows past its capacity") + let victim = UUID().uuidString + XCTAssertTrue(guardian.admit(victim)) + for _ in 0..<64 { XCTAssertTrue(guardian.admit(UUID().uuidString)) } + + XCTAssertFalse(guardian.admit(victim), + "an ID inside the retention window must never become replayable") + } + + /// Memory is still bounded, just by time rather than by count: entries are + /// forgotten once they are far older than any authorization could still be + /// valid for, so nothing usable is ever discarded. + func testReplayGuard_forgetsOnlyEntriesPastTheRetentionWindow() { + var now = Date(timeIntervalSince1970: 1_000_000) + let guardian = HelperReplayGuard(capacity: 8, retention: 60, now: { now }) + let old = UUID().uuidString + let recent = UUID().uuidString + XCTAssertTrue(guardian.admit(old)) + now = now.addingTimeInterval(59) + XCTAssertTrue(guardian.admit(recent)) + + // Cross the window for `old` but not for `recent`. + now = now.addingTimeInterval(2) + XCTAssertFalse(guardian.admit(recent), "a still-fresh ID stays remembered") + XCTAssertTrue(guardian.admit(old), "an expired ID is forgotten and its slot reclaimed") } // MARK: - Response encoding diff --git a/windows/Services/LocalMcpServerService.cs b/windows/Services/LocalMcpServerService.cs index 49b46a77..2e802c4a 100644 --- a/windows/Services/LocalMcpServerService.cs +++ b/windows/Services/LocalMcpServerService.cs @@ -266,7 +266,11 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT context.Request.HttpMethod, context.Request.ContentType, context.Request.ContentLength64); - if (decision == LocalRequestDecision.Allowed && !_requestRateLimiter.Allow()) + // Counted for EVERY request that reaches the handler, not only the ones + // that already authenticated. Limiting after the credential check + // leaves rejected requests unbounded, which hands an attacker unlimited + // guesses at the token out of the same loopback socket. + if (!_requestRateLimiter.Allow()) { decision = LocalRequestDecision.RateLimited; } From b9f705ff6de0454cfd57719fbbf136517c728ce5 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:59:09 -0700 Subject: [PATCH 09/11] fix(macos): stop reporting a successful clean as exit 1 The engine's export list names a parent AND its own children as separate entries. The helper deleted them in plan order, so a parent went first and every nested entry under it was already gone when its turn came -- `find` exited nonzero with "No such file or directory" for work that had actually succeeded, and the run surfaced as a failure. Two causes, both fixed. The helper path used raw plan order while the osascript path had always sorted deepest-first; both now share `orderedReviewedPaths()`, so no entry can vanish before it is reached. And the daemon treated `find`'s status as the verdict despite its own comment saying the postcondition is authoritative -- `-delete` returns true when it removed nothing and false for an entry a deeper delete already took, so it was never a usable signal. For reviewed cleans the daemon now decides purely on whether each path is gone afterwards, in both directions: survivors fail the run, and an absent entry is a success whatever `find` said on its way out. Entries the snapshot refuses now carry a `notCleanable` lock reason and render a "Can't clean" chip with the reason on hover, rather than sitting tickable in a list that could never act on them. They stay listed because the scan preview counted them, and silently dropping a line the user was just shown reads as a miscount. Clean now sits beside Scan your Mac on the idle hero for anyone who already trusts the engine's selection. It routes to the plain `clean` operation -- no plan, so no review -- and the elevation prompt is still ahead of any deletion. --- macos/HelperSources/HelperService.swift | 12 ++- macos/Sources/CleanReviewView.swift | 3 + macos/Sources/CleanSelection.swift | 4 + macos/Sources/CleanView.swift | 38 ++++++++- macos/Sources/CleanupAuthorization.swift | 89 ++++++++++++++++++---- macos/Sources/PrivilegedHelperClient.swift | 2 +- macos/Tests/PrivilegedSecurityTests.swift | 78 +++++++++++++++++++ 7 files changed, 208 insertions(+), 18 deletions(-) diff --git a/macos/HelperSources/HelperService.swift b/macos/HelperSources/HelperService.swift index 37fad95c..c7c41010 100644 --- a/macos/HelperSources/HelperService.swift +++ b/macos/HelperSources/HelperService.swift @@ -377,7 +377,12 @@ final class HelperOperationRunner: @unchecked Sendable { operationID: operationID, ownerUID: invokingUser.uid, environment: invokingUser.childEnvironment, emit: emit) - if status != 0 { last = status } + // For a reviewed clean, `find`'s status is NOT the verdict — the + // postcondition below is. `-delete` returns true even when it + // removed nothing, and it returns FALSE for an entry that a + // deeper delete already took away, so believing it reports a + // fully successful clean as "exit 1". + if status != 0 && !operation.needsReviewedPaths { last = status } // A reviewed clean is a list of independent entries: one that // can't be removed must not abandon the ones after it. Every other // operation is a sequence where a failed step invalidates the rest. @@ -681,9 +686,12 @@ final class HelperService: NSObject, BurrowHelperProtocol { // how a clean that freed nothing renders as "Done — caches cleared". if request.operation.needsReviewedPaths { let survivors = HelperReviewedCleanup.survivors(among: reviewedPaths) + // Authoritative, in both directions: still-present entries fail the + // run, and an entry that is gone is a success no matter what `find` + // said on its way out. + code = survivors.isEmpty ? 0 : 1 if !survivors.isEmpty { helperTrace("reviewed cleanup left \(survivors.count) of \(reviewedPaths.count) entries") - if code == 0 { code = 1 } } } helperTrace("operation finished with status \(code)") diff --git a/macos/Sources/CleanReviewView.swift b/macos/Sources/CleanReviewView.swift index ce30b9fc..9079325e 100644 --- a/macos/Sources/CleanReviewView.swift +++ b/macos/Sources/CleanReviewView.swift @@ -262,6 +262,9 @@ struct CleanReviewView: View { case .systemBusy: Chip(text: NSLocalizedString("System busy", comment: "clean badge"), color: Brand.textTertiary) .help(NSLocalizedString("A system service is using this path right now.", comment: "")) + case .notCleanable(let reason): + Chip(text: NSLocalizedString("Can't clean", comment: "clean badge"), color: Brand.amber) + .help(reason) } } diff --git a/macos/Sources/CleanSelection.swift b/macos/Sources/CleanSelection.swift index 72bb3d12..8146a24c 100644 --- a/macos/Sources/CleanSelection.swift +++ b/macos/Sources/CleanSelection.swift @@ -18,6 +18,10 @@ struct CleanSelection { enum LockReason: Equatable { case appOpen(appName: String) case systemBusy + /// The snapshot refused this entry, so it can never reach a plan. + /// Locked rather than hidden: the preview counted it, and silently + /// dropping a line the user was just shown reads as a miscount. + case notCleanable(reason: String) } enum CategoryState { case all, mixed, none } diff --git a/macos/Sources/CleanView.swift b/macos/Sources/CleanView.swift index 361625e8..1d077da7 100644 --- a/macos/Sources/CleanView.swift +++ b/macos/Sources/CleanView.swift @@ -130,9 +130,32 @@ struct CleanView: View { private var idleHero: some View { ToolHero(tool: .clean, title: "Clean", subtitle: Tool.clean.tagline) { PillButton(title: "Scan your Mac") { startDry() } + // The direct path this screen has always been documented to + // offer. Scanning first is the better habit, so it keeps the + // filled pill — but making review the ONLY way through means + // anyone who already trusts the engine has to sit through a + // scan they don't want. + // Goes straight to the clean — no confirmation sheet. The + // elevation prompt is still ahead of any deletion, so this is + // one deliberate press away from a system auth gate rather + // than a bare one-click delete. + PillButton(title: "Clean now", filled: false) { startDirectClean() } + .help(NSLocalizedString("Runs the engine's own cache selection without a per-item review.", comment: "")) } } + private func startDirectClean() { + trashResult = nil + screen = .hero + realFlow.reset() + // No cleanup plan: the engine chooses its own targets, so this routes + // to the plain `clean` operation rather than the reviewed one. + realFlow.start(ToolOperation( + label: NSLocalizedString("Cleaning caches", comment: ""), + arguments: ["clean"], elevated: true, + reduce: { parseTaskReport($0) }, notifyOnEnd: true)) + } + // MARK: - Scanning / result hero (2.1) /// Scanning and result share one layout — the number mounts at 0 and @@ -238,6 +261,13 @@ struct CleanView: View { } reviewList = list reviewLocked = CleanLock.lockedPaths(in: list, running: CleanLock.runningApps()) + // Entries the snapshot refused are shown locked, with the reason, and + // cannot be ticked. They used to take the whole preview down with + // them: one unrepresentable entry meant no snapshot, no Clean button, + // and an alert that blamed the preview as a whole. + for entry in reviewSnapshot?.skipped ?? [] { + reviewLocked[entry.path] = .notCleanable(reason: entry.reason) + } screen = .review } @@ -270,8 +300,11 @@ struct CleanView: View { // MARK: - The real run (permanent mode) private func runRealClean(_ selection: CleanSelection, snapshot: CleanupSnapshot) { + // Refused entries can never reach a plan — `plan(selectedPaths:)` + // rejects any path it didn't capture, which would fail the whole run. + let refused = Set(snapshot.skipped.map(\.path)) let paths = selection.list.categories.flatMap(\.items).map(\.path) - .filter { selection.isTicked($0) } + .filter { selection.isTicked($0) && !refused.contains($0) } let plan: CleanupExecutionPlan do { plan = try snapshot.plan(selectedPaths: paths) @@ -298,8 +331,9 @@ struct CleanView: View { /// space frees when Trash empties, and the run isn't in `mo history` /// — it lands in Burrow's Activity log instead. private func trashTicked(_ selection: CleanSelection, snapshot: CleanupSnapshot) { + let refused = Set(snapshot.skipped.map(\.path)) let paths = selection.list.categories.flatMap(\.items).map(\.path) - .filter { selection.isTicked($0) } + .filter { selection.isTicked($0) && !refused.contains($0) } // Refuse anything that didn't come from the dry-run enumeration. assert(Set(paths).isSubset(of: Set(selection.list.categories.flatMap(\.items).map(\.path)))) let total = selection.selectedBytes diff --git a/macos/Sources/CleanupAuthorization.swift b/macos/Sources/CleanupAuthorization.swift index 19256886..f091b7f8 100644 --- a/macos/Sources/CleanupAuthorization.swift +++ b/macos/Sources/CleanupAuthorization.swift @@ -70,16 +70,30 @@ struct CleanupExecutionPlan: Sendable, Equatable { } } - /// The shell-quoted reviewed paths, deepest-first, for the delete loop. - /// Ordering is cosmetic here — each entry is deleted as a whole tree — but - /// it keeps the transcript readable when nested caches are both ticked. - func quotedReviewedPaths() -> [String] { + /// The reviewed paths, deepest-first. + /// + /// Not cosmetic. The engine's export list routinely contains a parent and + /// its own children as separate entries — `~/Library/Caches` alongside + /// `~/Library/Caches/GeoServices` — because the writer collapses some + /// categories to a parent while others name leaves. Deleting the parent + /// first makes every nested entry vanish before its turn, and `find` then + /// exits nonzero with "No such file or directory" for work that actually + /// succeeded. Deepest-first removes the children before the parent, so + /// each entry still exists when it is reached. + /// + /// Both elevation routes MUST use this order; the helper path skipping it + /// is exactly how a fully successful clean reported "exit 1". + func orderedReviewedPaths() -> [String] { func depth(_ path: String) -> Int { path.filter { $0 == "/" }.count } - let ordered = items.map(\.identity.path).sorted { lhs, rhs in + return items.map(\.identity.path).sorted { lhs, rhs in let l = depth(lhs), r = depth(rhs) return l == r ? lhs < rhs : l > r } - return ordered.map { MoleCLI.shellQuote($0) } + } + + /// The shell-quoted form of `orderedReviewedPaths()`, for the delete loop. + func quotedReviewedPaths() -> [String] { + orderedReviewedPaths().map { MoleCLI.shellQuote($0) } } /// The complete irreversible cleanup: boundary checks, then the deletes. @@ -150,7 +164,11 @@ struct CleanupSnapshot: Sendable, Equatable { case .noApprovedRoots: return "No canonical cleanup roots are available." case .malformedPath(let p): return "The cleanup preview contained a malformed path: \(p)" case .symbolicLink(let p): return "The cleanup preview contained a symbolic link: \(p)" - case .outsideApprovedRoots(let p): return "The cleanup preview escaped its approved roots: \(p)" + case .outsideApprovedRoots(let p): + // Naming the offending path first: the old wording read as + // though the listed path were the approved root, which sent + // the reader looking in exactly the wrong place. + return "\(p) is outside the folders Burrow is allowed to clean." case .unexpectedVolume(let p): return "The cleanup preview crossed onto an unexpected volume: \(p)" case .missingPath(let p): return "A reviewed cleanup item no longer exists: \(p)" case .selectionMismatch: return "The cleanup selection no longer matches the reviewed preview." @@ -167,6 +185,25 @@ struct CleanupSnapshot: Sendable, Equatable { let list: CleanList let approvedRoots: [PinnedFileIdentity] let items: [CleanupExecutionPlan.Item] + /// Preview entries this snapshot refused, and why. + /// + /// These exist because the engine's export list collapses siblings to + /// their common PARENT: a category that removes two or more loose files + /// sitting directly inside an approved root records the root itself. The + /// `.DS_Store` sweep does exactly that — `find "$HOME" -name .DS_Store` + /// over a home folder with more than one match collapses to `$HOME` — so + /// accepting the entry would mean deleting the user's home directory. + /// + /// Refusing it is right. Refusing the WHOLE preview because of it was not: + /// one 471 KB entry blocked 1.79 GB of legitimate cleanup, and the Clean + /// button vanished with no way to proceed. Entries are independent, so a + /// refusal is now per-entry and reported rather than fatal. + let skipped: [SkippedEntry] + + struct SkippedEntry: Equatable, Sendable { + let path: String + let reason: String + } static func capture(list: CleanList, approvedRootURLs: [URL], @@ -181,27 +218,53 @@ struct CleanupSnapshot: Sendable, Equatable { var seen = Set() var captured: [CleanupExecutionPlan.Item] = [] + var skipped: [SkippedEntry] = [] + + // Every refusal below is per-entry. Skipping can only ever REMOVE + // something from the delete set, so failing this way is strictly safer + // than the previous behaviour, which was to abandon the whole plan. + func refuse(_ path: String, _ error: SnapshotError) { + skipped.append(SkippedEntry(path: path, + reason: error.errorDescription ?? "Refused.")) + } + for item in list.categories.flatMap(\.items) { let raw = item.path + // Structural corruption is still FATAL. A relative path, a NUL, a + // newline or a JSON fragment is not something the engine's export + // writer can produce, so the list isn't the list — and a preview + // that isn't trustworthy shouldn't be partially executed. Every + // check below this one is about an entry being unrepresentable, + // which is an ordinary fact about a well-formed list. guard !raw.isEmpty, raw.hasPrefix("/"), !raw.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), !raw.hasPrefix("{"), !raw.hasPrefix("[") else { throw SnapshotError.malformedPath(raw) } var lst = stat() - guard lstat(raw, &lst) == 0 else { throw SnapshotError.missingPath(raw) } - guard (lst.st_mode & S_IFMT) != S_IFLNK else { throw SnapshotError.symbolicLink(raw) } + guard lstat(raw, &lst) == 0 else { refuse(raw, .missingPath(raw)); continue } + guard (lst.st_mode & S_IFMT) != S_IFLNK else { + refuse(raw, .symbolicLink(raw)); continue + } guard let canonical = InvokingUserIdentity.canonicalPath(raw), canonical == raw else { - throw SnapshotError.symbolicLink(raw) + refuse(raw, .symbolicLink(raw)); continue } guard seen.insert(canonical).inserted else { continue } - let identity = try PinnedFileIdentity.capture(canonical) - _ = try approvedRoot(for: canonical, device: identity.device, roots: roots) + guard let identity = try? PinnedFileIdentity.capture(canonical) else { + refuse(canonical, .missingPath(canonical)); continue + } + do { + _ = try approvedRoot(for: canonical, device: identity.device, roots: roots) + } catch let error as SnapshotError { + refuse(canonical, error); continue + } captured.append(.init(identity: identity)) } + // Only a preview with NOTHING usable is fatal. Anything else stays + // cleanable, minus the entries named in `skipped`. guard !captured.isEmpty else { throw SnapshotError.selectionMismatch } return Self(id: UUID(), createdAt: now, expiresAt: now.addingTimeInterval(lifetime), - list: list, approvedRoots: roots, items: captured) + list: list, approvedRoots: roots, items: captured, skipped: skipped) } static func approvedRoot(for path: String, device: UInt64, diff --git a/macos/Sources/PrivilegedHelperClient.swift b/macos/Sources/PrivilegedHelperClient.swift index 1dfbe74a..955859c6 100644 --- a/macos/Sources/PrivilegedHelperClient.swift +++ b/macos/Sources/PrivilegedHelperClient.swift @@ -400,7 +400,7 @@ struct HelperAwareProcessPort: ProcessPort { continuation.finish() return } - reviewedPaths = plan.items.map(\.identity.path) + reviewedPaths = plan.orderedReviewedPaths() } else { reviewedPaths = [] } diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift index 7f9fe674..191deb3e 100644 --- a/macos/Tests/PrivilegedSecurityTests.swift +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -192,6 +192,9 @@ final class CleanupAuthorizationTests: XCTestCase { list: list([malformed]), approvedRootURLs: [root]), malformed) } + // A well-formed entry that simply can't be represented is SKIPPED, not + // fatal — see testOneUnrepresentableEntryDoesNotKillTheWholePreview. + // Alone in a list it leaves nothing to clean, which is still an error. let outside = FileManager.default.temporaryDirectory .appendingPathComponent("outside-\(UUID().uuidString)") try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: false) @@ -207,6 +210,81 @@ final class CleanupAuthorizationTests: XCTestCase { approvedRootURLs: [root])) } + /// The bug that broke Clean for essentially everyone. + /// + /// The engine's export writer collapses siblings to their common PARENT, + /// so a category removing two or more loose files directly inside an + /// approved root records the ROOT. `find "$HOME" -name .DS_Store` over a + /// home folder with more than one match writes `/Users/`, and almost + /// every Mac has that. Accepting it would have meant deleting the home + /// directory, so refusing is right — but refusing the whole preview took + /// the Clean button with it and blocked gigabytes of valid cleanup. + func testOneUnrepresentableEntryDoesNotKillTheWholePreview() throws { + let good = root.appendingPathComponent("cache") + try FileManager.default.createDirectory(at: good, withIntermediateDirectories: false) + + // `root.path` is the approved root itself — exactly what the collapse + // produces for a home-directory sweep. + let snapshot = try CleanupSnapshot.capture(list: list([root.path, good.path]), + approvedRootURLs: [root]) + + XCTAssertEqual(snapshot.items.map(\.identity.path), [good.path], + "the usable entry survives") + XCTAssertEqual(snapshot.skipped.map(\.path), [root.path], + "the root-collapsed entry is refused and reported") + + // And it must never reach a plan: this is the difference between + // deleting a cache directory and deleting someone's home folder. + let plan = try snapshot.plan(selectedPaths: [good.path]) + XCTAssertEqual(plan.items.map(\.identity.path), [good.path]) + XCTAssertThrowsError(try snapshot.plan(selectedPaths: [root.path, good.path]), + "a refused entry cannot be selected back in") + } + + /// Skipping must not become a way to launder a corrupt list. A path the + /// engine's writer cannot produce means the list isn't the engine's, and + /// partially executing it would be the wrong call. + func testStructurallyCorruptPreviewsAreStillFatalRatherThanSkipped() { + for corrupt in ["relative/cache", "{\"path\":\"/tmp/x\"}", "/tmp/bad\npath"] { + XCTAssertThrowsError(try CleanupSnapshot.capture( + list: list([corrupt, root.appendingPathComponent("cache").path]), + approvedRootURLs: [root]), corrupt) + } + } + + /// Why a fully successful clean reported "exit 1". + /// + /// The engine's export list routinely names a parent AND its own children + /// as separate entries. Delete the parent first and every nested entry is + /// already gone when its turn comes, so `find` exits nonzero with "No such + /// file or directory" for work that succeeded. Deepest-first means each + /// entry still exists when it is reached — and both elevation routes have + /// to use this order, since the helper path skipping it is what produced + /// the failure. + func testNestedEntriesAreOrderedDeepestFirstSoNoneVanishesBeforeItsTurn() throws { + let caches = root.appendingPathComponent("Caches") + let nested = caches.appendingPathComponent("GeoServices") + let deeper = nested.appendingPathComponent("tiles") + try FileManager.default.createDirectory(at: deeper, withIntermediateDirectories: true) + + // Parent first in the list, exactly as the engine emits it. + let snapshot = try CleanupSnapshot.capture( + list: list([caches.path, nested.path, deeper.path]), approvedRootURLs: [root]) + let plan = try snapshot.plan( + selectedPaths: [caches.path, nested.path, deeper.path]) + + XCTAssertEqual(plan.orderedReviewedPaths(), [deeper.path, nested.path, caches.path], + "a parent must never be deleted before its own listed children") + // The shell the osascript route runs is built from the same order. + // Only the delete loop matters — the boundary checks ahead of it stat + // every root and item, so searching the whole script finds those first. + let shell = plan.irreversibleCleanupShell() + let loop = String(shell[try XCTUnwrap(shell.range(of: "for p in")).lowerBound...]) + let deepIndex = try XCTUnwrap(loop.range(of: deeper.path)).lowerBound + let parentIndex = try XCTUnwrap(loop.range(of: caches.path + "'")).lowerBound + XCTAssertLessThan(deepIndex, parentIndex) + } + func testApprovedRootRejectsUnexpectedVolumeIdentity() throws { let rootIdentity = try PinnedFileIdentity.capture(root.path) XCTAssertThrowsError(try CleanupSnapshot.approvedRoot( From cf5574b252ffb7791627a81b4843eb7cef9d3fe1 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:48:50 -0700 Subject: [PATCH 10/11] fix: address review findings across the safe-release surface Verified each finding against the code rather than applying it; two were rejected on evidence and are left alone, with the reasons recorded below. Genuine defects: - The reviewed-clean header counted every locked entry but collected only .appOpen names, so a run whose sole locked entry was one the snapshot refused rendered "Close to clean another 12 GB" -- an empty app name promising that quitting something frees bytes no app holds. Restricted to .appOpen, with the neutral message when nothing is app-held. - HelperService shared ONE HelperLineSplitter between the stdout and stderr reader threads. That buffer exists to carry a partial line, so sharing it was both a data race and a splice: half a stdout line could be prefixed onto the next stderr chunk, emitting lines that appeared on neither stream. One splitter per pipe, each flushed separately. - HelperReplayGuard grew without bound. Age-based eviction is right -- a count-bounded FIFO lets a flood push out an ID that is still replayable -- but "keep everything inside the retention window" has no ceiling in a process running as root. It now refuses new IDs at a hard ceiling instead of forgetting old ones: a refused request is recoverable, a forgotten ID is replayable. - Cancelling a Sparkle update never resumed its checked continuation. Cancelling a Task does not resume one, so the session stayed registered and every `sparkleSessions.isEmpty` gate stayed shut for the rest of the launch. Cancel now settles through the existing idempotent finishOnce. - OperationFlow closed its pipe write copies only on the unelevated path, though the elevated branch hands the same two pipes to osascript and ends with readDataToEndOfFile on each -- a read that returns only once every write descriptor is gone, this parent's included. - The elevation failure named Homebrew as the trusted location, which trustedExecutable() deliberately stopped honouring; it pointed users at the one place that could never satisfy it. - safeDiagnosticLabel matched `token=` only when written tight, so `access_token = ...` and `api key=...` carried credentials into an uploaded diagnostic. Now matched by credential-name-plus-separator. - The stdio bridge took BURROWWIN_MCP_ENDPOINT unvalidated and then attached the stored bearer token to every request sent there, so any process able to set that variable redirected the bridge AND handed over the local MCP credential. Non-loopback endpoints now fall back. - Only Sec-Fetch-Site was rejected, so a request carrying just Sec-Fetch-Mode or Sec-Fetch-Dest reached the loopback server. Any fetch-metadata header now forbids. - No size ceiling existed on an update archive before it was kept and handed to ditto, which is where a small archive expands without bound. Correctness of the record, and tests that proved nothing: - SECURITY.md said four permitted Apple tools; the helper permits five. - macos-signing.md omitted SENTRY_AUTH_TOKEN, which sentry-issues.yml requires and which fails by warning rather than erroring when absent. - The staging-directory test asserted the name did not contain the literal source text "UUID().uuidString", which no path could contain, so it passed regardless. It now checks the real mkdtemp shape. - testSteps_neverInvokeAShell passed no reviewed paths, so the one step built from caller-supplied data was never examined. - The query-server request helper duplicated the token literal, and the cleanup test failed as root, where the permission denial it needs cannot happen. - TuneUp's notices and the query-server auth value were never localizable; zh-Hans already carried a translation that could not be reached. - Pressing Run with a stale cleanup plan returned silently. It now says so, and refuses only the clean so maintenance still runs. Rejected, with the evidence: - Making /Library/Logs allowsForeignOwner: false would disable the feature. That tree is root:wheel with 38 root-owned and 2 _windowserver-owned entries and none owned by the invoking user, so requiring an owner match refuses every entry. Foreign ownership is exactly why it needs true; the escalation concern applies to per-user trees, which are already false. - Admitting the replay guard before identity resolution would let anyone flood it with unvalidated IDs -- the surface the new ceiling closes -- and a full guard then fails closed on the legitimate user. Today a caller must pass the same-user identity check before it can burn an ID. --- SECURITY.md | 2 +- docs/macos-signing.md | 8 ++++- macos/HelperSources/HelperService.swift | 15 +++++++-- macos/Sources/CleanReviewView.swift | 4 +++ macos/Sources/CleanSelection.swift | 11 ++++++- .../ExternalSparkleUpdateSession.swift | 11 +++++++ macos/Sources/LaunchDiagnostics.swift | 7 ++++ macos/Sources/OperationFlow.swift | 30 +++++++++++------ .../PrivilegedHelper/HelperContract.swift | 14 +++++++- macos/Sources/SettingsView.swift | 2 +- macos/Sources/TuneUp.swift | 4 +-- macos/Sources/TuneUpView.swift | 18 +++++++++-- macos/Sources/UpdateWorkflow.swift | 26 +++++++++++++-- macos/Sources/UpdatesView.swift | 14 ++++++-- macos/Tests/CleanSelectionTests.swift | 22 +++++++++++++ macos/Tests/HelperContractTests.swift | 32 +++++++++++++++++-- macos/Tests/LaunchDiagnosticsTests.swift | 23 +++++++++++++ macos/Tests/PrivilegedSecurityTests.swift | 5 +++ macos/Tests/QueryServerTests.swift | 12 ++++--- macos/Tests/UpdateWorkflowTests.swift | 15 +++++++-- windows/Services/LocalMcpServerService.cs | 25 +++++++++++++-- .../LocalMcpServerServiceTests.cs | 6 ++-- windows/Tools/McpStdioBridge/Program.cs | 25 ++++++++++++++- 23 files changed, 289 insertions(+), 42 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 55a636a9..077ff262 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -108,7 +108,7 @@ This is the part people rightly scrutinize in cleaners. Burrow's model: bundle identifier and signing team via the XPC connection's code-signing requirement, so another local process cannot reach it or use it to raise a credential prompt. - - **It runs only your engine, or those four Apple tools.** The engine it + - **It runs only your engine, or those five Apple tools.** The engine it executes is the copy inside the app bundle, resolved relative to the helper's own path, never through `PATH` or an environment variable. Before running anything the helper copies the bundle below a fresh root-only diff --git a/docs/macos-signing.md b/docs/macos-signing.md index d1306ce8..8aaa2ff4 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -124,6 +124,7 @@ gh secret set MACOS_CERT_PASSWORD --repo caezium/Burrow gh secret set MACOS_SIGN_IDENTITY --repo caezium/Burrow gh secret set AC_API_KEY_ID --repo caezium/Burrow gh secret set AC_API_ISSUER_ID --repo caezium/Burrow +gh secret set SENTRY_AUTH_TOKEN --repo caezium/Burrow gh secret set SPARKLE_ED_PRIVATE_KEY --repo caezium/Burrow \ < /absolute/path/to/Burrow-Sparkle-Ed25519.key ``` @@ -132,6 +133,10 @@ gh secret set SPARKLE_ED_PRIVATE_KEY --repo caezium/Burrow \ scoped only to `caezium/homebrew-tap`, with repository **Contents: Read and write** permission. +`SENTRY_AUTH_TOKEN` is required by `sentry-issues.yml`, which files deduped +GitHub issues from Sentry. Without it that workflow warns and does nothing +rather than failing, so a missing value is easy to miss. + The required names are: - `MACOS_CERT_P12` @@ -142,6 +147,7 @@ The required names are: - `AC_API_KEY_P8` - `SPARKLE_ED_PRIVATE_KEY` - `TAP_PAT` +- `SENTRY_AUTH_TOKEN` `MACOS_SIGN_IDENTITY` is the full output name, including the team ID: `Developer ID Application: Name (TEAMID)`. @@ -150,7 +156,7 @@ Confirm only the names and timestamps, never their values: ```bash gh secret list --app actions --repo caezium/Burrow \ - | grep -E '^(MACOS_|AC_API_|SPARKLE_|TAP_PAT)' + | grep -E '^(MACOS_|AC_API_|SPARKLE_|TAP_PAT|SENTRY_AUTH_TOKEN)' ``` After creating or rotating `TAP_PAT`, run the manual credential check before diff --git a/macos/HelperSources/HelperService.swift b/macos/HelperSources/HelperService.swift index c7c41010..bebbbba9 100644 --- a/macos/HelperSources/HelperService.swift +++ b/macos/HelperSources/HelperService.swift @@ -432,9 +432,16 @@ final class HelperOperationRunner: @unchecked Sendable { // One reader per pipe, both draining to EOF before the exit status is // read, so no output can be lost between the last write and the reap. - let splitter = HelperLineSplitter() + // + // A splitter EACH, because it carries the partial tail of an incomplete + // line. Sharing one across both readers meant two threads mutating that + // buffer concurrently — a data race, and even when the timing was kind + // it spliced a half-written stdout line onto the front of the next + // stderr chunk, producing lines that never appeared on either stream. + let splitters = [HelperLineSplitter(), HelperLineSplitter()] let group = DispatchGroup() - for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] { + for (handle, splitter) in zip([outPipe.fileHandleForReading, errPipe.fileHandleForReading], + splitters) { group.enter() DispatchQueue.global(qos: .utility).async { // Bytes, not strings, across the pipe boundary. A read can end @@ -449,7 +456,9 @@ final class HelperOperationRunner: @unchecked Sendable { } } group.wait() - for line in splitter.flush() { emit(line) } + for splitter in splitters { + for line in splitter.flush() { emit(line) } + } process.waitUntilExit() return process.terminationStatus diff --git a/macos/Sources/CleanReviewView.swift b/macos/Sources/CleanReviewView.swift index 9079325e..de229854 100644 --- a/macos/Sources/CleanReviewView.swift +++ b/macos/Sources/CleanReviewView.swift @@ -265,6 +265,10 @@ struct CleanReviewView: View { case .notCleanable(let reason): Chip(text: NSLocalizedString("Can't clean", comment: "clean badge"), color: Brand.amber) .help(reason) + // The reason only lives in the tooltip, which VoiceOver never + // reaches — without this the row announces "Can't clean" and + // gives no way to find out why. + .accessibilityLabel(Text("\(NSLocalizedString("Can't clean", comment: "clean badge")). \(reason)")) } } diff --git a/macos/Sources/CleanSelection.swift b/macos/Sources/CleanSelection.swift index 8146a24c..a560969f 100644 --- a/macos/Sources/CleanSelection.swift +++ b/macos/Sources/CleanSelection.swift @@ -102,7 +102,16 @@ struct CleanSelection { /// "Close Helium, X to clean another N GB · M items" — the locked /// upside, summed over app-locked items. nil when nothing is locked. var lockedSummary: (appNames: [String], bytes: Int64, itemCount: Int)? { - let lockedItems = list.categories.flatMap(\.items).filter { locked[$0.path] != nil } + // Only .appOpen entries belong in this line. It reads "Close X to clean + // another N" — a promise that quitting an app reclaims those bytes, + // which is false for a path a system service holds and outright wrong + // for one the snapshot refused, since no action of the user's frees it. + // Counting those inflated the total and, when they were the ONLY locked + // entries, produced "Close to clean another 12 GB" with no app named. + let lockedItems = list.categories.flatMap(\.items).filter { + if case .appOpen? = locked[$0.path] { return true } + return false + } guard !lockedItems.isEmpty else { return nil } var names: [String] = [] for item in lockedItems { diff --git a/macos/Sources/ExternalSparkleUpdateSession.swift b/macos/Sources/ExternalSparkleUpdateSession.swift index 43b7b604..5dd948c7 100644 --- a/macos/Sources/ExternalSparkleUpdateSession.swift +++ b/macos/Sources/ExternalSparkleUpdateSession.swift @@ -42,6 +42,17 @@ final class ExternalSparkleUpdateSession: NSObject, SPUUpdaterDelegate { ) } + /// Abandon a session the user cancelled, settling it through the SAME + /// finishOnce path every other exit uses — so the awaiting continuation + /// resumes exactly once and the caller's bookkeeping is cleared by the + /// existing onFinish handler. Without this, cancelling left the + /// continuation suspended forever: the task was cancelled but a checked + /// continuation is not resumed by cancellation, so the session stayed + /// registered and every `sparkleSessions.isEmpty` gate stayed shut. + func cancelSession() { + finishOnce() + } + func begin() -> UpdateFailure? { transition(.checking) do { diff --git a/macos/Sources/LaunchDiagnostics.swift b/macos/Sources/LaunchDiagnostics.swift index b20c5044..80a80e70 100644 --- a/macos/Sources/LaunchDiagnostics.swift +++ b/macos/Sources/LaunchDiagnostics.swift @@ -437,6 +437,13 @@ enum DiagnosticPrivacy { "secret", "sk-live", "token=", "token:", "x-api-key", ] guard !sensitiveMarkers.contains(where: lowered.contains) else { return nil } + // A credential NAME followed by `:` or `=`, however it is spaced or + // spelled. The literal markers above only catch `token=`/`token:` + // written tight, so `access_token = abc123` and `api key=abc123` both + // walked straight through into a diagnostic that gets uploaded. + guard value.range( + of: #"(?i)(api[ _-]?key|access[ _-]?token|auth[ _-]?token|refresh[ _-]?token|token|secret|password)\s*[:=]"#, + options: .regularExpression) == nil else { return nil } let allowed = CharacterSet.alphanumerics.union( CharacterSet(charactersIn: " _.$:+<>()[]{}?!,@#&'`~-=") ) diff --git a/macos/Sources/OperationFlow.swift b/macos/Sources/OperationFlow.swift index 048e6041..e950cf8b 100644 --- a/macos/Sources/OperationFlow.swift +++ b/macos/Sources/OperationFlow.swift @@ -201,8 +201,14 @@ final class OperationFlow: ObservableObject { case .path(let p): exe = p } guard let executable = exe else { + // Elevation resolves ONLY the sealed copy inside the app bundle — + // trustedExecutable() deliberately dropped the Homebrew fallback, + // so naming Homebrew here sent people to the one location that + // could never satisfy this. A missing bundled engine is fixed by + // reinstalling the app, which is what installCommand documents. state = .finished(.failed(op.elevated - ? "mo not found in a trusted location (Homebrew)" : "mo not found")) + ? "The bundled engine is missing. Reinstall Burrow to restore it: \(MoleCLI.installCommand)" + : "mo not found")) return } @@ -576,15 +582,19 @@ struct SystemProcessPort: ProcessPort { do { try t.run() - if !spec.elevated { - // Process inherits duplicated write descriptors during - // spawn; the parent must close its copies so the readers - // observe EOF after the child exits. Keeping these handles - // open can strand the stream forever after a timeout even - // though terminate() successfully killed the child. - try? outPipe.fileHandleForWriting.close() - try? errPipe.fileHandleForWriting.close() - } + // Process inherits duplicated write descriptors during spawn; + // the parent must close its copies so the readers observe EOF + // after the child exits. Keeping these handles open can strand + // the stream forever after a timeout even though terminate() + // successfully killed the child. + // + // Both routes, not just the unelevated one: the elevated branch + // hands the SAME two pipes to osascript, and its termination + // handler ends with readDataToEndOfFile on each — a read that + // only returns once every write descriptor is gone, this + // parent's included. + try? outPipe.fileHandleForWriting.close() + try? errPipe.fileHandleForWriting.close() // Armed only after a successful spawn (a suspended source // must never be cancelled/deallocated). if let timeout = spec.timeout { diff --git a/macos/Sources/PrivilegedHelper/HelperContract.swift b/macos/Sources/PrivilegedHelper/HelperContract.swift index 29233b9e..1c215226 100644 --- a/macos/Sources/PrivilegedHelper/HelperContract.swift +++ b/macos/Sources/PrivilegedHelper/HelperContract.swift @@ -650,12 +650,24 @@ final class HelperReplayGuard: @unchecked Sendable { return seen.count } - /// `true` the first time an ID is presented, `false` for every repeat. + /// The hard memory ceiling. Refusing to forget a still-replayable ID is the + /// right call, but it cannot mean growing without limit inside a process + /// running as root: a caller sending fresh IDs would otherwise enlarge the + /// set for the whole retention window. Past this many live entries the + /// guard stops admitting instead of stops remembering, so the failure is a + /// refused request rather than a forgotten ID that could then be replayed. + private var ceiling: Int { capacity * 16 } + + /// `true` the first time an ID is presented, `false` for every repeat — + /// and `false` once the set is full, which fails the request closed. func admit(_ operationID: String) -> Bool { lock.lock(); defer { lock.unlock() } let moment = now() evictExpired(before: moment.addingTimeInterval(-retention)) guard !seen.contains(operationID) else { return false } + // Checked AFTER eviction, so a full set means genuinely fresh entries + // rather than accumulated expired ones. + guard seen.count < ceiling else { return false } seen.insert(operationID) order.append((operationID, moment)) return true diff --git a/macos/Sources/SettingsView.swift b/macos/Sources/SettingsView.swift index 5d79d95f..a63d8ce7 100644 --- a/macos/Sources/SettingsView.swift +++ b/macos/Sources/SettingsView.swift @@ -625,7 +625,7 @@ struct SettingsView: View { section("Local HTTP query server", "antenna.radiowaves.left.and.right") { toggleRow("Enable HTTP query server", isOn: $queryServerEnabled) { Store.queryServerEnabled = $0 } infoRow("Endpoint", "127.0.0.1:\(Store.queryServerPort)") - infoRow("Authentication", "Bearer token required") + infoRow("Authentication", NSLocalizedString("Bearer token required", comment: "query server auth")) footnote("Optional REST surface for dashboards or curl: /health, /info, /snapshot, /metrics over localhost. Every request needs the per-install token; retrieve it locally with `defaults read dev.caezium.Burrow query_auth_token`. Separate from the MCP stdio server above; toggle + port changes take effect after a relaunch.") } diff --git a/macos/Sources/TuneUp.swift b/macos/Sources/TuneUp.swift index 648755eb..745f4f10 100644 --- a/macos/Sources/TuneUp.swift +++ b/macos/Sources/TuneUp.swift @@ -19,8 +19,8 @@ enum TuneUp { var requiresIrreversibleConsent: Bool { includesClean } var notice: String { includesClean - ? "Clean caches & junk permanently deletes the reviewed cache files. They do not go to the Trash and cannot be recovered. Each elevated step asks for your password separately." - : "Maintenance does not run the cache-deletion step. Each elevated step asks for your password separately." + ? NSLocalizedString("Clean caches & junk permanently deletes the reviewed cache files. They do not go to the Trash and cannot be recovered. Each elevated step asks for your password separately.", comment: "tune-up notice") + : NSLocalizedString("Maintenance does not run the cache-deletion step. Each elevated step asks for your password separately.", comment: "tune-up notice") } func permitsRun(irreversibleConsent: Bool) -> Bool { diff --git a/macos/Sources/TuneUpView.swift b/macos/Sources/TuneUpView.swift index 49debf5c..efb1656e 100644 --- a/macos/Sources/TuneUpView.swift +++ b/macos/Sources/TuneUpView.swift @@ -408,8 +408,22 @@ struct TuneUpView: View { guard let snap = model.snapshot else { return } var steps: [SafeStep] = [] if includeClean, !snap.cleanableText.isEmpty { - guard let pendingCleanupPlan, pendingCleanupPlan.validateForLaunch() else { return } - steps.append(.clean(pendingCleanupPlan)) + if let pendingCleanupPlan, pendingCleanupPlan.validateForLaunch() { + steps.append(.clean(pendingCleanupPlan)) + } else { + // The plan went stale between the review sheet and here. Say so + // — returning silently left the user pressing Run and watching + // nothing happen — and refuse only the CLEAN: optimize has no + // dependency on this plan, so aborting the whole run would + // withhold maintenance that is still perfectly safe to do. + let alert = NSAlert() + alert.messageText = NSLocalizedString("The cleanup preview can't be authorized", comment: "") + alert.informativeText = NSLocalizedString( + "Nothing will be removed until you rescan. Any other maintenance you picked still runs.", + comment: "") + alert.alertStyle = .warning + alert.runModalQuiet() + } } if includeOptimize, !snap.optimizeAreas.isEmpty { steps.append(.optimize) } guard !steps.isEmpty else { return } diff --git a/macos/Sources/UpdateWorkflow.swift b/macos/Sources/UpdateWorkflow.swift index 4c7a69f6..a7dbc473 100644 --- a/macos/Sources/UpdateWorkflow.swift +++ b/macos/Sources/UpdateWorkflow.swift @@ -372,17 +372,16 @@ struct BundleUpdateIdentity: Equatable, Sendable { let staticCode else { return nil } // Validate every architecture in a universal app. Checking only the // host architecture could otherwise admit a tampered alternate slice. - let checkAllArchitectures = SecCSFlags(rawValue: 1 << 0) + let checkAllArchitectures = SecCSFlags(rawValue: kSecCSCheckAllArchitectures) // A requirement we could not build is treated as "refuse", never as // "skip the check" — the nil-requirement call is the weak one. let valid = appleAnchoredRequirement.map { SecStaticCodeCheckValidity(staticCode, checkAllArchitectures, $0) == errSecSuccess } ?? false var information: CFDictionary? - let signingInformation: UInt32 = 0x2 guard SecCodeCopySigningInformation( staticCode, - SecCSFlags(rawValue: signingInformation), + SecCSFlags(rawValue: kSecCSSigningInformation), &information ) == errSecSuccess, let values = information as? [String: Any], @@ -641,6 +640,12 @@ enum ElectronPostReplacementDecision: Equatable, Sendable { } enum ElectronReplacementInstaller { + /// Ceiling on an update archive we will keep and expand. Burrow's own + /// releases are tens of megabytes, so 512 MB clears any plausible build + /// while still refusing an archive whose only purpose is to fill the disk + /// or explode under ditto. + static let maximumArchiveBytes: Int64 = 512 * 1024 * 1024 + static func stagingDirectoryIdentity(at url: URL) -> BundleFileIdentity? { PrivateUpdateDirectory.directoryIdentity(at: url) } @@ -1195,6 +1200,21 @@ enum ElectronReplacementInstaller { retryable: http.statusCode == 408 || http.statusCode == 429 || (500...599).contains(http.statusCode) ) } + // Refuse an implausible archive BEFORE it is kept, hashed, or — + // the part that actually matters — handed to ditto, where a small + // zip expands into an arbitrarily large tree. URLSession has + // already written the body by the time this returns, so this does + // not bound what transits the network; it bounds what we retain + // and what we agree to expand. Burrow's own archives are tens of + // megabytes, so the ceiling is far above any real release. + if http.expectedContentLength > Self.maximumArchiveBytes { + throw UpdateFailure.invalidResponse + } + let downloadedBytes = (try? FileManager.default.attributesOfItem( + atPath: temporaryDownload.path)[.size] as? Int64) ?? nil + if let downloadedBytes, downloadedBytes > Self.maximumArchiveBytes { + throw UpdateFailure.invalidResponse + } try FileManager.default.moveItem(at: temporaryDownload, to: archive) guard try sha512(of: archive) == descriptor.sha512 else { throw UpdateFailure.verification(.invalidSignature) diff --git a/macos/Sources/UpdatesView.swift b/macos/Sources/UpdatesView.swift index a46e790a..6249e5d3 100644 --- a/macos/Sources/UpdatesView.swift +++ b/macos/Sources/UpdatesView.swift @@ -591,7 +591,6 @@ final class UpdatesModel: ObservableObject { var results: [AppUpdateCheckResult] = [] await withTaskGroup(of: AppUpdateCheckResult.self) { group in var iterator = items.makeIterator() - var inFlight = 0 func enqueue(_ item: AppUpdateItem) { group.addTask { await Self.checkWithRetry( @@ -602,7 +601,13 @@ final class UpdatesModel: ObservableObject { ) } } - while inFlight < 6, let next = iterator.next() { enqueue(next); inFlight += 1 } + // Prime the window; the `for await` below keeps exactly one + // task in flight per completion, so the count never needed + // tracking after this point. + for _ in 0..<6 { + guard let next = iterator.next() else { break } + enqueue(next) + } for await result in group { results.append(result) if let next = iterator.next() { enqueue(next) } @@ -904,6 +909,11 @@ final class UpdatesModel: ObservableObject { func cancel(_ item: AppUpdateItem) { guard !updateAllRunning, updateAllTask == nil else { return } cancelAppTask(for: item.id) + // Cancelling the task does NOT resume a checked continuation, so a + // Sparkle session left registered here would keep every + // `sparkleSessions.isEmpty` gate shut for the rest of the launch. + // Its onFinish handler does the removal and the single resume. + sparkleSessions[item.id]?.session.cancelSession() stagedElectronUpdates.removeValue(forKey: item.id)?.discard() phases[item.id] = .failed(.cancelled) } diff --git a/macos/Tests/CleanSelectionTests.swift b/macos/Tests/CleanSelectionTests.swift index bb1f72c2..d6084097 100644 --- a/macos/Tests/CleanSelectionTests.swift +++ b/macos/Tests/CleanSelectionTests.swift @@ -94,4 +94,26 @@ final class CleanSelectionTests: XCTestCase { func testLockedSummary_nilWhenNothingLocked() { XCTAssertNil(CleanSelection(list: makeList(), locked: [:]).lockedSummary) } + + /// The line says "Close X to clean another N". An entry the snapshot refused + /// is not something closing an app recovers, so it must not appear in the + /// count OR the byte total — and when it is the only locked entry there is + /// no advice to give, so the header falls back to the neutral message + /// instead of naming no app at all. + func testLockedSummary_ignoresEntriesNoAppIsHolding() { + let refused = CleanSelection( + list: makeList(), + locked: ["/u/Library/Caches/net.imput.helium": .notCleanable(reason: "outside the approved roots")]) + XCTAssertNil(refused.lockedSummary) + + let mixed = CleanSelection( + list: makeList(), + locked: ["/u/Library/Caches/net.imput.helium": .appOpen(appName: "Helium"), + "/u/.npm/_cacache": .notCleanable(reason: "outside the approved roots")]) + let summary = mixed.lockedSummary + XCTAssertEqual(summary?.appNames, ["Helium"]) + XCTAssertEqual(summary?.itemCount, 1, "the refused entry must not inflate the count") + XCTAssertEqual(summary?.bytes, CleanList.parseSize("400MB"), + "nor the bytes closing Helium would actually free") + } } diff --git a/macos/Tests/HelperContractTests.swift b/macos/Tests/HelperContractTests.swift index fe2fabfe..1660c74e 100644 --- a/macos/Tests/HelperContractTests.swift +++ b/macos/Tests/HelperContractTests.swift @@ -202,8 +202,13 @@ final class HelperContractTests: XCTestCase { /// which put a command string in front of a root shell parser. func testSteps_neverInvokeAShell() { let shells = ["/bin/sh", "/bin/bash", "/bin/zsh", "/usr/bin/env"] + // Give the reviewed clean its path list, as the sibling argv test does. + // Without one it resolves to no steps, so the `find` step — the only + // one built from caller-supplied data, and thus the one most worth + // checking for a shell — was never actually examined here. + let reviewedPaths = ["/Users/henry/Library/Caches/example"] for operation in HelperOperation.allCases { - for step in operation.steps(interface: "en0") { + for step in operation.steps(interface: "en0", reviewedPaths: reviewedPaths) { if case .system(let path) = step.executable { XCTAssertFalse(shells.contains(path), "\(operation) must not run a shell") XCTAssertTrue(HelperSystemTool.all.contains(path), @@ -424,7 +429,10 @@ final class HelperContractTests: XCTestCase { /// `capacity` fresh IDs and the ID you wanted to replay is forgotten, which /// turns the memory bound into the replay bypass it was meant to prevent. func testReplayGuard_floodOfFreshIDsCannotEvictAStillReplayableID() { - let guardian = HelperReplayGuard(capacity: 3) + // Capacity 8 still makes the flood 8x the count bound, which is the + // point; it just stays clear of the hard ceiling exercised below, so + // this test fails only for the reason it is about. + let guardian = HelperReplayGuard(capacity: 8) let victim = UUID().uuidString XCTAssertTrue(guardian.admit(victim)) for _ in 0..<64 { XCTAssertTrue(guardian.admit(UUID().uuidString)) } @@ -433,6 +441,26 @@ final class HelperContractTests: XCTestCase { "an ID inside the retention window must never become replayable") } + /// Age-based eviction alone leaves the set unbounded: every ID inside the + /// retention window is kept, so a caller sending fresh IDs grows a root + /// process's memory for an hour. The ceiling stops ADMITTING rather than + /// starting to forget — a refused request is recoverable, a forgotten ID + /// is replayable. + func testReplayGuard_failsClosedAtTheMemoryCeilingRatherThanForgetting() { + let guardian = HelperReplayGuard(capacity: 2) // ceiling = 32 + let victim = UUID().uuidString + XCTAssertTrue(guardian.admit(victim)) + while guardian.count < 32 { + XCTAssertTrue(guardian.admit(UUID().uuidString)) + } + + XCTAssertFalse(guardian.admit(UUID().uuidString), + "a full guard must refuse new work, not make room") + XCTAssertFalse(guardian.admit(victim), + "and must still refuse the replay it was holding") + XCTAssertEqual(guardian.count, 32, "a refused ID is not recorded") + } + /// Memory is still bounded, just by time rather than by count: entries are /// forgotten once they are far older than any authorization could still be /// valid for, so nothing usable is ever discarded. diff --git a/macos/Tests/LaunchDiagnosticsTests.swift b/macos/Tests/LaunchDiagnosticsTests.swift index 6aeeae69..a0cf26ec 100644 --- a/macos/Tests/LaunchDiagnosticsTests.swift +++ b/macos/Tests/LaunchDiagnosticsTests.swift @@ -469,4 +469,27 @@ final class StatusItemGuardFalsePositiveTests: XCTestCase { XCTAssertNil(LaunchRecovery.reason(environment: environment(build: "25F84"), previous: previous)) } + + // MARK: - Credential-shaped labels + + /// The literal marker list matched `token=` only when written tight, so a + /// space either side of the separator — or inside the name — was enough to + /// carry a live credential into an uploaded diagnostic. + func testSafeDiagnosticLabel_rejectsCredentialsWhateverTheSpacing() { + for value in ["access_token = abc123", "api key=abc123", "API_KEY : abc123", + "auth-token=abc123", "refresh token = abc123", "token=abc123", + "Secret = hunter2", "password:hunter2"] { + XCTAssertNil(DiagnosticPrivacy.safeDiagnosticLabel(value), + "\(value) must never reach a diagnostic") + } + } + + /// And it still keeps the symbol labels it exists to preserve — a rule that + /// rejects everything would be just as useless as one that rejects nothing. + func testSafeDiagnosticLabel_keepsOrdinarySymbolNames() { + for value in ["NSStatusItem", "-[BurrowApp applicationDidFinishLaunching:]", + "Swift.String.init(cString:)", "main"] { + XCTAssertEqual(DiagnosticPrivacy.safeDiagnosticLabel(value), value) + } + } } diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift index 191deb3e..97f3ba6e 100644 --- a/macos/Tests/PrivilegedSecurityTests.swift +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -421,6 +421,11 @@ final class CleanupAuthorizationTests: XCTestCase { } func testIrreversibleCleanupContinuesPastOneFailingEntryAndReportsFailure() throws { + // The failure this test needs is a permission denial, and root has no + // permissions to deny — as uid 0 the "undeletable" entry deletes fine + // and the test would fail for a reason that isn't a defect. + try XCTSkipIf(getuid() == 0, "the blocked entry is only blocked for a non-root user") + let good = root.appendingPathComponent("removable") let blocked = root.appendingPathComponent("blocked") let locked = blocked.appendingPathComponent("locked") diff --git a/macos/Tests/QueryServerTests.swift b/macos/Tests/QueryServerTests.swift index ddfffa3e..24b33ceb 100644 --- a/macos/Tests/QueryServerTests.swift +++ b/macos/Tests/QueryServerTests.swift @@ -84,24 +84,28 @@ final class QueryServerTests: XCTestCase { private func request(_ target: String, method: String = "GET", host: String? = "127.0.0.1:9277", - credential: String? = "test-only-query-credential", + credential: String? = nil, + omitCredential: Bool = false, extraHeaders: [String] = []) -> String { var lines = ["\(method) \(target) HTTP/1.1"] if let host { lines.append("Host: \(host)") } - if let credential { lines.append("Authorization: Bearer \(credential)") } + // nil means "the valid token", derived from the one symbol rather than + // a second copy of the literal that could drift out of step with it. + // Sending NO Authorization header is now spelled omitCredential: true. + if !omitCredential { lines.append("Authorization: Bearer \(credential ?? token)") } lines.append(contentsOf: extraHeaders) return lines.joined(separator: "\r\n") + "\r\n\r\n" } func testRoute_requiresValidBearerCredential() { - let missing = server.route(request("/health", credential: nil)) + let missing = server.route(request("/health", omitCredential: true)) XCTAssertEqual(missing.statusCode, 401) XCTAssertFalse(missing.body.contains("\"ok\":true")) let wrong = server.route(request("/health", credential: "wrong")) XCTAssertEqual(wrong.statusCode, 401) - let queryStringLeak = server.route(request("/health?token=\(token)", credential: nil)) + let queryStringLeak = server.route(request("/health?token=\(token)", omitCredential: true)) XCTAssertEqual(queryStringLeak.statusCode, 401, "credentials in URLs must not bypass the Authorization header gate") diff --git a/macos/Tests/UpdateWorkflowTests.swift b/macos/Tests/UpdateWorkflowTests.swift index 32d6614c..16e7758d 100644 --- a/macos/Tests/UpdateWorkflowTests.swift +++ b/macos/Tests/UpdateWorkflowTests.swift @@ -114,8 +114,19 @@ final class UpdateWorkflowTests: XCTestCase { } XCTAssertNotEqual(first, second) - XCTAssertFalse(first.lastPathComponent.contains("UUID().uuidString")) - XCTAssertFalse(second.lastPathComponent.contains("UUID().uuidString")) + // These used to assert the name did not contain the literal source text + // "UUID().uuidString", which no filesystem path could ever contain — so + // they passed no matter what create() produced. Assert the real mkdtemp + // shape instead: the fixed prefix, and six substituted template + // characters with no X left unreplaced. + for url in [first, second] { + let name = url.lastPathComponent + XCTAssertTrue(name.hasPrefix("BurrowUpdate."), + "unexpected staging name: \(name)") + let suffix = name.dropFirst("BurrowUpdate.".count) + XCTAssertEqual(suffix.count, 6, "mkdtemp substitutes exactly the six Xs: \(name)") + XCTAssertFalse(suffix.contains("X"), "an unsubstituted template leaked: \(name)") + } var firstStat = stat() var secondStat = stat() XCTAssertEqual(lstat(first.path, &firstStat), 0) diff --git a/windows/Services/LocalMcpServerService.cs b/windows/Services/LocalMcpServerService.cs index 2e802c4a..77b72e59 100644 --- a/windows/Services/LocalMcpServerService.cs +++ b/windows/Services/LocalMcpServerService.cs @@ -258,7 +258,7 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT context.Request.RemoteEndPoint?.Address, context.Request.UserHostName, context.Request.Headers["Origin"] ?? context.Request.Headers["Referer"], - context.Request.Headers["Sec-Fetch-Site"], + HasFetchMetadata(context.Request.Headers), context.Request.Headers["Authorization"], _settingsService.Current.HttpServerAuthToken, _activePort, @@ -1382,11 +1382,26 @@ private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode response.Close(); } + /// True when the request carries any Sec-Fetch-* header, i.e. a + /// browser built it. The values are set by the user agent and cannot be + /// forged by page script, so their mere presence is the signal. + internal static bool HasFetchMetadata(System.Collections.Specialized.NameValueCollection headers) + { + foreach (var key in headers.AllKeys) + { + if (key is not null && key.StartsWith("Sec-Fetch-", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + internal static LocalRequestDecision EvaluateRequest( IPAddress? remoteAddress, string? host, string? origin, - string? secFetchSite, + bool hasFetchMetadata, string? authorization, string expectedToken, int expectedPort, @@ -1395,7 +1410,11 @@ internal static LocalRequestDecision EvaluateRequest( string? contentType, long contentLength) { - if (!IsLoopback(remoteAddress) || !IsAllowedHost(host, expectedPort) || origin is not null || secFetchSite is not null) + // ANY fetch-metadata header means a browser built this request. Only + // Sec-Fetch-Site was checked before, so a request carrying just + // Sec-Fetch-Mode or Sec-Fetch-Dest reached a loopback server that any + // local process — including a page's helper — can address. + if (!IsLoopback(remoteAddress) || !IsAllowedHost(host, expectedPort) || origin is not null || hasFetchMetadata) { return LocalRequestDecision.Forbidden; } diff --git a/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs b/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs index 719d5268..c1416898 100644 --- a/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs +++ b/windows/Tests/BurrowWin.Tests/LocalMcpServerServiceTests.cs @@ -20,7 +20,7 @@ public void RequestGate_RequiresCredentialAndRejectsHostOrBrowserRequests() Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, host: null)); Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, origin: "http://localhost:3000")); Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, origin: "https://example.com")); - Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, secFetchSite: "cross-site")); + Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, hasFetchMetadata: true)); Assert.Equal(LocalRequestDecision.Forbidden, EvaluateRequest(token, remoteAddress: IPAddress.Parse("192.168.1.10"))); } @@ -196,7 +196,7 @@ private static LocalRequestDecision EvaluateRequest( IPAddress? remoteAddress = null, string? host = "127.0.0.1:9277", string? origin = null, - string? secFetchSite = null, + bool hasFetchMetadata = false, bool includeCredential = true, string? authorization = null, string path = "/health", @@ -212,7 +212,7 @@ private static LocalRequestDecision EvaluateRequest( remoteAddress ?? IPAddress.Loopback, host, origin, - secFetchSite, + hasFetchMetadata, authorization, expectedToken, LocalMcpServerService.DefaultPort, diff --git a/windows/Tools/McpStdioBridge/Program.cs b/windows/Tools/McpStdioBridge/Program.cs index adeed5a0..aa808af5 100644 --- a/windows/Tools/McpStdioBridge/Program.cs +++ b/windows/Tools/McpStdioBridge/Program.cs @@ -15,14 +15,37 @@ internal static class Program WriteIndented = false }; + /// Only an http/https URI addressed to this machine may receive the + /// stored credential. Uri.IsLoopback covers localhost, 127.0.0.0/8 and ::1. + private static bool IsLocalMcpEndpoint(string? value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + return false; + } + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + return uri.IsLoopback; + } + private static async Task Main() { var stored = ReadConnectionSettings(); var endpoint = Environment.GetEnvironmentVariable("BURROWWIN_MCP_ENDPOINT"); - if (string.IsNullOrWhiteSpace(endpoint)) + if (string.IsNullOrWhiteSpace(endpoint) || !IsLocalMcpEndpoint(endpoint)) { endpoint = stored.Endpoint ?? DefaultEndpoint; } + // The stored value gets the same treatment: whatever this ends up + // being, the local bearer token below is attached to every request + // sent to it, so an endpoint that is not loopback would hand Burrow's + // MCP credential to whoever set it. Fall back rather than trust it. + if (!IsLocalMcpEndpoint(endpoint)) + { + endpoint = DefaultEndpoint; + } var authToken = Environment.GetEnvironmentVariable("BURROWWIN_MCP_TOKEN"); if (string.IsNullOrWhiteSpace(authToken)) { From aa94e78cce8f7bdf71a57d34f13b6b1bd5d78fb8 Mon Sep 17 00:00:00 2001 From: Henry Zhang <113233555+caezium@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:07:37 +0800 Subject: [PATCH 11/11] test(macos): stop conductor tests depending on what the build staged (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(macos): stop conductor tests depending on what the build staged Six tests asserted that no `burrow` conductor was bundled, without ever saying so. Resources/burrow only appears when the vendor/burrow-cli submodule is checked out -- which a developer must do for the Network, Orphans and Photos panes to work at all -- so the suite was green on CI, where actions/checkout fetches no submodules, and red on a correctly-configured checkout, for reasons unrelated to the code under test. BurrowConductor now resolves its sidecars through an injectable `resourceDirectory` instead of reading Bundle.main directly, and ConductorBundleFixture stages a temporary directory that either is empty or holds an executable stub. Each test now declares which build it exercises. Verified green both with the conductor bundled and with it removed. Two of those tests were not testing what they claimed. Streaming is ON by default -- `streamingEnabled` returns true when the switch is unset, as its own comment says -- so `testStreamOverride_offByDefault_keepsDirectEngine` passed only because streamOverride bailed at its last guard, the executable lookup. It asserted nothing about the switch and would have kept passing had the default flipped either way. It is now three tests: no conductor falls back, a bundled conductor routes through by default, and the documented kill-switch actually kills it. testMissingExecutableFailsBeforeSpawn had the same shape -- with a conductor staged, streamOverride supplies an executable before resolveMo is consulted, so the branch under test was unreachable. Adds the conductor-present coverage that did not exist: bad arguments still surface as badArguments rather than a conductor error, and a non-executable file named `burrow` does not count as one. * test(macos): stop the conductor tests editing real preferences The streaming-switch tests wrote and then REMOVED `BurrowStreamViaConductor`, and the suite runs against UserDefaults for the real app domain — so running the tests erased a kill-switch a developer had genuinely set. They now save the prior value, absent or not, and restore it. Two assertions also proved less than they looked: the bad-arguments check matched substrings of `String(describing:)` rather than the error, so a different error carrying the same words would have passed, and the non-executable fixture never checked that its file was actually created. --- macos/Sources/BurrowConductor.swift | 17 ++++- macos/Tests/BurrowEnvelopeTests.swift | 64 ++++++++++++++++-- macos/Tests/ConductorBundleFixture.swift | 55 ++++++++++++++++ macos/Tests/MCPConductorToolsTests.swift | 82 ++++++++++++++++++------ macos/Tests/OperationFlowTests.swift | 18 ++++-- 5 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 macos/Tests/ConductorBundleFixture.swift diff --git a/macos/Sources/BurrowConductor.swift b/macos/Sources/BurrowConductor.swift index c771807a..ce488470 100644 --- a/macos/Sources/BurrowConductor.swift +++ b/macos/Sources/BurrowConductor.swift @@ -20,10 +20,21 @@ enum BurrowConductor { // MARK: - Resolution + /// Where the bundled sidecars are looked up. Production reads the app bundle; tests point it + /// at a directory they control. + /// + /// This is a seam rather than a direct `Bundle.main` read because the "no conductor bundled" + /// behaviour has to be *chosen* by a test, not inherited from whatever the build happened to + /// stage. Resources/burrow only exists when the vendor/burrow-cli submodule is checked out — + /// which a developer must do for the Network, Orphans and Photos panes to work at all — so + /// tests that simply assumed it was absent went red on a correctly-configured checkout while + /// passing on CI, where actions/checkout fetches no submodules. + static var resourceDirectory: () -> URL? = { Bundle.main.resourceURL } + /// The bundled conductor binary, or nil if this build didn't ship one — callers then fall /// back to the direct engine (MoEngine). static func executableURL() -> URL? { - guard let res = Bundle.main.resourceURL else { return nil } + guard let res = resourceDirectory() else { return nil } let burrow = res.appendingPathComponent("burrow") return FileManager.default.isExecutableFile(atPath: burrow.path) ? burrow : nil } @@ -31,7 +42,7 @@ enum BurrowConductor { /// The bundled engine directory the conductor should target (Resources/engine, from /// bundle-engine.sh), or nil if the engine isn't bundled either. static func engineDir() -> URL? { - guard let res = Bundle.main.resourceURL else { return nil } + guard let res = resourceDirectory() else { return nil } let dir = res.appendingPathComponent("engine") var isDir: ObjCBool = false let exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) @@ -43,7 +54,7 @@ enum BurrowConductor { /// back to a `$BURROW_FCLONES`/PATH fclones, and if none exists the Duplicates pane shows /// "fclones not found". static func fclonesURL() -> URL? { - guard let res = Bundle.main.resourceURL else { return nil } + guard let res = resourceDirectory() else { return nil } let fclones = res.appendingPathComponent("fclones") return FileManager.default.isExecutableFile(atPath: fclones.path) ? fclones : nil } diff --git a/macos/Tests/BurrowEnvelopeTests.swift b/macos/Tests/BurrowEnvelopeTests.swift index bb160a90..b835ca4d 100644 --- a/macos/Tests/BurrowEnvelopeTests.swift +++ b/macos/Tests/BurrowEnvelopeTests.swift @@ -139,15 +139,65 @@ final class BurrowEnvelopeTests: XCTestCase { ["optimize", "--apply", "--stream"]) } - func testStreamOverride_offByDefault_keepsDirectEngine() { - // The switch is off unless explicitly set → no override, the direct mo path is preserved. - XCTAssertNil(BurrowConductor.streamOverride(moArgs: ["clean"], elevated: false)) + /// No conductor staged → no override, whatever the switch says, because there is nothing to + /// route to. This is the fallback every call site depends on. + func testStreamOverride_withoutBundledConductor_keepsDirectEngine() { + ConductorBundleFixture.withConductor(present: false) { + XCTAssertNil(BurrowConductor.streamOverride(moArgs: ["clean"], elevated: false)) + } + } + + /// Streaming is ON by default (`BurrowStreamViaConductor` unset), so a build that bundled the + /// conductor routes `clean` through it. + /// + /// This used to be asserted the other way round, as "off by default keeps the direct engine". + /// It only ever passed because the test host bundled no conductor and `streamOverride` bailed + /// at its last guard — so the assertion held for a reason unrelated to the switch, and would + /// have kept holding had the default flipped either way. + func testStreamOverride_withBundledConductor_routesThroughItByDefault() { + withStreamSwitch(nil) { + ConductorBundleFixture.withConductor(present: true) { + let override = BurrowConductor.streamOverride(moArgs: ["clean"], elevated: false) + XCTAssertEqual(override?.arguments, ["clean", "--apply", "--stream"]) + XCTAssertEqual(URL(fileURLWithPath: override?.executable ?? "").lastPathComponent, "burrow") + } + } + } + + /// The documented kill-switch has to actually kill it. + func testStreamOverride_killSwitchKeepsDirectEngineEvenWithConductorBundled() { + withStreamSwitch(false) { + ConductorBundleFixture.withConductor(present: true) { + XCTAssertNil(BurrowConductor.streamOverride(moArgs: ["clean"], elevated: false)) + } + } } func testStreamOverride_elevatedAlwaysDirect() { - UserDefaults.standard.set(true, forKey: "BurrowStreamViaConductor") - defer { UserDefaults.standard.removeObject(forKey: "BurrowStreamViaConductor") } - // Elevated runs (osascript, fresh env) stay on mo even with the switch on. - XCTAssertNil(BurrowConductor.streamOverride(moArgs: ["clean"], elevated: true)) + // Elevated runs (osascript, fresh env) stay on mo even with the switch on — and this is + // only meaningful on a build that HAS a conductor to be tempted by. + withStreamSwitch(true) { + ConductorBundleFixture.withConductor(present: true) { + XCTAssertNil(BurrowConductor.streamOverride(moArgs: ["clean"], elevated: true)) + } + } + } + + /// Put the switch in a chosen state and put it back exactly as it was. + /// + /// These tests run against `UserDefaults.standard` for the real app domain, + /// so removing the key outright would erase a kill-switch the developer had + /// genuinely set — restoring the prior value, absent or not, keeps the suite + /// from editing anyone's configuration. + private func withStreamSwitch(_ value: Bool?, _ body: () -> Void) { + let key = "BurrowStreamViaConductor" + let saved = UserDefaults.standard.object(forKey: key) + defer { + if let saved { UserDefaults.standard.set(saved, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + if let value { UserDefaults.standard.set(value, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + body() } } diff --git a/macos/Tests/ConductorBundleFixture.swift b/macos/Tests/ConductorBundleFixture.swift new file mode 100644 index 00000000..ca6e89e7 --- /dev/null +++ b/macos/Tests/ConductorBundleFixture.swift @@ -0,0 +1,55 @@ +// +// ConductorBundleFixture.swift +// BurrowTests +// +// Lets a test say which build it is exercising — one that bundled the `burrow` conductor, or +// one that didn't — instead of inheriting whichever the test host happened to stage. +// +// Resources/burrow is produced by the "Bundle burrow (conductor)" phase, which is gated on a +// populated vendor/burrow-cli checkout. CI never fetches submodules, so it always tested the +// absent case; a developer who checked the submodule out (required for Network, Orphans and +// Photos to do anything) always tested the present case and saw unrelated failures. +// + +import Foundation +import XCTest + +@testable import Burrow + +enum ConductorBundleFixture { + + /// Runs `body` with the conductor lookup pointed at a temporary directory, then restores the + /// real one. `present: false` leaves the directory empty; `present: true` stages an executable + /// stub named `burrow`. + /// + /// The stub is never spawned by these tests — resolution only checks the executable bit — but + /// it is written as a shell script that emits a valid empty envelope so that a future test + /// which does run it gets something parseable rather than a crash. + static func withConductor(present: Bool, + file: StaticString = #filePath, line: UInt = #line, + _ body: () throws -> T) rethrows -> T { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-conductor-fixture-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + if present { + let stub = dir.appendingPathComponent("burrow") + FileManager.default.createFile( + atPath: stub.path, + contents: Data("#!/bin/sh\nprintf '{\"ok\":true,\"data\":{}}'\n".utf8), + attributes: [.posixPermissions: 0o755]) + } + + let saved = BurrowConductor.resourceDirectory + BurrowConductor.resourceDirectory = { dir } + defer { + BurrowConductor.resourceDirectory = saved + try? FileManager.default.removeItem(at: dir) + } + + XCTAssertEqual(BurrowConductor.isAvailable, present, + "fixture failed to put the conductor lookup in the requested state", + file: file, line: line) + return try body() + } +} diff --git a/macos/Tests/MCPConductorToolsTests.swift b/macos/Tests/MCPConductorToolsTests.swift index b549707e..07d0295f 100644 --- a/macos/Tests/MCPConductorToolsTests.swift +++ b/macos/Tests/MCPConductorToolsTests.swift @@ -159,33 +159,79 @@ final class MCPConductorToolsTests: XCTestCase { } } - // MARK: - Degrade, never throw (no conductor in the test bundle) + // MARK: - Degrade, never throw (build that bundled no conductor) - /// The test bundle ships no Resources/burrow, so `isAvailable` is false - /// and the call must come back as a JSON error object naming the - /// conductor — never a throw, never a crash, and no process spawned. + /// A build without Resources/burrow must answer with a JSON error object naming the + /// conductor — never a throw, never a crash, and no process spawned. The fixture chooses + /// that state explicitly; before it, the test merely inherited whatever the build staged. func testNet_withoutBundledConductor_returnsJSONErrorMentioningConductor() throws { - XCTAssertFalse(BurrowConductor.isAvailable, - "test bundles must not ship a conductor; this test depends on that") - let json = try catalog.call(name: "burrow_net", arguments: [:]) - let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - let error = try XCTUnwrap(obj["error"] as? String) - XCTAssertTrue(error.localizedCaseInsensitiveContains("conductor") - || error.localizedCaseInsensitiveContains("burrow"), - "the error must tell the agent the conductor is missing, got: \(error)") + try ConductorBundleFixture.withConductor(present: false) { + let json = try catalog.call(name: "burrow_net", arguments: [:]) + let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let error = try XCTUnwrap(obj["error"] as? String) + XCTAssertTrue(error.localizedCaseInsensitiveContains("conductor") + || error.localizedCaseInsensitiveContains("burrow"), + "the error must tell the agent the conductor is missing, got: \(error)") + } } func testSentinel_withoutBundledConductor_returnsJSONErrorNotThrow() throws { - let json = try catalog.call(name: "burrow_sentinel", arguments: [:]) - let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - XCTAssertNotNil(obj["error"]) + try ConductorBundleFixture.withConductor(present: false) { + let json = try catalog.call(name: "burrow_sentinel", arguments: [:]) + let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + XCTAssertNotNil(obj["error"]) + } } /// Valid arguments + missing conductor must still degrade (the /// argument check passes, then the availability check reports). func testSlimCheck_withBinaryButNoConductor_returnsJSONError() throws { - let json = try catalog.call(name: "burrow_slim_check", arguments: ["binary": "/usr/bin/true"]) - let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - XCTAssertNotNil(obj["error"]) + try ConductorBundleFixture.withConductor(present: false) { + let json = try catalog.call(name: "burrow_slim_check", arguments: ["binary": "/usr/bin/true"]) + let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + XCTAssertNotNil(obj["error"]) + } + } + + // MARK: - The other branch: a build that DID bundle one + + /// The complement nothing covered. Argument validation runs BEFORE the availability check, so + /// on a build that shipped the conductor a missing argument still surfaces as `badArguments` + /// — the caller is told what it got wrong instead of being handed a conductor error, and the + /// tool doesn't spawn anything to discover what the schema already knew. + func testSlimCheck_withConductorBundled_stillRejectsBadArgumentsWithoutSpawning() throws { + try ConductorBundleFixture.withConductor(present: true) { + XCTAssertThrowsError(try catalog.call(name: "burrow_slim_check", arguments: [:])) { err in + guard case MCPToolError.badArguments(let message) = err else { + return XCTFail("expected .badArguments, got \(err)") + } + XCTAssertTrue(message.contains("binary"), + "the caller must be told which argument is missing, got: \(message)") + XCTAssertFalse(message.localizedCaseInsensitiveContains("conductor"), + "a missing argument must not be reported as a missing conductor") + } + } + } + + /// Resolution is by executable bit, not by name: a non-executable file called `burrow` + /// (a stray artifact, a partially-staged copy) must not read as a usable conductor. + func testNonExecutableFileNamedBurrowDoesNotCountAsBundled() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-nonexec-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + XCTAssertTrue( + FileManager.default.createFile(atPath: dir.appendingPathComponent("burrow").path, + contents: Data("not a binary".utf8), + attributes: [.posixPermissions: 0o644]), + "the fixture must actually stage the file, or the assertions below prove nothing") + let saved = BurrowConductor.resourceDirectory + BurrowConductor.resourceDirectory = { dir } + defer { + BurrowConductor.resourceDirectory = saved + try? FileManager.default.removeItem(at: dir) + } + + XCTAssertNil(BurrowConductor.executableURL()) + XCTAssertFalse(BurrowConductor.isAvailable) } } diff --git a/macos/Tests/OperationFlowTests.swift b/macos/Tests/OperationFlowTests.swift index 6874024e..7513bf41 100644 --- a/macos/Tests/OperationFlowTests.swift +++ b/macos/Tests/OperationFlowTests.swift @@ -262,13 +262,19 @@ final class OperationFlowTests: XCTestCase { XCTAssertFalse(message.contains("Nothing was cleaned"), message) } + /// This is about the DIRECT engine path, so it has to pin the no-conductor world: with a + /// conductor staged, `streamOverride` supplies an executable for a non-elevated `clean` + /// before `resolveMo` is ever consulted, and the unresolvable-engine branch under test is + /// never reached. It previously relied on the test host happening not to bundle one. func testMissingExecutableFailsBeforeSpawn() { - let port = FakeProcessPort(script: []) - let flow = OperationFlow(process: port, hasFullDiskAccess: { true }, - resolveMo: { _ in nil }, center: OperationCenter()) - flow.start(Self.cleanOp()) - guard case .finished(.failed) = flow.state else { return XCTFail("expected failed") } - XCTAssertTrue(port.specs.isEmpty) + ConductorBundleFixture.withConductor(present: false) { + let port = FakeProcessPort(script: []) + let flow = OperationFlow(process: port, hasFullDiskAccess: { true }, + resolveMo: { _ in nil }, center: OperationCenter()) + flow.start(Self.cleanOp()) + guard case .finished(.failed) = flow.state else { return XCTFail("expected failed") } + XCTAssertTrue(port.specs.isEmpty) + } } func testElevatedRunFailsBeforeSpawnWhenInvokingAccountCannotBeResolved() {