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..d21c1951 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 @@ -220,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." @@ -228,48 +222,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 +247,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 +264,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 +292,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 +313,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 +338,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..077ff262 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.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.1 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`. @@ -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 @@ -44,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: @@ -73,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. @@ -94,11 +108,20 @@ 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 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/`. @@ -128,9 +151,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 +198,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..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 @@ -190,18 +196,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 +232,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,9 +249,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.1 current trust-chain baseline - -The current release was verified on August 3, 2026. Tag `v0.11.1` points to +### 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.12.0` points to +`adf9f89b676d79be23bcc4e930952adb81942dc7`; the ZIP has SHA-256 +`367ab1f8b6129f4154fb6c5e90bfcbe5d86909e6c71dd9b2cb24afb5ec455e6d`, +and `appcast.xml` has SHA-256 +`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 +[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 + +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 @@ -276,6 +327,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/HelperSources/HelperService.swift b/macos/HelperSources/HelperService.swift index df9a004f..bebbbba9 100644 --- a/macos/HelperSources/HelperService.swift +++ b/macos/HelperSources/HelperService.swift @@ -30,6 +30,7 @@ // import Foundation +import Darwin import Security import os @@ -84,38 +85,150 @@ func helperTrace(_ message: String) { try? helperTraceHandle.write(contentsOf: Data("[\(stamp)] \(message)\n".utf8)) } -// MARK: - Engine resolution +// 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) + } -enum HelperEngine { + 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)) + } - /// 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 + 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: - 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. /// - /// 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 + /// 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)) + } - // 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 + /// 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 { /// The app bundle containing this helper. static func appBundleURL() -> URL? { guard let executable = Bundle.main.executableURL?.resolvingSymlinksInPath() else { return nil } @@ -144,12 +257,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, @@ -182,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. @@ -195,13 +348,19 @@ final class HelperOperationRunner: @unchecked Sendable { func run(operation: HelperOperation, operationID: String, interface: String?, - enginePath: 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: + 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 @@ -214,9 +373,20 @@ final class HelperOperationRunner: @unchecked Sendable { } path = systemPath } - last = runOne(path: path, arguments: step.arguments, - operationID: operationID, 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) + // 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. + guard operation.needsReviewedPaths || status == 0 else { break } } return last } @@ -224,6 +394,8 @@ 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() process.executableURL = URL(fileURLWithPath: path) @@ -236,11 +408,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 @@ -254,7 +422,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() @@ -262,30 +432,46 @@ 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 + // 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() } } 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 } - /// 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 } @@ -297,23 +483,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] } } @@ -324,10 +523,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() @@ -368,11 +563,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) { @@ -381,6 +579,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 +602,41 @@ 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") + + // 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 { @@ -418,25 +659,50 @@ 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 { + var engineSnapshot: HelperExecutableSnapshot? + var enginePath: String? + if request.operation.engineArguments != nil { + guard let snapshot = HelperEngine.executableSnapshot(teamID: teamID) else { + helperTrace("engine unavailable: signed execution snapshot could not be prepared") + return respond(.engineUnavailable) + } + 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))") - let client = currentConnection?.remoteObjectProxy as? BurrowHelperClientProtocol + 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, - enginePath: enginePath) { line in + reviewedPaths: reviewedPaths, + enginePath: enginePath, + 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) {} + + // 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) + // 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") + } + } helperTrace("operation finished with status \(code)") respond(.exited(code)) } @@ -461,7 +727,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/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/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/Sources/CleanReviewView.swift b/macos/Sources/CleanReviewView.swift index ce30b9fc..de229854 100644 --- a/macos/Sources/CleanReviewView.swift +++ b/macos/Sources/CleanReviewView.swift @@ -262,6 +262,13 @@ 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) + // 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 72bb3d12..a560969f 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 } @@ -98,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/CleanView.swift b/macos/Sources/CleanView.swift index 8976446c..1d077da7 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,10 +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() } - PillButton(title: "Clean Now", filled: false) { confirmDirectClean() } + // 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 @@ -179,10 +202,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: { @@ -205,7 +224,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 } @@ -228,8 +247,27 @@ 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()) + // 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 } @@ -237,6 +275,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 +286,9 @@ struct CleanView: View { return } if Store.cacheRemovalMode == .trash { - trashTicked(selection) + trashTicked(selection, snapshot: snapshot) } else { - runRealClean(selection) + runRealClean(selection, snapshot: snapshot) } } @@ -260,56 +299,28 @@ 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) { + // 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) && !refused.contains($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,9 +330,10 @@ 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 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 @@ -332,20 +344,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)) @@ -378,7 +395,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() } } @@ -395,7 +412,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 "" @@ -421,6 +440,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..f091b7f8 --- /dev/null +++ b/macos/Sources/CleanupAuthorization.swift @@ -0,0 +1,366 @@ +// +// 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 { + /// 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 + } + + 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 } + 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 + 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) + } + } + } + + /// 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) ]" + } + } + + /// 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 } + return items.map(\.identity.path).sorted { lhs, rhs in + let l = depth(lhs), r = depth(rhs) + return l == r ? lhs < rhs : l > r + } + } + + /// 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. + /// + /// 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)" + } + 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, + 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 { "root:\(line($0))" } + ["--items--"] + + items.map { "item:\(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): + // 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." + 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] + /// 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], + 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] = [] + 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 { 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 { + refuse(raw, .symbolicLink(raw)); continue + } + guard seen.insert(canonical).inserted else { continue } + 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, skipped: skipped) + } + + 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, + 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 { + 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 + } + } + 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/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..5dd948c7 --- /dev/null +++ b/macos/Sources/ExternalSparkleUpdateSession.swift @@ -0,0 +1,203 @@ +// +// 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 + ) + } + + /// 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 { + 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..80a80e70 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,44 @@ 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 } + // 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: " _.$:+<>()[]{}?!,@#&'`~-=") + ) + 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..5f0c725d 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,72 @@ 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 \(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 \(ElevatedExitCode.executableRefused)") + } + + 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 { + // 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 + // 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: " ") + } + + 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 \(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 \(ElevatedExitCode.logSinkUnavailable)") + 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..e950cf8b 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 } @@ -194,13 +201,35 @@ 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 } + 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 @@ -249,17 +278,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. @@ -298,6 +339,36 @@ 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 { + 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) + } + } + private func captureTelemetryCompletion(result: String) { guard let telemetryFeature else { return } let duration = telemetryStartedAt.map { Date().timeIntervalSince($0) } ?? 0 @@ -334,6 +405,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 @@ -352,17 +440,24 @@ 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)) } } - 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,37 +470,96 @@ 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) + // 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) + 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) 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 } + // 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() - DispatchQueue.main.async { tailTimer?.invalidate() } 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 } - finish(proc.terminationStatus) + let stderr = String( + decoding: errPipe.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self) + _ = outPipe.fileHandleForReading.readDataToEndOfFile() + finish(proc.terminationStatus, appleScriptStderr: stderr) } } } else { @@ -422,21 +576,25 @@ struct SystemProcessPort: ProcessPort { } cont.onTermination = { @Sendable _ in - DispatchQueue.main.async { tailTimer?.invalidate() } + tailTimer?.cancel() if t.isRunning { t.terminate() } } 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 { @@ -458,7 +616,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() } @@ -475,13 +634,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..ba5a762d --- /dev/null +++ b/macos/Sources/PrivilegedExecution.swift @@ -0,0 +1,349 @@ +// +// 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 + +/// 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 + 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 } + var isSymbolicLink: Bool { (mode_t(mode) & S_IFMT) == S_IFLNK } + + 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 { + // 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 + } + + /// 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 { + /// 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(String) + 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(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." + } + } + } + + 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? + + /// `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, + 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 } + + 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 = ["/"] + var cursor = "" + for component in url.pathComponents.dropFirst().dropLast() { + 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 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 policy.acceptsOwner(executable.owner, invokingUser: invokingUser.uid) else { + throw ValidationError.executableNotRootOwned(canonical) + } + guard (mode_t(executable.mode) & policy.forbiddenWriteBits) == 0 else { + throw ValidationError.executableMutable(canonical) + } + 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 \(ElevatedExitCode.logSinkUnavailable); " + + "( set -C; : > \(file) ) || exit \(ElevatedExitCode.logSinkUnavailable)" + } +} diff --git a/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift b/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift index 412f4e7c..3ff5052f 100644 --- a/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift +++ b/macos/Sources/PrivilegedHelper/HelperCodeRequirement.swift @@ -30,16 +30,24 @@ // 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 import Security +import Darwin enum HelperCodeRequirement { @@ -153,3 +161,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/PrivilegedHelper/HelperContract.swift b/macos/Sources/PrivilegedHelper/HelperContract.swift index 25968c35..1c215226 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. @@ -169,6 +200,223 @@ 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: - 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 @@ -186,10 +434,16 @@ 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 + /// 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. 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 +460,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 +473,30 @@ 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, + 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 — /// the client's own validation is a courtesy, this one is the boundary. /// @@ -225,6 +508,27 @@ 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.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, @@ -309,36 +613,79 @@ 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 } - /// `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) - 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/PrivilegedHelperClient.swift b/macos/Sources/PrivilegedHelperClient.swift index 9b616703..955859c6 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,7 +241,20 @@ 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 + // 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 +279,8 @@ final class PrivilegedHelperClient: @unchecked Sendable { // not a guarantee. return withExtendedLifetime(granted) { send(payload: granted.externalForm, operation: operation, - interface: interface, onLine: onLine) + interface: interface, reviewedPaths: reviewedPaths, + invokingUser: invokingUser, onLine: onLine) } } @@ -266,10 +288,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,11 +313,17 @@ 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, operationID: UUID().uuidString, clientBuild: Self.appBuild, - networkInterface: interface) + invokingUser: HelperInvokingUserClaim( + uid: UInt32(invokingUser.uid), + canonicalHome: invokingUser.canonicalHome), + networkInterface: interface, + reviewedPaths: reviewedPaths) guard let payload = try? JSONEncoder().encode(request) else { return .launchFailed } let connection = makeConnection() @@ -352,10 +383,30 @@ 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 - let outcome = client.run(operation: operation) { line in + // 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.orderedReviewedPaths() + } 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/Sources/ProcessActions.swift b/macos/Sources/ProcessActions.swift index 1d11185d..d6bd1be7 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,150 @@ enum ProcessActions { return String(cString: buffer) } - /// SIGTERM — the polite ask. Caller confirms first. - @discardableResult - static func quit(pid: Int) -> Bool { kill(Int32(pid), SIGTERM) == 0 } + /// 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) + ) + } - /// SIGKILL — the hammer. Caller double-confirms first. - @discardableResult - static func forceKill(pid: Int) -> Bool { kill(Int32(pid), SIGKILL) == 0 } + 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 + } + + 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: ""), 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: "") + 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() + } /// SIGSTOP — pause a process (freeze it without killing). Reversible via /// `resume`; own-user processes only (PRD §α Process Inspector). 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/QueryServer.swift b/macos/Sources/QueryServer.swift index ec3a6a15..11e95fdd 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 } + + 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 + 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 } let head = "HTTP/1.1 200 OK\r\n" @@ -217,17 +373,41 @@ 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")) + // 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 parts = first.split(separator: " ") - guard parts.count >= 2, parts[0] == "GET" else { - return json(Self.errorJSON("only GET supported")) + 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 target = String(parts[1]) + guard let request = Self.parseRequest(raw) else { + return Self.errorResponse(400, "malformed request") + } + 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") + } + 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 +424,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 +526,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..a63d8ce7 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", 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.") } 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..426176d9 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 @@ -1076,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) + } } } } @@ -1098,6 +1098,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 +1110,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..745f4f10 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 + ? 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 { + !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..efb1656e 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,24 @@ 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 { + 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 } runSteps = steps @@ -417,10 +436,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: ""), @@ -449,14 +469,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 } @@ -494,6 +513,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 +525,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 +573,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..a7dbc473 --- /dev/null +++ b/macos/Sources/UpdateWorkflow.swift @@ -0,0 +1,1380 @@ +// +// 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 + +@_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 { + 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: "") + case .artifactIdentityChanged: + return NSLocalizedString("The app changed after Burrow verified it. Check for updates again.", 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 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 } + 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 + } + + 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 + } + + /// 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, + 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: 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? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) == errSecSuccess, + 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, + version: version, + build: build, + codeDirectoryHash: codeDirectoryHash, + fileIdentity: BundleFileIdentity( + device: UInt64(status.st_dev), + inode: UInt64(status.st_ino) + ) + ) + } +} + +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() { + PrivateUpdateDirectory.discard( + at: stagingDirectory, + expectedIdentity: stagingDirectoryIdentity, + expectedCanonicalURL: canonicalStagingDirectoryURL + ) + } +} + +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 + // 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), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(code))] + ) + } + 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 { + case ready(StagedElectronUpdate) + case failure(UpdateFailure) +} + +enum ElectronInstallOutcome: Sendable { + case installed + case failure(UpdateFailure) +} + +enum ElectronPostReplacementDecision: Equatable, Sendable { + case accept + case restore( + installedIdentity: BundleUpdateIdentity?, + backupIdentity: BundleUpdateIdentity, + failure: UpdateFailure + ) + case fail(UpdateFailure) +} + +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) + } + + 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.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 = 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.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: "" + ))) + } + + let root: URL + do { + root = try PrivateUpdateDirectory.create() + } 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) + 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) + ) + } + // 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) + } + 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) else { + throw UpdateFailure.verification(.invalidSignature) + } + 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, + stagingDirectoryIdentity: stagingIdentity, + canonicalStagingDirectoryURL: canonicalStagingDirectory, + expectedIdentity: expectedIdentity, + expectedCandidateIdentity: candidateIdentity, + descriptorVersion: descriptor.version + )) + } catch let failure as UpdateFailure { + discardRoot() + return .failure(failure) + } catch let error as URLError { + discardRoot() + 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 { + discardRoot() + return .failure(.installation(error.localizedDescription)) + } + } + + @MainActor + static func install(_ staged: StagedElectronUpdate) async -> ElectronInstallOutcome { + defer { staged.discard() } + if let failure = boundaryVerificationFailure(for: staged) { + return .failure(failure) + } + 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: "" + ))) + } + + 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) + } + do { + try replacePreservingBackup( + targetURL: staged.targetURL, + candidateURL: staged.candidateURL, + backupName: backupName + ) + 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(failure) + case let .fail(failure): + return .failure(failure) + } + 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..6249e5d3 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? @@ -51,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] = [] @@ -68,7 +124,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 +150,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 update steps processed", 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 +173,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 +226,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 +236,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 +321,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 +337,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 +399,156 @@ 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 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 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, + 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) } 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) } + checkFailureIDs.formIntersection(unchangedIDs) + electronDescriptors = electronDescriptors.filter { unchangedIDs.contains($0.key) } + for id in invalidatedIDs { + stagedElectronUpdates.removeValue(forKey: id)?.discard() + cancelAppTask(for: id) + } + + 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 } } } @@ -309,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 @@ -320,34 +573,94 @@ 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 + 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 updated: [AppUpdateItem] = [] - await withTaskGroup(of: AppUpdateItem.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 + ) + } + } + // 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) } - 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 } + let brews = await loadBrewOutdated() 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, 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 + } + 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 +673,474 @@ 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 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 -> AppUpdateCheckResult { + var result = AppUpdateCheckResult( + 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 !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, owner: .row(generation)) + self.finishAppTask(for: item.id, generation: generation) + } + appTasks[item.id] = TrackedAppTask(generation: generation, task: task) + } + + func retry(_ item: AppUpdateItem) { + if checkFailureIDs.contains(item.id) { checkNow() } + else if item.updateAvailable { update(item) } + else { checkNow() } + } + + func installReady(_ item: AppUpdateItem) { + 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, owner: .row(generation)) + self.finishAppTask(for: item.id, generation: generation) + } + 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) { + 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) + } + + func openReleaseNotes(_ item: AppUpdateItem) { + if let url = item.releaseNotesURL ?? item.pageURL { NSWorkspace.shared.open(url) } + } + + private func performUpdate(_ item: AppUpdateItem, owner: AppUpdateOwner) async { + guard isCurrentAppUpdate(for: item.id, owner: owner) else { return } switch item.source { - case .sparkle, .electron: - NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: item.path), - configuration: NSWorkspace.OpenConfiguration()) + case .sparkle: + 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 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) + return + } + switch outcome { + case let .ready(staged): + stagedElectronUpdates[item.id]?.discard() + stagedElectronUpdates[item.id] = staged + phases[item.id] = .readyToInstall + 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, 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.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, + 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 + copy.latestVersion = version + copy.releaseNotesURL = releaseNotesURL + return copy + } + }, + onFinish: { [weak self] in + self?.finishSparkleSession(for: item.id, generation: sessionGeneration) + 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] = TrackedSparkleSession( + generation: sessionGeneration, + session: session + ) + if session.begin() != nil { + finishSparkleSession(for: item.id, generation: sessionGeneration) + handOffToOwnUpdater(item) + } + } + } + + 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 + 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): + 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.loadBrewOutdated() + } + } + + /// 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, + 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 + updateAllTotal = apps.count + brews.count + updateAllTask = Task { @MainActor [weak self] in + guard let self else { return } + for item in apps { + 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 } - Task { @MainActor in - self.brewPhrase = "" - self.upgrading.remove(item.id) - self.brewItems = await Self.brewOutdated() + for item in brews { + 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.loadBrewOutdated() + 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 +1178,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 +1203,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 +1237,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/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/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/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/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/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/HelperContractTests.swift b/macos/Tests/HelperContractTests.swift index 9298956f..1660c74e 100644 --- a/macos/Tests/HelperContractTests.swift +++ b/macos/Tests/HelperContractTests.swift @@ -25,19 +25,159 @@ 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 - // "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 @@ -54,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 @@ -62,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), @@ -124,7 +269,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 +291,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") } @@ -172,8 +319,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) }), @@ -193,7 +343,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 +353,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 +391,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) } @@ -252,18 +423,60 @@ 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() { - let guardian = HelperReplayGuard(capacity: 3) - let ids = (0..<4).map { _ in UUID().uuidString } - for id in ids { XCTAssertTrue(guardian.admit(id)) } + /// 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() { + // 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)) } + + XCTAssertFalse(guardian.admit(victim), + "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") + } - 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") + /// 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 @@ -280,6 +493,7 @@ final class HelperContractTests: XCTestCase { .authorizationDenied, .rejected(.buildMismatch), .rejected(.replayedOperationID), + .rejected(.invalidInvokingUser), .engineUnavailable, ] for outcome in outcomes { @@ -311,3 +525,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/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/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/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..3cb6b1ba 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,63 @@ 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_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(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..7513bf41 100644 --- a/macos/Tests/OperationFlowTests.swift +++ b/macos/Tests/OperationFlowTests.swift @@ -191,13 +191,102 @@ 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(ElevatedExitCode.boundaryCheckFailed)]) + 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") + } + // 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) + } + + /// 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() { + 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") } } @@ -249,27 +338,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/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) + } } diff --git a/macos/Tests/PrivilegedSecurityTests.swift b/macos/Tests/PrivilegedSecurityTests.swift new file mode 100644 index 00000000..97f3ba6e --- /dev/null +++ b/macos/Tests/PrivilegedSecurityTests.swift @@ -0,0 +1,501 @@ +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")) + } + + /// 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) + 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) + } + + // 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) + 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])) + } + + /// 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( + 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") + } + + 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 testIrreversibleCleanupDeletesTheReviewedTreeAndReportsSuccess() throws { + let item = root.appendingPathComponent("permanent cache") + 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 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)) + } + + 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]) + + // 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")) + + XCTAssertEqual(try runCleanupShell(plan.irreversibleCleanupShell()), + ElevatedExitCode.boundaryCheckFailed) + XCTAssertEqual(try String(contentsOf: item.appendingPathComponent("marker")), + "unreviewed", "the substituted inode must not be deleted") + XCTAssertEqual(try String(contentsOf: moved.appendingPathComponent("marker")), + "reviewed", "the reviewed inode survives at its new name") + } + + /// 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) + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + + try Data("written after review".utf8) + .write(to: item.appendingPathComponent("added-later")) + + XCTAssertEqual(try runCleanupShell(plan.irreversibleCleanupShell()), 0, + "a cache written to between preview and confirmation still cleans") + XCTAssertFalse(FileManager.default.fileExists(atPath: item.path)) + } + + 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 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) + + let snapshot = try CleanupSnapshot.capture(list: list([item.path]), + approvedRootURLs: [root]) + let plan = try snapshot.plan(selectedPaths: [item.path]) + + 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") + } + + 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") + 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) + } + + 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") + process.arguments = ["-c", shell] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus + } +} + +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..551e9851 --- /dev/null +++ b/macos/Tests/ProcessActionsTests.swift @@ -0,0 +1,176 @@ +// +// 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 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() + + 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/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/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..24b33ceb 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,106 @@ final class QueryServerTests: XCTestCase { // MARK: - Routing + private func request(_ target: String, + method: String = "GET", + host: String? = "127.0.0.1:9277", + credential: String? = nil, + omitCredential: Bool = false, + extraHeaders: [String] = []) -> String { + var lines = ["\(method) \(target) HTTP/1.1"] + if let host { lines.append("Host: \(host)") } + // 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", 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)", omitCredential: true)) + 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 +189,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 +209,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 +225,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 +234,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/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 new file mode 100644 index 00000000..16e7758d --- /dev/null +++ b/macos/Tests/UpdateWorkflowTests.swift @@ -0,0 +1,1118 @@ +// +// 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) + // 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) + 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 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", + 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 = 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, + 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, + version: candidate.version, + build: candidate.build, + codeDirectoryHash: candidate.codeDirectoryHash, + fileIdentity: candidate.fileIdentity + ) + } + + XCTAssertEqual(reads, [staged.targetURL, staged.candidateURL]) + 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") + 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 + )! + } + + 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 new file mode 100644 index 00000000..175cd1db --- /dev/null +++ b/macos/Tests/UpdatesModelTests.swift @@ -0,0 +1,857 @@ +// +// 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) + } + + 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, + name: name, + bundleId: "dev.test.\(id)", + source: "App", + uninstallName: name, + path: path, + sizeStr: "1 B", + sizeBytes: 1, + lastUsed: nil + ) + } + + 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, + 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 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] = [] + + 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..9d5a102d 160000 --- a/macos/vendor/burrow-engine +++ b/macos/vendor/burrow-engine @@ -1 +1 @@ -Subproject commit a6d1a9837f0938600619e6c16505bf4e62d95904 +Subproject commit 9d5a102d73302f6404b96fc89565f1a03848ae9a 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..072a5d14 100644 --- a/scripts/tests/test_sentry_issues_workflow.py +++ b/scripts/tests/test_sentry_issues_workflow.py @@ -1,12 +1,70 @@ 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) + 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") @@ -19,8 +77,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 +92,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 +108,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..cf9ddc2c 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. @@ -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`. @@ -80,10 +80,10 @@ 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. +- `.\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. 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..77b72e59 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,52 @@ 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"], + HasFetchMetadata(context.Request.Headers), + context.Request.Headers["Authorization"], + _settingsService.Current.HttpServerAuthToken, + _activePort, + path, + context.Request.HttpMethod, + context.Request.ContentType, + context.Request.ContentLength64); + // 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; + } + 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 +1382,75 @@ private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode response.Close(); } - internal static bool IsRequestAllowed(IPAddress? remoteAddress, string? origin) + /// 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, + bool hasFetchMetadata, + string? authorization, + string expectedToken, + int expectedPort, + string path, + string method, + string? contentType, + long contentLength) { - return IsLoopback(remoteAddress) && IsAllowedOrigin(origin); + // 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; + } + 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 +1468,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..c1416898 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, hasFetchMetadata: true)); + 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, + bool hasFetchMetadata = false, + 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, + hasFetchMetadata, + 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..aa808af5 100644 --- a/windows/Tools/McpStdioBridge/Program.cs +++ b/windows/Tools/McpStdioBridge/Program.cs @@ -15,18 +15,52 @@ 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 = ReadEndpointFromSettings() ?? DefaultEndpoint; + 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)) + { + 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 +82,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 +91,7 @@ private static async Task Main() if (!File.Exists(path)) { - return null; + return (null, null); } try @@ -65,11 +99,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 }