Skip to content

fix(brew): remove protected cask app backups - #11219

Merged
jdx merged 5 commits into
mainfrom
agent/repro-brew-cask-app-cleanup
Jul 23, 2026
Merged

jdx merged 5 commits into
mainfrom
agent/repro-brew-cask-app-cleanup

Conversation

@jdx

@jdx jdx commented Jul 23, 2026 •

Copy link
Copy Markdown
Owner

Problem

Follow-up to discussion #11168. After the /usr/local binary-target fix, upgrading an existing docker-desktop cask could fail while deleting the app backup:

failed rm -rf: /Applications/Docker.mise-old-...
Permission denied (os error 13)

Root cause

The cask app swap renames the existing bundle to .mise-old-*, installs the new bundle, and then recursively removes the backup. Docker app bundles can contain ownership-, ACL-, or flag-protected descendants, so an ordinary unprivileged remove_dir_all can fail even though renaming the top-level bundle succeeded.

Fix

Match Homebrew's permission-recovery approach when app-backup removal returns PermissionDenied:

  1. clear file flags
  2. restore owner read/write/execute permissions
  3. remove ACLs
  4. retry deletion
  5. if ownership still blocks cleanup, use the existing controlled sudo path to take ownership, repair permissions again, and retry

Ordinary deletion remains the fast path. Permission repair and elevation are only attempted after EACCES.

The same cleanup is used for stale .mise-old-* bundles left by an interrupted or failed prior upgrade.

Tests

  • Added a macOS unit regression test with a delete-deny ACL inside an existing Docker.app. Before the fix it reproduced the reported Permission denied error; after the fix it passes.
  • Extended the existing macOS CI E2E test to install the small hiddenbar cask, protect its installed app contents with a delete-deny ACL, force a full reinstall, and verify the app is installed without a stale .mise-old-* backup.
  • cargo test system::packages::brew::cask::tests -- --nocapture — 84 passed
  • mise run test:e2e e2e/cli/test_system_install_brew_macos_slow — local harness passes and safely skips the CI-only /Applications mutation; unit-macos exercises it on the ephemeral runner
  • mise run lint-fix
  • pre-commit mise run lint

This PR was generated by an AI coding assistant.

Summary by CodeRabbit

  • Bug Fixes

    • Improved macOS app upgrades by atomically swapping upgraded bundles, with safer rollback if the swap fails.
    • Enhanced cleanup of old/temporary app bundles on macOS, including handling permission/ACL restrictions that previously blocked deletion.
    • Improved macOS install reliability by removing quarantine after the swap completes.
  • Tests

    • Added a macOS test for upgrades when the existing app’s contents deny child deletion.
    • Expanded the macOS slow Homebrew/brew-cask end-to-end test to cover forced reinstall recovery and confirm “mise-old-”/“mise-tmp-” leftovers are removed.

Note

Medium Risk
Touches macOS cask install/upgrade paths and may invoke sudo chown when deleting protected app bundles; behavior change is scoped to permission-denied removal with tests covering the swap path.

Overview
Fixes brew-cask upgrades that could fail after the new app was already in place when deleting the .mise-old-* backup hit Permission denied (e.g. docker-desktop with ACL/flag-protected bundle contents).

install_app now delegates the atomic rename to swap_app, which uses remove_app for stale backups and post-swap cleanup. remove_app retries like Homebrew: clear flags, restore owner perms, strip ACLs, then optional sudo chown before another delete. If backup removal still fails after a successful swap, the install warns instead of failing so quarantine stripping still runs.

Tests: macOS unit test with a delete-deny ACL on an existing app; slow CI E2E reinstalls hiddenbar with protected Contents and asserts no leftover .mise-old-*; E2E cleanup clears ACLs on temp/old app paths.

Reviewed by Cursor Bugbot for commit 41fc234. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The cask app installation flow now delegates staged replacement to swap_app, removes quarantine metadata after the swap, repairs permissions during protected-content cleanup, and validates forced macOS reinstall behavior.

Changes

Application replacement flow

Layer / File(s) Summary
App swap orchestration
src/system/packages/brew/cask.rs
install_app delegates replacement to swap_app, which stages the previous app, performs atomic renaming, repairs protected permissions during cleanup, and removes quarantine metadata afterward.
Protected cleanup and reinstall validation
src/system/packages/brew/cask.rs, e2e/cli/test_system_install_brew_macos_slow
macOS tests verify upgrades with protected contents, forced reinstall behavior, and removal of mise-old-* and mise-tmp-* paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Installer
  participant swap_app
  participant macOSFilesystem
  Installer->>swap_app: staged app and target paths
  swap_app->>macOSFilesystem: stage old app and install staged app
  swap_app->>macOSFilesystem: repair permissions and remove backup
  Installer->>macOSFilesystem: remove quarantine metadata
Loading

Suggested reviewers: casparbreloh, donbeave

Poem

I’m a rabbit swapping apps tonight,
Moving old and new just right.
ACLs guard the resource door,
Permission fixes clean the floor.
Quarantine waits until the end!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing Brew cask cleanup by removing protected app backups.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes protected macOS cask app-backup cleanup.

  • Adds permission, ACL, flag, and ownership recovery before retrying backup deletion.
  • Reuses the recovery path for stale backups while allowing successful app activation to continue if cleanup fails.
  • Adds macOS unit and end-to-end regression coverage for delete-protected app contents.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains in scope for this follow-up review.

Important Files Changed

Filename Overview
src/system/packages/brew/cask.rs Refactors app replacement and adds protected-backup cleanup with macOS regression coverage; the previously reported test failure is addressed.
e2e/cli/test_system_install_brew_macos_slow Extends the macOS cask scenario to exercise protected-app replacement and clean up temporary backup paths.

Reviews (5): Last reviewed commit: "fix(brew): drop needless borrows in cask..." | Re-trigger Greptile

Comment thread src/system/packages/brew/cask.rs Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/system/packages/brew/cask.rs (1)

730-730: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Handle protected old-app cleanup before merge.

After Line 723 installs the replacement, Line 730 propagates Permission denied for ACL-protected old contents. The upgrade is reported as failed despite the new app being live, and the added macOS test intentionally remains red. Make protected-content cleanup succeed before merging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/system/packages/brew/cask.rs` at line 730, Update the cleanup flow around
file::remove_all(&old_target) to handle ACL-protected old-app contents before
merging, so Permission denied does not propagate after the replacement is
installed. Ensure protected-content removal succeeds and the upgrade completes
successfully while preserving the existing replacement-installation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/system/packages/brew/cask.rs`:
- Line 730: Update the cleanup flow around file::remove_all(&old_target) to
handle ACL-protected old-app contents before merging, so Permission denied does
not propagate after the replacement is installed. Ensure protected-content
removal succeeds and the upgrade completes successfully while preserving the
existing replacement-installation behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e47e1fd-1e0d-4fd0-86df-f0a80734e30b

📥 Commits

Reviewing files that changed from the base of the PR and between 071f7c7 and 2cd6d4a.

📒 Files selected for processing (1)
  • src/system/packages/brew/cask.rs

@jdx jdx changed the title test(brew): reproduce protected cask app cleanup failure fix(brew): remove protected cask app backups Jul 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/system/packages/brew/cask.rs (1)

702-733: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Old-app cleanup failure after a successful swap wrongly fails the whole install and skips quarantine removal.

swap_app's final remove_app(&old_target)? (Line 731) runs after the rename to target already succeeded — the new app is live. If this backup cleanup fails (the exact scenario this PR's new test reproduces), swap_app returns Err, which propagates through install_app's swap_app(&target, &tmp_target)? (Line 702) and skips the quarantine xattr removal (Lines 703-709). The caller ends up reporting a failed install/upgrade even though the new app was successfully installed — just with a stale, undeletable backup left behind and Gatekeeper quarantine still attached.

Don't let old-backup cleanup failure fail an already-successful swap; log and continue instead.

🐛 Proposed fix
     if let Err(e) = file::rename(&tmp_target, &target) {
         // Restore the old app if the swap failed.
         if old_target.exists() {
             let _ = file::rename(&old_target, &target);
         }
         return Err(e);
     }
-    remove_app(&old_target)?;
-    Ok(())
+    if let Err(e) = remove_app(&old_target) {
+        warn!(
+            "brew-cask: failed to remove old app backup {}: {e}",
+            old_target.display()
+        );
+    }
+    Ok(())
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/system/packages/brew/cask.rs` around lines 702 - 733, Update swap_app so
failure from the final remove_app(&old_target) cleanup after successfully
renaming the new app is logged and ignored rather than propagated. Preserve
error propagation for the actual swap operations and ensure install_app
continues to quarantine removal even when the stale backup cannot be deleted.
🧹 Nitpick comments (1)
src/system/packages/brew/cask.rs (1)

735-784: 🧹 Nitpick | 🔵 Trivial

Escalation design looks sound; is_permission_denied correctly downcasts through wrap_err_with.

The retry/repair/sudo-escalate/retry sequence in remove_app is reasonable, and per eyre's docs, downcasting to the original io::Error still works after file::remove_all's wrap_err_with wrapping, so is_permission_denied will correctly detect the wrapped PermissionDenied errors.

One thing worth calling out operationally: this path calls sudo::run("chown", ...) from inside a fallback cleanup routine reachable from ordinary install_app calls. In a normal (non-CI) environment with an interactive TTY, a plain mise install/upgrade could unexpectedly prompt for a sudo password when it hits a protected leftover app — worth confirming this UX is intended/documented, since it's a new escalation surface introduced silently as part of app cleanup rather than an explicit user-facing "elevate" step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/system/packages/brew/cask.rs` around lines 735 - 784, Review the sudo
escalation in remove_app, which can prompt for a password during ordinary
install_app cleanup. Confirm that this behavior is intentional and documented;
if not, remove or gate the sudo::run("chown", ...) fallback behind an explicit
elevation path rather than triggering it silently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/system/packages/brew/cask.rs`:
- Around line 702-733: Update swap_app so failure from the final
remove_app(&old_target) cleanup after successfully renaming the new app is
logged and ignored rather than propagated. Preserve error propagation for the
actual swap operations and ensure install_app continues to quarantine removal
even when the stale backup cannot be deleted.

---

Nitpick comments:
In `@src/system/packages/brew/cask.rs`:
- Around line 735-784: Review the sudo escalation in remove_app, which can
prompt for a password during ordinary install_app cleanup. Confirm that this
behavior is intentional and documented; if not, remove or gate the
sudo::run("chown", ...) fallback behind an explicit elevation path rather than
triggering it silently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: a3600ffe-0bab-48d5-a2b0-585e4ed605ce

📥 Commits

Reviewing files that changed from the base of the PR and between 2cd6d4a and 1dcbd2a.

📒 Files selected for processing (1)
  • src/system/packages/brew/cask.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
e2e/cli/test_system_install_brew_macos_slow (1)

16-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the new cleanup trap itself.

Because cleanup runs only on EXIT, the assertions after reinstall execute before it and therefore do not verify removal of stale .mise-old-* or .mise-tmp-* paths. Add a fixture/subprocess that leaves each stale path and checks the filesystem after exit; cover both patterns.

Also applies to: 44-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/cli/test_system_install_brew_macos_slow` around lines 16 - 25, Extend the
test around the cleanup function and its EXIT trap to exercise cleanup in a
separate subprocess, creating one stale /Applications/Hidden Bar.mise-old-* path
and one .mise-tmp-* path before the subprocess exits. After the subprocess
completes, assert both paths were removed, while preserving the existing
reinstall assertions and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@e2e/cli/test_system_install_brew_macos_slow`:
- Around line 16-25: Extend the test around the cleanup function and its EXIT
trap to exercise cleanup in a separate subprocess, creating one stale
/Applications/Hidden Bar.mise-old-* path and one .mise-tmp-* path before the
subprocess exits. After the subprocess completes, assert both paths were
removed, while preserving the existing reinstall assertions and cleanup
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 3edfa10b-0a85-4a3c-9960-2557cbeaf402

📥 Commits

Reviewing files that changed from the base of the PR and between 1dcbd2a and e0a9e16.

📒 Files selected for processing (1)
  • e2e/cli/test_system_install_brew_macos_slow

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Hyperfine Performance

mise x -- echo

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.7.12 x -- echo 31.2 ± 4.0 21.0 49.3 1.08 ± 0.16
mise x -- echo 28.9 ± 1.9 23.7 35.8 1.00

mise env

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.7.12 env 25.7 ± 1.9 21.2 39.0 1.00
mise env 28.2 ± 4.1 21.0 47.7 1.10 ± 0.18

mise hook-env

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.7.12 hook-env 30.4 ± 4.9 20.8 46.1 1.28 ± 0.23
mise hook-env 23.8 ± 1.8 20.6 29.6 1.00
✅ Performance improvement for hook-env is 28%

mise ls

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.7.12 ls 19.3 ± 3.4 16.2 29.9 1.00
mise ls 28.9 ± 1.9 22.2 34.9 1.50 ± 0.28
⚠️ Warning: Performance variance for ls is 50%

xtasks/test/perf

Command mise-2026.7.12 mise Variance
install (cached) 263ms 277ms -5%
ls (cached) 92ms 101ms -8%
bin-paths (cached) 92ms 94ms -2%
task-ls (cached) 166ms 171ms -2%

Clippy 1.94's needless_borrows_for_generic_args flagged the &&Path
arguments to file::rename in swap_app, failing the lint CI job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/system/packages/brew/cask.rs (1)

4644-4654: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clean up the ACL on target when the swap fails before backup creation.

If swap_app errors before renaming the existing app, old_target does not exist and the protected ACL remains on target. The test’s cleanup then cannot reliably remove the temporary directory. Clear the ACL from target when result.is_err() and no backup exists.

Proposed fix
-        if old_target.exists() {
+        let cleanup_path = if old_target.exists() {
+            Some(old_target.as_path())
+        } else if result.is_err() && target.exists() {
+            Some(target.as_path())
+        } else {
+            None
+        };
+
+        if let Some(path) = cleanup_path {
             let status = std::process::Command::new("chmod")
                 .arg("-RN")
-                .arg(&old_target)
+                .arg(path)
                 .status()?;
             assert!(status.success());
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/system/packages/brew/cask.rs` around lines 4644 - 4654, Update the swap
failure cleanup around swap_app so that when result.is_err() and old_target does
not exist, it also clears the ACL from target before test cleanup. Preserve the
existing old_target cleanup path and use the same chmod -RN behavior for target.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/system/packages/brew/cask.rs`:
- Around line 4644-4654: Update the swap failure cleanup around swap_app so that
when result.is_err() and old_target does not exist, it also clears the ACL from
target before test cleanup. Preserve the existing old_target cleanup path and
use the same chmod -RN behavior for target.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: dfda62bd-fff4-41b5-aaa6-4c233c7f8237

📥 Commits

Reviewing files that changed from the base of the PR and between 3c719b2 and 41fc234.

📒 Files selected for processing (1)
  • src/system/packages/brew/cask.rs

@jdx
jdx enabled auto-merge (squash) July 23, 2026 20:44
@jdx
jdx merged commit 54b17b5 into main Jul 23, 2026
33 of 34 checks passed
@jdx
jdx deleted the agent/repro-brew-cask-app-cleanup branch July 23, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant