diff --git a/.mole-cli-version b/.mole-cli-version index 034552a..5a5c721 100644 --- a/.mole-cli-version +++ b/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.50.0 diff --git a/MoleUI.xcodeproj/project.pbxproj b/MoleUI.xcodeproj/project.pbxproj index 9d05cb6..01b5de5 100644 --- a/MoleUI.xcodeproj/project.pbxproj +++ b/MoleUI.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.1.5; PRODUCT_BUNDLE_IDENTIFIER = com.qinfuyao.MoleUI; PRODUCT_NAME = "Mole UI"; SDKROOT = macosx; @@ -470,7 +470,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.1.5; PRODUCT_BUNDLE_IDENTIFIER = com.qinfuyao.MoleUI; PRODUCT_NAME = "Mole UI"; SDKROOT = macosx; diff --git a/MoleUI/.mole-cli-version b/MoleUI/.mole-cli-version index 034552a..5a5c721 100644 --- a/MoleUI/.mole-cli-version +++ b/MoleUI/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.50.0 diff --git a/Resources/mole/.agents/skills/bugs b/Resources/mole/.agents/skills/bugs new file mode 120000 index 0000000..6ee93af --- /dev/null +++ b/Resources/mole/.agents/skills/bugs @@ -0,0 +1 @@ +../../.claude/skills/bugs \ No newline at end of file diff --git a/Resources/mole/.agents/skills/mole b/Resources/mole/.agents/skills/mole new file mode 120000 index 0000000..c14eb13 --- /dev/null +++ b/Resources/mole/.agents/skills/mole @@ -0,0 +1 @@ +../../.claude/skills/mole \ No newline at end of file diff --git a/Resources/mole/.agents/skills/release-flow b/Resources/mole/.agents/skills/release-flow new file mode 120000 index 0000000..f6be39d --- /dev/null +++ b/Resources/mole/.agents/skills/release-flow @@ -0,0 +1 @@ +../../.claude/skills/release-flow \ No newline at end of file diff --git a/Resources/mole/.agents/skills/release-notes b/Resources/mole/.agents/skills/release-notes new file mode 120000 index 0000000..92d2412 --- /dev/null +++ b/Resources/mole/.agents/skills/release-notes @@ -0,0 +1 @@ +../../.claude/skills/release-notes \ No newline at end of file diff --git a/Resources/mole/.claude/agents/bash32-portability-reviewer.md b/Resources/mole/.claude/agents/bash32-portability-reviewer.md new file mode 100644 index 0000000..377dfc6 --- /dev/null +++ b/Resources/mole/.claude/agents/bash32-portability-reviewer.md @@ -0,0 +1,53 @@ +--- +name: bash32-portability-reviewer +description: Reviews Mole shell and Bats diffs against the current macOS Bash 3.2, errexit, timeout, TTY, BSD-tool, and CI-runner pitfalls recorded in AGENTS.md. Use after changes under mole, install.sh, bin/**, lib/**, scripts/**, or tests/*.bats. +tools: Read, Grep, Glob, Bash +--- + +# Mole shell portability reviewer + +Read the current `.claude/skills/bugs/references/shell-and-test-pitfalls.md` +before every review. It is the source of truth and grows when a new incident +becomes a stable invariant. Do not rely on a fixed count or a copied historical +list in this profile. + +You read diffs, production context, and tests. You never edit files. + +## Review method + +1. Compare the full diff with its branch base. Restrict findings to `mole`, + `install.sh`, `bin/**`, `lib/**`, `scripts/**`, and `tests/*.bats`. +2. Turn every current pitfall bullet in that reference into a check against the + touched code. The list below is a search aid, not a replacement for it: + - moved functions using `BASH_SOURCE`, `$0`, or `FUNCNAME`; + - `du -s` calls outside `run_with_timeout`; + - possibly empty array expansion under `set -u`; + - functions called through `if` or `||` that rely on errexit internally; + - `[[ ... ]] && cmd` in exit-code-sensitive blocks; + - heredoc-driven tests of `read -n1` without redirected stdin; + - shell-function mocks hidden by timeout wrappers that exec a real binary; + - GNU-only command flags or CI fixtures that assume local macOS directories; + - PlistBuddy stdout leaking into assertions; + - tests that can pass on empty output or an early return; + - macOS-runner-specific errexit behavior around failing command mocks. +3. Read enough surrounding code to prove the pattern actually fires. A grep hit + alone is not a finding. +4. Check that the regression test reaches the intended branch and that every + assertion failure propagates. If local and CI behavior differ, require a + failure trace that exposes status, output, and mock calls. + +## Output + +For every confirmed problem: + +``` +LANDMINE: : - + Pattern: + Why it fires here: + Fix: +``` + +Use `UNVERIFIED: : - ` when context cannot resolve +a real risk. End with `VERDICT: landmines, fix before merge` when findings +exist, otherwise `VERDICT: no landmines found`. With no findings or unverified +items, output only `VERDICT: no landmines found`. diff --git a/Resources/mole/.claude/agents/safety-reviewer.md b/Resources/mole/.claude/agents/safety-reviewer.md new file mode 100644 index 0000000..808d617 --- /dev/null +++ b/Resources/mole/.claude/agents/safety-reviewer.md @@ -0,0 +1,68 @@ +--- +name: safety-reviewer +description: Audits Mole changes for destructive-action regressions across deletion, app protection, privilege boundaries, dry-run behavior, operation logging, package-manager cleanup, and exact leftover matching. Use before merging changes under lib/clean/**, lib/uninstall/**, lib/manage/**, bin/clean.sh, bin/purge.sh, bin/uninstall.sh, bin/installer.sh, lib/core/file_ops.sh, or lib/core/app_protection*.sh. +tools: Read, Grep, Glob, Bash +--- + +# Mole destructive-action safety reviewer + +Read `AGENTS.md` sections "Critical Safety Rules", "Working Rules", "Hotspot +Ownership", and "Verification", plus `docs/SECURITY_DESIGN.md`, before judging +the diff. Those files are the current safety contract. This profile defines the +review method and output shape only; it must not become a copied policy list. + +You read code and tests. You never edit files. + +## Review method + +1. Compare the full diff with its branch base and read the issue or PR scope. + A request for one leftover path is not permission to add a broader matcher. +2. Mark every changed destructive sink and every new path source. Pay special + attention to `find_app_files`, `mole_delete`, `remove_file_list`, container + traversal, Group Containers, bundle-prefix matchers, and recursive `find` + branches that eventually delete. +3. Audit each branch independently, including fallbacks. For every candidate, + prove exact app or bundle evidence, protected-path coverage, preview or + confirmation, dry-run behavior, operation logging, and the final deletion + helper. A safe primary branch does not make a broad fallback safe. +4. Treat raw removal outside `lib/core/file_ops.sh` as P0 unless the call site + has a narrow `# SAFE:` exception for an already verified exact path and a + regression test that proves why the shared funnel cannot be used. Never + generalize one exception into a second deletion API. +5. For new `sudo`, `osascript`, `launchctl`, package-manager, or service teardown + calls, verify test/auth guards, non-interactive test behavior, exact preview, + and failure propagation. Typed password input must not be mistaken for skip. +6. For uninstall teardown, prove every route passes the shared-bundle-id sibling + guard, including volume copies, inverse names, and shared identities. +7. Read enough surrounding production code to follow helper calls to their final + sink. Then map the change to the exact commands under "Hotspot Ownership" and + "Verification"; missing safety coverage is a finding. + +## Severity + +- **P0**: a path can escape its intended target, protection/confirmation/dry-run + is bypassed, a destructive failure can be reported as success, or a privileged + action can execute during ordinary verification. +- **P1**: matching is broader than exact evidence, a teardown route bypasses a + shared guard, active user/developer state can be removed, package-manager + candidates are not previewed, or a safety regression lacks a direct test. +- **P2**: the behavior is bounded but the documented targeted verification was + not run or the failure output is not actionable. + +Do not flag style, speculative refactors, or test-only panic/expect patterns. +If a helper or guard cannot be traced, report it as `UNVERIFIED` rather than +assuming it is safe. + +## Output + +Order findings by severity: + +``` +P0: : - + Why unsafe: + Fix: +``` + +Use the same shape for P1 and P2. End with `VERDICT: changes required` when any +P0 or P1 exists, otherwise `VERDICT: safe to merge`. With no findings or +unverified items, output only `VERDICT: safe to merge`. diff --git a/Resources/mole/.claude/hooks/format-on-edit.sh b/Resources/mole/.claude/hooks/format-on-edit.sh new file mode 100755 index 0000000..751cdf8 --- /dev/null +++ b/Resources/mole/.claude/hooks/format-on-edit.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Format files changed by Claude Code with the project's configured formatters. +# Stdin is the tool hook payload (JSON). Failures must not block the edit. + +set -u + +if ! command -v jq > /dev/null 2>&1; then + exit 0 +fi + +PAYLOAD=$(cat) +HOOK_CWD=$(printf '%s' "$PAYLOAD" | jq -r '.cwd // empty' 2> /dev/null || true) +[[ -n "$HOOK_CWD" ]] || HOOK_CWD="$PWD" + +PROJECT_ROOT=$(git -C "$HOOK_CWD" rev-parse --show-toplevel 2> /dev/null || true) +[[ -n "$PROJECT_ROOT" ]] || exit 0 +PROJECT_ROOT=$(cd -P "$PROJECT_ROOT" 2> /dev/null && pwd) || exit 0 + +format_repo_file() { + local input_path="$1" + local candidate="" + local candidate_dir="" + local resolved="" + + case "$input_path" in + /*) candidate="$input_path" ;; + *) candidate="$HOOK_CWD/$input_path" ;; + esac + + # Never follow an edited symlink outside the repository. + [[ -f "$candidate" && ! -L "$candidate" ]] || return 0 + candidate_dir=$(cd -P "$(dirname "$candidate")" 2> /dev/null && pwd) || return 0 + resolved="$candidate_dir/$(basename "$candidate")" + case "$resolved" in + "$PROJECT_ROOT"/*) ;; + *) return 0 ;; + esac + + case "$resolved" in + *.sh | "$PROJECT_ROOT/mole") + if command -v shfmt > /dev/null 2>&1; then + shfmt -i 4 -ci -sr -w "$resolved" > /dev/null 2>&1 || true + fi + ;; + *.go) + if command -v goimports > /dev/null 2>&1; then + goimports -w -local github.com/tw93/mole "$resolved" > /dev/null 2>&1 || true + elif command -v gofmt > /dev/null 2>&1; then + gofmt -w "$resolved" > /dev/null 2>&1 || true + fi + ;; + esac +} + +FILE=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2> /dev/null || true) +[[ -n "$FILE" ]] && format_repo_file "$FILE" + +exit 0 diff --git a/Resources/mole/.claude/settings.json b/Resources/mole/.claude/settings.json new file mode 100644 index 0000000..2585317 --- /dev/null +++ b/Resources/mole/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh" + } + ] + } + ] + } +} diff --git a/Resources/mole/.claude/skills/bugs/SKILL.md b/Resources/mole/.claude/skills/bugs/SKILL.md new file mode 100644 index 0000000..ff57b0e --- /dev/null +++ b/Resources/mole/.claude/skills/bugs/SKILL.md @@ -0,0 +1,231 @@ +--- +name: bugs +description: "Mole's project-specific defect catalog: twelve recurring bug shapes, grep probes, and regression guards. Use when reviewing, auditing, debugging, or accepting a contributed PR in Mole. Not for generic review workflow or unrelated repositories." +--- + +# Mole bug patterns + +Use this project-specific catalog after reading the current diff and code. Generic pattern sweeps belong to Waza `check` Pattern-Fix Completeness; root-causing a live symptom belongs to `hunt`. + +## How to use the catalog + +- **Sweep siblings without waiting for another report.** One instance of a recurring shape is evidence to inspect every same-shape call site. +- **A fix ships with a guard.** Add a regression or source-invariant test that fails on the pre-fix code. Inspect current history only when a numerical trend matters to the decision. +- **The dominant defect is not a crash.** It is a path deleted on weak evidence, a number that disagrees with another number computed elsewhere, or a scan that looks hung while it is merely unbounded. Nothing throws. So the productive question is never "can this crash", it is: + + > What does this produce when the probe is denied, the app is installed in a place the probe does not look, the machine is slow but healthy, or the cache was written by the previous release? + +## The twelve archetypes + +Ranked by how often they recur. Walk the ones the area touches and write down present / absent / unsure for each. "Absent" is a result worth reporting. + +| # | Shape | Probe | Evidence | +|---|---|---|---| +| 1 | Deletion candidate built from a weak name signal | grep name-derived globs | `3fa3eb5c` `5498edd1` `ec1cd647` `229bd0f9` | +| 2 | Existence decided by a single probe | grep `mdfind` / `command -v` / `pgrep` as sole gate | `6a055de4` `28ee58c9` `37a446c9` | +| 3 | Guard present on one branch only | diff dry-run branch against real branch | `cfe14601` `36f52a95` `8c781372` `3f42ad39` | +| 4 | Unbounded external command | grep the command, count `run_with_timeout` wraps | `edb214c0` `35d856f1` `63030e3a` | +| 5 | bash 3.2, errexit, pipefail semantics | grep array expansions and `fn \|\| handler` | `893b4e6f` `2c06cb91` `a33a0b51` | +| 6 | TTY, stdin, and process-group theft | grep background callers of `run_with_timeout` | `c93afca3` `63030e3a` | +| 7 | Parsing system command output | grep for missing `LC_ALL=C` and format assumptions | `4e83743b` `51b352a2` `f0896d03` | +| 8 | Stale persisted derived data | grep cache write sites, check schema and invalidation | `7a996aa5` | +| 9 | Two paths computing the same number differently | find every total, assert they agree | `3cbafed7` `7a996aa5` | +| 10 | Silence read as a freeze | walk each section for a >1s gap with no spinner | `8f064707` `c4258f5e` | +| 11 | Test that cannot fail | grep bare `[[ ]]` assertions, then verify red-green | `1b127787` `4db8a0d8` `20392444` | +| 12 | A gate that cannot say why it refused | count distinct `return 1` causes against distinct messages | `e2020772` `926c2efa` `46f5ba77` | + +### 1. Deletion candidate built from a weak name signal + +The most expensive class in this repo, and the one that produced both reverts. A matcher derived from a display name, a bundle-id prefix, or a substring glob will eventually match a neighbour. + +- `find_app_files` built `~/.config/` from a GUI app's display name, so uninstalling Claude.app wiped the Claude Code CLI's entire state directory. Case-insensitive APFS widened it further (`3fa3eb5c`). +- A `${bundle_id}*.plist` glob matched sibling vendors: `com.foo` also matched `com.foobar.plist` (`5498edd1`). +- Downstream matchers are substring-based, so uninstalling `Foo.app` while `Foo-beta.app` survived still removed the survivor's launch agents (`ec1cd647`). +- A TeamID-prefix wildcard in a fallback branch is why PR #874 and #875 were merged and then reverted (`229bd0f9`, `bc7f4c0a`). + +```bash +command grep -rnE '\*\$\{?(app_name|bundle_id|name)\}?\*|\$\{bundle_id\}\*' lib/ bin/ +``` + +For every hit, name the narrowest evidence that authorizes the delete. Exact bundle id or exact app path passes. Vendor prefix, generic word, or fallback wildcard does not. Check the fallback branch separately: it regresses to a broad glob even when the primary branch looks correct. + +### 2. Existence decided by a single probe + +Every "is this app installed" and "is this service active" question in this repo has been wrong at least once because it asked exactly one source. + +- `mdfind` alone misses Homebrew casks with no metadata importer and never indexes SMJobBless helpers embedded under `Contents/Library/LaunchServices` (`6a055de4`). +- `command -v` plus a LaunchAgents grep only covers CLI-style owners, so `~/.bridge` was flagged orphan while Proton Mail Bridge.app was installed (`28ee58c9`). +- Any UP `utun*` interface read as "VPN active" flagged every Mac with iCloud Private Relay (`37a446c9`). +- A probe can carry side effects that outweigh its answer: `brew list mole` asked whether Homebrew owns the install, but brew's entry point resets the user's sudo timestamp, so the probe executed the pre-authed ticket and every update paid a second password prompt (`cb4a3d66`). When a filesystem fact answers the question (the Cellar directory), never run the tool. Then apply this archetype to the replacement: the first Cellar version checked only `HOMEBREW_PREFIX`, `/opt/homebrew`, and `/usr/local`, so a custom prefix that does not export the variable went undetected where the old query had found it (`73f89841`). Swapping a probe for a filesystem fact still owes you every legitimate location of that fact, which here means deriving the prefix from `command -v brew` as well; reading the path is not running the binary. + +```bash +command grep -rn 'mdfind' lib/ bin/ | command grep -v run_with_timeout +``` + +The method: for each predicate, list every way the subject can legitimately exist, then check the probe sees all of them. Slow Spotlight is a timeout, not an absence; treat a timed-out probe as unknown and fall back to the filesystem rather than concluding "not installed". + +### 3. Guard present on one branch only + +Protection that lives at the call site instead of in the funnel will be missing from the next call site. + +- `should_protect_path` ran only inside the real-clean branch, so `--dry-run` promised to remove files the real run silently skipped (`cfe14601`). +- The user whitelist was consulted per caller, so one `clean_*` function simply forgot it on a system sweep. The fix hoisted the check into `safe_find_delete` and `safe_sudo_find_delete` next to the existing protection gate, so future callers get it for free (`5498edd1`). The forgetful caller has since been renamed; the commit names it, and this line deliberately does not, because a dead symbol here reads as a stale catalog. +- A Raycast v2 exclusion existed in one place but not in the `find` predicates that actually ran (`452e194d`). +- `_safe_clean_impl` ran its delete guard only on the real branch, so dry-run previewed (and counted) items an active-process guard would refuse at the same moment. The guard must run after protected, whitelisted, compiled-cache, and missing targets are filtered, but before any preview registration; otherwise dry-run can report a stopped cleanup whose real candidate set is empty (`3f42ad39`). + +The method: enumerate every caller of each protection helper, then every deletion site, and diff the two lists. The gap is the bug. Then check dry-run and real paths compute the same verdict, and prefer moving the guard into `validate_path_for_deletion` / `should_protect_path` over adding a fourth call site. + +### 4. Unbounded external command + +`du`, `mdfind`, `find`, `xcrun simctl`, and `brew` have no internal bound, and the caller usually pipes them into a command substitution that just waits. One stalled SMB mount wedges the whole scan. + +Every production `du -s` site should route through the timeout wrapper. `tests/core_timeout.bats` pins that with a source-invariant test; copy the shape for any new unbounded command. + +Installed-binary verification is the same class: every post-update `mo --version` or `"$mole_path" --version` probe must use `run_with_timeout` with `MOLE_TIMEOUT_QUICK_DETECT_SEC`, and standalone `install.sh` must use its bounded local wrapper for both `--version` and `--help`. A broken executable is exactly when a verification probe is most likely to hang. The update entrypoint also stays single-flight per install directory; otherwise one process can verify another process's metadata or binary generation. Standalone install and self-heal verification share the same target-adjacent `/usr/bin/lockf`; keep the kernel lock held by a parent-liveness-bound process instead of replacing it with a check-then-remove shell sequence. + +Two subtler variants: + +- **Checkpoint at the wrong nesting level.** `probe_project_artifact_hints` checked its deadline at the top of each root but not inside the nested-subdirectory loop, so once an iteration was entered it ran up to 120 more times past the budget. Every loop level needs its own checkpoint, not just the outer one (`edb214c0`). +- **A timeout tuned on a warm machine.** CoreSimulatorService takes over 2s on cold boot, so a 2s probe reported "simctl not available" (`35d856f1`). For each constant, name the slowest healthy case and check the constant clears it. +- **A bounded producer with an unbounded or status-blind consumer.** Wrapping `find` is not enough when process substitution adds `|| true`, hides status 124, and lets a deletion loop consume the partial prefix. Materialize the complete scan first, discard it on any nonzero status, then run the guarded delete pass. Bound the delete command too and propagate timeout/failure instead of returning a false success. +- **Probe and action use different eligibility plans.** A shallow `find -maxdepth 1` probe followed by a depth-5 delete does not authorize what the action reaches. Use one scan-to-delete helper, or make pattern, type, age, and depth identical and test the exact arguments. +- **The bound is on the right command but the slow stage is the consumer.** The lsregister dump finished in 2.3s; the bash parser behind it forked one command substitution per line and turned 250k lines into minutes, so the scan blew a 10s, then 30s, then 60s bound while every bump targeted the wrong stage. Before ever raising a timeout, time the producer and the consumer separately; a bash `while read` with `$(...)` per iteration over command output is minutes the moment the input is large, and belongs in one awk pass with the audited policy filter kept in bash over the survivors. Small bounded inputs (a directory listing, a plugin folder) are fine; a machine-wide dump is not. + +```bash +for c in 'du -s' mdfind xcrun system_profiler ioreg brew; do + printf '%-16s total=%-4s wrapped=%s\n' "$c" \ + "$(command grep -rn -- "$c" lib/ bin/ | wc -l | tr -d ' ')" \ + "$(command grep -rn -- "$c" lib/ bin/ | command grep -c run_with_timeout)" +done +``` + +### 5. bash 3.2, errexit, pipefail semantics + +macOS ships bash 3.2.57 and the shipped code runs under `set -u`. Read [references/shell-and-test-pitfalls.md](references/shell-and-test-pitfalls.md) before changing Shell code, Bats tests, install/update flows, timeout wrappers, TTY handling, plist fixtures, or macOS-specific CI behavior. The two highest-frequency shapes: + +- **Empty array expansion under nounset.** `"${arr[@]}"` on an empty array aborts. When it aborts inside a scan, the spinner subshell is orphaned and the user sees "scanning forever" (`893b4e6f`, `2c06cb91`). Guard with `[[ ${#arr[@]} -gt 0 ]]`. +- **`fn || handler` disables errexit inside `fn` for its whole body**, converting every unchecked failure into a no-op. That is how eight consecutive failed copies still reported a successful install. Safety-critical steps use explicit `if ! cmd; then return 1; fi`. +- **A graceful-skip path that only works because the caller's section window ran `set +e`.** `clean_orphaned_app_data` called `scan_installed_apps` bare, then read `$?`; under errexit the failing call aborts the shell before the skip message prints, and only the `set +e` window in `bin/clean.sh` masked it. Capture failures with explicit `if !` so degradation does not depend on the calling environment (`a33a0b51`). + +```bash +command grep -rn '\$\{[a-z_]*\[@\]\}' lib/ bin/ | wc -l # spot-check new sites +``` + +### 6. TTY, stdin, and process-group theft + +Background workers that never need the terminal keep stealing it. + +- The perl timeout fallback hands the controlling terminal to its child whenever stdin is a tty. A background metadata-refresh worker still holding the tty stole the foreground process group, so `mo uninstall` stopped with SIGTTIN at the confirmation prompt (`c93afca3`). +- BSD `mv`/`cp` prompt on stderr and read stdin when the destination exists and is not writable, so the UI froze on a `getchar()` with the spinner pinned to "Updating cache..." (`63030e3a`). + +The method: every background subshell, `&`, or disowned worker that calls `run_with_timeout` needs `< /dev/null`. Every command that can prompt needs stdin closed plus `-f`. Every trap installed by a menu or scan must save and restore the caller's traps (`lib/ui/menu_paginated.sh` is the reference implementation). + +### 7. Parsing system command output + +The output of a macOS tool is not a stable contract: it is localized, it drifts across OS releases, and its error text looks like data. + +- Metric subprocesses inherited the user's locale, so comma-decimal locales broke process collection, then system-health rendering, then more metrics. The eventual fix forces `LC_ALL=C` for every metric subprocess rather than patching each parser (`51b352a2`, `fa05b8cc`, `4e83743b`). Note the shape: three separate reports before someone fixed the class. +- `DTSDKBuild` ("24A335") was compared as a version string where `DTPlatformVersion` ("15.0") was meant (`f0896d03`). +- PlistBuddy prints `File Doesn't Exist, Will Create:` to stdout, and that text has been accepted as data. +- On stock macOS, `grep -Z` means `--decompress`, so a `grep -rlZ | while read -d ''` loop shipped dead for months. + +The method: force `LC_ALL=C` on anything parsed, validate the shape before trusting a field (absolute path, numeric, expected key present), reject error text as data, and prefer a structured signal (exit code, plist key) over re-parsing prose. Note that `grep` on a dev machine here may be ugrep-aliased; use `command grep` when flag behavior matters. + +### 8. Stale persisted derived data + +Changing how a cached value is computed without invalidating the cache means the fix is invisible and the old value keeps shipping. + +`7a996aa5` is the model: the hardlink dedup fix bumped the cache schema to v2 to discard entries written before the change, and marked dedup-dependent subtrees non-cacheable so a standalone re-scan is not poisoned. The analyze cache has separately needed expiry, selective invalidation on delete, and a manual-refresh path that bypasses nested caches. + +The method: for every cache, confirm it has a TTL, a schema version, and invalidation on each mutation that changes its inputs. When a fix changes a computed value, bump the schema in the same commit. When verifying any fix, confirm you are not reading last release's cache. + +A TTL proves "not too old". It never proves "complete", so check what the caller does with the answer. `pkg_receipt_nonstandard_app_paths --require-complete` feeds the shared-bundle-id sibling guard, which reads a complete answer as proof that no other install owns an app's leftovers; the cache short-circuit returned 0 without consulting `require_complete`, so a package installed after the last write stayed invisible for an hour and the guard cleared leftovers the survivor still needed (`b4f00651`). When a result is consumed as proof of absence, either bypass the cache for those callers or key it to a fingerprint of the evidence itself, so the entry dies the moment the evidence changes. That cache now stores a checksum of `pkgutil --pkgs` as a header: installing anything adds a receipt, which changes the fingerprint. Re-verifying each cached path still exists only filters entries that disappeared, never ones that appeared. + +### 9. Two paths computing the same number differently + +Any number rendered twice will eventually disagree: dry-run preview against the summary total, the item count against the raw target count, a subtree size against `du`, base-10 against base-2. + +The method: locate every site that computes a given total and make one of them the definition. Then add a test that compares the two renderings rather than asserting a literal, which is what `tests/clean_core.bats` does for preview against summary totals. Sub-megabyte rounding to `0` and per-link counting of hardlinks are both in this family. + +### 10. Silence read as a freeze + +Silence is often mistaken for a freeze when slow work happens outside the spinner window. + +The spinner was stopped at the start of the removal loop, so the terminal was silent for the full removal (`8f064707`). Dotdir, login-item, System Data, and large-file scans ran for seconds with no loading state, leaving the section blank. + +The method: walk each section and ask whether every operation over roughly one second sits inside a spinner window, and whether the spinner stops immediately before the line it would otherwise paint over. Section output follows one fixed rhythm here: title, loading state, content, one trailing blank line. When touching any step, re-read the whole rendered output rather than the one step reported. + +### 11. Test that cannot fail + +The meta-bug. Several regression tests passed against the pre-fix code, so the fix was never actually pinned. + +A non-final `[[ ]]` that returns non-zero does not fail the test; a non-final `[ ]` does. The bracket form decides it, which is why the same test can catch a crashed subshell through `[ "$status" -eq 0 ]` while every `[[ "$output" == ... ]]` above the last one is dead weight. Minimal repro, run it before trusting any assertion in this suite: + +```bash +cat > tests/zz_min.bats <<'EOF' +@test "non-final [[ ]] false" { [[ 1 -eq 2 ]]; [[ 1 -eq 1 ]]; } +@test "non-final [ ] false" { [ 1 -eq 2 ]; [ 1 -eq 1 ]; } +EOF +bats tests/zz_min.bats # test 1 passes, test 2 fails +rm tests/zz_min.bats +``` + +Count ineffective assertions per test block, not per line: a line-level grep includes final assertions and overstates the problem. Treat any count as a live diagnostic, not durable project truth. + +Four other ways a test here has passed vacuously: + +- `MOLE_TEST_MODE=1` (exported by `scripts/test.sh`) makes the function under test early-return, leaving `$output` empty, so a negative `!=` assertion is trivially true. Override to `0` and mock `sudo -n true` when the body must run. +- A shell-function mock puts the test in the wrong branch. Mocking `xcrun` as a function took the `declare -F xcrun` path, not the timeout-retry path where the fix lived. Use a PATH stub directory when the code under test execs the binary, because `run_with_timeout` execs and bypasses function mocks. +- A timeout test checks elapsed time but accepts status 0, so the old "swallow 124 and continue" implementation still passes. Assert the exact nonzero status, prove partial output was discarded, and add a positive trace showing the production external-command branch ran. +- A test inherited a previous test's cache through the shared `HOME`, so it validated a stale cache instead of a real scan. +- The asserted string does not exist. A Maven test asserted the absence of "Maven repository cache" when the real label is "Maven local repository", so it passed even if protection regressed. + +The rule: end every assertion with `|| return 1`, include a positive control proving the negative assertions are not vacuous, and verify red-green by reverting the fix and watching the test fail. + +The mirror image is also a test defect, not a product bug. A case that fails in the full suite and passes in isolation is usually asserting a boundary the code never promised: `get_path_size_kb` was given a 1s budget and the test asserted `mdls` had run, but deadlines count in whole `SECONDS`, so that budget can collapse before the probe is spawned (`e95dd750`). A case can also fail on prose: a source-invariant test grepped `install.sh` for `brew list mole`, and the comment explaining why that call was removed read as the call coming back (`73f89841`). Before chasing a red run into production code, ask whether the assertion is timing-sensitive or is matching text outside the code path. + +### 12. A gate that cannot say why it refused + +A guard with many independent failure causes and one message. The user cannot act, and the maintainer cannot triage, so the report arrives as "it does not work" and the fix targets whichever wording was quoted. + +`acquire_install_lock` refused for an untrusted ancestor, a denied `sudo -n`, an unusable lock directory, a lock path replaced by a symlink or fifo, a missing `/usr/bin/lockf`, and genuine contention. All printed one line about the lock being unavailable. Counting the causes is the probe, but do not quote the count here: it moves with every refactor, and a number this file cannot measure reads as rot the next time someone checks it. Three things followed, and each is worth checking for separately: + +- **The reporter did the triage.** #1335 reverse-engineered `install_lock_has_unsafe_ancestor` by hand from the source to learn why a plain install failed. +- **A new gate silently downgraded an older diagnosis.** `d4a4b80c` already printed the actionable `Cache credentials first, then retry: sudo -v && mo update` for a missing admin session. `e2020772` put the lock in front of it, hit the same condition first, and reported it as a busy lock. Nothing failed; the diagnosis just got worse. When adding a gate ahead of an existing failure path, read what the old path said and keep the new one at least as actionable. +- **The test suite pinned the regression.** `926c2efa` replaced one catch-all string with another and added `grep -qF 'Could not acquire the Mole installation lock for'` as a source invariant, making the vague message a requirement. Pin reason codes, never a catch-all string. + +Swallowed stderr is what hides this class during debugging: the privileged steps ran under `2> /dev/null`, so no run of the real command ever showed the underlying `sudo` error. Static reading went in circles until a differential probe (same code with and without a controlling terminal) isolated it in one run. + +```bash +# Distinct failure causes vs distinct messages, per gate. +command grep -c 'return 1' install.sh +command grep -c 'log_error' install.sh +# Any privileged step whose stderr cannot reach the user. +command grep -rn 'sudo .*2> */dev/null' install.sh lib/ +``` + +For each gate, list every reachable `return 1` and name the message and the remedy a user gets from it. Two causes sharing one message is the defect; a cause whose remedy is "reinstall" when reinstalling re-enters the same gate is the same defect wearing a fix. + +## Three methods that produced most of the finds + +1. **Diff two things that must agree.** Dry-run against real, preview total against summary total, cached against cold, first paint against refresh, the guard's caller list against the deletion-site list. Disagreement is mechanical to find and almost always a real defect. +2. **Enumerate call sites, not files.** Sweeping file by file finds far less than picking one helper (`should_protect_path`, `is_path_whitelisted`, `mole_delete`, `run_with_timeout`) and checking every site that should route through it. +3. **Turn the fix into a source invariant.** A one-off regression test pins one instance; a bats test that greps `lib/` and `bin/` pins the class. `tests/core_timeout.bats` (unbounded `du`) and the unsafe-`rm` scan in `.github/workflows/test.yml` are the two working examples. Prefer this whenever the bug is "someone will add another call site and forget". + +## Verification bar + +Never report a defect inferred from a function name or a file name. Grep the implementation. Confirm the code is production code: an unguarded call inside a bats fixture, a Go `_test.go` file, a comment, or a string literal is not a defect. + +```bash +./scripts/check.sh --format +MOLE_TEST_NO_AUTH=1 bats tests/.bats +MOLE_TEST_NO_AUTH=1 ./scripts/test.sh +go test ./... +MOLE_DRY_RUN=1 ./mole clean +``` + +Per-area test targets are listed under "Hotspot Ownership" in `AGENTS.md`. Use those rather than guessing. + +## Sibling sweep obligation + +One archetype hit means sweep the repo for its signature. Every first pass over a pattern in this history under-counted, and the follow-up review always found more. Grep the shape, not the literal text, and report the count: checked N sites, M defective, K not applicable. A bug the maintainer has seen before ("this was fixed once already") ships with a guard, not just a patch. diff --git a/Resources/mole/.claude/skills/bugs/references/shell-and-test-pitfalls.md b/Resources/mole/.claude/skills/bugs/references/shell-and-test-pitfalls.md new file mode 100644 index 0000000..8d4fb18 --- /dev/null +++ b/Resources/mole/.claude/skills/bugs/references/shell-and-test-pitfalls.md @@ -0,0 +1,20 @@ +# Shell and test pitfalls + +Read this reference when changing Shell code, Bats tests, update/install flows, timeout wrappers, TTY handling, plist fixtures, or macOS-version-specific CI behavior. The defect classes and repo-wide probes stay in the parent `bugs` skill. + +- **`BASH_SOURCE` / `$0` change meaning when a function moves files**: they name the file the code lives in, so copy-paste extraction is not behavior-preserving. `mole` captures `MOLE_ENTRY_SCRIPT="${BASH_SOURCE[0]}"` before sourcing anything, and update code reads that stable entrypoint. Before extracting a function, grep it for `BASH_SOURCE`, `$0`, and `FUNCNAME`. Regression coverage lives in `tests/update.bats`. +- **Every `du -s` must run under `run_with_timeout`**: one stalled mount can wedge the whole scan. Use `MOLE_TIMEOUT_DISK_VERIFY_SEC`. `tests/core_timeout.bats` pins the source invariant across `lib/` and `bin/`. +- **Bash 3.2 nounset rejects empty array expansion**: guard `"${arr[@]}"` with `[[ ${#arr[@]} -gt 0 ]]` under `set -u`. +- **`fn || handler` disables errexit inside `fn` for its whole body**: safety-critical steps must use explicit `if ! cmd; then return 1; fi` checks and installers must verify the installed binary's reported version before claiming success. `tests/install_checksum.bats` covers the exact caller shape. +- **`[[ -n "$var" ]] && cmd` returns 1 when the variable is empty**: inside exit-code-sensitive blocks, use `if/fi` so an optional action does not turn the block into failure. +- **Bats heredocs share stdin with `read -n1`**: an inner `read -r -s -n1` can consume the next byte of the heredoc source. Redirect the function under test from `/dev/null`. +- **macOS `script(1)` rejects socket-backed stdin**: PTY test helpers must redirect the wrapper's stdin from `/dev/null` or `script` can fail before starting the child. +- **`run_with_timeout` execs the binary and bypasses shell-function mocks**: tests must use a PATH stub directory for commands such as `osascript`. +- **CI runners may lack `/Library/PrivilegedHelperTools`**: orphan-service tests should exercise `/Library/LaunchDaemons`, which exists on GitHub macOS runners. +- **A test can pass vacuously after an early return**: `MOLE_TEST_MODE=1` can leave `$output` empty, and a final negative assertion then passes. End assertions with `|| return 1`, override test mode when the body must run, and add a positive control proving the output path executed. In an inner heredoc script use `|| exit 1`. Confirm the bracket behavior with a minimal repro when it matters: a non-final `[[ ]]` can be swallowed while `[ ]` still gates. +- **A large payload piped into `grep -q` leaks a broken-pipe line into user output**: `grep -q` exits on its first match, and the `printf` still writing into that closed pipe takes SIGPIPE, which bash reports as `printf: write error: Broken pipe` on stderr. The live-cache owner probe fed the whole process table that way and the message landed mid-run in `mo clean`. Pass the data by here-string instead. Small variables holding a few lines of command output finish in one write and are unaffected, so the existing `echo "$var" | grep -q` sites are fine. +- **Normalize with `10#` before any numeric comparison**: `[[ a -le b ]]` evaluates arithmetically, so a leading zero is read as octal and `0123` ranks below `100`, while `08` and `09` are not valid octal at all and abort the test with a bash error on stderr. A `^[0-9]+$` guard does not prevent either. Codex build numbers compared this way could have called a newer staged build superseded. +- **`SECONDS` advances in whole seconds, so a 1s budget is not a second**: a deadline built as `SECONDS + 1` really means "until the next second boundary" and can collapse to almost nothing, making `_mole_timeout_with_deadline` return 124 before the command ever runs. Every timeout constant is 2 or more for this reason; keep new budgets there, and never assert on a one-second bound in a test. +- **BSD grep has no GNU null-output `-Z` contract**: on stock macOS it means `--decompress`. Enumerate files with `find ... -print0`, then probe each file with `grep -qF`. +- **PlistBuddy reports missing-file creation on stdout**: redirect both stdout and stderr when creating plist fixtures so diagnostic prose does not pollute Bats `$output`. +- **macOS 14 Bash can fire errexit through an if-guarded exported mock**: a failing exported `sudo` function inside an `if fn; then` path may terminate a `set -e` script on that runner while passing locally. Around the first sudo probe, disable errexit only for the probe and restore it before validation-gate returns. CI-only failures must print exit status, output, and a mock call trace rather than a bare return-code assertion. diff --git a/Resources/mole/.claude/skills/mole/SKILL.md b/Resources/mole/.claude/skills/mole/SKILL.md new file mode 100644 index 0000000..dd26333 --- /dev/null +++ b/Resources/mole/.claude/skills/mole/SKILL.md @@ -0,0 +1,106 @@ +--- +name: mole +description: "Drive the installed Mole CLI (`mo`) safely, including machine-readable status, analysis, history, and dry-run surfaces. Use before running `mo` on a user's Mac. Not for editing or reviewing Mole source code." +--- + +# Using Mole from an agent + +Mole (`mo`) cleans, uninstalls, analyzes, optimizes, and monitors a Mac. It is +a real deletion tool operating on someone's live machine, so the way an agent +uses it differs from the way a human does: never guess, never let a TUI decide, +and never let a destructive command run without the user having seen the list. + +## The rules + +1. **Preview before you delete. Always.** Every destructive command takes + `--dry-run`. Run it, read the result, show the user what would go, and only + then offer the real run. An agent that runs `mo clean` before `mo clean + --dry-run` has skipped the only step the user can veto. +2. **The user runs the destructive command, not you**, unless they explicitly + asked you to do it in the current turn. "Clean my Mac" is such an ask; + "why is my disk full" is not. +3. **Never parse a TUI frame.** Interactive `mo analyze` and terminal-attached + `mo status` are full-screen Go programs whose output is drawn, not printed. + Use `mo analyze --json`, `mo status --json`, or `mo status --watch` instead. +4. **Never invent flags.** The command surface is small and listed here; if + something is not on this page, run `mo --help` and read it, do not + assume a `--yes` or `--force` exists. +5. **Protection is a whitelist, not an argument.** If the user wants a cache + kept, the answer is `mo clean --whitelist`, not a hand-rolled `find`. Never + work around Mole's safety layer with raw `rm`. + +## What answers which question + +| The user asks | Command | +|---|---| +| "What is eating my disk?" | `mo analyze --json` (whole disk) or `mo analyze --json` | +| "Free up space" | `mo clean --dry-run`, review, then `mo clean` | +| "Remove this app completely" | `mo uninstall --dry-run` then `mo uninstall` | +| "My Mac feels slow" / caches look broken | `mo optimize --dry-run` then `mo optimize` | +| "Clean up my old projects" | `mo purge --dry-run` then `mo purge` | +| "Get rid of downloaded installers" | `mo installer --dry-run` then `mo installer` | +| "What did Mole delete?" | `mo history --json --limit 20` | +| One CPU / memory / disk / network snapshot | `mo status --json` | +| A short time series for diagnosis | `mo status --watch --interval 1s` (NDJSON; stop after enough samples) | + +## Machine-readable surfaces + +These four surfaces are the agent-facing API. Everything else is for humans. + +**Disk usage.** `mo analyze --json` prints one JSON object: `path`, `overview`, +and `entries[]` of `{name, path, size, is_dir, insight}`. `size` is bytes. +`insight: true` marks an entry Mole considers noteworthy (a large iOS backup, a +runaway cache). Pass a path to scope it: `mo analyze ~/Library --json`. + +**Cleanup history.** `mo history --json [--limit N]` (N is 1-200) prints +`logs` (paths of the operations and deletions logs) plus `sessions[]` with +`command`, `started_at`, `items`, `size`, and an `actions` breakdown of +removed / trashed / skipped / failed. This is how you answer "did Mole delete +my file" without guessing: the deletions log has the paths. + +**The dry-run path list.** `mo clean --dry-run` prints a summary to the +terminal and writes every candidate path to `~/.config/mole/clean-list.txt`. +Read that file, not the terminal output, when you need to reason about or show +the user exactly what a real run would remove. This list is clean-only: `mo +purge --dry-run` and `mo installer --dry-run` print their candidates to the +terminal and write no file. + +**System status.** `mo status --json` prints one metrics snapshot. It also +switches to JSON automatically when stdout is not a TTY, but pass `--json` +explicitly in scripts so intent stays obvious. `mo status --watch --interval +1s` emits one complete JSON object per line from a warm collector. Bound the +watch duration or sample count and terminate it after collecting the evidence +the user asked for; do not leave an unbounded monitor running in the background. + +## Command notes worth knowing + +- `mo clean` also sweeps leftovers from apps the user already deleted. It does + not touch installed apps; that is `mo uninstall`. +- `mo clean --external ` cleans macOS metadata off an external volume. +- `mo purge` removes rebuildable project artifacts: local build output + (`target/`, `build/`, `dist/`, `.next/`) and dependency directories that need + a network to restore (`node_modules/`, `Pods/`, `venv/`, `vendor/`). A purge + is therefore not always recoverable offline, so say which kind the candidates + are before running it. `mo purge --paths` configures which directories are + scanned; `--include-empty` shows zero-size candidates. +- `mo optimize` refreshes caches and system services. It is the one destructive + command whose effects are not "files disappear", so say what it will do + before running it. +- `mo update` self-updates; `mo update --nightly` installs unreleased `main`. + Do not run either on a user's behalf without being asked. +- `--debug` on any command prints the detailed operation log. Reach for it when + a command silently did nothing; do not leave it on in normal use. + +## When something goes wrong + +**`mo clean` deletions are permanent by default.** Cache cleanup removes files +rather than moving them to the Trash, so there is usually nothing to restore. +That is exactly why rule 1 exists: the dry-run is the undo. `mo uninstall` is +the exception: it routes the app and its leftovers through the Trash, so an +uninstalled app is recoverable until the Trash is emptied. + +What you do have is a record. `mo history --json` names the deletions log, and +every deletion is one tab-separated line in it: timestamp, mode, size, status, +path. So when a user asks "did Mole take my file", read the log and answer with +the actual line instead of guessing. Then add the path to the whitelist (`mo +clean --whitelist`) so the next run leaves it alone. diff --git a/Resources/mole/.claude/skills/release-flow/SKILL.md b/Resources/mole/.claude/skills/release-flow/SKILL.md new file mode 100644 index 0000000..074dbc9 --- /dev/null +++ b/Resources/mole/.claude/skills/release-flow/SKILL.md @@ -0,0 +1,60 @@ +--- +name: release-flow +description: "Mole CLI release runbook for distribution channels, pre-flight checks, capital-V tags, artifacts, and curated-note handoff. Use when assessing or executing a Mole release. Not for release-note copy alone or ordinary code review." +--- + +# Mole CLI Release Flow + +Tag-driven flow. The `release.yml` workflow watches `'V*'` tag pushes (capital `V`), builds amd64 and arm64 binaries on macOS, generates `SHA256SUMS`, attaches build provenance, creates the GitHub Release without notes, then opens a Homebrew core PR. + +## Distribution channels + +| Channel | What ships | Trigger | Automation | +|---|---|---|---| +| Nightly (`mo update --nightly`) | `main` HEAD via `install.sh` | Any commit pushed to `main` | Automatic; no tag or release involved | +| GitHub stable release | amd64/arm64 binaries + `SHA256SUMS` | Push a capital-`V` tag | `release.yml` builds and creates the release; curated notes are a manual follow-up | +| Homebrew core | Version-bump PR to `Homebrew/homebrew-core` | Same `V*` tag workflow | Automatic PR; merge timing is upstream's | + +At the start of any release-flavored task, restate which channels this run will touch and which it will not, and confirm with the maintainer before acting. Channel scope is specified by the maintainer, never inferred. + +## Pre-flight checklist + +1. `grep '^VERSION=' mole` matches the new version. +2. `SECURITY_AUDIT.md` opening line reflects the new version and date. +3. `git status -s` is empty or only contains intentionally staged release work. +4. `git log origin/main..HEAD --oneline` shows only commits you intend to ship. +5. `./scripts/check.sh --format` and `MOLE_TEST_NO_AUTH=1 MOLE_TEST_JOBS=2 BATS_FORMATTER=tap ./scripts/test.sh` both exit 0. +6. `go test ./cmd/...` and `make build` both pass. + +## Tag and publish + +```bash +git push origin main +git tag V # capital V; release workflow ignores lowercase v +git push origin V +``` + +Wait for the workflow to finish. The workflow creates the release with assets but `generate_release_notes: false`, so notes must be added in a follow-up step. + +After the workflow finishes, verify the release assets before announcing anything: `gh release view V --json assets --jq '.assets[].name'` must list both architecture binaries AND `SHA256SUMS`. Install verification is fail-closed, so a release without a readable `SHA256SUMS` asset makes every install and `mo update` abort by design; a missing checksums file is a release blocker, not a cosmetic gap. + +Then run a **script self-update smoke** before publishing notes or announcing: install the previous stable release through the script channel, run `mo update`, and confirm `mo --version` prints the candidate version. Script-installed clients execute the new tag's `install.sh`, so this is the only gate that exercises their real upgrade path; the pre-flight suite cannot cover it before the release exists. Homebrew is a separate downstream gate: verify it only after the core formula has updated, and never treat a script-channel smoke as proof that Homebrew is ready. If the script smoke fails, pull the release (see the pulling-and-re-releasing pitfall) before anyone is told to update. + +## Apply curated release notes + +The curated-notes flow (bilingual format, `gh release edit` instead of `create`, thanks block, and the six-reaction set) is owned by `.claude/skills/release-notes/SKILL.md`. `.agents/skills/release-notes` is a symlink to that canonical directory for Codex discovery, and its Codex-only invocation policy lives in `agents/openai.yaml`; do not replace the symlink with a copied mirror. Follow that skill; do not duplicate its format details here. Version, codename, and emoji go only in the release title; the body h1 is just `Mole`. + +Ritual anchors: before drafting, read the latest stable release body as the hard format template (`gh release view --json body`); the title takes a codename plus emoji per repo convention (for example `V1.45.0 Quiet 🤫`). After publishing, add all six positive reactions (`+1`, `laugh`, `heart`, `hooray`, `rocket`, `eyes`) with `.claude/skills/release-notes/scripts/post-reactions.sh V` (the script lives inside the skill, not in the top-level `scripts/`), then re-read the release reactions to confirm all six landed. + +## Release-notes craft + +Format rules (impact ordering, command existence checks, icon semantics, no em dash, no inline PR refs) live in `.claude/skills/release-notes/SKILL.md` under "Format rules". Keep that skill as the single source of truth for notes formatting. + +## Release-only pitfalls + +- **`gh release create` conflicts with the workflow-created release**: the workflow already creates the release on tag push, so post-tag note publishing must use `gh release edit`, never `create`. +- **Tag prefix is case-sensitive**: `release.yml` filters on `'V*'`. A lowercase `v1.38.0` tag will not trigger the workflow. +- **Old clients fetch `install.sh` from the release tag, not from main**: a self-updating Mole downloads `raw.githubusercontent.com/tw93/mole/V/install.sh`, and tag content is immutable. An installer/updater bug therefore reaches existing stable users only through a new tag; fixing main changes Nightly but does not repair an already published stable updater. +- **Pulling and re-releasing a version**: `gh release delete V --cleanup-tag` removes the release and remote tag. Delete the local tag, close the superseded Homebrew core PR with a one-line supersede comment before pushing the replacement tag (an open PR for the same formula can block `brew bump-formula-pr`), then bump `VERSION` and `SECURITY_AUDIT.md`, commit `release: V`, tag, and run the normal publish flow. The Homebrew core PR regenerates on the new tag. + +When release work touches Shell code or tests, read `.claude/skills/bugs/references/shell-and-test-pitfalls.md` for Bash 3.2 arrays, heredoc input, mock bypasses, and CI-runner quirks. diff --git a/Resources/mole/.claude/skills/release-notes/SKILL.md b/Resources/mole/.claude/skills/release-notes/SKILL.md new file mode 100644 index 0000000..74b12da --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/SKILL.md @@ -0,0 +1,111 @@ +--- +name: release-notes +description: Publish curated release notes for an existing Mole `V` tag, including bilingual format, `gh release edit`, contributor thanks, and reactions. Use only when explicitly asked to edit or publish Mole release notes. Not for release readiness, tagging, or code review. +disable-model-invocation: true +--- + +# Mole release notes + +This skill drives the curated-notes step that runs **after** `release.yml` has finished. The workflow creates the GitHub Release with assets but with `generate_release_notes: false`, so notes must be added in a follow-up `gh release edit` (never `gh release create`, the release already exists, and `create` will conflict). + +## Inputs to gather + +Before drafting, confirm: + +1. **Version**. Capital `V`, e.g. `V1.38.0`. Lowercase `v` does not trigger the workflow and may indicate a botched tag. +2. **CodeName + emoji**. Ask the user. The title format is `V `. +3. **Release commit range**. `git log ..V --oneline` gives the raw material. +4. **User-visible behavior changes**. Scan the full commit message bodies (not just subjects) for narrowed detection, removed features, or controlled regressions. These belong in notes even when they are not bug-fix-shaped, because users will encounter the changed boundary in production. +5. **Issue reporters and PR contributors in this cycle**. Use the merged PRs and fixed issues in the release range. Keep it short, for example `Issue reporters and PR contributors this cycle: @a · @b.` Exclude `tw93` and bots. +6. **Verify release exists**. `gh release view V --repo tw93/Mole --json id,name` should return non-empty. If it doesn't, the workflow hasn't finished, wait, don't `gh release create`. + +## Pre-flight (cross-check against AGENTS.md) + +These should already be true if the tag was pushed correctly. Confirm before publishing notes: + +- `grep '^VERSION=' mole` matches ``. +- `SECURITY_AUDIT.md` opening line reflects the new version and date. +- `./scripts/check.sh --format` clean. +- `MOLE_TEST_NO_AUTH=1 MOLE_TEST_JOBS=2 BATS_FORMATTER=tap ./scripts/test.sh` exits 0. +- `go test ./cmd/...` and `make build` pass. + +If any fail, stop. The notes can wait; a bad release tag cannot. + +## Format + +Strictly follow the current compact release shape. Read the latest stable release as the live format reference before drafting: `gh release view --repo tw93/Mole --json tagName,body`. + +Structure: + +``` +
+ Mole Logo +

Mole

+

Deep clean and optimize your Mac.

+
+ +### Changelog + +1. ****: . +2. ... + +### 更新日志 + +1. **<中文 headline>**:<一句中文说明>。 +2. ... + +### Thanks + +Issue reporters and PR contributors this cycle: @handle1 · @handle2. + +### Mole Mac App + +Prefer a GUI? Try [Mole Mac App](https://mole.fit). The CLI stays free and open source. +``` + +No `---` separators between sections, and no trailing repository link; the published pages end on the Mole Mac App line. + +### Format rules (all are documented bugs that have shipped before) + +- **Body h1 is just `Mole`**. Version, codename, and emoji live only in the `--title` argument (`V `); repeating them in the body header is redundant and has been explicitly rejected before. +- **No em dash anywhere**. Use commas, periods, colons, semicolons, or parentheses. +- **No sponsor list by default**. The current public release style thanks issue reporters and PR contributors for this cycle only. +- **No emoji except the version emoji in the release title**. Body section headers stay plain, including `### Thanks` (the old `Thanks 💖` header is gone from the published pages). +- **No inline PR refs, no inline `@handle` thanks**. PRs and people belong in the dedicated Thanks block only. +- **English block first, 中文 block second**. Same numbered order in both blocks. Same number of items. +- **Order items by user-perceived impact, not commit chronology**. Headline change first; internal safety hardening, performance, and bug fixes follow. +- **Do not describe overview icons that no longer exist**. Analyze overview rows are text-only because emoji width and baselines vary across terminals. If icons return later, they must not imply that user data such as iOS Backups, Xcode Archives, or Old Downloads is safe to delete. +- **Verify every command mentioned in the notes actually exists in HEAD**. The deleted `mo check` / `mo doctor` commands nearly shipped in notes as a "feature" after they were removed. +- **An incident or troubleshooting note is one sentence of symptom plus one command**. No cause taxonomy, no command per branch; the user needs the one line that gets them unstuck. Match the previous release's language treatment for that note: if the last release carried it in one language, do not add a second. +- **Keep the Mole Mac App cross-link only if it matches the current release style**. Do not turn it into a sales block. + +## Publish + +Once the user approves the draft: + +```bash +gh release edit V --repo tw93/Mole \ + --title "V " \ + --notes-file +``` + +**Never** `gh release create`, it conflicts with the release the workflow already made. + +Then add the six reactions with this skill's helper (path is relative to this SKILL.md, not the repo-root `scripts/`): `bash "$(dirname )/scripts/post-reactions.sh" V`. + +## After publish + +- `gh release view V --repo tw93/Mole --web` (open in browser) so the user can eyeball it. +- Remind the user: the Homebrew Core PR is workflow-driven and should already be in flight; do not re-run it manually unless the workflow log shows a failure. + +## When NOT to act + +This skill is user-invocable only. It must not run unprompted: + +- If the user mentions release notes in passing, draft only; do not call `gh release edit`. +- If `gh release view` shows the release does not exist yet, wait for the workflow; do not create a competing release manually. +- If the user has not given an explicit "publish" / "提交" signal, stop after the draft. + +## Helper script + +`scripts/post-reactions.sh ` lives next to this SKILL.md and adds the six reactions (`+1`, `laugh`, `hooray`, `heart`, `rocket`, `eyes`) to the release. diff --git a/Resources/mole/.claude/skills/release-notes/agents/openai.yaml b/Resources/mole/.claude/skills/release-notes/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/Resources/mole/.claude/skills/release-notes/scripts/post-reactions.sh b/Resources/mole/.claude/skills/release-notes/scripts/post-reactions.sh new file mode 100755 index 0000000..25c76ba --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/scripts/post-reactions.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Add the standard six reactions (+1, laugh, hooray, heart, rocket, eyes) to a +# tw93/Mole release. Usage: post-reactions.sh V + +set -euo pipefail + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "Usage: $0 V" >&2 + exit 1 +fi + +if [[ "$TAG" != V* ]]; then + echo "Tag must start with capital V (release.yml ignores lowercase v): $TAG" >&2 + exit 1 +fi + +if ! command -v gh > /dev/null 2>&1; then + echo "gh CLI is required" >&2 + exit 1 +fi + +RELEASE_ID=$(gh api "repos/tw93/Mole/releases/tags/$TAG" --jq '.id') +if [[ -z "$RELEASE_ID" ]]; then + echo "Release not found for tag: $TAG" >&2 + exit 1 +fi + +for r in +1 laugh hooray heart rocket eyes; do + gh api "repos/tw93/Mole/releases/$RELEASE_ID/reactions" \ + -X POST -f content="$r" --silent +done + +echo "Posted 6 reactions to $TAG (release id $RELEASE_ID)" diff --git a/Resources/mole/.cursor/rules/mole-test-safety.mdc b/Resources/mole/.cursor/rules/mole-test-safety.mdc new file mode 100644 index 0000000..db996fd --- /dev/null +++ b/Resources/mole/.cursor/rules/mole-test-safety.mdc @@ -0,0 +1,11 @@ +--- +description: Mole test safety and verification defaults +alwaysApply: true +--- + +# Mole Test Safety + +- Default test and verification commands must run with `MOLE_TEST_NO_AUTH=1` or through `scripts/test.sh`, which sets it automatically. +- Tests must never trigger real `sudo`, Touch ID, password prompts, AppleScript permission dialogs, or system service changes. +- Any new direct use of `sudo`, `osascript`, or `launchctl` must either be guarded by `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH` or be fully mocked in the test path. +- Prefer focused regression tests first, then run full no-auth verification before release-sensitive changes. diff --git a/Resources/mole/.githooks/pre-commit b/Resources/mole/.githooks/pre-commit new file mode 100755 index 0000000..f1a483e --- /dev/null +++ b/Resources/mole/.githooks/pre-commit @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Pre-commit hook: mirrors GitHub CI checks locally. +# Installed via: git config core.hooksPath .githooks +# +# Runs on every `git commit`. Catches format/lint/test failures before push. + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +_ok() { echo -e "${GREEN}✓${NC} $1"; } +_fail() { echo -e "${RED}✗${NC} $1"; } +_info() { echo -e "${YELLOW}→${NC} $1"; } + +echo "" +_info "Running pre-commit checks (mirrors GitHub CI)..." +echo "" + +# Only check staged shell/Go files to keep commits fast. +STAGED=$(git diff --cached --name-only --diff-filter=ACM) +HAS_SHELL=$(echo "$STAGED" | grep -E '\.sh$|^mole$|^bin/' || true) +HAS_GO=$(echo "$STAGED" | grep -E '\.go$' || true) + +FAILED=0 + +# --- 1. Shell syntax check (fast, no tool required) --- +if [[ -n "$HAS_SHELL" ]]; then + _info "Shell syntax check..." + while IFS= read -r f; do + [[ -f "$f" ]] || continue + if ! bash -n "$f" 2>&1; then + _fail "Syntax error: $f" + FAILED=1 + fi + done <<< "$HAS_SHELL" + [[ $FAILED -eq 0 ]] && _ok "Shell syntax clean" +fi + +# --- 2. shfmt format check (if installed) --- +if [[ -n "$HAS_SHELL" ]] && command -v shfmt > /dev/null 2>&1; then + _info "shfmt format check..." + UNFORMATTED="" + while IFS= read -r f; do + [[ -f "$f" ]] || continue + if ! shfmt -i 4 -ci -sr -d "$f" > /dev/null 2>&1; then + UNFORMATTED="$UNFORMATTED $f" + fi + done <<< "$HAS_SHELL" + if [[ -n "$UNFORMATTED" ]]; then + _fail "shfmt: unformatted files:$UNFORMATTED" + _info "Fix with: ./scripts/check.sh --format" + FAILED=1 + else + _ok "shfmt format clean" + fi +fi + +# --- 3. shellcheck (if installed) --- +if [[ -n "$HAS_SHELL" ]] && command -v shellcheck > /dev/null 2>&1; then + _info "shellcheck..." + while IFS= read -r f; do + [[ -f "$f" ]] || continue + if ! shellcheck "$f" 2>&1; then + FAILED=1 + fi + done <<< "$HAS_SHELL" + [[ $FAILED -eq 0 ]] && _ok "shellcheck clean" +fi + +# --- 4. Go vet (if staged Go files) --- +if [[ -n "$HAS_GO" ]] && command -v go > /dev/null 2>&1; then + _info "go vet..." + if go vet ./cmd/... 2>&1; then + _ok "go vet clean" + else + _fail "go vet failed" + FAILED=1 + fi +fi + +echo "" +if [[ $FAILED -ne 0 ]]; then + _fail "Pre-commit checks failed. Fix the issues above before committing." + _info "Run './scripts/check.sh --format' to auto-fix formatting." + echo "" + exit 1 +fi + +_ok "All pre-commit checks passed." +echo "" diff --git a/Resources/mole/.github/CODEOWNERS b/Resources/mole/.github/CODEOWNERS new file mode 100644 index 0000000..74d9b7c --- /dev/null +++ b/Resources/mole/.github/CODEOWNERS @@ -0,0 +1 @@ +* @tw93 diff --git a/Resources/mole/.github/FUNDING.yml b/Resources/mole/.github/FUNDING.yml index 494d63e..db73b16 100644 --- a/Resources/mole/.github/FUNDING.yml +++ b/Resources/mole/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: ['tw93'] -custom: ['https://miaoyan.app/cats.html?name=Mole'] +custom: ['https://cats.tw93.fun?name=Mole'] diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md b/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md index 6e0779c..ca2ca4d 100644 --- a/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md +++ b/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,6 +10,8 @@ assignees: '' A clear and concise description of what the bug is. We suggest using English for better global understanding. +If you believe the issue may allow unsafe deletion, path validation bypass, privilege boundary bypass, or release/install integrity issues, do not file a public bug report. Report it privately using the contact details in `SECURITY.md`. + ## Steps to reproduce 1. Run command: `mo ...` diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/config.yml b/Resources/mole/.github/ISSUE_TEMPLATE/config.yml index 8d9ce89..9973666 100644 --- a/Resources/mole/.github/ISSUE_TEMPLATE/config.yml +++ b/Resources/mole/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,10 @@ blank_issues_enabled: false contact_links: + - name: Private Security Report + url: mailto:hitw93@gmail.com?subject=Mole%20security%20report + about: Report a suspected vulnerability privately instead of opening a public issue - name: Telegram Community - url: https://t.me/+GclQS9ZnxyI2ODQ1 + url: https://t.me/+9f9gf4ZrFSQ2OWVl about: Join our Telegram group for questions and discussions - name: GitHub Discussions url: https://github.com/tw93/mole/discussions diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_bug.yml b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_bug.yml new file mode 100644 index 0000000..93795d3 --- /dev/null +++ b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_bug.yml @@ -0,0 +1,101 @@ +name: Mac App Bug +description: Report a bug in the paid Mole Mac app. +title: "[Mac App Bug] " +labels: ["mac-app", "bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a Mole Mac app bug. + + The Mole Mac app is a paid native app. Its source code is not public. This repository hosts the open-source Mole CLI and public feedback for the Mac app. + + Do not include license keys, order numbers, private paths, or logs with personal data. For payment, refund, license, or security issues, email hi@mole.fit instead. + - type: input + id: app_version + attributes: + label: Mole Mac app version + description: Open Mole, choose About Mole, and paste the version shown there. + placeholder: "1.0.0" + validations: + required: true + - type: input + id: macos_version + attributes: + label: macOS version + placeholder: "macOS 15.5" + validations: + required: true + - type: input + id: mac_model + attributes: + label: Mac model + placeholder: "MacBook Pro M3 Pro, Mac mini M2, etc." + validations: + required: false + - type: dropdown + id: area + attributes: + label: Affected area + options: + - Full Disk Access / permissions + - Clean + - Uninstall + - Optimize + - Analyze + - Status + - Activation / license + - Download / update + - Other + validations: + required: true + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the bug clearly. Screenshots are welcome if they do not reveal private data. + placeholder: "Mole did..." + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open Mole + 2. Go to ... + 3. Click ... + 4. See ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: "I expected Mole to..." + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: "Instead, Mole..." + validations: + required: true + - type: textarea + id: attachments + attributes: + label: Screenshots or safe logs + description: Attach screenshots or paste logs only after removing private paths, license keys, and order details. + placeholder: "Drag screenshots here, or paste safe logs." + validations: + required: false + - type: checkboxes + id: checklist + attributes: + label: Before submitting + options: + - label: I am using the latest Mole Mac app build available from https://mole.fit. + required: true + - label: I did not include license keys, order numbers, or private data. + required: true diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_feature.yml b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_feature.yml new file mode 100644 index 0000000..a975096 --- /dev/null +++ b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_feature.yml @@ -0,0 +1,67 @@ +name: Mac App Feature +description: Suggest an improvement for the paid Mole Mac app. +title: "[Mac App Feature] " +labels: ["mac-app", "enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement for the Mole Mac app. + + The Mole Mac app is a paid native app. Its source code is not public. This repository hosts the open-source Mole CLI and public feedback for the Mac app. + + Please keep requests concrete: what workflow is hard today, what you expected Mole to do, and why it matters. + - type: dropdown + id: area + attributes: + label: Area + options: + - Clean + - Uninstall + - Optimize + - Analyze + - Status + - Activation / license + - Onboarding / permissions + - Website / help + - Other + validations: + required: true + - type: textarea + id: problem + attributes: + label: What problem would this solve? + placeholder: "When I use Mole, I want to..." + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What would you like Mole to do? + placeholder: "A good solution could be..." + validations: + required: true + - type: textarea + id: current_workaround + attributes: + label: Current workaround + placeholder: "Today I work around it by..." + validations: + required: false + - type: textarea + id: examples + attributes: + label: Examples, screenshots, or references + description: Add screenshots, links, or examples if they help explain the request. + placeholder: "Optional context." + validations: + required: false + - type: checkboxes + id: checklist + attributes: + label: Before submitting + options: + - label: This request is for the Mole Mac app, not the Mole CLI. + required: true + - label: I did not include license keys, order numbers, or private data. + required: true diff --git a/Resources/mole/.github/dependabot.yml b/Resources/mole/.github/dependabot.yml index 603f653..84d867f 100644 --- a/Resources/mole/.github/dependabot.yml +++ b/Resources/mole/.github/dependabot.yml @@ -4,8 +4,22 @@ updates: directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + reviewers: + - "tw93" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + reviewers: + - "tw93" + open-pull-requests-limit: 10 diff --git a/Resources/mole/.github/pull_request_template.md b/Resources/mole/.github/pull_request_template.md new file mode 100644 index 0000000..b383243 --- /dev/null +++ b/Resources/mole/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +- Describe the change. + +## Safety Review + +- Does this change affect cleanup, uninstall, optimize, installer, remove, analyze delete, update, or install behavior? +- Does this change affect path validation, protected directories, symlink handling, sudo boundaries, or release/install integrity? +- If yes, describe the new boundary or risk change clearly. + +## Tests + +- List the automated tests you ran. +- List any manual checks for high-risk paths or destructive flows. + +## Safety-related changes + +- None. diff --git a/Resources/mole/.github/workflows/bundle_audit.yml b/Resources/mole/.github/workflows/bundle_audit.yml new file mode 100644 index 0000000..d90b8ab --- /dev/null +++ b/Resources/mole/.github/workflows/bundle_audit.yml @@ -0,0 +1,55 @@ +name: Bundle Drift Audit + +# Monthly check that mole's protected bundle lists still cover every system +# app shipped in the latest macOS image. Opens an issue if /System/Applications +# contains a bundle ID not matched by the protection patterns. + +on: + schedule: + - cron: '0 12 1 * *' # 1st of each month, 12:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + audit: + name: Bundle drift + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run drift audit + id: drift + continue-on-error: true + run: | + # pipefail so the audit's exit 2 (drift found) propagates through + # tee. Without it the step's status is tee's 0, outcome stays + # 'success', and the "Open issue on drift" gate below never fires. + set -o pipefail + ./scripts/audit_bundle_drift.sh | tee /tmp/audit.txt + + - name: Open issue on drift + if: ${{ steps.drift.outcome != 'success' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + MACOS=$(sw_vers -productVersion) + TITLE="Bundle drift on macOS $MACOS" + # Avoid duplicate issues for the same macOS version. + EXISTING=$(gh issue list --label bundle-drift --search "$TITLE in:title" --json number --jq '.[0].number' || true) + if [[ -n "$EXISTING" ]]; then + echo "Updating existing issue #$EXISTING" + gh issue comment "$EXISTING" --body "$(cat /tmp/audit.txt)" + else + gh issue create \ + --title "$TITLE" \ + --label bundle-drift \ + --body "$(cat /tmp/audit.txt)" + fi diff --git a/Resources/mole/.github/workflows/check.yml b/Resources/mole/.github/workflows/check.yml index 6f7b0e0..5011965 100644 --- a/Resources/mole/.github/workflows/check.yml +++ b/Resources/mole/.github/workflows/check.yml @@ -6,22 +6,24 @@ on: pull_request: permissions: - contents: write + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: format: name: Format runs-on: macos-latest + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - with: - ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Cache Homebrew - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/Library/Caches/Homebrew @@ -33,48 +35,53 @@ jobs: ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck golangci-lint + run: brew install shfmt shellcheck - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24.6' + go-version-file: go.mod - name: Install goimports run: go install golang.org/x/tools/cmd/goimports@latest - - name: Format all code + - name: Check shell formatting (shfmt -d) run: | - export PATH=$(go env GOPATH)/bin:$PATH - ./scripts/check.sh --format + SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \ + -not -path "./.git/*" \ + -not -path "*/node_modules/*" \ + -not -path "*/tests/tmp-*/*" \ + -not -path "*/.*") + if ! echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -d; then + echo "::error::Shell files are not formatted. Run './scripts/check.sh --format' locally and commit." + exit 1 + fi - - name: Commit formatting changes - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + - name: Check Go formatting (goimports -l) run: | - git config user.name "Tw93" - git config user.email "tw93@qq.com" - if [[ -n $(git status --porcelain) ]]; then - git add . - git commit -m "chore: auto format code" - git push - echo "✓ Formatting changes committed" - else - echo "✓ No formatting changes needed" + export PATH=$(go env GOPATH)/bin:$PATH + UNFORMATTED=$(goimports -l -local github.com/tw93/mole ./cmd ./internal) + if [[ -n "$UNFORMATTED" ]]; then + echo "::error::Go files are not formatted:" + echo "$UNFORMATTED" + echo "Run './scripts/check.sh --format' locally and commit." + exit 1 fi quality: name: Check runs-on: macos-latest + timeout-minutes: 20 needs: format + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - with: - ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Cache Homebrew - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/Library/Caches/Homebrew @@ -86,12 +93,17 @@ jobs: ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck golangci-lint + run: brew install shfmt shellcheck - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24.6' + go-version-file: go.mod + + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run check script run: ./scripts/check.sh --no-format diff --git a/Resources/mole/.github/workflows/codeql.yml b/Resources/mole/.github/workflows/codeql.yml new file mode 100644 index 0000000..85741ae --- /dev/null +++ b/Resources/mole/.github/workflows/codeql.yml @@ -0,0 +1,57 @@ +name: CodeQL + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: manual + - language: actions + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Go + if: matrix.language == 'go' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Initialize CodeQL + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + + - name: Build for CodeQL + if: matrix.build-mode == 'manual' + run: make build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + category: "/language:${{ matrix.language }}" diff --git a/Resources/mole/.github/workflows/release.yml b/Resources/mole/.github/workflows/release.yml index dc22b27..b53ecb5 100644 --- a/Resources/mole/.github/workflows/release.yml +++ b/Resources/mole/.github/workflows/release.yml @@ -6,12 +6,13 @@ on: - 'V*' permissions: - contents: write + contents: read jobs: build: name: Build runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: matrix: include: @@ -23,17 +24,18 @@ jobs: artifact_name: binaries-arm64 steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Build Binaries run: | make ${{ matrix.target }} ls -l bin/ + ./scripts/check_release_minos.sh - name: Package binaries for Homebrew run: | @@ -48,7 +50,7 @@ jobs: fi - name: Upload artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.artifact_name }} path: bin/*-darwin-* @@ -58,9 +60,14 @@ jobs: name: Publish Release needs: build runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + attestations: write + id-token: write steps: - name: Download all artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: bin pattern: binaries-* @@ -69,24 +76,45 @@ jobs: - name: Display structure of downloaded files run: ls -R bin/ + - name: Generate release checksums + run: | + cd bin + mapfile -t release_files < <(find . -maxdepth 1 -type f -printf '%P\n' | sort) + if [[ ${#release_files[@]} -eq 0 ]]; then + echo "No release assets found" + exit 1 + fi + sha256sum "${release_files[@]}" > SHA256SUMS + cat SHA256SUMS + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + bin/analyze-darwin-* + bin/status-darwin-* + bin/binaries-darwin-*.tar.gz + bin/SHA256SUMS + - name: Create Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 if: startsWith(github.ref, 'refs/tags/') with: name: ${{ github.ref_name }} files: bin/* - body: | - Release assets are ready. - - Final curated release notes should be applied with `gh release edit` after workflow verification. generate_release_notes: false draft: false prerelease: false - update-formula: + update-homebrew-core: + name: Update Homebrew Core runs-on: ubuntu-latest + timeout-minutes: 15 needs: release steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Extract version from tag id: tag_version run: | @@ -96,39 +124,143 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Releasing version: $VERSION (tag: $TAG)" - - name: Update Homebrew formula (Personal Tap) - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6 - with: - formula-name: mole - formula-path: Formula/mole.rb - homebrew-tap: tw93/homebrew-tap - tag-name: ${{ steps.tag_version.outputs.tag }} - commit-message: | - mole ${{ steps.tag_version.outputs.version }} - - Automated release via GitHub Actions + - name: Update Homebrew formula (Official Core) + id: official_core env: - COMMITTER_TOKEN: ${{ secrets.PAT_TOKEN }} + GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} + TAG: ${{ steps.tag_version.outputs.tag }} + VERSION: ${{ steps.tag_version.outputs.version }} + # mislav/bump-homebrew-formula-action@v4.1 fatals on the GitHub tarball + # HEAD's HTTP 303 redirect (upstream issue #340). Until that lands, do + # the bump by hand: sync our fork, edit Formula/m/mole.rb, push, and + # open the PR via gh while preserving the existing release gates. + run: | + set -euo pipefail - - name: Update Homebrew formula (Official Core) - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6 - with: - formula-name: mole - homebrew-tap: Homebrew/homebrew-core - tag-name: ${{ steps.tag_version.outputs.tag }} - commit-message: | - mole ${{ steps.tag_version.outputs.version }} + SOURCE_SHA=$(curl -fsSL "https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" | sha256sum | awk '{print $1}') + if [[ -z "$SOURCE_SHA" ]]; then + echo "Failed to resolve source tarball sha256" + exit 1 + fi - Automated release via GitHub Actions - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} - continue-on-error: true + export GIT_TERMINAL_PROMPT=0 + REMOTE="https://x-access-token:${GH_TOKEN}@github.com/tw93/homebrew-core.git" + UPSTREAM="https://github.com/Homebrew/homebrew-core.git" + WORK_DIR=$(mktemp -d) + BRANCH="mole-${VERSION}" + + git clone --depth=1 "$REMOTE" "$WORK_DIR" + cd "$WORK_DIR" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote add upstream "$UPSTREAM" + git fetch upstream main --depth=1 + git reset --hard upstream/main + git checkout -B "$BRANCH" + + # Only rewrite url and the source sha256 right after it. The bottle + # block stays untouched: Homebrew's check-bottle-block CI hook fails + # PRs that modify it because BrewTestBot rebuilds bottles after the + # url change lands. + awk -v new_url="https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" \ + -v new_sha="$SOURCE_SHA" ' + BEGIN { url_done = 0; sha_done = 0 } + !url_done && /^ url "/ { print " url \"" new_url "\""; url_done = 1; next } + url_done && !sha_done && /^ sha256 "/ { print " sha256 \"" new_sha "\""; sha_done = 1; next } + { print } + ' Formula/m/mole.rb > Formula/m/mole.rb.new + mv Formula/m/mole.rb.new Formula/m/mole.rb + + if git diff --quiet -- Formula/m/mole.rb; then + echo "Formula already on ${VERSION}, nothing to push" + echo "core_status=published" >> "$GITHUB_OUTPUT" + echo "pr_url=https://formulae.brew.sh/formula/mole" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git add Formula/m/mole.rb + git commit -m "mole ${VERSION}" -m "Automated release via GitHub Actions" + git push --force-with-lease origin "$BRANCH" + + # Homebrew closes PRs that omit its current template. Keep the full + # checklist, with unperformed local checks left unticked, so the PR + # remains honest and the template checker can validate it. + read -r -d '' PR_BODY <<'PR_BODY_EOF' || true + Release notes: https://github.com/tw93/Mole/releases/tag/__TAG__ + + ----- + + + + + + - [ ] Have you followed the [guidelines for contributing](https://github.com/Homebrew/homebrew-core/blob/HEAD/CONTRIBUTING.md)? + - [x] Have you ensured that your commits follow the [commit style guide](https://docs.brew.sh/Formula-Cookbook#commit)? + - [ ] Have you checked that there aren't other open [pull requests](https://github.com/Homebrew/homebrew-core/pulls) for the same formula update/change? + - [ ] Have you built your formula locally with `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --build-from-source `? + - [ ] Is your test running fine `brew test `? + - [ ] Does your build pass `brew audit --strict ` (after doing `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --build-from-source `)? If this is a new formula, does it pass `brew audit --new `? + + The formula URL and checksum were generated by the Mole release workflow. Homebrew Core CI performs the platform builds, tests, and audit after the PR opens. + + ----- + + - [ ] AI was used to generate or assist with generating this PR. *Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes*. + + ----- + PR_BODY_EOF + PR_BODY="${PR_BODY//__TAG__/$TAG}" + + PR_NUMBER=$(gh api \ + "repos/Homebrew/homebrew-core/pulls?state=all&head=tw93:${BRANCH}" \ + --jq '.[0].number // empty') + if [[ -z "$PR_NUMBER" ]]; then + PR_NUMBER=$(gh api --method POST repos/Homebrew/homebrew-core/pulls \ + -f base=main \ + -f head="tw93:${BRANCH}" \ + -f title="mole ${VERSION}" \ + --raw-field body="$PR_BODY" \ + --jq '.number') + else + # Editing a template-closed PR triggers Homebrew's checker to + # reopen it automatically when the closer was its own bot. + gh api --method PATCH "repos/Homebrew/homebrew-core/pulls/${PR_NUMBER}" \ + --raw-field body="$PR_BODY" > /dev/null + fi + + PR_STATE="" + for _ in {1..12}; do + PR_STATE=$(gh api "repos/Homebrew/homebrew-core/pulls/${PR_NUMBER}" --jq '.state') + [[ "$PR_STATE" == "open" ]] && break + sleep 5 + done + if [[ "$PR_STATE" != "open" ]]; then + echo "Homebrew Core PR #${PR_NUMBER} is not open after template validation" >&2 + exit 1 + fi + + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "pr_url=https://github.com/Homebrew/homebrew-core/pull/${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "core_status=pr-open" >> "$GITHUB_OUTPUT" - name: Verify formula updates - if: success() + env: + GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} run: | - echo "✓ Homebrew formulae updated successfully" + CORE_STATUS="${{ steps.official_core.outputs.core_status }}" + PR_NUMBER="${{ steps.official_core.outputs.pr_number }}" + if [[ "$CORE_STATUS" == "pr-open" ]]; then + PR_STATE=$(gh api "repos/Homebrew/homebrew-core/pulls/${PR_NUMBER}" --jq '.state') + if [[ "$PR_STATE" != "open" ]]; then + echo "Homebrew Core PR #${PR_NUMBER} is not open" >&2 + exit 1 + fi + elif [[ "$CORE_STATUS" != "published" ]]; then + echo "Unknown Homebrew Core publication state: ${CORE_STATUS:-empty}" >&2 + exit 1 + fi + + echo "✓ Homebrew Core formula update verified" echo " Version: ${{ steps.tag_version.outputs.version }}" echo " Tag: ${{ steps.tag_version.outputs.tag }}" - echo " Personal tap: tw93/homebrew-tap" - echo " Official core: Homebrew/homebrew-core (PR created)" + echo " Official core: ${{ steps.official_core.outputs.pr_url }}" diff --git a/Resources/mole/.github/workflows/test.yml b/Resources/mole/.github/workflows/test.yml index 4151314..7540179 100644 --- a/Resources/mole/.github/workflows/test.yml +++ b/Resources/mole/.github/workflows/test.yml @@ -6,25 +6,39 @@ on: pull_request: branches: [main, dev] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: tests: name: Unit & Integration Tests runs-on: macos-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools - run: brew install bats-core shellcheck + run: brew install bats-core shellcheck coreutils parallel - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Run test script env: MOLE_PERF_BYTES_TO_HUMAN_LIMIT_MS: "6000" MOLE_PERF_GET_FILE_SIZE_LIMIT_MS: "3000" + MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS: "10000" + MOLE_TEST_JOBS: "6" + # Emits a "Slowest test files" table at the end of the run. Parallel + # TAP output cannot be attributed to a file, so without this a single + # slow file that sets the whole job's wall clock stays invisible. + MOLE_TEST_REPORT_DIR: ${{ runner.temp }}/bats-report BATS_FORMATTER: tap LANG: en_US.UTF-8 LC_ALL: en_US.UTF-8 @@ -36,27 +50,61 @@ jobs: matrix: os: [macos-14, macos-15] runs-on: ${{ matrix.os }} + timeout-minutes: 30 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install bats + run: brew install bats-core coreutils + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod - - name: Test on ${{ matrix.os }} + - name: Smoke load on ${{ matrix.os }} run: | echo "Testing on ${{ matrix.os }}..." bash -n mole source lib/core/common.sh echo "✓ Successfully loaded on ${{ matrix.os }}" + - name: Run core bats subset + env: + MOLE_TEST_NO_AUTH: "1" + MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS: "10000" + BATS_FORMATTER: tap + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + run: | + bats tests/core_safe_functions.bats \ + tests/core_common.bats \ + tests/core_timeout.bats \ + tests/regression.bats \ + tests/cli.bats + security: name: Security Checks runs-on: macos-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Install tools + run: brew install bats-core coreutils - name: Check for unsafe rm usage run: | echo "Checking for unsafe rm patterns..." - if grep -r "rm -rf" --include="*.sh" lib/ | grep -v "safe_remove\|validate_path\|# "; then - echo "✗ Unsafe rm -rf usage found" + # Filters in order: + # safe_remove / validate_path: known safe wrapper helpers in file_ops.sh + # # SAFE: explicit annotation that a direct rm is gated by narrow checks + # ^path:line:[ws]#: pure comment lines that mention rm -rf in docs + # echo : help text or messages that print rm -rf as documentation + if grep -rn "rm -rf" lib/ bin/ install.sh mole 2>/dev/null | grep -v -E "safe_remove|validate_path|# SAFE|^[^:]+:[0-9]+:[[:space:]]*#|echo "; then + echo "✗ Unsafe rm -rf usage found (annotate gated calls with '# SAFE: ' or route through safe_remove)" exit 1 fi echo "✓ No unsafe rm usage found" @@ -74,15 +122,16 @@ jobs: fi ' - - name: Check for secrets - run: | - echo "Checking for hardcoded secrets..." - matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \ - | grep -v "# \|test" \ - | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh|lib/uninstall/batch\.sh" || true) - if [[ -n "$matches" ]]; then - echo "$matches" - echo "✗ Potential secrets found" - exit 1 - fi - echo "✓ No secrets found" + - name: Scan for secrets (gitleaks) + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml + + - name: Run high-risk path regression tests + env: + MOLE_TEST_NO_AUTH: "1" + BATS_FORMATTER: tap + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + run: bats tests/core_safe_functions.bats tests/purge.bats tests/installer.bats diff --git a/Resources/mole/.github/workflows/update-contributors.yml b/Resources/mole/.github/workflows/update-contributors.yml index 7934087..9931d27 100644 --- a/Resources/mole/.github/workflows/update-contributors.yml +++ b/Resources/mole/.github/workflows/update-contributors.yml @@ -14,18 +14,19 @@ concurrency: jobs: update-contributors: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Generate contributors SVG - uses: tw93/contributors-list@master + uses: tw93/contributors-list@3722c5cb0d662c5e6dbffe78448002c461aab3b9 # master pinned 2026-07 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} svgPath: CONTRIBUTORS.svg @@ -52,11 +53,10 @@ jobs: - name: Commit & Push - uses: stefanzweifel/git-auto-commit-action@v7 + uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 with: commit_message: "chore: update contributors [skip ci]" file_pattern: CONTRIBUTORS.svg commit_user_name: github-actions[bot] commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> - push_options: '--force' diff --git a/Resources/mole/.gitignore b/Resources/mole/.gitignore index 451942f..ab4bca2 100644 --- a/Resources/mole/.gitignore +++ b/Resources/mole/.gitignore @@ -41,16 +41,25 @@ tests/tmp-* *.pid *.lock -# AI Assistant Instructions -.claude/ -.agents/ +# Local AI Assistant Instructions +# CLAUDE.md is a symlink to AGENTS.md (shared source of truth, tracked). +# Personal overrides go in *.local.md, which stay gitignored. +CLAUDE.local.md +AGENTS.local.md +AGENTS.override.md +.claude/* +!.claude/agents/ +!.claude/hooks/ +!.claude/skills/ +!.claude/settings.json +.claude/settings.local.json +.agents/settings.local.json .gemini/ .kiro/ -CLAUDE.md GEMINI.md ANTIGRAVITY.md WARP.md -AGENTS.md +journal/ .cursorrules # Go build artifacts (development) @@ -77,6 +86,4 @@ coverage.html session.json run_tests.ps1 -AGENTS.md mole_guidelines.md -CLAUDE.md diff --git a/Resources/mole/.gitleaks.toml b/Resources/mole/.gitleaks.toml new file mode 100644 index 0000000..353edba --- /dev/null +++ b/Resources/mole/.gitleaks.toml @@ -0,0 +1,27 @@ +# Mole gitleaks configuration +# Replaces grep-based secret scan in .github/workflows/test.yml +# Uses gitleaks default ruleset (AWS / GitHub / Slack / private keys / etc). + +[extend] +useDefault = true + +[allowlist] +description = "Mole-specific allowlist" + +# Test fixtures and mock binaries never hold real secrets. +paths = [ + '''tests/.*''', + '''.*\.bats$''', + # Bundle ID lists in app protection use "keychain*", "security*" etc. as + # wildcard patterns, not secrets. + '''lib/core/app_protection\.sh''', + '''lib/core/app_protection_data\.sh''', +] + +# Mole talks about "password" / "Touch ID" in UI prompts and sudo helpers. +# These are user-visible strings, not credentials. +regexes = [ + '''password( and| or| dialog| prompt| input|,)''', + '''Touch ID or password''', + '''_request_password''', +] diff --git a/Resources/mole/.golangci.yml b/Resources/mole/.golangci.yml index 11bb2c8..504a3e0 100644 --- a/Resources/mole/.golangci.yml +++ b/Resources/mole/.golangci.yml @@ -18,6 +18,11 @@ linters: - unused # Additional linters - modernize + - revive + - unparam + # Security scanner. mole deletes files and shells out, so gosec is + # high-value here even with some false positives. + - gosec settings: govet: @@ -30,8 +35,26 @@ linters: - (io.Closer).Close - (*os/exec.Cmd).Run - (*os/exec.Cmd).Start + revive: + rules: + - name: use-waitgroup-go staticcheck: checks: ["all", "-QF1003", "-SA9003"] + gosec: + # mole is a system tool: scanning user-supplied paths and shelling out + # to mdls/du/osascript are core features, not vulnerabilities. We rely + # on validatePath (covered by FuzzValidatePath) for path safety and + # on osascript escaping in delete.go for command construction. + excludes: + - G104 # already covered by errcheck + - G115 # integer overflow on stat.{Bavail,Bsize,Dev,Ino} and FileInfo.Size; macOS-safe + - G204 # subprocess with variable; required for du/mdls/osascript/mdfind + - G301 # mkdir perms; we use 0o755 intentionally + - G302 # chmod perms; same + - G304 # file inclusion via variable; we scan user paths by design + - G306 # WriteFile perms; we use 0o644 intentionally + - G702 # command injection taint; mdfind/du take user paths by design + - G703 # path traversal taint; scanning user paths is mole's purpose exclusions: rules: @@ -39,6 +62,8 @@ linters: - path: _test\.go linters: - errcheck + - gosec + - unparam # Ignore errors from os.Remove in cleanup code - text: "os.Remove" linters: diff --git a/Resources/mole/AGENTS.md b/Resources/mole/AGENTS.md new file mode 100644 index 0000000..35a5163 --- /dev/null +++ b/Resources/mole/AGENTS.md @@ -0,0 +1,182 @@ +# Mole Agent Guide + +This file is the shared source of truth for any AI agent working on this repo (Claude Code, Codex, etc.). `CLAUDE.md` is a symlink to this file. Put machine-specific or personal overrides in `AGENTS.local.md` / `CLAUDE.local.md`; both are gitignored. + +## Project + +Mole is a macOS system cleanup and optimization tool with shell and Go components. It performs file cleanup, app protection checks, and maintenance tasks, so safety rules matter more than speed. + +## Product Direction + +Mole is a terminal-first macOS maintenance toolkit. Its core job is to help power users inspect reclaimable space, remove known-safe leftovers, uninstall apps safely, run bounded maintenance, and check health from a CLI, script, or compact TUI. It is not a general Mac control center, package manager, background monitor, or GUI feature mirror. + +### What Mole Should Do + +- Make cleanup and uninstall actions boring, reviewable, logged, protected by path/app rules, and dry-run capable. +- Prefer reversible user-facing removals through Trash where the command surface expects recoverability. +- Keep `clean`, `uninstall`, `purge`, and `installer` focused on reclaimable files, app leftovers, rebuildable caches, installer artifacts, and exact known cleanup targets. +- Keep `analyze` as a disk explorer and ad hoc cleanup surface. Optimize first paint, navigation, sorting, filtering, and safe deletion before adding dashboard-style features. +- Keep `status` as a compact read-only health dashboard plus stable JSON/NDJSON automation output. It may surface actionable signals, but should not become an iStat clone, alerting daemon, or configurable metrics workbench. +- Keep `optimize` focused on explicit, bounded maintenance tasks that can be explained before execution and tested without real authorization prompts. +- Keep command UX dense and terminal-native: short labels, stable alignment, predictable shortcuts, one-screen summaries, then optional drill-down. +- Keep Mole Mac references as a cross-link or support path. The CLI and Mac app can share product values without requiring feature parity. + +### What Mole Should Not Do + +- Do not add broad system modification, privacy reset, package management, app bundle patching, or device-management features just because they are technically possible. +- Do not remove or rewrite third-party app bundle contents, signed resources, user documents, credentials, sessions, active databases, or active developer-tool state. +- Do not add background agents, persistent monitoring, notifications, schedulers, menu bar behavior, or GUI-like state unless explicitly requested and justified as CLI scope. +- Do not broaden leftover matching from exact app or bundle evidence into vendor-wide, TeamID-prefix, generic-name, or fallback wildcard deletion. +- Do not turn `status` into a noisy dashboard. Extra rows, live alerts, and tuning controls need a common user action, not just an available metric. +- Do not add prompts, preferences, or output modes to solve every edge case. Prefer quieter defaults, preview/read-only guidance, or declining unsupported operations. A new flag, environment variable, or config key is the same weight as a new setting: it passes only when no single default is right for everyone, and the fix-by-default alternative has to be stated and rejected first. Reaching for a knob to close an issue is the default failure here, not an edge case. +- Do not treat Mole Mac features as required CLI gaps. The CLI should stay narrower, scriptable, and safety-first when parity would add complexity or ambiguity. + +### Product Decision Filter + +Before accepting a new feature, answer these questions in the PR, issue, or review notes when the fit is not obvious: + +1. Does it clearly belong to clean, uninstall, analyze, optimize, status, purge, history, installer, update, completion, touchid, or remove? +2. Is it safe by default, previewable where destructive, testable without real auth, and explainable in one terminal screen? +3. Can the user verify what will change before Mole changes it? +4. Is the target data locally rebuildable, disposable, or backed by exact app/bundle evidence? +5. Would this be better as Mole Mac UI, documentation, a warning, or an explicit "not supported" answer? + +If the answer is no or unclear, decline the feature, narrow it, or park it until the product value beats the added surface area. + +## Repository Map + +- `AGENTS.md` is the cross-agent source of truth. `CLAUDE.md` must remain a symlink to it so Claude and Codex receive the same project contract. +- `.claude/skills/` is the canonical home for project skills. `.agents/skills/` contains relative symlinks for Codex discovery; do not maintain copied skill bodies. +- `.claude/agents/` contains focused Claude review profiles. They must read the current contract from this file instead of copying a frozen version of the safety or portability rules. +- `mole` - the CLI entrypoint. It is a **router only**: it parses args, renders the menu, and dispatches. Business logic does not belong here. Self-update lives in `lib/manage/update.sh` and self-removal in `lib/manage/remove.sh`; both are `source`d (not `exec`d) because the interactive menu and the update banner call them in-process. `VERSION=` stays in `mole` because `install.sh` reads it out of this file with `sed`. +- `lib/core/` - shared shell safety, UI, file operations, operation logs, app protection logic, and centralized timeout constants (`timeouts.sh`). +- `lib/core/app_protection_data.sh` - readonly bundle ID and pattern arrays consumed by `app_protection.sh`. Data only, no logic. +- `cmd/analyze/` - Go disk-analysis TUI. `main.go` is bootstrap only; `model.go` holds types and accessor methods; `update.go` holds the Bubble Tea Update chain. +- `tests/fuzz_corpus/` holds property-test corpora consumed by `path_validation_fuzz.bats`. +- `scripts/` - check, test, build, and release helpers. `audit_bundle_drift.sh` backs the monthly bundle audit; `audit_function_duplication.py` gates same-body-different-name shell functions and runs inside `check.sh` (`--list` shows every group); per-PR perf is covered by `tests/core_performance.bats`. +- `docs/SECURITY_DESIGN.md` - design doc for the path validation / app protection / # SAFE annotation contract. +- `SECURITY_AUDIT.md` - security review notes. + +## Commands + +```bash +./scripts/check.sh --format +MOLE_TEST_NO_AUTH=1 ./scripts/test.sh +MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats +MOLE_DRY_RUN=1 ./mole clean +MOLE_TEST_NO_AUTH=1 ./mole clean --dry-run +MOLE_TEST_NO_AUTH=1 ./mole purge --dry-run +MOLE_TEST_NO_AUTH=1 ./mole installer --dry-run +find bin lib -name '*.sh' -print0 | xargs -0 -n1 bash -n +make build +go test ./... +``` + +Public docs and examples should prefer the installed `mo` command. Use `./mole` in this repository when verifying source-tree behavior before installation. `analyze` and `analyse` are both accepted command spellings. + +## Critical Safety Rules + +- Route deletion through the safe helpers in `lib/core/file_ops.sh`. Raw `rm -rf` and `find -delete` are allowed only with a `# SAFE: ` annotation on the same line, which is the contract `docs/SECURITY_DESIGN.md` Layer 2 defines and `.github/workflows/test.yml` enforces by whitelist; seven `rm -rf` call sites use it today for paths the function itself created. Grepping `# SAFE:` returns far more than seven because ~100 `rm -f` removals of self-created mktemp files carry the same annotation, which is the prescribed pattern, not drift: the CI whitelist checks that the annotation is present and counts nothing. Do not route a mktemp scratch path through `mole_delete`: that adds Trash routing and an operation-log entry to a temp file. +- Use `mole_delete` from `lib/core/file_ops.sh` for removals so Trash routing, operation logs, dry-run behavior, and path protection stay consistent. +- Never modify protected paths such as `/System`, `/Library/Apple`, or `com.apple.*`. +- Route user-facing cleanup through Trash where the project expects recoverability, especially for analyze-driven ad hoc cleanup. +- Never let verification block on sudo, AppleScript, or macOS authorization prompts unless the task explicitly targets auth behavior. +- Use `MOLE_DRY_RUN=1` before destructive cleanup flows. +- Use `MOLE_TEST_NO_AUTH=1` for tests, manual repro, and verification unless real auth behavior is being tested. +- Any new direct use of `sudo`, `osascript`, or `launchctl` must have a `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH` guard or be fully mocked in tests. +- Never auto-delete Software Update-owned staging trees such as `/Library/Updates` or `/macOS Install Data`. Directory age, process lists, and Software Update plist state cannot prove those trees stay inactive across a scan-to-delete window; keep this surface read-only. +- Never delete, truncate, or vacuum the active PowerLog database at `/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/CurrentBackgroundProcessingDB.BGSQL` or its `-wal` / `-shm` companions. Size and mtime cannot prove that Apple has closed every SQLite connection; keep abnormal-size handling read-only. +- Never run a privileged path-based delete or move through an invoking-user-mutable ancestor. `safe_sudo_remove`, `safe_sudo_find_delete`, and `mole_delete` must downgrade or fail closed there; privileged Trash moves must cross into dedicated immutable root-owned staging under `/Library` before the invoking user moves the item into Trash. +- **`install.sh` stays fail-closed on verification failure.** A checksum or attestation mismatch aborts and says why; it must never downgrade to a source build, which turns "the binary was tampered with" into a quieter path with weaker verification. Resolving no release tag and falling back to `main` must warn that this is a nightly source install. The abort cases in `tests/install_checksum.bats` pin both. Keep the README install URL on unpinned `main`: pinning it there blocks fixes from reaching new installs. +- **A gate that refuses must name which cause it hit and what to run next.** Install and update gates fail for causes with nothing in common: an untrusted ancestor, no admin session, a planted lock path, real contention. "Reinstall" fixes none of them, so a single catch-all message leaves the user with no move. `acquire_install_lock` returns a reason through `INSTALL_LOCK_FAILURE` and `report_install_lock_failure` prints one line of cause plus one line of command; keep that shape and add a reason rather than widening the catch-all. Two traps this area has already sprung: a new gate placed in front of an older, better-diagnosed failure silently downgrades the diagnosis, so when adding one, check what the old path used to say and keep it at least as actionable; and never pin a catch-all string in a source-invariant test, which is how the fix for #1335 swapped one vague message for another and locked it in as a requirement. Pin the reason codes. A source-invariant test that greps for a forbidden call must strip comment lines first: the comment explaining why `brew list mole` was removed read as the call coming back, and the guard failed on prose rather than on code. One cause can carry several: `install_lock_has_unsafe_ancestor` refuses for a symlink, an unreadable stat, a foreign owner, a loose mode, or an ACL, and they need different commands, since `chown` does not clear an ACL and `chmod` does not undo a symlink. It reports which through `INSTALL_LOCK_UNSAFE_ANCESTOR_REASON`; `tests/install_checksum.bats` asserts every raised code reaches its own branch and every branch names a next step. +- The `mo update` self-heal fallback (`_update_self_heal_reinstall`) exists because the local bootstrap (temp file, registry, exec) is frozen on the user's machine and a broken installed version cannot fix itself (#1297). Keep it streaming install.sh from `main` straight into bash with no local temp files. Stable success is asserted against the installed binary's bounded version response, never installer output (the V1.47.1 false-success shape), and `install.sh` must bound its own `--version` / `--help` verification probes too. Nightly success additionally requires a per-attempt install receipt; pin the source archive to the resolved commit when HEAD is known, and never reuse an older `COMMIT_HASH` when it is not. Keep updates single-flight per install directory so receipt, commit metadata, and binary verification cannot cross concurrent generations: both writers take the same target-adjacent mutex, preferring absolute `/usr/bin/lockf` because the kernel drops that lock even if the holder is killed. `lockf` only ships with newer macOS, so requiring it made install and update exit before writing a file on every older release (#1348); where it is absent both fall back to an atomic `mkdir` in the lock directory, reclaiming it only against proof the recorded owner is gone (dead pid, or a live pid whose start time no longer matches). Distinguish the two fail-closed cases: a lock command that *runs and refuses* is contention, a platform that never had one is not, and only a system with neither primitive is turned away. Do not build the wrapper as a shell array; the empty one is the fallback path and an empty array under `set -u` is an unbound-variable error on the bash 3.2 macOS ships. Regression tests live in `tests/update.bats` and `tests/install_checksum.bats`. +- Do not change ESC timeout behavior in `lib/core/ui.sh` unless explicitly requested. +- Preserve operation logging to the project log path unless the user explicitly asks to change `MO_NO_OPLOG` behavior. +- **PRs touching destructive sinks need line-by-line review.** For `find_app_files`, `mole_delete`, `remove_file_list`, container traversal, identifier-prefix wildcards, or recursion that ends in deletion, audit every primary and fallback branch for matcher breadth, protected-path coverage, and preserved confirmation. Exact bundle ID or path evidence is required; vendor prefixes and common-name globs are not. Treat specialist or AI review output as a claim to verify, never as approval. + +## Working Rules + +- Before reviewing, auditing, debugging, or accepting a contributed PR, read `.claude/skills/bugs/SKILL.md`. Load its shell/test reference only when that surface is touched. +- Check `should_protect_path()` before adding cleanup behavior. +- Check app protection helpers before adding app cache, uninstall, or leftover cleanup behavior. +- Bundle protection matching is case-sensitive glob (`bundle_matches_pattern`), and macOS system bundles report inconsistent casing across releases (macOS 26 ships `com.apple.bootcampassistant` alongside the older `com.apple.BootCampAssistant`). When the monthly bundle drift audit reports gaps, add the exact IDs as the audit printed them, and check the runtime blanket `com.apple.*` guard before rating the gap's severity. The audit workflow's issue path requires the `bundle-drift` label to exist in the repo. +- **A new cleanup target needs measured value and an explicit non-target list.** State bytes reclaimed on a real app version, which sibling directories are excluded because they are user data, and whether protection covers every reachable cleanup path. "It looks like a cache" is not evidence; zero measured value stays out of scope. An encrypted or opaque index cannot prove a directory is unreferenced, so exclude it. +- Keep AI-tool cache cleanup conservative. Claude Code, opencode, Copilot CLI, Zed, Warp, Ghostty, and similar developer tools may have active versions, config, credentials, or session state that must not be removed accidentally. +- Do not clean tiny macOS UI state just because it is rebuildable. Wallpaper previews, preference thumbnails, and similar cover/state caches can create visible blank or cloud-download UI while reclaiming only a few MB; keep them unless there is strong user value and a regression test. +- Homebrew cleanup must be preview-first. Show the exact `brew autoremove` candidates before removal, preserve dry-run behavior, and keep tests on mocked `brew`; do not let a cleanup path execute real package-manager removals in verification. +- Sudo gates must not treat typed password characters as "skip". Only an explicit skip key should skip privileged cleanup; direct typed input must proceed into the real sudo prompt and have a regression test. +- Long cleanup scans need both an overall wall-clock budget and inner-loop checkpoints. A timed-out producer must not feed partial output into a deletion loop: materialize only completed scans, discard results on nonzero status, and propagate timeout/failure instead of reporting success. Probe and action must use the same pattern, type, age, and depth. If a project/artifact scan times out, degrade to partial or skipped-slow-scan output instead of appearing hung. +- System-service orphan scans must parse plist `Program` / `ProgramArguments` values as absolute paths only. Use non-interactive sudo for unreadable root-owned plists when needed, reject PlistBuddy error text as data, and keep CI tests on `/Library/LaunchDaemons` rather than relying on `/Library/PrivilegedHelperTools`. +- Uninstall leftover expansion must stay exact and boring: bundle ID or app-name variants only, reject generic/common words, keep short-name floors, skip broad locations like `Preferences/ByHost`, and only remove helper remnants after the parent app is confirmed gone and protected-path checks pass. +- Any new uninstall teardown path (launch services, login items, cask zap, helper bootout) must route through the shared-bundle-id sibling guard, covering `/Volumes` copies, inverse-name, and shared-identity variants, with a Bats regression per variant. +- Preference repair and optimize cleanup must skip protected and whitelisted plists before attempting removal. +- **Git worktree staleness is not decidable.** Clean only whitelisted rebuildable artifacts inside a worktree, never the worktree itself, and never emit a "safe to delete" verdict. Branch/remote heuristics fail on detached worktrees, ordinary status hides ignored files, and ignored entries may be the only copy of private state. A status surface may report blockers only: dirty, unpushed, locked, or ignored entries outside `MOLE_PURGE_TARGETS`. +- Purge discovery skips dot-directory containers by design. Add each supported container, such as `~/.codex/worktrees`, explicitly to `MOLE_PURGE_DEFAULT_SEARCH_PATHS`; do not broaden discovery to all dot directories. The scan layer already handles hidden descendants once their parent container is known, while project roots deeper than the existing two-level probe remain intentionally out of scope. +- **Do not add a shell-side directory size cache.** APFS does not propagate mtime up the tree, so a parent directory's mtime is unchanged when a descendant grows or shrinks and the cache hands the user a stale reclaimable number. Measure every time; `get_path_size_kb` is already timeout-bounded. +- Keep shell code formatted with `./scripts/check.sh --format`. +- Prefer targeted Bats tests during development; run the full suite before committing. +- Do not add AI attribution trailers to commits. +- `start_section` / `end_section` / `note_activity` have three intentionally different implementations in `lib/core/base.sh`, `bin/clean.sh`, and `bin/purge.sh`. Source order decides which one wins, and the wording, color, and dry-run export semantics differ on purpose. Read the cross-reference comment in `lib/core/base.sh` before changing any of them. +- **Judge duplication by body, not by name.** Copies that matter have already been renamed, so grep and a read-through both class them as separate helpers: two guards differing only by a `_MOLE_DEV_` vs `_MOLE_USER_` prefix, nine more re-implementing one process-state translation. `scripts/audit_function_duplication.py` hashes normalized bodies and gates on new groups; it found two pairs a manual sweep of the same release had missed. It is blind in the other direction: a probe plus guard that duplicate a live pair's purpose while differing in both name and body pass it cleanly, which is how a second, narrower Autodesk guard sat unreferenced until a caller sweep found it. The same caution applies to counts quoted in this file: check what a number counts before calling it stale, since `# SAFE:` legitimately annotates ~100 `rm -f` lines while its "seven" refers only to `rm -rf` under the CI-checked `lib/ bin/ install.sh mole` scope. +- **Test-orphan pattern:** before declaring a symbol dead, grep `lib`, `bin`, `cmd`, `scripts`, `tests`, and top-level entry/install scripts; check dynamic lookup through `eval`, `declare -f`, and `compgen`; then re-grep after removal. Trace variables and config written by a removed helper. Tests alone are not production callers, and sub-agent reports are leads, not verdicts. +- **`mole_clean_process_guard` in `lib/core/base.sh` is the only translator of the probe tri-state** (`0` running, `1` not, `2` could not tell). State `2` denies; a copy that folds it into "not running" deletes a live app's files while every other copy still reads correctly in review. Compound guards call it for the process question and add their own evidence after; eligibility goes through `mole_cleanup_targets_exist` (predicate list must match `_safe_clean_impl`'s), refusals through `mole_report_guard_stop`. Locked by `mole_clean_process_guard denies on an unknown process state` and `cleanup delete guards do not re-implement the process-state translation`. Left open-coded on purpose: the scan-stage `state -eq 2` blocks pick per-section wording, and the Codex open-file probe inverts the contract. +- **A `declare -f` probe into `bin/` is a shared shim, never a per-file copy.** Asking whether `safe_clean_guarded` or `defer_cleanup_family` exists is asking whether `bin/clean.sh` is loaded: always in production, never in a standalone Bats case. So each branch is a degraded second copy of a delete decision that only tests run. Use `mole_defer_cleanup_family`, or call `safe_clean_guarded` directly and let the test supply it. Locked by `cleanup libs share one engine-absent shim instead of forking their own` and `engine-absent cleanup fallbacks stay at their audited count`; lowering that cap after a removal is expected, raising it needs a reason. Rejected: hoisting `_safe_clean_impl` into `lib/core/` to delete the fallbacks, which bypasses the `safe_clean` stub 235 test points rely on and turns stubbed assertions into real deletions under a temp `HOME`. + +## Hotspot Ownership + +These files are intentionally large. Do not start by splitting them. Keep edits narrow, preserve local safety boundaries, and run the listed tests when touching each area. + +- `lib/clean/user.sh` owns user-level cleanup flows, browser caches, cloud/app support cleanup, device firmware, and Apple Silicon caches. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_user_core.bats tests/clean_browser_versions.bats tests/clean_app_caches.bats tests/clean_cached_device_firmware.bats` when touching this area, or `MOLE_TEST_NO_AUTH=1 ./scripts/test.sh` if behavior crosses sections. Chrome / Edge / Brave old-version cleanup is one table-driven helper (`_clean_chromium_old_versions`) plus three thin public wrappers; the wrapper names are the test surface, so keep them. `clean_edge_updater_old_versions` is deliberately NOT part of it: it prunes staged updater payloads strictly older than the installed Edge (falling back to keep-latest by `sort -V` when the installed version is unreadable), has no `Current` symlink, and never escalates to a sudo removal, so folding it in would silently change its semantics. +- `lib/core/app_protection.sh` owns uninstall/data/path protection policy and bundle matching; `lib/core/app_protection_data.sh` owns the protected app category lists. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall_safety.bats tests/uninstall_naming_variants.bats tests/bundle_resolver.bats`. +- `lib/clean/project.sh` owns purge discovery, project artifact filtering, purge menus, and purge config. Run `MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats`. +- `bin/uninstall.sh` owns uninstall command orchestration, app inventory, metadata refresh, and list/json output. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_scan_bash32.bats`. +- `lib/uninstall/batch.sh` owns batch uninstall execution, the shared-bundle-id sibling guard, launch service and login item teardown, and brew cask removal routing. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/brew_uninstall.bats tests/uninstall_remove_file_list.bats`. +- `lib/clean/dev.sh` owns developer-tool cleanup, language/toolchain caches, AI agent caches, and Codex runtime handling. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_dev_caches.bats tests/dev_extended.bats`. +- `lib/optimize/tasks.sh` owns optimize task registration and system maintenance actions. Run `MOLE_TEST_NO_AUTH=1 bats tests/optimize.bats tests/optimize_db.bats`. +- `bin/clean.sh` owns clean command orchestration, section output, and safe cleanup execution. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats tests/clean_apps.bats tests/cli.bats`. Section output follows one fixed rhythm: title → loading state → content → one trailing blank line, for every section. When touching any step of it, re-run the command and read the whole rendered output (column alignment, block spacing, icon consistency) instead of patching the one step that was reported. `_safe_clean_impl` filters protected, whitelisted, compiled-cache, and missing targets before consulting the dry-run delete guard or registering previews, so preview and real cleanup use the same eligible set; that check is intentional, not redundant with the per-path checks the real branch keeps at its deletion boundary. +- `lib/manage/update.sh` owns self-update, registry/bootstrap replacement, and self-heal fallback behavior. Preserve fail-closed version checks and test both normal update and broken-bootstrap recovery with `MOLE_TEST_NO_AUTH=1 bats tests/update.bats`. +- `cmd/analyze/update.go` owns the Bubble Tea `Update` chain and message handlers (Init, scanCmd, updateKey, goBack, switchToOverviewMode, enterSelectedDir). This is the largest file in `cmd/analyze/` and the natural landing spot for new key bindings, message types, or navigation behavior. Run `go test ./cmd/analyze`. `cmd/analyze/main.go` is bootstrap only (flag parsing, `main()`, helpers); `cmd/analyze/model.go` holds types and the model struct. +- `cmd/analyze/cache.go` owns analyze cache schema, expiry, load/save, invalidation, and cacheability decisions. Computation changes must invalidate stale persisted data in the same change. Run `go test ./cmd/analyze`. +- `cmd/analyze/analyze_test.go` and `cmd/status/view_test.go` are test hotspots. Add new cases near related behavior; split later only when touching many adjacent cases. Run `go test ./cmd/...`. +- `lib/core/file_ops.sh` owns the deletion funnel, Trash/permanent routing, operation-log outcomes, size accounting, and last-mile path validation. `lib/core/base.sh` owns shared shell primitives and source-order-sensitive section helpers. Keep policy in the existing protection helpers rather than adding a second delete path. Run `MOLE_TEST_NO_AUTH=1 bats tests/file_ops_mole_delete.bats tests/file_ops_size.bats tests/file_ops_safe_remove_symlink.bats tests/user_file_ops.bats tests/core_safe_functions.bats`. +- `cmd/analyze/scanner.go` owns disk traversal, Spotlight integration, cancellation, and all scan concurrency budgets. Treat its semaphores as independent resource limits and measure before changing them. Run `go test ./cmd/analyze`. +- `lib/clean/apps.sh` owns application-data cleanup, orphan service discovery, and the narrow verified-container-stub exception. `lib/clean/hints.sh` is read-only guidance and must stay bounded, timeout-aware, and non-destructive. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_apps.bats tests/clean_hints.bats`. +- `lib/ui/menu_paginated.sh` owns the shared Bash 3.2-compatible selection UI and terminal restoration. Preserve trap chaining, TTY restoration, and empty-selection behavior. Run `MOLE_TEST_NO_AUTH=1 bats tests/menu_trap_restore.bats tests/uninstall.bats`. +- `cmd/status/view.go` owns status rendering only; collection and JSON/NDJSON contracts live elsewhere in `cmd/status/`. Keep narrow-terminal layout and automation output independent. Run `go test ./cmd/status` and `MOLE_TEST_NO_AUTH=1 bats tests/cli.bats` when command routing changes. +- `bin/installer.sh` owns installer discovery, immutable delete-plan validation, the paginated selection flow, and incomplete-cleanup exit semantics. Run `MOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats`. + +## Verification + +- Shell changes: run `./scripts/check.sh --format`, then the relevant Bats test or `MOLE_TEST_NO_AUTH=1 ./scripts/test.sh`. +- Go changes: run `go test ./...`. +- Cleanup behavior: verify with dry-run or test mode first. +- File operation changes: run `MOLE_TEST_NO_AUTH=1 bats tests/file_ops_mole_delete.bats tests/user_file_ops.bats`. +- Installer changes: run `MOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats`. +- Purge changes: run `MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats`. +- Whitelist or management changes: run `MOLE_TEST_NO_AUTH=1 bats tests/manage_whitelist.bats tests/manage_sudo.bats`. +- Uninstall changes: run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_remove_file_list.bats`. +- Documentation-only changes: check links and commands. +- Never pipe a test, check, or CI run into `tail` or `head`. The pipeline reports the pager's exit code, so a red run reads green. Let it print in full, or capture to a file and check the status separately. + +`make check`, `make format`, `make test`, `make test-go`, and `make verify` are wrappers around the scripts above. `make verify` intentionally runs `check` plus Go tests only; use the full Bats suite before risky cleanup, uninstall, or release work. + +If `golangci-lint` reports issues from deleted temporary worktrees or non-existent paths, clear its local cache and rerun the linter: + +```bash +golangci-lint cache clean +golangci-lint run ./cmd/... +``` + +## GitHub Operations + +- Re-read the live issue or PR title, body, comments, state, labels, and author language before any public reply or closeout. +- Keep CLI issues and Mole Mac app issues separate. A fix in `mole-mac` does not imply a close in this CLI repo, and a CLI fix does not prove a Mac app issue is fixed unless the Mac app release path is verified. +- When closing a fixed bug or shipped feature, use project wording from the issue context and include the expected release path only when confirmed. +- **Mole Mac invitation after resolved reports**: after stating the verified fix or release state and the concrete retest step in the reporter's language, append one short final sentence to resolved bug or problem replies in both Issues and Discussions. Use `也欢迎试试我的 Mole Mac:https://mole.fit/,更易用,也更精致。` for Chinese and `You’re also welcome to try my Mole Mac app at https://mole.fit/ for a more polished, easier-to-use experience.` for English. Keep this separate from the resolution facts. It belongs only on a reply that resolves a user's defect report; leave it off PR thank-you notes, feature requests, questions, and other non-defect threads, and off threads that already concern Mole Mac or where the invitation would be redundant. +- **Discussion content cleanup**: when the maintainer classifies a Discussion as cleanup-only, such as spam, an empty or accidental post, duplicate promotion, or obsolete housekeeping with no technical answer needed, close it directly without replying. Do not apply this shortcut to substantive bug reports, Q&A, feature requests, or not-planned product decisions; those still need a concise disposition before closure. +- **Remote diagnostics for unreproducible reports**: for Mole Mac reports, ask the reporter to download the script with `curl -fL 'https://mole.fit/downloads/Mole-Diagnose.command' -o "$HOME/Desktop/Mole-Diagnose.command"`, then run `chmod +x "$HOME/Desktop/Mole-Diagnose.command" && open -R "$HOME/Desktop/Mole-Diagnose.command"`. Tell them to inspect it before double-clicking it and email the resulting `Mole-Diagnose-*.zip`; never ask them to attach the archive publicly because it contains local paths and logs. For CLI-only issues, prefer the relevant `mo` command output or `mo status` JSON. +- **Default issue closeout pipeline** once a fix is confirmed: commit lands on `main` (that alone makes it installable via nightly), verify the fix is actually on `main`, then reply in the reporter's language, opening with `@reporter`, in short paragraphs rather than one block, with the concrete update command: `mo update --nightly` now, the next stable release only when that path is confirmed. Closing needs the maintainer's word, but that word covers the whole pipeline: "该回复回复,该关闭关闭" or an equivalent authorizes commit, reply, and close in one turn, so run them to the end instead of returning for a separate confirmation at each step. The closing comment should invite reopening if the problem persists. + +## Release + +Tag-driven flow via `release.yml` on capital-`V` tag pushes. The full release runbook (distribution channels, pre-flight checklist, tag/publish commands, curated notes handoff, release-only pitfalls) lives in `.claude/skills/release-flow/SKILL.md`; read it before starting any release-flavored task. Notes formatting stays owned by `.claude/skills/release-notes/SKILL.md`. One rule that always applies: restate which distribution channels a release-flavored run will touch and confirm with the maintainer before acting; channel scope is specified by the maintainer, never inferred. diff --git a/Resources/mole/CONTRIBUTING.md b/Resources/mole/CONTRIBUTING.md index cf7ec5f..0bdb017 100644 --- a/Resources/mole/CONTRIBUTING.md +++ b/Resources/mole/CONTRIBUTING.md @@ -8,6 +8,9 @@ brew install shfmt shellcheck bats-core golangci-lint # Install goimports for better Go formatting go install golang.org/x/tools/cmd/goimports@latest + +# Install pre-commit hook (runs format/lint checks on every commit) +git config core.hooksPath .githooks ``` ## Development diff --git a/Resources/mole/CONTRIBUTORS.svg b/Resources/mole/CONTRIBUTORS.svg index 5db214e..2312691 100644 --- a/Resources/mole/CONTRIBUTORS.svg +++ b/Resources/mole/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -8,602 +8,1361 @@ - + tw93 - + - - - JackPhallen + + + youxi798 - + - - - bhadraagada + + + sebastianbreguel - + - - - iamxorum + + + JackPhallen - + - - - dwjoss + + + bhadraagada - + - - - alexandear + + + M-Hassan-Raza - + - - - imnotnoahhh + + + Yuxin-Qiao - + - - - amanthanvi + + + yuzeguitarist - + - - - Angelk90 + + + iamxorum - + - - - Sizk + + + dwjoss - + - - - rubnogueira + + + alexandear - + - - - biplavbarua + + + noah-qin - + - - - bsisduck + + + xronocode - + - - - spider-yamet + + + amanthanvi - + - - - jimmystridh + + + r266-tech - + - - - fte-jjmartres + + + hhh2210 - + - - - Else00 + + + LuisMIguelFurlanettoSousa - + - - - carolyn-sun + + + PremPrakashCodes - + - - - ndbroadbent + + + yhy0 - + - - - MohammedTarigg + + + Angelk90 - + - - - onurtashan + + + OWConnoi - + - - - ppauel + + + RajvardhanPatil07 - + - - - shakeelmohamed + + + guobosheng - + - - - Harsh-Kapoorr + + + yetval - + - - - thijsvanhal + + + Sizk - + - - - TomP0 + + + gloiiire - + - - - yuzeguitarist + + + alxhill - + - - - bikraj2 + + + bsisduck - + - - - bunizao + + + italojs - + - - - rans0 + + + rubnogueira - + - - - frozturk + + + bevanjkay - + - - - huyixi + + + biplavbarua - + - - - purofle + + + fabdelgado - + - - - yamamel + + + byronwang2005 - + - - - NanmiCoder + + + carolyn-sun - + - - - KoukeNeko + + + Else00 - + - - - andmev + + + fte-jjmartres - + - - - uluumbch + + + jimmystridh - + - - - ClathW + + + philippb - + - - - Copper-Eye + + + spider-yamet - + - - - DimitarNestorov + + + MohammedTarigg - + - - - gokulp01 + + + KoukeNeko - + - - - Hensell + + + chaochaoweb3 - + - - - jalen0x + + + bunizao - + - - - kowyo + + + bikraj2 - + - - - kwakubiney + + + abcreativ - + - - - LmanTW + + + IMZihad21 - + - - - injuxtice + + + zeldrisho - + - - - khipu-luke + + + Carsonn-Liu - + - - - mariovtor + + + Puhavik - + - - - anonymort + + + unnipv - + - - - Schlauer-Hax + + + tylergibbs1 - + - - - mickyyy68 + + + TyceHerrman - + - - - EastSun5566 + + + TomP0 - + - - - MohammedEsafi + + + thijsvanhal + + + + + + + + + + + Harsh-Kapoorr + + + + + + + + + + + shakeelmohamed + + + + + + + + + + + Shahfarzane + + + + + + + + + + + imrajyavardhan12 + + + + + + + + + + + QuakeWang + + + + + + + + + + + pranahonk + + + + + + + + + + + ppauel + + + + + + + + + + + NanmiCoder + + + + + + + + + + + Parsifa1 + + + + + + + + + + + z0ffy + + + + + + + + + + + YeJe-cpu + + + + + + + + + + + yamamel + + + + + + + + + + + sownhere + + + + + + + + + + + singkia + + + + + + + + + + + sibisai + + + + + + + + + + + purofle + + + + + + + + + + + mehmetcansahin + + + + + + + + + + + iwen-conf + + + + + + + + + + + iuhoay + + + + + + + + + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + fishwww-ww + + + + + + + + + + + honghoker + + + + + + + + + + + rans0 + + + + + + + + + + + corevibe555 + + + + + + + + + + + connorscott1 + + + + + + + + + + + clancyclaw + + + + + + + + + + + guangjun-super + + + + + + + + + + + jalen0x + + + + + + + + + + + ImL1s + + + + + + + + + + + Hensell + + + + + + + + + + + HaraldNordgren + + + + + + + + + + + gokulp01 + + + + + + + + + + + FelixLyfe + + + + + + + + + + + DimitarNestorov + + + + + + + + + + + Copper-Eye + + + + + + + + + + + ClathW + + + + + + + + + + + braianj + + + + + + + + + + + bashu + + + + + + + + + + + uluumbch + + + + + + + + + + + aronprins + + + + + + + + + + + MASNathan + + + + + + + + + + + andmev + + + + + + + + + + + AmanSikarwar + + + + + + + + + + + AlexanderAverin + + + + + + + + + + + AilfredBitworth + + + + + + + + + + + adilrc + + + + + + + + + + + rafay99-epic + + + + + + + + + + + onurtashan + + + + + + + + + + + ndbroadbent + + + + + + + + + + + MohammedEsafi + + + + + + + + + + + mshavliuk + + + + + + + + + + + EastSun5566 + + + + + + + + + + + mickyyy68 + + + + + + + + + + + Schlauer-Hax + + + + + + + + + + + anonymort + + + + + + + + + + + degouville + + + + + + + + + + + mariovtor + + + + + + + + + + + manuelguido + + + + + + + + + + + Lyleton + + + + + + + + + + + khipu-luke + + + + + + + + + + + injuxtice + + + + + + + + + + + LmanTW + + + + + + + + + + + iTofu + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + khisby + + + + + + + + + + + Jiaweeee + + + + + + + + + + + jason-costello \ No newline at end of file diff --git a/Resources/mole/LICENSE b/Resources/mole/LICENSE index 8ff99f4..f288702 100644 --- a/Resources/mole/LICENSE +++ b/Resources/mole/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2025 tw93 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Resources/mole/Makefile b/Resources/mole/Makefile index 4db8f37..47acb20 100644 --- a/Resources/mole/Makefile +++ b/Resources/mole/Makefile @@ -1,6 +1,6 @@ # Makefile for Mole -.PHONY: all build clean release +.PHONY: all build clean check format test test-go verify release release-amd64 release-arm64 mod-download # Output directory BIN_DIR := bin @@ -19,6 +19,7 @@ STATUS_SRC := ./cmd/status # Build flags LDFLAGS := -s -w +RELEASE_GO_ENV := CGO_ENABLED=0 all: build @@ -42,16 +43,31 @@ build: mod-download $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC) $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC) -# Release build targets (run on native architectures for CGO support) +check: + ./scripts/check.sh --no-format + +format: + ./scripts/check.sh --format + +test: + MOLE_TEST_NO_AUTH=1 ./scripts/test.sh + +test-go: + $(GO) test ./... + +verify: check test-go + +# Release build targets. Keep these pure-Go so the macOS SDK on the +# release runner cannot raise the Mach-O minimum OS version via cgo. release-amd64: mod-download @echo "Building release binaries (amd64)..." - GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) release-arm64: mod-download @echo "Building release binaries (arm64)..." - GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) clean: @echo "Cleaning binaries..." diff --git a/Resources/mole/README.md b/Resources/mole/README.md index 2c7ad76..2c667f1 100644 --- a/Resources/mole/README.md +++ b/Resources/mole/README.md @@ -1,54 +1,58 @@

Mole

-

Deep clean and optimize your Mac.

+

🐹 Clean, uninstall, analyze, optimize, and monitor your Mac from the terminal.

Stars Version - License + License Commits Twitter - Telegram + Telegram

- Mole - 95.50GB freed + Mole - 95.50GB freed

+> 💡 The CLI is free and open source. Prefer a native Mac app? [Mole for Mac](https://mole.fit) adds visual cleanup review, app updates, uninstall, maintenance, disk maps, live status, and a menu bar HUD. One license covers 2 Macs, with lifetime updates and a 14-day refund. + ## Features - **All-in-one toolkit**: Combines CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus in a **single binary** -- **Deep cleaning**: Removes caches, logs, and browser leftovers to **reclaim gigabytes of space** +- **Deep cleaning**: Removes caches, logs, leftovers, and orphaned app data to **reclaim gigabytes of space** - **Smart uninstaller**: Removes apps plus launch agents, preferences, and **hidden remnants** - **Disk insights**: Visualizes usage, finds large files, **rebuilds caches**, and refreshes system services - **Live monitoring**: Shows real-time CPU, GPU, memory, disk, and network stats ## Quick Start -**Install via Homebrew:** +**Install via Homebrew** ```bash brew install mole ``` -**Or via script:** +Homebrew follows Homebrew's supported macOS tiers. Use macOS 14 or later for the Homebrew path; older macOS versions should use the script installer below on a best-effort basis. + +**Or via script** ```bash # Optional args: -s latest for main branch code, -s 1.17.0 for specific version curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ``` -**Windows:** Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters. +> Note: Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters. -**Run:** +**Run** ```bash mo # Interactive menu -mo clean # Deep cleanup -mo uninstall # Remove apps + leftovers +mo clean # Deep cleanup + already-uninstalled app leftovers +mo uninstall # Remove installed apps + their leftovers mo optimize # Refresh caches & services -mo analyze # Visual disk explorer +mo analyze # Visual disk explorer (or 'mo analyse') mo status # Live system health dashboard mo purge # Clean project build artifacts mo installer # Find and remove installer files @@ -60,24 +64,43 @@ mo update --nightly # Update to latest unreleased main build, script in mo remove # Remove Mole from system mo --help # Show help mo --version # Show installed version +``` -# Safe preview before applying changes +**Preview safely** + +```bash mo clean --dry-run mo uninstall --dry-run +mo history +mo history --json mo purge --dry-run -# --dry-run also works with: optimize, installer, remove, completion, touchid enable +# Also works with: optimize, installer, remove, completion, touchid enable mo clean --dry-run --debug # Preview + detailed logs mo optimize --whitelist # Manage protected optimization rules mo clean --whitelist # Manage protected caches mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only +mo analyze /private/tmp # Review user-owned temporary directories ``` +Selections made with `mo clean --whitelist` persist in `~/.config/mole/whitelist`. + +## Security & Safety Design + +Mole is a local system maintenance tool, and some commands can perform destructive local operations. + +Mole uses safety-first defaults: path validation, protected-directory rules, conservative cleanup boundaries, and explicit confirmation for higher-risk actions. When risk or uncertainty is high, Mole skips, refuses, or requires stronger confirmation rather than broadening deletion scope. + +`mo analyze` is safer for ad hoc cleanup because it moves files to Trash through Finder instead of deleting them directly. + +Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for reporting guidance, safety boundaries, and current limitations. + ## Tips - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. -- Safety and logs: Deletions are permanent. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. See [Security Audit](SECURITY_AUDIT.md). +- Safety and logs: `clean`, `uninstall`, `purge`, `installer`, and `remove` are destructive. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/Library/Logs/mole/operations.log` and can be reviewed with `mo history`. Disable with `MO_NO_OPLOG=1`. Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md). +- App leftovers: use `mo clean` when the app is already uninstalled, and `mo uninstall` when the app is still installed. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. ## Features in Detail @@ -134,36 +157,41 @@ $ mo optimize System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d - ✓ Rebuild system databases and clear caches - ✓ Reset network services - ✓ Refresh Finder and Dock - ✓ Clean diagnostic and crash logs - ✓ Remove swap files and restart dynamic pager - ✓ Rebuild launch services and spotlight index + ✓ Inspect and repair supported system maintenance items + ✓ Refresh eligible Finder, network, and database state + ✓ Skip tasks that are unnecessary, unsafe now, or unavailable ==================================================================== -System optimization completed +Optimization Complete ==================================================================== - -Use `mo optimize --whitelist` to exclude specific optimizations. +Applied 8 optimizations +9 unchanged | 4 skipped | 2 unavailable +Optimization pass complete ``` +Use `mo optimize --whitelist` to exclude specific optimizations. Path patterns work too, so you can keep a long-lived mounted disk image around (for example `/Volumes/mail`) without it showing up as a detach candidate. + +Optimize results depend on the Mac's current state and available system tools, so the counts above are illustrative rather than fixed. + ### Disk Space Analyzer -By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path. +> Note: By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path. + +Developer tools may leave large temporary directories under `/private/tmp`. Review user-owned entries with `mo analyze /private/tmp`; selected entries move to Trash only after confirmation. Mole does not automatically delete third-party temporary directories because build markers and age alone cannot prove that a checkout or worktree is disposable. ```bash $ mo analyze -Analyze Disk ~/Documents | Total: 156.8GB +Analyze Disk (302.1GB free) +Select a location to explore: - ▶ 1. ███████████████████ 48.2% | 📁 Library 75.4GB >6mo - 2. ██████████░░░░░░░░░ 22.1% | 📁 Downloads 34.6GB - 3. ████░░░░░░░░░░░░░░░ 14.3% | 📁 Movies 22.4GB - 4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB - 5. ██░░░░░░░░░░░░░░░░░ 5.2% | 📄 backup_2023.zip 8.2GB + ▶ 1. ████████████████████████ 47.9% | Home 75.4GB + 2. ███████████ 22.0% | User Library 34.6GB + 3. ███████ 14.2% | Applications 22.4GB + 4. █████ 10.7% | System Library 16.9GB + 5. ███ 5.2% | Old Downloads (90d+) 8.2GB >3mo - ↑↓←→ Navigate | O Open | F Show | ⌫ Delete | L Large files | Q Quit +↑↓→ | Enter | R Refresh | O Open | P Preview | F File | Esc/Q Quit ``` ### Live System Status @@ -195,11 +223,53 @@ Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5% Health score is based on CPU, memory, disk, temperature, and I/O load, with color-coded ranges. -Shortcuts: In `mo status`, press `k` to toggle the cat and save the preference, and `q` to quit. +Shortcuts: In `mo status`, press `k` to toggle the cat, `c` to cycle how many CPU cores the card lists (2, 4, 8, all), and `q` to quit. Both preferences are saved. + +When enabled, `mo status` shows a read-only alert banner for processes that stay above the configured CPU threshold for a sustained window. Use `--proc-cpu-threshold`, `--proc-cpu-window`, or `--proc-cpu-alerts=false` to tune or disable it. + +#### Machine-Readable Output + +Both `mo analyze` and `mo status` support a `--json` flag for scripting and automation. + +`mo status` also auto-detects when its output is piped (not a terminal) and switches to JSON automatically. + +```bash +# Disk analysis as JSON +$ mo analyze --json ~/Documents +{ + "path": "/Users/you/Documents", + "overview": false, + "entries": [ + { "name": "Library", "path": "...", "size": 80939438080, "is_dir": true }, + ... + ], + "large_files": [ + { "name": "backup.zip", "path": "...", "size": 8796093022 } + ], + "total_size": 168393441280, + "total_files": 42187 +} + +# System status as JSON +$ mo status --json +{ + "host": "MacBook-Pro", + "health_score": 92, + "cpu": { "usage": 45.2, "logical_cpu": 8, ... }, + "memory": { "total": 25769803776, "used": 15049334784, "used_percent": 58.4 }, + "disks": [ ... ], + "uptime": "3d 12h 45m", + ... +} + +# Auto-detected JSON when piped +$ mo status | jq '.health_score' +92 +``` ### Project Artifact Purge -Clean old build artifacts such as `node_modules`, `target`, `build`, and `dist` to free up disk space. +Clean old build artifacts such as `node_modules`, `target`, `.build`, `build`, and `dist` to free up disk space. ```bash mo purge @@ -216,10 +286,10 @@ Select Categories to Clean - 18.5GB (8 selected) ● backend-service 2.5GB | node_modules ``` -> We recommend installing `fd` on macOS. +> Note: We recommend installing `fd` on macOS. > `brew install fd` -> **Use with caution:** This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default. +> Safety: This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default.
Custom Scan Paths @@ -291,16 +361,20 @@ Thanks to everyone who helped build Mole. Go follow them. ❤️

Real feedback from users who shared Mole on X. -Community feedback on Mole +Community feedback on Mole ## Support -- If Mole helped you, star the repo or [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. -- Got ideas or bugs? Read the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR. -- Like Mole? Buy Tw93 a Coke to support the project. 🥤 Supporters are below. +- Getting [Mole for Mac](https://mole.fit) is the most direct way to support Mole's development. +- If Mole helped you, give it a star, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.), or open an issue or PR. +- I have two cats, TangYuan and Coke. If you think Mole delights your life, you can feed them canned food 🥩. - +
+These lovely people already did 🐱 +
+ +
## License -MIT License. Feel free to use Mole and contribute. +Mole is open source under GPL-3.0, see [LICENSE](LICENSE). A version you modify and share stays open under the same license, and if you fork Mole into your own product, to avoid confusion please give it a different name and credit Mole as the source. [Mole for Mac](https://mole.fit) is a separate, proprietary app, and Mole is here for the long run. diff --git a/Resources/mole/SECURITY.md b/Resources/mole/SECURITY.md new file mode 100644 index 0000000..7a38830 --- /dev/null +++ b/Resources/mole/SECURITY.md @@ -0,0 +1,76 @@ +# Security Policy + +Mole is a local system maintenance tool. It includes high-risk operations such as cleanup, uninstall, optimization, and artifact removal. We treat safety boundaries, deletion logic, and release integrity as security-sensitive areas. + +## Reporting a Vulnerability + +Please report suspected security issues privately. + +- Email: `hitw93@gmail.com` +- Subject line: `Mole security report` + +Do not open a public GitHub issue for an unpatched vulnerability. + +If GitHub Security Advisories private reporting is enabled for the repository, you may use that channel instead of email. + +Include as much of the following as possible: + +- Mole version and install method +- macOS version +- Exact command or workflow involved +- Reproduction steps or proof of concept +- Whether the issue involves deletion boundaries, symlinks, sudo, path validation, or release/install integrity + +## Response Expectations + +- We aim to acknowledge new reports within 7 calendar days. +- We aim to provide a status update within 30 days if a fix or mitigation is not yet available. +- We will coordinate disclosure after a fix, mitigation, or clear user guidance is ready. + +Response times are best-effort for a maintainer-led open source project, but security reports are prioritized over normal bug reports. + +## Supported Versions + +Security fixes are only guaranteed for: + +- The latest published release +- The current `main` branch + +Older releases may not receive security fixes. Users running high-risk commands should stay current. + +## What We Consider a Security Issue + +Examples of security-relevant issues include: + +- Path validation bypasses +- Deletion outside intended cleanup boundaries +- Unsafe handling of symlinks or path traversal +- Unexpected privilege escalation or unsafe sudo behavior +- Sensitive data removal that bypasses documented protections +- Release, installation, update, or checksum integrity issues +- Vulnerabilities in logic that can cause unintended destructive behavior + +## What Usually Does Not Qualify + +The following are usually normal bugs, feature requests, or documentation issues rather than security issues: + +- Cleanup misses that leave recoverable junk behind +- False negatives where Mole refuses to clean something +- Cosmetic UI problems +- Requests for broader or more aggressive cleanup behavior +- Compatibility issues without a plausible security impact + +If you are unsure whether something is security-relevant, report it privately first. + +## Security-Focused Areas in Mole + +The project pays particular attention to: + +- Destructive command boundaries +- Path validation and protected-directory rules +- Sudo and privilege boundaries +- Symlink and path traversal handling +- Sensitive data exclusions +- Packaging, release artifacts, checksums, and update/install flows + +For the current technical design and known limitations, see [SECURITY_AUDIT.md](SECURITY_AUDIT.md). diff --git a/Resources/mole/SECURITY_AUDIT.md b/Resources/mole/SECURITY_AUDIT.md index 0a6e471..7e15da5 100644 --- a/Resources/mole/SECURITY_AUDIT.md +++ b/Resources/mole/SECURITY_AUDIT.md @@ -1,18 +1,65 @@ -# Mole Security Reference +# Mole Security Audit -Version 1.30.0 | 2026-03-08 +This document describes the security-relevant behavior of the current `main` branch, updated for V1.50.0 on 2026-08-08. It is intended as a public description of Mole's safety boundaries, destructive-operation controls, release integrity signals, and known limitations. -This document describes the security-relevant behavior of the current codebase on `main`. +## Executive Summary -## Path Validation +Mole is a local system maintenance tool. Its main risk surface is not remote code execution; it is unintended local damage caused by cleanup, uninstall, optimize, purge, installer cleanup, or other destructive operations. -All destructive file operations go through `lib/core/file_ops.sh`. +The project is designed around safety-first defaults: -- `validate_path_for_deletion()` rejects empty paths, relative paths, traversal segments such as `/../`, and control characters. -- Security-sensitive cleanup paths do not use raw `find ... -delete`. -- Removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()`. +- destructive paths are validated before deletion +- critical system roots and sensitive user-data categories are protected +- sudo use is bounded and additional restrictions apply when elevated deletion is required +- symlink handling is conservative +- preview, confirmation, timeout, and operation logging are used to make destructive behavior more visible and auditable -Blocked paths remain protected even with sudo, including: +Mole prioritizes bounded cleanup over aggressive cleanup. When uncertainty exists, the tool should refuse, skip, or require stronger confirmation instead of widening deletion scope. + +The project continues to strengthen: + +- release integrity and public security signals +- targeted regression coverage for high-risk paths +- clearer documentation for privilege boundaries and known limitations + +## Threat Surface + +The highest-risk areas in Mole are: + +- direct file and directory deletion +- recursive cleanup across common user and system cache locations +- uninstall flows that combine app removal with remnant cleanup +- project artifact purge for large dependency/build directories +- elevated cleanup paths that require sudo +- release, install, and update trust signals for distributed artifacts + +`mo analyze` is intentionally lower-risk than cleanup flows: + +- it does not require sudo +- it respects normal user permissions and SIP +- delete actions require explicit confirmation +- deletion routes through Finder Trash behavior rather than direct permanent removal + +## Destructive Operation Boundaries + +All destructive shell file operations are routed through guarded helpers in `lib/core/file_ops.sh`. + +Core controls include: + +- `validate_path_for_deletion()` rejects empty paths +- relative paths are rejected +- path traversal segments such as `..` as a path component are rejected +- paths containing control characters are rejected +- raw `find ... -delete` is avoided for security-sensitive cleanup logic +- removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()` +- uninstall removal flows that move items to Trash use `mole_delete`, which validates the path again and records the operation result. `mole_delete` now also validates symlinks instead of skipping them, and normalizes the target by collapsing repeated slashes and stripping a trailing slash before the protected-path check, so equivalent path spellings cannot slip past protection. +- incomplete download cleanup skips files currently open (lsof check) and uses quoted glob patterns to prevent word-splitting on filenames that contain spaces +- live user-cache protection refuses any reverse-DNS directory under `~/Library/Caches` whose owner is still running. Deleting an open SQLite cache can send the owning helper into a loop writing to unlinked files until the volume fills, observed with Autodesk Fusion's background helpers (issue #1390). The probe is tri-state and denies on both "running" and "could not tell": an unreadable process table is never read as idle. A SQLite family member is additionally refused while a WAL `-shm` companion exists or `lsof` proves an open handle, and a missing `lsof` fails closed. The check runs inside `validate_path_for_deletion()` and again in `safe_remove()` after size probing, so a helper that launches mid-scan still blocks the delete. The same gate covers the generic `~/Library/Caches/*` sweep, not only the app-specific cleaners +- `mo clean` does not touch the LaunchServices database. A per-record removal is not implementable: `lsregister -u` resolves the path before unregistering, so on macOS 15 and later it returns `-10814` for exactly the records whose app is already gone. The only supported repair is a domain rescan, which is an explicit user-triggered task in `mo optimize` (`opt_launch_services_rebuild`), never an automatic step inside cleanup +- uninstall leftover removal is gated by a shared-bundle-id sibling guard: when another install of the same bundle id is still present, shared leftovers are kept and only the selected bundle is removed. Absence of a sibling must be proven, not assumed, so the scan reports three states and only a complete "no other install" result unlocks full leftover removal; a timeout, an unreadable volume, or an unreadable bundle degrades to the narrowed plan. The package-receipt half of that evidence is cached on disk keyed by a checksum of the installed receipt list rather than by time alone, because a TTL cannot prove completeness and a package installed after the last write would otherwise stay invisible to the guard +- orphaned system-service cleanup in `mo clean` (`lib/clean/apps.sh` `clean_orphaned_system_services`) runs only when sudo is already available, scans `/Library/{LaunchDaemons,LaunchAgents,PrivilegedHelperTools}` while skipping `com.apple.*`, and flags an entry only when its launchd `Program`/`ProgramArguments[0]` path is absolute and missing, or a `PrivilegedHelperTools` helper whose parent app is uninstalled (`bundle_has_installed_app`). Package-manager and system binary locations, a known-helper protect list, mdfind-resolved installed apps, the whitelist, and `should_protect_path` (with `SYSTEM_CRITICAL_BUNDLES` still enforced) all exclude entries before removal. Root-owned plists are read with non-interactive sudo and fail closed, so an unreadable plist is never misread as a missing binary; removal runs `launchctl unload` then the guarded `safe_sudo_remove`, and honors dry-run (issue #1082) + +Blocked paths remain protected even with sudo. Examples include: ```text / @@ -26,7 +73,7 @@ Blocked paths remain protected even with sudo, including: /Library/Extensions ``` -Some subpaths under protected roots are explicitly allowlisted for bounded cache and log cleanup, for example: +Some subpaths under otherwise protected roots are explicitly allowlisted for bounded cleanup where the project intentionally supports cache/log maintenance. Examples include: - `/private/tmp` - `/private/var/tmp` @@ -37,164 +84,271 @@ Some subpaths under protected roots are explicitly allowlisted for bounded cache - `/private/var/db/powerlog` - `/private/var/db/reportmemoryexception` -When running with sudo, symlinked targets are validated before deletion and system-target symlinks are refused. +This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns. -## Cleanup Rules +## Path Protection Reference -### Orphan Detection +### Protected Prefixes (Never Deleted) -Orphaned app data is handled in `lib/clean/apps.sh`. +```text +/ +/System +/bin +/sbin +/usr +/etc +/var +/private +/Library/Extensions +``` -- Generic orphaned app data requires both: - - the app is not found by installed-app scanning and fallback checks, and - - the target has been inactive for at least 30 days. -- Claude VM bundles use a stricter app-specific window: - - `~/Library/Application Support/Claude/vm_bundles/claudevm.bundle` must appear orphaned, and - - it must be inactive for at least 7 days before cleanup. -- Sensitive categories such as keychains, password-manager data, and protected app families are excluded from generic orphan cleanup. +### Whitelist Exceptions (Allowlisted for Cleanup) -Installed-app detection is broader than a simple `/Applications` scan and includes: +Some subpaths under protected roots are explicitly allowlisted: -- `/Applications` -- `/System/Applications` -- `~/Applications` -- Homebrew Caskroom locations -- Setapp application paths +- `/private/tmp` +- `/private/var/tmp` +- `/private/var/log` +- `/private/var/folders` +- `/private/var/db/diagnostics` +- `/private/var/db/DiagnosticPipeline` +- `/private/var/db/powerlog` +- `/private/var/db/reportmemoryexception` -Spotlight fallback checks are bounded with short timeouts to avoid hangs. +### Protected Categories -### Uninstall Matching +In addition to path blocking, these categories are protected: -App uninstall behavior is implemented in `lib/uninstall/batch.sh` and related helpers. +- Keychains, password managers, credentials +- VPN/proxy tools (Shadowsocks, V2Ray, Clash, Tailscale, AmneziaWG, WireGuard, NetworkExtension preferences) +- AI tools (Cursor, Claude, ChatGPT, Ollama) +- Codex Desktop runtime state and active VM/runtime caches +- OrbStack and similar local container/VM runtimes: live container and machine images under `~/Library/Group Containers/*dev.orbstack` and `~/.orbstack`, plus protected bundles `dev.orbstack.*` and `dev.kdrag0n.MacVirt`. Rebuildable caches such as `~/Library/Caches/dev.orbstack.OrbStack` remain cleanable. +- Browser history and cookies +- Apple-owned app group containers, including `group.com.apple.notes` +- Time Machine data (during active backup) +- `com.apple.*` LaunchAgents/LaunchDaemons +- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration +- iCloud-synced `Mobile Documents` -- LaunchAgent and LaunchDaemon lookups require a valid reverse-DNS bundle identifier. -- Deletion candidates are decoded and validated as absolute paths before removal. -- Homebrew casks are preferentially removed with `brew uninstall --cask --zap`. -- LaunchServices unregister and rebuild steps are skipped safely if `lsregister` is unavailable. +## Implementation Details -### Developer and Project Cleanup +All deletion routes pass through `lib/core/file_ops.sh`: -Project artifact cleanup in `lib/clean/project.sh` protects recently modified targets: +- `validate_path_for_deletion()` - Empty, relative, traversal checks +- `should_protect_path()` - Prefix and pattern matching +- `safe_remove()`, `safe_find_delete()`, `safe_sudo_remove()` - Guarded operations -- recently modified project artifacts are treated as recent for 7 days -- protected vendor and build-output heuristics prevent broad accidental deletions -- nested artifacts are filtered to avoid duplicate or parent-child over-deletion +The current design rationale is kept in this audit document so the safety model stays next to the implementation notes. -Developer-cache cleanup preserves toolchains and other high-value state. Examples intentionally left alone include: +## Protected Directories and Categories -- `~/.cargo/bin` -- `~/.rustup` -- `~/.mix/archives` -- `~/.stack/programs` - -## Protected Categories +Mole has explicit protected-path and protected-category logic in addition to root-path blocking. Protected or conservatively handled categories include: -- system components such as Control Center, System Settings, TCC, Spotlight, and `/Library/Updates` -- password managers and keychain-related data -- VPN / proxy tools such as Shadowsocks, V2Ray, Clash, and Tailscale +- system components such as Control Center, System Settings, TCC, Spotlight, Finder, and Dock-related state +- keychains, password-manager data, tokens, credentials, and similar sensitive material +- VPN and proxy tools such as Shadowsocks, V2Ray, Clash, Tailscale, AmneziaWG, WireGuard, and NetworkExtension preferences - AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama +- Codex Desktop runtime state and active VM/runtime caches +- OrbStack and similar local container/VM runtimes, including live data under `~/Library/Group Containers/*dev.orbstack` and `~/.orbstack`, while rebuildable runtime caches stay eligible for cleanup - `~/Library/Messages/Attachments` +- Apple Notes and other Apple-owned app group containers, including `~/Library/Group Containers/group.com.apple.notes` - browser history and cookies - Time Machine data while backup state is active or ambiguous - `com.apple.*` LaunchAgents and LaunchDaemons +- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration +- iCloud-synced `Mobile Documents` data -## Analyzer +Project purge also uses conservative heuristics: -`mo analyze` is intentionally lower-risk than cleanup flows: +- purge targets must be inside configured project boundaries +- direct-child artifact cleanup is only allowed in single-project mode +- recently modified artifacts are treated as recent for 7 days +- nested artifacts are filtered to avoid parent-child over-deletion +- protected vendor/build-output heuristics block ambiguous directories -- it does not require sudo -- it respects normal user permissions and SIP -- interactive deletion requires an extra confirmation sequence -- deletions route through Trash/Finder behavior rather than direct permanent removal +Developer cleanup also preserves high-value state. Examples intentionally left alone include: -Code lives under `cmd/analyze/*.go`. +- `~/.cargo/bin` +- `~/.rustup` +- `~/.mix/archives` +- `~/.stack/programs` -## Timeouts and Hang Resistance +## Symlink and Path Traversal Handling -`lib/core/timeout.sh` uses this fallback order: +Symlink behavior is intentionally conservative. -1. `gtimeout` / `timeout` -2. a Perl helper with process-group cleanup -3. a shell fallback +- path validation checks symlink targets before deletion +- symlinks pointing at protected system targets are rejected +- an *ancestor* being a symlink is caught too, not just the leaf: the parent is canonicalized and the deny checks re-run on the resolved path, so a redirected `~/Library/Caches` cannot route a cache sweep into a protected tree. The re-check is deny-only, so a resolved path never grants permission the literal path lacked +- `mole_delete` validates symlinks rather than skipping them, so a symlink whose target resolves into a protected root is refused instead of silently moved +- `safe_sudo_remove()` refuses to sudo-delete symlinks +- `safe_find_delete()` and `safe_sudo_find_delete()` refuse to scan symlinked base directories +- installer discovery avoids treating symlinked installer files as deletion candidates +- analyzer scanning skips following symlinks to unexpected targets -Current notable timeouts in security-relevant paths: +Path traversal handling is also explicit: -- orphan/Spotlight `mdfind` checks: 2s -- LaunchServices rebuild during uninstall: 10s / 15s bounded steps -- Homebrew uninstall cask flow: 300s default, extended to 600s or 900s for large apps -- Application Support sizing: direct file `stat`, bounded `du` for directories +- non-absolute paths are rejected for destructive helpers +- `..` is rejected when it appears as a path component +- legitimate names containing `..` inside a single path element remain allowed to avoid false positives for real application data +- `mo analyze` delete validates the raw user-supplied path before `filepath.Abs` resolves it, then validates the resolved absolute path a second time, closing a window where traversal segments could survive `Abs` normalization -Additional safety behavior: +## Privilege Escalation and Sudo Boundaries -- `brew_uninstall_cask()` treats exit code `124` as timeout failure and returns failure immediately -- font cache rebuild is skipped while browsers are running -- project-cache discovery and scans use strict timeouts to avoid whole-home stalls +Mole uses sudo for a subset of system-maintenance paths, but elevated behavior is still bounded by validation and protected-path rules. -## User Configuration +Key properties: -Protected paths can be added to `~/.config/mole/whitelist`, one path per line. +- sudo access is explicitly requested instead of assumed +- non-interactive preview remains conservative when sudo is unavailable +- protected roots remain blocked even when sudo is available +- sudo deletion uses the same path validation gate as non-sudo deletion +- sudo cleanup skips or reports denied operations instead of widening scope +- sudo-required uninstall paths are routed to the invoking user's Trash where possible instead of root-owned Trash or direct deletion +- sudo Trash routing refuses unsafe Trash locations, including symlinked Trash directories +- authentication, SIP/MDM, and read-only filesystem failures are classified separately in file-operation results +- sudo credential prompting passes through the system's native PAM prompt rather than a hardcoded string, ensuring correct behavior across locales and PAM configurations +- Touch ID PAM configuration (`mo touchid`) uses `sudo install -m 444 -o root -g wheel` for atomic file writes, preventing temporary permission windows where PAM files could be user-writable (fixed in V1.39.0; prior versions used `sudo mv` which preserved temp-file ownership) +- the perl-based command timeout fallback creates a new process group with `setpgid(0, 0)` rather than calling `setsid()`, so the timed child keeps the controlling terminal. This lets nested sudo inside a Homebrew cask uninstall script reuse the already-cached credential instead of failing on a detached tty, while the group-kill cleanup semantics (`kill TERM -pid`) are unchanged. -Example: +When sudo is denied or unavailable, Mole prefers skipping privileged cleanup to forcing execution through unsafe fallback behavior. -```bash -/Users/me/important-cache -~/Library/Application Support/MyApp -``` +## Sensitive Data Exclusions -Exact path protection is preferred over pattern-style broad deletion rules. +Mole is not intended to aggressively delete high-value user data. -Use `--dry-run` before destructive operations when validating new cleanup behavior. +Examples of conservative handling include: -## Testing +- sensitive app families are excluded from generic orphan cleanup +- orphaned app data waits for inactivity windows before cleanup +- Claude VM orphan cleanup uses a separate stricter rule +- uninstall file lists are decoded and revalidated before removal +- reverse-DNS bundle ID validation is required before LaunchAgent and LaunchDaemon pattern matching; bundle ID matching uses boundary-aware comparisons (`mole_name_starts_with_bundle_id_boundary`, `mole_name_has_bundle_id_boundary`) to prevent cross-app false matches (e.g. `com.example` not matching `com.example123`), and `defaults delete` is guarded by `mole_is_reverse_dns_bundle_id()` to reject malformed or adversarial domain strings +- LaunchAgents that only declare `MachServices` are unload-only and are not treated as safe deletion targets without a backing executable or bundle match +- `force_kill_app()` refuses to terminate a process whose resolved name matches a known system process, and this guard runs before the entire pgrep/AppleScript/pkill escalation ladder, so a third-party app cannot weaponize it by setting a system-like `CFBundleExecutable` +- receipt payload removal is gated by `receipt_payload_path_is_allowlisted()`, which requires a well-formed reverse-DNS bundle ID and only allows files whose basename is anchored to that bundle ID under `/Library/LaunchAgents`, `/Library/LaunchDaemons`, `/Library/PrivilegedHelperTools`, or `/private/var/db/receipts` +- apps managed by an official vendor uninstaller are excluded from Mole's own removal list, so the vendor's uninstall flow remains authoritative +- XDG-style dotdirs belonging to a standalone CLI tool that shares a name with a GUI app are preserved during uninstall, preventing collateral removal of unrelated CLI state (issue #993, for example a CLI sharing a name with `Claude.app` or `OpenCode.app`) +- batch uninstall now displays system-level remnants for review instead of deleting them; the confirmation prompt is retained and any `launchctl unload`/`bootout` runs under dry-run and `MOLE_TEST_MODE` guards -There is no dedicated `tests/security.bats`. Security-relevant behavior is covered by targeted BATS suites, including: +Installed-app detection is broader than a single `/Applications` scan and includes: -- `tests/clean_core.bats` -- `tests/clean_user_core.bats` -- `tests/clean_dev_caches.bats` -- `tests/clean_system_maintenance.bats` -- `tests/clean_apps.bats` -- `tests/purge.bats` -- `tests/core_safe_functions.bats` -- `tests/optimize.bats` +- `/Applications` +- `/System/Applications` +- `~/Applications` +- Homebrew Caskroom locations +- Setapp application paths -Local verification used for the current branch includes: +This reduces the risk of incorrectly classifying active software as orphaned data. -```bash -bats tests/clean_core.bats tests/clean_user_core.bats tests/clean_dev_caches.bats tests/clean_system_maintenance.bats tests/purge.bats tests/core_safe_functions.bats tests/clean_apps.bats tests/optimize.bats -bash -n lib/core/base.sh lib/clean/apps.sh tests/clean_apps.bats tests/optimize.bats -``` +## Dry-Run, Confirmation, and Audit Logging + +Mole exposes multiple safety controls before and during destructive actions: + +- `--dry-run` previews are available for major destructive commands +- dry-run output deduplicates targets by filesystem identity (device+inode), so aliased paths and symlinks do not appear as separate items +- interactive high-risk flows require explicit confirmation before deletion +- purge marks recent projects conservatively and leaves them unselected by default +- purge configuration is written atomically (mktemp then rename) to prevent partial writes if the process is interrupted +- analyzer delete uses Finder Trash rather than direct permanent removal +- operation logs are written to `~/Library/Logs/mole/operations.log` unless disabled with `MO_NO_OPLOG=1` +- `mole_delete` Trash and permanent deletion attempts are also recorded by the file-operation layer with result status, target path, and error context where available +- `mo history` (`lib/core/history.sh`) is read-only: it reads `operations.log` and `deletions.log` to surface recent cleanup activity and performs no deletion or out-of-bounds writes +- timeouts bound external commands so stalled discovery or uninstall operations do not silently hang the entire flow + +Relevant timeout behavior includes: + +- orphan and Spotlight checks: 2s +- LaunchServices rebuild during uninstall: bounded 10s and 15s steps +- Homebrew uninstall cask flow: 300s by default, extended for large apps when needed +- project scans and sizing operations: bounded to avoid whole-home stalls + +## Optimize and System Maintenance Safety -CI additionally runs shell and Go validation on push. +Optimize tasks are maintenance actions rather than bulk deletion, but they still touch user-visible state, so they are bounded conservatively: -## Dependencies +- Optimize never restarts Dock or deletes any `*.db` under `~/Library/Application Support/Dock`. Earlier implementations reset the user's wallpaper or disrupted the desktop session; Dock repair is no longer an automatic maintenance task (#995, #1300). +- Spotlight orphan rule cleanup operates only in the user domain through `defaults`, runs under a dry-run guard, removes only entries whose app is confirmed no longer installed (`bundle_has_installed_app`), requires a well-formed reverse-DNS bundle ID, and never touches `System.*` or `com.apple.*` rules. +- Font Cache Rebuild (`atsutil databases -remove`) was removed because clearing the font cache could corrupt font rendering with no reliable benefit. -Primary Go dependencies are pinned in `go.mod`, including: +## Release Integrity and Continuous Security Signals -- `github.com/charmbracelet/bubbletea v1.3.10` -- `github.com/charmbracelet/lipgloss v1.1.0` -- `github.com/shirou/gopsutil/v4 v4.26.2` -- `github.com/cespare/xxhash/v2 v2.3.0` +Mole treats release trust as part of its security posture, not just a packaging detail. -System tooling relies mainly on Apple-provided binaries and standard macOS utilities such as: +Repository-level signals include: -- `tmutil` -- `diskutil` -- `plutil` -- `launchctl` -- `osascript` -- `find` -- `stat` +- weekly Dependabot updates for Go modules and GitHub Actions +- pre-commit hook that mirrors GitHub CI checks locally (shell syntax, shfmt, shellcheck, Go vet) +- CI checks for unsafe `rm -rf` usage patterns and core protection behavior +- targeted tests for path validation, purge boundaries, symlink behavior, dry-run flows, and destructive helpers +- macOS 14 and macOS 15 compatibility coverage for core Bats suites +- CodeQL scanning for Go and GitHub Actions workflows, with workflow permission hardening +- curated changelog-driven release notes for user-visible changes +- published SHA-256 checksums for release assets +- GitHub artifact attestations for release assets +- install-time verification of the GitHub Actions build-provenance attestation: `install.sh` runs `gh attestation verify` (with `--deny-self-hosted-runners`) on the downloaded asset when the GitHub CLI is available, and a mismatch is treated as fatal before checksums are read. This moves attestation from a release-side artifact to an install-side check. -Dependency vulnerability status should be checked separately from this document. +These controls do not eliminate all supply-chain risk, but they make release changes easier to review and verify. -## Limitations +## Testing Coverage -- Cleanup is destructive. There is no undo. -- Generic orphan data waits 30 days before automatic cleanup. -- Claude VM orphan cleanup waits 7 days before automatic cleanup. -- Time Machine safety windows are hour-based, not day-based, and remain more conservative. +There is no single `tests/security.bats` file. Instead, security-relevant behavior is covered by focused suites, including: + +- `tests/core_safe_functions.bats` +- `tests/clean_core.bats` +- `tests/clean_user_core.bats` +- `tests/clean_dev_caches.bats` +- `tests/clean_system_maintenance.bats` +- `tests/clean_apps.bats` +- `tests/file_ops_mole_delete.bats` +- `tests/purge.bats` +- `tests/installer.bats` +- `tests/optimize.bats` +- `tests/uninstall_safety.bats` +- `tests/uninstall_naming_variants.bats` +- `tests/path_validation_fuzz.bats` +- `tests/history.bats` +- `tests/core_timeout.bats` +- `cmd/analyze/*_test.go` + +Key coverage areas include: + +- path validation rejects empty, relative, traversal, and system paths +- symlinked directories are rejected for destructive scans +- purge protects shallow or ambiguous paths and filters nested artifacts +- dry-run flows preview actions without applying them and do not emit duplicate targets +- confirmation flows exist for high-risk interactive operations +- LaunchAgent unload-only handling, Homebrew Cask paths, and sudo-required Trash routing +- Apple Notes group containers and other Apple-owned group containers remain protected +- sudo credential prompting and session management (`tests/manage_sudo.bats`) +- purge config path discovery and write behavior (`tests/purge_config_paths.bats`) +- hint and cleanup-hint flows (`tests/clean_hints.bats`) +- Touch ID PAM file permission enforcement (`tests/cli.bats`) +- bundle ID boundary matching and malformed-ID rejection (`tests/uninstall_safety.bats`) +- official-uninstaller exclusion and receipt payload allowlisting (`tests/uninstall_safety.bats`) +- uninstall behavior across localized and naming-variant app names (`tests/uninstall_naming_variants.bats`) +- property-style path validation fuzzing over the corpus in `tests/fuzz_corpus/` (`tests/path_validation_fuzz.bats`) +- read-only history rendering from operation logs (`tests/history.bats`) +- command timeout behavior including process-group cleanup (`tests/core_timeout.bats`) +- bash 3.2 empty-array nounset compatibility (`tests/uninstall_scan_bash32.bats`) +- live user-cache refusal while an owner process runs, an unreadable process table denying rather than allowing, and SQLite family members held by an open handle or a WAL `-shm` companion (`tests/core_safe_functions.bats`, `tests/clean_core.bats`) +- sibling-install evidence completeness: a newly installed package receipt invalidates the cached answer the shared-bundle-id guard reads as proof of absence (`tests/uninstall_scan_bash32.bats`) + +## Known Limitations and Future Work + +- Cleanup is destructive. Most cleanup flows do not provide undo. +- `mo analyze` delete is safer because it uses Trash, but other cleanup flows are permanent once confirmed. +- `mo uninstall` now routes more removals through Trash, but Trash availability, permissions, and volume behavior still depend on the local macOS environment. +- Generic orphan data waits 30 days before cleanup; this is conservative but heuristic. +- Claude VM orphan cleanup waits 7 days before cleanup; this is also heuristic. +- Time Machine safety windows are hour-based and intentionally conservative. - Localized app names may still be missed in some heuristic paths, though bundle IDs are preferred where available. - Users who want immediate removal of app data should use explicit uninstall flows rather than waiting for orphan cleanup. +- Release artifacts include checksums and attestations, but downstream package-manager trust also depends on external distribution infrastructure. +- `mo history --json` escapes strings byte by byte under `LC_ALL=C` (`history_json_escape`) for portable behavior on bash 3.2. Printable multibyte bytes are emitted verbatim, so the emitted JSON stays valid UTF-8, but the escaper does not perform Unicode-aware codepoint iteration. This is a known display-layer detail, not a correctness issue. +- Planned follow-up work includes stronger destructive-command threat modeling, more regression coverage for high-risk paths, and continued hardening of release integrity and disclosure workflow. + +For reporting procedures and supported versions, see [SECURITY.md](SECURITY.md). diff --git a/Resources/mole/TRADEMARK.md b/Resources/mole/TRADEMARK.md new file mode 100644 index 0000000..a73b696 --- /dev/null +++ b/Resources/mole/TRADEMARK.md @@ -0,0 +1,16 @@ +# Trademark Policy + +The "Mole" name and logo are trademarks of the Mole project. GPL-3.0 covers the +code, not the brand. Open source licenses grant copyright, not trademark. + +We want users to trust that something called "Mole" really is this project. So if +you publish a fork, please: + +- Use your own name and icon, not "Mole" or the Mole logo. +- Don't imply your fork is endorsed by or affiliated with Mole. +- Don't use the Mole name to market a paid or competing product. + +Mole for Mac at https://mole.fit is a separate, proprietary product with its own +reserved name and assets. + +Permission requests: open an issue or reach out via https://mole.fit. diff --git a/Resources/mole/bin/analyze-go b/Resources/mole/bin/analyze-go index ebb96ba..e9f8632 100755 Binary files a/Resources/mole/bin/analyze-go and b/Resources/mole/bin/analyze-go differ diff --git a/Resources/mole/bin/check.sh b/Resources/mole/bin/check.sh deleted file mode 100755 index 24e4594..0000000 --- a/Resources/mole/bin/check.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Fix locale issues (similar to Issue #83) -export LC_ALL=C -export LANG=C - -# Load common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$SCRIPT_DIR/lib/core/common.sh" -source "$SCRIPT_DIR/lib/core/sudo.sh" -source "$SCRIPT_DIR/lib/manage/update.sh" -source "$SCRIPT_DIR/lib/manage/autofix.sh" - -source "$SCRIPT_DIR/lib/check/all.sh" - -cleanup_all() { - stop_inline_spinner 2> /dev/null || true - stop_sudo_session - cleanup_temp_files -} - -handle_interrupt() { - cleanup_all - exit 130 -} - -main() { - # Register unified cleanup handler - trap cleanup_all EXIT - trap handle_interrupt INT TERM - - if [[ -t 1 ]]; then - clear - fi - - printf '\n' - - # Create temp files for parallel execution - local updates_file=$(mktemp_file) - local health_file=$(mktemp_file) - local security_file=$(mktemp_file) - local config_file=$(mktemp_file) - - # Run all checks in parallel with spinner - if [[ -t 1 ]]; then - echo -ne "${PURPLE_BOLD}System Check${NC} " - start_inline_spinner "Running checks..." - else - echo -e "${PURPLE_BOLD}System Check${NC}" - echo "" - fi - - # Parallel execution - { - check_all_updates > "$updates_file" 2>&1 & - check_system_health > "$health_file" 2>&1 & - check_all_security > "$security_file" 2>&1 & - check_all_config > "$config_file" 2>&1 & - wait - } - - if [[ -t 1 ]]; then - stop_inline_spinner - printf '\n' - fi - - # Display results - echo -e "${BLUE}${ICON_ARROW}${NC} System updates" - cat "$updates_file" - - printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} System health" - cat "$health_file" - - printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} Security posture" - cat "$security_file" - - printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} Configuration" - cat "$config_file" - - # Show suggestions - show_suggestions - - # Ask about auto-fix - if ask_for_auto_fix; then - perform_auto_fix - fi - - # Ask about updates - if ask_for_updates; then - perform_updates - fi - - printf '\n' -} - -main "$@" diff --git a/Resources/mole/bin/clean.sh b/Resources/mole/bin/clean.sh index 039fd33..1dc2de8 100755 --- a/Resources/mole/bin/clean.sh +++ b/Resources/mole/bin/clean.sh @@ -23,20 +23,53 @@ source "$SCRIPT_DIR/../lib/clean/user.sh" SYSTEM_CLEAN=false DRY_RUN=false +if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + DRY_RUN=true +fi PROTECT_FINDER_METADATA=false +EXTERNAL_VOLUME_TARGET="" IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false") -EXPORT_LIST_FILE="$HOME/.config/mole/clean-list.txt" +# Whitelist and preview belong to the invoking user even when the whole +# command runs as root. Root dry-runs stage preview content in a root-owned +# file and publish it through an invoking-user process so user-controlled +# symlinks are never opened for writing with root privileges. See #1210. +MOLE_USER_HOME="$(get_invoking_home)" +[[ -n "$MOLE_USER_HOME" ]] || MOLE_USER_HOME="$HOME" + +CLEAN_PREVIEW_FINAL_FILE="$MOLE_USER_HOME/.config/mole/clean-list.txt" +CLEAN_PREVIEW_STAGING_FILE="" +CLEAN_PREVIEW_LEDGER_FILE="" +EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE" CURRENT_SECTION="" readonly PROTECTED_SW_DOMAINS=( + # Web editors "capcut.com" "photopea.com" "pixlr.com" + # Google Workspace (offline mode) + "docs.google.com" + "sheets.google.com" + "slides.google.com" + "drive.google.com" + "mail.google.com" + # Code platforms (offline/PWA) + "github.com" + "gitlab.com" + "codepen.io" + "codesandbox.io" + "replit.com" + "stackblitz.com" + # Collaboration tools (offline/PWA) + "notion.so" + "figma.com" + "linear.app" + "excalidraw.com" ) declare -a WHITELIST_PATTERNS=() WHITELIST_WARNINGS=() -if [[ -f "$HOME/.config/mole/whitelist" ]]; then +if [[ -f "$MOLE_USER_HOME/.config/mole/whitelist" ]]; then while IFS= read -r line; do # shellcheck disable=SC2295 line="${line#"${line%%[![:space:]]*}"}" @@ -44,16 +77,16 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then line="${line%"${line##*[![:space:]]}"}" [[ -z "$line" || "$line" =~ ^# ]] && continue - [[ "$line" == ~* ]] && line="${line/#~/$HOME}" - line="${line//\$HOME/$HOME}" - line="${line//\$\{HOME\}/$HOME}" + [[ "$line" == ~* ]] && line="${line/#~/$MOLE_USER_HOME}" + line="${line//\$HOME/$MOLE_USER_HOME}" + line="${line//\$\{HOME\}/$MOLE_USER_HOME}" if [[ "$line" =~ \.\. ]]; then WHITELIST_WARNINGS+=("Path traversal not allowed: $line") continue fi if [[ "$line" != "$FINDER_METADATA_SENTINEL" ]]; then - if [[ ! "$line" =~ ^[a-zA-Z0-9/_.@\ *-]+$ ]]; then + if [[ "$line" =~ [[:cntrl:]] ]]; then WHITELIST_WARNINGS+=("Invalid path format: $line") continue fi @@ -87,7 +120,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done < "$HOME/.config/mole/whitelist" + done < "$MOLE_USER_HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -98,13 +131,59 @@ expand_whitelist_patterns() { local -a EXPANDED_PATTERNS EXPANDED_PATTERNS=() for pattern in "${WHITELIST_PATTERNS[@]}"; do - local expanded="${pattern/#\~/$HOME}" + local expanded="${pattern/#\~/$MOLE_USER_HOME}" EXPANDED_PATTERNS+=("$expanded") done WHITELIST_PATTERNS=("${EXPANDED_PATTERNS[@]}") fi } expand_whitelist_patterns +# Existing user files replace defaults entirely; re-apply hard safety entries +# (FINDER_METADATA and future SAFETY_WHITELIST_PATTERNS) so they still protect. +ensure_safety_whitelist_patterns + +prepare_clean_preview_file() { + EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE" + CLEAN_PREVIEW_STAGING_FILE="" + CLEAN_PREVIEW_LEDGER_FILE="" + + if is_root_user && [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then + ensure_mole_temp_root || return 1 + local root_temp_dir="$MOLE_RESOLVED_TMPDIR" + + CLEAN_PREVIEW_STAGING_FILE=$(umask 077 && mktemp "$root_temp_dir/mole.clean-preview.XXXXXX") || return 1 + [[ -f "$CLEAN_PREVIEW_STAGING_FILE" && ! -L "$CLEAN_PREVIEW_STAGING_FILE" && -O "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 1 + MOLE_TEMP_FILES+=("$CLEAN_PREVIEW_STAGING_FILE") + EXPORT_LIST_FILE="$CLEAN_PREVIEW_STAGING_FILE" + else + ensure_user_file "$EXPORT_LIST_FILE" + fi + + CLEAN_PREVIEW_LEDGER_FILE=$(create_temp_file) || return 1 + [[ -f "$CLEAN_PREVIEW_LEDGER_FILE" && ! -L "$CLEAN_PREVIEW_LEDGER_FILE" ]] || return 1 + : > "$CLEAN_PREVIEW_LEDGER_FILE" +} + +run_clean_preview_as_invoking_user() { + /usr/bin/sudo -u "$SUDO_USER" -- "$@" +} + +publish_clean_preview_file() { + [[ -n "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 0 + [[ -f "$CLEAN_PREVIEW_STAGING_FILE" && ! -L "$CLEAN_PREVIEW_STAGING_FILE" && -O "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 1 + [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]] || return 1 + + local final_dir + final_dir=$(dirname "$CLEAN_PREVIEW_FINAL_FILE") + run_clean_preview_as_invoking_user /bin/mkdir -p "$final_dir" 2> /dev/null || return 1 + if ! /bin/cat "$CLEAN_PREVIEW_STAGING_FILE" | + run_clean_preview_as_invoking_user /usr/bin/tee "$CLEAN_PREVIEW_FINAL_FILE" > /dev/null; then + return 1 + fi + + EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE" + return 0 +} if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then for entry in "${WHITELIST_PATTERNS[@]}"; do @@ -129,6 +208,10 @@ PROJECT_ARTIFACT_HINT_EXAMPLES=() PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0 PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false +declare -a DRY_RUN_SEEN_IDENTITIES=() +DRY_RUN_TOTAL_PARTIAL=false +declare -a DEFERRED_CLEANUP_FAMILIES=() +DEFERRED_CLEANUP_FAMILIES_FILE="" # shellcheck disable=SC2329 note_activity() { @@ -137,6 +220,366 @@ note_activity() { fi } +# Record expected process-state skips without turning every protected target +# into a separate warning row. Unknown process state is not recorded here and +# remains visible at the call site. +# shellcheck disable=SC2329 +defer_cleanup_family() { + local family="${1:-}" + local existing + [[ -n "$family" ]] || return 0 + + if [[ ${#DEFERRED_CLEANUP_FAMILIES[@]} -gt 0 ]]; then + for existing in "${DEFERRED_CLEANUP_FAMILIES[@]}"; do + [[ "$existing" == "$family" ]] && return 0 + done + fi + + DEFERRED_CLEANUP_FAMILIES+=("$family") + if [[ -n "${DEFERRED_CLEANUP_FAMILIES_FILE:-}" && + -f "$DEFERRED_CLEANUP_FAMILIES_FILE" && + ! -L "$DEFERRED_CLEANUP_FAMILIES_FILE" ]]; then + printf '%s\0' "$family" >> "$DEFERRED_CLEANUP_FAMILIES_FILE" + fi + debug_log "Deferred cleanup while active: $family" +} + +# The Cloud & Office section runs in a timeout worker, so its array writes stay +# in that child shell. Replay its file-backed records before rendering the +# parent summary. +sync_deferred_cleanup_families() { + local record_file="${DEFERRED_CLEANUP_FAMILIES_FILE:-}" + local family + [[ -n "$record_file" && -f "$record_file" && ! -L "$record_file" ]] || return 0 + + DEFERRED_CLEANUP_FAMILIES_FILE="" + while IFS= read -r -d '' family; do + defer_cleanup_family "$family" + done < "$record_file" + DEFERRED_CLEANUP_FAMILIES_FILE="$record_file" +} + +format_deferred_cleanup_families() { + local family + local output="" + for family in "${DEFERRED_CLEANUP_FAMILIES[@]}"; do + [[ -n "$output" ]] && output+=", " + output+="$family" + done + printf '%s\n' "$output" +} + +# shellcheck disable=SC2329 +register_dry_run_cleanup_target() { + # Full clean runs deduplicate the file-backed ledger in one linear pass. + # Avoid an O(n²) Bash array scan while candidates are still being found. + if [[ -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" && -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then + return 0 + fi + + local path="$1" + local identity + identity=$(mole_path_identity "$path") + + if [[ ${#DRY_RUN_SEEN_IDENTITIES[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${DRY_RUN_SEEN_IDENTITIES[@]}"; then + return 1 + fi + + DRY_RUN_SEEN_IDENTITIES+=("$identity") + return 0 +} + +# Append one candidate to the dry-run ledger. Final rendering deduplicates the +# shared file, so timeout subprocess paths survive without an O(n²) hot loop. +# Fields are NUL-delimited to preserve whitespace in paths. +# Args: path, size_kb, item_count, size_known +append_dry_run_cleanup_target() { + local path="$1" + local size_kb="${2:-0}" + local item_count="${3:-1}" + local size_known="${4:-true}" + local identity + identity=$(mole_path_identity "$path") + + [[ "$size_kb" =~ ^[0-9]+$ ]] || { + size_kb=0 + size_known=false + } + [[ "$item_count" =~ ^[0-9]+$ && "$item_count" -gt 0 ]] || item_count=1 + [[ "$size_known" == "true" || "$size_known" == "false" ]] || size_known=false + + if [[ -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" && -f "$CLEAN_PREVIEW_LEDGER_FILE" && ! -L "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then + printf '%s\0%s\0%s\0%s\0%s\0%s\0' \ + "$identity" "$size_kb" "$item_count" "$size_known" "${CURRENT_SECTION:-Uncategorized}" "$path" \ + >> "$CLEAN_PREVIEW_LEDGER_FILE" + return 0 + fi + + # Keep focused function tests and sourced-module callers useful even when + # start_cleanup has not prepared the full ledger. + if [[ -n "${EXPORT_LIST_FILE:-}" ]]; then + ensure_user_file "$EXPORT_LIST_FILE" + if [[ "$size_known" == "true" ]]; then + echo "$path # $(bytes_to_human "$((size_kb * 1024))")" >> "$EXPORT_LIST_FILE" + else + echo "$path # size unknown" >> "$EXPORT_LIST_FILE" + fi + fi +} + +# Validate and append a candidate in one call. Focused callers without a +# prepared ledger retain the legacy in-memory duplicate check. +record_dry_run_cleanup_target() { + local path="$1" + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$path" 2> /dev/null; then + return 1 + fi + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$path" 2> /dev/null; then + return 1 + fi + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$path" 2> /dev/null; then + return 1 + fi + # Keep preview eligibility identical to real cleanup (#1390 / PR #1391). + if declare -f _mole_should_refuse_live_user_cache_path > /dev/null 2>&1 && + _mole_should_refuse_live_user_cache_path "$path"; then + return 1 + fi + if declare -f _mole_is_sqlite_database_path > /dev/null 2>&1 && + _mole_is_sqlite_database_path "$path" && + declare -f _mole_sqlite_database_in_use > /dev/null 2>&1; then + local sqlite_state=0 + _mole_sqlite_database_in_use "$path" || sqlite_state=$? + if [[ $sqlite_state -eq 0 || $sqlite_state -eq 2 ]]; then + return 1 + fi + fi + + if [[ -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" || ! -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then + register_dry_run_cleanup_target "$path" || return 1 + fi + append_dry_run_cleanup_target "$@" +} + +# Emit the first complete ledger record for each path identity. Perl keeps the +# normal path linear for large clean previews; the Bash fallback preserves the +# same NUL-safe format on systems without Perl. +emit_deduplicated_dry_run_ledger() { + if [[ -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" || ! -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then + return 0 + fi + + local perl_bin="" + perl_bin=$(command -v perl 2> /dev/null || true) + if [[ -n "$perl_bin" && -x "$perl_bin" ]]; then + # shellcheck disable=SC2016 # Embedded Perl uses Perl variables inside single quotes. + "$perl_bin" -e ' + use strict; + use warnings; + binmode STDIN; + binmode STDOUT; + local $/ = "\0"; + my %seen; + while (defined(my $identity = )) { + chomp $identity; + my @record = ($identity); + for (1 .. 5) { + my $field = ; + exit 0 unless defined $field; + chomp $field; + push @record, $field; + } + next if $seen{$identity}++; + print join("\0", @record), "\0"; + } + ' < "$CLEAN_PREVIEW_LEDGER_FILE" + return 0 + fi + + local identity size_kb count size_known section path + local -a seen_identities=() + while IFS= read -r -d '' identity && + IFS= read -r -d '' size_kb && + IFS= read -r -d '' count && + IFS= read -r -d '' size_known && + IFS= read -r -d '' section && + IFS= read -r -d '' path; do + if [[ ${#seen_identities[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${seen_identities[@]}"; then + continue + fi + seen_identities+=("$identity") + printf '%s\0%s\0%s\0%s\0%s\0%s\0' \ + "$identity" "$size_kb" "$count" "$size_known" "$section" "$path" + done < "$CLEAN_PREVIEW_LEDGER_FILE" +} + +write_clean_preview_header() { + cat > "$EXPORT_LIST_FILE" << EOF +# Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') +# +# How to protect files: +# 1. Copy any path below to ~/.config/mole/whitelist +# 2. Run: mo clean --whitelist +# +# Example: +# /Users/*/Library/Caches/com.example.app +# + +EOF +} + +render_clean_preview_from_ledger() { + write_clean_preview_header + + local identity size_kb count size_known section path + local current_rendered_section="" + local known_size_kb=0 + local rendered_items=0 + local rendered_categories=0 + local unknown_size_count=0 + local -a seen_sections=() + + if [[ -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" && -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then + while IFS= read -r -d '' identity && + IFS= read -r -d '' size_kb && + IFS= read -r -d '' count && + IFS= read -r -d '' size_known && + IFS= read -r -d '' section && + IFS= read -r -d '' path; do + if [[ "$section" != "$current_rendered_section" ]]; then + echo "" >> "$EXPORT_LIST_FILE" + echo "=== $section ===" >> "$EXPORT_LIST_FILE" + current_rendered_section="$section" + if [[ ${#seen_sections[@]} -eq 0 ]] || ! mole_identity_in_list "$section" "${seen_sections[@]}"; then + seen_sections+=("$section") + rendered_categories=$((rendered_categories + 1)) + fi + fi + + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + [[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]] || count=1 + local item_note="" + [[ "$count" -gt 1 ]] && item_note=", $count items" + if [[ "$size_known" == "true" ]]; then + echo "$path # $(bytes_to_human "$((size_kb * 1024))")$item_note" >> "$EXPORT_LIST_FILE" + else + echo "$path # size unknown$item_note" >> "$EXPORT_LIST_FILE" + unknown_size_count=$((unknown_size_count + 1)) + fi + + known_size_kb=$((known_size_kb + size_kb)) + rendered_items=$((rendered_items + count)) + done < <(emit_deduplicated_dry_run_ledger) + fi + + total_size_cleaned=$known_size_kb + files_cleaned=$rendered_items + total_items=$rendered_categories + if [[ "$unknown_size_count" -gt 0 ]]; then + DRY_RUN_TOTAL_PARTIAL=true + else + DRY_RUN_TOTAL_PARTIAL=false + fi +} + +read_clean_sudo_choice() { + local had_force_char=false + local previous_force_char="${MOLE_READ_KEY_FORCE_CHAR:-}" + if [[ ${MOLE_READ_KEY_FORCE_CHAR+x} ]]; then + had_force_char=true + fi + + export MOLE_READ_KEY_FORCE_CHAR=1 + local choice + choice=$(read_key) + + if [[ "$had_force_char" == "true" ]]; then + export MOLE_READ_KEY_FORCE_CHAR="$previous_force_char" + else + unset MOLE_READ_KEY_FORCE_CHAR + fi + + printf '%s\n' "$choice" +} + +read_clean_sudo_password_remainder() { + local __remainder_var="$1" + local remainder="" + + if [[ -r /dev/tty ]]; then + IFS= read -r -s remainder < /dev/tty || true + else + IFS= read -r -s remainder || true + fi + + printf -v "$__remainder_var" '%s' "$remainder" +} + +prompt_for_system_clean() { + local prompt_attempt=0 + while true; do + echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " + + local choice + choice=$(read_clean_sudo_choice) + + # ESC aborts, Space skips, Enter (or any typed key, e.g. someone who + # starts typing their password) proceeds to authentication. + if [[ "$choice" == "QUIT" ]]; then + echo -e " ${GRAY}Canceled${NC}" + exit 0 + fi + + if [[ "$choice" == "SPACE" ]]; then + echo -e " ${GRAY}Skipped${NC}" + echo "" + SYSTEM_CLEAN=false + break + elif [[ "$choice" == "ENTER" ]]; then + printf "\r\033[K" # Clear the prompt line + if ensure_sudo_session "System cleanup requires admin access"; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + echo "" + else + SYSTEM_CLEAN=false + echo "" + echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" + fi + break + elif [[ "$choice" == CHAR:* ]]; then + local typed_password="${choice#CHAR:}" + local password_remainder="" + read_clean_sudo_password_remainder password_remainder + typed_password="${typed_password}${password_remainder}" + + printf "\r\033[K" # Clear the prompt line + if ensure_sudo_session_with_password "$typed_password" "System cleanup requires admin access"; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + echo "" + else + SYSTEM_CLEAN=false + echo "" + echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" + fi + unset typed_password password_remainder + break + else + prompt_attempt=$((prompt_attempt + 1)) + drain_pending_input 0.05 + if [[ $prompt_attempt -ge 2 ]]; then + SYSTEM_CLEAN=false + echo -e " ${GRAY}Skipped${NC}" + echo "" + break + fi + printf "\r\033[K" + echo -e "${YELLOW}${ICON_WARNING}${NC} Press Enter to continue, or Space to skip" + fi + done +} + CLEANUP_DONE=false # shellcheck disable=SC2329 cleanup() { @@ -161,14 +604,43 @@ trap 'cleanup EXIT $?' EXIT trap 'cleanup INT 130; exit 130' INT trap 'cleanup TERM 143; exit 143' TERM +# IMPORTANT: This file overrides start_section / end_section from +# lib/core/base.sh by virtue of being sourced after it. The clean variant adds +# CURRENT_SECTION tracking, dry-run EXPORT_LIST_FILE writes, a section +# spinner stop, and idle-header recycling. See the cross-reference block in +# lib/core/base.sh and the differing purge variant in bin/purge.sh before +# changing any of these three. +# +# Idle-header recycling: an idle section used to erase its own header after +# the fact, which made all content below jump up two lines per idle section. +# Instead the header stays put and the NEXT start_section overwrites it in +# place, so the screen never moves vertically. Any output that is not a +# section header must clear a leftover idle header first via +# flush_idle_section_slot. +IDLE_SECTION_PENDING=0 + +flush_idle_section_slot() { + if [[ "${IDLE_SECTION_PENDING:-0}" == "1" ]]; then + IDLE_SECTION_PENDING=0 + safe_clear_lines 2 || true + fi +} + start_section() { TRACK_SECTION=1 SECTION_ACTIVITY=0 CURRENT_SECTION="$1" - echo "" - echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" + if [[ "${IDLE_SECTION_PENDING:-0}" == "1" ]]; then + # Overwrite the previous idle section's header line in place (the + # pending flag is only ever set on an interactive ANSI terminal). + IDLE_SECTION_PENDING=0 + printf '\033[1A\r\033[2K%b\n' "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" + else + echo "" + echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" + fi - if [[ "$DRY_RUN" == "true" ]]; then + if [[ "$DRY_RUN" == "true" && -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" ]]; then ensure_user_file "$EXPORT_LIST_FILE" echo "" >> "$EXPORT_LIST_FILE" echo "=== $1 ===" >> "$EXPORT_LIST_FILE" @@ -179,7 +651,18 @@ end_section() { stop_section_spinner if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to clean" + # On an interactive ANSI terminal, leave the header on screen and let + # the next start_section recycle its line, so idle sections disappear + # without the erase-and-jump. Piped output keeps the explicit fallback + # so logs stay self-describing. MO_DEBUG interleaves stderr lines that + # line recycling would corrupt, so keep the fallback there too. + if [[ -t 1 && "${MO_DEBUG:-}" != "1" ]] && is_ansi_supported 2> /dev/null; then + IDLE_SECTION_PENDING=1 + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to clean" + fi + else + IDLE_SECTION_PENDING=0 fi TRACK_SECTION=0 } @@ -187,11 +670,91 @@ end_section() { # shellcheck disable=SC2329 normalize_paths_for_cleanup() { local -a input_paths=("$@") + + local _normalized_cleanup_path="" + _normalize_single_cleanup_path() { + local raw_path="$1" + local normalized="${raw_path%/}" + [[ -z "$normalized" ]] && normalized="$raw_path" + + local gradle_caches_root="$HOME/.gradle/caches" + case "$normalized" in + "$gradle_caches_root"/*/groovy-dsl/*/* | "$gradle_caches_root"/*/kotlin-dsl/*/*) + local rel version dsl_dir rest hash + rel="${normalized#"$gradle_caches_root"/}" + version="${rel%%/*}" + rest="${rel#*/}" + dsl_dir="${rest%%/*}" + rest="${rest#*/}" + hash="${rest%%/*}" + if [[ -n "$version" && -n "$hash" && + ("$dsl_dir" == "groovy-dsl" || "$dsl_dir" == "kotlin-dsl") ]]; then + _normalized_cleanup_path="$gradle_caches_root/$version/$dsl_dir/$hash" + return + fi + ;; + esac + + _normalized_cleanup_path="$normalized" + } + + # Fast path for large batches: O(n log n) via sort|awk instead of O(n²) bash loops. + # Lex sort guarantees every parent path precedes its children, so a single-pass + # awk can filter child paths by tracking only the last kept path. + # Paths with embedded newlines cannot go through the newline-delimited pipeline; + # they are output directly with null-byte delimiters and skipped by the sort pass. + if [[ ${#input_paths[@]} -gt 50 ]]; then + # The gradle-DSL collapse below is intentionally inlined (not a call to + # _normalize_single_cleanup_path): this path runs for thousands of items + # and per-item function-call overhead trips the large-batch time budget + # in tests/regression.bats. Keep it in sync with that helper. + local -a _fast_pipeline=() + local _fast_path _fast_raw + for _fast_path in "${input_paths[@]}"; do + if [[ "$_fast_path" == *$'\n'* ]]; then + printf '%s\0' "$_fast_path" + else + _fast_raw="$_fast_path" + _fast_path="${_fast_path%/}" + [[ -z "$_fast_path" ]] && _fast_path="$_fast_raw" + local _gradle_caches_root="$HOME/.gradle/caches" + case "$_fast_path" in + "$_gradle_caches_root"/*/groovy-dsl/*/* | "$_gradle_caches_root"/*/kotlin-dsl/*/*) + local _rel _version _dsl_dir _rest _hash + _rel="${_fast_path#"$_gradle_caches_root"/}" + _version="${_rel%%/*}" + _rest="${_rel#*/}" + _dsl_dir="${_rest%%/*}" + _rest="${_rest#*/}" + _hash="${_rest%%/*}" + if [[ -n "$_version" && -n "$_hash" && + ("$_dsl_dir" == "groovy-dsl" || "$_dsl_dir" == "kotlin-dsl") ]]; then + _fast_path="$_gradle_caches_root/$_version/$_dsl_dir/$_hash" + fi + ;; + esac + _fast_pipeline+=("$_fast_path") + fi + done + if [[ ${#_fast_pipeline[@]} -gt 0 ]]; then + printf '%s\n' "${_fast_pipeline[@]}" | + awk '{sub(/\/$/, ""); if ($0 != "") print}' | + LC_ALL=C sort -u | + awk 'BEGIN { last = "" } { + if (last != "" && substr($0, 1, length(last) + 1) == last "/") next + last = $0; print + }' | + while IFS= read -r _fast_path; do printf '%s\0' "$_fast_path"; done + fi + return + fi + local -a unique_paths=() for path in "${input_paths[@]}"; do - local normalized="${path%/}" - [[ -z "$normalized" ]] && normalized="$path" + local normalized + _normalize_single_cleanup_path "$path" + normalized="$_normalized_cleanup_path" local found=false if [[ ${#unique_paths[@]} -gt 0 ]]; then for existing in "${unique_paths[@]}"; do @@ -204,9 +767,21 @@ normalize_paths_for_cleanup() { [[ "$found" == "true" ]] || unique_paths+=("$normalized") done + # Paths with embedded newlines cannot safely go through the newline-delimited + # sort pipeline. Collect them separately and append to result as-is. + local -a pipeline_paths=() + local -a passthrough_paths=() + for path in "${unique_paths[@]}"; do + if [[ "$path" == *$'\n'* ]]; then + passthrough_paths+=("$path") + else + pipeline_paths+=("$path") + fi + done + local sorted_paths - if [[ ${#unique_paths[@]} -gt 0 ]]; then - sorted_paths=$(printf '%s\n' "${unique_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-) + if [[ ${#pipeline_paths[@]} -gt 0 ]]; then + sorted_paths=$(printf '%s\n' "${pipeline_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-) else sorted_paths="" fi @@ -226,8 +801,13 @@ normalize_paths_for_cleanup() { [[ "$is_child" == "true" ]] || result_paths+=("$path") done <<< "$sorted_paths" + # Append passthrough paths (newline-containing; not deduplicated against others). + if [[ ${#passthrough_paths[@]} -gt 0 ]]; then + result_paths+=("${passthrough_paths[@]}") + fi + if [[ ${#result_paths[@]} -gt 0 ]]; then - printf '%s\n' "${result_paths[@]}" + printf '%s\0' "${result_paths[@]}" fi } @@ -235,26 +815,18 @@ normalize_paths_for_cleanup() { get_cleanup_path_size_kb() { local path="$1" - if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then - local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") - if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then - echo $(((bytes + 1023) / 1024)) - return 0 - fi + # A plain file or a symlink is a single stat. Directories and the + # stat-unavailable case fall back to get_path_size_kb. For a regular file + # with a zero/invalid stat we also fall back; a symlink reports 0 directly. + if [[ -L "$path" || -f "$path" ]] && command -v stat > /dev/null 2>&1; then + local bytes + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") + if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then + echo $(((bytes + 1023) / 1024)) + return 0 fi - fi - - if [[ -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then - local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") - if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then - echo $(((bytes + 1023) / 1024)) - else - echo 0 - fi + if [[ -L "$path" ]]; then + echo 0 return 0 fi fi @@ -302,8 +874,21 @@ classify_cleanup_risk() { echo "MEDIUM|User data files" } +# Internal implementation shared by the normal and process-guarded cleanup +# entry points. The first argument is an optional callback that must return 0 +# immediately before each deletion sink; callers use it to bind a process-state +# check to the path that was just sized. # shellcheck disable=SC2329 -safe_clean() { +_safe_clean_impl() { + local delete_guard="$1" + shift + + local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" && + ("$pending_clean_cancel" -eq 124 || "$pending_clean_cancel" -ge 128) ]]; then + return "$pending_clean_cancel" + fi + if [[ $# -eq 0 ]]; then return 0 fi @@ -328,7 +913,7 @@ safe_clean() { if [[ "$base_path" == */ ]]; then parent_dir="${base_path%/}" else - parent_dir=$(dirname "$base_path") + parent_dir="${base_path%/*}" fi if [[ ! -d "$parent_dir" ]]; then @@ -353,15 +938,27 @@ safe_clean() { local total_count=0 local skipped_count=0 local removal_failed_count=0 + local delete_guard_stopped=0 + local cleanup_interrupt_rc=0 + # A guarded cleanup may bind the exact object it approved to safe_remove's + # final identity check. These names deliberately use dynamic scope so the + # callback can populate them without stdout/command-substitution races. + local _MOLE_SAFE_CLEAN_BOUND_PATH="" + local _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" + local _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" + local _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" local permission_start=${MOLE_PERMISSION_DENIED_COUNT:-0} local show_scan_feedback=false if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then show_scan_feedback=true - stop_section_spinner - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning ${#targets[@]} items..." + # Updates a running section spinner in place instead of restarting it. + start_section_spinner "Scanning ${#targets[@]} items..." fi + local _perf_scan_start + debug_timer_start _perf_scan_start + local -a existing_paths=() for path in "${targets[@]}"; do local skip=false @@ -380,10 +977,37 @@ safe_clean() { log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue - [[ -e "$path" ]] && existing_paths+=("$path") + + if holds_compiled_model_cache "$path"; then + skip=true + skipped_count=$((skipped_count + 1)) + log_operation "clean" "SKIPPED" "$path" "compiled model cache" + fi + [[ "$skip" == "true" ]] && continue + + if [[ -e "$path" ]]; then + existing_paths+=("$path") + fi done - if [[ "$show_scan_feedback" == "true" ]]; then + if [[ ${#existing_paths[@]} -gt 1 ]]; then + local -a normalized_paths=() + while IFS= read -r -d '' path; do + [[ -n "$path" ]] && normalized_paths+=("$path") + done < <(normalize_paths_for_cleanup "${existing_paths[@]}") + + if [[ ${#normalized_paths[@]} -gt 0 ]]; then + existing_paths=("${normalized_paths[@]}") + else + existing_paths=() + fi + fi + + debug_timer_end "$description: path scan" _perf_scan_start + + # Keep the spinner alive between phases; the next phase swaps its text in + # place. Under MO_DEBUG stop it so debug lines print on a clean line. + if [[ "$show_scan_feedback" == "true" && "${MO_DEBUG:-}" == "1" ]]; then stop_section_spinner fi @@ -414,31 +1038,27 @@ safe_clean() { fi if [[ ${#existing_paths[@]} -eq 0 ]]; then - return 0 - fi - - if [[ ${#existing_paths[@]} -gt 1 ]]; then - local -a normalized_paths=() - while IFS= read -r path; do - [[ -n "$path" ]] && normalized_paths+=("$path") - done < <(normalize_paths_for_cleanup "${existing_paths[@]}") - - if [[ ${#normalized_paths[@]} -gt 0 ]]; then - existing_paths=("${normalized_paths[@]}") - else - existing_paths=() + # The scan spinner we started (or took over) must not outlive this + # call; callers print rows without stopping spinners themselves. + if [[ "$show_scan_feedback" == "true" ]]; then + stop_section_spinner fi + [[ $delete_guard_stopped -eq 1 ]] && return 75 + return 0 fi local show_spinner=false if [[ ${#existing_paths[@]} -gt 10 ]]; then show_spinner=true local total_paths=${#existing_paths[@]} - if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..."; fi + if [[ -t 1 ]]; then start_section_spinner "Scanning items..."; fi fi local cleaning_spinner_started=false + local _perf_size_start + debug_timer_start _perf_size_start + # For larger batches, precompute sizes in parallel for better UX/stat accuracy. if [[ ${#existing_paths[@]} -gt 3 ]]; then local temp_dir @@ -453,31 +1073,59 @@ safe_clean() { [[ -d "${existing_paths[i]}" ]] && ((dir_count++)) done - # Heuristic: mostly files -> sequential stat is faster than subshells. + # Heuristic: mostly files -> bulk stat is faster than per-file subshells. if [[ $dir_count -lt 5 && ${#existing_paths[@]} -gt 20 ]]; then if [[ -t 1 && "$show_spinner" == "false" ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..." + start_section_spinner "Scanning items..." show_spinner=true fi local idx=0 - local last_progress_update - last_progress_update=$(get_epoch_seconds) - for path in "${existing_paths[@]}"; do - local size - size=$(get_cleanup_path_size_kb "$path") - [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 - - if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$temp_dir/result_${idx}" + local _bytes + local bulk_stat_file="$temp_dir/bulk_stat" + local bulk_stat_rc=0 + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + stat -f%z "${existing_paths[@]}" < /dev/null \ + > "$bulk_stat_file" 2> /dev/null || bulk_stat_rc=$? + if [[ $bulk_stat_rc -ge 128 ]]; then + cleanup_interrupt_rc=$bulk_stat_rc + elif [[ $bulk_stat_rc -eq 124 ]]; then + # The size is only used for the freed total; a stalled stat + # must not cancel the delete set. Sizes are already 0 here. + MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + fi + while IFS= read -r _bytes; do + [[ "$_bytes" =~ ^[0-9]+$ ]] || _bytes=0 + local _kb=$(((_bytes + 1023) / 1024)) + if [[ "$_kb" -gt 0 ]]; then + echo "$_kb 1" > "$temp_dir/result_${idx}" else echo "0 0" > "$temp_dir/result_${idx}" fi - idx=$((idx + 1)) - if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then - update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true - last_progress_update=$(get_epoch_seconds) + done < "$bulk_stat_file" + while [[ $idx -lt ${#existing_paths[@]} ]]; do + echo "0 0" > "$temp_dir/result_${idx}" + idx=$((idx + 1)) + done + for ((idx = 0; idx < ${#existing_paths[@]}; idx++)); do + if [[ -d "${existing_paths[$idx]}" && ! -L "${existing_paths[$idx]}" ]]; then + local _dsize=0 + local _dsize_rc=0 + _dsize=$(get_cleanup_path_size_kb \ + "${existing_paths[$idx]}") || _dsize_rc=$? + if [[ $_dsize_rc -ge 128 ]]; then + cleanup_interrupt_rc=$_dsize_rc + break + elif [[ $_dsize_rc -eq 124 ]]; then + MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + fi + [[ "$_dsize" =~ ^[0-9]+$ ]] || _dsize=0 + if [[ "$_dsize" -gt 0 ]]; then + echo "$_dsize 1" > "$temp_dir/result_${idx}" + else + echo "0 0" > "$temp_dir/result_${idx}" + fi fi done else @@ -491,22 +1139,36 @@ safe_clean() { if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do ( - local size - size=$(get_cleanup_path_size_kb "$path") + local size=0 size_rc=0 + local size_unknown=0 + size=$(get_cleanup_path_size_kb "$path") || size_rc=$? + if [[ $size_rc -ge 128 ]]; then + exit "$size_rc" + fi + if [[ $size_rc -eq 124 ]]; then + # Sizing budget exhausted: keep the item in the + # delete set and report its size as 0. + size_unknown=1 + fi [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$tmp_file" + echo "$size 1 $size_unknown" > "$tmp_file" else - echo "0 0" > "$tmp_file" + echo "0 0 $size_unknown" > "$tmp_file" fi mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true - ) & + ) < /dev/null & pids+=($!) idx=$((idx + 1)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2> /dev/null || true + local wait_rc=0 + wait "${pids[0]}" 2> /dev/null || wait_rc=$? + if [[ $wait_rc -ge 128 ]]; then + cleanup_interrupt_rc=$wait_rc + break + fi pids=("${pids[@]:1}") completed=$((completed + 1)) @@ -519,7 +1181,11 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null || true + local wait_rc=0 + wait "$pid" 2> /dev/null || wait_rc=$? + if [[ $wait_rc -ge 128 ]]; then + [[ $cleanup_interrupt_rc -ne 0 ]] || cleanup_interrupt_rc=$wait_rc + fi completed=$((completed + 1)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -529,10 +1195,37 @@ safe_clean() { fi fi + # Count the items whose size check hit the budget; they were still + # cleaned, only the freed total is under-reported. + local _t_size=0 + local _t_count=0 + local _t_flag=0 + local _t_file + for _t_file in "$temp_dir"/result_*; do + [[ -f "$_t_file" ]] || continue + _t_flag=0 + read -r _t_size _t_count _t_flag < "$_t_file" 2> /dev/null || true + [[ "$_t_flag" == "1" ]] && MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + done + + if [[ $cleanup_interrupt_rc -ne 0 ]]; then + if [[ "$show_spinner" == "true" || "$show_scan_feedback" == "true" ]]; then + stop_inline_spinner + fi + MOLE_CLEAN_CANCEL_STATUS=$cleanup_interrupt_rc + export MOLE_CLEAN_CANCEL_STATUS + return "$cleanup_interrupt_rc" + fi + + debug_timer_end "$description: size calc" _perf_size_start + + local _perf_del_start + debug_timer_start _perf_del_start + # Read results back in original order. # Start spinner for cleaning phase if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + start_section_spinner "Cleaning..." cleaning_spinner_started=true fi idx=0 @@ -540,14 +1233,69 @@ safe_clean() { for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count < "$result_file" 2> /dev/null || true + read -r size count size_unknown < "$result_file" 2> /dev/null || true local removed=0 + local action_rc=0 if [[ "$DRY_RUN" != "true" ]]; then - if safe_remove "$path" true; then + if [[ -n "$delete_guard" ]]; then + _MOLE_SAFE_CLEAN_BOUND_PATH="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" + _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" + "$delete_guard" "$path" || action_rc=$? + if [[ $action_rc -eq 124 || $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -ne 0 ]]; then + delete_guard_stopped=1 + break + fi + fi + action_rc=0 + local bound_parent="" + local bound_parent_id="" + local bound_target_id="" + if [[ "$_MOLE_SAFE_CLEAN_BOUND_PATH" == "$path" ]]; then + bound_parent="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT" + bound_parent_id="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID" + bound_target_id="$_MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID" + fi + safe_remove "$path" true "$size" "" \ + "$bound_parent" "$bound_parent_id" \ + "$bound_target_id" || action_rc=$? + # A removal timeout (124) is a failed removal, not a + # user interrupt: count it below and keep cleaning so + # one slow disk item never cancels the rest of the run. + if [[ $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -eq 0 ]]; then removed=1 fi else - removed=1 + if [[ -n "$delete_guard" ]]; then + _MOLE_SAFE_CLEAN_BOUND_PATH="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" + _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" + "$delete_guard" "$path" || action_rc=$? + if [[ $action_rc -eq 124 || $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -ne 0 ]]; then + delete_guard_stopped=1 + break + fi + fi + action_rc=0 + record_dry_run_cleanup_target \ + "$path" "$size" 1 true || action_rc=$? + if [[ $action_rc -eq 124 || $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -eq 0 ]]; then + removed=1 + fi fi if [[ $removed -eq 1 ]]; then @@ -566,26 +1314,95 @@ safe_clean() { done fi + debug_timer_end "$description: deletion" _perf_del_start + else + debug_timer_end "$description: size calc" _perf_size_start + + local _perf_del_start + debug_timer_start _perf_del_start + # Start spinner for cleaning phase (small batch) if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + start_section_spinner "Cleaning..." cleaning_spinner_started=true fi local idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do - local size_kb - size_kb=$(get_cleanup_path_size_kb "$path") + local size_kb=0 + local size_rc=0 + size_kb=$(get_cleanup_path_size_kb "$path") || size_rc=$? + if [[ $size_rc -ge 128 ]]; then + cleanup_interrupt_rc=$size_rc + break + elif [[ $size_rc -eq 124 ]]; then + # Sizing budget exhausted: keep cleaning with size 0. + MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + fi [[ ! "$size_kb" =~ ^[0-9]+$ ]] && size_kb=0 local removed=0 + local action_rc=0 if [[ "$DRY_RUN" != "true" ]]; then - if safe_remove "$path" true; then + if [[ -n "$delete_guard" ]]; then + _MOLE_SAFE_CLEAN_BOUND_PATH="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" + _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" + "$delete_guard" "$path" || action_rc=$? + if [[ $action_rc -eq 124 || $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -ne 0 ]]; then + delete_guard_stopped=1 + break + fi + fi + action_rc=0 + local bound_parent="" + local bound_parent_id="" + local bound_target_id="" + if [[ "$_MOLE_SAFE_CLEAN_BOUND_PATH" == "$path" ]]; then + bound_parent="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT" + bound_parent_id="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID" + bound_target_id="$_MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID" + fi + safe_remove "$path" true "$size_kb" "" \ + "$bound_parent" "$bound_parent_id" \ + "$bound_target_id" || action_rc=$? + # Same non-fatal removal-timeout policy as the + # parallel-result loop above. + if [[ $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -eq 0 ]]; then removed=1 fi else - removed=1 + if [[ -n "$delete_guard" ]]; then + _MOLE_SAFE_CLEAN_BOUND_PATH="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" + _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" + "$delete_guard" "$path" || action_rc=$? + if [[ $action_rc -eq 124 || $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -ne 0 ]]; then + delete_guard_stopped=1 + break + fi + fi + action_rc=0 + record_dry_run_cleanup_target \ + "$path" "$size_kb" 1 true || action_rc=$? + if [[ $action_rc -eq 124 || $action_rc -ge 128 ]]; then + cleanup_interrupt_rc=$action_rc + break + elif [[ $action_rc -eq 0 ]]; then + removed=1 + fi fi if [[ $removed -eq 1 ]]; then @@ -602,19 +1419,27 @@ safe_clean() { idx=$((idx + 1)) done fi + + debug_timer_end "$description: deletion" _perf_del_start fi - if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then + if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" || "$show_scan_feedback" == "true" ]]; then stop_inline_spinner fi + if [[ $cleanup_interrupt_rc -ne 0 ]]; then + MOLE_CLEAN_CANCEL_STATUS=$cleanup_interrupt_rc + export MOLE_CLEAN_CANCEL_STATUS + return "$cleanup_interrupt_rc" + fi + local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0} # Track permission failures in debug output (avoid noisy user warnings). if [[ $permission_end -gt $permission_start && $removed_any -eq 0 ]]; then debug_log "Permission denied while cleaning: $description" fi if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then - debug_log "Skipped $removal_failed_count items, permission denied or in use, for: $description" + debug_log "Skipped $removal_failed_count items, permission denied, in use, or timed out, for: $description" fi if [[ $removed_any -eq 1 ]]; then @@ -624,73 +1449,24 @@ safe_clean() { local size_human size_human=$(bytes_to_human "$((total_size_kb * 1024))") - local label="$description" - if [[ ${#targets[@]} -gt 1 ]]; then - label+=" ${#targets[@]} items" + # Multi-target cleanups report the item count as part of the detail + # column, keeping the label clean: "npm logs · 2 items, 2KB". Use the + # actually-cleaned count (total_count), not the raw target count, so it + # stays consistent with the reported size after protected, whitelisted, + # missing, and deduplicated targets have been dropped. + local count_note="" + if [[ $total_count -gt 1 ]]; then + count_note="$total_count items, " fi if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}" - - local paths_temp - paths_temp=$(create_temp_file) - - idx=0 - if [[ ${#existing_paths[@]} -gt 0 ]]; then - for path in "${existing_paths[@]}"; do - local size=0 - - if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true - else - size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") - fi - - [[ "$size" == "0" || -z "$size" ]] && { - idx=$((idx + 1)) - continue - } - - echo "$(dirname "$path")|$size|$path" >> "$paths_temp" - idx=$((idx + 1)) - done - fi - - # Group dry-run paths by parent for a compact export list. - if [[ -f "$paths_temp" && -s "$paths_temp" ]]; then - sort -t'|' -k1,1 "$paths_temp" | awk -F'|' ' - { - parent = $1 - size = $2 - path = $3 - - parent_size[parent] += size - if (parent_count[parent] == 0) { - parent_first[parent] = path - } - parent_count[parent]++ - } - END { - for (parent in parent_size) { - if (parent_count[parent] > 1) { - printf "%s|%d|%d\n", parent, parent_size[parent], parent_count[parent] - } else { - printf "%s|%d|1\n", parent_first[parent], parent_size[parent] - } - } - } - ' | while IFS='|' read -r display_path total_size child_count; do - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") - if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" - else - echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" - fi - done - fi + local size_display + size_display=$(colorize_human_size "$size_human") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description${NC} · ${count_note}${size_display} ${YELLOW}dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} $description${NC} · ${count_note}${line_color}$size_human${NC}" fi files_cleaned=$((files_cleaned + total_count)) total_size_cleaned=$((total_size_cleaned + total_size_kb)) @@ -698,18 +1474,58 @@ safe_clean() { note_activity fi + # 75 is internal to safe_clean_guarded. Normal safe_clean calls never set a + # guard and retain the existing always-zero cleanup contract. + [[ $delete_guard_stopped -eq 1 ]] && return 75 return 0 } +# shellcheck disable=SC2329 +safe_clean() { + _safe_clean_impl "" "$@" +} + +# Run safe_clean with a callback rechecked after sizing and immediately before +# every safe_remove call. Returns 75 when the callback stops the batch after +# preserving any removals already completed. +# shellcheck disable=SC2329 +safe_clean_guarded() { + local delete_guard="$1" + shift + declare -f "$delete_guard" > /dev/null 2>&1 || return 2 + _safe_clean_impl "$delete_guard" "$@" +} + start_cleanup() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="clean" + MOLE_CLEAN_CANCEL_STATUS=0 + export MOLE_CLEAN_CANCEL_STATUS + MOLE_CLEAN_SIZING_TIMEOUTS=0 + export MOLE_CLEAN_SIZING_TIMEOUTS + MOLE_CLEAN_REMOVAL_TIMEOUTS=0 + export MOLE_CLEAN_REMOVAL_TIMEOUTS log_operation_session_start "clean" + DRY_RUN_SEEN_IDENTITIES=() + DRY_RUN_TOTAL_PARTIAL=false if [[ -t 1 ]]; then printf '\033[2J\033[H' fi printf '\n' + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + echo -e "${PURPLE_BOLD}Clean External Volume${NC}" + echo -e "${GRAY}${EXTERNAL_VOLUME_TARGET}${NC}" + echo "" + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" + echo "" + fi + SYSTEM_CLEAN=false + return 0 + fi + echo -e "${PURPLE_BOLD}Clean Your Mac${NC}" echo "" @@ -721,22 +1537,14 @@ start_cleanup() { echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" - ensure_user_file "$EXPORT_LIST_FILE" - cat > "$EXPORT_LIST_FILE" << EOF -# Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') -# -# How to protect files: -# 1. Copy any path below to ~/.config/mole/whitelist -# 2. Run: mo clean --whitelist -# -# Example: -# /Users/*/Library/Caches/com.example.app -# - -EOF + prepare_clean_preview_file || { + echo -e "${YELLOW}${ICON_WARNING}${NC} Unable to create a safe cleanup preview file" >&2 + return 1 + } + write_clean_preview_header # Preview system section when sudo is already cached (no password prompt). - if has_sudo_session; then + if adopt_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included" echo "" @@ -749,47 +1557,17 @@ EOF fi if [[ -t 0 ]]; then - if has_sudo_session; then + if adopt_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" else - echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " - - local choice - choice=$(read_key) - - # ESC/Q aborts, Space skips, Enter enables system cleanup. - if [[ "$choice" == "QUIT" ]]; then - echo -e " ${GRAY}Canceled${NC}" - exit 0 - fi - - if [[ "$choice" == "SPACE" ]]; then - echo -e " ${GRAY}Skipped${NC}" - echo "" - SYSTEM_CLEAN=false - elif [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" # Clear the prompt line - if ensure_sudo_session "System cleanup requires admin access"; then - SYSTEM_CLEAN=true - echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" - echo "" - else - SYSTEM_CLEAN=false - echo "" - echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" - fi - else - SYSTEM_CLEAN=false - echo -e " ${GRAY}Skipped${NC}" - echo "" - fi + prompt_for_system_clean fi else echo "" echo "Running in non-interactive mode" - if has_sudo_session; then + if adopt_sudo_session; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -802,9 +1580,18 @@ EOF } perform_cleanup() { + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + total_items=0 + files_cleaned=0 + total_size_cleaned=0 + fi + + local initial_free_space_kb="" + local initial_free_space_display="Unknown" + # Test mode skips expensive scans and returns minimal output. local test_mode_enabled=false - if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then + if [[ -z "$EXTERNAL_VOLUME_TARGET" && "${MOLE_TEST_MODE:-0}" == "1" ]]; then test_mode_enabled=true if [[ "$DRY_RUN" == "true" ]]; then echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" @@ -831,15 +1618,19 @@ perform_cleanup() { fi if [[ "$DRY_RUN" == "true" ]]; then echo "" - echo "Potential space: 0.00GB" + echo -e "Potential space: $(colorize_human_size "0.00GB")" fi total_items=1 files_cleaned=0 total_size_cleaned=0 fi - if [[ "$test_mode_enabled" == "false" ]]; then - echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $(get_free_space)" + if [[ "$test_mode_enabled" == "false" && -z "$EXTERNAL_VOLUME_TARGET" ]]; then + if ! initial_free_space_kb=$(get_free_space_kb 2> /dev/null); then + initial_free_space_kb="" + fi + initial_free_space_display=$(format_free_space_kb "$initial_free_space_kb") + echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $initial_free_space_display" fi if [[ "$test_mode_enabled" == "true" ]]; then @@ -853,7 +1644,9 @@ perform_cleanup() { fi # Pre-check TCC permissions to avoid mid-run prompts. - check_tcc_permissions + if [[ -z "$EXTERNAL_VOLUME_TARGET" ]]; then + check_tcc_permissions + fi if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then local predefined_count=0 @@ -907,6 +1700,8 @@ perform_cleanup() { total_items=0 files_cleaned=0 total_size_cleaned=0 + DEFERRED_CLEANUP_FAMILIES=() + DEFERRED_CLEANUP_FAMILIES_FILE=$(create_temp_file 2> /dev/null || true) local had_errexit=0 [[ $- == *e* ]] && had_errexit=1 @@ -914,118 +1709,224 @@ perform_cleanup() { # Allow per-section failures without aborting the full run. set +e - # ===== 1. System ===== - if [[ "$SYSTEM_CLEAN" == "true" ]]; then - start_section "System" - clean_deep_system - clean_local_snapshots - end_section - fi - - if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then - echo "" - for warning in "${WHITELIST_WARNINGS[@]}"; do - echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" - done - fi - - # ===== 2. User essentials ===== - start_section "User essentials" - clean_user_essentials - clean_finder_metadata - scan_external_volumes - end_section - - # ===== 3. App caches (merged sandboxed and standard app caches) ===== - start_section "App caches" - clean_app_caches - end_section + _run_cleanup_step() { + local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ $pending_clean_cancel -eq 124 || $pending_clean_cancel -ge 128 ]]; then + return "$pending_clean_cancel" + fi + local step_rc=0 + "$@" || step_rc=$? + pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ $step_rc -eq 124 || $step_rc -ge 128 ]]; then + MOLE_CLEAN_CANCEL_STATUS=$step_rc + export MOLE_CLEAN_CANCEL_STATUS + return "$step_rc" + fi + if [[ $pending_clean_cancel -eq 124 || $pending_clean_cancel -ge 128 ]]; then + return "$pending_clean_cancel" + fi + return 0 + } + + local cleanup_cancel_rc=0 + # Sections run inside a function so a cancelled step (timeout/signal, + # exit 124+) stops the remaining sections but still falls through to the + # final summary instead of returning from perform_cleanup with no output. + run_clean_sections() { + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + start_section "External volume" + _run_cleanup_step clean_external_volume_target "$EXTERNAL_VOLUME_TARGET" || return $? + end_section + else + # ===== 1. System ===== + if [[ "$SYSTEM_CLEAN" == "true" ]]; then + start_section "System" + _run_cleanup_step clean_deep_system || return $? + _run_cleanup_step clean_local_snapshots || return $? + end_section + fi - # ===== 4. Browsers ===== - start_section "Browsers" - clean_browsers - end_section + if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then + flush_idle_section_slot + echo "" + for warning in "${WHITELIST_WARNINGS[@]}"; do + echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" + done + fi - # ===== 5. Cloud & Office ===== - start_section "Cloud & Office" - clean_cloud_storage - clean_office_applications - end_section - - # ===== 6. Developer tools (merged CLI and GUI tooling) ===== - start_section "Developer tools" - clean_developer_tools - end_section - - # ===== 7. Applications ===== - start_section "Applications" - clean_user_gui_applications - end_section - - # ===== 8. Virtualization ===== - start_section "Virtualization" - clean_virtualization_tools - end_section - - # ===== 9. Application Support ===== - start_section "Application Support" - clean_application_support_logs - end_section - - # ===== 10. Orphaned data ===== - start_section "Orphaned data" - clean_orphaned_app_data - clean_orphaned_system_services - clean_orphaned_launch_agents - end_section - - # ===== 11. Apple Silicon ===== - clean_apple_silicon_caches - - # ===== 12. Device backups ===== - start_section "Device backups" - check_ios_device_backups - end_section - - # ===== 13. Time Machine ===== - start_section "Time Machine" - clean_time_machine_failed_backups - end_section - - # ===== 14. Large files ===== - start_section "Large files" - check_large_file_candidates - end_section - - # ===== 15. System Data clues ===== - start_section "System Data clues" - show_system_data_hint_notice - end_section - - # ===== 16. Project artifacts ===== - start_section "Project artifacts" - show_project_artifact_hint_notice - end_section + # ===== 2. User essentials ===== + start_section "User essentials" + _run_cleanup_step clean_user_essentials || return $? + _run_cleanup_step clean_finder_metadata || return $? + end_section + + # ===== 3. App caches (merged sandboxed and standard app caches) ===== + start_section "App caches" + _run_cleanup_step clean_app_caches || return $? + end_section + + # ===== 4. Browsers ===== + start_section "Browsers" + _run_cleanup_step clean_browsers || return $? + end_section + + # ===== 5. Cloud & Office ===== + start_section "Cloud & Office" + # Force shell fallback so timeout runs in this shell context. + # The Cloud/Office cleaners rely on helpers (safe_clean, whitelist checks) + # defined in this script and sourced modules. + if run_with_shell_timeout 300 run_cloud_and_office_cleanup; then + : # completed successfully + else + local ret=$? + if [[ $ret -eq 124 ]]; then + log_warning "Cloud & Office cleanup timed out after 5 minutes, skipping remaining items" + elif [[ $ret -ge 128 ]]; then + return "$ret" + else + log_warning "Cloud & Office cleanup failed with exit code $ret" + fi + fi + end_section + + # ===== 6. Developer tools (merged CLI and GUI tooling) ===== + start_section "Developer tools" + _run_cleanup_step clean_developer_tools || return $? + end_section + + # ===== 7. Apps & utilities ===== + start_section "Apps & utilities" + _run_cleanup_step clean_user_gui_applications || return $? + end_section + + # ===== 8. Virtualization ===== + start_section "Virtualization" + _run_cleanup_step clean_virtualization_tools || return $? + end_section + + # ===== 9. Application Support ===== + start_section "Application Support" + _run_cleanup_step clean_application_support_logs || return $? + end_section + + # ===== 10. App leftovers ===== + start_section "App leftovers" + _run_cleanup_step clean_orphaned_app_data || return $? + _run_cleanup_step clean_orphaned_system_services || return $? + # No stale-LaunchServices step here on purpose. `lsregister -u` + # cannot remove a record whose app is already gone: on macOS 15 and + # later it fails with -10814 for every such path, which is exactly + # the set this would have targeted, so the step could only ever + # report failures. `mo optimize` already offers the supported + # repair (`lsregister -gc` plus a domain rescan) as an explicit, + # user-triggered task. + _run_cleanup_step clean_orphaned_container_stubs || return $? + _run_cleanup_step show_user_launch_agent_hint_notice || return $? + end_section + + # ===== 11. Apple Silicon ===== + _run_cleanup_step clean_apple_silicon_caches || return $? + + # ===== 12. Device backups & firmware ===== + # iOS backups are reported once, in the Large files section; a second + # row here used a different size formatter and confused users. + start_section "Device backups & firmware" + _run_cleanup_step clean_cached_device_firmware || return $? + end_section + + # ===== 13. Time Machine ===== + start_section "Time Machine" + _run_cleanup_step clean_time_machine_failed_backups || return $? + end_section + + # ===== 14. Large files ===== + start_section "Large files" + _run_cleanup_step check_large_file_candidates || return $? + end_section + + # ===== 15. Project artifacts ===== + start_section "Project artifacts" + _run_cleanup_step show_project_artifact_hint_notice || return $? + end_section + fi + } + run_clean_sections || cleanup_cancel_rc=$? # ===== Final summary ===== + flush_idle_section_slot echo "" + if [[ "$DRY_RUN" == "true" ]]; then + render_clean_preview_from_ledger + fi + + sync_deferred_cleanup_families + local summary_heading="" local summary_status="success" - if [[ "$DRY_RUN" == "true" ]]; then + if [[ $cleanup_cancel_rc -eq 124 ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + summary_heading="Dry run cancelled" + else + summary_heading="Cleanup cancelled" + fi + summary_status="warning" + elif [[ $cleanup_cancel_rc -ge 128 ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + summary_heading="Dry run interrupted" + else + summary_heading="Cleanup interrupted" + fi + summary_status="warning" + elif [[ "$DRY_RUN" == "true" ]]; then summary_heading="Dry run complete - no changes made" else summary_heading="Cleanup complete" fi local -a summary_details=() + if [[ $cleanup_cancel_rc -ne 0 ]]; then + if [[ $cleanup_cancel_rc -eq 124 ]]; then + summary_details+=("${GRAY}${ICON_WARNING}${NC} Cancelled: a scan or size check timed out (exit 124). Remaining cleanup was skipped.") + elif [[ $cleanup_cancel_rc -ge 128 ]]; then + summary_details+=("${GRAY}${ICON_WARNING}${NC} Cancelled: a cleanup step was interrupted (exit $cleanup_cancel_rc). Remaining cleanup was skipped.") + fi + fi - if [[ $total_size_cleaned -gt 0 ]]; then + # Emit one "Free space" line, with the measured delta in parentheses when + # available. $1 is the free space in KB captured before cleanup started. + # Caller appends each printed line to summary_details. + emit_free_space_summary() { + local initial_kb="$1" + if [[ "$DRY_RUN" == "true" ]]; then + printf 'Free space: %s\n' "$(get_free_space)" + return 0 + fi + + local final_kb + if ! final_kb=$(get_free_space_kb 2> /dev/null); then + final_kb="" + fi + local delta_note="" + if [[ "$initial_kb" =~ ^[0-9]+$ && "$final_kb" =~ ^[0-9]+$ && "$initial_kb" -ne "$final_kb" ]]; then + delta_note=" ($(format_free_space_delta_kb "$((final_kb - initial_kb))"))" + fi + printf 'Free space: %s%s\n' "$(format_free_space_kb "$final_kb")" "$delta_note" + } + + if [[ $total_size_cleaned -gt 0 || + ("$DRY_RUN" == "true" && ("$DRY_RUN_TOTAL_PARTIAL" == "true" || $files_cleaned -gt 0)) ]]; then local freed_size_human freed_size_human=$(bytes_to_human_kb "$total_size_cleaned") if [[ "$DRY_RUN" == "true" ]]; then - local stats="Potential space: ${GREEN}${freed_size_human}${NC}" + local potential_label + if [[ "$DRY_RUN_TOTAL_PARTIAL" == "true" ]]; then + potential_label="At least $freed_size_human" + else + potential_label="$freed_size_human" + fi + local stats="Potential space: $(colorize_human_size "$potential_label")" [[ $files_cleaned -gt 0 ]] && stats+=" | Items: $files_cleaned" [[ $total_items -gt 0 ]] && stats+=" | Categories: $total_items" summary_details+=("$stats") @@ -1035,15 +1936,13 @@ perform_cleanup() { echo "# ============================================" echo "# Summary" echo "# ============================================" - echo "# Potential cleanup: ${freed_size_human}" + echo "# Potential cleanup: ${potential_label}" echo "# Items: $files_cleaned" echo "# Categories: $total_items" } >> "$EXPORT_LIST_FILE" - summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") - summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") else - local summary_line="Space freed: ${GREEN}${freed_size_human}${NC}" + local summary_line="Tracked cleanup: ${GREEN}${freed_size_human}${NC}" if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then summary_line+=" | Items cleaned: $files_cleaned | Categories: $total_items" @@ -1055,9 +1954,9 @@ perform_cleanup() { summary_details+=("$summary_line") - # Movie comparison only if >= 1GB (1048576 KB) - if ((total_size_cleaned >= 1048576)); then - local freed_gb=$((total_size_cleaned / 1048576)) + # Movie comparison only if >= 1GB + if ((total_size_cleaned >= MOLE_ONE_GIB_KB)); then + local freed_gb=$((total_size_cleaned / MOLE_ONE_GIB_KB)) local movies=$((freed_gb * 10 / 45)) if [[ $movies -gt 0 ]]; then @@ -1069,18 +1968,56 @@ perform_cleanup() { fi fi - local final_free_space - final_free_space=$(get_free_space) - summary_details+=("Free space now: $final_free_space") + local free_space_line + while IFS= read -r free_space_line; do + summary_details+=("$free_space_line") + done < <(emit_free_space_summary "$initial_free_space_kb") fi else summary_status="info" - if [[ "$DRY_RUN" == "true" ]]; then + if [[ ${#DEFERRED_CLEANUP_FAMILIES[@]} -gt 0 ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + summary_details+=("No additional reclaimable space detected.") + else + summary_details+=("No additional space freed.") + fi + elif [[ "$DRY_RUN" == "true" ]]; then summary_details+=("No significant reclaimable space detected, system already clean.") else summary_details+=("System was already clean; no additional space freed.") fi - summary_details+=("Free space now: $(get_free_space)") + local free_space_line + while IFS= read -r free_space_line; do + summary_details+=("$free_space_line") + done < <(emit_free_space_summary "$initial_free_space_kb") + fi + + # Caches of running apps are deferred silently: skipping them is Mole's + # ordinary behavior, not news, and for always-on tools (Codex, browsers) + # a summary line here appeared on every single run. Preview still lists + # what would be cleaned, and the ledger stays visible under --debug. + if [[ ${#DEFERRED_CLEANUP_FAMILIES[@]} -gt 0 ]]; then + debug_log "Deferred while active: $(format_deferred_cleanup_families)" + fi + + if [[ "$DRY_RUN" == "true" && + ($total_size_cleaned -gt 0 || "$DRY_RUN_TOTAL_PARTIAL" == "true" || $files_cleaned -gt 0) ]]; then + if publish_clean_preview_file; then + summary_details+=("Detailed file list: ${GRAY}$CLEAN_PREVIEW_FINAL_FILE${NC}") + summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") + else + summary_details+=("Cleanup preview file could not be written safely") + fi + elif [[ "$DRY_RUN" == "true" ]]; then + publish_clean_preview_file || true + fi + + if [[ ${MOLE_CLEAN_SIZING_TIMEOUTS:-0} -gt 0 ]]; then + summary_details+=("${GRAY}${ICON_WARNING}${NC} Some items exceeded the ${MOLE_TIMEOUT_DISK_VERIFY_SEC}s size-check budget and were counted as 0, so the total is under-reported. Raise ${GRAY}MOLE_TIMEOUT_DISK_VERIFY_SEC${NC} to measure them.") + fi + + if [[ ${MOLE_CLEAN_REMOVAL_TIMEOUTS:-0} -gt 0 ]]; then + summary_details+=("${GRAY}${ICON_WARNING}${NC} ${MOLE_CLEAN_REMOVAL_TIMEOUTS} item(s) exceeded the ${MOLE_TIMEOUT_DISK_VERIFY_SEC}s removal budget and may be only partly removed. Run clean again, or raise ${GRAY}MOLE_TIMEOUT_DISK_VERIFY_SEC${NC} for slower disks.") fi if [[ $had_errexit -eq 1 ]]; then @@ -1092,11 +2029,27 @@ perform_cleanup() { print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' + + return "$cleanup_cancel_rc" +} + +run_with_shell_timeout() { + local duration="$1" + shift || true + # Functions (for example safe_clean) are available only in the current shell. + # Force the shell fallback path so timeout can execute shell functions directly. + MO_TIMEOUT_BIN="" MO_TIMEOUT_PERL_BIN="" run_with_timeout "$duration" "$@" +} + +# shellcheck disable=SC2329 # Invoked indirectly via run_with_timeout fallback. +run_cloud_and_office_cleanup() { + clean_cloud_storage + clean_office_applications } main() { - for arg in "$@"; do - case "$arg" in + while [[ $# -gt 0 ]]; do + case "$1" in "--help" | "-h") show_clean_help exit 0 @@ -1108,19 +2061,46 @@ main() { DRY_RUN=true export MOLE_DRY_RUN=1 ;; + "--external") + shift + if [[ $# -eq 0 ]]; then + echo "Missing path for --external" >&2 + exit 1 + fi + EXTERNAL_VOLUME_TARGET=$(validate_external_volume_target "$1") || exit 1 + ;; "--whitelist") source "$SCRIPT_DIR/../lib/manage/whitelist.sh" manage_whitelist "clean" exit 0 ;; + "--select" | "--categories" | "--exclude") + echo "mo clean $1 was removed in this release." >&2 + echo "Use 'mo clean --dry-run' to preview cleanup and 'mo clean --whitelist' to protect paths." >&2 + exit 1 + ;; + -*) + echo "Unknown option for mo clean: $1" >&2 + echo "Run 'mo clean --help' for usage." >&2 + exit 1 + ;; + *) + echo "Unexpected argument for mo clean: $1" >&2 + echo "Run 'mo clean --help' for usage." >&2 + exit 1 + ;; esac + shift done start_cleanup hide_cursor - perform_cleanup + local cleanup_rc=0 + perform_cleanup || cleanup_rc=$? show_cursor - exit 0 + exit "$cleanup_rc" } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/Resources/mole/bin/completion.sh b/Resources/mole/bin/completion.sh index a575929..0aa55b8 100755 --- a/Resources/mole/bin/completion.sh +++ b/Resources/mole/bin/completion.sh @@ -11,6 +11,10 @@ for entry in "${MOLE_COMMANDS[@]}"; do command_names+=("${entry%%:*}") done command_words="${command_names[*]}" +clean_option_words="--dry-run -n --external --whitelist --debug --help -h" +analyze_option_words="--json --help -h" +history_option_words="--json --limit --help -h" +purge_option_words="--paths --dry-run -n --include-empty --debug --help -h" emit_zsh_subcommands() { for entry in "${MOLE_COMMANDS[@]}"; do @@ -23,13 +27,49 @@ emit_fish_completions() { for entry in "${MOLE_COMMANDS[@]}"; do local name="${entry%%:*}" local desc="${entry#*:}" - printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc" + printf 'complete -f -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc" done printf '\n' - printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" - printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" - printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l dry-run -s n -d "Preview cleanup without making changes"\n' "$cmd" + printf 'complete -c %s -n "__fish_seen_subcommand_from clean" -l external -r -a "(__fish_complete_directories)" -d "Clean OS metadata from an external volume"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l whitelist -d "Manage protected paths"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l debug -d "Show detailed logs"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l help -s h -d "Show help"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from analyze analyse" -l json -d "Output analysis as JSON"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from analyze analyse" -l help -s h -d "Show help"\n' "$cmd" + printf 'complete -c %s -n "__fish_seen_subcommand_from analyze analyse; and not __fish_seen_argument -l json -l help -s h" -a "(__fish_complete_directories)" -d "Path to analyze"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l json -d "Output history as JSON"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l limit -r -d "Limit recent entries"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l help -s h -d "Show help"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l paths -d "Edit custom scan directories"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l dry-run -s n -d "Preview purge actions without making changes"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l include-empty -d "Show zero-size project artifact directories"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l debug -d "Show detailed logs"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l help -s h -d "Show help"\n' "$cmd" + printf '\n' + printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" + printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" + printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" +} + +remove_stale_completion_entries() { + local config_file="$1" + local success_message="$2" + + if [[ ! -f "$config_file" ]] || ! grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then + return 1 + fi + + local original_mode="" + local temp_file + original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" + temp_file="$(mktemp)" + grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true + mv "$temp_file" "$config_file" + [[ -n "$original_mode" ]] && chmod "$original_mode" "$config_file" 2> /dev/null || true + [[ -n "$success_message" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $success_message" + return 0 } if [[ $# -gt 0 ]]; then @@ -71,6 +111,75 @@ if [[ $# -eq 0 ]]; then completion_name="mo" fi + # Fish uses a separate install path: write to ~/.config/fish/completions/ so + # both `mole` and `mo` load completions independently on terminal startup. + if [[ "$current_shell" == "fish" ]]; then + fish_dir="${HOME}/.config/fish/completions" + mole_file="${fish_dir}/mole.fish" + mo_file="${fish_dir}/mo.fish" + config_fish="${HOME}/.config/fish/config.fish" + + if [[ -z "$completion_name" ]]; then + # Clean up any stale config.fish entries even when mole is not in PATH + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + remove_stale_completion_entries "$config_fish" "Removed stale completion entries from config.fish" || true + fi + log_error "mole not found in PATH, install Mole before enabling completion" + exit 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would write Fish completions to:${NC}" + echo " $mole_file" + echo " $mo_file" + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + + # Remove stale config.fish source-based entries (previous install method) + if remove_stale_completion_entries "$config_fish" "Removed stale source-based entries from config.fish"; then + echo "" + fi + + # Prompt only on first install; silently update if files exist + if [[ ! -f "$mole_file" ]]; then + echo "" + echo -e "${GRAY}Will write Fish completions to:${NC}" + echo " $mole_file" + echo " $mo_file" + echo "" + echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}fish${NC}? ${GRAY}Enter confirm / Q cancel${NC}: " + IFS= read -r -s -n1 key || key="" + drain_pending_input + echo "" + + case "$key" in + $'\e' | [Qq] | [Nn]) + echo -e "${YELLOW}Cancelled${NC}" + exit 0 + ;; + "" | $'\n' | $'\r' | [Yy]) ;; + *) + log_error "Invalid key" + exit 1 + ;; + esac + fi + + mkdir -p "$fish_dir" + "$completion_name" completion fish > "$mole_file" + # mo.fish sources mole.fish so Fish loads mo completions on `mo` + printf '# Mole completions for mo (alias) -- auto-generated, do not edit\n' > "$mo_file" + printf 'source %s\n' "$mole_file" >> "$mo_file" + + if [[ -f "$mole_file" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Fish completions written to $fish_dir" + fi + echo "" + exit 0 + fi + case "$current_shell" in bash) config_file="${HOME}/.bashrc" @@ -83,11 +192,6 @@ if [[ $# -eq 0 ]]; then # shellcheck disable=SC2016 completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi' ;; - fish) - config_file="${HOME}/.config/fish/config.fish" - # shellcheck disable=SC2016 - completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source' - ;; *) log_error "Unsupported shell: $current_shell" echo " mole completion " @@ -209,14 +313,38 @@ case "$1" in cat << EOF _mole_completions() { - local cur_word prev_word + local cur_word prev_word subcommand cur_word="\${COMP_WORDS[\$COMP_CWORD]}" prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}" + subcommand="\${COMP_WORDS[1]}" if [ "\$COMP_CWORD" -eq 1 ]; then COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") ) else - case "\$prev_word" in + case "\$subcommand" in + clean) + case "\$prev_word" in + --external) + COMPREPLY=( \$(compgen -d -- "\$cur_word") ) + ;; + *) + COMPREPLY=( \$(compgen -W "$clean_option_words" -- "\$cur_word") ) + ;; + esac + ;; + analyze|analyse) + if [[ "\$cur_word" == -* ]]; then + COMPREPLY=( \$(compgen -W "$analyze_option_words" -- "\$cur_word") ) + else + COMPREPLY=( \$(compgen -f -- "\$cur_word") ) + fi + ;; + history) + COMPREPLY=( \$(compgen -W "$history_option_words" -- "\$cur_word") ) + ;; + purge) + COMPREPLY=( \$(compgen -W "$purge_option_words" -- "\$cur_word") ) + ;; completion) COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") ) ;; @@ -237,7 +365,48 @@ EOF printf ' subcommands=(\n' emit_zsh_subcommands printf ' )\n' - printf " _describe 'subcommand' subcommands\n" + printf ' if (( CURRENT == 2 )); then\n' + printf " _describe 'subcommand' subcommands\n" + printf ' return\n' + printf ' fi\n' + printf " case \"\$words[2]\" in\n" + printf ' clean)\n' + printf ' _arguments \\\n' + printf " '--dry-run[Preview cleanup without making changes]' \\\\\n" + printf " '-n[Preview cleanup without making changes]' \\\\\n" + printf " '--external[Clean OS metadata from an external volume]:path:_files -/' \\\\\n" + printf " '--whitelist[Manage protected paths]' \\\\\n" + printf " '--debug[Show detailed logs]' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]'\n" + printf ' ;;\n' + printf ' analyze|analyse)\n' + printf ' _arguments \\\n' + printf " '--json[Output analysis as JSON]' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]' \\\\\n" + printf " '*:path:_files'\n" + printf ' ;;\n' + printf ' history)\n' + printf ' _arguments \\\n' + printf " '--json[Output history as JSON]' \\\\\n" + printf " '--limit[Limit recent entries]:limit:' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]'\n" + printf ' ;;\n' + printf ' purge)\n' + printf ' _arguments \\\n' + printf " '--paths[Edit custom scan directories]' \\\\\n" + printf " '--dry-run[Preview purge actions without making changes]' \\\\\n" + printf " '-n[Preview purge actions without making changes]' \\\\\n" + printf " '--include-empty[Show zero-size project artifact directories]' \\\\\n" + printf " '--debug[Show detailed logs]' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]'\n" + printf ' ;;\n' + printf ' completion)\n' + printf " _arguments '1:shell:(bash zsh fish)'\n" + printf ' ;;\n' + printf ' *)\n' + printf " _describe 'subcommand' subcommands\n" + printf ' ;;\n' + printf ' esac\n' printf '}\n\n' printf 'compdef _mole mole mo\n' ;; diff --git a/Resources/mole/bin/history.sh b/Resources/mole/bin/history.sh new file mode 100755 index 0000000..97a0b1c --- /dev/null +++ b/Resources/mole/bin/history.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Mole - History command. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$ROOT_DIR/lib/core/history.sh" + +HISTORY_JSON=false +HISTORY_LIMIT="$MOLE_HISTORY_DEFAULT_LIMIT" + +show_history_help() { + echo "Usage: mo history [OPTIONS]" + echo "" + echo "Review recent Mole operation and deletion activity." + echo "" + echo "Options:" + echo " --json Output history as JSON" + echo " --limit N Show the most recent N entries, 1-200" + echo " -h, --help Show this help message" +} + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + "--json") + HISTORY_JSON=true + ;; + "--limit") + shift + if [[ $# -eq 0 ]]; then + echo "Missing value for --limit" >&2 + exit 1 + fi + if ! HISTORY_LIMIT=$(history_parse_limit "$1"); then + echo "Invalid value for --limit: $1" >&2 + exit 1 + fi + ;; + "--help" | "-h") + show_history_help + exit 0 + ;; + -*) + echo "Unknown option for mo history: $1" >&2 + echo "Run 'mo history --help' for usage." >&2 + exit 1 + ;; + *) + echo "Unexpected argument for mo history: $1" >&2 + echo "Run 'mo history --help' for usage." >&2 + exit 1 + ;; + esac + shift + done + + history_load_operations "$(history_operations_log_file)" + history_load_deletions "$(history_deletions_log_file)" + + if [[ "$HISTORY_JSON" == "true" ]]; then + history_render_json "$HISTORY_LIMIT" + else + history_render_text "$HISTORY_LIMIT" + fi +} + +main "$@" diff --git a/Resources/mole/bin/installer.sh b/Resources/mole/bin/installer.sh index 864404a..217c1f6 100755 --- a/Resources/mole/bin/installer.sh +++ b/Resources/mole/bin/installer.sh @@ -11,6 +11,7 @@ declare MOLE_INSTALLER_SCAN_MAX_DEPTH export LC_ALL=C export LANG=C +export MOLE_CURRENT_COMMAND="${MOLE_CURRENT_COMMAND:-installer}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" @@ -44,6 +45,7 @@ readonly INSTALLER_SCAN_PATHS=( "$HOME/Downloads/Telegram Desktop" ) readonly MAX_ZIP_ENTRIES=50 +readonly INSTALLER_EXIT_INCOMPLETE=3 ZIP_LIST_CMD=() IN_ALT_SCREEN=0 @@ -128,12 +130,17 @@ scan_all_installers() { # Initialize stats declare -i total_deleted=0 declare -i total_size_freed_kb=0 +declare -i total_delete_failed=0 # Global arrays for installer data declare -a INSTALLER_PATHS=() declare -a INSTALLER_SIZES=() declare -a INSTALLER_SOURCES=() declare -a DISPLAY_NAMES=() +declare -a INSTALLER_DELETE_PATHS=() +declare -a INSTALLER_DELETE_SIZES=() +declare -a INSTALLER_DELETE_IDENTITIES=() +declare -a INSTALLER_DELETE_FAILURES=() # Get source directory display name - for example "Downloads" or "Desktop" get_source_display() { @@ -184,9 +191,19 @@ format_installer_display() { truncated_name=$(truncate_by_display_width "$filename" "$available_width") local current_width current_width=$(get_display_width "$truncated_name") - local char_count=${#truncated_name} + + # Get byte count for printf width calculation + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local byte_count=${#truncated_name} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding=$((available_width - current_width)) - local printf_width=$((char_count + padding)) + local printf_width=$((byte_count + padding)) # Format: "filename size | source" printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source" @@ -518,41 +535,150 @@ show_installer_menu() { return 0 } +reset_installer_delete_results() { + total_deleted=0 + total_size_freed_kb=0 + total_delete_failed=0 + INSTALLER_DELETE_FAILURES=() +} + +reset_installer_delete_plan() { + INSTALLER_DELETE_PATHS=() + INSTALLER_DELETE_SIZES=() + INSTALLER_DELETE_IDENTITIES=() +} + +record_installer_delete_failure() { + local file_path="$1" + local reason="$2" + + INSTALLER_DELETE_FAILURES+=("$file_path ($reason)") + total_delete_failed=$((total_delete_failed + 1)) +} + +installer_file_size_bytes() { + local file_path="$1" + local file_size + + file_size=$(get_file_size "$file_path" 2> /dev/null || echo "") + [[ "$file_size" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$file_size" +} + +build_installer_delete_plan() { + reset_installer_delete_plan + + local idx + for idx in "$@"; do + if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then + record_installer_delete_failure "$idx" "stale selection" + continue + fi + + local file_path="${INSTALLER_PATHS[$idx]}" + local file_size="${INSTALLER_SIZES[$idx]:-0}" + if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then + file_size=0 + fi + + INSTALLER_DELETE_PATHS+=("$file_path") + INSTALLER_DELETE_SIZES+=("$file_size") + INSTALLER_DELETE_IDENTITIES+=("$(mole_path_identity "$file_path")") + done + + [[ ${#INSTALLER_DELETE_PATHS[@]} -gt 0 ]] +} + +execute_installer_delete_plan() { + local plan_index + for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do + local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}" + local planned_size="${INSTALLER_DELETE_SIZES[$plan_index]}" + local planned_identity="${INSTALLER_DELETE_IDENTITIES[$plan_index]}" + + if [[ ! -e "$file_path" && ! -L "$file_path" ]]; then + record_installer_delete_failure "$file_path" "missing" + continue + fi + + local current_identity + current_identity=$(mole_path_identity "$file_path") + if [[ "$current_identity" != "$planned_identity" ]]; then + record_installer_delete_failure "$file_path" "changed since scan" + continue + fi + + local current_size + if ! current_size=$(installer_file_size_bytes "$file_path"); then + record_installer_delete_failure "$file_path" "size unavailable" + continue + fi + if [[ "$current_size" != "$planned_size" ]]; then + record_installer_delete_failure "$file_path" "changed since scan" + continue + fi + + if mole_delete "$file_path" false; then + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]] || [[ ! -e "$file_path" && ! -L "$file_path" ]]; then + total_size_freed_kb=$((total_size_freed_kb + ((current_size + 1023) / 1024))) + total_deleted=$((total_deleted + 1)) + else + record_installer_delete_failure "$file_path" "still exists" + fi + else + record_installer_delete_failure "$file_path" "delete failed" + fi + done + + if [[ $total_delete_failed -gt 0 ]]; then + return "$INSTALLER_EXIT_INCOMPLETE" + fi + return 0 +} + # Delete selected installers delete_selected_installers() { + reset_installer_delete_results + # Parse selection indices local -a selected_indices=() - [[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" + if [[ -n "$MOLE_SELECTION_RESULT" ]]; then + IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" + fi if [[ ${#selected_indices[@]} -eq 0 ]]; then return 1 fi - # Calculate total size for confirmation - local confirm_size=0 - for idx in "${selected_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then - confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0})) + if ! build_installer_delete_plan "${selected_indices[@]}"; then + if [[ $total_delete_failed -gt 0 ]]; then + return "$INSTALLER_EXIT_INCOMPLETE" fi + return 1 + fi + + local confirm_size=0 + local plan_index + for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_SIZES[@]}; plan_index++)); do + confirm_size=$((confirm_size + ${INSTALLER_DELETE_SIZES[$plan_index]})) done + local confirm_human confirm_human=$(bytes_to_human "$confirm_size") # Show files to be deleted echo -e "${PURPLE_BOLD}Files to be removed:${NC}" - for idx in "${selected_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then - local file_path="${INSTALLER_PATHS[$idx]}" - local file_size="${INSTALLER_SIZES[$idx]}" - local size_human - size_human=$(bytes_to_human "$file_size") - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}" - fi + for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do + local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}" + local file_size="${INSTALLER_DELETE_SIZES[$plan_index]}" + local size_human + size_human=$(bytes_to_human "$file_size") + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}" done # Confirm deletion echo "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " + echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#INSTALLER_DELETE_PATHS[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " IFS= read -r -s -n1 confirm || confirm="" case "$confirm" in @@ -569,38 +695,18 @@ delete_selected_installers() { esac # Delete each selected installer with spinner - total_deleted=0 - total_size_freed_kb=0 - if [[ -t 1 ]]; then start_inline_spinner "Removing installers..." fi - for idx in "${selected_indices[@]}"; do - if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then - continue - fi - - local file_path="${INSTALLER_PATHS[$idx]}" - local file_size="${INSTALLER_SIZES[$idx]}" - - # Validate path before deletion - if ! validate_path_for_deletion "$file_path"; then - continue - fi - - # Delete the file - if safe_remove "$file_path" true; then - total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024))) - total_deleted=$((total_deleted + 1)) - fi - done + local delete_status=0 + execute_installer_delete_plan || delete_status=$? if [[ -t 1 ]]; then stop_inline_spinner fi - return 0 + return "$delete_status" } # Perform the installers cleanup @@ -640,8 +746,10 @@ perform_installers() { fi # Delete selected - if ! delete_selected_installers; then - return 1 + local delete_status=0 + delete_selected_installers || delete_status=$? + if [[ $delete_status -ne 0 ]]; then + return "$delete_status" fi return 0 @@ -654,6 +762,8 @@ show_summary() { if [[ "$dry_run_mode" == "1" ]]; then summary_heading="Dry run complete - no changes made" + elif [[ $total_delete_failed -gt 0 ]]; then + summary_heading="Installer cleanup incomplete" fi if [[ $total_deleted -gt 0 ]]; then @@ -664,12 +774,36 @@ show_summary() { summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}") else summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") - summary_details+=("Your Mac is cleaner now!") + if [[ $total_delete_failed -eq 0 ]]; then + summary_details+=("Your Mac is cleaner now!") + fi fi else summary_details+=("No installers were removed") fi + if [[ $total_delete_failed -gt 0 ]]; then + local failure_label="installers" + [[ $total_delete_failed -eq 1 ]] && failure_label="installer" + summary_details+=("Failed to remove ${YELLOW}$total_delete_failed${NC} $failure_label") + + local failure_count=${#INSTALLER_DELETE_FAILURES[@]} + local failure_limit=5 + if [[ $failure_count -lt $failure_limit ]]; then + failure_limit=$failure_count + fi + + local failure_index + for ((failure_index = 0; failure_index < failure_limit; failure_index++)); do + local failure_detail="${INSTALLER_DELETE_FAILURES[$failure_index]}" + summary_details+=("${ICON_WARNING} $failure_detail") + done + + if [[ $failure_count -gt $failure_limit ]]; then + summary_details+=("${ICON_WARNING} $((failure_count - failure_limit)) more failed") + fi + fi + print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } @@ -700,14 +834,21 @@ main() { fi hide_cursor - perform_installers - local exit_code=$? + # Capture the status without tripping errexit: a bare call under set -e + # would exit the script before the case handler below could report + # incomplete cleanup. + local exit_code=0 + perform_installers || exit_code=$? show_cursor case $exit_code in 0) show_summary ;; + "$INSTALLER_EXIT_INCOMPLETE") + show_summary + return 1 + ;; 1) printf '\n' ;; diff --git a/Resources/mole/bin/optimize.sh b/Resources/mole/bin/optimize.sh index 1a24451..9b37901 100755 --- a/Resources/mole/bin/optimize.sh +++ b/Resources/mole/bin/optimize.sh @@ -1,6 +1,6 @@ #!/bin/bash # Mole - Optimize command. -# Runs system maintenance checks and fixes. +# Runs system maintenance tasks. # Supports dry-run where applicable. set -euo pipefail @@ -15,98 +15,85 @@ source "$SCRIPT_DIR/lib/core/common.sh" # Clean temp files on exit. trap cleanup_temp_files EXIT INT TERM source "$SCRIPT_DIR/lib/core/sudo.sh" -source "$SCRIPT_DIR/lib/manage/update.sh" -source "$SCRIPT_DIR/lib/manage/autofix.sh" +source "$SCRIPT_DIR/lib/optimize/diagnostics.sh" source "$SCRIPT_DIR/lib/optimize/maintenance.sh" +source "$SCRIPT_DIR/lib/optimize/catalog.sh" source "$SCRIPT_DIR/lib/optimize/tasks.sh" source "$SCRIPT_DIR/lib/check/health_json.sh" -source "$SCRIPT_DIR/lib/check/all.sh" source "$SCRIPT_DIR/lib/manage/whitelist.sh" print_header() { printf '\n' - echo -e "${PURPLE_BOLD}Optimize and Check${NC}" + echo -e "${PURPLE_BOLD}Optimize${NC}" } -run_system_checks() { - # Skip checks in dry-run mode. - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - return 0 - fi - - unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS - unset MOLE_SECURITY_FIXES_SHOWN - unset MOLE_SECURITY_FIXES_SKIPPED - echo "" - - check_all_updates - echo "" - - check_system_health - echo "" - - check_all_security - if ask_for_security_fixes; then - perform_security_fixes - fi - if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then - echo "" - fi - - check_all_config - echo "" - - show_suggestions +# Extract a simple numeric value from JSON by key without a jq dependency. +json_get_value() { + local json="$1" + local key="$2" + local value + value=$(echo "$json" | grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9.]*" | head -1 | sed 's/.*:[[:space:]]*//') + echo "${value:-0}" +} - if ask_for_updates; then - perform_updates - fi - if ask_for_auto_fix; then - perform_auto_fix - fi +# Validate JSON has expected structure (basic check). +json_validate() { + local json="$1" + # Check for required keys + [[ "$json" == *'"memory_used_gb"'* ]] && + [[ "$json" == *'"optimizations"'* ]] && + [[ "$json" == *'{'* ]] && [[ "$json" == *'}'* ]] } show_optimization_summary() { - local safe_count="${OPTIMIZE_SAFE_COUNT:-0}" - local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}" - if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then + local total + total=$(optimize_outcome_total) + if ((total == 0)); then return fi local summary_title local -a summary_details=() - local total_applied=$((safe_count + confirm_count)) + local applied unchanged skipped unavailable attention failed + applied=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_APPLIED") + unchanged=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED") + skipped=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_SKIPPED") + unavailable=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE") + attention=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_ATTENTION") + failed=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_FAILED") + + local -a outcome_parts=() + [[ $unchanged -gt 0 ]] && outcome_parts+=("$unchanged unchanged") + [[ $skipped -gt 0 ]] && outcome_parts+=("$skipped skipped") + [[ $unavailable -gt 0 ]] && outcome_parts+=("$unavailable unavailable") + [[ $attention -gt 0 ]] && outcome_parts+=("$attention need attention") + [[ $failed -gt 0 ]] && outcome_parts+=("$failed failed") + + local outcome_line="" + if [[ ${#outcome_parts[@]} -gt 0 ]]; then + outcome_line="${outcome_parts[0]}" + local index + for ((index = 1; index < ${#outcome_parts[@]}; index++)); do + outcome_line+=" | ${outcome_parts[$index]}" + done + fi if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then summary_title="Dry Run Complete, No Changes Made" - summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations") + summary_details+=("Would apply ${YELLOW}${applied}${NC} optimizations") + [[ -n "$outcome_line" ]] && summary_details+=("$outcome_line") summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes") else - summary_title="Optimization and Check Complete" + summary_title="Optimization Complete" - # Build statistics summary - local -a stats=() local cache_kb="${OPTIMIZE_CACHE_CLEANED_KB:-0}" local db_count="${OPTIMIZE_DATABASES_COUNT:-0}" local config_count="${OPTIMIZE_CONFIGS_REPAIRED:-0}" - if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then - local cache_human=$(bytes_to_human "$((cache_kb * 1024))") - stats+=("${cache_human} cache cleaned") - fi - - if [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then - stats+=("${db_count} databases optimized") - fi - - if [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then - stats+=("${config_count} configs repaired") - fi - - # Build first summary line with most important stat only local key_stat="" if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then - local cache_human=$(bytes_to_human "$((cache_kb * 1024))") + local cache_human + cache_human=$(bytes_to_human "$((cache_kb * 1024))") key_stat="${cache_human} cache cleaned" elif [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then key_stat="${db_count} databases optimized" @@ -115,22 +102,17 @@ show_optimization_summary() { fi if [[ -n "$key_stat" ]]; then - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, ${key_stat}") + summary_details+=("Applied ${GREEN}${applied}${NC} optimizations, ${key_stat}") else - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, all services tuned") + summary_details+=("Applied ${GREEN}${applied}${NC} optimizations") fi - local summary_line3="" - if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then - summary_line3="${AUTO_FIX_SUMMARY}" - if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then - local detail_join - detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -) - [[ -n "$detail_join" ]] && summary_line3+=": ${detail_join}" - fi - summary_details+=("$summary_line3") + [[ -n "$outcome_line" ]] && summary_details+=("$outcome_line") + if [[ $attention -gt 0 || $failed -gt 0 ]]; then + summary_details+=("Review the warnings above") + else + summary_details+=("Optimization pass complete") fi - summary_details+=("System fully optimized") fi print_summary_block "$summary_title" "${summary_details[@]}" @@ -139,12 +121,12 @@ show_optimization_summary() { show_system_health() { local health_json="$1" - local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0") - local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0") - local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0") - local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0") - local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0") - local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0") + local mem_used=$(json_get_value "$health_json" "memory_used_gb") + local mem_total=$(json_get_value "$health_json" "memory_total_gb") + local disk_used=$(json_get_value "$health_json" "disk_used_gb") + local disk_total=$(json_get_value "$health_json" "disk_total_gb") + local disk_percent=$(json_get_value "$health_json" "disk_used_percent") + local uptime=$(json_get_value "$health_json" "uptime_days") mem_used=${mem_used:-0} mem_total=${mem_total:-0} @@ -153,19 +135,20 @@ show_system_health() { disk_percent=${disk_percent:-0} uptime=${uptime:-0} - printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \ - "$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime" -} + # printf parses float arguments with the locale's decimal separator, so + # comma-decimal locales reject dot values like "5.70" (#1220). Round in + # C-locale awk and print plain strings to avoid float parsing entirely. + local rounded + rounded=$(LC_ALL=C awk -v mu="$mem_used" -v mt="$mem_total" -v du="$disk_used" -v dt="$disk_total" -v ut="$uptime" \ + 'BEGIN { printf "%.0f %.0f %.0f %.0f %.0f", mu, mt, du, dt, ut }' 2> /dev/null || echo "0 0 0 0 0") + read -r mem_used mem_total disk_used disk_total uptime <<< "$rounded" -parse_optimizations() { - local health_json="$1" - echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null + printf "${ICON_ADMIN} System %s/%s GB RAM | %s/%s GB Disk | Uptime %sd\n" \ + "$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime" } announce_action() { local name="$1" - local desc="$2" - local kind="$3" if [[ "${FIRST_ACTION:-true}" == "true" ]]; then export FIRST_ACTION=false @@ -175,195 +158,34 @@ announce_action() { echo -e "${BLUE}${ICON_ARROW} ${name}${NC}" } -touchid_configured() { - local pam_file="/etc/pam.d/sudo" - [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null -} - -touchid_supported() { - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then - return 0 - fi - fi - - # Fallback: Apple Silicon Macs usually have Touch ID. - if [[ "$(uname -m)" == "arm64" ]]; then - return 0 - fi - return 1 -} - -cleanup_path() { - local raw_path="$1" - local label="$2" - - local expanded_path="${raw_path/#\~/$HOME}" - if [[ ! -e "$expanded_path" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" - return - fi - if should_protect_path "$expanded_path"; then - echo -e "${GRAY}${ICON_WARNING}${NC} Protected $label" - return - fi - - local size_kb - size_kb=$(get_path_size_kb "$expanded_path") - local size_display="" - if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then - size_display=$(bytes_to_human "$((size_kb * 1024))") - fi - - local removed=false - if safe_remove "$expanded_path" true; then - removed=true - elif request_sudo_access "Removing $label requires admin access"; then - if safe_sudo_remove "$expanded_path"; then - removed=true - fi - fi - - if [[ "$removed" == "true" ]]; then - if [[ -n "$size_display" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}${size_display}${NC}" - else - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" - fi - else - echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label${NC}" - echo -e "${GRAY}${ICON_REVIEW}${NC} ${GRAY}Grant Full Disk Access to your terminal, then retry${NC}" - fi -} - -ensure_directory() { - local raw_path="$1" - local expanded_path="${raw_path/#\~/$HOME}" - ensure_user_dir "$expanded_path" -} - -declare -a SECURITY_FIXES=() - -collect_security_fix_actions() { - SECURITY_FIXES=() - if [[ "${FIREWALL_DISABLED:-}" == "true" ]]; then - if ! is_whitelisted "firewall"; then - SECURITY_FIXES+=("firewall|Enable macOS firewall") - fi - fi - if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then - if ! is_whitelisted "gatekeeper"; then - SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper, app download protection") - fi - fi - if touchid_supported && ! touchid_configured; then - if ! is_whitelisted "check_touchid"; then - SECURITY_FIXES+=("touchid|Enable Touch ID for sudo") - fi - fi - - ((${#SECURITY_FIXES[@]} > 0)) -} - -ask_for_security_fixes() { - if ! collect_security_fix_actions; then - return 1 - fi - - echo "" - echo -e "${BLUE}SECURITY FIXES${NC}" - for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r _ label <<< "$entry" - echo -e " ${ICON_LIST} $label" - done - echo "" - export MOLE_SECURITY_FIXES_SHOWN=true - echo -ne "${GRAY}${ICON_REVIEW}${NC} ${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: " - - local key - if ! key=$(read_key); then - export MOLE_SECURITY_FIXES_SKIPPED=true - echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "" - return 0 - else - export MOLE_SECURITY_FIXES_SKIPPED=true - echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped" - echo "" - return 1 - fi -} - -apply_firewall_fix() { - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled" - FIREWALL_DISABLED=false - return 0 - fi - echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall, check permissions" - return 1 -} - -apply_gatekeeper_fix() { - if sudo spctl --master-enable 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled" - GATEKEEPER_DISABLED=false - return 0 - fi - echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable Gatekeeper" - return 1 -} - -apply_touchid_fix() { - if "$SCRIPT_DIR/bin/touchid.sh" enable; then - return 0 - fi - return 1 -} - -perform_security_fixes() { - if ! ensure_sudo_session "Security changes require admin access"; then - echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes, sudo denied" - return 1 - fi - - local applied=0 - for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r action _ <<< "$entry" - case "$action" in - firewall) - apply_firewall_fix && ((applied++)) - ;; - gatekeeper) - apply_gatekeeper_fix && ((applied++)) - ;; - touchid) - apply_touchid_fix && ((applied++)) - ;; - esac - done - - if ((applied > 0)); then - log_success "Security settings updated" - fi - SECURITY_FIXES=() -} - cleanup_all() { + local exit_status="${1:-0}" stop_inline_spinner 2> /dev/null || true stop_sudo_session cleanup_temp_files # Log session end - log_operation_session_end "optimize" "${OPTIMIZE_SAFE_COUNT:-0}" "0" + local applied=0 + local failed=0 + if declare -F optimize_outcome_count > /dev/null; then + applied=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_APPLIED") + failed=$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_FAILED") + local failed_action + while IFS= read -r failed_action; do + [[ -n "$failed_action" ]] || continue + log_operation "optimize" "TASK_FAILED" "$failed_action" "task outcome" + done < <(optimize_failed_actions) + fi + if [[ "$exit_status" -ne 0 && "$failed" -eq 0 ]]; then + local failure_action="session" + [[ "$exit_status" -eq 130 ]] && failure_action="interrupted" + log_operation "optimize" "TASK_FAILED" "$failure_action" "exit status $exit_status" + fi + log_operation_session_end "optimize" "$applied" "0" } handle_interrupt() { - cleanup_all + trap - EXIT + cleanup_all 130 exit 130 } @@ -388,12 +210,17 @@ main() { manage_whitelist "optimize" exit 0 ;; + *) + echo "Unknown optimize option: $arg" + echo "Use 'mo optimize --help' for supported options." + exit 1 + ;; esac done log_operation_session_start "optimize" - trap cleanup_all EXIT + trap 'cleanup_all "$?"' EXIT trap handle_interrupt INT TERM if [[ -t 1 ]]; then @@ -406,12 +233,6 @@ main() { echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n" fi - if ! command -v jq > /dev/null 2>&1; then - echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq" - echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}" - exit 1 - fi - if ! command -v bc > /dev/null 2>&1; then echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc" echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}" @@ -431,13 +252,13 @@ main() { exit 1 fi - if ! echo "$health_json" | jq empty 2> /dev/null; then + if ! json_validate "$health_json"; then if [[ -t 1 ]]; then stop_inline_spinner fi echo "" log_error "Invalid system health data format" - echo -e "${GRAY}${ICON_REVIEW}${NC} Check if jq, awk, sysctl, and df commands are available" + echo -e "${GRAY}${ICON_REVIEW}${NC} Check if awk, sysctl, and df commands are available" exit 1 fi @@ -445,8 +266,6 @@ main() { stop_inline_spinner fi - show_system_health "$health_json" - load_whitelist "optimize" if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then local count=${#CURRENT_WHITELIST_PATTERNS[@]} @@ -459,63 +278,43 @@ main() { fi fi - local -a safe_items=() - local -a confirm_items=() - local opts_file - opts_file=$(mktemp_file) - parse_optimizations "$health_json" > "$opts_file" - - while IFS= read -r opt_json; do - [[ -z "$opt_json" ]] && continue - - local name=$(echo "$opt_json" | jq -r '.name') - local desc=$(echo "$opt_json" | jq -r '.description') - local action=$(echo "$opt_json" | jq -r '.action') - local path=$(echo "$opt_json" | jq -r '.path // ""') - local safe=$(echo "$opt_json" | jq -r '.safe') - - local item="${name}|${desc}|${action}|${path}" + show_system_health "$health_json" - if [[ "$safe" == "true" ]]; then - safe_items+=("$item") - else - confirm_items+=("$item") - fi - done < "$opts_file" + run_optimize_diagnostics echo "" - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - ensure_sudo_session "System optimization requires admin access" || true + # Track sudo availability so individual tasks can skip cleanly when admin + # access was denied. Without this, every sudo task re-prompts for the + # password and half-runs after a refusal. Default true in dry-run so the + # task list still expands fully for inspection. + export MOLE_OPTIMIZE_SUDO_AVAILABLE="false" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + MOLE_OPTIMIZE_SUDO_AVAILABLE="true" + elif ensure_sudo_session "System optimization requires admin access"; then + MOLE_OPTIMIZE_SUDO_AVAILABLE="true" + else + opt_msg "Skipping sudo-required optimizations: admin access not granted" fi export FIRST_ACTION=true - if [[ ${#safe_items[@]} -gt 0 ]]; then - for item in "${safe_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" - announce_action "$name" "$desc" "safe" - execute_optimization "$action" "$path" - done - fi + optimize_outcomes_reset + local index action health_name + for ((index = 0; index < ${#MOLE_OPTIMIZE_ACTIONS[@]}; index++)); do + action=${MOLE_OPTIMIZE_ACTIONS[$index]} + health_name=${MOLE_OPTIMIZE_HEALTH_NAMES[$index]} + announce_action "$health_name" + execute_optimization "$action" + done - if [[ ${#confirm_items[@]} -gt 0 ]]; then - for item in "${confirm_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" - announce_action "$name" "$desc" "confirm" - execute_optimization "$action" "$path" - done + if [[ "$(optimize_outcome_total)" -ne ${#MOLE_OPTIMIZE_ACTIONS[@]} ]]; then + log_error "Optimize task outcomes are incomplete" + return 1 fi - local safe_count=${#safe_items[@]} - local confirm_count=${#confirm_items[@]} - - run_system_checks - - export OPTIMIZE_SAFE_COUNT=$safe_count - export OPTIMIZE_CONFIRM_COUNT=$confirm_count - show_optimization_summary printf '\n' + optimize_outcomes_succeeded } main "$@" diff --git a/Resources/mole/bin/purge.sh b/Resources/mole/bin/purge.sh index cd373bd..a41e69d 100755 --- a/Resources/mole/bin/purge.sh +++ b/Resources/mole/bin/purge.sh @@ -13,15 +13,26 @@ export LANG=C SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" -# Set up cleanup trap for temporary files -trap cleanup_temp_files EXIT INT TERM +# Restores cursor and clears temp files even when set -e aborts (#915). +cleanup() { + show_cursor 2> /dev/null || true + cleanup_temp_files +} +trap cleanup EXIT +trap 'trap - EXIT; cleanup; exit 130' INT TERM source "$SCRIPT_DIR/../lib/core/log.sh" source "$SCRIPT_DIR/../lib/clean/project.sh" # Configuration CURRENT_SECTION="" -# Section management +# IMPORTANT: This file overrides start_section / end_section / note_activity +# from lib/core/base.sh by virtue of being sourced after it. The purge variant +# uses a blue ━━━ box header, has no fallback "Nothing to ..." message, and +# writes every note_activity call straight to EXPORT_LIST_FILE (purge always +# wants the export list, not just under DRY_RUN). See the cross-reference in +# lib/core/base.sh and the clean variant in bin/clean.sh before changing any +# of these three. start_section() { local section_name="$1" CURRENT_SECTION="$section_name" @@ -33,13 +44,59 @@ end_section() { CURRENT_SECTION="" } -# Note activity for export list note_activity() { if [[ -n "$CURRENT_SECTION" ]]; then printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE" fi } +# Keep the most specific tail of a long purge path visible on the live scan line. +compact_purge_scan_path() { + local path="$1" + local max_path_len="${2:-0}" + + if ! [[ "$max_path_len" =~ ^[0-9]+$ ]] || [[ "$max_path_len" -lt 4 ]]; then + max_path_len=4 + fi + + if [[ ${#path} -le $max_path_len ]]; then + echo "$path" + return + fi + + local suffix_len=$((max_path_len - 3)) + local suffix="${path: -$suffix_len}" + local path_tail="" + local remainder="$path" + + while [[ "$remainder" == */* ]]; do + local segment="/${remainder##*/}" + remainder="${remainder%/*}" + + if [[ -z "$path_tail" ]]; then + if [[ ${#segment} -le $suffix_len ]]; then + path_tail="$segment" + else + break + fi + continue + fi + + if [[ $((${#segment} + ${#path_tail})) -le $suffix_len ]]; then + path_tail="${segment}${path_tail}" + else + break + fi + done + + if [[ -n "$path_tail" ]]; then + echo "...${path_tail}" + return + fi + + echo "...$suffix" +} + # Main purge function start_purge() { # Set current command for operation logging @@ -127,24 +184,18 @@ perform_purge() { # Set up trap to exit cleanly (erase the spinner line via /dev/tty) trap 'printf "\r\033[2K" >/dev/tty 2>/dev/null; exit 0' INT TERM - # Truncate path to guaranteed fit - truncate_path() { - local path="$1" - if [[ ${#path} -le $max_path_len ]]; then - echo "$path" - return - fi - local side_len=$(((max_path_len - 3) / 2)) - echo "${path:0:$side_len}...${path: -$side_len}" - } - + local _parent_pid=$$ while [[ -f "$stats_dir/purge_scanning" ]]; do + # Exit if parent process died (prevents orphaned spinner) + if ! kill -0 "$_parent_pid" 2> /dev/null; then + break + fi local current_path current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "") if [[ -n "$current_path" ]]; then local display_path="${current_path/#$HOME/~}" - display_path=$(truncate_path "$display_path") + display_path=$(compact_purge_scan_path "$display_path" "$max_path_len") last_path="$display_path" fi @@ -241,6 +292,7 @@ show_help() { echo -e "${YELLOW}Options:${NC}" echo " --paths Edit custom scan directories" echo " --dry-run Preview purge actions without making changes" + echo " --include-empty Show zero-size project artifact directories" echo " --debug Enable debug logging" echo " --help Show this help message" echo "" @@ -252,9 +304,6 @@ show_help() { # Main entry point main() { - # Set up signal handling - trap 'show_cursor; exit 130' INT TERM - # Parse arguments for arg in "$@"; do case "$arg" in @@ -273,6 +322,9 @@ main() { "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; + "--include-empty") + export MOLE_PURGE_INCLUDE_EMPTY=1 + ;; *) echo "Unknown option: $arg" echo "Use 'mo purge --help' for usage information" @@ -288,7 +340,14 @@ main() { fi hide_cursor perform_purge - show_cursor } +if [[ "${MOLE_SKIP_MAIN:-0}" == "1" ]]; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + return 0 + else + exit 0 + fi +fi + main "$@" diff --git a/Resources/mole/bin/status-go b/Resources/mole/bin/status-go index 290cabf..5f3894b 100755 Binary files a/Resources/mole/bin/status-go and b/Resources/mole/bin/status-go differ diff --git a/Resources/mole/bin/touchid.sh b/Resources/mole/bin/touchid.sh index 76b5cc2..b97cac9 100755 --- a/Resources/mole/bin/touchid.sh +++ b/Resources/mole/bin/touchid.sh @@ -22,6 +22,11 @@ readonly PAM_SUDO_FILE readonly PAM_SUDO_LOCAL_FILE readonly PAM_TID_LINE="auth sufficient pam_tid.so" +secure_install_pam() { + local src="$1" dst="$2" + sudo install -m 444 -o root -g wheel "$src" "$dst" && rm -f "$src" +} + # Check if Touch ID is already configured is_touchid_configured() { # Check sudo_local first @@ -108,7 +113,7 @@ enable_touchid() { # Clean up legacy config temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}" fi fi @@ -138,9 +143,7 @@ enable_touchid() { temp_file=$(create_temp_file) cp "$PAM_SUDO_LOCAL_FILE" "$temp_file" echo "$PAM_TID_LINE" >> "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" - sudo chmod 444 "$PAM_SUDO_LOCAL_FILE" - sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE" + secure_install_pam "$temp_file" "$PAM_SUDO_LOCAL_FILE" write_success=true else write_success=true # Already there (should be caught by first check, but safe fallback) @@ -152,7 +155,7 @@ enable_touchid() { if $is_legacy_configured; then temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_FILE" + secure_install_pam "$temp_file" "$PAM_SUDO_FILE" log_success "Touch ID migrated to sudo_local" else log_success "Touch ID enabled, via sudo_local, try: sudo ls" @@ -201,7 +204,7 @@ enable_touchid() { fi # Apply the changes - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then log_success "Touch ID enabled, try: sudo ls" return 0 else @@ -236,12 +239,12 @@ disable_touchid() { temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then # Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup) if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_FILE" + secure_install_pam "$temp_file" "$PAM_SUDO_FILE" fi echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled, removed from sudo_local${NC}" echo "" @@ -266,7 +269,7 @@ disable_touchid() { temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}" echo "" return 0 @@ -292,7 +295,7 @@ show_menu() { echo "" case "$key" in - $'\e') # ESC + $'\e' | q | Q) # ESC or Q return 0 ;; "" | $'\n' | $'\r') # Enter @@ -310,7 +313,7 @@ show_menu() { drain_pending_input # Clean up any escape sequence remnants case "$key" in - $'\e') # ESC + $'\e' | q | Q) # ESC or Q return 0 ;; "" | $'\n' | $'\r') # Enter diff --git a/Resources/mole/bin/uninstall.sh b/Resources/mole/bin/uninstall.sh index 5c96661..b8aad9e 100755 --- a/Resources/mole/bin/uninstall.sh +++ b/Resources/mole/bin/uninstall.sh @@ -35,41 +35,16 @@ readonly MOLE_UNINSTALL_META_CACHE_DIR="$HOME/.cache/mole" readonly MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstall_app_metadata_v1" readonly MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" readonly MOLE_UNINSTALL_META_REFRESH_TTL=604800 # 7 days -readonly MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC="0.25" -readonly MOLE_UNINSTALL_INLINE_METADATA_LIMIT=8 -readonly MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC="0.08" - -uninstall_relative_time_from_epoch() { - local value_epoch="${1:-0}" - local now_epoch="${2:-0}" - - if [[ ! "$value_epoch" =~ ^[0-9]+$ || $value_epoch -le 0 ]]; then - echo "Unknown" - return 0 - fi - - local days_ago=$(((now_epoch - value_epoch) / 86400)) - if [[ $days_ago -lt 0 ]]; then - days_ago=0 - fi - - if [[ $days_ago -eq 0 ]]; then - echo "Today" - elif [[ $days_ago -eq 1 ]]; then - echo "Yesterday" - elif [[ $days_ago -lt 7 ]]; then - echo "${days_ago} days ago" - elif [[ $days_ago -lt 30 ]]; then - local weeks_ago=$((days_ago / 7)) - [[ $weeks_ago -eq 1 ]] && echo "1 week ago" || echo "${weeks_ago} weeks ago" - elif [[ $days_ago -lt 365 ]]; then - local months_ago=$((days_ago / 30)) - [[ $months_ago -eq 1 ]] && echo "1 month ago" || echo "${months_ago} months ago" - else - local years_ago=$((days_ago / 365)) - [[ $years_ago -eq 1 ]] && echo "1 year ago" || echo "${years_ago} years ago" - fi -} +readonly MOLE_UNINSTALL_EPOCH_FLOOR=978307200 +# Display-name mdls lookup budget during scan; overridable for slow disks or +# cold Spotlight. +readonly MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC:-0.04}" +readonly MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC:-0.04}" +# Bounded inline du fallback for cold rows whose quick mdls probe missed +# (new apps are often not yet Spotlight-indexed). Only enabled when the +# cold-row count is small so a fully cold first scan keeps the fast path. +readonly MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC:-2}" +readonly MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS="${MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS:-20}" uninstall_normalize_size_display() { local size="${1:-}" @@ -91,6 +66,42 @@ uninstall_normalize_last_used_display() { echo "$display" } +uninstall_quick_app_size_kb() { + local app_path="$1" + [[ -n "$app_path" && -d "$app_path" ]] || { + echo "0" + return 0 + } + + local logical_size + logical_size=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC" mdls -name kMDItemLogicalSize -raw "$app_path" 2> /dev/null || echo "") + if [[ "$logical_size" =~ ^[0-9]+$ && "$logical_size" -gt 0 ]]; then + echo $(((logical_size + 1023) / 1024)) + return 0 + fi + + echo "0" +} + +# du can underreport APFS-cloned bundles relative to Finder, so this only +# stands in until the deferred refresh recomputes the logical size. +uninstall_inline_du_size_kb() { + local app_path="$1" + [[ -n "$app_path" && -d "$app_path" ]] || { + echo "0" + return 0 + } + + local du_size_kb + du_size_kb=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC" du -sk "$app_path" 2> /dev/null | awk '{print $1; exit}') || du_size_kb="" + if [[ "$du_size_kb" =~ ^[0-9]+$ && "$du_size_kb" -gt 0 ]]; then + echo "$du_size_kb" + return 0 + fi + + echo "0" +} + uninstall_resolve_display_name() { local app_path="$1" local app_name="$2" @@ -99,11 +110,11 @@ uninstall_resolve_display_name() { if [[ -f "$app_path/Contents/Info.plist" ]]; then local md_display_name if [[ -n "$MOLE_UNINSTALL_USER_LC_ALL" ]]; then - md_display_name=$(run_with_timeout 0.04 env LC_ALL="$MOLE_UNINSTALL_USER_LC_ALL" LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" env LC_ALL="$MOLE_UNINSTALL_USER_LC_ALL" LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") elif [[ -n "$MOLE_UNINSTALL_USER_LANG" ]]; then - md_display_name=$(run_with_timeout 0.04 env LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" env LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") else - md_display_name=$(run_with_timeout 0.04 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") fi local bundle_display_name @@ -135,6 +146,16 @@ uninstall_resolve_display_name() { if [[ "$display_name" == /* ]]; then display_name="$app_name" fi + + # Keep versioned bundle names when metadata collapses distinct installs. + if [[ -n "$display_name" && "$app_name" == "$display_name"* && "$app_name" != "$display_name" ]]; then + local suffix + suffix="${app_name#"$display_name"}" + if [[ "$suffix" == *[0-9]* ]]; then + display_name="$app_name" + fi + fi + display_name="${display_name%.app}" display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" @@ -176,32 +197,29 @@ uninstall_release_metadata_lock() { [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true } -uninstall_collect_inline_metadata() { - local app_path="$1" - local app_mtime="${2:-0}" - local now_epoch="${3:-0}" - - local size_kb - size_kb=$(get_path_size_kb "$app_path") - [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 +# Atomically replace the metadata cache file, healing stale root-owned copies. +# stdin is closed so BSD mv/cp never blocks prompting on a non-writable target. +uninstall_persist_cache_file() { + local src="$1" + local dst="$2" - local last_used_epoch=0 - local metadata_date - metadata_date=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC" mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") - if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") - fi + [[ -s "$src" ]] || { + rm -f "$src" 2> /dev/null || true + return 0 + } - # Fallback to app mtime so first scan does not show "...". - if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then - if [[ "$app_mtime" =~ ^[0-9]+$ && $app_mtime -gt 0 ]]; then - last_used_epoch="$app_mtime" - else - last_used_epoch=0 - fi + # Heal stale file the user cannot write to (e.g. root-owned from a prior + # sudo run). The parent dir is user-owned, so rm succeeds regardless. + if [[ -e "$dst" && ! -w "$dst" ]]; then + rm -f "$dst" 2> /dev/null || true fi - printf "%s|%s|%s\n" "$size_kb" "$last_used_epoch" "$now_epoch" + # shellcheck disable=SC2217 # BSD mv/cp read stdin when prompting; close it to avoid hang. + mv -f "$src" "$dst" < /dev/null 2> /dev/null || { + # shellcheck disable=SC2217 + cp -f "$src" "$dst" < /dev/null 2> /dev/null || true + rm -f "$src" 2> /dev/null || true + } } start_uninstall_metadata_refresh() { @@ -255,15 +273,18 @@ start_uninstall_metadata_refresh() { ((worker_idx++)) local worker_output="${updates_file}.${worker_idx}" + # stdin from /dev/null: these workers never read the terminal, and a + # background job that keeps the tty on stdin lets its timeout helpers + # take the terminal away from the foreground prompt (#1222). ( local last_used_epoch=0 local metadata_date - metadata_date=$(run_with_timeout 0.2 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + metadata_date=$(run_with_timeout 0.2 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") # 0.2s: per-app probe in tight scan loop, see lib/core/timeouts.sh if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") fi - if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then + if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 || $last_used_epoch -lt $MOLE_UNINSTALL_EPOCH_FLOOR ]]; then last_used_epoch=0 fi @@ -272,7 +293,7 @@ start_uninstall_metadata_refresh() { [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 printf "%s|%s|%s|%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$size_kb" "${last_used_epoch:-0}" "$now_epoch" "$bundle_id" "$display_name" > "$worker_output" - ) & + ) < /dev/null & worker_pids+=($!) if ((${#worker_pids[@]} >= max_parallel)); then @@ -304,8 +325,8 @@ start_uninstall_metadata_refresh() { exit 0 fi - local merged_file - merged_file=$(mktemp 2> /dev/null) || { + local refresh_merged_file + refresh_merged_file=$(mktemp 2> /dev/null) || { _refresh_debug "mktemp for merge failed, aborting" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" rm -f "$updates_file" @@ -320,131 +341,33 @@ start_uninstall_metadata_refresh() { print updates[path] } } - ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$merged_file" + ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$refresh_merged_file" - mv "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || { - cp "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true - rm -f "$merged_file" - } + uninstall_persist_cache_file "$refresh_merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" - rm -f "$updates_file" + rm -f "$updates_file" "$refresh_merged_file" rm -f "$refresh_file" 2> /dev/null || true - ) > /dev/null 2>&1 & + # Redirect stdin from /dev/null so the perl timeout fallback does not see + # a tty on stdin and hand the controlling terminal to its timed child. + # This background refresh (and its nested workers, which inherit this + # stdin) never needs the terminal; leaving stdin as the tty lets a worker + # steal the foreground process group and stop the foreground prompt with + # SIGTTIN (issue #1222). The interactive sudo handoff (#1201) is on + # non-background call sites and is unaffected. + ) > /dev/null 2>&1 < /dev/null & + disown "$!" 2> /dev/null || true } -# Scan applications and collect information. -scan_applications() { - local temp_file scan_raw_file merged_file refresh_file cache_snapshot_file - temp_file=$(create_temp_file) - scan_raw_file="${temp_file}.scan" - merged_file="${temp_file}.merged" - refresh_file="${temp_file}.refresh" - cache_snapshot_file="${temp_file}.cache" - local scan_status_file="${temp_file}.scan_status" - : > "$scan_raw_file" - : > "$refresh_file" - : > "$cache_snapshot_file" - : > "$scan_status_file" - - ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" - ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" - local cache_source="$MOLE_UNINSTALL_META_CACHE_FILE" - local cache_source_is_temp=false - if [[ ! -r "$cache_source" ]]; then - cache_source=$(create_temp_file) - : > "$cache_source" - cache_source_is_temp=true - fi - - # Fast lookup cache for unchanged apps: path+mtime -> bundle_id/display_name. - local -a cache_paths=() - local -a cache_mtimes=() - local -a cache_bundle_ids=() - local -a cache_display_names=() - local cache_path cache_mtime _cache_size _cache_epoch _cache_updated cache_bundle cache_display - while IFS='|' read -r cache_path cache_mtime _cache_size _cache_epoch _cache_updated cache_bundle cache_display; do - [[ -n "$cache_path" ]] || continue - cache_paths+=("$cache_path") - cache_mtimes+=("${cache_mtime:-0}") - cache_bundle_ids+=("${cache_bundle:-}") - cache_display_names+=("${cache_display:-}") - done < "$cache_source" - - lookup_cached_identity() { - local target_path="$1" - local target_mtime="$2" - local idx - for ((idx = 0; idx < ${#cache_paths[@]}; idx++)); do - if [[ "${cache_paths[idx]}" == "$target_path" ]]; then - if [[ "${cache_mtimes[idx]:-0}" == "${target_mtime:-0}" ]]; then - echo "${cache_bundle_ids[idx]:-}|${cache_display_names[idx]:-}" - else - echo "|" - fi - return 0 - fi - done - echo "|" - } - - # Local spinner_pid for cleanup - local spinner_pid="" - local spinner_shown_file="${temp_file}.spinner_shown" - local previous_int_trap="" - previous_int_trap=$(trap -p INT || true) - - restore_scan_int_trap() { - if [[ -n "$previous_int_trap" ]]; then - eval "$previous_int_trap" - else - trap - INT - fi - } - - # Trap to handle Ctrl+C during scan - # shellcheck disable=SC2329 # Function invoked indirectly via trap - trap_scan_cleanup() { - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - fi - if [[ -f "$spinner_shown_file" ]]; then - printf "\r\033[K" >&2 - fi - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true - exit 130 - } - trap trap_scan_cleanup INT - - update_scan_status() { - local message="$1" - local completed="${2:-0}" - local total="${3:-0}" - printf "%s|%s|%s\n" "$message" "$completed" "$total" > "$scan_status_file" - } - - stop_scan_spinner() { - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - spinner_pid="" - fi - if [[ -f "$spinner_shown_file" ]]; then - printf "\r\033[K" >&2 - fi - rm -f "$spinner_shown_file" "$scan_status_file" 2> /dev/null || true - } - - # Pass 1: collect app paths and bundle IDs (no mdls). - local -a app_data_tuples=() +uninstall_print_app_search_dirs() { local -a app_dirs=( "/Applications" "$HOME/Applications" "/Library/Input Methods" "$HOME/Library/Input Methods" ) + local vol_app_dir local nullglob_was_set=0 shopt -q nullglob && nullglob_was_set=1 @@ -463,63 +386,322 @@ scan_applications() { shopt -u nullglob fi - for app_dir in "${app_dirs[@]}"; do - if [[ ! -d "$app_dir" ]]; then continue; fi + printf '%s\n' "${app_dirs[@]}" +} - while IFS= read -r -d '' app_path; do - if [[ ! -e "$app_path" ]]; then continue; fi +uninstall_should_skip_app_path() { + local app_path="$1" + + [[ -e "$app_path" ]] || return 0 - local app_name - app_name=$(basename "$app_path" .app) + # Skip nested apps inside another .app bundle. + local parent_dir="${app_path%/*}" + if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then + return 0 + fi - # Skip nested apps inside another .app bundle. - local parent_dir - parent_dir=$(dirname "$app_path") - if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then - continue + if [[ -L "$app_path" ]]; then + local link_target + link_target=$(readlink "$app_path" 2> /dev/null) + if [[ -n "$link_target" ]]; then + local resolved_target="$link_target" + if [[ "$link_target" != /* ]]; then + local link_dir="${app_path%/*}" + local _link_parent="${link_target%/*}" + [[ "$_link_parent" == "$link_target" ]] && _link_parent="." + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$_link_parent" 2> /dev/null && pwd)/"${link_target##*/}" 2> /dev/null || echo "" fi + case "$resolved_target" in + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + return 0 + ;; + esac + fi + fi - if [[ -L "$app_path" ]]; then - local link_target - link_target=$(readlink "$app_path" 2> /dev/null) - if [[ -n "$link_target" ]]; then - local resolved_target="$link_target" - if [[ "$link_target" != /* ]]; then - local link_dir - link_dir=$(dirname "$app_path") - resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") 2> /dev/null || echo "" - fi - case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - continue - ;; - esac - fi + return 1 +} + +uninstall_resolve_bundle_id() { + local app_path="$1" + local fallback_bundle_id="${2:-}" + local bundle_id="" + local plist="$app_path/Contents/Info.plist" + + fallback_bundle_id="${fallback_bundle_id//|/-}" + fallback_bundle_id="${fallback_bundle_id//[$'\t\r\n']/}" + + if [[ -f "$plist" ]]; then + bundle_id=$(plutil -extract CFBundleIdentifier raw "$plist" 2> /dev/null || echo "") + bundle_id="${bundle_id//|/-}" + bundle_id="${bundle_id//[$'\t\r\n']/}" + fi + + if [[ -n "$bundle_id" && "$bundle_id" != "(null)" ]]; then + printf '%s\n' "$bundle_id" + return 0 + fi + + if [[ -n "$fallback_bundle_id" && "$fallback_bundle_id" != "(null)" ]]; then + printf '%s\n' "$fallback_bundle_id" + return 0 + fi + + printf '%s\n' "unknown" +} + +uninstall_app_is_background_only() { + local app_path="$1" + local plist="$app_path/Contents/Info.plist" + [[ -f "$plist" ]] || return 1 + + local bg_only + bg_only=$(plutil -extract LSBackgroundOnly raw "$plist" 2> /dev/null || echo "") + case "$bg_only" in + 1 | YES | yes | TRUE | true) + return 0 + ;; + esac + + return 1 +} + +uninstall_app_is_directly_in_search_root() { + local app_path="$1" + local app_parent="${app_path%/*}" + local app_dir + + while IFS= read -r app_dir; do + [[ -n "$app_dir" ]] || continue + if [[ "$app_parent" == "$app_dir" ]]; then + return 0 + fi + done < <(uninstall_print_app_search_dirs) + + return 1 +} + +uninstall_app_is_currently_eligible() { + local app_path="$1" + local bundle_id="${2:-}" + + [[ -n "$app_path" && -e "$app_path" ]] || return 1 + + if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] && should_protect_from_uninstall "$bundle_id"; then + return 1 + fi + + if uninstall_app_is_background_only "$app_path" && ! uninstall_app_is_directly_in_search_root "$app_path"; then + return 1 + fi + + return 0 +} + +uninstall_resolve_eligible_bundle_id() { + local app_path="$1" + local fallback_bundle_id="${2:-}" + local bundle_id + + bundle_id=$(uninstall_resolve_bundle_id "$app_path" "$fallback_bundle_id") + uninstall_app_is_currently_eligible "$app_path" "$bundle_id" || return 1 + printf '%s\n' "$bundle_id" +} + +uninstall_print_app_paths_with_mtime() { + local app_dir="$1" + local app_path app_mtime + + [[ -d "$app_dir" ]] || return 0 + + while IFS= read -r -d '' app_path; do + [[ -n "$app_path" ]] || continue + app_mtime=$(get_file_mtime "$app_path") + printf '%s\t%s\n' "${app_mtime:-0}" "$app_path" + done < <(command find "$app_dir" -maxdepth 3 -name "*.app" -print0 2> /dev/null) +} + +uninstall_app_inventory_fingerprint() { + local app_dir app_path app_mtime info_mtime pkg_app_path + + { + while IFS= read -r pkg_app_path; do + [[ -n "$pkg_app_path" && -d "$pkg_app_path" ]] || continue + app_mtime=$(get_file_mtime "$pkg_app_path") + info_mtime=$(get_file_mtime "$pkg_app_path/Contents/Info.plist") + printf '%s|%s|%s\n' "$pkg_app_path" "${app_mtime:-0}" "${info_mtime:-0}" + done < <(pkg_receipt_nonstandard_app_paths) + + while IFS= read -r app_dir; do + [[ -d "$app_dir" ]] || continue + while IFS=$'\t' read -r app_mtime app_path; do + [[ -n "$app_path" ]] || continue + uninstall_should_skip_app_path "$app_path" && continue + info_mtime=$(get_file_mtime "$app_path/Contents/Info.plist") + printf '%s|%s|%s\n' "$app_path" "${app_mtime:-0}" "${info_mtime:-0}" + done < <(uninstall_print_app_paths_with_mtime "$app_dir") + done < <(uninstall_print_app_search_dirs) + } | LC_ALL=C sort -u +} + +# The in-session app index remains valid when the live inventory only loses +# rows. load_applications rechecks path existence before displaying each row. +# New rows and changed mtimes must rebuild the index so protection and bundle +# metadata are evaluated again. +uninstall_inventory_can_reuse_cached_apps() { + local cached_inventory="$1" + local current_inventory="$2" + local additions="" + local removals="" + + [[ -n "$cached_inventory" && -n "$current_inventory" ]] || return 1 + additions=$(LC_ALL=C comm -13 \ + <(printf '%s\n' "$cached_inventory") \ + <(printf '%s\n' "$current_inventory")) || return 1 + [[ -z "$additions" ]] || return 1 + + removals=$(LC_ALL=C comm -23 \ + <(printf '%s\n' "$cached_inventory") \ + <(printf '%s\n' "$current_inventory")) || return 1 + local removed_row removed_path + while IFS= read -r removed_row; do + [[ -n "$removed_row" ]] || continue + removed_path="${removed_row%|*}" + removed_path="${removed_path%|*}" + [[ ! -e "$removed_path" ]] || return 1 + done <<< "$removals" + return 0 +} + +# Internal helpers for scan_applications. They read and write locals +# declared in the orchestrator's scope via bash dynamic scoping; do not +# call them outside scan_applications. + +# Phase 2 (Pass 1): discover candidate .app paths by combining the +# configured app search directories with pkg-receipt non-standard install +# locations, skipping bundles flagged by uninstall_should_skip_app_path. +# Each row in discovered_file is encoded as ||. +# Writes: discovered_file +_scan_discover_apps() { + local -a app_dirs=() + local app_dir + while IFS= read -r app_dir; do + [[ -n "$app_dir" ]] && app_dirs+=("$app_dir") + done < <(uninstall_print_app_search_dirs) + + # Scan for pkg-installed apps in non-standard locations. + local pkg_app_path + while IFS= read -r pkg_app_path; do + [[ -n "$pkg_app_path" ]] || continue + + local already_scanned=false + for app_dir in "${app_dirs[@]}"; do + if [[ "$pkg_app_path" == "$app_dir"/*.app ]]; then + already_scanned=true + break fi + done + [[ "$already_scanned" == true ]] && continue + + local app_name="${pkg_app_path##*/}" + app_name="${app_name%.app}" + + local app_mtime + app_mtime=$(get_file_mtime "$pkg_app_path") + + printf "%s|%s|%s\n" "$pkg_app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" + done < <(pkg_receipt_nonstandard_app_paths) + + for app_dir in "${app_dirs[@]}"; do + if [[ ! -d "$app_dir" ]]; then continue; fi - local app_mtime - app_mtime=$(get_file_mtime "$app_path") + while IFS=$'\t' read -r app_mtime app_path; do + if [[ ! -e "$app_path" ]]; then continue; fi + + local app_name="${app_path##*/}" + app_name="${app_name%.app}" - local cached_identity cached_bundle_id cached_display_name - cached_identity=$(lookup_cached_identity "$app_path" "$app_mtime") - IFS='|' read -r cached_bundle_id cached_display_name <<< "$cached_identity" + uninstall_should_skip_app_path "$app_path" && continue - # Store tuple for pass 2 (bundle + display resolution, then cache merge). - app_data_tuples+=("${app_path}|${app_name}|${app_mtime}|${cached_bundle_id}|${cached_display_name}") - done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) + printf "%s|%s|%s\n" "$app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" + done < <(uninstall_print_app_paths_with_mtime "$app_dir") done +} - if [[ ${#app_data_tuples[@]} -eq 0 ]]; then - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true - [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true - restore_scan_int_trap - printf "\r\033[K" >&2 - echo "No applications found to uninstall." >&2 - return 1 +# Phase 3: partition discovered apps into warm-cache rows (written +# directly to scan_raw_file) and cold rows (queued in app_data_tuples +# for parallel metadata resolution in _scan_resolve_uncached). +# Reads: cache_source, discovered_file +# Writes: cached_rows_file, uncached_rows_file, scan_raw_file (via the +# nested use_cached_scan_metadata helper), app_data_tuples +_scan_partition_cache() { + use_cached_scan_metadata() { + local cached_app_path="$1" + local cached_app_mtime="$2" + local cached_bundle_id="$3" + local cached_display_name="$4" + local cached_size_kb="$5" + + [[ -n "$cached_bundle_id" && -n "$cached_display_name" ]] || return 1 + [[ "$cached_size_kb" =~ ^[0-9]+$ && "$cached_size_kb" -gt 0 ]] || return 1 + + cached_bundle_id=$(uninstall_resolve_eligible_bundle_id "$cached_app_path" "$cached_bundle_id") || return 1 + + printf "%s|%s|%s|%s|%s\n" "$cached_app_path" "$cached_display_name" "$cached_bundle_id" "$cached_app_mtime" "$cached_size_kb" >> "$scan_raw_file" + return 0 + } + + if [[ -s "$discovered_file" ]]; then + awk -F'|' -v cached_out="$cached_rows_file" -v uncached_out="$uncached_rows_file" ' + FILENAME == ARGV[1] { + cache_mtime[$1] = $2 + cache_size[$1] = $3 + cache_bundle[$1] = $6 + cache_display[$1] = $7 + next + } + { + path = $1 + app_mtime = $3 + if (cache_mtime[path] == app_mtime && cache_display[path] != "" && cache_size[path] ~ /^[0-9]+$/ && cache_size[path] > 0) { + cached_bundle = cache_bundle[path] == "" ? "unknown" : cache_bundle[path] + print path "|" app_mtime "|" cached_bundle "|" cache_display[path] "|" cache_size[path] >> cached_out + } else { + print path "|" $2 "|" app_mtime "|" cache_bundle[path] "|" cache_display[path] >> uncached_out + } + } + ' "$cache_source" "$discovered_file" + + local cached_app_path cached_app_mtime cached_bundle_id cached_display_name cached_size_kb + while IFS='|' read -r cached_app_path cached_app_mtime cached_bundle_id cached_display_name cached_size_kb; do + use_cached_scan_metadata "$cached_app_path" "$cached_app_mtime" "$cached_bundle_id" "$cached_display_name" "$cached_size_kb" || true + done < "$cached_rows_file" + + local uncached_app_path uncached_app_name uncached_app_mtime uncached_bundle_id uncached_display_name + while IFS='|' read -r uncached_app_path uncached_app_name uncached_app_mtime uncached_bundle_id uncached_display_name; do + app_data_tuples+=("${uncached_app_path}|${uncached_app_name}|${uncached_app_mtime}|${uncached_bundle_id}|${uncached_display_name}") + done < "$uncached_rows_file" fi - # Pass 2: resolve display names in parallel. +} + +# Phase 5 (Pass 2): resolve display names and bundle IDs in parallel for +# the cold rows queued by _scan_partition_cache. Spawns the progress +# spinner subprocess (assigns spinner_pid), fans out workers up to +# max_parallel, and waits for completion. +# Reads: app_data_tuples +# Writes: scan_raw_file (appended by worker subshells) +_scan_resolve_uncached() { local app_count=0 local total_apps=${#app_data_tuples[@]} + # Cold rows are usually the handful of newly installed or updated apps; + # give those a bounded du when the quick mdls probe misses so the size + # shows on first paint. A fully cold cache (first run) exceeds the cap + # and keeps the fast path; the deferred refresh still fills the cache. + local inline_du_fallback=0 + if [[ "$MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS" =~ ^[0-9]+$ ]] && + ((total_apps > 0 && total_apps <= MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS)); then + inline_du_fallback=1 + fi local max_parallel max_parallel=$(get_optimal_parallel_jobs "io") if [[ $max_parallel -lt 8 ]]; then @@ -535,17 +717,8 @@ scan_applications() { IFS='|' read -r app_path app_name app_mtime cached_bundle_id cached_display_name <<< "$app_data_tuple" - local bundle_id="${cached_bundle_id:-}" - if [[ -z "$bundle_id" ]]; then - bundle_id="unknown" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - fi - fi - - if should_protect_from_uninstall "$bundle_id"; then - return 0 - fi + local bundle_id + bundle_id=$(uninstall_resolve_eligible_bundle_id "$app_path" "${cached_bundle_id:-}") || return 0 local display_name="${cached_display_name:-}" if [[ -z "$display_name" ]]; then @@ -556,64 +729,132 @@ scan_applications() { display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" - echo "${app_path}|${display_name}|${bundle_id}|${app_mtime}" >> "$output_file" + local quick_size_kb + quick_size_kb=$(uninstall_quick_app_size_kb "$app_path") + [[ "$quick_size_kb" =~ ^[0-9]+$ ]] || quick_size_kb=0 + + if [[ "$quick_size_kb" -eq 0 && "${inline_du_fallback:-0}" == "1" ]]; then + quick_size_kb=$(uninstall_inline_du_size_kb "$app_path") + [[ "$quick_size_kb" =~ ^[0-9]+$ ]] || quick_size_kb=0 + fi + + echo "${app_path}|${display_name}|${bundle_id}|${app_mtime}|${quick_size_kb}" >> "$output_file" } update_scan_status "Scanning applications..." "0" "$total_apps" - ( - # shellcheck disable=SC2329 # Function invoked indirectly via trap - cleanup_spinner() { exit 0; } - trap cleanup_spinner TERM INT EXIT - sleep "$MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC" 2> /dev/null || sleep 1 - [[ -f "$scan_status_file" ]] || exit 0 - local spinner_chars="|/-\\" - local i=0 - : > "$spinner_shown_file" - while true; do - local status_line status_message status_completed status_total - status_line=$(cat "$scan_status_file" 2> /dev/null || echo "") - IFS='|' read -r status_message status_completed status_total <<< "$status_line" - [[ -z "$status_message" ]] && status_message="Scanning applications..." - local c="${spinner_chars:$((i % 4)):1}" - if [[ "$status_completed" =~ ^[0-9]+$ && "$status_total" =~ ^[0-9]+$ && $status_total -gt 0 ]]; then - printf "\r\033[K%s %s %d/%d" "$c" "$status_message" "$status_completed" "$status_total" >&2 - else - printf "\r\033[K%s %s" "$c" "$status_message" >&2 + # Skip Pass 2 when the warm cache already wrote every row to $scan_raw_file. + # Also avoids expanding an empty array; macOS bash 3.2 (the /bin/bash that + # this script targets) treats `"${empty[@]}"` as unbound under `set -u`. + if ((total_apps > 0)); then + for app_data_tuple in "${app_data_tuples[@]}"; do + ((app_count++)) + # Redirect stdin from /dev/null so the perl timeout fallback used by + # process_app_metadata does not hand the controlling terminal to its + # timed mdls/du child from this background worker (issue #1222). + process_app_metadata "$app_data_tuple" "$scan_raw_file" < /dev/null & + pids+=($!) + update_scan_status "Scanning applications..." "$app_count" "$total_apps" + + if ((${#pids[@]} >= max_parallel)); then + wait "${pids[0]}" 2> /dev/null + pids=("${pids[@]:1}") fi - ((i++)) - sleep 0.1 2> /dev/null || sleep 1 done - ) & - spinner_pid=$! - - for app_data_tuple in "${app_data_tuples[@]}"; do - ((app_count++)) - process_app_metadata "$app_data_tuple" "$scan_raw_file" & - pids+=($!) - update_scan_status "Scanning applications..." "$app_count" "$total_apps" - if ((${#pids[@]} >= max_parallel)); then - wait "${pids[0]}" 2> /dev/null - pids=("${pids[@]:1}") - fi - done + for pid in "${pids[@]}"; do + wait "$pid" 2> /dev/null + done + fi +} - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null - done +# Phase 6: collapse duplicate bundle IDs discovered from backup volumes or +# mirrored Applications folders. Keep the live app locations first. +# The dedupe key includes the .app basename so distinct installs that share a +# bundle ID (e.g. Xcode.app and Xcode-beta.app, both com.apple.dt.Xcode) are +# kept, while true clones of the same bundle name in mirrored roots collapse. +_scan_dedupe_bundle_ids() { + [[ -s "$scan_raw_file" ]] || return 0 + + local deduped_file="${scan_raw_file}.deduped" + if ! awk -F'|' -v home_apps="$HOME/Applications/" ' + function starts_with(value, prefix) { + return prefix != "" && substr(value, 1, length(prefix)) == prefix + } + function direct_app_under(path, prefix, rest) { + if (!starts_with(path, prefix)) { + return 0 + } + rest = substr(path, length(prefix) + 1) + return index(rest, "/") == 0 && rest ~ /[.]app$/ + } + function path_rank(path) { + if (direct_app_under(path, "/Applications/")) { + return 1 + } + if (direct_app_under(path, home_apps)) { + return 2 + } + if (starts_with(path, "/Volumes/")) { + return 4 + } + return 3 + } + function app_basename(path, n, parts) { + n = split(path, parts, "/") + return parts[n] + } + { + bundle_id = $3 + if (bundle_id == "" || bundle_id == "unknown") { + key = "__path__" NR + rows[key] = $0 + order[++count] = key + next + } - update_scan_status "Building uninstall index..." "0" "0" + key = bundle_id "|" app_basename($1) + rank = path_rank($1) + if (!(key in rows)) { + rows[key] = $0 + ranks[key] = rank + order[++count] = key + next + } + if (rank < ranks[key]) { + rows[key] = $0 + ranks[key] = rank + } + } + END { + for (i = 1; i <= count; i++) { + key = order[i] + if (key in rows) { + print rows[key] + } + } + } + ' "$scan_raw_file" > "$deduped_file"; then + rm -f "$deduped_file" 2> /dev/null || true + return 0 + fi - if [[ ! -s "$scan_raw_file" ]]; then - stop_scan_spinner - echo "No applications found to uninstall" >&2 - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true - [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true - restore_scan_int_trap - return 1 + if ! mv "$deduped_file" "$scan_raw_file" 2> /dev/null; then + rm -f "$deduped_file" 2> /dev/null || true fi +} +# Phase 7+8: merge scan_raw_file with the persistent metadata cache, +# compute display size / last-used / refresh-needed flags via the embedded awk +# pipeline, persist the cache snapshot under a lock, sort the result by epoch, +# kick off the deferred background refresh, and echo the sorted index path for +# the caller to capture. +# Reads: scan_raw_file, cache_source +# Writes: merged_file, refresh_file, cache_snapshot_file, temp_file, +# ${temp_file}.sorted, MOLE_UNINSTALL_META_CACHE_FILE +# Returns: 0 on success (sorted path is echoed on stdout), 1 if sort +# fails or the sorted file did not materialize. +_scan_finalize_index() { update_scan_status "Merging cache data..." "0" "0" awk -F'|' ' NR == FNR { @@ -635,104 +876,136 @@ scan_applications() { local current_epoch current_epoch=$(get_epoch_seconds) - local inline_metadata_count=0 local metadata_total=0 metadata_total=$(wc -l < "$merged_file" 2> /dev/null || echo "0") [[ "$metadata_total" =~ ^[0-9]+$ ]] || metadata_total=0 - local metadata_processed=0 update_scan_status "Collecting metadata..." "0" "$metadata_total" - while IFS='|' read -r app_path display_name bundle_id app_mtime cached_mtime cached_size_kb cached_epoch cached_updated_epoch cached_bundle_id cached_display_name; do - ((metadata_processed++)) - if ((metadata_processed % 5 == 0 || metadata_processed == metadata_total)); then - update_scan_status "Collecting metadata..." "$metadata_processed" "$metadata_total" - fi - - [[ -n "$app_path" && -e "$app_path" ]] || continue - - local cache_match=false - if [[ -n "$cached_mtime" && -n "$app_mtime" && "$cached_mtime" == "$app_mtime" ]]; then - cache_match=true - fi - - local final_epoch=0 - if [[ "$cached_epoch" =~ ^[0-9]+$ && $cached_epoch -gt 0 ]]; then - final_epoch="$cached_epoch" - fi - - local final_size_kb=0 - local final_size="N/A" - if [[ "$cached_size_kb" =~ ^[0-9]+$ && $cached_size_kb -gt 0 ]]; then - final_size_kb="$cached_size_kb" - final_size=$(bytes_to_human "$((cached_size_kb * 1024))") - fi - - # Fallback to app mtime to avoid unknown "last used" on first scan. - if [[ ! "$final_epoch" =~ ^[0-9]+$ || $final_epoch -le 0 ]]; then - if [[ "$app_mtime" =~ ^[0-9]+$ && $app_mtime -gt 0 ]]; then - final_epoch="$app_mtime" - fi - fi + awk -F'|' \ + -v now="$current_epoch" \ + -v floor="$MOLE_UNINSTALL_EPOCH_FLOOR" \ + -v ttl="$MOLE_UNINSTALL_META_REFRESH_TTL" \ + -v refresh_out="$refresh_file" \ + -v snapshot_out="$cache_snapshot_file" \ + -v apps_out="$temp_file" ' + function isnum(value) { + return value ~ /^[0-9]+$/ + } + function human_size(kb, bytes, scaled) { + if (!isnum(kb) || kb <= 0) { + return "--" + } + bytes = kb * 1024 + if (bytes >= 1000000000) { + scaled = int((bytes * 100 + 500000000) / 1000000000) + return sprintf("%d.%02dGB", int(scaled / 100), scaled % 100) + } + if (bytes >= 1000000) { + scaled = int((bytes * 10 + 500000) / 1000000) + return sprintf("%d.%01dMB", int(scaled / 10), scaled % 10) + } + if (bytes >= 1000) { + return sprintf("%dKB", int((bytes + 500) / 1000)) + } + return sprintf("%dB", bytes) + } + function relative_time(epoch, now_epoch, days_ago, weeks_ago, months_ago, years_ago) { + if (!isnum(epoch) || epoch <= 0 || epoch < floor) { + return "Unknown" + } + days_ago = int((now_epoch - epoch) / 86400) + if (days_ago < 0) { + days_ago = 0 + } + if (days_ago == 0) { + return "Today" + } + if (days_ago == 1) { + return "Yesterday" + } + if (days_ago < 7) { + return days_ago " days ago" + } + if (days_ago < 30) { + weeks_ago = int(days_ago / 7) + return weeks_ago == 1 ? "1 week ago" : weeks_ago " weeks ago" + } + if (days_ago < 365) { + months_ago = int(days_ago / 30) + return months_ago == 1 ? "1 month ago" : months_ago " months ago" + } + years_ago = int(days_ago / 365) + return years_ago == 1 ? "1 year ago" : years_ago " years ago" + } + { + app_path = $1 + display_name = $2 + bundle_id = $3 + app_mtime = $4 + if (NF >= 11) { + inline_size_kb = $5 + cached_mtime = $6 + cached_size_kb = $7 + cached_epoch = $8 + cached_updated_epoch = $9 + cached_bundle_id = $10 + cached_display_name = $11 + } else { + inline_size_kb = 0 + cached_mtime = $5 + cached_size_kb = $6 + cached_epoch = $7 + cached_updated_epoch = $8 + cached_bundle_id = $9 + cached_display_name = $10 + } - local final_last_used - final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") - - local needs_refresh=false - if [[ $cache_match == false ]]; then - needs_refresh=true - elif [[ ! "$cached_size_kb" =~ ^[0-9]+$ || $cached_size_kb -le 0 ]]; then - needs_refresh=true - elif [[ ! "$cached_epoch" =~ ^[0-9]+$ || $cached_epoch -le 0 ]]; then - needs_refresh=true - elif [[ ! "$cached_updated_epoch" =~ ^[0-9]+$ ]]; then - needs_refresh=true - elif [[ -z "$cached_bundle_id" || -z "$cached_display_name" ]]; then - needs_refresh=true - else - local cache_age=$((current_epoch - cached_updated_epoch)) - if [[ $cache_age -gt $MOLE_UNINSTALL_META_REFRESH_TTL ]]; then - needs_refresh=true - fi - fi + cache_match = (cached_mtime != "" && app_mtime != "" && cached_mtime == app_mtime) - if [[ $needs_refresh == true ]]; then - if [[ $inline_metadata_count -lt $MOLE_UNINSTALL_INLINE_METADATA_LIMIT ]]; then - local inline_metadata inline_size_kb inline_epoch inline_updated_epoch - inline_metadata=$(uninstall_collect_inline_metadata "$app_path" "${app_mtime:-0}" "$current_epoch") - IFS='|' read -r inline_size_kb inline_epoch inline_updated_epoch <<< "$inline_metadata" - ((inline_metadata_count++)) + final_epoch = (isnum(cached_epoch) && cached_epoch > 0) ? cached_epoch : 0 + if (isnum(final_epoch) && final_epoch < floor) { + final_epoch = 0 + } + if ((!isnum(final_epoch) || final_epoch <= 0) && isnum(app_mtime) && app_mtime > floor) { + final_epoch = app_mtime + } - if [[ "$inline_size_kb" =~ ^[0-9]+$ && $inline_size_kb -gt 0 ]]; then - final_size_kb="$inline_size_kb" - final_size=$(bytes_to_human "$((inline_size_kb * 1024))") - fi - if [[ "$inline_epoch" =~ ^[0-9]+$ && $inline_epoch -gt 0 ]]; then - final_epoch="$inline_epoch" - final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") - fi - if [[ "$inline_updated_epoch" =~ ^[0-9]+$ && $inline_updated_epoch -gt 0 ]]; then - cached_updated_epoch="$inline_updated_epoch" - fi - fi - printf "%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$bundle_id" "$display_name" >> "$refresh_file" - fi + final_size_kb = (isnum(cached_size_kb) && cached_size_kb > 0) ? cached_size_kb : 0 + if ((!isnum(final_size_kb) || final_size_kb <= 0) && isnum(inline_size_kb) && inline_size_kb > 0) { + final_size_kb = inline_size_kb + } + final_size = human_size(final_size_kb) + final_last_used = relative_time(final_epoch, now) + + needs_refresh = 0 + if (!cache_match) { + needs_refresh = 1 + } else if (!isnum(cached_size_kb) || cached_size_kb <= 0) { + needs_refresh = 1 + } else if (!isnum(cached_epoch) || cached_epoch <= 0) { + needs_refresh = 1 + } else if (!isnum(cached_updated_epoch)) { + needs_refresh = 1 + } else if (cached_bundle_id == "" || cached_display_name == "") { + needs_refresh = 1 + } else if ((now - cached_updated_epoch) > ttl) { + needs_refresh = 1 + } - local persist_updated_epoch=0 - if [[ "$cached_updated_epoch" =~ ^[0-9]+$ && $cached_updated_epoch -gt 0 ]]; then - persist_updated_epoch="$cached_updated_epoch" - fi - printf "%s|%s|%s|%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "${final_size_kb:-0}" "${final_epoch:-0}" "${persist_updated_epoch:-0}" "$bundle_id" "$display_name" >> "$cache_snapshot_file" + if (needs_refresh) { + print app_path "|" app_mtime "|" bundle_id "|" display_name >> refresh_out + } - echo "${final_epoch}|${app_path}|${display_name}|${bundle_id}|${final_size}|${final_last_used}|${final_size_kb}" >> "$temp_file" - done < "$merged_file" + persist_updated_epoch = (isnum(cached_updated_epoch) && cached_updated_epoch > 0) ? cached_updated_epoch : 0 + print app_path "|" app_mtime "|" final_size_kb "|" final_epoch "|" persist_updated_epoch "|" bundle_id "|" display_name >> snapshot_out + print final_epoch "|" app_path "|" display_name "|" bundle_id "|" final_size "|" final_last_used "|" final_size_kb >> apps_out + } + ' "$merged_file" update_scan_status "Updating cache..." "0" "0" if [[ -s "$cache_snapshot_file" ]]; then if uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then - mv "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || { - cp "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true - rm -f "$cache_snapshot_file" - } + uninstall_persist_cache_file "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" fi fi @@ -740,12 +1013,12 @@ scan_applications() { update_scan_status "Sorting application list..." "0" "0" sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { stop_scan_spinner - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap return 1 } - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$cache_snapshot_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true update_scan_status "Finalizing list..." "0" "0" @@ -753,6 +1026,7 @@ scan_applications() { stop_scan_spinner if [[ -f "${temp_file}.sorted" ]]; then + register_temp_file "${temp_file}.sorted" restore_scan_int_trap echo "${temp_file}.sorted" return 0 @@ -762,6 +1036,157 @@ scan_applications() { fi } +# Scan applications and collect information. Orchestrates the four +# phases (discover, partition, resolve, finalize) and owns the shared +# temp files, spinner subprocess, INT trap, and metadata cache lock. +scan_applications() { + local temp_file scan_raw_file merged_file refresh_file cache_snapshot_file discovered_file cached_rows_file uncached_rows_file + temp_file=$(create_temp_file) + scan_raw_file="${temp_file}.scan" + merged_file="${temp_file}.merged" + refresh_file="${temp_file}.refresh" + cache_snapshot_file="${temp_file}.cache" + discovered_file="${temp_file}.discovered" + cached_rows_file="${temp_file}.cached_rows" + uncached_rows_file="${temp_file}.uncached_rows" + local scan_status_file="${temp_file}.scan_status" + : > "$scan_raw_file" + : > "$refresh_file" + : > "$cache_snapshot_file" + : > "$discovered_file" + : > "$cached_rows_file" + : > "$uncached_rows_file" + : > "$scan_status_file" + + ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" + ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" + local cache_source="$MOLE_UNINSTALL_META_CACHE_FILE" + local cache_source_is_temp=false + if [[ ! -r "$cache_source" ]]; then + cache_source=$(create_temp_file) + : > "$cache_source" + cache_source_is_temp=true + fi + + # Local spinner_pid for cleanup + local spinner_pid="" + local spinner_shown_file="${temp_file}.spinner_shown" + local previous_int_trap="" + previous_int_trap=$(trap -p INT || true) + + restore_scan_int_trap() { + if [[ -n "$previous_int_trap" ]]; then + # eval: restore previous trap captured by $(trap -p INT) + eval "$previous_int_trap" + else + trap - INT + fi + } + + # Trap to handle Ctrl+C during scan + # shellcheck disable=SC2329 # Function invoked indirectly via trap + trap_scan_cleanup() { + if [[ -n "$spinner_pid" ]]; then + kill -TERM "$spinner_pid" 2> /dev/null || true + wait "$spinner_pid" 2> /dev/null || true + fi + if [[ -f "$spinner_shown_file" ]]; then + printf "\r\033[K" >&2 + fi + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true + exit 130 + } + trap trap_scan_cleanup INT + + update_scan_status() { + local message="$1" + local completed="${2:-0}" + local total="${3:-0}" + printf "%s|%s|%s\n" "$message" "$completed" "$total" > "$scan_status_file" + } + + start_scan_spinner() { + [[ -n "$spinner_pid" ]] && return 0 + [[ -t 2 || "${MOLE_TEST_FORCE_SCAN_SPINNER:-0}" == "1" ]] || return 0 + ( + # shellcheck disable=SC2329 # Function invoked indirectly via trap + cleanup_spinner() { exit 0; } + trap cleanup_spinner TERM INT EXIT + [[ -f "$scan_status_file" ]] || exit 0 + local spinner_chars="|/-\\" + local i=0 + : > "$spinner_shown_file" + while true; do + local status_line status_message status_completed status_total + status_line=$(cat "$scan_status_file" 2> /dev/null || echo "") + IFS='|' read -r status_message status_completed status_total <<< "$status_line" + [[ -z "$status_message" ]] && status_message="Scanning applications..." + local c="${spinner_chars:$((i % 4)):1}" + if [[ "$status_completed" =~ ^[0-9]+$ && "$status_total" =~ ^[0-9]+$ && $status_total -gt 0 ]]; then + printf "\r\033[K%s %s %d/%d" "$c" "$status_message" "$status_completed" "$status_total" >&2 + else + printf "\r\033[K%s %s" "$c" "$status_message" >&2 + fi + ((i++)) + sleep 0.1 2> /dev/null || sleep 1 + done + ) & + spinner_pid=$! + } + + stop_scan_spinner() { + if [[ -n "$spinner_pid" ]]; then + kill -TERM "$spinner_pid" 2> /dev/null || true + wait "$spinner_pid" 2> /dev/null || true + spinner_pid="" + fi + if [[ -f "$spinner_shown_file" ]]; then + printf "\r\033[K" >&2 + fi + rm -f "$spinner_shown_file" "$scan_status_file" 2> /dev/null || true + } + + update_scan_status "Scanning applications..." "0" "0" + start_scan_spinner + + # Phase 2: discover candidate apps. + _scan_discover_apps + + # Phase 3: partition into warm-cache and cold rows. + local -a app_data_tuples=() + _scan_partition_cache + + # Phase 4: bail out if discovery yielded nothing. + if [[ ${#app_data_tuples[@]} -eq 0 && ! -s "$scan_raw_file" ]]; then + stop_scan_spinner + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap + printf "\r\033[K" >&2 + echo "No applications found to uninstall." >&2 + return 1 + fi + # Phase 5: parallel metadata resolution for cold rows. + _scan_resolve_uncached + + # Phase 6: bail out if Pass 2 produced nothing. + update_scan_status "Building uninstall index..." "0" "0" + + if [[ ! -s "$scan_raw_file" ]]; then + stop_scan_spinner + echo "No applications found to uninstall" >&2 + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap + return 1 + fi + + _scan_dedupe_bundle_ids + + # Phase 7+8: merge cache, persist, sort, return path. + _scan_finalize_index +} + load_applications() { local apps_file="$1" @@ -788,13 +1213,38 @@ load_applications() { return 0 } -# Cleanup: restore cursor and kill keepalive. -cleanup() { - local exit_code="${1:-$?}" +# Keep the scan and selector on one alternate screen so restoring the terminal +# also restores the primary-screen cursor to the command's original row. +start_uninstall_interactive_screen() { + if [[ -t 1 && -t 2 && "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then + enter_alt_screen + export MOLE_ALT_SCREEN_ACTIVE=1 + export MOLE_MANAGED_ALT_SCREEN=1 + printf '\033[2J\033[H' >&2 + fi +} + +stop_uninstall_interactive_screen() { if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE fi + unset MOLE_ALT_SCREEN_ACTIVE MOLE_MANAGED_ALT_SCREEN +} + +# Surface an abort during scan/load/selection instead of returning to the +# prompt as if the run had succeeded. Interactive mode renders on an alternate +# screen, so the reason has to be printed after the screen is restored (#1339). +uninstall_abort() { + local reason="$1" + stop_uninstall_interactive_screen + show_cursor + log_error "Uninstall aborted: $reason" +} + +# Cleanup: restore cursor and kill keepalive. +cleanup() { + local exit_code="${1:-$?}" + stop_uninstall_interactive_screen if [[ -n "${sudo_keepalive_pid:-}" ]]; then kill "$sudo_keepalive_pid" 2> /dev/null || true wait "$sudo_keepalive_pid" 2> /dev/null || true @@ -808,12 +1258,275 @@ cleanup() { trap cleanup EXIT INT TERM +# Match app names from scan data against user-provided search terms. +# Performs case-insensitive substring matching on app display names. +# Returns matched entries from apps_data in selected_apps. +match_apps_by_name() { + local -a search_terms=("$@") + selected_apps=() + local -a matched_indices=() + + # `mo uninstall Tor Browser` arrives as two words. Matching each word + # alone sent "Tor" into a substring hit on WebSTORm while the app the + # user actually named sat in the list (#1365). When the words joined + # with spaces exactly match an installed app's display or directory + # name, that is the query, UNLESS every word already exactly names its + # own installed app: with Foo.app, Bar.app, and "Foo Bar.app" all + # present, `mo uninstall Foo Bar` keeps its original two-app meaning + # rather than silently collapsing into the third. + if [[ ${#search_terms[@]} -gt 1 ]]; then + local every_word_exact=true + local word word_lower word_app word_hit + for word in "${search_terms[@]}"; do + word_lower=$(echo "$word" | tr '[:upper:]' '[:lower:]') + word_hit=false + for word_app in "${apps_data[@]}"; do + IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$word_app" + local word_name_lower word_dir_lower + word_name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + word_dir_lower=$(basename "$app_path" .app | tr '[:upper:]' '[:lower:]') + if [[ "$word_name_lower" == "$word_lower" || "$word_dir_lower" == "$word_lower" ]]; then + word_hit=true + break + fi + done + if [[ "$word_hit" == "false" ]]; then + every_word_exact=false + break + fi + done + if [[ "$every_word_exact" == "false" ]]; then + local joined_lower + joined_lower=$(echo "$*" | tr '[:upper:]' '[:lower:]') + local joined_app + for joined_app in "${apps_data[@]}"; do + IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$joined_app" + local joined_name_lower joined_dir_lower + joined_name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + joined_dir_lower=$(basename "$app_path" .app | tr '[:upper:]' '[:lower:]') + if [[ "$joined_name_lower" == "$joined_lower" || "$joined_dir_lower" == "$joined_lower" ]]; then + selected_apps=("$joined_app") + return 0 + fi + done + fi + fi + + for search_term in "${search_terms[@]}"; do + local search_lower + search_lower=$(echo "$search_term" | tr '[:upper:]' '[:lower:]') + # Escape glob characters to prevent pattern injection + search_lower=${search_lower//\\/\\\\} + search_lower=${search_lower//\*/\\*} + search_lower=${search_lower//\?/\\?} + search_lower=${search_lower//\[/\\[} + local found=false + local idx=0 + for app_data in "${apps_data[@]}"; do + IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$app_data" + local name_lower + name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + # Also try matching against the .app directory base name + local dir_name + dir_name=$(basename "$app_path" .app) + local dir_lower + dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]') + + if [[ "$name_lower" == "$search_lower" || "$dir_lower" == "$search_lower" ]]; then + # Exact match - prefer this + local already=false + local mi + for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do + [[ -z "$mi" ]] && continue + [[ "$mi" == "$idx" ]] && already=true && break + done + if [[ "$already" == "false" ]]; then + selected_apps+=("$app_data") + matched_indices+=("$idx") + fi + found=true + break + fi + idx=$((idx + 1)) + done + + # If no exact match, try substring match + if [[ "$found" == "false" ]]; then + idx=0 + for app_data in "${apps_data[@]}"; do + IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$app_data" + local name_lower + name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + local dir_name + dir_name=$(basename "$app_path" .app) + local dir_lower + dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]') + + if [[ "$name_lower" == *"$search_lower"* || "$dir_lower" == *"$search_lower"* ]]; then + local already=false + local mi + for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do + [[ -z "$mi" ]] && continue + [[ "$mi" == "$idx" ]] && already=true && break + done + if [[ "$already" == "false" ]]; then + selected_apps+=("$app_data") + matched_indices+=("$idx") + fi + found=true + fi + idx=$((idx + 1)) + done + fi + + if [[ "$found" == "false" ]]; then + echo -e "${YELLOW}Warning:${NC} No application found matching '$search_term'" + fi + done +} + +# Escape a value for embedding in a single-line JSON string. Only handles +# the chars that would break a one-line value: backslash, quote, and C0 +# whitespace. Bundle IDs / display names never contain control bytes worth +# preserving in this output. +uninstall_list_json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/ }" + s="${s//$'\r'/ }" + s="${s//$'\n'/ }" + printf '%s' "$s" +} + +# Read-only listing: surface each installed app's display name, bundle id, +# the exact name `mo uninstall` accepts, and human-readable size. Reuses the +# existing scanner so the output stays in lockstep with what the destructive +# path sees. +uninstall_list_apps() { + local apps_file="" + if ! apps_file=$(scan_applications); then + uninstall_abort "could not complete the application scan" + return 1 + fi + if [[ ! -f "$apps_file" ]]; then + uninstall_abort "application scan produced no list" + return 1 + fi + if ! load_applications "$apps_file"; then + rm -f "$apps_file" + uninstall_abort "no applications available for uninstallation" + return 1 + fi + rm -f "$apps_file" + + # Auto-switch to JSON when stdout is piped, matching `mo status`. + local format="text" + if [[ ! -t 1 ]]; then + format="json" + fi + + if [[ "$format" == "json" ]]; then + printf '[' + local first=1 + local app_data + for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data" + local cask="" + if is_homebrew_available; then + cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + fi + local uninstall_name="${cask:-$app_name}" + local source_label="App" + [[ -n "$cask" ]] && source_label="Homebrew" + local size_display + size_display=$(uninstall_normalize_size_display "$size") + if [[ $first -eq 1 ]]; then + first=0 + printf '\n' + else + printf ',\n' + fi + printf ' {"name": "%s", "bundle_id": "%s", "source": "%s", "uninstall_name": "%s", "path": "%s", "size": "%s"}' \ + "$(uninstall_list_json_escape "$app_name")" \ + "$(uninstall_list_json_escape "$bundle_id")" \ + "$source_label" \ + "$(uninstall_list_json_escape "$uninstall_name")" \ + "$(uninstall_list_json_escape "$app_path")" \ + "$(uninstall_list_json_escape "$size_display")" + done + if [[ $first -eq 0 ]]; then + printf '\n' + fi + printf ']\n' + return 0 + fi + + local total=${#apps_data[@]} + if [[ $total -eq 0 ]]; then + echo "No applications found." + return 0 + fi + + printf '\n' + printf '%-36s %-30s %-30s %8s\n' 'NAME' 'BUNDLE ID' 'UNINSTALL NAME' 'SIZE' + printf -- '-%.0s' $(seq 1 108) + printf '\n' + + local app_data + for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data" + local cask="" + if is_homebrew_available; then + cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + fi + local uninstall_name="${cask:-$app_name}" + local size_display + size_display=$(uninstall_normalize_size_display "$size") + + # Truncate by display columns, then adjust printf width for CJK. + # printf counts bytes (LC_ALL=C), but CJK chars are 3 bytes yet only + # 2 display columns wide, so we pad with the extra bytes to land on + # the correct visual column. + local name_trunc name_display_w name_byte_count name_printf_w + name_trunc=$(truncate_by_display_width "$app_name" 34) + name_display_w=$(get_display_width "$name_trunc") + + # Get byte count in C locale for printf + local old_lc="${LC_ALL:-}" + export LC_ALL=C + name_byte_count=${#name_trunc} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + + name_printf_w=$((36 + name_byte_count - name_display_w)) + + printf "%-*s %-30s %-30s %8s\n" \ + "$name_printf_w" "$name_trunc" \ + "${bundle_id:0:28}" \ + "${uninstall_name:0:28}" \ + "$size_display" + done + + printf '\n%d application(s) | Remove with: mo uninstall \n\n' "$total" + return 0 +} + main() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="uninstall" log_operation_session_start "uninstall" - # Global flags + # Default to Trash routing so an accidental uninstall is recoverable. + # The caller can opt back into rm -rf with --permanent. See #723. + export MOLE_DELETE_MODE="${MOLE_DELETE_MODE:-trash}" + + # Parse flags and collect app name arguments + local -a app_name_args=() + local list_mode=0 for arg in "$@"; do case "$arg" in "--help" | "-h") @@ -826,6 +1539,12 @@ main() { "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; + "--permanent") + export MOLE_DELETE_MODE="permanent" + ;; + "--list") + list_mode=1 + ;; "--whitelist") echo "Unknown uninstall option: $arg" echo "Whitelist management is currently supported by: mo clean --whitelist / mo optimize --whitelist" @@ -838,63 +1557,169 @@ main() { exit 1 ;; *) - echo "Unknown uninstall argument: $arg" - echo "Use 'mo uninstall --help' for supported options." - exit 1 + app_name_args+=("$arg") ;; esac done + # --list short-circuits before any destructive code. Read-only path: + # scan, resolve uninstall names, print table or JSON, exit 0. + if [[ $list_mode -eq 1 ]]; then + uninstall_list_apps + return $? + fi + hide_cursor if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified" printf '\n' fi + # Direct uninstall by app name + if [[ ${#app_name_args[@]} -gt 0 ]]; then + local apps_file="" + if ! apps_file=$(scan_applications); then + uninstall_abort "could not complete the application scan" + return 1 + fi + if [[ ! -f "$apps_file" ]]; then + uninstall_abort "application scan produced no list" + return 1 + fi + if ! load_applications "$apps_file"; then + rm -f "$apps_file" + uninstall_abort "no applications available for uninstallation" + return 1 + fi + + match_apps_by_name "${app_name_args[@]}" + rm -f "$apps_file" + + if [[ ${#selected_apps[@]} -eq 0 ]]; then + show_cursor + echo "No matching applications found." + return 1 + fi + + show_cursor + clear_screen + local selection_count=${#selected_apps[@]} + echo -e "${BLUE}${ICON_CONFIRM}${NC} Matched ${selection_count} app(s):" + local index=1 + for selected_app in "${selected_apps[@]}"; do + IFS='|' read -r _ app_path app_name _ size last_used _ <<< "$selected_app" + local size_display + size_display=$(uninstall_normalize_size_display "$size") + local last_display + last_display=$(uninstall_normalize_last_used_display "$last_used") + printf "%d. %s %s | Last: %s\n" "$index" "$app_name" "$size_display" "$last_display" + ((index++)) + done + + printf '\n' + printf "Proceed with uninstallation? [y/N] " + local confirm + read -r confirm + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo "Aborted." + return 0 + fi + + batch_uninstall_applications + return 0 + fi + local first_scan=true + local cached_apps_file="" + local cached_inventory_fingerprint="" + unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE while true; do - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN + unset MOLE_INLINE_LOADING + + # Keep scanning and selection on one alternate screen. Entering the + # selector only after the scan leaves the primary-screen cursor below + # the scan progress; restoring it on cancel then creates a large blank + # gap before the next shell prompt (#1194). + start_uninstall_interactive_screen if [[ $first_scan == false ]]; then - echo -e "${GRAY}Refreshing application list...${NC}" >&2 + echo -e "${GRAY}Checking application list...${NC}" >&2 fi first_scan=false local apps_file="" - if ! apps_file=$(scan_applications); then - return 1 + local reused_app_cache=false + if [[ -n "$cached_apps_file" && -f "$cached_apps_file" && -n "$cached_inventory_fingerprint" ]]; then + local current_inventory_fingerprint + current_inventory_fingerprint=$(uninstall_app_inventory_fingerprint 2> /dev/null || echo "") + if uninstall_inventory_can_reuse_cached_apps "$cached_inventory_fingerprint" "$current_inventory_fingerprint"; then + apps_file="$cached_apps_file" + reused_app_cache=true + cached_inventory_fingerprint="$current_inventory_fingerprint" + fi fi - if [[ ! -f "$apps_file" ]]; then - return 1 + if [[ "$reused_app_cache" != "true" ]]; then + if [[ -n "$cached_apps_file" && -f "$cached_apps_file" ]]; then + rm -f "$cached_apps_file" 2> /dev/null || true + fi + + local scan_abort_reason="" + if ! apps_file=$(scan_applications); then + scan_abort_reason="could not complete the application scan" + elif [[ ! -f "$apps_file" ]]; then + scan_abort_reason="application scan produced no list" + fi + if [[ -n "$scan_abort_reason" ]]; then + uninstall_abort "$scan_abort_reason" + rm -f "$apps_file" + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" + return 1 + fi + + cached_apps_file="$apps_file" + cached_inventory_fingerprint=$(uninstall_app_inventory_fingerprint 2> /dev/null || echo "") fi if ! load_applications "$apps_file"; then rm -f "$apps_file" + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" + uninstall_abort "no applications available for uninstallation" return 1 fi + # Keystrokes typed during the scan/load phase must not leak into the + # selector. A queued Enter would confirm whichever app is highlighted + # first and drop the user straight into the destructive path. See #726. + drain_pending_input 0.2 + set +e select_apps_for_uninstall local exit_code=$? set -e if [[ $exit_code -ne 0 ]]; then - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 rm -f "$apps_file" - - return 0 + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" + if [[ "${_MOLE_MENU_USER_QUIT:-0}" == "1" ]]; then + # A deliberate q is a cancel, not a failure: leave quietly + # with success, matching mole's other cancel flows. Only a + # selector that broke gets the visible abort below. + stop_uninstall_interactive_screen + show_cursor + return 0 + fi + uninstall_abort "application selection did not complete" + return 1 fi + stop_uninstall_interactive_screen show_cursor clear_screen printf '\033[2J\033[H' >&2 local selection_count=${#selected_apps[@]} if [[ $selection_count -eq 0 ]]; then echo "No apps selected" - rm -f "$apps_file" continue fi echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" @@ -962,9 +1787,19 @@ main() { IFS='|' read -r name_cell size_cell last_cell <<< "$row" local name_display_width name_display_width=$(get_display_width "$name_cell") - local name_char_count=${#name_cell} + + # Get byte count for printf width calculation + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local name_byte_count=${#name_cell} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding_needed=$((max_name_display_width - name_display_width)) - local printf_name_width=$((name_char_count + padding_needed)) + local printf_name_width=$((name_byte_count + padding_needed)) printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" ((index++)) @@ -972,24 +1807,30 @@ main() { batch_uninstall_applications - rm -f "$apps_file" - - local prompt_timeout="${MOLE_UNINSTALL_RETURN_PROMPT_TIMEOUT_SEC:-3}" - if [[ ! "$prompt_timeout" =~ ^[0-9]+$ ]] || [[ "$prompt_timeout" -lt 1 ]]; then - prompt_timeout=3 + # A nested command may have returned the controlling terminal to the + # parent shell. Reading while Mole is no longer the foreground process + # group would suspend the completed uninstall with SIGTTIN. The removal + # is already finished, so exit cleanly instead of touching terminal input. + if ! mole_tty_is_foreground; then + show_cursor + return 0 fi - echo -e "${GRAY}Press Enter to return to the app list, press any other key or wait ${prompt_timeout}s to exit.${NC}" - local key - local read_ok=false - if IFS= read -r -s -n1 -t "$prompt_timeout" key; then - read_ok=true - else - key="" - fi + local _countdown=5 + local _key="" + local _pressed=false + while [[ $_countdown -gt 0 ]]; do + printf "\r${GRAY}Press Enter to return to the app list, press q to exit (%d)${NC} " "$_countdown" + if IFS= read -r -s -n1 -t 1 _key; then + _pressed=true + break + fi + ((_countdown--)) + done + printf "\n" drain_pending_input - if [[ "$read_ok" == "true" && -z "$key" ]]; then + if [[ "$_pressed" == "true" && -z "$_key" ]]; then : else show_cursor @@ -999,4 +1840,7 @@ main() { done } -main "$@" +# Run only when executed; sourcing loads definitions for tests. Kept on one +# line because test harnesses slice this file with sed/awk anchored on the +# `main "$@"` sentinel, and a multi-line guard leaves them an unclosed `if`. +[[ "${BASH_SOURCE[0]}" != "$0" ]] || main "$@" diff --git a/Resources/mole/cmd/analyze/analyze_filter_test.go b/Resources/mole/cmd/analyze/analyze_filter_test.go new file mode 100644 index 0000000..6546bb6 --- /dev/null +++ b/Resources/mole/cmd/analyze/analyze_filter_test.go @@ -0,0 +1,350 @@ +//go:build darwin + +package main + +import ( + "slices" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func topFilesFixture() model { + files := []fileEntry{ + {Name: "alpha.mp4", Path: "/tmp/p/alpha.mp4", Size: 300}, + {Name: "photo.jpg", Path: "/tmp/p/photo.jpg", Size: 200}, + {Name: "beta.mp4", Path: "/tmp/p/beta.mp4", Size: 100}, + } + cloned := make([]fileEntry, len(files)) + copy(cloned, files) + return model{ + path: "/tmp/p", + showLargeFiles: true, + largeFilesAll: files, + largeFiles: cloned, + largeMultiSelected: map[string]bool{}, + height: 40, + width: 120, + } +} + +func filterKey(t *testing.T, m model, msg tea.KeyMsg) (model, tea.Cmd) { + t.Helper() + updated, cmd := m.updateKey(msg) + got, ok := updated.(model) + if !ok { + t.Fatalf("expected model, got %T", updated) + } + return got, cmd +} + +func filterRune(t *testing.T, m model, r rune) (model, tea.Cmd) { + t.Helper() + return filterKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) +} + +func filterType(t *testing.T, m model, s string) model { + t.Helper() + for _, r := range s { + m, _ = filterRune(t, m, r) + } + return m +} + +func TestLargeFilterNarrowsApplyAndClear(t *testing.T) { + m := topFilesFixture() + + m, _ = filterRune(t, m, '/') + if !m.largeFiltering { + t.Fatalf("expected to enter filter input mode") + } + + m = filterType(t, m, "mp4") + if len(m.largeFiles) != 2 { + t.Fatalf("want 2 matches for mp4, got %d", len(m.largeFiles)) + } + + // Enter applies the filter and returns to navigation, keeping the subset. + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.largeFiltering { + t.Fatalf("Enter should exit input mode") + } + if len(m.largeFiles) != 2 { + t.Fatalf("filter should persist after Enter, got %d", len(m.largeFiles)) + } + + // Esc clears the filter and restores the full list. + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.largeFilter != "" { + t.Fatalf("Esc should clear the query, got %q", m.largeFilter) + } + if len(m.largeFiles) != 3 { + t.Fatalf("want full list of 3 after clear, got %d", len(m.largeFiles)) + } +} + +func TestLargeFilterSwallowsNavigationKeys(t *testing.T) { + m := topFilesFixture() + m, _ = filterRune(t, m, '/') + + // 'q' would normally quit; while filtering it must edit the query instead. + m, cmd := filterRune(t, m, 'q') + if cmd != nil { + t.Fatalf("q while filtering must not emit a command (no quit)") + } + if m.largeFilter != "q" { + t.Fatalf("q should append to the query, got %q", m.largeFilter) + } +} + +func TestLargeFilterBackspaceEditsQuery(t *testing.T) { + m := topFilesFixture() + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "mp") + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if m.largeFilter != "m" { + t.Fatalf("backspace should trim the query to 'm', got %q", m.largeFilter) + } +} + +func TestLargeFilterClearsMultiSelectOnQueryChange(t *testing.T) { + m := topFilesFixture() + m.largeMultiSelected = map[string]bool{"/tmp/p/photo.jpg": true} + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "a") + + if len(m.largeMultiSelected) != 0 { + t.Fatalf("changing the query should clear multi-selection, got %d", len(m.largeMultiSelected)) + } +} + +func TestLargeFilterClampsSelection(t *testing.T) { + m := topFilesFixture() + m.largeSelected = 2 // beta.mp4 in the full list + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "photo") // single match, full index 1 + + if len(m.largeFiles) != 1 { + t.Fatalf("want 1 match for photo, got %d", len(m.largeFiles)) + } + if m.largeSelected != 0 { + t.Fatalf("selection should clamp into the visible range, got %d", m.largeSelected) + } +} + +func TestLargeFilterDeleteTargetsVisibleMatch(t *testing.T) { + m := topFilesFixture() + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "beta") // single visible match: beta.mp4 (hidden in full list at index 2) + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + // backspace maps to the delete action once we are out of input mode. + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if !m.deleteConfirm { + t.Fatalf("expected delete confirmation to open") + } + if m.deleteTarget == nil || m.deleteTarget.Path != "/tmp/p/beta.mp4" { + t.Fatalf("delete must target the visible match, got %+v", m.deleteTarget) + } +} + +func TestLargeFilterIgnoredOutsideTopView(t *testing.T) { + m := topFilesFixture() + m.showLargeFiles = false + m.entries = []dirEntry{{Name: "x", Path: "/tmp/p/x", Size: 1}} + + m, _ = filterRune(t, m, '/') + if m.largeFiltering { + t.Fatalf("'/' should do nothing outside the Top-files view") + } +} + +func treeFixture() model { + entries := []dirEntry{ + {Name: "apps", Path: "/tmp/p/apps", Size: 300, IsDir: true}, + {Name: "logs", Path: "/tmp/p/logs", Size: 200, IsDir: true}, + {Name: "node_modules", Path: "/tmp/p/node_modules", Size: 100, IsDir: true}, + } + var filesScanned, dirsScanned, bytesScanned int64 + return model{ + path: "/tmp/p", + entriesAll: entries, + entries: slices.Clone(entries), + multiSelected: map[string]bool{}, + cache: map[string]historyEntry{}, + filesScanned: &filesScanned, + dirsScanned: &dirsScanned, + bytesScanned: &bytesScanned, + height: 40, + width: 120, + } +} + +func TestEntryFilterNarrowsApplyAndClear(t *testing.T) { + m := treeFixture() + + m, _ = filterRune(t, m, '/') + if !m.entryFiltering { + t.Fatalf("expected to enter directory filter input mode") + } + + m = filterType(t, m, "s") // apps, logs, node_modules all end in 's' + if len(m.entries) != 3 { + t.Fatalf("want 3 matches for s, got %d", len(m.entries)) + } + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + m = filterType(t, m, "ode") // only node_modules contains "ode" + if len(m.entries) != 1 { + t.Fatalf("want 1 match for ode, got %d", len(m.entries)) + } + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.entryFilter != "" { + t.Fatalf("Esc should clear the query, got %q", m.entryFilter) + } + if len(m.entries) != 3 { + t.Fatalf("want full list of 3 after clear, got %d", len(m.entries)) + } +} + +func TestEntryFilterSwallowsNavigationKeys(t *testing.T) { + m := treeFixture() + m, _ = filterRune(t, m, '/') + + m, cmd := filterRune(t, m, 'q') + if cmd != nil { + t.Fatalf("q while filtering must not emit a command (no quit)") + } + if m.entryFilter != "q" { + t.Fatalf("q should append to the query, got %q", m.entryFilter) + } +} + +func TestEntryFilterClearsMultiSelectOnQueryChange(t *testing.T) { + m := treeFixture() + m.multiSelected = map[string]bool{"/tmp/p/logs": true} + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "a") + + if len(m.multiSelected) != 0 { + t.Fatalf("changing the query should clear multi-selection, got %d", len(m.multiSelected)) + } +} + +func TestEntryFilterIgnoredInOverview(t *testing.T) { + m := treeFixture() + m.isOverview = true + m.path = "/" + + m, _ = filterRune(t, m, '/') + if m.entryFiltering { + t.Fatalf("'/' should do nothing in overview mode") + } +} + +// The load-bearing case: filter the tree, drill into a match, then go back. +// The parent must be restored in full (not the one-row filtered view) with the +// entered directory still highlighted. +func TestEntryFilterDrillInPreservesFullParentOnBack(t *testing.T) { + m := treeFixture() + m.cache["/tmp/p/node_modules"] = historyEntry{ + Path: "/tmp/p/node_modules", + Entries: []dirEntry{{Name: "pkg", Path: "/tmp/p/node_modules/pkg", Size: 50, IsDir: true}}, + TotalSize: 50, + } + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "node") // single match: node_modules (full index 2) + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if len(m.entries) != 1 { + t.Fatalf("want 1 match before drilling in, got %d", len(m.entries)) + } + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // drill into the match + if m.path != "/tmp/p/node_modules" { + t.Fatalf("expected to drill into node_modules, got %s", m.path) + } + if m.entryFilter != "" { + t.Fatalf("filter must be cleared after drilling in, got %q", m.entryFilter) + } + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEsc}) // go back to parent + if m.path != "/tmp/p" { + t.Fatalf("expected to return to /tmp/p, got %s", m.path) + } + if len(m.entries) != 3 { + t.Fatalf("parent must be restored with all 3 entries, got %d", len(m.entries)) + } + if m.selected < 0 || m.selected >= len(m.entries) || m.entries[m.selected].Path != "/tmp/p/node_modules" { + t.Fatalf("entered entry should stay highlighted, selected=%d", m.selected) + } +} + +// Deleting with no active filter must not corrupt the backing lists. Before the +// rebuild-from-backing fix, removing from both a list and its aliased view +// shifted the shared array twice, leaving a duplicated, stale entry behind. +func TestRemovePathPreservesBackingLists(t *testing.T) { + m := treeFixture() // entriesAll aliases entries: [apps, logs, node_modules] + m.totalSize = 600 + + m.removePathFromView("/tmp/p/logs") + + if len(m.entriesAll) != 2 { + t.Fatalf("entriesAll should drop to 2, got %d", len(m.entriesAll)) + } + if len(m.entries) != 2 { + t.Fatalf("entries should drop to 2, got %d", len(m.entries)) + } + seen := map[string]int{} + for _, e := range m.entriesAll { + seen[e.Path]++ + } + if seen["/tmp/p/logs"] != 0 { + t.Fatalf("deleted path still present in entriesAll") + } + for p, c := range seen { + if c != 1 { + t.Fatalf("entry %s duplicated %d times in entriesAll", p, c) + } + } +} + +func TestEntryFilterViewShowsHintAndQuery(t *testing.T) { + m := treeFixture() + if hint := m.View(); !strings.Contains(hint, "/ Filter") { + t.Fatalf("expected '/ Filter' footer hint, got:\n%s", hint) + } + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "node") + view := m.View() + if !strings.Contains(view, "Filter:") { + t.Fatalf("expected active 'Filter:' line, got:\n%s", view) + } + if !strings.Contains(view, "No matches") && !strings.Contains(view, "node_modules") { + t.Fatalf("expected the single match rendered, got:\n%s", view) + } +} + +func TestLargeFilterViewShowsHintAndQuery(t *testing.T) { + m := topFilesFixture() + if hint := m.View(); !strings.Contains(hint, "/ Filter") { + t.Fatalf("expected '/ Filter' footer hint, got:\n%s", hint) + } + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "mp4") + view := m.View() + if !strings.Contains(view, "Filter:") { + t.Fatalf("expected active 'Filter:' line, got:\n%s", view) + } + if !strings.Contains(view, "matches") { + t.Fatalf("expected match count in filter line, got:\n%s", view) + } +} diff --git a/Resources/mole/cmd/analyze/analyze_test.go b/Resources/mole/cmd/analyze/analyze_test.go index 7a2fecd..5387900 100644 --- a/Resources/mole/cmd/analyze/analyze_test.go +++ b/Resources/mole/cmd/analyze/analyze_test.go @@ -1,220 +1,2438 @@ +//go:build darwin + package main -import ( - "encoding/gob" - "fmt" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) +import ( + "context" + "encoding/gob" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func resetOverviewSnapshotForTest() { + overviewSnapshotMu.Lock() + overviewSnapshotCache = nil + overviewSnapshotLoaded = false + overviewSnapshotMu.Unlock() +} + +func runScanResultCmd(t *testing.T, cmd tea.Cmd) scanResultMsg { + t.Helper() + + msg := cmd() + if scanMsg, ok := scanResultMsgFromMsg(t, msg); ok { + return scanMsg + } + t.Fatalf("expected scanResultMsg or live scan result, got %T", msg) + return scanResultMsg{} +} + +func scanResultMsgFromMsg(t *testing.T, msg tea.Msg) (scanResultMsg, bool) { + t.Helper() + + switch typed := msg.(type) { + case scanResultMsg: + return typed, true + case liveScanStartMsg: + return drainLiveScanToResultMsg(t, typed), true + case tea.BatchMsg: + for _, batchCmd := range typed { + if batchCmd == nil { + continue + } + if scanMsg, ok := scanResultMsgFromMsg(t, batchCmd()); ok { + return scanMsg, true + } + } + return scanResultMsg{}, false + default: + return scanResultMsg{}, false + } +} + +func drainLiveScanToResultMsg(t *testing.T, start liveScanStartMsg) scanResultMsg { + t.Helper() + if start.err != nil { + return scanResultMsg{path: start.path, err: start.err} + } + deadline := time.After(5 * time.Second) + for { + select { + case event, ok := <-start.events: + if !ok { + t.Fatalf("live scan event channel closed without completion") + } + switch event.kind { + case liveScanComplete: + return scanResultMsg{path: start.path, result: event.result} + case liveScanFailed: + return scanResultMsg{path: start.path, err: event.err} + case liveScanCanceled: + return scanResultMsg{path: start.path, err: event.err} + } + case <-deadline: + if start.cancel != nil { + start.cancel() + } + t.Fatalf("timed out waiting for live scan completion") + } + } +} + +func cancelAndDrainLiveScan(start liveScanStartMsg) { + if start.cancel != nil { + start.cancel() + } + for range start.events { + } +} + +func rowContaining(view, needle string) string { + for line := range strings.SplitSeq(view, "\n") { + if strings.Contains(line, needle) { + return line + } + } + return "" +} + +func progressFillCount(row string) int { + return strings.Count(row, "█") + strings.Count(row, "▓") + strings.Count(row, "▒") +} + +func TestScanPathConcurrentBasic(t *testing.T) { + root := t.TempDir() + + rootFile := filepath.Join(root, "root.txt") + if err := os.WriteFile(rootFile, []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root file: %v", err) + } + + nested := filepath.Join(root, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("create nested dir: %v", err) + } + + fileOne := filepath.Join(nested, "a.bin") + if err := os.WriteFile(fileOne, []byte("alpha"), 0o644); err != nil { + t.Fatalf("write file one: %v", err) + } + fileTwo := filepath.Join(nested, "b.bin") + if err := os.WriteFile(fileTwo, []byte(strings.Repeat("b", 32)), 0o644); err != nil { + t.Fatalf("write file two: %v", err) + } + + linkPath := filepath.Join(root, "link-to-a") + if err := os.Symlink(fileOne, linkPath); err != nil { + t.Fatalf("create symlink: %v", err) + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + if err != nil { + t.Fatalf("scanPathConcurrent returned error: %v", err) + } + + linkInfo, err := os.Lstat(linkPath) + if err != nil { + t.Fatalf("stat symlink: %v", err) + } + + expectedDirSize := int64(len("alpha") + len(strings.Repeat("b", 32))) + expectedRootFileSize := int64(len("root-data")) + expectedLinkSize := getActualFileSize(linkPath, linkInfo) + expectedTotal := expectedDirSize + expectedRootFileSize + expectedLinkSize + + if result.TotalSize != expectedTotal { + t.Fatalf("expected total size %d, got %d", expectedTotal, result.TotalSize) + } + + if got := atomic.LoadInt64(&filesScanned); got != 3 { + t.Fatalf("expected 3 files scanned, got %d", got) + } + if dirs := atomic.LoadInt64(&dirsScanned); dirs == 0 { + t.Fatalf("expected directory scan count to increase") + } + if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 { + t.Fatalf("expected byte counter to increase") + } + foundSymlink := false + for _, entry := range result.Entries { + if strings.HasSuffix(entry.Name, " →") { + foundSymlink = true + if entry.IsDir { + t.Fatalf("symlink entry should not be marked as directory") + } + } + } + if !foundSymlink { + t.Fatalf("expected symlink entry to be present in scan result") + } +} + +// TestScanPathConcurrentDedupsHardlinks guards #906: a file with multiple +// hardlinks (e.g. Final Cut Pro managed media) must be counted once, the way +// `du` does, instead of once per link. +func TestScanPathConcurrentDedupsHardlinks(t *testing.T) { + root := t.TempDir() + + nested := filepath.Join(root, "nested") + other := filepath.Join(root, "other") + for _, d := range []string{nested, other} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + + original := filepath.Join(nested, "media.bin") + if err := os.WriteFile(original, []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write original: %v", err) + } + // Two more hardlinks to the same inode, one in this dir and one in a + // sibling dir, so the shared scan-wide dedup set is exercised. + for _, link := range []string{ + filepath.Join(nested, "media-copy.bin"), + filepath.Join(other, "media-link.bin"), + } { + if err := os.Link(original, link); err != nil { + t.Fatalf("hardlink %s: %v", link, err) + } + } + // An unrelated plain file that must still be counted in full. + plain := filepath.Join(other, "plain.bin") + if err := os.WriteFile(plain, []byte("plaindata"), 0o644); err != nil { + t.Fatalf("write plain: %v", err) + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + if err != nil { + t.Fatalf("scanPathConcurrent returned error: %v", err) + } + + mediaInfo, err := os.Lstat(original) + if err != nil { + t.Fatalf("stat original: %v", err) + } + plainInfo, err := os.Lstat(plain) + if err != nil { + t.Fatalf("stat plain: %v", err) + } + want := getActualFileSize(original, mediaInfo) + getActualFileSize(plain, plainInfo) + if result.TotalSize != want { + t.Fatalf("expected hardlinked media counted once (total %d), got %d", want, result.TotalSize) + } + if !result.dedupedHardlink { + t.Fatalf("expected dedupedHardlink flag to be set when a hardlink is deduped") + } +} + +func TestPerformScanForJSONCountsTopLevelFiles(t *testing.T) { + root := t.TempDir() + + rootFile := filepath.Join(root, "root.txt") + if err := os.WriteFile(rootFile, []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root file: %v", err) + } + + nested := filepath.Join(root, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("create nested dir: %v", err) + } + + nestedFile := filepath.Join(nested, "nested.txt") + if err := os.WriteFile(nestedFile, []byte("nested-data"), 0o644); err != nil { + t.Fatalf("write nested file: %v", err) + } + + result := performScanForJSON(root, false) + + if result.TotalFiles != 2 { + t.Fatalf("expected 2 files in JSON output, got %d", result.TotalFiles) + } +} + +func TestDeletePathWithProgress(t *testing.T) { + skipIfFinderUnavailable(t) + + parent := t.TempDir() + target := filepath.Join(parent, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + files := []string{ + filepath.Join(target, "one.txt"), + filepath.Join(target, "two.txt"), + } + for _, f := range files { + if err := os.WriteFile(f, []byte("content"), 0o644); err != nil { + t.Fatalf("write %s: %v", f, err) + } + } + + var counter int64 + count, err := trashPathWithProgress(target, &counter) + if err != nil { + t.Fatalf("trashPathWithProgress returned error: %v", err) + } + if count != 1 { + t.Fatalf("expected one path-level Trash operation, got %d", count) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("expected target to be moved to Trash, stat err=%v", err) + } +} + +func TestOverviewStoreAndLoad(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + resetOverviewSnapshotForTest() + t.Cleanup(resetOverviewSnapshotForTest) + + path := filepath.Join(home, "project") + want := int64(123456) + + if err := storeOverviewSize(path, want); err != nil { + t.Fatalf("storeOverviewSize: %v", err) + } + + got, err := loadStoredOverviewSize(path) + if err != nil { + t.Fatalf("loadStoredOverviewSize: %v", err) + } + if got != want { + t.Fatalf("snapshot mismatch: want %d, got %d", want, got) + } + + // Reload from disk and ensure value persists. + resetOverviewSnapshotForTest() + got, err = loadStoredOverviewSize(path) + if err != nil { + t.Fatalf("loadStoredOverviewSize after reset: %v", err) + } + if got != want { + t.Fatalf("snapshot mismatch after reset: want %d, got %d", want, got) + } +} + +func TestUpdateKeyEscGoesBackFromDirectoryView(t *testing.T) { + m := model{ + path: "/tmp/child", + history: []historyEntry{ + { + Path: "/tmp", + Entries: []dirEntry{{Name: "child", Path: "/tmp/child", Size: 1, IsDir: true}}, + TotalSize: 1, + Selected: 0, + EntryOffset: 0, + }, + }, + entries: []dirEntry{{Name: "file.txt", Path: "/tmp/child/file.txt", Size: 1}}, + } + + updated, cmd := m.updateKey(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil { + t.Fatalf("expected no command when returning from cached history, got %v", cmd) + } + + got, ok := updated.(model) + if !ok { + t.Fatalf("expected model, got %T", updated) + } + if got.path != "/tmp" { + t.Fatalf("expected path /tmp after Esc, got %s", got.path) + } + if got.status == "" { + t.Fatalf("expected status to be updated after Esc navigation") + } +} + +func TestUpdateKeyCtrlCQuits(t *testing.T) { + m := model{} + + _, cmd := m.updateKey(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatalf("expected quit command for Ctrl+C") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg from quit command") + } +} + +func TestViewShowsEscBackAndCtrlCQuitHints(t *testing.T) { + m := model{ + path: "/tmp/project", + history: []historyEntry{{Path: "/tmp"}}, + entries: []dirEntry{{Name: "cache", Path: "/tmp/project/cache", Size: 1, IsDir: true}}, + largeFiles: []fileEntry{{Name: "large.bin", Path: "/tmp/project/large.bin", Size: 1024}}, + totalSize: 1024, + } + + view := m.View() + if !strings.Contains(view, "Esc Back") { + t.Fatalf("expected Esc Back hint in view, got:\n%s", view) + } + if !strings.Contains(view, "Ctrl+C Quit") { + t.Fatalf("expected Ctrl+C Quit hint in view, got:\n%s", view) + } +} + +func TestOverviewPendingSizeUsesScanningSpinner(t *testing.T) { + // A pending overview row reuses the list view's animated scanning idiom + // instead of a static text placeholder: "pending.." broke the numeric + // column rhythm, and a static "--" read as stuck. The spinner string is + // exactly 10 display columns, flush with the right-aligned sizes. + m := model{ + isOverview: true, + path: "/", + entries: []dirEntry{ + {Name: "Applications", Path: "/Applications", Size: 16 << 30, IsDir: true}, + {Name: "iOS Backups", Path: "/tmp/backups", Size: -1, IsDir: true}, + }, + totalSize: 16 << 30, + } + + view := m.View() + if strings.Contains(view, "pending") { + t.Fatalf("pending rows must not render a text placeholder, got:\n%s", view) + } + if !strings.Contains(view, fmt.Sprintf("%s scanning", spinnerFrames[0])) { + t.Fatalf("expected animated scanning placeholder for pending row, got:\n%s", view) + } +} + +func TestViewKeepsCachedEntriesWhileRefreshing(t *testing.T) { + m := model{ + path: "/tmp/project/child", + history: []historyEntry{{Path: "/tmp/project"}}, + entries: []dirEntry{{Name: "warmed-child", Path: "/tmp/project/child/warmed-child", Size: 100, IsDir: true}}, + totalSize: 100, + scanning: true, + viewNeedsRefresh: true, + } + + view := m.View() + if !strings.Contains(view, "warmed-child") { + t.Fatalf("expected cached entry to render during refresh, got:\n%s", view) + } + if !strings.Contains(view, "Showing cached results while refreshing") { + t.Fatalf("expected refreshing hint during cached refresh, got:\n%s", view) + } +} + +func TestViewBlanksToScanOnlyWithoutWarmCache(t *testing.T) { + // Right after entering an uncached child, m.entries still holds the parent's + // stale entries while viewNeedsRefresh is false. The view must not paint + // those stale rows under the new path; it stays scan-only until results land. + m := model{ + path: "/tmp/project/child", + history: []historyEntry{{Path: "/tmp/project"}}, + entries: []dirEntry{{Name: "stale-parent-row", Path: "/tmp/project/stale-parent-row", Size: 100, IsDir: true}}, + totalSize: 100, + scanning: true, + viewNeedsRefresh: false, + } + + view := m.View() + if strings.Contains(view, "stale-parent-row") { + t.Fatalf("expected scan-only view to hide stale entries, got:\n%s", view) + } + if strings.Contains(view, "Showing cached results while refreshing") { + t.Fatalf("did not expect cached-refresh hint without a warm cache, got:\n%s", view) + } + if !strings.Contains(view, "Scanning") { + t.Fatalf("expected scan-only view to show scanning progress, got:\n%s", view) + } +} + +func TestOverviewViewShowsFreeSpaceLabel(t *testing.T) { + m := model{ + path: "/", + isOverview: true, + diskFree: 123_400_000, + entries: []dirEntry{{Name: "Home", Path: "/tmp/home", Size: 1, IsDir: true}}, + } + + view := m.View() + want := fmt.Sprintf("(%s free)", humanizeBytes(m.diskFree)) + if !strings.Contains(view, want) { + t.Fatalf("expected free-space label %q in overview view, got:\n%s", want, view) + } +} + +func TestOverviewViewOmitsFreeSpaceLabelWhenUnknown(t *testing.T) { + m := model{ + path: "/", + isOverview: true, + diskFree: 0, + entries: []dirEntry{{Name: "Home", Path: "/tmp/home", Size: 1, IsDir: true}}, + } + + view := m.View() + if strings.Contains(view, "free)") { + t.Fatalf("expected overview view to omit free-space label when unavailable, got:\n%s", view) + } +} + +func TestOverviewViewUsesTextOnlyLabels(t *testing.T) { + m := model{ + path: "/", + isOverview: true, + entries: []dirEntry{ + {Name: "Home", Path: "/tmp/home", Size: 80, IsDir: true}, + {Name: "iOS Backups", Path: "/tmp/backups", Size: 20, IsDir: true}, + }, + totalSize: 100, + } + + view := m.View() + for _, label := range []string{"Home", "iOS Backups"} { + if !strings.Contains(view, label) { + t.Fatalf("expected overview label %q, got:\n%s", label, view) + } + } + for _, icon := range []string{"📁", "👀"} { + if strings.Contains(view, icon) { + t.Fatalf("overview should not render emoji icon %q, got:\n%s", icon, view) + } + } +} + +func TestDirectoryViewKeepsLowPercentRowsAligned(t *testing.T) { + m := model{ + path: "/tmp/project", + width: 120, + height: 20, + selected: -1, + totalSize: 100_000, + entries: []dirEntry{ + {Name: "large", Path: "/tmp/project/large", Size: 47_000, IsDir: true}, + {Name: "tiny", Path: "/tmp/project/tiny", Size: 46, IsDir: true}, + }, + } + + stripColors := strings.NewReplacer( + colorPurple, "", + colorPurpleBold, "", + colorGray, "", + colorRed, "", + colorYellow, "", + colorGreen, "", + colorBlue, "", + colorCyan, "", + colorReset, "", + colorBold, "", + ) + largeRow := stripColors.Replace(rowContaining(m.View(), "large")) + tinyRow := stripColors.Replace(rowContaining(m.View(), "tiny")) + if !strings.Contains(tinyRow, "< 0.1%") { + t.Fatalf("expected tiny row to show < 0.1%%, got:\n%s", tinyRow) + } + if strings.Contains(m.View(), "░") { + t.Fatalf("directory view should not render gray progress tracks:\n%s", m.View()) + } + + largePrefix, _, largeHasDivider := strings.Cut(largeRow, " | ") + tinyPrefix, _, tinyHasDivider := strings.Cut(tinyRow, " | ") + if !largeHasDivider || !tinyHasDivider { + t.Fatalf("missing percent divider\nlarge: %q\ntiny: %q", largeRow, tinyRow) + } + largeDividerColumn := displayWidth(largePrefix) + tinyDividerColumn := displayWidth(tinyPrefix) + if largeDividerColumn != tinyDividerColumn { + t.Fatalf("percent divider columns differ: large=%d tiny=%d\nlarge: %q\ntiny: %q", + largeDividerColumn, tinyDividerColumn, largeRow, tinyRow) + } + if largeWidth, tinyWidth := displayWidth(largeRow), displayWidth(tinyRow); largeWidth != tinyWidth { + t.Fatalf("row widths differ: large=%d tiny=%d\nlarge: %q\ntiny: %q", + largeWidth, tinyWidth, largeRow, tinyRow) + } +} + +func TestCacheSaveLoadRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "cache-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target dir: %v", err) + } + + result := scanResult{ + Entries: []dirEntry{ + {Name: "alpha", Path: filepath.Join(target, "alpha"), Size: 10, IsDir: true}, + }, + LargeFiles: []fileEntry{ + {Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2048}, + }, + TotalSize: 42, + } + + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cache, err := loadCacheFromDisk(target) + if err != nil { + t.Fatalf("loadCacheFromDisk: %v", err) + } + if cache.TotalSize != result.TotalSize { + t.Fatalf("total size mismatch: want %d, got %d", result.TotalSize, cache.TotalSize) + } + if len(cache.Entries) != len(result.Entries) { + t.Fatalf("entry count mismatch: want %d, got %d", len(result.Entries), len(cache.Entries)) + } + if len(cache.LargeFiles) != len(result.LargeFiles) { + t.Fatalf("large file count mismatch: want %d, got %d", len(result.LargeFiles), len(cache.LargeFiles)) + } +} + +func TestPruneAnalyzerCacheDirRemovesOnlyExpiredCacheFiles(t *testing.T) { + cacheDir := t.TempDir() + now := time.Now() + oldTime := now.Add(-analyzerCacheTTL - time.Hour) + freshTime := now.Add(-time.Hour) + + oldCache := filepath.Join(cacheDir, "old.cache") + freshCache := filepath.Join(cacheDir, "fresh.cache") + namedState := filepath.Join(cacheDir, overviewCacheFile) + cacheDirEntry := filepath.Join(cacheDir, "directory.cache") + symlinkTarget := filepath.Join(cacheDir, "target") + symlinkCache := filepath.Join(cacheDir, "link.cache") + + for _, path := range []string{oldCache, freshCache, namedState, symlinkTarget} { + if err := os.WriteFile(path, []byte("cache"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + if err := os.Mkdir(cacheDirEntry, 0o755); err != nil { + t.Fatalf("mkdir cache dir entry: %v", err) + } + if err := os.Symlink(symlinkTarget, symlinkCache); err != nil { + t.Fatalf("symlink cache entry: %v", err) + } + + for _, path := range []string{oldCache, namedState, cacheDirEntry, symlinkCache} { + if err := os.Chtimes(path, oldTime, oldTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } + if err := os.Chtimes(freshCache, freshTime, freshTime); err != nil { + t.Fatalf("chtimes fresh cache: %v", err) + } + + if err := pruneAnalyzerCacheDir(cacheDir, now); err != nil { + t.Fatalf("pruneAnalyzerCacheDir: %v", err) + } + + if _, err := os.Stat(oldCache); !os.IsNotExist(err) { + t.Fatalf("expected expired cache file to be removed, stat err: %v", err) + } + for _, path := range []string{freshCache, namedState, cacheDirEntry, symlinkCache} { + if _, err := os.Lstat(path); err != nil { + t.Fatalf("expected %s to be preserved: %v", path, err) + } + } +} + +func TestPruneAnalyzerCacheDirMissingDirectory(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + if err := pruneAnalyzerCacheDir(missing, time.Now()); err != nil { + t.Fatalf("expected missing cache dir to be ignored, got: %v", err) + } +} + +func TestPruneAnalyzerCacheDirIgnoresRemoveFailures(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can remove files from read-only directories") + } + + cacheDir := t.TempDir() + oldCache := filepath.Join(cacheDir, "old.cache") + if err := os.WriteFile(oldCache, []byte("cache"), 0o644); err != nil { + t.Fatalf("write old cache: %v", err) + } + oldTime := time.Now().Add(-analyzerCacheTTL - time.Hour) + if err := os.Chtimes(oldCache, oldTime, oldTime); err != nil { + t.Fatalf("chtimes old cache: %v", err) + } + + if err := os.Chmod(cacheDir, 0o555); err != nil { + t.Fatalf("chmod cache dir read-only: %v", err) + } + defer func() { + _ = os.Chmod(cacheDir, 0o755) + }() + + if err := pruneAnalyzerCacheDir(cacheDir, time.Now()); err != nil { + t.Fatalf("expected remove failure to be ignored, got: %v", err) + } + if _, err := os.Stat(oldCache); err != nil { + t.Fatalf("expected failed removal to leave cache file in place: %v", err) + } +} + +// writeAgedCacheFiles lays down n fresh cache files, oldest first, one minute +// apart so eviction order is unambiguous. +func writeAgedCacheFiles(t *testing.T, cacheDir string, n int, payload int) []string { + t.Helper() + base := time.Now().Add(-time.Duration(n) * time.Minute) + names := make([]string, 0, n) + for i := range n { + name := filepath.Join(cacheDir, fmt.Sprintf("entry-%03d.cache", i)) + if err := os.WriteFile(name, []byte(strings.Repeat("x", payload)), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + stamp := base.Add(time.Duration(i) * time.Minute) + if err := os.Chtimes(name, stamp, stamp); err != nil { + t.Fatalf("chtimes %s: %v", name, err) + } + names = append(names, name) + } + return names +} + +// A TTL alone cannot bound a store whose entries are all refreshed inside it; +// the count cap is what keeps the newest N and drops the rest, oldest first. +func TestPruneAnalyzerCacheDirEnforcesEntryCap(t *testing.T) { + cacheDir := t.TempDir() + names := writeAgedCacheFiles(t, cacheDir, 10, 16) + + if err := pruneAnalyzerCacheDirWithLimits(cacheDir, time.Now(), 4, 0); err != nil { + t.Fatalf("pruneAnalyzerCacheDirWithLimits: %v", err) + } + + for i, name := range names { + _, err := os.Stat(name) + if i < 6 && !os.IsNotExist(err) { + t.Fatalf("expected oldest entry %s to be evicted, stat err: %v", name, err) + } + if i >= 6 && err != nil { + t.Fatalf("expected newest entry %s to be retained: %v", name, err) + } + } +} + +func TestPruneAnalyzerCacheDirEnforcesByteCap(t *testing.T) { + cacheDir := t.TempDir() + names := writeAgedCacheFiles(t, cacheDir, 10, 100) + + // The count cap is set out of the way so only the byte cap can evict: + // room for exactly three of the 100-byte entries. + if err := pruneAnalyzerCacheDirWithLimits(cacheDir, time.Now(), len(names), 300); err != nil { + t.Fatalf("pruneAnalyzerCacheDirWithLimits: %v", err) + } + + for i, name := range names { + _, err := os.Stat(name) + if i < 7 && !os.IsNotExist(err) { + t.Fatalf("expected oldest entry %s to be evicted, stat err: %v", name, err) + } + if i >= 7 && err != nil { + t.Fatalf("expected newest entry %s to be retained: %v", name, err) + } + } +} + +// The legacy flat store shares `~/.cache/mole` with shell-side state, so the +// sweep is scoped to the two names the analyzer ever wrote there. +func TestSweepLegacyAnalyzerCacheRemovesOnlyAnalyzerFiles(t *testing.T) { + root := t.TempDir() + + legacyEntry := filepath.Join(root, "deadbeef.cache") + legacyOverview := filepath.Join(root, overviewCacheFile) + shellState := filepath.Join(root, "installed_apps_cache") + permissionFlag := filepath.Join(root, "permissions_granted") + for _, path := range []string{legacyEntry, legacyOverview, shellState, permissionFlag} { + if err := os.WriteFile(path, []byte("state"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + analyzerDir := filepath.Join(root, analyzerCacheDirName) + if err := os.Mkdir(analyzerDir, 0o755); err != nil { + t.Fatalf("mkdir analyzer dir: %v", err) + } + currentEntry := filepath.Join(analyzerDir, "deadbeef.cache") + if err := os.WriteFile(currentEntry, []byte("current"), 0o644); err != nil { + t.Fatalf("write current entry: %v", err) + } + + if err := sweepLegacyAnalyzerCache(root); err != nil { + t.Fatalf("sweepLegacyAnalyzerCache: %v", err) + } + + for _, path := range []string{legacyEntry, legacyOverview} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected legacy file %s to be swept, stat err: %v", path, err) + } + } + for _, path := range []string{shellState, permissionFlag, currentEntry, analyzerDir} { + if _, err := os.Lstat(path); err != nil { + t.Fatalf("expected %s to be preserved: %v", path, err) + } + } +} + +func TestSweepLegacyAnalyzerCacheMissingRoot(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + if err := sweepLegacyAnalyzerCache(missing); err != nil { + t.Fatalf("expected missing root to be ignored, got: %v", err) + } +} + +// Rejecting an expired entry without deleting it leaves the file on disk for a +// whole TTL, waiting on a prune pass that may never reach it. +func TestLoadCacheFromDiskRemovesExpiredEntry(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + if err := saveCacheToDisk(target, scanResult{TotalSize: 1024, TotalFiles: 4}); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + + expired := time.Now().Add(-analyzerCacheTTL - time.Hour) + if err := os.Chtimes(cachePath, expired, expired); err != nil { + t.Fatalf("chtimes cache: %v", err) + } + // ScanTime lives inside the payload, so age it there too. + entry, err := loadRawCacheFromDisk(target) + if err != nil { + t.Fatalf("loadRawCacheFromDisk: %v", err) + } + entry.ScanTime = expired + file, err := os.Create(cachePath) + if err != nil { + t.Fatalf("rewrite cache: %v", err) + } + if err := gob.NewEncoder(file).Encode(*entry); err != nil { + file.Close() //nolint:errcheck + t.Fatalf("encode cache: %v", err) + } + file.Close() //nolint:errcheck + + if _, err := loadCacheFromDisk(target); err == nil { + t.Fatalf("expected expired cache to be rejected") + } + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("expected expired cache file to be deleted, stat err: %v", err) + } +} + +func TestLoadCacheFromDiskRemovesEntryForMissingDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + if err := saveCacheToDisk(target, scanResult{TotalSize: 1024, TotalFiles: 4}); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + if err := os.RemoveAll(target); err != nil { + t.Fatalf("remove target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err == nil { + t.Fatalf("expected missing directory to fail the load") + } + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("expected orphaned cache file to be deleted, stat err: %v", err) + } +} + +func TestLoadRawCacheFromDiskRemovesUndecodableEntry(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + if err := os.WriteFile(cachePath, []byte("not gob"), 0o644); err != nil { + t.Fatalf("write corrupt cache: %v", err) + } + + if _, err := loadRawCacheFromDisk(target); err == nil { + t.Fatalf("expected corrupt cache to fail decoding") + } + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("expected corrupt cache file to be deleted, stat err: %v", err) + } +} + +// getCacheDir memoizes MkdirAll, so it has to notice when HOME moves or every +// test after the first would write into the first one's temp directory. +func TestGetCacheDirFollowsHomeChanges(t *testing.T) { + firstHome := t.TempDir() + t.Setenv("HOME", firstHome) + first, err := getCacheDir() + if err != nil { + t.Fatalf("getCacheDir(first): %v", err) + } + if !strings.HasPrefix(first, firstHome) { + t.Fatalf("cache dir %q not under HOME %q", first, firstHome) + } + + secondHome := t.TempDir() + t.Setenv("HOME", secondHome) + second, err := getCacheDir() + if err != nil { + t.Fatalf("getCacheDir(second): %v", err) + } + if !strings.HasPrefix(second, secondHome) { + t.Fatalf("cache dir %q not under new HOME %q", second, secondHome) + } + if first == second { + t.Fatalf("expected cache dir to change with HOME, got %q twice", first) + } + if _, err := os.Stat(second); err != nil { + t.Fatalf("expected new cache dir to be created: %v", err) + } +} + +// Every save rewrites the whole overview store, so re-measuring a directory to +// the size already on record must not touch the disk at all. +func TestStoreOverviewSizeSkipsWriteWhenValueUnchanged(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + resetOverviewSnapshotForTest() + + const target = "/Users/someone/project" + if err := storeOverviewSize(target, 4096); err != nil { + t.Fatalf("storeOverviewSize: %v", err) + } + storePath, err := getOverviewSizeStorePath() + if err != nil { + t.Fatalf("getOverviewSizeStorePath: %v", err) + } + if err := os.Remove(storePath); err != nil { + t.Fatalf("remove store: %v", err) + } + + if err := storeOverviewSize(target, 4096); err != nil { + t.Fatalf("storeOverviewSize(repeat): %v", err) + } + if _, err := os.Stat(storePath); !os.IsNotExist(err) { + t.Fatalf("expected repeat save of an unchanged size to skip the write, stat err: %v", err) + } + + if err := storeOverviewSize(target, 8192); err != nil { + t.Fatalf("storeOverviewSize(changed): %v", err) + } + if _, err := os.Stat(storePath); err != nil { + t.Fatalf("expected a changed size to be persisted: %v", err) + } +} + +func TestEnsureOverviewSnapshotCacheDropsExpiredAndLegacyEntries(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + resetOverviewSnapshotForTest() + + storePath, err := getOverviewSizeStorePath() + if err != nil { + t.Fatalf("getOverviewSizeStorePath: %v", err) + } + seeded := map[string]overviewSizeSnapshot{ + "/fresh": {Size: 1 << 20, Updated: time.Now().Add(-time.Hour), SchemaVersion: cacheSchemaVersion}, + "/expired": {Size: 1 << 20, Updated: time.Now().Add(-overviewCacheTTL - time.Hour), SchemaVersion: cacheSchemaVersion}, + "/empty": {Size: 0, Updated: time.Now(), SchemaVersion: cacheSchemaVersion}, + "/legacy": {Size: 1 << 20, Updated: time.Now()}, + } + data, err := json.Marshal(seeded) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + if err := os.WriteFile(storePath, data, 0o644); err != nil { + t.Fatalf("write seed: %v", err) + } + + if _, err := loadStoredOverviewSize("/fresh"); err != nil { + t.Fatalf("expected fresh snapshot to load: %v", err) + } + + overviewSnapshotMu.Lock() + _, hasExpired := overviewSnapshotCache["/expired"] + _, hasEmpty := overviewSnapshotCache["/empty"] + _, hasLegacy := overviewSnapshotCache["/legacy"] + _, hasFresh := overviewSnapshotCache["/fresh"] + overviewSnapshotMu.Unlock() + + if hasExpired || hasEmpty || hasLegacy { + t.Fatalf("expected expired, empty, and legacy snapshots to be dropped on load") + } + if !hasFresh { + t.Fatalf("expected fresh snapshot to survive load") + } +} + +func TestEvictOverviewSnapshotsKeepsNewest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + resetOverviewSnapshotForTest() + + base := time.Now().Add(-time.Duration(overviewCacheMaxEntries+1) * time.Minute) + overviewSnapshotMu.Lock() + overviewSnapshotCache = make(map[string]overviewSizeSnapshot, overviewCacheMaxEntries+1) + overviewSnapshotLoaded = true + for i := range overviewCacheMaxEntries + 1 { + overviewSnapshotCache[fmt.Sprintf("/dir-%04d", i)] = overviewSizeSnapshot{ + Size: int64(i + 1), + Updated: base.Add(time.Duration(i) * time.Minute), + SchemaVersion: cacheSchemaVersion, + } + } + evictOverviewSnapshotsLocked() + remaining := len(overviewSnapshotCache) + _, oldestKept := overviewSnapshotCache["/dir-0000"] + _, newestKept := overviewSnapshotCache[fmt.Sprintf("/dir-%04d", overviewCacheMaxEntries)] + overviewSnapshotMu.Unlock() + + if remaining != overviewCacheKeepEntries { + t.Fatalf("expected %d snapshots after eviction, got %d", overviewCacheKeepEntries, remaining) + } + if oldestKept { + t.Fatalf("expected the oldest snapshot to be evicted") + } + if !newestKept { + t.Fatalf("expected the newest snapshot to be kept") + } +} + +// Dropping snapshots one child at a time rewrote the whole overview store per +// child; the tree invalidation has to land as a single save. +func TestInvalidateCacheTreeDropsChildSnapshotsInOneSave(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + resetOverviewSnapshotForTest() + + parent := filepath.Join(home, "parent") + childA := filepath.Join(parent, "a") + childB := filepath.Join(parent, "b") + for _, dir := range []string{childA, childB} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + for _, dir := range []string{parent, childA, childB} { + if err := storeOverviewSize(dir, 1<<20); err != nil { + t.Fatalf("storeOverviewSize(%s): %v", dir, err) + } + if err := saveCacheToDisk(dir, scanResult{TotalSize: 1 << 20, TotalFiles: 3}); err != nil { + t.Fatalf("saveCacheToDisk(%s): %v", dir, err) + } + } + + storePath, err := getOverviewSizeStorePath() + if err != nil { + t.Fatalf("getOverviewSizeStorePath: %v", err) + } + if err := os.Remove(storePath); err != nil { + t.Fatalf("remove store: %v", err) + } + + invalidateCacheTree(parent) + + // Exactly one save recreated the file, and it holds none of the tree. + data, err := os.ReadFile(storePath) + if err != nil { + t.Fatalf("expected the invalidation to persist once: %v", err) + } + var persisted map[string]overviewSizeSnapshot + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("unmarshal store: %v", err) + } + for _, dir := range []string{parent, childA, childB} { + if _, ok := persisted[dir]; ok { + t.Fatalf("expected %s snapshot to be dropped", dir) + } + cachePath, err := getCachePath(dir) + if err != nil { + t.Fatalf("getCachePath(%s): %v", dir, err) + } + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("expected %s cache entry to be removed, stat err: %v", dir, err) + } + } +} + +// Atomic saves stage through temp files, and prune is the only thing that ever +// looks in that directory: without this, a process killed mid-write leaks a +// temp file that nothing would ever collect. +func TestPruneAnalyzerCacheDirRemovesStaleTempFiles(t *testing.T) { + cacheDir := t.TempDir() + now := time.Now() + + staleTemp := filepath.Join(cacheDir, "entry-123.tmp") + freshTemp := filepath.Join(cacheDir, "entry-456.tmp") + liveCache := filepath.Join(cacheDir, "live.cache") + for _, path := range []string{staleTemp, freshTemp, liveCache} { + if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + old := now.Add(-staleTempFileTTL - time.Minute) + if err := os.Chtimes(staleTemp, old, old); err != nil { + t.Fatalf("chtimes stale temp: %v", err) + } + + if err := pruneAnalyzerCacheDir(cacheDir, now); err != nil { + t.Fatalf("pruneAnalyzerCacheDir: %v", err) + } + + if _, err := os.Stat(staleTemp); !os.IsNotExist(err) { + t.Fatalf("expected stale temp file to be removed, stat err: %v", err) + } + for _, path := range []string{freshTemp, liveCache} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to be preserved: %v", path, err) + } + } +} + +func TestSaveCacheToDiskLeavesNoTempFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + if err := saveCacheToDisk(target, scanResult{TotalSize: 2048, TotalFiles: 8}); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cacheDir, err := getCacheDir() + if err != nil { + t.Fatalf("getCacheDir: %v", err) + } + entries, err := os.ReadDir(cacheDir) + if err != nil { + t.Fatalf("read cache dir: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("expected no temp file left behind, found %s", entry.Name()) + } + } + if _, err := loadCacheFromDisk(target); err != nil { + t.Fatalf("expected the entry to be readable after an atomic save: %v", err) + } +} + +func TestPeekCacheTotalFilesRejectsSchemaMismatch(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + file, err := os.Create(cachePath) + if err != nil { + t.Fatalf("create cache: %v", err) + } + stale := cacheEntry{TotalFiles: 42, SchemaVersion: cacheSchemaVersion + 1, ScanTime: time.Now()} + if err := gob.NewEncoder(file).Encode(stale); err != nil { + file.Close() //nolint:errcheck + t.Fatalf("encode stale entry: %v", err) + } + file.Close() //nolint:errcheck + + if _, err := peekCacheTotalFiles(target); err == nil { + t.Fatalf("expected a schema mismatch to be rejected") + } + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("expected the stale entry to be deleted, stat err: %v", err) + } +} + +// The analyzer store must not sit in the directory the shell side uses for its +// own state: the legacy sweep and the entry caps both assume they own it. +func TestGetCacheDirIsAnalyzerScoped(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root, err := getMoleCacheRoot() + if err != nil { + t.Fatalf("getMoleCacheRoot: %v", err) + } + cacheDir, err := getCacheDir() + if err != nil { + t.Fatalf("getCacheDir: %v", err) + } + if want := filepath.Join(root, analyzerCacheDirName); cacheDir != want { + t.Fatalf("cache dir = %q, want %q", cacheDir, want) + } + if _, err := os.Stat(cacheDir); err != nil { + t.Fatalf("expected cache dir to be created: %v", err) + } +} + +func TestScanPathConcurrentWarmsChildDirectoryCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "root.txt"), []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root data: %v", err) + } + // Only subtrees expensive enough to rescan are persisted, so the child has + // to clear subdirCacheMinFiles to be warmed at all. + for i := range subdirCacheMinFiles { + name := filepath.Join(child, fmt.Sprintf("data-%d.bin", i)) + if err := os.WriteFile(name, []byte(strings.Repeat("x", 64)), 0o644); err != nil { + t.Fatalf("write child data: %v", err) + } + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + if _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current); err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + cached, err := loadCacheFromDisk(child) + if err != nil { + t.Fatalf("expected warmed child cache, got error: %v", err) + } + if cached.TotalSize <= 0 { + t.Fatalf("expected positive cached child size, got %d", cached.TotalSize) + } + if len(cached.Entries) == 0 { + t.Fatalf("expected cached child entries to be populated") + } + if cached.TotalFiles != subdirCacheMinFiles { + t.Fatalf("expected warmed child cache to track local file count %d, got %d", subdirCacheMinFiles, cached.TotalFiles) + } + if !cached.NeedsRefresh { + t.Fatalf("expected warmed child cache to be marked for refresh") + } +} + +// A cache file costs a 4KB block plus an inode to memoize what one readdir +// returns, so cheap subtrees must not get one. Unbounded admission is what grew +// ~/.cache/mole to 1.88M files / 7.82GB on a user's Mac. +func TestScanPathConcurrentSkipsCacheForCheapSubdir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "root.txt"), []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root data: %v", err) + } + if err := os.WriteFile(filepath.Join(child, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write child data: %v", err) + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + if err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + childPath, err := getCachePath(child) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + if _, err := os.Stat(childPath); !os.IsNotExist(err) { + t.Fatalf("expected cheap subtree to be left uncached, stat err: %v", err) + } + + // The size still has to be reported; only the persistence is skipped. + found := false + for _, entry := range result.Entries { + if entry.Path == child { + found = true + if entry.Size <= 0 { + t.Fatalf("expected uncached child to still report a size, got %d", entry.Size) + } + } + } + if !found { + t.Fatalf("expected child entry in scan result") + } +} + +func TestAnalyzeIncludesParallelsVMStorageButKeepsOtherVirtualizationSkips(t *testing.T) { + root := t.TempDir() + parallels := filepath.Join(root, "Parallels") + orbStack := filepath.Join(root, "OrbStack") + for _, dir := range []string{parallels, orbStack} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "disk.img"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write data in %s: %v", dir, err) + } + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + result, err := scanPathConcurrentWithOptions(root, &filesScanned, &dirsScanned, &bytesScanned, current, false, 0) + if err != nil { + t.Fatalf("scan root: %v", err) + } + + foundParallels := false + for _, entry := range result.Entries { + switch entry.Path { + case parallels: + foundParallels = true + if entry.Size <= 0 { + t.Fatalf("expected Parallels to contribute a positive size, got %d", entry.Size) + } + case orbStack: + t.Fatalf("expected existing OrbStack skip to remain in place") + } + } + if !foundParallels { + t.Fatalf("expected Parallels VM storage in scan entries") + } +} + +func TestLiveScanIncludesParallelsVMStorageButKeepsOtherVirtualizationSkips(t *testing.T) { + root := t.TempDir() + parallels := filepath.Join(root, "Parallels") + orbStack := filepath.Join(root, "OrbStack") + for _, dir := range []string{parallels, orbStack} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create %s: %v", dir, err) + } + } + + entries, targets, _, _, _, err := readLiveScanInitialEntries(root, nil) + if err != nil { + t.Fatalf("read live scan entries: %v", err) + } + + foundParallelsEntry := false + for _, entry := range entries { + switch entry.Path { + case parallels: + foundParallelsEntry = true + case orbStack: + t.Fatalf("expected existing OrbStack skip to remain in live entries") + } + } + foundParallelsTarget := false + for _, target := range targets { + switch target.path { + case parallels: + foundParallelsTarget = true + case orbStack: + t.Fatalf("expected existing OrbStack skip to remain in live targets") + } + } + if !foundParallelsEntry || !foundParallelsTarget { + t.Fatalf("expected Parallels in both live entries and targets, entry=%v target=%v", foundParallelsEntry, foundParallelsTarget) + } +} + +func TestShouldPersistSubdirCacheThresholds(t *testing.T) { + cases := []struct { + name string + result scanResult + want bool + }{ + {"tiny subtree", scanResult{TotalFiles: 1, TotalSize: 4096}, false}, + {"just below file threshold", scanResult{TotalFiles: subdirCacheMinFiles - 1, TotalSize: 1024}, false}, + {"file threshold", scanResult{TotalFiles: subdirCacheMinFiles, TotalSize: 1024}, true}, + {"size threshold", scanResult{TotalFiles: 1, TotalSize: subdirCacheMinSize}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldPersistSubdirCache(tc.result); got != tc.want { + t.Fatalf("shouldPersistSubdirCache(%+v) = %v, want %v", tc.result, got, tc.want) + } + }) + } +} + +func TestScanPathConcurrentUsesChildCacheLargeFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + largeFile := filepath.Join(child, "large.bin") + if err := os.WriteFile(largeFile, []byte(strings.Repeat("x", 2<<20)), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + var childFiles, childDirs, childBytes int64 + childCurrent := &atomic.Value{} + childCurrent.Store("") + childResult, err := scanPathConcurrent(child, &childFiles, &childDirs, &childBytes, childCurrent) + if err != nil { + t.Fatalf("scanPathConcurrent(child): %v", err) + } + if err := saveCacheToDisk(child, childResult); err != nil { + t.Fatalf("saveCacheToDisk(child): %v", err) + } + + if err := os.Chmod(child, 0o000); err != nil { + t.Fatalf("chmod child unreadable: %v", err) + } + defer func() { + _ = os.Chmod(child, 0o755) + }() + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + if err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + foundChild := false + for _, entry := range result.Entries { + if entry.Path == child { + foundChild = true + if entry.Size != childResult.TotalSize { + t.Fatalf("cached child size mismatch: want %d, got %d", childResult.TotalSize, entry.Size) + } + break + } + } + if !foundChild { + t.Fatalf("expected cached child directory in root entries") + } + + foundLargeFile := false + for _, file := range result.LargeFiles { + if file.Path == largeFile { + foundLargeFile = true + break + } + } + if !foundLargeFile { + t.Fatalf("expected root large files to include cached child large file") + } +} + +func TestScanPathConcurrentWarmsChildCachesWithoutRecursiveSpotlight(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + childOne := filepath.Join(root, "child-one") + childTwo := filepath.Join(root, "child-two") + for _, dir := range []string{childOne, childTwo} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create dir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write data in %s: %v", dir, err) + } + } + + originalRunner := spotlightQueryRunner + spotlightRoots := []string{} + spotlightQueryRunner = func(_ context.Context, queryRoot, _ string) ([]byte, error) { + spotlightRoots = append(spotlightRoots, queryRoot) + return nil, nil + } + t.Cleanup(func() { + spotlightQueryRunner = originalRunner + }) + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + if _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current); err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + if len(spotlightRoots) != 1 || spotlightRoots[0] != root { + t.Fatalf("expected only root spotlight invocation, got %q", spotlightRoots) + } +} + +func TestScanCmdTreatsWarmedCacheAsStale(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{ + Entries: []dirEntry{{Name: "child", Path: filepath.Join(target, "child"), Size: 1, IsDir: true}}, + LargeFiles: []fileEntry{{Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2 << 20}}, + TotalSize: 42, + TotalFiles: 1, + } + if err := saveCacheToDiskWithOptions(target, result, true); err != nil { + t.Fatalf("saveCacheToDiskWithOptions: %v", err) + } + + m := newModel(target, false) + msg := m.scanCmd(target)() + scanMsg, ok := msg.(scanResultMsg) + if !ok { + t.Fatalf("expected scanResultMsg, got %T", msg) + } + if !scanMsg.stale { + t.Fatalf("expected warmed cache to trigger stale refresh path") + } + if scanMsg.result.TotalFiles != result.TotalFiles { + t.Fatalf("expected cached result to survive stale load, got %d", scanMsg.result.TotalFiles) + } +} + +func TestLiveScanSortConfigFromEnv(t *testing.T) { + t.Run("defaults to freeze on move", func(t *testing.T) { + t.Setenv(liveSortModeEnv, "") + + m := newModel(t.TempDir(), false) + if m.liveSortMode != liveSortFreezeOnMove { + t.Fatalf("expected freeze-on-move sort mode, got %v", m.liveSortMode) + } + }) + + t.Run("continuous remains available", func(t *testing.T) { + t.Setenv(liveSortModeEnv, "continuous") + + m := newModel(t.TempDir(), false) + if m.liveSortMode != liveSortContinuous { + t.Fatalf("expected continuous sort mode, got %v", m.liveSortMode) + } + }) +} + +func TestLiveScanInitialListingShowsImmediateChildren(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + filePath := filepath.Join(root, "root.txt") + if err := os.WriteFile(filePath, []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root file: %v", err) + } + + m := newModel(root, false) + msg := m.scanFreshCmd(root)() + start, ok := msg.(liveScanStartMsg) + if !ok { + t.Fatalf("expected liveScanStartMsg, got %T", msg) + } + defer cancelAndDrainLiveScan(start) + + foundFile := false + foundDir := false + for _, entry := range start.entries { + switch entry.Path { + case filePath: + foundFile = true + if entry.Size <= 0 { + t.Fatalf("expected file size to be known immediately, got %d", entry.Size) + } + case child: + foundDir = true + if entry.Size != -1 { + t.Fatalf("expected child directory to start pending, got %d", entry.Size) + } + } + } + if !foundFile || !foundDir { + t.Fatalf("expected immediate file and directory entries, got %+v", start.entries) + } +} + +func TestLiveScanStartDoesNotAddSecondSpinnerTick(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + m := newModel(root, false) + start := m.scanFreshCmd(root)().(liveScanStartMsg) + defer cancelAndDrainLiveScan(start) + + _, cmd := m.Update(start) + if cmd == nil { + t.Fatalf("expected live scan start to wait for scan events") + } + if _, ok := cmd().(tickMsg); ok { + t.Fatalf("live scan start must not schedule an extra spinner tick") + } +} + +func TestOverviewHomeNavigationRendersImmediateRows(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + downloads := filepath.Join(home, "Downloads") + desktop := filepath.Join(home, "Desktop") + for _, dir := range []string{downloads, desktop} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create %s: %v", dir, err) + } + } + if err := os.WriteFile(filepath.Join(home, "note.txt"), []byte("home-note"), 0o644); err != nil { + t.Fatalf("write home file: %v", err) + } + + m := newModel("/", true) + for i, entry := range m.entries { + if entry.Path == home { + m.selected = i + break + } + } + + updated, cmd := m.enterSelectedDir() + if cmd == nil { + t.Fatalf("expected Home navigation to start a scan") + } + got := updated.(model) + if got.path != home { + t.Fatalf("expected path %s, got %s", home, got.path) + } + + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("expected navigation command batch, got %T", msg) + } + var start liveScanStartMsg + for _, batchCmd := range batch { + if batchCmd == nil { + continue + } + if candidate, ok := batchCmd().(liveScanStartMsg); ok { + start = candidate + break + } + } + if start.events == nil { + t.Fatalf("expected batch to include live scan start") + } + defer cancelAndDrainLiveScan(start) + + updated, _ = got.Update(start) + got = updated.(model) + view := got.View() + for _, want := range []string{"Downloads", "Desktop", "note.txt"} { + if !strings.Contains(view, want) { + t.Fatalf("expected Home view to contain %q, got:\n%s", want, view) + } + } +} + +func TestLiveScanChildUpdateUpdatesRowTotalAndCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + if err := os.WriteFile(filepath.Join(child, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write child file: %v", err) + } + + m := newModel(root, false) + start := m.scanFreshCmd(root)().(liveScanStartMsg) + defer cancelAndDrainLiveScan(start) + + updated, _ := m.Update(start) + liveModel := updated.(model) + + deadline := time.After(5 * time.Second) + for { + select { + case event := <-start.events: + if event.kind != liveScanChildDone { + continue + } + updated, _ = liveModel.Update(event) + liveModel = updated.(model) + + var found dirEntry + for _, entry := range liveModel.entries { + if entry.Path == child { + found = entry + break + } + } + if found.Path == "" { + t.Fatalf("expected child row to remain visible") + } + if found.Size <= 0 { + t.Fatalf("expected child row size to update, got %d", found.Size) + } + if liveModel.totalSize != found.Size { + t.Fatalf("expected total size %d, got %d", found.Size, liveModel.totalSize) + } + cached, ok := liveModel.cache[child] + if !ok { + t.Fatalf("expected child result to warm in-memory cache") + } + if cached.TotalSize != found.Size { + t.Fatalf("cached child size mismatch: want %d, got %d", found.Size, cached.TotalSize) + } + return + case <-deadline: + t.Fatalf("timed out waiting for child update") + } + } +} + +func TestManualRefreshBypassesNestedSubdirCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + nested := filepath.Join(root, "a", "b") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("create nested directory: %v", err) + } + for i := range subdirCacheMinFiles { + path := filepath.Join(nested, fmt.Sprintf("data-%d.bin", i)) + if err := os.WriteFile(path, []byte(strings.Repeat("x", 64)), 0o644); err != nil { + t.Fatalf("write nested data: %v", err) + } + } + removedPath := filepath.Join(nested, "removed.bin") + if err := os.WriteFile(removedPath, []byte(strings.Repeat("x", 2*1024*1024)), 0o644); err != nil { + t.Fatalf("write removable data: %v", err) + } + + m := newModel(root, false) + warmed := runScanResultCmd(t, m.scanFreshCmd(root)) + if warmed.err != nil { + t.Fatalf("warm scan: %v", warmed.err) + } + if _, err := loadCacheFromDisk(nested); err != nil { + t.Fatalf("expected nested cache to be warmed: %v", err) + } + + if err := os.Remove(removedPath); err != nil { + t.Fatalf("remove nested data: %v", err) + } + + reused := runScanResultCmd(t, m.scanFreshCmd(root)) + if reused.err != nil { + t.Fatalf("cached scan: %v", reused.err) + } + if reused.result.TotalSize != warmed.result.TotalSize { + t.Fatalf("expected ordinary scan to reuse nested cache size %d, got %d", warmed.result.TotalSize, reused.result.TotalSize) + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}}) + if cmd == nil { + t.Fatalf("expected manual refresh command") + } + if !updated.(model).scanning { + t.Fatalf("expected manual refresh to enter scanning state") + } + refreshed := runScanResultCmd(t, cmd) + if refreshed.err != nil { + t.Fatalf("manual refresh: %v", refreshed.err) + } + if refreshed.result.TotalSize >= warmed.result.TotalSize { + t.Fatalf("expected manual refresh to drop removed file size below %d, got %d", warmed.result.TotalSize, refreshed.result.TotalSize) + } + + cached, err := loadCacheFromDisk(nested) + if err != nil { + t.Fatalf("load refreshed nested cache: %v", err) + } + for _, entry := range cached.Entries { + if entry.Path == removedPath { + t.Fatalf("manual refresh left removed file in nested cache") + } + } +} + +func TestCacheBypassSkipsHomeLibraryOverviewSnapshot(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + resetOverviewSnapshotForTest() + t.Cleanup(resetOverviewSnapshotForTest) + + library := filepath.Join(home, "Library") + if err := os.MkdirAll(library, 0o755); err != nil { + t.Fatalf("create Library: %v", err) + } + if err := os.WriteFile(filepath.Join(library, "live.bin"), []byte(strings.Repeat("x", 8192)), 0o644); err != nil { + t.Fatalf("write Library data: %v", err) + } + if err := storeOverviewSize(library, 1); err != nil { + t.Fatalf("store stale overview size: %v", err) + } + + scanTarget := func(policy scanCachePolicy) scanResult { + t.Helper() + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + limiter := newScanLimiter(1) + largeFileMinSize := int64(largeFileWarmupMinSize) + result, err := scanLiveTarget( + context.Background(), + liveScanTarget{name: "Library", path: library, kind: liveScanTargetHomeLibrary}, + make(chan fileEntry, maxLargeFiles*2), + &largeFileMinSize, + limiter, + &filesScanned, + &dirsScanned, + &bytesScanned, + current, + policy, + ) + if err != nil { + t.Fatalf("scan Home Library: %v", err) + } + return result + } + + if got := scanTarget(scanCacheReuse).TotalSize; got != 1 { + t.Fatalf("expected reuse policy to return snapshot size 1, got %d", got) + } + if got := scanTarget(scanCacheBypass).TotalSize; got <= 1 { + t.Fatalf("expected bypass policy to scan live Library size, got %d", got) + } + + scanHome := func(policy scanCachePolicy) int64 { + t.Helper() + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + result, err := scanPathConcurrentWithLimiter(home, &filesScanned, &dirsScanned, &bytesScanned, current, false, maxEntries, nil, policy) + if err != nil { + t.Fatalf("scan Home: %v", err) + } + for _, entry := range result.Entries { + if entry.Path == library { + return entry.Size + } + } + t.Fatalf("Library entry missing from Home scan") + return 0 + } -func resetOverviewSnapshotForTest() { - overviewSnapshotMu.Lock() - overviewSnapshotCache = nil - overviewSnapshotLoaded = false - overviewSnapshotMu.Unlock() + if got := scanHome(scanCacheReuse); got != 1 { + t.Fatalf("expected concurrent reuse policy to return snapshot size 1, got %d", got) + } + if got := scanHome(scanCacheBypass); got <= 1 { + t.Fatalf("expected concurrent bypass policy to scan live Library size, got %d", got) + } } -func TestScanPathConcurrentBasic(t *testing.T) { +func TestLiveScanStartPreservesEntryFilterBackingList(t *testing.T) { root := t.TempDir() + apps := filepath.Join(root, "apps") + logs := filepath.Join(root, "logs") + + m := newModel(root, false) + m.entryFilter = "app" + start := liveScanStartMsg{ + id: 1, + path: root, + entries: []dirEntry{ + {Name: "apps", Path: apps, Size: -1, IsDir: true}, + {Name: "logs", Path: logs, Size: -1, IsDir: true}, + }, + events: make(chan liveScanEventMsg), + cancel: func() {}, + } - rootFile := filepath.Join(root, "root.txt") - if err := os.WriteFile(rootFile, []byte("root-data"), 0o644); err != nil { - t.Fatalf("write root file: %v", err) + updated, _ := m.Update(start) + got := updated.(model) + if len(got.entriesAll) != 2 { + t.Fatalf("expected backing list to keep both live entries, got %+v", got.entriesAll) + } + if len(got.entries) != 1 || got.entries[0].Path != apps { + t.Fatalf("expected active filter to render only apps, got %+v", got.entries) } +} - nested := filepath.Join(root, "nested") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatalf("create nested dir: %v", err) +func TestLiveScanIgnoresStaleEventsAfterNavigation(t *testing.T) { + root := t.TempDir() + other := t.TempDir() + + m := newModel(other, false) + m.liveScanID = 2 + m.liveScanEvents = make(chan liveScanEventMsg) + m.entries = []dirEntry{{Name: "current", Path: filepath.Join(other, "current"), Size: 1}} + m.totalSize = 1 + + stale := liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{ + Name: "stale", + Path: filepath.Join(root, "stale"), + Size: 99, + IsDir: true, + }, + result: scanResult{TotalSize: 99}, } - fileOne := filepath.Join(nested, "a.bin") - if err := os.WriteFile(fileOne, []byte("alpha"), 0o644); err != nil { - t.Fatalf("write file one: %v", err) + updated, _ := m.Update(stale) + got := updated.(model) + if got.totalSize != 1 || len(got.entries) != 1 || got.entries[0].Name != "current" { + t.Fatalf("stale event changed model: %+v", got) } - fileTwo := filepath.Join(nested, "b.bin") - if err := os.WriteFile(fileTwo, []byte(strings.Repeat("b", 32)), 0o644); err != nil { - t.Fatalf("write file two: %v", err) +} + +func TestLiveScanDefaultCursorStaysOnFirstRowAcrossReorder(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + m.entriesAll = slices.Clone(m.entries) + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + if got := []string{m.entries[0].Path, m.entries[1].Path}; !slices.Equal(got, []string{b, a}) { + t.Fatalf("expected live sort to reorder by size, got %v", got) + } + if m.selected != 0 || m.entries[m.selected].Path != b { + t.Fatalf("expected default cursor to stay on the first row, selected=%d entries=%+v", m.selected, m.entries) + } + + updated, _ = m.enterSelectedDir() + got := updated.(model) + if got.path != b { + t.Fatalf("expected Enter to drill into first-row path %s, got %s", b, got.path) } +} - linkPath := filepath.Join(root, "link-to-a") - if err := os.Symlink(fileOne, linkPath); err != nil { - t.Fatalf("create symlink: %v", err) +func TestLiveScanProgressUpdatesRowBarAndPercent(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "child") + sibling := filepath.Join(root, "sibling.bin") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = false + m.liveScanningPaths = map[string]bool{child: true} + m.entries = []dirEntry{ + {Name: "child", Path: child, Size: -1, IsDir: true}, + {Name: "sibling.bin", Path: sibling, Size: 100}, + } + m.totalSize = 100 + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildProgress, + entry: dirEntry{Name: "child", Path: child, Size: 10, IsDir: true}, + }) + m = updated.(model) + firstRow := rowContaining(m.View(), "child") + firstFill := progressFillCount(firstRow) + if !strings.Contains(firstRow, "9.1%") { + t.Fatalf("expected first progress row to show 9.1%%, got:\n%s", firstRow) + } + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildProgress, + entry: dirEntry{Name: "child", Path: child, Size: 50, IsDir: true}, + }) + m = updated.(model) + secondRow := rowContaining(m.View(), "child") + secondFill := progressFillCount(secondRow) + if !strings.Contains(secondRow, "33.3%") { + t.Fatalf("expected second progress row to show 33.3%%, got:\n%s", secondRow) + } + if secondFill <= firstFill { + t.Fatalf("expected child progress bar fill to increase, first=%d second=%d\nfirst: %s\nsecond: %s", firstFill, secondFill, firstRow, secondRow) + } + if m.totalSize != 150 { + t.Fatalf("expected total known size to grow to 150, got %d", m.totalSize) + } + if _, ok := m.cache[child]; ok { + t.Fatalf("progress event must not warm child cache before completion") + } + if !m.liveScanningPaths[child] { + t.Fatalf("progress event must keep child marked as scanning") } +} - var filesScanned, dirsScanned, bytesScanned int64 - current := &atomic.Value{} - current.Store("") +func TestLiveScanContinuousSortKeepsCursorByPath(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortContinuous + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if m.entries[m.selected].Path != a { + t.Fatalf("expected selection to move to a before reorder, got selected=%d entries=%+v", m.selected, m.entries) + } + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "a", Path: a, Size: 100, IsDir: true}, + result: scanResult{TotalSize: 100}, + }) + m = updated.(model) + if got := []string{m.entries[0].Path, m.entries[1].Path}; !slices.Equal(got, []string{a, b}) { + t.Fatalf("expected live sort to continue after navigation, got %v", got) + } + if m.entries[m.selected].Path != a { + t.Fatalf("expected cursor-by-path to stay on %s after reorder, selected=%d entries=%+v", a, m.selected, m.entries) + } +} - result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) - if err != nil { - t.Fatalf("scanPathConcurrent returned error: %v", err) +func TestLiveScanSortCanFreezeAfterNavigationKey(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortFreezeOnMove + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if m.autoSortLiveEntries { + t.Fatalf("expected freeze-on-move to disable live sort") + } + before := []string{m.entries[0].Path, m.entries[1].Path} + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "a", Path: a, Size: 100, IsDir: true}, + result: scanResult{TotalSize: 100}, + }) + m = updated.(model) + after := []string{m.entries[0].Path, m.entries[1].Path} + if !slices.Equal(before, after) { + t.Fatalf("expected freeze-on-move to keep row order %v, got %v", before, after) } +} - linkInfo, err := os.Lstat(linkPath) - if err != nil { - t.Fatalf("stat symlink: %v", err) +func TestLiveScanSortDoesNotFreezeWhenCursorCannotMove(t *testing.T) { + root := t.TempDir() + + m := newModel(root, false) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortFreezeOnMove + m.entries = []dirEntry{ + {Name: "only", Path: filepath.Join(root, "only"), Size: 10, IsDir: true}, } - expectedDirSize := int64(len("alpha") + len(strings.Repeat("b", 32))) - expectedRootFileSize := int64(len("root-data")) - expectedLinkSize := getActualFileSize(linkPath, linkInfo) - expectedTotal := expectedDirSize + expectedRootFileSize + expectedLinkSize + updated, _ := m.updateKey(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(model) + if !m.autoSortLiveEntries { + t.Fatal("an up key at the first row must not freeze live sorting") + } - if result.TotalSize != expectedTotal { - t.Fatalf("expected total size %d, got %d", expectedTotal, result.TotalSize) + updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if !m.autoSortLiveEntries { + t.Fatal("a down key with no next row must not freeze live sorting") } +} - if got := atomic.LoadInt64(&filesScanned); got != 3 { - t.Fatalf("expected 3 files scanned, got %d", got) +func TestScanningViewRendersRowsWithSpinner(t *testing.T) { + m := model{ + path: "/tmp/project", + scanning: true, + spinner: 1, + totalSize: 8, + entries: []dirEntry{ + {Name: "child", Path: "/tmp/project/child", Size: -1, IsDir: true}, + {Name: "file.txt", Path: "/tmp/project/file.txt", Size: 8}, + }, + liveScanningPaths: map[string]bool{"/tmp/project/child": true}, } - if dirs := atomic.LoadInt64(&dirsScanned); dirs == 0 { - t.Fatalf("expected directory scan count to increase") + + view := m.View() + if !strings.Contains(view, "child") || !strings.Contains(view, "file.txt") { + t.Fatalf("expected scanning view to render rows, got:\n%s", view) } - if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 { - t.Fatalf("expected byte counter to increase") + if !strings.Contains(view, spinnerFrames[m.spinner]+" scanning") { + t.Fatalf("expected pending directory spinner in row, got:\n%s", view) } - foundSymlink := false - for _, entry := range result.Entries { - if strings.HasSuffix(entry.Name, " →") { - foundSymlink = true - if entry.IsDir { - t.Fatalf("symlink entry should not be marked as directory") - } - } +} + +func TestScanningViewShowsSpinnerDividerForPartiallySizedFolders(t *testing.T) { + m := model{ + path: "/tmp/project", + scanning: true, + spinner: 1, + totalSize: 150, + entries: []dirEntry{ + {Name: "child", Path: "/tmp/project/child", Size: 50, IsDir: true}, + {Name: "file.txt", Path: "/tmp/project/file.txt", Size: 100}, + }, + liveScanningPaths: map[string]bool{"/tmp/project/child": true}, } - if !foundSymlink { - t.Fatalf("expected symlink entry to be present in scan result") + + view := m.View() + childRow := rowContaining(view, "child") + fileRow := rowContaining(view, "file.txt") + if !strings.Contains(childRow, spinnerFrames[m.spinner]) { + t.Fatalf("expected active child row divider to show spinner, got:\n%s", childRow) + } + if strings.Contains(fileRow, spinnerFrames[m.spinner]) { + t.Fatalf("expected non-scanning file row to keep static divider, got:\n%s", fileRow) } } -func TestPerformScanForJSONCountsTopLevelFiles(t *testing.T) { +func TestEnterSelectedDirMarksScanningParentForRefresh(t *testing.T) { root := t.TempDir() - - rootFile := filepath.Join(root, "root.txt") - if err := os.WriteFile(rootFile, []byte("root-data"), 0o644); err != nil { - t.Fatalf("write root file: %v", err) + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) } - nested := filepath.Join(root, "nested") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatalf("create nested dir: %v", err) + cancelled := false + m := newModel(root, false) + m.entries = []dirEntry{{Name: "child", Path: child, Size: -1, IsDir: true}} + m.scanning = true + m.liveScanID = 1 + m.liveScanCancel = func() { cancelled = true } + + updated, cmd := m.enterSelectedDir() + if cmd == nil { + t.Fatalf("expected child navigation to start a scan") + } + got := updated.(model) + if !cancelled { + t.Fatalf("expected active parent scan to be cancelled") } + if len(got.history) != 1 || !got.history[0].NeedsRefresh { + t.Fatalf("expected scanning parent history to be marked for refresh, got %+v", got.history) + } +} - nestedFile := filepath.Join(nested, "nested.txt") - if err := os.WriteFile(nestedFile, []byte("nested-data"), 0o644); err != nil { - t.Fatalf("write nested file: %v", err) +func TestEnterSelectedDirRefreshesStaleInMemoryCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + parent := filepath.Join(home, "parent") + child := filepath.Join(parent, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) } - result := performScanForJSON(root) + freshPath := filepath.Join(child, "fresh.bin") + if err := os.WriteFile(freshPath, []byte("fresh-data"), 0o644); err != nil { + t.Fatalf("write fresh file: %v", err) + } + freshInfo, err := os.Stat(freshPath) + if err != nil { + t.Fatalf("stat fresh file: %v", err) + } + freshSize := getActualFileSize(freshPath, freshInfo) - if result.TotalFiles != 2 { - t.Fatalf("expected 2 files in JSON output, got %d", result.TotalFiles) + warmed := scanResult{ + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 1}}, + TotalSize: 1, + TotalFiles: 1, + } + if err := saveCacheToDiskWithOptions(child, warmed, true); err != nil { + t.Fatalf("saveCacheToDiskWithOptions: %v", err) } -} -func TestDeletePathWithProgress(t *testing.T) { - skipIfFinderUnavailable(t) + m := newModel(parent, false) + m.entries = []dirEntry{{Name: "child", Path: child, Size: 9, IsDir: true}} + m.cache[child] = historyEntry{ + Path: child, + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 1}}, + TotalSize: 1, + TotalFiles: 1, + NeedsRefresh: true, + } - parent := t.TempDir() - target := filepath.Join(parent, "target") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target: %v", err) + updated, cmd := m.enterSelectedDir() + if cmd == nil { + t.Fatalf("expected stale in-memory child cache to trigger a refresh") } - files := []string{ - filepath.Join(target, "one.txt"), - filepath.Join(target, "two.txt"), + got := updated.(model) + if got.path != child { + t.Fatalf("expected path %s, got %s", child, got.path) } - for _, f := range files { - if err := os.WriteFile(f, []byte("content"), 0o644); err != nil { - t.Fatalf("write %s: %v", f, err) - } + if !got.scanning { + t.Fatalf("expected directory to remain scanning while refreshing stale cache") + } + if got.totalSize != 1 { + t.Fatalf("expected stale cache contents to be shown immediately, got %d", got.totalSize) } - var counter int64 - count, err := trashPathWithProgress(target, &counter) - if err != nil { - t.Fatalf("trashPathWithProgress returned error: %v", err) + scanMsg := runScanResultCmd(t, cmd) + if scanMsg.stale { + t.Fatalf("expected stale cached navigation to force a fresh scan") } - if count != int64(len(files)) { - t.Fatalf("expected %d files trashed, got %d", len(files), count) + if scanMsg.result.TotalSize != freshSize { + t.Fatalf("expected fresh rescan total size %d, got %d", freshSize, scanMsg.result.TotalSize) } - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("expected target to be moved to Trash, stat err=%v", err) + if scanMsg.result.Entries[0].Name != "fresh.bin" { + t.Fatalf("expected rescan to surface live filesystem contents, got %+v", scanMsg.result.Entries) } } -func TestOverviewStoreAndLoad(t *testing.T) { +func TestGoBackRefreshesHistoryEntryNeedingRefresh(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - resetOverviewSnapshotForTest() - t.Cleanup(resetOverviewSnapshotForTest) - - path := filepath.Join(home, "project") - want := int64(123456) - if err := storeOverviewSize(path, want); err != nil { - t.Fatalf("storeOverviewSize: %v", err) + child := filepath.Join(home, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) } - got, err := loadStoredOverviewSize(path) + freshPath := filepath.Join(child, "fresh.bin") + if err := os.WriteFile(freshPath, []byte("fresh-data-2"), 0o644); err != nil { + t.Fatalf("write fresh file: %v", err) + } + freshInfo, err := os.Stat(freshPath) if err != nil { - t.Fatalf("loadStoredOverviewSize: %v", err) + t.Fatalf("stat fresh file: %v", err) } - if got != want { - t.Fatalf("snapshot mismatch: want %d, got %d", want, got) + freshSize := getActualFileSize(freshPath, freshInfo) + + warmed := scanResult{ + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 2}}, + TotalSize: 2, + TotalFiles: 1, + } + if err := saveCacheToDiskWithOptions(child, warmed, true); err != nil { + t.Fatalf("saveCacheToDiskWithOptions: %v", err) } - // Reload from disk and ensure value persists. - resetOverviewSnapshotForTest() - got, err = loadStoredOverviewSize(path) - if err != nil { - t.Fatalf("loadStoredOverviewSize after reset: %v", err) + m := newModel(filepath.Join(child, "grandchild"), false) + m.history = []historyEntry{{ + Path: child, + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 2}}, + TotalSize: 2, + TotalFiles: 1, + NeedsRefresh: true, + }} + + updated, cmd := m.goBack() + if cmd == nil { + t.Fatalf("expected stale history entry to trigger a refresh") } - if got != want { - t.Fatalf("snapshot mismatch after reset: want %d, got %d", want, got) + + got := updated.(model) + if got.path != child { + t.Fatalf("expected path %s after goBack, got %s", child, got.path) + } + if !got.scanning { + t.Fatalf("expected goBack to keep scanning while refreshing stale history entry") + } + if got.totalSize != 2 { + t.Fatalf("expected stale history snapshot to be restored immediately, got %d", got.totalSize) + } + + scanMsg := runScanResultCmd(t, cmd) + if scanMsg.stale { + t.Fatalf("expected stale history navigation to force a fresh scan") + } + if scanMsg.result.TotalSize != freshSize { + t.Fatalf("expected fresh rescan total size %d, got %d", freshSize, scanMsg.result.TotalSize) + } + if scanMsg.result.Entries[0].Name != "fresh.bin" { + t.Fatalf("expected rescan to surface live filesystem contents, got %+v", scanMsg.result.Entries) } } -func TestCacheSaveLoadRoundTrip(t *testing.T) { +func TestScanPathConcurrentWarmsChildCacheWithLiveProgress(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - target := filepath.Join(home, "cache-target") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target dir: %v", err) + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) } - result := scanResult{ - Entries: []dirEntry{ - {Name: "alpha", Path: filepath.Join(target, "alpha"), Size: 10, IsDir: true}, - }, - LargeFiles: []fileEntry{ - {Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2048}, - }, - TotalSize: 42, + const dirCount = 32 + const filesPerDir = 256 + for i := range dirCount { + dir := filepath.Join(child, fmt.Sprintf("dir-%02d", i)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create nested dir %s: %v", dir, err) + } + for j := range filesPerDir { + file := filepath.Join(dir, fmt.Sprintf("file-%03d.bin", j)) + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", file, err) + } + } } - if err := saveCacheToDisk(target, result); err != nil { - t.Fatalf("saveCacheToDisk: %v", err) - } + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") - cache, err := loadCacheFromDisk(target) - if err != nil { - t.Fatalf("loadCacheFromDisk: %v", err) - } - if cache.TotalSize != result.TotalSize { - t.Fatalf("total size mismatch: want %d, got %d", result.TotalSize, cache.TotalSize) + done := make(chan struct{}) + errCh := make(chan error, 1) + go func() { + _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + errCh <- err + close(done) + }() + + deadline := time.Now().Add(5 * time.Second) + sawLiveProgress := false + for time.Now().Before(deadline) { + if atomic.LoadInt64(&filesScanned) > 0 { + select { + case <-done: + default: + sawLiveProgress = true + } + if sawLiveProgress { + break + } + } + select { + case <-done: + if !sawLiveProgress { + t.Fatalf("expected live progress before child warm scan completed, final files=%d", atomic.LoadInt64(&filesScanned)) + } + default: + } + time.Sleep(2 * time.Millisecond) } - if len(cache.Entries) != len(result.Entries) { - t.Fatalf("entry count mismatch: want %d, got %d", len(result.Entries), len(cache.Entries)) + + if !sawLiveProgress { + t.Fatalf("expected filesScanned to advance before warm child scan finished") } - if len(cache.LargeFiles) != len(result.LargeFiles) { - t.Fatalf("large file count mismatch: want %d, got %d", len(result.LargeFiles), len(cache.LargeFiles)) + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + case <-time.After(5 * time.Second): + t.Fatalf("scan did not complete") } } @@ -700,3 +2918,135 @@ func TestCalculateDirSizeFastHighFanoutCompletes(t *testing.T) { t.Fatalf("calculateDirSizeFast did not complete under high fan-out") } } + +func TestSystemOverviewRootsDefaultsToRealSystemPaths(t *testing.T) { + roots := systemOverviewRoots() + if len(roots) != 2 { + t.Fatalf("expected 2 default system roots, got %d", len(roots)) + } + if roots[0].Path != "/Applications" || roots[1].Path != "/Library" { + t.Fatalf("unexpected default system roots: %q, %q", roots[0].Path, roots[1].Path) + } + for _, root := range roots { + if root.Size != -1 || !root.IsDir { + t.Fatalf("default root %q must start pending and be a dir, got size=%d isDir=%v", + root.Path, root.Size, root.IsDir) + } + } +} + +func TestDeleteViewHidesZeroTally(t *testing.T) { + // The delete counter is path-level and only advances once a move finishes, so a + // single large directory sits at zero for the whole operation. Printing + // "0 items removed" there reads as a stalled delete. + var counter int64 + m := model{deleting: true, deleteCount: &counter} + + view := m.View() + if strings.Contains(view, "0 items") { + t.Fatalf("expected no zero tally while nothing has completed, got:\n%s", view) + } + if !strings.Contains(view, "moving to Trash") { + t.Fatalf("expected a progress line while deleting, got:\n%s", view) + } + + atomic.StoreInt64(&counter, 2) + view = m.View() + if !strings.Contains(view, "2") || !strings.Contains(view, "items") { + t.Fatalf("expected the tally once paths completed, got:\n%s", view) + } +} + +func TestDeleteProgressPartialFailureRemovesSucceededPathsAndRefreshes(t *testing.T) { + var filesScanned int64 + var dirsScanned int64 + var bytesScanned int64 + var currentPath atomic.Value + parent := t.TempDir() + removed := filepath.Join(parent, "removed") + failed := filepath.Join(parent, "failed") + + m := model{ + path: parent, + entries: []dirEntry{{Path: removed, Size: 10}, {Path: failed, Size: 20}}, + entriesAll: []dirEntry{{Path: removed, Size: 10}, {Path: failed, Size: 20}}, + totalSize: 30, + deleting: true, + filesScanned: &filesScanned, + dirsScanned: &dirsScanned, + bytesScanned: &bytesScanned, + currentPath: ¤tPath, + cache: map[string]historyEntry{ + parent: {}, + }, + multiSelected: map[string]bool{removed: true, failed: true}, + largeMultiSelected: map[string]bool{}, + } + + updated, cmd := m.Update(deleteProgressMsg{ + done: true, + err: fmt.Errorf("permission denied"), + count: 1, + removedPaths: []string{removed}, + }) + got := updated.(model) + + if len(got.entries) != 1 || got.entries[0].Path != failed { + t.Fatalf("expected only failed path to remain, got %#v", got.entries) + } + if got.totalSize != 20 { + t.Fatalf("expected successful removal to update total size, got %d", got.totalSize) + } + if !strings.Contains(got.status, "Deleted 1 items; some failed") { + t.Fatalf("expected partial-failure status, got %q", got.status) + } + if entry := got.cache[parent]; !entry.NeedsRefresh { + t.Fatal("expected current path cache to be marked for refresh") + } + if cmd == nil { + t.Fatal("expected partial success to trigger a rescan") + } +} + +// The no-argument invocation is the overview scan. Flipping that routing used to +// be invisible: every Go and CLI JSON test passed with the overview branch +// disabled, because the CLI cases all pass an explicit directory. +func TestResolveScanTargetRouting(t *testing.T) { + cases := []struct { + name string + envPath string + args []string + wantOverview bool + wantPath string + }{ + {name: "no target is the overview scan", wantOverview: true, wantPath: "/"}, + {name: "explicit arg is a directory scan", args: []string{"/tmp"}, wantPath: "/tmp"}, + {name: "env target is a directory scan", envPath: "/tmp", wantPath: "/tmp"}, + {name: "env target wins over args", envPath: "/tmp", args: []string{"/var"}, wantPath: "/tmp"}, + {name: "relative arg resolves to absolute", args: []string{"."}, wantPath: mustAbs(t, ".")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path, isOverview, err := resolveScanTarget(tc.envPath, tc.args) + if err != nil { + t.Fatalf("resolveScanTarget: %v", err) + } + if isOverview != tc.wantOverview { + t.Errorf("isOverview = %v, want %v", isOverview, tc.wantOverview) + } + if path != tc.wantPath { + t.Errorf("path = %q, want %q", path, tc.wantPath) + } + }) + } +} + +func mustAbs(t *testing.T, path string) string { + t.Helper() + abs, err := filepath.Abs(path) + if err != nil { + t.Fatalf("filepath.Abs(%q): %v", path, err) + } + return abs +} diff --git a/Resources/mole/cmd/analyze/cache.go b/Resources/mole/cmd/analyze/cache.go index 872a8d5..cb698ad 100644 --- a/Resources/mole/cmd/analyze/cache.go +++ b/Resources/mole/cmd/analyze/cache.go @@ -1,22 +1,37 @@ +//go:build darwin + package main import ( + "container/heap" "context" "encoding/gob" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "slices" + "strconv" + "strings" "sync" + "sync/atomic" "time" "github.com/cespare/xxhash/v2" ) +// cacheSchemaVersion is bumped whenever directory-size semantics change so +// stale on-disk cache entries are rejected instead of silently reused. +// v2: analyze deduplicates hardlinked files to match `du`. +// v3: ordinary Parallels VM storage is included instead of skipped by name. +const cacheSchemaVersion = 3 + type overviewSizeSnapshot struct { - Size int64 `json:"size"` - Updated time.Time `json:"updated"` + Size int64 `json:"size"` + Updated time.Time `json:"updated"` + SchemaVersion int `json:"schema_version"` } var ( @@ -36,13 +51,35 @@ func snapshotFromModel(m model) historyEntry { EntryOffset: m.offset, LargeSelected: m.largeSelected, LargeOffset: m.largeOffset, + NeedsRefresh: m.viewNeedsRefresh || m.scanning, IsOverview: m.isOverview, } } -func cacheSnapshot(m model) historyEntry { - entry := snapshotFromModel(m) - entry.Dirty = false +func filterNonEmptyEntries(entries []dirEntry) []dirEntry { + filtered := make([]dirEntry, 0, len(entries)) + for _, entry := range entries { + if entry.Size > 0 { + filtered = append(filtered, entry) + } + } + return filtered +} + +func historyEntryFromScanResult(path string, result scanResult, previous historyEntry, needsRefresh bool) historyEntry { + entry := historyEntry{ + Path: path, + Entries: slices.Clone(result.Entries), + LargeFiles: slices.Clone(result.LargeFiles), + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + Selected: previous.Selected, + EntryOffset: previous.EntryOffset, + LargeSelected: previous.LargeSelected, + LargeOffset: previous.LargeOffset, + NeedsRefresh: needsRefresh, + IsOverview: previous.IsOverview, + } return entry } @@ -76,6 +113,15 @@ func ensureOverviewSnapshotCacheLocked() error { overviewSnapshotLoaded = true return nil } + // Drop what the loader would refuse anyway. Snapshots were only ever added, + // so without this every directory ever browsed stayed in the file forever, + // and each save re-serialized all of them. + now := time.Now() + for path, snapshot := range snapshots { + if snapshot.SchemaVersion != cacheSchemaVersion || snapshot.Size <= 0 || now.Sub(snapshot.Updated) >= overviewCacheTTL { + delete(snapshots, path) + } + } overviewSnapshotCache = snapshots overviewSnapshotLoaded = true return nil @@ -122,27 +168,84 @@ func storeOverviewSize(path string, size int64) error { if overviewSnapshotCache == nil { overviewSnapshotCache = make(map[string]overviewSizeSnapshot) } + // Re-measuring a directory usually returns the size already on record, and + // every save re-serializes and rewrites the entire store. Skip the write + // while the recorded value still stands; the timestamp is only refreshed + // often enough to keep a live entry from aging out. + if existing, ok := overviewSnapshotCache[path]; ok && existing.Size == size && + time.Since(existing.Updated) < overviewCacheTTL/overviewRefreshDivisor { + return nil + } overviewSnapshotCache[path] = overviewSizeSnapshot{ - Size: size, - Updated: time.Now(), + Size: size, + Updated: time.Now(), + SchemaVersion: cacheSchemaVersion, } + evictOverviewSnapshotsLocked() return persistOverviewSnapshotLocked() } +// evictOverviewSnapshotsLocked keeps the store bounded by dropping the oldest +// snapshots once it outgrows the cap, in one pass down to the low-water mark so +// eviction does not run on every subsequent save. +func evictOverviewSnapshotsLocked() { + if len(overviewSnapshotCache) <= overviewCacheMaxEntries || + overviewCacheKeepEntries >= len(overviewSnapshotCache) { + return + } + type agedSnapshot struct { + path string + updated time.Time + } + aged := make([]agedSnapshot, 0, len(overviewSnapshotCache)) + for path, snapshot := range overviewSnapshotCache { + aged = append(aged, agedSnapshot{path: path, updated: snapshot.Updated}) + } + slices.SortFunc(aged, func(a, b agedSnapshot) int { return a.updated.Compare(b.updated) }) + for _, entry := range aged[:len(aged)-overviewCacheKeepEntries] { + delete(overviewSnapshotCache, entry.path) + } +} + func persistOverviewSnapshotLocked() error { storePath, err := getOverviewSizeStorePath() if err != nil { return err } - tmpPath := storePath + ".tmp" - data, err := json.MarshalIndent(overviewSnapshotCache, "", " ") + // No indentation: nothing reads this by eye, and the padding was a third of + // a file rewritten on every save. + data, err := json.Marshal(overviewSnapshotCache) + if err != nil { + return err + } + // A uniquely named temp file, so two analyzers running at once cannot land + // in each other's half-written store. + tmp, err := os.CreateTemp(filepath.Dir(storePath), overviewCacheFile+".*.tmp") if err != nil { return err } - if err := os.WriteFile(tmpPath, data, 0644); err != nil { + tmpPath := tmp.Name() + // Chmod through the handle, not the path: CreateTemp opens at 0600 and the + // store has always been world-readable. + if err := tmp.Chmod(0644); err != nil { + tmp.Close() //nolint:errcheck + _ = os.Remove(tmpPath) return err } - return os.Rename(tmpPath, storePath) + if _, err := tmp.Write(data); err != nil { + tmp.Close() //nolint:errcheck + _ = os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, storePath); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil } func loadOverviewCachedSize(path string) (int64, error) { @@ -160,16 +263,57 @@ func loadOverviewCachedSize(path string) (int64, error) { return cacheEntry.TotalSize, nil } -func getCacheDir() (string, error) { +// moleCacheRoot is the single definition of the shared cache location; both +// accessors below build on it so the layout is stated once. +func moleCacheRoot(home string) string { + return filepath.Join(home, ".cache", "mole") +} + +// getMoleCacheRoot is the shared `~/.cache/mole` directory. The shell side +// keeps its own state files there, so nothing may be swept from it wholesale. +func getMoleCacheRoot() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } - cacheDir := filepath.Join(home, ".cache", "mole") - if err := os.MkdirAll(cacheDir, 0755); err != nil { + return moleCacheRoot(home), nil +} + +// resolvedCacheDir memoizes the analyzer cache directory together with the HOME +// it was derived from, so a test that repoints HOME still gets a fresh answer. +type resolvedCacheDir struct { + home string + dir string + err error +} + +var cachedAnalyzerDir atomic.Pointer[resolvedCacheDir] + +// getCacheDir is the analyzer-owned subdirectory. Keeping analyzer entries out +// of the shared root is what lets the legacy sweep and the entry caps operate +// on a directory whose every file the analyzer owns. +// +// The MkdirAll result is memoized because this sits on the scan hot path: every +// directory walked resolves a cache path at least once, so re-running the +// syscall per directory cost a measured 1.5us and 688B each, roughly 470ms and +// 200MB of garbage across a whole-disk scan, all to re-learn that a directory +// created moments ago still exists. +func getCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { return "", err } - return cacheDir, nil + if resolved := cachedAnalyzerDir.Load(); resolved != nil && resolved.home == home { + return resolved.dir, resolved.err + } + + dir := filepath.Join(moleCacheRoot(home), analyzerCacheDirName) + resolved := &resolvedCacheDir{home: home, dir: dir} + if mkErr := os.MkdirAll(dir, 0755); mkErr != nil { + resolved.dir, resolved.err = "", mkErr + } + cachedAnalyzerDir.Store(resolved) + return resolved.dir, resolved.err } func getCachePath(path string) (string, error) { @@ -177,9 +321,236 @@ func getCachePath(path string) (string, error) { if err != nil { return "", err } + // Built by concatenation rather than filepath.Join: cacheDir is already + // clean, and Join would re-Clean it on every directory scanned. hash := xxhash.Sum64String(path) - filename := fmt.Sprintf("%x.cache", hash) - return filepath.Join(cacheDir, filename), nil + var name strings.Builder + name.Grow(len(cacheDir) + 23) + name.WriteString(cacheDir) + name.WriteByte(filepath.Separator) + name.WriteString(strconv.FormatUint(hash, 16)) + name.WriteString(".cache") + return name.String(), nil +} + +// shouldPersistSubdirCache decides whether a scanned subtree earns a cache +// file. Memoizing a directory that holds a handful of files costs more than it +// saves: the entry occupies a whole 4KB block plus an inode, and reading it +// back is slower than the single readdir it replaces. Only subtrees expensive +// enough to rescan are persisted; see the budget comment in constants.go. +func shouldPersistSubdirCache(result scanResult) bool { + return result.TotalFiles >= subdirCacheMinFiles || result.TotalSize >= subdirCacheMinSize +} + +// removeCacheEntry drops a cache file that can never be useful again (its +// schema is gone, it failed to decode, its directory no longer exists, or it +// outlived the TTL). Expiring an entry only at load time used to leave the file +// on disk until a prune pass happened to reach it, which is how a store grows +// far past what any scan still reads. +// +// Deliberately not invalidateCache: that also drops the overview snapshot, +// which rewrites the whole JSON store under a lock. This runs per entry inside +// a scan, so it stays a single unlink. +func removeCacheEntry(path string) { + if cachePath, err := getCachePath(path); err == nil { + _ = os.Remove(cachePath) + } +} + +// cacheFileStat is the eviction record for one analyzer cache file. +type cacheFileStat struct { + name string + modTime time.Time + size int64 +} + +// oldestFirstHeap is a min-heap by mod time, so the root is always the next +// entry to evict. Retaining the newest N through a bounded heap keeps prune's +// memory tied to the cap instead of to the directory it is pruning. +type oldestFirstHeap []cacheFileStat + +func (h oldestFirstHeap) Len() int { return len(h) } +func (h oldestFirstHeap) Less(i, j int) bool { return h[i].modTime.Before(h[j].modTime) } +func (h oldestFirstHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *oldestFirstHeap) Push(x any) { *h = append(*h, x.(cacheFileStat)) } +func (h *oldestFirstHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + *h = old[0 : n-1] + return x +} + +func pruneAnalyzerCache() { + // Best-effort throughout; errors are ignored so startup never blocks on + // cache housekeeping. + if root, err := getMoleCacheRoot(); err == nil { + _ = sweepLegacyAnalyzerCache(root) + } + cacheDir, err := getCacheDir() + if err != nil { + return + } + _ = pruneAnalyzerCacheDir(cacheDir, time.Now()) +} + +// pruneAnalyzerCacheDir removes expired entries and then enforces the count and +// byte caps, evicting oldest first. The directory is streamed in batches rather +// than read whole: os.ReadDir buffers and sorts every name, which is exactly +// what an oversized store cannot afford. +func pruneAnalyzerCacheDir(cacheDir string, now time.Time) error { + return pruneAnalyzerCacheDirWithLimits(cacheDir, now, analyzerCacheMaxEntries, analyzerCacheMaxBytes) +} + +// maxEntries must stay positive: it is what bounds the retained heap, and with +// it the memory this pass needs. A non-positive maxBytes just disables the byte +// cap. +func pruneAnalyzerCacheDirWithLimits(cacheDir string, now time.Time, maxEntries int, maxBytes int64) error { + if cacheDir == "" || analyzerCacheTTL <= 0 { + return nil + } + + dir, err := os.Open(cacheDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer dir.Close() //nolint:errcheck + + cutoff := now.Add(-analyzerCacheTTL) + retained := &oldestFirstHeap{} + var retainedBytes int64 + + evictOldest := func() { + if retained.Len() == 0 { + return + } + oldest := heap.Pop(retained).(cacheFileStat) + retainedBytes -= oldest.size + _ = os.Remove(filepath.Join(cacheDir, oldest.name)) + } + + tempCutoff := now.Add(-staleTempFileTTL) + for { + entries, readErr := dir.ReadDir(cacheDirReadBatch) + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + continue + } + // Atomic saves stage through temp files. One only outlives its + // write by milliseconds, so anything older is debris from a killed + // process, and nothing else in this pass would ever collect it. + if filepath.Ext(entry.Name()) == ".tmp" { + if info, infoErr := entry.Info(); infoErr == nil && + info.Mode().IsRegular() && !info.ModTime().After(tempCutoff) { + _ = os.Remove(filepath.Join(cacheDir, entry.Name())) + } + continue + } + if filepath.Ext(entry.Name()) != ".cache" { + continue + } + + info, infoErr := entry.Info() + if infoErr != nil || !info.Mode().IsRegular() { + continue + } + if !info.ModTime().After(cutoff) { + _ = os.Remove(filepath.Join(cacheDir, entry.Name())) + continue + } + + heap.Push(retained, cacheFileStat{ + name: entry.Name(), + modTime: info.ModTime(), + size: info.Size(), + }) + retainedBytes += info.Size() + if maxEntries > 0 && retained.Len() > maxEntries { + evictOldest() + } + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + break + } + return readErr + } + if len(entries) == 0 { + break + } + } + + for maxBytes > 0 && retained.Len() > 0 && retainedBytes > maxBytes { + evictOldest() + } + + return nil +} + +// sweepLegacyAnalyzerCache removes the flat `.cache` entries the analyzer +// used to write directly into `~/.cache/mole`, plus the overview snapshot that +// sat beside them. Everything else in that directory belongs to the shell side +// and is left alone. +// +// The sweep streams the directory rather than reading it whole, so a legacy +// store with millions of entries is never held in memory, and it is resumable: +// whatever a short-lived process does not reach is picked up by the next run. +// Unlinks are spread over a few workers because a single one clears roughly +// 7k files/s on APFS, which is a long tail on the largest stores seen in the +// wild (1.88M files); the small pool roughly doubles that without turning +// housekeeping into a competitor for the scan itself. +func sweepLegacyAnalyzerCache(root string) error { + if root == "" { + return nil + } + + dir, err := os.Open(root) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer dir.Close() //nolint:errcheck + + names := make(chan string, cacheDirReadBatch) + var workers sync.WaitGroup + for range legacySweepWorkers { + workers.Go(func() { + for name := range names { + _ = os.Remove(filepath.Join(root, name)) + } + }) + } + defer func() { + close(names) + workers.Wait() + }() + + for { + entries, readErr := dir.ReadDir(cacheDirReadBatch) + for _, entry := range entries { + if !entry.Type().IsRegular() { + continue + } + if filepath.Ext(entry.Name()) != ".cache" && entry.Name() != overviewCacheFile { + continue + } + names <- entry.Name() + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + if len(entries) == 0 { + return nil + } + } } func loadRawCacheFromDisk(path string) (*cacheEntry, error) { @@ -197,9 +568,17 @@ func loadRawCacheFromDisk(path string) (*cacheEntry, error) { var entry cacheEntry decoder := gob.NewDecoder(file) if err := decoder.Decode(&entry); err != nil { + // A truncated or foreign payload will never decode; keeping it only + // costs a block until some prune pass reaches it. + _ = os.Remove(cachePath) return nil, err } + if entry.SchemaVersion != cacheSchemaVersion { + _ = os.Remove(cachePath) + return nil, fmt.Errorf("cache schema mismatch: got %d, want %d", entry.SchemaVersion, cacheSchemaVersion) + } + return &entry, nil } @@ -211,11 +590,18 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) { info, err := os.Stat(path) if err != nil { + // The directory is gone, so nothing will ever refresh or reuse this + // entry. Churn-heavy trees (node_modules, simulator data) otherwise + // leave one orphan per directory behind for a full TTL. + if os.IsNotExist(err) { + removeCacheEntry(path) + } return nil, err } scanAge := time.Since(entry.ScanTime) - if scanAge > 7*24*time.Hour { + if scanAge > analyzerCacheTTL { + removeCacheEntry(path) return nil, fmt.Errorf("cache expired: too old") } @@ -242,9 +628,14 @@ func loadStaleCacheFromDisk(path string) (*cacheEntry, error) { } if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + removeCacheEntry(path) + } return nil, err } + // Only the authoritative TTL deletes: staleCacheTTL is the shorter + // first-paint window, and an entry past it is still valid for a full scan. if time.Since(entry.ScanTime) > staleCacheTTL { return nil, fmt.Errorf("stale cache expired") } @@ -253,6 +644,10 @@ func loadStaleCacheFromDisk(path string) (*cacheEntry, error) { } func saveCacheToDisk(path string, result scanResult) error { + return saveCacheToDiskWithOptions(path, result, false) +} + +func saveCacheToDiskWithOptions(path string, result scanResult, needsRefresh bool) error { cachePath, err := getCachePath(path) if err != nil { return err @@ -264,57 +659,84 @@ func saveCacheToDisk(path string, result scanResult) error { } entry := cacheEntry{ - Entries: result.Entries, - LargeFiles: result.LargeFiles, - TotalSize: result.TotalSize, - TotalFiles: result.TotalFiles, - ModTime: info.ModTime(), - ScanTime: time.Now(), - } - - file, err := os.Create(cachePath) + Entries: result.Entries, + LargeFiles: result.LargeFiles, + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + ModTime: info.ModTime(), + ScanTime: time.Now(), + NeedsRefresh: needsRefresh, + SchemaVersion: cacheSchemaVersion, + } + + // Written through a temp file so a kill mid-encode cannot leave a truncated + // entry in place of a good one, and so two analyzers scanning the same tree + // never interleave into one file. + tmp, err := os.CreateTemp(filepath.Dir(cachePath), "entry-*.tmp") if err != nil { return err } - defer file.Close() //nolint:errcheck - - encoder := gob.NewEncoder(file) - return encoder.Encode(entry) + tmpPath := tmp.Name() + if err := tmp.Chmod(0644); err != nil { + tmp.Close() //nolint:errcheck + _ = os.Remove(tmpPath) + return err + } + if err := gob.NewEncoder(tmp).Encode(entry); err != nil { + tmp.Close() //nolint:errcheck + _ = os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, cachePath); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil } -// peekCacheTotalFiles attempts to read the total file count from cache, -// ignoring expiration. Used for initial scan progress estimates. +// peekCacheTotalFiles reads the total file count from cache, ignoring +// expiration, for initial scan progress estimates. It shares +// loadRawCacheFromDisk so a schema-stale or corrupt entry is rejected and +// cleaned up here too rather than feeding an estimate no scan would accept. func peekCacheTotalFiles(path string) (int64, error) { - cachePath, err := getCachePath(path) - if err != nil { - return 0, err - } - - file, err := os.Open(cachePath) + entry, err := loadRawCacheFromDisk(path) if err != nil { return 0, err } - defer file.Close() //nolint:errcheck - - var entry cacheEntry - decoder := gob.NewDecoder(file) - if err := decoder.Decode(&entry); err != nil { - return 0, err - } - return entry.TotalFiles, nil } func invalidateCache(path string) { - cachePath, err := getCachePath(path) - if err == nil { - _ = os.Remove(cachePath) + removeCacheEntry(path) + removeOverviewSnapshots(path) +} + +// invalidateCacheTree invalidates the cache for path and all its direct +// child directories so that a rescan does not reuse stale subdirectory +// sizes. See #812. +func invalidateCacheTree(path string) { + paths := []string{path} + if children, err := os.ReadDir(path); err == nil { + for _, child := range children { + if child.IsDir() { + paths = append(paths, filepath.Join(path, child.Name())) + } + } } - removeOverviewSnapshot(path) + for _, target := range paths { + removeCacheEntry(target) + } + // One snapshot save for the whole tree: dropping them one at a time + // rewrote the entire overview store once per child directory. + removeOverviewSnapshots(paths...) } -func removeOverviewSnapshot(path string) { - if path == "" { +func removeOverviewSnapshots(paths ...string) { + if len(paths) == 0 { return } overviewSnapshotMu.Lock() @@ -325,8 +747,17 @@ func removeOverviewSnapshot(path string) { if overviewSnapshotCache == nil { return } - if _, ok := overviewSnapshotCache[path]; ok { - delete(overviewSnapshotCache, path) + removed := false + for _, path := range paths { + if path == "" { + continue + } + if _, ok := overviewSnapshotCache[path]; ok { + delete(overviewSnapshotCache, path) + removed = true + } + } + if removed { _ = persistOverviewSnapshotLocked() } } @@ -347,16 +778,29 @@ func prefetchOverviewCache(ctx context.Context) { return } + sem := make(chan struct{}, maxConcurrentOverview) + var wg sync.WaitGroup for _, path := range needScan { select { case <-ctx.Done(): + wg.Wait() return default: } - size, err := measureOverviewSize(path) - if err == nil && size > 0 { - _ = storeOverviewSize(path, size) - } + wg.Go(func() { + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + + size, err := measureOverviewSize(path) + if err == nil && size > 0 { + _ = storeOverviewSize(path, size) + } + }) } + wg.Wait() } diff --git a/Resources/mole/cmd/analyze/cleanable.go b/Resources/mole/cmd/analyze/cleanable.go index 4c80879..f69d83e 100644 --- a/Resources/mole/cmd/analyze/cleanable.go +++ b/Resources/mole/cmd/analyze/cleanable.go @@ -1,10 +1,19 @@ +//go:build darwin + package main import ( + "io" + "os" "path/filepath" "strings" ) +const ( + cacheDirTagFileName = "CACHEDIR.TAG" + cacheDirTagSignature = "Signature: 8a477f597d28d172789f06886806bc55" +) + // isCleanableDir marks paths safe to delete manually (not handled by mo clean). func isCleanableDir(path string) bool { if path == "" { @@ -18,6 +27,11 @@ func isCleanableDir(path string) bool { baseName := filepath.Base(path) + // CACHEDIR.TAG marks the whole directory tree as regenerable cache. + if hasValidCacheDirTag(path) { + return true + } + // Project dependencies and build outputs are safe. if projectDependencyDirs[baseName] { return true @@ -26,18 +40,33 @@ func isCleanableDir(path string) bool { return false } -// isHandledByMoClean checks if a path is cleaned by mo clean. -func isHandledByMoClean(path string) bool { - cleanPaths := []string{ - "/Library/Caches/", - "/Library/Logs/", - "/Library/Saved Application State/", - "/.Trash/", - "/Library/DiagnosticReports/", +func hasValidCacheDirTag(path string) bool { + tagPath := filepath.Join(path, cacheDirTagFileName) + info, err := os.Lstat(tagPath) + if err != nil || !info.Mode().IsRegular() { + return false } - for _, p := range cleanPaths { - if strings.Contains(path, p) { + file, err := os.Open(tagPath) + if err != nil { + return false + } + defer func() { + _ = file.Close() + }() + + buf := make([]byte, len(cacheDirTagSignature)) + if _, err := io.ReadFull(file, buf); err != nil { + return false + } + + return string(buf) == cacheDirTagSignature +} + +// isHandledByMoClean checks if a path is cleaned by mo clean. +func isHandledByMoClean(path string) bool { + for _, fragment := range moCleanHandledPathFragments { + if strings.Contains(path, fragment) { return true } } @@ -45,6 +74,14 @@ func isHandledByMoClean(path string) bool { return false } +var moCleanHandledPathFragments = []string{ + "/Library/Caches/", + "/Library/Logs/", + "/Library/Saved Application State/", + "/.Trash/", + "/Library/DiagnosticReports/", +} + // Project dependency and build directories. var projectDependencyDirs = map[string]bool{ // JavaScript/Node. diff --git a/Resources/mole/cmd/analyze/cleanable_test.go b/Resources/mole/cmd/analyze/cleanable_test.go new file mode 100644 index 0000000..41cab95 --- /dev/null +++ b/Resources/mole/cmd/analyze/cleanable_test.go @@ -0,0 +1,58 @@ +//go:build darwin + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeCacheDirTag(t testing.TB, dir string, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, cacheDirTagFileName), []byte(content), 0o644); err != nil { + t.Fatalf("write cache dir tag: %v", err) + } +} + +func TestIsCleanableDirAcceptsValidCacheDirTag(t *testing.T) { + dir := t.TempDir() + writeCacheDirTag(t, dir, cacheDirTagSignature+"\n# https://bford.info/cachedir/") + + if !isCleanableDir(dir) { + t.Fatalf("expected valid CACHEDIR.TAG directory to be cleanable") + } +} + +func TestIsCleanableDirRejectsInvalidCacheDirTag(t *testing.T) { + tests := map[string]string{ + "wrong signature": "Signature: invalid", + "short file": cacheDirTagSignature[:len(cacheDirTagSignature)-1], + } + + for name, content := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeCacheDirTag(t, dir, content) + + if isCleanableDir(dir) { + t.Fatalf("expected invalid CACHEDIR.TAG directory to stay non-cleanable") + } + }) + } +} + +func TestIsCleanableDirRejectsSymlinkCacheDirTag(t *testing.T) { + dir := t.TempDir() + realTag := filepath.Join(dir, "real-tag") + if err := os.WriteFile(realTag, []byte(cacheDirTagSignature), 0o644); err != nil { + t.Fatalf("write real tag: %v", err) + } + if err := os.Symlink(realTag, filepath.Join(dir, cacheDirTagFileName)); err != nil { + t.Fatalf("symlink cache dir tag: %v", err) + } + + if isCleanableDir(dir) { + t.Fatalf("expected symlink CACHEDIR.TAG directory to stay non-cleanable") + } +} diff --git a/Resources/mole/cmd/analyze/constants.go b/Resources/mole/cmd/analyze/constants.go index d400035..1b3f999 100644 --- a/Resources/mole/cmd/analyze/constants.go +++ b/Resources/mole/cmd/analyze/constants.go @@ -1,14 +1,20 @@ +//go:build darwin + package main import "time" const ( - maxEntries = 30 - maxLargeFiles = 20 - barWidth = 24 + maxEntries = 30 + maxLargeFiles = 20 + barWidth = 24 + // Below this many columns a scanned path is too clipped to tell anything + // apart, so it moves to its own row instead of sharing the status line. + scanPathInlineMinWidth = 24 spotlightMinFileSize = 100 << 20 largeFileWarmupMinSize = 1 << 20 defaultViewport = 12 + analyzerCacheTTL = 7 * 24 * time.Hour overviewCacheTTL = 7 * 24 * time.Hour overviewCacheFile = "overview_sizes.json" duTimeout = 30 * time.Second @@ -19,14 +25,55 @@ const ( cacheReuseWindow = 24 * time.Hour staleCacheTTL = 3 * 24 * time.Hour - // Worker pool limits. - minWorkers = 16 - maxWorkers = 64 - cpuMultiplier = 4 - maxDirWorkers = 32 + // Analyzer cache admission and eviction budget. A subtree is only worth a + // cache file when rescanning it is actually expensive: on a dev machine's + // 157k-entry cache the MEDIAN entry described a directory holding one file + // and 98% held fewer than 100, so nearly every file spent a 4KB APFS block + // plus an inode memoizing what a single readdir returns. Unbounded and + // admission-free, that reached 1.88M files / 7.82GB for one user. The + // thresholds keep the ~1.5% of entries that carry the reuse value; the + // count/byte caps are the backstop for trees that clear them anyway. + analyzerCacheDirName = "analyzer" + subdirCacheMinFiles = 100 + subdirCacheMinSize = 10 << 20 + analyzerCacheMaxEntries = 5000 + analyzerCacheMaxBytes = 50 << 20 + cacheDirReadBatch = 512 + legacySweepWorkers = 4 + staleTempFileTTL = time.Hour + + // Overview snapshot store budget. The store is one JSON file rewritten in + // full on every save, so both its length and its save rate have to be held + // down: the cap bounds the file, and the refresh divisor turns repeat + // measurements of an unchanged directory into no-ops until the entry is + // within 1/8 of the TTL of aging out. + overviewCacheMaxEntries = 1000 + overviewCacheKeepEntries = 900 + overviewRefreshDivisor = 8 + + // Worker pool limits. Deliberately conservative: the User Library scan + // blocks many goroutines in syscalls on high-fan-out trees (Steam + // workshop/temp, browser caches), and each blocked goroutine holds an + // OS thread. Exceeding the per-user thread limit on macOS produces a + // fatal "runtime: failed to create new OS thread" with no recovery. + // Further reduced after #765: System Library (184GB, 261k files) with + // deep permission checks can still exhaust threads at previous limits. + minWorkers = 2 + maxWorkers = 12 + cpuMultiplier = 1 + maxDirWorkers = 6 openCommandTimeout = 10 * time.Second + scanSendTimeout = 100 * time.Millisecond + uiTickInterval = 100 * time.Millisecond ) +var overviewDuIgnoreNames = map[string]bool{ + // iCloud Drive's FileProvider tree can block `du` for tens of seconds even + // when most entries are cloud placeholders. Keep the overview responsive; + // users can still drill into the folder explicitly when they need it. + "Mobile Documents": true, +} + var foldDirs = map[string]bool{ // VCS. ".git": true, @@ -193,7 +240,6 @@ var defaultSkipDirs = map[string]bool{ // Virtualization/Container mounts (NFS, network filesystems). "OrbStack": true, // OrbStack NFS mounts "Colima": true, // Colima VM mounts - "Parallels": true, // Parallels Desktop VMs "VMware Fusion": true, // VMware Fusion VMs "VirtualBox VMs": true, // VirtualBox VMs "Rancher Desktop": true, // Rancher Desktop mounts @@ -251,7 +297,7 @@ var spinnerFrames = []string{"|", "/", "-", "\\", "|", "/", "-", "\\"} const ( colorPurple = "\033[0;35m" colorPurpleBold = "\033[1;35m" - colorGray = "\033[0;90m" + colorGray = "\033[0;38;5;244m" colorRed = "\033[0;31m" colorYellow = "\033[0;33m" colorGreen = "\033[0;32m" diff --git a/Resources/mole/cmd/analyze/delete.go b/Resources/mole/cmd/analyze/delete.go index 11feaee..f3f5f5b 100644 --- a/Resources/mole/cmd/analyze/delete.go +++ b/Resources/mole/cmd/analyze/delete.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -5,17 +7,34 @@ import ( "fmt" "os" "os/exec" + "os/user" "path/filepath" + "slices" "sort" "strings" "sync/atomic" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" + "golang.org/x/sys/unix" ) const trashTimeout = 30 * time.Second +// trashBinary is Apple's own trash(8). It moves paths to the user Trash without +// involving Finder, which is what makes deletion work over SSH: the Finder +// AppleScript path raises a dialog on the physical machine that a remote user +// cannot answer, so the delete only ever times out (discussion #474). +// +// Invoked by absolute path rather than a PATH lookup so a "trash" shadowed +// earlier in the user's PATH can never receive a delete request. Arguments are +// always absolute paths, so no "--" separator is needed; passing one would make +// trash(8) report a missing file named "--" and exit non-zero even though it +// still trashed the real target, which would trigger a duplicate delete via the +// Finder fallback. +const trashBinary = "/usr/bin/trash" + func deletePathCmd(path string, counter *int64) tea.Cmd { return func() tea.Msg { count, err := trashPathWithProgress(path, counter) @@ -33,6 +52,7 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { return func() tea.Msg { var totalCount int64 var errors []string + var removedPaths []string // Process deeper paths first to avoid parent/child conflicts. pathsToDelete := append([]string(nil), paths...) @@ -45,10 +65,13 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { totalCount += count if err != nil { if os.IsNotExist(err) { + removedPaths = append(removedPaths, path) continue } errors = append(errors, err.Error()) + continue } + removedPaths = append(removedPaths, path) } var resultErr error @@ -57,10 +80,11 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { } return deleteProgressMsg{ - done: true, - err: resultErr, - count: totalCount, - path: "", + done: true, + err: resultErr, + count: totalCount, + path: "", + removedPaths: removedPaths, } } } @@ -77,53 +101,182 @@ func (e *multiDeleteError) Error() string { return strings.Join(e.errors[:min(3, len(e.errors))], "; ") } -// trashPathWithProgress moves a path to Trash using Finder. -// This allows users to recover accidentally deleted files. +// trashPathWithProgress moves one selected path to Trash and reports completion. func trashPathWithProgress(root string, counter *int64) (int64, error) { // Verify path exists (use Lstat to handle broken symlinks). - info, err := os.Lstat(root) + _, err := os.Lstat(root) if err != nil { return 0, err } - // Count items for progress reporting. - var count int64 - if info.IsDir() { - _ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if !d.IsDir() { - count++ - if counter != nil { - atomic.StoreInt64(counter, count) - } - } - return nil - }) - } else { - count = 1 - if counter != nil { - atomic.StoreInt64(counter, 1) - } - } + // Trash moves one selected path as a unit. Recursively counting every file + // first made large directory deletes appear hung before the move began. + const count int64 = 1 - // Move to Trash using Finder AppleScript. + // Move through a headless Trash route, with Finder as the last compatibility fallback. if err := moveToTrash(root); err != nil { return 0, err } + if counter != nil { + atomic.AddInt64(counter, count) + } return count, nil } -// moveToTrash uses macOS Finder to move a file/directory to Trash. -// This is the safest method as it uses the system's native trash mechanism. +// moveToTrash moves a file/directory to the user Trash. macOS 15+ ships +// trash(8); older supported systems use an atomic, no-overwrite move into the +// correct per-volume Trash. Finder is only the final compatibility fallback. func moveToTrash(path string) error { + // Validate raw input before Abs resolves ".." components away. + if err := validateTrashTarget(path); err != nil { + return err + } + absPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("failed to resolve path: %w", err) } + // Validate resolved path as well (defense-in-depth). + if err := validateTrashTarget(absPath); err != nil { + return err + } + + if trashErr := moveToTrashViaBinary(absPath); trashErr == nil { + return nil + } + if filesystemErr := moveToTrashViaFilesystem(absPath); filesystemErr == nil { + return nil + } + + return moveToTrashViaFinder(absPath) +} + +// moveToTrashViaBinary moves absPath to Trash using trash(8). Returns an error +// when the binary is missing so callers fall back to Finder. +func moveToTrashViaBinary(absPath string) error { + if _, err := os.Stat(trashBinary); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), trashTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, trashBinary, absPath) + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timeout moving to Trash") + } + return fmt.Errorf("failed to move to Trash: %s", strings.TrimSpace(string(output))) + } + + return nil +} + +// moveToTrashViaFilesystem provides a headless path for macOS versions before +// trash(8). It uses renameatx_np(RENAME_EXCL), so a concurrent name collision +// can never overwrite an existing Trash item. +func moveToTrashViaFilesystem(absPath string) error { + trashDir, err := trashDirectoryForPath(absPath) + if err != nil { + return err + } + + base := filepath.Base(absPath) + if base == "." || base == string(filepath.Separator) || base == "" { + return fmt.Errorf("invalid Trash item name") + } + + stamp := time.Now().UnixNano() + for attempt := range 100 { + name := base + if attempt > 0 { + name = fmt.Sprintf("%s.%d.%d.%d", base, stamp, os.Getpid(), attempt) + } + dest := filepath.Join(trashDir, name) + err = unix.RenameatxNp(unix.AT_FDCWD, absPath, unix.AT_FDCWD, dest, unix.RENAME_EXCL) + if err == nil { + return nil + } + if err != syscall.EEXIST { + return fmt.Errorf("failed to move to Trash: %w", err) + } + } + + return fmt.Errorf("failed to choose unique Trash destination for %s", absPath) +} + +func trashDirectoryForPath(absPath string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + var pathFS, homeFS unix.Statfs_t + if err := unix.Statfs(absPath, &pathFS); err != nil { + return "", fmt.Errorf("failed to inspect target volume: %w", err) + } + if err := unix.Statfs(home, &homeFS); err != nil { + return "", fmt.Errorf("failed to inspect home volume: %w", err) + } + + if pathFS.Fsid == homeFS.Fsid { + trashDir := filepath.Join(home, ".Trash") + if err := ensureOwnedTrashDirectory(trashDir, true); err != nil { + return "", err + } + return trashDir, nil + } + + mountPoint := strings.TrimRight(string(pathFS.Mntonname[:]), "\x00") + if mountPoint == "" { + return "", fmt.Errorf("target volume has no mount point") + } + trashRoot := filepath.Join(mountPoint, ".Trashes") + rootInfo, err := os.Lstat(trashRoot) + if err != nil { + return "", fmt.Errorf("volume Trash is unavailable: %w", err) + } + if rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() { + return "", fmt.Errorf("volume Trash is not a normal directory") + } + + trashDir := filepath.Join(trashRoot, fmt.Sprintf("%d", os.Getuid())) + if err := ensureOwnedTrashDirectory(trashDir, true); err != nil { + return "", err + } + return trashDir, nil +} + +func ensureOwnedTrashDirectory(path string, create bool) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) && create { + if err := os.Mkdir(path, 0o700); err != nil { + return fmt.Errorf("failed to create Trash directory: %w", err) + } + info, err = os.Lstat(path) + } + if err != nil { + return fmt.Errorf("failed to inspect Trash directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("trash path is not a normal directory") + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Getuid()) { + return fmt.Errorf("trash directory is not owned by the current user") + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("trash directory is writable by another user") + } + return nil +} + +// moveToTrashViaFinder remains as a last fallback for unusual volume layouts. +func moveToTrashViaFinder(absPath string) error { // Escape path for AppleScript (handle quotes and backslashes). escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\") escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"") @@ -144,3 +297,271 @@ func moveToTrash(path string) error { return nil } + +func validateTrashTarget(path string) error { + if err := validatePath(path); err != nil { + return err + } + if isProtectedAnalyzeDeletePath(path) { + return fmt.Errorf("protected path cannot be deleted: %s", path) + } + if resolvedPath, err := filepath.EvalSymlinks(path); err == nil && isProtectedAnalyzeDeletePath(resolvedPath) { + return fmt.Errorf("protected path cannot be deleted: %s", path) + } + return nil +} + +func isProtectedAnalyzeDeletePath(path string) bool { + if path == "" { + return false + } + + cleanPath := filepath.Clean(path) + + // EDR / Darwin-cache protection is based on the absolute path and does not + // depend on HOME, so check it first: an unset HOME must not let a Falcon + // cache slip through (e.g. `env -u HOME mo analyze`). + if isEndpointSecurityCachePath(cleanPath) { + return true + } + if isCriticalAnalyzeDeletePath(cleanPath) { + return true + } + + homeRoots := protectedAnalyzeHomeRoots() + if len(homeRoots) == 0 { + return false + } + + for _, homeRoot := range homeRoots { + if cleanPath == homeRoot || isSameExistingPath(cleanPath, homeRoot) { + return true + } + + dockerDesktopState := filepath.Join(homeRoot, "Library", "Containers", "com.docker.docker") + if cleanPath == dockerDesktopState || strings.HasPrefix(cleanPath, dockerDesktopState+string(filepath.Separator)) { + return true + } + if isPathWithinExistingRoot(cleanPath, dockerDesktopState) { + return true + } + + orbstackState := filepath.Join(homeRoot, ".orbstack") + if cleanPath == orbstackState || strings.HasPrefix(cleanPath, orbstackState+string(filepath.Separator)) { + return true + } + if isPathWithinExistingRoot(cleanPath, orbstackState) { + return true + } + + groupContainers := filepath.Join(homeRoot, "Library", "Group Containers") + if entries, err := os.ReadDir(groupContainers); err == nil { + for _, entry := range entries { + if !strings.HasSuffix(strings.ToLower(entry.Name()), "dev.orbstack") { + continue + } + if isPathWithinExistingRoot(cleanPath, filepath.Join(groupContainers, entry.Name())) { + return true + } + } + } + + rel, err := filepath.Rel(strings.ToLower(groupContainers), strings.ToLower(cleanPath)) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + + containerName := rel + if idx := strings.Index(containerName, string(filepath.Separator)); idx >= 0 { + containerName = containerName[:idx] + } + if strings.HasSuffix(strings.ToLower(containerName), "dev.orbstack") { + return true + } + } + return false +} + +func isCriticalAnalyzeDeletePath(path string) bool { + criticalRoots := []string{ + "/", + "/Applications", + "/Applications/Finder.app", + "/Applications/Safari.app", + "/Library", + "/Library/Apple", + "/Library/Application Support", + "/Library/Extensions", + "/Library/Keychains", + "/System", + "/Users", + "/Volumes", + "/Network", + "/cores", + "/dev", + "/etc", + "/home", + "/net", + "/tmp", + "/var", + "/private", + "/private/etc", + "/private/tmp", + "/private/var", + "/private/var/audit", + "/private/var/db", + "/private/var/root", + "/private/var/tmp", + "/private/var/folders", + "/bin", + "/sbin", + "/usr", + "/opt", + "/opt/homebrew", + } + for _, root := range criticalRoots { + if path == root || isSameExistingPath(path, root) { + return true + } + } + + // A child directly under /Users is another account's home root, not an + // ordinary directory. Protect every account root while keeping its + // descendants available to the owning user. + if isDirectChildOfExistingRoot(path, "/Users") { + return true + } + + // These system-owned trees are never an Analyze cleanup surface, even when + // a caller starts inside one instead of selecting its top-level row. + protectedTrees := []string{ + "/System", + "/bin", + "/sbin", + "/usr", + "/private/etc", + "/private/var/audit", + "/private/var/db", + "/private/var/root", + "/Library/Apple", + "/Library/Extensions", + "/Library/Keychains", + "/Applications/Finder.app", + "/Applications/Safari.app", + "/dev", + } + for _, root := range protectedTrees { + if strings.HasPrefix(path, root+string(filepath.Separator)) || + isPathWithinExistingRoot(path, root) { + return true + } + } + return false +} + +func protectedAnalyzeHomeRoots() []string { + var homeRoots []string + seenHomeRoots := make(map[string]bool) + addHomeRoot := func(home string) { + if home == "" { + return + } + cleanHome := filepath.Clean(home) + if !seenHomeRoots[cleanHome] { + homeRoots = append(homeRoots, cleanHome) + seenHomeRoots[cleanHome] = true + } + if resolvedHome, err := filepath.EvalSymlinks(cleanHome); err == nil && !seenHomeRoots[resolvedHome] { + homeRoots = append(homeRoots, resolvedHome) + seenHomeRoots[resolvedHome] = true + } + } + + addHomeRoot(os.Getenv("HOME")) + if currentUser, err := user.Current(); err == nil { + addHomeRoot(currentUser.HomeDir) + } + return homeRoots +} + +func isPathWithinExistingRoot(path, protectedRoot string) bool { + protectedInfo, err := os.Stat(protectedRoot) + if err != nil { + return false + } + + for current := filepath.Clean(path); ; current = filepath.Dir(current) { + if currentInfo, err := os.Stat(current); err == nil && os.SameFile(currentInfo, protectedInfo) { + return true + } + parent := filepath.Dir(current) + if parent == current { + return false + } + } +} + +func isSameExistingPath(path, protectedPath string) bool { + pathInfo, pathErr := os.Stat(path) + protectedInfo, protectedErr := os.Stat(protectedPath) + return pathErr == nil && protectedErr == nil && os.SameFile(pathInfo, protectedInfo) +} + +func isDirectChildOfExistingRoot(path, protectedRoot string) bool { + cleanPath := filepath.Clean(path) + return cleanPath != filepath.Clean(protectedRoot) && + filepath.Dir(cleanPath) != cleanPath && + isSameExistingPath(filepath.Dir(cleanPath), protectedRoot) +} + +// endpointSecurityBundlePrefixes mirrors ENDPOINT_SECURITY_BUNDLE_PREFIXES in +// lib/core/app_protection_data.sh. Deleting anything inside one of these EDR/MDM +// agents' per-user Darwin caches trips sensor tamper detection (e.g. CrowdStrike +// MacFalconSensorTamper, MITRE T1562.001), so analyze must never Trash them. +var endpointSecurityBundlePrefixes = []string{ + "com.crowdstrike.", + "com.sentinelone.", + "com.sentinel-labs.", + "com.eset.", + "com.jamf.", + "com.jamfsoftware.", + "com.paloaltonetworks.", + "com.cisco.anyconnect", + "com.cisco.secureclient", +} + +// isEndpointSecurityCachePath reports whether path is an endpoint-security / EDR +// agent file under the per-user Darwin folder (/private/var/folders or its +// /var/folders symlink form). Mirror of is_endpoint_security_cache_path() in +// lib/core/app_protection.sh. +func isEndpointSecurityCachePath(path string) bool { + lowerPath := strings.ToLower(path) + if !strings.HasPrefix(lowerPath, "/private/var/folders/") && !strings.HasPrefix(lowerPath, "/var/folders/") { + return false + } + for _, prefix := range endpointSecurityBundlePrefixes { + if strings.Contains(lowerPath, prefix) { + return true + } + } + return false +} + +// validatePath checks path safety for external commands. +// Returns error if path is empty, relative, contains null bytes, or has traversal. +func validatePath(path string) error { + if path == "" { + return fmt.Errorf("path is empty") + } + if !filepath.IsAbs(path) { + return fmt.Errorf("path must be absolute: %s", path) + } + if strings.Contains(path, "\x00") { + return fmt.Errorf("path contains null bytes") + } + // Check for path traversal attempts (.. components). + if slices.Contains(strings.Split(path, string(filepath.Separator)), "..") { + return fmt.Errorf("path contains traversal components: %s", path) + } + return nil +} diff --git a/Resources/mole/cmd/analyze/delete_fuzz_test.go b/Resources/mole/cmd/analyze/delete_fuzz_test.go new file mode 100644 index 0000000..c107b35 --- /dev/null +++ b/Resources/mole/cmd/analyze/delete_fuzz_test.go @@ -0,0 +1,61 @@ +//go:build darwin + +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +// FuzzValidatePath asserts the invariant: anything validatePath accepts +// (returns nil) must be absolute, free of null bytes, and free of ".." +// path components. The fuzzer also catches panics on adversarial input. +func FuzzValidatePath(f *testing.F) { + seeds := []string{ + "/Users/alice", + "/", + "", + "../etc/passwd", + "/../etc/passwd", + "/Users/../etc", + "relative/path", + "/path\x00with/null", + "/with spaces/file", + "/中文路径/测试", + "/very/deeply/nested/path/that/is/quite/long", + "/.", + "/..", + "/.../legitimate", + "/name..files/ok", + "//double/slash", + "/trailing/slash/", + "\x00", + string(make([]byte, 4096)), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, path string) { + err := validatePath(path) + if err != nil { + return + } + + if path == "" { + t.Errorf("accepted empty path") + } + if !filepath.IsAbs(path) { + t.Errorf("accepted non-absolute path: %q", path) + } + if strings.Contains(path, "\x00") { + t.Errorf("accepted path with null bytes: %q", path) + } + for p := range strings.SplitSeq(path, string(filepath.Separator)) { + if p == ".." { + t.Errorf("accepted path with .. component: %q", path) + } + } + }) +} diff --git a/Resources/mole/cmd/analyze/delete_test.go b/Resources/mole/cmd/analyze/delete_test.go index 8d89ae1..814ab43 100644 --- a/Resources/mole/cmd/analyze/delete_test.go +++ b/Resources/mole/cmd/analyze/delete_test.go @@ -1,8 +1,12 @@ +//go:build darwin + package main import ( "os" + "os/user" "path/filepath" + "strings" "testing" ) @@ -30,8 +34,11 @@ func TestTrashPathWithProgress(t *testing.T) { if err != nil { t.Fatalf("trashPathWithProgress returned error: %v", err) } - if count != int64(len(files)) { - t.Fatalf("expected %d files trashed, got %d", len(files), count) + if count != 1 { + t.Fatalf("expected one path-level Trash operation, got %d", count) + } + if counter != 1 { + t.Fatalf("expected one completed Trash operation, got %d", counter) } if _, err := os.Stat(target); !os.IsNotExist(err) { t.Fatalf("expected target to be moved to Trash, stat err=%v", err) @@ -66,7 +73,10 @@ func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) { t.Fatalf("unexpected error: %v", progress.err) } if progress.count != 2 { - t.Fatalf("expected 2 files trashed, got %d", progress.count) + t.Fatalf("expected 2 paths trashed, got %d", progress.count) + } + if counter != 2 { + t.Fatalf("expected two completed Trash operations, got %d", counter) } if _, err := os.Stat(parent); !os.IsNotExist(err) { t.Fatalf("expected parent to be moved to Trash, err=%v", err) @@ -79,3 +89,602 @@ func TestMoveToTrashNonExistent(t *testing.T) { t.Fatal("expected error for non-existent path") } } + +func TestMoveToTrashRejectsTraversal(t *testing.T) { + // Verify the full production path rejects ".." before filepath.Abs resolves it. + err := moveToTrash("/tmp/fakedir/../../../etc/passwd") + if err == nil { + t.Fatal("expected error for path with traversal components") + } + if !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected traversal error, got: %v", err) + } +} + +func TestValidateTrashTargetRejectsOrbStackLiveData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack"), + filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data"), + filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data", "data.img.raw"), + filepath.Join(home, ".orbstack"), + filepath.Join(home, ".orbstack", "state.db"), + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } + }) + } +} + +func TestValidateTrashTargetRejectsDockerDesktopLiveData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + filepath.Join(home, "Library", "Containers", "com.docker.docker"), + filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data"), + filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data", "vms", "0", "data", "Docker.raw"), + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } + }) + } +} + +func TestValidateTrashTargetRejectsDockerDesktopSymlinkAlias(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dockerData := filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data") + if err := os.MkdirAll(dockerData, 0o755); err != nil { + t.Fatalf("create Docker data fixture: %v", err) + } + alias := filepath.Join(home, "docker-data") + if err := os.Symlink(filepath.Dir(dockerData), alias); err != nil { + t.Fatalf("create Docker data symlink: %v", err) + } + + path := filepath.Join(alias, "Data") + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } +} + +func TestValidateTrashTargetRejectsDockerDesktopCaseVariant(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dockerData := filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data") + if err := os.MkdirAll(dockerData, 0o755); err != nil { + t.Fatalf("create Docker data fixture: %v", err) + } + + caseVariant := filepath.Join(home, "library", "containers", "COM.DOCKER.DOCKER", "Data") + if _, err := os.Stat(caseVariant); err != nil { + t.Skip("filesystem is case-sensitive") + } + if err := validateTrashTarget(caseVariant); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", caseVariant, err) + } +} + +func TestValidateTrashTargetRejectsDockerDesktopLiveDataWithoutHOME(t *testing.T) { + currentUser, err := user.Current() + if err != nil || currentUser.HomeDir == "" { + t.Skipf("current user home unavailable: %v", err) + } + t.Setenv("HOME", "") + + path := filepath.Join(currentUser.HomeDir, "Library", "Containers", "com.docker.docker", "Data") + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) with empty HOME error = %v, want protected path error", path, err) + } +} + +func TestValidateTrashTargetRejectsOrbStackGroupContainerAliases(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + groupRoot := filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack") + groupData := filepath.Join(groupRoot, "data") + if err := os.MkdirAll(groupData, 0o755); err != nil { + t.Fatalf("create OrbStack group fixture: %v", err) + } + + alias := filepath.Join(home, "orbstack-group") + if err := os.Symlink(groupRoot, alias); err != nil { + t.Fatalf("create OrbStack group symlink: %v", err) + } + if err := validateTrashTarget(filepath.Join(alias, "data")); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("OrbStack group symlink error = %v, want protected path error", err) + } + + caseVariant := filepath.Join(home, "library", "group containers", "huaq24hbr6.DEV.ORBSTACK", "data") + if _, err := os.Stat(caseVariant); err == nil { + if err := validateTrashTarget(caseVariant); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("OrbStack group case variant error = %v, want protected path error", err) + } + } +} + +func TestValidateTrashTargetRejectsOrbStackGroupContainerWithoutHOME(t *testing.T) { + currentUser, err := user.Current() + if err != nil || currentUser.HomeDir == "" { + t.Skipf("current user home unavailable: %v", err) + } + t.Setenv("HOME", "") + + path := filepath.Join(currentUser.HomeDir, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data") + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) with empty HOME error = %v, want protected path error", path, err) + } +} + +func TestValidateTrashTargetRejectsCriticalRoots(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + "/", + "/Applications", + "/Library", + "/System", + "/Users", + "/Volumes", + "/dev", + "/etc", + "/tmp", + "/var", + "/private", + "/private/etc", + "/private/tmp", + "/private/var", + "/private/var/audit", + "/private/var/db", + "/private/var/root", + "/private/var/tmp", + "/private/var/folders", + "/Library/Apple", + "/Library/Extensions", + "/Library/Keychains", + "/Applications/Finder.app", + "/Applications/Safari.app", + "/usr", + "/opt", + "/opt/homebrew", + "/Users/another-account", + home, + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } + }) + } +} + +func TestValidateTrashTargetRejectsCriticalRootCaseAliases(t *testing.T) { + tests := []struct { + alias string + canonical string + }{ + {"/SYSTEM", "/System"}, + {"/DEV", "/dev"}, + {"/PRIVATE/TMP", "/private/tmp"}, + {"/PRIVATE/VAR/FOLDERS", "/private/var/folders"}, + {"/USERS", "/Users"}, + } + + for _, tt := range tests { + t.Run(tt.alias, func(t *testing.T) { + if !isSameExistingPath(tt.alias, tt.canonical) { + t.Skip("filesystem does not expose this case-insensitive alias") + } + if err := validateTrashTarget(tt.alias); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", tt.alias, err) + } + }) + } +} + +func TestEndpointSecurityCachePathIsCaseInsensitive(t *testing.T) { + path := "/PRIVATE/VAR/FOLDERS/9D/ABC/C/COM.CROWDSTRIKE.FALCON.APP/cache" + if !isEndpointSecurityCachePath(path) { + t.Fatalf("isEndpointSecurityCachePath(%q) = false, want true", path) + } +} + +func TestValidateTrashTargetRejectsOrbStackCaseVariant(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path := filepath.Join(home, "LIBRARY", "GROUP CONTAINERS", "HUAQ24HBR6.DEV.ORBSTACK", "DATA") + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } +} + +func TestValidateTrashTargetAllowsChildrenOfOtherUserHomes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path := "/Users/another-account/Library/Caches/old-cache" + if err := validateTrashTarget(path); err != nil { + t.Fatalf("validateTrashTarget(%q) error = %v, want nil", path, err) + } +} + +func TestValidateTrashTargetRejectsSymlinkToCriticalRoot(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + parent := t.TempDir() + alias := filepath.Join(parent, "home-alias") + if err := os.Symlink(home, alias); err != nil { + t.Fatalf("create critical-root symlink: %v", err) + } + + if err := validateTrashTarget(alias); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", alias, err) + } +} + +func TestValidateTrashTargetAllowsChildrenOfCriticalRoots(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + filepath.Join(home, "Downloads", "old.zip"), + "/Applications/Example.app", + "/Library/Caches/com.example.app", + "/Volumes/External/old-artifact", + "/private/tmp/mole-user-artifact", + "/private/var/tmp/mole-user-artifact", + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err != nil { + t.Fatalf("validateTrashTarget(%q) error = %v, want nil", path, err) + } + }) + } +} + +func TestValidateTrashTargetRejectsEndpointSecurityCaches(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + "/private/var/folders/zz/aa/C/com.crowdstrike.falcon.App/com.apple.metalfe", + "/private/var/folders/zz/aa/X/com.sentinelone.agent.code_sign_clone", + "/var/folders/zz/aa/C/com.jamf.management/cache", + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } + }) + } +} + +func TestValidateTrashTargetAllowsNonEDRDarwinCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // A normal app's rebuildable GPU cache under var/folders stays deletable. + path := "/private/var/folders/zz/aa/C/com.example.App/com.apple.metalfe" + if err := validateTrashTarget(path); err != nil { + t.Fatalf("validateTrashTarget(%q) error = %v, want nil", path, err) + } +} + +func TestValidateTrashTargetRejectsEndpointSecurityCachesWithoutHOME(t *testing.T) { + // The EDR check must not depend on HOME (e.g. `env -u HOME mo analyze`). + t.Setenv("HOME", "") + path := "/private/var/folders/zz/aa/C/com.crowdstrike.falcon.App/com.apple.metalfe" + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) with empty HOME error = %v, want protected path error", path, err) + } +} + +func TestEndpointSecurityBundlePrefixesMirrorShellData(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "lib", "core", "app_protection_data.sh")) + if err != nil { + t.Fatalf("read app_protection_data.sh: %v", err) + } + + shellPrefixes := endpointSecurityPrefixesFromShellData(t, string(data)) + if len(shellPrefixes) != len(endpointSecurityBundlePrefixes) { + t.Fatalf("endpointSecurityBundlePrefixes length = %d, shell ENDPOINT_SECURITY_BUNDLE_PREFIXES length = %d", + len(endpointSecurityBundlePrefixes), len(shellPrefixes)) + } + for i, prefix := range endpointSecurityBundlePrefixes { + if prefix != shellPrefixes[i] { + t.Fatalf("endpointSecurityBundlePrefixes[%d] = %q, shell ENDPOINT_SECURITY_BUNDLE_PREFIXES[%d] = %q", + i, prefix, i, shellPrefixes[i]) + } + } +} + +func TestEndpointSecurityBundlePrefixesAllProtectDarwinCaches(t *testing.T) { + for _, prefix := range endpointSecurityBundlePrefixes { + t.Run(prefix, func(t *testing.T) { + path := "/private/var/folders/zz/aa/C/" + prefix + "agent/cache" + if !isEndpointSecurityCachePath(path) { + t.Fatalf("isEndpointSecurityCachePath(%q) = false, want true", path) + } + }) + } +} + +func endpointSecurityPrefixesFromShellData(t *testing.T, data string) []string { + t.Helper() + + const marker = "readonly ENDPOINT_SECURITY_BUNDLE_PREFIXES=(" + _, body, ok := strings.Cut(data, marker) + if !ok { + t.Fatalf("ENDPOINT_SECURITY_BUNDLE_PREFIXES array not found") + } + + body, _, ok = strings.Cut(body, "\n)") + if !ok { + t.Fatalf("ENDPOINT_SECURITY_BUNDLE_PREFIXES array terminator not found") + } + + var prefixes []string + for line := range strings.SplitSeq(body, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + prefixes = append(prefixes, strings.Trim(line, "\"")) + } + return prefixes +} + +func TestValidateTrashTargetAllowsRegularUserPaths(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + filepath.Join(home, "Downloads", "old.zip"), + filepath.Join(home, "Library", "Caches", "example.cache"), + filepath.Join(home, "Library", "Containers", "com.docker.docker-helper", "Data"), + filepath.Join(home, "Library", "Group Containers", "group.com.example.tool", "Library", "Caches", "item"), + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err != nil { + t.Fatalf("validateTrashTarget(%q) error = %v, want nil", path, err) + } + }) + } +} + +func TestValidatePath(t *testing.T) { + tests := []struct { + name string + path string + wantErr bool + }{ + // 基本合法路径 + {"absolute path", "/Users/test/file.txt", false}, + {"path with spaces", "/Users/test/My Documents/file.txt", false}, + {"root", "/", false}, + + // 中文路径 + {"chinese path", "/Users/test/中文文件夹/文件.txt", false}, + {"chinese mixed", "/Users/test/Downloads/报告2024.pdf", false}, + + // Emoji 路径 + {"emoji path", "/Users/test/📁文件夹/📝笔记.txt", false}, + {"emoji only", "/Users/test/🎉/🎊.txt", false}, + + // 特殊字符路径 (之前被错误拒绝的) + {"dollar sign", "/Users/test/$HOME/workspace", false}, + {"semicolon", "/Users/test/project;v2", false}, + {"colon", "/Users/test/project:2024", false}, + {"ampersand", "/Users/test/R&D/project", false}, + {"at sign", "/Users/test/user@domain", false}, + {"hash", "/Users/test/project#123", false}, + {"percent", "/Users/test/100% complete", false}, + {"exclamation", "/Users/test/important!.txt", false}, + {"single quote", "/Users/test/user's files", false}, + {"equals", "/Users/test/key=value", false}, + {"plus", "/Users/test/file+v2", false}, + {"brackets", "/Users/test/[2024] report", false}, + {"parentheses", "/Users/test/project (copy)", false}, + {"comma", "/Users/test/file, backup", false}, + + // 非法路径 + {"empty", "", true}, + {"relative", "relative/path", true}, + {"relative dot", "./file.txt", true}, + {"null byte", "/Users/test\x00/file", true}, + {"path traversal", "/Users/test/../../../etc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("validatePath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr) + } + }) + } +} + +func TestValidatePathWithChineseAndSpecialChars(t *testing.T) { + // 专门测试之前会导致兼容性回退的路径 + parent := t.TempDir() + testCases := []struct { + name string + path string + }{ + {"chinese", "中文文件夹"}, + {"emoji", "📁 文档"}, + {"mixed", "报告-2024_v2 (终稿) [已审核]"}, + {"special", "Project$2024; Q1: R&D"}, + {"complex", "用户@公司 100% 完成!"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fullPath := filepath.Join(parent, tc.path) + if err := os.MkdirAll(fullPath, 0o755); err != nil { + t.Fatalf("mkdir %q: %v", tc.path, err) + } + + if err := validatePath(fullPath); err != nil { + t.Errorf("validatePath rejected valid path %q: %v", tc.path, err) + } + }) + } +} + +// Regression for discussion #474: deleting from analyze over SSH appeared to do +// nothing. The only Trash path was Finder AppleScript, which raises a dialog on +// the physical machine that a remote user cannot answer, so every delete sat for +// trashTimeout and then failed. trash(8) needs no Finder, so it must be tried +// first and must actually move the file. +func TestMoveToTrashViaBinaryMovesFile(t *testing.T) { + if _, err := os.Stat(trashBinary); err != nil { + t.Skipf("%s not present on this macOS version", trashBinary) + } + + dir := t.TempDir() + probe, err := os.CreateTemp(dir, "mole-trash-binary-probe-*.txt") + if err != nil { + t.Fatalf("failed to create unique target: %v", err) + } + target := probe.Name() + if _, err := probe.WriteString("probe"); err != nil { + _ = probe.Close() + t.Fatalf("failed to seed target: %v", err) + } + if err := probe.Close(); err != nil { + t.Fatalf("failed to close target: %v", err) + } + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to resolve user home: %v", err) + } + trashCopy := filepath.Join(home, ".Trash", filepath.Base(target)) + if _, err := os.Lstat(trashCopy); !os.IsNotExist(err) { + t.Fatalf("refusing Trash probe basename collision at %s", trashCopy) + } + t.Cleanup(func() { + _ = os.Remove(trashCopy) + }) + + if err := moveToTrashViaBinary(target); err != nil { + t.Fatalf("moveToTrashViaBinary failed: %v", err) + } + + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("expected %s to be gone, Lstat returned %v", target, err) + } +} + +func TestMoveToTrashViaFilesystemMovesWithoutOverwritingCollision(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + trashDir := filepath.Join(home, ".Trash") + sourceDir := filepath.Join(root, "source") + if err := os.MkdirAll(trashDir, 0o700); err != nil { + t.Fatalf("create fake Trash: %v", err) + } + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create source directory: %v", err) + } + t.Setenv("HOME", home) + + target := filepath.Join(sourceDir, "report.txt") + if err := os.WriteFile(target, []byte("new"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + existing := filepath.Join(trashDir, filepath.Base(target)) + if err := os.WriteFile(existing, []byte("keep"), 0o644); err != nil { + t.Fatalf("write collision: %v", err) + } + + if err := moveToTrashViaFilesystem(target); err != nil { + t.Fatalf("filesystem Trash move failed: %v", err) + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("expected source to be gone, Lstat returned %v", err) + } + if got, err := os.ReadFile(existing); err != nil || string(got) != "keep" { + t.Fatalf("existing Trash item was changed: data=%q err=%v", got, err) + } + matches, err := filepath.Glob(filepath.Join(trashDir, "report.txt.*")) + if err != nil || len(matches) != 1 { + t.Fatalf("expected one collision-safe Trash item, matches=%v err=%v", matches, err) + } + if got, err := os.ReadFile(matches[0]); err != nil || string(got) != "new" { + t.Fatalf("moved Trash item mismatch: data=%q err=%v", got, err) + } +} + +func TestMoveToTrashViaFilesystemRejectsSymlinkedTrash(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + realTrash := filepath.Join(root, "real-trash") + sourceDir := filepath.Join(root, "source") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatalf("create fake home: %v", err) + } + if err := os.MkdirAll(realTrash, 0o700); err != nil { + t.Fatalf("create real Trash: %v", err) + } + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create source directory: %v", err) + } + if err := os.Symlink(realTrash, filepath.Join(home, ".Trash")); err != nil { + t.Fatalf("create Trash symlink: %v", err) + } + t.Setenv("HOME", home) + + target := filepath.Join(sourceDir, "report.txt") + if err := os.WriteFile(target, []byte("keep"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + + if err := moveToTrashViaFilesystem(target); err == nil { + t.Fatal("expected symlinked Trash to be rejected") + } + if _, err := os.Lstat(target); err != nil { + t.Fatalf("target changed after rejected Trash move: %v", err) + } +} + +// trash(8) takes no "--" separator. Passing one makes it report a missing file +// named "--" and exit non-zero while still trashing the real target, which would +// make moveToTrash fall through to Finder and delete a second time. Absolute +// paths cannot be mistaken for options, so no separator is used. +func TestMoveToTrashViaBinaryUsesAbsolutePathWithoutSeparator(t *testing.T) { + data, err := os.ReadFile("delete.go") + if err != nil { + t.Fatalf("failed to read delete.go: %v", err) + } + if strings.Contains(string(data), `trashBinary, "--"`) { + t.Error(`moveToTrashViaBinary must not pass "--" to trash(8); it trashes the target but exits non-zero`) + } +} diff --git a/Resources/mole/cmd/analyze/format.go b/Resources/mole/cmd/analyze/format.go index 371539b..a899aea 100644 --- a/Resources/mole/cmd/analyze/format.go +++ b/Resources/mole/cmd/analyze/format.go @@ -1,12 +1,22 @@ +//go:build darwin + package main import ( "fmt" + "math" "os" + "slices" "strings" "time" + + "github.com/tw93/mole/internal/units" ) +// Left-aligned block elements filling 1/8 through 7/8 of a cell, indexed by +// eighths. Index 0 is unused: no eighths means nothing to draw. +var subCellBlocks = [8]string{"", "▏", "▎", "▍", "▌", "▋", "▊", "▉"} + func displayPath(path string) string { home, err := os.UserHomeDir() if err != nil || home == "" { @@ -54,8 +64,8 @@ func truncateMiddle(s string, maxWidth int) string { tailWidth := 0 tailIdx := len(runes) - for i := len(runes) - 1; i >= 0; i-- { - w := runeWidth(runes[i]) + for i, r := range slices.Backward(runes) { + w := runeWidth(r) if tailWidth+w > targetTailWidth { break } @@ -77,29 +87,27 @@ func formatNumber(n int64) string { } func humanizeBytes(size int64) string { - if size < 0 { - return "0 B" - } - const unit = 1000 - if size < unit { - return fmt.Sprintf("%d B", size) + return units.BytesSI(size) +} + +func formatPercent(percent float64, known bool) string { + const width = 6 + if !known { + return " -- " } - div, exp := int64(unit), 0 - for n := size / unit; n >= unit; n /= unit { - div *= unit - exp++ + + label := fmt.Sprintf("%.1f%%", percent) + if percent > 0 && percent < 0.1 { + label = "< 0.1%" } - value := float64(size) / float64(div) - return fmt.Sprintf("%.1f %cB", value, "kMGTPE"[exp]) + return fmt.Sprintf("%*s", width, label) } func coloredProgressBar(value, maxValue int64, percent float64) string { - if maxValue <= 0 { - return colorGray + strings.Repeat("░", barWidth) + colorReset + if value <= 0 || maxValue <= 0 { + return strings.Repeat(" ", barWidth) } - filled := min(int((value*int64(barWidth))/maxValue), barWidth) - var barColor string if percent >= 50 { barColor = colorRed @@ -111,27 +119,43 @@ func coloredProgressBar(value, maxValue int64, percent float64) string { barColor = colorGreen } + // Length is measured in eighths of a cell throughout, so one ruler covers + // the whole range. Mixing shaded blocks for the remainder with width blocks + // below one cell made a 2.3% row look lighter than a 1.3% one, because the + // two glyph families encode magnitude differently. + // + // The ratio is taken in float64 rather than scaling the byte count first: + // value * barWidth * 8 overflows int64 at 42.7 PB and wraps negative, which + // reaches strings.Repeat with a negative count and panics. float64 carries + // far more precision than 192 distinct lengths need. + eighths := max(int64(math.Round(float64(value)/float64(maxValue)*float64(barWidth)*8)), 0) + full := int(eighths / 8) + remainder := int(eighths % 8) + if full >= barWidth { + return barColor + strings.Repeat("█", barWidth) + colorReset + } + + if full == 0 && remainder == 0 { + // Under an eighth of a cell there is no honest length left to draw, but + // the row still holds a real value and an empty column reads as a + // rendering fault. A gray tick holds the place without competing with + // the bars above it, which is what the old colored sliver did: stacked + // down a long tail it formed a bright vertical rule over the least + // significant rows. + return colorGray + subCellBlocks[1] + strings.Repeat(" ", barWidth-1) + colorReset + } + var bar strings.Builder bar.WriteString(barColor) - for i := range barWidth { - if i < filled { - if i < filled-1 { - bar.WriteString("█") - } else { - remainder := (value * int64(barWidth)) % maxValue - if remainder > maxValue/2 { - bar.WriteString("█") - } else if remainder > maxValue/4 { - bar.WriteString("▓") - } else { - bar.WriteString("▒") - } - } - } else { - bar.WriteString(colorGray + "░" + barColor) - } + bar.WriteString(strings.Repeat("█", full)) + drawn := full + if remainder > 0 { + bar.WriteString(subCellBlocks[remainder]) + drawn++ } - return bar.String() + colorReset + bar.WriteString(strings.Repeat(" ", barWidth-drawn)) + bar.WriteString(colorReset) + return bar.String() } // runeWidth returns display width for wide characters and emoji. diff --git a/Resources/mole/cmd/analyze/format_test.go b/Resources/mole/cmd/analyze/format_test.go index 65a8333..5f4928e 100644 --- a/Resources/mole/cmd/analyze/format_test.go +++ b/Resources/mole/cmd/analyze/format_test.go @@ -1,9 +1,14 @@ +//go:build darwin + package main import ( + "math" "strings" "testing" "time" + + "github.com/charmbracelet/lipgloss" ) func TestRuneWidth(t *testing.T) { @@ -20,7 +25,7 @@ func TestRuneWidth(t *testing.T) { {"CJK ideograph", '語', 2}, {"Full-width number", '1', 2}, {"ASCII space", ' ', 1}, - {"Tab", '\t', 1}, + {"Tab", ' ', 1}, } for _, tt := range tests { @@ -55,30 +60,14 @@ func TestDisplayWidth(t *testing.T) { } } +// Core byte-format coverage lives in internal/units; this is a wiring sanity +// check to ensure humanizeBytes still delegates to BytesSI. func TestHumanizeBytes(t *testing.T) { - tests := []struct { - input int64 - want string - }{ - {-100, "0 B"}, - {0, "0 B"}, - {512, "512 B"}, - {999, "999 B"}, - {1000, "1.0 kB"}, - {1500, "1.5 kB"}, - {10000, "10.0 kB"}, - {1000000, "1.0 MB"}, - {1500000, "1.5 MB"}, - {1000000000, "1.0 GB"}, - {1000000000000, "1.0 TB"}, - {1000000000000000, "1.0 PB"}, + if got := humanizeBytes(1500); got != "1.5 kB" { + t.Errorf("humanizeBytes(1500) = %q, want %q", got, "1.5 kB") } - - for _, tt := range tests { - got := humanizeBytes(tt.input) - if got != tt.want { - t.Errorf("humanizeBytes(%d) = %q, want %q", tt.input, got, tt.want) - } + if got := humanizeBytes(-1); got != "0 B" { + t.Errorf("humanizeBytes(-1) = %q, want %q", got, "0 B") } } @@ -105,6 +94,108 @@ func TestFormatNumber(t *testing.T) { } } +func TestFormatPercentKeepsFixedWidth(t *testing.T) { + tests := []struct { + name string + percent float64 + known bool + want string + }{ + {"whole", 47, true, " 47.0%"}, + {"fraction", 0.9, true, " 0.9%"}, + {"threshold", 0.1, true, " 0.1%"}, + {"tiny nonzero", 0.046, true, "< 0.1%"}, + {"zero", 0, true, " 0.0%"}, + {"pending", 0, false, " -- "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatPercent(tt.percent, tt.known) + if got != tt.want { + t.Fatalf("formatPercent(%v, %v) = %q, want %q", tt.percent, tt.known, got, tt.want) + } + if displayWidth(got) != 6 { + t.Fatalf("formatPercent width = %d, want 6 for %q", displayWidth(got), got) + } + }) + } +} + +// The bar measures length in eighths of a cell across the whole range, so one +// glyph family encodes magnitude everywhere. Three outcomes matter: no value +// draws nothing, a value too small to scale keeps its place with a gray tick +// rather than an empty column, and anything larger draws to scale in color. +func TestColoredProgressBarKeepsFixedWidth(t *testing.T) { + tests := []struct { + name string + value int64 + maxValue int64 + percent float64 + want string // "blank", "grayTick", or "scaled" + }{ + {"empty", 0, 100, 0, "blank"}, + {"below one eighth of a cell", 1, 1000, 0.01, "grayTick"}, + {"sub-cell but scalable", 1, 40, 2.5, "scaled"}, + {"partial", 25, 100, 25, "scaled"}, + {"full", 100, 100, 100, "scaled"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := coloredProgressBar(tt.value, tt.maxValue, tt.percent) + if width := lipgloss.Width(got); width != barWidth { + t.Fatalf("progress bar width = %d, want %d for %q", width, barWidth, got) + } + if strings.Contains(got, "░") || strings.Contains(got, "▓") || strings.Contains(got, "▒") { + t.Fatalf("progress bar should encode length by width, not shading: %q", got) + } + switch tt.want { + case "blank": + if got != strings.Repeat(" ", barWidth) { + t.Fatalf("no value should render blank, got %q", got) + } + case "grayTick": + if !strings.HasPrefix(got, colorGray) { + t.Fatalf("an unscalable value should keep its place in gray, got %q", got) + } + if !strings.Contains(got, subCellBlocks[1]) { + t.Fatalf("gray tick should use the smallest block, got %q", got) + } + case "scaled": + if strings.HasPrefix(got, colorGray) { + t.Fatalf("a scalable value should draw in its own color, got %q", got) + } + if lipgloss.Width(strings.TrimSpace(got)) == 0 { + t.Fatalf("a scalable value should draw glyphs, got %q", got) + } + } + }) + } +} + +// Scaling the byte count before dividing overflowed int64 at 42.7 PB, wrapped +// negative, and reached strings.Repeat with a negative count, which panics. +// Sizes that large are not realistic, but a rendering helper must not be able +// to take the whole TUI down on unexpected input. +func TestColoredProgressBarSurvivesExtremeSizes(t *testing.T) { + sizes := []int64{ + 1 << 50, // 1 PB + 48038396025285290, // the old overflow threshold + 48038396025285291, // just past it + math.MaxInt64 / 2, + math.MaxInt64, + } + for _, size := range sizes { + for _, pair := range [][2]int64{{size, size}, {1, size}, {size, 1}} { + got := coloredProgressBar(pair[0], pair[1], 50) + if width := lipgloss.Width(got); width != barWidth { + t.Fatalf("value=%d max=%d width=%d, want %d", pair[0], pair[1], width, barWidth) + } + } + } +} + func TestTruncateMiddle(t *testing.T) { tests := []struct { name string diff --git a/Resources/mole/cmd/analyze/heap.go b/Resources/mole/cmd/analyze/heap.go index 0b4a5a5..919ad1a 100644 --- a/Resources/mole/cmd/analyze/heap.go +++ b/Resources/mole/cmd/analyze/heap.go @@ -1,3 +1,5 @@ +//go:build darwin + package main // entryHeap is a min-heap of dirEntry used to keep Top N largest entries. diff --git a/Resources/mole/cmd/analyze/heap_test.go b/Resources/mole/cmd/analyze/heap_test.go index 77408fc..12a1341 100644 --- a/Resources/mole/cmd/analyze/heap_test.go +++ b/Resources/mole/cmd/analyze/heap_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/insights.go b/Resources/mole/cmd/analyze/insights.go new file mode 100644 index 0000000..c223775 --- /dev/null +++ b/Resources/mole/cmd/analyze/insights.go @@ -0,0 +1,166 @@ +//go:build darwin + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// createInsightEntries returns the list of hidden-space insight entries +// to show in the overview screen alongside the standard directory entries. +func createInsightEntries() []dirEntry { + home := os.Getenv("HOME") + if home == "" { + return nil + } + + var entries []dirEntry + + // iOS Backups: ~/Library/Application Support/MobileSync/Backup + backupPath := filepath.Join(home, "Library", "Application Support", "MobileSync", "Backup") + if info, err := os.Stat(backupPath); err == nil && info.IsDir() { + entries = append(entries, dirEntry{ + Name: "iOS Backups", + Path: backupPath, + IsDir: true, + Size: -1, + }) + } + + // Old Downloads: ~/Downloads (files older than 90 days) + downloadsPath := filepath.Join(home, "Downloads") + if info, err := os.Stat(downloadsPath); err == nil && info.IsDir() { + entries = append(entries, dirEntry{ + Name: "Old Downloads (90d+)", + Path: downloadsPath, + IsDir: true, + Size: -1, + }) + } + + // Cleanable paths: things mo clean can remove or the user can safely delete. + // System Caches (~Library/Caches) is intentionally omitted here because the + // specific cache subdirectories below are already its children; listing both + // would double-count the same bytes. + cleanablePaths := []struct { + name string + path string + }{ + // Universal (everyone has these) + {"System Logs", filepath.Join(home, "Library", "Logs")}, + {"Homebrew Cache", filepath.Join(home, "Library", "Caches", "Homebrew")}, + + // Developer-specific (only shown if path exists) + {"Xcode DerivedData", filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")}, + {"Xcode Simulators", filepath.Join(home, "Library", "Developer", "CoreSimulator", "Devices")}, + {"Xcode Archives", filepath.Join(home, "Library", "Developer", "Xcode", "Archives")}, + {"Spotify Cache", filepath.Join(home, "Library", "Application Support", "Spotify", "PersistentCache")}, + {"JetBrains Cache", filepath.Join(home, "Library", "Caches", "JetBrains")}, + {"Docker Data", filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data")}, + {"pip Cache", filepath.Join(home, "Library", "Caches", "pip")}, + {"Gradle Cache", filepath.Join(home, ".gradle", "caches")}, + {"CocoaPods Cache", filepath.Join(home, "Library", "Caches", "CocoaPods")}, + } + if matches, err := filepath.Glob(filepath.Join(home, "Library", "Group Containers", "*dev.orbstack", "data")); err == nil { + for _, match := range matches { + if info, statErr := os.Stat(match); statErr == nil && info.IsDir() { + cleanablePaths = append(cleanablePaths, struct { + name string + path string + }{"OrbStack Data", match}) + break + } + } + } + for _, c := range cleanablePaths { + if info, err := os.Stat(c.path); err == nil && info.IsDir() { + entries = append(entries, dirEntry{ + Name: c.name, + Path: c.path, + IsDir: true, + Size: -1, + }) + } + } + + return entries +} + +// measureInsightSize measures the size of a path. +// Old Downloads is treated specially: only files older than 90 days are counted. +func measureInsightSize(path string) (int64, error) { + home := os.Getenv("HOME") + + if home != "" && path == filepath.Join(home, "Downloads") { + return measureOldDownloads(path, 90) + } + + return measureOverviewSize(path) +} + +// measureOldDownloads calculates total size of files in a directory +// that haven't been modified in the given number of days. +func measureOldDownloads(dir string, daysOld int) (int64, error) { + cutoff := time.Now().AddDate(0, 0, -daysOld) + var total int64 + + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + + for _, entry := range entries { + // Skip hidden files. + if strings.HasPrefix(entry.Name(), ".") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + if info.ModTime().Before(cutoff) { + if entry.IsDir() { + // Use du for directories. + if size, err := getDirSizeFast(filepath.Join(dir, entry.Name())); err == nil { + total += size + } + } else { + total += info.Size() + } + } + } + + return total, nil +} + +// getDirSizeFast measures directory size using du. +func getDirSizeFast(path string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "du", "-sk", path) + output, err := cmd.Output() + if err != nil { + return 0, err + } + + fields := strings.Fields(string(output)) + if len(fields) == 0 { + return 0, nil + } + + kb, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return 0, err + } + + return kb * 1024, nil +} diff --git a/Resources/mole/cmd/analyze/insights_test.go b/Resources/mole/cmd/analyze/insights_test.go new file mode 100644 index 0000000..9aa9a71 --- /dev/null +++ b/Resources/mole/cmd/analyze/insights_test.go @@ -0,0 +1,106 @@ +//go:build darwin + +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCreateInsightEntries(t *testing.T) { + entries := createInsightEntries() + // Should return at least some entries on a real Mac. + // iOS Backups may not exist, but Old Downloads and Mail Data likely do. + if len(entries) == 0 { + t.Log("No insight entries found (some paths may not exist on this machine)") + } + + // Verify all entries have required fields. + for _, e := range entries { + if e.Name == "" { + t.Error("insight entry has empty Name") + } + if e.Path == "" { + t.Error("insight entry has empty Path") + } + if e.Size != -1 { + t.Errorf("insight entry %q should have Size=-1 (pending), got %d", e.Name, e.Size) + } + if !e.IsDir { + t.Errorf("insight entry %q should be a directory", e.Name) + } + } +} + +func TestCreateInsightEntriesIncludesOrbStackData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + orbstackData := filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data") + if err := os.MkdirAll(orbstackData, 0755); err != nil { + t.Fatal(err) + } + + entries := createInsightEntries() + for _, entry := range entries { + if entry.Name == "OrbStack Data" { + if entry.Path != orbstackData { + t.Fatalf("OrbStack path = %q, want %q", entry.Path, orbstackData) + } + return + } + } + t.Fatal("OrbStack Data insight not found") +} + +func TestMeasureOldDownloads(t *testing.T) { + // Create a temp directory with old and new files. + dir := t.TempDir() + + // Create an old file (set mtime to 100 days ago). + oldFile := filepath.Join(dir, "old.txt") + if err := os.WriteFile(oldFile, []byte("old content here"), 0644); err != nil { + t.Fatal(err) + } + oldTime := time.Now().AddDate(0, 0, -100) + os.Chtimes(oldFile, oldTime, oldTime) + + // Create a new file. + newFile := filepath.Join(dir, "new.txt") + if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil { + t.Fatal(err) + } + + size, err := measureOldDownloads(dir, 90) + if err != nil { + t.Fatalf("measureOldDownloads: %v", err) + } + + if size == 0 { + t.Error("expected non-zero size for old files") + } + + // Size should be approximately the size of old.txt (16 bytes) but not new.txt. + if size > 1024 { + t.Errorf("size %d seems too large for a 16-byte file", size) + } +} + +func TestMeasureInsightSizeFallsBackToOverview(t *testing.T) { + // For a non-Downloads path, measureInsightSize should use measureOverviewSize. + dir := t.TempDir() + testFile := filepath.Join(dir, "test.dat") + if err := os.WriteFile(testFile, make([]byte, 4096), 0644); err != nil { + t.Fatal(err) + } + + size, err := measureInsightSize(dir) + if err != nil { + t.Fatalf("measureInsightSize: %v", err) + } + if size == 0 { + t.Error("expected non-zero size") + } +} diff --git a/Resources/mole/cmd/analyze/json.go b/Resources/mole/cmd/analyze/json.go index 1c2ab44..60d593f 100644 --- a/Resources/mole/cmd/analyze/json.go +++ b/Resources/mole/cmd/analyze/json.go @@ -6,25 +6,39 @@ import ( "encoding/json" "fmt" "os" + "sort" + "sync" "sync/atomic" + "time" ) type jsonOutput struct { - Path string `json:"path"` - Entries []jsonEntry `json:"entries"` - TotalSize int64 `json:"total_size"` - TotalFiles int64 `json:"total_files"` + Path string `json:"path"` + Overview bool `json:"overview"` + Entries []jsonEntry `json:"entries"` + LargeFiles []jsonFileEntry `json:"large_files,omitempty"` + TotalSize int64 `json:"total_size"` + TotalFiles int64 `json:"total_files,omitempty"` } type jsonEntry struct { - Name string `json:"name"` - Path string `json:"path"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + Insight bool `json:"insight,omitempty"` + Cleanable bool `json:"cleanable,omitempty"` + LastAccess string `json:"last_access,omitempty"` +} + +type jsonFileEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` } func runJSONMode(path string, isOverview bool) { - result := performScanForJSON(path) + result := performScanForJSON(path, isOverview) encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") @@ -34,48 +48,146 @@ func runJSONMode(path string, isOverview bool) { } } -func performScanForJSON(path string) jsonOutput { +func performScanForJSON(path string, isOverview bool) jsonOutput { + if isOverview { + return performOverviewScanForJSON(path) + } + return performDirectoryScanForJSON(path) +} + +func performDirectoryScanForJSON(path string) jsonOutput { var filesScanned, dirsScanned, bytesScanned int64 currentPath := &atomic.Value{} currentPath.Store("") - items, err := os.ReadDir(path) + result, err := scanPathConcurrentAllEntries(path, &filesScanned, &dirsScanned, &bytesScanned, currentPath) if err != nil { - fmt.Fprintf(os.Stderr, "failed to read directory: %v\n", err) + fmt.Fprintf(os.Stderr, "failed to scan directory: %v\n", err) os.Exit(1) } - var entries []jsonEntry + return jsonOutput{ + Path: path, + Overview: false, + Entries: jsonEntriesFromDirEntries(result.Entries, false, nil), + LargeFiles: jsonFileEntriesFromFileEntries(result.LargeFiles), + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + } +} + +func performOverviewScanForJSON(path string) jsonOutput { + insightEntries := createInsightEntries() + overviewEntries := createOverviewEntriesWithInsights(insightEntries) + return performOverviewScanForJSONWithEntries(path, insightEntries, overviewEntries) +} + +func performOverviewScanForJSONWithEntries(path string, insightEntries, overviewEntries []dirEntry) jsonOutput { + insightPaths := make(map[string]bool, len(insightEntries)) + for _, insight := range insightEntries { + insightPaths[insight.Path] = true + } + var totalSize int64 + entries := make([]dirEntry, 0, len(overviewEntries)) + for _, entry := range measureOverviewEntriesForJSON(overviewEntries, insightPaths) { + // Match the TUI: omit scanned insight/tool entries that ended up empty. + if entry.Size == 0 { + continue + } + totalSize += entry.Size + entries = append(entries, entry) + } + + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Size > entries[j].Size + }) + + return jsonOutput{ + Path: path, + Overview: true, + Entries: jsonEntriesFromDirEntries(entries, true, insightPaths), + TotalSize: totalSize, + } +} + +func measureOverviewEntriesForJSON(overviewEntries []dirEntry, insightPaths map[string]bool) []dirEntry { + if len(overviewEntries) == 0 { + return nil + } + + type measurement struct { + index int + entry dirEntry + } - for _, item := range items { - fullPath := path + "/" + item.Name() - var size int64 + measured := make([]dirEntry, len(overviewEntries)) + sem := make(chan struct{}, maxConcurrentOverview) + results := make(chan measurement, len(overviewEntries)) + + var wg sync.WaitGroup + for index, item := range overviewEntries { + wg.Go(func() { + sem <- struct{}{} + defer func() { <-sem }() + + var ( + size int64 + err error + ) + + if cached, cacheErr := loadOverviewCachedSize(item.Path); cacheErr == nil && cached > 0 { + size = cached + } else if insightPaths[item.Path] { + size, err = measureInsightSize(item.Path) + } else { + size, err = measureOverviewSize(item.Path) + } - if item.IsDir() { - size = calculateDirSizeFast(fullPath, &filesScanned, &dirsScanned, &bytesScanned, currentPath) - } else { - info, err := item.Info() if err == nil { - size = info.Size() - atomic.AddInt64(&filesScanned, 1) - atomic.AddInt64(&bytesScanned, size) + item.Size = size } + results <- measurement{index: index, entry: item} + }) + } + + wg.Wait() + close(results) + + for result := range results { + measured[result.index] = result.entry + } + return measured +} + +func jsonEntriesFromDirEntries(entries []dirEntry, isOverview bool, insightPaths map[string]bool) []jsonEntry { + output := make([]jsonEntry, 0, len(entries)) + for _, entry := range entries { + item := jsonEntry{ + Name: entry.Name, + Path: entry.Path, + Size: entry.Size, + IsDir: entry.IsDir, + Cleanable: entry.IsDir && isCleanableDir(entry.Path), } - totalSize += size - entries = append(entries, jsonEntry{ - Name: item.Name(), - Path: fullPath, - Size: size, - IsDir: item.IsDir(), - }) + if isOverview { + item.Insight = insightPaths[entry.Path] + } + + if !entry.LastAccess.IsZero() { + item.LastAccess = entry.LastAccess.UTC().Format(time.RFC3339) + } + + output = append(output, item) } + return output +} - return jsonOutput{ - Path: path, - Entries: entries, - TotalSize: totalSize, - TotalFiles: atomic.LoadInt64(&filesScanned), +func jsonFileEntriesFromFileEntries(files []fileEntry) []jsonFileEntry { + output := make([]jsonFileEntry, 0, len(files)) + for _, f := range files { + output = append(output, jsonFileEntry(f)) } + return output } diff --git a/Resources/mole/cmd/analyze/json_test.go b/Resources/mole/cmd/analyze/json_test.go new file mode 100644 index 0000000..4d1ddd2 --- /dev/null +++ b/Resources/mole/cmd/analyze/json_test.go @@ -0,0 +1,126 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPerformScanForJSONIncludesAllEntriesAndLargeFiles(t *testing.T) { + root := t.TempDir() + + totalFiles := maxEntries + 6 + for i := 0; i < totalFiles-1; i++ { + path := filepath.Join(root, fmt.Sprintf("small-%02d.txt", i)) + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write small file %d: %v", i, err) + } + } + + hugeFile := filepath.Join(root, "huge.bin") + if err := os.WriteFile(hugeFile, make([]byte, 2<<20), 0o644); err != nil { + t.Fatalf("write huge file: %v", err) + } + + result := performScanForJSON(root, false) + + if result.Overview { + t.Fatalf("expected non-overview JSON result") + } + if got := len(result.Entries); got != totalFiles { + t.Fatalf("expected %d entries, got %d", totalFiles, got) + } + if result.TotalFiles != int64(totalFiles) { + t.Fatalf("expected %d total files, got %d", totalFiles, result.TotalFiles) + } + if len(result.LargeFiles) == 0 { + t.Fatalf("expected large_files to include the large file") + } + + foundHuge := false + for _, file := range result.LargeFiles { + if file.Name == "huge.bin" && file.Path == hugeFile { + foundHuge = true + break + } + } + if !foundHuge { + t.Fatalf("expected huge.bin in large_files, got %#v", result.LargeFiles) + } +} + +func TestJSONEntriesFromDirEntriesIncludesMetadata(t *testing.T) { + oldAccess := time.Now().AddDate(0, 0, -120) + + entries := jsonEntriesFromDirEntries([]dirEntry{ + { + Name: "old.bin", + Path: "/tmp/old.bin", + Size: 42, + IsDir: false, + LastAccess: oldAccess, + }, + { + Name: "node_modules", + Path: "/tmp/project/node_modules", + Size: 128, + IsDir: true, + }, + }, false, nil) + + if entries[0].LastAccess == "" { + t.Fatalf("expected last_access to be populated") + } + if entries[1].Cleanable != true { + t.Fatalf("expected node_modules entry to be marked cleanable") + } +} + +func TestJSONEntriesFromDirEntriesMarksOverviewInsights(t *testing.T) { + entry := dirEntry{ + Name: "Old Downloads (90d+)", + Path: "/tmp/test-home/Downloads", + Size: 256, + IsDir: true, + } + + entries := jsonEntriesFromDirEntries([]dirEntry{entry}, true, map[string]bool{ + entry.Path: true, + }) + + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + if !entries[0].Insight { + t.Fatalf("expected entry to be marked as insight") + } +} + +func TestPerformOverviewScanForJSONSchemaWithInjectedEntries(t *testing.T) { + root := t.TempDir() + payload := filepath.Join(root, "payload") + if err := os.WriteFile(payload, []byte("overview"), 0o644); err != nil { + t.Fatalf("write overview payload: %v", err) + } + + result := performOverviewScanForJSONWithEntries("/", nil, []dirEntry{{ + Name: "Fixture", + Path: root, + IsDir: true, + Size: -1, + }}) + + if result.Path != "/" || !result.Overview { + t.Fatalf("unexpected overview identity: path=%q overview=%v", result.Path, result.Overview) + } + if result.Entries == nil { + t.Fatal("overview entries must be a JSON list, not nil") + } + if result.TotalSize <= 0 { + t.Fatalf("expected measured overview size, got %d", result.TotalSize) + } +} diff --git a/Resources/mole/cmd/analyze/live_config.go b/Resources/mole/cmd/analyze/live_config.go new file mode 100644 index 0000000..bf69e86 --- /dev/null +++ b/Resources/mole/cmd/analyze/live_config.go @@ -0,0 +1,33 @@ +//go:build darwin + +package main + +import ( + "os" + "strings" +) + +const liveSortModeEnv = "MOLE_ANALYZE_LIVE_SORT" + +func liveScanSortModeFromEnv() liveSortMode { + switch strings.ToLower(strings.TrimSpace(os.Getenv(liveSortModeEnv))) { + case "continuous": + return liveSortContinuous + default: + return liveSortFreezeOnMove + } +} + +func nextLiveSortMode(mode liveSortMode) liveSortMode { + if mode == liveSortContinuous { + return liveSortFreezeOnMove + } + return liveSortContinuous +} + +func liveSortModeLabel(mode liveSortMode) string { + if mode == liveSortFreezeOnMove { + return "freeze-on-move" + } + return "continuous" +} diff --git a/Resources/mole/cmd/analyze/live_scan.go b/Resources/mole/cmd/analyze/live_scan.go new file mode 100644 index 0000000..ba8f886 --- /dev/null +++ b/Resources/mole/cmd/analyze/live_scan.go @@ -0,0 +1,476 @@ +//go:build darwin + +package main + +import ( + "container/heap" + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "sync" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +var nextLiveScanID atomic.Int64 + +type liveScanTargetKind int + +const ( + liveScanTargetDirectory liveScanTargetKind = iota + 1 + liveScanTargetFoldedDirectory + liveScanTargetHomeLibrary +) + +type liveScanTarget struct { + name string + path string + kind liveScanTargetKind +} + +func startLiveScanCmd(path string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) tea.Cmd { + return startLiveScanCmdWithPolicy(path, filesScanned, dirsScanned, bytesScanned, currentPath, scanCacheReuse) +} + +func startLiveScanCmdWithPolicy(path string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, cachePolicy scanCachePolicy) tea.Cmd { + return func() tea.Msg { + id := nextLiveScanID.Add(1) + ctx, cancel := context.WithCancel(context.Background()) + + limiter := newScanLimiter(0) + entries, targets, totalSize, totalFiles, largeFiles, err := readLiveScanInitialEntries(path, limiter) + if err != nil { + cancel() + return liveScanStartMsg{id: id, path: path, err: err} + } + + if totalFiles > 0 { + atomic.AddInt64(filesScanned, totalFiles) + } + if totalSize > 0 { + atomic.AddInt64(bytesScanned, totalSize) + } + + events := make(chan liveScanEventMsg, max(len(targets)*4, 1)) + go runLiveScan(ctx, id, path, entries, targets, totalSize, totalFiles, largeFiles, limiter, filesScanned, dirsScanned, bytesScanned, currentPath, events, cachePolicy) + + scanningPaths := make([]string, 0, len(targets)) + for _, target := range targets { + scanningPaths = append(scanningPaths, target.path) + } + + return liveScanStartMsg{ + id: id, + path: path, + entries: entries, + totalSize: totalSize, + totalFiles: totalFiles, + largeFiles: largeFiles, + scanningPaths: scanningPaths, + events: events, + cancel: cancel, + } + } +} + +func readLiveScanInitialEntries(root string, limiter *scanLimiter) ([]dirEntry, []liveScanTarget, int64, int64, []fileEntry, error) { + children, err := os.ReadDir(root) + if err != nil { + return nil, nil, 0, 0, nil, err + } + if limiter == nil { + limiter = newScanLimiter(len(children)) + } + + isRootDir := root == "/" + home := os.Getenv("HOME") + isHomeDir := home != "" && root == home + + entries := make([]dirEntry, 0, len(children)) + targets := make([]liveScanTarget, 0, len(children)) + largeFiles := make([]fileEntry, 0) + var totalSize int64 + var totalFiles int64 + + for _, child := range children { + fullPath := filepath.Join(root, child.Name()) + + if child.Type()&fs.ModeSymlink != 0 { + targetInfo, err := os.Stat(fullPath) + isDir := false + if err == nil && targetInfo.IsDir() { + isDir = true + } + info, err := child.Info() + if err != nil { + continue + } + size := getActualFileSize(fullPath, info) + totalSize += size + entries = append(entries, dirEntry{ + Name: child.Name() + " →", + Path: fullPath, + Size: size, + IsDir: isDir, + LastAccess: getLastAccessTimeFromInfo(info), + }) + continue + } + + if child.IsDir() { + if defaultSkipDirs[child.Name()] { + continue + } + if isRootDir && skipSystemDirs[child.Name()] { + continue + } + + targetKind := liveScanTargetDirectory + if isHomeDir && child.Name() == "Library" { + targetKind = liveScanTargetHomeLibrary + } else if shouldFoldDirWithPath(child.Name(), fullPath) { + targetKind = liveScanTargetFoldedDirectory + } + + entries = append(entries, dirEntry{ + Name: child.Name(), + Path: fullPath, + Size: -1, + IsDir: true, + }) + targets = append(targets, liveScanTarget{ + name: child.Name(), + path: fullPath, + kind: targetKind, + }) + continue + } + + info, err := child.Info() + if err != nil { + continue + } + size, _ := countableFileSize(info, &limiter.seen) + totalSize += size + totalFiles++ + entries = append(entries, dirEntry{ + Name: child.Name(), + Path: fullPath, + Size: size, + IsDir: false, + LastAccess: getLastAccessTimeFromInfo(info), + }) + if !shouldSkipFileForLargeTracking(fullPath) && size >= largeFileWarmupMinSize { + largeFiles = append(largeFiles, fileEntry{Name: child.Name(), Path: fullPath, Size: size}) + } + } + + sortDirEntriesBySize(entries) + largeFiles = topLargeFiles(largeFiles) + return entries, targets, totalSize, totalFiles, largeFiles, nil +} + +func runLiveScan( + ctx context.Context, + id int64, + root string, + initialEntries []dirEntry, + targets []liveScanTarget, + initialTotalSize int64, + initialTotalFiles int64, + initialLargeFiles []fileEntry, + limiter *scanLimiter, + filesScanned, dirsScanned, bytesScanned *int64, + currentPath *atomic.Value, + events chan<- liveScanEventMsg, + cachePolicy scanCachePolicy, +) { + defer close(events) + + entriesByPath := make(map[string]dirEntry, len(initialEntries)) + for _, entry := range initialEntries { + entriesByPath[entry.Path] = entry + } + + var totalSize atomic.Int64 + var totalFiles atomic.Int64 + totalSize.Store(initialTotalSize) + totalFiles.Store(initialTotalFiles) + + largeFileChan := make(chan fileEntry, maxLargeFiles*2) + largeFileMinSize := int64(largeFileWarmupMinSize) + largeFilesDone := make(chan []fileEntry, 1) + go collectLiveLargeFiles(initialLargeFiles, largeFileChan, &largeFileMinSize, largeFilesDone) + + var dedupedHardlink atomic.Bool + var mu sync.Mutex + var wg sync.WaitGroup + + for _, target := range targets { + if ctx.Err() != nil { + break + } + target := target + scanTarget := func() { + defer wg.Done() + result, err := scanLiveTargetWithProgress(ctx, id, root, target, largeFileChan, &largeFileMinSize, limiter, currentPath, events, cachePolicy) + if err != nil && !errors.Is(err, context.Canceled) { + sendLiveScanEvent(ctx, events, liveScanEventMsg{id: id, path: root, kind: liveScanFailed, entry: dirEntry{Name: target.name, Path: target.path, IsDir: true}, err: err}) + return + } + if ctx.Err() != nil { + return + } + + entry := dirEntry{ + Name: target.name, + Path: target.path, + Size: result.TotalSize, + IsDir: true, + } + mu.Lock() + entriesByPath[target.path] = entry + mu.Unlock() + + totalSize.Add(result.TotalSize) + if result.TotalFiles > 0 { + totalFiles.Add(result.TotalFiles) + } + if result.dedupedHardlink { + dedupedHardlink.Store(true) + } + atomic.AddInt64(dirsScanned, 1) + if result.TotalFiles > 0 { + atomic.AddInt64(filesScanned, result.TotalFiles) + } + if result.TotalSize > 0 { + atomic.AddInt64(bytesScanned, result.TotalSize) + } + + sendLiveScanEvent(ctx, events, liveScanEventMsg{ + id: id, + path: root, + kind: liveScanChildDone, + entry: entry, + result: result, + }) + } + + wg.Add(1) + if limiter.tryAcquireEntry() { + go func() { + defer limiter.releaseEntry() + scanTarget() + }() + } else { + scanTarget() + } + } + + wg.Wait() + close(largeFileChan) + largeFiles := <-largeFilesDone + + if ctx.Err() != nil { + sendLiveScanEvent(context.Background(), events, liveScanEventMsg{id: id, path: root, kind: liveScanCanceled, err: ctx.Err()}) + return + } + + mu.Lock() + finalEntries := make([]dirEntry, 0, len(entriesByPath)) + for _, entry := range entriesByPath { + finalEntries = append(finalEntries, entry) + } + mu.Unlock() + sortDirEntriesBySize(finalEntries) + if len(finalEntries) > maxEntries { + finalEntries = finalEntries[:maxEntries] + } + + result := scanResult{ + Entries: finalEntries, + LargeFiles: largeFiles, + TotalSize: totalSize.Load(), + TotalFiles: totalFiles.Load(), + dedupedHardlink: dedupedHardlink.Load(), + } + + sendLiveScanEvent(ctx, events, liveScanEventMsg{id: id, path: root, kind: liveScanComplete, result: result}) +} + +func scanLiveTargetWithProgress(ctx context.Context, id int64, root string, target liveScanTarget, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, currentPath *atomic.Value, events chan<- liveScanEventMsg, cachePolicy scanCachePolicy) (scanResult, error) { + var filesScanned int64 + var dirsScanned int64 + var bytesScanned int64 + localCurrentPath := &atomic.Value{} + localCurrentPath.Store("") + done := make(chan struct{}) + progressDone := make(chan struct{}) + + go func() { + defer close(progressDone) + ticker := time.NewTicker(uiTickInterval * 2) + defer ticker.Stop() + + var lastSize int64 + for { + select { + case <-ctx.Done(): + return + case <-done: + return + case <-ticker.C: + size := atomic.LoadInt64(&bytesScanned) + if size <= 0 || size == lastSize { + continue + } + lastSize = size + if currentPath != nil { + if path, _ := localCurrentPath.Load().(string); path != "" { + currentPath.Store(path) + } + } + sendLiveScanProgress(ctx, events, liveScanEventMsg{ + id: id, + path: root, + kind: liveScanChildProgress, + entry: dirEntry{ + Name: target.name, + Path: target.path, + Size: size, + IsDir: true, + }, + }) + } + } + }() + + result, err := scanLiveTarget(ctx, target, largeFileChan, largeFileMinSize, limiter, &filesScanned, &dirsScanned, &bytesScanned, localCurrentPath, cachePolicy) + close(done) + <-progressDone + if result.TotalFiles == 0 { + result.TotalFiles = atomic.LoadInt64(&filesScanned) + } + if result.TotalSize == 0 { + result.TotalSize = atomic.LoadInt64(&bytesScanned) + } + return result, err +} + +func scanLiveTarget(ctx context.Context, target liveScanTarget, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, cachePolicy scanCachePolicy) (scanResult, error) { + if err := ctx.Err(); err != nil { + return scanResult{}, err + } + + switch target.kind { + case liveScanTargetHomeLibrary: + if cachePolicy == scanCacheReuse { + if cached, err := loadStoredOverviewSize(target.path); err == nil && cached > 0 { + return scanResult{TotalSize: cached}, nil + } + } + case liveScanTargetFoldedDirectory: + size, err := getDirectorySizeFromDu(target.path) + if err != nil || size <= 0 { + size = calculateDirSizeFastWithLimiter(target.path, limiter, filesScanned, dirsScanned, bytesScanned, currentPath) + } else { + atomic.AddInt64(bytesScanned, size) + } + return scanResult{TotalSize: size}, nil + } + + if err := ctx.Err(); err != nil { + return scanResult{}, err + } + + result := scanSubdirWithCache(target.path, largeFileChan, largeFileMinSize, limiter, limiter.dirSem, limiter.duSem, limiter.duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath, cachePolicy) + return result, ctx.Err() +} + +func collectLiveLargeFiles(initial []fileEntry, largeFileChan <-chan fileEntry, largeFileMinSize *int64, done chan<- []fileEntry) { + h := &largeFileHeap{} + heap.Init(h) + for _, file := range initial { + pushLiveLargeFile(h, file, largeFileMinSize) + } + for file := range largeFileChan { + pushLiveLargeFile(h, file, largeFileMinSize) + } + files := make([]fileEntry, h.Len()) + for i := range slices.Backward(files) { + files[i] = heap.Pop(h).(fileEntry) + } + done <- files +} + +func pushLiveLargeFile(h *largeFileHeap, file fileEntry, largeFileMinSize *int64) { + if h.Len() < maxLargeFiles { + heap.Push(h, file) + if h.Len() == maxLargeFiles { + atomic.StoreInt64(largeFileMinSize, (*h)[0].Size) + } + return + } + if file.Size > (*h)[0].Size { + heap.Pop(h) + heap.Push(h, file) + atomic.StoreInt64(largeFileMinSize, (*h)[0].Size) + } +} + +func sendLiveScanEvent(ctx context.Context, events chan<- liveScanEventMsg, msg liveScanEventMsg) { + select { + case <-ctx.Done(): + case events <- msg: + } +} + +func sendLiveScanProgress(ctx context.Context, events chan<- liveScanEventMsg, msg liveScanEventMsg) { + select { + case <-ctx.Done(): + case events <- msg: + default: + } +} + +func waitLiveScanEventCmd(events <-chan liveScanEventMsg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-events + if !ok { + return nil + } + return msg + } +} + +func sortDirEntriesBySize(entries []dirEntry) { + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Size > entries[j].Size + }) +} + +func topLargeFiles(files []fileEntry) []fileEntry { + if len(files) <= maxLargeFiles { + sort.SliceStable(files, func(i, j int) bool { + return files[i].Size > files[j].Size + }) + return files + } + h := &largeFileHeap{} + heap.Init(h) + var minSize int64 = largeFileWarmupMinSize + for _, file := range files { + pushLiveLargeFile(h, file, &minSize) + } + top := make([]fileEntry, h.Len()) + for i := range slices.Backward(top) { + top[i] = heap.Pop(h).(fileEntry) + } + return top +} diff --git a/Resources/mole/cmd/analyze/main.go b/Resources/mole/cmd/analyze/main.go index c8ed030..4c088a3 100644 --- a/Resources/mole/cmd/analyze/main.go +++ b/Resources/mole/cmd/analyze/main.go @@ -9,9 +9,8 @@ import ( "os" "os/exec" "path/filepath" - "slices" - "sort" "sync/atomic" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -21,140 +20,16 @@ var ( jsonMode = flag.Bool("json", false, "output analysis as JSON instead of TUI") ) -type dirEntry struct { - Name string - Path string - Size int64 - IsDir bool - LastAccess time.Time -} - -type fileEntry struct { - Name string - Path string - Size int64 -} - -type scanResult struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 -} - -type cacheEntry struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - ModTime time.Time - ScanTime time.Time -} - -type historyEntry struct { - Path string - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - Selected int - EntryOffset int - LargeSelected int - LargeOffset int - Dirty bool - IsOverview bool -} - -type scanResultMsg struct { - path string - result scanResult - err error - stale bool -} - -type overviewSizeMsg struct { - Path string - Index int - Size int64 - Err error -} - -type tickMsg time.Time - -type deleteProgressMsg struct { - done bool - err error - count int64 - path string -} - -type model struct { - path string - history []historyEntry - entries []dirEntry - largeFiles []fileEntry - selected int - offset int - status string - totalSize int64 - scanning bool - spinner int - filesScanned *int64 - dirsScanned *int64 - bytesScanned *int64 - currentPath *atomic.Value - showLargeFiles bool - isOverview bool - deleteConfirm bool - deleteTarget *dirEntry - deleting bool - deleteCount *int64 - cache map[string]historyEntry - largeSelected int - largeOffset int - overviewSizeCache map[string]int64 - overviewFilesScanned *int64 - overviewDirsScanned *int64 - overviewBytesScanned *int64 - overviewCurrentPath *string - overviewScanning bool - overviewScanningSet map[string]bool // Track which paths are currently being scanned - width int // Terminal width - height int // Terminal height - multiSelected map[string]bool // Track multi-selected items by path (safer than index) - largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) - totalFiles int64 // Total files found in current/last scan - lastTotalFiles int64 // Total files from previous scan (for progress bar) -} - -func (m model) inOverviewMode() bool { - return m.isOverview && m.path == "/" -} - func main() { flag.Parse() - target := os.Getenv("MO_ANALYZE_PATH") - if target == "" && len(flag.Args()) > 0 { - target = flag.Args()[0] - } - - var abs string - var isOverview bool - - if target == "" { - isOverview = true - abs = "/" - } else { - var err error - abs, err = filepath.Abs(target) - if err != nil { - fmt.Fprintf(os.Stderr, "cannot resolve %q: %v\n", target, err) - os.Exit(1) - } - isOverview = false + abs, isOverview, err := resolveScanTarget(os.Getenv("MO_ANALYZE_PATH"), flag.Args()) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } + go pruneAnalyzerCache() if *jsonMode { runJSONMode(abs, isOverview) } else { @@ -162,11 +37,38 @@ func main() { } } +// resolveScanTarget decides which scan a given invocation asks for. Kept +// separate from main so the overview-vs-directory routing has a test that fails +// when it flips: an end-to-end overview scan measures the real /Applications +// and /Library, which cost 106s of a single CI test file's 134s. +func resolveScanTarget(envPath string, args []string) (string, bool, error) { + target := envPath + if target == "" && len(args) > 0 { + target = args[0] + } + + // No explicit target means the machine-wide overview, not the root + // directory: "/" is only where the overview rows are anchored. + if target == "" { + return "/", true, nil + } + + abs, err := filepath.Abs(target) + if err != nil { + return "", false, fmt.Errorf("cannot resolve %q: %v", target, err) + } + return abs, false, nil +} + func runTUIMode(path string, isOverview bool) { - // Warm overview cache in background. - prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer prefetchCancel() - go prefetchOverviewCache(prefetchCtx) + // Warm overview cache only when the user opens a specific directory. + // Overview mode already schedules the same measurements for the foreground UI; + // running the prefetcher there doubles the du/io workload on cold start. + if !isOverview { + prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer prefetchCancel() + go prefetchOverviewCache(prefetchCtx) + } p := tea.NewProgram(newModel(path, isOverview), tea.WithAltScreen()) if _, err := p.Run(); err != nil { @@ -179,29 +81,30 @@ func newModel(path string, isOverview bool) model { var filesScanned, dirsScanned, bytesScanned int64 currentPath := &atomic.Value{} currentPath.Store("") - var overviewFilesScanned, overviewDirsScanned, overviewBytesScanned int64 - overviewCurrentPath := "" + var diskFreeBytes int64 + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err == nil { + diskFreeBytes = int64(stat.Bavail) * int64(stat.Bsize) + } m := model{ - path: path, - selected: 0, - status: "Preparing scan...", - scanning: !isOverview, - filesScanned: &filesScanned, - dirsScanned: &dirsScanned, - bytesScanned: &bytesScanned, - currentPath: currentPath, - showLargeFiles: false, - isOverview: isOverview, - cache: make(map[string]historyEntry), - overviewFilesScanned: &overviewFilesScanned, - overviewDirsScanned: &overviewDirsScanned, - overviewBytesScanned: &overviewBytesScanned, - overviewCurrentPath: &overviewCurrentPath, - overviewSizeCache: make(map[string]int64), - overviewScanningSet: make(map[string]bool), - multiSelected: make(map[string]bool), - largeMultiSelected: make(map[string]bool), + path: path, + selected: 0, + status: "Preparing scan...", + diskFree: diskFreeBytes, + scanning: !isOverview, + filesScanned: &filesScanned, + dirsScanned: &dirsScanned, + bytesScanned: &bytesScanned, + currentPath: currentPath, + showLargeFiles: false, + isOverview: isOverview, + cache: make(map[string]historyEntry), + overviewSizeCache: make(map[string]int64), + overviewScanningSet: make(map[string]bool), + multiSelected: make(map[string]bool), + largeMultiSelected: make(map[string]bool), + liveSortMode: liveScanSortModeFromEnv(), } if isOverview { @@ -228,6 +131,10 @@ func newModel(path string, isOverview bool) model { } func createOverviewEntries() []dirEntry { + return createOverviewEntriesWithInsights(createInsightEntries()) +} + +func createOverviewEntriesWithInsights(insightEntries []dirEntry) []dirEntry { home := os.Getenv("HOME") entries := []dirEntry{} @@ -237,865 +144,25 @@ func createOverviewEntries() []dirEntry { userLibrary := filepath.Join(home, "Library") if _, err := os.Stat(userLibrary); err == nil { - entries = append(entries, dirEntry{Name: "App Library", Path: userLibrary, IsDir: true, Size: -1}) - } - } - - entries = append(entries, - dirEntry{Name: "Applications", Path: "/Applications", IsDir: true, Size: -1}, - dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1}, - ) - - return entries -} - -func (m *model) hydrateOverviewEntries() { - m.entries = createOverviewEntries() - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - for i := range m.entries { - if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok { - m.entries[i].Size = size - continue - } - if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil { - m.entries[i].Size = size - m.overviewSizeCache[m.entries[i].Path] = size - } - } - m.totalSize = sumKnownEntrySizes(m.entries) -} - -func (m *model) sortOverviewEntriesBySize() { - // Stable sort by size. - sort.SliceStable(m.entries, func(i, j int) bool { - return m.entries[i].Size > m.entries[j].Size - }) -} - -func (m *model) scheduleOverviewScans() tea.Cmd { - if !m.inOverviewMode() { - return nil - } - - var pendingIndices []int - for i, entry := range m.entries { - if entry.Size < 0 && !m.overviewScanningSet[entry.Path] { - pendingIndices = append(pendingIndices, i) - if len(pendingIndices) >= maxConcurrentOverview { - break - } - } - } - - if len(pendingIndices) == 0 { - m.overviewScanning = false - if !hasPendingOverviewEntries(m.entries) { - m.sortOverviewEntriesBySize() - m.status = "Ready" - } - return nil - } - - var cmds []tea.Cmd - for _, idx := range pendingIndices { - entry := m.entries[idx] - m.overviewScanningSet[entry.Path] = true - cmd := scanOverviewPathCmd(entry.Path, idx) - cmds = append(cmds, cmd) - } - - m.overviewScanning = true - remaining := 0 - for _, e := range m.entries { - if e.Size < 0 { - remaining++ - } - } - if len(pendingIndices) > 0 { - firstEntry := m.entries[pendingIndices[0]] - if len(pendingIndices) == 1 { - m.status = fmt.Sprintf("Scanning %s..., %d left", firstEntry.Name, remaining) - } else { - m.status = fmt.Sprintf("Scanning %d directories..., %d left", len(pendingIndices), remaining) - } - } - - cmds = append(cmds, tickCmd()) - return tea.Batch(cmds...) -} - -func (m *model) getScanProgress() (files, dirs, bytes int64) { - if m.filesScanned != nil { - files = atomic.LoadInt64(m.filesScanned) - } - if m.dirsScanned != nil { - dirs = atomic.LoadInt64(m.dirsScanned) - } - if m.bytesScanned != nil { - bytes = atomic.LoadInt64(m.bytesScanned) - } - return -} - -func (m model) Init() tea.Cmd { - if m.inOverviewMode() { - return m.scheduleOverviewScans() - } - return tea.Batch(m.scanCmd(m.path), tickCmd()) -} - -func (m model) scanCmd(path string) tea.Cmd { - return func() tea.Msg { - if cached, err := loadCacheFromDisk(path); err == nil { - result := scanResult{ - Entries: cached.Entries, - LargeFiles: cached.LargeFiles, - TotalSize: cached.TotalSize, - TotalFiles: cached.TotalFiles, - } - return scanResultMsg{path: path, result: result, err: nil} - } - - if stale, err := loadStaleCacheFromDisk(path); err == nil { - result := scanResult{ - Entries: stale.Entries, - LargeFiles: stale.LargeFiles, - TotalSize: stale.TotalSize, - TotalFiles: stale.TotalFiles, - } - return scanResultMsg{path: path, result: result, err: nil, stale: true} - } - - v, err, _ := scanGroup.Do(path, func() (any, error) { - return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) - }) - - if err != nil { - return scanResultMsg{path: path, err: err} - } - - result := v.(scanResult) - - go func(p string, r scanResult) { - if err := saveCacheToDisk(p, r); err != nil { - _ = err // Cache save failure is not critical - } - }(path, result) - - return scanResultMsg{path: path, result: result, err: nil} - } -} - -func (m model) scanFreshCmd(path string) tea.Cmd { - return func() tea.Msg { - v, err, _ := scanGroup.Do(path, func() (any, error) { - return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) - }) - - if err != nil { - return scanResultMsg{path: path, err: err} - } - - result := v.(scanResult) - go func(p string, r scanResult) { - if err := saveCacheToDisk(p, r); err != nil { - _ = err - } - }(path, result) - - return scanResultMsg{path: path, result: result} - } -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m.updateKey(msg) - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case deleteProgressMsg: - if msg.done { - m.deleting = false - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - if msg.err != nil { - m.status = fmt.Sprintf("Failed to delete: %v", msg.err) - } else { - if msg.path != "" { - m.removePathFromView(msg.path) - invalidateCache(msg.path) - } - invalidateCache(m.path) - m.status = fmt.Sprintf("Deleted %d items", msg.count) - for i := range m.history { - m.history[i].Dirty = true - } - for path := range m.cache { - entry := m.cache[path] - entry.Dirty = true - m.cache[path] = entry - } - m.scanning = true - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - m.currentPath.Store("") - } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } + // Renamed from "App Library" to "User Library" so it parallels + // "System Library" (`/Library`) and is not confused with + // `/Applications`. Path unchanged. + entries = append(entries, dirEntry{Name: "User Library", Path: userLibrary, IsDir: true, Size: -1}) } - return m, nil - case scanResultMsg: - if msg.path != "" && msg.path != m.path { - return m, nil - } - m.scanning = false - if msg.err != nil { - m.status = fmt.Sprintf("Scan failed: %v", msg.err) - return m, nil - } - filteredEntries := make([]dirEntry, 0, len(msg.result.Entries)) - for _, e := range msg.result.Entries { - if e.Size > 0 { - filteredEntries = append(filteredEntries, e) - } - } - m.entries = filteredEntries - m.largeFiles = msg.result.LargeFiles - m.totalSize = msg.result.TotalSize - m.totalFiles = msg.result.TotalFiles - m.clampEntrySelection() - m.clampLargeSelection() - m.cache[m.path] = cacheSnapshot(m) - if m.totalSize > 0 { - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - m.overviewSizeCache[m.path] = m.totalSize - go func(path string, size int64) { - _ = storeOverviewSize(path, size) - }(m.path, m.totalSize) - } - - if msg.stale { - m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) - m.scanning = true - if m.totalFiles > 0 { - m.lastTotalFiles = m.totalFiles - } - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - m.currentPath.Store("") - } - return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) - } - - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - return m, nil - case overviewSizeMsg: - delete(m.overviewScanningSet, msg.Path) - - if msg.Err == nil { - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - m.overviewSizeCache[msg.Path] = msg.Size - } - - if m.inOverviewMode() { - for i := range m.entries { - if m.entries[i].Path == msg.Path { - if msg.Err == nil { - m.entries[i].Size = msg.Size - } else { - m.entries[i].Size = 0 - } - break - } - } - m.totalSize = sumKnownEntrySizes(m.entries) - - if msg.Err != nil { - m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err) - } - - cmd := m.scheduleOverviewScans() - return m, cmd - } - return m, nil - case tickMsg: - hasPending := false - if m.inOverviewMode() { - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - } - if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) { - m.spinner = (m.spinner + 1) % len(spinnerFrames) - if m.deleting && m.deleteCount != nil { - count := atomic.LoadInt64(m.deleteCount) - if count > 0 { - m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count)) - } - } - return m, tickCmd() - } - return m, nil - default: - return m, nil } -} - -func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Delete confirm flow. - if m.deleteConfirm { - switch msg.String() { - case "enter": - m.deleteConfirm = false - m.deleting = true - var deleteCount int64 - m.deleteCount = &deleteCount - // Collect paths (safer than indices). - var pathsToDelete []string - if m.showLargeFiles { - if len(m.largeMultiSelected) > 0 { - for path := range m.largeMultiSelected { - pathsToDelete = append(pathsToDelete, path) - } - } else if m.deleteTarget != nil { - pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) - } - } else { - if len(m.multiSelected) > 0 { - for path := range m.multiSelected { - pathsToDelete = append(pathsToDelete, path) - } - } else if m.deleteTarget != nil { - pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) - } - } + entries = append(entries, systemOverviewRoots()...) - m.deleteTarget = nil - if len(pathsToDelete) == 0 { - m.deleting = false - m.status = "Nothing to delete" - return m, nil - } + // Hidden space insights: paths that silently accumulate disk usage. + entries = append(entries, insightEntries...) - if len(pathsToDelete) == 1 { - targetPath := pathsToDelete[0] - m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath)) - return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) - } - - m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) - return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) - case "esc", "q": - m.status = "Cancelled" - m.deleteConfirm = false - m.deleteTarget = nil - return m, nil - default: - return m, nil - } - } - - switch msg.String() { - case "q", "ctrl+c", "Q": - return m, tea.Quit - case "esc": - if m.showLargeFiles { - m.showLargeFiles = false - return m, nil - } - return m, tea.Quit - case "up", "k", "K": - if m.showLargeFiles { - if m.largeSelected > 0 { - m.largeSelected-- - if m.largeSelected < m.largeOffset { - m.largeOffset = m.largeSelected - } - } - } else if len(m.entries) > 0 && m.selected > 0 { - m.selected-- - if m.selected < m.offset { - m.offset = m.selected - } - } - case "down", "j", "J": - if m.showLargeFiles { - if m.largeSelected < len(m.largeFiles)-1 { - m.largeSelected++ - viewport := calculateViewport(m.height, true) - if m.largeSelected >= m.largeOffset+viewport { - m.largeOffset = m.largeSelected - viewport + 1 - } - } - } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { - m.selected++ - viewport := calculateViewport(m.height, false) - if m.selected >= m.offset+viewport { - m.offset = m.selected - viewport + 1 - } - } - case "enter", "right", "l", "L": - if m.showLargeFiles { - return m, nil - } - return m.enterSelectedDir() - case "b", "left", "h", "B", "H": - if m.showLargeFiles { - m.showLargeFiles = false - return m, nil - } - if len(m.history) == 0 { - if !m.inOverviewMode() { - return m, m.switchToOverviewMode() - } - return m, nil - } - last := m.history[len(m.history)-1] - m.history = m.history[:len(m.history)-1] - m.path = last.Path - m.selected = last.Selected - m.offset = last.EntryOffset - m.largeSelected = last.LargeSelected - m.largeOffset = last.LargeOffset - m.isOverview = last.IsOverview - if last.Dirty { - // On overview return, refresh cached entries. - if last.IsOverview { - m.hydrateOverviewEntries() - m.totalSize = sumKnownEntrySizes(m.entries) - m.status = "Ready" - m.scanning = false - if nextPendingOverviewIndex(m.entries) >= 0 { - m.overviewScanning = true - return m, m.scheduleOverviewScans() - } - return m, nil - } - m.status = "Scanning..." - m.scanning = true - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - m.entries = last.Entries - m.largeFiles = last.LargeFiles - m.totalSize = last.TotalSize - m.clampEntrySelection() - m.clampLargeSelection() - if len(m.entries) == 0 { - m.selected = 0 - } else if m.selected >= len(m.entries) { - m.selected = len(m.entries) - 1 - } - if m.selected < 0 { - m.selected = 0 - } - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - m.scanning = false - return m, nil - case "r", "R": - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - - if m.inOverviewMode() { - // Explicitly invalidate cache for all overview entries to force re-scan - for _, entry := range m.entries { - invalidateCache(entry.Path) - } - - m.overviewSizeCache = make(map[string]int64) - m.overviewScanningSet = make(map[string]bool) - m.hydrateOverviewEntries() // Reset sizes to pending - - for i := range m.entries { - m.entries[i].Size = -1 - } - m.totalSize = 0 - - m.status = "Refreshing..." - m.overviewScanning = true - return m, tea.Batch(m.scheduleOverviewScans(), tickCmd()) - } - - invalidateCache(m.path) - m.status = "Refreshing..." - m.scanning = true - if m.totalFiles > 0 { - m.lastTotalFiles = m.totalFiles - } - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - m.currentPath.Store("") - } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - case "t", "T": - if !m.inOverviewMode() { - m.showLargeFiles = !m.showLargeFiles - if m.showLargeFiles { - m.largeSelected = 0 - m.largeOffset = 0 - m.largeMultiSelected = make(map[string]bool) - } else { - m.multiSelected = make(map[string]bool) - } - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - case "o", "O": - // Open selected entries (multi-select aware). - const maxBatchOpen = 20 - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - count := len(m.largeMultiSelected) - if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) - return m, nil - } - for path := range m.largeMultiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } - m.status = fmt.Sprintf("Opening %d items...", count) - } else { - selected := m.largeFiles[m.largeSelected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) - } - } - } else if len(m.entries) > 0 { - if len(m.multiSelected) > 0 { - count := len(m.multiSelected) - if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) - return m, nil - } - for path := range m.multiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } - m.status = fmt.Sprintf("Opening %d items...", count) - } else { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) - } - } - case "f", "F": - // Reveal in Finder (multi-select aware). - const maxBatchReveal = 20 - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - count := len(m.largeMultiSelected) - if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) - return m, nil - } - for path := range m.largeMultiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } - m.status = fmt.Sprintf("Showing %d items in Finder...", count) - } else { - selected := m.largeFiles[m.largeSelected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) - } - } - } else if len(m.entries) > 0 { - if len(m.multiSelected) > 0 { - count := len(m.multiSelected) - if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) - return m, nil - } - for path := range m.multiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } - m.status = fmt.Sprintf("Showing %d items in Finder...", count) - } else { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) - } - } - case " ": - // Toggle multi-select (paths as keys). - if m.showLargeFiles { - if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) { - if m.largeMultiSelected == nil { - m.largeMultiSelected = make(map[string]bool) - } - selectedPath := m.largeFiles[m.largeSelected].Path - if m.largeMultiSelected[selectedPath] { - delete(m.largeMultiSelected, selectedPath) - } else { - m.largeMultiSelected[selectedPath] = true - } - count := len(m.largeMultiSelected) - if count > 0 { - var totalSize int64 - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - totalSize += file.Size - break - } - } - } - m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) - } else { - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - } - } else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) { - if m.multiSelected == nil { - m.multiSelected = make(map[string]bool) - } - selectedPath := m.entries[m.selected].Path - if m.multiSelected[selectedPath] { - delete(m.multiSelected, selectedPath) - } else { - m.multiSelected[selectedPath] = true - } - count := len(m.multiSelected) - if count > 0 { - var totalSize int64 - for path := range m.multiSelected { - for _, entry := range m.entries { - if entry.Path == path { - totalSize += entry.Size - break - } - } - } - m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) - } else { - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - } - case "delete", "backspace": - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - m.deleteConfirm = true - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - m.deleteTarget = &dirEntry{ - Name: file.Name, - Path: file.Path, - Size: file.Size, - IsDir: false, - } - break - } - } - break // Only need first one for display - } - } else if m.largeSelected < len(m.largeFiles) { - selected := m.largeFiles[m.largeSelected] - m.deleteConfirm = true - m.deleteTarget = &dirEntry{ - Name: selected.Name, - Path: selected.Path, - Size: selected.Size, - IsDir: false, - } - } - } - } else if len(m.entries) > 0 && !m.inOverviewMode() { - if len(m.multiSelected) > 0 { - m.deleteConfirm = true - for path := range m.multiSelected { - // Resolve entry by path. - for i := range m.entries { - if m.entries[i].Path == path { - m.deleteTarget = &m.entries[i] - break - } - } - break // Only need first one for display - } - } else if m.selected < len(m.entries) { - selected := m.entries[m.selected] - m.deleteConfirm = true - m.deleteTarget = &selected - } - } - } - return m, nil -} - -func (m *model) switchToOverviewMode() tea.Cmd { - m.isOverview = true - m.path = "/" - m.scanning = false - m.showLargeFiles = false - m.largeFiles = nil - m.largeSelected = 0 - m.largeOffset = 0 - m.deleteConfirm = false - m.deleteTarget = nil - m.selected = 0 - m.offset = 0 - m.hydrateOverviewEntries() - cmd := m.scheduleOverviewScans() - if cmd == nil { - m.status = "Ready" - return nil - } - return tea.Batch(cmd, tickCmd()) -} - -func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { - if len(m.entries) == 0 { - return m, nil - } - selected := m.entries[m.selected] - if selected.IsDir { - if len(m.history) == 0 || m.history[len(m.history)-1].Path != m.path { - m.history = append(m.history, snapshotFromModel(m)) - } - m.path = selected.Path - m.selected = 0 - m.offset = 0 - m.status = "Scanning..." - m.scanning = true - m.isOverview = false - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - m.currentPath.Store("") - } - - if cached, ok := m.cache[m.path]; ok && !cached.Dirty { - m.entries = slices.Clone(cached.Entries) - m.largeFiles = slices.Clone(cached.LargeFiles) - m.totalSize = cached.TotalSize - m.totalFiles = cached.TotalFiles - m.selected = cached.Selected - m.offset = cached.EntryOffset - m.largeSelected = cached.LargeSelected - m.largeOffset = cached.LargeOffset - m.clampEntrySelection() - m.clampLargeSelection() - m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) - m.scanning = false - return m, nil - } - m.lastTotalFiles = 0 - if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 { - m.lastTotalFiles = total - } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - m.status = fmt.Sprintf("File: %s, %s", selected.Name, humanizeBytes(selected.Size)) - return m, nil -} - -func (m *model) clampEntrySelection() { - if len(m.entries) == 0 { - m.selected = 0 - m.offset = 0 - return - } - if m.selected >= len(m.entries) { - m.selected = len(m.entries) - 1 - } - if m.selected < 0 { - m.selected = 0 - } - viewport := calculateViewport(m.height, false) - maxOffset := max(len(m.entries)-viewport, 0) - if m.offset > maxOffset { - m.offset = maxOffset - } - if m.selected < m.offset { - m.offset = m.selected - } - if m.selected >= m.offset+viewport { - m.offset = m.selected - viewport + 1 - } + return entries } -func (m *model) clampLargeSelection() { - if len(m.largeFiles) == 0 { - m.largeSelected = 0 - m.largeOffset = 0 - return - } - if m.largeSelected >= len(m.largeFiles) { - m.largeSelected = len(m.largeFiles) - 1 - } - if m.largeSelected < 0 { - m.largeSelected = 0 - } - viewport := calculateViewport(m.height, true) - maxOffset := max(len(m.largeFiles)-viewport, 0) - if m.largeOffset > maxOffset { - m.largeOffset = maxOffset - } - if m.largeSelected < m.largeOffset { - m.largeOffset = m.largeSelected - } - if m.largeSelected >= m.largeOffset+viewport { - m.largeOffset = m.largeSelected - viewport + 1 +func systemOverviewRoots() []dirEntry { + return []dirEntry{ + {Name: "Applications", Path: "/Applications", IsDir: true, Size: -1}, + {Name: "System Library", Path: "/Library", IsDir: true, Size: -1}, } } @@ -1127,48 +194,25 @@ func hasPendingOverviewEntries(entries []dirEntry) bool { return false } -func (m *model) removePathFromView(path string) { - if path == "" { - return - } - - var removedSize int64 - for i, entry := range m.entries { - if entry.Path == path { - if entry.Size > 0 { - removedSize = entry.Size - } - m.entries = append(m.entries[:i], m.entries[i+1:]...) - break - } - } - - for i := 0; i < len(m.largeFiles); i++ { - if m.largeFiles[i].Path == path { - m.largeFiles = append(m.largeFiles[:i], m.largeFiles[i+1:]...) - break - } +func safeOpen(path string, reveal bool) error { + if err := validatePath(path); err != nil { + return err } - - if removedSize > 0 { - if removedSize > m.totalSize { - m.totalSize = 0 - } else { - m.totalSize -= removedSize - } - m.clampEntrySelection() + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + args := []string{path} + if reveal { + args = []string{"-R", path} } - m.clampLargeSelection() + return exec.CommandContext(ctx, "open", args...).Run() } -func scanOverviewPathCmd(path string, index int) tea.Cmd { - return func() tea.Msg { - size, err := measureOverviewSize(path) - return overviewSizeMsg{ - Path: path, - Index: index, - Size: size, - Err: err, - } +// safePreview opens the file with the default macOS application. +func safePreview(path string) error { + if err := validatePath(path); err != nil { + return err } + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + return exec.CommandContext(ctx, "open", path).Run() } diff --git a/Resources/mole/cmd/analyze/main_stub.go b/Resources/mole/cmd/analyze/main_stub.go new file mode 100644 index 0000000..89bd0e0 --- /dev/null +++ b/Resources/mole/cmd/analyze/main_stub.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "analyze is only supported on macOS") + os.Exit(1) +} diff --git a/Resources/mole/cmd/analyze/model.go b/Resources/mole/cmd/analyze/model.go new file mode 100644 index 0000000..6911574 --- /dev/null +++ b/Resources/mole/cmd/analyze/model.go @@ -0,0 +1,385 @@ +//go:build darwin + +package main + +import ( + "context" + "sort" + "strings" + "sync/atomic" + "time" +) + +type dirEntry struct { + Name string + Path string + Size int64 + IsDir bool + LastAccess time.Time +} + +type fileEntry struct { + Name string + Path string + Size int64 +} + +type scanResult struct { + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + // dedupedHardlink is true when a hardlinked file in this subtree was + // counted as zero because another link was seen earlier in the same + // scan. Such a result is scan-order dependent and must not be written + // to the on-disk cache. In-memory only; never serialized to cacheEntry. + dedupedHardlink bool +} + +type cacheEntry struct { + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + ModTime time.Time + ScanTime time.Time + NeedsRefresh bool + // SchemaVersion guards against reusing cache written by an older binary + // with different sizing semantics. Entries not at cacheSchemaVersion are + // rejected on load. Old caches decode this as 0. + SchemaVersion int +} + +type historyEntry struct { + Path string + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + Selected int + EntryOffset int + LargeSelected int + LargeOffset int + NeedsRefresh bool + IsOverview bool +} + +type scanResultMsg struct { + path string + result scanResult + err error + stale bool +} + +type liveScanStartMsg struct { + id int64 + path string + entries []dirEntry + totalSize int64 + totalFiles int64 + largeFiles []fileEntry + scanningPaths []string + events <-chan liveScanEventMsg + cancel context.CancelFunc + err error +} + +type liveScanEventKind int + +const ( + liveScanChildProgress liveScanEventKind = iota + 1 + liveScanChildDone + liveScanComplete + liveScanFailed + liveScanCanceled +) + +type liveScanEventMsg struct { + id int64 + path string + kind liveScanEventKind + entry dirEntry + result scanResult + err error +} + +type liveSortMode int + +const ( + liveSortContinuous liveSortMode = iota + liveSortFreezeOnMove +) + +type overviewSizeMsg struct { + Path string + Index int + Size int64 + Err error +} + +type tickMsg time.Time + +type deleteProgressMsg struct { + done bool + err error + count int64 + path string + removedPaths []string +} + +type model struct { + path string + history []historyEntry + entries []dirEntry + largeFiles []fileEntry + selected int + offset int + status string + totalSize int64 + scanning bool + spinner int + filesScanned *int64 + dirsScanned *int64 + bytesScanned *int64 + currentPath *atomic.Value + showLargeFiles bool + isOverview bool + deleteConfirm bool + deleteTarget *dirEntry + deleting bool + deleteCount *int64 + cache map[string]historyEntry + largeSelected int + largeOffset int + overviewSizeCache map[string]int64 + overviewScanning bool + overviewScanningSet map[string]bool // Track which paths are currently being scanned + width int // Terminal width + height int // Terminal height + multiSelected map[string]bool // Track multi-selected items by path (safer than index) + largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) + totalFiles int64 // Total files found in current/last scan + lastTotalFiles int64 // Total files from previous scan (for progress bar) + diskFree int64 // Free disk space for the analyzed volume + viewNeedsRefresh bool + // Top-files (T) view incremental filter. largeFilesAll is the full, + // size-ranked list; largeFiles is the view actually rendered and acted on, + // which equals largeFilesAll when no filter is set and the matching subset + // otherwise. largeFiltering is true only while the user is typing a query. + largeFilesAll []fileEntry + largeFilter string + largeFiltering bool + // Directory (drill-down) view incremental filter, mirroring the Top-files + // one. entriesAll is the full non-empty entry list; entries is the rendered, + // possibly filtered view. Disabled in overview mode. + entriesAll []dirEntry + entryFilter string + entryFiltering bool + liveScanID int64 + liveScanCancel context.CancelFunc + liveScanEvents <-chan liveScanEventMsg + liveScanningPaths map[string]bool + autoSortLiveEntries bool + liveSortMode liveSortMode +} + +func (m model) inOverviewMode() bool { + return m.isOverview && m.path == "/" +} + +func (m *model) hydrateOverviewEntries() { + m.entries = createOverviewEntries() + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + for i := range m.entries { + if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok { + m.entries[i].Size = size + continue + } + if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil { + m.entries[i].Size = size + m.overviewSizeCache[m.entries[i].Path] = size + } + } + m.totalSize = sumKnownEntrySizes(m.entries) +} + +func (m *model) sortOverviewEntriesBySize() { + // Stable sort by size. + sort.SliceStable(m.entries, func(i, j int) bool { + return m.entries[i].Size > m.entries[j].Size + }) +} + +func (m *model) getScanProgress() (files, dirs, bytes int64) { + if m.filesScanned != nil { + files = atomic.LoadInt64(m.filesScanned) + } + if m.dirsScanned != nil { + dirs = atomic.LoadInt64(m.dirsScanned) + } + if m.bytesScanned != nil { + bytes = atomic.LoadInt64(m.bytesScanned) + } + return +} + +func (m *model) clampEntrySelection() { + if len(m.entries) == 0 { + m.selected = 0 + m.offset = 0 + return + } + if m.selected >= len(m.entries) { + m.selected = len(m.entries) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + viewport := calculateViewport(m.height, false) + maxOffset := max(len(m.entries)-viewport, 0) + if m.offset > maxOffset { + m.offset = maxOffset + } + if m.selected < m.offset { + m.offset = m.selected + } + if m.selected >= m.offset+viewport { + m.offset = m.selected - viewport + 1 + } +} + +func (m *model) clampLargeSelection() { + if len(m.largeFiles) == 0 { + m.largeSelected = 0 + m.largeOffset = 0 + return + } + if m.largeSelected >= len(m.largeFiles) { + m.largeSelected = len(m.largeFiles) - 1 + } + if m.largeSelected < 0 { + m.largeSelected = 0 + } + viewport := calculateViewport(m.height, true) + maxOffset := max(len(m.largeFiles)-viewport, 0) + if m.largeOffset > maxOffset { + m.largeOffset = maxOffset + } + if m.largeSelected < m.largeOffset { + m.largeOffset = m.largeSelected + } + if m.largeSelected >= m.largeOffset+viewport { + m.largeOffset = m.largeSelected - viewport + 1 + } +} + +func (m *model) removePathFromView(path string) { + if path == "" { + return + } + + var removedSize int64 + for _, entry := range m.entriesAll { + if entry.Path == path { + if entry.Size > 0 { + removedSize = entry.Size + } + break + } + } + + // Trim the backing lists once, then rebuild each view from them. Removing + // directly from both a backing list and its (possibly aliased) view would + // shift the shared array twice and corrupt it; rebuilding via the filters + // keeps the view, the query, and the selection consistent. + m.entriesAll = removeByPath(m.entriesAll, path, dirEntryPath) + m.largeFilesAll = removeByPath(m.largeFilesAll, path, fileEntryPath) + + if removedSize > 0 { + if removedSize > m.totalSize { + m.totalSize = 0 + } else { + m.totalSize -= removedSize + } + } + + m.applyEntryFilter() + m.applyLargeFilter() +} + +func fileEntryName(f fileEntry) string { return f.Name } +func fileEntryPath(f fileEntry) string { return f.Path } +func dirEntryName(e dirEntry) string { return e.Name } +func dirEntryPath(e dirEntry) string { return e.Path } + +// filterMatches reports whether an item with the given name and path matches a +// case-insensitive substring query. Single source of truth for both the +// Top-files and directory filters so their match semantics cannot drift. +func filterMatches(name, path, query string) bool { + needle := strings.ToLower(query) + return strings.Contains(strings.ToLower(name), needle) || + strings.Contains(strings.ToLower(displayPath(path)), needle) +} + +// filterByQuery returns the items matching query, or the original slice +// unchanged when the query is empty. nameOf/pathOf project the fields matched. +func filterByQuery[T any](all []T, query string, nameOf, pathOf func(T) string) []T { + if query == "" { + return all + } + out := make([]T, 0, len(all)) + for _, item := range all { + if filterMatches(nameOf(item), pathOf(item), query) { + out = append(out, item) + } + } + return out +} + +// removeByPath drops the first item whose projected path equals path. +func removeByPath[T any](items []T, path string, pathOf func(T) string) []T { + for i := range items { + if pathOf(items[i]) == path { + return append(items[:i], items[i+1:]...) + } + } + return items +} + +// applyLargeFilter rebuilds the rendered Top-files view from largeFilesAll +// using the current query. An empty query restores the full list. +func (m *model) applyLargeFilter() { + m.largeFiles = filterByQuery(m.largeFilesAll, m.largeFilter, fileEntryName, fileEntryPath) + m.clampLargeSelection() +} + +// resetLargeFilter clears any active Top-files filter and restores the full +// list. Callers that leave the Top-files view use this so the next visit and +// the per-path navigation state start clean. +func (m *model) resetLargeFilter() { + m.largeFilter = "" + m.largeFiltering = false + if m.largeFilesAll != nil { + m.largeFiles = m.largeFilesAll + } +} + +// applyEntryFilter rebuilds the rendered directory view from entriesAll using +// the current query. The directory view is the drill-down list (m.entries) in +// non-overview mode. +func (m *model) applyEntryFilter() { + m.entries = filterByQuery(m.entriesAll, m.entryFilter, dirEntryName, dirEntryPath) + m.clampEntrySelection() +} + +// resetEntryFilter clears any active directory filter and restores the full +// entry list. +func (m *model) resetEntryFilter() { + m.entryFilter = "" + m.entryFiltering = false + if m.entriesAll != nil { + m.entries = m.entriesAll + } +} diff --git a/Resources/mole/cmd/analyze/scanner.go b/Resources/mole/cmd/analyze/scanner.go index f22387b..983041c 100644 --- a/Resources/mole/cmd/analyze/scanner.go +++ b/Resources/mole/cmd/analyze/scanner.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -10,17 +12,90 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" + "sort" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" - - "golang.org/x/sync/singleflight" ) -var scanGroup singleflight.Group +var spotlightQueryRunner = func(ctx context.Context, root, query string) ([]byte, error) { + return exec.CommandContext(ctx, "mdfind", "-onlyin", root, query).Output() +} + +// scanLimiter bundles the concurrency budgets used by a single scan pass. +// +// There are five separate semaphores on purpose: each protects a different +// scarce resource. Collapsing two of them changes scaling behavior in ways +// that are easy to get wrong; see the per-field notes before adjusting. +type scanLimiter struct { + // entrySem caps the number of in-flight top-level entry workers (one per + // child of the root being scanned). Acquired with tryAcquireEntry so the + // caller can fall back to inline scanning when the budget is saturated. + entrySem chan struct{} + + // dirSem caps the number of concurrent recursive directory walkers + // inside calculateDirSizeConcurrent. Independent of entrySem because a + // single entry can fan out into many directory walkers. + dirSem chan struct{} + + // duSem caps how many `du` subprocesses execute concurrently. Tuned + // low (NumCPU capped at 4) because each du process is itself heavily + // I/O parallel and saturating the disk hurts wall-clock latency. + duSem chan struct{} + + // duQueueSem caps how many goroutines are *queued* to run du. + // Distinct from duSem so we don't spawn one goroutine per pending + // directory and grow memory linearly with the input set; without + // this bound, large home dirs allocate thousands of stacks waiting + // on duSem. Sized at 2x duSem to keep the worker side warm without + // unbounded queueing. + duQueueSem chan struct{} + + // fastSem caps the workers used by the fallback fast-sizing path + // when du is unavailable or rejected. Same scale as entrySem because + // the fast path replaces a single du subprocess with one walker. + fastSem chan struct{} + + // seen tracks (dev, ino) of hardlinked files counted so far in this + // scan so a file with multiple links is counted once, matching `du`. + seen sync.Map +} + +func newScanLimiter(childCount int) *scanLimiter { + if childCount <= 0 { + childCount = maxWorkers + } + numWorkers := max(min(max(runtime.NumCPU()*cpuMultiplier, minWorkers), maxWorkers, childCount), 1) + return &scanLimiter{ + entrySem: make(chan struct{}, numWorkers), + dirSem: make(chan struct{}, min(runtime.NumCPU()*2, maxDirWorkers)), + duSem: make(chan struct{}, min(4, runtime.NumCPU())), + duQueueSem: make(chan struct{}, min(4, runtime.NumCPU())*2), + fastSem: make(chan struct{}, min(runtime.NumCPU()*cpuMultiplier, maxWorkers)), + } +} + +func (l *scanLimiter) tryAcquireEntry() bool { + if l == nil || l.entrySem == nil { + return false + } + select { + case l.entrySem <- struct{}{}: + return true + default: + return false + } +} + +func (l *scanLimiter) releaseEntry() { + if l != nil && l.entrySem != nil { + <-l.entrySem + } +} // trySend attempts to send an item to a channel with a timeout. // Returns true if the item was sent, false if the timeout was reached. @@ -59,29 +134,55 @@ func trySend[T any](ch chan<- T, item T, timeout time.Duration) bool { } func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { + return scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, true, maxEntries) +} + +func scanPathConcurrentAllEntries(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { + return scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, true, 0) +} + +func scanPathConcurrentWithOptions(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, useSpotlight bool, entryLimit int) (scanResult, error) { + return scanPathConcurrentWithLimiter(root, filesScanned, dirsScanned, bytesScanned, currentPath, useSpotlight, entryLimit, nil, scanCacheReuse) +} + +type scanCachePolicy uint8 + +const ( + scanCacheReuse scanCachePolicy = iota + scanCacheBypass +) + +func scanPathConcurrentWithLimiter(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, useSpotlight bool, entryLimit int, limiter *scanLimiter, cachePolicy scanCachePolicy) (scanResult, error) { children, err := os.ReadDir(root) if err != nil { return scanResult{}, err } + if limiter == nil { + limiter = newScanLimiter(len(children)) + } var total int64 var localFilesScanned int64 var localBytesScanned int64 + var subtreeFilesScanned atomic.Int64 + var dedupedHardlink atomic.Bool + + collectAllEntries := entryLimit <= 0 + var collectedEntries []dirEntry - // Keep Top N heaps. + // Keep Top N heaps when a limit is requested. entriesHeap := &entryHeap{} - heap.Init(entriesHeap) + if !collectAllEntries { + heap.Init(entriesHeap) + } largeFilesHeap := &largeFileHeap{} heap.Init(largeFilesHeap) largeFileMinSize := int64(largeFileWarmupMinSize) - // Worker pool sized for I/O-bound scanning. - numWorkers := max(min(max(runtime.NumCPU()*cpuMultiplier, minWorkers), maxWorkers, len(children)), 1) - sem := make(chan struct{}, numWorkers) - dirSem := make(chan struct{}, min(runtime.NumCPU()*2, maxDirWorkers)) - duSem := make(chan struct{}, min(4, runtime.NumCPU())) // limits concurrent du processes - duQueueSem := make(chan struct{}, min(4, runtime.NumCPU())*2) // limits how many goroutines may be waiting to run du + dirSem := limiter.dirSem + duSem := limiter.duSem + duQueueSem := limiter.duQueueSem var wg sync.WaitGroup // Collect results via channels. @@ -91,20 +192,22 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFileChan := make(chan fileEntry, maxLargeFiles*2) var collectorWg sync.WaitGroup - collectorWg.Add(2) - go func() { - defer collectorWg.Done() + collectorWg.Go(func() { for entry := range entryChan { - if entriesHeap.Len() < maxEntries { + if collectAllEntries { + collectedEntries = append(collectedEntries, entry) + continue + } + + if entriesHeap.Len() < entryLimit { heap.Push(entriesHeap, entry) } else if entry.Size > (*entriesHeap)[0].Size { heap.Pop(entriesHeap) heap.Push(entriesHeap, entry) } } - }() - go func() { - defer collectorWg.Done() + }) + collectorWg.Go(func() { for file := range largeFileChan { if largeFilesHeap.Len() < maxLargeFiles { heap.Push(largeFilesHeap, file) @@ -117,7 +220,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.StoreInt64(&largeFileMinSize, (*largeFilesHeap)[0].Size) } } - }() + }) isRootDir := root == "/" home := os.Getenv("HOME") @@ -148,7 +251,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in Size: size, IsDir: isDir, LastAccess: getLastAccessTimeFromInfo(info), - }, 100*time.Millisecond) + }, scanSendTimeout) continue } @@ -165,82 +268,99 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in // ~/Library is scanned separately; reuse cache when possible. if isHomeDir && child.Name() == "Library" { - sem <- struct{}{} - wg.Add(1) - go func(name, path string) { - defer wg.Done() - defer func() { <-sem }() - - var size int64 - if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { - size = cached - } else if cached, err := loadCacheFromDisk(path); err == nil { - size = cached.TotalSize - } else { - size = calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + processDir := func(name, path string) { + result := scanResult{} + if cachePolicy == scanCacheReuse { + if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { + result.TotalSize = cached + } + } + if result.TotalSize <= 0 { + result = scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath, cachePolicy) + } + atomic.AddInt64(&total, result.TotalSize) + if result.TotalFiles > 0 { + subtreeFilesScanned.Add(result.TotalFiles) + } + if result.dedupedHardlink { + dedupedHardlink.Store(true) } - atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ Name: name, Path: path, - Size: size, + Size: result.TotalSize, IsDir: true, LastAccess: time.Time{}, - }, 100*time.Millisecond) - }(child.Name(), fullPath) + }, scanSendTimeout) + } + if limiter.tryAcquireEntry() { + wg.Go(func() { + defer limiter.releaseEntry() + processDir(child.Name(), fullPath) + }) + } else { + processDir(child.Name(), fullPath) + } continue } // Folded dirs: fast size without expanding. if shouldFoldDirWithPath(child.Name(), fullPath) { duQueueSem <- struct{}{} - wg.Add(1) - go func(name, path string) { - defer wg.Done() + wg.Go(func() { defer func() { <-duQueueSem }() size, err := func() (int64, error) { duSem <- struct{}{} defer func() { <-duSem }() - return getDirectorySizeFromDu(path) + return getDirectorySizeFromDu(fullPath) }() if err != nil || size <= 0 { - size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeFastWithLimiter(fullPath, limiter, filesScanned, dirsScanned, bytesScanned, currentPath) } atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ - Name: name, - Path: path, + Name: child.Name(), + Path: fullPath, Size: size, IsDir: true, LastAccess: time.Time{}, - }, 100*time.Millisecond) - }(child.Name(), fullPath) + }, scanSendTimeout) + }) continue } - sem <- struct{}{} - wg.Add(1) - go func(name, path string) { - defer wg.Done() - defer func() { <-sem }() - - size := calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) + processDir := func(name, path string) { + result := scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath, cachePolicy) + atomic.AddInt64(&total, result.TotalSize) + if result.TotalFiles > 0 { + subtreeFilesScanned.Add(result.TotalFiles) + } + if result.dedupedHardlink { + dedupedHardlink.Store(true) + } atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ Name: name, Path: path, - Size: size, + Size: result.TotalSize, IsDir: true, LastAccess: time.Time{}, - }, 100*time.Millisecond) - }(child.Name(), fullPath) + }, scanSendTimeout) + } + if limiter.tryAcquireEntry() { + wg.Go(func() { + defer limiter.releaseEntry() + processDir(child.Name(), fullPath) + }) + } else { + processDir(child.Name(), fullPath) + } continue } @@ -248,8 +368,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in if err != nil { continue } - // Actual disk usage for sparse/cloud files. - size := getActualFileSize(fullPath, info) + // Actual disk usage for sparse/cloud files, deduping hardlinks. + size, deduped := countableFileSize(info, &limiter.seen) + if deduped { + dedupedHardlink.Store(true) + } atomic.AddInt64(&total, size) localFilesScanned++ localBytesScanned += size @@ -260,13 +383,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in Size: size, IsDir: false, LastAccess: getLastAccessTimeFromInfo(info), - }, 100*time.Millisecond) + }, scanSendTimeout) // Track large files only. if !shouldSkipFileForLargeTracking(fullPath) { minSize := atomic.LoadInt64(&largeFileMinSize) if size >= minSize { - trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond) + trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, scanSendTimeout) } } } @@ -286,29 +409,92 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in collectorWg.Wait() // Convert heaps to sorted slices (descending). - entries := make([]dirEntry, entriesHeap.Len()) - for i := len(entries) - 1; i >= 0; i-- { - entries[i] = heap.Pop(entriesHeap).(dirEntry) + var entries []dirEntry + if collectAllEntries { + entries = append(entries, collectedEntries...) + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Size > entries[j].Size + }) + } else { + entries = make([]dirEntry, entriesHeap.Len()) + for i := range slices.Backward(entries) { + entries[i] = heap.Pop(entriesHeap).(dirEntry) + } } largeFiles := make([]fileEntry, largeFilesHeap.Len()) - for i := len(largeFiles) - 1; i >= 0; i-- { + for i := range slices.Backward(largeFiles) { largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry) } // Use Spotlight for large files when it expands the list. - if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) { - largeFiles = spotlightFiles + if useSpotlight { + if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) { + largeFiles = spotlightFiles + } } return scanResult{ - Entries: entries, - LargeFiles: largeFiles, - TotalSize: total, - TotalFiles: atomic.LoadInt64(filesScanned), + Entries: entries, + LargeFiles: largeFiles, + TotalSize: total, + TotalFiles: localFilesScanned + subtreeFilesScanned.Load(), + dedupedHardlink: dedupedHardlink.Load(), }, nil } +func publishLargeFiles(files []fileEntry, largeFileChan chan<- fileEntry) { + for _, file := range files { + trySend(largeFileChan, file, scanSendTimeout) + } +} + +func loadCachedSubdirResult(path string, largeFileChan chan<- fileEntry) (scanResult, bool) { + cached, err := loadCacheFromDisk(path) + if err != nil { + return scanResult{}, false + } + + result := scanResult{ + Entries: cached.Entries, + LargeFiles: cached.LargeFiles, + TotalSize: cached.TotalSize, + TotalFiles: cached.TotalFiles, + } + publishLargeFiles(result.LargeFiles, largeFileChan) + return result, true +} + +func scanSubdirWithCache(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, cachePolicy scanCachePolicy) scanResult { + if cachePolicy == scanCacheReuse { + if cached, ok := loadCachedSubdirResult(root, largeFileChan); ok { + if cached.TotalFiles > 0 { + atomic.AddInt64(filesScanned, cached.TotalFiles) + } + if cached.TotalSize > 0 { + atomic.AddInt64(bytesScanned, cached.TotalSize) + } + return cached + } + } + + result, err := scanPathConcurrentWithLimiter(root, filesScanned, dirsScanned, bytesScanned, currentPath, false, maxEntries, limiter, cachePolicy) + if err == nil { + publishLargeFiles(result.LargeFiles, largeFileChan) + // A subtree whose size depended on hardlink dedup is scan-order + // dependent; caching it would poison standalone re-scans. Cheap + // subtrees are not persisted at all: see shouldPersistSubdirCache. + if !result.dedupedHardlink && shouldPersistSubdirCache(result) { + _ = saveCacheToDiskWithOptions(root, result, true) + } else if cachePolicy == scanCacheBypass { + removeCacheEntry(root) + } + return result + } + + return scanResult{TotalSize: calculateDirSizeConcurrent(root, largeFileChan, largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)} +} + func shouldFoldDirWithPath(name, path string) bool { if foldDirs[name] { return true @@ -335,14 +521,21 @@ func shouldSkipFileForLargeTracking(path string) bool { // calculateDirSizeFast performs concurrent dir sizing using os.ReadDir. func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { - var total int64 + return calculateDirSizeFastWithLimiter(root, newScanLimiter(0), filesScanned, dirsScanned, bytesScanned, currentPath) +} + +func calculateDirSizeFastWithLimiter(root string, limiter *scanLimiter, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { + var total atomic.Int64 var wg sync.WaitGroup ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - concurrency := min(runtime.NumCPU()*4, 64) + concurrency := min(runtime.NumCPU()*cpuMultiplier, maxWorkers) sem := make(chan struct{}, concurrency) + if limiter != nil && limiter.fastSem != nil { + sem = limiter.fastSem + } var walk func(string) walk = func(dirPath string) { @@ -370,12 +563,10 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * select { case sem <- struct{}{}: - wg.Add(1) - go func(p string) { - defer wg.Done() + wg.Go(func() { defer func() { <-sem }() - walk(p) - }(subDir) + walk(subDir) + }) default: // Fallback to synchronous traversal to avoid semaphore deadlock under high fan-out. walk(subDir) @@ -391,7 +582,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * } if localBytes > 0 { - atomic.AddInt64(&total, localBytes) + total.Add(localBytes) atomic.AddInt64(bytesScanned, localBytes) } if localFiles > 0 { @@ -402,18 +593,27 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * walk(root) wg.Wait() - return total + return total.Load() } // Use Spotlight (mdfind) to quickly find large files. func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { + // Validate root path. + if err := validatePath(root); err != nil { + return nil + } + + // Validate minSize is reasonable (non-negative and not excessively large). + if minSize < 0 || minSize > 1<<50 { // 1 PB max + return nil + } + query := fmt.Sprintf("kMDItemFSSize >= %d", minSize) ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query) - output, err := cmd.Output() + output, err := spotlightQueryRunner(ctx, root, query) if err != nil { return nil } @@ -462,7 +662,7 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { } files := make([]fileEntry, h.Len()) - for i := len(files) - 1; i >= 0; i-- { + for i := range slices.Backward(files) { files[i] = heap.Pop(h).(fileEntry) } @@ -480,13 +680,14 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { children, err := os.ReadDir(root) if err != nil { return 0 } - var total int64 + var total atomic.Int64 + var localTotal int64 var localFilesScanned int64 var localDirsScanned int64 var localBytesScanned int64 @@ -501,7 +702,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar continue } size := getActualFileSize(fullPath, info) - total += size + localTotal += size localFilesScanned++ localBytesScanned += size continue @@ -512,39 +713,35 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar if shouldFoldDirWithPath(child.Name(), fullPath) { duQueueSem <- struct{}{} - wg.Add(1) - go func(path string) { - defer wg.Done() + wg.Go(func() { defer func() { <-duQueueSem }() size, err := func() (int64, error) { duSem <- struct{}{} defer func() { <-duSem }() - return getDirectorySizeFromDu(path) + return getDirectorySizeFromDu(fullPath) }() if err != nil || size <= 0 { - size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeFastWithLimiter(fullPath, limiter, filesScanned, dirsScanned, bytesScanned, currentPath) } else { atomic.AddInt64(bytesScanned, size) } - atomic.AddInt64(&total, size) - }(fullPath) + total.Add(size) + }) continue } select { case dirSem <- struct{}{}: - wg.Add(1) - go func(path string) { - defer wg.Done() + wg.Go(func() { defer func() { <-dirSem }() - size := calculateDirSizeConcurrent(path, largeFileChan, largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) - }(fullPath) + size := calculateDirSizeConcurrent(fullPath, largeFileChan, largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + total.Add(size) + }) default: - size := calculateDirSizeConcurrent(fullPath, largeFileChan, largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) + size := calculateDirSizeConcurrent(fullPath, largeFileChan, largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + localTotal += size } continue } @@ -555,14 +752,14 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar } size := getActualFileSize(fullPath, info) - total += size + localTotal += size localFilesScanned++ localBytesScanned += size if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { minSize := atomic.LoadInt64(largeFileMinSize) if size >= minSize { - trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond) + trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, scanSendTimeout) } } @@ -572,6 +769,10 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar } } + if localTotal > 0 { + total.Add(localTotal) + } + wg.Wait() if localFilesScanned > 0 { @@ -584,7 +785,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar atomic.AddInt64(dirsScanned, localDirsScanned) } - return total + return total.Load() } // measureOverviewSize calculates the size of a directory using multiple strategies. @@ -610,12 +811,12 @@ func measureOverviewSize(path string) (int64, error) { excludePath = filepath.Join(home, "Library") } - if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 { + if duSize, err := getDirectorySizeFromDuWithExcludeAndIgnores(path, excludePath, overviewIgnoreNamesForPath(path)); err == nil { _ = storeOverviewSize(path, duSize) return duSize, nil } - if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil && logicalSize > 0 { + if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil { _ = storeOverviewSize(path, logicalSize) return logicalSize, nil } @@ -633,6 +834,25 @@ func getDirectorySizeFromDu(path string) (int64, error) { } func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) { + return getDirectorySizeFromDuWithExcludeAndIgnores(path, excludePath, nil) +} + +func getDirectorySizeFromDuWithExcludeAndIgnores(path string, excludePath string, ignoreNames []string) (int64, error) { + // Validate paths. + if err := validatePath(path); err != nil { + return 0, err + } + if excludePath != "" { + if err := validatePath(excludePath); err != nil { + return 0, err + } + } + for _, ignoreName := range ignoreNames { + if err := validateDuIgnoreName(ignoreName); err != nil { + return 0, err + } + } + runDuSize := func(target string) (int64, error) { if _, err := os.Stat(target); err != nil { return 0, err @@ -641,29 +861,43 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, ctx, cancel := context.WithTimeout(context.Background(), duTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "du", "-skP", target) + args := []string{"-skPx"} + for _, ignoreName := range ignoreNames { + args = append(args, "-I", ignoreName) + } + args = append(args, target) + cmd := exec.CommandContext(ctx, "du", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { + runErr := cmd.Run() + fields := strings.Fields(stdout.String()) + if runErr != nil { if ctx.Err() == context.DeadlineExceeded { return 0, fmt.Errorf("du timeout after %v", duTimeout) } - if stderr.Len() > 0 { - return 0, fmt.Errorf("du failed: %v, %s", err, stderr.String()) + // BSD du may return non-zero for unreadable descendants while still + // printing a useful aggregate for the requested root. Use that best + // effort total instead of falling back to a much slower recursive walk. + if len(fields) == 0 { + if stderr.Len() > 0 { + return 0, fmt.Errorf("du failed: %v, %s", runErr, stderr.String()) + } + return 0, fmt.Errorf("du failed: %v", runErr) } - return 0, fmt.Errorf("du failed: %v", err) } - fields := strings.Fields(stdout.String()) if len(fields) == 0 { return 0, fmt.Errorf("du output empty") } - kb, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil { - return 0, fmt.Errorf("failed to parse du output: %v", err) + kb, parseErr := strconv.ParseInt(fields[0], 10, 64) + if parseErr != nil { + return 0, fmt.Errorf("failed to parse du output: %v", parseErr) } if kb <= 0 { + if runErr != nil { + return 0, fmt.Errorf("du failed: %v", runErr) + } return 0, fmt.Errorf("du size invalid: %d", kb) } return kb * 1024, nil @@ -671,6 +905,10 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, // When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library" if excludePath != "" { + if size, err := getDirectorySizeFromDuSkippingImmediateChild(path, excludePath, runDuSize); err == nil { + return size, nil + } + totalSize, err := runDuSize(path) if err != nil { return 0, err @@ -691,6 +929,113 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, return runDuSize(path) } +func validateDuIgnoreName(name string) error { + if name == "" { + return fmt.Errorf("empty du ignore name") + } + if strings.Contains(name, "\x00") { + return fmt.Errorf("du ignore name contains null bytes") + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("du ignore name must be a basename: %s", name) + } + return nil +} + +func overviewIgnoreNamesForPath(path string) []string { + entries, err := os.ReadDir(path) + if err != nil { + return nil + } + + ignoreNames := make([]string, 0, len(overviewDuIgnoreNames)) + for _, entry := range entries { + name := entry.Name() + if overviewDuIgnoreNames[name] && entry.IsDir() { + ignoreNames = append(ignoreNames, name) + } + } + return ignoreNames +} + +func getDirectorySizeFromDuSkippingImmediateChild(path string, excludePath string, runDuSize func(string) (int64, error)) (int64, error) { + path = filepath.Clean(path) + excludePath = filepath.Clean(excludePath) + + rel, err := filepath.Rel(path, excludePath) + if err != nil { + return 0, err + } + if rel == "." || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return 0, fmt.Errorf("exclude path is outside base: %s", excludePath) + } + if strings.Contains(rel, string(os.PathSeparator)) { + return 0, fmt.Errorf("exclude path is not an immediate child: %s", excludePath) + } + + entries, err := os.ReadDir(path) + if err != nil { + return 0, err + } + + var total int64 + if info, err := os.Lstat(path); err == nil { + atomic.AddInt64(&total, getActualFileSize(path, info)) + } + + var wg sync.WaitGroup + var firstErr error + var errMu sync.Mutex + workerCount := min(max(runtime.NumCPU()*2, 2), 8) + sem := make(chan struct{}, workerCount) + + recordErr := func(err error) { + if err == nil { + return + } + errMu.Lock() + defer errMu.Unlock() + if firstErr == nil { + firstErr = err + } + } + + for _, entry := range entries { + fullPath := filepath.Join(path, entry.Name()) + if filepath.Clean(fullPath) == excludePath { + continue + } + + if entry.Type()&fs.ModeSymlink != 0 || !entry.IsDir() { + info, err := entry.Info() + if err != nil { + continue + } + atomic.AddInt64(&total, getActualFileSize(fullPath, info)) + continue + } + + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + + size, err := runDuSize(fullPath) + if err != nil { + recordErr(err) + return + } + atomic.AddInt64(&total, size) + }) + } + + wg.Wait() + + if firstErr != nil { + return 0, firstErr + } + return total, nil +} + func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) { var total int64 err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { @@ -720,6 +1065,27 @@ func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, return total, nil } +// countableFileSize returns the on-disk size to attribute to a regular file. +// Hardlinked files are deduplicated the way `du` does: the first link counts +// its full size and subsequent links seen in the same scan count zero. The +// bool reports whether this call was a deduplicated (zero-counted) hardlink. +// A nil seen map disables deduplication. +func countableFileSize(info fs.FileInfo, seen *sync.Map) (int64, bool) { + size := getActualFileSize("", info) + if seen == nil { + return size, false + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Nlink <= 1 { + return size, false + } + key := [2]uint64{uint64(uint32(stat.Dev)), stat.Ino} + if _, loaded := seen.LoadOrStore(key, struct{}{}); loaded { + return 0, true + } + return size, false +} + func getActualFileSize(_ string, info fs.FileInfo) int64 { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { diff --git a/Resources/mole/cmd/analyze/scanner_test.go b/Resources/mole/cmd/analyze/scanner_test.go index 718d276..8ebe372 100644 --- a/Resources/mole/cmd/analyze/scanner_test.go +++ b/Resources/mole/cmd/analyze/scanner_test.go @@ -1,12 +1,15 @@ +//go:build darwin + package main import ( + "fmt" "os" "path/filepath" "testing" ) -func writeFileWithSize(t *testing.T, path string, size int) { +func writeFileWithSize(t testing.TB, path string, size int) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", path, err) @@ -43,3 +46,91 @@ func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) { t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding) } } + +func TestGetDirectorySizeFromDuSkippingImmediateChildDoesNotMeasureExcludedPath(t *testing.T) { + base := t.TempDir() + excluded := filepath.Join(base, "Library") + included := filepath.Join(base, "Documents") + if err := os.MkdirAll(excluded, 0o755); err != nil { + t.Fatalf("mkdir excluded: %v", err) + } + if err := os.MkdirAll(included, 0o755); err != nil { + t.Fatalf("mkdir included: %v", err) + } + + var measured []string + size, err := getDirectorySizeFromDuSkippingImmediateChild(base, excluded, func(path string) (int64, error) { + measured = append(measured, path) + return 100, nil + }) + if err != nil { + t.Fatalf("getDirectorySizeFromDuSkippingImmediateChild: %v", err) + } + if size < 100 { + t.Fatalf("expected included directory size in total, got %d", size) + } + if len(measured) != 1 || measured[0] != included { + t.Fatalf("expected to measure only %s, measured %#v", included, measured) + } +} + +func TestGetDirectorySizeFromDuWithIgnoresSkipsCloudPlaceholderTree(t *testing.T) { + base := t.TempDir() + writeFileWithSize(t, filepath.Join(base, "Application Support", "state.dat"), 4096) + writeFileWithSize(t, filepath.Join(base, "Mobile Documents", "cloud.dat"), 1024*1024) + + withoutIgnore, err := getDirectorySizeFromDuWithExcludeAndIgnores(base, "", nil) + if err != nil { + t.Fatalf("getDirectorySizeFromDuWithExcludeAndIgnores without ignore: %v", err) + } + withIgnore, err := getDirectorySizeFromDuWithExcludeAndIgnores(base, "", []string{"Mobile Documents"}) + if err != nil { + t.Fatalf("getDirectorySizeFromDuWithExcludeAndIgnores with ignore: %v", err) + } + if withIgnore >= withoutIgnore { + t.Fatalf("expected ignored Mobile Documents to reduce size, got ignored=%d without=%d", withIgnore, withoutIgnore) + } + if withIgnore <= 0 { + t.Fatalf("expected non-zero size for included files, got %d", withIgnore) + } +} + +func TestValidateDuIgnoreNameRejectsPathPatterns(t *testing.T) { + for _, name := range []string{"", "../Library", "Library/Developer", "bad\x00name"} { + if err := validateDuIgnoreName(name); err == nil { + t.Fatalf("expected %q to be rejected", name) + } + } + if err := validateDuIgnoreName("Mobile Documents"); err != nil { + t.Fatalf("expected basename ignore to be accepted: %v", err) + } +} + +func BenchmarkGetDirectorySizeFromDuWithExcludeHomeLibrary(b *testing.B) { + base := b.TempDir() + libraryDir := filepath.Join(base, "Library") + for dirIdx := range 250 { + for fileIdx := range 20 { + writeFileWithSize( + b, + filepath.Join(libraryDir, "bulk", fmt.Sprintf("dir-%03d", dirIdx), "bucket", fmt.Sprintf("file-%03d.dat", fileIdx)), + 16, + ) + } + } + writeFileWithSize(b, filepath.Join(base, "Documents", "keep.dat"), 4096) + + excludePath := filepath.Join(base, "Library") + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + size, err := getDirectorySizeFromDuWithExclude(base, excludePath) + if err != nil { + b.Fatalf("getDirectorySizeFromDuWithExclude: %v", err) + } + if size <= 0 { + b.Fatalf("expected non-zero size, got %d", size) + } + } +} diff --git a/Resources/mole/cmd/analyze/test_helpers_test.go b/Resources/mole/cmd/analyze/test_helpers_test.go index 9490833..a84fe1a 100644 --- a/Resources/mole/cmd/analyze/test_helpers_test.go +++ b/Resources/mole/cmd/analyze/test_helpers_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/update.go b/Resources/mole/cmd/analyze/update.go new file mode 100644 index 0000000..5c5aa57 --- /dev/null +++ b/Resources/mole/cmd/analyze/update.go @@ -0,0 +1,1232 @@ +//go:build darwin + +package main + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m *model) scheduleOverviewScans() tea.Cmd { + if !m.inOverviewMode() { + return nil + } + + var pendingIndices []int + for i, entry := range m.entries { + if entry.Size < 0 && !m.overviewScanningSet[entry.Path] { + pendingIndices = append(pendingIndices, i) + if len(pendingIndices) >= maxConcurrentOverview { + break + } + } + } + + if len(pendingIndices) == 0 { + m.overviewScanning = false + if !hasPendingOverviewEntries(m.entries) { + m.sortOverviewEntriesBySize() + m.status = "Ready" + } + return nil + } + + var cmds []tea.Cmd + for _, idx := range pendingIndices { + entry := m.entries[idx] + m.overviewScanningSet[entry.Path] = true + cmd := scanOverviewPathCmd(entry.Path, idx) + cmds = append(cmds, cmd) + } + + m.overviewScanning = true + remaining := 0 + for _, e := range m.entries { + if e.Size < 0 { + remaining++ + } + } + if len(pendingIndices) > 0 { + firstEntry := m.entries[pendingIndices[0]] + if len(pendingIndices) == 1 { + m.status = fmt.Sprintf("Scanning %s..., %d left", firstEntry.Name, remaining) + } else { + m.status = fmt.Sprintf("Scanning %d directories..., %d left", len(pendingIndices), remaining) + } + } + + cmds = append(cmds, tickCmd()) + return tea.Batch(cmds...) +} + +func (m model) Init() tea.Cmd { + if m.inOverviewMode() { + return m.scheduleOverviewScans() + } + return tea.Batch(m.scanCmd(m.path), tickCmd()) +} + +func (m model) scanCmd(path string) tea.Cmd { + return func() tea.Msg { + if cached, err := loadCacheFromDisk(path); err == nil { + result := scanResult{ + Entries: cached.Entries, + LargeFiles: cached.LargeFiles, + TotalSize: cached.TotalSize, + TotalFiles: cached.TotalFiles, + } + if cached.NeedsRefresh { + return scanResultMsg{path: path, result: result, err: nil, stale: true} + } + return scanResultMsg{path: path, result: result, err: nil} + } + + if stale, err := loadStaleCacheFromDisk(path); err == nil { + result := scanResult{ + Entries: stale.Entries, + LargeFiles: stale.LargeFiles, + TotalSize: stale.TotalSize, + TotalFiles: stale.TotalFiles, + } + return scanResultMsg{path: path, result: result, err: nil, stale: true} + } + + return startLiveScanCmd(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)() + } +} + +func (m model) scanFreshCmd(path string) tea.Cmd { + return func() tea.Msg { + return startLiveScanCmd(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)() + } +} + +func (m model) scanBypassingCacheCmd(path string) tea.Cmd { + return func() tea.Msg { + return startLiveScanCmdWithPolicy(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath, scanCacheBypass)() + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(uiTickInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m *model) cancelLiveScan() { + if m.liveScanCancel != nil { + m.liveScanCancel() + } + m.liveScanID = 0 + m.liveScanCancel = nil + m.liveScanEvents = nil + m.liveScanningPaths = nil + m.autoSortLiveEntries = false +} + +func (m model) isCurrentLiveScan(id int64, path string) bool { + return id != 0 && id == m.liveScanID && path == m.path && m.liveScanEvents != nil +} + +func (m *model) noteLiveCursorMove() { + if m.scanning && !m.showLargeFiles && m.liveSortMode == liveSortFreezeOnMove { + m.autoSortLiveEntries = false + } +} + +func (m *model) applyLiveChildProgress(entry dirEntry) { + m.applyLiveChildSize(entry, false, scanResult{}) +} + +func (m *model) applyLiveChildUpdate(entry dirEntry, result scanResult) { + m.applyLiveChildSize(entry, true, result) +} + +func (m *model) ensureLiveEntryBacking() { + if m.entriesAll == nil { + m.entriesAll = slices.Clone(m.entries) + } +} + +func (m *model) applyLiveChildSize(entry dirEntry, complete bool, result scanResult) { + if complete && m.liveScanningPaths != nil { + delete(m.liveScanningPaths, entry.Path) + } + + m.ensureLiveEntryBacking() + previousSize := int64(-1) + found := false + for i := range m.entriesAll { + if m.entriesAll[i].Path == entry.Path { + previousSize = m.entriesAll[i].Size + m.entriesAll[i] = entry + found = true + break + } + } + if !found { + m.entriesAll = append(m.entriesAll, entry) + } + + if entry.Size > 0 { + if previousSize > 0 { + m.totalSize += entry.Size - previousSize + } else { + m.totalSize += entry.Size + } + } + if complete && result.TotalFiles > 0 { + m.totalFiles += result.TotalFiles + } + if complete && (len(result.Entries) > 0 || len(result.LargeFiles) > 0 || result.TotalSize > 0 || result.TotalFiles > 0) { + childResult := result + childResult.Entries = filterNonEmptyEntries(result.Entries) + m.cache[entry.Path] = historyEntryFromScanResult(entry.Path, childResult, m.cache[entry.Path], true) + } + if m.autoSortLiveEntries { + m.sortLiveEntriesForActiveMode() + } else { + m.applyEntryFilter() + } + m.clampEntrySelection() + m.status = fmt.Sprintf("Scanning %s...", displayPath(m.path)) +} + +func (m *model) finishLiveScan(result scanResult) { + pinFirstRow := m.liveSortMode == liveSortFreezeOnMove && m.autoSortLiveEntries + m.scanning = false + m.liveScanID = 0 + m.liveScanCancel = nil + m.liveScanEvents = nil + m.liveScanningPaths = nil + m.autoSortLiveEntries = false + + filteredEntries := filterNonEmptyEntries(result.Entries) + result.Entries = filteredEntries + selectedPath := m.selectedEntryPath() + m.entriesAll = filteredEntries + m.largeFilesAll = result.LargeFiles + m.totalSize = result.TotalSize + m.totalFiles = result.TotalFiles + m.viewNeedsRefresh = false + m.applyEntryFilter() + m.applyLargeFilter() + if pinFirstRow { + m.selected = 0 + m.offset = 0 + } else if selectedPath != "" { + m.selectEntryPath(selectedPath) + } + m.cache[m.path] = historyEntryFromScanResult(m.path, result, m.cache[m.path], false) + if m.totalSize > 0 { + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + m.overviewSizeCache[m.path] = m.totalSize + go func(path string, size int64) { + _ = storeOverviewSize(path, size) + }(m.path, m.totalSize) + } + go func(path string, scan scanResult) { + _ = saveCacheToDisk(path, scan) + }(m.path, result) + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) +} + +func (m *model) finishCanceledLiveScan() { + m.scanning = false + m.liveScanID = 0 + m.liveScanCancel = nil + m.liveScanEvents = nil + m.liveScanningPaths = nil + m.autoSortLiveEntries = false + m.status = "Scan cancelled" +} + +func (m *model) sortLiveEntriesForActiveMode() { + m.ensureLiveEntryBacking() + selectedPath := "" + if m.liveSortMode == liveSortContinuous { + selectedPath = m.selectedEntryPath() + } + sortDirEntriesBySize(m.entriesAll) + m.applyEntryFilter() + if m.liveSortMode == liveSortFreezeOnMove { + m.selected = 0 + m.offset = 0 + return + } + if selectedPath == "" { + return + } + m.selectEntryPath(selectedPath) +} + +func (m model) selectedEntryPath() string { + if len(m.entries) == 0 || m.selected < 0 || m.selected >= len(m.entries) { + return "" + } + return m.entries[m.selected].Path +} + +func (m *model) selectEntryPath(path string) { + for i, entry := range m.entries { + if entry.Path == path { + m.selected = i + return + } + } + m.clampEntrySelection() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.updateKey(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case deleteProgressMsg: + if msg.done { + m.deleting = false + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) + removedPaths := append([]string(nil), msg.removedPaths...) + if msg.err == nil && msg.path != "" { + removedPaths = append(removedPaths, msg.path) + } + for _, removedPath := range removedPaths { + m.removePathFromView(removedPath) + invalidateCache(removedPath) + } + + if len(removedPaths) > 0 { + invalidateCache(m.path) + if msg.err != nil { + m.status = fmt.Sprintf("Deleted %d items; some failed: %v", msg.count, msg.err) + } else { + m.status = fmt.Sprintf("Deleted %d items", msg.count) + } + + // Selective invalidation: only mark current path and ancestors as needing refresh + currentPath := m.path + for currentPath != "/" && currentPath != "" { + if entry, exists := m.cache[currentPath]; exists { + entry.NeedsRefresh = true + m.cache[currentPath] = entry + } + currentPath = filepath.Dir(currentPath) + } + + // Mark history entries for current path and ancestors as needing refresh + for i := range m.history { + histPath := m.history[i].Path + if histPath == m.path || strings.HasPrefix(m.path, histPath+"/") { + m.history[i].NeedsRefresh = true + } + } + + m.cancelLiveScan() + m.scanning = true + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + if msg.err != nil { + m.status = fmt.Sprintf("Failed to delete: %v", msg.err) + } else { + m.status = fmt.Sprintf("Deleted %d items", msg.count) + } + } + return m, nil + case scanResultMsg: + if msg.path != "" && msg.path != m.path { + if msg.err == nil { + filteredEntries := filterNonEmptyEntries(msg.result.Entries) + result := msg.result + result.Entries = filteredEntries + m.cache[msg.path] = historyEntryFromScanResult(msg.path, result, m.cache[msg.path], msg.stale) + } + return m, nil + } + m.scanning = false + if msg.err != nil { + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, nil + } + filteredEntries := filterNonEmptyEntries(msg.result.Entries) + result := msg.result + result.Entries = filteredEntries + m.entriesAll = filteredEntries + m.entries = filteredEntries + m.largeFilesAll = msg.result.LargeFiles + m.largeFiles = msg.result.LargeFiles + m.totalSize = msg.result.TotalSize + m.totalFiles = msg.result.TotalFiles + m.viewNeedsRefresh = msg.stale + // Re-narrow to the active query if a background refresh landed while a + // filter is showing; each is a no-op (restores the full list) when its + // query is empty. + m.applyEntryFilter() + m.applyLargeFilter() + m.cache[m.path] = historyEntryFromScanResult(m.path, result, m.cache[m.path], msg.stale) + if m.totalSize > 0 { + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + m.overviewSizeCache[m.path] = m.totalSize + go func(path string, size int64) { + _ = storeOverviewSize(path, size) + }(m.path, m.totalSize) + } + + if msg.stale { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } + + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + case liveScanStartMsg: + if msg.path != m.path { + if msg.cancel != nil { + msg.cancel() + } + return m, nil + } + m.cancelLiveScan() + if msg.err != nil { + m.scanning = false + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, nil + } + m.liveScanID = msg.id + m.liveScanCancel = msg.cancel + m.liveScanEvents = msg.events + m.liveScanningPaths = make(map[string]bool, len(msg.scanningPaths)) + for _, path := range msg.scanningPaths { + m.liveScanningPaths[path] = true + } + m.autoSortLiveEntries = true + selectedPath := "" + if m.liveSortMode == liveSortContinuous { + selectedPath = m.selectedEntryPath() + } + m.entriesAll = slices.Clone(msg.entries) + m.largeFilesAll = slices.Clone(msg.largeFiles) + m.totalSize = msg.totalSize + m.totalFiles = msg.totalFiles + m.viewNeedsRefresh = false + m.scanning = true + m.status = fmt.Sprintf("Scanning %s...", displayPath(m.path)) + m.sortLiveEntriesForActiveMode() + m.applyLargeFilter() + if selectedPath != "" { + m.selectEntryPath(selectedPath) + } + m.clampEntrySelection() + return m, waitLiveScanEventCmd(msg.events) + case liveScanEventMsg: + if !m.isCurrentLiveScan(msg.id, msg.path) { + return m, nil + } + switch msg.kind { + case liveScanChildProgress: + m.applyLiveChildProgress(msg.entry) + return m, waitLiveScanEventCmd(m.liveScanEvents) + case liveScanChildDone: + m.applyLiveChildUpdate(msg.entry, msg.result) + return m, waitLiveScanEventCmd(m.liveScanEvents) + case liveScanComplete: + m.finishLiveScan(msg.result) + return m, nil + case liveScanFailed: + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, waitLiveScanEventCmd(m.liveScanEvents) + case liveScanCanceled: + m.finishCanceledLiveScan() + return m, nil + default: + return m, waitLiveScanEventCmd(m.liveScanEvents) + } + case overviewSizeMsg: + delete(m.overviewScanningSet, msg.Path) + + if msg.Err == nil { + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + m.overviewSizeCache[msg.Path] = msg.Size + } + + if m.inOverviewMode() { + for i := range m.entries { + if m.entries[i].Path == msg.Path { + if msg.Err == nil { + m.entries[i].Size = msg.Size + } else { + m.entries[i].Size = 0 + } + break + } + } + m.totalSize = sumKnownEntrySizes(m.entries) + + if msg.Err != nil { + m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err) + } + + cmd := m.scheduleOverviewScans() + return m, cmd + } + return m, nil + case tickMsg: + hasPending := false + if m.inOverviewMode() { + for _, entry := range m.entries { + if entry.Size < 0 { + hasPending = true + break + } + } + } + if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) { + m.spinner = (m.spinner + 1) % len(spinnerFrames) + if m.deleting && m.deleteCount != nil { + count := atomic.LoadInt64(m.deleteCount) + if count > 0 { + m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count)) + } + } + return m, tickCmd() + } + return m, nil + default: + return m, nil + } +} + +func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Delete confirm flow. + if m.deleteConfirm { + switch msg.String() { + case "enter": + m.deleteConfirm = false + m.deleting = true + var deleteCount int64 + m.deleteCount = &deleteCount + + // Collect paths (safer than indices). + var pathsToDelete []string + if m.showLargeFiles { + if len(m.largeMultiSelected) > 0 { + for path := range m.largeMultiSelected { + pathsToDelete = append(pathsToDelete, path) + } + } else if m.deleteTarget != nil { + pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) + } + } else { + if len(m.multiSelected) > 0 { + for path := range m.multiSelected { + pathsToDelete = append(pathsToDelete, path) + } + } else if m.deleteTarget != nil { + pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) + } + } + + m.deleteTarget = nil + if len(pathsToDelete) == 0 { + m.deleting = false + m.status = "Nothing to delete" + return m, nil + } + + if len(pathsToDelete) == 1 { + targetPath := pathsToDelete[0] + m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath)) + return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) + } + + m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) + return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) + case "esc", "q": + m.status = "Cancelled" + m.deleteConfirm = false + m.deleteTarget = nil + return m, nil + case "ctrl+c": + return m, tea.Quit + default: + return m, nil + } + } + + // Filter prompts swallow all keys while the user types a query. + if m.largeFiltering { + return m.updateLargeFilterInput(msg) + } + if m.entryFiltering { + return m.updateEntryFilterInput(msg) + } + + switch msg.String() { + case "q", "Q", "ctrl+c": + return m, tea.Quit + case "esc": + if m.showLargeFiles { + if m.largeFilter != "" { + m.resetLargeFilter() + m.clampLargeSelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + } + m.showLargeFiles = false + return m, nil + } + if m.entryFilter != "" { + m.resetEntryFilter() + m.clampEntrySelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + } + return m.goBack() + case "up", "k", "K": + if m.showLargeFiles { + if m.largeSelected > 0 { + m.largeSelected-- + if m.largeSelected < m.largeOffset { + m.largeOffset = m.largeSelected + } + } + } else if len(m.entries) > 0 && m.selected > 0 { + m.noteLiveCursorMove() + next := m.selected - 1 + for next > 0 && m.entries[next].Size == 0 { + next-- + } + m.selected = next + if m.selected < m.offset { + m.offset = m.selected + } + } + case "down", "j", "J": + if m.showLargeFiles { + if m.largeSelected < len(m.largeFiles)-1 { + m.largeSelected++ + viewport := calculateViewport(m.height, true) + if m.largeSelected >= m.largeOffset+viewport { + m.largeOffset = m.largeSelected - viewport + 1 + } + } + } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { + m.noteLiveCursorMove() + next := m.selected + 1 + for next < len(m.entries)-1 && m.entries[next].Size == 0 { + next++ + } + m.selected = next + viewport := calculateViewport(m.height, false) + if m.selected >= m.offset+viewport { + m.offset = m.selected - viewport + 1 + } + } + case "enter", "right", "l", "L": + if m.showLargeFiles { + return m, nil + } + return m.enterSelectedDir() + case "b", "left", "h", "B", "H": + if m.showLargeFiles { + m.showLargeFiles = false + m.resetLargeFilter() + return m, nil + } + return m.goBack() + case "r", "R": + m.cancelLiveScan() + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) + + if m.inOverviewMode() { + // Explicitly invalidate cache for all overview entries to force re-scan + for _, entry := range m.entries { + invalidateCache(entry.Path) + } + + m.overviewSizeCache = make(map[string]int64) + m.overviewScanningSet = make(map[string]bool) + m.hydrateOverviewEntries() // Reset sizes to pending + m.selected = 0 + m.offset = 0 + + for i := range m.entries { + m.entries[i].Size = -1 + } + m.totalSize = 0 + + m.status = "Refreshing..." + m.overviewScanning = true + return m, tea.Batch(m.scheduleOverviewScans(), tickCmd()) + } + + invalidateCacheTree(m.path) + m.status = "Refreshing..." + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanBypassingCacheCmd(m.path), tickCmd()) + case "t", "T": + if m.scanning { + m.status = "Top files are available after the scan finishes" + return m, nil + } + if !m.inOverviewMode() { + m.showLargeFiles = !m.showLargeFiles + m.resetLargeFilter() + m.resetEntryFilter() + if m.showLargeFiles { + m.largeSelected = 0 + m.largeOffset = 0 + m.largeMultiSelected = make(map[string]bool) + } else { + m.multiSelected = make(map[string]bool) + } + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + case "/": + if m.inOverviewMode() { + break + } + if m.showLargeFiles { + if len(m.largeFilesAll) > 0 { + m.largeFiltering = true + m.status = "Filter: type to match, Enter to apply, Esc to clear" + } + } else if len(m.entriesAll) > 0 { + m.entryFiltering = true + m.status = "Filter: type to match, Enter to apply, Esc to clear" + } + case "s", "S": + if m.scanning && !m.inOverviewMode() { + m.liveSortMode = nextLiveSortMode(m.liveSortMode) + m.autoSortLiveEntries = m.liveSortMode == liveSortContinuous + if m.autoSortLiveEntries { + m.sortLiveEntriesForActiveMode() + } + m.status = fmt.Sprintf("Live sort: %s", liveSortModeLabel(m.liveSortMode)) + } + case "o", "O": + // Open selected entries (multi-select aware). + const maxBatchOpen = 20 + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + if len(m.largeMultiSelected) > 0 { + count := len(m.largeMultiSelected) + if count > maxBatchOpen { + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) + return m, nil + } + for path := range m.largeMultiSelected { + go func(p string) { + _ = safeOpen(p, false) + }(path) + } + m.status = fmt.Sprintf("Opening %d items...", count) + } else { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = safeOpen(path, false) + }(selected.Path) + m.status = fmt.Sprintf("Opening %s...", selected.Name) + } + } + } else if len(m.entries) > 0 { + if len(m.multiSelected) > 0 { + count := len(m.multiSelected) + if count > maxBatchOpen { + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) + return m, nil + } + for path := range m.multiSelected { + go func(p string) { + _ = safeOpen(p, false) + }(path) + } + m.status = fmt.Sprintf("Opening %d items...", count) + } else { + selected := m.entries[m.selected] + go func(path string) { + _ = safeOpen(path, false) + }(selected.Path) + m.status = fmt.Sprintf("Opening %s...", selected.Name) + } + } + case "f", "F": + // Reveal in Finder (multi-select aware). + const maxBatchReveal = 20 + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + if len(m.largeMultiSelected) > 0 { + count := len(m.largeMultiSelected) + if count > maxBatchReveal { + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) + return m, nil + } + for path := range m.largeMultiSelected { + go func(p string) { + _ = safeOpen(p, true) + }(path) + } + m.status = fmt.Sprintf("Showing %d items in Finder...", count) + } else { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = safeOpen(path, true) + }(selected.Path) + m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) + } + } + } else if len(m.entries) > 0 { + if len(m.multiSelected) > 0 { + count := len(m.multiSelected) + if count > maxBatchReveal { + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) + return m, nil + } + for path := range m.multiSelected { + go func(p string) { + _ = safeOpen(p, true) + }(path) + } + m.status = fmt.Sprintf("Showing %d items in Finder...", count) + } else { + selected := m.entries[m.selected] + go func(path string) { + _ = safeOpen(path, true) + }(selected.Path) + m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) + } + } + case "p", "P": + // Quick Look preview (single file only, no multi-select). + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = safePreview(path) + }(selected.Path) + m.status = fmt.Sprintf("Previewing %s...", selected.Name) + } + } else if len(m.entries) > 0 { + selected := m.entries[m.selected] + if !selected.IsDir { + go func(path string) { + _ = safePreview(path) + }(selected.Path) + m.status = fmt.Sprintf("Previewing %s...", selected.Name) + } + } + case " ": + if m.scanning { + m.status = "Selection is available after the scan finishes" + return m, nil + } + // Toggle multi-select (paths as keys). + if m.showLargeFiles { + if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) { + if m.largeMultiSelected == nil { + m.largeMultiSelected = make(map[string]bool) + } + selectedPath := m.largeFiles[m.largeSelected].Path + if m.largeMultiSelected[selectedPath] { + delete(m.largeMultiSelected, selectedPath) + } else { + m.largeMultiSelected[selectedPath] = true + } + count := len(m.largeMultiSelected) + if count > 0 { + var totalSize int64 + for path := range m.largeMultiSelected { + for _, file := range m.largeFiles { + if file.Path == path { + totalSize += file.Size + break + } + } + } + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) + } else { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + } + } else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) { + if m.multiSelected == nil { + m.multiSelected = make(map[string]bool) + } + selectedPath := m.entries[m.selected].Path + if m.multiSelected[selectedPath] { + delete(m.multiSelected, selectedPath) + } else { + m.multiSelected[selectedPath] = true + } + count := len(m.multiSelected) + if count > 0 { + var totalSize int64 + for path := range m.multiSelected { + for _, entry := range m.entries { + if entry.Path == path { + totalSize += entry.Size + break + } + } + } + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) + } else { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + } + case "delete", "backspace": + if m.scanning { + m.status = "Delete is available after the scan finishes" + return m, nil + } + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + if len(m.largeMultiSelected) > 0 { + m.deleteConfirm = true + for path := range m.largeMultiSelected { + for _, file := range m.largeFiles { + if file.Path == path { + m.deleteTarget = &dirEntry{ + Name: file.Name, + Path: file.Path, + Size: file.Size, + IsDir: false, + } + break + } + } + break // Only need first one for display + } + } else if m.largeSelected < len(m.largeFiles) { + selected := m.largeFiles[m.largeSelected] + m.deleteConfirm = true + m.deleteTarget = &dirEntry{ + Name: selected.Name, + Path: selected.Path, + Size: selected.Size, + IsDir: false, + } + } + } + } else if len(m.entries) > 0 && !m.inOverviewMode() { + if len(m.multiSelected) > 0 { + m.deleteConfirm = true + for path := range m.multiSelected { + // Resolve entry by path. + for i := range m.entries { + if m.entries[i].Path == path { + m.deleteTarget = &m.entries[i] + break + } + } + break // Only need first one for display + } + } else if m.selected < len(m.entries) { + selected := m.entries[m.selected] + m.deleteConfirm = true + m.deleteTarget = &selected + } + } + } + return m, nil +} + +// updateLargeFilterInput handles keystrokes while the Top-files filter prompt +// is active. Typing edits the query and re-filters live; Enter applies and +// hands control back to navigation; Esc clears the filter entirely. Navigation +// and action keys are intentionally swallowed so they edit the query instead of +// moving the cursor or deleting files. Changing the query clears any +// multi-selection so an action can never touch a row hidden by the filter. +func (m model) updateLargeFilterInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + m.resetLargeFilter() + m.clampLargeSelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + case tea.KeyEnter: + m.largeFiltering = false + if m.largeFilter == "" { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } else { + m.status = fmt.Sprintf("Filter %q, %d matches", m.largeFilter, len(m.largeFiles)) + } + return m, nil + case tea.KeyBackspace, tea.KeyDelete: + if r := []rune(m.largeFilter); len(r) > 0 { + m.largeFilter = string(r[:len(r)-1]) + m.largeMultiSelected = make(map[string]bool) + m.applyLargeFilter() + } + return m, nil + case tea.KeySpace: + m.largeFilter += " " + m.largeMultiSelected = make(map[string]bool) + m.applyLargeFilter() + return m, nil + case tea.KeyRunes: + m.largeFilter += string(msg.Runes) + m.largeMultiSelected = make(map[string]bool) + m.applyLargeFilter() + return m, nil + default: + return m, nil + } +} + +// updateEntryFilterInput is the directory-view counterpart to +// updateLargeFilterInput: it edits the drill-down filter query live, applies on +// Enter, clears on Esc, and clears multi-selection on any query change so an +// action can never touch a row hidden by the filter. +func (m model) updateEntryFilterInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + m.resetEntryFilter() + m.clampEntrySelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + case tea.KeyEnter: + m.entryFiltering = false + if m.entryFilter == "" { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } else { + m.status = fmt.Sprintf("Filter %q, %d matches", m.entryFilter, len(m.entries)) + } + return m, nil + case tea.KeyBackspace, tea.KeyDelete: + if r := []rune(m.entryFilter); len(r) > 0 { + m.entryFilter = string(r[:len(r)-1]) + m.multiSelected = make(map[string]bool) + m.applyEntryFilter() + } + return m, nil + case tea.KeySpace: + m.entryFilter += " " + m.multiSelected = make(map[string]bool) + m.applyEntryFilter() + return m, nil + case tea.KeyRunes: + m.entryFilter += string(msg.Runes) + m.multiSelected = make(map[string]bool) + m.applyEntryFilter() + return m, nil + default: + return m, nil + } +} + +func (m model) goBack() (tea.Model, tea.Cmd) { + m.cancelLiveScan() + if len(m.history) == 0 { + if !m.inOverviewMode() { + return m, m.switchToOverviewMode() + } + return m, tea.Quit + } + + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.path = last.Path + m.selected = last.Selected + m.offset = last.EntryOffset + m.largeSelected = last.LargeSelected + m.largeOffset = last.LargeOffset + m.isOverview = last.IsOverview + m.resetEntryFilter() + m.resetLargeFilter() + m.entriesAll = last.Entries + m.entries = last.Entries + m.largeFilesAll = last.LargeFiles + m.largeFiles = last.LargeFiles + m.totalSize = last.TotalSize + m.totalFiles = last.TotalFiles + m.viewNeedsRefresh = last.NeedsRefresh + m.clampEntrySelection() + m.clampLargeSelection() + if len(m.entries) == 0 { + m.selected = 0 + } else if m.selected >= len(m.entries) { + m.selected = len(m.entries) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + if last.NeedsRefresh { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + m.scanning = false + return m, nil +} + +func (m *model) switchToOverviewMode() tea.Cmd { + m.cancelLiveScan() + m.isOverview = true + m.path = "/" + m.scanning = false + m.showLargeFiles = false + m.entriesAll = nil + m.resetEntryFilter() + m.resetLargeFilter() + m.largeFilesAll = nil + m.largeFiles = nil + m.largeSelected = 0 + m.largeOffset = 0 + m.deleteConfirm = false + m.deleteTarget = nil + m.selected = 0 + m.offset = 0 + m.hydrateOverviewEntries() + cmd := m.scheduleOverviewScans() + if cmd == nil { + m.status = "Ready" + return nil + } + return tea.Batch(cmd, tickCmd()) +} + +func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { + if len(m.entries) == 0 { + return m, nil + } + selected := m.entries[m.selected] + if selected.IsDir { + m.cancelLiveScan() + // Drilling in commits and drops any active directory filter so the parent + // is snapshotted in full. Remap the selection onto the unfiltered list so + // the entry we enter stays highlighted when we navigate back. + if m.entryFilter != "" { + for i := range m.entriesAll { + if m.entriesAll[i].Path == selected.Path { + m.selected = i + break + } + } + } + m.resetEntryFilter() + m.clampEntrySelection() + + if len(m.history) == 0 || m.history[len(m.history)-1].Path != m.path { + m.history = append(m.history, snapshotFromModel(m)) + } + m.path = selected.Path + m.selected = 0 + m.offset = 0 + m.status = "Scanning..." + m.scanning = true + m.isOverview = false + m.viewNeedsRefresh = false + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) + + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + + m.resetLargeFilter() + if cached, ok := m.cache[m.path]; ok { + m.entriesAll = slices.Clone(cached.Entries) + m.entries = m.entriesAll + m.largeFilesAll = slices.Clone(cached.LargeFiles) + m.largeFiles = m.largeFilesAll + m.totalSize = cached.TotalSize + m.totalFiles = cached.TotalFiles + m.viewNeedsRefresh = cached.NeedsRefresh + m.selected = cached.Selected + m.offset = cached.EntryOffset + m.largeSelected = cached.LargeSelected + m.largeOffset = cached.LargeOffset + m.clampEntrySelection() + m.clampLargeSelection() + if cached.NeedsRefresh { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } + m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) + m.scanning = false + return m, nil + } + m.lastTotalFiles = 0 + if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 { + m.lastTotalFiles = total + } + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + m.status = fmt.Sprintf("File: %s, %s", selected.Name, humanizeBytes(selected.Size)) + return m, nil +} + +func scanOverviewPathCmd(path string, index int) tea.Cmd { + return func() tea.Msg { + size, err := measureInsightSize(path) + return overviewSizeMsg{ + Path: path, + Index: index, + Size: size, + Err: err, + } + } +} diff --git a/Resources/mole/cmd/analyze/view.go b/Resources/mole/cmd/analyze/view.go index d696969..8e8799a 100644 --- a/Resources/mole/cmd/analyze/view.go +++ b/Resources/mole/cmd/analyze/view.go @@ -13,36 +13,30 @@ func (m model) View() string { var b strings.Builder fmt.Fprintln(&b) + // A warm cache already loaded for the current path keeps rendering while the + // background refresh runs, instead of blanking to a scan-only screen. Fresh + // scans (no cached entries yet) still fall back to the scan-only view. + showingCachedView := m.scanning && !m.inOverviewMode() && m.viewNeedsRefresh && len(m.entries) > 0 + showingLiveScanView := m.scanning && !m.inOverviewMode() && len(m.entries) > 0 && + (m.liveScanEvents != nil || len(m.liveScanningPaths) > 0) + if m.inOverviewMode() { - fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset) + freeLabel := "" + if m.diskFree > 0 { + freeLabel = fmt.Sprintf(" %s(%s free)%s", colorGray, humanizeBytes(m.diskFree), colorReset) + } + fmt.Fprintf(&b, "%sAnalyze Disk%s%s\n", colorPurpleBold, colorReset, freeLabel) if m.overviewScanning { - allPending := true - for _, entry := range m.entries { - if entry.Size >= 0 { - allPending = false - break - } - } - - if allPending { - fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, colorReset) - return b.String() + if allOverviewEntriesPending(m.entries) { + fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) + fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage...\n\n", + colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) } else { fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) fmt.Fprintf(&b, "%s%s%s%s %s\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, m.status) } } else { - hasPending := false - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - if hasPending { + if hasPendingOverviewEntries(m.entries) { fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) fmt.Fprintf(&b, "%s%s%s%s %s\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, m.status) } else { @@ -51,7 +45,7 @@ func (m model) View() string { } } else { fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurpleBold, colorReset, colorGray, displayPath(m.path), colorReset) - if !m.scanning { + if !m.scanning || m.totalSize > 0 { fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize)) } fmt.Fprintf(&b, "\n\n") @@ -63,11 +57,22 @@ func (m model) View() string { count = atomic.LoadInt64(m.deleteCount) } - fmt.Fprintf(&b, "%s%s%s%s Deleting: %s%s items%s removed, please wait...\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, - colorYellow, formatNumber(count), colorReset) + // The counter is path-level and only advances once a move completes, so a + // single large directory sits at zero for the whole operation. Printing + // "0 items removed" there reads as a stalled delete; say what is happening + // instead, and show the tally only once it means something. + if count > 0 { + fmt.Fprintf(&b, "%s%s%s%s Deleting: %s%s items%s removed, please wait...\n", + colorCyan, colorBold, + spinnerFrames[m.spinner], + colorReset, + colorYellow, formatNumber(count), colorReset) + } else { + fmt.Fprintf(&b, "%s%s%s%s Deleting: moving to Trash, please wait...\n", + colorCyan, colorBold, + spinnerFrames[m.spinner], + colorReset) + } return b.String() } @@ -89,7 +94,7 @@ func (m model) View() string { progressPrefix = fmt.Sprintf(" %s%.0f%%%s", colorCyan, percent, colorReset) } - fmt.Fprintf(&b, "%s%s%s%s Scanning%s: %s%s files%s, %s%s dirs%s, %s%s%s\n", + statusLine := fmt.Sprintf("%s%s%s%s Scanning%s: %s%s files%s, %s%s dirs%s, %s%s%s", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, @@ -98,31 +103,63 @@ func (m model) View() string { colorYellow, formatNumber(dirsScanned), colorReset, colorGreen, humanizeBytes(bytesScanned), colorReset) + currentPath := "" if m.currentPath != nil { - currentPath := m.currentPath.Load().(string) - if currentPath != "" { - shortPath := displayPath(currentPath) - shortPath = truncateMiddle(shortPath, 50) - fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset) + currentPath, _ = m.currentPath.Load().(string) + } + + if currentPath == "" { + fmt.Fprintf(&b, "%s\n", statusLine) + } else { + // Keep the path on the status line whenever the terminal is wide + // enough to show a useful piece of it, instead of always spending a + // second row on it. The old code also truncated to a fixed 50 + // columns, which cut paths short on wide terminals and could still + // overflow narrow ones. + shortPath := displayPath(currentPath) + const pathSeparator = " " + remaining := m.width - displayWidth(statusLine) - len(pathSeparator) + if remaining >= scanPathInlineMinWidth { + fmt.Fprintf(&b, "%s%s%s%s%s\n", statusLine, pathSeparator, + colorGray, truncateMiddle(shortPath, remaining), colorReset) + } else { + pathWidth := max(m.width, scanPathInlineMinWidth) + fmt.Fprintf(&b, "%s\n%s%s%s\n", statusLine, + colorGray, truncateMiddle(shortPath, pathWidth), colorReset) } } - return b.String() + if !showingCachedView && !showingLiveScanView { + return b.String() + } + if showingCachedView { + fmt.Fprintf(&b, "%sShowing cached results while refreshing...%s\n\n", colorGray, colorReset) + } else { + fmt.Fprintln(&b) + } } if m.showLargeFiles { + if m.largeFiltering || m.largeFilter != "" { + cursor := "" + if m.largeFiltering { + cursor = "▌" + } + fmt.Fprintf(&b, " %sFilter:%s %s%s %s(%d matches)%s\n\n", + colorCyan, colorReset, m.largeFilter, cursor, + colorGray, len(m.largeFiles), colorReset) + } if len(m.largeFiles) == 0 { - fmt.Fprintln(&b, " No large files found") + if m.largeFilter != "" { + fmt.Fprintf(&b, " No matches for %q\n", m.largeFilter) + } else { + fmt.Fprintln(&b, " No large files found") + } } else { viewport := calculateViewport(m.height, true) start := max(m.largeOffset, 0) end := min(start+viewport, len(m.largeFiles)) - maxLargeSize := int64(1) - for _, file := range m.largeFiles { - if file.Size > maxLargeSize { - maxLargeSize = file.Size - } - } + maxLargeSize := maxLargeFileSize(m.largeFiles) nameWidth := calculateNameWidth(m.width) for idx := start; idx < end; idx++ { file := m.largeFiles[idx] @@ -156,22 +193,35 @@ func (m model) View() string { } } } else { + if !m.inOverviewMode() && (m.entryFiltering || m.entryFilter != "") { + cursor := "" + if m.entryFiltering { + cursor = "▌" + } + fmt.Fprintf(&b, " %sFilter:%s %s%s %s(%d matches)%s\n\n", + colorCyan, colorReset, m.entryFilter, cursor, + colorGray, len(m.entries), colorReset) + } if len(m.entries) == 0 { - fmt.Fprintln(&b, " Empty directory") + if !m.inOverviewMode() && m.entryFilter != "" { + fmt.Fprintf(&b, " No matches for %q\n", m.entryFilter) + } else { + fmt.Fprintln(&b, " Empty directory") + } } else { if m.inOverviewMode() { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } + maxSize := maxDirEntrySize(m.entries) totalSize := m.totalSize - // Overview paths are short; fixed width keeps layout stable. - nameWidth := 20 + // Overview labels are short; fixed width keeps layout stable. + nameWidth := 22 + displayNum := 0 for idx, entry := range m.entries { - icon := "📁" sizeVal := entry.Size + // Hide entries that have been scanned and are empty (standard dirs + // are never 0 bytes; only insight dirs in unused tool paths are). + if sizeVal == 0 { + continue + } barValue := max(sizeVal, 0) var percent float64 if totalSize > 0 && sizeVal >= 0 { @@ -179,50 +229,42 @@ func (m model) View() string { } else { percent = 0 } - percentStr := fmt.Sprintf("%5.1f%%", percent) - if totalSize == 0 || sizeVal < 0 { - percentStr = " -- " - } + percentStr := formatPercent(percent, totalSize > 0 && sizeVal >= 0) bar := coloredProgressBar(barValue, maxSize, percent) - sizeText := "pending.." + // Pending rows reuse the list view's scanning idiom: the + // animated spinner keeps the row visibly alive, and the + // string is exactly 10 display columns, flush with the + // right-aligned sizes (a static placeholder read as stuck). + sizeText := fmt.Sprintf("%s scanning", spinnerFrames[m.spinner]) + sizeColor := colorCyan if sizeVal >= 0 { sizeText = humanizeBytes(sizeVal) - } - sizeColor := colorGray - if sizeVal >= 0 && totalSize > 0 { - switch { - case percent >= 50: - sizeColor = colorRed - case percent >= 20: - sizeColor = colorYellow - case percent >= 5: - sizeColor = colorBlue - default: - sizeColor = colorGray + sizeColor = colorGray + if totalSize > 0 { + sizeColor = sizeColorForPercent(percent) } } entryPrefix := " " name := trimNameWithWidth(entry.Name, nameWidth) paddedName := padName(name, nameWidth) - nameSegment := fmt.Sprintf("%s %s", icon, paddedName) + nameSegment := paddedName numColor := "" percentColor := "" if idx == m.selected { entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) - nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) + nameSegment = fmt.Sprintf("%s%s%s", colorCyan, paddedName, colorReset) numColor = colorCyan percentColor = colorCyan sizeColor = colorCyan } - displayIndex := idx + 1 - - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) - } else { - if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } + displayNum++ + displayIndex := displayNum + + // Keep the overview text-only. Emoji width and baselines vary + // across terminals, while every row has the same navigation. + hintLabel := "" + if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { + hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) } if hintLabel == "" { @@ -236,12 +278,7 @@ func (m model) View() string { } } } else { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } + maxSize := maxDirEntrySize(m.entries) viewport := calculateViewport(m.height, false) nameWidth := calculateNameWidth(m.width) @@ -254,24 +291,23 @@ func (m model) View() string { if entry.IsDir { icon = "📁" } - size := humanizeBytes(entry.Size) name := trimNameWithWidth(entry.Name, nameWidth) paddedName := padName(name, nameWidth) - percent := float64(entry.Size) / float64(m.totalSize) * 100 - percentStr := fmt.Sprintf("%5.1f%%", percent) + sizeValue := max(entry.Size, 0) + percent := 0.0 + if m.totalSize > 0 && entry.Size >= 0 { + percent = float64(entry.Size) / float64(m.totalSize) * 100 + } + percentStr := formatPercent(percent, entry.Size >= 0 && m.totalSize > 0) - bar := coloredProgressBar(entry.Size, maxSize, percent) + bar := coloredProgressBar(sizeValue, maxSize, percent) - var sizeColor string - if percent >= 50 { - sizeColor = colorRed - } else if percent >= 20 { - sizeColor = colorYellow - } else if percent >= 5 { - sizeColor = colorBlue - } else { - sizeColor = colorGray + sizeColor := sizeColorForPercent(percent) + size := humanizeBytes(entry.Size) + if entry.Size < 0 { + size = fmt.Sprintf("%s %s", spinnerFrames[m.spinner], "scanning") + sizeColor = colorCyan } isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path] @@ -301,23 +337,20 @@ func (m model) View() string { displayIndex := idx + 1 - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) - } else { - if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } + hintLabel := entryHintLabel(entry) + activityMarker := "|" + if entry.IsDir && m.liveScanningPaths != nil && m.liveScanningPaths[entry.Path] { + activityMarker = fmt.Sprintf("%s%s%s%s", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) } if hintLabel == "" { - fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n", + fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s %s %s %s%10s%s\n", entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset) + activityMarker, nameSegment, sizeColor, size, colorReset) } else { - fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", + fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s %s %s %s%10s%s %s\n", entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset, hintLabel) + activityMarker, nameSegment, sizeColor, size, colorReset, hintLabel) } } } @@ -327,31 +360,41 @@ func (m model) View() string { fmt.Fprintln(&b) if m.inOverviewMode() { if len(m.history) > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | P Preview | F File | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) } else { - fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset) + fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | P Preview | F File | Esc/Q Quit%s\n", colorGray, colorReset) } } else if m.showLargeFiles { - selectCount := len(m.largeMultiSelected) - if selectCount > 0 { - fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del %d | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset) + if m.largeFiltering { + fmt.Fprintf(&b, "%sType to filter | Enter Apply | Esc Clear | Ctrl+C Quit%s\n", colorGray, colorReset) + } else if m.largeFilter != "" { + fmt.Fprintf(&b, "%s↑↓← | Space Select | / Edit | Esc Clear filter | O Open | P Preview | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) } else { - fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) + selectCount := len(m.largeMultiSelected) + if selectCount > 0 { + fmt.Fprintf(&b, "%s↑↓← | Space Select | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, colorReset) + } else { + fmt.Fprintf(&b, "%s↑↓← | Space Select | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) + } } + } else if m.entryFiltering { + fmt.Fprintf(&b, "%sType to filter | Enter Apply | Esc Clear | Ctrl+C Quit%s\n", colorGray, colorReset) + } else if m.entryFilter != "" { + fmt.Fprintf(&b, "%s↑↓←→ | Enter | Space Select | / Edit | Esc Clear filter | O Open | P Preview | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) } else { largeFileCount := len(m.largeFiles) selectCount := len(m.multiSelected) if selectCount > 0 { if largeFileCount > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del %d | T Top %d | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | T Top %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, largeFileCount, colorReset) } else { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del %d | Q Quit%s\n", colorGray, selectCount, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, colorReset) } } else { if largeFileCount > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top %d | Q Quit%s\n", colorGray, largeFileCount, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del | T Top %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, largeFileCount, colorReset) } else { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) } } } @@ -396,6 +439,58 @@ func (m model) View() string { return b.String() } +func allOverviewEntriesPending(entries []dirEntry) bool { + for _, entry := range entries { + if entry.Size >= 0 { + return false + } + } + return true +} + +func maxLargeFileSize(files []fileEntry) int64 { + var maxSize int64 = 1 + for _, file := range files { + if file.Size > maxSize { + maxSize = file.Size + } + } + return maxSize +} + +func maxDirEntrySize(entries []dirEntry) int64 { + var maxSize int64 = 1 + for _, entry := range entries { + if entry.Size > maxSize { + maxSize = entry.Size + } + } + return maxSize +} + +func sizeColorForPercent(percent float64) string { + switch { + case percent >= 50: + return colorRed + case percent >= 20: + return colorYellow + case percent >= 5: + return colorBlue + default: + return colorGray + } +} + +func entryHintLabel(entry dirEntry) string { + if entry.IsDir && isCleanableDir(entry.Path) { + return fmt.Sprintf("%s🧹%s", colorYellow, colorReset) + } + if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { + return fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) + } + return "" +} + // calculateViewport returns visible rows for the current terminal height. func calculateViewport(termHeight int, isLargeFiles bool) int { if termHeight <= 0 { diff --git a/Resources/mole/cmd/status/diagnosis.go b/Resources/mole/cmd/status/diagnosis.go new file mode 100644 index 0000000..7af06b4 --- /dev/null +++ b/Resources/mole/cmd/status/diagnosis.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "strings" +) + +func statusDiagnosisLine(m MetricsSnapshot) string { + for _, disk := range m.Disks { + if disk.SmartStatus == smartStatusFailing { + return "SMART failing, back up now" + } + } + if m.CPU.Usage > cpuHighThreshold { + if proc, ok := leadingCPUProcess(m.TopProcesses, 50); ok { + return fmt.Sprintf("%s high CPU", shorten(proc.Name, 18)) + } + return "CPU load high" + } + if m.Memory.Pressure == "warn" || m.Memory.Pressure == "critical" || m.Memory.UsedPercent > memHighThreshold { + if proc, ok := leadingMemoryProcess(m.TopProcesses); ok && proc.Memory > 0 { + return fmt.Sprintf("%s memory pressure", shorten(proc.Name, 18)) + } + return "Memory pressure high" + } + if disk, ok := rootDisk(m.Disks); ok && disk.UsedPercent > diskCritThreshold { + free := uint64(0) + if disk.Total > disk.Used { + free = disk.Total - disk.Used + } + return fmt.Sprintf("Disk low, %s free", humanBytesShort(free)) + } + for _, battery := range m.Batteries { + if battery.Capacity > 0 && battery.Capacity < batteryCapWarn { + return "Battery health low" + } + if battery.CycleCount > batteryCycleWarn { + return "Battery cycles high" + } + } + if m.Thermal.CPUTemp > thermalNormalThreshold { + return "CPU temperature high" + } + if totalIO := m.DiskIO.ReadRate + m.DiskIO.WriteRate; totalIO > ioHighThreshold { + return "Disk I/O busy" + } + if strings.Contains(m.HealthScoreMsg, ":") { + return m.HealthScoreMsg + } + return "All clear" +} + +func leadingCPUProcess(procs []ProcessInfo, threshold float64) (ProcessInfo, bool) { + var best ProcessInfo + for i, proc := range procs { + if i == 0 || proc.CPU > best.CPU { + best = proc + } + } + if best.CPU < threshold { + return ProcessInfo{}, false + } + return best, true +} + +func leadingMemoryProcess(procs []ProcessInfo) (ProcessInfo, bool) { + var best ProcessInfo + for i, proc := range procs { + if i == 0 || proc.Memory > best.Memory { + best = proc + } + } + return best, len(procs) > 0 +} + +func rootDisk(disks []DiskStatus) (DiskStatus, bool) { + for _, disk := range disks { + if disk.Mount == "/" { + return disk, true + } + } + if len(disks) == 0 { + return DiskStatus{}, false + } + return disks[0], true +} diff --git a/Resources/mole/cmd/status/main.go b/Resources/mole/cmd/status/main.go index a31a8ba..d55d63f 100644 --- a/Resources/mole/cmd/status/main.go +++ b/Resources/mole/cmd/status/main.go @@ -6,7 +6,6 @@ import ( "flag" "fmt" "os" - "path/filepath" "strings" "time" @@ -14,14 +13,22 @@ import ( "github.com/charmbracelet/lipgloss" ) -const refreshInterval = time.Second +const ( + refreshInterval = time.Second + processWatchInterval = refreshInterval + slowRefreshInterval = 30 * time.Second +) var ( - Version = "dev" - BuildTime = "" - // Command-line flags - jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI") + jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI") + procCPUThreshold = flag.Float64("proc-cpu-threshold", 100, "alert when a process stays above this CPU percent") + procCPUWindow = flag.Duration("proc-cpu-window", 5*time.Minute, "continuous duration a process must exceed the CPU threshold") + procCPUAlerts = flag.Bool("proc-cpu-alerts", true, "enable persistent high-CPU process alerts") + + // Watch mode: stream NDJSON (one snapshot per line) from a single warm collector. + watchMode = flag.Bool("watch", false, "stream metrics continuously as newline-delimited JSON instead of the one-shot TUI/JSON") + watchInterval = flag.String("interval", "", "with --watch, collection interval (e.g. 1s, 2s); defaults to 1s") ) func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool { @@ -41,69 +48,75 @@ func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool { type tickMsg struct{} type animTickMsg struct{} +type collectionMode int + +const ( + collectionFast collectionMode = iota + collectionProcess + collectionFull +) + type metricsMsg struct { data MetricsSnapshot err error + mode collectionMode } type model struct { - collector *Collector - width int - height int - metrics MetricsSnapshot - errMessage string - ready bool - lastUpdated time.Time - collecting bool - animFrame int - catHidden bool // true = hidden, false = visible + collector *Collector + width int + height int + metrics MetricsSnapshot + errMessage string + ready bool + lastUpdated time.Time + lastFullAt time.Time + lastProcessAt time.Time + collecting bool + animFrame int + catHidden bool // true = hidden, false = visible + cpuCores int // how many CPU cores to list; 0 = all } -// getConfigPath returns the path to the status preferences file. -func getConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" +// padViewToHeight ensures the rendered frame always overwrites the full +// terminal region by padding with empty lines up to the current height. +func padViewToHeight(view string, height int) string { + if height <= 0 { + return view } - return filepath.Join(home, ".config", "mole", "status_prefs") -} -// loadCatHidden loads the cat hidden preference from config file. -func loadCatHidden() bool { - path := getConfigPath() - if path == "" { - return false + contentHeight := lipgloss.Height(view) + if contentHeight >= height { + return view } - data, err := os.ReadFile(path) - if err != nil { - return false - } - return strings.TrimSpace(string(data)) == "cat_hidden=true" -} -// saveCatHidden saves the cat hidden preference to config file. -func saveCatHidden(hidden bool) { - path := getConfigPath() - if path == "" { - return - } - // Ensure directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return - } - value := "cat_hidden=false" - if hidden { - value = "cat_hidden=true" - } - _ = os.WriteFile(path, []byte(value+"\n"), 0644) + return view + strings.Repeat("\n", height-contentHeight) } func newModel() model { return model{ - collector: NewCollector(), + collector: NewCollector(processWatchOptionsFromFlags()), catHidden: loadCatHidden(), + cpuCores: loadCPUCores(), + } +} + +func processWatchOptionsFromFlags() ProcessWatchOptions { + return ProcessWatchOptions{ + Enabled: *procCPUAlerts, + CPUThreshold: *procCPUThreshold, + Window: *procCPUWindow, + } +} + +func validateFlags() error { + if *procCPUThreshold < 0 { + return fmt.Errorf("--proc-cpu-threshold must be >= 0") + } + if *procCPUWindow <= 0 { + return fmt.Errorf("--proc-cpu-window must be > 0") } + return nil } func (m model) Init() tea.Cmd { @@ -121,6 +134,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.catHidden = !m.catHidden saveCatHidden(m.catHidden) return m, nil + case "c": + // Cycle how many CPU cores the card lists (2 → 4 → 8 → all) and persist. + m.cpuCores = nextCPUCores(m.cpuCores) + saveCPUCores(m.cpuCores) + return m, nil } case tea.WindowSizeMsg: m.width = msg.Width @@ -131,8 +149,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.collecting = true - return m, m.collectCmd() + return m, m.collectCmd(m.nextCollectionMode(time.Now())) case metricsMsg: + wasReady := m.ready if msg.err != nil { m.errMessage = msg.err.Error() } else { @@ -140,12 +159,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.metrics = msg.data m.lastUpdated = msg.data.CollectedAt + if msg.err == nil { + recordCollectionFreshness(msg.mode, msg.data.CollectedAt, &m.lastFullAt, &m.lastProcessAt) + } m.collecting = false // Mark ready after first successful data collection. if !m.ready { m.ready = true } - return m, tickAfter(refreshInterval) + delay := refreshInterval + if !wasReady { + delay = 0 + } + return m, tickAfter(delay) case animTickMsg: m.animFrame++ return m, animTickWithSpeed(m.metrics.CPU.Usage) @@ -164,48 +190,97 @@ func (m model) View() string { } header, mole := renderHeader(m.metrics, m.errMessage, m.animFrame, termWidth, m.catHidden) - - if termWidth <= 80 { - cardWidth := termWidth - if cardWidth > 2 { - cardWidth -= 2 - } - cards := buildCards(m.metrics, cardWidth) - - var rendered []string - for i, c := range cards { - if i > 0 { - rendered = append(rendered, "") + alertBar := renderProcessAlertBar(m.metrics.ProcessAlerts, termWidth) + + renderFrame := func(cpuCores int) string { + var cardContent string + if termWidth <= 80 { + cardWidth := termWidth + if cardWidth > 2 { + cardWidth -= 2 } - rendered = append(rendered, renderCard(c, cardWidth, 0)) + cards := buildCards(m.metrics, cardWidth, cpuCores, !m.lastFullAt.IsZero()) + + var rendered []string + for i, c := range cards { + if i > 0 { + rendered = append(rendered, "") + } + rendered = append(rendered, renderCard(c, cardWidth)) + } + cardContent = lipgloss.JoinVertical(lipgloss.Left, rendered...) + } else { + cardWidth := max(24, termWidth/2-4) + cards := buildCards(m.metrics, cardWidth, cpuCores, !m.lastFullAt.IsZero()) + cardContent = renderTwoColumns(cards, termWidth) } + // Combine header, mole, and cards with consistent spacing - var content []string - content = append(content, header) + parts := []string{header} + if alertBar != "" { + parts = append(parts, alertBar) + } if mole != "" { - content = append(content, mole) + parts = append(parts, mole) } - content = append(content, lipgloss.JoinVertical(lipgloss.Left, rendered...)) - return lipgloss.JoinVertical(lipgloss.Left, content...) + parts = append(parts, cardContent) + return lipgloss.JoinVertical(lipgloss.Left, parts...) + } + + // Every extra core is another card row, and the frame has no scrollback: on + // a 20-core Mac "all" adds ~18 lines and pushes the lower cards off a short + // window. Step the preference back down until the frame fits; the stored + // preference is untouched, so a taller window gets it back. + cpuCores := m.cpuCores + output := renderFrame(cpuCores) + for m.height > 0 && lipgloss.Height(output) > m.height && cpuCores != cpuCoresCycle[0] { + cpuCores = smallerCPUCores(cpuCores) + output = renderFrame(cpuCores) + } + return padViewToHeight(output, m.height) +} + +func (m model) nextCollectionMode(now time.Time) collectionMode { + return nextCollectionMode(m.ready, m.lastFullAt, m.lastProcessAt, now) +} + +func nextCollectionMode(ready bool, lastFullAt, lastProcessAt, now time.Time) collectionMode { + if !ready { + return collectionFast + } + if lastFullAt.IsZero() || now.Sub(lastFullAt) >= slowRefreshInterval { + return collectionFull } + if lastProcessAt.IsZero() || now.Sub(lastProcessAt) >= processWatchInterval { + return collectionProcess + } + return collectionFast +} - cardWidth := max(24, termWidth/2-4) - cards := buildCards(m.metrics, cardWidth) - twoCol := renderTwoColumns(cards, termWidth) - // Combine header, mole, and cards with consistent spacing - var content []string - content = append(content, header) - if mole != "" { - content = append(content, mole) +func recordCollectionFreshness(mode collectionMode, collectedAt time.Time, lastFullAt, lastProcessAt *time.Time) { + if mode == collectionFull { + *lastFullAt = collectedAt + } + if mode == collectionProcess || mode == collectionFull { + *lastProcessAt = collectedAt } - content = append(content, twoCol) - return lipgloss.JoinVertical(lipgloss.Left, content...) } -func (m model) collectCmd() tea.Cmd { +func (m model) collectCmd(mode collectionMode) tea.Cmd { return func() tea.Msg { - data, err := m.collector.Collect() - return metricsMsg{data: data, err: err} + var ( + data MetricsSnapshot + err error + ) + switch mode { + case collectionFull: + data, err = m.collector.Collect() + case collectionProcess: + data, err = m.collector.CollectProcesses() + default: + data, err = m.collector.CollectFast() + } + return metricsMsg{data: data, err: err, mode: mode} } } @@ -225,15 +300,8 @@ func animTickWithSpeed(cpuUsage float64) tea.Cmd { // runJSONMode collects metrics once and outputs as JSON. func runJSONMode() { - collector := NewCollector() - - // First collection initializes network state (returns nil for network) - _, _ = collector.Collect() - - // Wait 1 second for network rate calculation - time.Sleep(1 * time.Second) + collector := NewCollector(processWatchOptionsFromFlags()) - // Second collection has actual network data data, err := collector.Collect() if err != nil { fmt.Fprintf(os.Stderr, "error collecting metrics: %v\n", err) @@ -257,8 +325,37 @@ func runTUIMode() { } } +func parseWatchInterval(raw string) (time.Duration, error) { + if raw == "" { + return refreshInterval, nil + } + + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("invalid --interval %q (want e.g. 1s, 2s): %w", raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("invalid --interval %q (must be > 0)", raw) + } + return d, nil +} + func main() { flag.Parse() + if err := validateFlags(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(2) + } + + if *watchMode { + interval, err := parseWatchInterval(*watchInterval) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(2) + } + runWatchMode(interval) + return + } if shouldUseJSONOutput(*jsonOutput, os.Stdout) { runJSONMode() @@ -266,3 +363,13 @@ func main() { runTUIMode() } } + +func activeAlerts(alerts []ProcessAlert) []ProcessAlert { + var active []ProcessAlert + for _, alert := range alerts { + if alert.Status == "active" { + active = append(active, alert) + } + } + return active +} diff --git a/Resources/mole/cmd/status/main_test.go b/Resources/mole/cmd/status/main_test.go index 76f31cb..bc786a7 100644 --- a/Resources/mole/cmd/status/main_test.go +++ b/Resources/mole/cmd/status/main_test.go @@ -1,8 +1,11 @@ package main import ( + "errors" "os" + "reflect" "testing" + "time" ) func TestShouldUseJSONOutput_ForceFlag(t *testing.T) { @@ -42,3 +45,394 @@ func TestShouldUseJSONOutput_NonTTYFile(t *testing.T) { t.Fatalf("expected file stdout to use JSON mode") } } + +func TestProcessWatchOptionsFromFlags(t *testing.T) { + oldThreshold := *procCPUThreshold + oldWindow := *procCPUWindow + oldAlerts := *procCPUAlerts + defer func() { + *procCPUThreshold = oldThreshold + *procCPUWindow = oldWindow + *procCPUAlerts = oldAlerts + }() + + *procCPUThreshold = 125 + *procCPUWindow = 2 * time.Minute + *procCPUAlerts = false + + opts := processWatchOptionsFromFlags() + if opts.CPUThreshold != 125 { + t.Fatalf("CPUThreshold = %v, want 125", opts.CPUThreshold) + } + if opts.Window != 2*time.Minute { + t.Fatalf("Window = %v, want 2m", opts.Window) + } + if opts.Enabled { + t.Fatal("Enabled = true, want false") + } +} + +func TestValidateFlags(t *testing.T) { + oldThreshold := *procCPUThreshold + oldWindow := *procCPUWindow + defer func() { + *procCPUThreshold = oldThreshold + *procCPUWindow = oldWindow + }() + + *procCPUThreshold = -1 + *procCPUWindow = 5 * time.Minute + if err := validateFlags(); err == nil { + t.Fatal("expected negative threshold to fail validation") + } + + *procCPUThreshold = 100 + *procCPUWindow = 0 + if err := validateFlags(); err == nil { + t.Fatal("expected zero window to fail validation") + } +} + +func TestParseWatchInterval(t *testing.T) { + tests := []struct { + name string + raw string + want time.Duration + wantErr bool + }{ + {name: "default", raw: "", want: refreshInterval}, + {name: "duration", raw: "250ms", want: 250 * time.Millisecond}, + {name: "invalid", raw: "soon", wantErr: true}, + {name: "zero", raw: "0s", wantErr: true}, + {name: "negative", raw: "-1s", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseWatchInterval(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("parseWatchInterval(%q) returned nil error", tt.raw) + } + return + } + if err != nil { + t.Fatalf("parseWatchInterval(%q) error = %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("parseWatchInterval(%q) = %v, want %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestNextCollectionModeUsesFastFirstThenPeriodicFull(t *testing.T) { + now := time.Now() + + m := model{} + if got := m.nextCollectionMode(now); got != collectionFast { + t.Fatalf("new model nextCollectionMode() = %v, want fast", got) + } + + m.ready = true + if got := m.nextCollectionMode(now); got != collectionFull { + t.Fatalf("ready model without full collection = %v, want full", got) + } + + m.lastFullAt = now.Add(-slowRefreshInterval + time.Second) + if got := m.nextCollectionMode(now); got != collectionProcess { + t.Fatalf("fresh full without process collection mode = %v, want process", got) + } + + m.lastProcessAt = now.Add(-processWatchInterval + time.Millisecond) + if got := m.nextCollectionMode(now); got != collectionFast { + t.Fatalf("fresh process collection mode = %v, want fast", got) + } + + m.lastProcessAt = now.Add(-processWatchInterval) + if got := m.nextCollectionMode(now); got != collectionProcess { + t.Fatalf("stale process collection mode = %v, want process", got) + } + + m.lastFullAt = now.Add(-slowRefreshInterval) + if got := m.nextCollectionMode(now); got != collectionFull { + t.Fatalf("expired full collection mode = %v, want full", got) + } +} + +func TestWatchStateUsesSharedCollectionCadence(t *testing.T) { + now := time.Now() + + st := watchState{} + if got := st.nextMode(now); got != collectionFast { + t.Fatalf("new watchState nextMode() = %v, want fast", got) + } + + st.ready = true + if got := st.nextMode(now); got != collectionFull { + t.Fatalf("ready watchState without full collection = %v, want full", got) + } + + st.lastFullAt = now + if got := st.nextMode(now); got != collectionProcess { + t.Fatalf("fresh full without process collection mode = %v, want process", got) + } + + st.lastProcessAt = now + if got := st.nextMode(now); got != collectionFast { + t.Fatalf("fresh process collection mode = %v, want fast", got) + } +} + +func TestFullCollectionErrorDoesNotMarkFullFresh(t *testing.T) { + now := time.Now() + lastFull := now.Add(-slowRefreshInterval) + m := model{ + ready: true, + lastFullAt: lastFull, + } + + updated, _ := m.Update(metricsMsg{ + data: MetricsSnapshot{ + CollectedAt: now, + }, + err: errors.New("full collector failed"), + mode: collectionFull, + }) + got := updated.(model) + + if !got.lastFullAt.Equal(lastFull) { + t.Fatalf("full error updated lastFullAt = %v, want %v", got.lastFullAt, lastFull) + } + if got.nextCollectionMode(now) != collectionFull { + t.Fatalf("full error should leave the next tick eligible for a full retry") + } +} + +func TestProcessCollectionUpdatesProcessFreshness(t *testing.T) { + now := time.Now() + m := model{ready: true} + + updated, _ := m.Update(metricsMsg{ + data: MetricsSnapshot{CollectedAt: now}, + mode: collectionProcess, + }) + got := updated.(model) + + if !got.lastProcessAt.Equal(now) { + t.Fatalf("process collection updated lastProcessAt = %v, want %v", got.lastProcessAt, now) + } + if !got.lastFullAt.IsZero() { + t.Fatalf("process collection should not update lastFullAt, got %v", got.lastFullAt) + } +} + +func TestCollectorAppliesCachedEnrichmentToFastSnapshot(t *testing.T) { + previous := MetricsSnapshot{ + CPU: CPUStatus{PCoreCount: 8, ECoreCount: 4}, + Memory: MemoryStatus{Cached: 512, Pressure: "warn"}, + Hardware: HardwareInfo{Model: "MacBook Pro", CPUModel: "M3", OSVersion: "macOS 15", RefreshRate: "120Hz"}, + GPU: []GPUStatus{{Name: "Apple GPU", Usage: 12}}, + TrashSize: 42, + TrashApprox: true, + Proxy: ProxyStatus{Enabled: true, Type: "HTTP", Host: "127.0.0.1:8080"}, + Batteries: []BatteryStatus{{Percent: 80, Capacity: 92}}, + Thermal: ThermalStatus{CPUTemp: 45}, + Sensors: []SensorReading{{Label: "Fan", Value: 1200, Unit: "rpm"}}, + Bluetooth: []BluetoothDevice{{Name: "Keyboard", Connected: true}}, + TopProcesses: []ProcessInfo{ + {PID: 42, Name: "Xcode", CPU: 82}, + }, + ProcessAlerts: []ProcessAlert{ + {PID: 42, Name: "Xcode", CPU: 140, Status: "active"}, + }, + } + + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(previous) + previous.GPU[0].Name = "mutated" + + next := MetricsSnapshot{ + UptimeSeconds: 60, + Hardware: HardwareInfo{TotalRAM: "16G", DiskSize: "1T"}, + CPU: CPUStatus{Usage: 10}, + Memory: MemoryStatus{UsedPercent: 30, Pressure: "normal"}, + Disks: []DiskStatus{{Mount: "/", Total: 100, Used: 20, UsedPercent: 20}}, + DiskIO: DiskIOStatus{ReadRate: 1, WriteRate: 1}, + } + + collector.applyEnrichment(&next, false) + + if next.Hardware.Model != "MacBook Pro" { + t.Fatalf("expected hardware details to be preserved, got %#v", next.Hardware) + } + if next.CPU.PCoreCount != 8 || next.CPU.ECoreCount != 4 { + t.Fatalf("expected CPU topology to be preserved, got %#v", next.CPU) + } + if next.Memory.Cached != 512 || next.Memory.Pressure != "warn" { + t.Fatalf("expected slow memory annotations to be preserved, got %#v", next.Memory) + } + if next.TrashSize != 42 || !next.TrashApprox { + t.Fatalf("expected trash metadata to be preserved, got size=%d approx=%v", next.TrashSize, next.TrashApprox) + } + if !next.Proxy.Enabled || next.Proxy.Host != "127.0.0.1:8080" { + t.Fatalf("expected proxy metadata to be preserved, got %#v", next.Proxy) + } + if len(next.GPU) != 1 || next.GPU[0].Name != "Apple GPU" { + t.Fatalf("expected GPU metadata to be preserved from cache, got %#v", next.GPU) + } + if len(next.Batteries) != 1 || next.Batteries[0].Capacity != 92 { + t.Fatalf("expected battery metadata to be preserved, got %#v", next.Batteries) + } + if next.Thermal.CPUTemp != 45 { + t.Fatalf("expected thermal metadata to be preserved, got %#v", next.Thermal) + } + if len(next.Bluetooth) != 1 || next.Bluetooth[0].Name != "Keyboard" { + t.Fatalf("expected Bluetooth metadata to be preserved, got %#v", next.Bluetooth) + } + if len(next.TopProcesses) != 1 || next.TopProcesses[0].Name != "Xcode" { + t.Fatalf("expected top processes to be preserved, got %#v", next.TopProcesses) + } + if len(next.ProcessAlerts) != 1 || next.ProcessAlerts[0].Status != "active" { + t.Fatalf("expected process alerts to be preserved, got %#v", next.ProcessAlerts) + } + if next.HealthScore == 0 || next.HealthScoreMsg == "" { + t.Fatalf("expected health score to be recalculated, got %d %q", next.HealthScore, next.HealthScoreMsg) + } +} + +func TestCollectorAppliesZeroValueEnrichmentExactly(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + Memory: MemoryStatus{ + Cached: 0, + Pressure: "", + }, + }) + + next := MetricsSnapshot{ + Memory: MemoryStatus{ + Cached: 512, + Pressure: "critical", + }, + } + + collector.applyEnrichment(&next, false) + + if next.Memory.Cached != 0 || next.Memory.Pressure != "" { + t.Fatalf("expected exact memory enrichment, got %#v", next.Memory) + } +} + +func TestCollectorOverridesFastDisksWithCorrectedCache(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + Disks: []DiskStatus{ + {Mount: "/", Total: 1000, Used: 600, UsedPercent: 60, External: false, SmartStatus: smartStatusVerified}, + }, + }) + + // Fast path produced raw statfs numbers that ignore APFS purgeable space. + next := MetricsSnapshot{ + Disks: []DiskStatus{ + {Mount: "/", Total: 1000, Used: 900, UsedPercent: 90, External: true, SmartStatus: smartStatusUnknown}, + }, + } + + collector.applyEnrichment(&next, false) + + if len(next.Disks) != 1 { + t.Fatalf("expected one disk, got %#v", next.Disks) + } + if next.Disks[0].Used != 600 || next.Disks[0].UsedPercent != 60 || + next.Disks[0].External || next.Disks[0].SmartStatus != smartStatusVerified { + t.Fatalf("expected corrected disk values from cache, got %#v", next.Disks[0]) + } +} + +func TestCollectorKeepsFastDisksWhenCacheHasNone(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + // First full refresh failed to enumerate disks; the cache should not blank + // out the fast path's raw disks. + collector.cacheEnrichment(MetricsSnapshot{Disks: nil}) + + next := MetricsSnapshot{ + Disks: []DiskStatus{ + {Mount: "/", Total: 1000, Used: 900, UsedPercent: 90}, + }, + } + + collector.applyEnrichment(&next, false) + + if len(next.Disks) != 1 || next.Disks[0].Used != 900 { + t.Fatalf("expected raw fast disks to survive empty cache, got %#v", next.Disks) + } +} + +func TestCollectorKeepsLiveProcessDataWhenApplyingEnrichment(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + TopProcesses: []ProcessInfo{{PID: 1, Name: "old", CPU: 10}}, + ProcessAlerts: []ProcessAlert{ + {PID: 1, Name: "old", Status: "active"}, + }, + }) + + next := MetricsSnapshot{ + TopProcesses: []ProcessInfo{{PID: 2, Name: "new", CPU: 90}}, + ProcessAlerts: []ProcessAlert{ + {PID: 2, Name: "new", Status: "active"}, + }, + } + + collector.applyEnrichment(&next, true) + + if len(next.TopProcesses) != 1 || next.TopProcesses[0].Name != "new" { + t.Fatalf("expected live top process data, got %#v", next.TopProcesses) + } + if len(next.ProcessAlerts) != 1 || next.ProcessAlerts[0].Name != "new" { + t.Fatalf("expected live process alerts, got %#v", next.ProcessAlerts) + } +} + +func TestMetricsSnapshotFieldsHaveCollectionClassifications(t *testing.T) { + classified := map[string]string{ + "CollectedAt": "fast", + "Host": "fast", + "Platform": "fast", + "Uptime": "fast", + "UptimeSeconds": "fast", + "Procs": "fast", + "Hardware": "enrichment", + "HealthScore": "recomputed", + "HealthScoreMsg": "recomputed", + "CPU": "mixed", + "GPU": "enrichment", + "Memory": "mixed", + "Disks": "enrichment", + "TrashSize": "enrichment", + "TrashApprox": "enrichment", + "DiskIO": "fast", + "Network": "fast", + "NetworkHistory": "fast", + "Proxy": "enrichment", + "Batteries": "enrichment", + "Thermal": "enrichment", + "Sensors": "enrichment", + "Bluetooth": "enrichment", + "TopProcesses": "live-or-enrichment", + "ProcessWatch": "config", + "ProcessAlerts": "live-or-enrichment", + } + + typ := reflect.TypeFor[MetricsSnapshot]() + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, ok := classified[name]; !ok { + t.Fatalf("MetricsSnapshot.%s has no collection classification", name) + } + } + if len(classified) != typ.NumField() { + t.Fatalf("field classification count = %d, want %d", len(classified), typ.NumField()) + } +} diff --git a/Resources/mole/cmd/status/metrics.go b/Resources/mole/cmd/status/metrics.go index 75ecd13..db19816 100644 --- a/Resources/mole/cmd/status/metrics.go +++ b/Resources/mole/cmd/status/metrics.go @@ -3,7 +3,10 @@ package main import ( "context" "fmt" + "os" "os/exec" + "slices" + "strings" "sync" "time" @@ -61,24 +64,29 @@ type MetricsSnapshot struct { Host string `json:"host"` Platform string `json:"platform"` Uptime string `json:"uptime"` + UptimeSeconds uint64 `json:"uptime_seconds"` Procs uint64 `json:"procs"` Hardware HardwareInfo `json:"hardware"` HealthScore int `json:"health_score"` // 0-100 system health score HealthScoreMsg string `json:"health_score_msg"` // Brief explanation - CPU CPUStatus `json:"cpu"` - GPU []GPUStatus `json:"gpu"` - Memory MemoryStatus `json:"memory"` - Disks []DiskStatus `json:"disks"` - DiskIO DiskIOStatus `json:"disk_io"` - Network []NetworkStatus `json:"network"` - NetworkHistory NetworkHistory `json:"network_history"` - Proxy ProxyStatus `json:"proxy"` - Batteries []BatteryStatus `json:"batteries"` - Thermal ThermalStatus `json:"thermal"` - Sensors []SensorReading `json:"sensors"` - Bluetooth []BluetoothDevice `json:"bluetooth"` - TopProcesses []ProcessInfo `json:"top_processes"` + CPU CPUStatus `json:"cpu"` + GPU []GPUStatus `json:"gpu"` + Memory MemoryStatus `json:"memory"` + Disks []DiskStatus `json:"disks"` + TrashSize uint64 `json:"trash_size"` + TrashApprox bool `json:"trash_approx"` + DiskIO DiskIOStatus `json:"disk_io"` + Network []NetworkStatus `json:"network"` + NetworkHistory NetworkHistory `json:"network_history"` + Proxy ProxyStatus `json:"proxy"` + Batteries []BatteryStatus `json:"batteries"` + Thermal ThermalStatus `json:"thermal"` + Sensors []SensorReading `json:"sensors"` + Bluetooth []BluetoothDevice `json:"bluetooth"` + TopProcesses []ProcessInfo `json:"top_processes"` + ProcessWatch ProcessWatchConfig `json:"process_watch"` + ProcessAlerts []ProcessAlert `json:"process_alerts"` } type HardwareInfo struct { @@ -96,9 +104,13 @@ type DiskIOStatus struct { } type ProcessInfo struct { - Name string `json:"name"` - CPU float64 `json:"cpu"` - Memory float64 `json:"memory"` + PID int `json:"pid"` + PPID int `json:"ppid"` + Name string `json:"name"` + Command string `json:"command"` + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` // Percent of physical memory, kept for compatibility. + MemoryBytes uint64 `json:"memory_bytes,omitempty"` } type CPUStatus struct { @@ -126,6 +138,7 @@ type GPUStatus struct { type MemoryStatus struct { Used uint64 `json:"used"` Total uint64 `json:"total"` + Available uint64 `json:"available"` UsedPercent float64 `json:"used_percent"` SwapUsed uint64 `json:"swap_used"` SwapTotal uint64 `json:"swap_total"` @@ -141,6 +154,10 @@ type DiskStatus struct { UsedPercent float64 `json:"used_percent"` Fstype string `json:"fstype"` External bool `json:"external"` + SmartStatus string `json:"smart_status"` + // Purgeable is the reclaimable purgeable bytes Finder counts as free on + // macOS APFS. Zero when unknown. + Purgeable uint64 `json:"purgeable,omitempty"` } type NetworkStatus struct { @@ -162,6 +179,11 @@ type ProxyStatus struct { Enabled bool `json:"enabled"` Type string `json:"type"` // HTTP, HTTPS, SOCKS, PAC, WPAD, TUN Host string `json:"host"` + // True when the only evidence is an active tunnel interface rather than a + // configured proxy. A `utun` is equally iCloud Private Relay, a corporate + // VPN, or a TUN-mode proxy client, and nothing at this layer can tell them + // apart, so the reading must not be presented as "you have a proxy". + IsTunnel bool `json:"-"` } type BatteryStatus struct { @@ -176,6 +198,7 @@ type BatteryStatus struct { type ThermalStatus struct { CPUTemp float64 `json:"cpu_temp"` GPUTemp float64 `json:"gpu_temp"` + BatteryTemp float64 `json:"battery_temp"` // Battery temperature in Celsius when exposed by AppleSmartBattery FanSpeed int `json:"fan_speed"` FanCount int `json:"fan_count"` SystemPower float64 `json:"system_power"` // System power consumption in Watts @@ -207,150 +230,362 @@ type Collector struct { lastBT []BluetoothDevice // Fast metrics (1s). - prevNet map[string]net.IOCountersStat - lastNetAt time.Time - rxHistoryBuf *RingBuffer - txHistoryBuf *RingBuffer - lastGPUAt time.Time - cachedGPU []GPUStatus - prevDiskIO disk.IOCountersStat - lastDiskAt time.Time -} - -func NewCollector() *Collector { - return &Collector{ - prevNet: make(map[string]net.IOCountersStat), - rxHistoryBuf: NewRingBuffer(NetworkHistorySize), - txHistoryBuf: NewRingBuffer(NetworkHistorySize), - } + prevNet map[string]net.IOCountersStat + lastNetAt time.Time + rxHistoryBuf *RingBuffer + txHistoryBuf *RingBuffer + lastNetIPAt time.Time + cachedNetIPs map[string]string + lastGPUAt time.Time + cachedGPU []GPUStatus + lastGPUUsageAt time.Time + cachedGPUUsage float64 + prevDiskIO disk.IOCountersStat + lastDiskAt time.Time + + watchMu sync.Mutex + processWatch ProcessWatchConfig + processWatcher *ProcessWatcher + enrichment snapshotEnrichment + hasEnrichment bool } -func (c *Collector) Collect() (MetricsSnapshot, error) { - now := time.Now() +type collectedMetrics struct { + cpuStats CPUStatus + memStats MemoryStatus + diskStats []DiskStatus + trashSize uint64 + trashApprox bool + diskIO DiskIOStatus + netStats []NetworkStatus + proxyStats ProxyStatus + batteryStats []BatteryStatus + thermalStats ThermalStatus + sensorStats []SensorReading + gpuStats []GPUStatus + btStats []BluetoothDevice + allProcs []ProcessInfo + hasProcesses bool +} + +type snapshotEnrichment struct { + // When adding MetricsSnapshot fields, update + // TestMetricsSnapshotFieldsHaveCollectionClassifications. + hardware HardwareInfo + cpuPCores int + cpuECores int + memoryCached uint64 + memoryPressure string + disks []DiskStatus + hasDisks bool + gpu []GPUStatus + trashSize uint64 + trashApprox bool + proxy ProxyStatus + batteries []BatteryStatus + thermal ThermalStatus + sensors []SensorReading + bluetooth []BluetoothDevice + topProcesses []ProcessInfo + processAlerts []ProcessAlert +} + +func NewCollector(options ProcessWatchOptions) *Collector { + c := &Collector{ + prevNet: make(map[string]net.IOCountersStat), + rxHistoryBuf: NewRingBuffer(NetworkHistorySize), + txHistoryBuf: NewRingBuffer(NetworkHistorySize), + cachedNetIPs: make(map[string]string), + processWatch: options.SnapshotConfig(), + processWatcher: NewProcessWatcher(options), + } + c.primeNetworkCounters(time.Now()) + return c +} - // Host info is cached by gopsutil; fetch once. +func collectHostInfo() *host.InfoStat { hostInfo, _ := host.Info() if hostInfo == nil { hostInfo = &host.InfoStat{} } + return hostInfo +} +func collectConcurrently(tasks ...func() error) error { var ( - wg sync.WaitGroup - errMu sync.Mutex - mergeErr error - - cpuStats CPUStatus - memStats MemoryStatus - diskStats []DiskStatus - diskIO DiskIOStatus - netStats []NetworkStatus - proxyStats ProxyStatus - batteryStats []BatteryStatus - thermalStats ThermalStatus - sensorStats []SensorReading - gpuStats []GPUStatus - btStats []BluetoothDevice - topProcs []ProcessInfo + wg sync.WaitGroup + errMu sync.Mutex + merged error ) - // Helper to launch concurrent collection. - collect := func(fn func() error) { - wg.Add(1) - go func() { - defer wg.Done() + for _, task := range tasks { + wg.Go(func() { defer func() { if r := recover(); r != nil { errMu.Lock() panicErr := fmt.Errorf("collector panic: %v", r) - if mergeErr == nil { - mergeErr = panicErr + if merged == nil { + merged = panicErr } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, panicErr) + merged = fmt.Errorf("%v; %w", merged, panicErr) } errMu.Unlock() } }() - if err := fn(); err != nil { + if err := task(); err != nil { errMu.Lock() - if mergeErr == nil { - mergeErr = err + if merged == nil { + merged = err } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, err) + merged = fmt.Errorf("%v; %w", merged, err) } errMu.Unlock() } - }() + }) } + wg.Wait() + return merged +} + +func (c *Collector) CollectFast() (MetricsSnapshot, error) { + return c.collectFast(false) +} + +func (c *Collector) CollectProcesses() (MetricsSnapshot, error) { + return c.collectFast(true) +} + +func (c *Collector) collectFast(includeProcesses bool) (MetricsSnapshot, error) { + now := time.Now() + hostInfo := collectHostInfo() + var collected collectedMetrics + + tasks := []func() error{ + func() (err error) { collected.cpuStats, err = collectCPUFast(); return }, + func() (err error) { collected.memStats, err = collectMemoryFast(); return }, + func() (err error) { collected.diskStats, err = collectDisksFast(); return }, + func() (err error) { collected.diskIO = c.collectDiskIO(now); return nil }, + func() (err error) { collected.netStats = c.collectNetwork(now); return nil }, + } + if includeProcesses { + tasks = append(tasks, func() error { return collectProcessesInto(&collected) }) + } + + mergeErr := collectConcurrently(tasks...) + + snapshot := c.snapshotFromMetrics(now, hostInfo, collected, false) + c.applyEnrichment(&snapshot, collected.hasProcesses) + return snapshot, mergeErr +} + +func (c *Collector) Collect() (MetricsSnapshot, error) { + return c.collectFull() +} + +func (c *Collector) collectFull() (MetricsSnapshot, error) { + now := time.Now() + hostInfo := collectHostInfo() + var collected collectedMetrics + + // Sample CPU first, before the concurrent collectors below spawn their + // subprocesses (system_profiler, df, ps, ...). The usage window is only + // 100ms, so measuring while our own collection burst runs inflates the + // reading with Mole's own load (#1237). + var cpuErr error + collected.cpuStats, cpuErr = collectCPU() + // Launch independent collection tasks. - collect(func() (err error) { cpuStats, err = collectCPU(); return }) - collect(func() (err error) { memStats, err = collectMemory(); return }) - collect(func() (err error) { diskStats, err = collectDisks(); return }) - collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil }) - collect(func() (err error) { netStats, err = c.collectNetwork(now); return }) - collect(func() (err error) { proxyStats = collectProxy(); return nil }) - collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil }) - collect(func() (err error) { thermalStats = collectThermal(); return nil }) - // Sensors disabled - CPU temp already shown in CPU card - // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) - collect(func() (err error) { gpuStats, err = c.collectGPU(now); return }) - collect(func() (err error) { - // Bluetooth is slow; cache for 30s. - if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { - btStats = c.collectBluetooth(now) - c.lastBT = btStats - c.lastBTAt = now - } else { - btStats = c.lastBT - } - return nil - }) - collect(func() (err error) { topProcs = collectTopProcesses(); return nil }) + tasks := []func() error{ + func() error { return cpuErr }, + func() (err error) { collected.memStats, err = collectMemory(); return }, + func() (err error) { collected.diskStats, err = collectDisks(); return }, + func() (err error) { collected.trashSize, collected.trashApprox = collectTrashSize(); return nil }, + func() (err error) { collected.diskIO = c.collectDiskIO(now); return nil }, + func() (err error) { collected.netStats = c.collectNetwork(now); return nil }, + func() (err error) { collected.proxyStats = collectProxy(); return nil }, + func() (err error) { collected.batteryStats, _ = collectBatteries(); return nil }, + func() (err error) { collected.thermalStats = collectThermal(); return nil }, + // Sensors disabled - CPU temp already shown in CPU card + // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) + func() (err error) { collected.gpuStats, err = c.collectGPU(now); return }, + func() (err error) { + // Bluetooth is slow; cache for 30s. + if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { + collected.btStats = c.collectBluetooth(now) + c.lastBT = collected.btStats + c.lastBTAt = now + } else { + collected.btStats = c.lastBT + } + return nil + }, + func() error { return collectProcessesInto(&collected) }, + } + mergeErr := collectConcurrently(tasks...) - // Wait for all to complete. - wg.Wait() + snapshot := c.snapshotFromMetrics(now, hostInfo, collected, true) + if mergeErr == nil { + c.cacheEnrichment(snapshot) + } + return snapshot, mergeErr +} + +func collectProcessesInto(collected *collectedMetrics) error { + procs, err := collectProcessesFunc() + if err != nil { + return err + } + collected.allProcs = procs + collected.hasProcesses = true + return nil +} +func (c *Collector) snapshotFromMetrics(now time.Time, hostInfo *host.InfoStat, collected collectedMetrics, refreshHardware bool) MetricsSnapshot { // Dependent tasks (post-collect). // Cache hardware info as it's expensive and rarely changes. - if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute { - c.cachedHW = collectHardware(memStats.Total, diskStats) + if refreshHardware && (!c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute) { + c.cachedHW = collectHardware(collected.memStats.Total, collected.diskStats) c.lastHWAt = now c.hasStatic = true } - hwInfo := c.cachedHW + hwInfo := c.hardwareForSnapshot() + + score, scoreMsg := calculateHealthScore( + collected.cpuStats, + collected.memStats, + collected.diskStats, + collected.diskIO, + collected.thermalStats, + collected.batteryStats, + hostInfo.Uptime, + ) + var topProcs []ProcessInfo + if collected.hasProcesses { + topProcs = topProcesses(collected.allProcs, 5) + } - score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) + var processAlerts []ProcessAlert + c.watchMu.Lock() + if c.processWatcher != nil { + if collected.hasProcesses { + processAlerts = c.processWatcher.Update(now, collected.allProcs) + } else { + processAlerts = c.processWatcher.Snapshot() + } + } + c.watchMu.Unlock() return MetricsSnapshot{ CollectedAt: now, Host: hostInfo.Hostname, Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion), Uptime: formatUptime(hostInfo.Uptime), + UptimeSeconds: hostInfo.Uptime, Procs: hostInfo.Procs, Hardware: hwInfo, HealthScore: score, HealthScoreMsg: scoreMsg, - CPU: cpuStats, - GPU: gpuStats, - Memory: memStats, - Disks: diskStats, - DiskIO: diskIO, - Network: netStats, + CPU: collected.cpuStats, + GPU: collected.gpuStats, + Memory: collected.memStats, + Disks: collected.diskStats, + TrashSize: collected.trashSize, + TrashApprox: collected.trashApprox, + DiskIO: collected.diskIO, + Network: collected.netStats, NetworkHistory: NetworkHistory{ RxHistory: c.rxHistoryBuf.Slice(), TxHistory: c.txHistoryBuf.Slice(), }, - Proxy: proxyStats, - Batteries: batteryStats, - Thermal: thermalStats, - Sensors: sensorStats, - Bluetooth: btStats, - TopProcesses: topProcs, - }, mergeErr + Proxy: collected.proxyStats, + Batteries: collected.batteryStats, + Thermal: collected.thermalStats, + Sensors: collected.sensorStats, + Bluetooth: collected.btStats, + TopProcesses: topProcs, + ProcessWatch: c.processWatch, + ProcessAlerts: processAlerts, + } +} + +func (c *Collector) hardwareForSnapshot() HardwareInfo { + if c.hasStatic { + return c.cachedHW + } + return HardwareInfo{} +} + +func (c *Collector) cacheEnrichment(snapshot MetricsSnapshot) { + c.enrichment = snapshotEnrichment{ + hardware: snapshot.Hardware, + cpuPCores: snapshot.CPU.PCoreCount, + cpuECores: snapshot.CPU.ECoreCount, + memoryCached: snapshot.Memory.Cached, + memoryPressure: snapshot.Memory.Pressure, + disks: slices.Clone(snapshot.Disks), + hasDisks: true, + gpu: slices.Clone(snapshot.GPU), + trashSize: snapshot.TrashSize, + trashApprox: snapshot.TrashApprox, + proxy: snapshot.Proxy, + batteries: slices.Clone(snapshot.Batteries), + thermal: snapshot.Thermal, + sensors: slices.Clone(snapshot.Sensors), + bluetooth: slices.Clone(snapshot.Bluetooth), + topProcesses: slices.Clone(snapshot.TopProcesses), + processAlerts: slices.Clone(snapshot.ProcessAlerts), + } + c.hasEnrichment = true +} + +func (c *Collector) applyEnrichment(snapshot *MetricsSnapshot, preserveLiveProcesses bool) { + if snapshot == nil || !c.hasEnrichment { + return + } + c.enrichment.apply(snapshot, preserveLiveProcesses) + snapshot.HealthScore, snapshot.HealthScoreMsg = calculateHealthScore( + snapshot.CPU, + snapshot.Memory, + snapshot.Disks, + snapshot.DiskIO, + snapshot.Thermal, + snapshot.Batteries, + snapshot.UptimeSeconds, + ) +} + +func (e snapshotEnrichment) apply(snapshot *MetricsSnapshot, preserveLiveProcesses bool) { + snapshot.Hardware = e.hardware + snapshot.CPU.PCoreCount = e.cpuPCores + snapshot.CPU.ECoreCount = e.cpuECores + snapshot.Memory.Cached = e.memoryCached + snapshot.Memory.Pressure = e.memoryPressure + // Disk capacity is slow-changing and the corrections (APFS purgeable, + // diskutil, Finder) are expensive, so the fast path collects raw statfs + // values and we overwrite them with the last full-refresh corrected + // snapshot. DiskIO stays live. Skip when the cache is empty so the first + // fast paint still shows raw disks instead of a blank card. + if e.hasDisks && len(e.disks) > 0 { + snapshot.Disks = slices.Clone(e.disks) + } + snapshot.GPU = slices.Clone(e.gpu) + snapshot.TrashSize = e.trashSize + snapshot.TrashApprox = e.trashApprox + snapshot.Proxy = e.proxy + snapshot.Batteries = slices.Clone(e.batteries) + snapshot.Thermal = e.thermal + snapshot.Sensors = slices.Clone(e.sensors) + snapshot.Bluetooth = slices.Clone(e.bluetooth) + if !preserveLiveProcesses { + snapshot.TopProcesses = slices.Clone(e.topProcesses) + snapshot.ProcessAlerts = slices.Clone(e.processAlerts) + } } -func runCmd(ctx context.Context, name string, args ...string) (string, error) { +var runCmd = func(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = cLocaleEnv() output, err := cmd.Output() if err != nil { return "", err @@ -358,13 +593,53 @@ func runCmd(ctx context.Context, name string, args ...string) (string, error) { return string(output), nil } -func commandExists(name string) bool { +// cLocaleEnv forces the C locale on every metric subprocess. ps and uptime +// localize their decimal separator, so under ru_RU.UTF-8 they emit "8,0" and +// every strconv.ParseFloat in the collectors fails (#1267). The shell commands +// in bin/ already export LC_ALL=C; status-go is exec'd directly and did not. +func cLocaleEnv() []string { + env := os.Environ() + filtered := make([]string, 0, len(env)+1) + for _, kv := range env { + key, _, found := strings.Cut(kv, "=") + if found && (key == "LC_ALL" || key == "LANG" || strings.HasPrefix(key, "LC_")) { + continue + } + filtered = append(filtered, kv) + } + return append(filtered, "LC_ALL=C") +} + +var commandExists = func(name string) bool { if name == "" { return false } + + commandExistsCacheMu.Lock() + if exists, ok := commandExistsCache[name]; ok { + commandExistsCacheMu.Unlock() + return exists + } + commandExistsCacheMu.Unlock() + + exists := lookPathExists(name) + + commandExistsCacheMu.Lock() + commandExistsCache[name] = exists + commandExistsCacheMu.Unlock() + return exists +} + +var ( + commandExistsCacheMu sync.Mutex + commandExistsCache = make(map[string]bool) +) + +func lookPathExists(name string) (exists bool) { defer func() { - // Treat LookPath panics as "missing". - _ = recover() + if recover() != nil { + exists = false + } }() _, err := exec.LookPath(name) return err == nil diff --git a/Resources/mole/cmd/status/metrics_battery.go b/Resources/mole/cmd/status/metrics_battery.go index c28e319..4fe3aba 100644 --- a/Resources/mole/cmd/status/metrics_battery.go +++ b/Resources/mole/cmd/status/metrics_battery.go @@ -2,21 +2,27 @@ package main import ( "context" + "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "runtime" "strconv" "strings" + "sync" "time" ) var ( // Cache for heavy system_profiler output. - lastPowerAt time.Time - cachedPower string - powerCacheTTL = 30 * time.Second + powerCacheMu sync.Mutex + lastPowerAt time.Time + lastPowerJSONAt time.Time + cachedPower string + cachedPowerJSON string + powerCacheTTL = 30 * time.Second ) func collectBatteries() (batts []BatteryStatus, err error) { @@ -30,7 +36,7 @@ func collectBatteries() (batts []BatteryStatus, err error) { // macOS: pmset for real-time percentage/status. if runtime.GOOS == "darwin" && commandExists("pmset") { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - // Health/cycles/capacity from cached system_profiler. + // Health/cycles/capacity from AppleSmartBattery and cached system_profiler. health, cycles, capacity := getCachedPowerData() if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { return batts, nil @@ -118,13 +124,40 @@ func parsePMSet(raw string, health string, cycles int, capacity int) []BatterySt return out } -// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. +// getCachedPowerData returns condition, cycles, and capacity from macOS power sources. func getCachedPowerData() (health string, cycles int, capacity int) { + health, cycles, capacity = getCachedSystemPowerData() + ioregCycles, ioregCapacity := getAppleSmartBatteryHealthData() + return mergeBatteryHealthData(health, cycles, capacity, ioregCycles, ioregCapacity) +} + +func getCachedSystemPowerData() (health string, cycles int, capacity int) { + if out := getSystemPowerJSONOutput(); out != "" { + if health, cycles, capacity, ok := parseSystemPowerJSON(out); ok { + return health, cycles, capacity + } + } + out := getSystemPowerOutput() if out == "" { return "", 0, 0 } + return parseSystemPowerText(out) +} + +func mergeBatteryHealthData(health string, cycles int, capacity int, ioregCycles int, ioregCapacity int) (string, int, int) { + if ioregCycles > 0 { + cycles = ioregCycles + } + // system_profiler publishes the same Maximum Capacity value users see in + // macOS. IORegistry ratios are estimates and only fill a missing value. + if capacity <= 0 && ioregCapacity > 0 { + capacity = ioregCapacity + } + return health, cycles, capacity +} +func parseSystemPowerText(out string) (health string, cycles int, capacity int) { for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "cycle count") { @@ -139,20 +172,159 @@ func getCachedPowerData() (health string, cycles int, capacity int) { } if strings.Contains(lower, "maximum capacity") { if _, after, found := strings.Cut(line, ":"); found { - capacityStr := strings.TrimSpace(after) - capacityStr = strings.TrimSuffix(capacityStr, "%") - capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr)) + capacity = parsePercentInt(after) } } } return health, cycles, capacity } +type systemPowerJSON struct { + SPPowerDataType []struct { + BatteryHealthInfo struct { + CycleCount int `json:"sppower_battery_cycle_count"` + Health string `json:"sppower_battery_health"` + MaximumCapacity string `json:"sppower_battery_health_maximum_capacity"` + } `json:"sppower_battery_health_info"` + } `json:"SPPowerDataType"` +} + +func parseSystemPowerJSON(raw string) (health string, cycles int, capacity int, ok bool) { + var payload systemPowerJSON + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "", 0, 0, false + } + + for _, item := range payload.SPPowerDataType { + info := item.BatteryHealthInfo + parsedCapacity := parsePercentInt(info.MaximumCapacity) + if info.Health != "" || info.CycleCount > 0 || parsedCapacity > 0 { + return info.Health, info.CycleCount, parsedCapacity, true + } + } + return "", 0, 0, false +} + +func parsePercentInt(raw string) int { + raw = strings.TrimSpace(raw) + raw = strings.TrimSuffix(raw, "%") + raw = strings.TrimSpace(raw) + value, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return value +} + +func getAppleSmartBatteryHealthData() (cycles int, capacity int) { + if runtime.GOOS != "darwin" || !commandExists("ioreg") { + return 0, 0 + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + out, err := runCmd(ctx, "ioreg", "-rn", "AppleSmartBattery") + if err != nil { + return 0, 0 + } + return parseAppleSmartBatteryHealth(out) +} + +func parseAppleSmartBatteryHealth(out string) (cycles int, capacity int) { + var design, nominal, rawMax int + for line := range strings.Lines(out) { + line = strings.TrimSpace(line) + if cycles == 0 { + if raw, found := ioRegValueForKey(line, "CycleCount"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 && value < 100000 { + cycles = value + } + } + } + if design == 0 { + if raw, found := ioRegValueForKey(line, "DesignCapacity"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + design = value + } + } + } + if nominal == 0 { + if raw, found := ioRegValueForKey(line, "NominalChargeCapacity"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + nominal = value + } + } + } + if rawMax == 0 { + if raw, found := ioRegValueForKey(line, "AppleRawMaxCapacity"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + rawMax = value + } + } + } + } + return cycles, batteryHealthPercent(design, nominal, rawMax) +} + +// batteryHealthPercent estimates health only when system_profiler does not +// publish Maximum Capacity. AppleRawMaxCapacity is preferred because +// NominalChargeCapacity includes a buffer and can read high. MaxCapacity is +// intentionally ignored: on Apple Silicon it is the denominator of the current +// charge percentage and is normally 100, not a battery-health measurement. +func batteryHealthPercent(design, nominal, rawMax int) int { + if design <= 0 { + return 0 + } + capacity := rawMax + if capacity == 0 { + capacity = nominal + } + if capacity <= 0 { + return 0 + } + pct := math.Round(float64(capacity) * 100.0 / float64(design)) + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + return int(pct) +} + +func getSystemPowerJSONOutput() string { + if runtime.GOOS != "darwin" { + return "" + } + + powerCacheMu.Lock() + defer powerCacheMu.Unlock() + + now := time.Now() + if cachedPowerJSON != "" && now.Sub(lastPowerJSONAt) < powerCacheTTL { + return cachedPowerJSON + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType", "-json") + if err == nil { + cachedPowerJSON = out + lastPowerJSONAt = now + } + return cachedPowerJSON +} + func getSystemPowerOutput() string { if runtime.GOOS != "darwin" { return "" } + powerCacheMu.Lock() + defer powerCacheMu.Unlock() + now := time.Now() if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL { return cachedPower @@ -195,89 +367,171 @@ func collectThermal() ThermalStatus { ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancelPower() if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil { - for line := range strings.Lines(out) { - line = strings.TrimSpace(line) + powerThermal := parseAppleSmartBatteryThermal(out) + thermal.BatteryTemp = powerThermal.BatteryTemp + thermal.SystemPower = powerThermal.SystemPower + thermal.AdapterPower = powerThermal.AdapterPower + thermal.BatteryPower = powerThermal.BatteryPower + } - // Battery temperature ("Temperature" = 3055). - if _, after, found := strings.Cut(line, "\"Temperature\" = "); found { - valStr := strings.TrimSpace(after) - if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 { - thermal.CPUTemp = float64(tempRaw) / 100.0 - } + // Do not synthesize CPU temperature from battery sensors or cpu_thermal_level. + // Those values are not CPU-package temperatures and produce false overheating data. + return thermal +} + +func parseAppleSmartBatteryThermal(out string) ThermalStatus { + var thermal ThermalStatus + var ( + voltageMV float64 + amperageMA float64 + ) + + for line := range strings.Lines(out) { + line = strings.TrimSpace(line) + + // AppleSmartBattery reports battery temperature in centi-degrees Celsius. + if tempRaw, found := parseIORegFloatValue(line, "Temperature"); found && tempRaw > 0 { + if tempRaw < 1000 { + // Some fixtures and non-Apple platforms report Celsius directly. + thermal.BatteryTemp = tempRaw + } else { + thermal.BatteryTemp = float64(tempRaw) / 100.0 } + } - // Adapter power (Watts) from current adapter. - if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") { - if _, after, found := strings.Cut(line, "\"Watts\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 { - thermal.AdapterPower = watts - } - } + // Adapter power (Watts) from current adapter. Ignore AppleRawAdapterDetails: + // raw entries can appear before the normalized adapter details and should + // not win the display value. + if strings.Contains(line, `"AdapterDetails"`) && !strings.Contains(line, "AppleRaw") && thermal.AdapterPower == 0 { + watts, found := parseIORegFloatValue(line, "Watts") + if found && watts > 0 { + thermal.AdapterPower = watts } + } - // System power consumption (mW -> W). - if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil { - // SystemPower should always be positive, reject invalid values - if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W - thermal.SystemPower = powerMW / 1000.0 - } - } + // System power consumption (mW -> W). + if powerMW, found := parseIORegFloatValue(line, "SystemPowerIn"); found { + setSystemPowerMW(&thermal, powerMW) + } + if thermal.SystemPower == 0 { + if powerMW, found := parseIORegFloatValue(line, "SystemPower"); found { + setSystemPowerMW(&thermal, powerMW) } + } - // Battery power (mW -> W, positive = discharging, negative = charging). - if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - - var powerMW float64 - var parsed bool - - // Strategy 1: Try parsing as a signed integer first. - // This handles standard positive values and explicit negative strings like "-12345". - if valInt, err := strconv.ParseInt(valStr, 10, 64); err == nil { - powerMW = float64(valInt) - parsed = true - } else if valUint, err := strconv.ParseUint(valStr, 10, 64); err == nil { - // Strategy 2: Try parsing as an unsigned integer (Two's Complement). - // ioreg often returns negative values as huge uint64 numbers (e.g. 2^64 - 100). - // Casting such a uint64 to int64 correctly restores the negative value. - powerMW = float64(int64(valUint)) - parsed = true - } + // Battery power (mW -> W, positive = discharging, negative = charging). + if powerMW, found := parseIORegSignedNumber(line, "BatteryPower"); found { + setBatteryPowerMW(&thermal, powerMW) + } - if parsed { - // Validate reasonable battery power range: -200W to 200W - if powerMW > -200000 && powerMW < 200000 { - thermal.BatteryPower = powerMW / 1000.0 - } - } - } + if voltage, found := parseIORegFloatValue(line, "Voltage"); found && voltage > 0 { + voltageMV = voltage + } + if voltage, found := parseIORegFloatValue(line, "AppleRawBatteryVoltage"); found && voltage > 0 { + voltageMV = voltage + } + if amperage, found := parseIORegSignedNumber(line, "InstantAmperage"); found && amperage != 0 { + amperageMA = amperage + } + if amperage, found := parseIORegSignedNumber(line, "Amperage"); found && amperage != 0 && amperageMA == 0 { + amperageMA = amperage } } - // Fallback: thermal level proxy. - if thermal.CPUTemp == 0 { - ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel2() - out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") - if err == nil { - level, _ := strconv.Atoi(strings.TrimSpace(out2)) - if level >= 0 { - thermal.CPUTemp = 45 + float64(level)*0.5 - } + if thermal.BatteryPower == 0 && voltageMV > 0 && amperageMA != 0 { + // AppleSmartBattery amperage is signed mA. Negative current means the + // battery is discharging, so keep BatteryPower positive for discharge. + batteryPowerW := -(voltageMV * amperageMA) / 1000000.0 + if batteryPowerW > -200 && batteryPowerW < 200 { + thermal.BatteryPower = batteryPowerW } } - return thermal } + +func setSystemPowerMW(thermal *ThermalStatus, powerMW float64) { + // SystemPower should always be positive; reject invalid values. + if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W + thermal.SystemPower = powerMW / 1000.0 + } +} + +func setBatteryPowerMW(thermal *ThermalStatus, powerMW float64) { + // Validate reasonable battery power range: -200W to 200W. + if powerMW > -200000 && powerMW < 200000 { + thermal.BatteryPower = powerMW / 1000.0 + } +} + +func parseIORegFloatValue(line string, key string) (float64, bool) { + raw, found := ioRegValueForKey(line, key) + if !found { + return 0, false + } + val, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, false + } + return val, true +} + +func parseIORegSignedNumber(line string, key string) (float64, bool) { + raw, found := ioRegValueForKey(line, key) + if !found { + return 0, false + } + val, ok := parseIORegSignedInteger(raw) + if !ok { + return 0, false + } + return float64(val), true +} + +func parseIORegSignedInteger(raw string) (int64, bool) { + if valInt, err := strconv.ParseInt(raw, 10, 64); err == nil { + return valInt, true + } + valUint, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, false + } + if valUint <= math.MaxInt64 { + return int64(valUint), true + } + // ioreg sometimes prints negative int64 values as uint64 two's complement. + negMag := ^valUint + 1 + if negMag > math.MaxInt64 { + return 0, false + } + return -int64(negMag), true +} + +func ioRegValueForKey(line string, key string) (string, bool) { + marker := `"` + key + `"` + _, rest, found := strings.Cut(line, marker) + if !found { + return "", false + } + rest = strings.TrimLeft(rest, " \t") + if !strings.HasPrefix(rest, "=") { + return "", false + } + rest = strings.TrimLeft(rest[1:], " \t") + if rest == "" || strings.HasPrefix(rest, ",") { + return "", false + } + end := len(rest) +scan: + for i, r := range rest { + switch r { + case ',', '}', ')', ' ', '\t', '\n', '\r': + end = i + break scan + } + } + value := strings.Trim(rest[:end], `"`) + if value == "" { + return "", false + } + return value, true +} diff --git a/Resources/mole/cmd/status/metrics_battery_test.go b/Resources/mole/cmd/status/metrics_battery_test.go new file mode 100644 index 0000000..4329d61 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_battery_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "math" + "testing" +) + +func TestParseSystemPowerJSON(t *testing.T) { + raw := `{ + "SPPowerDataType" : [ + { + "sppower_battery_health_info" : { + "sppower_battery_cycle_count" : 4, + "sppower_battery_health" : "Good", + "sppower_battery_health_maximum_capacity" : "100\u00a0%" + } + } + ] +}` + + health, cycles, capacity, ok := parseSystemPowerJSON(raw) + + if !ok { + t.Fatal("expected battery health JSON to parse") + } + if health != "Good" { + t.Fatalf("health = %q, want Good", health) + } + if cycles != 4 { + t.Fatalf("cycles = %d, want 4", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want 100", capacity) + } +} + +func TestParseSystemPowerJSONRejectsInvalidPayload(t *testing.T) { + _, _, _, ok := parseSystemPowerJSON(`{"SPPowerDataType":[{}]}`) + if ok { + t.Fatal("expected empty battery health JSON to be ignored") + } +} + +func TestParseSystemPowerText(t *testing.T) { + nonBreakingSpace := string(rune(0x00a0)) + raw := " Battery Information:\n\n" + + " Health Information:\n" + + " Cycle Count: 12\n" + + " Condition: Normal\n" + + " Maximum Capacity: 97" + nonBreakingSpace + "%\n" + + health, cycles, capacity := parseSystemPowerText(raw) + + if health != "Normal" { + t.Fatalf("health = %q, want Normal", health) + } + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 97 { + t.Fatalf("capacity = %d, want 97", capacity) + } +} + +func TestMergeBatteryHealthDataPrefersPublishedCapacity(t *testing.T) { + health, cycles, capacity := mergeBatteryHealthData("Good", 12, 100, 13, 96) + + if health != "Good" { + t.Fatalf("health = %q, want Good", health) + } + if cycles != 13 { + t.Fatalf("cycles = %d, want 13", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want published 100", capacity) + } +} + +func TestMergeBatteryHealthDataFallsBackToAppleSmartBatteryCapacity(t *testing.T) { + _, cycles, capacity := mergeBatteryHealthData("Good", 12, 0, 13, 96) + + if cycles != 13 { + t.Fatalf("cycles = %d, want 13", cycles) + } + if capacity != 96 { + t.Fatalf("capacity = %d, want fallback 96", capacity) + } +} + +func TestParseAppleSmartBatteryHealthNeverTreatsMaxCapacityAsHealth(t *testing.T) { + out := ` + | | "BatteryData" = {"MaxCapacity"=100,"DesignCapacity"=6075,"BatteryHealthMetric"=0} + | | "NominalChargeCapacity" = 6008 + | | "MaxCapacity" = 100 + | | "DesignCapacity" = 6075 + | | "AppleRawMaxCapacity" = 5858 + | | "CycleCount" = 48 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 48 { + t.Fatalf("cycles = %d, want 48", cycles) + } + if capacity != 96 { + t.Fatalf("capacity = %d, want raw fallback 96", capacity) + } +} + +func TestParseAppleSmartBatteryHealthFallsBackToNominalWhenRawUnavailable(t *testing.T) { + out := ` + | | "MaxCapacity" = 7745 + | | "DesignCapacity" = 8579 + | | "NominalChargeCapacity" = 7989 + | | "CycleCount" = 12 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 93 { + t.Fatalf("capacity = %d, want 93", capacity) + } +} + +func TestParseAppleSmartBatteryHealthPrefersRawMaxCapacity(t *testing.T) { + out := ` + | | "DesignCapacity" = 10000 + | | "AppleRawMaxCapacity" = 7800 + | | "NominalChargeCapacity" = 8300 + | | "CycleCount" = 250 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 250 { + t.Fatalf("cycles = %d, want 250", cycles) + } + if capacity != 78 { + t.Fatalf("capacity = %d, want 78", capacity) + } +} + +func TestParseAppleSmartBatteryHealthFallsBackToRawMaxCapacity(t *testing.T) { + out := ` + | | "DesignCapacity" = 10000 + | | "AppleRawMaxCapacity" = 7800 + | | "CycleCount" = 12 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 78 { + t.Fatalf("capacity = %d, want 78", capacity) + } +} + +func TestBatteryHealthPercentRoundsAndClamps(t *testing.T) { + if got := batteryHealthPercent(10000, 8249, 0); got != 82 { + t.Errorf("8249/10000 rounded = %d, want 82", got) + } + if got := batteryHealthPercent(10000, 8250, 0); got != 83 { + t.Errorf("8250/10000 rounded = %d, want 83", got) + } + if got := batteryHealthPercent(10000, 12000, 0); got != 100 { + t.Errorf("12000/10000 clamped = %d, want 100", got) + } + if got := batteryHealthPercent(0, 8000, 0); got != 0 { + t.Errorf("zero design = %d, want 0", got) + } + if got := batteryHealthPercent(10000, 0, 0); got != 0 { + t.Errorf("no capacity = %d, want 0", got) + } +} + +func TestParseAppleSmartBatteryThermalKeepsBatteryTemperatureOutOfCPUTemp(t *testing.T) { + out := ` + | | "Temperature" = 3055 + | | "SystemPowerIn" = 19967 + | | "BatteryPower" = 13654 + | | "AdapterDetails" = {"Watts" = 96} +` + + thermal := parseAppleSmartBatteryThermal(out) + + if thermal.CPUTemp != 0 { + t.Fatalf("expected cpu temp to stay unset, got %v", thermal.CPUTemp) + } + if math.Abs(thermal.BatteryTemp-30.55) > 0.001 { + t.Fatalf("expected battery temp 30.55, got %v", thermal.BatteryTemp) + } + if math.Abs(thermal.SystemPower-19.967) > 0.001 { + t.Fatalf("expected system power 19.967W, got %v", thermal.SystemPower) + } + if thermal.AdapterPower != 96 { + t.Fatalf("expected adapter power 96W, got %v", thermal.AdapterPower) + } + if math.Abs(thermal.BatteryPower-13.654) > 0.001 { + t.Fatalf("expected battery power 13.654W, got %v", thermal.BatteryPower) + } +} + +func TestParseAppleSmartBatteryThermalParsesTwosComplementBatteryPower(t *testing.T) { + out := ` + | | "BatteryPower"=18446744073709539271 +` + + thermal := parseAppleSmartBatteryThermal(out) + + if math.Abs(thermal.BatteryPower-(-12.345)) > 0.001 { + t.Fatalf("expected battery power -12.345W, got %v", thermal.BatteryPower) + } +} + +func TestParseAppleSmartBatteryThermalDerivesBatteryWattsFromVoltageAndAmperage(t *testing.T) { + out := ` + | | "Voltage" = 12000 + | | "InstantAmperage" = -1500 +` + + thermal := parseAppleSmartBatteryThermal(out) + + if math.Abs(thermal.BatteryPower-18.0) > 0.001 { + t.Fatalf("expected derived battery power 18W, got %v", thermal.BatteryPower) + } +} + +func TestParseAppleSmartBatteryThermalIgnoresRawAdapterWatts(t *testing.T) { + out := ` + | | "AppleRawAdapterDetails" = {"Watts" = 140} + | | "AdapterDetails" = {"Watts" = 96} +` + + thermal := parseAppleSmartBatteryThermal(out) + + if thermal.AdapterPower != 96 { + t.Fatalf("expected normalized adapter power 96W, got %v", thermal.AdapterPower) + } +} diff --git a/Resources/mole/cmd/status/metrics_cpu.go b/Resources/mole/cmd/status/metrics_cpu.go index 1c89df5..f32a1a7 100644 --- a/Resources/mole/cmd/status/metrics_cpu.go +++ b/Resources/mole/cmd/status/metrics_cpu.go @@ -14,10 +14,18 @@ import ( ) const ( - cpuSampleInterval = 200 * time.Millisecond + cpuSampleInterval = 100 * time.Millisecond ) func collectCPU() (CPUStatus, error) { + return collectCPUWithOptions(true) +} + +func collectCPUFast() (CPUStatus, error) { + return collectCPUWithOptions(false) +} + +func collectCPUWithOptions(includeSlowFallbacks bool) (CPUStatus, error) { counts, countsErr := cpu.Counts(false) if countsErr != nil || counts == 0 { counts = runtime.NumCPU() @@ -31,24 +39,45 @@ func collectCPU() (CPUStatus, error) { logical = 1 } - // Two-call pattern for more reliable CPU usage. - warmUpCPU() - time.Sleep(cpuSampleInterval) - percents, err := cpu.Percent(0, true) + var percents []float64 + var err error var totalPercent float64 + sampled := false + if includeSlowFallbacks { + // Explicit two-snapshot sampling on the full refresh path. On Apple + // Silicon, host_processor_info stops accumulating idle ticks for a + // parked core, so busy/(busy+idle) over the raw deltas reports a + // mostly-sleeping E-core as 90-100% (#1237). sampleCPUPercents floors + // each core's denominator at the wall-clock window, which counts the + // missing parked time as idle; on Intel the ticks already cover the + // window and the result is unchanged. + warmUpCPU() + percents, totalPercent, err = sampleCPUPercents(cpuSampleInterval) + sampled = err == nil && len(percents) > 0 + } + if !sampled { + percents, err = cpu.Percent(0, true) + } perCoreEstimated := false - if err != nil || len(percents) == 0 { - fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) - if fallbackErr != nil { - if err != nil { - return CPUStatus{}, err + if !sampled && (err != nil || len(percents) == 0) { + if !includeSlowFallbacks { + // Fast path: skip the expensive secondary sampling and just + // estimate zeroed per-core usage. The next full refresh corrects it. + percents = make([]float64, logical) + perCoreEstimated = true + } else { + fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) + if fallbackErr != nil { + if err != nil { + return CPUStatus{}, err + } + return CPUStatus{}, fallbackErr } - return CPUStatus{}, fallbackErr + totalPercent = fallbackUsage + percents = fallbackPerCore + perCoreEstimated = true } - totalPercent = fallbackUsage - percents = fallbackPerCore - perCoreEstimated = true - } else { + } else if !sampled { for _, v := range percents { totalPercent += v } @@ -60,14 +89,17 @@ func collectCPU() (CPUStatus, error) { if loadStats != nil { loadAvg = *loadStats } - if loadErr != nil || isZeroLoad(loadAvg) { + if includeSlowFallbacks && (loadErr != nil || isZeroLoad(loadAvg)) { if fallback, err := fallbackLoadAvgFromUptime(); err == nil { loadAvg = fallback } } // P/E core counts for Apple Silicon. - pCores, eCores := getCoreTopology() + var pCores, eCores int + if includeSlowFallbacks { + pCores, eCores = getCoreTopology() + } return CPUStatus{ Usage: totalPercent, @@ -259,3 +291,75 @@ func fallbackCPUUtilization(logical int) (float64, []float64, error) { func warmUpCPU() { cpu.Percent(0, true) //nolint:errcheck } + +// sampleCPUPercents measures per-core and total CPU usage over one wall-clock +// window using two cpu.Times snapshots. +func sampleCPUPercents(interval time.Duration) ([]float64, float64, error) { + before, err := cpu.Times(true) + if err != nil { + return nil, 0, err + } + start := time.Now() + time.Sleep(interval) + after, err := cpu.Times(true) + if err != nil { + return nil, 0, err + } + return perCoreUsageFromTimes(before, after, time.Since(start).Seconds()) +} + +// perCoreUsageFromTimes converts two per-core cpu.Times snapshots into per-core +// percentages and a tick-weighted total. +// +// Each core's denominator is floored at the elapsed wall-clock time: a parked +// Apple Silicon core stops accumulating idle ticks, so its raw delta covers +// only the sliver of the window it was awake for and busy/(busy+idle) reads +// ~100% while the core mostly slept (#1237). Treating the missing ticks as +// idle time yields the fraction of the window the core actually worked. The +// total is busy-over-window across cores, not the mean of per-core +// percentages, so a core that barely ran cannot drag the total upward. +func perCoreUsageFromTimes(before, after []cpu.TimesStat, elapsed float64) ([]float64, float64, error) { + if len(before) == 0 || len(before) != len(after) { + return nil, 0, errors.New("mismatched cpu times snapshots") + } + if elapsed <= 0 { + return nil, 0, errors.New("non-positive sampling window") + } + + percents := make([]float64, len(before)) + var busySum, windowSum float64 + for i := range before { + busy := cpuBusyTime(after[i]) - cpuBusyTime(before[i]) + if busy < 0 { + busy = 0 + } + total := busy + (after[i].Idle - before[i].Idle) + window := total + if window < elapsed { + window = elapsed + } + usage := busy / window * 100 + if usage < 0 { + usage = 0 + } else if usage > 100 { + usage = 100 + } + percents[i] = usage + busySum += busy + windowSum += window + } + if windowSum <= 0 { + return nil, 0, errors.New("empty cpu sampling window") + } + total := busySum / windowSum * 100 + if total < 0 { + total = 0 + } else if total > 100 { + total = 100 + } + return percents, total, nil +} + +func cpuBusyTime(t cpu.TimesStat) float64 { + return t.User + t.System + t.Nice + t.Irq + t.Softirq + t.Steal +} diff --git a/Resources/mole/cmd/status/metrics_cpu_test.go b/Resources/mole/cmd/status/metrics_cpu_test.go new file mode 100644 index 0000000..b8d5a3a --- /dev/null +++ b/Resources/mole/cmd/status/metrics_cpu_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "math" + "testing" + + "github.com/shirou/gopsutil/v4/cpu" +) + +func almostEqual(a, b float64) bool { + return math.Abs(a-b) < 0.01 +} + +// A parked Apple Silicon core stops accumulating idle ticks, so its raw delta +// only covers the sliver of the window it was awake for. The old +// busy/(busy+idle) math read such a core as ~100% busy (#1237); flooring the +// denominator at the wall-clock window must report the real fraction. +func TestPerCoreUsageParkedCoreIsNotInflated(t *testing.T) { + before := []cpu.TimesStat{{CPU: "cpu0", User: 10, System: 5, Idle: 100}} + // Awake for only 0.02s of a 0.1s window, all of it busy; no idle ticks. + after := []cpu.TimesStat{{CPU: "cpu0", User: 10.01, System: 5.01, Idle: 100}} + + percents, total, err := perCoreUsageFromTimes(before, after, 0.1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !almostEqual(percents[0], 20) { + t.Fatalf("parked core should read 20%% of the window, got %.2f", percents[0]) + } + if !almostEqual(total, 20) { + t.Fatalf("total should read 20%%, got %.2f", total) + } +} + +// When the tick deltas already cover the window (Intel, awake cores), the +// wall-clock floor must not change the classic busy/(busy+idle) result. +func TestPerCoreUsageFullyCoveredWindowUnchanged(t *testing.T) { + before := []cpu.TimesStat{{CPU: "cpu0", User: 1, System: 1, Idle: 10}} + after := []cpu.TimesStat{{CPU: "cpu0", User: 1.03, System: 1.02, Idle: 10.05}} + + percents, total, err := perCoreUsageFromTimes(before, after, 0.1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !almostEqual(percents[0], 50) { + t.Fatalf("expected 50%%, got %.2f", percents[0]) + } + if !almostEqual(total, 50) { + t.Fatalf("expected 50%% total, got %.2f", total) + } +} + +// The total must be busy-over-window across cores, not the mean of the raw +// per-core percentages: a mostly-parked core contributes its true window, so +// it cannot drag the machine total toward 100%. +func TestPerCoreUsageTotalIsWindowWeighted(t *testing.T) { + before := []cpu.TimesStat{ + {CPU: "cpu0", User: 10, Idle: 100}, + {CPU: "cpu1", User: 20, Idle: 200}, + } + after := []cpu.TimesStat{ + // Parked: 0.01s busy, no idle ticks. Raw math would say 100%. + {CPU: "cpu0", User: 10.01, Idle: 100}, + // Awake: 0.09s busy + 0.01s idle over the full window. + {CPU: "cpu1", User: 20.09, Idle: 200.01}, + } + + percents, total, err := perCoreUsageFromTimes(before, after, 0.1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !almostEqual(percents[0], 10) { + t.Fatalf("parked core expected 10%%, got %.2f", percents[0]) + } + if !almostEqual(percents[1], 90) { + t.Fatalf("busy core expected 90%%, got %.2f", percents[1]) + } + // (0.01 + 0.09) busy over (0.1 + 0.1) window = 50%. + if !almostEqual(total, 50) { + t.Fatalf("window-weighted total expected 50%%, got %.2f", total) + } +} + +func TestPerCoreUsageClampsAndErrors(t *testing.T) { + // Busy jitter slightly above the window clamps to 100. + before := []cpu.TimesStat{{CPU: "cpu0", User: 1, Idle: 1}} + after := []cpu.TimesStat{{CPU: "cpu0", User: 1.12, Idle: 1}} + percents, total, err := perCoreUsageFromTimes(before, after, 0.1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if percents[0] != 100 || total != 100 { + t.Fatalf("expected clamp to 100, got %.2f / %.2f", percents[0], total) + } + + if _, _, err := perCoreUsageFromTimes(before, nil, 0.1); err == nil { + t.Fatal("mismatched snapshots must error") + } + if _, _, err := perCoreUsageFromTimes(before, after, 0); err == nil { + t.Fatal("non-positive window must error") + } + // A counter reset (negative busy delta) degrades to 0, not garbage. + reset := []cpu.TimesStat{{CPU: "cpu0", User: 0.5, Idle: 1.1}} + percents, _, err = perCoreUsageFromTimes(before, reset, 0.1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if percents[0] != 0 { + t.Fatalf("negative busy delta should clamp to 0, got %.2f", percents[0]) + } +} diff --git a/Resources/mole/cmd/status/metrics_disk.go b/Resources/mole/cmd/status/metrics_disk.go index da14f4d..b88e179 100644 --- a/Resources/mole/cmd/status/metrics_disk.go +++ b/Resources/mole/cmd/status/metrics_disk.go @@ -4,9 +4,14 @@ import ( "context" "errors" "fmt" + "io/fs" + "os" + "path/filepath" "runtime" "sort" + "strconv" "strings" + "sync" "time" "github.com/shirou/gopsutil/v4/disk" @@ -22,8 +27,45 @@ var skipDiskMounts = map[string]bool{ "/dev": true, } +var skipDiskFSTypes = map[string]bool{ + "afpfs": true, + "autofs": true, + "cifs": true, + "devfs": true, + "fuse": true, + "fuseblk": true, + "fusefs": true, + "macfuse": true, + "nfs": true, + "osxfuse": true, + "procfs": true, + "smbfs": true, + "tmpfs": true, + "webdav": true, +} + +var ( + diskPartitionsFunc = disk.Partitions + diskUsageFunc = disk.Usage +) + +const ( + smartStatusVerified = "verified" + smartStatusFailing = "failing" + smartStatusUnsupported = "unsupported" + smartStatusUnknown = "unknown" +) + func collectDisks() ([]DiskStatus, error) { - partitions, err := disk.Partitions(false) + return collectDisksWithCorrections(true) +} + +func collectDisksFast() ([]DiskStatus, error) { + return collectDisksWithCorrections(false) +} + +func collectDisksWithCorrections(useCorrections bool) ([]DiskStatus, error) { + partitions, err := diskPartitionsFunc(false) if err != nil { return nil, err } @@ -34,17 +76,7 @@ func collectDisks() ([]DiskStatus, error) { seenVolume = make(map[string]bool) ) for _, part := range partitions { - if strings.HasPrefix(part.Device, "/dev/loop") { - continue - } - if skipDiskMounts[part.Mountpoint] { - continue - } - if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { - continue - } - // Skip /private mounts. - if strings.HasPrefix(part.Mountpoint, "/private/") { + if shouldSkipDiskPartition(part) { continue } baseDevice := baseDeviceName(part.Device) @@ -54,32 +86,48 @@ func collectDisks() ([]DiskStatus, error) { if seenDevice[baseDevice] { continue } - usage, err := disk.Usage(part.Mountpoint) + usage, err := diskUsageFunc(part.Mountpoint) if err != nil || usage.Total == 0 { continue } + total := usage.Total + if useCorrections && runtime.GOOS == "darwin" { + total = correctDiskTotalBytes(part.Mountpoint, total) + } // Skip <1GB volumes. - if usage.Total < 1<<30 { + if total < 1<<30 { continue } // Use size-based dedupe key for shared pools. - volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) + volKey := fmt.Sprintf("%s:%d", part.Fstype, total) if seenVolume[volKey] { continue } + used := usage.Used + usedPercent := usage.UsedPercent + purgeable := uint64(0) + if useCorrections && runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" { + used, usedPercent, purgeable = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used, usage.Free) + } + disks = append(disks, DiskStatus{ Mount: part.Mountpoint, Device: part.Device, - Used: usage.Used, - Total: usage.Total, - UsedPercent: usage.UsedPercent, + Used: used, + Total: total, + UsedPercent: usedPercent, Fstype: part.Fstype, + External: !useCorrections && strings.HasPrefix(part.Mountpoint, "/Volumes/"), + SmartStatus: smartStatusUnknown, + Purgeable: purgeable, }) seenDevice[baseDevice] = true seenVolume[volKey] = true } - annotateDiskTypes(disks) + if useCorrections { + annotateDiskMetadata(disks) + } sort.Slice(disks, func(i, j int) bool { // First, prefer internal disks over external @@ -97,14 +145,62 @@ func collectDisks() ([]DiskStatus, error) { return disks, nil } +func shouldSkipDiskPartition(part disk.PartitionStat) bool { + if strings.HasPrefix(part.Device, "/dev/loop") { + return true + } + if skipDiskMounts[part.Mountpoint] { + return true + } + if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { + return true + } + if strings.HasPrefix(part.Mountpoint, "/private/") { + return true + } + + fstype := strings.ToLower(part.Fstype) + if skipDiskFSTypes[fstype] || strings.Contains(fstype, "fuse") { + return true + } + + // On macOS, local disks should come from /dev. This filters sshfs/macFUSE-style + // mounts that can mirror the root volume and show up as duplicate internal disks. + if runtime.GOOS == "darwin" && part.Device != "" && !strings.HasPrefix(part.Device, "/dev/") { + return true + } + + return false +} + +type diskMetadata struct { + External bool + SmartStatus string +} + var ( - // External disk cache. - lastDiskCacheAt time.Time - diskTypeCache = make(map[string]bool) - diskCacheTTL = 2 * time.Minute + // Slow disk metadata cache. + lastDiskCacheAt time.Time + diskMetadataCache = make(map[string]diskMetadata) + diskCacheTTL = 2 * time.Minute + + // Finder startup disk usage cache (macOS APFS purgeable-aware). + finderDiskCacheMu sync.Mutex + finderDiskCachedAt time.Time + finderDiskFree uint64 + finderDiskTotal uint64 + + // Trash size cache. ~/.Trash can contain deep trees, and status refreshes + // every second; a short cache prevents repeated WalkDir work without + // hiding changes for long. + trashSizeCacheMu sync.Mutex + trashSizeCachedAt time.Time + trashSizeCachedValue uint64 + trashSizeCachedApprox bool + trashSizeCacheTTL = 5 * time.Second ) -func annotateDiskTypes(disks []DiskStatus) { +func annotateDiskMetadata(disks []DiskStatus) { if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") { return } @@ -112,7 +208,7 @@ func annotateDiskTypes(disks []DiskStatus) { now := time.Now() // Clear stale cache. if now.Sub(lastDiskCacheAt) > diskCacheTTL { - diskTypeCache = make(map[string]bool) + diskMetadataCache = make(map[string]diskMetadata) lastDiskCacheAt = now } @@ -122,17 +218,19 @@ func annotateDiskTypes(disks []DiskStatus) { base = disks[i].Device } - if val, ok := diskTypeCache[base]; ok { - disks[i].External = val + if metadata, ok := diskMetadataCache[base]; ok { + disks[i].External = metadata.External + disks[i].SmartStatus = metadata.SmartStatus continue } - external, err := isExternalDisk(base) + metadata, err := readDiskMetadata(base) if err != nil { - external = strings.HasPrefix(disks[i].Mount, "/Volumes/") + metadata.External = strings.HasPrefix(disks[i].Mount, "/Volumes/") } - disks[i].External = external - diskTypeCache[base] = external + disks[i].External = metadata.External + disks[i].SmartStatus = metadata.SmartStatus + diskMetadataCache[base] = metadata } } @@ -149,34 +247,229 @@ func baseDeviceName(device string) string { return device } -func isExternalDisk(device string) (bool, error) { +func readDiskMetadata(device string) (diskMetadata, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() out, err := runCmd(ctx, "diskutil", "info", device) if err != nil { - return false, err + return diskMetadata{SmartStatus: smartStatusUnknown}, err } + return parseDiskMetadata(out) +} + +func parseDiskMetadata(out string) (diskMetadata, error) { + metadata := diskMetadata{SmartStatus: smartStatusUnknown} var ( - found bool - external bool + externalFound bool + locationFound bool + locationValue bool ) for line := range strings.Lines(out) { trim := strings.TrimSpace(line) if strings.HasPrefix(trim, "Internal:") { - found = true - external = strings.Contains(trim, "No") - break + externalFound = true + metadata.External = strings.Contains(trim, "No") } - if strings.HasPrefix(trim, "Device Location:") { - found = true - external = strings.Contains(trim, "External") + if !externalFound && strings.HasPrefix(trim, "Device Location:") { + locationFound = true + locationValue = strings.Contains(trim, "External") } + if smartValue, ok := strings.CutPrefix(trim, "SMART Status:"); ok { + value := strings.ToLower(strings.TrimSpace(smartValue)) + switch value { + case "verified": + metadata.SmartStatus = smartStatusVerified + case "failing", "failed": + metadata.SmartStatus = smartStatusFailing + case "not supported", "unsupported": + metadata.SmartStatus = smartStatusUnsupported + default: + metadata.SmartStatus = smartStatusUnknown + } + } + } + if !externalFound && locationFound { + externalFound = true + metadata.External = locationValue } - if !found { - return false, errors.New("diskutil info missing Internal field") + if !externalFound { + return metadata, errors.New("diskutil info missing Internal field") } - return external, nil + return metadata, nil +} + +// correctDiskTotalBytes uses diskutil's plist output when macOS reports a +// meaningfully different disk size than gopsutil. This fixes external APFS +// volumes that can show doubled capacities through statfs/gopsutil. +func correctDiskTotalBytes(mountpoint string, rawTotal uint64) uint64 { + if rawTotal == 0 || !commandExists("diskutil") { + return rawTotal + } + + diskutilTotal, err := getDiskutilTotalBytes(mountpoint) + if err != nil || diskutilTotal == 0 { + return rawTotal + } + + if uint64AbsDiff(rawTotal, diskutilTotal) > 1<<30 { + return diskutilTotal + } + + return rawTotal +} + +func getDiskutilTotalBytes(mountpoint string) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint) + if err != nil { + return 0, err + } + + // Prefer TotalSize, but keep older/plainer keys as fallbacks. + return extractPlistUint(out, "TotalSize", "DiskSize", "Size") +} + +// correctAPFSDiskUsage returns Finder-accurate used bytes, percent, and the +// purgeable bytes Finder counts as free, for an APFS volume. It accounts for +// purgeable caches and APFS local snapshots that statfs incorrectly counts as +// "used". Uses a three-tier fallback: +// 1. Finder via osascript (startup disk only), exact match with macOS Finder +// 2. diskutil APFSContainerFree, corrects APFS snapshot space +// 3. Raw gopsutil values, original statfs-based calculation +func correctAPFSDiskUsage(mountpoint string, total, rawUsed, rawFree uint64) (used uint64, usedPercent float64, purgeable uint64) { + // Tier 1: Finder via osascript (startup disk at "/" only). + if mountpoint == "/" && commandExists("osascript") { + if finderFree, finderTotal, err := getFinderStartupDiskFreeBytes(); err == nil && + finderTotal > 0 && finderFree <= finderTotal { + used = finderTotal - finderFree + usedPercent = float64(used) / float64(finderTotal) * 100.0 + return used, usedPercent, finderPurgeableBytes(rawFree, finderFree) + } + } + + // Tier 2: diskutil APFSContainerFree (corrects APFS local snapshots). + if commandExists("diskutil") { + if containerFree, err := getAPFSContainerFreeBytes(mountpoint); err == nil && containerFree <= total { + corrected := total - containerFree + // Only apply if it meaningfully differs (>1GB) from raw to avoid noise. + if rawUsed > corrected && rawUsed-corrected > 1<<30 { + used = corrected + usedPercent = float64(used) / float64(total) * 100.0 + return used, usedPercent, 0 + } + } + } + + // Tier 3: fall back to raw gopsutil values. + return rawUsed, float64(rawUsed) / float64(total) * 100.0, 0 +} + +// finderPurgeableBytes returns the purgeable portion of Finder's free space: +// Finder counts reclaimable purgeable files as free, while statfs (df) does +// not, so the difference is the purgeable total. Both inputs must come from +// the same accounting: rawFree is the statfs free figure, never a value +// derived from the diskutil-corrected total, or the correction itself would +// be misreported as purgeable space. +func finderPurgeableBytes(rawFree, finderFree uint64) uint64 { + if finderFree <= rawFree { + return 0 + } + return finderFree - rawFree +} + +// getAPFSContainerFreeBytes returns the APFS container free space (including +// purgeable snapshot space) by parsing `diskutil info -plist`. This corrects +// for APFS local snapshots which statfs counts as used. +func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint) + if err != nil { + return 0, err + } + + return extractPlistUint(out, "APFSContainerFree") +} + +// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup +// disk free space. Finder's value includes purgeable caches and APFS snapshots, +// matching the "X GB of Y GB used" display. Results are cached for 2 minutes. +func getFinderStartupDiskFreeBytes() (free, total uint64, err error) { + finderDiskCacheMu.Lock() + defer finderDiskCacheMu.Unlock() + + if !finderDiskCachedAt.IsZero() && time.Since(finderDiskCachedAt) < diskCacheTTL { + return finderDiskFree, finderDiskTotal, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Single call returns both values as a comma-separated pair. + out, err := runCmd(ctx, "osascript", "-e", + `tell application "Finder" to return {free space of startup disk, capacity of startup disk}`) + if err != nil { + // Cache the failure timestamp so repeated calls within diskCacheTTL + // return immediately instead of each waiting the full 5s timeout. + finderDiskCachedAt = time.Now() + return 0, 0, err + } + + // Output format: "3.2489E+11, 4.9438E+11" or "324892202048, 494384795648" + parts := strings.SplitN(strings.TrimSpace(out), ",", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("unexpected osascript output: %q", out) + } + + freeF, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + totalF, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + if err1 != nil || err2 != nil || freeF <= 0 || totalF <= 0 { + return 0, 0, fmt.Errorf("failed to parse osascript output: %q", out) + } + + finderDiskFree = uint64(freeF) + finderDiskTotal = uint64(totalF) + finderDiskCachedAt = time.Now() + return finderDiskFree, finderDiskTotal, nil +} + +func extractPlistUint(plist string, keys ...string) (uint64, error) { + for _, key := range keys { + marker := "" + key + "" + _, rest, found := strings.Cut(plist, marker) + if !found { + continue + } + + _, rest, found = strings.Cut(rest, "") + if !found { + continue + } + + value, _, found := strings.Cut(rest, "") + if !found { + continue + } + + parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse %s: %v", key, err) + } + return parsed, nil + } + + return 0, fmt.Errorf("%s not found", strings.Join(keys, "/")) +} + +func uint64AbsDiff(a, b uint64) uint64 { + if a > b { + return a - b + } + return b - a } func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { @@ -202,8 +495,8 @@ func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { elapsed = 1 } - readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed - writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed + readRate := float64(counterDelta(total.ReadBytes, c.prevDiskIO.ReadBytes)) / 1024 / 1024 / elapsed + writeRate := float64(counterDelta(total.WriteBytes, c.prevDiskIO.WriteBytes)) / 1024 / 1024 / elapsed c.prevDiskIO = total c.lastDiskAt = now @@ -217,3 +510,62 @@ func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} } + +func counterDelta(current, previous uint64) uint64 { + if current < previous { + return 0 + } + return current - previous +} + +// collectTrashSize returns the total size in bytes of ~/.Trash and whether +// the result is approximate (true when the 2s timeout was reached). +func collectTrashSize() (uint64, bool) { + trashSizeCacheMu.Lock() + if !trashSizeCachedAt.IsZero() && time.Since(trashSizeCachedAt) < trashSizeCacheTTL { + value := trashSizeCachedValue + approx := trashSizeCachedApprox + trashSizeCacheMu.Unlock() + return value, approx + } + trashSizeCacheMu.Unlock() + + total, approx := scanTrashSize() + + trashSizeCacheMu.Lock() + trashSizeCachedValue = total + trashSizeCachedApprox = approx + trashSizeCachedAt = time.Now() + trashSizeCacheMu.Unlock() + + return total, approx +} + +func scanTrashSize() (uint64, bool) { + home, err := os.UserHomeDir() + if err != nil { + return 0, false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var total uint64 + trashPath := filepath.Join(home, ".Trash") + _ = filepath.WalkDir(trashPath, func(_ string, d fs.DirEntry, err error) error { + if ctx.Err() != nil { + return fs.SkipAll + } + if err != nil { + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + if !d.IsDir() { + if info, err := d.Info(); err == nil { + total += uint64(info.Size()) + } + } + return nil + }) + return total, ctx.Err() != nil +} diff --git a/Resources/mole/cmd/status/metrics_disk_test.go b/Resources/mole/cmd/status/metrics_disk_test.go new file mode 100644 index 0000000..dfad14f --- /dev/null +++ b/Resources/mole/cmd/status/metrics_disk_test.go @@ -0,0 +1,456 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "runtime" + "strings" + "testing" + "time" + + "github.com/shirou/gopsutil/v4/disk" +) + +func TestShouldSkipDiskPartition(t *testing.T) { + tests := []struct { + name string + part disk.PartitionStat + want bool + }{ + { + name: "keep local apfs root volume", + part: disk.PartitionStat{ + Device: "/dev/disk3s1s1", + Mountpoint: "/", + Fstype: "apfs", + }, + want: false, + }, + { + name: "skip macfuse mirror mount", + part: disk.PartitionStat{ + Device: "kaku-local:/", + Mountpoint: "/Users/tw93/Library/Caches/dev.kaku/sshfs/kaku-local", + Fstype: "macfuse", + }, + want: true, + }, + { + name: "skip smb share", + part: disk.PartitionStat{ + Device: "//server/share", + Mountpoint: "/Volumes/share", + Fstype: "smbfs", + }, + want: true, + }, + { + name: "skip system volume", + part: disk.PartitionStat{ + Device: "/dev/disk3s5", + Mountpoint: "/System/Volumes/Data", + Fstype: "apfs", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldSkipDiskPartition(tt.part); got != tt.want { + t.Fatalf("shouldSkipDiskPartition(%+v) = %v, want %v", tt.part, got, tt.want) + } + }) + } +} + +func TestExtractPlistUint(t *testing.T) { + t.Run("prefers first matching key", func(t *testing.T) { + raw := ` +TotalSize1099511627776 +DiskSize2199023255552 +` + + got, err := extractPlistUint(raw, "TotalSize", "DiskSize") + if err != nil { + t.Fatalf("extractPlistUint() error = %v", err) + } + if got != 1099511627776 { + t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("falls back to later keys", func(t *testing.T) { + raw := `DiskSize1099511627776` + + got, err := extractPlistUint(raw, "TotalSize", "DiskSize", "Size") + if err != nil { + t.Fatalf("extractPlistUint() error = %v", err) + } + if got != 1099511627776 { + t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("returns error for malformed integer", func(t *testing.T) { + raw := `TotalSizeoops` + + if _, err := extractPlistUint(raw, "TotalSize"); err == nil { + t.Fatalf("extractPlistUint() expected parse error") + } + }) +} + +func TestParseDiskMetadataSMARTStatuses(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "verified", raw: "Verified", want: smartStatusVerified}, + {name: "failing", raw: "Failing", want: smartStatusFailing}, + {name: "unsupported", raw: "Not Supported", want: smartStatusUnsupported}, + {name: "unknown value", raw: "Unavailable", want: smartStatusUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metadata, err := parseDiskMetadata("Internal: Yes\nSMART Status: " + tt.raw + "\n") + if err != nil { + t.Fatalf("parseDiskMetadata() error = %v", err) + } + if metadata.SmartStatus != tt.want { + t.Fatalf("parseDiskMetadata() smart status = %q, want %q", metadata.SmartStatus, tt.want) + } + if metadata.External { + t.Fatal("parseDiskMetadata() internal disk marked external") + } + }) + } +} + +func TestParseDiskMetadataUsesDeviceLocationAndUnknownForMissingSMART(t *testing.T) { + metadata, err := parseDiskMetadata("Device Location: External\n") + if err != nil { + t.Fatalf("parseDiskMetadata() error = %v", err) + } + if !metadata.External { + t.Fatal("parseDiskMetadata() external location was not detected") + } + if metadata.SmartStatus != smartStatusUnknown { + t.Fatalf("parseDiskMetadata() missing SMART = %q, want unknown", metadata.SmartStatus) + } +} + +func TestAnnotateDiskMetadataCachesDiskutilResult(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("diskutil metadata is macOS-only") + } + + origRunCmd := runCmd + origCommandExists := commandExists + origCache := diskMetadataCache + origCacheAt := lastDiskCacheAt + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + diskMetadataCache = origCache + lastDiskCacheAt = origCacheAt + }) + + diskMetadataCache = make(map[string]diskMetadata) + lastDiskCacheAt = time.Time{} + commandExists = func(name string) bool { return name == "diskutil" } + calls := 0 + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + calls++ + return "Internal: No\nSMART Status: Verified\n", nil + } + + first := []DiskStatus{{Device: "/dev/disk9s2", Mount: "/Volumes/Fast", SmartStatus: smartStatusUnknown}} + second := []DiskStatus{{Device: "/dev/disk9s3", Mount: "/Volumes/Fast", SmartStatus: smartStatusUnknown}} + annotateDiskMetadata(first) + annotateDiskMetadata(second) + + if calls != 1 { + t.Fatalf("diskutil calls = %d, want 1 cache miss", calls) + } + for _, disks := range [][]DiskStatus{first, second} { + if !disks[0].External || disks[0].SmartStatus != smartStatusVerified { + t.Fatalf("cached disk metadata = %#v", disks[0]) + } + } +} + +func TestAnnotateDiskMetadataKeepsUnknownWhenDiskutilFails(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("diskutil metadata is macOS-only") + } + + origRunCmd := runCmd + origCommandExists := commandExists + origCache := diskMetadataCache + origCacheAt := lastDiskCacheAt + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + diskMetadataCache = origCache + lastDiskCacheAt = origCacheAt + }) + + diskMetadataCache = make(map[string]diskMetadata) + lastDiskCacheAt = time.Time{} + commandExists = func(name string) bool { return name == "diskutil" } + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return "", errors.New("diskutil failed") + } + + disks := []DiskStatus{{Device: "/dev/disk8s1", Mount: "/Volumes/Backup", SmartStatus: smartStatusUnknown}} + annotateDiskMetadata(disks) + if !disks[0].External { + t.Fatal("failed diskutil query should keep the mount-based external fallback") + } + if disks[0].SmartStatus != smartStatusUnknown { + t.Fatalf("failed diskutil query smart status = %q, want unknown", disks[0].SmartStatus) + } +} + +func TestDiskStatusJSONAndNDJSONAlwaysIncludeSMARTStatus(t *testing.T) { + snapshot := MetricsSnapshot{ + Disks: []DiskStatus{{Mount: "/", SmartStatus: smartStatusUnsupported}}, + } + + oneShot, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if !strings.Contains(string(oneShot), `"smart_status":"unsupported"`) { + t.Fatalf("JSON missing smart_status: %s", oneShot) + } + + var stream bytes.Buffer + encoder := json.NewEncoder(&stream) + if err := encoder.Encode(snapshot); err != nil { + t.Fatalf("first NDJSON encode error = %v", err) + } + snapshot.Disks[0].SmartStatus = smartStatusUnknown + if err := encoder.Encode(snapshot); err != nil { + t.Fatalf("second NDJSON encode error = %v", err) + } + lines := strings.Split(strings.TrimSpace(stream.String()), "\n") + if len(lines) != 2 || !strings.Contains(lines[0], `"smart_status":"unsupported"`) || + !strings.Contains(lines[1], `"smart_status":"unknown"`) { + t.Fatalf("NDJSON smart_status lines = %q", lines) + } +} + +func TestCollectDisksFastSkipsSlowCorrections(t *testing.T) { + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origRunCmd := runCmd + origCommandExists := commandExists + t.Cleanup(func() { + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + runCmd = origRunCmd + commandExists = origCommandExists + }) + + const rawTotal = uint64(2 * 1024 * 1024 * 1024) + const rawUsed = uint64(1024 * 1024 * 1024) + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + if all { + t.Fatalf("collectDisksFast() should request physical partitions only") + } + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + if path != "/" { + t.Fatalf("unexpected disk usage path %q", path) + } + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: rawTotal, + Used: rawUsed, + UsedPercent: 50, + }, nil + } + commandExists = func(name string) bool { + t.Fatalf("collectDisksFast() should not check external command %q", name) + return false + } + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + t.Fatalf("collectDisksFast() should not run external command %q", name) + return "", errors.New("unexpected command") + } + + got, err := collectDisksFast() + if err != nil { + t.Fatalf("collectDisksFast() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("collectDisksFast() returned %d disks, want 1: %#v", len(got), got) + } + if got[0].Total != rawTotal || got[0].Used != rawUsed || got[0].UsedPercent != 50 { + t.Fatalf("collectDisksFast() should keep raw usage, got %#v", got[0]) + } + if got[0].SmartStatus != smartStatusUnknown { + t.Fatalf("collectDisksFast() smart status = %q, want unknown", got[0].SmartStatus) + } +} + +func TestCorrectDiskTotalBytes(t *testing.T) { + origRunCmd := runCmd + origCommandExists := commandExists + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + }) + + commandExists = func(name string) bool { + return name == "diskutil" + } + + t.Run("uses diskutil total when meaningfully different", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + if name != "diskutil" { + return "", errors.New("unexpected command") + } + return `TotalSize1099511627776`, nil + } + + got := correctDiskTotalBytes("/Volumes/Backup", 2199023255552) + if got != 1099511627776 { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("keeps raw total for small differences", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return `TotalSize1000500000000`, nil + } + + const rawTotal = 1000000000000 + got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal) + if got != rawTotal { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal)) + } + }) + + t.Run("keeps raw total when diskutil fails", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return "", errors.New("diskutil failed") + } + + const rawTotal = 1099511627776 + got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal) + if got != rawTotal { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal)) + } + }) +} + +func TestCounterDeltaClampsCounterReset(t *testing.T) { + if got := counterDelta(150, 100); got != 50 { + t.Fatalf("counterDelta increasing = %d, want 50", got) + } + if got := counterDelta(10, 100); got != 0 { + t.Fatalf("counterDelta reset = %d, want 0", got) + } +} + +func TestCorrectAPFSDiskUsageReportsFinderPurgeable(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("APFS Finder corrections are macOS-only") + } + + origRunCmd := runCmd + origCommandExists := commandExists + origCachedAt := finderDiskCachedAt + origFree := finderDiskFree + origTotal := finderDiskTotal + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + finderDiskCachedAt = origCachedAt + finderDiskFree = origFree + finderDiskTotal = origTotal + }) + finderDiskCachedAt = time.Time{} + + commandExists = func(name string) bool { return name == "osascript" } + // Finder: 663.7 GB free of 1.8897 TB, i.e. statfs free (522.7 GB) plus + // 141 GB purgeable, matching the #1357 scenario. + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return "663700000000, 1889700000000", nil + } + + total := uint64(1889700000000) + rawFree := uint64(522700000000) + rawUsed := total - rawFree + used, usedPercent, purgeable := correctAPFSDiskUsage("/", total, rawUsed, rawFree) + + if want := total - uint64(663700000000); used != want { + t.Fatalf("used = %d, want %d", used, want) + } + if usedPercent < 64.87 || usedPercent > 64.89 { + t.Fatalf("usedPercent = %f, want ~64.88", usedPercent) + } + if purgeable != uint64(141000000000) { + t.Fatalf("purgeable = %d, want %d", purgeable, uint64(141000000000)) + } +} + +func TestCorrectAPFSDiskUsageClampsPurgeableToZero(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("APFS Finder corrections are macOS-only") + } + + origRunCmd := runCmd + origCommandExists := commandExists + origCachedAt := finderDiskCachedAt + origFree := finderDiskFree + origTotal := finderDiskTotal + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + finderDiskCachedAt = origCachedAt + finderDiskFree = origFree + finderDiskTotal = origTotal + }) + finderDiskCachedAt = time.Time{} + + commandExists = func(name string) bool { return name == "osascript" } + // Finder free below the statfs free means there is no purgeable space. + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return "500000000000, 1889700000000", nil + } + + total := uint64(1889700000000) + rawFree := uint64(522700000000) + rawUsed := total - rawFree + _, _, purgeable := correctAPFSDiskUsage("/", total, rawUsed, rawFree) + if purgeable != 0 { + t.Fatalf("purgeable = %d, want 0", purgeable) + } +} + +func TestFinderPurgeableBytesIgnoresCorrectedTotal(t *testing.T) { + // The regression this pins: purgeable was once derived from the + // diskutil-corrected total minus statfs used, so the correction itself + // showed up as purgeable space. The statfs free figure is the only + // valid baseline. + rawFree := uint64(500 << 30) + finderFree := uint64(520 << 30) + if got, want := finderPurgeableBytes(rawFree, finderFree), uint64(20<<30); got != want { + t.Fatalf("purgeable = %d, want %d", got, want) + } + if got := finderPurgeableBytes(finderFree, rawFree); got != 0 { + t.Fatalf("purgeable when finder below statfs = %d, want 0", got) + } +} diff --git a/Resources/mole/cmd/status/metrics_fast_test.go b/Resources/mole/cmd/status/metrics_fast_test.go new file mode 100644 index 0000000..f4810ec --- /dev/null +++ b/Resources/mole/cmd/status/metrics_fast_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/shirou/gopsutil/v4/disk" + gopsutilnet "github.com/shirou/gopsutil/v4/net" +) + +func TestCollectFastAvoidsExternalCommands(t *testing.T) { + origRunCmd := runCmd + origCommandExists := commandExists + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origIOCounters := ioCountersFunc + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + ioCountersFunc = origIOCounters + }) + + var externalCalls atomic.Int32 + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + externalCalls.Add(1) + return "", errors.New("unexpected command") + } + commandExists = func(name string) bool { + externalCalls.Add(1) + return false + } + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: 2 * 1024 * 1024 * 1024, + Used: 1024 * 1024 * 1024, + UsedPercent: 50, + }, nil + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 1024, BytesSent: 2048}, + }, nil + } + + collector := NewCollector(ProcessWatchOptions{}) + if _, err := collector.CollectFast(); err != nil { + t.Fatalf("CollectFast() error = %v", err) + } + if externalCalls.Load() != 0 { + t.Fatalf("CollectFast() made %d external command calls", externalCalls.Load()) + } +} + +func TestCollectProcessesKeepsLiveProcessesWithCachedEnrichment(t *testing.T) { + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origIOCounters := ioCountersFunc + origCollectProcesses := collectProcessesFunc + t.Cleanup(func() { + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + ioCountersFunc = origIOCounters + collectProcessesFunc = origCollectProcesses + }) + + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: 2 * 1024 * 1024 * 1024, + Used: 1024 * 1024 * 1024, + UsedPercent: 50, + }, nil + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{{Name: "en0", BytesRecv: 1024, BytesSent: 2048}}, nil + } + collectProcessesFunc = func() ([]ProcessInfo, error) { + return []ProcessInfo{ + {PID: 200, PPID: 1, Name: "new-hot-process", Command: "/usr/bin/new-hot-process", CPU: 240, Memory: 1.5}, + }, nil + } + + collector := NewCollector(ProcessWatchOptions{Enabled: true, CPUThreshold: 50}) + collector.cacheEnrichment(MetricsSnapshot{ + Hardware: HardwareInfo{Model: "MacBook Pro"}, + TrashSize: 99, + TopProcesses: []ProcessInfo{ + {PID: 100, Name: "old-process", CPU: 10}, + }, + ProcessAlerts: []ProcessAlert{ + {PID: 100, Name: "old-process", Status: "active"}, + }, + }) + + snapshot, err := collector.CollectProcesses() + if err != nil { + t.Fatalf("CollectProcesses() error = %v", err) + } + + if snapshot.Hardware.Model != "MacBook Pro" || snapshot.TrashSize != 99 { + t.Fatalf("expected cached enrichment to be preserved, got hardware=%#v trash=%d", snapshot.Hardware, snapshot.TrashSize) + } + if len(snapshot.TopProcesses) != 1 || snapshot.TopProcesses[0].Name != "new-hot-process" { + t.Fatalf("expected live top process data, got %#v", snapshot.TopProcesses) + } + if len(snapshot.ProcessAlerts) != 1 || snapshot.ProcessAlerts[0].Name != "new-hot-process" { + t.Fatalf("expected live process alert data, got %#v", snapshot.ProcessAlerts) + } +} diff --git a/Resources/mole/cmd/status/metrics_gpu.go b/Resources/mole/cmd/status/metrics_gpu.go index bb60235..e83879c 100644 --- a/Resources/mole/cmd/status/metrics_gpu.go +++ b/Resources/mole/cmd/status/metrics_gpu.go @@ -14,6 +14,7 @@ import ( const ( systemProfilerTimeout = 4 * time.Second macGPUInfoTTL = 10 * time.Minute + macGPUUsageTTL = 5 * time.Second powermetricsTimeout = 2 * time.Second ) @@ -35,7 +36,7 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { // Real-time GPU usage. if len(c.cachedGPU) > 0 { - usage := getMacGPUUsage() + usage := c.getMacGPUUsage(now) result := make([]GPUStatus, len(c.cachedGPU)) copy(result, c.cachedGPU) // Apply usage to first GPU (Apple Silicon). @@ -151,6 +152,17 @@ func readMacGPUInfo() ([]GPUStatus, error) { return gpus, nil } +func (c *Collector) getMacGPUUsage(now time.Time) float64 { + if !c.lastGPUUsageAt.IsZero() && now.Sub(c.lastGPUUsageAt) < macGPUUsageTTL { + return c.cachedGPUUsage + } + + usage := getMacGPUUsage() + c.cachedGPUUsage = usage + c.lastGPUUsageAt = now + return usage +} + // getMacGPUUsage reads GPU active residency from powermetrics. func getMacGPUUsage() float64 { ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout) diff --git a/Resources/mole/cmd/status/metrics_health.go b/Resources/mole/cmd/status/metrics_health.go index 4bfd090..82b8a81 100644 --- a/Resources/mole/cmd/status/metrics_health.go +++ b/Resources/mole/cmd/status/metrics_health.go @@ -15,29 +15,46 @@ const ( healthIOWeight = 10.0 // CPU. - cpuNormalThreshold = 30.0 - cpuHighThreshold = 70.0 + cpuNormalThreshold = 50.0 + cpuHighThreshold = 85.0 // Memory. - memNormalThreshold = 50.0 - memHighThreshold = 80.0 + memNormalThreshold = 70.0 + memHighThreshold = 88.0 memPressureWarnPenalty = 5.0 memPressureCritPenalty = 15.0 // Disk. - diskWarnThreshold = 70.0 - diskCritThreshold = 90.0 + diskWarnThreshold = 80.0 + diskCritThreshold = 93.0 // Thermal. - thermalNormalThreshold = 60.0 + thermalNormalThreshold = 65.0 thermalHighThreshold = 85.0 // Disk IO (MB/s). ioNormalThreshold = 50.0 ioHighThreshold = 150.0 + + // Battery. + batteryCycleWarn = 800 + batteryCycleDanger = 900 + batteryCapWarn = 80 + batteryCapDanger = 60 + + // Uptime (seconds). + uptimeWarnDays = 7 + uptimeDangerDays = 14 + uptimeWarnSecs = uptimeWarnDays * 86400 + uptimeDangerSecs = uptimeDangerDays * 86400 + + // Score display bands (shared with view.go score styling). + scoreExcellentThreshold = 85 + scoreGoodThreshold = 65 + scoreFairThreshold = 45 ) -func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) { +func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus, batteries []BatteryStatus, uptimeSecs uint64) (int, string) { score := 100.0 issues := []string{} @@ -45,7 +62,11 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d cpuPenalty := 0.0 if cpu.Usage > cpuNormalThreshold { if cpu.Usage > cpuHighThreshold { - cpuPenalty = healthCPUWeight * (cpu.Usage - cpuNormalThreshold) / cpuHighThreshold + // Scale across the remaining range up to 100% so the penalty keeps + // growing with usage (matches the disk branch). Dividing by the raw + // high threshold instead made the penalty drop past 85%, letting the + // score rise as CPU load got worse. + cpuPenalty = healthCPUWeight * (cpu.Usage - cpuNormalThreshold) / (100 - cpuNormalThreshold) } else { cpuPenalty = (healthCPUWeight / 2) * (cpu.Usage - cpuNormalThreshold) / (cpuHighThreshold - cpuNormalThreshold) } @@ -59,7 +80,11 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d memPenalty := 0.0 if mem.UsedPercent > memNormalThreshold { if mem.UsedPercent > memHighThreshold { - memPenalty = healthMemWeight * (mem.UsedPercent - memNormalThreshold) / memNormalThreshold + // Scale across the remaining range up to 100% so the penalty keeps + // growing with usage (matches the disk branch). Dividing by the raw + // normal threshold instead made the penalty drop past 88%, letting + // the score rise as memory pressure got worse. + memPenalty = healthMemWeight * (mem.UsedPercent - memNormalThreshold) / (100 - memNormalThreshold) } else { memPenalty = (healthMemWeight / 2) * (mem.UsedPercent - memNormalThreshold) / (memHighThreshold - memNormalThreshold) } @@ -95,6 +120,15 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d issues = append(issues, "Disk Almost Full") } } + for _, disk := range disks { + if disk.SmartStatus == smartStatusFailing { + if score > 44 { + score = 44 + } + issues = append(issues, "Disk SMART Failing") + break + } + } // Thermal penalty. thermalPenalty := 0.0 @@ -123,6 +157,27 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d } score -= ioPenalty + // Battery health penalty (only when battery present). + if len(batteries) > 0 { + b := batteries[0] + _, sev := batteryHealthLabel(b.CycleCount, b.Capacity) + switch sev { + case "danger": + score -= 5 + issues = append(issues, "Battery Service Soon") + case "warn": + score -= 2 + } + } + + // Uptime penalty (long uptime without restart). + if uptimeSecs > uptimeDangerSecs { + score -= 3 + issues = append(issues, "Restart Recommended") + } else if uptimeSecs > uptimeWarnSecs { + score -= 1 + } + // Clamp score. if score < 0 { score = 0 @@ -134,16 +189,14 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d // Build message. var msg string switch { - case score >= 90: + case score >= scoreExcellentThreshold: msg = "Excellent" - case score >= 75: + case score >= scoreGoodThreshold: msg = "Good" - case score >= 60: + case score >= scoreFairThreshold: msg = "Fair" - case score >= 40: - msg = "Poor" default: - msg = "Critical" + msg = "Needs Attention" } if len(issues) > 0 { @@ -153,6 +206,29 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d return int(score), msg } +// batteryHealthLabel returns a human-readable health label and severity based on cycle count and capacity. +// Severity is "ok", "warn", or "danger". +func batteryHealthLabel(cycles int, capacity int) (string, string) { + if cycles > batteryCycleDanger || (capacity > 0 && capacity < batteryCapDanger) { + return "Service Soon", "danger" + } + if cycles > batteryCycleWarn || (capacity > 0 && capacity < batteryCapWarn) { + return "Fair", "warn" + } + return "Healthy", "ok" +} + +// uptimeSeverity returns "ok", "warn", or "danger" based on uptime seconds. +func uptimeSeverity(secs uint64) string { + if secs > uptimeDangerSecs { + return "danger" + } + if secs > uptimeWarnSecs { + return "warn" + } + return "ok" +} + func formatUptime(secs uint64) string { days := secs / 86400 hours := (secs % 86400) / 3600 diff --git a/Resources/mole/cmd/status/metrics_health_test.go b/Resources/mole/cmd/status/metrics_health_test.go index b88df18..50d4889 100644 --- a/Resources/mole/cmd/status/metrics_health_test.go +++ b/Resources/mole/cmd/status/metrics_health_test.go @@ -12,6 +12,7 @@ func TestCalculateHealthScorePerfect(t *testing.T) { []DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5}, ThermalStatus{CPUTemp: 40}, + nil, 0, ) if score != 100 { @@ -25,13 +26,14 @@ func TestCalculateHealthScorePerfect(t *testing.T) { func TestCalculateHealthScoreDetectsIssues(t *testing.T) { score, msg := calculateHealthScore( CPUStatus{Usage: 95}, - MemoryStatus{UsedPercent: 90, Pressure: "critical"}, - []DiskStatus{{UsedPercent: 95}}, + MemoryStatus{UsedPercent: 95, Pressure: "critical"}, + []DiskStatus{{UsedPercent: 98}}, DiskIOStatus{ReadRate: 120, WriteRate: 80}, ThermalStatus{CPUTemp: 90}, + nil, 0, ) - if score >= 40 { + if score >= 60 { t.Fatalf("expected heavy penalties bringing score down, got %d", score) } if msg == "Excellent" { @@ -45,6 +47,83 @@ func TestCalculateHealthScoreDetectsIssues(t *testing.T) { } } +func TestCalculateHealthScoreCapsFailingSMARTAt44(t *testing.T) { + score, msg := calculateHealthScore( + CPUStatus{Usage: 10}, + MemoryStatus{UsedPercent: 20, Pressure: "normal"}, + []DiskStatus{ + {UsedPercent: 30, SmartStatus: smartStatusVerified}, + {UsedPercent: 20, SmartStatus: smartStatusFailing}, + }, + DiskIOStatus{ReadRate: 5, WriteRate: 5}, + ThermalStatus{CPUTemp: 40}, + nil, 0, + ) + + if score != 44 { + t.Fatalf("failing SMART score = %d, want 44", score) + } + if !strings.Contains(msg, "Disk SMART Failing") { + t.Fatalf("failing SMART message = %q", msg) + } +} + +func TestCalculateHealthScoreDoesNotPenalizeUnavailableSMART(t *testing.T) { + for _, status := range []string{smartStatusUnsupported, smartStatusUnknown} { + score, msg := calculateHealthScore( + CPUStatus{Usage: 10}, + MemoryStatus{UsedPercent: 20, Pressure: "normal"}, + []DiskStatus{{UsedPercent: 30, SmartStatus: status}}, + DiskIOStatus{ReadRate: 5, WriteRate: 5}, + ThermalStatus{CPUTemp: 40}, + nil, 0, + ) + if score != 100 || msg != "Excellent" { + t.Fatalf("SMART %q changed health to %d %q", status, score, msg) + } + } +} + +func TestCalculateHealthScoreMonotonicInCPU(t *testing.T) { + // Rising CPU usage must never improve (raise) the health score, including + // across the high-usage threshold at 85%. + prev := 101 + for usage := 40.0; usage <= 100.0; usage += 0.5 { + score, _ := calculateHealthScore( + CPUStatus{Usage: usage}, + MemoryStatus{UsedPercent: 20, Pressure: "normal"}, + []DiskStatus{{UsedPercent: 30}}, + DiskIOStatus{ReadRate: 5, WriteRate: 5}, + ThermalStatus{CPUTemp: 40}, + nil, 0, + ) + if score > prev { + t.Fatalf("health score rose from %d to %d as CPU usage increased to %.1f%%", prev, score, usage) + } + prev = score + } +} + +func TestCalculateHealthScoreMonotonicInMemory(t *testing.T) { + // Rising memory usage must never improve (raise) the health score, including + // across the high-usage threshold at 88%. + prev := 101 + for usage := 60.0; usage <= 100.0; usage += 0.5 { + score, _ := calculateHealthScore( + CPUStatus{Usage: 10}, + MemoryStatus{UsedPercent: usage, Pressure: "normal"}, + []DiskStatus{{UsedPercent: 30}}, + DiskIOStatus{ReadRate: 5, WriteRate: 5}, + ThermalStatus{CPUTemp: 40}, + nil, 0, + ) + if score > prev { + t.Fatalf("health score rose from %d to %d as memory usage increased to %.1f%%", prev, score, usage) + } + prev = score + } +} + func TestFormatUptime(t *testing.T) { if got := formatUptime(65); got != "1m" { t.Fatalf("expected 1m, got %s", got) @@ -63,11 +142,11 @@ func TestColorizeTempThresholds(t *testing.T) { expected string }{ {temp: 30.0, expected: "30.0"}, // Normal - should use okStyle (green) - {temp: 55.9, expected: "55.9"}, // Just below warning threshold - {temp: 56.0, expected: "56.0"}, // Warning threshold - should use warnStyle (yellow) - {temp: 65.0, expected: "65.0"}, // Mid warning range - {temp: 75.9, expected: "75.9"}, // Just below danger threshold - {temp: 76.0, expected: "76.0"}, // Danger threshold - should use dangerStyle (red) + {temp: 64.9, expected: "64.9"}, // Just below warning threshold + {temp: 65.0, expected: "65.0"}, // Warning threshold - should use warnStyle (yellow) + {temp: 78.0, expected: "78.0"}, // Mid warning range + {temp: 84.9, expected: "84.9"}, // Just below danger threshold + {temp: 85.0, expected: "85.0"}, // Danger threshold - should use dangerStyle (red) {temp: 90.0, expected: "90.0"}, // High temperature {temp: 0.0, expected: "0.0"}, // Edge case: zero } @@ -87,8 +166,8 @@ func TestColorizeTempThresholds(t *testing.T) { func TestColorizeTempStyleRanges(t *testing.T) { normalTemp := colorizeTemp(40.0) - warningTemp := colorizeTemp(65.0) - dangerTemp := colorizeTemp(85.0) + warningTemp := colorizeTemp(72.0) + dangerTemp := colorizeTemp(90.0) if normalTemp == "" || warningTemp == "" || dangerTemp == "" { t.Fatal("colorizeTemp should not return empty strings") @@ -97,11 +176,11 @@ func TestColorizeTempStyleRanges(t *testing.T) { if !strings.Contains(normalTemp, "40.0") { t.Errorf("normal temp should contain '40.0', got: %s", normalTemp) } - if !strings.Contains(warningTemp, "65.0") { - t.Errorf("warning temp should contain '65.0', got: %s", warningTemp) + if !strings.Contains(warningTemp, "72.0") { + t.Errorf("warning temp should contain '72.0', got: %s", warningTemp) } - if !strings.Contains(dangerTemp, "85.0") { - t.Errorf("danger temp should contain '85.0', got: %s", dangerTemp) + if !strings.Contains(dangerTemp, "90.0") { + t.Errorf("danger temp should contain '90.0', got: %s", dangerTemp) } } @@ -118,11 +197,11 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) { }{ { name: "all metrics at normal threshold", - cpu: CPUStatus{Usage: 30.0}, - mem: MemoryStatus{UsedPercent: 50.0}, - disks: []DiskStatus{{UsedPercent: 70.0}}, + cpu: CPUStatus{Usage: 50.0}, + mem: MemoryStatus{UsedPercent: 70.0}, + disks: []DiskStatus{{UsedPercent: 80.0}}, diskIO: DiskIOStatus{ReadRate: 25.0, WriteRate: 25.0}, - thermal: ThermalStatus{CPUTemp: 60.0}, + thermal: ThermalStatus{CPUTemp: 65.0}, wantMin: 95, wantMax: 100, }, @@ -160,7 +239,7 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal) + score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal, nil, 0) if score < tt.wantMin || score > tt.wantMax { t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax) } @@ -168,6 +247,77 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) { } } +func TestBatteryHealthLabel(t *testing.T) { + tests := []struct { + name string + cycles int + capacity int + label string + severity string + }{ + {"new battery", 100, 98, "Healthy", "ok"}, + {"moderate cycles", 600, 92, "Healthy", "ok"}, + {"high cycles", 950, 85, "Service Soon", "danger"}, + {"low capacity", 200, 55, "Service Soon", "danger"}, + {"warn capacity", 200, 75, "Fair", "warn"}, + {"zero values", 0, 0, "Healthy", "ok"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + label, severity := batteryHealthLabel(tt.cycles, tt.capacity) + if label != tt.label { + t.Errorf("batteryHealthLabel(%d, %d) label = %q, want %q", tt.cycles, tt.capacity, label, tt.label) + } + if severity != tt.severity { + t.Errorf("batteryHealthLabel(%d, %d) severity = %q, want %q", tt.cycles, tt.capacity, severity, tt.severity) + } + }) + } +} + +func TestUptimeSeverity(t *testing.T) { + tests := []struct { + name string + secs uint64 + want string + }{ + {"fresh restart", 3600, "ok"}, + {"6 days", 6 * 86400, "ok"}, + {"8 days", 8 * 86400, "warn"}, + {"15 days", 15 * 86400, "danger"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := uptimeSeverity(tt.secs) + if got != tt.want { + t.Errorf("uptimeSeverity(%d) = %q, want %q", tt.secs, got, tt.want) + } + }) + } +} + +func TestHealthScoreBatteryPenalty(t *testing.T) { + base := func(batts []BatteryStatus, uptime uint64) int { + s, _ := calculateHealthScore( + CPUStatus{Usage: 10}, MemoryStatus{UsedPercent: 20}, + []DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5}, + ThermalStatus{CPUTemp: 40}, batts, uptime, + ) + return s + } + + perfect := base(nil, 0) + withOldBattery := base([]BatteryStatus{{CycleCount: 950, Capacity: 75}}, 0) + withLongUptime := base(nil, 15*86400) + + if withOldBattery >= perfect { + t.Errorf("old battery should reduce score: got %d vs perfect %d", withOldBattery, perfect) + } + if withLongUptime >= perfect { + t.Errorf("long uptime should reduce score: got %d vs perfect %d", withLongUptime, perfect) + } +} + func TestFormatUptimeEdgeCases(t *testing.T) { tests := []struct { name string diff --git a/Resources/mole/cmd/status/metrics_memory.go b/Resources/mole/cmd/status/metrics_memory.go index 3e61631..ea1687b 100644 --- a/Resources/mole/cmd/status/metrics_memory.go +++ b/Resources/mole/cmd/status/metrics_memory.go @@ -11,6 +11,14 @@ import ( ) func collectMemory() (MemoryStatus, error) { + return collectMemoryWithOptions(true) +} + +func collectMemoryFast() (MemoryStatus, error) { + return collectMemoryWithOptions(false) +} + +func collectMemoryWithOptions(includeSlowAnnotations bool) (MemoryStatus, error) { vm, err := mem.VirtualMemory() if err != nil { return MemoryStatus{}, err @@ -20,17 +28,21 @@ func collectMemory() (MemoryStatus, error) { if swap == nil { swap = &mem.SwapMemoryStat{} } - pressure := getMemoryPressure() + var pressure string + if includeSlowAnnotations { + pressure = getMemoryPressure() + } // On macOS, vm.Cached is 0, so we calculate from file-backed pages. cached := vm.Cached - if runtime.GOOS == "darwin" && cached == 0 { + if includeSlowAnnotations && runtime.GOOS == "darwin" && cached == 0 { cached = getFileBackedMemory() } return MemoryStatus{ Used: vm.Used, Total: vm.Total, + Available: vm.Available, UsedPercent: vm.UsedPercent, SwapUsed: swap.Used, SwapTotal: swap.Total, diff --git a/Resources/mole/cmd/status/metrics_network.go b/Resources/mole/cmd/status/metrics_network.go index a310474..61b01f0 100644 --- a/Resources/mole/cmd/status/metrics_network.go +++ b/Resources/mole/cmd/status/metrics_network.go @@ -16,39 +16,66 @@ import ( var ioCountersFunc = net.IOCounters -func collectIOCountersSafely(pernic bool) (stats []net.IOCountersStat, err error) { +const ( + minNetworkSampleInterval = 100 * time.Millisecond + networkIPCacheTTL = 10 * time.Second +) + +var noiseInterfacePrefixes = [...]string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} + +func collectIOCountersSafely() (stats []net.IOCountersStat, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic collecting network counters: %v", r) } }() - return ioCountersFunc(pernic) + return ioCountersFunc(true) } -func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { - stats, err := collectIOCountersSafely(true) +func (c *Collector) primeNetworkCounters(now time.Time) { + stats, err := collectIOCountersSafely() + if err != nil { + return + } + c.lastNetAt = now + for _, s := range stats { + c.prevNet[s.Name] = s + } +} + +func (c *Collector) collectNetwork(now time.Time) []NetworkStatus { + if c.prevNet == nil { + c.prevNet = make(map[string]net.IOCountersStat) + } + if c.rxHistoryBuf == nil { + c.rxHistoryBuf = NewRingBuffer(NetworkHistorySize) + } + if c.txHistoryBuf == nil { + c.txHistoryBuf = NewRingBuffer(NetworkHistorySize) + } + + stats, err := collectIOCountersSafely() if err != nil { // Some restricted environments can break netstat-backed collectors. // Degrade gracefully to keep status output available. c.rxHistoryBuf.Add(0) c.txHistoryBuf.Add(0) - return nil, nil + return nil } // Map interface IPs. - ifAddrs := getInterfaceIPs() + ifAddrs := c.getInterfaceIPsCached(now) if c.lastNetAt.IsZero() { c.lastNetAt = now for _, s := range stats { c.prevNet[s.Name] = s } - return nil, nil } elapsed := now.Sub(c.lastNetAt).Seconds() - if elapsed <= 0 { - elapsed = 1 + if elapsed < minNetworkSampleInterval.Seconds() { + elapsed = minNetworkSampleInterval.Seconds() } var result []NetworkStatus @@ -60,14 +87,8 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { if !ok { continue } - rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed - tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed - if rx < 0 { - rx = 0 - } - if tx < 0 { - tx = 0 - } + rx := float64(counterDelta(cur.BytesRecv, prev.BytesRecv)) / 1024.0 / 1024.0 / elapsed + tx := float64(counterDelta(cur.BytesSent, prev.BytesSent)) / 1024.0 / 1024.0 / elapsed result = append(result, NetworkStatus{ Name: cur.Name, RxRateMBs: rx, @@ -98,7 +119,16 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { c.rxHistoryBuf.Add(totalRx) c.txHistoryBuf.Add(totalTx) - return result, nil + return result +} + +func (c *Collector) getInterfaceIPsCached(now time.Time) map[string]string { + if c.cachedNetIPs != nil && now.Sub(c.lastNetIPAt) < networkIPCacheTTL { + return c.cachedNetIPs + } + c.cachedNetIPs = getInterfaceIPs() + c.lastNetIPAt = now + return c.cachedNetIPs } func getInterfaceIPs() map[string]string { @@ -122,8 +152,7 @@ func getInterfaceIPs() map[string]string { func isNoiseInterface(name string) bool { lower := strings.ToLower(name) - noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} - for _, prefix := range noiseList { + for _, prefix := range noiseInterfacePrefixes { if strings.HasPrefix(lower, prefix) { return true } @@ -230,7 +259,7 @@ func collectProxyFromScutilOutput(out string) ProxyStatus { } func collectProxyFromTunInterfaces() ProxyStatus { - stats, err := net.IOCounters(true) + stats, err := collectIOCountersSafely() if err != nil { return ProxyStatus{Enabled: false} } @@ -252,7 +281,14 @@ func collectProxyFromTunInterfaces() ProxyStatus { if len(activeTun) > 1 { host = activeTun[0] + "+" } - return ProxyStatus{Enabled: true, Type: "TUN", Host: host} + // Reported as a tunnel, never as a proxy. This branch is only reached when + // no proxy is configured in the environment or in scutil, which is exactly + // the case where an active `utun` is most likely iCloud Private Relay or a + // VPN. Calling that "Proxy TUN" told users something false about a machine + // that has no proxy at all. + // Keep the existing JSON type value for automation consumers. IsTunnel is + // the presentation hint that lets the terminal UI use an honest label. + return ProxyStatus{Enabled: true, Type: "TUN", Host: host, IsTunnel: true} } func scutilProxyEnabled(out, key string) bool { diff --git a/Resources/mole/cmd/status/metrics_network_test.go b/Resources/mole/cmd/status/metrics_network_test.go index 51e47cc..4daec1f 100644 --- a/Resources/mole/cmd/status/metrics_network_test.go +++ b/Resources/mole/cmd/status/metrics_network_test.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "strings" "testing" + "time" gopsutilnet "github.com/shirou/gopsutil/v4/net" ) @@ -71,7 +73,7 @@ func TestCollectIOCountersSafelyRecoversPanic(t *testing.T) { } t.Cleanup(func() { ioCountersFunc = original }) - stats, err := collectIOCountersSafely(true) + stats, err := collectIOCountersSafely() if err == nil { t.Fatalf("expected error from panic recovery") } @@ -93,7 +95,7 @@ func TestCollectIOCountersSafelyReturnsData(t *testing.T) { } t.Cleanup(func() { ioCountersFunc = original }) - got, err := collectIOCountersSafely(true) + got, err := collectIOCountersSafely() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -101,3 +103,126 @@ func TestCollectIOCountersSafelyReturnsData(t *testing.T) { t.Fatalf("unexpected stats: %+v", got) } } + +func TestCollectNetworkFirstSampleReturnsZeroRateInterfaces(t *testing.T) { + original := ioCountersFunc + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 1000, BytesSent: 2000}, + }, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + c := &Collector{} + got := c.collectNetwork(time.Now()) + if len(got) != 1 { + t.Fatalf("expected first sample to render one interface, got %+v", got) + } + if got[0].RxRateMBs != 0 || got[0].TxRateMBs != 0 { + t.Fatalf("expected first sample zero rates, got %+v", got[0]) + } + if len(c.rxHistoryBuf.Slice()) != 1 || len(c.txHistoryBuf.Slice()) != 1 { + t.Fatalf("expected history to be seeded on first sample") + } +} + +func TestCollectNetworkUsesPrimedCountersForInitialRates(t *testing.T) { + original := ioCountersFunc + calls := 0 + samples := [][]gopsutilnet.IOCountersStat{ + {{Name: "en0", BytesRecv: 1024 * 1024, BytesSent: 0}}, + {{Name: "en0", BytesRecv: 2 * 1024 * 1024, BytesSent: 512 * 1024}}, + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + if calls >= len(samples) { + return samples[len(samples)-1], nil + } + got := samples[calls] + calls++ + return got, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + c := NewCollector(ProcessWatchOptions{}) + got := c.collectNetwork(c.lastNetAt.Add(time.Second)) + if len(got) != 1 { + t.Fatalf("expected one interface, got %+v", got) + } + if got[0].RxRateMBs != 1.0 { + t.Fatalf("expected 1 MB/s down, got %v", got[0].RxRateMBs) + } + if got[0].TxRateMBs != 0.5 { + t.Fatalf("expected 0.5 MB/s up, got %v", got[0].TxRateMBs) + } +} + +func TestCollectNetworkClampsCounterReset(t *testing.T) { + original := ioCountersFunc + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 10, BytesSent: 20}, + }, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + base := time.Now() + c := &Collector{ + prevNet: map[string]gopsutilnet.IOCountersStat{ + "en0": {Name: "en0", BytesRecv: 1024 * 1024, BytesSent: 1024 * 1024}, + }, + lastNetAt: base, + rxHistoryBuf: NewRingBuffer(NetworkHistorySize), + txHistoryBuf: NewRingBuffer(NetworkHistorySize), + } + + got := c.collectNetwork(base.Add(time.Second)) + if len(got) != 1 { + t.Fatalf("expected one interface, got %+v", got) + } + if got[0].RxRateMBs != 0 || got[0].TxRateMBs != 0 { + t.Fatalf("expected reset counters to clamp to zero, got %+v", got[0]) + } +} + +func TestTunnelInterfaceIsNotReportedAsAProxy(t *testing.T) { + // A machine with no configured proxy but an active utun (iCloud Private + // Relay, a corporate VPN, or a TUN-mode client) must not be told it has a + // proxy. The reading is still surfaced, just honestly labelled. + original := ioCountersFunc + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 100}, + {Name: "utun4", BytesRecv: 20, BytesSent: 30}, + }, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + got := collectProxyFromTunInterfaces() + if !got.Enabled || !got.IsTunnel || got.Type != "TUN" || got.Host != "utun4" { + t.Fatalf("unexpected tunnel status: %+v", got) + } + card := renderNetworkCard( + []NetworkStatus{{Name: "en0", IP: "192.0.2.10"}}, + NetworkHistory{}, got, 40, + ) + rendered := strings.Join(card.lines, "\n") + if !strings.Contains(rendered, "Tunnel") || strings.Contains(rendered, "Proxy Tunnel") { + t.Fatalf("tunnel must be rendered without a proxy claim: %q", rendered) + } +} + +func TestTunnelHintDoesNotExpandProxyJSONContract(t *testing.T) { + encoded, err := json.Marshal(ProxyStatus{ + Enabled: true, + Type: "TUN", + Host: "utun4", + IsTunnel: true, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + const want = `{"enabled":true,"type":"TUN","host":"utun4"}` + if string(encoded) != want { + t.Fatalf("proxy JSON contract changed: got %s, want %s", encoded, want) + } +} diff --git a/Resources/mole/cmd/status/metrics_process.go b/Resources/mole/cmd/status/metrics_process.go index b11f25c..c3b3c5a 100644 --- a/Resources/mole/cmd/status/metrics_process.go +++ b/Resources/mole/cmd/status/metrics_process.go @@ -1,53 +1,204 @@ package main import ( + "container/heap" "context" + "fmt" "runtime" + "slices" "strconv" "strings" "time" ) -func collectTopProcesses() []ProcessInfo { +var collectProcessesFunc = collectProcesses + +func collectProcesses() ([]ProcessInfo, error) { if runtime.GOOS != "darwin" { - return nil + return nil, nil } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - // Use ps to get top processes by CPU. - out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") + out, err := runCmd(ctx, "ps", "-Aceo", "pid=,ppid=,pcpu=,pmem=,rss=,comm=", "-r") if err != nil { - return nil + out, err = runCmd(ctx, "ps", "aux") + if err != nil { + return nil, err + } + return parsePsAuxOutput(out), nil } + return parseProcessOutput(out), nil +} + +func parseProcessOutput(raw string) []ProcessInfo { + procs := make([]ProcessInfo, 0, strings.Count(raw, "\n")) + for line := range strings.Lines(strings.TrimSpace(raw)) { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } - var procs []ProcessInfo - i := 0 - for line := range strings.Lines(strings.TrimSpace(out)) { - if i == 0 { - i++ + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { continue } - if i > 5 { - break + ppid, _ := strconv.Atoi(fields[1]) + cpuVal, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + continue + } + memVal, err := strconv.ParseFloat(fields[3], 64) + if err != nil { + continue + } + + rssBytes := uint64(0) + commandStart := 4 + if len(fields) >= 6 { + if rssKB, err := strconv.ParseUint(fields[4], 10, 64); err == nil { + rssBytes = rssKB * 1024 + commandStart = 5 + } + } + + command := strings.Join(fields[commandStart:], " ") + if command == "" { + continue + } + procs = append(procs, ProcessInfo{ + PID: pid, + PPID: ppid, + Name: processNameFromCommand(command), + Command: command, + CPU: cpuVal, + Memory: memVal, + MemoryBytes: rssBytes, + }) + } + return procs +} + +// parsePsAuxOutput parses the fallback "ps aux" format. +// Columns: USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND +func parsePsAuxOutput(raw string) []ProcessInfo { + procs := make([]ProcessInfo, 0, strings.Count(raw, "\n")) + first := true + for line := range strings.Lines(strings.TrimSpace(raw)) { + if first { + first = false + continue } - i++ fields := strings.Fields(line) - if len(fields) < 3 { + if len(fields) < 11 { continue } - cpuVal, _ := strconv.ParseFloat(fields[0], 64) - memVal, _ := strconv.ParseFloat(fields[1], 64) - name := fields[len(fields)-1] - // Strip path from command name. - if idx := strings.LastIndex(name, "/"); idx >= 0 { - name = name[idx+1:] + pid, err := strconv.Atoi(fields[1]) + if err != nil || pid <= 0 { + continue + } + cpuVal, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + continue + } + memVal, err := strconv.ParseFloat(fields[3], 64) + if err != nil { + continue + } + rssKB, err := strconv.ParseUint(fields[5], 10, 64) + if err != nil { + rssKB = 0 + } + command := strings.Join(fields[10:], " ") + if command == "" { + continue } procs = append(procs, ProcessInfo{ - Name: name, - CPU: cpuVal, - Memory: memVal, + PID: pid, + PPID: 0, + Name: processNameFromCommand(command), + Command: command, + CPU: cpuVal, + Memory: memVal, + MemoryBytes: rssKB * 1024, }) } return procs } + +func processNameFromCommand(command string) string { + name := command + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + if spIdx := strings.Index(name, " "); spIdx >= 0 { + name = name[:spIdx] + } + return name +} + +func topProcesses(processes []ProcessInfo, limit int) []ProcessInfo { + if limit <= 0 || len(processes) == 0 { + return nil + } + + h := &processHeap{} + heap.Init(h) + for _, proc := range processes { + if h.Len() < limit { + heap.Push(h, proc) + continue + } + if processRanksBefore(proc, (*h)[0]) { + heap.Pop(h) + heap.Push(h, proc) + } + } + + top := make([]ProcessInfo, h.Len()) + for i := range slices.Backward(top) { + top[i] = heap.Pop(h).(ProcessInfo) + } + return top +} + +func formatProcessLabel(proc ProcessInfo) string { + if proc.Name != "" { + return fmt.Sprintf("%s (%d)", proc.Name, proc.PID) + } + return fmt.Sprintf("pid %d", proc.PID) +} + +func processRanksBefore(a, b ProcessInfo) bool { + if a.CPU != b.CPU { + return a.CPU > b.CPU + } + if a.Memory != b.Memory { + return a.Memory > b.Memory + } + return a.PID < b.PID +} + +type processHeap []ProcessInfo + +func (h processHeap) Len() int { return len(h) } + +func (h processHeap) Less(i, j int) bool { + return processRanksBefore(h[j], h[i]) +} + +func (h processHeap) Swap(i, j int) { + h[i], h[j] = h[j], h[i] +} + +func (h *processHeap) Push(x any) { + *h = append(*h, x.(ProcessInfo)) +} + +func (h *processHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + *h = old[:n-1] + return x +} diff --git a/Resources/mole/cmd/status/prefs.go b/Resources/mole/cmd/status/prefs.go new file mode 100644 index 0000000..602a344 --- /dev/null +++ b/Resources/mole/cmd/status/prefs.go @@ -0,0 +1,201 @@ +// Package main: status preferences store. +// +// Persisted preferences live in a tiny `key=value` file (one pair per line) at +// ~/.config/mole/status_prefs. This file replaces the previous single-value +// implementation, which compared the whole file against one exact string and so +// could only ever hold a single preference. The map-based store below lets any +// number of preferences coexist: writing one key preserves all the others. +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "golang.org/x/sys/unix" +) + +// getConfigPath returns the path to the status preferences file, or "" if the +// user's home directory cannot be resolved. +func getConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "mole", "status_prefs") +} + +// loadPrefs reads the preferences file into a map. A missing or unreadable file +// yields an empty (never nil) map, so callers can index it safely. Blank lines +// and lines starting with '#' are ignored, so the file stays hand-editable. +func loadPrefs() map[string]string { + prefs := map[string]string{} + + path := getConfigPath() + if path == "" { + return prefs + } + data, err := os.ReadFile(path) + if err != nil { + return prefs + } + + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue // no '=' on the line, skip it rather than guess + } + prefs[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return prefs +} + +// savePref sets a single preference and rewrites the file, preserving every +// other key already stored. Failures are silently ignored: a preference that +// cannot be persisted must never crash the status TUI. +func savePref(key, value string) { + path := getConfigPath() + if path == "" { + return + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return + } + defer func() { + _ = lock.Close() + }() + if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { + return + } + defer unix.Flock(int(lock.Fd()), unix.LOCK_UN) //nolint:errcheck + + // Read only after acquiring the inter-process lock. Otherwise two status + // instances can both load the same old map and the later writer drops the + // other instance's new key. + prefs := loadPrefs() + prefs[key] = value + + // Sort keys so the file is deterministic: stable on disk, clean diffs, + // and testable (map iteration order is randomized in Go). + keys := make([]string, 0, len(prefs)) + for k := range prefs { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(prefs[k]) + b.WriteByte('\n') + } + if err := writePrefsAtomically(path, []byte(b.String())); err != nil { + return + } +} + +func writePrefsAtomically(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), ".status_prefs.*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + + if err := temp.Chmod(0o644); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("replace preferences: %w", err) + } + return nil +} + +// Typed accessors: the rest of the code speaks in bools/ints, not raw strings. + +// loadCatHidden reports whether the ASCII cat should be hidden. +func loadCatHidden() bool { + return loadPrefs()["cat_hidden"] == "true" +} + +// saveCatHidden persists the cat visibility preference. +func saveCatHidden(hidden bool) { + savePref("cat_hidden", strconv.FormatBool(hidden)) +} + +// cpuCoresCycle is the sequence the 'c' key steps through. 0 means "all cores". +// The default (first entry) is 2, matching the historical hard-coded behaviour. +var cpuCoresCycle = []int{2, 4, 8, 0} + +// loadCPUCores reports how many CPU cores the status card should list. 0 means +// "all". It falls back to the default (2) when unset or invalid, so a corrupt +// pref never breaks the display. +func loadCPUCores() int { + raw, ok := loadPrefs()["cpu_cores"] + if !ok { + return cpuCoresCycle[0] + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return cpuCoresCycle[0] + } + return n +} + +// saveCPUCores persists the CPU core count preference (0 = all). +func saveCPUCores(n int) { + savePref("cpu_cores", strconv.Itoa(n)) +} + +// nextCPUCores returns the value after current in cpuCoresCycle, wrapping around. +// An unrecognised current value restarts the cycle at its first entry. +func nextCPUCores(current int) int { + for i, v := range cpuCoresCycle { + if v == current { + return cpuCoresCycle[(i+1)%len(cpuCoresCycle)] + } + } + return cpuCoresCycle[0] +} + +// smallerCPUCores returns the entry before current in cpuCoresCycle, without +// wrapping: the first entry is the floor. Used by the view to shrink the CPU +// card when the rendered frame does not fit the window. +func smallerCPUCores(current int) int { + for i, v := range cpuCoresCycle { + if v == current { + if i == 0 { + return cpuCoresCycle[0] + } + return cpuCoresCycle[i-1] + } + } + return cpuCoresCycle[0] +} diff --git a/Resources/mole/cmd/status/prefs_test.go b/Resources/mole/cmd/status/prefs_test.go new file mode 100644 index 0000000..ab9e1b0 --- /dev/null +++ b/Resources/mole/cmd/status/prefs_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" +) + +// TestSavePrefPreservesOtherKeys is the whole point of the refactor: writing one +// preference must not clobber another. The previous single-string store failed +// exactly this scenario. t.Setenv redirects HOME to a temp dir so getConfigPath +// resolves inside it and the test never touches the user's real config. +func TestSavePrefPreservesOtherKeys(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + savePref("cat_hidden", "true") + savePref("cpu_cores", "8") + + prefs := loadPrefs() + if got := prefs["cat_hidden"]; got != "true" { + t.Errorf("cat_hidden = %q, want %q (clobbered by the second write)", got, "true") + } + if got := prefs["cpu_cores"]; got != "8" { + t.Errorf("cpu_cores = %q, want %q", got, "8") + } +} + +// TestCatHiddenRoundTrip checks the typed accessors still behave like before. +func TestCatHiddenRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + if loadCatHidden() { + t.Fatal("cat_hidden should default to false when no file exists") + } + saveCatHidden(true) + if !loadCatHidden() { + t.Error("cat_hidden should be true after saveCatHidden(true)") + } + saveCatHidden(false) + if loadCatHidden() { + t.Error("cat_hidden should be false after saveCatHidden(false)") + } +} + +// TestLoadPrefsIgnoresBlanksAndComments keeps the file hand-editable. +func TestLoadPrefsIgnoresBlanksAndComments(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Seed a file with a comment, a blank line, and a malformed line. + savePref("cat_hidden", "true") + prefs := loadPrefs() + if len(prefs) != 1 || prefs["cat_hidden"] != "true" { + t.Fatalf("unexpected prefs after seed: %#v", prefs) + } +} + +// Staggered process starts do not prove anything here: each writer finishes its +// whole read-modify-write before the next one is scheduled, so an unlocked +// implementation passes too. Every helper must sit on a barrier and enter +// savePref together. Without the flock, this loses keys. +func TestSavePrefConcurrentProcessesPreserveEveryKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + const writers = 16 + barrier := filepath.Join(home, "barrier") + readyDir := filepath.Join(home, "ready") + if err := os.MkdirAll(readyDir, 0o700); err != nil { + t.Fatalf("create ready dir: %v", err) + } + + commands := make([]*exec.Cmd, 0, writers) + for i := range writers { + cmd := exec.Command(os.Args[0], "-test.run=^TestSavePrefProcessHelper$") + cmd.Env = append(os.Environ(), + "HOME="+home, + "MOLE_PREFS_TEST_HELPER=1", + fmt.Sprintf("MOLE_PREFS_TEST_KEY=key_%02d", i), + "MOLE_PREFS_TEST_VALUE="+strconv.Itoa(i), + "MOLE_PREFS_TEST_BARRIER="+barrier, + fmt.Sprintf("MOLE_PREFS_TEST_READY=%s", filepath.Join(readyDir, strconv.Itoa(i))), + ) + if err := cmd.Start(); err != nil { + t.Fatalf("start writer %d: %v", i, err) + } + commands = append(commands, cmd) + } + + deadline := time.Now().Add(30 * time.Second) + for { + entries, err := os.ReadDir(readyDir) + if err != nil { + t.Fatalf("read ready dir: %v", err) + } + if len(entries) == writers { + break + } + if time.Now().After(deadline) { + t.Fatalf("only %d of %d writers reached the barrier", len(entries), writers) + } + time.Sleep(2 * time.Millisecond) + } + if err := os.WriteFile(barrier, []byte("go"), 0o600); err != nil { + t.Fatalf("release barrier: %v", err) + } + + for i, cmd := range commands { + if err := cmd.Wait(); err != nil { + t.Fatalf("writer %d failed: %v", i, err) + } + } + + prefs := loadPrefs() + for i := range writers { + key := fmt.Sprintf("key_%02d", i) + if got := prefs[key]; got != strconv.Itoa(i) { + t.Errorf("%s = %q, want %q", key, got, strconv.Itoa(i)) + } + } +} + +func TestSavePrefProcessHelper(t *testing.T) { + if os.Getenv("MOLE_PREFS_TEST_HELPER") != "1" { + return + } + if barrier := os.Getenv("MOLE_PREFS_TEST_BARRIER"); barrier != "" { + if ready := os.Getenv("MOLE_PREFS_TEST_READY"); ready != "" { + if err := os.WriteFile(ready, []byte("1"), 0o600); err != nil { + t.Fatalf("signal ready: %v", err) + } + } + deadline := time.Now().Add(30 * time.Second) + for { + if _, err := os.Stat(barrier); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("barrier %s never opened", barrier) + } + time.Sleep(time.Millisecond) + } + } + savePref(os.Getenv("MOLE_PREFS_TEST_KEY"), os.Getenv("MOLE_PREFS_TEST_VALUE")) +} diff --git a/Resources/mole/cmd/status/process_watch.go b/Resources/mole/cmd/status/process_watch.go new file mode 100644 index 0000000..819b881 --- /dev/null +++ b/Resources/mole/cmd/status/process_watch.go @@ -0,0 +1,150 @@ +package main + +import ( + "sort" + "time" +) + +type ProcessWatchOptions struct { + Enabled bool + CPUThreshold float64 + Window time.Duration +} + +type ProcessWatchConfig struct { + Enabled bool `json:"enabled"` + CPUThreshold float64 `json:"cpu_threshold"` + Window string `json:"window"` +} + +type ProcessAlert struct { + PID int `json:"pid"` + Name string `json:"name"` + Command string `json:"command,omitempty"` + CPU float64 `json:"cpu"` + Threshold float64 `json:"threshold"` + Window string `json:"window"` + TriggeredAt time.Time `json:"triggered_at"` + Status string `json:"status"` +} + +type trackedProcess struct { + info ProcessInfo + firstAbove time.Time + triggeredAt time.Time + currentAbove bool +} + +type processIdentity struct { + pid int + ppid int + command string +} + +type ProcessWatcher struct { + options ProcessWatchOptions + tracks map[processIdentity]*trackedProcess +} + +func NewProcessWatcher(options ProcessWatchOptions) *ProcessWatcher { + return &ProcessWatcher{ + options: options, + tracks: make(map[processIdentity]*trackedProcess), + } +} + +func (o ProcessWatchOptions) SnapshotConfig() ProcessWatchConfig { + return ProcessWatchConfig{ + Enabled: o.Enabled, + CPUThreshold: o.CPUThreshold, + Window: o.Window.String(), + } +} + +func (w *ProcessWatcher) Update(now time.Time, processes []ProcessInfo) []ProcessAlert { + if w == nil || !w.options.Enabled { + return nil + } + + seen := make(map[processIdentity]bool, len(processes)) + for _, proc := range processes { + if proc.PID <= 0 { + continue + } + key := processIdentity{ + pid: proc.PID, + ppid: proc.PPID, + command: proc.Command, + } + seen[key] = true + + track, ok := w.tracks[key] + if !ok { + track = &trackedProcess{} + w.tracks[key] = track + } + + track.info = proc + track.currentAbove = proc.CPU >= w.options.CPUThreshold + + if track.currentAbove { + if track.firstAbove.IsZero() { + track.firstAbove = now + } + if now.Sub(track.firstAbove) >= w.options.Window && track.triggeredAt.IsZero() { + track.triggeredAt = now + } + continue + } + + track.firstAbove = time.Time{} + track.triggeredAt = time.Time{} + } + + for pid := range w.tracks { + if !seen[pid] { + delete(w.tracks, pid) + } + } + + return w.Snapshot() +} + +func (w *ProcessWatcher) Snapshot() []ProcessAlert { + if w == nil || !w.options.Enabled { + return nil + } + + alerts := make([]ProcessAlert, 0, len(w.tracks)) + for _, track := range w.tracks { + if !track.currentAbove || track.triggeredAt.IsZero() { + continue + } + + alerts = append(alerts, ProcessAlert{ + PID: track.info.PID, + Name: track.info.Name, + Command: track.info.Command, + CPU: track.info.CPU, + Threshold: w.options.CPUThreshold, + Window: w.options.Window.String(), + TriggeredAt: track.triggeredAt, + Status: "active", + }) + } + + sort.Slice(alerts, func(i, j int) bool { + if alerts[i].Status != alerts[j].Status { + return alerts[i].Status == "active" + } + if !alerts[i].TriggeredAt.Equal(alerts[j].TriggeredAt) { + return alerts[i].TriggeredAt.Before(alerts[j].TriggeredAt) + } + if alerts[i].CPU != alerts[j].CPU { + return alerts[i].CPU > alerts[j].CPU + } + return alerts[i].PID < alerts[j].PID + }) + + return alerts +} diff --git a/Resources/mole/cmd/status/process_watch_test.go b/Resources/mole/cmd/status/process_watch_test.go new file mode 100644 index 0000000..5442637 --- /dev/null +++ b/Resources/mole/cmd/status/process_watch_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "encoding/json" + "runtime" + "strings" + "testing" + "time" +) + +// Guards #1267: ps and uptime emit comma decimals under locales like +// ru_RU.UTF-8, which made every ParseFloat in the collectors fail. +func TestRunCmdForcesCLocale(t *testing.T) { + t.Setenv("LC_ALL", "ru_RU.UTF-8") + t.Setenv("LC_NUMERIC", "ru_RU.UTF-8") + t.Setenv("LANG", "ru_RU.UTF-8") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := runCmd(ctx, "sh", "-c", "printf '%s|%s|%s' \"$LC_ALL\" \"$LC_NUMERIC\" \"$LANG\"") + if err != nil { + t.Fatalf("runCmd() error = %v", err) + } + if out != "C||" { + t.Fatalf("runCmd() subprocess locale = %q, want %q", out, "C||") + } +} + +func TestCollectProcessesUnderCommaLocale(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("ps output format is darwin-specific") + } + t.Setenv("LC_ALL", "ru_RU.UTF-8") + t.Setenv("LC_NUMERIC", "ru_RU.UTF-8") + + procs, err := collectProcesses() + if err != nil { + t.Fatalf("collectProcesses() error = %v", err) + } + if len(procs) == 0 { + t.Fatal("collectProcesses() returned no processes under a comma-decimal locale") + } +} + +func TestParseProcessOutput(t *testing.T) { + raw := strings.Join([]string{ + "123 1 145.2 10.1 7340032 /Applications/Visual Studio Code.app/Contents/MacOS/Electron", + "456 1 99.5 2.2 262144 /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", + "bad line", + }, "\n") + + procs := parseProcessOutput(raw) + if len(procs) != 2 { + t.Fatalf("parseProcessOutput() len = %d, want 2", len(procs)) + } + + if procs[0].PID != 123 || procs[0].PPID != 1 { + t.Fatalf("unexpected pid/ppid: %+v", procs[0]) + } + if procs[0].Name != "Electron" { + t.Fatalf("unexpected process name %q", procs[0].Name) + } + if !strings.Contains(procs[0].Command, "Visual Studio Code.app") { + t.Fatalf("command path missing spaces: %q", procs[0].Command) + } + if procs[0].MemoryBytes != 7340032*1024 { + t.Fatalf("unexpected memory bytes %d", procs[0].MemoryBytes) + } +} + +func TestParseProcessOutputKeepsOldFiveColumnShape(t *testing.T) { + raw := "123 1 145.2 10.1 /Applications/Visual Studio Code.app/Contents/MacOS/Electron" + + procs := parseProcessOutput(raw) + if len(procs) != 1 { + t.Fatalf("parseProcessOutput() len = %d, want 1", len(procs)) + } + if procs[0].Memory != 10.1 { + t.Fatalf("unexpected memory percent %.1f", procs[0].Memory) + } + if procs[0].MemoryBytes != 0 { + t.Fatalf("old ps shape should not invent memory bytes, got %d", procs[0].MemoryBytes) + } + if procs[0].Command != "/Applications/Visual Studio Code.app/Contents/MacOS/Electron" { + t.Fatalf("unexpected command %q", procs[0].Command) + } +} + +func TestParsePsAuxOutputCapturesResidentMemory(t *testing.T) { + raw := strings.Join([]string{ + "USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND", + "raj 123 4.5 6.0 123456 2097152 ?? S 10:00AM 1:23 /Applications/Chrome.app/Contents/MacOS/Chrome --type=renderer", + }, "\n") + + procs := parsePsAuxOutput(raw) + if len(procs) != 1 { + t.Fatalf("parsePsAuxOutput() len = %d, want 1", len(procs)) + } + if procs[0].MemoryBytes != 2097152*1024 { + t.Fatalf("unexpected memory bytes %d", procs[0].MemoryBytes) + } + if !strings.Contains(procs[0].Command, "--type=renderer") { + t.Fatalf("command path missing args: %q", procs[0].Command) + } +} + +func TestTopProcessesSortsByCPU(t *testing.T) { + procs := []ProcessInfo{ + {PID: 3, Name: "low", CPU: 20, Memory: 3}, + {PID: 1, Name: "high", CPU: 120, Memory: 1}, + {PID: 2, Name: "mid", CPU: 120, Memory: 8}, + } + + top := topProcesses(procs, 2) + if len(top) != 2 { + t.Fatalf("topProcesses() len = %d, want 2", len(top)) + } + if top[0].PID != 2 || top[1].PID != 1 { + t.Fatalf("unexpected order: %+v", top) + } +} + +func TestProcessNameFromCommand(t *testing.T) { + tests := []struct { + command string + want string + }{ + {"/Applications/Visual Studio Code.app/Contents/MacOS/Electron", "Electron"}, + {"/usr/local/bin/node /tmp/server.js", "server.js"}, + {"Finder", "Finder"}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + if got := processNameFromCommand(tt.command); got != tt.want { + t.Fatalf("processNameFromCommand(%q) = %q, want %q", tt.command, got, tt.want) + } + }) + } +} + +func TestProcessWatcherTriggersAfterContinuousWindow(t *testing.T) { + base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC) + watcher := NewProcessWatcher(ProcessWatchOptions{ + Enabled: true, + CPUThreshold: 100, + Window: 5 * time.Minute, + }) + + proc := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}} + if alerts := watcher.Update(base, proc); len(alerts) != 0 { + t.Fatalf("unexpected early alerts: %+v", alerts) + } + if alerts := watcher.Update(base.Add(4*time.Minute), proc); len(alerts) != 0 { + t.Fatalf("unexpected early alerts at 4m: %+v", alerts) + } + alerts := watcher.Update(base.Add(5*time.Minute), proc) + if len(alerts) != 1 { + t.Fatalf("expected 1 alert after full window, got %+v", alerts) + } + if alerts[0].Status != "active" { + t.Fatalf("unexpected alert status %q", alerts[0].Status) + } +} + +func TestProcessWatcherResetsWhenUsageDrops(t *testing.T) { + base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC) + watcher := NewProcessWatcher(ProcessWatchOptions{ + Enabled: true, + CPUThreshold: 100, + Window: 5 * time.Minute, + }) + + high := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}} + low := []ProcessInfo{{PID: 42, Name: "stress", CPU: 30}} + + watcher.Update(base, high) + watcher.Update(base.Add(4*time.Minute), high) + if alerts := watcher.Update(base.Add(4*time.Minute+30*time.Second), low); len(alerts) != 0 { + t.Fatalf("expected reset after dip, got %+v", alerts) + } + if alerts := watcher.Update(base.Add(9*time.Minute), high); len(alerts) != 0 { + t.Fatalf("expected no alert after reset, got %+v", alerts) + } + if alerts := watcher.Update(base.Add(14*time.Minute), high); len(alerts) != 1 { + t.Fatalf("expected alert after second full window, got %+v", alerts) + } +} + +func TestProcessWatcherResetsOnPIDReuse(t *testing.T) { + base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC) + watcher := NewProcessWatcher(ProcessWatchOptions{ + Enabled: true, + CPUThreshold: 100, + Window: 2 * time.Minute, + }) + + firstProc := []ProcessInfo{{ + PID: 42, + PPID: 1, + Name: "stress", + Command: "/usr/bin/stress", + CPU: 140, + }} + secondProc := []ProcessInfo{{ + PID: 42, + PPID: 99, + Name: "node", + Command: "/usr/local/bin/node /tmp/server.js", + CPU: 135, + }} + + watcher.Update(base, firstProc) + if alerts := watcher.Update(base.Add(2*time.Minute), firstProc); len(alerts) != 1 { + t.Fatalf("expected first process to alert after window, got %+v", alerts) + } + + if alerts := watcher.Update(base.Add(3*time.Minute), secondProc); len(alerts) != 0 { + t.Fatalf("expected pid reuse to reset tracking, got %+v", alerts) + } + if alerts := watcher.Update(base.Add(5*time.Minute), secondProc); len(alerts) != 1 { + t.Fatalf("expected reused pid to alert only after its own window, got %+v", alerts) + } +} + +func TestRenderProcessAlertBar(t *testing.T) { + alerts := []ProcessAlert{ + {PID: 10, Name: "node", CPU: 150, Threshold: 100, Window: "5m0s", Status: "active"}, + {PID: 11, Name: "java", CPU: 130, Threshold: 100, Window: "5m0s", Status: "active"}, + } + + bar := renderProcessAlertBar(alerts, 120) + if !strings.Contains(bar, "ALERT") { + t.Fatalf("missing alert prefix: %q", bar) + } + if !strings.Contains(bar, "node (10)") { + t.Fatalf("missing lead process label: %q", bar) + } + if !strings.Contains(bar, "+1 more") { + t.Fatalf("missing additional alert count: %q", bar) + } + if strings.Contains(bar, "terminate") || strings.Contains(bar, "ignore") { + t.Fatalf("unexpected action text in read-only alert bar: %q", bar) + } +} + +func TestMetricsSnapshotJSONIncludesProcessWatch(t *testing.T) { + snapshot := MetricsSnapshot{ + ProcessWatch: ProcessWatchConfig{ + Enabled: true, + CPUThreshold: 100, + Window: "5m0s", + }, + ProcessAlerts: []ProcessAlert{{ + PID: 99, + Name: "node", + CPU: 140, + Threshold: 100, + Window: "5m0s", + Status: "active", + }}, + } + + data, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + out := string(data) + if !strings.Contains(out, "\"process_watch\"") { + t.Fatalf("missing process_watch in json: %s", out) + } + if !strings.Contains(out, "\"process_alerts\"") { + t.Fatalf("missing process_alerts in json: %s", out) + } +} diff --git a/Resources/mole/cmd/status/view.go b/Resources/mole/cmd/status/view.go index 217d53c..dbdc54d 100644 --- a/Resources/mole/cmd/status/view.go +++ b/Resources/mole/cmd/status/view.go @@ -3,10 +3,11 @@ package main import ( "fmt" "sort" - "strconv" "strings" "github.com/charmbracelet/lipgloss" + + "github.com/tw93/mole/internal/units" ) var ( @@ -17,7 +18,12 @@ var ( okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")) - primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) + primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) + alertBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2B1200")). + Background(lipgloss.Color("#FFD75F")). + Bold(true). + Padding(0, 1) ) const ( @@ -30,6 +36,14 @@ const ( iconBattery = "◪" iconSensors = "◈" iconProcs = "❊" + + metricLabelWidth = 6 + processMemoryWidth = 7 + processWideMinWidth = 46 + + // Stands in for a value that has not been measured yet, so a card keeps its + // shape from the first frame instead of growing when the data lands. + placeholderValue = "--" ) // Mole body frames (facing right). @@ -140,11 +154,15 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int scoreStyle := getScoreStyle(m.HealthScore) scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) + if errMsg == "" { + diagnosis := statusDiagnosisLine(m) + scoreText += " " + subtleStyle.Render(diagnosis) + } // Hardware info for a single line. - infoParts := []string{} + identityParts := []string{} if m.Hardware.Model != "" { - infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model)) + identityParts = append(identityParts, primaryStyle.Render(m.Hardware.Model)) } if m.Hardware.CPUModel != "" { cpuInfo := m.Hardware.CPUModel @@ -152,27 +170,45 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 { cpuInfo += fmt.Sprintf(", %dGPU", m.GPU[0].CoreCount) } - infoParts = append(infoParts, cpuInfo) + identityParts = append(identityParts, cpuInfo) } - var specs []string + specParts := []string{} if m.Hardware.TotalRAM != "" { - specs = append(specs, m.Hardware.TotalRAM) + specParts = append(specParts, "RAM "+m.Hardware.TotalRAM) + } else if m.Memory.Total > 0 { + specParts = append(specParts, "RAM "+humanBytes(m.Memory.Total)) } if m.Hardware.DiskSize != "" { - specs = append(specs, m.Hardware.DiskSize) - } - if len(specs) > 0 { - infoParts = append(infoParts, strings.Join(specs, "/")) + specParts = append(specParts, "Disk "+m.Hardware.DiskSize) + } else if disk, ok := rootDisk(m.Disks); ok && disk.Total > 0 { + specParts = append(specParts, "Disk "+humanBytes(disk.Total)) } + refreshParts := []string{} if m.Hardware.RefreshRate != "" { - infoParts = append(infoParts, m.Hardware.RefreshRate) + refreshParts = append(refreshParts, m.Hardware.RefreshRate) } optionalInfoParts := []string{} if !compactHeader && m.Hardware.OSVersion != "" { optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion) } if !compactHeader && m.Uptime != "" { - optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime)) + uptimeText := "up " + m.Uptime + switch uptimeSeverity(m.UptimeSeconds) { + case "danger": + uptimeText = dangerStyle.Render(uptimeText + " ↻") + case "warn": + uptimeText = warnStyle.Render(uptimeText) + default: + uptimeText = subtleStyle.Render(uptimeText) + } + optionalInfoParts = append(optionalInfoParts, uptimeText) + } + joinInfoParts := func(groups ...[]string) []string { + parts := []string{} + for _, group := range groups { + parts = append(parts, group...) + } + return parts } headLeft := title + " " + scoreText @@ -181,22 +217,42 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int headerLine = wrapToWidth(headLeft, termWidth)[0] } if termWidth > 0 { - allParts := append(append([]string{}, infoParts...), optionalInfoParts...) - if len(allParts) > 0 { - combined := headLeft + " " + strings.Join(allParts, " · ") + fitHeaderParts := func(parts []string) (string, bool) { + if len(parts) == 0 { + return "", false + } + combined := headLeft + " " + strings.Join(parts, " · ") if lipgloss.Width(combined) <= termWidth { - headerLine = combined - } else { - // When width is tight, drop lower-priority tail (OS and uptime) as a group. - fitParts := append([]string{}, infoParts...) - for len(fitParts) > 0 { - candidate := headLeft + " " + strings.Join(fitParts, " · ") - if lipgloss.Width(candidate) <= termWidth { - headerLine = candidate - break - } - fitParts = fitParts[:len(fitParts)-1] + return combined, true + } + return "", false + } + candidates := [][]string{ + joinInfoParts(identityParts, specParts, refreshParts, optionalInfoParts), + joinInfoParts(identityParts, specParts, refreshParts), + joinInfoParts(identityParts, specParts), + } + if len(identityParts) > 1 { + // Keep labeled RAM/Disk visible on narrow terminals before CPU details. + candidates = append(candidates, joinInfoParts(identityParts[:1], specParts)) + } + candidates = append(candidates, specParts) + for _, parts := range candidates { + if line, ok := fitHeaderParts(parts); ok { + headerLine = line + break + } + } + if headerLine == headLeft { + // Last resort: preserve the existing tail-drop behavior for unusual + // hardware strings that still do not fit the priority candidates. + fitParts := joinInfoParts(identityParts, specParts, refreshParts) + for len(fitParts) > 0 { + if line, ok := fitHeaderParts(fitParts); ok { + headerLine = line + break } + fitParts = fitParts[:len(fitParts)-1] } } } @@ -221,20 +277,47 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int func getScoreStyle(score int) lipgloss.Style { switch { - case score >= 90: + case score >= scoreExcellentThreshold: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true) - case score >= 75: + case score >= scoreGoodThreshold: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) - case score >= 60: + case score >= scoreFairThreshold: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) - case score >= 40: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) default: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) } } -func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { +func renderProcessAlertBar(alerts []ProcessAlert, width int) string { + active := activeAlerts(alerts) + if len(active) == 0 { + return "" + } + + focus := active[0] + + text := fmt.Sprintf( + "ALERT %s at %.1f%% for %s (threshold %.1f%%)", + formatProcessLabel(ProcessInfo{PID: focus.PID, Name: focus.Name}), + focus.CPU, + focus.Window, + focus.Threshold, + ) + if len(active) > 1 { + text += fmt.Sprintf(" · +%d more", len(active)-1) + } + + return renderBanner(alertBarStyle, text, width) +} + +func renderBanner(style lipgloss.Style, text string, width int) string { + if width > 0 { + style = style.MaxWidth(width) + } + return style.Render(text) +} + +func renderCPUCard(cpu CPUStatus, thermal ThermalStatus, cpuCores int) cardData { var lines []string // Line 1: Usage + Temp (Format: 15% @ 30.4°C) @@ -260,7 +343,12 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { } sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val }) - maxCores := min(len(cores), 3) + // cpuCores selects how many of the busiest cores to list; 0 (or any + // value >= the core count) means "all". Set via the 'c' key, persisted. + maxCores := len(cores) + if cpuCores > 0 { + maxCores = min(len(cores), cpuCores) + } for i := range maxCores { c := cores[i] lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val)) @@ -288,14 +376,16 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent)) // Line 2: Free - freePercent := 100 - mem.UsedPercent + var freePercent float64 + if mem.Total > 0 { + freePercent = (float64(mem.Available) / float64(mem.Total)) * 100.0 + } lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent)) if hasSwap { // Layout with Swap: // 3. Swap (progress bar + text) - // 4. Total - // 5. Avail + // 4. Total + Avail var swapPercent float64 if mem.SwapTotal > 0 { swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 @@ -311,25 +401,18 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { lines = append(lines, swapLine) } - lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) - lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency + lines = append(lines, formatMemoryDetailLine("Total", humanBytes(mem.Used)+" / "+humanBytes(mem.Total), mem.Available, cardWidth)) } else { // Layout without Swap: // 3. Total - // 4. Cached (if > 0) - // 5. Avail + // 4. Cache + Avail lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) if mem.Cached > 0 { - lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached))) + lines = append(lines, formatMemoryDetailLine("Cache", humanBytes(mem.Cached), mem.Available, cardWidth)) + } else { + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Available))) } - // Calculate available if not provided directly, or use Total-Used as proxy if needed, - // but typically available is more nuanced. Using what we have. - // Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail" - // in simple terms for this view or we could use the passed definition. - // Original code calculated: available := mem.Total - mem.Used - available := mem.Total - mem.Used - lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available))) } // Memory pressure status. if mem.Pressure != "" { @@ -346,7 +429,15 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { return cardData{icon: iconMemory, title: "Memory", lines: lines} } -func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData { +func formatMemoryDetailLine(label string, value string, available uint64, cardWidth int) string { + line := fmt.Sprintf("%-6s %s · Avail %s", label, value, humanBytes(available)) + if cardWidth <= 0 || lipgloss.Width(line) <= cardWidth { + return line + } + return fmt.Sprintf("%-6s %s · Avail %s", label, value, humanBytesCompact(available)) +} + +func renderDiskCard(disks []DiskStatus, io DiskIOStatus, _ uint64, _ bool) cardData { var lines []string if len(disks) == 0 { lines = append(lines, subtleStyle.Render("Collecting...")) @@ -365,12 +456,14 @@ func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData { addGroup("EXTR", external) if len(lines) == 0 { lines = append(lines, subtleStyle.Render("No disks detected")) + } else if len(disks) == 1 { + lines = append(lines, formatDiskMetaLine(disks[0])) + } + if smartLine := formatDiskSMARTLine(disks); smartLine != "" { + lines = append(lines, smartLine) } } - readBar := ioBar(io.ReadRate) - writeBar := ioBar(io.WriteRate) - lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate)) - lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate)) + lines = append(lines, formatDiskIOLine(io)) return cardData{icon: iconDisk, title: "Disk", lines: lines} } @@ -398,8 +491,67 @@ func formatDiskLine(label string, d DiskStatus) string { } bar := progressBar(d.UsedPercent) used := humanBytesShort(d.Used) - total := humanBytesShort(d.Total) - return fmt.Sprintf("%-6s %s %5.1f%%, %s/%s", label, bar, d.UsedPercent, used, total) + free := uint64(0) + if d.Total > d.Used { + free = d.Total - d.Used + } + return fmt.Sprintf("%-6s %s %s used, %s free", label, bar, used, humanBytesShort(free)) +} + +func formatDiskMetaLine(d DiskStatus) string { + parts := []string{humanBytesShort(d.Total)} + if d.Fstype != "" { + parts = append(parts, strings.ToUpper(d.Fstype)) + } + if d.Purgeable > 0 { + parts = append(parts, humanBytesShort(d.Purgeable)+" purgeable") + } + return fmt.Sprintf("Total %s", strings.Join(parts, " · ")) +} + +// formatDiskSMARTLine returns "" unless a disk is actually failing. +// +// SMART has one actionable state. "Verified" asks nothing of the user, and +// external enclosures usually do not pass SMART through at all, so a Mac with +// two USB disks rendered "SMART INTR OK · EXTR1 N/A · EXTR2 N/A": a row whose +// length grew with disk count, carrying no information, and the only row in the +// card wide enough to break the alignment of the ones above it. A failing disk +// still gets a full-width red line, and the health score already counts SMART +// failures either way. +func formatDiskSMARTLine(disks []DiskStatus) string { + failingLabels := make([]string, 0, len(disks)) + internal, external := splitDisks(disks) + collectFailing := func(prefix string, list []DiskStatus) { + for index, disk := range list { + if disk.SmartStatus != smartStatusFailing { + continue + } + if len(disks) == 1 { + failingLabels = append(failingLabels, dangerStyle.Render("Failing")) + continue + } + failingLabels = append(failingLabels, + diskLabel(prefix, index, len(list))+" "+dangerStyle.Render("FAIL")) + } + } + collectFailing("INTR", internal) + collectFailing("EXTR", external) + if len(failingLabels) == 0 { + return "" + } + + failingLabels = append(failingLabels, dangerStyle.Render("Back up now")) + return fmt.Sprintf("%-*s %s", metricLabelWidth, "SMART", strings.Join(failingLabels, " · ")) +} + +func formatDiskIOLine(io DiskIOStatus) string { + text := fmt.Sprintf("%s R %s · %s W %s MB/s", + ioBar(io.ReadRate), + formatRateCompact(io.ReadRate), + ioBar(io.WriteRate), + formatRateCompact(io.WriteRate), + ) + return fmt.Sprintf("%-*s %s", metricLabelWidth, "I/O", text) } func ioBar(rate float64) string { @@ -414,30 +566,62 @@ func ioBar(rate float64) string { return okStyle.Render(bar) } -func renderProcessCard(procs []ProcessInfo) cardData { +func renderProcessCard(procs []ProcessInfo, cardWidth int) cardData { var lines []string maxProcs := 3 for i, p := range procs { if i >= maxProcs { break } - name := shorten(p.Name, 12) - cpuBar := miniBar(p.CPU) - lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU)) + rank := fmt.Sprintf("#%d", i+1) + cpuBar := processBar(p.CPU, cardWidth) + line := fmt.Sprintf( + "%-*s %s %5.1f%% %*s", + metricLabelWidth, + rank, + cpuBar, + p.CPU, + processMemoryWidth, + processMemoryText(p), + ) + if nameWidth := remainingLineWidth(cardWidth, line); nameWidth > 0 { + line += " " + shorten(p.Name, nameWidth) + } + lines = append(lines, strings.TrimRight(line, " ")) } if len(lines) == 0 { - lines = append(lines, subtleStyle.Render("No data")) + lines = append(lines, subtleStyle.Render("Collecting...")) } return cardData{icon: iconProcs, title: "Processes", lines: lines} } -func buildCards(m MetricsSnapshot, width int) []cardData { +func processBar(percent float64, cardWidth int) string { + if cardWidth >= processWideMinWidth { + return progressBar(percent) + } + return miniBar(percent) +} + +func processMemoryText(p ProcessInfo) string { + if p.MemoryBytes > 0 { + return humanBytesCompact(p.MemoryBytes) + } + if p.Memory >= 10 { + return fmt.Sprintf("M%.0f%%", p.Memory) + } + return "" +} + +// buildCards renders every card. batteryProbed says whether a full collection +// has completed at least once; until it has, an empty battery list means "not +// measured yet", not "this Mac has no battery". +func buildCards(m MetricsSnapshot, width int, cpuCores int, batteryProbed bool) []cardData { cards := []cardData{ - renderCPUCard(m.CPU, m.Thermal), + renderCPUCard(m.CPU, m.Thermal, cpuCores), renderMemoryCard(m.Memory, width), - renderDiskCard(m.Disks, m.DiskIO), - renderBatteryCard(m.Batteries, m.Thermal), - renderProcessCard(m.TopProcesses), + renderDiskCard(m.Disks, m.DiskIO, m.TrashSize, m.TrashApprox), + renderBatteryCard(m.Batteries, m.Thermal, batteryProbed), + renderProcessCard(m.TopProcesses, width), renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width), } // Sensors card disabled - redundant with CPU temp @@ -466,7 +650,7 @@ func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy P } if len(netStats) == 0 { - lines = []string{subtleStyle.Render("Collecting...")} + lines = append(lines, subtleStyle.Render("Collecting...")) } else { // Calculate dynamic width // Layout: "Down " (7) + graph + " " (2) + rate (approx 10-12) @@ -482,7 +666,11 @@ func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy P // Show proxy and IP on one line. var infoParts []string if proxy.Enabled { - infoParts = append(infoParts, "Proxy "+proxy.Type) + if proxy.IsTunnel { + infoParts = append(infoParts, "Tunnel") + } else { + infoParts = append(infoParts, "Proxy "+proxy.Type) + } } if primaryIP != "" { infoParts = append(infoParts, primaryIP) @@ -541,9 +729,20 @@ func sparkline(history []float64, current float64, width int) string { return okStyle.Render(result) } -func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { +func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus, probed bool) cardData { var lines []string - if len(batts) == 0 { + if len(batts) == 0 && !probed { + // The first collection is the fast one, and it does not read batteries. + // Saying "No battery" here told every laptop it had none for the couple + // of seconds before the first full collection landed. Hold the card's + // shape with placeholders instead, so the real values replace them + // without the layout jumping. + lines = append(lines, + fmt.Sprintf("Level %s %6s", batteryProgressBar(0), placeholderValue), + fmt.Sprintf("Health %s %6s", batteryProgressBar(0), placeholderValue), + subtleStyle.Render(placeholderValue), + ) + } else if len(batts) == 0 { lines = append(lines, subtleStyle.Render("No battery")) } else { b := batts[0] @@ -565,44 +764,54 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText)) } - statusIcon := "" + if thermal.AdapterPower > 0 && isPoweredByAC(statusLower) { + lines = append(lines, fmt.Sprintf("%-6s %s %6s", + "Input", + okStyle.Render(plainProgressBar(100)), + fmt.Sprintf("%.0fW max", thermal.AdapterPower), + )) + } + statusStyle := subtleStyle - if statusLower == "charging" || statusLower == "charged" { - statusIcon = " ⚡" + if isPoweredByAC(statusLower) { statusStyle = okStyle } else if b.Percent < 20 { statusStyle = dangerStyle } - statusText := b.Status - if len(statusText) > 0 { - statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:]) - } - if b.TimeLeft != "" { + statusText := formatBatteryStatus(b.Status) + if b.TimeLeft != "" && b.TimeLeft != "0:00" { statusText += " · " + b.TimeLeft } - // Add power info. - if statusLower == "charging" || statusLower == "charged" { - if thermal.SystemPower > 0 { - statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower) - } else if thermal.AdapterPower > 0 { - statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower) - } - } else if thermal.BatteryPower > 0 { - // Only show battery power when discharging (positive value) - statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower) - } - lines = append(lines, statusStyle.Render(statusText+statusIcon)) healthParts := []string{} - if b.Health != "" { + + // Battery health assessment label. + if b.CycleCount > 0 || b.Capacity > 0 { + label, severity := batteryHealthLabel(b.CycleCount, b.Capacity) + switch severity { + case "danger": + healthParts = append(healthParts, dangerStyle.Render(label)) + case "warn": + healthParts = append(healthParts, warnStyle.Render(label)) + default: + healthParts = append(healthParts, okStyle.Render(label)) + } + } else if b.Health != "" { healthParts = append(healthParts, b.Health) } + if b.CycleCount > 0 { - healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount)) + cycleText := fmt.Sprintf("%d cycles", b.CycleCount) + if b.CycleCount > batteryCycleDanger { + cycleText = dangerStyle.Render(cycleText) + } else if b.CycleCount > batteryCycleWarn { + cycleText = warnStyle.Render(cycleText) + } + healthParts = append(healthParts, cycleText) } - if thermal.CPUTemp > 0 { - tempText := colorizeTemp(thermal.CPUTemp) + "°C" // Reuse common color logic + if thermal.BatteryTemp > 0 { + tempText := colorizeTemp(thermal.BatteryTemp) + "°C" healthParts = append(healthParts, tempText) } @@ -610,15 +819,40 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed)) } - if len(healthParts) > 0 { - lines = append(lines, strings.Join(healthParts, " · ")) - } + summaryParts := append([]string{statusStyle.Render(statusText)}, healthParts...) + lines = append(lines, strings.Join(summaryParts, " · ")) } return cardData{icon: iconBattery, title: "Power", lines: lines} } -func renderCard(data cardData, width int, height int) string { +func isPoweredByAC(statusLower string) bool { + return statusLower == "charging" || + statusLower == "charged" || + statusLower == "ac" || + strings.Contains(statusLower, "ac attached") +} + +func formatBatteryStatus(status string) string { + status = strings.TrimSpace(status) + if status == "" { + return "Unknown" + } + lower := strings.ToLower(status) + switch lower { + case "ac": + return "AC" + case "charged": + return "Charged" + case "charging": + return "Charging" + case "discharging": + return "Discharging" + } + return strings.ToUpper(status[:1]) + strings.ToLower(status[1:]) +} + +func renderCard(data cardData, width int) string { if width <= 0 { width = colWidth } @@ -636,9 +870,6 @@ func renderCard(data cardData, width int, height int) string { lines = append(lines, wrapToWidth(line, width)...) } - for len(lines) < height { - lines = append(lines, "") - } return strings.Join(lines, "\n") } @@ -651,6 +882,10 @@ func wrapToWidth(text string, width int) []string { } func progressBar(percent float64) string { + return colorizePercent(percent, plainProgressBar(percent)) +} + +func plainProgressBar(percent float64) string { total := 16 if percent < 0 { percent = 0 @@ -668,7 +903,7 @@ func progressBar(percent float64) string { builder.WriteString("░") } } - return colorizePercent(percent, builder.String()) + return builder.String() } func batteryProgressBar(percent float64) string { @@ -716,9 +951,9 @@ func colorizeBattery(percent float64, s string) string { func colorizeTemp(t float64) string { switch { - case t >= 76: + case t >= thermalHighThreshold: return dangerStyle.Render(fmt.Sprintf("%.1f", t)) - case t >= 56: + case t >= thermalNormalThreshold: return warnStyle.Render(fmt.Sprintf("%.1f", t)) default: return okStyle.Render(fmt.Sprintf("%.1f", t)) @@ -738,49 +973,26 @@ func formatRate(mb float64) string { return fmt.Sprintf("%.0f MB/s", mb) } -func humanBytes(v uint64) string { - switch { - case v > 1<<40: - return fmt.Sprintf("%.1f TB", float64(v)/(1<<40)) - case v > 1<<30: - return fmt.Sprintf("%.1f GB", float64(v)/(1<<30)) - case v > 1<<20: - return fmt.Sprintf("%.1f MB", float64(v)/(1<<20)) - case v > 1<<10: - return fmt.Sprintf("%.1f KB", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) + " B" +func formatRateCompact(mb float64) string { + if mb < 0.01 { + return "0" } + if mb < 10 { + return fmt.Sprintf("%.1f", mb) + } + return fmt.Sprintf("%.0f", mb) +} + +func humanBytes(v uint64) string { + return units.BytesBin(v) } func humanBytesShort(v uint64) string { - switch { - case v >= 1<<40: - return fmt.Sprintf("%.0fT", float64(v)/(1<<40)) - case v >= 1<<30: - return fmt.Sprintf("%.0fG", float64(v)/(1<<30)) - case v >= 1<<20: - return fmt.Sprintf("%.0fM", float64(v)/(1<<20)) - case v >= 1<<10: - return fmt.Sprintf("%.0fK", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) - } + return units.BytesBinShort(v) } func humanBytesCompact(v uint64) string { - switch { - case v >= 1<<40: - return fmt.Sprintf("%.1fT", float64(v)/(1<<40)) - case v >= 1<<30: - return fmt.Sprintf("%.1fG", float64(v)/(1<<30)) - case v >= 1<<20: - return fmt.Sprintf("%.1fM", float64(v)/(1<<20)) - case v >= 1<<10: - return fmt.Sprintf("%.1fK", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) - } + return units.BytesBinCompact(v) } func shorten(s string, maxLen int) string { @@ -790,6 +1002,54 @@ func shorten(s string, maxLen int) string { return s[:maxLen-1] + "…" } +func remainingLineWidth(width int, prefix string) int { + if width <= 0 { + width = colWidth + } + return max(width-lipgloss.Width(prefix)-1, 0) +} + +// columnRow is one row of the two-column layout: a left cell and a right cell, +// each a vertical stack of one or more cards. +type columnRow struct { + left []cardData + right []cardData +} + +// layoutColumnRows groups cards into rows so section titles stay aligned across +// the two columns. Each row seeds its left cell with one card, then stacks cards +// into the right cell until it is at least as tall as the left, so a tall card +// (e.g. CPU listing many cores) is matched by several short cards (GPU, Memory, +// ...) beside it instead of leaving a gap. The shorter cell is padded to the row +// height at render time, which keeps the next row's titles aligned. Heights are +// measured at column width cw, so the grouping adapts live as cards grow/shrink. +func layoutColumnRows(cards []cardData, cw int) []columnRow { + stackHeight := func(cs []cardData) int { + h := 0 + for i, c := range cs { + if i > 0 { + h++ // blank separator between stacked cards + } + h += lipgloss.Height(renderCard(c, cw)) + } + return h + } + + var rows []columnRow + i := 0 + for i < len(cards) { + row := columnRow{left: []cardData{cards[i]}} + i++ + leftH := stackHeight(row.left) + for i < len(cards) && stackHeight(row.right) < leftH { + row.right = append(row.right, cards[i]) + i++ + } + rows = append(rows, row) + } + return rows +} + func renderTwoColumns(cards []cardData, width int) string { if len(cards) == 0 { return "" @@ -798,29 +1058,58 @@ func renderTwoColumns(cards []cardData, width int) string { if width > 0 && width/2-2 > cw { cw = width/2 - 2 } - var rows []string - for i := 0; i < len(cards); i += 2 { - left := renderCard(cards[i], cw, 0) - right := "" - if i+1 < len(cards) { - right = renderCard(cards[i+1], cw, 0) + + renderCell := func(cs []cardData) string { + var parts []string + for i, c := range cs { + if i > 0 { + parts = append(parts, "") + } + parts = append(parts, renderCard(c, cw)) } - targetHeight := max(lipgloss.Height(left), lipgloss.Height(right)) - left = renderCard(cards[i], cw, targetHeight) - if right != "" { - right = renderCard(cards[i+1], cw, targetHeight) + return lipgloss.JoinVertical(lipgloss.Left, parts...) + } + + renderRows := func(layout []columnRow) string { + var rows []string + for _, row := range layout { + left := renderCell(row.left) + if len(row.right) == 0 { + rows = append(rows, left) + continue + } + right := renderCell(row.right) + // JoinHorizontal (Top) pads the shorter cell to the taller, so the next + // row's titles line up in both columns. rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)) - } else { - rows = append(rows, left) } + + var spaced []string + for i, row := range rows { + if i > 0 { + spaced = append(spaced, "") + } + spaced = append(spaced, row) + } + return lipgloss.JoinVertical(lipgloss.Left, spaced...) } - var spacedRows []string - for i, r := range rows { - if i > 0 { - spacedRows = append(spacedRows, "") + var fixedLayout []columnRow + for i := 0; i < len(cards); i += 2 { + row := columnRow{left: []cardData{cards[i]}} + if i+1 < len(cards) { + row.right = []cardData{cards[i+1]} } - spacedRows = append(spacedRows, r) + fixedLayout = append(fixedLayout, row) + } + + fixed := renderRows(fixedLayout) + stacked := renderRows(layoutColumnRows(cards, cw)) + // Dynamic stacking is useful for a tall CPU card, but a greedy stack can + // overshoot a shorter row and grow the whole dashboard. Keep the stable + // pair layout unless stacking saves vertical space. + if lipgloss.Height(stacked) < lipgloss.Height(fixed) { + return stacked } - return lipgloss.JoinVertical(lipgloss.Left, spacedRows...) + return fixed } diff --git a/Resources/mole/cmd/status/view_test.go b/Resources/mole/cmd/status/view_test.go index d49f72b..a10df01 100644 --- a/Resources/mole/cmd/status/view_test.go +++ b/Resources/mole/cmd/status/view_test.go @@ -18,17 +18,17 @@ func TestFormatRate(t *testing.T) { {"tiny", 0.001, "0 MB/s"}, {"just under threshold", 0.009, "0 MB/s"}, - // Small rates (0.01 to < 1) — 2 decimal places. + // Small rates (0.01 to < 1): 2 decimal places. {"at threshold", 0.01, "0.01 MB/s"}, {"small rate", 0.5, "0.50 MB/s"}, {"just under 1", 0.99, "0.99 MB/s"}, - // Medium rates (1 to < 10) — 1 decimal place. + // Medium rates (1 to < 10): 1 decimal place. {"exactly 1", 1.0, "1.0 MB/s"}, {"medium rate", 5.5, "5.5 MB/s"}, {"just under 10", 9.9, "9.9 MB/s"}, - // Large rates (>= 10) — no decimal places. + // Large rates (>= 10): no decimal places. {"exactly 10", 10.0, "10 MB/s"}, {"large rate", 100.5, "100 MB/s"}, {"very large", 1000.0, "1000 MB/s"}, @@ -164,126 +164,23 @@ func TestShorten(t *testing.T) { } } +// Core byte-format coverage lives in internal/units; these are wiring sanity +// checks to ensure the cmd/status helpers still delegate to that package. func TestHumanBytesShort(t *testing.T) { - tests := []struct { - name string - input uint64 - want string - }{ - // Zero and small values. - {"zero", 0, "0"}, - {"one byte", 1, "1"}, - {"999 bytes", 999, "999"}, - - // Kilobyte boundaries. - {"exactly 1KB", 1 << 10, "1K"}, - {"just under 1KB", (1 << 10) - 1, "1023"}, - {"1.5KB rounds to 2K", 1536, "2K"}, - {"999KB", 999 << 10, "999K"}, - - // Megabyte boundaries. - {"exactly 1MB", 1 << 20, "1M"}, - {"just under 1MB", (1 << 20) - 1, "1024K"}, - {"500MB", 500 << 20, "500M"}, - - // Gigabyte boundaries. - {"exactly 1GB", 1 << 30, "1G"}, - {"just under 1GB", (1 << 30) - 1, "1024M"}, - {"100GB", 100 << 30, "100G"}, - - // Terabyte boundaries. - {"exactly 1TB", 1 << 40, "1T"}, - {"just under 1TB", (1 << 40) - 1, "1024G"}, - {"2TB", 2 << 40, "2T"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := humanBytesShort(tt.input) - if got != tt.want { - t.Errorf("humanBytesShort(%d) = %q, want %q", tt.input, got, tt.want) - } - }) + if got := humanBytesShort(100 << 30); got != "100G" { + t.Errorf("humanBytesShort(100<<30) = %q, want %q", got, "100G") } } func TestHumanBytes(t *testing.T) { - tests := []struct { - name string - input uint64 - want string - }{ - // Zero and small values. - {"zero", 0, "0 B"}, - {"one byte", 1, "1 B"}, - {"1023 bytes", 1023, "1023 B"}, - - // Kilobyte boundaries (uses > not >=). - {"exactly 1KB", 1 << 10, "1024 B"}, - {"just over 1KB", (1 << 10) + 1, "1.0 KB"}, - {"1.5KB", 1536, "1.5 KB"}, - - // Megabyte boundaries (uses > not >=). - {"exactly 1MB", 1 << 20, "1024.0 KB"}, - {"just over 1MB", (1 << 20) + 1, "1.0 MB"}, - {"500MB", 500 << 20, "500.0 MB"}, - - // Gigabyte boundaries (uses > not >=). - {"exactly 1GB", 1 << 30, "1024.0 MB"}, - {"just over 1GB", (1 << 30) + 1, "1.0 GB"}, - {"100GB", 100 << 30, "100.0 GB"}, - - // Terabyte boundaries (uses > not >=). - {"exactly 1TB", 1 << 40, "1024.0 GB"}, - {"just over 1TB", (1 << 40) + 1, "1.0 TB"}, - {"2TB", 2 << 40, "2.0 TB"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := humanBytes(tt.input) - if got != tt.want { - t.Errorf("humanBytes(%d) = %q, want %q", tt.input, got, tt.want) - } - }) + if got := humanBytes((1 << 20) + 1); got != "1.0 MB" { + t.Errorf("humanBytes(1MB+1) = %q, want %q", got, "1.0 MB") } } func TestHumanBytesCompact(t *testing.T) { - tests := []struct { - name string - input uint64 - want string - }{ - // Zero and small values. - {"zero", 0, "0"}, - {"one byte", 1, "1"}, - {"1023 bytes", 1023, "1023"}, - - // Kilobyte boundaries (uses >= not >). - {"exactly 1KB", 1 << 10, "1.0K"}, - {"1.5KB", 1536, "1.5K"}, - - // Megabyte boundaries. - {"exactly 1MB", 1 << 20, "1.0M"}, - {"500MB", 500 << 20, "500.0M"}, - - // Gigabyte boundaries. - {"exactly 1GB", 1 << 30, "1.0G"}, - {"100GB", 100 << 30, "100.0G"}, - - // Terabyte boundaries. - {"exactly 1TB", 1 << 40, "1.0T"}, - {"2TB", 2 << 40, "2.0T"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := humanBytesCompact(tt.input) - if got != tt.want { - t.Errorf("humanBytesCompact(%d) = %q, want %q", tt.input, got, tt.want) - } - }) + if got := humanBytesCompact(1536); got != "1.5K" { + t.Errorf("humanBytesCompact(1536) = %q, want %q", got, "1.5K") } } @@ -351,11 +248,11 @@ func TestDiskLabel(t *testing.T) { total int want string }{ - // Single disk — no numbering. + // Single disk: no numbering. {"single disk", "INTR", 0, 1, "INTR"}, {"single external", "EXTR", 0, 1, "EXTR"}, - // Multiple disks — numbered (1-indexed). + // Multiple disks: numbered (1-indexed). {"first of two", "INTR", 0, 2, "INTR1"}, {"second of two", "INTR", 1, 2, "INTR2"}, {"third of three", "EXTR", 2, 3, "EXTR3"}, @@ -431,7 +328,7 @@ func TestParseRefreshRate(t *testing.T) { {"decimal Hz", "Resolution: 3840x2160 @ 59.94Hz", "59Hz"}, {"ProMotion", "Resolution: 3456x2234 @ 120.00Hz", "120Hz"}, - // Multiple lines — picks highest valid. + // Multiple lines: picks highest valid. {"multiple rates", "Display 1: 60Hz\nDisplay 2: 120Hz", "120Hz"}, // Edge cases. @@ -658,6 +555,49 @@ func TestBatteryProgressBar(t *testing.T) { } } +func TestRenderBatteryCardShowsAdapterInputOnly(t *testing.T) { + card := renderBatteryCard([]BatteryStatus{{ + Percent: 80, + Status: "AC", + Capacity: 100, + CycleCount: 4, + }}, ThermalStatus{ + BatteryTemp: 30.7, + AdapterPower: 94, + }, true) + + var joined []string + for _, line := range card.lines { + joined = append(joined, stripANSI(line)) + } + got := strings.Join(joined, "\n") + + if len(card.lines) != 4 { + t.Fatalf("expected compact 4-line power card, got %d lines:\n%s", len(card.lines), got) + } + if !strings.Contains(got, "Input") || !strings.Contains(got, "94W max") { + t.Fatalf("expected input line with adapter max watts, got:\n%s", got) + } + if strings.Contains(got, "Draw") || strings.Contains(got, "Charge") { + t.Fatalf("expected no live draw or charge watt row, got:\n%s", got) + } + if strings.Contains(got, "94W adapter") { + t.Fatalf("expected adapter watts only on the Input line, got:\n%s", got) + } + if !strings.Contains(got, "AC · Healthy · 4 cycles · 30.7°C") { + t.Fatalf("expected compact AC health summary, got:\n%s", got) + } + if strings.Contains(got, "Battery 30.7°C") { + t.Fatalf("expected compact temperature without Battery prefix, got:\n%s", got) + } + if strings.Contains(got, "Ac") { + t.Fatalf("expected AC to stay uppercase, got:\n%s", got) + } + if strings.Contains(got, "⚡") { + t.Fatalf("expected no charging glyph, got:\n%s", got) + } +} + func TestColorizeTemp(t *testing.T) { tests := []struct { name string @@ -665,12 +605,12 @@ func TestColorizeTemp(t *testing.T) { }{ {"very low", 20.0}, {"low", 40.0}, - {"normal threshold", 55.9}, - {"at warn threshold", 56.0}, - {"warn range", 65.0}, - {"just below danger", 75.9}, - {"at danger threshold", 76.0}, - {"high", 85.0}, + {"normal range", 55.0}, + {"at warn threshold", 65.0}, + {"warn range", 75.0}, + {"just below danger", 84.9}, + {"at danger threshold", 85.0}, + {"high", 92.0}, {"very high", 95.0}, } @@ -684,64 +624,64 @@ func TestColorizeTemp(t *testing.T) { } } -func TestIoBar(t *testing.T) { +func TestMiniBar(t *testing.T) { tests := []struct { - name string - rate float64 + name string + percent float64 }{ {"zero", 0}, - {"very low", 5}, - {"low normal", 20}, - {"at warn threshold", 30}, - {"warn range", 50}, - {"just below danger", 79}, - {"at danger threshold", 80}, - {"high", 100}, - {"very high", 200}, + {"negative", -5}, + {"low", 15}, + {"at first step", 20}, + {"mid", 50}, + {"high", 75}, + {"full", 100}, + {"over 100", 120}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ioBar(tt.rate) + got := miniBar(tt.percent) if got == "" { - t.Errorf("ioBar(%v) returned empty string", tt.rate) + t.Errorf("miniBar(%v) returned empty string", tt.percent) return } gotClean := stripANSI(got) gotRuneCount := len([]rune(gotClean)) if gotRuneCount != 5 { - t.Errorf("ioBar(%v) rune count = %d, want 5", tt.rate, gotRuneCount) + t.Errorf("miniBar(%v) rune count = %d, want 5", tt.percent, gotRuneCount) } }) } } -func TestMiniBar(t *testing.T) { +func TestIoBar(t *testing.T) { tests := []struct { - name string - percent float64 + name string + rate float64 }{ {"zero", 0}, - {"negative", -5}, - {"low", 15}, - {"at first step", 20}, - {"mid", 50}, - {"high", 75}, - {"full", 100}, - {"over 100", 120}, + {"very low", 5}, + {"low normal", 20}, + {"at warn threshold", 30}, + {"warn range", 50}, + {"just below danger", 79}, + {"at danger threshold", 80}, + {"high", 100}, + {"very high", 200}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := miniBar(tt.percent) + got := ioBar(tt.rate) if got == "" { - t.Errorf("miniBar(%v) returned empty string", tt.percent) + t.Errorf("ioBar(%v) returned empty string", tt.rate) return } gotClean := stripANSI(got) gotRuneCount := len([]rune(gotClean)) if gotRuneCount != 5 { - t.Errorf("miniBar(%v) rune count = %d, want 5", tt.percent, gotRuneCount) + t.Errorf("ioBar(%v) rune count = %d, want 5", tt.rate, gotRuneCount) } }) } @@ -749,29 +689,52 @@ func TestMiniBar(t *testing.T) { func TestFormatDiskLine(t *testing.T) { tests := []struct { - name string - label string - disk DiskStatus + name string + label string + disk DiskStatus + wantUsed string + wantFree string + wantNoSubstr string }{ { - name: "empty label defaults to DISK", - label: "", - disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + name: "empty label defaults to DISK", + label: "", + disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + wantUsed: "100G used", + wantFree: "100G free", + wantNoSubstr: "%", }, { - name: "internal disk", - label: "INTR", - disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + name: "internal disk", + label: "INTR", + disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + wantUsed: "336G used", + wantFree: "164G free", + wantNoSubstr: "%", }, { - name: "external disk", - label: "EXTR1", - disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + name: "external disk", + label: "EXTR1", + disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + wantUsed: "850G used", + wantFree: "150G free", + wantNoSubstr: "%", }, { - name: "low usage", - label: "INTR", - disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + name: "low usage", + label: "INTR", + disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + wantUsed: "15G used", + wantFree: "85G free", + wantNoSubstr: "%", + }, + { + name: "used exceeds total clamps free to zero", + label: "INTR", + disk: DiskStatus{UsedPercent: 110.0, Used: 110 << 30, Total: 100 << 30}, + wantUsed: "110G used", + wantFree: "0 free", + wantNoSubstr: "%", }, } @@ -786,13 +749,182 @@ func TestFormatDiskLine(t *testing.T) { if expectedLabel == "" { expectedLabel = "DISK" } - if !contains(got, expectedLabel) { + if !strings.Contains(got, expectedLabel) { t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel) } + if !strings.Contains(got, tt.wantUsed) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain used value %q", tt.label, got, tt.wantUsed) + } + if !strings.Contains(got, tt.wantFree) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain free value %q", tt.label, got, tt.wantFree) + } + if tt.wantNoSubstr != "" && strings.Contains(got, tt.wantNoSubstr) { + t.Errorf("formatDiskLine(%q, ...) = %q, should not contain %q", tt.label, got, tt.wantNoSubstr) + } }) } } +func TestRenderDiskCardAddsMetaLineForSingleDisk(t *testing.T) { + card := renderDiskCard([]DiskStatus{{ + UsedPercent: 28.4, + Used: 263 << 30, + Total: 926 << 30, + Fstype: "apfs", + }}, DiskIOStatus{ReadRate: 0, WriteRate: 0.1}, 0, false) + + if len(card.lines) != 3 { + t.Fatalf("renderDiskCard() single disk expected 3 lines, got %d", len(card.lines)) + } + + meta := stripANSI(card.lines[1]) + if meta != "Total 926G · APFS" { + t.Fatalf("renderDiskCard() single disk meta line = %q, want %q", meta, "Total 926G · APFS") + } +} + +func TestRenderDiskCardMetaLineShowsPurgeable(t *testing.T) { + card := renderDiskCard([]DiskStatus{{ + UsedPercent: 64.9, + Used: 1226 << 30, + Total: 926 << 30, + Fstype: "apfs", + Purgeable: 141 << 30, + }}, DiskIOStatus{}, 0, false) + + meta := stripANSI(card.lines[1]) + if meta != "Total 926G · APFS · 141G purgeable" { + t.Fatalf("renderDiskCard() meta line = %q, want %q", meta, "Total 926G · APFS · 141G purgeable") + } +} + +func TestRenderDiskCardDoesNotAddMetaLineForMultipleDisks(t *testing.T) { + card := renderDiskCard([]DiskStatus{ + {UsedPercent: 28.4, Used: 263 << 30, Total: 926 << 30, Fstype: "apfs"}, + {UsedPercent: 50.0, Used: 500 << 30, Total: 1000 << 30, Fstype: "apfs"}, + }, DiskIOStatus{}, 0, false) + + if len(card.lines) != 3 { + t.Fatalf("renderDiskCard() multiple disks expected 3 lines, got %d", len(card.lines)) + } + + for _, line := range card.lines { + if stripANSI(line) == "Total 926G · APFS" || stripANSI(line) == "Total 1000G · APFS" { + t.Fatalf("renderDiskCard() multiple disks should not add meta line, got %q", line) + } + } +} + +func TestRenderDiskCardOmitsTrashFromMainView(t *testing.T) { + disk := DiskStatus{UsedPercent: 50, Used: 500 << 30, Total: 1000 << 30, Fstype: "apfs"} + tests := []struct { + name string + trashSize uint64 + approx bool + }{ + {"no trash", 0, false}, + {"1.5 GB exact", 1536 << 20, false}, + {"approx 12 GB", 12 << 30, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + card := renderDiskCard([]DiskStatus{disk}, DiskIOStatus{}, tt.trashSize, tt.approx) + ioLine := "" + trashLine := "" + for _, line := range card.lines { + s := stripANSI(line) + if strings.HasPrefix(s, "I/O") { + ioLine = s + } + if strings.HasPrefix(s, "Trash") { + trashLine = s + } + } + if ioLine == "" { + t.Fatal("expected I/O line") + } + if trashLine != "" { + t.Fatalf("expected no trash line in main view, got %q", trashLine) + } + }) + } +} + +func TestRenderDiskCardUsesGraphicIOLine(t *testing.T) { + card := renderDiskCard([]DiskStatus{ + {UsedPercent: 50.0, Used: 500 << 30, Total: 1000 << 30}, + {UsedPercent: 95.0, Used: 18 << 30, Total: 18<<30 + 472<<20, External: true}, + {UsedPercent: 95.0, Used: 16 << 30, Total: 16<<30 + 444<<20, External: true}, + }, DiskIOStatus{ReadRate: 0, WriteRate: 24.6}, 101<<20, false) + + if len(card.lines) != 4 { + t.Fatalf("renderDiskCard() expected 4 lines without trash, got %d", len(card.lines)) + } + if got := stripANSI(card.lines[3]); got != "I/O ▯▯▯▯▯ R 0 · ▮▮▯▯▯ W 25 MB/s" { + t.Fatalf("I/O line = %q", got) + } +} + +// SMART earns a row only when a disk is failing. "Verified" needs no action, +// and USB enclosures rarely pass SMART through, so healthy machines used to +// carry a row that said nothing and grew with every disk attached. +func TestRenderDiskCardShowsSMARTOnlyWhenFailing(t *testing.T) { + healthy := renderDiskCard([]DiskStatus{ + {UsedPercent: 30, Used: 30 << 30, Total: 100 << 30, SmartStatus: smartStatusVerified}, + {UsedPercent: 20, Used: 20 << 30, Total: 100 << 30, External: true, SmartStatus: smartStatusUnsupported}, + }, DiskIOStatus{}, 0, false) + for _, line := range healthy.lines { + if strings.Contains(stripANSI(line), "SMART") { + t.Fatalf("healthy disks should not render a SMART row, got %q", stripANSI(line)) + } + } + + failing := renderDiskCard([]DiskStatus{{ + UsedPercent: 30, + Used: 30 << 30, + Total: 100 << 30, + SmartStatus: smartStatusFailing, + }}, DiskIOStatus{}, 0, false) + var smartLine string + for _, line := range failing.lines { + if strings.Contains(stripANSI(line), "SMART") { + smartLine = stripANSI(line) + } + } + if smartLine == "" { + t.Fatal("a failing disk must still render a SMART row") + } + if !strings.Contains(smartLine, "Failing") || !strings.Contains(smartLine, "Back up now") { + t.Fatalf("failing SMART row must name the state and the action, got %q", smartLine) + } +} + +func TestRenderDiskCardHighlightsFailingSMARTAndFitsNarrowWidth(t *testing.T) { + card := renderDiskCard([]DiskStatus{ + {UsedPercent: 30, Used: 30 << 30, Total: 100 << 30, SmartStatus: smartStatusFailing}, + {UsedPercent: 20, Used: 20 << 30, Total: 100 << 30, External: true, SmartStatus: smartStatusUnknown}, + }, DiskIOStatus{}, 0, false) + + smartLine := card.lines[2] + if !strings.Contains(smartLine, dangerStyle.Render("FAIL")) || + !strings.Contains(smartLine, dangerStyle.Render("Back up now")) { + t.Fatalf("failing SMART line lacks danger styling or backup hint: %q", smartLine) + } + + const narrowWidth = 38 + rendered := renderCard(card, narrowWidth) + for line := range strings.Lines(rendered) { + if lipgloss.Width(stripANSI(line)) > narrowWidth { + t.Fatalf("narrow disk card line exceeds %d columns: %q", narrowWidth, line) + } + } + if plain := stripANSI(rendered); !strings.Contains(plain, "Back up now") { + t.Fatalf("narrow disk card lost backup hint: %q", plain) + } else if !strings.Contains(plain, "FAIL") { + t.Fatalf("narrow disk card lost failing status: %q", plain) + } +} + func TestGetScoreStyle(t *testing.T) { tests := []struct { name string @@ -936,6 +1068,177 @@ func TestRenderHeaderErrorReturnsMoleOnce(t *testing.T) { } } +func TestStatusDiagnosisLinePrioritizesFailingSMART(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 95}, + Disks: []DiskStatus{{SmartStatus: smartStatusFailing}}, + } + + if got := statusDiagnosisLine(m); got != "SMART failing, back up now" { + t.Fatalf("statusDiagnosisLine() = %q", got) + } +} + +func TestStatusDiagnosisLineUsesTopCPUProcess(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 95}, + TopProcesses: []ProcessInfo{ + {Name: "Safari", CPU: 12}, + {Name: "Xcode", CPU: 82}, + }, + } + + got := statusDiagnosisLine(m) + if got != "Xcode high CPU" { + t.Fatalf("statusDiagnosisLine() = %q, want top CPU process", got) + } +} + +func TestStatusDiagnosisLineUsesMemoryContributorWhenCPUIsCalm(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 20}, + Memory: MemoryStatus{ + UsedPercent: 86, + Pressure: "warn", + }, + TopProcesses: []ProcessInfo{ + {Name: "Chrome", Memory: 31}, + {Name: "Finder", Memory: 2}, + }, + } + + got := statusDiagnosisLine(m) + if got != "Chrome memory pressure" { + t.Fatalf("statusDiagnosisLine() = %q, want memory contributor", got) + } +} + +func TestStatusDiagnosisLineFallsBackToAllClear(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 10}, + Memory: MemoryStatus{UsedPercent: 20, Pressure: "normal"}, + HealthScoreMsg: "Excellent", + } + + got := statusDiagnosisLine(m) + if got != "All clear" { + t.Fatalf("statusDiagnosisLine() = %q, want All clear", got) + } +} + +func TestRenderProcessCardAddsInlineMemoryWithoutExtraRows(t *testing.T) { + card := renderProcessCard([]ProcessInfo{ + {Name: "Chrome", CPU: 12, Memory: 22, MemoryBytes: 2 * 1024 * 1024 * 1024}, + {Name: "Xcode", CPU: 95, Memory: 8, MemoryBytes: 512 * 1024 * 1024}, + }, colWidth) + + if len(card.lines) != 2 { + t.Fatalf("renderProcessCard() lines = %d, want 2", len(card.lines)) + } + plain := stripANSI(strings.Join(card.lines, "\n")) + if !strings.Contains(plain, "2.0G") { + t.Fatalf("renderProcessCard() missing resident memory hint, got %q", plain) + } + if !strings.Contains(plain, "95.0%") { + t.Fatalf("renderProcessCard() missing cpu value, got %q", plain) + } +} + +func TestRenderProcessCardShowsCollectingWhenEmpty(t *testing.T) { + card := renderProcessCard(nil, colWidth) + + if len(card.lines) != 1 { + t.Fatalf("renderProcessCard() empty lines = %d, want 1", len(card.lines)) + } + if got := stripANSI(card.lines[0]); got != "Collecting..." { + t.Fatalf("renderProcessCard() empty line = %q", got) + } +} + +func TestRenderProcessCardAlignsMetricColumns(t *testing.T) { + const wideCardWidth = 56 + card := renderProcessCard([]ProcessInfo{ + {Name: "duetexpertd", CPU: 97.3, MemoryBytes: 75 << 20}, + {Name: "WindowServer", CPU: 46.8, MemoryBytes: 352 << 20}, + {Name: "Xcode", CPU: 24.3, MemoryBytes: 1018 << 20}, + }, wideCardWidth) + + if len(card.lines) != 3 { + t.Fatalf("renderProcessCard() lines = %d, want 3", len(card.lines)) + } + lines := make([]string, 0, len(card.lines)) + for _, line := range card.lines { + lines = append(lines, stripANSI(line)) + } + + barText := []string{ + "███████████████░", + "███████░░░░░░░░░", + "███░░░░░░░░░░░░░", + } + memoryText := []string{"75.0M", "352.0M", "1018.0M"} + barCol := strings.Index(lines[0], barText[0]) + if barCol != metricLabelWidth+1 { + t.Fatalf("process bar column = %d, want %d in %q", barCol, metricLabelWidth+1, lines[0]) + } + afterBar := lines[0][barCol+len(barText[0]):] + if !strings.HasPrefix(afterBar, " 97.3%") { + t.Fatalf("process percent should sit one column closer to the bar, got %q", lines[0]) + } + percentCol := strings.Index(lines[0], "%") + memoryEndCol := strings.Index(lines[0], memoryText[0]) + len(memoryText[0]) + for i, line := range lines { + if got := strings.Index(line, barText[i]); got != barCol { + t.Fatalf("process bar column line %d = %d, want %d in %q", i, got, barCol, line) + } + if got := strings.Index(line, "%"); got != percentCol { + t.Fatalf("process percent column line %d = %d, want %d in %q", i, got, percentCol, line) + } + gotMemoryEnd := strings.Index(line, memoryText[i]) + len(memoryText[i]) + if gotMemoryEnd != memoryEndCol { + t.Fatalf("process memory column line %d ends at %d, want %d in %q", i, gotMemoryEnd, memoryEndCol, line) + } + } + if lipgloss.Width(lines[0]) > wideCardWidth { + t.Fatalf("renderProcessCard() line exceeds card width: %q", lines[0]) + } + if !strings.Contains(lines[0], "duet") { + t.Fatalf("renderProcessCard() should keep process name after metrics, got %q", lines[0]) + } +} + +func TestRenderProcessCardFallsBackToMemoryPercent(t *testing.T) { + card := renderProcessCard([]ProcessInfo{ + {Name: "Chrome", CPU: 12, Memory: 22}, + }, colWidth) + + plain := stripANSI(strings.Join(card.lines, "\n")) + if !strings.Contains(plain, "M22%") { + t.Fatalf("renderProcessCard() missing memory percent fallback, got %q", plain) + } +} + +func TestRenderHeaderUsesFastMetricSpecFallbacks(t *testing.T) { + const ram = uint64(16 * 1024 * 1024 * 1024) + const diskSize = uint64(512 * 1024 * 1024 * 1024) + m := MetricsSnapshot{ + HealthScore: 90, + Memory: MemoryStatus{Total: ram}, + Disks: []DiskStatus{{Mount: "/", Total: diskSize}}, + } + + header, _ := renderHeader(m, "", 0, 120, true) + plain := stripANSI(header) + wantRAM := "RAM " + humanBytes(ram) + wantDisk := "Disk " + humanBytes(diskSize) + if !strings.Contains(plain, wantRAM) || !strings.Contains(plain, wantDisk) { + t.Fatalf("renderHeader() should label fast metric specs %q and %q, got %q", wantRAM, wantDisk, plain) + } + if strings.Contains(plain, humanBytes(ram)+"/"+humanBytes(diskSize)) { + t.Fatalf("renderHeader() should not render RAM and disk as a slash pair, got %q", plain) + } +} + func TestRenderHeaderWrapsOnNarrowWidth(t *testing.T) { m := MetricsSnapshot{ HealthScore: 91, @@ -982,6 +1285,34 @@ func TestRenderHeaderHidesOSAndUptimeOnNarrowWidth(t *testing.T) { } } +func TestRenderHeaderKeepsLabeledSpecsOnCompactWidth(t *testing.T) { + m := MetricsSnapshot{ + HealthScore: 91, + Hardware: HardwareInfo{ + Model: "MacBook Pro", + CPUModel: "Apple M4 Pro", + TotalRAM: "48G", + DiskSize: "926GB", + RefreshRate: "120Hz", + }, + GPU: []GPUStatus{{CoreCount: 20}}, + } + + header, _ := renderHeader(m, "", 0, 80, true) + plain := stripANSI(header) + if !strings.Contains(plain, "RAM 48G") || !strings.Contains(plain, "Disk 926GB") { + t.Fatalf("renderHeader() compact width should keep labeled specs, got %q", plain) + } + if strings.Contains(plain, "48G/926GB") { + t.Fatalf("renderHeader() compact width should not use slash specs, got %q", plain) + } + for line := range strings.Lines(header) { + if lipgloss.Width(stripANSI(line)) > 80 { + t.Fatalf("renderHeader() compact line exceeds width: %q", line) + } + } +} + func TestRenderHeaderDropsLowPriorityInfoToStaySingleLine(t *testing.T) { m := MetricsSnapshot{ HealthScore: 90, @@ -1020,7 +1351,7 @@ func TestRenderCardWrapsOnNarrowWidth(t *testing.T) { }, } - rendered := renderCard(card, 26, 0) + rendered := renderCard(card, 26) for line := range strings.Lines(rendered) { if lipgloss.Width(stripANSI(line)) > 26 { t.Fatalf("renderCard() line exceeds width: %q", line) @@ -1028,10 +1359,223 @@ func TestRenderCardWrapsOnNarrowWidth(t *testing.T) { } } +func TestRenderCPUCardKeepsOnlyTwoHotCores(t *testing.T) { + card := renderCPUCard(CPUStatus{ + Usage: 6.1, + PerCore: []float64{8.0, 27.9, 18.9, 16.8}, + Load1: 2.30, + Load5: 2.27, + Load15: 2.16, + LogicalCPU: 4, + }, ThermalStatus{}, 2) + + plain := stripANSI(strings.Join(card.lines, "\n")) + if len(card.lines) != 4 { + t.Fatalf("renderCPUCard() lines = %d, want 4", len(card.lines)) + } + if strings.Count(plain, "Core") != 2 { + t.Fatalf("renderCPUCard() should render two core rows, got %q", plain) + } + if !strings.Contains(plain, "Core2") || !strings.Contains(plain, "Core3") { + t.Fatalf("renderCPUCard() should keep the two hottest cores, got %q", plain) + } +} + +func TestRenderCPUCardHonoursCoreCount(t *testing.T) { + cpu := CPUStatus{ + Usage: 6.1, + PerCore: []float64{8.0, 27.9, 18.9, 16.8}, + LogicalCPU: 4, + } + + // cpuCores = 0 means "all": every core gets a row. + all := stripANSI(strings.Join(renderCPUCard(cpu, ThermalStatus{}, 0).lines, "\n")) + if got := strings.Count(all, "Core"); got != 4 { + t.Fatalf("cpuCores=0 should render all 4 cores, got %d rows: %q", got, all) + } + + // A custom count lists exactly that many of the hottest cores. + three := stripANSI(strings.Join(renderCPUCard(cpu, ThermalStatus{}, 3).lines, "\n")) + if got := strings.Count(three, "Core"); got != 3 { + t.Fatalf("cpuCores=3 should render 3 cores, got %d rows: %q", got, three) + } + + // A count larger than the core total is clamped, not padded. + many := stripANSI(strings.Join(renderCPUCard(cpu, ThermalStatus{}, 99).lines, "\n")) + if got := strings.Count(many, "Core"); got != 4 { + t.Fatalf("cpuCores=99 should clamp to 4 cores, got %d rows: %q", got, many) + } +} + +func TestNextCPUCoresCyclesAndWraps(t *testing.T) { + want := []int{4, 8, 0, 2} // starting from 2, one full loop back to 2 + got := 2 + for i, exp := range want { + got = nextCPUCores(got) + if got != exp { + t.Fatalf("step %d: nextCPUCores gave %d, want %d", i, got, exp) + } + } + // An unknown value restarts the cycle at the default. + if n := nextCPUCores(7); n != 2 { + t.Fatalf("nextCPUCores(7) = %d, want default 2", n) + } +} + +func TestSmallerCPUCoresStepsDownAndFloors(t *testing.T) { + for _, tc := range []struct{ from, want int }{ + {0, 8}, {8, 4}, {4, 2}, {2, 2}, {7, 2}, + } { + if got := smallerCPUCores(tc.from); got != tc.want { + t.Errorf("smallerCPUCores(%d) = %d, want %d", tc.from, got, tc.want) + } + } +} + +// A tall CPU card must never push the frame past the window: the view steps the +// core count back down until it fits, so the lower cards stay on screen. +func TestViewShrinksCPUCardToFitHeight(t *testing.T) { + cpu := CPUStatus{Usage: 6.1, LogicalCPU: 20} + for i := range 20 { + cpu.PerCore = append(cpu.PerCore, float64(i)) + } + m := model{ + ready: true, + width: 120, + metrics: MetricsSnapshot{CPU: cpu}, + cpuCores: 0, // "all" + } + + tall := m + tall.height = 200 + if got := lipgloss.Height(tall.View()); got != 200 { + t.Fatalf("tall window should render all cores and pad to 200, got %d", got) + } + + short := m + short.height = 20 + if got := lipgloss.Height(short.View()); got > 20 { + t.Fatalf("frame overflows a 20-line window: %d lines", got) + } + if strings.Count(stripANSI(short.View()), "Core") > 8 { + t.Error("short window should have stepped the core count down") + } +} + +func TestLayoutColumnRowsStacksBesideTallCard(t *testing.T) { + tall := cardData{icon: iconCPU, title: "CPU", lines: make([]string, 12)} + short := func(title string) cardData { + return cardData{title: title, lines: []string{"x"}} + } + cards := []cardData{tall, short("GPU"), short("Memory"), short("Disk")} + + rows := layoutColumnRows(cards, colWidth) + + if len(rows) == 0 || len(rows[0].left) != 1 || rows[0].left[0].title != "CPU" { + t.Fatalf("first row should seed its left cell with CPU, got %+v", rows) + } + // Several short cards stack beside the tall CPU to fill its height and keep + // the following rows' titles aligned. + if len(rows[0].right) < 2 { + t.Errorf("tall CPU should be matched by >=2 stacked cards, got %d", len(rows[0].right)) + } + // Every card is placed exactly once. + total := 0 + for _, r := range rows { + total += len(r.left) + len(r.right) + } + if total != len(cards) { + t.Errorf("placed %d cards, want %d", total, len(cards)) + } +} + +func TestRenderTwoColumnsAlignsRowTitles(t *testing.T) { + mk := func(icon, title string, n int) cardData { + lines := make([]string, n) + for i := range lines { + lines[i] = title + } + return cardData{icon: icon, title: title, lines: lines} + } + // A tall CPU beside short cards: GPU+Memory stack in row 0, then Disk and + // Power form row 1 and must line up on the same output line. + cards := []cardData{ + mk(iconCPU, "CPU", 11), + mk(iconGPU, "GPU", 6), + mk(iconMemory, "Memory", 4), + mk(iconDisk, "Disk", 3), + mk(iconBattery, "Power", 3), + } + + out := stripANSI(renderTwoColumns(cards, 120)) + aligned := false + for line := range strings.Lines(out) { + if strings.Contains(line, "Disk") && strings.Contains(line, "Power") { + aligned = true + break + } + } + if !aligned { + t.Errorf("Disk and Power titles should sit on the same row:\n%s", out) + } +} + +func TestRenderTwoColumnsNeverGrowsFixedPairLayout(t *testing.T) { + const width = 120 + cards := buildCards(MetricsSnapshot{}, width/2-4, 2, true) + cw := width/2 - 2 + + var fixedRows []string + for i := 0; i < len(cards); i += 2 { + left := renderCard(cards[i], cw) + if i+1 >= len(cards) { + fixedRows = append(fixedRows, left) + continue + } + right := renderCard(cards[i+1], cw) + fixedRows = append(fixedRows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)) + } + var spacedFixedRows []string + for i, row := range fixedRows { + if i > 0 { + spacedFixedRows = append(spacedFixedRows, "") + } + spacedFixedRows = append(spacedFixedRows, row) + } + fixed := lipgloss.JoinVertical(lipgloss.Left, spacedFixedRows...) + balanced := renderTwoColumns(cards, width) + + if got, limit := lipgloss.Height(balanced), lipgloss.Height(fixed); got > limit { + t.Fatalf("balanced layout grew from %d to %d lines:\n%s", limit, got, stripANSI(balanced)) + } +} + +func TestRenderTwoColumnsInsertsRowGap(t *testing.T) { + cards := []cardData{ + {icon: iconCPU, title: "CPU", lines: []string{"Total ok"}}, + {icon: iconMemory, title: "Memory", lines: []string{"Used ok"}}, + {icon: iconDisk, title: "Disk", lines: []string{"INTR ok"}}, + {icon: iconNetwork, title: "Network", lines: []string{"Down ok"}}, + } + + rendered := stripANSI(renderTwoColumns(cards, 120)) + hasBlankRow := false + for line := range strings.Lines(rendered) { + if strings.TrimSpace(line) == "" { + hasBlankRow = true + break + } + } + if !hasBlankRow { + t.Fatalf("renderTwoColumns() should insert one blank row between card rows, got %q", rendered) + } +} + func TestRenderMemoryCardHidesSwapSizeOnNarrowWidth(t *testing.T) { card := renderMemoryCard(MemoryStatus{ Used: 8 << 30, Total: 16 << 30, + Available: 8 << 30, UsedPercent: 50.0, SwapUsed: 482, SwapTotal: 1000, @@ -1051,6 +1595,7 @@ func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) { card := renderMemoryCard(MemoryStatus{ Used: 8 << 30, Total: 16 << 30, + Available: 8 << 30, UsedPercent: 50.0, SwapUsed: 482, SwapTotal: 1000, @@ -1066,6 +1611,72 @@ func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) { } } +func TestRenderMemoryCardUsesCollectedAvailableMemory(t *testing.T) { + card := renderMemoryCard(MemoryStatus{ + Used: 12 << 30, + Total: 16 << 30, + Available: 9 << 30, + UsedPercent: 75.0, + }, 60) + + plain := stripANSI(strings.Join(card.lines, "\n")) + if !strings.Contains(plain, "Free") || !strings.Contains(plain, "56.2%") { + t.Fatalf("renderMemoryCard() should derive free percent from Available, got %q", plain) + } + if !strings.Contains(plain, "Avail 9.0 GB") { + t.Fatalf("renderMemoryCard() should render collected Available memory, got %q", plain) + } +} + +func TestRenderMemoryCardCombinesCacheAndAvailable(t *testing.T) { + card := renderMemoryCard(MemoryStatus{ + Used: 12 << 30, + Total: 16 << 30, + Available: 9 << 30, + Cached: 2 << 30, + UsedPercent: 75.0, + }, 60) + + plain := stripANSI(strings.Join(card.lines, "\n")) + if len(card.lines) != 4 { + t.Fatalf("renderMemoryCard() lines = %d, want 4", len(card.lines)) + } + if !strings.Contains(plain, "Cache 2.0 GB · Avail 9.0 GB") { + t.Fatalf("renderMemoryCard() should combine cache and available memory, got %q", plain) + } +} + +func TestModelViewPadsToTerminalHeight(t *testing.T) { + tests := []struct { + name string + width int + height int + }{ + {"narrow terminal", 60, 40}, + {"wide terminal", 120, 40}, + {"tall terminal", 120, 80}, + {"short terminal", 120, 10}, + {"zero height", 120, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := model{ + width: tt.width, + height: tt.height, + ready: true, + metrics: MetricsSnapshot{}, + } + + view := m.View() + got := lipgloss.Height(view) + if got < tt.height { + t.Errorf("View() height = %d, want >= %d (terminal height)", got, tt.height) + } + }) + } +} + func TestModelViewErrorRendersSingleMole(t *testing.T) { m := model{ width: 120, @@ -1102,16 +1713,3 @@ func stripANSI(s string) string { } return result.String() } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) -} - -func containsMiddle(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/Resources/mole/cmd/status/watch.go b/Resources/mole/cmd/status/watch.go new file mode 100644 index 0000000..4741a90 --- /dev/null +++ b/Resources/mole/cmd/status/watch.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +// runWatchMode streams metrics continuously as newline-delimited JSON (one full +// MetricsSnapshot per line) using a single warm Collector, so rate metrics +// (network, disk IO) stay accurate across ticks. +func runWatchMode(interval time.Duration) { + runWatchStdout(interval) +} + +// watchState mirrors the TUI's collection cadence (cmd/status/main.go): a full +// collect priming the enrichment cache, then mostly fast collects that inherit +// the cached slow-changing fields, with periodic process/full refreshes. +type watchState struct { + ready bool + lastFullAt time.Time + lastProcessAt time.Time +} + +func (s *watchState) nextMode(now time.Time) collectionMode { + return nextCollectionMode(s.ready, s.lastFullAt, s.lastProcessAt, now) +} + +func (s *watchState) collect(c *Collector) (MetricsSnapshot, error) { + now := time.Now() + mode := s.nextMode(now) + + var ( + snap MetricsSnapshot + err error + ) + switch mode { + case collectionFull: + snap, err = c.Collect() + case collectionProcess: + snap, err = c.CollectProcesses() + default: + snap, err = c.CollectFast() + } + + if err == nil { + recordCollectionFreshness(mode, snap.CollectedAt, &s.lastFullAt, &s.lastProcessAt) + s.ready = true + } + return snap, err +} + +// runWatchStdout emits the first snapshot immediately (so the consumer paints +// without waiting a full interval), then mirrors the TUI cadence: the first +// successful fast snapshot is followed by an immediate full snapshot, and later +// ticks wait for the configured interval after each collection finishes. Exits +// cleanly when stdout closes (parent process gone). +func runWatchStdout(interval time.Duration) { + collector := NewCollector(processWatchOptionsFromFlags()) + enc := json.NewEncoder(os.Stdout) + var st watchState + + for { + wasReady := st.ready + snap, err := st.collect(collector) + if err != nil { + fmt.Fprintf(os.Stderr, "status: collect failed: %v\n", err) + if snap.CollectedAt.IsZero() { + time.Sleep(interval) + continue + } + } + if err := enc.Encode(snap); err != nil { + return // stdout closed; parent died, nothing left to feed. + } + if wasReady { + time.Sleep(interval) + } + } +} diff --git a/Resources/mole/docs/SECURITY_DESIGN.md b/Resources/mole/docs/SECURITY_DESIGN.md new file mode 100644 index 0000000..18a8d7b --- /dev/null +++ b/Resources/mole/docs/SECURITY_DESIGN.md @@ -0,0 +1,263 @@ +# Mole Security Design + +This document describes the safety mechanisms that prevent mole from +destroying data it shouldn't. It is written for reviewers, contributors, +and anyone evaluating mole for production use. + +The corresponding implementation lives in `lib/core/file_ops.sh`, +`lib/core/app_protection.sh`, and `lib/core/app_protection_data.sh`. Path +validation has machine-checked fuzz tests in `cmd/analyze/delete_fuzz_test.go` +and `tests/path_validation_fuzz.bats`. + +--- + +## Threat model + +Mole is a user-invoked CLI that performs three classes of destructive +operations on the local machine: + +1. **Cleanup**: remove caches, logs, and temp data the OS or apps regenerate. +2. **Uninstall**: remove an app bundle and its data directories. +3. **Trash routing**: move user-selected files in `mo analyze` to Trash. + +We assume: +- The invoking user has shell access and runs mole intentionally. +- The user is **not** trying to attack their own machine. +- The user **does** make mistakes (typo a path, click wrong menu, run + cleanup with stale config). +- Third-party apps writing into `~/Library` may have arbitrary names and + may not follow Apple naming conventions. + +We are **not** defending against: +- A user who runs `sudo mole` with malicious flags they typed in deliberately. +- A compromised macOS host where SIP is disabled and `/System` is writable. +- Supply-chain compromise of the mole binary itself (covered separately + by signed releases + SHA256SUMS attestations in `release.yml`). + +The lines we will not cross, regardless of input: +- Never delete a path inside `/System`, `/bin`, `/sbin`, `/usr` (except + `/usr/local`, where Homebrew and user software live), `/etc`, + `/Library/Extensions`, or `/var/db` (system databases). +- Never delete a bare top-level root itself, even when children are + deletable: `/Applications`, `/Library`, `/Library/Application Support`, + `/Volumes`, `/opt`, `/Users`, a user home root `/Users/`, or + `/var/root`. This blocks the empty-variable collapse where `"$dir/$name"` + with an empty `$name` resolves to the parent root. +- Never delete a path that resolves (after symlink chasing) into one of + the above. +- Never uninstall a `com.apple.*` system app, except the explicit list + of App Store / developer-portal Apple apps that users actually buy + (Xcode, Final Cut Pro, Logic, GarageBand, iWork, MainStage, etc.). + +--- + +## Layer 1: `validate_path_for_deletion` + +Every removal in mole funnels through `mole_delete` / +`safe_remove` / `safe_sudo_remove`, which all call +`validate_path_for_deletion` before touching the filesystem. The validator +applies six independent checks. Any one rejecting kills the operation. + +Location: `lib/core/file_ops.sh:133`. + +1. **Non-empty + absolute.** Empty paths and any path not starting with + `/` are rejected. Eliminates ambiguity from relative paths interacting + with caller `$PWD`. + +2. **Symlink resolution.** If the path is itself a symlink, the validator + reads the link target, resolves it to an absolute path, and re-checks + the target against the protected-path list. Prevents an attacker (or + an accidental config bug) from pointing `/tmp/foo` at `/System` and + getting the validator to wave it through. + +3. **Ancestor symlink resolution.** Check 2 only inspects the leaf. If an + *ancestor* component is a symlink, the literal path string matches + nothing dangerous while the actual `rm` follows the link into the real + target: a redirected `~/Library/Caches` would let a cache sweep walk + into `~/Documents` or a system tree. So the validator canonicalizes the + parent (a physical `cd` resolves every ancestor link) and re-runs the + deny predicates on the resolved leaf. Two properties matter here: + - **Deny-only.** A resolved path never *grants* permission the literal + path lacked, so legitimate targets keep their existing verdict. This + is why `/var` is deliberately not in the fuzz test's critical-root + list: `/var/folders` temp trees are cleanable literally, so they must + stay cleanable through a link too. + - **Runs before the allow-list** in check 6, which would otherwise + early-return past this gate for `/private/*` paths. + + The common case stays fork-free: ancestors are walked with the `[[ -L ]]` + builtin and the canonicalizing subshell is only paid for when one of them + really is a symlink. Doing it unconditionally cost ~2ms per call, about + +23% on validation, on a function that runs once per deletion candidate. + +4. **Path traversal.** `..` is rejected only when it appears as a full + path component (`/foo/../bar`, `/..`, `../bar`, `foo/..`). This is + tighter than naive substring matching: it allows legitimate names + like Firefox's `name..files` directory while still blocking + `/Users/me/Library/../../etc`. + +5. **Control characters.** Any path containing `\n`, `\t`, or other + `[[:cntrl:]]` bytes is rejected. Defends against log-injection and + surprising-shell-interpretation scenarios. + +6. **Allow-then-deny match.** + - First, explicit allow-list for known-safe subtrees under `/private` + (`/private/tmp`, `/private/var/log`, `/private/var/folders`, + `/private/var/db/diagnostics`, etc.) and `/System/Library/Caches/com.apple.coresymbolicationd/data` (rebuildable). + - Then, deny-list for `/`, `/bin*`, `/sbin*`, `/usr*`, `/System*`, + `/Library/Extensions*`, `/etc*`, `/var/db*`, `/private`, and + `/private/etc*`. + - Finally, calls `should_protect_path` (Layer 2) for fine-grained + bundle / app / data protection. + +The allow-then-deny ordering matters: rebuildable system caches we +*want* to clean live under paths we'd otherwise block. Listing them +first means a maintainer adding a new safe path doesn't have to surgically +weaken the deny rules. + +--- + +## Layer 2: `# SAFE: ` contract for raw `rm` + +The validator is opt-in: a contributor could bypass it by writing `rm -rf` +directly. To make that bypass loud and reviewable, the CI security job in +`.github/workflows/test.yml` greps for `rm -rf` outside known safe +wrappers and requires an explicit annotation: + +```bash +rm -rf "$temp_file" # SAFE: created by mktemp in this function, never user input +``` + +The CI rule rejects any `rm -rf` that is not either: +- Inside `safe_remove` / `safe_sudo_remove` (the validated wrappers), or +- A pure documentation line (comment-only or echoed help text), or +- Annotated with `# SAFE: `. + +Every annotated bypass in the codebase currently has a reason that +constrains the input: confined to `$temp_file` from `mktemp`, confined +to a stub container we just created, confined to `tests/tmp-*` from a +test runner. The annotation forces the author to articulate the constraint +before the code can land. + +For current uses, grep `# SAFE:`. Anchored by symbol rather than line number, +because line refs rot: `_remove_verified_container_stub` in `lib/clean/apps.sh`, +`cleanup_temp_files` and the Mole-temp-root sweeps in `lib/core/base.sh`, and +the orphan-tmp cleanup in `scripts/test.sh`. + +`_remove_verified_container_stub` is the one bypass worth understanding before +you "fix" it: it *must* use raw `rm`, because `should_protect_path` blankets +`~/Library/Containers`, so routing it through `safe_remove` would have the +validator refuse both stub paths and silently kill the feature. That reasoning +is pinned by a test in `tests/clean_apps.bats`. + +--- + +## Layer 3: App protection, split fast vs. detailed lists + +Uninstall and per-app cleanup decisions go through +`should_protect_from_uninstall` and `should_protect_data` in +`lib/core/app_protection.sh`. They consult two data sources, both kept +in `lib/core/app_protection_data.sh`: + +| List | Used by | Shape | Purpose | +|---|---|---|---| +| `SYSTEM_CRITICAL_BUNDLES_FAST` | Cleanup paths (`should_protect_data`) | Wildcard patterns | Fast `com.apple.*` and family-pattern guards. Misses are acceptable here; cleanup of an unknown system component just means leftover files, not deletion of a live app. | +| `SYSTEM_CRITICAL_BUNDLES` | Uninstall (`should_protect_from_uninstall`) | Explicit bundle IDs | Detailed list of every `/System/Applications` and Apple system service. Must be exhaustive: a miss here would let a user uninstall Finder. | +| `APPLE_UNINSTALLABLE_APPS` | Uninstall | Explicit bundle IDs | Allow-list of Apple-developed apps the user actually installed (Xcode, FCP, Logic, etc.). Required because `com.apple.*` cannot be a blanket block. | +| `DATA_PROTECTED_BUNDLES` | Cleanup (`should_protect_data`) | Wildcard patterns | Third-party apps with sensitive state (1Password, JetBrains, IM tools, VPNs, etc.) whose caches must not be touched. | + +The deliberate redundancy between FAST and CRITICAL is **not** a bug: +- FAST is a wildcard fast-path used in tight loops during cleanup, where + a `com.apple.*` blanket is correct. +- CRITICAL is the detailed allow-list used at uninstall time, where the + blanket is wrong (it would block Xcode uninstall) so individual bundles + must be enumerated. + +### Keeping the lists honest + +A new macOS major release can ship new system apps and daemons. The +monthly `.github/workflows/bundle_audit.yml` job runs +`scripts/audit_bundle_drift.sh` against the latest `macos-latest` +runner. The script enumerates every `.app` under `/System/Applications`, +computes its `CFBundleIdentifier`, and reports any bundle ID not matched +by FAST + CRITICAL + DATA_PROTECTED. Any miss opens a tracking issue. + +Each macOS major release should also trigger the +`macos-release-review` issue template +(`.github/ISSUE_TEMPLATE/macos-release-review.yml`), which forces a +human checklist over: bundle drift, mdls timeout regression, SIP path +changes, and CI matrix updates. + +--- + +## Layer 4: Trash routing default + +`mo analyze` and `mo clean`'s ad-hoc paths route deletions to the macOS +Trash via Finder AppleScript (`cmd/analyze/delete.go:124`). This gives +users the standard Apple-native "Put Back" recovery flow. Permanent +deletion requires explicit `--permanent` or going through `mo clean`'s +batched cleanup path. + +The `osascript` call uses a 30-second timeout (`trashTimeout`) so a +hung Finder can't wedge the binary, and escapes both `\\` and `"` in +the path before substituting into the AppleScript literal. Defense in +depth: `validatePath` is also called before `osascript`, so even if +escape logic missed a case, a path containing `..` or null bytes is +rejected before it reaches Finder. + +--- + +## Layer 5: Test mode + dry run + property tests + +Three orthogonal mechanisms make the safety claims testable and +prevent live-machine test runs from doing real damage: + +- `MOLE_DRY_RUN=1`: every safe-remove logs what it would do and + returns 0 without touching the filesystem. Used in CI for the + no-mock path coverage and recommended before any local cleanup. +- `MOLE_TEST_NO_AUTH=1`: refuses to call `sudo`, `osascript`, + `launchctl`, or any path that would prompt the user. Required for + bats and the integration tests. Enforced by `scripts/test.sh` PATH + stubs that fail loudly when called. +- `tests/path_validation_fuzz.bats` and `cmd/analyze/delete_fuzz_test.go` + harden the validators. The bats test asserts that every line in + `tests/fuzz_corpus/dangerous_paths.txt` (79 adversarial paths today) + is rejected. The Go fuzz target runs its seed corpus during normal + `go test`; maintainers can run `go test -fuzz=FuzzValidatePath ./cmd/analyze` + when changing path validation. It asserts the invariant: + anything accepted must be absolute, free of null bytes, and free of + `..` components. + +If you add a new way to bypass these layers, you are expected to add +a corresponding test that fails before your code lands. + +--- + +## What this design intentionally does not do + +- **No code signing of the cleanup config.** We rely on filesystem + permissions to protect the protection lists from tampering. If a + user can edit `lib/core/app_protection_data.sh` they can already + edit `mole` itself; the threat model says we don't defend that. +- **No anti-rollback.** A user who restores an old mole binary or + installs a forked build with weaker lists gets weaker protection. + We address this through release signing, not runtime checks. +- **No protection for arbitrary user paths.** `~/Documents/important` + has no special status. The user is responsible for selecting safe + cleanup targets; mole only guarantees system integrity. +- **No telemetry.** We never report what was scanned, deleted, or + attempted. Mistakes are diagnosed locally via `~/.cache/mole/` + operation logs (path is `MOLE_OPLOG_PATH` overridable; + `MO_NO_OPLOG=1` disables entirely). + +--- + +## When to update this document + +- A new layer (e.g., a notarization check, a per-volume policy) is added. +- The validator gains a new check class or relaxes an existing one. +- A new app protection list is introduced. +- An incident occurred where one of the layers failed and the writeup + belongs in the "lessons" section here, not just the commit log. + +Last reviewed: 2026-05-21 (mole V1.39.0). diff --git a/Resources/mole/docs/release-notes/V1.42.0.md b/Resources/mole/docs/release-notes/V1.42.0.md new file mode 100644 index 0000000..2fae03a --- /dev/null +++ b/Resources/mole/docs/release-notes/V1.42.0.md @@ -0,0 +1,35 @@ +
+ Mole Logo +

Mole

+

Deep clean and optimize your Mac.

+
+ +### Changelog + +1. **mo clean**: Project artifacts scan now finishes on huge workspaces and shows partial results when slow scans time out +2. **mo clean**: Typing a sudo password no longer gets treated as skipping sudo +3. **mo clean**: App leftovers can remove dead macOS app records from deleted apps and unmounted DMGs without deleting files +4. **mo clean**: Developer cleanup finds more rebuildable caches, including VS Code WebStorage, QQ, LM Studio, Folo, SenPlayer, Xcode simulator caches, and XCTest test data +5. **mo analyze**: Cached folders keep their results visible while Mole refreshes in the background +6. **mo update**: Nightly updates skip reinstalling the same build, and `mo status` now matches the Mac App health signals more closely +7. **mo uninstall**: Related-file cleanup is stricter around system items, app helpers, symlinks, and container caches + +### 更新日志 + +1. **mo clean**:Project artifacts 在超大工作区里不会再卡住,慢扫描超时后仍会展示已找到的结果 +2. **mo clean**:在 sudo 提示里输入密码不会再被误判成跳过 sudo +3. **mo clean**:App leftovers 可以清理已删除应用、已卸载 DMG 留下的失效应用记录,且不会删除文件 +4. **mo clean**:Developer cleanup 能发现更多可再生成缓存,包括 VS Code WebStorage、QQ、LM Studio、Folo、SenPlayer、Xcode 模拟器缓存和 XCTest 测试数据 +5. **mo analyze**:打开已有缓存的文件夹时,后台刷新期间会继续显示已有结果 +6. **mo update**:Nightly 更新不再重复安装同一个版本,`mo status` 也更接近 Mac App 的健康状态判断 +7. **mo uninstall**:相关文件清理在系统项、应用 helper、symlink 和 container cache 上更谨慎 + +### Mole Mac App + +Prefer a GUI? Try [Mole Mac App](https://mole.fit), the CLI stays free and open source + +### Thanks 💖 + +Issue reporters and PR contributors this cycle: @QasimXAli · @thedavidweng · @iammike2 · @wottpal · @seepine · @Saafo · @wupeter0001 · @sebastianbreguel · @Puhavik + +> https://github.com/tw93/Mole diff --git a/Resources/mole/go.mod b/Resources/mole/go.mod index 153fbad..faa84cd 100644 --- a/Resources/mole/go.mod +++ b/Resources/mole/go.mod @@ -1,15 +1,13 @@ module github.com/tw93/mole -go 1.24.2 - -toolchain go1.24.6 +go 1.25.0 require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/shirou/gopsutil/v4 v4.26.2 - golang.org/x/sync v0.19.0 + github.com/shirou/gopsutil/v4 v4.26.7 + golang.org/x/sys v0.47.0 ) require ( @@ -21,7 +19,7 @@ require ( github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/ebitengine/purego v0.10.0 // indirect + github.com/ebitengine/purego v0.10.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -38,6 +36,5 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.33.0 // indirect ) diff --git a/Resources/mole/go.sum b/Resources/mole/go.sum index 66dea7c..2e3c4a0 100644 --- a/Resources/mole/go.sum +++ b/Resources/mole/go.sum @@ -22,8 +22,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuh github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -53,8 +53,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= -github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc= +github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -67,15 +67,13 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/Resources/mole/install.sh b/Resources/mole/install.sh index d5eeb1a..f912df1 100755 --- a/Resources/mole/install.sh +++ b/Resources/mole/install.sh @@ -5,20 +5,32 @@ set -euo pipefail -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' +# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes. +if [[ -n "${NO_COLOR:-}" ]]; then + GREEN='' + BLUE='' + YELLOW='' + RED='' + NC='' +else + GREEN='\033[0;32m' + BLUE='\033[0;34m' + YELLOW='\033[1;33m' + RED='\033[0;31m' + NC='\033[0m' +fi _SPINNER_PID="" start_line_spinner() { local msg="$1" - [[ ! -t 1 ]] && { - echo -e "${BLUE}|${NC} $msg" - return - } + # A progress line only means something while it is being watched. When the + # output is captured, as `mo update` does, every one of these lands in the + # final block as a stale "Downloading..." / "Verifying..." line that the + # very next line contradicts. Say nothing there and let the result lines + # speak; a tty still gets the animation. + [[ -t 1 ]] || return 0 local chars="|/-\\" + # shellcheck disable=SC1003 [[ -z "$chars" ]] && chars='|/-\\' local i=0 (while true; do @@ -52,6 +64,50 @@ log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1"; } log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; } log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; } +curl_download_with_retry() { + local url="$1" + local output_file="$2" + local attempt=1 + local max_attempts=3 + local curl_exit=0 + # Capture curl's stderr per attempt and surface it only when the download + # ultimately fails: a transient TLS reset that the retry recovers from is + # not news, and printing it made successful installs look broken. + local curl_err="" + curl_err="$(mktemp "${TMPDIR:-/tmp}/mole-curl-err.XXXXXX")" || curl_err="" + + while true; do + if curl -fsSL --connect-timeout 10 --max-time 60 "$url" -o "$output_file" \ + 2> "${curl_err:-/dev/null}"; then + [[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above + return 0 + else + curl_exit=$? + fi + + rm -f "$output_file" 2> /dev/null || true + case "$curl_exit" in + 6 | 7 | 18 | 28 | 35 | 52 | 55 | 56) ;; + *) + [[ -s "$curl_err" ]] && cat "$curl_err" >&2 + [[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above + return "$curl_exit" + ;; + esac + + if [[ "$attempt" -ge "$max_attempts" ]]; then + [[ -s "$curl_err" ]] && cat "$curl_err" >&2 + [[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above + return "$curl_exit" + fi + sleep 1 || { + [[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above + return "$curl_exit" + } + attempt=$((attempt + 1)) + done +} + safe_rm() { local target="${1:-}" local tmp_root @@ -65,6 +121,9 @@ safe_rm() { fi tmp_root="${TMPDIR:-/tmp}" + while [[ "$tmp_root" != "/" && "$tmp_root" == */ ]]; do + tmp_root="${tmp_root%/}" + done case "$target" in "$tmp_root" | /tmp) log_error "safe_rm: refusing to remove temp root: $target" @@ -89,6 +148,22 @@ safe_rm() { INSTALL_DIR="/usr/local/bin" CONFIG_DIR="$HOME/.config/mole" SOURCE_DIR="" +SOURCE_COMMIT_HASH="" +INSTALL_LOCK_PATH="" +INSTALL_LOCK_CONTROL="" +INSTALL_LOCK_HOLDER_PID="" +INSTALL_LOCK_USE_SUDO=false +# Why the last acquire_install_lock attempt failed. A bare "the lock is busy" +# message sent one reporter reverse-engineering the ancestor check by hand +# (#1335), so callers report the actual cause instead. +INSTALL_LOCK_FAILURE="" +INSTALL_LOCK_UNSAFE_ANCESTOR="" +# Which of the ancestor check's five independent rejections fired. They need +# different commands: chown does not clear an ACL and chmod does not turn a +# symlink into a directory, so one shared "it is writable" sentence sends the +# user to run something that changes nothing and retry into the same refusal. +INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="" +INSTALL_SOURCE_TMP="" ACTION="install" @@ -104,14 +179,497 @@ needs_sudo() { [[ ! -w "$parent_dir" ]] } +ensure_sudo_ready() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_error "Admin access required, blocked in test mode" + return 1 + fi + + if [[ "${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then + sudo -n -v + return + fi + + sudo -v +} + maybe_sudo() { if needs_sudo; then - sudo "$@" + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_error "Admin access required, blocked in test mode" + return 1 + fi + if [[ "${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then + sudo -n "$@" + else + sudo "$@" + fi else "$@" fi } +# Keep verification of a newly installed executable bounded. The installer +# cannot source lib/core/timeout.sh until after it has installed the source +# tree, so use a small standalone wrapper for --version/--help probes. +run_install_probe_with_timeout() { + local duration="${1:-5}" + shift || true + [[ $# -gt 0 ]] || return 125 + [[ "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || duration=5 + + local candidate timeout_bin="" + for candidate in gtimeout timeout; do + if command -v "$candidate" > /dev/null 2>&1; then + timeout_bin=$(command -v "$candidate") + break + fi + done + if [[ -n "$timeout_bin" ]]; then + "$timeout_bin" -k 1 "$duration" "$@" + return $? + fi + + # Stock macOS has no timeout utility, but it does ship Perl. Supervise the + # probe from outside its process group so a shell wrapper and every child + # holding inherited output descriptors are terminated at the deadline. + local perl_bin="" + perl_bin=$(command -v perl 2> /dev/null || true) + [[ -n "$perl_bin" ]] || return 125 + # shellcheck disable=SC2016 # Perl source is intentionally single-quoted. + "$perl_bin" -MPOSIX=:sys_wait_h,setpgid -MTime::HiRes=time,sleep -e ' + my ($duration, @command) = @ARGV; + exit 125 unless @command; + my $pid = fork(); + exit 125 unless defined $pid; + if ($pid == 0) { + my $setpgid_status = POSIX::setpgid(0, 0); + exit 126 unless defined $setpgid_status && $setpgid_status == 0; + exec { $command[0] } @command; + exit 127; + } + POSIX::setpgid($pid, $pid); + my $deadline = time() + $duration; + while (time() < $deadline) { + my $result = waitpid($pid, WNOHANG); + if ($result == $pid) { + exit(($? & 127) ? 128 + ($? & 127) : $? >> 8); + } + sleep(0.02); + } + kill "TERM", -$pid; + my $grace = time() + 1; + while (time() < $grace) { + my $result = waitpid($pid, WNOHANG); + exit 124 if $result == $pid; + sleep(0.02); + } + kill "KILL", -$pid; + waitpid($pid, 0); + exit 124; + ' "$duration" "$@" +} + +install_lock_has_unsafe_ancestor() { + local use_sudo="$1" + local probe="$INSTALL_DIR" + local current_uid owner_uid mode acl_listing + current_uid=$(id -u 2> /dev/null || true) + INSTALL_LOCK_UNSAFE_ANCESTOR="$probe" + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable" + [[ "$current_uid" =~ ^[0-9]+$ ]] || return 0 + + while true; do + # Record the directory under inspection so a rejection can name it. + INSTALL_LOCK_UNSAFE_ANCESTOR="$probe" + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="symlink" + [[ ! -L "$probe" ]] || return 0 + owner_uid=$(/usr/bin/stat -f%u "$probe" 2> /dev/null || true) + mode=$(/usr/bin/stat -f%Lp "$probe" 2> /dev/null || true) + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable" + [[ "$owner_uid" =~ ^[0-9]+$ && "$mode" =~ ^[0-7]+$ ]] || return 0 + if [[ "$use_sudo" == "true" || ${EUID:-0} -eq 0 ]]; then + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="not_root_owned" + [[ "$owner_uid" -eq 0 ]] || return 0 + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="writable" + (((8#$mode & 0022) == 0)) || return 0 + elif [[ "$owner_uid" -ne 0 && "$owner_uid" -ne "$current_uid" ]]; then + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="foreign_owner" + return 0 + else + # A regular installer already writes through this tree. Accept + # group-writable prefixes, but keep world-writable paths closed. + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="writable" + (((8#$mode & 0002) == 0)) || return 0 + fi + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable" + acl_listing=$(/bin/ls -lde "$probe" 2> /dev/null) || return 0 + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="acl" + if printf '%s\n' "$acl_listing" | + /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:.*[[:space:]]allow[[:space:]]'; then + return 0 + fi + [[ "$probe" == "/" ]] && break + local parent_probe="${probe%/*}" + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable" + [[ "$parent_probe" != "$probe" ]] || return 0 + probe="$parent_probe" + [[ -n "$probe" ]] || probe="/" + done + INSTALL_LOCK_UNSAFE_ANCESTOR="" + INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="" + return 1 +} + +install_lock_process_start() { + local pid="$1" + LC_ALL=C /bin/ps -p "$pid" -o lstart= 2> /dev/null | + /usr/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | /usr/bin/head -1 +} + +install_lock_command() { + local use_sudo="$1" + shift + if [[ "$use_sudo" != "true" ]]; then + "$@" + return + fi + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + if [[ "${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then + sudo -n "$@" + else + sudo "$@" + fi +} + +# One interactive recovery for a lapsed sudo session, through the controlling +# terminal so it works even when the installer's stdio is captured by a caller. +# Returns non-zero without prompting when there is no terminal to ask on. +install_lock_reauthenticate() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + [[ -r /dev/tty && -w /dev/tty ]] || return 1 + # shellcheck disable=SC2024 # sudo's own prompt belongs on the same terminal. + sudo -v < /dev/tty > /dev/tty 2> /dev/tty +} + +install_lock_current_shell_pid() { + local variable_name="$1" + local pid_file current_pid="" + pid_file=$(/usr/bin/mktemp /tmp/mole-install-pid.XXXXXX) || return 1 + if ! /bin/sh -c 'printf "%s\n" "$PPID" > "$1"' sh "$pid_file"; then + safe_rm "$pid_file" + return 1 + fi + IFS= read -r current_pid < "$pid_file" || true + safe_rm "$pid_file" + [[ "$current_pid" =~ ^[0-9]+$ ]] || return 1 + printf -v "$variable_name" '%s' "$current_pid" +} + +install_lock_prepare_dir() { + local use_sudo="$1" + local lock_dir="$INSTALL_DIR/.mole-update.lock" + local expected_uid owner_uid mode acl_listing + expected_uid=$(id -u 2> /dev/null || true) + [[ "$expected_uid" =~ ^[0-9]+$ ]] || return 1 + + if [[ ! -e "$lock_dir" && ! -L "$lock_dir" ]]; then + install_lock_command "$use_sudo" mkdir -m 0700 "$lock_dir" 2> /dev/null || return 1 + fi + [[ -d "$lock_dir" && ! -L "$lock_dir" ]] || return 1 + # macOS applies inherited ACLs even when mkdir requests mode 0700. Remove + # them before touching the lock file, then verify that no ACL entry remains. + # Once this succeeds, only the expected owner can mutate directory entries. + install_lock_command "$use_sudo" /bin/chmod -N "$lock_dir" 2> /dev/null || return 1 + acl_listing=$(install_lock_command "$use_sudo" /bin/ls -lde "$lock_dir" 2> /dev/null) || return 1 + if printf '%s\n' "$acl_listing" | /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:'; then + return 1 + fi + owner_uid=$(install_lock_command "$use_sudo" /usr/bin/stat -f%u "$lock_dir" 2> /dev/null || true) + mode=$(install_lock_command "$use_sudo" /usr/bin/stat -f%Lp "$lock_dir" 2> /dev/null || true) + if [[ "$use_sudo" == "true" ]]; then + expected_uid=0 + fi + [[ "$owner_uid" == "$expected_uid" && "$mode" =~ ^[0-7]+$ ]] || return 1 + (((8#$mode & 0077) == 0)) || return 1 +} + +install_lock_read_owner() { + local lock_path="$1" + local use_sudo="$2" + install_lock_command "$use_sudo" /bin/test -f "$lock_path" 2> /dev/null || return 1 + ! install_lock_command "$use_sudo" /bin/test -L "$lock_path" 2> /dev/null || return 1 + install_lock_command "$use_sudo" cat "$lock_path" 2> /dev/null +} + +install_lock_remove_control() { + local control_path="$1" + local use_sudo="$2" + local control_prefix="$INSTALL_DIR/.mole-update.lock/control." + local control_suffix + [[ -n "$control_path" ]] || return 0 + [[ "$control_path" == "$control_prefix"* ]] || return 1 + control_suffix="${control_path#"$control_prefix"}" + [[ -n "$control_suffix" && "$control_suffix" != */* ]] || return 1 + install_lock_command "$use_sudo" /bin/test -f "$control_path" 2> /dev/null || return 1 + ! install_lock_command "$use_sudo" /bin/test -L "$control_path" 2> /dev/null || return 1 + install_lock_command "$use_sudo" /bin/rm -f "$control_path" 2> /dev/null # SAFE: exact mktemp-created install lock control file. +} + +acquire_install_lock() { + local lock_path="$INSTALL_DIR/.mole-update.lock/kernel.lock" + local control_path holder_pid owner_pid owner_start token owner_value="" attempt=0 + local use_sudo=false + if [[ ${EUID:-0} -ne 0 && ! -w "$INSTALL_DIR" ]]; then + use_sudo=true + fi + INSTALL_LOCK_FAILURE="busy" + if install_lock_has_unsafe_ancestor "$use_sudo"; then + INSTALL_LOCK_FAILURE="unsafe_ancestor" + return 1 + fi + # Probe admin access before the first privileged lock step. Without this the + # denial surfaces as a swallowed `sudo -n` inside install_lock_prepare_dir + # and gets reported as a busy lock. + if [[ "$use_sudo" == "true" ]] && + ! install_lock_command "$use_sudo" /usr/bin/true 2> /dev/null; then + # A caller that set MOLE_ASSUME_SUDO_AUTH did authenticate, but the + # session can lapse before this point on a slow download or when the + # keepalive dies. Recover once through the controlling terminal instead + # of failing an install that only needs the password again. Without a + # terminal this is a no-op and the refusal stands. + if ! install_lock_reauthenticate || + ! install_lock_command "$use_sudo" /usr/bin/true 2> /dev/null; then + INSTALL_LOCK_FAILURE="no_admin" + return 1 + fi + fi + if ! install_lock_prepare_dir "$use_sudo"; then + INSTALL_LOCK_FAILURE="lock_dir" + return 1 + fi + if install_lock_command "$use_sudo" /bin/test -e "$lock_path" 2> /dev/null || + install_lock_command "$use_sudo" /bin/test -L "$lock_path" 2> /dev/null; then + INSTALL_LOCK_FAILURE="lock_path" + install_lock_command "$use_sudo" /bin/test -f "$lock_path" 2> /dev/null || return 1 + ! install_lock_command "$use_sudo" /bin/test -L "$lock_path" 2> /dev/null || return 1 + INSTALL_LOCK_FAILURE="busy" + fi + # /usr/bin/lockf gives a kernel lock the OS drops even if the holder is + # killed -9, so prefer it. It only ships with newer macOS, and requiring it + # made both install and update exit before writing a single file on every + # older release (#1348). Where it is absent, an atomic mkdir inside the lock + # directory provides the same mutual exclusion; what it does not provide is + # release-on-death, so that path reclaims a mutex whose recorded owner is + # provably gone. Only a system with neither is refused. + local mutex_dir="" + if [[ ! -x /usr/bin/lockf ]]; then + if [[ ! -x /bin/mkdir ]]; then + INSTALL_LOCK_FAILURE="no_mutex" + return 1 + fi + mutex_dir="$INSTALL_DIR/.mole-update.lock/holder" + fi + install_lock_current_shell_pid owner_pid || return 1 + owner_start=$(install_lock_process_start "$owner_pid") + [[ -n "$owner_start" ]] || return 1 + control_path=$(install_lock_command "$use_sudo" /usr/bin/mktemp "$INSTALL_DIR/.mole-update.lock/control.XXXXXX") || return 1 + token="$owner_pid|$owner_start|${control_path##*.}" + + # shellcheck disable=SC2016 # The lock-holder shell expands these values. + local holder_script=' + token="$1" + owner_pid="$2" + owner_start="$3" + lock_path="$4" + control_path="$5" + mutex_dir="$6" + current_start="" + if [ ! -f "$control_path" ]; then + exit 1 + fi + if [ -n "$mutex_dir" ] && ! /bin/mkdir "$mutex_dir" 2>/dev/null; then + # Occupied. Reclaim only against proof the recorded owner is gone: + # a live pid whose start time still matches is a real concurrent + # writer, and a reused pid is why the start time is compared too. + previous=$(/bin/cat "$lock_path" 2>/dev/null || true) + previous_pid=${previous%%|*} + previous_rest=${previous#*|} + previous_start=${previous_rest%%|*} + owner_gone=1 + if [ -n "$previous_pid" ] && kill -0 "$previous_pid" 2>/dev/null; then + current_start=$(LC_ALL=C /bin/ps -p "$previous_pid" -o lstart= 2>/dev/null | + /usr/bin/sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" | /usr/bin/head -1) + if [ "$current_start" = "$previous_start" ]; then + owner_gone=0 + fi + fi + [ "$owner_gone" = 1 ] || exit 1 + /bin/rmdir "$mutex_dir" 2>/dev/null || exit 1 + /bin/mkdir "$mutex_dir" 2>/dev/null || exit 1 + fi + if ! printf "%s\n" "$token" > "$lock_path"; then + [ -n "$mutex_dir" ] && /bin/rmdir "$mutex_dir" 2>/dev/null + exit 1 + fi + while [ -f "$control_path" ]; do + kill -0 "$owner_pid" 2>/dev/null || break + current_start=$(LC_ALL=C /bin/ps -p "$owner_pid" -o lstart= 2>/dev/null | + /usr/bin/sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" | /usr/bin/head -1) + [ "$current_start" = "$owner_start" ] || break + /bin/sleep 0.1 + done + /bin/rm -f "$control_path" # SAFE: exact mktemp-created install lock control file. + [ -n "$mutex_dir" ] && /bin/rmdir "$mutex_dir" 2>/dev/null + exit 0 + ' + if [[ -n "$mutex_dir" ]]; then + install_lock_command "$use_sudo" /bin/sh -c "$holder_script" \ + sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "$mutex_dir" & + else + install_lock_command "$use_sudo" /usr/bin/lockf -k -s -t 0 -w "$lock_path" /bin/sh -c "$holder_script" \ + sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "" & + fi + holder_pid=$! + + while [[ "$attempt" -lt 100 ]]; do + if ! kill -0 "$holder_pid" 2> /dev/null; then + wait "$holder_pid" 2> /dev/null || true + install_lock_remove_control "$control_path" "$use_sudo" || true + return 1 + fi + owner_value=$(install_lock_read_owner "$lock_path" "$use_sudo" || true) + if [[ "$owner_value" == "$token" ]]; then + INSTALL_LOCK_PATH="$lock_path" + INSTALL_LOCK_CONTROL="$control_path" + INSTALL_LOCK_HOLDER_PID="$holder_pid" + INSTALL_LOCK_USE_SUDO="$use_sudo" + INSTALL_LOCK_FAILURE="" + return 0 + fi + /bin/sleep 0.05 + attempt=$((attempt + 1)) + done + + install_lock_remove_control "$control_path" "$use_sudo" || true + wait "$holder_pid" 2> /dev/null || true + return 1 +} + +# One rejection reason, one remedy. Callers must not print a bare lock message. +report_install_lock_failure() { + local install_lock_dir + case "$INSTALL_LOCK_FAILURE" in + no_admin) + log_error "Admin access to $INSTALL_DIR is required but not available" + log_error "Cache credentials first, then retry: sudo -v && mo update" + ;; + unsafe_ancestor) + install_lock_dir="${INSTALL_LOCK_UNSAFE_ANCESTOR:-}" + case "$INSTALL_LOCK_UNSAFE_ANCESTOR_REASON" in + symlink) + log_error "$install_lock_dir is a symlink, so a privileged write through it cannot be trusted" + log_error "See where it points, install under a real directory, then retry: ls -ld $install_lock_dir" + ;; + not_root_owned) + log_error "$install_lock_dir is not owned by root, so a privileged write there cannot be trusted" + log_error "Restore ownership, then retry: sudo chown root:wheel $install_lock_dir" + ;; + foreign_owner) + log_error "$install_lock_dir is owned by neither root nor you" + log_error "Take ownership, then retry: sudo chown $(id -un) $install_lock_dir" + ;; + acl) + log_error "$install_lock_dir carries an ACL that grants access beyond its mode" + log_error "Clear it, then retry: sudo chmod -N $install_lock_dir" + ;; + unreadable) + log_error "$install_lock_dir could not be inspected, so it cannot be cleared for a privileged write" + log_error "Look at it, then retry: sudo ls -lde $install_lock_dir" + ;; + writable) + log_error "$install_lock_dir is writable by someone other than root" + log_error "Close it, then retry: sudo chmod go-w $install_lock_dir" + ;; + *) + log_error "$install_lock_dir cannot be trusted for a privileged write" + log_error "Inspect it, then retry: sudo ls -lde $install_lock_dir" + ;; + esac + ;; + lock_dir) + log_error "$INSTALL_DIR/.mole-update.lock is not a usable lock directory" + log_error "Inspect it, then retry: sudo ls -lde $INSTALL_DIR/.mole-update.lock" + ;; + lock_path) + log_error "$INSTALL_DIR/.mole-update.lock/kernel.lock is not a regular file" + log_error "Remove it, then retry: sudo rm -f $INSTALL_DIR/.mole-update.lock/kernel.lock" + ;; + no_mutex) + log_error "Neither /usr/bin/lockf nor /bin/mkdir is usable, so concurrent installs cannot be kept apart" + log_error "Check that /bin and /usr/bin are intact, then retry: ls -l /bin/mkdir /usr/bin/lockf" + ;; + *) + log_error "Another install or update is running, wait for it to finish and retry" + ;; + esac +} + +release_install_lock() { + [[ -n "$INSTALL_LOCK_CONTROL" ]] && + install_lock_remove_control "$INSTALL_LOCK_CONTROL" "$INSTALL_LOCK_USE_SUDO" || true + [[ -n "$INSTALL_LOCK_HOLDER_PID" ]] && wait "$INSTALL_LOCK_HOLDER_PID" 2> /dev/null || true + INSTALL_LOCK_PATH="" + INSTALL_LOCK_CONTROL="" + INSTALL_LOCK_HOLDER_PID="" + INSTALL_LOCK_USE_SUDO=false +} + +cleanup_installer() { + stop_line_spinner 2> /dev/null || true + release_install_lock + if [[ -n "$INSTALL_SOURCE_TMP" ]]; then + # Teardown never decides the exit status. This runs from the EXIT trap + # under `set -e`, so a refusal here used to turn a finished, verified + # install into exit 1 and every caller that checks the status, package + # managers included, read it as a failed install (#1343). safe_rm still + # prints why it refused; it just no longer overrides the verdict the + # install itself already reached. + safe_rm "$INSTALL_SOURCE_TMP" || true + INSTALL_SOURCE_TMP="" + fi +} + +get_remote_main_commit_hash() { + command -v curl > /dev/null 2>&1 || return 1 + + local response="" + local commit_hash="" + response=$(curl -fsSL --connect-timeout 3 --max-time 5 \ + "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null || true) + commit_hash=$(printf '%s\n' "$response" | + sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([a-f0-9]\{40\}\)".*/\1/p' | head -1) + [[ "$commit_hash" =~ ^[0-9a-f]{40}$ ]] || return 1 + printf '%s\n' "$commit_hash" +} + +source_archive_url() { + local branch="$1" + local source_commit="${2:-}" + + if [[ "$branch" == "main" && "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then + printf 'https://github.com/tw93/mole/archive/%s.tar.gz\n' "$source_commit" + elif [[ "$branch" == "main" || "$branch" == "dev" ]]; then + printf 'https://github.com/tw93/mole/archive/refs/heads/%s.tar.gz\n' "$branch" + else + printf 'https://github.com/tw93/mole/archive/refs/tags/%s.tar.gz\n' "$branch" + fi +} + resolve_source_dir() { if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" && -f "$SOURCE_DIR/mole" ]]; then return 0 @@ -132,17 +690,13 @@ resolve_source_dir() { fi local tmp - tmp="$(mktemp -d)" - - # Safe cleanup function for temporary directory - cleanup_tmp() { - stop_line_spinner 2> /dev/null || true - if [[ -z "${tmp:-}" ]]; then - return 0 - fi - safe_rm "$tmp" - } - trap cleanup_tmp EXIT + # Derive the directory from the same TMPDIR safe_rm gates on. A bare + # `mktemp -d` ignores TMPDIR on macOS and always lands in the Darwin + # per-user temp dir, so with TMPDIR unset the two disagreed and cleanup + # refused to remove what the installer had just created (#1343). Homebrew + # strips TMPDIR from the environment, which made that deterministic there. + tmp="$(mktemp -d "${TMPDIR:-/tmp}/mole.XXXXXX")" + INSTALL_SOURCE_TMP="$tmp" local branch="${MOLE_VERSION:-}" if [[ -z "$branch" ]]; then @@ -152,22 +706,35 @@ resolve_source_dir() { branch="$(get_latest_release_tag_from_git || true)" fi if [[ -z "$branch" ]]; then + # Both release-tag lookups failed (typically GitHub API rate limits + # while codeload still works). Keep the install usable, but say + # loudly that this is now a nightly source install, not a release. + log_warning "Could not resolve the latest release tag; installing from main (nightly source)" branch="main" fi if [[ "$branch" != "main" && "$branch" != "dev" ]]; then branch="$(normalize_release_tag "$branch")" fi - local url="https://github.com/tw93/mole/archive/refs/heads/main.tar.gz" - - if [[ "$branch" == "dev" ]]; then - url="https://github.com/tw93/mole/archive/refs/heads/dev.tar.gz" - elif [[ "$branch" != "main" ]]; then - url="https://github.com/tw93/mole/archive/refs/tags/${branch}.tar.gz" + local source_commit="" + if [[ "$branch" == "main" ]]; then + source_commit="${MOLE_INSTALL_COMMIT:-}" + if [[ -n "$source_commit" && ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then + log_error "Invalid pinned source commit" + exit 1 + fi + if [[ -z "$source_commit" ]]; then + source_commit=$(get_remote_main_commit_hash || true) + fi + if [[ "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then + SOURCE_COMMIT_HASH="$source_commit" + fi fi + local url + url=$(source_archive_url "$branch" "$source_commit") start_line_spinner "Fetching Mole source, ${branch}..." if command -v curl > /dev/null 2>&1; then - if curl -fsSL --connect-timeout 10 --max-time 60 -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then + if curl_download_with_retry "$url" "$tmp/mole.tar.gz" 2> /dev/null; then if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then stop_line_spinner @@ -192,14 +759,28 @@ resolve_source_dir() { start_line_spinner "Cloning Mole source..." if command -v git > /dev/null 2>&1; then - local git_args=("--depth=1") - if [[ "$branch" != "main" ]]; then - git_args+=("--branch" "$branch") + local clone_succeeded=false + if [[ -n "$source_commit" ]]; then + if git init -q "$tmp/mole" > /dev/null 2>&1 && + git -C "$tmp/mole" remote add origin https://github.com/tw93/mole.git > /dev/null 2>&1 && + git -C "$tmp/mole" fetch -q --depth=1 origin "$source_commit" > /dev/null 2>&1 && + git -C "$tmp/mole" checkout -q --detach FETCH_HEAD > /dev/null 2>&1; then + clone_succeeded=true + fi + else + local git_args=("--depth=1") + if [[ "$branch" != "main" ]]; then + git_args+=("--branch" "$branch") + fi + if git clone "${git_args[@]}" https://github.com/tw93/mole.git "$tmp/mole" > /dev/null 2>&1; then + clone_succeeded=true + fi fi - if git clone "${git_args[@]}" https://github.com/tw93/mole.git "$tmp/mole" > /dev/null 2>&1; then + if [[ "$clone_succeeded" == "true" ]]; then stop_line_spinner SOURCE_DIR="$tmp/mole" + SOURCE_COMMIT_HASH=$(git -C "$SOURCE_DIR" rev-parse HEAD 2> /dev/null || true) return 0 fi fi @@ -218,14 +799,15 @@ get_source_version() { } get_source_commit_hash() { + if [[ "$SOURCE_COMMIT_HASH" =~ ^[0-9a-f]{7,40}$ ]]; then + printf '%s\n' "$SOURCE_COMMIT_HASH" + return 0 + fi # Try to get from local git repo first if [[ -d "$SOURCE_DIR/.git" ]]; then git -C "$SOURCE_DIR" rev-parse --short HEAD 2> /dev/null && return fi - # Fallback to GitHub API - curl -fsSL --connect-timeout 3 \ - "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null | - sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([a-f0-9]\{7\}\).*/\1/p' | head -1 + return 1 } get_latest_release_tag() { @@ -264,11 +846,137 @@ normalize_release_tag() { fi } +release_checksums_url() { + local tag="$1" + printf 'https://github.com/tw93/mole/releases/download/%s/SHA256SUMS\n' "$tag" +} + +download_release_checksums() { + local tag="$1" + local output_file="$2" + local url + url="$(release_checksums_url "$tag")" + + curl_download_with_retry "$url" "$output_file" +} + +# Verify the Sigstore/GitHub Actions build-provenance attestation for a release +# asset. Returns: +# 0 - attestation verified +# 1 - verification failed (asset has no matching attestation, or signature invalid) +# 2 - cannot verify (gh CLI missing or unauthenticated); caller decides policy +# +# The release workflow generates attestations via actions/attest-build-provenance +# covering SHA256SUMS, the per-arch binaries, and the homebrew tarballs. +# Verifying the SHA256SUMS file is sufficient: the binary's sha256 is then +# anchored to that attested file by verify_release_asset_checksum(). +verify_release_attestation() { + local file="$1" + + if ! command -v gh > /dev/null 2>&1; then + return 2 + fi + if ! gh auth status > /dev/null 2>&1; then + return 2 + fi + + # --owner restricts the trusted signer identity to the upstream repo's + # GitHub Actions workflow. --deny-self-hosted-runners blocks attestations + # produced by self-hosted runners, which a repo compromise could otherwise + # introduce as a sidechannel. + if gh attestation verify "$file" \ + --owner tw93 \ + --deny-self-hosted-runners \ + > /dev/null 2>&1; then + return 0 + fi + return 1 +} + +extract_release_checksum() { + local checksums_file="$1" + local asset_name="$2" + + awk -v asset="$asset_name" '$2 == asset { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$checksums_file" +} + +calculate_file_sha256() { + local file="$1" + + if command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1; exit}' + return + fi + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$file" | awk '{print $1; exit}' + return + fi + + return 1 +} + +verify_release_asset_checksum() { + local tag="$1" + local asset_name="$2" + local file="$3" + local checksums_file + checksums_file="$(mktemp "${TMPDIR:-/tmp}/mole-checksums.XXXXXX")" || return 1 + + local expected="" + local actual="" + local result=1 + local attestation_status=2 + + # The checksums fetch and the attestation check are separate network round + # trips that can take several seconds each. With nothing on screen this is + # the longest silent stretch of an install and reads as a hang, so keep a + # spinner up until the first real line prints. + start_line_spinner "Verifying ${asset_name}..." + + if download_release_checksums "$tag" "$checksums_file" > /dev/null 2>&1; then + # Anchor the SHA256SUMS file to its GitHub Actions build-provenance + # attestation before reading checksums from it. If gh is available, + # an attestation mismatch is fatal; without gh, fall through to + # checksum-only verification (matches prior behavior). + verify_release_attestation "$checksums_file" + attestation_status=$? + + if [[ "$attestation_status" -eq 1 ]]; then + stop_line_spinner + log_error "Release attestation verification failed for ${asset_name}" + rm -f "$checksums_file" + return 1 + fi + + if [[ "$attestation_status" -eq 2 && "${MOLE_REQUIRE_ATTESTATION:-0}" == "1" ]]; then + stop_line_spinner + log_error "MOLE_REQUIRE_ATTESTATION=1 set but gh CLI unavailable or unauthenticated" + rm -f "$checksums_file" + return 1 + fi + + expected=$(extract_release_checksum "$checksums_file" "$asset_name" 2> /dev/null || true) + actual=$(calculate_file_sha256 "$file" 2> /dev/null || true) + if [[ -n "$expected" && -n "$actual" && "$expected" == "$actual" ]]; then + result=0 + if [[ "$attestation_status" -eq 0 ]]; then + stop_line_spinner + log_success "Verified ${asset_name} · sha256 + attestation" + fi + fi + fi + + stop_line_spinner + rm -f "$checksums_file" + return "$result" +} + get_installed_version() { local binary="$INSTALL_DIR/mole" if [[ -x "$binary" ]]; then local version - version=$("$binary" --version 2> /dev/null | awk '/Mole version/ {print $NF; exit}') + version=$(run_install_probe_with_timeout 5 "$binary" --version 2> /dev/null | + awk '/Mole version/ {print $NF; exit}' || true) if [[ -n "$version" ]]; then echo "$version" else @@ -300,13 +1008,28 @@ resolve_install_channel() { write_install_channel_metadata() { local channel="$1" local commit_hash="${2:-}" + local install_receipt="${3:-}" local metadata_file="$CONFIG_DIR/install_channel" + if [[ -n "$install_receipt" && ! "$install_receipt" =~ ^(heal|update)-[0-9]+-[0-9]+-[0-9]+$ ]]; then + return 1 + fi + + mkdir -p "$CONFIG_DIR" 2> /dev/null || return 1 local tmp_file tmp_file=$(mktemp "${CONFIG_DIR}/install_channel.XXXXXX") || return 1 + # Use a plain if/fi so the block's exit code reflects only I/O failure. + # The previous form `[[ -n "$h" ]] && printf ...` returned 1 whenever the + # commit hash was empty (the stable channel always omits it), which made + # the redirect look like it had failed and tripped the warning. { printf 'CHANNEL=%s\n' "$channel" - [[ -n "$commit_hash" ]] && printf 'COMMIT_HASH=%s\n' "$commit_hash" + if [[ -n "$commit_hash" ]]; then + printf 'COMMIT_HASH=%s\n' "$commit_hash" + fi + if [[ -n "$install_receipt" ]]; then + printf 'INSTALL_RECEIPT=%s\n' "$install_receipt" + fi } > "$tmp_file" || { rm -f "$tmp_file" 2> /dev/null || true return 1 @@ -410,6 +1133,16 @@ parse_args() { done } +normalize_install_dir() { + case "$INSTALL_DIR" in + /*) return 0 ;; + esac + + local physical_cwd + physical_cwd=$(pwd -P 2> /dev/null) || return 1 + INSTALL_DIR="$physical_cwd/$INSTALL_DIR" +} + # Environment checks and directory setup check_requirements() { if [[ "$OSTYPE" != "darwin"* ]]; then @@ -417,7 +1150,7 @@ check_requirements() { exit 1 fi - if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then + if homebrew_owns_mole; then local mole_path mole_path=$(command -v mole 2> /dev/null || true) local is_homebrew_binary=false @@ -490,11 +1223,7 @@ build_binary_from_source() { return 1 fi - if [[ -t 1 ]]; then - start_line_spinner "Building ${binary_name} from source..." - else - echo "Building ${binary_name} from source..." - fi + start_line_spinner "Building ${binary_name} from source..." if (cd "$SOURCE_DIR" && go build -ldflags="-s -w" -o "$target_path" "./$cmd_dir" > /dev/null 2>&1); then if [[ -t 1 ]]; then stop_line_spinner; fi @@ -508,9 +1237,20 @@ build_binary_from_source() { return 1 } +install_staged_binary() { + local staged_path="$1" + local target_path="$2" + + chmod +x "$staged_path" || return 1 + xattr -c "$staged_path" 2> /dev/null || true + mv -f "$staged_path" "$target_path" +} + download_binary() { local binary_name="$1" local target_path="$CONFIG_DIR/bin/${binary_name}-go" + local staged_path + staged_path=$(mktemp "$CONFIG_DIR/bin/.${binary_name}-go.XXXXXX") || return 1 local arch arch=$(uname -m) local arch_suffix="amd64" @@ -519,56 +1259,110 @@ download_binary() { fi if [[ -f "$SOURCE_DIR/bin/${binary_name}-go" ]]; then - cp "$SOURCE_DIR/bin/${binary_name}-go" "$target_path" - chmod +x "$target_path" + if ! cp "$SOURCE_DIR/bin/${binary_name}-go" "$staged_path" || + ! install_staged_binary "$staged_path" "$target_path"; then + rm -f "$staged_path" + return 1 + fi log_success "Installed local ${binary_name} binary" return 0 elif [[ -f "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" ]]; then - cp "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" "$target_path" - chmod +x "$target_path" + if ! cp "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" "$staged_path" || + ! install_staged_binary "$staged_path" "$target_path"; then + rm -f "$staged_path" + return 1 + fi log_success "Installed local ${binary_name} binary" return 0 fi if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then - if build_binary_from_source "$binary_name" "$target_path"; then + if build_binary_from_source "$binary_name" "$staged_path" && + install_staged_binary "$staged_path" "$target_path"; then return 0 fi + rm -f "$staged_path" fi local version version=$(get_source_version) if [[ -z "$version" ]]; then log_warning "Could not determine version for ${binary_name}, trying local build" - if build_binary_from_source "$binary_name" "$target_path"; then + if build_binary_from_source "$binary_name" "$staged_path" && + install_staged_binary "$staged_path" "$target_path"; then return 0 fi + rm -f "$staged_path" return 1 fi - local url="https://github.com/tw93/mole/releases/download/V${version}/${binary_name}-darwin-${arch_suffix}" + local release_tag + release_tag="$(normalize_release_tag "$version")" + local asset_name="${binary_name}-darwin-${arch_suffix}" + local url="https://github.com/tw93/mole/releases/download/${release_tag}/${asset_name}" # Skip preflight network checks to avoid false negatives. - if [[ -t 1 ]]; then - start_line_spinner "Downloading ${binary_name}..." - else - echo "Downloading ${binary_name}..." - fi + start_line_spinner "Downloading ${binary_name}..." - if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then + # Both download attempts are followed by another route, so a failure here + # is a step in the flow rather than the end of it. Surfacing curl's raw + # stderr made the ordinary "tag not published yet" case print a 404 the + # user cannot act on, immediately above the line explaining the fallback. + if curl_download_with_retry "$url" "$staged_path" 2> /dev/null; then if [[ -t 1 ]]; then stop_line_spinner; fi - chmod +x "$target_path" - xattr -c "$target_path" 2> /dev/null || true - log_success "Downloaded ${binary_name} binary" - else - if [[ -t 1 ]]; then stop_line_spinner; fi - log_warning "Could not download ${binary_name} binary, v${version}, trying local build" - if build_binary_from_source "$binary_name" "$target_path"; then + if verify_release_asset_checksum "$release_tag" "$asset_name" "$staged_path" && + install_staged_binary "$staged_path" "$target_path"; then + log_success "Installed ${binary_name}" return 0 fi - log_error "Failed to install ${binary_name} binary" + rm -f "$staged_path" + # Integrity failure is fatal, never a downgrade. The asset arrived + # but its SHA256SUMS/attestation check did not pass; a blocked or + # tampered checksums file must not be able to reroute the install + # onto an unverified source build (classic verification-stripping + # downgrade). Explicit source builds remain available via + # MOLE_VERSION=main / MOLE_EDGE_INSTALL=true. + log_error "Verification failed for ${binary_name}; aborting instead of falling back to an unverified build" + log_error "Retry later, or opt into a source build explicitly: MOLE_VERSION=main ./install.sh (piping from curl: | bash -s latest)" return 1 fi + rm -f "$staged_path" + if [[ -t 1 ]]; then stop_line_spinner; fi + + local fallback_tag + fallback_tag=$(get_latest_release_tag 2> /dev/null || true) + if [[ -n "$fallback_tag" && "$fallback_tag" != "$release_tag" ]]; then + local fallback_url="https://github.com/tw93/mole/releases/download/${fallback_tag}/${asset_name}" + start_line_spinner "Retrying ${binary_name} from ${fallback_tag}..." + if curl_download_with_retry "$fallback_url" "$staged_path" 2> /dev/null; then + if [[ -t 1 ]]; then stop_line_spinner; fi + if verify_release_asset_checksum "$fallback_tag" "$asset_name" "$staged_path" && + install_staged_binary "$staged_path" "$target_path"; then + log_success "Installed ${binary_name} from ${fallback_tag} · v${version} not yet published" + return 0 + fi + rm -f "$staged_path" + if [[ -t 1 ]]; then stop_line_spinner; fi + # Same integrity contract as the primary tag above: the fallback + # asset arrived but did not verify, which is evidence of tampering + # or a corrupted checksums file, not of unavailability. Only a + # plain download failure may continue into the source build. + log_error "Verification failed for ${binary_name} from ${fallback_tag}; aborting instead of falling back to an unverified build" + log_error "Retry later, or opt into a source build explicitly: MOLE_VERSION=main ./install.sh (piping from curl: | bash -s latest)" + return 1 + fi + rm -f "$staged_path" + if [[ -t 1 ]]; then stop_line_spinner; fi + fi + + log_warning "Could not download ${binary_name} binary, v${version}, trying local build" + if build_binary_from_source "$binary_name" "$staged_path" && + install_staged_binary "$staged_path" "$target_path"; then + return 0 + fi + rm -f "$staged_path" + log_error "Failed to install ${binary_name} binary" + return 1 } # File installation (bin/lib/scripts + go helpers). @@ -587,44 +1381,57 @@ install_files() { if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then if needs_sudo; then log_admin "Admin access required for /usr/local/bin" + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_error "Admin access required, blocked in test mode" + return 1 + fi + # Explicit failure checks throughout this function: callers run + # `install_files || {...}`, and bash disables errexit inside a + # function invoked that way, so a failed sudo would otherwise be + # swallowed and the install would report success while leaving + # the old entry script in place (the 1.45.0 -> 1.47.0 fake + # "Updated to latest version" incident). + if ! ensure_sudo_ready; then + log_error "Admin access to $INSTALL_DIR is required but not available" + log_error "Cache credentials first, then retry: sudo -v && mo update" + return 1 + fi fi # Atomic update: copy to temporary name first, then move - maybe_sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole.new" - maybe_sudo chmod +x "$INSTALL_DIR/mole.new" - maybe_sudo mv -f "$INSTALL_DIR/mole.new" "$INSTALL_DIR/mole" + if ! maybe_sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole.new" || + ! maybe_sudo chmod +x "$INSTALL_DIR/mole.new" || + ! maybe_sudo mv -f "$INSTALL_DIR/mole.new" "$INSTALL_DIR/mole"; then + log_error "Failed to install mole to $INSTALL_DIR (admin access missing or denied)" + log_error "Cache credentials first, then retry: sudo -v && mo update" + return 1 + fi - log_success "Installed mole to $INSTALL_DIR" fi else log_error "mole executable not found in ${SOURCE_DIR:-unknown}" exit 1 fi - if [[ -f "$SOURCE_DIR/mo" ]]; then - if [[ "$source_dir_abs" == "$install_dir_abs" ]]; then - log_success "mo alias already present" - else - maybe_sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo.new" - maybe_sudo chmod +x "$INSTALL_DIR/mo.new" - maybe_sudo mv -f "$INSTALL_DIR/mo.new" "$INSTALL_DIR/mo" - log_success "Installed mo alias" + if [[ -f "$SOURCE_DIR/mo" && "$source_dir_abs" != "$install_dir_abs" ]]; then + if ! maybe_sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo.new" || + ! maybe_sudo chmod +x "$INSTALL_DIR/mo.new" || + ! maybe_sudo mv -f "$INSTALL_DIR/mo.new" "$INSTALL_DIR/mo"; then + log_error "Failed to install mo alias to $INSTALL_DIR (admin access missing or denied)" + return 1 fi fi if [[ -d "$SOURCE_DIR/bin" ]]; then local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)" local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)" - if [[ "$source_bin_abs" == "$config_bin_abs" ]]; then - log_success "Modules already synced" - else + if [[ "$source_bin_abs" != "$config_bin_abs" ]]; then local -a bin_files=("$SOURCE_DIR/bin"/*) if [[ ${#bin_files[@]} -gt 0 ]]; then cp -r "${bin_files[@]}" "$CONFIG_DIR/bin/" for file in "$CONFIG_DIR/bin/"*; do [[ -e "$file" ]] && chmod +x "$file" done - log_success "Installed modules" fi fi fi @@ -632,13 +1439,10 @@ install_files() { if [[ -d "$SOURCE_DIR/lib" ]]; then local source_lib_abs="$(cd "$SOURCE_DIR/lib" && pwd)" local config_lib_abs="$(cd "$CONFIG_DIR/lib" && pwd)" - if [[ "$source_lib_abs" == "$config_lib_abs" ]]; then - log_success "Libraries already synced" - else + if [[ "$source_lib_abs" != "$config_lib_abs" ]]; then local -a lib_files=("$SOURCE_DIR/lib"/*) if [[ ${#lib_files[@]} -gt 0 ]]; then cp -r "${lib_files[@]}" "$CONFIG_DIR/lib/" - log_success "Installed libraries" fi fi fi @@ -656,26 +1460,54 @@ install_files() { fi if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then - maybe_sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" + # Use absolute /usr/bin/sed (always BSD on macOS) so PATH-shadowed + # GNU sed from Homebrew gnu-sed does not break the -i '' syntax. + if ! maybe_sudo /usr/bin/sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole"; then + log_error "Failed to point $INSTALL_DIR/mole at $CONFIG_DIR" + return 1 + fi fi + # One line for the whole file install. Reporting the entry script, the mo + # alias, the modules and the libraries separately described the layout + # rather than the outcome, and every one of them is a hard failure that + # returns above if it goes wrong. + log_success "Installed to $INSTALL_DIR" + + local helper_install_marker="$CONFIG_DIR/.helper_install_incomplete" + : > "$helper_install_marker" if ! download_binary "analyze"; then exit 1 fi if ! download_binary "status"; then exit 1 fi + rm -f "$helper_install_marker" } # Verification and PATH hint verify_installation() { if [[ -x "$INSTALL_DIR/mole" ]] && [[ -f "$CONFIG_DIR/lib/core/common.sh" ]]; then + # A runnable old entry script also passes --help, so cross-check the + # installed version against the source that was just installed. A + # mismatch means the entry script was not actually replaced (for + # example a swallowed sudo failure) and the install is a mixed-version + # state that must not be reported as success. + local expected_version installed_version + expected_version="$(get_source_version 2> /dev/null || true)" + installed_version="$(get_installed_version 2> /dev/null || true)" + if [[ -n "$expected_version" && -n "$installed_version" && "$expected_version" != "$installed_version" ]]; then + log_error "Installed mole reports $installed_version but $expected_version was expected" + log_error "The entry script at $INSTALL_DIR/mole was not replaced; retry with: sudo -v && mo update" + exit 1 + fi - if "$INSTALL_DIR/mole" --help > /dev/null 2>&1; then + if run_install_probe_with_timeout 5 "$INSTALL_DIR/mole" --help > /dev/null 2>&1; then return 0 else - log_warning "Mole command installed but may not be working properly" + log_error "Installed Mole did not answer the bounded help probe" + return 1 fi else log_error "Installation verification failed" @@ -707,6 +1539,13 @@ print_usage_summary() { return fi + # A usage cheat sheet is for someone watching a fresh install scroll by. + # `mo update` runs this same installer with its output captured, so on a + # tty-less run the block arrives after the fact and tells an existing user + # the nine commands they already use, followed by a second success line + # contradicting nothing. The caller prints its own result there. + [[ -t 1 ]] || return 0 + echo "" local message="Mole ${action} successfully" @@ -745,6 +1584,29 @@ print_usage_summary() { echo "" } +# Whether Homebrew owns a mole install, decided from the Cellar on disk and +# never by running `brew`: the brew entry point resets the user's sudo +# timestamp as a security measure, and invoking it after the update flow's +# pre-authentication is exactly what killed the handed-over ticket within +# five seconds and forced a second password prompt on every update, with +# the ticket dying between installer start and the first privileged step. +homebrew_owns_mole() { + local -a brew_prefixes=("${HOMEBREW_PREFIX:-}" /opt/homebrew /usr/local) + # A custom prefix need not export HOMEBREW_PREFIX, and `brew list mole` + # used to find those installs. Derive the prefix from brew's own location + # instead: reading the path is not running the binary. + local brew_bin="" + brew_bin=$(command -v brew 2> /dev/null || true) + if [[ -n "$brew_bin" ]]; then + brew_prefixes+=("$(dirname "$(dirname "$brew_bin")")") + fi + local brew_prefix + for brew_prefix in "${brew_prefixes[@]}"; do + [[ -n "$brew_prefix" && -d "$brew_prefix/Cellar/mole" ]] && return 0 + done + return 1 +} + # Main install/update flows perform_install() { resolve_source_dir @@ -753,6 +1615,10 @@ perform_install() { check_requirements create_directories + acquire_install_lock || { + report_install_lock_failure + exit 1 + } install_files verify_installation setup_path @@ -767,9 +1633,9 @@ perform_install() { local install_channel commit_hash="" install_channel="$(resolve_install_channel)" if [[ "$install_channel" == "nightly" ]]; then - commit_hash=$(get_source_commit_hash) + commit_hash=$(get_source_commit_hash || true) fi - if ! write_install_channel_metadata "$install_channel" "$commit_hash"; then + if ! write_install_channel_metadata "$install_channel" "$commit_hash" "${MOLE_INSTALL_RECEIPT:-}"; then log_warning "Could not write install channel metadata" fi @@ -788,7 +1654,7 @@ perform_install() { perform_update() { check_requirements - if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then + if homebrew_owns_mole; then resolve_source_dir 2> /dev/null || true local current_version current_version=$(get_installed_version || echo "unknown") @@ -836,6 +1702,11 @@ perform_update() { log_error "Failed to create directories" exit 1 } + acquire_install_lock || { + VERBOSE=$old_verbose + report_install_lock_failure + exit 1 + } install_files || { VERBOSE=$old_verbose log_error "Failed to install files" @@ -859,9 +1730,9 @@ perform_update() { local install_channel commit_hash="" install_channel="$(resolve_install_channel)" if [[ "$install_channel" == "nightly" ]]; then - commit_hash=$(get_source_commit_hash) + commit_hash=$(get_source_commit_hash || true) fi - if ! write_install_channel_metadata "$install_channel" "$commit_hash"; then + if ! write_install_channel_metadata "$install_channel" "$commit_hash" "${MOLE_INSTALL_RECEIPT:-}"; then log_warning "Could not write install channel metadata" fi @@ -869,6 +1740,13 @@ perform_update() { } parse_args "$@" +normalize_install_dir || { + log_error "Could not resolve the installation directory: $INSTALL_DIR" + exit 1 +} + +trap 'cleanup_installer' EXIT +trap 'cleanup_installer; exit 130' INT TERM case "$ACTION" in update) diff --git a/Resources/mole/internal/units/bytes.go b/Resources/mole/internal/units/bytes.go new file mode 100644 index 0000000..eaf7b45 --- /dev/null +++ b/Resources/mole/internal/units/bytes.go @@ -0,0 +1,87 @@ +// Package units centralizes byte-size formatting helpers shared by the analyze +// and status commands. +// +// The two callers intentionally use different conventions: analyze formats +// disk-related figures with SI (1000-based) units to match Finder/diskutil, +// while status reports memory and live counters with binary (1024-based) +// units to match macOS Activity Monitor and gopsutil. Both styles live here so +// that any future tweak (precision, rounding, label set) stays in one place. +package units + +import ( + "fmt" + "strconv" +) + +// BytesSI formats a signed byte count using SI (1000-based) units, matching +// Finder/diskutil. Negative inputs are clamped to "0 B". +func BytesSI(size int64) string { + if size < 0 { + return "0 B" + } + const unit = 1000 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + value := float64(size) / float64(div) + return fmt.Sprintf("%.1f %cB", value, "kMGTPE"[exp]) +} + +// BytesBin formats an unsigned byte count using binary (1024-based) units with +// a trailing space and unit label (e.g. "1.0 GB"). Boundary uses '>' so values +// at exactly 1< "1024 B"). +func BytesBin(v uint64) string { + switch { + case v > 1<<40: + return fmt.Sprintf("%.1f TB", float64(v)/(1<<40)) + case v > 1<<30: + return fmt.Sprintf("%.1f GB", float64(v)/(1<<30)) + case v > 1<<20: + return fmt.Sprintf("%.1f MB", float64(v)/(1<<20)) + case v > 1<<10: + return fmt.Sprintf("%.1f KB", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + " B" + } +} + +// BytesBinShort formats an unsigned byte count using binary units, no decimal +// places, single-letter suffix and no space (e.g. "100G"). Boundary uses '>=' +// so values at exactly 1<= 1<<40: + return fmt.Sprintf("%.0fT", float64(v)/(1<<40)) + case v >= 1<<30: + return fmt.Sprintf("%.0fG", float64(v)/(1<<30)) + case v >= 1<<20: + return fmt.Sprintf("%.0fM", float64(v)/(1<<20)) + case v >= 1<<10: + return fmt.Sprintf("%.0fK", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + } +} + +// BytesBinCompact formats an unsigned byte count using binary units, one +// decimal place, single-letter suffix and no space (e.g. "1.5G"). Boundary +// uses '>=' to mirror BytesBinShort. +func BytesBinCompact(v uint64) string { + switch { + case v >= 1<<40: + return fmt.Sprintf("%.1fT", float64(v)/(1<<40)) + case v >= 1<<30: + return fmt.Sprintf("%.1fG", float64(v)/(1<<30)) + case v >= 1<<20: + return fmt.Sprintf("%.1fM", float64(v)/(1<<20)) + case v >= 1<<10: + return fmt.Sprintf("%.1fK", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + } +} diff --git a/Resources/mole/internal/units/bytes_test.go b/Resources/mole/internal/units/bytes_test.go new file mode 100644 index 0000000..f56d86f --- /dev/null +++ b/Resources/mole/internal/units/bytes_test.go @@ -0,0 +1,138 @@ +package units + +import "testing" + +func TestBytesSI(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {-100, "0 B"}, + {0, "0 B"}, + {512, "512 B"}, + {999, "999 B"}, + {1000, "1.0 kB"}, + {1500, "1.5 kB"}, + {10000, "10.0 kB"}, + {1000000, "1.0 MB"}, + {1500000, "1.5 MB"}, + {1000000000, "1.0 GB"}, + {1000000000000, "1.0 TB"}, + {1000000000000000, "1.0 PB"}, + } + + for _, tt := range tests { + got := BytesSI(tt.input) + if got != tt.want { + t.Errorf("BytesSI(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestBytesBin(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + {"zero", 0, "0 B"}, + {"one byte", 1, "1 B"}, + {"1023 bytes", 1023, "1023 B"}, + + {"exactly 1KB", 1 << 10, "1024 B"}, + {"just over 1KB", (1 << 10) + 1, "1.0 KB"}, + {"1.5KB", 1536, "1.5 KB"}, + + {"exactly 1MB", 1 << 20, "1024.0 KB"}, + {"just over 1MB", (1 << 20) + 1, "1.0 MB"}, + {"500MB", 500 << 20, "500.0 MB"}, + + {"exactly 1GB", 1 << 30, "1024.0 MB"}, + {"just over 1GB", (1 << 30) + 1, "1.0 GB"}, + {"100GB", 100 << 30, "100.0 GB"}, + + {"exactly 1TB", 1 << 40, "1024.0 GB"}, + {"just over 1TB", (1 << 40) + 1, "1.0 TB"}, + {"2TB", 2 << 40, "2.0 TB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BytesBin(tt.input) + if got != tt.want { + t.Errorf("BytesBin(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBytesBinShort(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + {"zero", 0, "0"}, + {"one byte", 1, "1"}, + {"999 bytes", 999, "999"}, + + {"exactly 1KB", 1 << 10, "1K"}, + {"just under 1KB", (1 << 10) - 1, "1023"}, + {"1.5KB rounds to 2K", 1536, "2K"}, + {"999KB", 999 << 10, "999K"}, + + {"exactly 1MB", 1 << 20, "1M"}, + {"just under 1MB", (1 << 20) - 1, "1024K"}, + {"500MB", 500 << 20, "500M"}, + + {"exactly 1GB", 1 << 30, "1G"}, + {"just under 1GB", (1 << 30) - 1, "1024M"}, + {"100GB", 100 << 30, "100G"}, + + {"exactly 1TB", 1 << 40, "1T"}, + {"just under 1TB", (1 << 40) - 1, "1024G"}, + {"2TB", 2 << 40, "2T"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BytesBinShort(tt.input) + if got != tt.want { + t.Errorf("BytesBinShort(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBytesBinCompact(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + {"zero", 0, "0"}, + {"one byte", 1, "1"}, + {"1023 bytes", 1023, "1023"}, + + {"exactly 1KB", 1 << 10, "1.0K"}, + {"1.5KB", 1536, "1.5K"}, + + {"exactly 1MB", 1 << 20, "1.0M"}, + {"500MB", 500 << 20, "500.0M"}, + + {"exactly 1GB", 1 << 30, "1.0G"}, + {"100GB", 100 << 30, "100.0G"}, + + {"exactly 1TB", 1 << 40, "1.0T"}, + {"2TB", 2 << 40, "2.0T"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BytesBinCompact(tt.input) + if got != tt.want { + t.Errorf("BytesBinCompact(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/Resources/mole/lib/check/all.sh b/Resources/mole/lib/check/all.sh deleted file mode 100644 index 4a6960a..0000000 --- a/Resources/mole/lib/check/all.sh +++ /dev/null @@ -1,714 +0,0 @@ -#!/bin/bash -# System Checks Module -# Combines configuration, security, updates, and health checks - -set -euo pipefail - -# ============================================================================ -# Helper Functions -# ============================================================================ - -list_login_items() { - if ! command -v osascript > /dev/null 2>&1; then - return - fi - - local raw_items - raw_items=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || echo "") - [[ -z "$raw_items" || "$raw_items" == "missing value" ]] && return - - IFS=',' read -ra login_items_array <<< "$raw_items" - for entry in "${login_items_array[@]}"; do - local trimmed - trimmed=$(echo "$entry" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - [[ -n "$trimmed" ]] && printf "%s\n" "$trimmed" - done -} - -# ============================================================================ -# Configuration Checks -# ============================================================================ - -check_touchid_sudo() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_touchid"; then return; fi - # Check if Touch ID is configured for sudo - local pam_file="/etc/pam.d/sudo" - if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then - echo -e " ${GREEN}✓${NC} Touch ID Biometric authentication enabled" - else - # Check if Touch ID is supported - local is_supported=false - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -q "Touch ID"; then - is_supported=true - fi - elif [[ "$(uname -m)" == "arm64" ]]; then - is_supported=true - fi - - if [[ "$is_supported" == "true" ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" - export TOUCHID_NOT_CONFIGURED=true - fi - fi -} - -check_rosetta() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_rosetta"; then return; fi - # Check Rosetta 2 (for Apple Silicon Macs) - informational only, not auto-fixed - if [[ "$(uname -m)" == "arm64" ]]; then - if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then - echo -e " ${GREEN}✓${NC} Rosetta 2 Intel app translation ready" - else - echo -e " ${GRAY}${ICON_EMPTY}${NC} Rosetta 2 ${GRAY}Not installed${NC}" - fi - fi -} - -check_git_config() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_git_config"; then return; fi - # Check basic Git configuration - if command -v git > /dev/null 2>&1; then - local git_name=$(git config --global user.name 2> /dev/null || echo "") - local git_email=$(git config --global user.email 2> /dev/null || echo "") - - if [[ -n "$git_name" && -n "$git_email" ]]; then - echo -e " ${GREEN}✓${NC} Git Global identity configured" - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}" - fi - fi -} - -check_all_config() { - echo -e "${BLUE}${ICON_ARROW}${NC} System Configuration" - check_touchid_sudo - check_rosetta - check_git_config -} - -# ============================================================================ -# Security Checks -# ============================================================================ - -check_filevault() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_filevault"; then return; fi - # Check FileVault encryption status - if command -v fdesetup > /dev/null 2>&1; then - local fv_status=$(fdesetup status 2> /dev/null || echo "") - if echo "$fv_status" | grep -q "FileVault is On"; then - echo -e " ${GREEN}✓${NC} FileVault Disk encryption active" - else - echo -e " ${RED}✗${NC} FileVault ${RED}Disk encryption disabled${NC}" - export FILEVAULT_DISABLED=true - fi - fi -} - -check_firewall() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "firewall"; then return; fi - - unset FIREWALL_DISABLED - - # Check third-party firewalls first (lightweight path-based detection, no sudo required) - local third_party_firewall="" - if [[ -d "/Applications/Little Snitch.app" ]] || [[ -d "/Library/Little Snitch" ]]; then - third_party_firewall="Little Snitch" - elif [[ -d "/Applications/LuLu.app" ]]; then - third_party_firewall="LuLu" - elif [[ -d "/Applications/Radio Silence.app" ]]; then - third_party_firewall="Radio Silence" - elif [[ -d "/Applications/Hands Off!.app" ]]; then - third_party_firewall="Hands Off!" - elif [[ -d "/Applications/Murus.app" ]]; then - third_party_firewall="Murus" - elif [[ -d "/Applications/Vallum.app" ]]; then - third_party_firewall="Vallum" - fi - - if [[ -n "$third_party_firewall" ]]; then - echo -e " ${GREEN}✓${NC} Firewall ${third_party_firewall} active" - return - fi - - # Fall back to macOS built-in firewall check - local firewall_output=$(sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "") - if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then - echo -e " ${GREEN}✓${NC} Firewall Network protection enabled" - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" - export FIREWALL_DISABLED=true - fi -} - -check_gatekeeper() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "gatekeeper"; then return; fi - # Check Gatekeeper status - if command -v spctl > /dev/null 2>&1; then - local gk_status=$(spctl --status 2> /dev/null || echo "") - if echo "$gk_status" | grep -q "enabled"; then - echo -e " ${GREEN}✓${NC} Gatekeeper App download protection active" - unset GATEKEEPER_DISABLED - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}" - export GATEKEEPER_DISABLED=true - fi - fi -} - -check_sip() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_sip"; then return; fi - # Check System Integrity Protection - if command -v csrutil > /dev/null 2>&1; then - local sip_status=$(csrutil status 2> /dev/null || echo "") - if echo "$sip_status" | grep -q "enabled"; then - echo -e " ${GREEN}✓${NC} SIP System integrity protected" - else - echo -e " ${GRAY}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}" - fi - fi -} - -check_all_security() { - echo -e "${BLUE}${ICON_ARROW}${NC} Security Status" - check_filevault - check_firewall - check_gatekeeper - check_sip -} - -# ============================================================================ -# Software Update Checks -# ============================================================================ - -# Cache configuration -CACHE_DIR="${HOME}/.cache/mole" -CACHE_TTL=600 # 10 minutes in seconds - -# Ensure cache directory exists -ensure_user_dir "$CACHE_DIR" - -clear_cache_file() { - local file="$1" - rm -f "$file" 2> /dev/null || true -} - -reset_brew_cache() { - clear_cache_file "$CACHE_DIR/brew_updates" -} - -reset_softwareupdate_cache() { - clear_cache_file "$CACHE_DIR/softwareupdate_list" - SOFTWARE_UPDATE_LIST="" -} - -reset_mole_cache() { - clear_cache_file "$CACHE_DIR/mole_version" -} - -# Check if cache is still valid -is_cache_valid() { - local cache_file="$1" - local ttl="${2:-$CACHE_TTL}" - - if [[ ! -f "$cache_file" ]]; then - return 1 - fi - - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -lt $ttl ]] -} - -# Cache software update list to avoid calling softwareupdate twice -SOFTWARE_UPDATE_LIST="" - -get_software_updates() { - local cache_file="$CACHE_DIR/softwareupdate_list" - - # Optimized: Use defaults to check if updates are pending (much faster) - local pending_updates - pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0") - - if [[ "$pending_updates" -gt 0 ]]; then - echo "Updates Available" - else - echo "" - fi -} - -check_homebrew_updates() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_homebrew_updates"; then return; fi - - export BREW_OUTDATED_COUNT=0 - export BREW_FORMULA_OUTDATED_COUNT=0 - export BREW_CASK_OUTDATED_COUNT=0 - - if ! command -v brew > /dev/null 2>&1; then - printf " ${GRAY}${ICON_EMPTY}${NC} %-12s %s\n" "Homebrew" "Not installed" - return - fi - - local cache_file="$CACHE_DIR/brew_updates" - local formula_count=0 - local cask_count=0 - local total_count=0 - local use_cache=false - - if is_cache_valid "$cache_file"; then - local cached_formula="" - local cached_cask="" - IFS=' ' read -r cached_formula cached_cask < "$cache_file" || true - if [[ "$cached_formula" =~ ^[0-9]+$ && "$cached_cask" =~ ^[0-9]+$ ]]; then - formula_count="$cached_formula" - cask_count="$cached_cask" - use_cache=true - fi - fi - - if [[ "$use_cache" == "false" ]]; then - local formula_outdated="" - local cask_outdated="" - local formula_status=0 - local cask_status=0 - local spinner_started=false - - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Homebrew updates..." - spinner_started=true - fi - - if formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null); then - : - else - formula_status=$? - fi - - if cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null); then - : - else - cask_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - if [[ $formula_status -eq 0 || $cask_status -eq 0 ]]; then - formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}') - cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}') - ensure_user_file "$cache_file" - printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true - elif [[ $formula_status -eq 124 || $cask_status -eq 124 ]]; then - printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check timed out" - return - else - printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check failed" - return - fi - fi - - total_count=$((formula_count + cask_count)) - export BREW_FORMULA_OUTDATED_COUNT="$formula_count" - export BREW_CASK_OUTDATED_COUNT="$cask_count" - export BREW_OUTDATED_COUNT="$total_count" - - if [[ $total_count -gt 0 ]]; then - local detail="" - if [[ $formula_count -gt 0 ]]; then - detail="${formula_count} formula" - fi - if [[ $cask_count -gt 0 ]]; then - [[ -n "$detail" ]] && detail="${detail}, " - detail="${detail}${cask_count} cask" - fi - [[ -z "$detail" ]] && detail="${total_count} updates" - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Homebrew" "${detail} available" - else - printf " ${GREEN}✓${NC} %-12s %s\n" "Homebrew" "Up to date" - fi -} - -check_appstore_updates() { - # Skipped for speed optimization - consolidated into check_macos_update - # We can't easily distinguish app store vs macos updates without the slow softwareupdate -l call - export APPSTORE_UPDATE_COUNT=0 -} - -check_macos_update() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi - - # Fast check using system preferences - local updates_available="false" - if [[ $(get_software_updates) == "Updates Available" ]]; then - updates_available="true" - - # Verify with softwareupdate using --no-scan to avoid triggering a fresh scan - # which can timeout. We prioritize avoiding false negatives (missing actual updates) - # over false positives, so we only clear the update flag when softwareupdate - # explicitly reports "No new software available" - local sw_output="" - local sw_status=0 - local spinner_started=false - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking macOS updates..." - spinner_started=true - fi - - local softwareupdate_timeout=10 - if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then - : - else - sw_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - # Debug logging for troubleshooting - if [[ -n "${MO_DEBUG:-}" ]]; then - echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2 - fi - - # Prefer avoiding false negatives: if the system indicates updates are pending, - # only clear the flag when softwareupdate returns a list without any update entries. - if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then - if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then - updates_available="false" - fi - fi - fi - - export MACOS_UPDATE_AVAILABLE="$updates_available" - - if [[ "$updates_available" == "true" ]]; then - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available" - else - printf " ${GREEN}✓${NC} %-12s %s\n" "macOS" "System up to date" - fi -} - -check_mole_update() { - if command -v is_whitelisted > /dev/null && is_whitelisted "check_mole_update"; then return; fi - - # Check if Mole has updates - # Auto-detect version from mole main script - local current_version - if [[ -f "${SCRIPT_DIR:-/usr/local/bin}/mole" ]]; then - current_version=$(grep '^VERSION=' "${SCRIPT_DIR:-/usr/local/bin}/mole" 2> /dev/null | head -1 | sed 's/VERSION="\(.*\)"/\1/' || echo "unknown") - else - current_version="${VERSION:-unknown}" - fi - - local latest_version="" - local cache_file="$CACHE_DIR/mole_version" - - export MOLE_UPDATE_AVAILABLE="false" - - # Check cache first - if is_cache_valid "$cache_file"; then - latest_version=$(cat "$cache_file" 2> /dev/null || echo "") - else - # Show spinner while checking - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Mole version..." - fi - - # Try to get latest version from GitHub - if command -v curl > /dev/null 2>&1; then - # Run in background to allow Ctrl+C to interrupt - local temp_version - temp_version=$(mktemp_file "mole_version_check") - curl -fsSL --connect-timeout 3 --max-time 5 https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' > "$temp_version" & - local curl_pid=$! - - # Wait for curl to complete (allows Ctrl+C to interrupt) - if wait "$curl_pid" 2> /dev/null; then - latest_version=$(cat "$temp_version" 2> /dev/null || echo "") - # Save to cache - if [[ -n "$latest_version" ]]; then - ensure_user_file "$cache_file" - echo "$latest_version" > "$cache_file" 2> /dev/null || true - fi - fi - rm -f "$temp_version" 2> /dev/null || true - fi - - # Stop spinner - if [[ -t 1 ]]; then - stop_inline_spinner - fi - fi - - # Normalize version strings (remove leading 'v' or 'V') - current_version="${current_version#v}" - current_version="${current_version#V}" - latest_version="${latest_version#v}" - latest_version="${latest_version#V}" - - if [[ -n "$latest_version" && "$current_version" != "$latest_version" ]]; then - # Compare versions - if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then - export MOLE_UPDATE_AVAILABLE="true" - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}, running %s\n" "$ICON_WARNING" "Mole" "${latest_version} available" "${current_version}" - else - printf " ${GREEN}✓${NC} %-12s %s\n" "Mole" "Latest version ${current_version}" - fi - else - printf " ${GREEN}✓${NC} %-12s %s\n" "Mole" "Latest version ${current_version}" - fi -} - -check_all_updates() { - # Reset spinner flag for softwareupdate - unset SOFTWAREUPDATE_SPINNER_SHOWN - - # Preload software update data to avoid delays between subsequent checks - # Only redirect stdout, keep stderr for spinner display - get_software_updates > /dev/null - - echo -e "${BLUE}${ICON_ARROW}${NC} System Updates" - check_homebrew_updates - check_appstore_updates - check_macos_update - check_mole_update -} - -get_appstore_update_labels() { - get_software_updates | awk ' - /^\*/ { - label=$0 - sub(/^[[:space:]]*\* Label: */, "", label) - sub(/,.*/, "", label) - lower=tolower(label) - if (index(lower, "macos") == 0) { - print label - } - } - ' -} - -get_macos_update_labels() { - get_software_updates | awk ' - /^\*/ { - label=$0 - sub(/^[[:space:]]*\* Label: */, "", label) - sub(/,.*/, "", label) - lower=tolower(label) - if (index(lower, "macos") != 0) { - print label - } - } - ' -} - -# ============================================================================ -# System Health Checks -# ============================================================================ - -check_disk_space() { - # Use df -k to get KB values (always numeric), then calculate GB via math - # This avoids unit suffix parsing issues (df -H can return MB or GB) - local free_kb=$(command df -k / | awk 'NR==2 {print $4}') - local free_gb=$(awk "BEGIN {printf \"%.1f\", $free_kb / 1048576}") - local free_num=$(awk "BEGIN {printf \"%d\", $free_kb / 1048576}") - - export DISK_FREE_GB=$free_num - - if [[ $free_num -lt 20 ]]; then - echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC}, Critical" - elif [[ $free_num -lt 50 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC}, Low" - else - echo -e " ${GREEN}✓${NC} Disk Space ${free_gb}GB free" - fi -} - -check_memory_usage() { - local mem_total - mem_total=$(sysctl -n hw.memsize 2> /dev/null || echo "0") - if [[ -z "$mem_total" || "$mem_total" -le 0 ]]; then - echo -e " ${GRAY}-${NC} Memory Unable to determine" - return - fi - - local vm_output - vm_output=$(vm_stat 2> /dev/null || echo "") - - local page_size - page_size=$(echo "$vm_output" | awk '/page size of/ {print $8}') - [[ -z "$page_size" ]] && page_size=4096 - - local free_pages inactive_pages spec_pages - free_pages=$(echo "$vm_output" | awk '/Pages free/ {gsub(/\./,"",$3); print $3}') - inactive_pages=$(echo "$vm_output" | awk '/Pages inactive/ {gsub(/\./,"",$3); print $3}') - spec_pages=$(echo "$vm_output" | awk '/Pages speculative/ {gsub(/\./,"",$3); print $3}') - - free_pages=${free_pages:-0} - inactive_pages=${inactive_pages:-0} - spec_pages=${spec_pages:-0} - - # Estimate used percent: (total - free - inactive - speculative) / total - local total_pages=$((mem_total / page_size)) - local free_total=$((free_pages + inactive_pages + spec_pages)) - local used_pages=$((total_pages - free_total)) - if ((used_pages < 0)); then - used_pages=0 - fi - - local used_percent - used_percent=$(awk "BEGIN {printf \"%.0f\", ($used_pages / $total_pages) * 100}") - ((used_percent > 100)) && used_percent=100 - ((used_percent < 0)) && used_percent=0 - - if [[ $used_percent -gt 90 ]]; then - echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC}, Critical" - elif [[ $used_percent -gt 80 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC}, High" - else - echo -e " ${GREEN}✓${NC} Memory ${used_percent}% used" - fi -} - -check_login_items() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_login_items"; then return; fi - local login_items_count=0 - local -a login_items_list=() - - if [[ -t 0 ]]; then - # Show spinner while getting login items - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking login items..." - fi - - while IFS= read -r login_item; do - [[ -n "$login_item" ]] && login_items_list+=("$login_item") - done < <(list_login_items || true) - login_items_count=${#login_items_list[@]} - - # Stop spinner before output - if [[ -t 1 ]]; then - stop_inline_spinner - fi - fi - - if [[ $login_items_count -gt 15 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}" - elif [[ $login_items_count -gt 0 ]]; then - echo -e " ${GREEN}✓${NC} Login Items ${login_items_count} apps" - else - echo -e " ${GREEN}✓${NC} Login Items None" - return - fi - - # Show items in a single line (compact) - local preview_limit=3 - ((preview_limit > login_items_count)) && preview_limit=$login_items_count - - local items_display="" - for ((i = 0; i < preview_limit; i++)); do - if [[ $i -eq 0 ]]; then - items_display="${login_items_list[$i]}" - else - items_display="${items_display}, ${login_items_list[$i]}" - fi - done - - if ((login_items_count > preview_limit)); then - local remaining=$((login_items_count - preview_limit)) - items_display="${items_display} +${remaining}" - fi - - echo -e " ${GRAY}${items_display}${NC}" -} - -check_cache_size() { - local cache_size_kb=0 - - # Check common cache locations - local -a cache_paths=( - "$HOME/Library/Caches" - "$HOME/Library/Logs" - ) - - # Show spinner while calculating cache size - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning cache..." - fi - - for cache_path in "${cache_paths[@]}"; do - if [[ -d "$cache_path" ]]; then - local size_output - size_output=$(get_path_size_kb "$cache_path") - [[ "$size_output" =~ ^[0-9]+$ ]] || size_output=0 - cache_size_kb=$((cache_size_kb + size_output)) - fi - done - - local cache_size_gb=$(echo "scale=1; $cache_size_kb / 1024 / 1024" | bc) - export CACHE_SIZE_GB=$cache_size_gb - - # Stop spinner before output - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - # Convert to integer for comparison - local cache_size_int=$(echo "$cache_size_gb" | cut -d'.' -f1) - - if [[ $cache_size_int -gt 10 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" - elif [[ $cache_size_int -gt 5 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" - else - echo -e " ${GREEN}✓${NC} Cache Size ${cache_size_gb}GB" - fi -} - -check_swap_usage() { - # Check swap usage - if command -v sysctl > /dev/null 2>&1; then - local swap_info=$(sysctl vm.swapusage 2> /dev/null || echo "") - if [[ -n "$swap_info" ]]; then - local swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*[GM]" | awk 'NR==1{print $3}') - swap_used=${swap_used:-0M} - local swap_num="${swap_used//[GM]/}" - - if [[ "$swap_used" == *"G"* ]]; then - local swap_gb=${swap_num%.*} - if [[ $swap_gb -gt 2 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC}, High" - else - echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" - fi - else - echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" - fi - fi - fi -} - -check_brew_health() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi -} - -check_system_health() { - echo -e "${BLUE}${ICON_ARROW}${NC} System Health" - check_disk_space - check_memory_usage - check_swap_usage - check_login_items - check_cache_size - # Time Machine check is optional; skip by default to avoid noise on systems without backups -} diff --git a/Resources/mole/lib/check/health_json.sh b/Resources/mole/lib/check/health_json.sh index cdda7fa..eca3828 100644 --- a/Resources/mole/lib/check/health_json.sh +++ b/Resources/mole/lib/check/health_json.sh @@ -4,12 +4,21 @@ set -euo pipefail +if [[ -n "${MOLE_HEALTH_JSON_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_HEALTH_JSON_LOADED=1 + # Ensure dependencies are loaded (only if running standalone) if [[ -z "${MOLE_FILE_OPS_LOADED:-}" ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$SCRIPT_DIR/lib/core/file_ops.sh" fi +_MOLE_HEALTH_JSON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly _MOLE_HEALTH_JSON_DIR +source "$_MOLE_HEALTH_JSON_DIR/../optimize/catalog.sh" + # Get memory info in GB get_memory_info() { local total_bytes used_gb total_gb @@ -22,7 +31,12 @@ get_memory_info() { # Used memory from vm_stat local vm_output active wired compressed page_size vm_output=$(vm_stat 2> /dev/null || echo "") - page_size=4096 + # vm_stat reports page counts in units of its own page size, which is + # 16384 on Apple Silicon, not 4096. Read the size it declares in its + # header so used_bytes is correct; fall back to sysctl, then 4096. + page_size=$(printf '%s\n' "$vm_output" | LC_ALL=C sed -n 's/.*page size of \([0-9][0-9]*\) bytes.*/\1/p' | head -1) + [[ "$page_size" =~ ^[0-9]+$ ]] || page_size=$(sysctl -n hw.pagesize 2> /dev/null) + [[ "$page_size" =~ ^[0-9]+$ ]] || page_size=4096 active=$(echo "$vm_output" | LC_ALL=C awk '/Pages active:/ {print $NF}' | tr -d '.\n' 2> /dev/null) wired=$(echo "$vm_output" | LC_ALL=C awk '/Pages wired down:/ {print $NF}' | tr -d '.\n' 2> /dev/null) @@ -120,42 +134,17 @@ generate_health_json() { "optimizations": [ EOF - # Collect all optimization items - local -a items=() - - # Core optimizations (safe and valuable) - items+=('system_maintenance|DNS & Spotlight Check|Refresh DNS cache & verify Spotlight status|true') - items+=('cache_refresh|Finder Cache Refresh|Refresh QuickLook thumbnails & icon services cache|true') - items+=('saved_state_cleanup|App State Cleanup|Remove old saved application states (30+ days)|true') - items+=('fix_broken_configs|Broken Config Repair|Fix corrupted preferences files|true') - items+=('network_optimization|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true') - - # Advanced optimizations (high value, auto-run with safety checks) - items+=('sqlite_vacuum|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|true') - items+=('launch_services_rebuild|LaunchServices Repair|Repair "Open with" menu & file associations|true') - items+=('font_cache_rebuild|Font Cache Rebuild|Rebuild font database to fix rendering issues (skips if browsers are running)|true') - items+=('dock_refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|true') - - # System performance optimizations (new) - items+=('memory_pressure_relief|Memory Optimization|Release inactive memory to improve system responsiveness|true') - items+=('network_stack_optimize|Network Stack Refresh|Flush routing table and ARP cache to resolve network issues|true') - items+=('disk_permissions_repair|Permission Repair|Fix user directory permission issues|true') - items+=('bluetooth_reset|Bluetooth Refresh|Restart Bluetooth module to fix connectivity (skips if in use)|true') - items+=('spotlight_index_optimize|Spotlight Optimization|Rebuild index if search is slow (smart detection)|true') - - # Removed high-risk optimizations: - # - startup_items_cleanup: Risk of deleting legitimate app helpers - # - system_services_refresh: Risk of data loss when killing system services - # - dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS - - # Output items as JSON local first=true - for item in "${items[@]}"; do - IFS='|' read -r action name desc safe <<< "$item" + local index action health_name desc safe + for ((index = 0; index < ${#MOLE_OPTIMIZE_ACTIONS[@]}; index++)); do + action=${MOLE_OPTIMIZE_ACTIONS[$index]} + health_name=${MOLE_OPTIMIZE_HEALTH_NAMES[$index]} + desc=${MOLE_OPTIMIZE_DESCRIPTIONS[$index]} + safe=${MOLE_OPTIMIZE_SAFE_VALUES[$index]} # Escape strings action=$(json_escape "$action") - name=$(json_escape "$name") + health_name=$(json_escape "$health_name") desc=$(json_escape "$desc") [[ "$first" == "true" ]] && first=false || echo "," @@ -163,7 +152,7 @@ EOF cat << EOF { "category": "system", - "name": "$name", + "name": "$health_name", "description": "$desc", "action": "$action", "safe": $safe diff --git a/Resources/mole/lib/clean/app_caches.sh b/Resources/mole/lib/clean/app_caches.sh index d99ebea..2ce95bc 100644 --- a/Resources/mole/lib/clean/app_caches.sh +++ b/Resources/mole/lib/clean/app_caches.sh @@ -1,28 +1,392 @@ #!/bin/bash # User GUI Applications Cleanup Module (desktop apps, media, utilities). set -euo pipefail + +_xcode_cleanup_process_state() { + xcode_build_tooling_process_state +} + +_simulator_cleanup_process_state() { + if declare -f _coresimulator_activity_state > /dev/null 2>&1; then + _coresimulator_activity_state + return $? + fi + + mole_pgrep_any \ + -x "Xcode" \ + -x "Simulator" \ + -x "xcodebuild" \ + -x "xctest" \ + -x "XCTRunner" +} + +_xcode_cleanup_skip_reason() { + if [[ "$1" -eq 0 ]]; then + printf 'Xcode or build tooling running\n' + else + printf 'process state unknown\n' + fi +} + +_app_cache_cleanup_directories_exist() { + local target + for target in "$@"; do + [[ -d "$target" ]] || continue + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$target" 2> /dev/null; then + continue + fi + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$target" 2> /dev/null; then + continue + fi + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$target" 2> /dev/null; then + continue + fi + return 0 + done + return 1 +} + +_xcode_app_cache_delete_guard_allows() { + # Same mapping _xcode_cleanup_skip_reason applies (state 0 running, state 2 + # unknown; state 1 already returned), without its command substitution. The + # scan-stage callers still use that helper, where one fork per section is + # free; this one runs per delete candidate. + mole_clean_process_guard _xcode_cleanup_process_state "Xcode or build tooling running" +} + +_simulator_app_cache_delete_guard_allows() { + mole_clean_process_guard _simulator_cleanup_process_state "Simulator or CoreSimulator running" +} + +_final_cut_pro_delete_guard_allows() { + mole_clean_process_guard final_cut_pro_is_running "Final Cut Pro started" +} + +_defer_app_cache_guard_family() { + case "$1" in + _simulator_app_cache_delete_guard_allows) mole_defer_cleanup_family "Simulator" ;; + _final_cut_pro_delete_guard_allows) mole_defer_cleanup_family "Final Cut Pro" ;; + _autodesk_cache_delete_guard_allows) mole_defer_cleanup_family "Autodesk" ;; + *) mole_defer_cleanup_family "Xcode" ;; + esac +} + +_app_cache_safe_clean_guarded() { + local delete_guard="$1" + local display_name="$2" + shift 2 + local _MOLE_CLEAN_GUARD_REASON="process state changed" + + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! "$delete_guard"; then + mole_report_guard_stop "$display_name" _defer_app_cache_guard_family "$delete_guard" + return 1 + fi + safe_clean "$@" + return $? + fi + + local guarded_rc=0 + safe_clean_guarded "$delete_guard" "$@" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + mole_report_guard_stop "$display_name" _defer_app_cache_guard_family "$delete_guard" + return 1 + fi + return "$guarded_rc" +} + +# Xcode DerivedData cleanup with project count and size reporting. +# Fully regenerated on next build, safe to remove. +clean_xcode_derived_data() { + local dd_dir="$HOME/Library/Developer/Xcode/DerivedData" + + [[ -d "$dd_dir" ]] || return 0 + + # Count projects before recording an active-process skip, so an empty + # DerivedData root stays silent. + local -a projects=() + local dir + for dir in "$dd_dir"/*; do + [[ -d "$dir" ]] || continue + if should_protect_path "$dir" || is_path_whitelisted "$dir" || holds_compiled_model_cache "$dir"; then + continue + fi + projects+=("$dir") + done + + local project_count=${#projects[@]} + [[ $project_count -eq 0 ]] && return 0 + + # Only a conclusive "no matching process" result authorizes cleanup. + local xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -ne 1 ]]; then + if [[ $xcode_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode DerivedData · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + + local project_label="projects" + [[ $project_count -eq 1 ]] && project_label="project" + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + # Measure and register only the filtered project set. Sizing the parent + # would include protected or whitelisted siblings that real cleanup + # intentionally leaves untouched. + local size_kb=0 + local dir_size_kb=0 + local dry_run_count=0 + local dry_run_stopped_reason="" + local dry_run_seen=0 + # Sizing every project means one du per entry plus a process probe on + # each side of it. On a real DerivedData that is tens of seconds with + # nothing on screen, which reads as a freeze. + start_section_spinner "Measuring Xcode DerivedData, 0/${project_count}..." + for dir in "${projects[@]}"; do + dry_run_seen=$((dry_run_seen + 1)) + start_section_spinner "Measuring Xcode DerivedData, ${dry_run_seen}/${project_count}..." + xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -ne 1 ]]; then + dry_run_stopped_reason=$(_xcode_cleanup_skip_reason "$xcode_state") + break + fi + local size_rc=0 + dir_size_kb=$(get_path_size_kb "$dir" 2> /dev/null) || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + stop_section_spinner + _mole_record_clean_cancellation "$size_rc" + return "$size_rc" + fi + [[ "$dir_size_kb" =~ ^[0-9]+$ ]] || dir_size_kb=0 + xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -ne 1 ]]; then + dry_run_stopped_reason=$(_xcode_cleanup_skip_reason "$xcode_state") + break + fi + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$dir" "$dir_size_kb" 1 true || continue + fi + size_kb=$((size_kb + dir_size_kb)) + dry_run_count=$((dry_run_count + 1)) + done + stop_section_spinner + if [[ $dry_run_count -gt 0 ]]; then + project_label="projects" + [[ $dry_run_count -eq 1 ]] && project_label="project" + local size_human + size_human=$(bytes_to_human "$((size_kb * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode DerivedData · ${dry_run_count} ${project_label}, ${size_human}" + note_activity + fi + if [[ -n "$dry_run_stopped_reason" ]]; then + if [[ "$dry_run_stopped_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode DerivedData · stopped (${dry_run_stopped_reason})" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + fi + return 0 + fi + + # Remove all project build dirs using safe_remove. + local removed=0 + local removed_size_kb=0 + local stopped_reason="" + local seen=0 + # Each project costs a du, two process probes, and the removal itself, so a + # large DerivedData runs for tens of seconds. Without this the section + # prints nothing until every project is gone. + start_section_spinner "Removing Xcode DerivedData, 0/${project_count}..." + for dir in "${projects[@]}"; do + seen=$((seen + 1)) + start_section_spinner "Removing Xcode DerivedData, ${seen}/${project_count}..." + xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -ne 1 ]]; then + stopped_reason=$(_xcode_cleanup_skip_reason "$xcode_state") + break + fi + + local dir_size_kb=0 + local size_rc=0 + dir_size_kb=$(get_path_size_kb "$dir" 2> /dev/null) || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + stop_section_spinner + _mole_record_clean_cancellation "$size_rc" + return "$size_rc" + fi + [[ "$dir_size_kb" =~ ^[0-9]+$ ]] || dir_size_kb=0 + + # Sizing is timeout-bounded but can still take long enough for a build + # to start. Recheck at the deletion boundary, not only before du. + xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -ne 1 ]]; then + stopped_reason=$(_xcode_cleanup_skip_reason "$xcode_state") + break + fi + if safe_remove "$dir" "true" "$dir_size_kb"; then + removed=$((removed + 1)) + removed_size_kb=$((removed_size_kb + dir_size_kb)) + fi + done + stop_section_spinner + + if [[ $removed -gt 0 ]]; then + project_label="projects" + [[ $removed -eq 1 ]] && project_label="project" + local size_human + size_human=$(bytes_to_human "$((removed_size_kb * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$removed_size_kb" 2> /dev/null || echo "$GREEN") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode DerivedData · ${removed} ${project_label}, ${line_color}${size_human}${NC}" + files_cleaned=$((${files_cleaned:-0} + removed)) + total_size_cleaned=$((${total_size_cleaned:-0} + removed_size_kb)) + total_items=$((${total_items:-0} + 1)) + note_activity + fi + if [[ -n "$stopped_reason" ]]; then + if [[ "$stopped_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode DerivedData · stopped (${stopped_reason})" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + fi +} # Xcode and iOS tooling. clean_xcode_tools() { - # Skip DerivedData/Archives while Xcode is running. - local xcode_running=false - if pgrep -x "Xcode" > /dev/null 2>&1; then - xcode_running=true - fi - safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache" - safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files" - safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache" - safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs" - safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs" - safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs" - safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products" - if [[ "$xcode_running" == "false" ]]; then - safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data" - safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives" - safe_clean ~/Library/Developer/Xcode/DocumentationCache/* "Xcode documentation cache" - safe_clean ~/Library/Developer/Xcode/DocumentationIndex/* "Xcode documentation index" - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData/Archives/Documentation cleanup" + local simulator_has_targets=false + if mole_cleanup_targets_exist \ + "$HOME/Library/Developer/CoreSimulator/Caches"/* \ + "$HOME/Library/Developer/CoreSimulator/Devices"/*/data/tmp/* \ + "$HOME/Library/Logs/CoreSimulator"/*; then + simulator_has_targets=true + fi + + if [[ "$simulator_has_targets" == "true" ]]; then + # Probe errors are unknown, never permission to clean active tool state. + local simulator_state=0 + _simulator_cleanup_process_state || simulator_state=$? + if [[ $simulator_state -eq 1 ]]; then + _app_cache_safe_clean_guarded \ + _simulator_app_cache_delete_guard_allows \ + "Simulator caches" \ + ~/Library/Developer/CoreSimulator/Caches/* \ + "Simulator cache" || return 0 + _app_cache_safe_clean_guarded \ + _simulator_app_cache_delete_guard_allows \ + "Simulator temp files" \ + ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* \ + "Simulator temp files" || return 0 + _app_cache_safe_clean_guarded \ + _simulator_app_cache_delete_guard_allows \ + "CoreSimulator logs" \ + ~/Library/Logs/CoreSimulator/* \ + "CoreSimulator logs" || return 0 + else + if [[ $simulator_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Simulator caches · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Simulator" + fi + fi + fi + + local xcode_cache_has_targets=false + local xcode_build_has_targets=false + mole_cleanup_targets_exist \ + "$HOME/Library/Caches/com.apple.dt.Xcode"/* && xcode_cache_has_targets=true + if mole_cleanup_targets_exist "$HOME/Library/Developer/Xcode/Products"/* || + _app_cache_cleanup_directories_exist "$HOME/Library/Developer/Xcode/DerivedData"/*; then + xcode_build_has_targets=true fi + + if [[ "$xcode_cache_has_targets" == "true" || "$xcode_build_has_targets" == "true" ]]; then + local xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -eq 1 ]]; then + if [[ "$xcode_cache_has_targets" == "true" ]]; then + _app_cache_safe_clean_guarded \ + _xcode_app_cache_delete_guard_allows \ + "Xcode cache" \ + ~/Library/Caches/com.apple.dt.Xcode/* \ + "Xcode cache" || return 0 + fi + + # The cache pass may take long enough for the separate build + # candidates to disappear. Revalidate before another process gate + # so a completed cache-only pass never reports a deferred build. + xcode_build_has_targets=false + if mole_cleanup_targets_exist "$HOME/Library/Developer/Xcode/Products"/* || + _app_cache_cleanup_directories_exist "$HOME/Library/Developer/Xcode/DerivedData"/*; then + xcode_build_has_targets=true + fi + [[ "$xcode_build_has_targets" == "true" ]] || return 0 + + xcode_state=0 + _xcode_cleanup_process_state || xcode_state=$? + if [[ $xcode_state -ne 1 ]]; then + if [[ $xcode_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode build products/DerivedData · stopped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + _app_cache_safe_clean_guarded \ + _xcode_app_cache_delete_guard_allows \ + "Xcode build products" \ + ~/Library/Developer/Xcode/Products/* \ + "Xcode build products" || return 0 + clean_xcode_derived_data || return $? + else + if [[ $xcode_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode cache/build products · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + fi + fi +} +# Remove extension directories that VS Code / Cursor have marked obsolete. +# Each editor writes a .obsolete JSON file under its extensions root whose keys +# are stale extension directory names left behind after an extension update. +clean_editor_obsolete_extensions() { + local -a editor_roots=( + "$HOME/.vscode/extensions|VS Code" + "$HOME/.vscode-insiders/extensions|VS Code Insiders" + "$HOME/.cursor/extensions|Cursor" + ) + local entry ext_root editor_label obsolete_file key target + for entry in "${editor_roots[@]}"; do + ext_root="${entry%%|*}" + editor_label="${entry##*|}" + obsolete_file="$ext_root/.obsolete" + [[ -f "$obsolete_file" ]] || continue + + while IFS= read -r key; do + # Each key must be a plain direct-child directory name; reject + # anything that could escape the extensions root. + case "$key" in + "" | "." | ".." | */*) continue ;; + esac + target="$ext_root/$key" + [[ -d "$target" ]] || continue + safe_clean "$target" "Obsolete $editor_label extension" + done < <(plutil -p "$obsolete_file" 2> /dev/null | + sed -nE 's/^[[:space:]]*"([^"]+)"[[:space:]]*=>.*/\1/p') + done } # Code editors. clean_code_editors() { @@ -30,7 +394,31 @@ clean_code_editors() { safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache" safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache" safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache" + safe_clean ~/Library/Application\ Support/Code/WebStorage/*/CacheStorage/* "VS Code webview cache" safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache" + safe_clean ~/Library/Caches/Zed/* "Zed cache" + # Zed npm caches: node/cache (system-node scratch) and node/node-v*/cache + # (per-version managed runtime, see #88); keep editor state under db/ untouched. + safe_clean ~/Library/Application\ Support/Zed/node/cache/* "Zed npm cache" + safe_clean ~/Library/Application\ Support/Zed/node/node-v*/cache/* "Zed npm cache" + safe_clean ~/Library/Logs/Zed/* "Zed logs" + clean_editor_obsolete_extensions + # CodeBuddy Extension (VS Code fork, Electron) + if [[ -d ~/Library/Application\ Support/CodeBuddyExtension ]]; then + safe_clean ~/Library/Application\ Support/CodeBuddyExtension/Cache/* "CodeBuddy Extension cache" + safe_clean ~/Library/Application\ Support/CodeBuddyExtension/logs/* "CodeBuddy Extension logs" + fi + # CodeBuddy CN (VS Code fork, Electron) + if [[ -d ~/Library/Application\ Support/CodeBuddy\ CN ]]; then + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/Cache/* "CodeBuddy CN cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/CachedData/* "CodeBuddy CN cached data" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/CachedExtensionVSIXs/* "CodeBuddy CN extension cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/Code\ Cache/* "CodeBuddy CN code cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/GPUCache/* "CodeBuddy CN GPU cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/DawnGraphiteCache/* "CodeBuddy CN Dawn cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/DawnWebGPUCache/* "CodeBuddy CN WebGPU cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/logs/* "CodeBuddy CN logs" + fi } # Communication apps. clean_communication_apps() { @@ -40,31 +428,47 @@ clean_communication_apps() { safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache" safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache" safe_clean ~/Library/Caches/ru.keepcoder.Telegram/* "Telegram cache" + safe_clean ~/Library/Caches/com.microsoft.teams2/* "Microsoft Teams cache" safe_clean ~/Library/Caches/net.whatsapp.WhatsApp/* "WhatsApp cache" safe_clean ~/Library/Caches/com.skype.skype/* "Skype cache" safe_clean ~/Library/Caches/com.tencent.meeting/* "Tencent Meeting cache" safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache" + safe_clean ~/Library/Caches/com.tencent.qq/* "QQ cache" safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/Cache/* "Microsoft Teams legacy cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/Application\ Cache/* "Microsoft Teams legacy application cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/Code\ Cache/* "Microsoft Teams legacy code cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/GPUCache/* "Microsoft Teams legacy GPU cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/logs/* "Microsoft Teams legacy logs" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/tmp/* "Microsoft Teams legacy temp files" + if [[ -d ~/Library/Application\ Support/Microsoft/Teams ]]; then + safe_clean ~/Library/Application\ Support/Microsoft/Teams/Cache/* "Microsoft Teams legacy cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/Application\ Cache/* "Microsoft Teams legacy application cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/Code\ Cache/* "Microsoft Teams legacy code cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/GPUCache/* "Microsoft Teams legacy GPU cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/logs/* "Microsoft Teams legacy logs" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/tmp/* "Microsoft Teams legacy temp files" + fi } # DingTalk. clean_dingtalk() { safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache" safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component" - safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs" - safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs" + if [[ -d ~/Library/Application\ Support/iDingTalk ]]; then + safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs" + safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs" + fi } # AI assistants. clean_ai_apps() { safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache" safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache" safe_clean ~/Library/Logs/Claude/* "Claude logs" + safe_clean ~/Library/Caches/com.lmstudio.lmstudio/* "LM Studio cache" + # LM Studio <=0.3.5 used ~/.cache/lm-studio as its complete home directory, + # including models, presets, chats, and runtime state. LM Studio moved new + # installs to ~/.lmstudio in 0.3.6, but existing data is not migrated, so + # never recursively clean the legacy root. The Library/Caches target above + # is the only path treated as an auto-rebuildable cache here. + safe_clean ~/Library/Caches/CCTClearcutLogger "Google Clearcut logs" + if [[ -d "$HOME/Library/Application Support/Codex" || -d "$HOME/Library/Logs/com.openai.codex" ]]; then + debug_log "Codex Desktop state left intact by default" + fi } # Design and creative tools. clean_design_tools() { @@ -74,20 +478,278 @@ clean_design_tools() { safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches" safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" safe_clean ~/Library/Application\ Support/Adobe/Common/Media\ Cache\ Files/* "Adobe media cache files" - # Raycast cache is protected (clipboard history, images). } # Video editing tools. +final_cut_pro_is_running() { + mole_pgrep_any \ + -x "Final Cut Pro" \ + -f "/Final Cut Pro.app/" +} + +final_cut_pro_path_has_protected_component() { + local path="$1" + + case "$path" in + */Original\ Media | */Original\ Media/* | \ + */CurrentVersion.flexolibrary | */CurrentVersion.plist | */Settings.plist | \ + */Motion\ Templates | */Motion\ Templates/* | \ + */Final\ Cut\ Pro\ Backups | */Final\ Cut\ Pro\ Backups/*) + return 0 + ;; + esac + + return 1 +} + +is_final_cut_pro_generated_cache_target() { + local library="$1" + local target="$2" + + [[ -n "$library" && -n "$target" ]] || return 1 + [[ "$library" == /* && "$target" == /* ]] || return 1 + [[ "$library" == "$HOME"/Movies/*.fcpbundle ]] || return 1 + [[ "$target" == "$library"/* ]] || return 1 + [[ -d "$library" && ! -L "$library" ]] || return 1 + [[ -d "$target" && ! -L "$target" ]] || return 1 + + final_cut_pro_path_has_protected_component "$target" && return 1 + + if declare -f validate_path_for_deletion > /dev/null 2>&1; then + validate_path_for_deletion "$target" > /dev/null 2>&1 || return 1 + fi + + local relative_target="${target#"$library"/}" + case "$relative_target" in + */Render\ Files/High\ Quality\ Media | */Transcoded\ Media/Proxy\ Media) + return 0 + ;; + esac + + return 1 +} + +find_final_cut_pro_generated_cache_targets() { + local movies_dir="$HOME/Movies" + [[ -d "$movies_dir" ]] || return 0 + + local library target + while IFS= read -r -d '' library; do + [[ -d "$library" && ! -L "$library" ]] || continue + + while IFS= read -r -d '' target; do + if is_final_cut_pro_generated_cache_target "$library" "$target"; then + printf '%s\0' "$target" + fi + done < <(command find "$library" \ + \( -type d \( \ + -name "Original Media" -o \ + -name "Analysis Files" -o \ + -name "Motion Templates" -o \ + -name "Final Cut Pro Backups" \ + \) -prune \) -o \ + \( -type d \( \ + -path "*/Render Files/High Quality Media" -o \ + -path "*/Transcoded Media/Proxy Media" \ + \) -print0 \) 2> /dev/null || true) + done < <(command find "$movies_dir" -maxdepth 4 -type d -name "*.fcpbundle" -prune -print0 2> /dev/null || true) +} + +clean_final_cut_pro_generated_caches() { + local -a fcp_cache_targets=() + local target + while IFS= read -r -d '' target; do + if mole_cleanup_targets_exist "$target"; then + fcp_cache_targets+=("$target") + fi + done < <(find_final_cut_pro_generated_cache_targets) + + [[ ${#fcp_cache_targets[@]} -gt 0 ]] || return 0 + + local process_state=0 + final_cut_pro_is_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Final Cut Pro generated caches · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Final Cut Pro" + fi + return 0 + fi + + # Final Cut Pro generated cache cleanup (issue #843). + # Safety scope for the first pass: + # - only scan ~/Movies, the default Apple library location; + # - only delete exact generated-media directories documented by Apple as + # regenerable: render media and proxy media; + # - never touch Original Media, library databases, plist settings, backups, + # Motion templates, Analysis Files, optimized media, or external .fcpcache. + # Future expansion can add explicit flags or configurable roots for + # optimized media, Analysis Files, and external cache bundles after more + # field feedback. + _app_cache_safe_clean_guarded \ + _final_cut_pro_delete_guard_allows \ + "Final Cut Pro generated caches" \ + "${fcp_cache_targets[@]}" \ + "Final Cut Pro generated cache" || true +} + +jianying_pro_is_running() { + command -v pgrep > /dev/null 2>&1 || return 2 + + # Match the main editor process only. Narrow the -f pattern to the primary + # executable path so the always-resident menu-bar agent + # (.../Frameworks/VideoFusion-macOSTrayHelper.app/.../VideoFusion-macOSTrayHelper) + # does not read as "editor running" and permanently skip cleanup. + local probe_rc=0 + if pgrep -x "VideoFusion-macOS" > /dev/null 2>&1; then + return 0 + else + probe_rc=$? + [[ $probe_rc -eq 1 ]] || return 2 + fi + if pgrep -f "/VideoFusion-macOS.app/Contents/MacOS/VideoFusion-macOS" > /dev/null 2>&1; then + return 0 + else + probe_rc=$? + [[ $probe_rc -eq 1 ]] || return 2 + fi + return 1 +} + +clean_jianying_pro_generated_caches() { + local cache_root="$HOME/Movies/JianyingPro/User Data/Cache" + [[ -d "$cache_root" && ! -L "$cache_root" ]] || return 0 + + local process_state=0 + jianying_pro_is_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + local skip_reason="JianyingPro running" + [[ $process_state -eq 2 ]] && skip_reason="process state unknown" + echo -e " ${GRAY}${ICON_WARNING}${NC} JianyingPro generated caches · skipped ($skip_reason)" + note_activity + return 0 + fi + + # JianyingPro (剪映专业版 / CapCut CN, com.lemon.lvpro) generated cache + # cleanup (issue #1277). Same shape as Final Cut Pro (#843): the editor + # keeps heavy generated caches under ~/Movies/JianyingPro/User Data/Cache/ + # instead of ~/Library/Caches, so standard cleanup never reaches them. + # + # Safety scope for the first pass: + # - only the default cache root under ~/Movies; never User Data/Projects + # (the user's editable drafts) or any sibling of Cache; + # - only remove an explicit whitelist of regenerable subdirectories: + # subtitle-recognition PCM scratch, frame thumbnails, audio waveforms, + # algorithm scratch, and prerender/remux temp; + # - never touch draft-referenced or downloaded assets (effect, + # onlineMaterial, artistEffect, music, AITextTemplate, template, + # local_models, AigcMaterailCache, agencycache); plaintext draft configs + # reference effect 8000+ times, so anything not on this list is preserved. + # + # image/ and importcache3/ are deliberately excluded: both hold copies of + # material the user imported, draft_info.json is encrypted so no plaintext + # reference check can prove they are unreferenced, and mo clean deletes + # permanently. If the user has since moved or deleted the source file, the + # cached copy is the only remaining one. Revisit only with evidence that + # the editor re-imports from the original on demand. + # + # Verified on a real machine (macOS 15.7 Intel, JianyingPro 11.1.0): + # removing this set reclaimed ~70GB, 2025-era projects reopened cleanly, and + # the app recreated the scratch directories on next launch. + local -a regenerable_subdirs=( + recognize + frameThumbnail + audioWave + AlgorithmCache + ILASDKDB + RemuxCache + prerender + segmentPrerenderCache + MotionBlurCache + ttsTemp + tmp + ) + + local -a targets=() + local subdir path + for subdir in "${regenerable_subdirs[@]}"; do + path="$cache_root/$subdir" + if [[ -d "$path" && ! -L "$path" ]]; then + targets+=("$path") + fi + done + + [[ ${#targets[@]} -gt 0 ]] || return 0 + + safe_clean "${targets[@]}" "JianyingPro generated cache" +} + clean_video_tools() { safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache" safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache" + clean_final_cut_pro_generated_caches safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache" + safe_clean ~/Movies/CacheClip/* "DaVinci Resolve CacheClip" safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache" + clean_jianying_pro_generated_caches +} +# Autodesk Fusion helpers (AcCoreConsole, ADPClientService) outlive the main +# window and keep SQLite caches open under ~/Library/Caches/com.autodesk.*. +# Deleting those while the helper runs can fill the volume with unlinked temp +# writes (#1390). Probe is intentionally broad on the Autodesk family; the +# safe_remove live-cache gate is the per-path backstop for every reverse-DNS +# cache tree, including the generic ~/Library/Caches/* sweep. +autodesk_cache_process_state() { + mole_pgrep_any \ + -f "com.autodesk." \ + -x "AcCoreConsole" \ + -f "/AcCoreConsole" \ + -x "ADPClientService" \ + -f "/ADPClientService" \ + -f "Autodesk Fusion" \ + -f "Fusion 360" \ + -f "Fusion360" } + +_autodesk_cache_delete_guard_allows() { + mole_clean_process_guard autodesk_cache_process_state "Autodesk running" +} + # 3D and CAD tools. clean_3d_tools() { safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache" safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache" - safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache" + + local -a autodesk_targets=() + local autodesk_entry + for autodesk_entry in "$HOME"/Library/Caches/com.autodesk.*; do + [[ -e "$autodesk_entry" ]] || continue + if mole_cleanup_targets_exist "$autodesk_entry"/*; then + autodesk_targets+=("$autodesk_entry"/*) + elif mole_cleanup_targets_exist "$autodesk_entry"; then + autodesk_targets+=("$autodesk_entry") + fi + done + if [[ ${#autodesk_targets[@]} -gt 0 ]]; then + local process_state=0 + autodesk_cache_process_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autodesk cache · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Autodesk" + fi + else + _app_cache_safe_clean_guarded \ + _autodesk_cache_delete_guard_allows \ + "Autodesk cache" \ + "${autodesk_targets[@]}" \ + "Autodesk cache" || true + fi + fi + safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache" } # Productivity apps. @@ -99,22 +761,25 @@ clean_productivity_apps() { safe_clean ~/Library/Caches/com.filo.client/* "Filo cache" safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache" safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache" + safe_clean ~/Library/Containers/com.ranchero.NetNewsWire-Evergreen/Data/Library/Caches/* "NetNewsWire cache" + safe_clean ~/Library/Containers/com.ideasoncanvas.mindnode/Data/Library/Caches/* "MindNode cache" + safe_clean ~/.cache/kaku/* "Kaku cache" + safe_clean ~/Library/Application\ Support/spacedrive/thumbnails/* "Spacedrive thumbnail cache" + safe_clean ~/Library/Containers/is.follow/Data/Library/Application\ Support/Folo/Cache/Cache_Data/* "Folo cache" } # Music/media players (protect Spotify offline music). clean_media_players() { local spotify_cache="$HOME/Library/Caches/com.spotify.client" local spotify_data="$HOME/Library/Application Support/Spotify" local has_offline_music=false - # Heuristics: offline DB or large cache. - if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] || + # offline.bnk exists even with no offline downloads; only treat it as evidence + # when it has real content (>1 KB). Encrypted track blobs (*.file) are reliable. + local bnk_file="$spotify_data/PersistentCache/Storage/offline.bnk" + local bnk_size=0 + [[ -f "$bnk_file" ]] && bnk_size=$(stat -f%z "$bnk_file" 2> /dev/null || echo 0) + if [[ $bnk_size -gt 1024 ]] || [[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then has_offline_music=true - elif [[ -d "$spotify_cache" ]]; then - local cache_size_kb - cache_size_kb=$(get_path_size_kb "$spotify_cache") - if [[ $cache_size_kb -ge 512000 ]]; then - has_offline_music=true - fi fi if [[ "$has_offline_music" == "true" ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" @@ -133,6 +798,16 @@ clean_media_players() { safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache" safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache" safe_clean ~/Library/Caches/com.tencent.QQMusic/* "QQ Music cache" + safe_clean ~/Library/Caches/com.tencent.QQMusicMac/* "QQ Music Mac cache" + # QQ Music Mac sandboxed container caches (protect offline downloads in iDownloadProxy). + local _qqmusic_container="$HOME/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac" + if [[ -d "$_qqmusic_container" ]]; then + safe_clean "$_qqmusic_container/iRRCache"/* "QQ Music streaming cache" + safe_clean "$_qqmusic_container/iLog"/* "QQ Music logs" + safe_clean "$_qqmusic_container/iCache"/* "QQ Music cache" + safe_clean "$_qqmusic_container/iTemp"/* "QQ Music temp files" + fi + safe_clean ~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Caches/* "QQ Music container cache" safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache" safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache" } @@ -143,9 +818,21 @@ clean_video_players() { safe_clean ~/Library/Caches/io.mpv "MPV cache" safe_clean ~/Library/Caches/com.iqiyi.player "iQIYI cache" safe_clean ~/Library/Caches/com.tencent.tenvideo "Tencent Video cache" + # Tencent Video sandboxed container caches. + local _tenvideo_as="$HOME/Library/Containers/com.tencent.tenvideo/Data/Library/Application Support" + if [[ -d "$_tenvideo_as" ]]; then + safe_clean "$_tenvideo_as/Upgrade"/* "Tencent Video old installer" + safe_clean "$_tenvideo_as/VideoNative"/* "Tencent Video native cache" + safe_clean "$_tenvideo_as/documentCache"/* "Tencent Video document cache" + fi safe_clean ~/Library/Caches/tv.danmaku.bili/* "Bilibili cache" safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache" safe_clean ~/Library/Caches/com.huya.*/* "Huya cache" + safe_clean ~/Library/Containers/com.wuziqi.SenPlayer/Data/tmp/videoCache/* "SenPlayer video cache" + safe_clean ~/Library/Caches/smart.stremio*/* "Stremio cache" + if [[ -d ~/Library/Application\ Support/stremio ]]; then + safe_clean ~/Library/Application\ Support/stremio/stremio-server/stremio-cache/* "Stremio server cache" + fi } # Download managers. clean_download_managers() { @@ -155,30 +842,116 @@ clean_download_managers() { safe_clean ~/Library/Caches/com.downie.Downie-* "Downie cache" safe_clean ~/Library/Caches/com.folx.*/* "Folx cache" safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache" + clean_neatdm_stale_segments || return $? +} +# Neat Download Manager: clean stale incomplete download segments. +# History database (NeatDB.db) is never touched; only numbered segment +# directories whose seg.x0 file is older than MOLE_ORPHAN_AGE_DAYS are removed. +# Download URLs expire within hours/days so 30-day-old segments cannot be resumed. +clean_neatdm_stale_segments() { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + [[ -d "$neatdm_dir" ]] || return 0 + + local stale_count=0 + local stale_kb=0 + local current_epoch + current_epoch=$(get_epoch_seconds) + + local -a stale_dirs=() + local seg_dir + for seg_dir in "$neatdm_dir"/*/; do + [[ -d "$seg_dir" ]] || continue + local seg_name + seg_name=$(basename "${seg_dir%/}") + [[ "$seg_name" =~ ^[0-9]+$ ]] || continue + [[ -f "$seg_dir/seg.x0" ]] || continue + + local seg_mtime + seg_mtime=$(get_file_mtime "$seg_dir/seg.x0") + local age_days=$(((current_epoch - seg_mtime) / 86400)) + + if [[ $age_days -ge ${MOLE_ORPHAN_AGE_DAYS:-30} ]]; then + stale_dirs+=("$seg_dir") + fi + done + + [[ ${#stale_dirs[@]} -eq 0 ]] && return 0 + + for seg_dir in "${stale_dirs[@]}"; do + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$seg_dir") || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + if [[ "$DRY_RUN" != "true" ]]; then + if safe_remove "$seg_dir" true; then + stale_count=$((stale_count + 1)) + stale_kb=$((stale_kb + size_kb)) + fi + else + stale_count=$((stale_count + 1)) + stale_kb=$((stale_kb + size_kb)) + fi + done + + if [[ $stale_count -gt 0 ]]; then + local size_human + size_human=$(bytes_to_human "$((stale_kb * 1024))") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} NeatDM stale downloads · ${stale_count} items, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$stale_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} NeatDM stale downloads · ${stale_count} items, ${line_color}${size_human}${NC}" + fi + files_cleaned=$((files_cleaned + stale_count)) + total_size_cleaned=$((total_size_cleaned + stale_kb)) + total_items=$((total_items + 1)) + note_activity + fi } # Gaming platforms. clean_gaming_platforms() { safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache" - safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache" - safe_clean ~/Library/Application\ Support/Steam/appcache/* "Steam app cache" - safe_clean ~/Library/Application\ Support/Steam/depotcache/* "Steam depot cache" - safe_clean ~/Library/Application\ Support/Steam/steamapps/shadercache/* "Steam shader cache" - safe_clean ~/Library/Application\ Support/Steam/logs/* "Steam logs" + if [[ -d ~/Library/Application\ Support/Steam ]]; then + safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache" + safe_clean ~/Library/Application\ Support/Steam/appcache/* "Steam app cache" + safe_clean ~/Library/Application\ Support/Steam/depotcache/* "Steam depot cache" + safe_clean ~/Library/Application\ Support/Steam/steamapps/shadercache/* "Steam shader cache" + safe_clean ~/Library/Application\ Support/Steam/logs/* "Steam logs" + fi safe_clean ~/Library/Caches/com.epicgames.EpicGamesLauncher/* "Epic Games cache" safe_clean ~/Library/Caches/com.blizzard.Battle.net/* "Battle.net cache" - safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache" + if [[ -d ~/Library/Application\ Support/Battle.net ]]; then + safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache" + fi safe_clean ~/Library/Caches/com.ea.*/* "EA Origin cache" safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache" safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache" - safe_clean ~/Library/Application\ Support/minecraft/logs/* "Minecraft logs" - safe_clean ~/Library/Application\ Support/minecraft/crash-reports/* "Minecraft crash reports" - safe_clean ~/Library/Application\ Support/minecraft/webcache/* "Minecraft web cache" - safe_clean ~/Library/Application\ Support/minecraft/webcache2/* "Minecraft web cache 2" - safe_clean ~/.lunarclient/game-cache/* "Lunar Client game cache" - safe_clean ~/.lunarclient/launcher-cache/* "Lunar Client launcher cache" - safe_clean ~/.lunarclient/logs/* "Lunar Client logs" - safe_clean ~/.lunarclient/offline/*/logs/* "Lunar Client offline logs" - safe_clean ~/.lunarclient/offline/files/*/logs/* "Lunar Client offline file logs" + if [[ -d ~/Library/Application\ Support/minecraft ]]; then + safe_clean ~/Library/Application\ Support/minecraft/logs/* "Minecraft logs" + safe_clean ~/Library/Application\ Support/minecraft/crash-reports/* "Minecraft crash reports" + safe_clean ~/Library/Application\ Support/minecraft/webcache/* "Minecraft web cache" + safe_clean ~/Library/Application\ Support/minecraft/webcache2/* "Minecraft web cache 2" + fi + if [[ -d ~/.lunarclient ]]; then + safe_clean ~/.lunarclient/game-cache/* "Lunar Client game cache" + safe_clean ~/.lunarclient/launcher-cache/* "Lunar Client launcher cache" + safe_clean ~/.lunarclient/logs/* "Lunar Client logs" + safe_clean ~/.lunarclient/offline/*/logs/* "Lunar Client offline logs" + safe_clean ~/.lunarclient/offline/files/*/logs/* "Lunar Client offline file logs" + fi + safe_clean ~/Library/Caches/net.pcsx2.PCSX2/* "PCSX2 cache" + if [[ -d ~/Library/Application\ Support/PCSX2 ]]; then + safe_clean ~/Library/Application\ Support/PCSX2/cache/* "PCSX2 shader cache" + safe_clean ~/Library/Logs/PCSX2/* "PCSX2 logs" + fi + if [[ -d ~/Library/Application\ Support/rpcs3 ]]; then + safe_clean ~/Library/Caches/net.rpcs3.rpcs3/* "RPCS3 cache" + safe_clean ~/Library/Application\ Support/rpcs3/logs/* "RPCS3 logs" + fi } # Translation/dictionary apps. clean_translation_apps() { @@ -210,11 +983,29 @@ clean_shell_utils() { safe_clean ~/.wget-hsts "wget HSTS cache" safe_clean ~/.cacher/logs/* "Cacher logs" safe_clean ~/.kite/logs/* "Kite logs" + safe_clean ~/Library/Caches/dev.warp.Warp-Stable/* "Warp cache" + safe_clean ~/Library/Logs/warp.log "Warp log" + safe_clean ~/Library/Caches/SentryCrash/Warp/* "Warp Sentry crash reports" + safe_clean ~/Library/Caches/com.mitchellh.ghostty/* "Ghostty cache" } # Input methods and system utilities. clean_system_utils() { safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache" safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache" + # WeType input method (image and dict update cache, not engine or user dict) + safe_clean ~/Library/Application\ Support/WeType/com.onevcat.Kingfisher.ImageCache.WeType/* "WeType image cache" + safe_clean ~/Library/Application\ Support/WeType/DictUpdate/* "WeType dict update cache" + # mihomo-party proxy tool (Electron) + if [[ -d ~/Library/Application\ Support/mihomo-party ]]; then + safe_clean ~/Library/Application\ Support/mihomo-party/Cache/* "mihomo-party cache" + safe_clean ~/Library/Application\ Support/mihomo-party/Code\ Cache/* "mihomo-party code cache" + safe_clean ~/Library/Application\ Support/mihomo-party/GPUCache/* "mihomo-party GPU cache" + safe_clean ~/Library/Application\ Support/mihomo-party/DawnGraphiteCache/* "mihomo-party Dawn cache" + safe_clean ~/Library/Application\ Support/mihomo-party/DawnWebGPUCache/* "mihomo-party WebGPU cache" + safe_clean ~/Library/Application\ Support/mihomo-party/logs/* "mihomo-party logs" + fi + # Stash proxy tool + safe_clean ~/Library/Caches/ws.stash.app.mac/* "Stash cache" } # Note-taking apps. clean_note_apps() { @@ -240,8 +1031,6 @@ clean_remote_desktop() { # Main entry for GUI app cleanup. clean_user_gui_applications() { stop_section_spinner - clean_xcode_tools - clean_code_editors clean_communication_apps clean_dingtalk clean_ai_apps @@ -251,7 +1040,7 @@ clean_user_gui_applications() { clean_productivity_apps clean_media_players clean_video_players - clean_download_managers + clean_download_managers || return $? clean_gaming_platforms clean_translation_apps clean_screenshot_tools diff --git a/Resources/mole/lib/clean/apps.sh b/Resources/mole/lib/clean/apps.sh index 75402f3..05a13f6 100644 --- a/Resources/mole/lib/clean/apps.sh +++ b/Resources/mole/lib/clean/apps.sh @@ -4,6 +4,7 @@ set -euo pipefail readonly ORPHAN_AGE_THRESHOLD=${ORPHAN_AGE_THRESHOLD:-${MOLE_ORPHAN_AGE_DAYS:-30}} readonly CLAUDE_VM_ORPHAN_AGE_THRESHOLD=${MOLE_CLAUDE_VM_ORPHAN_AGE_DAYS:-7} +readonly INSTALLED_APPS_CACHE_COMPLETE_MARKER="# mole-installed-apps-cache:v3:complete" # Args: $1=target_dir, $2=label clean_ds_store_tree() { local target="$1" @@ -25,57 +26,148 @@ clean_ds_store_tree() { -path "*/.git" -prune -o -path "*/Library/Caches" -prune -o ) - local -a find_cmd=("command" "find" "$target") + local -a find_cmd=("find" "$target") if [[ "$target" == "$HOME" ]]; then find_cmd+=("-maxdepth" "5") fi find_cmd+=("${exclude_paths[@]}" "-type" "f" "-name" ".DS_Store" "-print0") + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + run_with_timeout "$MOLE_TIMEOUT_HINT_SCAN_SEC" "${find_cmd[@]}" \ + > "$scan_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + [[ "$spinner_active" == "true" ]] && stop_section_spinner + [[ $scan_rc -eq 124 || $scan_rc -ge 128 ]] && return "$scan_rc" + return 1 + fi + + local delete_rc=0 while IFS= read -r -d '' ds_file; do local size size=$(get_file_size "$ds_file") - total_bytes=$((total_bytes + size)) - file_count=$((file_count + 1)) + if [[ "$DRY_RUN" == "true" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + local preview_size_kb=$(((size + 1023) / 1024)) + local preview_rc=0 + record_dry_run_cleanup_target "$ds_file" "$preview_size_kb" 1 true || preview_rc=$? + if [[ $preview_rc -eq 124 || $preview_rc -ge 128 ]]; then + delete_rc=$preview_rc + break + elif [[ $preview_rc -ne 0 ]]; then + continue + fi + fi if [[ "$DRY_RUN" != "true" ]]; then - rm -f "$ds_file" 2> /dev/null || true + local remove_rc=0 + safe_remove "$ds_file" true 2> /dev/null || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + delete_rc=$remove_rc + break + elif [[ $remove_rc -ne 0 ]]; then + continue + fi fi + total_bytes=$((total_bytes + size)) + file_count=$((file_count + 1)) if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then break fi - done < <("${find_cmd[@]}" 2> /dev/null || true) + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above if [[ "$spinner_active" == "true" ]]; then stop_section_spinner fi if [[ $file_count -gt 0 ]]; then local size_human size_human=$(bytes_to_human "$total_bytes") + local size_kb=$(((total_bytes + 1023) / 1024)) if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$file_count files, $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC} · ${YELLOW}$file_count files, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} $label${NC} · ${line_color}$file_count files, $size_human${NC}" fi - local size_kb=$(((total_bytes + 1023) / 1024)) files_cleaned=$((files_cleaned + file_count)) total_size_cleaned=$((total_size_cleaned + size_kb)) total_items=$((total_items + 1)) note_activity fi + if [[ $delete_rc -ne 0 ]]; then + return "$delete_rc" + fi + return 0 +} +publish_installed_apps_cache() { + local installed_bundles="$1" + local cache_file="$2" + local cache_dir + cache_dir=$(dirname "$cache_file") + + ensure_user_dir "$cache_dir" + [[ -d "$cache_dir" ]] || return 1 + + # The staging file must share the cache filesystem so rename is atomic. + local cache_stage="" + if ! cache_stage=$(mktemp "${cache_file}.tmp.XXXXXX" 2> /dev/null); then + return 1 + fi + if ! command cat "$installed_bundles" > "$cache_stage" 2> /dev/null; then + command rm -f "$cache_stage" 2> /dev/null || true # SAFE: This function created the same-directory cache staging file. + return 1 + fi + if ! printf '%s\n' "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" >> "$cache_stage"; then + command rm -f "$cache_stage" 2> /dev/null || true # SAFE: This function created the same-directory cache staging file. + return 1 + fi + if ! mv -f "$cache_stage" "$cache_file" 2> /dev/null; then + command rm -f "$cache_stage" 2> /dev/null || true # SAFE: This function created the same-directory cache staging file. + return 1 + fi + ensure_user_file "$cache_file" + return 0 } + # Orphaned app data (30+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN # Usage: scan_installed_apps "output_file" scan_installed_apps() { local installed_bundles="$1" - # Cache installed app scan briefly to speed repeated runs. + # Reset the failure detail so a retry cannot report a stale bundle. + MOLE_APP_SCAN_FAILURE_DETAIL="" + # Cache installed app scans briefly. Only a current-schema file with the + # completeness footer can authorize orphan decisions. local cache_file="$HOME/.cache/mole/installed_apps_cache" local cache_age_seconds=300 # 5 minutes if [[ -f "$cache_file" ]]; then - local cache_mtime=$(get_file_mtime "$cache_file") - local current_time + local cache_mtime="" + local current_time="" + local age="" + cache_mtime=$(get_file_mtime "$cache_file" 2> /dev/null || true) current_time=$(get_epoch_seconds) - local age=$((current_time - cache_mtime)) - if [[ $age -lt $cache_age_seconds ]]; then + if [[ "$cache_mtime" =~ ^[0-9]+$ && "$current_time" =~ ^[0-9]+$ ]]; then + age=$((current_time - cache_mtime)) + fi + if [[ "$age" =~ ^[0-9]+$ && $age -ge 0 && $age -lt $cache_age_seconds ]]; then debug_log "Using cached app list, age: ${age}s" if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then - if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then + local cache_footer="" + local cached_bundles="" + cache_footer=$(tail -n 1 "$cache_file" 2> /dev/null || true) + if [[ "$cache_footer" != "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" ]]; then + debug_log "Warning: Installed app cache is incomplete or from an older schema, rebuilding" + elif cached_bundles=$(sed '$d' "$cache_file" 2> /dev/null); then + if [[ -n "$cached_bundles" ]]; then + if ! printf '%s\n' "$cached_bundles" > "$installed_bundles"; then + debug_log "Failed to copy installed application cache into scan output" + return 1 + fi + else + if ! : > "$installed_bundles"; then + debug_log "Failed to initialize installed application scan output from cache" + return 1 + fi + fi return 0 else debug_log "Warning: Failed to read cache, rebuilding" @@ -86,6 +178,10 @@ scan_installed_apps() { fi fi debug_log "Scanning installed applications, cache expired or missing" + if ! : > "$installed_bundles"; then + debug_log "Failed to initialize installed application scan output" + return 1 + fi local -a app_dirs=( "/Applications" "/System/Applications" @@ -96,59 +192,196 @@ scan_installed_apps() { # Setapp applications "$HOME/Library/Application Support/Setapp/Applications" ) - # Temp dir avoids write contention across parallel scans. - local scan_tmp_dir=$(create_temp_dir) - local pids=() + # Temp dir avoids write contention across parallel scans. The temp registry + # owns cleanup because safe_remove intentionally rejects root's private tree. + local scan_tmp_dir + if ! scan_tmp_dir=$(create_temp_dir); then + debug_log "Failed to create installed application scan temp directory" + return 1 + fi + local app_scan_pids=() + local auxiliary_pids=() local dir_idx=0 + local scan_started_at=$SECONDS for app_dir in "${app_dirs[@]}"; do [[ -d "$app_dir" ]] || continue ( - local -a app_paths=() + local worker_started_at=$SECONDS + local app_paths + if ! app_paths=$(command find "$app_dir" -maxdepth 3 -type d -name '*.app' 2> /dev/null); then + printf '%s\n' "$app_dir" >> "$scan_tmp_dir/scan_failures.list" + exit 1 + fi while IFS= read -r app_path; do - [[ -n "$app_path" ]] && app_paths+=("$app_path") - done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null) - local count=0 - for app_path in "${app_paths[@]:-}"; do + [[ -n "$app_path" ]] || continue + # The wrapped payload inside an iOS app is the same install as + # the bundle containing it, and the outer one already reports + # that id. Enumerating it separately only produced a bundle + # whose plist is not under Contents/, which then failed the + # scan. Helper bundles nested elsewhere are left alone. + case "$app_path" in + */Wrapper/*.app) continue ;; + esac local plist_path="$app_path/Contents/Info.plist" - [[ ! -f "$plist_path" ]] && continue - local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "") - if [[ -n "$bundle_id" ]]; then - echo "$bundle_id" - count=$((count + 1)) + # iOS and iPadOS apps installed on Apple Silicon have no + # Contents/ at all; their plist sits under Wrapper/.app. + # Reading only the Contents/ path made every one of them look + # unreadable, and because an incomplete app list cannot + # authorize orphan decisions, one of them skipped the whole + # App leftovers section. + if [[ ! -f "$plist_path" ]]; then + local wrapped_plist="" + for wrapped_plist in "$app_path"/Wrapper/*.app/Info.plist; do + if [[ -f "$wrapped_plist" ]]; then + plist_path="$wrapped_plist" + break + fi + done fi - done + local bundle_id="" + if [[ ! -f "$plist_path" || ! -r "$plist_path" ]] || + ! bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null) || + [[ -z "$bundle_id" || "$bundle_id" == "missing value" ]]; then + # A plist that parses and simply carries no + # CFBundleIdentifier is an answer, not a failed read. + # Vendor uninstallers and Steam launchers ship those, and a + # bundle with no id owns no bundle-id-named leftovers, so + # leaving it out of the list cannot invent an orphan. + # Anything that will not parse still fails the scan closed, + # because there the id may exist and simply be unreadable, + # which is what would turn a live app's data into an orphan. + if [[ -f "$plist_path" ]] && /usr/bin/plutil -lint "$plist_path" > /dev/null 2>&1; then + continue + fi + # An iOS wrapper whose WrappedBundle symlink is dangling has + # its entire payload, plist included, missing: no identity can + # be read anywhere, so like a plist that parses with no id it + # cannot own bundle-id-named leftovers and skipping it invents + # no orphan. Deliberately narrow: only a dangling WrappedBundle + # symlink paired with no plist qualifies, so an app with a + # merely unreadable Info.plist still fails the scan closed, + # where the id may exist and simply be unreadable. + if [[ ! -f "$plist_path" && -L "$app_path/WrappedBundle" && + ! -e "$app_path/WrappedBundle" ]]; then + continue + fi + printf '%s\n' "$app_path" >> "$scan_tmp_dir/scan_failures.list" + exit 1 + fi + echo "$bundle_id" + done <<< "$app_paths" + printf '%s: %ss\n' "$app_dir" "$((SECONDS - worker_started_at))" \ + >> "$scan_tmp_dir/perf.list" ) > "$scan_tmp_dir/apps_${dir_idx}.txt" & - pids+=($!) + app_scan_pids+=($!) dir_idx=$((dir_idx + 1)) done # Collect running apps and LaunchAgents to avoid false orphan cleanup. ( - local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "") - echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt" + : > "$scan_tmp_dir/running.txt" + local running_probe_succeeded=0 + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + local running_apps="" + # On a managed Mac the System Events automation permission is + # often undetermined or denied, and this probe then burns its + # whole timeout on every scan; the perf line is what proves it. + local osascript_started_at=$SECONDS + if command -v osascript > /dev/null 2>&1 && + running_apps=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null); then + printf '%s\n' "$running_apps" | tr ',' '\n' | + sed -e 's/^ *//;s/ *$//' -e '/^$/d' -e '/^missing value$/d' > "$scan_tmp_dir/running.txt" + running_probe_succeeded=1 + printf 'running-apps osascript: %ss\n' "$((SECONDS - osascript_started_at))" \ + >> "$scan_tmp_dir/perf.list" + else + printf 'running-apps osascript failed after %ss\n' "$((SECONDS - osascript_started_at))" \ + >> "$scan_tmp_dir/perf.list" + fi + else + running_probe_succeeded=1 + fi # Fallback: lsappinfo is more reliable than osascript if command -v lsappinfo > /dev/null 2>&1; then - run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true + local lsappinfo_output="" + local lsappinfo_started_at=$SECONDS + if lsappinfo_output=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" lsappinfo list 2> /dev/null); then + printf '%s\n' "$lsappinfo_output" | + sed -n 's/.*"CFBundleIdentifier"="\([^"]*\)".*/\1/p' >> "$scan_tmp_dir/running.txt" + running_probe_succeeded=1 + printf 'running-apps lsappinfo: %ss\n' "$((SECONDS - lsappinfo_started_at))" \ + >> "$scan_tmp_dir/perf.list" + fi fi - ) & - pids+=($!) + [[ $running_probe_succeeded -eq 1 ]] || exit 1 + ) < /dev/null & + auxiliary_pids+=($!) ( - run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \ - -name "*.plist" -type f 2> /dev/null | - xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true - ) & - pids+=($!) - debug_log "Waiting for ${#pids[@]} background processes: ${pids[*]}" - if [[ ${#pids[@]} -gt 0 ]]; then - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null || true + : > "$scan_tmp_dir/agents.txt" + local -a agent_dirs=() + [[ -d "$HOME/Library/LaunchAgents" ]] && agent_dirs+=("$HOME/Library/LaunchAgents") + [[ -d /Library/LaunchAgents ]] && agent_dirs+=("/Library/LaunchAgents") + [[ ${#agent_dirs[@]} -gt 0 ]] || exit 0 + + local agent_paths_file="$scan_tmp_dir/agent_paths.nul" + if ! run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find "${agent_dirs[@]}" \ + -name "*.plist" -type f -print0 > "$agent_paths_file" 2> /dev/null; then + exit 1 + fi + local agent_path="" + while IFS= read -r -d '' agent_path; do + basename "$agent_path" .plist + done < "$agent_paths_file" > "$scan_tmp_dir/agents.txt" + ) < /dev/null & + auxiliary_pids+=($!) + debug_log "Waiting for $((${#app_scan_pids[@]} + ${#auxiliary_pids[@]})) background processes" + local app_scan_failed=0 + if [[ ${#app_scan_pids[@]} -gt 0 ]]; then + for pid in "${app_scan_pids[@]}"; do + if ! wait "$pid" 2> /dev/null; then + app_scan_failed=1 + fi + done + fi + if [[ ${#auxiliary_pids[@]} -gt 0 ]]; then + for pid in "${auxiliary_pids[@]}"; do + if ! wait "$pid" 2> /dev/null; then + app_scan_failed=1 + fi done fi - debug_log "All background processes completed" - cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null || true - safe_remove "$scan_tmp_dir" true - sort -u "$installed_bundles" -o "$installed_bundles" - ensure_user_dir "$(dirname "$cache_file")" - cp "$installed_bundles" "$cache_file" 2> /dev/null || true + debug_log "All background processes completed in $((SECONDS - scan_started_at))s" + if [[ -s "$scan_tmp_dir/perf.list" ]]; then + local perf_line="" + while IFS= read -r perf_line; do + debug_log "PERF [installed-app scan] $perf_line" + done < "$scan_tmp_dir/perf.list" + fi + if [[ $app_scan_failed -ne 0 ]]; then + # Surface the first unreadable bundle so the skip message is actionable + # without --debug. Workers append before exiting nonzero. + MOLE_APP_SCAN_FAILURE_DETAIL="" + if [[ -s "$scan_tmp_dir/scan_failures.list" ]]; then + local raw_failure_detail="" + raw_failure_detail=$(head -1 "$scan_tmp_dir/scan_failures.list") + MOLE_APP_SCAN_FAILURE_DETAIL=$(mole_terminal_safe_text "${raw_failure_detail##*/}") + fi + debug_log "Failed to scan one or more installed application directories" + return 1 + fi + if ! cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null; then + debug_log "Failed to aggregate installed application scan results" + return 1 + fi + if ! sort -u "$installed_bundles" -o "$installed_bundles"; then + debug_log "Failed to normalize installed application scan results" + return 1 + fi + local cache_publish_status=0 + publish_installed_apps_cache "$installed_bundles" "$cache_file" || cache_publish_status=$? + if [[ $cache_publish_status -ne 0 ]]; then + debug_log "Warning: Failed to publish installed application cache; using current scan" + fi local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') debug_log "Scanned $app_count unique applications" } @@ -166,8 +399,34 @@ readonly ORPHAN_NEVER_DELETE_PATTERNS=( "com.apple.keychain*" ) -# Cache file for mdfind results (Bash 3.2 compatible, no associative arrays) -ORPHAN_MDFIND_CACHE_FILE="" +# In-memory mdfind result cache (Bash 3.2 compatible, no associative arrays). +# Newline-delimited strings checked via case glob, no subprocess per lookup. +_MOLE_MDFIND_FOUND="" +_MOLE_MDFIND_NOTFOUND="" + +_mdfind_cache_check() { + local bundle_id="$1" + local _nl=$'\n' + case "${_nl}${_MOLE_MDFIND_FOUND}${_nl}" in + *"${_nl}${bundle_id}${_nl}"*) return 0 ;; + esac + case "${_nl}${_MOLE_MDFIND_NOTFOUND}${_nl}" in + *"${_nl}${bundle_id}${_nl}"*) return 1 ;; + esac + return 2 +} + +_mdfind_cache_store() { + local bundle_id="$1" + local found="$2" + if [[ "$found" == "true" ]]; then + _MOLE_MDFIND_FOUND="${_MOLE_MDFIND_FOUND:+${_MOLE_MDFIND_FOUND} +}${bundle_id}" + else + _MOLE_MDFIND_NOTFOUND="${_MOLE_MDFIND_NOTFOUND:+${_MOLE_MDFIND_NOTFOUND} +}${bundle_id}" + fi +} # Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file" is_bundle_orphaned() { @@ -213,31 +472,30 @@ is_bundle_orphaned() { fi fi - # 6. Slow path: mdfind fallback with file-based caching (Bash 3.2 compatible) + # 6. Slow path: mdfind fallback with in-memory caching (Bash 3.2 compatible) # This catches apps installed in non-standard locations - if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then - # Initialize cache file if needed - if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then - ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") - register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" - fi - - # Check cache first (grep is fast for small files) - if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + local _cache_rc=0 + _mdfind_cache_check "$bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then return 1 - fi - if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then - # Already checked, not found - continue to return 0 - : - else - # Query mdfind with strict timeout (2 seconds max) - local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") - if [[ -n "$app_exists" ]]; then - echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + elif [[ $_cache_rc -eq 2 ]]; then + # Capture mdfind's own exit code, not head's: on timeout (124) + # the piped form yielded empty output and was cached as "not + # installed", so a transient Spotlight stall marked a live app as + # an orphan and deleted its data. On timeout/error, keep the app + # and do not poison the cache. + local app_exists _mdfind_rc=0 + app_exists=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null) || _mdfind_rc=$? + if [[ $_mdfind_rc -eq 124 || $_mdfind_rc -ge 128 ]]; then + return "$_mdfind_rc" + elif [[ $_mdfind_rc -ne 0 ]]; then + return 1 + elif [[ -n "$app_exists" ]]; then + _mdfind_cache_store "$bundle_id" "true" return 1 else - echo "NOTFOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + _mdfind_cache_store "$bundle_id" "false" fi fi fi @@ -273,64 +531,232 @@ is_claude_vm_bundle_orphaned() { fi fi - if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then - ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") - register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" - fi - - if grep -Fxq "FOUND:$claude_bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then + local _cache_rc=0 + _mdfind_cache_check "$claude_bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then return 1 - fi - if ! grep -Fxq "NOTFOUND:$claude_bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then - local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$claude_bundle_id'" 2> /dev/null | head -1 || echo "") - if [[ -n "$app_exists" ]]; then - echo "FOUND:$claude_bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + elif [[ $_cache_rc -eq 2 ]]; then + # On mdfind timeout/error keep the app (see is_bundle_orphaned). + local app_exists _mdfind_rc=0 + app_exists=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemCFBundleIdentifier == '$claude_bundle_id'" 2> /dev/null) || _mdfind_rc=$? + if [[ $_mdfind_rc -eq 124 || $_mdfind_rc -ge 128 ]]; then + return "$_mdfind_rc" + elif [[ $_mdfind_rc -ne 0 ]]; then + return 1 + elif [[ -n "$app_exists" ]]; then + _mdfind_cache_store "$claude_bundle_id" "true" return 1 fi - echo "NOTFOUND:$claude_bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + _mdfind_cache_store "$claude_bundle_id" "false" fi return 0 } +orphan_cleanup_candidate_identity() { + local path="$1" + run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$STAT_BSD" -f%d:%i:%m "$path" < /dev/null 2> /dev/null +} + +# Capture one coherent deletion capability for a candidate. The mtime check +# preserves the existing "unchanged since discovery" contract, while the +# parent and target identities are passed through safe_clean_guarded into +# safe_remove's final sink check. +orphan_cleanup_candidate_snapshot() { + local path="$1" + _ORPHAN_CANDIDATE_IDENTITY="" + _ORPHAN_CANDIDATE_PARENT="" + _ORPHAN_CANDIDATE_PARENT_ID="" + _ORPHAN_CANDIDATE_TARGET_ID="" + + _mole_snapshot_path_identity "$path" || return 1 + local snapshot_parent="$_MOLE_PATH_SNAPSHOT_PARENT" + local snapshot_parent_id="$_MOLE_PATH_SNAPSHOT_PARENT_ID" + local snapshot_target_id="$_MOLE_PATH_SNAPSHOT_TARGET_ID" + local identity="" + local identity_rc=0 + identity=$(orphan_cleanup_candidate_identity "$path") || identity_rc=$? + [[ $identity_rc -eq 124 || $identity_rc -ge 128 ]] && return "$identity_rc" + [[ $identity_rc -eq 0 && "${identity%:*}" == "$snapshot_target_id" ]] || return 1 + + _ORPHAN_CANDIDATE_IDENTITY="$identity" + _ORPHAN_CANDIDATE_PARENT="$snapshot_parent" + _ORPHAN_CANDIDATE_PARENT_ID="$snapshot_parent_id" + _ORPHAN_CANDIDATE_TARGET_ID="$snapshot_target_id" +} + +# Dynamic inputs are set immediately around safe_clean_guarded below. Recheck +# both the exact object and current app presence after every size probe, without +# trusting the negative mdfind cache used during the broad discovery pass. +orphan_cleanup_candidate_still_eligible() { + local path="$1" + + if [[ "${_ORPHAN_CLEANUP_KIND:-bundle}" == "claude" ]]; then + local process_rc=0 + pgrep -x "Claude" > /dev/null 2>&1 || process_rc=$? + [[ $process_rc -eq 124 || $process_rc -ge 128 ]] && return "$process_rc" + [[ $process_rc -eq 1 ]] || return 1 + fi + + local resolver_rc=0 + bundle_has_installed_app "${_ORPHAN_CLEANUP_BUNDLE_ID:-}" \ + "$((SECONDS + MOLE_TIMEOUT_MEDIUM_PROBE_SEC))" || resolver_rc=$? + [[ $resolver_rc -eq 124 || $resolver_rc -ge 128 ]] && return "$resolver_rc" + # Resolver 0 means installed or unknown; only an exact 1 authorizes cleanup. + [[ $resolver_rc -eq 1 ]] || return 1 + + local _ORPHAN_CANDIDATE_IDENTITY="" + local _ORPHAN_CANDIDATE_PARENT="" + local _ORPHAN_CANDIDATE_PARENT_ID="" + local _ORPHAN_CANDIDATE_TARGET_ID="" + local snapshot_rc=0 + orphan_cleanup_candidate_snapshot "$path" || snapshot_rc=$? + [[ $snapshot_rc -eq 124 || $snapshot_rc -ge 128 ]] && return "$snapshot_rc" + [[ $snapshot_rc -eq 0 ]] || return 1 + [[ "$_ORPHAN_CANDIDATE_IDENTITY" == "${_ORPHAN_CLEANUP_EXPECTED_IDENTITY:-}" ]] || return 1 + [[ "$_ORPHAN_CANDIDATE_PARENT" == "${_ORPHAN_CLEANUP_EXPECTED_PARENT:-}" ]] || return 1 + [[ "$_ORPHAN_CANDIDATE_PARENT_ID" == "${_ORPHAN_CLEANUP_EXPECTED_PARENT_ID:-}" ]] || return 1 + [[ "$_ORPHAN_CANDIDATE_TARGET_ID" == "${_ORPHAN_CLEANUP_EXPECTED_TARGET_ID:-}" ]] || return 1 + + _MOLE_SAFE_CLEAN_BOUND_PATH="$path" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT="$_ORPHAN_CANDIDATE_PARENT" + _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="$_ORPHAN_CANDIDATE_PARENT_ID" + _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="$_ORPHAN_CANDIDATE_TARGET_ID" + return 0 +} + # Orphaned app data sweep. clean_orphaned_app_data() { if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then stop_section_spinner echo -e " ${GRAY}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" + note_activity return 0 fi start_section_spinner "Scanning installed apps..." - local installed_bundles=$(create_temp_file) - scan_installed_apps "$installed_bundles" + local installed_bundles="" + local scan_status=0 + # Explicit if-not capture keeps the graceful skip below reachable even for + # a caller running with errexit enabled; a bare call would abort the shell + # before the skip message prints. + if ! installed_bundles=$(create_temp_file); then + scan_status=1 + fi + if [[ $scan_status -eq 0 && -n "$installed_bundles" ]]; then + if ! scan_installed_apps "$installed_bundles"; then + scan_status=1 + fi + fi + if [[ $scan_status -ne 0 || -z "$installed_bundles" ]]; then + stop_section_spinner + local scan_detail="${MOLE_APP_SCAN_FAILURE_DETAIL:-}" + if [[ -n "$scan_detail" ]]; then + printf ' %b Skipped: Unable to scan installed applications (%s)\n' \ + "${GRAY}${ICON_WARNING}${NC}" "$scan_detail" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipped: Unable to scan installed applications" + fi + note_activity + return 0 + fi stop_section_spinner local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps" + debug_log "Found $app_count active/installed apps" local orphaned_count=0 local total_orphaned_kb=0 start_section_spinner "Scanning orphaned app resources..." - local claude_vm_bundle="$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" - if is_claude_vm_bundle_orphaned "$claude_vm_bundle" "$installed_bundles"; then - local claude_vm_size_kb - claude_vm_size_kb=$(get_path_size_kb "$claude_vm_bundle") - if [[ -n "$claude_vm_size_kb" && "$claude_vm_size_kb" != "0" ]]; then - if safe_clean "$claude_vm_bundle" "Orphaned Claude workspace VM"; then - orphaned_count=$((orphaned_count + 1)) - total_orphaned_kb=$((total_orphaned_kb + claude_vm_size_kb)) + # Dynamically discover Claude VM bundles (path may vary across versions). + local claude_support_dir="$HOME/Library/Application Support/Claude" + if [[ -d "$claude_support_dir" ]]; then + local claude_scan_file="" + claude_scan_file=$(create_temp_file) || { + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return 1 + } + local claude_scan_rc=0 + run_with_timeout "$MOLE_TIMEOUT_HINT_SCAN_SEC" find "$claude_support_dir" \ + -maxdepth 3 -name "*.bundle" -type d -print0 \ + > "$claude_scan_file" 2> /dev/null || claude_scan_rc=$? + if [[ $claude_scan_rc -ne 0 ]]; then + rm -f -- "$claude_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$claude_scan_rc" + fi + local claude_result_rc=0 + while IFS= read -r -d '' claude_vm_bundle; do + local claude_orphan_rc=0 + is_claude_vm_bundle_orphaned \ + "$claude_vm_bundle" "$installed_bundles" || claude_orphan_rc=$? + if [[ $claude_orphan_rc -eq 124 || $claude_orphan_rc -ge 128 ]]; then + claude_result_rc=$claude_orphan_rc + break + elif [[ $claude_orphan_rc -eq 0 ]]; then + if is_path_whitelisted "$claude_vm_bundle"; then + debug_log "Skipping whitelisted orphan: $claude_vm_bundle" + continue + fi + local _ORPHAN_CANDIDATE_IDENTITY="" + local _ORPHAN_CANDIDATE_PARENT="" + local _ORPHAN_CANDIDATE_PARENT_ID="" + local _ORPHAN_CANDIDATE_TARGET_ID="" + local claude_vm_snapshot_rc=0 + orphan_cleanup_candidate_snapshot \ + "$claude_vm_bundle" || claude_vm_snapshot_rc=$? + if [[ $claude_vm_snapshot_rc -eq 124 || $claude_vm_snapshot_rc -ge 128 ]]; then + claude_result_rc=$claude_vm_snapshot_rc + break + elif [[ $claude_vm_snapshot_rc -ne 0 || -z "$_ORPHAN_CANDIDATE_IDENTITY" ]]; then + continue + fi + local claude_vm_identity="$_ORPHAN_CANDIDATE_IDENTITY" + local claude_vm_parent="$_ORPHAN_CANDIDATE_PARENT" + local claude_vm_parent_id="$_ORPHAN_CANDIDATE_PARENT_ID" + local claude_vm_target_id="$_ORPHAN_CANDIDATE_TARGET_ID" + local claude_vm_size_kb + local claude_vm_size_rc=0 + claude_vm_size_kb=$(get_path_size_kb "$claude_vm_bundle") || claude_vm_size_rc=$? + if [[ $claude_vm_size_rc -eq 124 || $claude_vm_size_rc -ge 128 ]]; then + claude_result_rc=$claude_vm_size_rc + break + fi + if [[ -n "$claude_vm_size_kb" && "$claude_vm_size_kb" != "0" ]]; then + local _ORPHAN_CLEANUP_EXPECTED_IDENTITY="$claude_vm_identity" + local _ORPHAN_CLEANUP_EXPECTED_PARENT="$claude_vm_parent" + local _ORPHAN_CLEANUP_EXPECTED_PARENT_ID="$claude_vm_parent_id" + local _ORPHAN_CLEANUP_EXPECTED_TARGET_ID="$claude_vm_target_id" + local _ORPHAN_CLEANUP_BUNDLE_ID="com.anthropic.claudefordesktop" + local _ORPHAN_CLEANUP_KIND="claude" + local claude_clean_rc=0 + safe_clean_guarded orphan_cleanup_candidate_still_eligible \ + "$claude_vm_bundle" \ + "Orphaned Claude workspace VM" || claude_clean_rc=$? + if [[ $claude_clean_rc -eq 124 || $claude_clean_rc -ge 128 ]]; then + claude_result_rc=$claude_clean_rc + break + elif [[ $claude_clean_rc -eq 0 ]]; then + orphaned_count=$((orphaned_count + 1)) + total_orphaned_kb=$((total_orphaned_kb + claude_vm_size_kb)) + fi + fi fi + done < "$claude_scan_file" + rm -f -- "$claude_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ $claude_result_rc -ne 0 ]]; then + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$claude_result_rc" fi fi # CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps). + # CRITICAL: NEVER add Containers/ (managed by containermanagerd, stubs expected). + # CRITICAL: NEVER add Application Scripts/ (could break Shortcuts/Automator workflows). + # CRITICAL: NEVER add Group Containers/ (TeamID.BundleID names cause false-positive orphan checks). local -a resource_types=( "$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*" "$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*" "$HOME/Library/Saved Application State|States|*.savedState" - "$HOME/Library/WebKit|WebKit|com.*:org.*:net.*:io.*" - "$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*" - "$HOME/Library/Cookies|Cookies|*.binarycookies" ) for resource_type in "${resource_types[@]}"; do IFS='|' read -r base_path label patterns <<< "$resource_type" @@ -369,48 +795,153 @@ clean_orphaned_app_data() { local bundle_id=$(basename "$match") bundle_id="${bundle_id%.savedState}" bundle_id="${bundle_id%.binarycookies}" - if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then + bundle_id="${bundle_id%.plist}" + local orphan_rc=0 + is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles" || orphan_rc=$? + if [[ $orphan_rc -eq 124 || $orphan_rc -ge 128 ]]; then + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$orphan_rc" + elif [[ $orphan_rc -eq 0 ]]; then + if is_path_whitelisted "$match"; then + debug_log "Skipping whitelisted orphan: $match" + continue + fi + local _ORPHAN_CANDIDATE_IDENTITY="" + local _ORPHAN_CANDIDATE_PARENT="" + local _ORPHAN_CANDIDATE_PARENT_ID="" + local _ORPHAN_CANDIDATE_TARGET_ID="" + local candidate_snapshot_rc=0 + orphan_cleanup_candidate_snapshot \ + "$match" || candidate_snapshot_rc=$? + if [[ $candidate_snapshot_rc -eq 124 || $candidate_snapshot_rc -ge 128 ]]; then + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$candidate_snapshot_rc" + elif [[ $candidate_snapshot_rc -ne 0 || -z "$_ORPHAN_CANDIDATE_IDENTITY" ]]; then + continue + fi + local candidate_identity="$_ORPHAN_CANDIDATE_IDENTITY" + local candidate_parent="$_ORPHAN_CANDIDATE_PARENT" + local candidate_parent_id="$_ORPHAN_CANDIDATE_PARENT_ID" + local candidate_target_id="$_ORPHAN_CANDIDATE_TARGET_ID" local size_kb - size_kb=$(get_path_size_kb "$match") + local size_rc=0 + size_kb=$(get_path_size_kb "$match") || size_rc=$? + if [[ $size_rc -eq 124 || $size_rc -ge 128 ]]; then + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$size_rc" + fi if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then continue fi - if safe_clean "$match" "Orphaned $label: $bundle_id"; then + local _ORPHAN_CLEANUP_EXPECTED_IDENTITY="$candidate_identity" + local _ORPHAN_CLEANUP_EXPECTED_PARENT="$candidate_parent" + local _ORPHAN_CLEANUP_EXPECTED_PARENT_ID="$candidate_parent_id" + local _ORPHAN_CLEANUP_EXPECTED_TARGET_ID="$candidate_target_id" + local _ORPHAN_CLEANUP_BUNDLE_ID="$bundle_id" + local _ORPHAN_CLEANUP_KIND="bundle" + local clean_rc=0 + safe_clean_guarded orphan_cleanup_candidate_still_eligible \ + "$match" "Orphaned $label: $bundle_id" || clean_rc=$? + if [[ $clean_rc -eq 124 || $clean_rc -ge 128 ]]; then + rm -f -- "$installed_bundles" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$clean_rc" + elif [[ $clean_rc -eq 0 ]]; then orphaned_count=$((orphaned_count + 1)) total_orphaned_kb=$((total_orphaned_kb + size_kb)) fi fi done done + # eval: restore shopt state captured by $(shopt -p) eval "$_nullglob_state" fi done stop_section_spinner if [[ $orphaned_count -gt 0 ]]; then local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB" + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would clean $orphaned_count items, about ${orphaned_mb}MB" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB" + fi note_activity fi rm -f "$installed_bundles" } +_privileged_helper_bundle_id_from_binary() { + local binary="$1" + local deadline_seconds="${2:-}" + local helper_bundle_id="" + + case "$binary" in + /Library/PrivilegedHelperTools/*.bundle/Contents/MacOS/*) + local helper_bundle_dir info_plist + helper_bundle_dir="${binary%/Contents/MacOS/*}" + info_plist="$helper_bundle_dir/Contents/Info.plist" + local plist_timeout="$MOLE_TIMEOUT_QUICK_DETECT_SEC" + local plist_rc=0 + if [[ -n "$deadline_seconds" ]]; then + plist_timeout=$(_mole_timeout_with_deadline "$plist_timeout" \ + "$deadline_seconds") || plist_rc=$? + fi + if [[ $plist_rc -eq 0 ]]; then + if declare -F plutil > /dev/null 2>&1; then + helper_bundle_id=$(plutil -extract CFBundleIdentifier raw \ + "$info_plist" 2> /dev/null) || plist_rc=$? + else + helper_bundle_id=$(run_with_timeout "$plist_timeout" plutil \ + -extract CFBundleIdentifier raw "$info_plist" 2> /dev/null) || plist_rc=$? + fi + fi + [[ $plist_rc -eq 124 || $plist_rc -ge 128 ]] && return "$plist_rc" + [[ -n "$helper_bundle_id" ]] || helper_bundle_id=$(basename "$helper_bundle_dir" .bundle) + ;; + *) + helper_bundle_id=$(basename "$binary") + helper_bundle_id="${helper_bundle_id%.plist}" + ;; + esac + + printf '%s\n' "$helper_bundle_id" +} + # Clean orphaned system-level services (LaunchDaemons, LaunchAgents, PrivilegedHelperTools) # These are left behind when apps are uninstalled but their system services remain clean_orphaned_system_services() { # Requires sudo - if ! sudo -n true 2> /dev/null; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then return 0 fi + local service_cleanup_deadline=$((SECONDS + 60)) + local service_auth_rc=0 + local service_auth_timeout="" + service_auth_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$service_cleanup_deadline") || service_auth_rc=$? + if [[ $service_auth_rc -eq 0 ]]; then + _mole_bounded_sudo "$service_auth_timeout" \ + -n true < /dev/null 2> /dev/null || service_auth_rc=$? + fi + if [[ $service_auth_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}authorization check timed out, skipped cleanup${NC}" + note_activity + return 0 + elif [[ $service_auth_rc -ge 128 ]]; then + return "$service_auth_rc" + fi + [[ $service_auth_rc -eq 0 ]] || return 0 start_section_spinner "Scanning orphaned system services..." local orphaned_count=0 - local total_orphaned_kb=0 local -a orphaned_files=() - - # Known bundle ID patterns for common apps that leave system services behind - # Format: "file_pattern:app_check_command" - local -a known_orphan_patterns=( + local -a orphaned_identities=() + # Force-protect list: if a plist's bundle ID matches one of these patterns AND + # the associated app IS installed, skip removal even if the binary appears missing. + # Format: "bundle_id_glob:pipe-separated app paths" + # NOTE: This list is now purely protective. Generic binary-existence detection + # (below) handles discovery; this list prevents false positives for known apps. + local -a known_protect_patterns=( # Sogou Input Method "com.sogou.*:/Library/Input Methods/SogouInput.app" # ClashX @@ -421,19 +952,52 @@ clean_orphaned_system_services() { "com.nektony.AC*:/Applications/App Cleaner & Uninstaller.app" # i4tools (爱思助手) "cn.i4tools.*:/Applications/i4Tools.app" + # MacPaw CleanMyMac X / CleanMyMac (MAS and direct) + "com.macpaw.CleanMyMac*:/Applications/CleanMyMac X.app" + # Wireshark Foundation – ChmodBPF daemon + "org.wireshark.ChmodBPF:/Applications/Wireshark.app" + # Zoom Video Communications – daemon, updater agents, PrivilegedHelperTool + "us.zoom.*:/Applications/zoom.us.app" + # remot3.it / Remote.It – CLI daemon + "it.remote.cli:/Applications/Remote.It.app" + # Docker – system socket and vmnetd helpers (Docker.app manages these) + "com.docker.*:/Applications/Docker.app" + # NetBird / Wiretrustee – CLI-managed daemon (binary in /usr/local/bin) + "netbird:/usr/local/bin/netbird" + # Intego (One, VirusBarrier, NetBarrier) – self-protecting AV whose + # /Library/Intego tree is root-only readable; never treat its services + # as orphans while any Intego install evidence exists. See #1188. + "com.intego.*:/Library/Intego|/Applications/Intego|/Library/Application Support/Intego" + # Homebrew-managed services (managed by brew services, not .app bundles) + "homebrew.mxcl.*:" ) - local mdfind_cache_file="" + # Returns 0 (found/protected) when any app backing a system service is installed. + # app_path may be a pipe-separated list of candidate .app paths; any match = protected. + # An empty app_path always returns 0 (unconditionally protected). _system_service_app_exists() { local bundle_id="$1" - local app_path="$2" + local app_path_raw="$2" + + # Empty path = unconditionally protected (e.g. homebrew.mxcl.*) + [[ -z "$app_path_raw" ]] && return 0 + + # Split on '|' to support multi-app helpers (e.g. Cindori TEHelper). + local _IFS_save="$IFS" + IFS='|' + # shellcheck disable=SC2206 # intentional word-split on '|' delimiter + local -a app_paths=($app_path_raw) + IFS="$_IFS_save" - [[ -n "$app_path" && -d "$app_path" ]] && return 0 + local _path + for _path in "${app_paths[@]}"; do + [[ -n "$_path" ]] || continue + # Protect if the app path or binary exists + [[ -d "$_path" || -e "$_path" ]] && return 0 - if [[ -n "$app_path" ]]; then local app_name - app_name=$(basename "$app_path") - case "$app_path" in + app_name=$(basename "$_path") + case "$_path" in /Applications/*) [[ -d "$HOME/Applications/$app_name" ]] && return 0 [[ -d "/Applications/Setapp/$app_name" ]] && return 0 @@ -442,350 +1006,926 @@ clean_orphaned_system_services() { [[ -d "$HOME/Library/Input Methods/$app_name" ]] && return 0 ;; esac + done + + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + local _cache_rc=0 + _mdfind_cache_check "$bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then + return 0 + elif [[ $_cache_rc -eq 2 ]]; then + # On mdfind timeout/error assume the app exists (return 0 = + # installed here) so a transient Spotlight stall never flags a + # live app's service/container as an orphan; do not cache. + local app_found _mdfind_rc=0 + local mdfind_timeout="" + mdfind_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$service_cleanup_deadline") || _mdfind_rc=$? + if [[ $_mdfind_rc -eq 0 ]]; then + app_found=$(run_with_timeout "$mdfind_timeout" \ + mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null) || _mdfind_rc=$? + fi + if [[ $_mdfind_rc -ge 128 ]]; then + return "$_mdfind_rc" + elif [[ $_mdfind_rc -ne 0 ]]; then + return 0 + elif [[ -n "$app_found" ]]; then + _mdfind_cache_store "$bundle_id" "true" + return 0 + fi + _mdfind_cache_store "$bundle_id" "false" + fi fi - if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then - if [[ -z "$mdfind_cache_file" ]]; then - mdfind_cache_file=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") - register_temp_file "$mdfind_cache_file" + return 1 + } + + # Read a launchd program path from a system plist. + # The plist itself was discovered with sudo, so read it with sudo too (the + # caller already cleared a `sudo -n true` probe, so keep it non-interactive): + # unreadable root-owned plists make PlistBuddy print a non-path "File Doesn't + # Exist, Will Create..." message on stdout, which must never be treated as a + # missing binary path. + _plist_program_value() { + local plist="$1" + local key="$2" + local value="" + local plist_probe_rc=0 + local plist_probe_timeout="" + plist_probe_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$service_cleanup_deadline") || plist_probe_rc=$? + if [[ $plist_probe_rc -eq 0 ]]; then + value=$(_mole_bounded_sudo "$plist_probe_timeout" \ + -n /usr/libexec/PlistBuddy -c "Print :$key" "$plist" < /dev/null 2> /dev/null) || plist_probe_rc=$? + fi + + if [[ $plist_probe_rc -eq 124 || $plist_probe_rc -ge 128 ]]; then + return "$plist_probe_rc" + fi + [[ $plist_probe_rc -eq 0 ]] || return 1 + + [[ -z "$value" ]] && return 1 + [[ "$value" != /* ]] && return 1 + + printf '%s\n' "$value" + } + + # Read the program binary from a plist (Program or ProgramArguments[0]). + # Prints the path; returns 1 if no usable absolute Program key found. + _plist_binary_path() { + local plist="$1" + local binary="" + local binary_probe_rc=0 + binary=$(_plist_program_value "$plist" "ProgramArguments:0") || binary_probe_rc=$? + if [[ $binary_probe_rc -eq 124 || $binary_probe_rc -ge 128 ]]; then + return "$binary_probe_rc" + fi + if [[ -z "$binary" ]]; then + binary_probe_rc=0 + binary=$(_plist_program_value "$plist" "Program") || binary_probe_rc=$? + if [[ $binary_probe_rc -eq 124 || $binary_probe_rc -ge 128 ]]; then + return "$binary_probe_rc" fi + fi + [[ -z "$binary" ]] && return 1 + printf '%s\n' "$binary" + } - if grep -Fxq "FOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then + # Returns 0 if the binary path is managed by a package manager or lives in a + # system directory; these should never be treated as orphans even when missing. + _is_package_managed_binary() { + local binary="$1" + case "$binary" in + /usr/local/bin/* | /usr/local/sbin/* | \ + /opt/homebrew/bin/* | /opt/homebrew/sbin/* | \ + /opt/homebrew/opt/*/bin/* | /opt/homebrew/opt/*/sbin/* | \ + /usr/bin/* | /usr/sbin/* | /bin/* | /sbin/* | \ + /usr/libexec/*) return 0 + ;; + esac + return 1 + } + + # Generic plist orphan check: returns 0 if the plist is orphaned. + # A plist is orphaned when: + # 1. Its Program binary path is known and missing from disk, AND + # 2. The binary is not in a package-manager / system directory, AND + # 3. No protect pattern covers this bundle ID. + _plist_is_orphaned() { + local plist="$1" + local bundle_id="$2" + + # Read the binary the plist points to. + local binary="" + local binary_path_rc=0 + binary=$(_plist_binary_path "$plist") || binary_path_rc=$? + if [[ $binary_path_rc -eq 124 || $binary_path_rc -ge 128 ]]; then + return "$binary_path_rc" + fi + [[ $binary_path_rc -eq 0 ]] || return 1 # no Program key → skip + + # Self-protecting software (Intego and similar antivirus / endpoint + # agents) makes its install directories root-only readable, so an + # unprivileged -e probe reports the daemon binary missing even though + # it exists. The plist was discovered with sudo; re-probe the binary + # with sudo before treating it as missing. See #1188. + local binary_exists=false + if [[ -e "$binary" ]]; then + binary_exists=true + else + local binary_probe_rc=0 + local binary_probe_timeout="" + binary_probe_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$service_cleanup_deadline") || binary_probe_rc=$? + if [[ $binary_probe_rc -eq 0 ]]; then + _mole_bounded_sudo "$binary_probe_timeout" \ + -n test -e "$binary" < /dev/null 2> /dev/null || binary_probe_rc=$? fi - if ! grep -Fxq "NOTFOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then - local app_found - app_found=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") - if [[ -n "$app_found" ]]; then - echo "FOUND:$bundle_id" >> "$mdfind_cache_file" - return 0 + if [[ $binary_probe_rc -eq 0 ]]; then + binary_exists=true + elif [[ $binary_probe_rc -eq 1 ]]; then + # Status 1 can also mean sudo authorization expired. Only a + # live follow-up credential proves the path predicate was false. + local binary_auth_rc=0 + local binary_auth_timeout="" + binary_auth_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$service_cleanup_deadline") || binary_auth_rc=$? + if [[ $binary_auth_rc -eq 0 ]]; then + _mole_bounded_sudo "$binary_auth_timeout" \ + -n true < /dev/null 2> /dev/null || binary_auth_rc=$? + fi + if [[ $binary_auth_rc -eq 124 || $binary_auth_rc -ge 128 ]]; then + return "$binary_auth_rc" + elif [[ $binary_auth_rc -ne 0 ]]; then + return 1 fi - echo "NOTFOUND:$bundle_id" >> "$mdfind_cache_file" + else + # Timeout/exec failure is unknown, never evidence of absence. + if [[ $binary_probe_rc -eq 124 || $binary_probe_rc -ge 128 ]]; then + return "$binary_probe_rc" + fi + return 1 fi fi - return 1 + # If the binary still exists, check if it's in PrivilegedHelperTools. + # If so, verify the parent app is still installed. If the parent app + # is gone, the binary itself is orphaned, so this plist is too. See #1082. + if [[ "$binary_exists" == "true" ]]; then + if [[ "$binary" == /Library/PrivilegedHelperTools/* ]]; then + local helper_bundle_id + local helper_id_rc=0 + helper_bundle_id=$(_privileged_helper_bundle_id_from_binary \ + "$binary" "$service_cleanup_deadline") || helper_id_rc=$? + if [[ $helper_id_rc -eq 124 || $helper_id_rc -ge 128 ]]; then + return "$helper_id_rc" + fi + local helper_resolver_rc=0 + bundle_has_installed_app "$helper_bundle_id" \ + "$service_cleanup_deadline" || helper_resolver_rc=$? + if [[ $helper_resolver_rc -eq 0 ]]; then + return 1 # Parent app still installed, plist is healthy + elif [[ $helper_resolver_rc -ge 128 ]]; then + return "$helper_resolver_rc" + fi + # Parent app is gone, binary is orphaned, so plist is orphaned + return 0 + fi + return 1 # Binary exists and not in PrivilegedHelperTools, plist is healthy + fi + + # If the binary is in a package-manager / system path, skip. + _is_package_managed_binary "$binary" && return 1 + + # Check protect patterns: if any matching pattern declares the app as + # installed, this plist is protected. + local pattern_entry + for pattern_entry in "${known_protect_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + # shellcheck disable=SC2053 + [[ "$bundle_id" == $file_pattern ]] || continue + local protect_rc=0 + _system_service_app_exists "$bundle_id" "$app_path" || protect_rc=$? + if [[ $protect_rc -eq 0 ]]; then + return 1 + elif [[ $protect_rc -ge 128 ]]; then + return "$protect_rc" + fi + # Pattern matched and app is gone → don't protect (fall through). + break + done + + return 0 # orphaned } - # Scan system LaunchDaemons - if [[ -d /Library/LaunchDaemons ]]; then - while IFS= read -r -d '' plist; do - local filename - filename=$(basename "$plist") + _orphan_service_identity() { + local candidate="$1" + local identity_timeout="" + local identity="" + local identity_rc=0 + identity_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$service_cleanup_deadline") || identity_rc=$? + if [[ $identity_rc -eq 0 ]]; then + identity=$(_mole_bounded_sudo "$identity_timeout" \ + -n "$STAT_BSD" -f%d:%i:%m "$candidate" < /dev/null 2> /dev/null) || identity_rc=$? + fi + if [[ $identity_rc -ne 0 ]]; then + return "$identity_rc" + fi + [[ "$identity" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]] || return 1 + printf '%s\n' "$identity" + } - # Skip Apple system files - [[ "$filename" == com.apple.* ]] && continue + _record_orphan_service_candidate() { + local candidate="$1" + local identity="" + identity=$(_orphan_service_identity "$candidate") || return $? + orphaned_files+=("$candidate") + orphaned_identities+=("$identity") + orphaned_count=$((orphaned_count + 1)) + } + + _orphan_service_candidate_still_eligible() { + local candidate="$1" + local expected_identity="$2" + [[ $SECONDS -lt $service_cleanup_deadline ]] || return 124 - # Extract bundle ID from filename (remove .plist extension) - local bundle_id="${filename%.plist}" + local current_identity="" + current_identity=$(_orphan_service_identity "$candidate") || return $? + [[ "$current_identity" == "$expected_identity" ]] || return 1 - # Check against known orphan patterns - for pattern_entry in "${known_orphan_patterns[@]}"; do + local filename + filename=$(basename "$candidate") + local bundle_id="${filename%.plist}" + if [[ "$candidate" == *.plist ]]; then + _plist_is_orphaned "$candidate" "$bundle_id" || return $? + else + local pattern_entry + for pattern_entry in "${known_protect_patterns[@]}"; do local file_pattern="${pattern_entry%%:*}" local app_path="${pattern_entry#*:}" - # shellcheck disable=SC2053 - if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then - if _system_service_app_exists "$bundle_id" "$app_path"; then - continue - fi - orphaned_files+=("$plist") - local size_kb - size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - orphaned_count=$((orphaned_count + 1)) - break + [[ "$filename" == $file_pattern || "$bundle_id" == $file_pattern ]] || continue + local protect_rc=0 + _system_service_app_exists "$bundle_id" "$app_path" || protect_rc=$? + if [[ $protect_rc -eq 0 ]]; then + return 1 + elif [[ $protect_rc -ge 128 ]]; then + return "$protect_rc" fi + break done - done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) - fi - - # Scan system LaunchAgents - if [[ -d /Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - local filename - filename=$(basename "$plist") + local helper_resolver_rc=0 + bundle_has_installed_app "$bundle_id" \ + "$service_cleanup_deadline" || helper_resolver_rc=$? + if [[ $helper_resolver_rc -eq 0 ]]; then + return 1 + elif [[ $helper_resolver_rc -ge 128 ]]; then + return "$helper_resolver_rc" + fi + [[ $SECONDS -lt $service_cleanup_deadline ]] || return 124 + fi - # Skip Apple system files - [[ "$filename" == com.apple.* ]] && continue + # Classification may touch Spotlight and application bundles. Reject a + # concurrent installer replacement before unload/removal. + current_identity=$(_orphan_service_identity "$candidate") || return $? + [[ "$current_identity" == "$expected_identity" ]] + } - local bundle_id="${filename%.plist}" + # Materialize each privileged inventory before consuming it. If any root + # cannot be scanned completely, keep every candidate from this pass: a + # partial inventory is not enough evidence for service deletion. + local service_scan_file="" + local service_scan_status=0 + if ! service_scan_file=$(create_temp_file 2> /dev/null); then + stop_section_spinner + debug_log "Skipping orphaned system services: could not create scan file" + return 0 + fi - for pattern_entry in "${known_orphan_patterns[@]}"; do - local file_pattern="${pattern_entry%%:*}" - local app_path="${pattern_entry#*:}" + # Scan system LaunchDaemons + if [[ -d /Library/LaunchDaemons ]]; then + local launch_daemon_scan_rc=0 + local launch_daemon_scan_timeout="" + launch_daemon_scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$service_cleanup_deadline") || launch_daemon_scan_rc=$? + if [[ $launch_daemon_scan_rc -eq 0 ]]; then + _mole_materialize_bounded_sudo_find "$service_scan_file" \ + "$launch_daemon_scan_timeout" /Library/LaunchDaemons \ + -maxdepth 1 -name "*.plist" -print0 || launch_daemon_scan_rc=$? + fi + if [[ $launch_daemon_scan_rc -eq 0 ]]; then + while IFS= read -r -d '' plist; do + if [[ $SECONDS -ge $service_cleanup_deadline ]]; then + service_scan_status=124 + break + fi + local filename + filename=$(basename "$plist") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + local bundle_id="${filename%.plist}" + + # Generic detection: binary-existence check. + local daemon_orphan_rc=0 + _plist_is_orphaned "$plist" "$bundle_id" || daemon_orphan_rc=$? + if [[ $daemon_orphan_rc -eq 0 ]]; then + local daemon_record_rc=0 + _record_orphan_service_candidate "$plist" || daemon_record_rc=$? + if [[ $daemon_record_rc -eq 124 || $daemon_record_rc -ge 128 ]]; then + service_scan_status=$daemon_record_rc + break + fi + elif [[ $daemon_orphan_rc -eq 124 || $daemon_orphan_rc -ge 128 ]]; then + service_scan_status=$daemon_orphan_rc + break + fi + done < "$service_scan_file" + else + service_scan_status=$launch_daemon_scan_rc + fi + fi - # shellcheck disable=SC2053 - if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then - if _system_service_app_exists "$bundle_id" "$app_path"; then - continue + # Scan system LaunchAgents + if [[ $service_scan_status -eq 0 && -d /Library/LaunchAgents ]]; then + local launch_agent_scan_rc=0 + local launch_agent_scan_timeout="" + launch_agent_scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$service_cleanup_deadline") || launch_agent_scan_rc=$? + if [[ $launch_agent_scan_rc -eq 0 ]]; then + _mole_materialize_bounded_sudo_find "$service_scan_file" \ + "$launch_agent_scan_timeout" /Library/LaunchAgents \ + -maxdepth 1 -name "*.plist" -print0 || launch_agent_scan_rc=$? + fi + if [[ $launch_agent_scan_rc -eq 0 ]]; then + while IFS= read -r -d '' plist; do + if [[ $SECONDS -ge $service_cleanup_deadline ]]; then + service_scan_status=124 + break + fi + local filename + filename=$(basename "$plist") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + local bundle_id="${filename%.plist}" + + # Generic detection: binary-existence check. + local agent_orphan_rc=0 + _plist_is_orphaned "$plist" "$bundle_id" || agent_orphan_rc=$? + if [[ $agent_orphan_rc -eq 0 ]]; then + local agent_record_rc=0 + _record_orphan_service_candidate "$plist" || agent_record_rc=$? + if [[ $agent_record_rc -eq 124 || $agent_record_rc -ge 128 ]]; then + service_scan_status=$agent_record_rc + break fi - orphaned_files+=("$plist") - local size_kb - size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - orphaned_count=$((orphaned_count + 1)) + elif [[ $agent_orphan_rc -eq 124 || $agent_orphan_rc -ge 128 ]]; then + service_scan_status=$agent_orphan_rc break fi - done - done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + done < "$service_scan_file" + else + service_scan_status=$launch_agent_scan_rc + fi fi # Scan PrivilegedHelperTools - if [[ -d /Library/PrivilegedHelperTools ]]; then - while IFS= read -r -d '' helper; do - local filename - filename=$(basename "$helper") - local bundle_id="$filename" - - # Skip Apple system files - [[ "$filename" == com.apple.* ]] && continue - - for pattern_entry in "${known_orphan_patterns[@]}"; do - local file_pattern="${pattern_entry%%:*}" - local app_path="${pattern_entry#*:}" - - # shellcheck disable=SC2053 - if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then - if _system_service_app_exists "$bundle_id" "$app_path"; then + if [[ $service_scan_status -eq 0 && -d /Library/PrivilegedHelperTools ]]; then + local helper_scan_rc=0 + local helper_scan_timeout="" + helper_scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$service_cleanup_deadline") || helper_scan_rc=$? + if [[ $helper_scan_rc -eq 0 ]]; then + _mole_materialize_bounded_sudo_find "$service_scan_file" \ + "$helper_scan_timeout" /Library/PrivilegedHelperTools \ + -maxdepth 1 -type f -print0 || helper_scan_rc=$? + fi + if [[ $helper_scan_rc -eq 0 ]]; then + while IFS= read -r -d '' helper; do + if [[ $SECONDS -ge $service_cleanup_deadline ]]; then + service_scan_status=124 + break + fi + local filename + filename=$(basename "$helper") + + # Skip non-plist data files (configs, JSON, etc.) that are not + # bundle-ID-named helpers. Only .plist and extensionless files + # can be orphaned service registrations. See #808. + case "$filename" in + *.json | *.cfg | *.conf | *.me2me_enabled | *.log | *.dat | *.db | *.xml | *.yml | *.yaml | *.ini | *.txt | *.pid | *.sock | *.lock) continue + ;; + esac + + local bundle_id="${filename%.plist}" + + # Skip Apple system files + [[ "$bundle_id" == com.apple.* ]] && continue + + # Check force-protect list first: if the helper's app is still installed, + # never flag it as orphaned regardless of what bundle_has_installed_app says. + local is_protected=false + local protect_interrupt_rc=0 + local pattern_entry + for pattern_entry in "${known_protect_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + # shellcheck disable=SC2053 + [[ "$filename" == $file_pattern || "$bundle_id" == $file_pattern ]] || continue + local protect_rc=0 + _system_service_app_exists "$bundle_id" "$app_path" || protect_rc=$? + if [[ $protect_rc -eq 0 ]]; then + is_protected=true + break + elif [[ $protect_rc -ge 128 ]]; then + protect_interrupt_rc=$protect_rc + break fi - orphaned_files+=("$helper") - local size_kb - size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - orphaned_count=$((orphaned_count + 1)) + # Pattern matched but app is absent → not protected; stop searching. + break + done + if [[ $protect_interrupt_rc -ge 128 ]]; then + service_scan_status=$protect_interrupt_rc break fi - done - done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null) + [[ "$is_protected" == "true" ]] && continue + + # Generic detection: bundle-ID-style helpers registered via SMJobBless + # ship inside the parent app bundle (Contents/Library/LaunchServices/), + # which Spotlight doesn't index directly. Use the shared resolver so we do + # not falsely flag Adobe / 1Password / Docker helpers when their parent app + # is installed. See #733. + if [[ "$bundle_id" =~ ^(com|org|net|io)\. ]]; then + local helper_resolver_rc=0 + bundle_has_installed_app "$bundle_id" \ + "$service_cleanup_deadline" || helper_resolver_rc=$? + if [[ $helper_resolver_rc -ge 128 ]]; then + service_scan_status=$helper_resolver_rc + break + elif [[ $helper_resolver_rc -eq 1 ]]; then + local helper_record_rc=0 + _record_orphan_service_candidate "$helper" || helper_record_rc=$? + if [[ $helper_record_rc -eq 124 || $helper_record_rc -ge 128 ]]; then + service_scan_status=$helper_record_rc + break + fi + fi + fi + done < "$service_scan_file" + else + service_scan_status=$helper_scan_rc + fi + fi + + if [[ $service_scan_status -eq 0 && $SECONDS -ge $service_cleanup_deadline ]]; then + service_scan_status=124 fi + rm -f -- "$service_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above stop_section_spinner - # Report and clean - if [[ $orphaned_count -gt 0 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" + if [[ $service_scan_status -ne 0 ]]; then + debug_log "Skipping orphaned system services: privileged scan incomplete (status $service_scan_status)" + if [[ $service_scan_status -ge 128 ]]; then + return "$service_scan_status" + fi + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}scan incomplete, skipped cleanup${NC}" + note_activity + return 0 + fi - for orphan_file in "${orphaned_files[@]}"; do - local filename - filename=$(basename "$orphan_file") + # Drop whitelisted entries before reporting/cleaning. + if [[ $orphaned_count -gt 0 && ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + local -a kept_files=() + local -a kept_identities=() + local whitelist_index=0 + for ((whitelist_index = 0; whitelist_index < orphaned_count; whitelist_index++)); do + local orphan_file="${orphaned_files[$whitelist_index]}" + if is_path_whitelisted "$orphan_file"; then + debug_log "Skipping whitelisted orphan service: $orphan_file" + continue + fi + kept_files+=("$orphan_file") + kept_identities+=("${orphaned_identities[$whitelist_index]}") + done + orphaned_count=${#kept_files[@]} + # Guard the empty-array expansion: macOS /bin/bash is 3.2, which treats + # "${empty[@]}" as an unbound variable under `set -u`. When every orphan + # is whitelisted kept_files is empty, so a bare expansion would abort the + # whole clean run. See #1127. + if ((orphaned_count > 0)); then + orphaned_files=("${kept_files[@]}") + orphaned_identities=("${kept_identities[@]}") + else + orphaned_files=() + orphaned_identities=() + fi + fi - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + # Report and clean + if [[ $orphaned_count -gt 0 ]]; then + debug_log "Found $orphaned_count orphaned system services" + + local removed_count=0 + local skipped_protected_count=0 + local failed_count=0 + local removed_kb=0 + + local orphan_index=0 + for ((orphan_index = 0; orphan_index < orphaned_count; orphan_index++)); do + local orphan_file="${orphaned_files[$orphan_index]}" + local expected_identity="${orphaned_identities[$orphan_index]}" + local eligibility_rc=0 + _orphan_service_candidate_still_eligible "$orphan_file" "$expected_identity" || eligibility_rc=$? + if [[ $eligibility_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}time limit reached, stopped cleanup${NC}" + debug_log "Orphaned services stopped by deadline at stage: eligibility recheck" + note_activity + return 0 + elif [[ $eligibility_rc -ge 128 ]]; then + return "$eligibility_rc" + elif [[ $eligibility_rc -ne 0 ]]; then + debug_log "Keeping changed or no-longer-orphaned service: $orphan_file" + continue + fi + # Orphans were already verified to have no installed parent app, so + # bypass the data-protection filename check (which would otherwise block + # legitimately orphaned files like Docker helpers) for this single call. + # MOLE_UNINSTALL_MODE is scoped to the call and never leaks to later + # cleanup sections; SYSTEM_CRITICAL_BUNDLES stay protected. See #1082. + if MOLE_UNINSTALL_MODE=1 should_protect_path "$orphan_file"; then + debug_log "Skipping protected orphaned service: $orphan_file" + skipped_protected_count=$((skipped_protected_count + 1)) + continue + fi + if [[ "$DRY_RUN" == "true" ]]; then debug_log "[DRY RUN] Would remove orphaned service: $orphan_file" + local orphan_size_kb=0 + local orphan_size_rc=0 + local orphan_size_timeout="" + orphan_size_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$service_cleanup_deadline") || orphan_size_rc=$? + if [[ $orphan_size_rc -eq 0 ]]; then + orphan_size_kb=$(_mole_bounded_sudo "$orphan_size_timeout" \ + -n du -skP "$orphan_file" < /dev/null 2> /dev/null | awk '{print $1}') || orphan_size_rc=$? + fi + if [[ $orphan_size_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}time limit reached, stopped cleanup${NC}" + debug_log "Orphaned services stopped by deadline at stage: launchctl bootout" + note_activity + return 0 + fi + if [[ $orphan_size_rc -ge 128 ]]; then + return "$orphan_size_rc" + fi + [[ $orphan_size_rc -eq 0 && "$orphan_size_kb" =~ ^[0-9]+$ ]] || orphan_size_kb=0 + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$orphan_file" "$orphan_size_kb" 1 true || continue + elif [[ -n "${EXPORT_LIST_FILE:-}" && -f "$EXPORT_LIST_FILE" ]]; then + # Standalone module tests do not prepare the clean ledger. + echo "$orphan_file # $(bytes_to_human "$((orphan_size_kb * 1024))")" >> "$EXPORT_LIST_FILE" + fi else + local file_size_kb=0 + local file_size_rc=0 + local file_size_timeout="" + file_size_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$service_cleanup_deadline") || file_size_rc=$? + if [[ $file_size_rc -eq 0 ]]; then + file_size_kb=$(_mole_bounded_sudo "$file_size_timeout" \ + -n du -skP "$orphan_file" < /dev/null 2> /dev/null | awk '{print $1}') || file_size_rc=$? + fi + if [[ $file_size_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}time limit reached, stopped cleanup${NC}" + debug_log "Orphaned services stopped by deadline at stage: plist removal" + note_activity + return 0 + fi + if [[ $file_size_rc -ge 128 ]]; then + return "$file_size_rc" + fi + [[ $file_size_rc -eq 0 && "$file_size_kb" =~ ^[0-9]+$ ]] || file_size_kb=0 + # Unload if it's a LaunchDaemon/LaunchAgent if [[ "$orphan_file" == *.plist ]]; then - sudo launchctl unload "$orphan_file" 2> /dev/null || true + local pre_unload_rc=0 + _orphan_service_candidate_still_eligible "$orphan_file" \ + "$expected_identity" || pre_unload_rc=$? + if [[ $pre_unload_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}time limit reached, stopped cleanup${NC}" + debug_log "Orphaned services stopped by deadline at stage: privileged plist removal" + note_activity + return 0 + elif [[ $pre_unload_rc -ge 128 ]]; then + return "$pre_unload_rc" + elif [[ $pre_unload_rc -ne 0 ]]; then + debug_log "Keeping changed or no-longer-orphaned service before unload: $orphan_file" + continue + fi + local unload_rc=0 + local unload_timeout="" + unload_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$service_cleanup_deadline") || unload_rc=$? + if [[ $unload_rc -eq 0 ]]; then + _mole_bounded_sudo "$unload_timeout" \ + -n launchctl unload "$orphan_file" < /dev/null 2> /dev/null || unload_rc=$? + fi + if [[ $unload_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}unload timed out, stopped cleanup${NC}" + note_activity + return 0 + elif [[ $unload_rc -ge 128 ]]; then + return "$unload_rc" + fi fi - if safe_sudo_remove "$orphan_file"; then + local final_eligibility_rc=0 + _orphan_service_candidate_still_eligible "$orphan_file" \ + "$expected_identity" || final_eligibility_rc=$? + if [[ $final_eligibility_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}time limit reached, stopped cleanup${NC}" + debug_log "Orphaned services stopped by deadline at stage: helper binary removal" + note_activity + return 0 + elif [[ $final_eligibility_rc -ge 128 ]]; then + return "$final_eligibility_rc" + elif [[ $final_eligibility_rc -ne 0 ]]; then + debug_log "Keeping changed or no-longer-orphaned service before removal: $orphan_file" + continue + fi + local remove_rc=0 + safe_sudo_remove "$orphan_file" "" "$service_cleanup_deadline" || remove_rc=$? + if [[ $remove_rc -eq 0 ]]; then debug_log "Removed orphaned service: $orphan_file" + removed_count=$((removed_count + 1)) + removed_kb=$((removed_kb + file_size_kb)) + elif [[ $remove_rc -eq $MOLE_ERR_PROTECTED_PATH ]]; then + debug_log "Skipping protected orphaned service: $orphan_file" + skipped_protected_count=$((skipped_protected_count + 1)) + elif [[ $remove_rc -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphaned system services · ${GRAY}removal timed out, stopped cleanup${NC}" + note_activity + return 0 + elif [[ $remove_rc -ge 128 ]]; then + return "$remove_rc" + else + debug_log "Failed to remove orphaned service: $orphan_file" + failed_count=$((failed_count + 1)) fi fi done - local orphaned_kb_display - if [[ $total_orphaned_kb -gt 1024 ]]; then - orphaned_kb_display=$(echo "$total_orphaned_kb" | awk '{printf "%.1fMB", $1/1024}') - else - orphaned_kb_display="${total_orphaned_kb}KB" + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Orphaned services · ${YELLOW}${orphaned_count} found dry${NC}" + note_activity + elif [[ $removed_count -gt 0 ]]; then + local orphaned_kb_display + orphaned_kb_display=$(bytes_to_human "$((removed_kb * 1024))") + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Orphaned services · cleaned ${removed_count}, ${orphaned_kb_display}" + note_activity + fi + # Surface protected/failed counts in BOTH dry-run and real-clean so the + # two modes agree on what gets touched. Before #886, dry-run silently + # reported protected files under "Would remove" and real-clean then + # skipped them, leaving the user confused about which files actually + # disappeared. + if [[ $skipped_protected_count -gt 0 || $failed_count -gt 0 ]]; then + local issue_note="" + if [[ $skipped_protected_count -gt 0 ]]; then + issue_note="skipped ${skipped_protected_count} protected" + fi + if [[ $failed_count -gt 0 ]]; then + issue_note+="${issue_note:+, }${failed_count} failed" + fi + echo -e " ${GRAY}${ICON_WARNING}${NC} Orphaned services · ${issue_note}" + note_activity fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display" - note_activity fi } +# Policy: mo clean does NOT touch user LaunchAgents (~/Library/LaunchAgents), +# they are user-owned automation and not generic cleanup targets. + # ============================================================================ -# Orphaned LaunchAgent/LaunchDaemon Cleanup (Generic Detection) +# Orphaned container stubs # ============================================================================ -# Extract program path from plist (supports both ProgramArguments and Program) -_extract_program_path() { - local plist="$1" - local program="" - - program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null) - if [[ -z "$program" ]]; then - program=$(plutil -extract Program raw "$plist" 2> /dev/null) - fi +# Whether a stub container's app is still installed anywhere Mole scans. +# Defined at file scope so callers and tests can pin it deterministically. +_container_stub_app_exists() { + local bundle_id="$1" + local app_path="$2" - echo "$program" -} + [[ -d "$app_path" || -e "$app_path" ]] && return 0 -# Extract associated bundle identifier from plist -_extract_associated_bundle() { - local plist="$1" - local associated="" + local app_name + app_name=$(basename "$app_path") + case "$app_path" in + /Applications/*) + [[ -d "$HOME/Applications/$app_name" ]] && return 0 + [[ -d "/Applications/Setapp/$app_name" ]] && return 0 + [[ -d "$HOME/Library/Application Support/Setapp/Applications/$app_name" ]] && return 0 + ;; + esac - # Try array format first - associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null) - if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then - # Try string format - associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null) - # Filter out dict/array markers - if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then - associated="" + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + local _cache_rc=0 + _mdfind_cache_check "$bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then + return 0 + elif [[ $_cache_rc -eq 2 ]]; then + # On mdfind timeout/error assume the app exists (return 0 = + # installed here) so a transient Spotlight stall never flags a + # live app's service/container as an orphan; do not cache. + local app_found _mdfind_rc=0 + app_found=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null) || _mdfind_rc=$? + if [[ $_mdfind_rc -eq 124 || $_mdfind_rc -ge 128 ]]; then + return "$_mdfind_rc" + elif [[ $_mdfind_rc -ne 0 ]]; then + return 0 + elif [[ -n "$app_found" ]]; then + _mdfind_cache_store "$bundle_id" "true" + return 0 + fi + _mdfind_cache_store "$bundle_id" "false" fi fi - echo "$associated" + return 1 } -# Check if a LaunchAgent/LaunchDaemon is orphaned using multi-layer verification -# Returns 0 if orphaned, 1 if not orphaned -is_launch_item_orphaned() { - local plist="$1" +# Remove stub-only ~/Library/Containers directories left by uninstalled apps. +# A stub container contains only .com.apple.containermanagerd.metadata.plist +# with no Data/ subdirectory, it holds no user data and is safe to remove. +# Only targets a hardcoded allowlist of apps known to leave such stubs. +_remove_verified_container_stub() { + local container_dir="$1" + local metadata_plist="$2" - # Layer 1: Check if program path exists - local program=$(_extract_program_path "$plist") + [[ -d "$container_dir" ]] || return 1 + [[ ! -L "$container_dir" ]] || return 1 + [[ "$metadata_plist" == "$container_dir/.com.apple.containermanagerd.metadata.plist" ]] || return 1 + [[ -f "$metadata_plist" ]] || return 1 - # No program path - skip (not a standard launch item) - [[ -z "$program" ]] && return 1 + if find "$container_dir" -mindepth 1 -maxdepth 1 ! -name ".com.apple.containermanagerd.metadata.plist" -print -quit 2> /dev/null | grep -q .; then + return 1 + fi - # Program exists -> not orphaned - [[ -e "$program" ]] && return 1 + # SAFE: deliberate carve-out from safe_remove, do NOT "unify" this back + # into the shared helper. should_protect_path blankets ~/Library/Containers + # (container interiors are user data), so safe_remove refuses BOTH paths and + # routing through it silently disables this cleaner entirely (verified: both + # validate_path_for_deletion calls return 1). The removal stays narrow by + # construction instead: the caller matches a hardcoded app allowlist, and the + # guards above pin the target to a non-symlink directory whose ONLY entry is + # the exact containermanagerd metadata plist, i.e. a stub with no Data/ dir + # and no user content. rmdir (not rm -r) means a container that gains any + # file between the check and the removal survives untouched. + command rm -f -- "$metadata_plist" || return 1 + command rmdir -- "$container_dir" +} - # Layer 2: Check AssociatedBundleIdentifiers - local associated=$(_extract_associated_bundle "$plist") - if [[ -n "$associated" ]]; then - # Check if associated app exists via mdfind - if run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$associated'" 2> /dev/null | head -1 | grep -q .; then - return 1 # Associated app found -> not orphaned - fi +clean_orphaned_container_stubs() { + local containers_dir="$HOME/Library/Containers" + [[ -d "$containers_dir" ]] || return 0 + + # Keep the section spinner alive: the mdfind probes below can take + # seconds, and without a spinner the section looks hung after the + # previous step's output (per-step loading feedback). + start_section_spinner "Scanning orphaned containers..." + + # Format: "bundle_id_glob:app_path_to_check" + # The app_path_to_check is the canonical .app location; the stub is removed + # only when no common install location nor mdfind can locate the app. + local -a stub_patterns=( + # MacPaw CleanMyMac X (direct and MAS variants, bare bundle ID) + "com.macpaw.CleanMyMac*:/Applications/CleanMyMac X.app" + # MacPaw CleanMyMac X TeamID-prefixed helpers (e.g. S8EX82NJP6.com.macpaw.*) + "*.com.macpaw.CleanMyMac*:/Applications/CleanMyMac X.app" + ) - # Extract vendor name from bundle ID (com.vendor.app -> vendor) - local vendor=$(echo "$associated" | cut -d'.' -f2) - if [[ -n "$vendor" ]] && [[ ${#vendor} -ge 3 ]]; then - # Check if any app from this vendor exists - if find /Applications ~/Applications -maxdepth 2 -iname "*${vendor}*" -type d 2> /dev/null | grep -iq "\.app"; then - return 1 # Vendor app exists -> not orphaned + local removed_count=0 + local failed_count=0 + local _ng_state + _ng_state=$(shopt -p nullglob || true) + shopt -s nullglob + + local pattern_entry + for pattern_entry in "${stub_patterns[@]}"; do + local bundle_glob="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + local container_dir + for container_dir in "$containers_dir"/$bundle_glob; do + [[ -d "$container_dir" ]] || continue + [[ -L "$container_dir" ]] && continue + + local metadata_plist="$container_dir/.com.apple.containermanagerd.metadata.plist" + [[ -f "$metadata_plist" ]] || continue + local sibling_entry="" + local sibling_scan_rc=0 + sibling_entry=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" find \ + "$container_dir" -mindepth 1 -maxdepth 1 \ + ! -name ".com.apple.containermanagerd.metadata.plist" \ + -print -quit 2> /dev/null) || sibling_scan_rc=$? + if [[ $sibling_scan_rc -eq 124 || $sibling_scan_rc -ge 128 ]]; then + eval "$_ng_state" + stop_section_spinner + return "$sibling_scan_rc" + elif [[ $sibling_scan_rc -ne 0 ]]; then + continue + elif [[ -n "$sibling_entry" ]]; then + continue fi - fi - fi - # Layer 3: Check Application Support directory activity - if [[ "$program" =~ /Library/Application\ Support/([^/]+)/ ]]; then - local app_support_name="${BASH_REMATCH[1]}" + local bundle_id="${container_dir##*/}" - # Check both user and system Application Support - for base in "$HOME/Library/Application Support" "/Library/Application Support"; do - local support_path="$base/$app_support_name" - if [[ -d "$support_path" ]]; then - # Check if there are files modified in last 7 days (active usage) - local recent_file=$(find "$support_path" -type f -mtime -7 2> /dev/null | head -1) - if [[ -n "$recent_file" ]]; then - return 1 # Active Application Support -> not orphaned - fi + local stub_app_rc=0 + _container_stub_app_exists "$bundle_id" "$app_path" || stub_app_rc=$? + if [[ $stub_app_rc -eq 0 ]]; then + continue + elif [[ $stub_app_rc -eq 124 || $stub_app_rc -ge 128 ]]; then + eval "$_ng_state" + stop_section_spinner + return "$stub_app_rc" fi - done - fi - - # Layer 4: Check if app name from program path exists - if [[ "$program" =~ /Applications/([^/]+)\.app/ ]]; then - local app_name="${BASH_REMATCH[1]}" - # Look for apps with similar names (case-insensitive) - if find /Applications ~/Applications -maxdepth 2 -iname "*${app_name}*" -type d 2> /dev/null | grep -iq "\.app"; then - return 1 # Similar app exists -> not orphaned - fi - fi - - # Layer 5: PrivilegedHelper special handling - if [[ "$program" =~ ^/Library/PrivilegedHelperTools/ ]]; then - local filename=$(basename "$plist") - local bundle_id="${filename%.plist}" - - # Extract app hint from bundle ID (com.vendor.app.helper -> vendor) - local app_hint=$(echo "$bundle_id" | sed 's/com\.//; s/\..*helper.*//') - if [[ -n "$app_hint" ]] && [[ ${#app_hint} -ge 3 ]]; then - # Look for main app - if find /Applications ~/Applications -maxdepth 2 -iname "*${app_hint}*" -type d 2> /dev/null | grep -iq "\.app"; then - return 1 # Helper's main app exists -> not orphaned + if is_path_whitelisted "$container_dir" 2> /dev/null; then + debug_log "Skipping whitelisted stub container: $container_dir" + continue fi - fi - fi - - # All checks failed -> likely orphaned - return 0 -} - -# Clean orphaned user-level LaunchAgents -# Only processes ~/Library/LaunchAgents (safer than system-level) -clean_orphaned_launch_agents() { - local launch_agents_dir="$HOME/Library/LaunchAgents" - - [[ ! -d "$launch_agents_dir" ]] && return 0 - - start_section_spinner "Scanning orphaned launch agents..." - - local -a orphaned_items=() - local total_orphaned_kb=0 - - # Scan user LaunchAgents - while IFS= read -r -d '' plist; do - local filename=$(basename "$plist") - - # Skip Apple's LaunchAgents - [[ "$filename" == com.apple.* ]] && continue - local bundle_id="${filename%.plist}" + if [[ "$DRY_RUN" != "true" ]]; then + # These directories have already passed the narrow stub-only + # checks above. Remove only the exact metadata file, then rmdir, + # so any new content that appears before deletion is preserved. + local stub_remove_rc=0 + _remove_verified_container_stub \ + "$container_dir" "$metadata_plist" > /dev/null 2>&1 || stub_remove_rc=$? + if [[ $stub_remove_rc -eq 124 || $stub_remove_rc -ge 128 ]]; then + eval "$_ng_state" + stop_section_spinner + return "$stub_remove_rc" + elif [[ $stub_remove_rc -eq 0 ]]; then + removed_count=$((removed_count + 1)) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$container_dir" "stub-container" + else + debug_log "Failed to remove stub container: $container_dir" + failed_count=$((failed_count + 1)) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$container_dir" "stub-container" + fi + else + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + local stub_size_kb + local stub_size_rc=0 + stub_size_kb=$(get_path_size_kb "$container_dir" 2> /dev/null) || stub_size_rc=$? + [[ $stub_size_rc -eq 124 || $stub_size_rc -ge 128 ]] && { + eval "$_ng_state" + stop_section_spinner + return "$stub_size_rc" + } + [[ "$stub_size_kb" =~ ^[0-9]+$ ]] || stub_size_kb=0 + local stub_record_rc=0 + record_dry_run_cleanup_target \ + "$container_dir" "$stub_size_kb" 1 true || stub_record_rc=$? + if [[ $stub_record_rc -eq 124 || $stub_record_rc -ge 128 ]]; then + eval "$_ng_state" + stop_section_spinner + return "$stub_record_rc" + elif [[ $stub_record_rc -ne 0 ]]; then + continue + fi + fi + removed_count=$((removed_count + 1)) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$container_dir" "dry-run stub-container" + fi + done + done - # Check if orphaned using multi-layer verification - if is_launch_item_orphaned "$plist"; then - local size_kb=$(get_path_size_kb "$plist") - orphaned_items+=("$bundle_id|$plist") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - fi - done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + # eval: restore shopt state captured by $(shopt -p) + eval "$_ng_state" stop_section_spinner - - local orphaned_count=${#orphaned_items[@]} - - if [[ $orphaned_count -eq 0 ]]; then - return 0 - fi - - # Clean the orphaned items automatically - local removed_count=0 - local dry_run_count=0 - local is_dry_run=false - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - is_dry_run=true - fi - for item in "${orphaned_items[@]}"; do - IFS='|' read -r bundle_id plist_path <<< "$item" - - if [[ "$is_dry_run" == "true" ]]; then - dry_run_count=$((dry_run_count + 1)) - log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent" - continue - fi - - # Try to unload first (if currently loaded) - launchctl unload "$plist_path" 2> /dev/null || true - - # Remove the plist file - if safe_remove "$plist_path" false; then - removed_count=$((removed_count + 1)) - log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" + if [[ $removed_count -gt 0 ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Orphaned app container stubs, ${YELLOW}${removed_count} stubs dry${NC}" else - log_operation "clean" "FAILED" "$plist_path" "permission denied" - fi - done - - if [[ "$is_dry_run" == "true" ]]; then - if [[ $dry_run_count -gt 0 ]]; then - local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove $dry_run_count orphaned launch agent(s), ${cleaned_mb}MB" - note_activity - fi - else - if [[ $removed_count -gt 0 ]]; then - local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${GREEN}${ICON_SUCCESS}${NC} Removed $removed_count orphaned launch agent(s), ${cleaned_mb}MB" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Orphaned app container stubs, ${GREEN}${removed_count} removed${NC}" note_activity fi + files_cleaned=$((files_cleaned + removed_count)) + total_items=$((total_items + 1)) + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Orphaned container stubs: $failed_count could not be removed" + # Keep the warning visible past the idle-section erase. + note_activity fi } diff --git a/Resources/mole/lib/clean/brew.sh b/Resources/mole/lib/clean/brew.sh index 202c45a..b6a1c5b 100644 --- a/Resources/mole/lib/clean/brew.sh +++ b/Resources/mole/lib/clean/brew.sh @@ -1,21 +1,231 @@ #!/bin/bash -# Clean Homebrew caches and remove orphaned dependencies +# Clean Homebrew caches and report orphaned dependencies # Env: DRY_RUN -# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout +# Skips if run within 7 days, runs cleanup with package-manager timeouts +BREW_ACTIVE_LINK_PATHS=() +BREW_ACTIVE_LINK_TARGETS=() +BREW_ACTIVE_RESOLVED_TARGETS=() +BREW_ACTIVE_PREFIX="" +BREW_ACTIVE_CELLAR="" + +brew_autoremove_preview_has_items() { + local preview_file="$1" + [[ -s "$preview_file" ]] || return 1 + grep -Eq '^(==> )?Would autoremove [0-9]+ unneeded formula' "$preview_file" +} + +show_brew_autoremove_preview() { + local preview_file="$1" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew autoremove would remove:" + sed 's/^/ /' "$preview_file" +} + +run_brew_autoremove_preview() { + local timeout_seconds="$1" + local preview_file="$2" + + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_COLOR=1 NONINTERACTIVE=1 \ + run_with_timeout "$timeout_seconds" brew autoremove --dry-run > "$preview_file" 2>&1 +} + +# Resolve an existing path through any symlink chain without requiring GNU +# readlink -f (unavailable on the macOS versions Mole supports). +brew_cleanup_resolve_existing_path() { + local path="$1" + local target="" + local hops=0 + + [[ "$path" == /* ]] || return 1 + while [[ -L "$path" ]]; do + target=$(readlink "$path" 2> /dev/null) || return 1 + if [[ "$target" == /* ]]; then + path="$target" + else + path="${path%/*}/$target" + fi + hops=$((hops + 1)) + [[ $hops -le 32 ]] || return 1 + done + + [[ -e "$path" ]] || return 1 + local parent + parent=$(cd "${path%/*}" 2> /dev/null && pwd -P) || return 1 + printf '%s/%s\n' "$parent" "${path##*/}" +} + +run_homebrew_link_restore_as_invoking_user() { + /usr/bin/sudo -u "$SUDO_USER" -- "$@" +} + +restore_homebrew_link() { + local link_target="$1" + local link_path="$2" + + if is_root_user; then + [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]] || return 1 + # Homebrew's bin directories belong to the invoking user. Dropping + # privileges for the actual write closes the parent-directory TOCTOU: + # even if that user swaps bin after validation, root never follows it. + run_homebrew_link_restore_as_invoking_user /bin/ln -s "$link_target" "$link_path" + return $? + fi + + /bin/ln -s "$link_target" "$link_path" +} + +# Record active Homebrew executable links in memory before delegating to +# `brew cleanup`. A file in the invoking user's temp tree cannot safely +# authorize later link creation when the whole command is running as root. +snapshot_homebrew_active_links() { + BREW_ACTIVE_LINK_PATHS=() + BREW_ACTIVE_LINK_TARGETS=() + BREW_ACTIVE_RESOLVED_TARGETS=() + BREW_ACTIVE_PREFIX="" + BREW_ACTIVE_CELLAR="" + + local prefix cellar + prefix=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew --prefix 2> /dev/null) || return 0 + cellar=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew --cellar 2> /dev/null) || return 0 + [[ "$prefix" == /* && "$cellar" == /* && -d "$prefix" && -d "$cellar" ]] || return 0 + prefix=$(cd "$prefix" 2> /dev/null && pwd -P) || return 0 + cellar=$(cd "$cellar" 2> /dev/null && pwd -P) || return 0 + BREW_ACTIVE_PREFIX="$prefix" + BREW_ACTIVE_CELLAR="$cellar" + + local link_dir link_path link_target resolved_target + for link_dir in "$prefix/bin" "$prefix/sbin"; do + [[ -d "$link_dir" ]] || continue + while IFS= read -r -d '' link_path; do + link_target=$(readlink "$link_path" 2> /dev/null) || continue + case "$link_target" in + "$cellar"/*) + [[ "$link_target" != *"/../"* && "$link_target" != */.. ]] || continue + resolved_target="$link_target" + ;; + ../Cellar/*) + [[ "$cellar" == "$prefix/Cellar" ]] || continue + local cellar_relative="${link_target#../Cellar/}" + [[ -n "$cellar_relative" && "$cellar_relative" != ../* && "$cellar_relative" != *"/../"* && "$cellar_relative" != */.. ]] || continue + resolved_target="$cellar/$cellar_relative" + ;; + *) continue ;; + esac + [[ -e "$resolved_target" ]] || continue + case "$resolved_target" in + "$cellar"/*) + BREW_ACTIVE_LINK_PATHS+=("$link_path") + BREW_ACTIVE_LINK_TARGETS+=("$link_target") + BREW_ACTIVE_RESOLVED_TARGETS+=("$resolved_target") + ;; + esac + done < <(command find "$link_dir" -mindepth 1 -maxdepth 1 -type l -print0 2> /dev/null) + done +} + +# Restore only links that disappeared while their exact pre-cleanup Cellar +# target still exists. Never overwrite a replacement or revive a removed keg. +restore_homebrew_active_links() { + [[ ${#BREW_ACTIVE_LINK_PATHS[@]} -gt 0 ]] || return 0 + + local prefix cellar + prefix=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew --prefix 2> /dev/null) || return 0 + cellar=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew --cellar 2> /dev/null) || return 0 + [[ "$prefix" == /* && "$cellar" == /* && -d "$prefix" && -d "$cellar" ]] || return 0 + prefix=$(cd "$prefix" 2> /dev/null && pwd -P) || return 0 + cellar=$(cd "$cellar" 2> /dev/null && pwd -P) || return 0 + [[ "$prefix" == "$BREW_ACTIVE_PREFIX" && "$cellar" == "$BREW_ACTIVE_CELLAR" ]] || return 0 + + local restored=0 + local failed=0 + local link_path link_target resolved_target expected_target relative_path + local current_target current_parent + local index + for ((index = 0; index < ${#BREW_ACTIVE_LINK_PATHS[@]}; index++)); do + link_path="${BREW_ACTIVE_LINK_PATHS[$index]}" + link_target="${BREW_ACTIVE_LINK_TARGETS[$index]}" + resolved_target="${BREW_ACTIVE_RESOLVED_TARGETS[$index]}" + + # Restore only direct children of the real Homebrew bin/sbin roots. + case "$link_path" in + "$prefix/bin/"*) relative_path="${link_path#"$prefix/bin/"}" ;; + "$prefix/sbin/"*) relative_path="${link_path#"$prefix/sbin/"}" ;; + *) continue ;; + esac + [[ -n "$relative_path" && "$relative_path" != */* ]] || continue + [[ ! -e "$link_path" && ! -L "$link_path" ]] || continue + + case "$link_target" in + "$cellar"/*) + [[ "$link_target" != *"/../"* && "$link_target" != */.. ]] || continue + expected_target="$link_target" + ;; + ../Cellar/*) + [[ "$cellar" == "$prefix/Cellar" ]] || continue + relative_path="${link_target#../Cellar/}" + [[ -n "$relative_path" && "$relative_path" != ../* && "$relative_path" != *"/../"* && "$relative_path" != */.. ]] || continue + expected_target="$cellar/$relative_path" + ;; + *) continue ;; + esac + [[ "$resolved_target" == "$expected_target" ]] || continue + + current_target=$(brew_cleanup_resolve_existing_path "$resolved_target") || continue + case "$current_target" in + "$cellar"/*) ;; + *) continue ;; + esac + + current_parent=$(cd "${link_path%/*}" 2> /dev/null && pwd -P) || continue + [[ "$current_parent" == "${link_path%/*}" ]] || continue + if restore_homebrew_link "$link_target" "$link_path" 2> /dev/null; then + restored=$((restored + 1)) + else + failed=$((failed + 1)) + fi + done + + if [[ $restored -gt 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew links · restored ${restored} active executable(s)" + note_activity + fi + if [[ $failed -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew links · ${failed} could not be restored, run ${GRAY}brew link ${NC}" + note_activity + fi +} + clean_homebrew() { command -v brew > /dev/null 2>&1 || return 0 + local cleanup_timeout="${MOLE_TIMEOUT_PKG_CLEANUP_SEC:-20}" + local autoremove_preview_timeout="${MOLE_TIMEOUT_PKG_LIST_SEC:-10}" if [[ "${DRY_RUN:-false}" == "true" ]]; then # Check if Homebrew cache is whitelisted if is_path_whitelisted "$HOME/Library/Caches/Homebrew"; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped whitelist" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped (whitelist)" + note_activity else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup" + note_activity + local dry_run_autoremove_file + dry_run_autoremove_file=$(create_temp_file) + local dry_run_autoremove_exit=0 + run_brew_autoremove_preview "$autoremove_preview_timeout" "$dry_run_autoremove_file" || dry_run_autoremove_exit=$? + if [[ $dry_run_autoremove_exit -eq 0 ]] && brew_autoremove_preview_has_items "$dry_run_autoremove_file"; then + show_brew_autoremove_preview "$dry_run_autoremove_file" + elif [[ $dry_run_autoremove_exit -eq 124 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove preview timed out · run ${GRAY}brew autoremove --dry-run${NC} manually" + fi fi return 0 fi # Keep behavior consistent with dry-run preview. if is_path_whitelisted "$HOME/Library/Caches/Homebrew"; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped whitelist" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped (whitelist)" + note_activity return 0 fi # Skip if cleaned recently to avoid repeated heavy operations. @@ -31,63 +241,43 @@ clean_homebrew() { local days_diff=$((time_diff / 86400)) if [[ $days_diff -lt $cache_valid_days ]]; then should_skip=true - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · cleaned ${days_diff}d ago, skipped" + local cleaned_when="cleaned ${days_diff}d ago" + [[ $days_diff -eq 0 ]] && cleaned_when="cleaned today" + debug_log "Homebrew cleanup skipped: ${cleaned_when}" fi fi [[ "$should_skip" == "true" ]] && return 0 - # Skip cleanup if cache is small; still run autoremove. + # Skip cleanup if cache is small; autoremove is previewed separately. local skip_cleanup=false local brew_cache_size=0 if [[ -d ~/Library/Caches/Homebrew ]]; then - brew_cache_size=$(run_with_timeout 3 du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') + brew_cache_size=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') local du_exit=$? if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then skip_cleanup=true fi fi - # Spinner reflects whether cleanup is skipped. - if [[ -t 1 ]]; then - if [[ "$skip_cleanup" == "true" ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew autoremove (cleanup skipped)..." - else - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..." - fi - fi - # Run cleanup/autoremove in parallel with timeout guard per command. - local timeout_seconds=120 - local brew_tmp_file autoremove_tmp_file - local brew_pid autoremove_pid + local brew_tmp_file local brew_exit=0 - local autoremove_exit=0 if [[ "$skip_cleanup" == "false" ]]; then brew_tmp_file=$(create_temp_file) - run_with_timeout "$timeout_seconds" brew cleanup > "$brew_tmp_file" 2>&1 & - brew_pid=$! + snapshot_homebrew_active_links || true + if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup..."; fi + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_AUTOREMOVE=1 NONINTERACTIVE=1 \ + run_with_timeout "$cleanup_timeout" brew cleanup --prune=30 > "$brew_tmp_file" 2>&1 || brew_exit=$? + if [[ -t 1 ]]; then stop_inline_spinner; fi + restore_homebrew_active_links fi - autoremove_tmp_file=$(create_temp_file) - run_with_timeout "$timeout_seconds" brew autoremove > "$autoremove_tmp_file" 2>&1 & - autoremove_pid=$! - - if [[ -n "$brew_pid" ]]; then - wait "$brew_pid" 2> /dev/null || brew_exit=$? - fi - wait "$autoremove_pid" 2> /dev/null || autoremove_exit=$? local brew_success=false if [[ "$skip_cleanup" == "false" && $brew_exit -eq 0 ]]; then brew_success=true fi - local autoremove_success=false - if [[ $autoremove_exit -eq 0 ]]; then - autoremove_success=true - fi - if [[ -t 1 ]]; then stop_inline_spinner; fi + # Process cleanup output and extract metrics # Summarize cleanup results. if [[ "$skip_cleanup" == "true" ]]; then - # Cleanup was skipped due to small cache size - local size_mb=$((brew_cache_size / 1024)) - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup · cache ${size_mb}MB, skipped" + debug_log "Homebrew cleanup skipped: cache below threshold (${brew_cache_size}KB)" elif [[ "$brew_success" == "true" && -f "$brew_tmp_file" ]]; then local brew_output brew_output=$(cat "$brew_tmp_file" 2> /dev/null || echo "") @@ -96,31 +286,37 @@ clean_homebrew() { freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true) if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then if [[ -n "$freed_space" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup${NC}, ${GREEN}$freed_space${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup${NC} · ${GREEN}$freed_space${NC}" + note_activity else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup, ${removed_count} items" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup · ${removed_count} items" + note_activity fi fi elif [[ $brew_exit -eq 124 ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually" + note_activity fi - # Process autoremove output - only show if packages were removed - # Only surface autoremove output when packages were removed. - if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then - local autoremove_output - autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "") - local removed_packages - removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true) - if [[ $removed_packages -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies, ${removed_packages} packages" - fi - elif [[ $autoremove_exit -eq 124 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" + local autoremove_preview_file + autoremove_preview_file=$(create_temp_file) + local autoremove_preview_exit=0 + run_brew_autoremove_preview "$autoremove_preview_timeout" "$autoremove_preview_file" || autoremove_preview_exit=$? + if [[ $autoremove_preview_exit -eq 124 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove preview timed out · run ${GRAY}brew autoremove --dry-run${NC} manually" + # Keep the manual-action guidance visible past the idle-section erase. + note_activity + elif [[ $autoremove_preview_exit -ne 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove preview failed · run ${GRAY}brew autoremove --dry-run${NC} manually" + note_activity + elif brew_autoremove_preview_has_items "$autoremove_preview_file"; then + show_brew_autoremove_preview "$autoremove_preview_file" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew autoremove · skipped (run ${GRAY}brew autoremove${NC} manually)" + note_activity fi # Update cache timestamp on successful completion or when cleanup was intelligently skipped # This prevents repeated cache size checks within the 7-day window # Update cache timestamp when any work succeeded or was intentionally skipped. - if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then + if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]]; then ensure_user_file "$brew_cache_file" get_epoch_seconds > "$brew_cache_file" fi diff --git a/Resources/mole/lib/clean/caches.sh b/Resources/mole/lib/clean/caches.sh index 72892ce..4440392 100644 --- a/Resources/mole/lib/clean/caches.sh +++ b/Resources/mole/lib/clean/caches.sh @@ -41,19 +41,27 @@ check_tcc_permissions() { ensure_user_file "$permission_flag" return 0 } -# Args: $1=browser_name, $2=cache_path +# Args: $1=browser_name, $2=cache_path, $3=optional post-size guard callback # Clean Service Worker cache while protecting critical web editors. clean_service_worker_cache() { local browser_name="$1" local cache_path="$2" + local delete_guard="${3:-}" [[ ! -d "$cache_path" ]] && return 0 local cleaned_size=0 local protected_count=0 + local guard_stopped=false + # shellcheck disable=SC2016 while IFS= read -r cache_dir; do [[ ! -d "$cache_dir" ]] && continue # Extract a best-effort domain name from cache folder. local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "") - local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir") + local size=0 + local _du_out + if _du_out=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" du -skP "$cache_dir" 2> /dev/null); then + local _sz="${_du_out%%[^0-9]*}" + [[ "$_sz" =~ ^[0-9]+$ ]] && size="$_sz" + fi local is_protected=false for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do if [[ "$domain" == *"$protected_domain"* ]]; then @@ -62,34 +70,58 @@ clean_service_worker_cache() { break fi done + # Service Worker cache dirs are keyed by origin hash, so they never + # match PROTECTED_SW_DOMAINS even when the user added Chrome SW paths + # to their whitelist. Honor the whitelist explicitly, otherwise MV3 + # extensions lose their registered workers mid-session. See #724. + if [[ "$is_protected" == "false" ]] && is_path_whitelisted "$cache_dir"; then + is_protected=true + protected_count=$((protected_count + 1)) + fi if [[ "$is_protected" == "false" ]]; then - if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$cache_dir" true || true + if [[ -n "$delete_guard" ]] && ! "$delete_guard"; then + guard_stopped=true + break + fi + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$cache_dir" "$size" 1 true || continue + fi + elif ! safe_remove "$cache_dir" true "$size"; then + continue fi cleaned_size=$((cleaned_size + size)) fi - done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true") + done < <(run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sh -c 'find "$1" -type d -depth 2 2>/dev/null || true' _ "$cache_path") if [[ $cleaned_size -gt 0 ]]; then local spinner_was_running=false if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then stop_inline_spinner spinner_was_running=true fi - local cleaned_mb=$((cleaned_size / 1024)) + # cleaned_size is in KB. Hand-rolled KB/1024 truncation reported any + # sub-megabyte cleanup as "0MB"; use the shared formatter like every + # other cleaner so amounts under 1MB render as KB. + local cleaned_human + cleaned_human=$(bytes_to_human "$((cleaned_size * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$cleaned_size") if [[ "$DRY_RUN" != "true" ]]; then if [[ $protected_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB, ${protected_count} protected" + echo -e " ${line_color}${ICON_SUCCESS}${NC} $browser_name Service Worker${NC} · ${line_color}${cleaned_human}${NC}, ${protected_count} protected" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB" + echo -e " ${line_color}${ICON_SUCCESS}${NC} $browser_name Service Worker${NC} · ${line_color}${cleaned_human}${NC}" fi else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean ${cleaned_mb}MB, ${protected_count} protected" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean $(colorize_human_size "$cleaned_human"), ${protected_count} protected" fi note_activity if [[ "$spinner_was_running" == "true" ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning browser Service Worker caches..." fi fi + [[ "$guard_stopped" == "true" ]] && return 75 + return 0 } # Check whether a directory looks like a project container. project_cache_has_indicators() { @@ -117,6 +149,8 @@ project_cache_has_indicators() { # Discover candidate project roots without scanning the whole home directory. discover_project_cache_roots() { local -a roots=() + local -a unique_roots=() + local -a seen_identities=() local root for root in "${MOLE_PURGE_DEFAULT_SEARCH_PATHS[@]}"; do @@ -127,12 +161,23 @@ discover_project_cache_roots() { [[ -d "$root" ]] && roots+=("$root") done < <(mole_purge_read_paths_config "$HOME/.config/mole/purge_paths") + local _indicator_tmp + _indicator_tmp=$(create_temp_file) + local -a _indicator_pids=() + local _max_jobs + _max_jobs=$(get_optimal_parallel_jobs scan) + if ! [[ "$_max_jobs" =~ ^[0-9]+$ ]] || [[ "$_max_jobs" -lt 1 ]]; then + _max_jobs=1 + elif [[ "$_max_jobs" -gt 8 ]]; then + _max_jobs=8 + fi + local dir local base for dir in "$HOME"/*/; do [[ -d "$dir" ]] || continue dir="${dir%/}" - base=$(basename "$dir") + base="${dir##*/}" case "$base" in .* | Library | Applications | Movies | Music | Pictures | Public) @@ -140,14 +185,61 @@ discover_project_cache_roots() { ;; esac - if project_cache_has_indicators "$dir" 5; then - roots+=("$dir") + (project_cache_has_indicators "$dir" 5 && echo "$dir" >> "$_indicator_tmp") < /dev/null & + _indicator_pids+=($!) + + if [[ ${#_indicator_pids[@]} -ge $_max_jobs ]]; then + wait "${_indicator_pids[0]}" 2> /dev/null || true + _indicator_pids=("${_indicator_pids[@]:1}") fi done + # bash 3.2 under nounset treats "${arr[@]}" on an empty array as unbound, and + # the loop above leaves the array empty whenever $HOME has no scannable + # project dir (every test home, and any real home whose top level is all + # Library/Applications/dot dirs). + if [[ ${#_indicator_pids[@]} -gt 0 ]]; then + for _pid in "${_indicator_pids[@]}"; do + wait "$_pid" 2> /dev/null || true + done + fi + + local _found_dir + while IFS= read -r _found_dir; do + [[ -n "$_found_dir" ]] && roots+=("$_found_dir") + done < "$_indicator_tmp" + rm -f "$_indicator_tmp" [[ ${#roots[@]} -eq 0 ]] && return 0 - printf '%s\n' "${roots[@]}" | LC_ALL=C sort -u + for root in "${roots[@]}"; do + local identity + identity=$(mole_path_identity "$root") + if [[ ${#seen_identities[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${seen_identities[@]}"; then + continue + fi + + seen_identities+=("$identity") + unique_roots+=("$root") + done + + [[ ${#unique_roots[@]} -gt 0 ]] && printf '%s\n' "${unique_roots[@]}" +} + +pycache_has_bytecode() { + local pycache_dir="$1" + [[ -d "$pycache_dir" ]] || return 1 + + local nullglob_was_set=0 + if shopt -q nullglob; then + nullglob_was_set=1 + fi + shopt -s nullglob + local -a bytecode_files=("$pycache_dir"/*.pyc "$pycache_dir"/*.pyo) + if [[ $nullglob_was_set -eq 0 ]]; then + shopt -u nullglob + fi + + [[ ${#bytecode_files[@]} -gt 0 ]] } # Scan a project root for supported build caches while pruning heavy subtrees. @@ -159,7 +251,7 @@ scan_project_cache_root() { local -a find_args=( find -P "$root" -maxdepth 9 -mount - "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" ")" + "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" -o -name "miniconda3" -o -name "anaconda3" -o -name "miniforge3" -o -name "mambaforge" -o -name "site-packages" ")" -prune -o -type d "(" -name ".next" -o -name "__pycache__" -o -name ".dart_tool" ")" @@ -167,7 +259,24 @@ scan_project_cache_root() { ) local status=0 - run_with_timeout "$scan_timeout" "${find_args[@]}" >> "$output_file" 2> /dev/null || status=$? + local tmp_file + tmp_file=$(create_temp_file) + run_with_timeout "$scan_timeout" "${find_args[@]}" > "$tmp_file" 2> /dev/null || status=$? + + if [[ -s "$tmp_file" ]]; then + while IFS= read -r match_path; do + [[ -z "$match_path" ]] && continue + # Skip __pycache__ dirs with no .pyc/.pyo files (empty or already cleaned) + if [[ "${match_path##*/}" == "__pycache__" ]]; then + pycache_has_bytecode "$match_path" || continue + fi + local project_root="" + project_root=$(project_cache_group_root "$root" "$match_path") + [[ -z "$project_root" ]] && project_root="$root" + printf '%s\t%s\n' "$project_root" "$match_path" >> "$output_file" + done < "$tmp_file" + fi + rm -f "$tmp_file" if [[ $status -eq 124 ]]; then debug_log "Project cache scan timed out: $root" @@ -178,13 +287,220 @@ scan_project_cache_root() { return 0 } +project_cache_group_root() { + local scan_root="$1" + local cache_path="$2" + local candidate + + candidate=$(dirname "$cache_path") + while [[ -n "$candidate" && "$candidate" != "/" ]]; do + if mole_purge_is_project_root "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + [[ "$candidate" == "$scan_root" ]] && break + candidate=$(dirname "$candidate") + done + + printf '%s\n' "$scan_root" +} + +clean_project_cache_target() { + if [[ $# -lt 2 ]]; then + return 0 + fi + + local description="${*: -1}" + local -a target_paths=("${@:1:$#-1}") + + if declare -f safe_clean > /dev/null 2>&1; then + local clean_rc=0 + safe_clean "${target_paths[@]}" "$description" || clean_rc=$? + if [[ $clean_rc -eq 124 || $clean_rc -ge 128 ]]; then + return "$clean_rc" + fi + return 0 + fi + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + return 0 + fi + + local target_path="" + for target_path in "${target_paths[@]}"; do + [[ -e "$target_path" ]] || continue + local remove_rc=0 + safe_remove "$target_path" true || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + return "$remove_rc" + fi + done +} + +flush_python_group_if_needed() { + local group_root="$1" + local array_name="$2" + + local group_count=0 + # eval: indirect array length by name; bash 3.2 has no nameref + eval 'group_count=${#'"$array_name"'[@]}' + [[ -z "$group_root" || "$group_count" -eq 0 ]] && return 0 + # eval: indirect array copy by name; bash 3.2 has no nameref + eval 'local -a group_dirs=( "${'"$array_name"'[@]}" )' + # shellcheck disable=SC2154 # group_dirs assigned via eval above + clean_python_bytecode_cache_group "$group_root" "${group_dirs[@]}" +} + +process_project_cache_matches() { + local matches_file="$1" + [[ -f "$matches_file" ]] || return 0 + + local current_python_root="" + local -a current_python_dirs=() + local record_root="" + local cache_dir="" + while IFS=$'\t' read -r record_root cache_dir; do + [[ -n "$record_root" && -n "$cache_dir" ]] || continue + case "${cache_dir##*/}" in + ".next") + flush_python_group_if_needed "$current_python_root" current_python_dirs || return $? + current_python_root="" + current_python_dirs=() + if [[ -d "$cache_dir/cache" ]]; then + clean_project_cache_target "$cache_dir/cache"/* "Next.js build cache" || return $? + fi + ;; + "__pycache__") + if [[ "$record_root" != "$current_python_root" && ${#current_python_dirs[@]} -gt 0 ]]; then + flush_python_group_if_needed "$current_python_root" current_python_dirs || return $? + current_python_dirs=() + fi + current_python_root="$record_root" + [[ -d "$cache_dir" ]] && current_python_dirs+=("$cache_dir") + ;; + ".dart_tool") + flush_python_group_if_needed "$current_python_root" current_python_dirs || return $? + current_python_root="" + current_python_dirs=() + if [[ -d "$cache_dir" ]]; then + clean_project_cache_target "$cache_dir" "Flutter build cache (.dart_tool)" || return $? + local build_dir="$(dirname "$cache_dir")/build" + if [[ -d "$build_dir" ]]; then + clean_project_cache_target "$build_dir" "Flutter build cache (build/)" || return $? + fi + fi + ;; + esac + done < <(LC_ALL=C sort -u "$matches_file" 2> /dev/null) + + flush_python_group_if_needed "$current_python_root" current_python_dirs +} + +clean_python_bytecode_cache_group() { + local project_root="$1" + shift + + local -a cache_dirs=("$@") + [[ ${#cache_dirs[@]} -eq 0 ]] && return 0 + + local display_root + display_root=$(basename "$project_root") + local total_size_kb=0 + local removed_count=0 + local skipped_count=0 + local -a dry_run_paths=() + local -a dry_run_sizes=() + + local cache_dir + for cache_dir in "${cache_dirs[@]}"; do + [[ -d "$cache_dir" ]] || continue + + if should_protect_path "$cache_dir"; then + skipped_count=$((skipped_count + 1)) + whitelist_skipped_count=$((${whitelist_skipped_count:-0} + 1)) + log_operation "clean" "SKIPPED" "$cache_dir" "protected" + continue + fi + + if is_path_whitelisted "$cache_dir"; then + skipped_count=$((skipped_count + 1)) + whitelist_skipped_count=$((${whitelist_skipped_count:-0} + 1)) + log_operation "clean" "SKIPPED" "$cache_dir" "whitelist" + continue + fi + + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$cache_dir") || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$cache_dir" "$size_kb" 1 true || continue + elif declare -f register_dry_run_cleanup_target > /dev/null 2>&1; then + register_dry_run_cleanup_target "$cache_dir" || continue + fi + dry_run_paths+=("$cache_dir") + dry_run_sizes+=("$size_kb") + else + if ! safe_remove "$cache_dir" true; then + continue + fi + fi + + total_size_kb=$((total_size_kb + size_kb)) + removed_count=$((removed_count + 1)) + done + + if [[ $removed_count -eq 0 ]]; then + return 0 + fi + + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + + if [[ "$DRY_RUN" == "true" ]]; then + if ! declare -f record_dry_run_cleanup_target > /dev/null 2>&1 && [[ -n "${EXPORT_LIST_FILE:-}" ]]; then + ensure_user_file "$EXPORT_LIST_FILE" + local i=0 + for ((i = 0; i < ${#dry_run_paths[@]}; i++)); do + local path="${dry_run_paths[i]}" + local path_size_kb="${dry_run_sizes[i]:-0}" + local path_size_human + path_size_human=$(bytes_to_human "$((path_size_kb * 1024))") + echo "${path} # ${path_size_human}" >> "$EXPORT_LIST_FILE" + done + fi + + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC} · ${YELLOW}${removed_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}dry, ${skipped_count} skipped${NC}" + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC} · ${YELLOW}${removed_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + fi + else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${line_color}${ICON_SUCCESS}${NC} Python bytecode cache · ${display_root}${NC} · ${line_color}${removed_count} dirs, ${size_human}${NC}, ${skipped_count} skipped" + else + echo -e " ${line_color}${ICON_SUCCESS}${NC} Python bytecode cache · ${display_root}${NC} · ${line_color}${removed_count} dirs, ${size_human}${NC}" + fi + fi + + files_cleaned=$((${files_cleaned:-0} + removed_count)) + total_size_cleaned=$((${total_size_cleaned:-0} + total_size_kb)) + total_items=$((${total_items:-0} + 1)) + if declare -f note_activity > /dev/null 2>&1; then + note_activity + fi +} + # Next.js/Python/Flutter project caches scoped to discovered project roots. clean_project_caches() { stop_inline_spinner 2> /dev/null || true - local matches_tmp_file - matches_tmp_file=$(create_temp_file) - local -a scan_roots=() local root while IFS= read -r root; do @@ -199,30 +515,26 @@ clean_project_caches() { fi for root in "${scan_roots[@]}"; do - scan_project_cache_root "$root" "$matches_tmp_file" + local root_matches_file + root_matches_file=$(create_temp_file) + scan_project_cache_root "$root" "$root_matches_file" + + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + local process_rc=0 + process_project_cache_matches "$root_matches_file" || process_rc=$? + rm -f "$root_matches_file" + [[ $process_rc -eq 0 ]] || return "$process_rc" + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " + start_inline_spinner "Searching project caches..." + fi done if [[ -t 1 ]]; then stop_inline_spinner fi - - while IFS= read -r cache_dir; do - case "$(basename "$cache_dir")" in - ".next") - [[ -d "$cache_dir/cache" ]] && safe_clean "$cache_dir/cache"/* "Next.js build cache" || true - ;; - "__pycache__") - [[ -d "$cache_dir" ]] && safe_clean "$cache_dir"/* "Python bytecode cache" || true - ;; - ".dart_tool") - if [[ -d "$cache_dir" ]]; then - safe_clean "$cache_dir" "Flutter build cache (.dart_tool)" || true - local build_dir="$(dirname "$cache_dir")/build" - if [[ -d "$build_dir" ]]; then - safe_clean "$build_dir" "Flutter build cache (build/)" || true - fi - fi - ;; - esac - done < <(LC_ALL=C sort -u "$matches_tmp_file" 2> /dev/null) } diff --git a/Resources/mole/lib/clean/dev.sh b/Resources/mole/lib/clean/dev.sh index fb8eefd..3e077b1 100644 --- a/Resources/mole/lib/clean/dev.sh +++ b/Resources/mole/lib/clean/dev.sh @@ -2,10 +2,27 @@ # Developer Tools Cleanup Module set -euo pipefail -# Tool cache helper (respects DRY_RUN). +# Tool cache helper (respects DRY_RUN and whitelist). +# Args: +# $1 = description (display name) +# $2 = cache path to check against whitelist (empty string to skip check) +# $3+ = command to run clean_tool_cache() { local description="$1" - shift + local cache_path="$2" + shift 2 + + if [[ -n "$cache_path" ]] && is_path_whitelisted "$cache_path"; then + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would skip (whitelist)" + note_activity + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description · skipped (whitelist)" + note_activity + fi + return 0 + fi + if [[ "$DRY_RUN" != "true" ]]; then local command_succeeded=false if [[ -t 1 ]]; then @@ -19,28 +36,254 @@ clean_tool_cache() { fi if [[ "$command_succeeded" == "true" ]]; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" + note_activity fi else echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would clean" + note_activity + fi + return 0 +} + +clean_corepack_cache() { + local corepack_home="${COREPACK_HOME:-$HOME/.cache/node/corepack}" + [[ -n "$corepack_home" && "$corepack_home" == /* ]] || return 0 + case "$corepack_home" in + / | "$HOME" | "$HOME/" | "$HOME/Library" | "$HOME/Library/") + debug_log "Skipping unsafe Corepack cache path: $corepack_home" + return 0 + ;; + esac + # COREPACK_ENABLE_DOWNLOAD_PROMPT=0 mirrors the pnpm path above: without it + # corepack can stop on an interactive "download? [Y/n]" prompt. Because the + # call is wrapped with stdout/stderr to /dev/null, that prompt is invisible + # and the command looks frozen until the timeout fires (seen on a Node setup + # where corepack is installed; machines without corepack take the else + # branch and never hit this). + if command -v corepack > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" corepack --version > /dev/null 2>&1; then + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "Corepack cache" "$corepack_home" run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" corepack cache clean + else + safe_clean "$corepack_home"/* "Corepack cache" + fi +} + +clean_uv_cache() { + local uv_cache_path="$HOME/.cache/uv" + if command -v uv > /dev/null 2>&1 && run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" uv --version > /dev/null 2>&1; then + local detected_cache + detected_cache=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" uv cache dir 2> /dev/null || true) + if [[ -n "$detected_cache" && "$detected_cache" == /* ]]; then + uv_cache_path="$detected_cache" + fi + clean_tool_cache "uv cache" "$uv_cache_path" run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" uv cache prune + else + safe_clean "$uv_cache_path"/* "uv cache" + fi +} + +conda_cache_whitelisted() { + local root + for root in "$@"; do + [[ -n "$root" ]] || continue + if is_path_whitelisted "$root" 2> /dev/null || is_path_whitelisted "$root/.mole-cache-guard" 2> /dev/null; then + return 0 + fi + done + return 1 +} + +clean_conda_metadata_caches() { + local -a conda_pkg_roots=( + "$HOME/.conda/pkgs" + "$HOME/anaconda3/pkgs" + "$HOME/miniconda3/pkgs" + "$HOME/miniforge3/pkgs" + "$HOME/mambaforge/pkgs" + ) + if conda_cache_whitelisted "${conda_pkg_roots[@]}"; then + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} conda index/tarball/log caches · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} conda index/tarball/log caches · skipped (whitelist)" + note_activity + fi + return 0 + fi + + local conda_cache_hint="$HOME/.conda/pkgs" + if command -v conda > /dev/null 2>&1 && run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" conda --version > /dev/null 2>&1; then + clean_tool_cache "conda index/tarball/log caches" "$conda_cache_hint" \ + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" conda clean --yes --index-cache --tarballs --logfiles + note_activity + return 0 + fi + + local root + for root in "${conda_pkg_roots[@]}"; do + [[ -d "$root" ]] || continue + debug_log "Conda package cache present but conda is unavailable, leaving for manual review: $root" + done +} + +gradle_daemon_running() { + mole_pgrep_any \ + -f "org.gradle.launcher.daemon" \ + -f "GradleDaemon" +} + +# True when a pnpm process is running, or when process state cannot be +# determined (fail closed: skip prune rather than race a live install). +# shellcheck disable=SC2329 +pnpm_process_blocks_prune() { + if ! command -v pgrep > /dev/null 2>&1; then + return 0 fi + # `-x pnpm` only sees the standalone binary. Corepack and npm-installed + # pnpm run as `node .../pnpm.cjs`, so the guard it was written to be + # (fail closed while an install is live) never fired for them. Match the + # invoked program instead, delimited so `pnpm-lock.yaml` in some other + # process's argv cannot block the prune forever. + # + # Capture pgrep's own status: after a failed `if pgrep; then`, $? is the + # if-statement status (0), not pgrep's 1, which would false-positive block. + local pgrep_rc=0 + pgrep -f '(^|/)pnpm(\.cjs)?([[:space:]]|$)' > /dev/null 2>&1 || pgrep_rc=$? + # 0 = running (block), 1 = no match (allow), other = unknown (block). + [[ $pgrep_rc -ne 1 ]] +} + +# Absolute store path without ".." / control characters only. +# shellcheck disable=SC2329 +is_safe_pnpm_store_path() { + local path="${1:-}" + [[ -n "$path" && "$path" == /* ]] || return 1 + case "$path" in + *'/../'* | */.. | .. | *$'\n'* | *$'\r'*) + return 1 + ;; + esac return 0 } + +# Emit candidate pnpm binaries already installed locally. Never downloads. +# Order: PATH pnpm first, then mise versioned installs. Callers dedupe by +# resolved store path (issue #1370). +# shellcheck disable=SC2329 +list_installed_pnpm_binaries() { + local bin="" + if command -v pnpm > /dev/null 2>&1; then + # type -P resolves only real files; shell-function stubs (tests) fall + # back to the bare "pnpm" name still reachable on PATH. + bin=$(type -P pnpm 2> /dev/null || true) + if [[ -n "$bin" && -x "$bin" ]]; then + printf '%s\n' "$bin" + else + printf '%s\n' "pnpm" + fi + fi + + local mise_root="$HOME/.local/share/mise/installs/pnpm" + if [[ -d "$mise_root" ]]; then + local version_dir + for version_dir in "$mise_root"/*; do + [[ -d "$version_dir" ]] || continue + if [[ -x "$version_dir/pnpm" ]]; then + printf '%s\n' "$version_dir/pnpm" + fi + done + fi +} + +# Prune every distinct pnpm store generation reachable through an already +# installed matching pnpm binary. Owner command only (plain `store prune`, +# no --force, no raw directory delete). Dedupes by store path so two +# binaries that resolve to the same generation run once (issue #1370). +# shellcheck disable=SC2329 +clean_pnpm_stores() { + local pnpm_default_store="$HOME/Library/pnpm/store" + + if pnpm_process_blocks_prune; then + debug_log "pnpm process running or process state unknown, skipping store prune" + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} pnpm cache · would skip (pnpm busy)" + note_activity + fi + return 0 + fi + + local -a pnpm_bins=() + local bin_line + while IFS= read -r bin_line; do + [[ -n "$bin_line" ]] || continue + pnpm_bins+=("$bin_line") + done < <(list_installed_pnpm_binaries) + + if [[ ${#pnpm_bins[@]} -eq 0 ]]; then + debug_log "pnpm is unavailable, leaving global pnpm store for manual review: $pnpm_default_store" + return 0 + fi + + local -a seen_stores=() + local pruned_any=false + local pnpm_bin store_path store_seen store_entry + for pnpm_bin in "${pnpm_bins[@]}"; do + # Usable binary only; never prompt Corepack to download another major. + if ! COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "$pnpm_bin" --version > /dev/null 2>&1; then + debug_log "Skipping unusable pnpm binary: $pnpm_bin" + continue + fi + + start_section_spinner "Checking pnpm store path..." + store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "$pnpm_bin" store path 2> /dev/null) || store_path="" + stop_section_spinner + + if ! is_safe_pnpm_store_path "$store_path"; then + debug_log "Rejecting unsafe or empty pnpm store path from $pnpm_bin: ${store_path:-}" + continue + fi + store_path="${store_path%/}" + + store_seen=false + for store_entry in "${seen_stores[@]+"${seen_stores[@]}"}"; do + if [[ "$store_entry" == "$store_path" ]]; then + store_seen=true + break + fi + done + if [[ "$store_seen" == "true" ]]; then + debug_log "pnpm store already scheduled: $store_path" + continue + fi + seen_stores+=("$store_path") + + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" "$store_path" \ + run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" \ + env COREPACK_ENABLE_DOWNLOAD_PROMPT=0 "$pnpm_bin" store prune + pruned_any=true + done + + if [[ "$pruned_any" != "true" ]]; then + debug_log "No pruneable pnpm store resolved from installed binaries" + fi +} + # npm/pnpm/yarn/bun caches. clean_dev_npm() { local npm_default_cache="$HOME/.npm" local npm_cache_path="$npm_default_cache" if command -v npm > /dev/null 2>&1; then - clean_tool_cache "npm cache" npm cache clean --force - start_section_spinner "Checking npm cache path..." - npm_cache_path=$(run_with_timeout 2 npm config get cache 2> /dev/null) || npm_cache_path="" + npm_cache_path=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" npm config get cache 2> /dev/null) || npm_cache_path="" stop_section_spinner if [[ -z "$npm_cache_path" || "$npm_cache_path" != /* ]]; then npm_cache_path="$npm_default_cache" fi + clean_tool_cache "npm cache" "$npm_cache_path" npm cache clean --force note_activity fi @@ -71,38 +314,94 @@ clean_dev_npm() { done fi - # Clean pnpm store cache - local pnpm_default_store=~/Library/pnpm/store - # Check if pnpm is actually usable (not just Corepack shim) - if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune - local pnpm_store_path - start_section_spinner "Checking store path..." - pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path="" - stop_section_spinner - if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then - safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" + clean_pnpm_stores + clean_corepack_cache + local bun_default_cache="$HOME/.bun/install/cache" + local bun_cache_path="$bun_default_cache" + local bun_cache_cleaned=false + local bun_dry_run="${DRY_RUN:-false}" + if command -v bun > /dev/null 2>&1 && bun --version > /dev/null 2>&1; then + if [[ -t 1 ]]; then start_section_spinner "Checking bun cache path..."; fi + bun_cache_path=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" bun pm cache 2> /dev/null) || bun_cache_path="" + if [[ -t 1 ]]; then stop_section_spinner; fi + + if [[ -z "$bun_cache_path" || "$bun_cache_path" != /* ]]; then + bun_cache_path="$bun_default_cache" + fi + + local bun_protected=false + is_path_whitelisted "$bun_cache_path" && bun_protected=true + + if [[ "$bun_protected" == "true" ]]; then + if [[ "$bun_dry_run" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} bun cache · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} bun cache · skipped (whitelist)" + note_activity + fi + bun_cache_cleaned=true + elif [[ "$bun_dry_run" != "true" ]]; then + if [[ -t 1 ]]; then + start_section_spinner "Cleaning bun cache..." + fi + if run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" bun pm cache rm > /dev/null 2>&1; then + bun_cache_cleaned=true + fi + if [[ -t 1 ]]; then + stop_section_spinner + fi + if [[ "$bun_cache_cleaned" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} bun cache" + note_activity + fi + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} bun cache · would clean" + note_activity + bun_cache_cleaned=true + fi + + local bun_cache_path_normalized="${bun_cache_path%/}" + local bun_default_cache_normalized="${bun_default_cache%/}" + if [[ -d "$bun_cache_path_normalized" ]]; then + bun_cache_path_normalized=$(cd "$bun_cache_path_normalized" 2> /dev/null && pwd -P) || bun_cache_path_normalized="${bun_cache_path%/}" + fi + if [[ -d "$bun_default_cache_normalized" ]]; then + bun_default_cache_normalized=$(cd "$bun_default_cache_normalized" 2> /dev/null && pwd -P) || bun_default_cache_normalized="${bun_default_cache%/}" + fi + + if [[ "$bun_cache_path_normalized" != "$bun_default_cache_normalized" ]]; then + safe_clean "$bun_default_cache"/* "Orphaned bun cache" + fi + + # If bun pm cache rm fails, fall back to filesystem cleanup to avoid no-op. + if [[ "$bun_cache_cleaned" != "true" ]]; then + safe_clean "$bun_cache_path"/* "Bun cache" fi else - # pnpm not installed or not usable, just clean the default store directory - safe_clean "$pnpm_default_store"/* "pnpm store" + safe_clean "$bun_default_cache"/* "Bun cache" fi + note_activity safe_clean ~/.tnpm/_cacache/* "tnpm cache directory" safe_clean ~/.tnpm/_logs/* "tnpm logs" safe_clean ~/.yarn/cache/* "Yarn cache" - safe_clean ~/.bun/install/cache/* "Bun cache" + safe_clean ~/Library/Caches/Yarn/* "Yarn v1 cache" } # Python/pip ecosystem caches. clean_dev_python() { # Check pip3 is functional (not just macOS stub that triggers CLT install dialog) if command -v pip3 > /dev/null 2>&1 && pip3 --version > /dev/null 2>&1; then - clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' + local pip_cache_path + pip_cache_path=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" pip3 cache dir 2> /dev/null) || pip_cache_path="" + if [[ -z "$pip_cache_path" || "$pip_cache_path" != /* ]]; then + pip_cache_path="$HOME/Library/Caches/pip" + fi + clean_tool_cache "pip cache" "$pip_cache_path" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi safe_clean ~/.pyenv/cache/* "pyenv cache" safe_clean ~/.cache/poetry/* "Poetry cache" - safe_clean ~/.cache/uv/* "uv cache" + clean_uv_cache safe_clean ~/.cache/ruff/* "Ruff cache" safe_clean ~/.cache/mypy/* "MyPy cache" safe_clean ~/.pytest_cache/* "Pytest cache" @@ -110,8 +409,7 @@ clean_dev_python() { safe_clean ~/.cache/huggingface/* "Hugging Face cache" safe_clean ~/.cache/torch/* "PyTorch cache" safe_clean ~/.cache/tensorflow/* "TensorFlow cache" - safe_clean ~/.conda/pkgs/* "Conda packages cache" - safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache" + clean_conda_metadata_caches safe_clean ~/.cache/wandb/* "Weights & Biases cache" } # Go build/module caches. @@ -131,26 +429,107 @@ clean_dev_go() { echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Go cache · would skip (whitelist)" else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go cache · skipped (whitelist)" + note_activity fi return 0 fi if [[ "$build_protected" != "true" && "$mod_protected" != "true" ]]; then - clean_tool_cache "Go cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true' + clean_tool_cache "Go cache" "" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true' elif [[ "$build_protected" == "true" ]]; then - clean_tool_cache "Go module cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true' + clean_tool_cache "Go module cache" "" bash -c 'go clean -modcache > /dev/null 2>&1 || true' echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go build cache · skipped (whitelist)" + note_activity else - clean_tool_cache "Go build cache" bash -c 'go clean -cache > /dev/null 2>&1 || true' + clean_tool_cache "Go build cache" "" bash -c 'go clean -cache > /dev/null 2>&1 || true' echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go module cache · skipped (whitelist)" fi note_activity } -# Rust/cargo caches. + +get_mise_cache_path() { + if [[ -n "${MISE_CACHE_DIR:-}" && "${MISE_CACHE_DIR}" == /* ]]; then + echo "$MISE_CACHE_DIR" + return 0 + fi + + if command -v mise > /dev/null 2>&1; then + local mise_cache_path + mise_cache_path=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" mise cache path 2> /dev/null || echo "") + if [[ -n "$mise_cache_path" && "$mise_cache_path" == /* ]]; then + echo "$mise_cache_path" + return 0 + fi + fi + + echo "$HOME/Library/Caches/mise" +} + +clean_dev_mise() { + local mise_cache_path + mise_cache_path=$(get_mise_cache_path) + + if command -v mise > /dev/null 2>&1; then + if [[ "${DRY_RUN:-false}" != "true" ]]; then + clean_tool_cache "mise cache" "$mise_cache_path" bash -c 'mise cache clear > /dev/null 2>&1 || true' + note_activity + elif is_path_whitelisted "$mise_cache_path"; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} mise cache · would skip (whitelist)" + note_activity + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} mise cache · would clean" + note_activity + fi + fi + + safe_clean "$mise_cache_path"/* "mise cache" +} +# Resolve a tool home from an optional env value plus default. +# Only absolute paths without ".." / control characters are accepted from +# env; anything else falls back to the default so a poisoned CARGO_HOME +# cannot redirect cleanup (issue #1378, mise-relocated cargo/rustup). +# shellcheck disable=SC2329 +resolve_tool_home() { + local env_value="${1:-}" + local default_home="$2" + + if [[ -n "$env_value" && "$env_value" == /* ]]; then + case "$env_value" in + *'/../'* | */.. | .. | *$'\n'* | *$'\r'*) + ;; + *) + printf '%s\n' "${env_value%/}" + return 0 + ;; + esac + fi + printf '%s\n' "${default_home%/}" +} + +# Rust/cargo caches. Honor CARGO_HOME / RUSTUP_HOME when they point at a +# validated absolute path (mise and other version managers relocate these). +# Scope stays regenerable cache only: registry/cache, git, downloads — never +# bin, toolchains, registry/index, or registry/src. clean_dev_rust() { - safe_clean ~/.cargo/registry/cache/* "Rust cargo cache" - safe_clean ~/.cargo/git/* "Cargo git cache" - safe_clean ~/.rustup/downloads/* "Rust downloads cache" + local cargo_home rustup_home + cargo_home=$(resolve_tool_home "${CARGO_HOME:-}" "${HOME}/.cargo") + rustup_home=$(resolve_tool_home "${RUSTUP_HOME:-}" "${HOME}/.rustup") + + safe_clean "${cargo_home}/registry/cache"/* "Rust cargo cache" + safe_clean "${cargo_home}/git"/* "Cargo git cache" + safe_clean "${rustup_home}/downloads"/* "Rustup downloads cache" +} +# Ruby/gem ecosystem caches (not installed versions). +clean_dev_ruby() { + safe_clean ~/.rbenv/cache/* "rbenv download cache" + safe_clean ~/.gem/specs/* "gem spec cache" + safe_clean ~/.gem/ruby/*/cache/*.gem "gem package cache" + safe_clean ~/.bundle/cache/* "Ruby Bundler cache" +} +# Perl ecosystem caches (not installed modules). +clean_dev_perl() { + safe_clean ~/.cpan/build/* "CPAN build artifacts" + safe_clean ~/.cpan/sources/* "CPAN source cache" } # Helper: Check for multiple versions in a directory. @@ -172,9 +551,9 @@ check_multiple_versions() { note_activity local hint="" if [[ -n "$list_cmd" ]]; then - hint=" · ${GRAY}${list_cmd}${NC}" + hint=" ${GRAY}(${list_cmd})${NC}" fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${tool_name}: ${count} found${hint}" + echo -e " ${YELLOW}${ICON_REVIEW}${NC} ${tool_name} · ${count} found${hint}" fi } @@ -182,30 +561,46 @@ check_multiple_versions() { check_rust_toolchains() { command -v rustup > /dev/null 2>&1 || return 0 + local rustup_home + rustup_home=$(resolve_tool_home "${RUSTUP_HOME:-}" "${HOME}/.rustup") + check_multiple_versions \ - "$HOME/.rustup/toolchains" \ + "${rustup_home}/toolchains" \ "Rust toolchains" \ "rustup toolchain list" } # Docker caches (guarded by daemon check). +find_orbstack_data_dir() { + local candidate + for candidate in "$HOME"/Library/Group\ Containers/*dev.orbstack/data; do + [[ -d "$candidate" ]] || continue + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + clean_dev_docker() { if command -v docker > /dev/null 2>&1; then - if [[ "$DRY_RUN" != "true" ]]; then - start_section_spinner "Checking Docker daemon..." - local docker_running=false - if run_with_timeout 3 docker info > /dev/null 2>&1; then - docker_running=true - fi - stop_section_spinner - if [[ "$docker_running" == "true" ]]; then - clean_tool_cache "Docker build cache" docker builder prune -af - else - debug_log "Docker daemon not running, skipping Docker cache cleanup" - fi - else - note_activity - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean" + note_activity + echo -e " ${GRAY}${ICON_REVIEW}${NC} Docker unused data · review with docker system df" + debug_log "Docker daemon-managed cleanup skipped by default" + fi + + local orb_data="" + orb_data=$(find_orbstack_data_dir 2> /dev/null || true) + if command -v orb > /dev/null 2>&1 || command -v orbctl > /dev/null 2>&1 || [[ -d "$HOME/.orbstack" || -n "$orb_data" ]]; then + local orb_size=0 + if [[ -n "$orb_data" ]]; then + local size_rc=0 + orb_size=$(get_path_size_kb "$orb_data" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$orb_size" =~ ^[0-9]+$ ]] || orb_size=0 fi + note_activity + echo -e " ${GRAY}${ICON_REVIEW}${NC} OrbStack container data · $(bytes_to_human $((orb_size * 1024))) · review with docker system df" + debug_log "OrbStack daemon-managed data left for manual prune ($orb_size KB)" fi safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" } @@ -213,7 +608,10 @@ clean_dev_docker() { clean_dev_nix() { if command -v nix-collect-garbage > /dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then - clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d + clean_tool_cache "Nix garbage collection" "/nix/store" nix-collect-garbage --delete-older-than 30d + elif is_path_whitelisted "/nix/store"; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would skip (whitelist)" + note_activity else echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would clean" fi @@ -254,14 +652,9 @@ clean_xcode_documentation_cache() { local doc_cache_root="${MOLE_XCODE_DOCUMENTATION_CACHE_DIR:-/Library/Developer/Xcode/DocumentationCache}" [[ -d "$doc_cache_root" ]] || return 0 - if pgrep -x "Xcode" > /dev/null 2>&1; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping documentation cache cleanup" - note_activity - return 0 - fi - local -a index_entries=() while IFS= read -r -d '' entry; do + [[ ! -L "$entry" ]] || continue index_entries+=("$entry") done < <(command find "$doc_cache_root" -mindepth 1 -maxdepth 1 \( -name "DeveloperDocumentation.index" -o -name "DeveloperDocumentation*.index" \) -print0 2> /dev/null) @@ -269,21 +662,32 @@ clean_xcode_documentation_cache() { return 0 fi - local -a sorted_entries=() - while IFS= read -r line; do - sorted_entries+=("${line#* }") - done < <( - for entry in "${index_entries[@]}"; do - local mtime - mtime=$(stat -f%m "$entry" 2> /dev/null || echo "0") - printf '%s %s\n' "$mtime" "$entry" - done | sort -rn - ) + local -a index_mtimes=() + local entry mtime + for entry in "${index_entries[@]}"; do + mtime=$(stat -f%m "$entry" 2> /dev/null || echo "0") + [[ "$mtime" =~ ^[0-9]+$ ]] || mtime=0 + index_mtimes+=("$mtime") + done + + # Keep pathnames byte-exact; index names can legally contain newlines. + local i j key_mtime key_entry + for ((i = 1; i < ${#index_entries[@]}; i++)); do + key_entry="${index_entries[$i]}" + key_mtime="${index_mtimes[$i]}" + j=$((i - 1)) + while [[ $j -ge 0 && ${index_mtimes[$j]} -lt $key_mtime ]]; do + index_entries[j + 1]="${index_entries[$j]}" + index_mtimes[j + 1]="${index_mtimes[$j]}" + j=$((j - 1)) + done + index_entries[j + 1]="$key_entry" + index_mtimes[j + 1]="$key_mtime" + done local -a stale_entries=() local idx=0 - local entry - for entry in "${sorted_entries[@]}"; do + for entry in "${index_entries[@]}"; do if [[ $idx -eq 0 ]]; then idx=$((idx + 1)) continue @@ -296,663 +700,3553 @@ clean_xcode_documentation_cache() { return 0 fi + # Form the same eligible set as the deletion loop before consulting + # process state. Protected-only work must stay silent instead of promising + # an Xcode defer that can never be cleaned. + local -a eligible_stale_entries=() + local pre_skipped_count=0 + for entry in "${stale_entries[@]}"; do + [[ ! -L "$entry" ]] || continue + if should_protect_path "$entry" || is_path_whitelisted "$entry" || holds_compiled_model_cache "$entry"; then + pre_skipped_count=$((pre_skipped_count + 1)) + continue + fi + eligible_stale_entries+=("$entry") + done + [[ ${#eligible_stale_entries[@]} -gt 0 ]] || return 0 + stale_entries=("${eligible_stale_entries[@]}") + + local process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $pre_skipped_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped ${pre_skipped_count} protected items" + note_activity + fi + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + if [[ "${DRY_RUN:-false}" == "true" ]]; then - safe_clean "${stale_entries[@]}" "Xcode documentation cache (old indexes)" - note_activity + _xcode_safe_clean_guarded \ + _xcode_delete_guard_allows \ + "Xcode documentation cache" \ + "${stale_entries[@]}" \ + "Xcode documentation cache (old indexes)" || return 0 return 0 fi if ! has_sudo_session; then if ! ensure_sudo_session "Cleaning Xcode documentation cache requires admin access"; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache cleanup skipped (sudo denied)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped (sudo denied)" note_activity return 0 fi fi local removed_count=0 - local skipped_count=0 + local removed_size_kb=0 + local skipped_count=$pre_skipped_count + local failed_count=0 + local stop_reason="" local stale_entry for stale_entry in "${stale_entries[@]}"; do - if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry"; then + if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry" || holds_compiled_model_cache "$stale_entry"; then skipped_count=$((skipped_count + 1)) continue fi - if safe_sudo_remove "$stale_entry"; then + local stale_size_kb=0 + stale_size_kb=$(_sim_runtime_size_kb "$stale_entry") + + process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="Xcode or build tools started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if safe_sudo_remove "$stale_entry" "$stale_size_kb"; then removed_count=$((removed_count + 1)) + removed_size_kb=$((removed_size_kb + stale_size_kb)) + else + failed_count=$((failed_count + 1)) fi done if [[ $removed_count -gt 0 ]]; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode documentation cache · removed ${removed_count} old indexes" + files_cleaned=$((${files_cleaned:-0} + removed_count)) + total_size_cleaned=$((${total_size_cleaned:-0} + removed_size_kb)) + total_items=$((${total_items:-0} + 1)) + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped ${skipped_count} protected items" + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · could not remove ${failed_count} old indexes" + fi + note_activity + elif [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · could not remove ${failed_count} old indexes" if [[ $skipped_count -gt 0 ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped ${skipped_count} protected items" fi note_activity elif [[ $skipped_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode documentation cache · nothing to clean" echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped ${skipped_count} protected items" note_activity - else + elif [[ -z "$stop_reason" ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · no items removed" note_activity fi + if [[ -n "$stop_reason" ]]; then + if [[ "$stop_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · stopped (${stop_reason})" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + fi } -# Clean old Xcode DeviceSupport versions, keeping the most recent ones. -# Each version holds debug symbols (1-3 GB) for a specific iOS/watchOS/tvOS version. -# Symbols regenerate automatically when a device running that version is connected. -# Args: $1=directory path, $2=display name (e.g. "iOS DeviceSupport") -clean_xcode_device_support() { - local ds_dir="$1" - local display_name="$2" - local keep_count="${MOLE_XCODE_DEVICE_SUPPORT_KEEP:-2}" - [[ "$keep_count" =~ ^[0-9]+$ ]] || keep_count=2 - - [[ -d "$ds_dir" ]] || return 0 - - # Collect version directories (each is a platform version like "17.5 (21F79)") - local -a version_dirs=() - while IFS= read -r -d '' entry; do - # Skip non-directories (e.g. .log files at the top level) - [[ -d "$entry" ]] || continue - version_dirs+=("$entry") - done < <(command find "$ds_dir" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) - - if [[ ${#version_dirs[@]} -gt 0 ]]; then - # Sort by modification time (most recent first) - local -a sorted_dirs=() - while IFS= read -r line; do - sorted_dirs+=("${line#* }") - done < <( - for entry in "${version_dirs[@]}"; do - printf '%s %s\n' "$(stat -f%m "$entry" 2> /dev/null || echo 0)" "$entry" - done | sort -rn - ) +_MOLE_XCODE_PROCESS_MATCH="" - # Get stale versions (everything after keep_count) - local -a stale_dirs=("${sorted_dirs[@]:$keep_count}") +# Return 0 when a foreground owner is active, 1 when none is present, and 2 +# when the process state cannot be established. Short-lived build and simulator +# tools stay guarded because they can mutate these caches before a device is +# fully booted. CoreSimulatorService and simdiskimaged are +# launchd-managed services that can remain alive after Simulator exits, so +# their existence alone is not evidence that a simulator is active (#1319). +_coresimulator_cache_process_running() { + local match_mode match_pattern match_label probe_status + _MOLE_XCODE_PROCESS_MATCH="" - if [[ ${#stale_dirs[@]} -gt 0 ]]; then - # Calculate total size of stale versions - local stale_size_kb=0 entry_size_kb - for stale_entry in "${stale_dirs[@]}"; do - entry_size_kb=$(get_path_size_kb "$stale_entry" 2> /dev/null || echo 0) - stale_size_kb=$((stale_size_kb + entry_size_kb)) - done - local stale_size_human - stale_size_human=$(bytes_to_human "$((stale_size_kb * 1024))") + while IFS='|' read -r match_mode match_pattern match_label; do + [[ -n "$match_pattern" ]] || continue + if pgrep "$match_mode" "$match_pattern" > /dev/null 2>&1; then + _MOLE_XCODE_PROCESS_MATCH="$match_label" + debug_log "CoreSimulator process detected: $match_label" + return 0 + else + probe_status=$? + fi + if [[ $probe_status -ne 1 ]]; then + debug_log "CoreSimulator process check failed: $match_label (exit=$probe_status)" + return 2 + fi + done << 'EOF' +-x|Xcode|Xcode +-x|Simulator|Simulator +-x|xcodebuild|xcodebuild +-x|xctest|xctest +-x|XCTRunner|XCTRunner +-x|simctl|simctl +EOF - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${display_name} · would remove ${#stale_dirs[@]} old versions (${stale_size_human}), keeping ${keep_count} most recent" - note_activity - else - # Remove old versions - local removed_count=0 - for stale_entry in "${stale_dirs[@]}"; do - if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry"; then - continue - fi - if safe_remove "$stale_entry"; then - removed_count=$((removed_count + 1)) - fi - done + return 1 +} - if [[ $removed_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${stale_size_human}" - note_activity - fi - fi +# Return 0 when simctl reports a booted device, 1 for a valid empty result, and +# 2 when the active state cannot be established. Unknown must fail closed +# before system CoreSimulator or XCTest data is removed. +_coresimulator_booted_device_state() { + if [[ "$_MOLE_SIMCTL_RESOLUTION_STATUS" != "ready" ]]; then + if ! command -v xcrun > /dev/null 2>&1 || ! _resolve_simctl_developer_dir; then + debug_log "Unable to resolve simctl for booted-device check" + return 2 fi fi - # Clean caches/logs inside kept versions - safe_clean "$ds_dir"/*/Symbols/System/Library/Caches/* "$display_name symbol cache" - safe_clean "$ds_dir"/*.log "$display_name logs" -} - -_sim_runtime_mount_points() { - if [[ -n "${MOLE_XCODE_SIM_RUNTIME_MOUNT_POINTS:-}" ]]; then - printf '%s\n' "$MOLE_XCODE_SIM_RUNTIME_MOUNT_POINTS" + local booted_output="" + local probe_status=0 + booted_output=$(_run_simctl "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" list devices booted -j 2> /dev/null) || probe_status=$? + if [[ $probe_status -ne 0 ]]; then + debug_log "Booted simulator probe failed (exit=$probe_status)" + return 2 + fi + if [[ "$booted_output" != *'"devices"'* ]]; then + debug_log "Booted simulator probe returned an unexpected response" + return 2 + fi + if [[ "$booted_output" == *'"udid"'* ]]; then + _MOLE_XCODE_PROCESS_MATCH="booted simulator" + debug_log "CoreSimulator active state detected: booted simulator" return 0 fi - mount 2> /dev/null | command awk '{print $3}' || true + + return 1 } -_sim_runtime_is_path_in_use() { - local target_path="$1" - shift || true - local mount_path - for mount_path in "$@"; do - [[ -z "$mount_path" ]] && continue - if [[ "$mount_path" == "$target_path" || "$mount_path" == "$target_path"/* ]]; then +# Bracket the booted-device probe with the same process check on both sides. +# That structured probe can take several seconds, which is long enough for +# simctl or xcodebuild to start; without the second check the guard would +# authorize a deletion on evidence gathered before the build began. +# Returns the probe tri-state: 0 running, 1 not running, 2 unknown. +_probe_simulator_activity() { + local process_probe="$1" + local state=0 + "$process_probe" || state=$? + [[ $state -eq 1 ]] || return "$state" + + state=0 + _coresimulator_booted_device_state || state=$? + [[ $state -eq 1 ]] || return "$state" + + state=0 + "$process_probe" || state=$? + return "$state" +} + +_coresimulator_activity_state() { + _probe_simulator_activity _coresimulator_cache_process_running +} + +_xcode_xctest_devices_process_running() { + local match_mode match_pattern match_label probe_status=0 + + xcode_build_tooling_process_state || probe_status=$? + if [[ $probe_status -eq 0 ]]; then + _MOLE_XCODE_PROCESS_MATCH="Xcode/build tooling" + return 0 + fi + [[ $probe_status -eq 1 ]] || return 2 + + probe_status=0 + if _coresimulator_cache_process_running; then + return 0 + else + probe_status=$? + fi + [[ $probe_status -eq 1 ]] || return 2 + + while IFS='|' read -r match_mode match_pattern match_label; do + [[ -n "$match_pattern" ]] || continue + if pgrep "$match_mode" "$match_pattern" > /dev/null 2>&1; then + _MOLE_XCODE_PROCESS_MATCH="$match_label" + debug_log "XCTest process detected: $match_label" return 0 + else + probe_status=$? fi - done + if [[ $probe_status -ne 1 ]]; then + debug_log "XCTest process check failed: $match_label (exit=$probe_status)" + return 2 + fi + done << 'EOF' +-f|com.apple.dt.XCTest|com.apple.dt.XCTest +-f|XCTest|XCTest +EOF + return 1 } -_sim_runtime_size_kb() { - local target_path="$1" - local size_kb=0 - if has_sudo_session; then - size_kb=$(sudo du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") - else - size_kb=$(du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") - fi +_xcode_delete_guard_allows() { + mole_clean_process_guard _xcode_xctest_devices_process_running "Xcode or build tools started" +} - [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 - echo "$size_kb" +_coresimulator_delete_guard_allows() { + mole_clean_process_guard _coresimulator_activity_state "CoreSimulator started" } -clean_xcode_simulator_runtime_volumes() { - local volumes_root="${MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT:-/Library/Developer/CoreSimulator/Volumes}" - local cryptex_root="${MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT:-/Library/Developer/CoreSimulator/Cryptex}" +_xctest_devices_activity_state() { + _probe_simulator_activity _xcode_xctest_devices_process_running +} - local -a candidates=() - local candidate - for candidate in "$volumes_root" "$cryptex_root"; do - [[ -d "$candidate" ]] || continue - while IFS= read -r -d '' entry; do - candidates+=("$entry") - done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) - done +_xctest_devices_delete_guard_allows() { + mole_clean_process_guard _xctest_devices_activity_state "Xcode, XCTest, or Simulator started" +} - if [[ ${#candidates[@]} -eq 0 ]]; then - return 0 +_dev_process_delete_guard_allows() { + mole_clean_process_guard "$_MOLE_DEV_PROCESS_GUARD_PROBE" "$_MOLE_DEV_PROCESS_GUARD_FAMILY started" +} + +_dev_report_process_guard_stop() { + local display_name="$1" + local family="$2" + local reason="$3" + + if [[ "$reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${reason})" + note_activity + else + mole_defer_cleanup_family "$family" fi +} - local -a mount_points=() - while IFS= read -r line; do - [[ -n "$line" ]] && mount_points+=("$line") - done < <(_sim_runtime_mount_points) +# Bind a tri-state process probe to safe_clean's post-size boundary. The local +# guard state is intentionally dynamic so Bash 3.2 callbacks can read it. +_dev_safe_clean_process_guarded() { + local probe="$1" + local family="$2" + local display_name="$3" + shift 3 + local _MOLE_DEV_PROCESS_GUARD_PROBE="$probe" + local _MOLE_DEV_PROCESS_GUARD_FAMILY="$family" + local _MOLE_CLEAN_GUARD_REASON="${family} started" - local -a entry_statuses=() - local -a sorted_candidates=() - local sorted - while IFS= read -r sorted; do - [[ -n "$sorted" ]] && sorted_candidates+=("$sorted") - done < <(printf '%s\n' "${candidates[@]}" | LC_ALL=C sort) + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! _dev_process_delete_guard_allows; then + _dev_report_process_guard_stop "$display_name" "$family" "$_MOLE_CLEAN_GUARD_REASON" + return 1 + fi + safe_clean "$@" + return $? + fi - # Only show scanning message in debug mode; spinner provides visual feedback otherwise - if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo -e " ${GRAY}${ICON_LIST}${NC} Xcode runtime volumes · scanning ${#sorted_candidates[@]} entries" + local guarded_rc=0 + safe_clean_guarded _dev_process_delete_guard_allows "$@" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + _dev_report_process_guard_stop "$display_name" "$family" "$_MOLE_CLEAN_GUARD_REASON" + return 1 fi - local runtime_scan_spinner=false - if [[ -t 1 ]]; then - start_section_spinner "Scanning Xcode runtime volumes..." - runtime_scan_spinner=true + return "$guarded_rc" +} + +_dev_clean_service_worker_process_guarded() { + local probe="$1" + local family="$2" + local display_name="$3" + local browser_name="$4" + local cache_path="$5" + local _MOLE_DEV_PROCESS_GUARD_PROBE="$probe" + local _MOLE_DEV_PROCESS_GUARD_FAMILY="$family" + local _MOLE_CLEAN_GUARD_REASON="${family} started" + local guarded_rc=0 + + clean_service_worker_cache \ + "$browser_name" \ + "$cache_path" \ + _dev_process_delete_guard_allows || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + _dev_report_process_guard_stop "$display_name" "$family" "$_MOLE_CLEAN_GUARD_REASON" + return 1 fi + return "$guarded_rc" +} - local in_use_count=0 - local unused_count=0 - for candidate in "${sorted_candidates[@]}"; do - local status="UNUSED" - if [[ ${#mount_points[@]} -gt 0 ]] && _sim_runtime_is_path_in_use "$candidate" "${mount_points[@]}"; then - status="IN_USE" - in_use_count=$((in_use_count + 1)) - else - unused_count=$((unused_count + 1)) - fi - entry_statuses+=("$status") - done +# Production safe_clean_guarded binds the process probe to its internal +# post-size deletion boundary. Unit-level callers that source this module +# without bin/clean.sh keep the historical safe_clean surface. +_xcode_safe_clean_guarded() { + local delete_guard="$1" + local display_name="$2" + shift 2 + local _MOLE_CLEAN_GUARD_REASON="process state changed" - if [[ "$DRY_RUN" == "true" ]]; then - local -a size_values=() - local in_use_kb=0 - local unused_kb=0 - local i=0 - for candidate in "${sorted_candidates[@]}"; do - local size_kb - size_kb=$(_sim_runtime_size_kb "$candidate") - size_values+=("$size_kb") - local status="${entry_statuses[$i]:-UNUSED}" - if [[ "$status" == "IN_USE" ]]; then - in_use_kb=$((in_use_kb + size_kb)) + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! "$delete_guard"; then + if [[ "$_MOLE_CLEAN_GUARD_REASON" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + elif [[ "$delete_guard" == "_coresimulator_delete_guard_allows" ]]; then + mole_defer_cleanup_family "Simulator" else - unused_kb=$((unused_kb + size_kb)) + mole_defer_cleanup_family "Xcode" fi - i=$((i + 1)) - done - if [[ "$runtime_scan_spinner" == "true" ]]; then - stop_section_spinner - runtime_scan_spinner=false + return 1 fi + safe_clean "$@" + return $? + fi - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode runtime volumes · ${unused_count} unused, ${in_use_count} in use" - local dryrun_total_kb=$((unused_kb + in_use_kb)) - local dryrun_total_human - dryrun_total_human=$(bytes_to_human "$((dryrun_total_kb * 1024))") - local dryrun_unused_human - dryrun_unused_human=$(bytes_to_human "$((unused_kb * 1024))") - local dryrun_in_use_human - dryrun_in_use_human=$(bytes_to_human "$((in_use_kb * 1024))") - echo -e " ${GRAY}${ICON_LIST}${NC} Runtime volumes total: ${dryrun_total_human} (unused ${dryrun_unused_human}, in-use ${dryrun_in_use_human})" - - local dryrun_max_items="${MOLE_SIM_RUNTIME_DRYRUN_MAX_ITEMS:-20}" - [[ "$dryrun_max_items" =~ ^[0-9]+$ ]] || dryrun_max_items=20 - if [[ "$dryrun_max_items" -le 0 ]]; then - dryrun_max_items=20 + local guarded_rc=0 + safe_clean_guarded "$delete_guard" "$@" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + if [[ "$_MOLE_CLEAN_GUARD_REASON" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + elif [[ "$delete_guard" == "_coresimulator_delete_guard_allows" ]]; then + mole_defer_cleanup_family "Simulator" + else + mole_defer_cleanup_family "Xcode" fi + return 1 + fi + return "$guarded_rc" +} - local shown=0 - local line_size_kb line_status line_path - while IFS=$'\t' read -r line_size_kb line_status line_path; do - [[ -z "${line_path:-}" ]] && continue - local line_human - line_human=$(bytes_to_human "$((line_size_kb * 1024))") - echo -e " ${GRAY}${line_status}${NC} ${line_human} · ${line_path}" - shown=$((shown + 1)) - if [[ "$shown" -ge "$dryrun_max_items" ]]; then - break - fi - done < <( - local j=0 - while [[ $j -lt ${#sorted_candidates[@]} ]]; do - printf '%s\t%s\t%s\n' "${size_values[$j]:-0}" "${entry_statuses[$j]:-UNUSED}" "${sorted_candidates[$j]}" - j=$((j + 1)) - done | LC_ALL=C sort -nr -k1,1 - ) +clean_xcode_xctest_devices() { + local xctest_devices_dir="${MOLE_XCODE_XCTEST_DEVICES_DIR:-$HOME/Library/Developer/XCTestDevices}" + [[ -d "$xctest_devices_dir" ]] || return 0 - local total_entries="${#sorted_candidates[@]}" - if [[ "$total_entries" -gt "$shown" ]]; then - local remaining=$((total_entries - shown)) - echo -e " ${GRAY}${ICON_LIST}${NC} ... and ${remaining} more runtime volume entries" + # Preserve safe_clean's skip accounting while checking eligibility before + # the process gate. This avoids a false defer for policy-excluded roots. + local policy_reason="" + if should_protect_path "$xctest_devices_dir"; then + policy_reason="protected" + elif is_path_whitelisted "$xctest_devices_dir"; then + policy_reason="whitelist" + elif declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$xctest_devices_dir"; then + policy_reason="compiled model cache" + fi + if [[ -n "$policy_reason" ]]; then + if declare -p whitelist_skipped_count > /dev/null 2>&1; then + whitelist_skipped_count=$((whitelist_skipped_count + 1)) + fi + if declare -f log_operation > /dev/null 2>&1; then + log_operation "clean" "SKIPPED" "$xctest_devices_dir" "$policy_reason" fi - note_activity return 0 fi - # Auto-clean all UNUSED runtime volumes (no user selection) - local -a selected_paths=() - local skipped_protected=0 - local i=0 - for ((i = 0; i < ${#sorted_candidates[@]}; i++)); do - local status="${entry_statuses[$i]:-UNUSED}" - [[ "$status" == "IN_USE" ]] && continue + local process_state=0 + _xctest_devices_activity_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode XCTestDevices · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi - local candidate_path="${sorted_candidates[$i]}" - if should_protect_path "$candidate_path" || is_path_whitelisted "$candidate_path"; then - skipped_protected=$((skipped_protected + 1)) + _xcode_safe_clean_guarded \ + _xctest_devices_delete_guard_allows \ + "Xcode XCTestDevices" \ + "$xctest_devices_dir" \ + "Xcode XCTestDevices test data" || true +} + +clean_xcode_system_coresimulator_caches() { + local cache_root="${MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR:-/Library/Developer/CoreSimulator/Caches}" + [[ -d "$cache_root" ]] || return 0 + + local -a cache_entries=() + local entry + while IFS= read -r -d '' entry; do + cache_entries+=("$entry") + done < <(command find "$cache_root" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) + + [[ ${#cache_entries[@]} -gt 0 ]] || return 0 + + # Match the privileged removal loop before the process gate. Otherwise a + # protected-only cache root is incorrectly advertised as deferred work. + local -a eligible_cache_entries=() + local pre_skipped_count=0 + for entry in "${cache_entries[@]}"; do + [[ ! -L "$entry" ]] || continue + if should_protect_path "$entry" || is_path_whitelisted "$entry" || holds_compiled_model_cache "$entry"; then + pre_skipped_count=$((pre_skipped_count + 1)) continue fi - selected_paths+=("$candidate_path") + eligible_cache_entries+=("$entry") done + [[ ${#eligible_cache_entries[@]} -gt 0 ]] || return 0 + cache_entries=("${eligible_cache_entries[@]}") - if [[ "$runtime_scan_spinner" == "true" ]]; then - stop_section_spinner - runtime_scan_spinner=false + local process_state=0 + _coresimulator_activity_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Simulator" + fi + return 0 fi - if [[ ${#selected_paths[@]} -eq 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · already clean" - note_activity + if [[ "${DRY_RUN:-false}" == "true" ]]; then + local total_size_kb=0 + local cleanable_count=0 + local stop_reason="" + for entry in "${cache_entries[@]}"; do + process_state=0 + _coresimulator_activity_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="CoreSimulator started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if should_protect_path "$entry" || is_path_whitelisted "$entry" || holds_compiled_model_cache "$entry"; then + continue + fi + local entry_size_kb="" + local size_rc=0 + entry_size_kb=$(get_path_size_kb "$entry" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + + process_state=0 + _coresimulator_activity_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="CoreSimulator started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$entry" "$entry_size_kb" 1 true || continue + fi + total_size_kb=$((total_size_kb + entry_size_kb)) + cleanable_count=$((cleanable_count + 1)) + done + if [[ "$cleanable_count" -gt 0 ]]; then + local total_size_human + total_size_human=$(bytes_to_human "$((total_size_kb * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode Simulator system cache · would remove ${cleanable_count} entries (${total_size_human})" + note_activity + fi + if [[ -n "$stop_reason" ]]; then + if [[ "$stop_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode Simulator system cache · stopped (${stop_reason})" + note_activity + else + mole_defer_cleanup_family "Simulator" + fi + fi return 0 fi if ! has_sudo_session; then - if ! ensure_sudo_session "Cleaning Xcode runtime volumes requires admin access"; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode runtime volumes · skipped (sudo denied)" + if ! ensure_sudo_session "Cleaning Xcode Simulator system cache requires admin access"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped (sudo denied)" note_activity return 0 fi fi - # Perform cleanup and report final result in one line local removed_count=0 local removed_size_kb=0 - local selected_path - for selected_path in "${selected_paths[@]}"; do - local selected_size_kb=0 - selected_size_kb=$(_sim_runtime_size_kb "$selected_path") - if safe_sudo_remove "$selected_path"; then + local skipped_count=$pre_skipped_count + local failed_count=0 + local stop_reason="" + for entry in "${cache_entries[@]}"; do + process_state=0 + _coresimulator_activity_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="CoreSimulator started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if should_protect_path "$entry" || is_path_whitelisted "$entry" || holds_compiled_model_cache "$entry"; then + skipped_count=$((skipped_count + 1)) + continue + fi + local entry_size_kb="" + local size_rc=0 + entry_size_kb=$(get_path_size_kb "$entry" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + + # A bounded size probe can still overlap Simulator startup. Recheck + # immediately before the privileged deletion sink. + process_state=0 + _coresimulator_activity_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="CoreSimulator started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if safe_sudo_remove "$entry" "$entry_size_kb"; then removed_count=$((removed_count + 1)) - removed_size_kb=$((removed_size_kb + selected_size_kb)) + removed_size_kb=$((removed_size_kb + entry_size_kb)) + else + failed_count=$((failed_count + 1)) + fi + done + + if [[ $removed_count -gt 0 ]]; then + local removed_human + removed_human=$(bytes_to_human "$((removed_size_kb * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$removed_size_kb") + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode Simulator system cache · removed ${removed_count} (${line_color}${removed_human}${NC}), skipped ${skipped_count} protected" + else + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode Simulator system cache · removed ${removed_count} (${line_color}${removed_human}${NC})" + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode Simulator system cache · could not remove ${failed_count} entries" + fi + files_cleaned=$((${files_cleaned:-0} + removed_count)) + total_size_cleaned=$((${total_size_cleaned:-0} + removed_size_kb)) + total_items=$((${total_items:-0} + 1)) + note_activity + elif [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode Simulator system cache · could not remove ${failed_count} entries" + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped ${skipped_count} protected" + fi + note_activity + elif [[ $skipped_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped ${skipped_count} protected, none removed" + note_activity + fi + if [[ -n "$stop_reason" ]]; then + if [[ "$stop_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode Simulator system cache · stopped (${stop_reason})" + note_activity + else + mole_defer_cleanup_family "Simulator" fi + fi +} + +# Clean old Xcode DeviceSupport versions, keeping the most recent ones. +# Each version holds debug symbols (1-3 GB) for a specific iOS/watchOS/tvOS version. +# Symbols regenerate automatically when a device running that version is connected. +# Args: $1=directory path, $2=display name (e.g. "iOS DeviceSupport") +clean_xcode_device_support() { + local ds_dir="$1" + local display_name="$2" + local keep_count="${MOLE_XCODE_DEVICE_SUPPORT_KEEP:-2}" + local -a preview_stale_dirs=() + [[ "$keep_count" =~ ^[0-9]+$ ]] || keep_count=2 + + [[ -d "$ds_dir" ]] || return 0 + + local candidate + local -a preflight_dirs=() + while IFS= read -r -d '' candidate; do + preflight_dirs+=("$candidate") + done < <(command find "$ds_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null || true) + local has_inner_cleanup_target=false + mole_cleanup_targets_exist \ + "$ds_dir"/*/Symbols/System/Library/Caches/* \ + "$ds_dir"/*.log && has_inner_cleanup_target=true + + local has_stale_cleanup_target=false + if [[ ${#preflight_dirs[@]} -gt $keep_count ]]; then + local -a preflight_mtimes=() + local preflight_mtime="" + for candidate in "${preflight_dirs[@]}"; do + if ! preflight_mtime=$(stat -f%m "$candidate" 2> /dev/null) || [[ ! "$preflight_mtime" =~ ^[0-9]+$ ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · skipped (metadata unavailable)" + note_activity + return 0 + fi + preflight_mtimes+=("$preflight_mtime") + done + + local -a preflight_pending_dirs=("${preflight_dirs[@]}") + local -a preflight_pending_mtimes=("${preflight_mtimes[@]}") + local preflight_rank=0 preflight_index preflight_best_index + while [[ ${#preflight_pending_dirs[@]} -gt 0 ]]; do + preflight_best_index=0 + for ((preflight_index = 1; preflight_index < ${#preflight_pending_dirs[@]}; preflight_index++)); do + if [[ ${preflight_pending_mtimes[$preflight_index]} -gt ${preflight_pending_mtimes[$preflight_best_index]} ]]; then + preflight_best_index=$preflight_index + fi + done + if [[ $preflight_rank -ge $keep_count ]] && + ! should_protect_path "${preflight_pending_dirs[$preflight_best_index]}" && + ! is_path_whitelisted "${preflight_pending_dirs[$preflight_best_index]}" && + ! holds_compiled_model_cache "${preflight_pending_dirs[$preflight_best_index]}"; then + has_stale_cleanup_target=true + break + fi + preflight_rank=$((preflight_rank + 1)) + unset "preflight_pending_dirs[$preflight_best_index]" "preflight_pending_mtimes[$preflight_best_index]" + if [[ ${#preflight_pending_dirs[@]} -gt 0 ]]; then + preflight_pending_dirs=("${preflight_pending_dirs[@]}") + preflight_pending_mtimes=("${preflight_pending_mtimes[@]}") + fi + done + fi + + if [[ "$has_stale_cleanup_target" != "true" && "$has_inner_cleanup_target" != "true" ]]; then + return 0 + fi + + # DeviceSupport contains symbols used by active builds, tests, and device + # debugging. Keep every version when any Xcode tooling is active. + local process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + + # Collect version directories (each is a platform version like "17.5 (21F79)") + local -a version_dirs=() + local scan_file="" + scan_file=$(mktemp "${TMPDIR:-/tmp}/mole-device-support.XXXXXX") || { + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · skipped (scan unavailable)" + note_activity + return 0 + } + if ! command find "$ds_dir" -mindepth 1 -maxdepth 1 -print0 > "$scan_file" 2> /dev/null; then + rm -f "$scan_file" # SAFE: exact temporary file created by mktemp above + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · skipped (scan unavailable)" + note_activity + return 0 + fi + while IFS= read -r -d '' entry; do + # Skip non-directories (e.g. .log files at the top level) + [[ -d "$entry" ]] || continue + version_dirs+=("$entry") + done < "$scan_file" + rm -f "$scan_file" # SAFE: exact temporary file created by mktemp above + + if [[ ${#version_dirs[@]} -gt 0 ]]; then + local -a version_mtimes=() + local mtime="" + for entry in "${version_dirs[@]}"; do + if ! mtime=$(stat -f%m "$entry" 2> /dev/null) || [[ ! "$mtime" =~ ^[0-9]+$ ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · skipped (metadata unavailable)" + note_activity + return 0 + fi + version_mtimes+=("$mtime") + done + + # Sort by modification time (most recent first) without serializing + # pathnames through newline-delimited text. DeviceSupport is writable by + # the invoking user, so even unusual names must stay byte-exact. + local -a sorted_dirs=() + local -a pending_dirs=("${version_dirs[@]}") + local -a pending_mtimes=("${version_mtimes[@]}") + local index best_index + while [[ ${#pending_dirs[@]} -gt 0 ]]; do + best_index=0 + for ((index = 1; index < ${#pending_dirs[@]}; index++)); do + if [[ ${pending_mtimes[$index]} -gt ${pending_mtimes[$best_index]} ]]; then + best_index=$index + fi + done + sorted_dirs+=("${pending_dirs[$best_index]}") + unset "pending_dirs[$best_index]" "pending_mtimes[$best_index]" + if [[ ${#pending_dirs[@]} -gt 0 ]]; then + pending_dirs=("${pending_dirs[@]}") + pending_mtimes=("${pending_mtimes[@]}") + fi + done + + # Get stale versions (everything after keep_count) + local -a stale_dirs=("${sorted_dirs[@]:$keep_count}") + + if [[ ${#stale_dirs[@]} -gt 0 ]]; then + local stale_size_kb=0 entry_size_kb + local stale_size_human="" + + if [[ "$DRY_RUN" == "true" ]]; then + local dry_run_count=0 + local dry_stop_reason="" + stale_size_kb=0 + for stale_entry in "${stale_dirs[@]}"; do + process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + dry_stop_reason="Xcode or build tools started" + [[ $process_state -eq 2 ]] && dry_stop_reason="process state unknown" + break + fi + if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry" || holds_compiled_model_cache "$stale_entry"; then + continue + fi + local size_rc=0 + entry_size_kb=$(get_path_size_kb "$stale_entry" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + + process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + dry_stop_reason="Xcode or build tools started" + [[ $process_state -eq 2 ]] && dry_stop_reason="process state unknown" + break + fi + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$stale_entry" "$entry_size_kb" 1 true || continue + fi + preview_stale_dirs+=("$stale_entry") + stale_size_kb=$((stale_size_kb + entry_size_kb)) + dry_run_count=$((dry_run_count + 1)) + done + if [[ "$dry_run_count" -gt 0 ]]; then + stale_size_human=$(bytes_to_human "$((stale_size_kb * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${display_name} · would remove ${dry_run_count} old versions (${stale_size_human}), keeping ${keep_count} most recent" + note_activity + fi + if [[ -n "$dry_stop_reason" ]]; then + if [[ "$dry_stop_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${dry_stop_reason})" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + else + # Remove old versions + local removed_count=0 + local removed_size_kb=0 + local stop_reason="" + for stale_entry in "${stale_dirs[@]}"; do + process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="Xcode or build tools started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry" || holds_compiled_model_cache "$stale_entry"; then + continue + fi + local size_rc=0 + entry_size_kb=$(get_path_size_kb "$stale_entry" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + + # The size probe may consume the full disk-verification + # budget. Bind authorization to the deletion boundary by + # checking active Xcode tooling again after it completes. + process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + stop_reason="Xcode or build tools started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + break + fi + if safe_remove "$stale_entry" "true" "$entry_size_kb"; then + removed_count=$((removed_count + 1)) + removed_size_kb=$((removed_size_kb + entry_size_kb)) + fi + done + + if [[ $removed_count -gt 0 ]]; then + stale_size_human=$(bytes_to_human "$((removed_size_kb * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$removed_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${line_color}${stale_size_human}${NC}" + files_cleaned=$((${files_cleaned:-0} + removed_count)) + total_size_cleaned=$((${total_size_cleaned:-0} + removed_size_kb)) + total_items=$((${total_items:-0} + 1)) + note_activity + fi + if [[ -n "$stop_reason" ]]; then + if [[ "$stop_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${stop_reason})" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + fi + fi + fi + + # Clean caches/logs inside kept versions. In dry-run, stale versions still + # exist on disk but their whole directories are already in the preview + # ledger, so exclude their children to avoid double-counting. + local -a inner_cache_targets=() + local -a log_targets=() + local stale_dir skip_inner + for candidate in "$ds_dir"/*/Symbols/System/Library/Caches/*; do + skip_inner=false + if [[ "${DRY_RUN:-false}" == "true" && ${#preview_stale_dirs[@]} -gt 0 ]]; then + for stale_dir in "${preview_stale_dirs[@]}"; do + case "$candidate" in + "$stale_dir"/*) + skip_inner=true + break + ;; + esac + done + fi + [[ "$skip_inner" == "true" ]] && continue + mole_cleanup_targets_exist "$candidate" && inner_cache_targets+=("$candidate") + done + for candidate in "$ds_dir"/*.log; do + mole_cleanup_targets_exist "$candidate" && log_targets+=("$candidate") + done + if [[ ${#inner_cache_targets[@]} -eq 0 && ${#log_targets[@]} -eq 0 ]]; then + return 0 + fi + + if [[ "${DRY_RUN:-false}" != "true" ]]; then + process_state=0 + _xcode_xctest_devices_process_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + local stop_reason="Xcode or build tools started" + [[ $process_state -eq 2 ]] && stop_reason="process state unknown" + if [[ "$stop_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${stop_reason})" + note_activity + else + mole_defer_cleanup_family "Xcode" + fi + return 0 + fi + fi + if [[ ${#inner_cache_targets[@]} -gt 0 ]]; then + _xcode_safe_clean_guarded \ + _xcode_delete_guard_allows \ + "$display_name symbol cache" \ + "${inner_cache_targets[@]}" \ + "$display_name symbol cache" || return 0 + fi + if [[ ${#log_targets[@]} -gt 0 ]]; then + _xcode_safe_clean_guarded \ + _xcode_delete_guard_allows \ + "$display_name logs" \ + "${log_targets[@]}" \ + "$display_name logs" || return 0 + fi +} + +_sim_runtime_mount_points() { + if [[ -n "${MOLE_XCODE_SIM_RUNTIME_MOUNT_POINTS:-}" ]]; then + printf '%s\n' "$MOLE_XCODE_SIM_RUNTIME_MOUNT_POINTS" + return 0 + fi + mount 2> /dev/null | command awk '{print $3}' || true +} + +_sim_runtime_is_path_in_use() { + local target_path="$1" + shift || true + local mount_path + for mount_path in "$@"; do + [[ -z "$mount_path" ]] && continue + if [[ "$mount_path" == "$target_path" || "$mount_path" == "$target_path"/* ]]; then + return 0 + fi + done + return 1 +} + +_sim_runtime_size_kb() { + local target_path="$1" + local size_kb=0 + if has_sudo_session; then + size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" sudo -n du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") + else + size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") + fi + + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + echo "$size_kb" +} + +clean_xcode_simulator_runtime_volumes() { + local volumes_root="${MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT:-/Library/Developer/CoreSimulator/Volumes}" + local cryptex_root="${MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT:-/Library/Developer/CoreSimulator/Cryptex}" + + local -a candidates=() + local candidate + for candidate in "$volumes_root" "$cryptex_root"; do + [[ -d "$candidate" ]] || continue + while IFS= read -r -d '' entry; do + candidates+=("$entry") + done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + done + + if [[ ${#candidates[@]} -eq 0 ]]; then + return 0 + fi + + local -a mount_points=() + while IFS= read -r line; do + [[ -n "$line" ]] && mount_points+=("$line") + done < <(_sim_runtime_mount_points) + + # A real macOS system always mounts at least "/", so an empty list means + # `mount` failed. Without this guard every candidate falls to the UNUSED + # branch below and gets sudo-deleted, possibly while still mounted. Treat + # "cannot enumerate mounts" as "cannot prove unused" and skip cleanup. + if [[ ${#mount_points[@]} -eq 0 ]]; then + return 0 + fi + + local -a entry_statuses=() + local -a sorted_candidates=() + local sorted + while IFS= read -r sorted; do + [[ -n "$sorted" ]] && sorted_candidates+=("$sorted") + done < <(printf '%s\n' "${candidates[@]}" | LC_ALL=C sort) + + # Only show scanning message in debug mode; spinner provides visual feedback otherwise + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo -e " ${GRAY}${ICON_LIST}${NC} Xcode runtime volumes · scanning ${#sorted_candidates[@]} entries" + fi + local runtime_scan_spinner=false + if [[ -t 1 ]]; then + start_section_spinner "Scanning Xcode runtime volumes..." + runtime_scan_spinner=true + fi + + local in_use_count=0 + for candidate in "${sorted_candidates[@]}"; do + local status="UNUSED" + if [[ ${#mount_points[@]} -gt 0 ]] && _sim_runtime_is_path_in_use "$candidate" "${mount_points[@]}"; then + status="IN_USE" + in_use_count=$((in_use_count + 1)) + fi + entry_statuses+=("$status") + done + + if [[ "$DRY_RUN" == "true" ]]; then + local -a size_values=() + local in_use_kb=0 + local unused_kb=0 + local cleanable_unused_count=0 + local preview_in_use_count=0 + local dry_stop_reason="" + local i=0 + for candidate in "${sorted_candidates[@]}"; do + local size_kb + size_kb=$(_sim_runtime_size_kb "$candidate") + local status="${entry_statuses[$i]:-UNUSED}" + if [[ "$status" == "IN_USE" ]]; then + size_values+=("$size_kb") + in_use_kb=$((in_use_kb + size_kb)) + preview_in_use_count=$((preview_in_use_count + 1)) + else + local -a current_mount_points=() + while IFS= read -r line; do + [[ -n "$line" ]] && current_mount_points+=("$line") + done < <(_sim_runtime_mount_points) + if [[ ${#current_mount_points[@]} -eq 0 ]]; then + dry_stop_reason="mount state unknown" + break + fi + if _sim_runtime_is_path_in_use "$candidate" "${current_mount_points[@]}"; then + dry_stop_reason="runtime became mounted" + break + fi + if ! should_protect_path "$candidate" && ! is_path_whitelisted "$candidate"; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$candidate" "$size_kb" 1 true || { + size_values+=("$size_kb") + i=$((i + 1)) + continue + } + fi + unused_kb=$((unused_kb + size_kb)) + cleanable_unused_count=$((cleanable_unused_count + 1)) + fi + size_values+=("$size_kb") + fi + i=$((i + 1)) + done + if [[ "$runtime_scan_spinner" == "true" ]]; then + stop_section_spinner + runtime_scan_spinner=false + fi + + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode runtime volumes · ${cleanable_unused_count} unused, ${preview_in_use_count} in use" + local dryrun_total_kb=$((unused_kb + in_use_kb)) + local dryrun_total_human + dryrun_total_human=$(bytes_to_human "$((dryrun_total_kb * 1024))") + local dryrun_unused_human + dryrun_unused_human=$(bytes_to_human "$((unused_kb * 1024))") + local dryrun_in_use_human + dryrun_in_use_human=$(bytes_to_human "$((in_use_kb * 1024))") + echo -e " ${GRAY}${ICON_LIST}${NC} Runtime volumes total: ${dryrun_total_human} (unused ${dryrun_unused_human}, in-use ${dryrun_in_use_human})" + + local dryrun_max_items="${MOLE_SIM_RUNTIME_DRYRUN_MAX_ITEMS:-20}" + [[ "$dryrun_max_items" =~ ^[0-9]+$ ]] || dryrun_max_items=20 + if [[ "$dryrun_max_items" -le 0 ]]; then + dryrun_max_items=20 + fi + + local shown=0 + local line_size_kb line_status line_path + while IFS=$'\t' read -r line_size_kb line_status line_path; do + [[ -z "${line_path:-}" ]] && continue + local line_human + line_human=$(bytes_to_human "$((line_size_kb * 1024))") + echo -e " ${GRAY}${line_status}${NC} ${line_human} · ${line_path}" + shown=$((shown + 1)) + if [[ "$shown" -ge "$dryrun_max_items" ]]; then + break + fi + done < <( + local j=0 + while [[ $j -lt ${#sorted_candidates[@]} ]]; do + printf '%s\t%s\t%s\n' "${size_values[$j]:-0}" "${entry_statuses[$j]:-UNUSED}" "${sorted_candidates[$j]}" + j=$((j + 1)) + done | LC_ALL=C sort -nr -k1,1 + ) + + local total_entries="${#size_values[@]}" + if [[ "$total_entries" -gt "$shown" ]]; then + local remaining=$((total_entries - shown)) + echo -e " ${GRAY}${ICON_LIST}${NC} ... and ${remaining} more runtime volume entries" + fi + note_activity + if [[ -n "$dry_stop_reason" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode runtime volumes · stopped (${dry_stop_reason})" + note_activity + fi + return 0 + fi + + # Auto-clean all UNUSED runtime volumes (no user selection) + local -a selected_paths=() + local skipped_protected=0 + local i=0 + for ((i = 0; i < ${#sorted_candidates[@]}; i++)); do + local status="${entry_statuses[$i]:-UNUSED}" + [[ "$status" == "IN_USE" ]] && continue + + local candidate_path="${sorted_candidates[$i]}" + if should_protect_path "$candidate_path" || is_path_whitelisted "$candidate_path"; then + skipped_protected=$((skipped_protected + 1)) + continue + fi + selected_paths+=("$candidate_path") + done + + if [[ "$runtime_scan_spinner" == "true" ]]; then + stop_section_spinner + runtime_scan_spinner=false + fi + + if [[ ${#selected_paths[@]} -eq 0 ]]; then + debug_log "Xcode runtime volumes have no unused cleanable entries" + return 0 + fi + + if ! has_sudo_session; then + if ! ensure_sudo_session "Cleaning Xcode runtime volumes requires admin access"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode runtime volumes · skipped (sudo denied)" + note_activity + return 0 + fi + fi + + # Perform cleanup and report final result in one line + local removed_count=0 + local removed_size_kb=0 + local failed_count=0 + local stop_reason="" + local selected_path + for selected_path in "${selected_paths[@]}"; do + local selected_size_kb=0 + selected_size_kb=$(_sim_runtime_size_kb "$selected_path") + + # Mount state can change while sizing or waiting for sudo. Refresh it + # for each candidate and stop when the path is mounted or the probe is + # unavailable; an old UNUSED snapshot never authorizes deletion. + local -a current_mount_points=() + while IFS= read -r line; do + [[ -n "$line" ]] && current_mount_points+=("$line") + done < <(_sim_runtime_mount_points) + if [[ ${#current_mount_points[@]} -eq 0 ]]; then + stop_reason="mount state unknown" + break + fi + if _sim_runtime_is_path_in_use "$selected_path" "${current_mount_points[@]}"; then + stop_reason="runtime became mounted" + break + fi + + if safe_sudo_remove "$selected_path" "$selected_size_kb"; then + removed_count=$((removed_count + 1)) + removed_size_kb=$((removed_size_kb + selected_size_kb)) + else + failed_count=$((failed_count + 1)) + fi + done + + # Unified output: report result, not intermediate steps + if [[ $removed_count -gt 0 ]]; then + local removed_human + removed_human=$(bytes_to_human "$((removed_size_kb * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$removed_size_kb") + if [[ $skipped_protected -gt 0 ]]; then + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC}), skipped ${skipped_protected} protected" + else + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC})" + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode runtime volumes · could not remove ${failed_count} entries" + fi + note_activity + else + if [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode runtime volumes · could not remove ${failed_count} entries" + if [[ $skipped_protected -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode runtime volumes · skipped ${skipped_protected} protected" + fi + elif [[ $skipped_protected -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode runtime volumes · skipped ${skipped_protected} protected, none removed" + fi + if [[ $failed_count -gt 0 || $skipped_protected -gt 0 ]]; then + note_activity + fi + fi + if [[ -n "$stop_reason" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode runtime volumes · stopped (${stop_reason})" + note_activity + fi +} + +_MOLE_SIMCTL_DEVELOPER_DIR="" +_MOLE_SIMCTL_RESOLUTION_STATUS="unavailable" +_MOLE_SIMCTL_XCODE_APP_ROOTS=( + "/Applications" + "$HOME/Applications" +) + +_simctl_developer_dir_is_usable() { + local developer_dir="$1" + [[ -d "$developer_dir" ]] || return 1 + + run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + env "DEVELOPER_DIR=$developer_dir" xcrun --find simctl > /dev/null 2>&1 +} + +# Resolve simctl without changing the machine-wide xcode-select setting. +# An explicit DEVELOPER_DIR is authoritative: if it is invalid, do not +# silently switch the caller to a different Xcode installation. +_resolve_simctl_developer_dir() { + _MOLE_SIMCTL_DEVELOPER_DIR="" + _MOLE_SIMCTL_RESOLUTION_STATUS="unavailable" + + if [[ -n "${DEVELOPER_DIR:-}" ]]; then + if _simctl_developer_dir_is_usable "$DEVELOPER_DIR"; then + _MOLE_SIMCTL_DEVELOPER_DIR="$DEVELOPER_DIR" + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" + return 0 + fi + + _MOLE_SIMCTL_RESOLUTION_STATUS="explicit-invalid" + debug_log "Explicit DEVELOPER_DIR does not provide simctl: $DEVELOPER_DIR" + return 1 + fi + + local selected_developer_dir="" + if command -v xcode-select > /dev/null 2>&1; then + selected_developer_dir=$(xcode-select -p 2> /dev/null || true) + fi + case "$selected_developer_dir" in + /Library/Developer/CommandLineTools | /Library/Developer/CommandLineTools/) + ;; + "") + return 1 + ;; + *) + if _simctl_developer_dir_is_usable "$selected_developer_dir"; then + _MOLE_SIMCTL_DEVELOPER_DIR="$selected_developer_dir" + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" + return 0 + fi + debug_log "Selected Xcode does not provide simctl: $selected_developer_dir" + return 1 + ;; + esac + + local -a candidates=() + local app_root candidate_app candidate_developer_dir + local nullglob_was_set=0 + shopt -q nullglob && nullglob_was_set=1 + shopt -s nullglob + for app_root in "${_MOLE_SIMCTL_XCODE_APP_ROOTS[@]}"; do + [[ -d "$app_root" ]] || continue + for candidate_app in "$app_root"/Xcode*.app; do + [[ -d "$candidate_app" ]] || continue + candidate_developer_dir="$candidate_app/Contents/Developer" + if _simctl_developer_dir_is_usable "$candidate_developer_dir"; then + candidates+=("$candidate_developer_dir") + fi + done + done + if [[ $nullglob_was_set -eq 0 ]]; then + shopt -u nullglob + fi + + if [[ ${#candidates[@]} -eq 1 ]]; then + _MOLE_SIMCTL_DEVELOPER_DIR="${candidates[0]}" + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" + debug_log "Using detected Xcode for simctl: ${candidates[0]%/Contents/Developer}" + return 0 + fi + + if [[ ${#candidates[@]} -gt 1 ]]; then + _MOLE_SIMCTL_RESOLUTION_STATUS="ambiguous" + for candidate_developer_dir in "${candidates[@]}"; do + debug_log "simctl Xcode candidate: ${candidate_developer_dir%/Contents/Developer}" + done + fi + + return 1 +} + +_run_simctl() { + local timeout_seconds="$1" + shift + + [[ "$_MOLE_SIMCTL_RESOLUTION_STATUS" == "ready" ]] || return 127 + run_with_timeout "$timeout_seconds" \ + env "DEVELOPER_DIR=$_MOLE_SIMCTL_DEVELOPER_DIR" xcrun simctl "$@" +} + +_simctl_debug_excerpt() { + local value="$1" + value=$(printf '%s' "$value" | LC_ALL=C tr '\r\n\t' ' ' | LC_ALL=C tr -cd '[:print:] ') || value="" + + if [[ -n "${HOME:-}" ]]; then + local prefix suffix + while [[ "$value" == *"$HOME"* ]]; do + prefix=${value%%"$HOME"*} + suffix=${value#*"$HOME"} + value="${prefix}~${suffix}" + done + fi + + if [[ ${#value} -gt 240 ]]; then + value="${value:0:240}..." + fi + printf '%s' "$value" +} + +_debug_simctl_probe_stderr() { + local attempt="$1" + local raw_stderr="$2" + local excerpt + excerpt=$(_simctl_debug_excerpt "$raw_stderr") + [[ -n "$excerpt" ]] || return 0 + debug_log "simctl probe $attempt stderr: $excerpt" +} + +clean_dev_mobile() { + check_android_ndk + clean_xcode_documentation_cache || return $? + clean_xcode_system_coresimulator_caches || return $? + clean_xcode_simulator_runtime_volumes || return $? + clean_xcode_xctest_devices || return $? + + if command -v xcrun > /dev/null 2>&1; then + _resolve_simctl_developer_dir || true + if [[ "$_MOLE_SIMCTL_RESOLUTION_STATUS" == "ambiguous" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · multiple Xcode apps found; set DEVELOPER_DIR" + note_activity + elif [[ "$_MOLE_SIMCTL_RESOLUTION_STATUS" == "explicit-invalid" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · DEVELOPER_DIR has no simctl" + note_activity + elif [[ "$_MOLE_SIMCTL_RESOLUTION_STATUS" == "ready" ]]; then + debug_log "Checking for unavailable Xcode simulators" + debug_log "Resolved simctl DEVELOPER_DIR: $_MOLE_SIMCTL_DEVELOPER_DIR" + local unavailable_before=0 + local unavailable_after=0 + local removed_unavailable=0 + local unavailable_size_kb=0 + local unavailable_size_human="0B" + local -a unavailable_udids=() + local unavailable_udid="" + + # Check if simctl is accessible and working; timeout prevents hang when CLT-only. + # CoreSimulatorService may need >2s to warm up on cold boot, so we retry once + # with a longer timeout. See #890. + local simctl_available=true + local simctl_probe_ok=false + local simctl_probe_first_status=0 + local simctl_probe_retry_status=0 + local simctl_probe_first_stderr="" + local simctl_probe_retry_stderr="" + local simctl_probe_stderr_file="" + local simctl_probe_stderr_target="/dev/null" + if ensure_mole_temp_root && [[ -n "${MOLE_RESOLVED_TMPDIR:-}" ]]; then + simctl_probe_stderr_file=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole-simctl-probe.XXXXXX" 2> /dev/null || true) + fi + if [[ -n "$simctl_probe_stderr_file" ]]; then + simctl_probe_stderr_target="$simctl_probe_stderr_file" + else + debug_log "Unable to create simctl probe stderr capture file" + fi + + if _run_simctl "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" list devices > /dev/null 2> "$simctl_probe_stderr_target"; then + simctl_probe_ok=true + else + simctl_probe_first_status=$? + if [[ -n "$simctl_probe_stderr_file" ]]; then + simctl_probe_first_stderr=$(< "$simctl_probe_stderr_file") + : > "$simctl_probe_stderr_file" + fi + if _run_simctl 8 list devices > /dev/null 2> "$simctl_probe_stderr_target"; then # 8s: simctl retry after warmup, see lib/core/timeouts.sh + simctl_probe_ok=true + simctl_probe_retry_status=0 + debug_log "simctl probe succeeded on retry (CoreSimulatorService warmup)" + else + simctl_probe_retry_status=$? + fi + if [[ -n "$simctl_probe_stderr_file" ]]; then + simctl_probe_retry_stderr=$(< "$simctl_probe_stderr_file") + fi + debug_log "simctl probe statuses: first=$simctl_probe_first_status retry=$simctl_probe_retry_status" + _debug_simctl_probe_stderr "first" "$simctl_probe_first_stderr" + _debug_simctl_probe_stderr "retry" "$simctl_probe_retry_stderr" + fi + if [[ -n "$simctl_probe_stderr_file" ]]; then + rm -f -- "$simctl_probe_stderr_file" 2> /dev/null || true # SAFE: exact temporary file created by mktemp above + fi + if [[ "$simctl_probe_ok" != "true" ]]; then + if [[ $simctl_probe_first_status -eq 124 && $simctl_probe_retry_status -eq 124 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl probe timed out" + else + local simctl_probe_exit_code=$simctl_probe_retry_status + if [[ $simctl_probe_exit_code -eq 124 ]]; then + simctl_probe_exit_code=$simctl_probe_first_status + fi + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl probe failed (exit=${simctl_probe_exit_code})" + fi + note_activity + simctl_available=false + fi + + if [[ "$simctl_available" == "true" ]]; then + local unavailable_devices_output="" + local unavailable_list_exit_code=0 + unavailable_devices_output=$(_run_simctl "$MOLE_TIMEOUT_PKG_LIST_SEC" list devices unavailable 2> /dev/null) || unavailable_list_exit_code=$? + if [[ $unavailable_list_exit_code -ne 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl list failed (exit=${unavailable_list_exit_code})" + debug_log "simctl list devices unavailable returned $unavailable_list_exit_code" + note_activity + simctl_available=false + fi + fi + + if [[ "$simctl_available" == "true" ]]; then + unavailable_before=$(printf '%s\n' "$unavailable_devices_output" | command awk '/\(unavailable/ { count++ } END { print count+0 }') + [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 + while IFS= read -r unavailable_udid; do + [[ -n "$unavailable_udid" ]] && unavailable_udids+=("$unavailable_udid") + done < <( + printf '%s\n' "$unavailable_devices_output" | + command sed -nE 's/.*\(([0-9A-Fa-f-]{36})\).*\(unavailable.*/\1/p' || true + ) + if [[ ${#unavailable_udids[@]} -gt 0 ]]; then + local udid + for udid in "${unavailable_udids[@]}"; do + local simulator_device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" + if [[ -d "$simulator_device_path" ]]; then + local simulator_size_kb="" + local size_rc=0 + simulator_size_kb=$(get_path_size_kb "$simulator_device_path") || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$simulator_size_kb" =~ ^[0-9]+$ ]] || simulator_size_kb=0 + unavailable_size_kb=$((unavailable_size_kb + simulator_size_kb)) + fi + done + fi + unavailable_size_human=$(bytes_to_human "$((unavailable_size_kb * 1024))") + + if [[ "$DRY_RUN" == "true" ]]; then + if ((unavailable_before > 0)); then + for unavailable_udid in "${unavailable_udids[@]}"; do + local unavailable_path="$HOME/Library/Developer/CoreSimulator/Devices/$unavailable_udid" + [[ -d "$unavailable_path" ]] || continue + local unavailable_path_size_kb + size_rc=0 + unavailable_path_size_kb=$(get_path_size_kb "$unavailable_path" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$unavailable_path_size_kb" =~ ^[0-9]+$ ]] || unavailable_path_size_kb=0 + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$unavailable_path" "$unavailable_path_size_kb" 1 true || true + fi + done + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode unavailable simulators · would clean ${unavailable_before}, ${unavailable_size_human}" + note_activity + else + debug_log "Xcode unavailable simulators already clean" + fi + else + # Skip if no unavailable simulators + if ((unavailable_before == 0)); then + debug_log "Xcode unavailable simulators already clean" + else + start_section_spinner "Checking unavailable simulators..." + + # Capture error output for diagnostics + local delete_output + local delete_exit_code=0 + delete_output=$(_run_simctl "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" delete unavailable 2>&1) || delete_exit_code=$? + + if [[ $delete_exit_code -eq 0 ]]; then + stop_section_spinner + local recount_exit_code=0 + unavailable_devices_output=$(_run_simctl "$MOLE_TIMEOUT_PKG_LIST_SEC" list devices unavailable 2> /dev/null) || recount_exit_code=$? + if [[ $recount_exit_code -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · cleanup completed, unable to verify remaining devices" + debug_log "simctl recount returned $recount_exit_code" + else + unavailable_after=$(printf '%s\n' "$unavailable_devices_output" | command awk '/\(unavailable/ { count++ } END { print count+0 }') + [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 + + removed_unavailable=$((unavailable_before - unavailable_after)) + if ((removed_unavailable < 0)); then + removed_unavailable=0 + fi + + local line_color + line_color=$(cleanup_result_color_kb "$unavailable_size_kb") + if ((removed_unavailable > 0)); then + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${line_color}${unavailable_size_human}${NC}" + else + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${line_color}${unavailable_size_human}${NC}" + fi + fi + else + stop_section_spinner + + # Analyze error and provide helpful message + local error_hint="" + if echo "$delete_output" | grep -qi "permission denied"; then + error_hint=" (permission denied)" + elif echo "$delete_output" | grep -qi "in use\|busy"; then + error_hint=" (device in use)" + elif echo "$delete_output" | grep -qi "unable to boot\|failed to boot"; then + error_hint=" (boot failure)" + elif echo "$delete_output" | grep -qi "service"; then + error_hint=" (CoreSimulator service issue)" + fi + + # Native simctl owns simulator state. A nonzero result can + # mean the device became active after the list, so never + # bypass it with direct directory removal. + if [[ $delete_exit_code -eq 124 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · cleanup timed out" + debug_log "simctl delete unavailable timed out" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" + debug_log "simctl delete error: $delete_output" + fi + fi + note_activity + fi + fi # Close if ((unavailable_before == 0)) + fi # End of simctl_available check + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl could not be resolved" + note_activity + fi + fi + # Old iOS/watchOS/tvOS DeviceSupport versions (debug symbols for connected devices). + # Each iOS version creates a 1-3 GB folder of debug symbols. Only the versions + # matching currently used devices are needed; older ones regenerate on device connect. + clean_xcode_device_support ~/Library/Developer/Xcode/iOS\ DeviceSupport "iOS DeviceSupport" || return $? + clean_xcode_device_support ~/Library/Developer/Xcode/watchOS\ DeviceSupport "watchOS DeviceSupport" || return $? + clean_xcode_device_support ~/Library/Developer/Xcode/tvOS\ DeviceSupport "tvOS DeviceSupport" || return $? + # Simulator runtime caches. + _xcode_safe_clean_guarded \ + _coresimulator_delete_guard_allows \ + "Simulator runtime cache" \ + ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* \ + "Simulator runtime cache" || return 0 + safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache" + # safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" + # safe_clean ~/.cache/flutter/* "Flutter cache" + safe_clean ~/.android/build-cache/* "Android build cache" + safe_clean ~/.android/cache/* "Android SDK cache" + _xcode_safe_clean_guarded \ + _xcode_delete_guard_allows \ + "Xcode Interface Builder cache" \ + ~/Library/Developer/Xcode/UserData/IB\ Support/* \ + "Xcode Interface Builder cache" || return 0 + safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache" + safe_clean ~/Library/Caches/org.swift.swiftpm/* "Swift package manager library cache" + # Expo/React Native caches (preserve state.json which contains auth tokens). + safe_clean ~/.expo/expo-go/* "Expo Go cache" + safe_clean ~/.expo/android-apk-cache/* "Expo Android APK cache" + safe_clean ~/.expo/ios-simulator-app-cache/* "Expo iOS simulator app cache" + safe_clean ~/.expo/native-modules-cache/* "Expo native modules cache" + safe_clean ~/.expo/schema-cache/* "Expo schema cache" + safe_clean ~/.expo/template-cache/* "Expo template cache" + safe_clean ~/.expo/versions-cache/* "Expo versions cache" +} +# JVM ecosystem caches. +# Gradle: Respects whitelist, cleaned when not protected via: mo clean --whitelist +clean_dev_jvm() { + # Source Maven cleanup module (requires bash for BASH_SOURCE) + # shellcheck disable=SC1091 + source "$(dirname "${BASH_SOURCE[0]}")/maven.sh" 2> /dev/null || true + if declare -f clean_maven_repository > /dev/null 2>&1; then + clean_maven_repository + fi + safe_clean ~/.sbt/boot/* "SBT boot cache" + safe_clean ~/.sbt/launchers/* "SBT launcher cache" + safe_clean ~/.ivy2/cache/* "Ivy cache" + if mole_cleanup_targets_exist \ + "$HOME/.gradle/caches/build-cache-"*/* \ + "$HOME/.gradle/notifications"/* \ + "$HOME/.gradle/daemon"/* \ + "$HOME/.gradle/workers"/*; then + local gradle_state=0 + gradle_daemon_running || gradle_state=$? + if [[ $gradle_state -eq 0 ]]; then + mole_defer_cleanup_family "Gradle" + elif [[ $gradle_state -eq 1 ]]; then + # Each group rechecks the probe at its deletion boundary; any + # refusal stops the remaining Gradle cleanup. + _dev_safe_clean_process_guarded \ + gradle_daemon_running \ + "Gradle" \ + "Gradle build cache" \ + "$HOME/.gradle/caches/build-cache-"*/* \ + "Gradle build cache" || return 0 + _dev_safe_clean_process_guarded \ + gradle_daemon_running \ + "Gradle" \ + "Gradle notifications cache" \ + "$HOME/.gradle/notifications"/* \ + "Gradle notifications cache" || return 0 + _dev_safe_clean_process_guarded \ + gradle_daemon_running \ + "Gradle" \ + "Gradle daemon/workers" \ + "$HOME/.gradle/daemon"/* \ + "$HOME/.gradle/workers"/* \ + "Gradle daemon/workers" || return 0 + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Gradle targets · skipped (process state unknown)" + note_activity + fi + fi +} +# JetBrains Toolbox old IDE versions (keep current + recent backup). +clean_dev_jetbrains_toolbox() { + local toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" + [[ -d "$toolbox_root" ]] || return 0 + + local keep_previous="${MOLE_JETBRAINS_TOOLBOX_KEEP:-1}" + [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1 + + # Save and filter whitelist patterns for toolbox path + local whitelist_overridden="false" + local -a original_whitelist=() + if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + original_whitelist=("${WHITELIST_PATTERNS[@]}") + local -a filtered_whitelist=() + local pattern + for pattern in "${WHITELIST_PATTERNS[@]}"; do + [[ "$toolbox_root" == "$pattern" || "$pattern" == "$toolbox_root"* ]] && continue + filtered_whitelist+=("$pattern") + done + WHITELIST_PATTERNS=("${filtered_whitelist[@]+${filtered_whitelist[@]}}") + whitelist_overridden="true" + fi + + # Helper to restore whitelist on exit + _restore_whitelist() { + [[ "$whitelist_overridden" == "true" ]] && WHITELIST_PATTERNS=("${original_whitelist[@]}") + return 0 + } + + local -a product_dirs=() + while IFS= read -r -d '' product_dir; do + product_dirs+=("$product_dir") + done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + if [[ ${#product_dirs[@]} -eq 0 ]]; then + _restore_whitelist + return 0 + fi + + local product_dir + for product_dir in "${product_dirs[@]}"; do + while IFS= read -r -d '' channel_dir; do + local current_link="" + local current_real="" + if [[ -L "$channel_dir/current" ]]; then + current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) + if [[ -n "$current_link" ]]; then + if [[ "$current_link" == /* ]]; then + current_real="$current_link" + else + current_real="$channel_dir/$current_link" + fi + fi + elif [[ -d "$channel_dir/current" ]]; then + current_real="$channel_dir/current" + fi + + local -a version_dirs=() + while IFS= read -r -d '' version_dir; do + local name + name=$(basename "$version_dir") + + [[ "$name" == "current" ]] && continue + [[ "$name" == .* ]] && continue + [[ "$name" == "plugins" || "$name" == "plugins-lib" || "$name" == "plugins-libs" ]] && continue + [[ -n "$current_real" && "$version_dir" == "$current_real" ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + + version_dirs+=("$version_dir") + done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + [[ ${#version_dirs[@]} -eq 0 ]] && continue + + local -a sorted_dirs=() + while IFS= read -r line; do + local dir_path="${line#* }" + sorted_dirs+=("$dir_path") + done < <( + for version_dir in "${version_dirs[@]}"; do + local mtime + mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") + printf '%s %s\n' "$mtime" "$version_dir" + done | sort -rn + ) + + if [[ ${#sorted_dirs[@]} -le "$keep_previous" ]]; then + continue + fi + + local idx=0 + local dir_path + for dir_path in "${sorted_dirs[@]}"; do + if [[ $idx -lt $keep_previous ]]; then + idx=$((idx + 1)) + continue + fi + safe_clean "$dir_path" "JetBrains Toolbox old IDE version" + note_activity + idx=$((idx + 1)) + done + done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) + done + + _restore_whitelist +} + +# JetBrains IDE logs are safe to rebuild, unlike some cache subtrees that can +# invalidate IDE indexes and trigger expensive reindexing. +clean_dev_jetbrains_logs() { + safe_clean ~/Library/Logs/JetBrains/* "JetBrains IDE logs" +} + +# AI coding agents (Claude Code, Cursor Agent, etc.) auto-update but never +# remove previous versions, so ~/.local/share//versions accumulates +# hundreds of MB per release. Keep the most recently modified N entries +# plus the version pointed at by the active CLI symlink (mtime alone is +# unreliable: Claude Code pre-downloads the next version before flipping +# the symlink, so newest mtime is not always the active version). +_MOLE_VERSIONED_AGENT_CLEANUP_TARGETS=() +_MOLE_VERSIONED_AGENT_RETENTION_TARGETS=() +_MOLE_VERSIONED_AGENT_ACTIVE_PATH="" + +_versioned_agent_scan_deadline() { + local timeout_seconds="${1:-$MOLE_TIMEOUT_DISK_VERIFY_SEC}" + [[ "$timeout_seconds" =~ ^[0-9]+(\.[0-9]+)?$ ]] || timeout_seconds="$MOLE_TIMEOUT_DISK_VERIFY_SEC" + local timeout_whole="${timeout_seconds%%.*}" + local timeout_budget=$((10#$timeout_whole)) + if [[ "$timeout_seconds" == *.* && "${timeout_seconds#*.}" =~ [1-9] ]]; then + timeout_budget=$((timeout_budget + 1)) + fi + [[ $timeout_budget -gt 0 ]] || timeout_budget=1 + printf '%s\n' "$((SECONDS + timeout_budget))" +} + +_materialize_versioned_agent_entries() { + local versions_root="$1" + local output_file="$2" + local timeout_seconds="${3:-$MOLE_TIMEOUT_DISK_VERIFY_SEC}" + : > "$output_file" || return 1 + + local scan_rc=0 + run_with_timeout "$timeout_seconds" find "$versions_root" -mindepth 1 -maxdepth 1 \ + \( -type f -o -type d \) -print0 \ + < /dev/null > "$output_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + : > "$output_file" || true + return "$scan_rc" + fi + return 0 +} + +_versioned_agent_entry_mtime() { + local entry="$1" + local timeout_seconds="$2" + run_with_timeout "$timeout_seconds" stat \ + -f%m "$entry" < /dev/null 2> /dev/null +} + +_plan_versioned_agent_cleanup_targets() { + local versions_root="$1" + local keep_previous="$2" + local active_path="${3:-}" + + _MOLE_VERSIONED_AGENT_CLEANUP_TARGETS=() + _MOLE_VERSIONED_AGENT_RETENTION_TARGETS=() + + [[ -d "$versions_root" ]] || return 0 + + local -a entries=() + local -a entry_mtimes=() + local scan_deadline="" + scan_deadline=$(_versioned_agent_scan_deadline) + local scan_timeout="" + local deadline_rc=0 + scan_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$scan_deadline") || deadline_rc=$? + [[ $deadline_rc -eq 0 ]] || return "$deadline_rc" + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + _materialize_versioned_agent_entries \ + "$versions_root" "$scan_file" "$scan_timeout" || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + + local entry + local inventory_rc=0 + while IFS= read -r -d '' entry; do + local name + name=$(basename "$entry") + [[ "$name" == .* ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + entries+=("$entry") + local mtime="" + local stat_rc=0 + local stat_timeout="" + stat_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$scan_deadline") || stat_rc=$? + if [[ $stat_rc -eq 0 ]]; then + mtime=$(_versioned_agent_entry_mtime \ + "$entry" "$stat_timeout") || stat_rc=$? + fi + if [[ $stat_rc -ne 0 || ! "$mtime" =~ ^[0-9]+$ ]]; then + inventory_rc=$stat_rc + [[ $inventory_rc -ne 0 ]] || inventory_rc=1 + break + fi + entry_mtimes+=("$mtime") + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ $inventory_rc -ne 0 ]]; then + _MOLE_VERSIONED_AGENT_CLEANUP_TARGETS=() + _MOLE_VERSIONED_AGENT_RETENTION_TARGETS=() + return "$inventory_rc" + fi + + [[ ${#entries[@]} -le "$keep_previous" ]] && return 0 + + # Sort the parallel arrays by mtime without serializing pathnames through + # newline-delimited text. Version directories can legally contain newlines. + local i j key_mtime key_path + for ((i = 1; i < ${#entries[@]}; i++)); do + key_path="${entries[$i]}" + key_mtime="${entry_mtimes[$i]}" + j=$((i - 1)) + while [[ $j -ge 0 && ${entry_mtimes[$j]} -lt $key_mtime ]]; do + entries[j + 1]="${entries[$j]}" + entry_mtimes[j + 1]="${entry_mtimes[$j]}" + j=$((j - 1)) + done + entries[j + 1]="$key_path" + entry_mtimes[j + 1]="$key_mtime" + done + + local idx=0 + local target + for target in "${entries[@]}"; do + if [[ -n "$active_path" && "$target" == "$active_path" ]]; then + continue + fi + if [[ $idx -lt $keep_previous ]]; then + idx=$((idx + 1)) + continue + fi + _MOLE_VERSIONED_AGENT_RETENTION_TARGETS+=("$target") + if mole_cleanup_targets_exist "$target"; then + _MOLE_VERSIONED_AGENT_CLEANUP_TARGETS+=("$target") + fi + idx=$((idx + 1)) + done +} + +_resolve_versioned_agent_active_path() { + local versions_root="$1" + local active_symlink="$2" + _MOLE_VERSIONED_AGENT_ACTIVE_PATH="" + + [[ -L "$active_symlink" ]] || return 1 + [[ -e "$active_symlink" ]] || return 2 + + local target + target=$(readlink "$active_symlink" 2> /dev/null || true) + [[ -n "$target" ]] || return 2 + case "$target" in + /*) ;; + *) target="$(dirname "$active_symlink")/$target" ;; + esac + + # Resolve dot segments and symlinked parent directories before comparing. + # Launchers commonly use ../../relative targets, and a lexical comparison + # would fail to pin the active version. + local target_parent target_name + target_parent=$(dirname "$target") + target_name=$(basename "$target") + target_parent=$(cd "$target_parent" 2> /dev/null && pwd -P) || return 2 + target="$target_parent/$target_name" + + local scan_deadline="" + scan_deadline=$(_versioned_agent_scan_deadline) + local scan_timeout="" + local deadline_rc=0 + scan_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$scan_deadline") || deadline_rc=$? + [[ $deadline_rc -eq 0 ]] || return "$deadline_rc" + local scan_file="" + scan_file=$(create_temp_file) || return 2 + local scan_rc=0 + _materialize_versioned_agent_entries \ + "$versions_root" "$scan_file" "$scan_timeout" || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + + local entry entry_resolved entry_parent entry_name + local found_active=false + while IFS= read -r -d '' entry; do + if [[ -d "$entry" ]]; then + entry_resolved=$(cd "$entry" 2> /dev/null && pwd -P) || continue + else + entry_parent=$(dirname "$entry") + entry_name=$(basename "$entry") + entry_parent=$(cd "$entry_parent" 2> /dev/null && pwd -P) || continue + entry_resolved="$entry_parent/$entry_name" + fi + case "$target/" in + "$entry_resolved"/*) + _MOLE_VERSIONED_AGENT_ACTIVE_PATH="$entry" + found_active=true + break + ;; + esac + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + + [[ "$found_active" == "true" ]] && return 0 + return 2 +} + +_MOLE_VERSIONED_AGENT_GUARD_ROOT="" +_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK="" +_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_REQUIRED=false +_MOLE_VERSIONED_AGENT_GUARD_KEEP=1 +_MOLE_CLEAN_GUARD_REASON="" + +_versioned_agent_delete_guard_allows() { + local target="${1:-}" + local active_path="" + local active_status=0 + + if [[ -n "$_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK" ]]; then + _resolve_versioned_agent_active_path \ + "$_MOLE_VERSIONED_AGENT_GUARD_ROOT" \ + "$_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK" || active_status=$? + if [[ $active_status -eq 124 || $active_status -ge 128 ]]; then + _MOLE_CLEAN_GUARD_REASON="inventory interrupted" + return "$active_status" + fi + if [[ $active_status -eq 0 ]]; then + active_path="$_MOLE_VERSIONED_AGENT_ACTIVE_PATH" + elif [[ $active_status -eq 1 && "$_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_REQUIRED" != "true" ]]; then + : # This agent currently has no active launcher symlink to preserve. + elif [[ $active_status -eq 1 ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + else + _MOLE_CLEAN_GUARD_REASON="active version unknown" + return "$active_status" + fi + fi + + # An updater can switch the launcher or install a newer version while size + # is being measured. Re-plan and authorize this exact target at the delete + # boundary so neither the active version nor the new retention set is lost. + local plan_rc=0 + _plan_versioned_agent_cleanup_targets \ + "$_MOLE_VERSIONED_AGENT_GUARD_ROOT" \ + "$_MOLE_VERSIONED_AGENT_GUARD_KEEP" \ + "$active_path" || plan_rc=$? + if [[ $plan_rc -ne 0 ]]; then + _MOLE_CLEAN_GUARD_REASON="inventory unknown" + [[ $plan_rc -eq 124 || $plan_rc -ge 128 ]] && return "$plan_rc" + return 1 + fi + + if [[ -n "$_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK" ]]; then + local verified_active_path="" + local verified_active_status=0 + _resolve_versioned_agent_active_path \ + "$_MOLE_VERSIONED_AGENT_GUARD_ROOT" \ + "$_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK" || verified_active_status=$? + if [[ $verified_active_status -eq 124 || $verified_active_status -ge 128 ]]; then + _MOLE_CLEAN_GUARD_REASON="inventory interrupted" + return "$verified_active_status" + fi + [[ $verified_active_status -ne 0 ]] || verified_active_path="$_MOLE_VERSIONED_AGENT_ACTIVE_PATH" + if [[ $verified_active_status -ne 0 && $verified_active_status -ne 1 ]]; then + _MOLE_CLEAN_GUARD_REASON="active version unknown" + return "$verified_active_status" + fi + if [[ $verified_active_status -eq 1 && "$_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_REQUIRED" == "true" ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + fi + if [[ $verified_active_status -ne $active_status || "$verified_active_path" != "$active_path" ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + fi + fi + + local planned_target + if [[ ${#_MOLE_VERSIONED_AGENT_CLEANUP_TARGETS[@]} -gt 0 ]]; then + for planned_target in "${_MOLE_VERSIONED_AGENT_CLEANUP_TARGETS[@]}"; do + [[ "$planned_target" == "$target" ]] && return 0 + done + fi + + _MOLE_CLEAN_GUARD_REASON="retention changed" + return 1 +} + +_report_versioned_agent_guard_stop() { + local label="$1" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${label} · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity +} + +clean_versioned_agent_root() { + local versions_root="$1" + local label="$2" + local keep_previous="$3" + local active_path="${4:-}" + local active_symlink="${5:-}" + + local plan_rc=0 + _plan_versioned_agent_cleanup_targets \ + "$versions_root" "$keep_previous" "$active_path" || plan_rc=$? + if [[ $plan_rc -ne 0 ]]; then + _MOLE_CLEAN_GUARD_REASON="inventory unknown" + [[ $plan_rc -eq 124 || $plan_rc -ge 128 ]] && return "$plan_rc" + _report_versioned_agent_guard_stop "$label" + return "$plan_rc" + fi + [[ ${#_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]} -gt 0 ]] || return 0 + + _MOLE_VERSIONED_AGENT_GUARD_ROOT="$versions_root" + _MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK="$active_symlink" + _MOLE_VERSIONED_AGENT_GUARD_ACTIVE_REQUIRED=false + [[ -n "$active_path" ]] && _MOLE_VERSIONED_AGENT_GUARD_ACTIVE_REQUIRED=true + _MOLE_VERSIONED_AGENT_GUARD_KEEP="$keep_previous" + _MOLE_CLEAN_GUARD_REASON="retention changed" + + if declare -f safe_clean_guarded > /dev/null 2>&1; then + local guarded_rc=0 + safe_clean_guarded \ + _versioned_agent_delete_guard_allows \ + "${_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]}" \ + "$label" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + _report_versioned_agent_guard_stop "$label" + return 0 + fi + return "$guarded_rc" + fi + + local target + for target in "${_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]}"; do + local guard_rc=0 + _versioned_agent_delete_guard_allows "$target" || guard_rc=$? + if [[ $guard_rc -ne 0 ]]; then + [[ $guard_rc -eq 124 || $guard_rc -ge 128 ]] && return "$guard_rc" + _report_versioned_agent_guard_stop "$label" + return 0 + fi + safe_clean "$target" "$label" + note_activity + done +} + +count_versioned_agent_entries() { + local versions_root="$1" + local count=0 + local entry + + [[ -d "$versions_root" ]] || { + echo 0 + return 0 + } + + local scan_timeout="$MOLE_TIMEOUT_DISK_VERIFY_SEC" + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + _materialize_versioned_agent_entries \ + "$versions_root" "$scan_file" "$scan_timeout" || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + + while IFS= read -r -d '' entry; do + local name + name=$(basename "$entry") + [[ "$name" == .* ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + count=$((count + 1)) + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + + echo "$count" +} + +claude_desktop_sdk_version_is_safe() { + local sdk_version="${1:-}" + + [[ -n "$sdk_version" ]] || return 1 + [[ "$sdk_version" == .* ]] && return 1 + [[ "$sdk_version" == *"/"* ]] && return 1 + [[ "$sdk_version" == *".."* ]] && return 1 + [[ "$sdk_version" =~ ^[0-9] ]] || return 1 + return 0 +} + +claude_desktop_running() { + mole_pgrep_any \ + -x "Claude" \ + -f "/Claude.app/" +} + +claude_desktop_sdk_version() { + local claude_support="$1" + local sdk_file="$claude_support/claude-code-vm/.sdk-version" + [[ -f "$sdk_file" ]] || return 1 + + local sdk_version + sdk_version=$(head -n 1 "$sdk_file" 2> /dev/null | LC_ALL=C tr -d '[:space:]' || true) + claude_desktop_sdk_version_is_safe "$sdk_version" || return 1 + + printf '%s\n' "$sdk_version" +} + +_MOLE_CLAUDE_DESKTOP_GUARD_SUPPORT="" +_MOLE_CLAUDE_DESKTOP_GUARD_SDK_VERSION="" +_MOLE_CLAUDE_DESKTOP_GUARD_VERSIONS_ROOT="" +_MOLE_CLAUDE_DESKTOP_GUARD_CLI_ROOT="" +_MOLE_CLAUDE_DESKTOP_GUARD_VM_ROOT="" +_MOLE_CLAUDE_DESKTOP_GUARD_KEEP=1 +_MOLE_CLEAN_GUARD_REASON="" + +_claude_desktop_delete_guard_allows() { + local target="${1:-}" + mole_clean_process_guard claude_desktop_running "Claude Desktop started" || return 1 + + local current_sdk="" + current_sdk=$(claude_desktop_sdk_version "$_MOLE_CLAUDE_DESKTOP_GUARD_SUPPORT" || true) + if [[ -z "$current_sdk" || "$current_sdk" != "$_MOLE_CLAUDE_DESKTOP_GUARD_SDK_VERSION" ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + fi + + local active_root active_entry + for active_root in \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_CLI_ROOT" \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_VM_ROOT"; do + [[ -n "$active_root" ]] || continue + active_entry="$active_root/$current_sdk" + if [[ -L "$active_entry" || (! -f "$active_entry" && ! -d "$active_entry") ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + fi + done + + # A staged update can change which previous version retention should keep + # while size is being measured. Re-plan and authorize this exact target. + local plan_rc=0 + _plan_versioned_agent_cleanup_targets \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_VERSIONS_ROOT" \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_KEEP" \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_VERSIONS_ROOT/$current_sdk" || plan_rc=$? + if [[ $plan_rc -ne 0 ]]; then + _MOLE_CLEAN_GUARD_REASON="inventory unknown" + [[ $plan_rc -eq 124 || $plan_rc -ge 128 ]] && return "$plan_rc" + return 1 + fi + + # Re-read the SDK marker and active entries after retention planning. The + # planner walks and stats every version, which gives an updater time to + # switch the active SDK after the first evidence check. + local verified_sdk="" + verified_sdk=$(claude_desktop_sdk_version "$_MOLE_CLAUDE_DESKTOP_GUARD_SUPPORT" || true) + if [[ -z "$verified_sdk" || "$verified_sdk" != "$current_sdk" ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + fi + for active_root in \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_CLI_ROOT" \ + "$_MOLE_CLAUDE_DESKTOP_GUARD_VM_ROOT"; do + [[ -n "$active_root" ]] || continue + active_entry="$active_root/$verified_sdk" + if [[ -L "$active_entry" || (! -f "$active_entry" && ! -d "$active_entry") ]]; then + _MOLE_CLEAN_GUARD_REASON="active version changed" + return 1 + fi + done + + local planned_target + if [[ ${#_MOLE_VERSIONED_AGENT_CLEANUP_TARGETS[@]} -gt 0 ]]; then + for planned_target in "${_MOLE_VERSIONED_AGENT_CLEANUP_TARGETS[@]}"; do + [[ "$planned_target" == "$target" ]] && return 0 + done + fi + + _MOLE_CLEAN_GUARD_REASON="retention changed" + return 1 +} + +_claude_desktop_safe_clean_guarded() { + local display_name="$1" + shift + _MOLE_CLEAN_GUARD_REASON="process state changed" + + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + local -a cleanup_args=("$@") + local cleanup_arg_count=${#cleanup_args[@]} + [[ $cleanup_arg_count -gt 1 ]] || return 0 + local description="${cleanup_args[$((cleanup_arg_count - 1))]}" + local index target + for ((index = 0; index < cleanup_arg_count - 1; index++)); do + target="${cleanup_args[$index]}" + local guard_rc=0 + _claude_desktop_delete_guard_allows "$target" || guard_rc=$? + if [[ $guard_rc -ne 0 ]]; then + [[ $guard_rc -eq 124 || $guard_rc -ge 128 ]] && return "$guard_rc" + if [[ "$_MOLE_CLEAN_GUARD_REASON" == "Claude Desktop started" ]]; then + mole_defer_cleanup_family "Claude Desktop" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + fi + return 1 + fi + safe_clean "$target" "$description" || return $? + done + return 0 + fi + + local guarded_rc=0 + safe_clean_guarded _claude_desktop_delete_guard_allows "$@" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + if [[ "$_MOLE_CLEAN_GUARD_REASON" == "Claude Desktop started" ]]; then + mole_defer_cleanup_family "Claude Desktop" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + fi + return 1 + fi + return "$guarded_rc" +} + +_deny_versioned_agent_delete() { + return 1 +} + +clean_claude_desktop_bundled_versions() { + local keep_previous="$1" + local claude_support="$HOME/Library/Application Support/Claude" + [[ -d "$claude_support" ]] || return 0 + _MOLE_CLAUDE_DESKTOP_GUARD_CLI_ROOT="" + _MOLE_CLAUDE_DESKTOP_GUARD_VM_ROOT="" + + local -a desktop_specs=( + "$claude_support/claude-code|Claude Desktop bundled Claude Code old version" + "$claude_support/claude-code-vm|Claude Desktop bundled Claude Code VM old version" + ) + + local has_multiple_versions=false + local spec + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + [[ -d "$versions_root" ]] || continue + + local version_count="" + local count_rc=0 + version_count=$(count_versioned_agent_entries "$versions_root") || count_rc=$? + [[ $count_rc -eq 0 ]] || return "$count_rc" + [[ "$version_count" =~ ^[0-9]+$ ]] || return 1 + if [[ "$version_count" -gt 1 ]]; then + has_multiple_versions=true + break + fi + done + + [[ "$has_multiple_versions" == "true" ]] || return 0 + + local sdk_version="" + sdk_version=$(claude_desktop_sdk_version "$claude_support" || true) + if [[ -z "$sdk_version" ]]; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} Claude Desktop bundled Claude Code · skipped (active version unknown)" + return 0 + fi + + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + + local active_entry="$versions_root/$sdk_version" + if [[ -L "$active_entry" || (! -f "$active_entry" && ! -d "$active_entry") ]]; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active version unknown)" + return 0 + fi + done + + [[ -d "$claude_support/claude-code" ]] && _MOLE_CLAUDE_DESKTOP_GUARD_CLI_ROOT="$claude_support/claude-code" + [[ -d "$claude_support/claude-code-vm" ]] && _MOLE_CLAUDE_DESKTOP_GUARD_VM_ROOT="$claude_support/claude-code-vm" + + # Confirm at least one exact, unprotected stale version exists before the + # process gate. Multiple directories alone are insufficient when retention + # keeps them all or policy excludes every old entry. + local has_cleanup_target=false + local has_retention_target=false + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + [[ -d "$versions_root" ]] || continue + local plan_rc=0 + _plan_versioned_agent_cleanup_targets \ + "$versions_root" "$keep_previous" "$versions_root/$sdk_version" || plan_rc=$? + [[ $plan_rc -eq 0 ]] || return "$plan_rc" + if [[ ${#_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]} -gt 0 ]]; then + has_retention_target=true + fi + if [[ ${#_MOLE_VERSIONED_AGENT_CLEANUP_TARGETS[@]} -gt 0 ]]; then + has_cleanup_target=true + break + fi + done + [[ "$has_retention_target" == "true" ]] || return 0 + + # Preserve safe_clean's skip logging/counters for retention-selected paths + # even when policy currently excludes every target. The deny guard prevents + # a policy-changing race from turning this accounting pass into deletion. + if [[ "$has_cleanup_target" != "true" ]]; then + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + local plan_rc=0 + _plan_versioned_agent_cleanup_targets \ + "$versions_root" "$keep_previous" "$versions_root/$sdk_version" || plan_rc=$? + [[ $plan_rc -eq 0 ]] || return "$plan_rc" + [[ ${#_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]} -gt 0 ]] || continue + if declare -f safe_clean_guarded > /dev/null 2>&1; then + safe_clean_guarded \ + _deny_versioned_agent_delete \ + "${_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]}" \ + "$label" > /dev/null || true + else + safe_clean "${_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]}" "$label" + fi + done + return 0 + fi + + local process_state=0 + claude_desktop_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Claude Desktop bundled Claude Code · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Claude Desktop" + fi + return 0 + fi + + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + + local version_count="" + local count_rc=0 + version_count=$(count_versioned_agent_entries "$versions_root") || count_rc=$? + [[ $count_rc -eq 0 ]] || return "$count_rc" + [[ "$version_count" =~ ^[0-9]+$ ]] || return 1 + [[ "$version_count" -le 1 ]] && continue + + local plan_rc=0 + _plan_versioned_agent_cleanup_targets \ + "$versions_root" "$keep_previous" "$versions_root/$sdk_version" || plan_rc=$? + [[ $plan_rc -eq 0 ]] || return "$plan_rc" + [[ ${#_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]} -gt 0 ]] || continue + + _MOLE_CLAUDE_DESKTOP_GUARD_SUPPORT="$claude_support" + _MOLE_CLAUDE_DESKTOP_GUARD_SDK_VERSION="$sdk_version" + _MOLE_CLAUDE_DESKTOP_GUARD_VERSIONS_ROOT="$versions_root" + _MOLE_CLAUDE_DESKTOP_GUARD_KEEP="$keep_previous" + local clean_rc=0 + _claude_desktop_safe_clean_guarded \ + "$label" \ + "${_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]}" \ + "$label" || clean_rc=$? + [[ $clean_rc -eq 0 || $clean_rc -eq 1 ]] || return "$clean_rc" + done +} + +clean_dev_ai_agents() { + local keep_previous="${MOLE_AI_AGENTS_KEEP:-1}" + [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1 + + local -a agent_specs=( + "$HOME/.local/share/claude/versions|Claude Code old version|$HOME/.local/bin/claude" + "$HOME/.local/share/cursor-agent/versions|Cursor Agent old version|$HOME/.local/bin/cursor-agent" + "$HOME/.copilot/pkg/universal|GitHub Copilot CLI old version|$HOME/.local/bin/copilot" + ) + + local spec + for spec in "${agent_specs[@]}"; do + local versions_root="${spec%%|*}" + local rest="${spec#*|}" + local label="${rest%%|*}" + local active_symlink="${rest#*|}" + [[ "$active_symlink" == "$rest" ]] && active_symlink="" + [[ -d "$versions_root" ]] || continue + + local active_path="" + if [[ -n "$active_symlink" && -L "$active_symlink" ]]; then + local active_status=0 + _resolve_versioned_agent_active_path "$versions_root" "$active_symlink" || active_status=$? + if [[ $active_status -ne 0 ]]; then + [[ $active_status -eq 124 || $active_status -ge 128 ]] && return "$active_status" + if [[ ! -e "$active_symlink" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active symlink broken)" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active version unknown)" + fi + note_activity + continue + fi + active_path="$_MOLE_VERSIONED_AGENT_ACTIVE_PATH" + if [[ -z "$active_path" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active symlink broken)" + note_activity + continue + fi + fi + + clean_versioned_agent_root \ + "$versions_root" "$label" "$keep_previous" "$active_path" "$active_symlink" || return $? + done + + clean_claude_desktop_bundled_versions "$keep_previous" +} + +# Other language tool caches. +clean_dev_other_langs() { + safe_clean ~/.composer/cache/* "PHP Composer cache (legacy)" + safe_clean ~/Library/Caches/composer/* "PHP Composer cache" + safe_clean ~/.nuget/packages/* "NuGet packages cache" + # safe_clean ~/.pub-cache/* "Dart Pub cache" + safe_clean ~/.cache/bazel/* "Bazel cache" + safe_clean ~/.cache/zig/* "Zig cache" + safe_clean ~/Library/Caches/deno/* "Deno cache" +} +# CI/CD and DevOps caches. +clean_dev_cicd() { + safe_clean ~/.cache/terraform/* "Terraform cache" + safe_clean ~/.grafana/cache/* "Grafana cache" + safe_clean ~/.prometheus/data/wal/* "Prometheus WAL cache" + safe_clean ~/.jenkins/workspace/*/target/* "Jenkins workspace cache" + safe_clean ~/.cache/gitlab-runner/* "GitLab Runner cache" + safe_clean ~/.github/cache/* "GitHub Actions cache" + safe_clean ~/.circleci/cache/* "CircleCI cache" + safe_clean ~/.sonar/* "SonarQube cache" +} +# Database tool caches. +clean_dev_database() { + safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache" + safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache" + safe_clean ~/Library/Caches/redis-desktop-manager/* "Redis Desktop Manager cache" + safe_clean ~/Library/Caches/com.navicat.* "Navicat cache" + safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache" + safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache" +} +# API/debugging tool caches. +clean_dev_api_tools() { + safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache" + safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache" + safe_clean ~/Library/Caches/com.tinyapp.TablePlus/* "TablePlus cache" + safe_clean ~/Library/Caches/com.getpaw.Paw/* "Paw API cache" + safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" + safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" +} + +codex_desktop_process_state() { + command -v pgrep > /dev/null 2>&1 || return 2 + + local mode pattern probe_status + while IFS='|' read -r mode pattern; do + if pgrep "$mode" "$pattern" > /dev/null 2>&1; then + return 0 + else + probe_status=$? + [[ $probe_status -eq 1 ]] || return 2 + fi + done << 'EOF' +-x|Codex +-x|ChatGPT +-f|/Codex.app/ +-f|/ChatGPT.app/ +EOF + return 1 +} + +codex_desktop_running() { + local process_state=0 + codex_desktop_process_state || process_state=$? + [[ $process_state -ne 1 ]] +} + +codex_runtime_process_state() { + local cli_state=0 + local desktop_state=0 + mole_pgrep_any -x "codex" || cli_state=$? + codex_desktop_process_state || desktop_state=$? + + if [[ $cli_state -eq 0 || $desktop_state -eq 0 ]]; then + return 0 + fi + if [[ $cli_state -eq 1 && $desktop_state -eq 1 ]]; then + return 1 + fi + return 2 +} + +codex_desktop_cache_physical_path() { + local candidate="$1" + local cache_root="$HOME/Library/Caches/Codex" + local home_prefix="${HOME%/}/" + + case "$candidate" in + "$cache_root" | "$cache_root"/*) ;; + *) return 1 ;; + esac + [[ -d "$candidate" ]] || return 1 + + # Reject every symlink component below HOME before resolving paths. This is + # deliberately local to Codex: global deletion validation still permits + # legitimate platform aliases such as /tmp -> /private/tmp. + local relative="${candidate#"$home_prefix"}" + local old_ifs="$IFS" + local -a components=() + IFS='/' read -r -a components <<< "$relative" + IFS="$old_ifs" + [[ ${#components[@]} -gt 0 ]] || return 1 + + local probe="$HOME" + local component + for component in "${components[@]}"; do + [[ -n "$component" ]] || return 1 + probe="$probe/$component" + [[ -L "$probe" ]] && return 1 + done + + local physical_root="" + local physical_candidate="" + physical_root=$(cd -P "$cache_root" 2> /dev/null && pwd -P) || return 1 + physical_candidate=$(cd -P "$candidate" 2> /dev/null && pwd -P) || return 1 + case "$physical_candidate" in + "$physical_root" | "$physical_root"/*) + printf '%s\n' "$physical_candidate" + return 0 + ;; + esac + return 1 +} + +_codex_desktop_cache_delete_guard_allows() { + mole_clean_process_guard codex_desktop_process_state "Codex started" +} + +_codex_desktop_safe_clean_guarded() { + local display_name="$1" + shift + local _MOLE_CLEAN_GUARD_REASON="process state changed" + + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! _codex_desktop_cache_delete_guard_allows; then + mole_report_guard_stop "$display_name" mole_defer_cleanup_family "Codex" + return 1 + fi + safe_clean "$@" + return $? + fi + + local guarded_rc=0 + safe_clean_guarded _codex_desktop_cache_delete_guard_allows "$@" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + mole_report_guard_stop "$display_name" mole_defer_cleanup_family "Codex" + return 1 + fi + return "$guarded_rc" +} + +clean_codex_desktop_caches() { + local cache_root="$HOME/Library/Caches/Codex" + [[ -d "$cache_root" ]] || return 0 + + if ! codex_desktop_cache_physical_path "$cache_root" > /dev/null; then + debug_log "Codex Desktop caches skipped: unsafe cache root" + return 0 + fi + + # Measured on ChatGPT 26.727.40816: these six Chromium leaves reclaimed + # 313,860 KiB. Local Storage, IndexedDB, cookies, sessions, preferences, and + # every Application Support sibling are deliberate non-targets because they + # can hold durable state rather than rebuildable cache data. + local -a profiles=( + "$cache_root/Default" + "$cache_root/Default/Partitions/codex-browser-app" + "$cache_root/codex-browser-app" + ) + local -a leaves=("Cache" "Code Cache") + local -a cleanable_physical_leaves=() + local -a cleanable_leaf_labels=() + local profile leaf physical_leaf + for profile in "${profiles[@]}"; do + for leaf in "${leaves[@]}"; do + physical_leaf="" + if physical_leaf=$(codex_desktop_cache_physical_path "$profile/$leaf"); then + if mole_cleanup_targets_exist "$physical_leaf"/*; then + cleanable_physical_leaves+=("$physical_leaf") + cleanable_leaf_labels+=("$leaf") + fi + else + debug_log "Codex Desktop cache leaf skipped: $profile/$leaf" + fi + done + done + [[ ${#cleanable_physical_leaves[@]} -gt 0 ]] || return 0 + + local process_state=0 + codex_desktop_process_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop caches · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Codex" + fi + return 0 + fi + + local cleanable_index + for ((cleanable_index = 0; cleanable_index < ${#cleanable_physical_leaves[@]}; cleanable_index++)); do + physical_leaf="${cleanable_physical_leaves[$cleanable_index]}" + leaf="${cleanable_leaf_labels[$cleanable_index]}" + _codex_desktop_safe_clean_guarded \ + "Codex Desktop caches" \ + "$physical_leaf"/* \ + "Codex Desktop $leaf" || return 0 + done +} + +codex_sparkle_updater_running() { + mole_pgrep_any \ + -f "org[.]sparkle-project[.]Sparkle" \ + -f "Sparkle[.]framework/.*/(Autoupdate|Installer|Downloader|Updater)" +} + +codex_sparkle_staging_has_open_files() { + local staging_root="$1" + command -v lsof > /dev/null 2>&1 || return 2 + + local lsof_output="" + local lsof_error_file="" + local lsof_rc=0 + lsof_error_file=$(create_temp_file 2> /dev/null || true) + [[ -n "$lsof_error_file" && -f "$lsof_error_file" && ! -L "$lsof_error_file" ]] || return 2 + + if lsof_output=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" lsof -Fn +D "$staging_root" 2> "$lsof_error_file"); then + [[ -n "$lsof_output" ]] + return + else + lsof_rc=$? + fi + + # `lsof +D` returns 1 when no open files match. Timeouts or other failures + # are different. Some probe errors also return 1, so stderr must be empty + # before treating that status as a reliable no-match. + [[ "$lsof_rc" -eq 1 && ! -s "$lsof_error_file" ]] && return 1 + return 2 +} + +# Physical-path gate for a staging directory that must sit exactly one level +# under a fixed, app-owned root below HOME. Prints the resolved physical path, +# or fails when the candidate is outside the root, is not a directory, sits too +# deep, or reaches the root through a symlink. No component below HOME may be a +# link: these are fixed app-owned paths, and accepting one would let a lexical +# staging root be redirected into ordinary user data. +codex_staging_physical_path() { + local candidate="$1" + local staging_root="$2" + local home_prefix="${HOME%/}/" + + case "$candidate" in + "$staging_root" | "$staging_root"/*) ;; + *) return 1 ;; + esac + [[ -d "$candidate" ]] || return 1 + if [[ "$candidate" != "$staging_root" && "${candidate%/*}" != "$staging_root" ]]; then + return 1 + fi + + local relative="${candidate#"$home_prefix"}" + local old_ifs="$IFS" + local -a components=() + IFS='/' read -r -a components <<< "$relative" + IFS="$old_ifs" + [[ ${#components[@]} -gt 0 ]] || return 1 + + local probe="$HOME" + local component + for component in "${components[@]}"; do + [[ -n "$component" ]] || return 1 + probe="$probe/$component" + [[ -L "$probe" ]] && return 1 + done + + local physical_root="" + local physical_candidate="" + physical_root=$(cd -P "$staging_root" 2> /dev/null && pwd -P) || return 1 + physical_candidate=$(cd -P "$candidate" 2> /dev/null && pwd -P) || return 1 + if [[ "$candidate" == "$staging_root" ]]; then + [[ "$physical_candidate" == "$physical_root" ]] || return 1 + else + [[ "${physical_candidate%/*}" == "$physical_root" ]] || return 1 + fi + + printf '%s\n' "$physical_candidate" +} + +# Sparkle's staging root is fixed, so it only supplies the root. +codex_sparkle_staging_physical_path() { + codex_staging_physical_path \ + "$1" "$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" +} + +_MOLE_CODEX_STAGING_ROOT="" +_MOLE_CODEX_STAGING_ENTRY="" +_MOLE_CLEAN_GUARD_REASON="" + +_codex_staging_entry_is_still_stale() { + local staging_root="$_MOLE_CODEX_STAGING_ROOT" + local stale_entry="$_MOLE_CODEX_STAGING_ENTRY" + [[ -n "$staging_root" && -n "$stale_entry" ]] || return 1 + [[ "${stale_entry%/*}" == "$staging_root" ]] || return 1 + [[ -d "$stale_entry" && ! -L "$stale_entry" ]] || return 1 + + local physical_before="" + local physical_after="" + physical_before=$(codex_sparkle_staging_physical_path "$stale_entry") || return 1 + + # The boundary must re-verify the same evidence that made the entry + # eligible. A version-superseded entry re-reads its staged build and + # compares against the installed build captured at scan time; an + # age-eligible entry re-checks its age. Mixing them would let a young + # superseded entry be refused here, or an aged-out check authorize an + # entry whose staged build was swapped after the scan. + if [[ "${_MOLE_CODEX_STAGING_MODE:-age}" == "superseded" ]]; then + local installed_build="${_MOLE_CODEX_INSTALLED_BUILD:-}" + [[ "$installed_build" =~ ^[0-9]+$ ]] || return 1 + # The scan-time verdict rested on the installed set being unique and + # at this build. Re-resolve at the boundary: a copy installed or + # swapped since the scan (say an older one whose pending update is + # exactly this staged build) must void the supersession, not ride + # a stale snapshot into a deletion. + local installed_now="" + installed_now=$(_codex_installed_build_version) || return 1 + [[ "$installed_now" == "$installed_build" ]] || return 1 + local staged_build="" + staged_build=$(_codex_staged_build_version "$stale_entry") || return 1 + [[ "$staged_build" -le "$installed_build" ]] || return 1 + physical_after=$(codex_sparkle_staging_physical_path "$stale_entry") || return 1 + [[ "$physical_after" == "$physical_before" ]] || return 1 + return 0 + fi + + local stale_match="" + while IFS= read -r -d '' stale_match; do + physical_after=$(codex_sparkle_staging_physical_path "$stale_entry") || return 1 + [[ "$physical_after" == "$physical_before" ]] || return 1 + return 0 + done < <(command find -P "$stale_entry" -maxdepth 0 -type d -mtime +"$MOLE_ORPHAN_AGE_DAYS" -print0 2> /dev/null) + return 1 +} + +_codex_staging_delete_guard_allows() { + _MOLE_CLEAN_GUARD_REASON="staging entry changed" + _codex_staging_entry_is_still_stale || return 1 + + mole_clean_process_guard codex_desktop_process_state "Codex started" || return 1 + + mole_clean_process_guard codex_sparkle_updater_running "Sparkle updater started" "updater state unknown" || return 1 + + local open_file_state=0 + if codex_sparkle_staging_has_open_files "$_MOLE_CODEX_STAGING_ROOT"; then + _MOLE_CLEAN_GUARD_REASON="staging files opened" + return 1 + else + open_file_state=$? + fi + if [[ $open_file_state -eq 2 ]]; then + _MOLE_CLEAN_GUARD_REASON="open-file check unavailable" + return 1 + fi + + # Process and open-file probes add another race window after the first path + # check. Bind the same physical first-level entry and age again immediately + # before safe_clean reaches its deletion sink. + _codex_staging_entry_is_still_stale || return 1 + + return 0 +} + +_codex_staging_safe_clean_guarded() { + local staging_root="$1" + local stale_entry="$2" + _MOLE_CODEX_STAGING_ROOT="$staging_root" + _MOLE_CODEX_STAGING_ENTRY="$stale_entry" + _MOLE_CODEX_STAGING_MODE="${3:-age}" + + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! _codex_staging_delete_guard_allows; then + return 75 + fi + safe_clean "$stale_entry" "Codex Desktop stale update staging" + return $? + fi + + safe_clean_guarded \ + _codex_staging_delete_guard_allows \ + "$stale_entry" \ + "Codex Desktop stale update staging" +} + +# Installed Codex Desktop build number, or failure. Two sources, per the +# single-probe rule: the fixed install paths first, a bounded mdfind second, +# both verified against the exact bundle id, and a strict bounded-decimal +# gate so prose or error text can never win a version comparison. +# Build number of one verified Codex install, or failure. Identity and +# version both come from the same plist, both gated. +_codex_app_build_version() { + local candidate="$1" + [[ -d "$candidate" ]] || return 1 + local cand_id="" + cand_id=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + /usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \ + "$candidate/Contents/Info.plist" 2> /dev/null) || return 1 + [[ "$cand_id" == "com.openai.codex" ]] || return 1 + local build="" + build=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" \ + "$candidate/Contents/Info.plist" 2> /dev/null) || return 1 + [[ "$build" =~ ^[0-9]+$ && ${#build} -le 10 ]] || return 1 + # Normalize to base 10 before anyone compares these. `[[ a -le b ]]` reads a + # leading zero as octal, so a CFBundleVersion of 0123 would rank below 100 + # and a newer staged build could be called superseded; 08 and 09 are not + # even valid octal and abort the test with a bash error on stderr. + printf '%s\n' "$((10#$build))" +} + +_codex_installed_build_version() { + # All copies share one bundle id and therefore one staging cache, so a + # staged build can belong to any of them. Supersession is only provable + # against a UNIQUE installed version: two copies disagreeing on their + # build make ownership ambiguous, and the caller falls back to the age + # rule rather than deleting what may be the other copy's pending update. + local resolved_build="" candidate build + for candidate in "/Applications/Codex.app" "$HOME/Applications/Codex.app"; do + build=$(_codex_app_build_version "$candidate") || continue + if [[ -n "$resolved_build" && "$resolved_build" != "$build" ]]; then + return 1 + fi + resolved_build="$build" + done + # A failed or timed-out mdfind is an unanswered question, not an empty + # answer: an unindexed extra copy elsewhere could be the owner of a + # staged build we are about to call superseded. Fail the resolution and + # let the caller fall back to the age rule; only a clean rc 0 with no + # rows may mean "no other copies". + local mdfind_out="" line + mdfind_out=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" mdfind \ + "kMDItemCFBundleIdentifier == 'com.openai.codex'" 2> /dev/null) || return 1 + while IFS= read -r line; do + [[ -d "$line" && "$line" == *.app ]] || continue + case "$line" in + "/Applications/Codex.app" | "$HOME/Applications/Codex.app") continue ;; + "$HOME/Library/Caches/"*) continue ;; + esac + build=$(_codex_app_build_version "$line") || continue + if [[ -n "$resolved_build" && "$resolved_build" != "$build" ]]; then + return 1 + fi + resolved_build="$build" + done <<< "$mdfind_out" + [[ -n "$resolved_build" ]] || return 1 + printf '%s\n' "$resolved_build" +} + +# Build number of the app staged inside one first-level Installation entry. +# Only immediate .app children count, and two children disagreeing on the +# version is ambiguous and fails, so the caller falls back to the age rule. +_codex_staged_build_version() { + local entry="$1" + local staged_build="" staged_app build + for staged_app in "$entry"/*.app; do + [[ -d "$staged_app" ]] || continue + # The staged app must prove the same identity as the installed one: + # a version comparison across two different apps authorizes nothing, + # and _codex_app_build_version gates id and version from one plist. + build=$(_codex_app_build_version "$staged_app") || return 1 + if [[ -n "$staged_build" && "$staged_build" != "$build" ]]; then + return 1 + fi + staged_build="$build" + done + [[ -n "$staged_build" ]] || return 1 + printf '%s\n' "$staged_build" +} + +clean_codex_desktop_staging() { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + [[ -d "$staging_root" ]] || return 0 + if ! codex_sparkle_staging_physical_path "$staging_root" > /dev/null; then + debug_log "Codex Desktop staging skipped: unsafe staging root" + return 0 + fi + + # Version-aware eligibility (#1359): a staged build at or below the + # installed one is superseded and removable at any age, a staged build + # above it is a pending update and kept at any age, and an entry whose + # versions cannot be read exactly keeps the original age-only rule. + # Every guard between here and deletion is unchanged. + local installed_build="" + installed_build=$(_codex_installed_build_version) || installed_build="" + _MOLE_CODEX_INSTALLED_BUILD="$installed_build" + + local -a stale_entries=() + local -a stale_entry_modes=() + local stale_entry + while IFS= read -r -d '' stale_entry; do + if ! codex_sparkle_staging_physical_path "$stale_entry" > /dev/null; then + continue + fi + if ! mole_cleanup_targets_exist "$stale_entry"; then + continue + fi + local staged_build="" + if [[ -n "$installed_build" ]]; then + staged_build=$(_codex_staged_build_version "$stale_entry") || staged_build="" + fi + if [[ -n "$installed_build" && -n "$staged_build" ]]; then + if [[ "$staged_build" -le "$installed_build" ]]; then + stale_entries+=("$stale_entry") + stale_entry_modes+=("superseded") + fi + continue + fi + if [[ -n "$(command find -P "$stale_entry" -maxdepth 0 -type d \ + -mtime +"$MOLE_ORPHAN_AGE_DAYS" 2> /dev/null)" ]]; then + stale_entries+=("$stale_entry") + stale_entry_modes+=("age") + fi + done < <(command find -P "$staging_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + [[ ${#stale_entries[@]} -gt 0 ]] || return 0 + + if is_path_whitelisted "$staging_root"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Codex Desktop update staging · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Codex Desktop update staging · skipped (whitelist)" + fi + note_activity + return 0 + fi + + local process_state=0 + codex_desktop_process_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Codex" + fi + return 0 + fi + + local updater_state=0 + codex_sparkle_updater_running || updater_state=$? + if [[ $updater_state -ne 1 ]]; then + if [[ $updater_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · skipped (updater state unknown)" + note_activity + else + mole_defer_cleanup_family "Codex" + fi + return 0 + fi + + local open_file_state=0 + if codex_sparkle_staging_has_open_files "$staging_root"; then + mole_defer_cleanup_family "Codex" + return 0 + else + open_file_state=$? + fi + if [[ "$open_file_state" -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · skipped (open-file check unavailable)" + note_activity + return 0 + fi + + local stale_entry_idx=0 + for stale_entry in "${stale_entries[@]}"; do + local guarded_rc=0 + _codex_staging_safe_clean_guarded "$staging_root" "$stale_entry" \ + "${stale_entry_modes[$stale_entry_idx]}" || guarded_rc=$? + stale_entry_idx=$((stale_entry_idx + 1)) + if [[ $guarded_rc -eq 75 ]]; then + case "$_MOLE_CLEAN_GUARD_REASON" in + "process state unknown" | "updater state unknown" | "open-file check unavailable") + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + ;; + "staging entry changed") + debug_log "Codex Desktop staging entry changed before cleanup: $stale_entry" + ;; + *) mole_defer_cleanup_family "Codex" ;; + esac + return 0 + fi + done +} + +antigravity_or_gemini_running() { + mole_pgrep_any \ + -x "Antigravity" \ + -f "/Antigravity.app/" \ + -x "gemini" \ + -f "antigravity-browser-profile" +} + +chrome_devtools_mcp_running() { + mole_pgrep_any -f "chrome-devtools-mcp" +} + +is_codex_runtime_active() { + local runtime_dir="$1" + [[ -d "$runtime_dir" ]] || return 1 + [[ -f "$runtime_dir/runtime.json" ]] || return 1 + [[ -d "$runtime_dir/dependencies/node" || -d "$runtime_dir/dependencies/python" ]] || return 1 + return 0 +} + +is_codex_runtime_stale() { + local runtime_dir="$1" + [[ -d "$runtime_dir" ]] || return 1 + + local runtime_name + runtime_name="$(basename "$runtime_dir")" + case "$runtime_name" in + tmp* | temp* | *.tmp | incomplete* | *.incomplete | *-incomplete | partial* | *.partial) + return 0 + ;; + esac + + if [[ ! -e "$runtime_dir/runtime.json" && ! -e "$runtime_dir/dependencies" ]]; then + return 0 + fi + + return 1 +} + +_codex_runtime_size_human() { + local target="$1" + local output_var="$2" + local size_kb=0 + + if declare -f get_path_size_kb > /dev/null 2>&1; then + local size_rc=0 + size_kb=$(get_path_size_kb "$target" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + fi + + local formatted_size + if declare -f bytes_to_human > /dev/null 2>&1; then + formatted_size=$(bytes_to_human "$((size_kb * 1024))") + else + formatted_size="${size_kb} KB" + fi + printf -v "$output_var" '%s' "$formatted_size" +} + +_codex_runtime_delete_guard_allows() { + mole_clean_process_guard codex_runtime_process_state "Codex started" || return 1 + + if is_codex_runtime_active "$_MOLE_CODEX_RUNTIME_GUARD_PATH" || + ! is_codex_runtime_stale "$_MOLE_CODEX_RUNTIME_GUARD_PATH"; then + _MOLE_CLEAN_GUARD_REASON="runtime state changed" + return 1 + fi + return 0 +} + +_codex_runtime_safe_clean_guarded() { + local runtime_dir="$1" + local _MOLE_CODEX_RUNTIME_GUARD_PATH="$runtime_dir" + local _MOLE_CLEAN_GUARD_REASON="Codex started" + local guarded_rc=0 + + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! _codex_runtime_delete_guard_allows; then + guarded_rc=75 + else + safe_clean "$runtime_dir" "Codex CLI runtimes" + return $? + fi + else + safe_clean_guarded \ + _codex_runtime_delete_guard_allows \ + "$runtime_dir" \ + "Codex CLI runtimes" || guarded_rc=$? + fi + + if [[ $guarded_rc -eq 75 ]]; then + if [[ "$_MOLE_CLEAN_GUARD_REASON" == "Codex started" ]]; then + mole_defer_cleanup_family "Codex" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex runtimes · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + fi + return 1 + fi + return "$guarded_rc" +} + +clean_codex_runtimes() { + local runtime_root="$HOME/.cache/codex-runtimes" + [[ -d "$runtime_root" ]] || return 0 + + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$runtime_root"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Codex runtimes · would skip (whitelist)" + note_activity + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Codex runtimes · skipped (whitelist)" + fi + note_activity + return 0 + fi + + local has_stale_runtime=false + local runtime_dir + while IFS= read -r -d '' runtime_dir; do + if ! is_codex_runtime_active "$runtime_dir" && + is_codex_runtime_stale "$runtime_dir" && + mole_cleanup_targets_exist "$runtime_dir"; then + has_stale_runtime=true + break + fi + done < <(command find "$runtime_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + local process_state=0 + codex_runtime_process_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ "$has_stale_runtime" != "true" ]]; then + return 0 + elif [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex runtimes · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Codex" + fi + return 0 + fi + + local size_human="" + _codex_runtime_size_human "$runtime_root" size_human || return $? + echo -e " ${GRAY}${ICON_REVIEW}${NC} Codex runtimes · manual review (${size_human})" + note_activity + + while IFS= read -r -d '' runtime_dir; do + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$runtime_dir"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Codex runtimes · would skip (whitelist)" + note_activity + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Codex runtimes · skipped (whitelist)" + fi + note_activity + continue + fi + + if is_codex_runtime_active "$runtime_dir"; then + debug_log "Codex runtime left for manual review: $runtime_dir" + continue + fi + + if is_codex_runtime_stale "$runtime_dir"; then + _codex_runtime_safe_clean_guarded "$runtime_dir" || return 0 + else + debug_log "Codex runtime left for manual review: $runtime_dir" + fi + done < <(command find "$runtime_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) +} + +# Codex CLI and Desktop share state under ~/.codex. Keep it out of default +# cleanup so app indexes, sessions, credentials, and local thread state survive. +clean_codex_cli() { + local codex_root="$HOME/.codex" + [[ -d "$codex_root" ]] || return 0 + + debug_log "Codex CLI state left intact by default: $codex_root" +} + +_MOLE_CODEX_MARKETPLACE_STAGING_ROOT="" +_MOLE_CODEX_MARKETPLACE_STAGING_ENTRY="" + +_codex_marketplace_staging_entry_is_still_stale() { + local staging_root="$_MOLE_CODEX_MARKETPLACE_STAGING_ROOT" + local stale_entry="$_MOLE_CODEX_MARKETPLACE_STAGING_ENTRY" + [[ -n "$staging_root" && -n "$stale_entry" ]] || return 1 + [[ "${stale_entry%/*}" == "$staging_root" ]] || return 1 + [[ -d "$stale_entry" && ! -L "$stale_entry" ]] || return 1 + + local physical_before="" + local physical_after="" + physical_before=$(codex_staging_physical_path "$stale_entry" "$staging_root") || return 1 + + if [[ -z "$(command find -P "$stale_entry" -maxdepth 0 -type d \ + -mtime +"$MOLE_ORPHAN_AGE_DAYS" 2> /dev/null)" ]]; then + return 1 + fi + + physical_after=$(codex_staging_physical_path "$stale_entry" "$staging_root") || return 1 + [[ "$physical_before" == "$physical_after" ]] || return 1 + return 0 +} + +_codex_marketplace_staging_delete_guard_allows() { + _codex_marketplace_staging_entry_is_still_stale || return 1 + mole_clean_process_guard codex_runtime_process_state "Codex started" || return 1 + if codex_sparkle_staging_has_open_files "$_MOLE_CODEX_MARKETPLACE_STAGING_ROOT"; then + _MOLE_CLEAN_GUARD_REASON="open files" + return 1 + else + local open_file_state=$? + if [[ "$open_file_state" -eq 2 ]]; then + _MOLE_CLEAN_GUARD_REASON="open-file check unavailable" + return 1 + fi + fi + _codex_marketplace_staging_entry_is_still_stale || return 1 + return 0 +} + +_codex_marketplace_staging_safe_clean_guarded() { + local staging_root="$1" + local stale_entry="$2" + local display_name="$3" + _MOLE_CODEX_MARKETPLACE_STAGING_ROOT="$staging_root" + _MOLE_CODEX_MARKETPLACE_STAGING_ENTRY="$stale_entry" + local _MOLE_CLEAN_GUARD_REASON="staging entry changed" + + # No engine-absent fallback here on purpose: a second, degraded copy of the + # delete guard is a place the guarded and unguarded verdicts can disagree, + # and the audit in `tests/clean_core.bats` caps how many of those exist. + # Standalone callers provide `safe_clean_guarded` instead. + local guarded_rc=0 + safe_clean_guarded _codex_marketplace_staging_delete_guard_allows \ + "$stale_entry" "$display_name" || guarded_rc=$? + return "$guarded_rc" +} + +# Abandoned Codex marketplace staging leftovers (#1389). Completes marketplaces +# such as openai-bundled and configured marketplace dirs are never candidates; +# only exact staging prefixes under fixed roots, aged by MOLE_ORPHAN_AGE_DAYS. +# marketplace-backup-* stays out of scope until recovery semantics are clear. +clean_codex_marketplace_staging() { + local tmp_root="$HOME/.codex/.tmp" + [[ -d "$tmp_root" ]] || return 0 + + local -a staging_roots=( + "$tmp_root/bundled-marketplaces" + "$tmp_root/marketplaces/.staging" + ) + local -a staging_prefixes=( + "openai-bundled.staging-" + "marketplace-upgrade-" + "marketplace-add-" + ) + + local -a stale_entries=() + local -a stale_roots=() + local staging_root prefix stale_entry + for staging_root in "${staging_roots[@]}"; do + [[ -d "$staging_root" ]] || continue + if ! codex_staging_physical_path "$staging_root" "$staging_root" > /dev/null; then + debug_log "Codex marketplace staging skipped: unsafe root $staging_root" + continue + fi + while IFS= read -r -d '' stale_entry; do + local base + base=$(basename "$stale_entry") + local matched=false + for prefix in "${staging_prefixes[@]}"; do + case "$base" in + "$prefix"*) + matched=true + break + ;; + esac + done + [[ "$matched" == "true" ]] || continue + if ! codex_staging_physical_path "$stale_entry" "$staging_root" > /dev/null; then + continue + fi + if ! mole_cleanup_targets_exist "$stale_entry"; then + continue + fi + if [[ -n "$(command find -P "$stale_entry" -maxdepth 0 -type d \ + -mtime +"$MOLE_ORPHAN_AGE_DAYS" 2> /dev/null)" ]]; then + stale_entries+=("$stale_entry") + stale_roots+=("$staging_root") + fi + done < <(command find -P "$staging_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) done + [[ ${#stale_entries[@]} -gt 0 ]] || return 0 - # Unified output: report result, not intermediate steps - if [[ $removed_count -gt 0 ]]; then - local removed_human - removed_human=$(bytes_to_human "$((removed_size_kb * 1024))") - if [[ $skipped_protected -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human}), skipped ${skipped_protected} protected" + if is_path_whitelisted "$tmp_root" || is_path_whitelisted "$HOME/.codex"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Codex marketplace staging · would skip (whitelist)" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human})" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Codex marketplace staging · skipped (whitelist)" fi note_activity - else - if [[ $skipped_protected -gt 0 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode runtime volumes · skipped ${skipped_protected} protected, none removed" + return 0 + fi + + local process_state=0 + codex_runtime_process_state || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex marketplace staging · skipped (process state unknown)" + note_activity else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · already clean" + mole_defer_cleanup_family "Codex" fi - note_activity + return 0 fi -} -clean_dev_mobile() { - check_android_ndk - clean_xcode_documentation_cache - clean_xcode_simulator_runtime_volumes + local open_file_state=0 + # Reuse the Sparkle open-file helper shape against each staging root only + # when that root still has candidates (open files on an idle sibling root + # must not block cleanup elsewhere). + local idx=0 + for stale_entry in "${stale_entries[@]}"; do + staging_root="${stale_roots[$idx]}" + idx=$((idx + 1)) - if command -v xcrun > /dev/null 2>&1; then - debug_log "Checking for unavailable Xcode simulators" - local unavailable_before=0 - local unavailable_after=0 - local removed_unavailable=0 - local unavailable_size_kb=0 - local unavailable_size_human="0B" - local -a unavailable_udids=() - local unavailable_udid="" - - # Check if simctl is accessible and working - local simctl_available=true - if ! xcrun simctl list devices > /dev/null 2>&1; then - debug_log "simctl not accessible or CoreSimulator service not running" - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl not available" - note_activity - simctl_available=false + if is_path_whitelisted "$stale_entry"; then + continue fi - if [[ "$simctl_available" == "true" ]]; then - unavailable_before=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") - [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 - while IFS= read -r unavailable_udid; do - [[ -n "$unavailable_udid" ]] && unavailable_udids+=("$unavailable_udid") - done < <( - xcrun simctl list devices unavailable 2> /dev/null | - command sed -nE 's/.*\(([0-9A-Fa-f-]{36})\).*\(unavailable.*/\1/p' || true - ) - if [[ ${#unavailable_udids[@]} -gt 0 ]]; then - local udid - for udid in "${unavailable_udids[@]}"; do - local simulator_device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" - if [[ -d "$simulator_device_path" ]]; then - unavailable_size_kb=$((unavailable_size_kb + $(get_path_size_kb "$simulator_device_path"))) - fi - done - fi - unavailable_size_human=$(bytes_to_human "$((unavailable_size_kb * 1024))") + open_file_state=0 + if codex_sparkle_staging_has_open_files "$staging_root"; then + mole_defer_cleanup_family "Codex" + return 0 + else + open_file_state=$? + fi + if [[ "$open_file_state" -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex marketplace staging · skipped (open-file check unavailable)" + note_activity + return 0 + fi - if [[ "$DRY_RUN" == "true" ]]; then - if ((unavailable_before > 0)); then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode unavailable simulators · would clean ${unavailable_before}, ${unavailable_size_human}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" - fi - else - # Skip if no unavailable simulators - if ((unavailable_before == 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + local guarded_rc=0 + _codex_marketplace_staging_safe_clean_guarded \ + "$staging_root" "$stale_entry" \ + "Codex marketplace staging" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + case "${_MOLE_CLEAN_GUARD_REASON:-}" in + "process state unknown" | "open-file check unavailable") + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex marketplace staging · stopped (${_MOLE_CLEAN_GUARD_REASON})" note_activity - else - start_section_spinner "Checking unavailable simulators..." + ;; + "staging entry changed") + debug_log "Codex marketplace staging entry changed before cleanup: $stale_entry" + ;; + *) mole_defer_cleanup_family "Codex" ;; + esac + return 0 + fi + done +} - # Capture error output for diagnostics - local delete_output - local delete_exit_code=0 - delete_output=$(xcrun simctl delete unavailable 2>&1) || delete_exit_code=$? +# Shared Chromium Default profile caches that are safe to regenerate. +clean_chromium_default_caches() { + local profile_root="$1" + local label="$2" + local running_probe="${3:-}" + local family="${4:-$label}" - if [[ $delete_exit_code -eq 0 ]]; then - stop_section_spinner - unavailable_after=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") - [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 + [[ -d "$profile_root" ]] || return 0 - removed_unavailable=$((unavailable_before - unavailable_after)) - if ((removed_unavailable < 0)); then - removed_unavailable=0 - fi + if [[ -z "$running_probe" ]]; then + safe_clean "$profile_root/Default/Cache"/* "$label browser cache" + safe_clean "$profile_root/Default/Code Cache"/* "$label code cache" + safe_clean "$profile_root/Default/GPUCache"/* "$label GPU cache" + safe_clean "$profile_root/Default/DawnGraphiteCache"/* "$label Dawn cache" + safe_clean "$profile_root/Default/DawnWebGPUCache"/* "$label WebGPU cache" + return 0 + fi - if ((removed_unavailable > 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${unavailable_size_human}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${unavailable_size_human}" - fi - else - stop_section_spinner - - # Analyze error and provide helpful message - local error_hint="" - if echo "$delete_output" | grep -qi "permission denied"; then - error_hint=" (permission denied)" - elif echo "$delete_output" | grep -qi "in use\|busy"; then - error_hint=" (device in use)" - elif echo "$delete_output" | grep -qi "unable to boot\|failed to boot"; then - error_hint=" (boot failure)" - elif echo "$delete_output" | grep -qi "service"; then - error_hint=" (CoreSimulator service issue)" - fi + _dev_safe_clean_process_guarded "$running_probe" "$family" "$label browser cache" \ + "$profile_root/Default/Cache"/* "$label browser cache" || return 1 + _dev_safe_clean_process_guarded "$running_probe" "$family" "$label code cache" \ + "$profile_root/Default/Code Cache"/* "$label code cache" || return 1 + _dev_safe_clean_process_guarded "$running_probe" "$family" "$label GPU cache" \ + "$profile_root/Default/GPUCache"/* "$label GPU cache" || return 1 + _dev_safe_clean_process_guarded "$running_probe" "$family" "$label Dawn cache" \ + "$profile_root/Default/DawnGraphiteCache"/* "$label Dawn cache" || return 1 + _dev_safe_clean_process_guarded "$running_probe" "$family" "$label WebGPU cache" \ + "$profile_root/Default/DawnWebGPUCache"/* "$label WebGPU cache" || return 1 +} - # Try fallback: manual deletion of unavailable device directories - if [[ ${#unavailable_udids[@]} -gt 0 ]]; then - debug_log "Attempting fallback: manual deletion of unavailable simulators" - local manually_removed=0 - local manual_failed=0 - - for udid in "${unavailable_udids[@]}"; do - # Validate UUID format (36 chars: 8-4-4-4-12 hex pattern) - if [[ ! "$udid" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then - debug_log "Invalid UUID format, skipping: $udid" - ((manual_failed++)) || true - continue - fi +# Antigravity (Gemini) keeps a full Chromium profile under +# ~/.gemini/antigravity-browser-profile. Clean its regenerable browser +# caches, mirroring the Antigravity Electron cache cleanup in clean_dev_misc. +clean_antigravity_caches() { + local ag_profile="$HOME/.gemini/antigravity-browser-profile" + [[ -d "$ag_profile" ]] || return 0 - local device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" - if [[ -d "$device_path" ]]; then - # Use safe_remove for validated simulator device directory - if safe_remove "$device_path" true; then - ((manually_removed++)) || true - debug_log "Manually removed simulator: $udid" - else - ((manual_failed++)) || true - debug_log "Failed to manually remove simulator: $udid" - fi - fi - done + mole_cleanup_targets_exist \ + "$ag_profile/Default/Cache"/* \ + "$ag_profile/Default/Code Cache"/* \ + "$ag_profile/Default/GPUCache"/* \ + "$ag_profile/Default/DawnGraphiteCache"/* \ + "$ag_profile/Default/DawnWebGPUCache"/* \ + "$ag_profile/GraphiteDawnCache"/* \ + "$ag_profile/component_crx_cache"/* \ + "$ag_profile/extensions_crx_cache"/* \ + "$ag_profile/Default/Service Worker/CacheStorage"/* || return 0 - if ((manually_removed > 0)); then - if ((manual_failed == 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${unavailable_size_human}" - else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · partially cleaned ${manually_removed}/${#unavailable_udids[@]}, ${unavailable_size_human}" - fi - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" - debug_log "simctl delete error: $delete_output" - fi - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" - debug_log "simctl delete error: $delete_output" - fi - fi - fi - fi # Close if ((unavailable_before == 0)) + local process_state=0 + antigravity_or_gemini_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Antigravity/Gemini caches · skipped (process state unknown)" note_activity - fi # End of simctl_available check - fi - # Old iOS/watchOS/tvOS DeviceSupport versions (debug symbols for connected devices). - # Each iOS version creates a 1-3 GB folder of debug symbols. Only the versions - # matching currently used devices are needed; older ones regenerate on device connect. - clean_xcode_device_support ~/Library/Developer/Xcode/iOS\ DeviceSupport "iOS DeviceSupport" - clean_xcode_device_support ~/Library/Developer/Xcode/watchOS\ DeviceSupport "watchOS DeviceSupport" - clean_xcode_device_support ~/Library/Developer/Xcode/tvOS\ DeviceSupport "tvOS DeviceSupport" - # Simulator runtime caches. - safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache" - safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache" - # safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" - # safe_clean ~/.cache/flutter/* "Flutter cache" - safe_clean ~/.android/build-cache/* "Android build cache" - safe_clean ~/.android/cache/* "Android SDK cache" - safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache" - safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache" - # Expo/React Native caches (preserve state.json which contains auth tokens). - safe_clean ~/.expo/expo-go/* "Expo Go cache" - safe_clean ~/.expo/android-apk-cache/* "Expo Android APK cache" - safe_clean ~/.expo/ios-simulator-app-cache/* "Expo iOS simulator app cache" - safe_clean ~/.expo/native-modules-cache/* "Expo native modules cache" - safe_clean ~/.expo/schema-cache/* "Expo schema cache" - safe_clean ~/.expo/template-cache/* "Expo template cache" - safe_clean ~/.expo/versions-cache/* "Expo versions cache" -} -# JVM ecosystem caches. -# Gradle: Respects whitelist, cleaned when not protected via: mo clean --whitelist -clean_dev_jvm() { - # Source Maven cleanup module (requires bash for BASH_SOURCE) - # shellcheck disable=SC1091 - source "$(dirname "${BASH_SOURCE[0]}")/maven.sh" 2> /dev/null || true - if declare -f clean_maven_repository > /dev/null 2>&1; then - clean_maven_repository + else + mole_defer_cleanup_family "Antigravity/Gemini" + fi + return 0 fi - safe_clean ~/.sbt/* "SBT cache" - safe_clean ~/.ivy2/cache/* "Ivy cache" - safe_clean ~/.gradle/caches/* "Gradle cache" - safe_clean ~/.gradle/daemon/* "Gradle daemon" -} -# JetBrains Toolbox old IDE versions (keep current + recent backup). -clean_dev_jetbrains_toolbox() { - local toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" - [[ -d "$toolbox_root" ]] || return 0 - local keep_previous="${MOLE_JETBRAINS_TOOLBOX_KEEP:-1}" - [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1 - - # Save and filter whitelist patterns for toolbox path - local whitelist_overridden="false" - local -a original_whitelist=() - if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then - original_whitelist=("${WHITELIST_PATTERNS[@]}") - local -a filtered_whitelist=() - local pattern - for pattern in "${WHITELIST_PATTERNS[@]}"; do - [[ "$toolbox_root" == "$pattern" || "$pattern" == "$toolbox_root"* ]] && continue - filtered_whitelist+=("$pattern") - done - WHITELIST_PATTERNS=("${filtered_whitelist[@]+${filtered_whitelist[@]}}") - whitelist_overridden="true" - fi + clean_chromium_default_caches \ + "$ag_profile" \ + "Antigravity" \ + antigravity_or_gemini_running \ + "Antigravity/Gemini" || return 0 + _dev_safe_clean_process_guarded antigravity_or_gemini_running "Antigravity/Gemini" \ + "Antigravity Graphite cache" "$ag_profile/GraphiteDawnCache"/* "Antigravity Graphite cache" || return 0 + _dev_safe_clean_process_guarded antigravity_or_gemini_running "Antigravity/Gemini" \ + "Antigravity component cache" "$ag_profile/component_crx_cache"/* "Antigravity component cache" || return 0 + _dev_safe_clean_process_guarded antigravity_or_gemini_running "Antigravity/Gemini" \ + "Antigravity extension cache" "$ag_profile/extensions_crx_cache"/* "Antigravity extension cache" || return 0 + _dev_clean_service_worker_process_guarded \ + antigravity_or_gemini_running \ + "Antigravity/Gemini" \ + "Antigravity Service Worker" \ + "Antigravity" \ + "$ag_profile/Default/Service Worker/CacheStorage" || return 0 + # Never clean ~/.gemini/tmp: despite the name it stores gemini-cli + # conversation checkpoints and prompt history (AI chat state, not temp). +} - # Helper to restore whitelist on exit - _restore_whitelist() { - [[ "$whitelist_overridden" == "true" ]] && WHITELIST_PATTERNS=("${original_whitelist[@]}") - return 0 - } +clean_chrome_devtools_mcp_caches() { + local mcp_profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + [[ -d "$mcp_profile" ]] || return 0 - local -a product_dirs=() - while IFS= read -r -d '' product_dir; do - product_dirs+=("$product_dir") - done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + mole_cleanup_targets_exist \ + "$mcp_profile/Default/Cache"/* \ + "$mcp_profile/Default/Code Cache"/* \ + "$mcp_profile/Default/GPUCache"/* \ + "$mcp_profile/Default/DawnGraphiteCache"/* \ + "$mcp_profile/Default/DawnWebGPUCache"/* \ + "$mcp_profile/Default/DawnCache"/* \ + "$mcp_profile/Default/GrShaderCache"/* \ + "$mcp_profile/Default/GraphiteDawnCache"/* \ + "$mcp_profile/GraphiteDawnCache"/* \ + "$mcp_profile/component_crx_cache"/* \ + "$mcp_profile/extensions_crx_cache"/* \ + "$mcp_profile/Default/Service Worker/CacheStorage"/* || return 0 - if [[ ${#product_dirs[@]} -eq 0 ]]; then - _restore_whitelist + local process_state=0 + chrome_devtools_mcp_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome DevTools MCP caches · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Chrome DevTools MCP" + fi return 0 fi - local product_dir - for product_dir in "${product_dirs[@]}"; do - while IFS= read -r -d '' channel_dir; do - local current_link="" - local current_real="" - if [[ -L "$channel_dir/current" ]]; then - current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) - if [[ -n "$current_link" ]]; then - if [[ "$current_link" == /* ]]; then - current_real="$current_link" - else - current_real="$channel_dir/$current_link" - fi - fi - elif [[ -d "$channel_dir/current" ]]; then - current_real="$channel_dir/current" - fi - - local -a version_dirs=() - while IFS= read -r -d '' version_dir; do - local name - name=$(basename "$version_dir") - - [[ "$name" == "current" ]] && continue - [[ "$name" == .* ]] && continue - [[ "$name" == "plugins" || "$name" == "plugins-lib" || "$name" == "plugins-libs" ]] && continue - [[ -n "$current_real" && "$version_dir" == "$current_real" ]] && continue - [[ ! "$name" =~ ^[0-9] ]] && continue - - version_dirs+=("$version_dir") - done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) - - [[ ${#version_dirs[@]} -eq 0 ]] && continue - - local -a sorted_dirs=() - while IFS= read -r line; do - local dir_path="${line#* }" - sorted_dirs+=("$dir_path") - done < <( - for version_dir in "${version_dirs[@]}"; do - local mtime - mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") - printf '%s %s\n' "$mtime" "$version_dir" - done | sort -rn - ) - - if [[ ${#sorted_dirs[@]} -le "$keep_previous" ]]; then - continue - fi - - local idx=0 - local dir_path - for dir_path in "${sorted_dirs[@]}"; do - if [[ $idx -lt $keep_previous ]]; then - idx=$((idx + 1)) - continue - fi - safe_clean "$dir_path" "JetBrains Toolbox old IDE version" - note_activity - idx=$((idx + 1)) - done - done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) - done + clean_chromium_default_caches \ + "$mcp_profile" \ + "Chrome DevTools MCP" \ + chrome_devtools_mcp_running \ + "Chrome DevTools MCP" || return 0 + _dev_safe_clean_process_guarded chrome_devtools_mcp_running "Chrome DevTools MCP" \ + "Chrome DevTools MCP Dawn cache" "$mcp_profile/Default/DawnCache"/* "Chrome DevTools MCP Dawn cache" || return 0 + _dev_safe_clean_process_guarded chrome_devtools_mcp_running "Chrome DevTools MCP" \ + "Chrome DevTools MCP shader cache" "$mcp_profile/Default/GrShaderCache"/* "Chrome DevTools MCP shader cache" || return 0 + _dev_safe_clean_process_guarded chrome_devtools_mcp_running "Chrome DevTools MCP" \ + "Chrome DevTools MCP Graphite cache" "$mcp_profile/Default/GraphiteDawnCache"/* \ + "$mcp_profile/GraphiteDawnCache"/* "Chrome DevTools MCP Graphite cache" || return 0 + _dev_safe_clean_process_guarded chrome_devtools_mcp_running "Chrome DevTools MCP" \ + "Chrome DevTools MCP component cache" "$mcp_profile/component_crx_cache"/* "Chrome DevTools MCP component cache" || return 0 + _dev_safe_clean_process_guarded chrome_devtools_mcp_running "Chrome DevTools MCP" \ + "Chrome DevTools MCP extension cache" "$mcp_profile/extensions_crx_cache"/* "Chrome DevTools MCP extension cache" || return 0 - _restore_whitelist -} -# Other language tool caches. -clean_dev_other_langs() { - safe_clean ~/.bundle/cache/* "Ruby Bundler cache" - safe_clean ~/.composer/cache/* "PHP Composer cache" - safe_clean ~/.nuget/packages/* "NuGet packages cache" - # safe_clean ~/.pub-cache/* "Dart Pub cache" - safe_clean ~/.cache/bazel/* "Bazel cache" - safe_clean ~/.cache/zig/* "Zig cache" - safe_clean ~/Library/Caches/deno/* "Deno cache" -} -# CI/CD and DevOps caches. -clean_dev_cicd() { - safe_clean ~/.cache/terraform/* "Terraform cache" - safe_clean ~/.grafana/cache/* "Grafana cache" - safe_clean ~/.prometheus/data/wal/* "Prometheus WAL cache" - safe_clean ~/.jenkins/workspace/*/target/* "Jenkins workspace cache" - safe_clean ~/.cache/gitlab-runner/* "GitLab Runner cache" - safe_clean ~/.github/cache/* "GitHub Actions cache" - safe_clean ~/.circleci/cache/* "CircleCI cache" - safe_clean ~/.sonar/* "SonarQube cache" -} -# Database tool caches. -clean_dev_database() { - safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache" - safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache" - safe_clean ~/Library/Caches/redis-desktop-manager/* "Redis Desktop Manager cache" - safe_clean ~/Library/Caches/com.navicat.* "Navicat cache" - safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache" - safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache" -} -# API/debugging tool caches. -clean_dev_api_tools() { - safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache" - safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache" - safe_clean ~/Library/Caches/com.tinyapp.TablePlus/* "TablePlus cache" - safe_clean ~/Library/Caches/com.getpaw.Paw/* "Paw API cache" - safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" - safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" + if declare -f clean_service_worker_cache > /dev/null 2>&1; then + _dev_clean_service_worker_process_guarded \ + chrome_devtools_mcp_running \ + "Chrome DevTools MCP" \ + "Chrome DevTools MCP Service Worker" \ + "Chrome DevTools MCP" \ + "$mcp_profile/Default/Service Worker/CacheStorage" || return 0 + fi } + # Misc dev tool caches. clean_dev_misc() { safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache" @@ -962,23 +4256,73 @@ clean_dev_misc() { safe_clean ~/Library/Caches/SentryCrash/* "Sentry crash reports" safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports" safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data" - safe_clean ~/Library/Application\ Support/Antigravity/Cache/* "Antigravity cache" - safe_clean ~/Library/Application\ Support/Antigravity/Code\ Cache/* "Antigravity code cache" - safe_clean ~/Library/Application\ Support/Antigravity/GPUCache/* "Antigravity GPU cache" - safe_clean ~/Library/Application\ Support/Antigravity/DawnGraphiteCache/* "Antigravity Dawn cache" - safe_clean ~/Library/Application\ Support/Antigravity/DawnWebGPUCache/* "Antigravity WebGPU cache" + if [[ -d ~/Library/Application\ Support/Antigravity ]]; then + safe_clean ~/Library/Application\ Support/Antigravity/Cache/* "Antigravity cache" + safe_clean ~/Library/Application\ Support/Antigravity/Code\ Cache/* "Antigravity code cache" + safe_clean ~/Library/Application\ Support/Antigravity/GPUCache/* "Antigravity GPU cache" + safe_clean ~/Library/Application\ Support/Antigravity/DawnGraphiteCache/* "Antigravity Dawn cache" + safe_clean ~/Library/Application\ Support/Antigravity/DawnWebGPUCache/* "Antigravity WebGPU cache" + fi + # Antigravity browser profile caches (~/.gemini) + clean_antigravity_caches # Filo (Electron) - safe_clean ~/Library/Application\ Support/Filo/production/Cache/* "Filo cache" - safe_clean ~/Library/Application\ Support/Filo/production/Code\ Cache/* "Filo code cache" - safe_clean ~/Library/Application\ Support/Filo/production/GPUCache/* "Filo GPU cache" - safe_clean ~/Library/Application\ Support/Filo/production/DawnGraphiteCache/* "Filo Dawn cache" - safe_clean ~/Library/Application\ Support/Filo/production/DawnWebGPUCache/* "Filo WebGPU cache" + if [[ -d ~/Library/Application\ Support/Filo ]]; then + safe_clean ~/Library/Application\ Support/Filo/production/Cache/* "Filo cache" + safe_clean ~/Library/Application\ Support/Filo/production/Code\ Cache/* "Filo code cache" + safe_clean ~/Library/Application\ Support/Filo/production/GPUCache/* "Filo GPU cache" + safe_clean ~/Library/Application\ Support/Filo/production/DawnGraphiteCache/* "Filo Dawn cache" + safe_clean ~/Library/Application\ Support/Filo/production/DawnWebGPUCache/* "Filo WebGPU cache" + fi # Claude (Electron) - safe_clean ~/Library/Application\ Support/Claude/Cache/* "Claude cache" - safe_clean ~/Library/Application\ Support/Claude/Code\ Cache/* "Claude code cache" - safe_clean ~/Library/Application\ Support/Claude/GPUCache/* "Claude GPU cache" - safe_clean ~/Library/Application\ Support/Claude/DawnGraphiteCache/* "Claude Dawn cache" - safe_clean ~/Library/Application\ Support/Claude/DawnWebGPUCache/* "Claude WebGPU cache" + if [[ -d ~/Library/Application\ Support/Claude ]]; then + safe_clean ~/Library/Application\ Support/Claude/Cache/* "Claude cache" + safe_clean ~/Library/Application\ Support/Claude/Code\ Cache/* "Claude code cache" + safe_clean ~/Library/Application\ Support/Claude/GPUCache/* "Claude GPU cache" + safe_clean ~/Library/Application\ Support/Claude/DawnGraphiteCache/* "Claude Dawn cache" + safe_clean ~/Library/Application\ Support/Claude/DawnWebGPUCache/* "Claude WebGPU cache" + safe_clean ~/Library/Application\ Support/Claude/sentry/* "Claude sentry cache" + fi + # Qoder (VS Code fork, Electron) + if [[ -d ~/Library/Application\ Support/Qoder ]]; then + safe_clean ~/Library/Application\ Support/Qoder/Cache/* "Qoder cache" + safe_clean ~/Library/Application\ Support/Qoder/CachedData/* "Qoder cached data" + safe_clean ~/Library/Application\ Support/Qoder/CachedExtensionVSIXs/* "Qoder extension cache" + safe_clean ~/Library/Application\ Support/Qoder/Code\ Cache/* "Qoder code cache" + safe_clean ~/Library/Application\ Support/Qoder/GPUCache/* "Qoder GPU cache" + safe_clean ~/Library/Application\ Support/Qoder/DawnGraphiteCache/* "Qoder Dawn cache" + safe_clean ~/Library/Application\ Support/Qoder/DawnWebGPUCache/* "Qoder WebGPU cache" + safe_clean ~/Library/Application\ Support/Qoder/logs/* "Qoder logs" + fi + # Prisma ORM engine binaries cache + safe_clean ~/.cache/prisma/* "Prisma cache" + # OpenCode AI tool cache + safe_clean ~/.cache/opencode/* "OpenCode cache" + # OpenCode snapshots back restore/revert, while diagnostic JSONL logs can + # contain prompts and transcript events. Neither is default-cleanable. + # Codex Chromium runtime caches are separate from its protected Application + # Support profile and are restricted to measured fixed leaves. + clean_codex_desktop_caches + # Codex Desktop runtimes contain active Node/Python dependencies. + clean_codex_runtimes + # Sparkle uses random first-level directories for each update installation. + clean_codex_desktop_staging + # Abandoned marketplace staging under ~/.codex/.tmp (completed marketplaces stay). + clean_codex_marketplace_staging + # Codex CLI working-directory caches (~/.codex) + clean_codex_cli + # Cursor Agent session logs (versions cleaned separately in clean_dev_ai_agents) + [[ -d "$HOME/.local/share/cursor-agent" ]] && safe_find_delete "$HOME/.local/share/cursor-agent" "*.log" "$MOLE_LOG_AGE_DAYS" "f" + # Playwright browser revisions are hard-protected by should_protect_path, + # so no safe_clean call here can ever remove them. They cost a 100-500 MB + # CDN re-download and a stale registry link does not prove the user is done + # with the revision. Keep this surface read-only. + # Chrome DevTools MCP keeps a Chromium profile; clean only rebuildable caches. + clean_chrome_devtools_mcp_caches + # Claude Code state under ~/.claude can include persistent memory, + # plugin registry data, hooks, and session context. Do not clean it + # automatically; users can remove specific paths manually if needed. + # Wondershare orphan installer payload (bundle ID differs from live app) + safe_clean ~/Library/Application\ Support/com.wondershare.Installer/* "Wondershare installer payload" } # Shell and VCS leftovers. clean_dev_shell() { @@ -997,10 +4341,6 @@ clean_dev_network() { safe_clean ~/Library/Caches/curl/* "macOS curl cache" safe_clean ~/Library/Caches/wget/* "macOS wget cache" } -# Orphaned SQLite temp files (-shm/-wal). Disabled due to low ROI. -clean_sqlite_temp_files() { - return 0 -} # Elixir/Erlang ecosystem. # Note: ~/.mix/archives contains installed Mix tools - excluded from cleanup clean_dev_elixir() { @@ -1015,65 +4355,90 @@ clean_dev_haskell() { clean_dev_ocaml() { safe_clean ~/.opam/download-cache/* "Opam cache" } -# Editor caches. -# Note: ~/Library/Application Support/Code/User/workspaceStorage contains workspace settings - excluded from cleanup -clean_dev_editors() { - safe_clean ~/Library/Caches/com.microsoft.VSCode/Cache/* "VS Code cached data" - safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code cached data" - safe_clean ~/Library/Application\ Support/Code/DawnGraphiteCache/* "VS Code Dawn cache" - safe_clean ~/Library/Application\ Support/Code/DawnWebGPUCache/* "VS Code WebGPU cache" - safe_clean ~/Library/Application\ Support/Code/GPUCache/* "VS Code GPU cache" - safe_clean ~/Library/Application\ Support/Code/CachedExtensionVSIXs/* "VS Code extension cache" - safe_clean ~/Library/Caches/Zed/* "Zed cache" + +_run_developer_cleanup_step() { + local strict=false + if [[ "${1:-}" == "--strict" ]]; then + strict=true + shift + fi + + local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ $pending_clean_cancel -eq 124 || $pending_clean_cancel -ge 128 ]]; then + return "$pending_clean_cancel" + fi + + local step_rc=0 + "$@" || step_rc=$? + if [[ $step_rc -eq 124 || $step_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$step_rc" + return "$step_rc" + fi + + pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ $pending_clean_cancel -eq 124 || $pending_clean_cancel -ge 128 ]]; then + return "$pending_clean_cancel" + fi + [[ "$strict" == "true" && $step_rc -ne 0 ]] && return "$step_rc" + return 0 } + # Main developer tools cleanup sequence. clean_developer_tools() { stop_section_spinner # CLI tools and languages - clean_sqlite_temp_files - clean_dev_npm - clean_dev_python - clean_dev_go - clean_dev_rust - check_rust_toolchains - clean_dev_docker - clean_dev_cloud - clean_dev_nix - clean_dev_shell - clean_dev_frontend - clean_project_caches - clean_dev_mobile - clean_dev_jvm - clean_dev_jetbrains_toolbox - clean_dev_other_langs - clean_dev_cicd - clean_dev_database - clean_dev_api_tools - clean_dev_network - clean_dev_misc - clean_dev_elixir - clean_dev_haskell - clean_dev_ocaml + _run_developer_cleanup_step clean_dev_npm || return $? + _run_developer_cleanup_step clean_dev_python || return $? + _run_developer_cleanup_step clean_dev_go || return $? + _run_developer_cleanup_step clean_dev_mise || return $? + _run_developer_cleanup_step clean_dev_rust || return $? + _run_developer_cleanup_step check_rust_toolchains || return $? + _run_developer_cleanup_step clean_dev_ruby || return $? + _run_developer_cleanup_step clean_dev_perl || return $? + _run_developer_cleanup_step clean_dev_docker || return $? + _run_developer_cleanup_step clean_dev_cloud || return $? + _run_developer_cleanup_step clean_dev_nix || return $? + _run_developer_cleanup_step clean_dev_shell || return $? + _run_developer_cleanup_step clean_dev_frontend || return $? + _run_developer_cleanup_step clean_project_caches || return $? + _run_developer_cleanup_step --strict clean_dev_mobile || return $? + _run_developer_cleanup_step clean_dev_jvm || return $? + _run_developer_cleanup_step clean_dev_jetbrains_toolbox || return $? + _run_developer_cleanup_step clean_dev_jetbrains_logs || return $? + _run_developer_cleanup_step --strict clean_dev_ai_agents || return $? + _run_developer_cleanup_step clean_dev_other_langs || return $? + _run_developer_cleanup_step clean_dev_cicd || return $? + _run_developer_cleanup_step clean_dev_database || return $? + _run_developer_cleanup_step clean_dev_api_tools || return $? + _run_developer_cleanup_step clean_dev_network || return $? + _run_developer_cleanup_step clean_dev_misc || return $? + _run_developer_cleanup_step clean_dev_elixir || return $? + _run_developer_cleanup_step clean_dev_haskell || return $? + _run_developer_cleanup_step clean_dev_ocaml || return $? # GUI developer applications - clean_xcode_tools - clean_code_editors + _run_developer_cleanup_step --strict clean_xcode_tools || return $? + _run_developer_cleanup_step clean_code_editors || return $? - # Homebrew - safe_clean ~/Library/Caches/Homebrew/* "Homebrew cache" + # Homebrew: only blanket-clean downloads/. Wiping api/ (JSON that needs a + # network re-download) and bootsnap/ (recompiled Ruby) leaves brew silent + # for ~94s before its first output on the next run. + _run_developer_cleanup_step \ + safe_clean ~/Library/Caches/Homebrew/downloads/* "Homebrew cache" || return $? local brew_lock_dirs=( "/opt/homebrew/var/homebrew/locks" "/usr/local/var/homebrew/locks" ) for lock_dir in "${brew_lock_dirs[@]}"; do if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then - safe_clean "$lock_dir"/* "Homebrew lock files" + _run_developer_cleanup_step \ + safe_clean "$lock_dir"/* "Homebrew lock files" || return $? elif [[ -d "$lock_dir" ]]; then if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then debug_log "Skipping read-only Homebrew locks in $lock_dir" fi fi done - clean_homebrew + _run_developer_cleanup_step clean_homebrew || return $? } diff --git a/Resources/mole/lib/clean/hints.sh b/Resources/mole/lib/clean/hints.sh index f6538bf..bc225d2 100644 --- a/Resources/mole/lib/clean/hints.sh +++ b/Resources/mole/lib/clean/hints.sh @@ -54,6 +54,106 @@ hint_get_path_size_kb_with_timeout() { printf '%s\n' "$size_kb" } +# shellcheck disable=SC2329 +hint_collect_child_dirs_with_timeout() { + local parent="$1" + local output_file="$2" + local timeout_seconds="${3:-1}" + + [[ -d "$parent" ]] || return 1 + : > "$output_file" || return 1 + + # 1s: shallow directory listing should be near-instant on healthy local + # paths. Slow/cloud-backed roots are skipped so `mo clean` never appears + # stuck while rendering this non-destructive hint. + run_with_timeout "$timeout_seconds" find "$parent" -mindepth 1 -maxdepth 1 -type d -print0 > "$output_file" 2> /dev/null +} + +# shellcheck disable=SC2329 +hint_extract_launch_agent_program_path() { + local plist="$1" + local program="" + + if ! program=$(plutil -extract Program raw "$plist" 2> /dev/null); then + program="" + fi + if [[ -z "$program" ]]; then + if ! program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null); then + program="" + fi + fi + + printf '%s\n' "$program" +} + +# shellcheck disable=SC2329 +hint_launch_agent_has_mach_services() { + local plist="$1" + plutil -extract MachServices raw "$plist" > /dev/null 2>&1 +} + +# shellcheck disable=SC2329 +hint_extract_launch_agent_associated_bundle() { + local plist="$1" + local associated="" + + if ! associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null); then + associated="" + fi + if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then + if ! associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null); then + associated="" + fi + if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then + associated="" + fi + fi + + printf '%s\n' "$associated" +} + +# shellcheck disable=SC2329 +hint_is_app_scoped_launch_target() { + local program="$1" + + case "$program" in + /Applications/Setapp/*.app/* | \ + /Applications/*.app/* | \ + "$HOME"/Applications/*.app/* | \ + "$HOME"/Library/Application\ Support/*.app/* | \ + /Library/Input\ Methods/*.app/* | \ + /Library/PrivilegedHelperTools/*) + return 0 + ;; + esac + + return 1 +} + +# shellcheck disable=SC2329 +hint_is_system_binary() { + local program="$1" + + case "$program" in + /bin/* | /sbin/* | /usr/bin/* | /usr/sbin/* | /usr/libexec/*) + return 0 + ;; + esac + + return 1 +} + +# shellcheck disable=SC2329 +hint_launch_agent_bundle_exists() { + local bundle_id="$1" + + [[ -z "$bundle_id" ]] && return 1 + + # Delegate to the shared resolver so Spotlight misses (e.g. KeePassXC + # installed via Homebrew) fall back to a direct /Applications scan. See #732. + bundle_has_installed_app "$bundle_id" +} + # shellcheck disable=SC2329 record_project_artifact_hint() { local path="$1" @@ -100,11 +200,21 @@ probe_project_artifact_hints() { PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0 PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=false local max_projects=200 local max_projects_per_root=0 local max_nested_per_project=120 local max_matches=12 + local list_timeout_seconds=1 + + # Wall-clock ceiling for the whole walk. Per-listing finds are already + # capped at 1s, but with up to max_projects roots the cumulative scan can + # stretch into minutes on busy machines and look hung (#1053). Checked + # between iterations so the section degrades gracefully instead of stalling. + local hint_budget_seconds="${MOLE_TIMEOUT_HINT_SCAN_SEC:-15}" + [[ "$hint_budget_seconds" =~ ^[0-9]+$ ]] || hint_budget_seconds=15 + local scan_deadline=$((SECONDS + hint_budget_seconds)) local -a target_names=() while IFS= read -r target_name; do @@ -125,18 +235,21 @@ probe_project_artifact_hints() { fi [[ $max_projects_per_root -gt $max_projects ]] && max_projects_per_root=$max_projects - local nullglob_was_set=0 - if shopt -q nullglob; then - nullglob_was_set=1 - fi - shopt -s nullglob - local scanned_projects=0 local stop_scan=false local root project_dir nested_dir target_name candidate + local project_dirs_file nested_dirs_file for root in "${scan_roots[@]}"; do + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + break + fi [[ -d "$root" ]] || continue + # In-place spinner text swap per root: a multi-second walk with a + # static label reads as a hang, a moving path reads as progress. + start_section_spinner "Scanning projects · ${root/#$HOME/~}" local root_projects_scanned=0 if is_quick_purge_project_root "$root"; then @@ -162,9 +275,26 @@ probe_project_artifact_hints() { continue fi - for project_dir in "$root"/*/; do + project_dirs_file=$(mktemp_file "project_artifact_dirs") || { + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + continue + } + if ! hint_collect_child_dirs_with_timeout "$root" "$project_dirs_file" "$list_timeout_seconds"; then + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + rm -f "$project_dirs_file" + continue + fi + + while IFS= read -r -d '' project_dir; do + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + stop_scan=true + break + fi [[ -d "$project_dir" ]] || continue - project_dir="${project_dir%/}" local project_name project_name=$(basename "$project_dir") @@ -191,10 +321,34 @@ probe_project_artifact_hints() { done [[ "$stop_scan" == "true" ]] && break + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + stop_scan=true + break + fi + local nested_count=0 - for nested_dir in "$project_dir"/*/; do + nested_dirs_file=$(mktemp_file "project_artifact_nested") || { + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + continue + } + if ! hint_collect_child_dirs_with_timeout "$project_dir" "$nested_dirs_file" "$list_timeout_seconds"; then + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + rm -f "$nested_dirs_file" + continue + fi + + while IFS= read -r -d '' nested_dir; do + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + stop_scan=true + break + fi [[ -d "$nested_dir" ]] || continue - nested_dir="${nested_dir%/}" local nested_name nested_name=$(basename "$nested_dir") @@ -217,20 +371,16 @@ probe_project_artifact_hints() { record_project_artifact_hint "$candidate" fi done - - [[ "$stop_scan" == "true" ]] && break - done + done < "$nested_dirs_file" + rm -f "$nested_dirs_file" [[ "$stop_scan" == "true" ]] && break - done + done < "$project_dirs_file" + rm -f "$project_dirs_file" [[ "$stop_scan" == "true" ]] && break done - if [[ $nullglob_was_set -eq 0 ]]; then - shopt -u nullglob - fi - if [[ $PROJECT_ARTIFACT_HINT_COUNT -gt 0 ]]; then PROJECT_ARTIFACT_HINT_DETECTED=true fi @@ -244,74 +394,20 @@ probe_project_artifact_hints() { return 0 } -# shellcheck disable=SC2329 -show_system_data_hint_notice() { - local min_gb=2 - local timeout_seconds="0.8" - local max_hits=3 - - local threshold_kb=$((min_gb * 1024 * 1024)) - local -a clue_labels=() - local -a clue_sizes=() - local -a clue_paths=() - - local -a labels=( - "Xcode DerivedData" - "Xcode Archives" - "iPhone backups" - "Simulator data" - "Docker Desktop data" - "Mail data" - ) - local -a paths=( - "$HOME/Library/Developer/Xcode/DerivedData" - "$HOME/Library/Developer/Xcode/Archives" - "$HOME/Library/Application Support/MobileSync/Backup" - "$HOME/Library/Developer/CoreSimulator/Devices" - "$HOME/Library/Containers/com.docker.docker/Data" - "$HOME/Library/Mail" - ) - - local i - for i in "${!paths[@]}"; do - local path="${paths[$i]}" - [[ -d "$path" ]] || continue - - local size_kb="" - if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then - if [[ "$size_kb" -ge "$threshold_kb" ]]; then - clue_labels+=("${labels[$i]}") - clue_sizes+=("$size_kb") - clue_paths+=("${path/#$HOME/~}") - if [[ ${#clue_labels[@]} -ge $max_hits ]]; then - break - fi - fi - fi - done - - if [[ ${#clue_labels[@]} -eq 0 ]]; then - note_activity - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No common System Data clues detected" - return 0 - fi - - note_activity - - for i in "${!clue_labels[@]}"; do - local human_size - human_size=$(bytes_to_human "$((clue_sizes[i] * 1024))") - echo -e " ${GREEN}${ICON_LIST}${NC} ${clue_labels[$i]}: ${human_size}" - echo -e " ${GRAY}${ICON_SUBLIST}${NC} Path: ${GRAY}${clue_paths[$i]}${NC}" - done - echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo analyze, Device backups, docker system df" -} - # shellcheck disable=SC2329 show_project_artifact_hint_notice() { + # The probe walks up to 200 project roots and du-samples candidates under + # a 15s budget; without a loading state the section title just sits there + # and the result row pops out of nowhere. + start_section_spinner "Scanning project artifacts..." probe_project_artifact_hints + stop_section_spinner if [[ "$PROJECT_ARTIFACT_HINT_DETECTED" != "true" ]]; then + if [[ "${PROJECT_ARTIFACT_HINT_SCAN_SKIPPED:-false}" == "true" ]]; then + note_activity + echo -e " ${YELLOW}${ICON_WARNING}${NC} Build artifacts · scan skipped · ${GRAY}mo purge${NC}" + fi return 0 fi @@ -320,14 +416,13 @@ show_project_artifact_hint_notice() { local hint_count_label="$PROJECT_ARTIFACT_HINT_COUNT" [[ "$PROJECT_ARTIFACT_HINT_TRUNCATED" == "true" ]] && hint_count_label="${hint_count_label}+" - local example_text="" - if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -gt 0 ]]; then - example_text="${PROJECT_ARTIFACT_HINT_EXAMPLES[0]}" - if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -gt 1 ]]; then - example_text+=", ${PROJECT_ARTIFACT_HINT_EXAMPLES[1]}" - fi + local review_command="mo purge" + if [[ $PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES -gt 0 && $PROJECT_ARTIFACT_HINT_ESTIMATED_KB -eq 0 ]]; then + review_command="mo purge --include-empty" fi + # One compact row: "Build artifacts · 15+ dirs, 985.6MB+ · mo purge". + local detail="${hint_count_label} dirs" if [[ $PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES -gt 0 ]]; then local estimate_human estimate_human=$(bytes_to_human "$((PROJECT_ARTIFACT_HINT_ESTIMATED_KB * 1024))") @@ -338,16 +433,86 @@ show_project_artifact_hint_notice() { fi if [[ "$estimate_is_partial" == "true" ]]; then - echo -e " ${GREEN}${ICON_LIST}${NC} ${GREEN}${hint_count_label}${NC} candidates, at least ${estimate_human} sampled from ${PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES} items" + detail+=", ${estimate_human}+" else - echo -e " ${GREEN}${ICON_LIST}${NC} ${GREEN}${hint_count_label}${NC} candidates, sampled ${estimate_human}" + detail+=", ${estimate_human}" fi - else - echo -e " ${GREEN}${ICON_LIST}${NC} ${GREEN}${hint_count_label}${NC} candidates" fi - if [[ -n "$example_text" ]]; then - echo -e " ${GRAY}${ICON_SUBLIST}${NC} Examples: ${GRAY}${example_text}${NC}" + local partial_note="" + if [[ "${PROJECT_ARTIFACT_HINT_SCAN_SKIPPED:-false}" == "true" ]]; then + partial_note=" ${GRAY}(partial scan)${NC}" fi - echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge" + + echo -e " ${YELLOW}${ICON_REVIEW}${NC} Build artifacts · ${GREEN}${detail}${NC} · ${GRAY}${review_command}${NC}${partial_note}" +} + +# shellcheck disable=SC2329 +show_user_launch_agent_hint_notice() { + local launch_agents_dir="$HOME/Library/LaunchAgents" + [[ -d "$launch_agents_dir" ]] || return 0 + + local max_hits=3 + local -a sources=() + local -a reasons=() + local -a targets=() + local plist + + # Per-plist target probes add up; keep loading feedback on screen. + start_section_spinner "Checking login items..." + + while IFS= read -r -d '' plist; do + local filename + filename=$(basename "$plist") + [[ "$filename" == com.apple.* ]] && continue + + local reason="" + local target="" + local program="" + local associated="" + + program=$(hint_extract_launch_agent_program_path "$plist") + if [[ -z "$program" ]] && hint_launch_agent_has_mach_services "$plist"; then + continue + fi + if [[ -n "$program" ]] && hint_is_system_binary "$program"; then + continue + fi + if [[ "$program" == /* && -f "$program" && -x "$program" ]]; then + continue + elif [[ -n "$program" ]] && hint_is_app_scoped_launch_target "$program"; then + if [[ ! -e "$program" ]]; then + reason="Missing app/helper target" + target="${program/#$HOME/~}" + elif [[ ! -f "$program" || ! -x "$program" ]]; then + reason="Program target is not executable" + target="${program/#$HOME/~}" + fi + else + associated=$(hint_extract_launch_agent_associated_bundle "$plist") + if [[ -n "$associated" ]] && ! hint_launch_agent_bundle_exists "$associated"; then + reason="Associated app not found" + target="$associated" + fi + fi + + if [[ -n "$reason" ]]; then + sources+=("${plist/#$HOME/~}") + reasons+=("$reason") + targets+=("$target") + if [[ ${#sources[@]} -ge $max_hits ]]; then + break + fi + fi + done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + + stop_section_spinner + [[ ${#sources[@]} -eq 0 ]] && return 0 + + note_activity + + local i + for i in "${!sources[@]}"; do + echo -e " ${YELLOW}${ICON_WARNING}${NC} Stale login item · ${sources[$i]} · ${GRAY}${reasons[$i]}: ${targets[$i]} · review before removing${NC}" + done } diff --git a/Resources/mole/lib/clean/project.sh b/Resources/mole/lib/clean/project.sh index c1a9ee7..8c0df0b 100644 --- a/Resources/mole/lib/clean/project.sh +++ b/Resources/mole/lib/clean/project.sh @@ -26,6 +26,7 @@ readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths" # Resolved search paths. PURGE_SEARCH_PATHS=() +PURGE_CATEGORY_FULL_PATHS_ARRAY=() # Project indicators for container detection. # Monorepo indicators (higher priority) @@ -74,7 +75,9 @@ discover_project_dirs() { for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do if [[ -d "$path" ]]; then - discovered+=("$path") + # Resolve to canonical casing to avoid duplicates on + # case-insensitive filesystems (macOS APFS). + discovered+=("$(mole_purge_resolve_path_case "$path")") fi done @@ -83,9 +86,11 @@ discover_project_dirs() { for dir in "$HOME"/*/; do [[ ! -d "$dir" ]] && continue dir="${dir%/}" # Remove trailing slash + # Resolve casing so that ~/code and ~/Code compare equal. + dir=$(mole_purge_resolve_path_case "$dir") local already_found=false - for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do + for existing in "${discovered[@]+"${discovered[@]}"}"; do if [[ "$dir" == "$existing" ]]; then already_found=true break @@ -98,27 +103,67 @@ discover_project_dirs() { fi done - printf '%s\n' "${discovered[@]}" | sort -u + printf '%s\n' "${discovered[@]+"${discovered[@]}"}" | sort -u } -# Save discovered paths to config. -save_discovered_paths() { +# Prepare purge config directory/file ownership when possible. +prepare_purge_config_path() { + ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")" + ensure_user_file "$PURGE_CONFIG_FILE" +} + +# Write purge config content atomically when possible. +write_purge_config() { + local header="$1" + shift local -a paths=("$@") - ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")" + prepare_purge_config_path - cat > "$PURGE_CONFIG_FILE" << 'EOF' -# Mole Purge Paths - Auto-discovered project directories -# Edit this file to customize, or run: mo purge --paths -# Add one path per line (supports ~ for home directory) + local tmp_file + tmp_file=$(mktemp_file "mole-purge-paths") || return 1 + + if ! cat > "$tmp_file" << EOF; then +$header EOF + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi - printf '\n' >> "$PURGE_CONFIG_FILE" - for path in "${paths[@]}"; do - # Convert $HOME to ~ for portability - path="${path/#$HOME/~}" - echo "$path" >> "$PURGE_CONFIG_FILE" - done + # Guard empty-array expansion under `set -u` on bash 3.2 (first-run case + # from `mo purge --paths` passes only the header with no paths). + if [[ ${#paths[@]} -gt 0 ]]; then + for path in "${paths[@]}"; do + # Convert $HOME to ~ for portability + path="${path/#$HOME/~}" + if ! printf '%s\n' "$path" >> "$tmp_file"; then + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi + done + fi + + if ! mv "$tmp_file" "$PURGE_CONFIG_FILE" 2> /dev/null; then + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi + + return 0 +} + +warn_purge_config_write_failure() { + [[ -t 1 ]] || return 0 + [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]] || return 0 + echo -e "${YELLOW}${ICON_WARNING}${NC} Could not save purge paths to ${PURGE_CONFIG_FILE/#$HOME/~}, using discovered paths for this run" >&2 +} + +# Save discovered paths to config. +save_discovered_paths() { + local -a paths=("$@") + write_purge_config "# Mole Purge Paths - Auto-discovered project directories +# Edit this file to customize, or run: mo purge --paths +# Add one path per line (supports ~ for home directory) +" "${paths[@]}" } # Load purge paths from config or auto-discover @@ -141,10 +186,12 @@ load_purge_config() { if [[ ${#discovered[@]} -gt 0 ]]; then PURGE_SEARCH_PATHS=("${discovered[@]}") - save_discovered_paths "${discovered[@]}" - - if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then - echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2 + if save_discovered_paths "${discovered[@]}"; then + if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then + echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2 + fi + else + warn_purge_config_write_failure fi else PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}") @@ -155,6 +202,53 @@ load_purge_config() { # Initialize paths on script load. load_purge_config +format_purge_target_path() { + local path="$1" + echo "${path/#$HOME/~}" +} + +compact_purge_menu_path() { + local path="$1" + local max_width="${2:-0}" + + if ! [[ "$max_width" =~ ^[0-9]+$ ]] || [[ "$max_width" -lt 4 ]]; then + max_width=4 + fi + + local path_width + path_width=$(get_display_width "$path") + if [[ $path_width -le $max_width ]]; then + echo "$path" + return + fi + + local tail="" + local remainder="$path" + local prefix_width=3 + + while [[ "$remainder" == */* ]]; do + local segment="/${remainder##*/}" + remainder="${remainder%/*}" + + local candidate="${segment}${tail}" + local candidate_width + candidate_width=$(get_display_width "$candidate") + if [[ $((candidate_width + prefix_width)) -le $max_width ]]; then + tail="$candidate" + else + break + fi + done + + if [[ -n "$tail" ]]; then + echo "...${tail}" + return + fi + + local suffix_len=$((max_width - 3)) + echo "...${path: -$suffix_len}" +} + # Args: $1 - directory path # Determine whether a directory is a project root. # This is used to safely allow cleaning direct-child artifacts when @@ -199,7 +293,8 @@ is_safe_project_artifact() { # Must not be a direct child of the search root. local relative_path="${path#"$search_path"/}" - local depth=$(echo "$relative_path" | LC_ALL=C tr -cd '/' | wc -c) + local _rel_stripped="${relative_path//\//}" + local depth=$((${#relative_path} - ${#_rel_stripped})) if [[ $depth -lt 1 ]]; then # Allow direct-child artifacts only when the search path is itself # a project root (single-project mode). @@ -211,6 +306,26 @@ is_safe_project_artifact() { return 0 } +# Revalidate a selected artifact against the configured scan roots immediately +# before deletion. Purge supports explicit roots outside HOME (for example +# /var/www), so HOME containment is neither sufficient nor correct here. +is_safe_configured_purge_artifact() { + local path="$1" + + [[ -n "$path" && "$path" != "/" && "$path" != "$HOME" ]] || return 1 + [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]] || return 1 + + local search_path + for search_path in "${PURGE_SEARCH_PATHS[@]}"; do + [[ -n "$search_path" ]] || continue + if is_safe_project_artifact "$path" "$search_path"; then + return 0 + fi + done + + return 1 +} + # Detect if directory is a Rails project root is_rails_project_root() { local dir="$1" @@ -326,10 +441,23 @@ scan_purge_targets() { return fi + local cachedir_tag_min_depth=$((min_depth + 1)) + local cachedir_tag_max_depth=$((max_depth + 1)) + # Update current scanning path local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" echo "$search_path" > "$stats_dir/purge_scanning" 2> /dev/null || true + emit_valid_cachedir_tag_dirs() { + while IFS= read -r tag_file; do + [[ -n "$tag_file" ]] || continue + local cache_dir="${tag_file%/*}" + if [[ -n "$cache_dir" ]] && mole_dir_has_cachedir_tag "$cache_dir"; then + printf '%s\n' "$cache_dir" + fi + done + } + # Helper to process raw results process_scan_results() { local input_file="$1" @@ -343,7 +471,7 @@ scan_purge_targets() { if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then echo "$item" # Update scanning path to show current project directory - local project_dir=$(dirname "$item") + local project_dir="${item%/*}" echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true fi done < "$input_file" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" @@ -360,15 +488,11 @@ scan_purge_targets() { debug_log "MO_USE_FIND=1: Forcing find instead of fd" use_find=true elif command -v fd > /dev/null 2>&1; then - # Escape regex special characters in target names for fd patterns - local escaped_targets=() - for target in "${PURGE_TARGETS[@]}"; do - escaped_targets+=("^$(printf '%s' "$target" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g')\$") - done - local pattern="($( - IFS='|' - echo "${escaped_targets[*]}" - ))" + # Escape regex special characters in target names for fd patterns (single sed pass) + local _escaped_lines + _escaped_lines=$(printf '%s\n' "${PURGE_TARGETS[@]}" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g') + local pattern + pattern="($(printf '%s\n' "$_escaped_lines" | sed -e 's/^/^/' -e 's/$/$/' | paste -sd '|' -))" local fd_args=( "--absolute-path" "--hidden" @@ -382,19 +506,30 @@ scan_purge_targets() { "--exclude" ".Trash" "--exclude" "Applications" ) + local fd_tag_args=( + "--absolute-path" + "--hidden" + "--no-ignore" + "--type" "f" + "--min-depth" "$cachedir_tag_min_depth" + "--max-depth" "$cachedir_tag_max_depth" + "--threads" "8" + "--exclude" ".git" + "--exclude" "Library" + "--exclude" ".Trash" + "--exclude" "Applications" + ) - # Try running fd. If it succeeds (exit code 0), use it. - # If it fails (e.g. bad flag, permissions, binary issue), fallback to find. - if fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then - # Check if fd actually found anything - if empty, fallback to find - if [[ -s "$output_file.raw" ]]; then - debug_log "Using fd for scanning (found results)" - use_find=false - process_scan_results "$output_file.raw" - else - debug_log "fd returned empty results, falling back to find" - rm -f "$output_file.raw" - fi + # Trust fd when it exits successfully, including an empty result set. + # Empty scans are common in healthy project trees; falling back to find + # doubles the scan cost and can make "nothing to clean" feel slow. + local _scan_timeout="${MO_PURGE_SCAN_TIMEOUT_SEC:-60}" + if run_with_timeout "$_scan_timeout" fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then + run_with_timeout "$_scan_timeout" fd "${fd_tag_args[@]}" "^${MOLE_CACHEDIR_TAG_NAME}$" "$search_path" \ + 2> /dev/null | emit_valid_cachedir_tag_dirs >> "$output_file.raw" || true + debug_log "Using fd for scanning" + process_scan_results "$output_file.raw" + use_find=false else debug_log "fd command failed, falling back to find" fi @@ -420,11 +555,17 @@ scan_purge_targets() { # Use plain `find` here for compatibility with environments where # `command find` behaves inconsistently in this complex expression. - find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ + local _scan_timeout="${MO_PURGE_SCAN_TIMEOUT_SEC:-60}" + run_with_timeout "$_scan_timeout" find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ \( "${prune_expr[@]}" \) -prune -o \ \( "${target_expr[@]}" \) -print -prune \ 2> /dev/null > "$output_file.raw" || true + run_with_timeout "$_scan_timeout" find "$search_path" -mindepth "$cachedir_tag_min_depth" -maxdepth "$cachedir_tag_max_depth" \ + \( "${prune_expr[@]}" \) -prune -o \ + -type f -name "$MOLE_CACHEDIR_TAG_NAME" -print \ + 2> /dev/null | emit_valid_cachedir_tag_dirs >> "$output_file.raw" || true + process_scan_results "$output_file.raw" fi } @@ -456,26 +597,104 @@ filter_protected_artifacts() { fi done } -# Args: $1 - path -# Check if a path was modified recently (safety check). -is_recently_modified() { +# Args: $1 - path, $2 - optional current epoch +# Classify artifact activity as recent, old, or uncertain. Only a complete +# bounded scan may return old; timeouts and read failures fail closed. +classify_purge_activity() { local path="$1" + local current_time="${2:-}" local age_days=$MIN_AGE_DAYS + _PURGE_ACTIVITY_STATE="uncertain" + if [[ ! -e "$path" ]]; then - return 1 + _PURGE_ACTIVITY_STATE="old" + return 0 fi + local mod_time - mod_time=$(get_file_mtime "$path") - local current_time - current_time=$(get_epoch_seconds) + mod_time=$(get_file_mtime "$path" 2> /dev/null || true) + if [[ ! "$mod_time" =~ ^[0-9]+$ ]]; then + debug_log "Unable to read purge activity timestamp: $path" + return 0 + fi + if [[ -z "$current_time" || ! "$current_time" =~ ^[0-9]+$ ]]; then + current_time=$(get_epoch_seconds) + fi + local age_seconds=$((current_time - mod_time)) local age_in_days=$((age_seconds / 86400)) if [[ $age_in_days -lt $age_days ]]; then - return 0 # Recently modified + _PURGE_ACTIVITY_STATE="recent" + return 0 + fi + + if [[ ! -d "$path" ]]; then + _PURGE_ACTIVITY_STATE="old" + return 0 + fi + + local probe_timeout="${MO_PURGE_ACTIVITY_TIMEOUT_SEC:-$MOLE_TIMEOUT_MEDIUM_PROBE_SEC}" + if [[ ! "$probe_timeout" =~ ^[1-9][0-9]*$ ]]; then + probe_timeout="$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" + fi + + # clean_project_artifacts sets one deadline for the whole classification + # pass. A standalone caller still gets the per-item ceiling above. + if [[ "${_PURGE_ACTIVITY_DEADLINE_EPOCH:-}" =~ ^[0-9]+$ ]]; then + local now_epoch remaining + now_epoch=$(get_epoch_seconds) + remaining=$((_PURGE_ACTIVITY_DEADLINE_EPOCH - now_epoch)) + if [[ $remaining -le 0 ]]; then + debug_log "Purge activity scan budget exhausted before: $path" + return 0 + fi + if [[ $probe_timeout -gt $remaining ]]; then + probe_timeout=$remaining + fi + fi + + local recent_file="" + local probe_status=0 + recent_file=$(run_with_timeout "$probe_timeout" \ + find "$path" -type f -mtime "-$age_days" -print -quit 2> /dev/null) || probe_status=$? + + if [[ $probe_status -ne 0 ]]; then + debug_log "Purge activity scan failed closed (exit $probe_status): $path" + return 0 + fi + if [[ -n "$recent_file" ]]; then + _PURGE_ACTIVITY_STATE="recent" else - return 1 # Old enough to clean + _PURGE_ACTIVITY_STATE="old" fi } + +# Args: $1 - path, $2 - optional current epoch +# Check whether a path must be protected from default purge selection. +is_recently_modified() { + classify_purge_activity "$@" + [[ "$_PURGE_ACTIVITY_STATE" != "old" ]] +} + +# An artifact that was old when the menu opened can become active before the +# user confirms deletion. Recheck only those default-safe rows; a user who +# explicitly selected an already-recent row has already overridden that hint. +purge_target_activity_still_safe() { + local path="$1" + local was_recent="${2:-true}" + [[ "$was_recent" == "true" ]] && return 0 + + # Do not inherit the menu pass's expired shared deadline. + local _PURGE_ACTIVITY_DEADLINE_EPOCH="" + local _PURGE_ACTIVITY_STATE="uncertain" + if is_recently_modified "$path" "$(get_epoch_seconds)"; then + return 1 + fi + # Preserve the established test/caller seam where an override returning 1 + # means old without setting the newer classification detail. + [[ "$_PURGE_ACTIVITY_STATE" == "old" || "$_PURGE_ACTIVITY_STATE" == "uncertain" ]] +} + # Args: $1 - path # Get directory size in KB. get_dir_size_kb() { @@ -508,7 +727,8 @@ get_dir_size_kb() { fi if [[ $du_exit -ne 0 ]]; then - echo "0" + debug_log "Size calculation failed (exit $du_exit): $path" + echo "ERROR" return fi @@ -517,7 +737,8 @@ get_dir_size_kb() { if [[ "$size_kb" =~ ^[0-9]+$ ]]; then echo "$size_kb" else - echo "0" + debug_log "Size calculation returned invalid output: $path" + echo "ERROR" fi } # Purge category selector. @@ -542,7 +763,7 @@ select_purge_categories() { term_height=24 fi fi - local reserved=6 + local reserved=8 local available=$((term_height - reserved)) if [[ $available -lt 3 ]]; then echo 3 @@ -587,20 +808,28 @@ select_purge_categories() { fi terminal_restored=true + # Clear traps first to prevent re-entrant firing during eval below. trap - EXIT INT TERM + + # Restore terminal state before re-installing caller traps, so the + # terminal is always usable even if a restored trap handler exits. show_cursor if [[ -n "${original_stty:-}" ]]; then stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true fi - if [[ -n "$previous_exit_trap" ]]; then - eval "$previous_exit_trap" - fi - if [[ -n "$previous_int_trap" ]]; then - eval "$previous_int_trap" - fi - if [[ -n "$previous_term_trap" ]]; then - eval "$previous_term_trap" - fi + + # Snapshot and clear saved traps before eval to prevent infinite + # recursion if the restored handler triggers another signal. + local _prev_exit="$previous_exit_trap" + local _prev_int="$previous_int_trap" + local _prev_term="$previous_term_trap" + previous_exit_trap="" + previous_int_trap="" + previous_term_trap="" + # eval: restore caller traps captured by $(trap -p) + [[ -n "$_prev_exit" ]] && eval "$_prev_exit" + [[ -n "$_prev_int" ]] && eval "$_prev_int" + [[ -n "$_prev_term" ]] && eval "$_prev_term" } # shellcheck disable=SC2329 handle_interrupt() { @@ -659,6 +888,7 @@ select_purge_categories() { printf "%s\n" "$clear_line" IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" + IFS=',' read -r -a age_labels <<< "${PURGE_AGE_LABELS:-}" # Calculate visible range local end_index=$((top_index + visible_count)) @@ -668,7 +898,8 @@ select_purge_categories() { local checkbox="$ICON_EMPTY" [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID" local recent_marker="" - [[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}" + local _age="${age_labels[i]:-}" + [[ -n "$_age" ]] && recent_marker=" ${GRAY}| ${_age}${NC}" local rel_pos=$((i - top_index)) if [[ $rel_pos -eq $cursor_pos ]]; then printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker" @@ -680,7 +911,18 @@ select_purge_categories() { # Keep one blank line between the list and footer tips. printf "%s\n" "$clear_line" - # Adaptive footer hints — mirrors menu_paginated.sh pattern + local current_index=$((top_index + cursor_pos)) + local current_full_path="" + local paths_len="${#PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}" + if [[ "$paths_len" -gt 0 && "$current_index" -lt "$paths_len" ]]; then + current_full_path="${PURGE_CATEGORY_FULL_PATHS_ARRAY[current_index]}" + fi + if [[ -n "$current_full_path" ]]; then + printf "%s${GRAY}Full path:${NC} %s\n" "$clear_line" "$current_full_path" + printf "%s\n" "$clear_line" + fi + + # Adaptive footer hints, mirrors menu_paginated.sh pattern local _term_w _term_w=$(tput cols 2> /dev/null || echo 80) [[ "$_term_w" =~ ^[0-9]+$ ]] || _term_w=80 @@ -821,10 +1063,13 @@ confirm_purge_cleanup() { local item_count="${1:-0}" local total_size_kb="${2:-0}" local unknown_count="${3:-0}" + local cloud_count="${4:-0}" + local -a selected_paths=("${@:5}") [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0 [[ "$total_size_kb" =~ ^[0-9]+$ ]] || total_size_kb=0 [[ "$unknown_count" =~ ^[0-9]+$ ]] || unknown_count=0 + [[ "$cloud_count" =~ ^[0-9]+$ ]] || cloud_count=0 local item_text="artifact" [[ $item_count -ne 1 ]] && item_text="artifacts" @@ -839,6 +1084,21 @@ confirm_purge_cleanup() { unknown_hint=", ${unknown_count} ${unknown_text}" fi + if [[ ${#selected_paths[@]} -gt 0 ]]; then + echo "" + echo -e "${GRAY}Selected paths:${NC}" + local selected_path="" + for selected_path in "${selected_paths[@]}"; do + echo " $selected_path" + done + fi + + if [[ $cloud_count -gt 0 ]]; then + echo "" + echo -e "${YELLOW}${ICON_WARNING}${NC} Cloud-synced artifacts may also be removed from other devices." + echo -e "${GRAY}Use 'mo purge --paths' to exclude cloud storage roots.${NC}" + fi + echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove ${item_count} ${item_text}, ${size_display}${unknown_hint} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " drain_pending_input local key="" @@ -861,7 +1121,8 @@ confirm_purge_cleanup() { clean_project_artifacts() { local -a all_found_items=() local -a safe_to_clean=() - local -a recently_modified=() + local -a safe_recent_flags=() + local -a safe_activity_states=() local previous_int_trap="" local previous_term_trap="" local trap_installed_by_this_call=false @@ -869,8 +1130,11 @@ clean_project_artifacts() { # Note: Declared without 'local' so cleanup_scan trap can access them scan_pids=() scan_temps=() + _cleanup_scan_done=false # shellcheck disable=SC2329 cleanup_scan() { + [[ "$_cleanup_scan_done" == "true" ]] && return + _cleanup_scan_done=true # Kill all background scans for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do kill "$pid" 2> /dev/null || true @@ -898,7 +1162,7 @@ clean_project_artifacts() { scan_output=$(mktemp) scan_temps+=("$scan_output") # Launch scan in background for true parallelism - scan_purge_targets "$path" "$scan_output" & + scan_purge_targets "$path" "$scan_output" < /dev/null & local scan_pid=$! scan_pids+=("$scan_pid") fi @@ -917,20 +1181,26 @@ clean_project_artifacts() { sleep 0.2 fi - # Collect all results + # Collect all results and deduplicate once. This avoids an O(N²) shell loop + # when overlapping search roots produce the same artifact many times. + local dedupe_output + dedupe_output=$(mktemp_file "mole-purge-dedupe") || return 1 for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do if [[ -f "$scan_output" ]]; then - while IFS= read -r item; do - if [[ -n "$item" ]]; then - all_found_items+=("$item") - fi - done < "$scan_output" + cat "$scan_output" >> "$dedupe_output" rm -f "$scan_output" fi done + if [[ -s "$dedupe_output" ]]; then + while IFS= read -r item; do + [[ -n "$item" ]] && all_found_items+=("$item") + done < <(LC_COLLATE=C sort -u "$dedupe_output") + fi + rm -f "$dedupe_output" # Restore caller traps after this function completes. if [[ "$trap_installed_by_this_call" == "true" ]]; then trap - INT TERM + # eval: restore caller traps captured by $(trap -p) [[ -n "$previous_int_trap" ]] && eval "$previous_int_trap" [[ -n "$previous_term_trap" ]] && eval "$previous_term_trap" fi @@ -941,37 +1211,113 @@ clean_project_artifacts() { return 2 # Special code: nothing to clean fi # Mark recently modified items (for default selection state) + if [[ -t 1 ]]; then + start_inline_spinner "Checking recent activity..." + fi + local _now_epoch + _now_epoch=$(get_epoch_seconds) + local _activity_total_timeout="${MO_PURGE_ACTIVITY_TOTAL_TIMEOUT_SEC:-$MOLE_TIMEOUT_HINT_SCAN_SEC}" + if [[ ! "$_activity_total_timeout" =~ ^[1-9][0-9]*$ ]]; then + _activity_total_timeout="$MOLE_TIMEOUT_HINT_SCAN_SEC" + fi + local _PURGE_ACTIVITY_DEADLINE_EPOCH=$((_now_epoch + _activity_total_timeout)) for item in "${all_found_items[@]}"; do - if is_recently_modified "$item"; then - recently_modified+=("$item") + local is_recent=false + _PURGE_ACTIVITY_STATE="uncertain" + if is_recently_modified "$item" "$_now_epoch"; then + is_recent=true + fi + local activity_state="${_PURGE_ACTIVITY_STATE:-uncertain}" + if [[ "$activity_state" != "recent" && "$activity_state" != "old" && "$activity_state" != "uncertain" ]]; then + activity_state="uncertain" + elif [[ "$activity_state" == "uncertain" && "$is_recent" == "false" ]]; then + # Preserve the long-standing is_recently_modified test/mocking seam: + # a legacy override returning 1 means definitely old. + activity_state="old" fi # Add all items to safe_to_clean, let user choose safe_to_clean+=("$item") + safe_recent_flags+=("$is_recent") + safe_activity_states+=("$activity_state") done + if [[ -t 1 ]]; then + stop_inline_spinner + fi # Build menu options - one per artifact if [[ -t 1 ]]; then start_inline_spinner "Calculating sizes..." fi + + # Pre-compute sizes in parallel with sliding-window throttle. + # Unbounded parallelism (all N at once) causes I/O contention on cold + # filesystem cache, making du timeout and display "unknown" sizes. + local -a _size_tmpfiles=() + local -a _size_pids=() + local _max_size_jobs + _max_size_jobs=$(get_optimal_parallel_jobs io) + if ! [[ "$_max_size_jobs" =~ ^[0-9]+$ ]] || [[ "$_max_size_jobs" -lt 1 ]]; then + _max_size_jobs=1 + elif [[ "$_max_size_jobs" -gt 8 ]]; then + _max_size_jobs=8 + fi + + # Reap any finished PID from the sliding window. Uses `wait -n` when + # available (bash 4.3+) to avoid blocking on the slowest job; falls + # back to first-PID wait on macOS default bash 3.2. + local _has_wait_n=false + if [[ "${BASH_VERSINFO[0]:-0}" -gt 4 ]] || + { [[ "${BASH_VERSINFO[0]:-0}" -eq 4 ]] && [[ "${BASH_VERSINFO[1]:-0}" -ge 3 ]]; }; then + _has_wait_n=true + fi + _reap_one_size_pid() { + if [[ "$_has_wait_n" == "true" ]]; then + wait -n "${_size_pids[@]}" 2> /dev/null || true + local -a _remaining=() + for _p in "${_size_pids[@]}"; do + if kill -0 "$_p" 2> /dev/null; then + _remaining+=("$_p") + fi + done + _size_pids=("${_remaining[@]}") + else + wait "${_size_pids[0]}" 2> /dev/null || true + _size_pids=("${_size_pids[@]:1}") + fi + } + + for _sz_item in "${safe_to_clean[@]}"; do + local _stmp + _stmp=$(mktemp) + register_temp_file "$_stmp" + _size_tmpfiles+=("$_stmp") + (get_dir_size_kb "$_sz_item" > "$_stmp" 2> /dev/null) < /dev/null & + _size_pids+=($!) + + if [[ ${#_size_pids[@]} -ge $_max_size_jobs ]]; then + _reap_one_size_pid + fi + done + for _spid in "${_size_pids[@]+"${_size_pids[@]}"}"; do + wait "$_spid" 2> /dev/null || true + done + local -a menu_options=() local -a item_paths=() local -a item_sizes=() local -a item_size_unknown_flags=() local -a item_recent_flags=() - # Helper to get project name from path - # For ~/www/pake/src-tauri/target -> returns "pake" - # For ~/work/code/MyProject/node_modules -> returns "MyProject" - # Strategy: Find the nearest ancestor directory containing a project indicator file - get_project_name() { + local -a item_age_labels=() + local -a item_cloud_flags=() + # Find the best project root for an artifact once; callers decide how to + # display it. Monorepo indicators win over plain project indicators. + find_purge_project_root_for_artifact() { local path="$1" - - local current_dir - current_dir=$(dirname "$path") + local current_dir="${path%/*}" + [[ -z "$current_dir" ]] && current_dir="/" local monorepo_root="" local project_root="" - # Single pass: check both monorepo and project indicators while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do - # First check for monorepo indicators (higher priority) if [[ -z "$monorepo_root" ]]; then for indicator in "${MONOREPO_INDICATORS[@]}"; do if [[ -e "$current_dir/$indicator" ]]; then @@ -981,7 +1327,6 @@ clean_project_artifacts() { done fi - # Then check for project indicators (save first match) if [[ -z "$project_root" ]]; then for indicator in "${PROJECT_INDICATORS[@]}"; do if [[ -e "$current_dir/$indicator" ]]; then @@ -991,136 +1336,123 @@ clean_project_artifacts() { done fi - # If we found monorepo, we can stop (monorepo always wins) if [[ -n "$monorepo_root" ]]; then break fi - # If we found project but still checking for monorepo above - # (only stop if we're beyond reasonable depth) - local depth=$(echo "${current_dir#"$HOME"}" | LC_ALL=C tr -cd '/' | wc -c | tr -d ' ') + local _rel="${current_dir#"$HOME"}" + local _stripped="${_rel//\//}" + local depth=$((${#_rel} - ${#_stripped})) if [[ -n "$project_root" && $depth -lt 2 ]]; then break fi - current_dir=$(dirname "$current_dir") + local _parent="${current_dir%/*}" + current_dir="${_parent:-/}" done - # Determine result: monorepo > project > fallback - local result="" if [[ -n "$monorepo_root" ]]; then - result=$(basename "$monorepo_root") - elif [[ -n "$project_root" ]]; then - result=$(basename "$project_root") - else - # Fallback: first directory under search root - local search_roots=() - if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then - search_roots=("${PURGE_SEARCH_PATHS[@]}") - else - search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") - fi - for root in "${search_roots[@]}"; do - root="${root%/}" - if [[ -n "$root" && "$path" == "$root/"* ]]; then - local relative_path="${path#"$root"/}" - result=$(echo "$relative_path" | cut -d'/' -f1) - break - fi - done + echo "$monorepo_root" + return 0 + fi - # Final fallback: use grandparent directory - if [[ -z "$result" ]]; then - result=$(dirname "$(dirname "$path")" | xargs basename) - fi + if [[ -n "$project_root" ]]; then + echo "$project_root" + return 0 fi - echo "$result" + return 1 } - # Helper to get project path (more complete than just project name) - # For ~/www/pake/src-tauri/target -> returns "~/www/pake" - # For ~/work/code/MyProject/node_modules -> returns "~/work/code/MyProject" - # Shows the full path relative to HOME with ~ prefix for better clarity - get_project_path() { + # Helper to get project name from path. + get_project_name() { local path="$1" - - local current_dir - current_dir=$(dirname "$path") - local monorepo_root="" local project_root="" - # Single pass: check both monorepo and project indicators - while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do - # First check for monorepo indicators (higher priority) - if [[ -z "$monorepo_root" ]]; then - for indicator in "${MONOREPO_INDICATORS[@]}"; do - if [[ -e "$current_dir/$indicator" ]]; then - monorepo_root="$current_dir" - break - fi - done - fi - - # Then check for project indicators (save first match) - if [[ -z "$project_root" ]]; then - for indicator in "${PROJECT_INDICATORS[@]}"; do - if [[ -e "$current_dir/$indicator" ]]; then - project_root="$current_dir" - break - fi - done - fi - - # If we found monorepo, we can stop (monorepo always wins) - if [[ -n "$monorepo_root" ]]; then - break - fi + if project_root=$(find_purge_project_root_for_artifact "$path"); then + echo "${project_root##*/}" + return + fi - # If we found project but still checking for monorepo above - local depth=$(echo "${current_dir#"$HOME"}" | LC_ALL=C tr -cd '/' | wc -c | tr -d ' ') - if [[ -n "$project_root" && $depth -lt 2 ]]; then + local result="" + local search_roots=() + if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then + search_roots=("${PURGE_SEARCH_PATHS[@]}") + else + search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") + fi + for root in "${search_roots[@]}"; do + root="${root%/}" + if [[ -n "$root" && "$path" == "$root/"* ]]; then + local relative_path="${path#"$root"/}" + result="${relative_path%%/*}" break fi - - current_dir=$(dirname "$current_dir") done - # Determine result: monorepo > project > fallback - local result="" - if [[ -n "$monorepo_root" ]]; then - result="$monorepo_root" - elif [[ -n "$project_root" ]]; then - result="$project_root" - else - # Fallback: use parent directory of artifact - result=$(dirname "$path") + if [[ -z "$result" ]]; then + local _gp="${path%/*}" + _gp="${_gp%/*}" + result="${_gp##*/}" fi - # Convert to ~ format for cleaner display - result="${result/#$HOME/~}" echo "$result" } + # Helper to get project path (more complete than just project name). + get_project_path() { + local path="$1" + local project_root="" + if ! project_root=$(find_purge_project_root_for_artifact "$path"); then + project_root="${path%/*}" + fi + echo "${project_root/#$HOME/~}" + } + # Helper to get artifact display name # For duplicate artifact names within same project, include parent directory for context + # Uses pre-computed _cached_basenames and _cached_project_names arrays when available. get_artifact_display_name() { local path="$1" - local artifact_name=$(basename "$path") - local project_name=$(get_project_name "$path") - local parent_name=$(basename "$(dirname "$path")") + local artifact_name="${path##*/}" + local parent_name="${path%/*}" + parent_name="${parent_name##*/}" + + local project_name + if [[ -n "${_cached_project_names[*]+x}" ]]; then + # Fast path: use pre-computed cache + local _idx + project_name="" + for _idx in "${!safe_to_clean[@]}"; do + if [[ "${safe_to_clean[$_idx]}" == "$path" ]]; then + project_name="${_cached_project_names[$_idx]}" + break + fi + done + else + project_name=$(get_project_name "$path") + fi # Check if there are other items with same artifact name AND same project local has_duplicate=false - for other_item in "${safe_to_clean[@]}"; do - if [[ "$other_item" != "$path" && "$(basename "$other_item")" == "$artifact_name" ]]; then - # Same artifact name, check if same project - if [[ "$(get_project_name "$other_item")" == "$project_name" ]]; then + if [[ -n "${_cached_basenames[*]+x}" ]]; then + local _idx + for _idx in "${!safe_to_clean[@]}"; do + if [[ "${safe_to_clean[$_idx]}" != "$path" && "${_cached_basenames[$_idx]}" == "$artifact_name" && "${_cached_project_names[$_idx]}" == "$project_name" ]]; then has_duplicate=true break fi - fi - done + done + else + for other_item in "${safe_to_clean[@]}"; do + if [[ "$other_item" != "$path" && "${other_item##*/}" == "$artifact_name" ]]; then + if [[ "$(get_project_name "$other_item")" == "$project_name" ]]; then + has_duplicate=true + break + fi + fi + done + fi # If duplicate exists in same project and parent is not the project itself, show parent/artifact if [[ "$has_duplicate" == "true" && "$parent_name" != "$project_name" && "$parent_name" != "." && "$parent_name" != "/" ]]; then @@ -1139,6 +1471,12 @@ clean_project_artifacts() { local max_path_width="${5:-}" local artifact_col="${6:-12}" local available_width + local path_prefix="" + + if [[ "$project_path" == "[cloud] "* ]]; then + path_prefix="[cloud] " + project_path="${project_path#"[cloud] "}" + fi if [[ -n "$max_path_width" ]]; then available_width="$max_path_width" @@ -1157,31 +1495,59 @@ clean_project_artifacts() { fi [[ $available_width -lt $min_width ]] && available_width=$min_width - [[ $available_width -gt 60 ]] && available_width=60 fi # Truncate project path if needed local truncated_path - truncated_path=$(truncate_by_display_width "$project_path" "$available_width") + local compact_width=$((available_width - ${#path_prefix})) + [[ $compact_width -lt 4 ]] && compact_width=4 + truncated_path="${path_prefix}$(compact_purge_menu_path "$project_path" "$compact_width")" local current_width current_width=$(get_display_width "$truncated_path") - local char_count=${#truncated_path} + + # Get byte count for printf width calculation + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local byte_count=${#truncated_path} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding=$((available_width - current_width)) - local printf_width=$((char_count + padding)) + local printf_width=$((byte_count + padding)) # Format: "project_path size | artifact_type" printf "%-*s %9s | %-*s" "$printf_width" "$truncated_path" "$size_str" "$artifact_col" "$artifact_type" } + # Pre-compute basenames and project names once so get_artifact_display_name() + # can avoid repeated filesystem traversals during the O(N^2) duplicate check. + local -a _cached_basenames=() + local -a _cached_project_names=() + local -a _cached_project_paths=() + local _pre_idx + for _pre_idx in "${!safe_to_clean[@]}"; do + _cached_basenames[_pre_idx]="${safe_to_clean[$_pre_idx]##*/}" + _cached_project_names[_pre_idx]=$(get_project_name "${safe_to_clean[$_pre_idx]}") + _cached_project_paths[_pre_idx]=$(get_project_path "${safe_to_clean[$_pre_idx]}") + done + # Build menu options - one line per artifact - # Pass 1: collect data into parallel arrays (needed for pre-scan of widths) + # Pass 1: collect data into parallel arrays (needed for pre-scan of widths). + # Sizes are read from pre-computed results (parallel du calls launched above). local -a raw_project_paths=() local -a raw_artifact_types=() + local -a item_display_paths=() + local _sz_idx=0 for item in "${safe_to_clean[@]}"; do - local project_path - project_path=$(get_project_path "$item") + local item_index=$_sz_idx + local project_path="${_cached_project_paths[$item_index]}" local artifact_type artifact_type=$(get_artifact_display_name "$item") local size_raw - size_raw=$(get_dir_size_kb "$item") + size_raw=$(cat "${_size_tmpfiles[$item_index]}" 2> /dev/null || echo "0") + rm -f "${_size_tmpfiles[$item_index]}" 2> /dev/null || true + _sz_idx=$((_sz_idx + 1)) local size_kb=0 local size_human="" local size_unknown=false @@ -1189,31 +1555,59 @@ clean_project_artifacts() { if [[ "$size_raw" == "TIMEOUT" ]]; then size_unknown=true size_human="unknown" + elif [[ "$size_raw" == "ERROR" ]]; then + debug_log "Skipping purge target with unknown size: $item" + continue elif [[ "$size_raw" =~ ^[0-9]+$ ]]; then size_kb="$size_raw" - # Skip empty directories (0 bytes) - if [[ $size_kb -eq 0 ]]; then + if [[ $size_kb -eq 0 && "${MOLE_PURGE_INCLUDE_EMPTY:-0}" != "1" ]]; then continue fi size_human=$(bytes_to_human "$((size_kb * 1024))") else + debug_log "Skipping purge target with invalid size result '$size_raw': $item" continue fi - # Check if recent - local is_recent=false - for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do - if [[ "$item" == "$recent_item" ]]; then - is_recent=true - break - fi - done - raw_project_paths+=("$project_path") + local is_recent="${safe_recent_flags[$item_index]:-true}" + local activity_state="${safe_activity_states[$item_index]:-uncertain}" + local is_cloud=false + if mole_purge_is_cloud_synced_path "$item"; then + is_cloud=true + fi + local display_project_path="$project_path" + local display_item_path + display_item_path=$(format_purge_target_path "$item") + if [[ "$is_cloud" == "true" ]]; then + display_project_path="[cloud] $display_project_path" + display_item_path="[cloud] $display_item_path" + fi + raw_project_paths+=("$display_project_path") raw_artifact_types+=("$artifact_type") item_paths+=("$item") + item_display_paths+=("$display_item_path") item_sizes+=("$size_kb") item_size_unknown_flags+=("$size_unknown") item_recent_flags+=("$is_recent") + item_cloud_flags+=("$is_cloud") + # Build human-readable age label (bash 3.2 compatible, no assoc arrays). + local _mod_time _age_secs _age_d + _mod_time=$(get_file_mtime "$item" 2> /dev/null || echo "0") + _age_secs=$((_now_epoch - _mod_time)) + _age_d=$((_age_secs / 86400)) + if [[ "$activity_state" == "uncertain" ]]; then + item_age_labels+=("unknown") + elif [[ "$activity_state" == "recent" && $_age_d -ge $MIN_AGE_DAYS ]]; then + item_age_labels+=("<${MIN_AGE_DAYS}d") + elif [[ $_age_d -lt 1 ]]; then + item_age_labels+=("<1d") + elif [[ $_age_d -lt 30 ]]; then + item_age_labels+=("${_age_d}d") + elif [[ $_age_d -lt 365 ]]; then + item_age_labels+=("$((_age_d / 30))mo") + else + item_age_labels+=("$((_age_d / 365))y") + fi done # Pre-scan: find max path and artifact display widths (mirrors app_selector.sh approach) @@ -1236,7 +1630,7 @@ clean_project_artifacts() { [[ $max_artifact_width -lt 6 ]] && max_artifact_width=6 [[ $max_artifact_width -gt 17 ]] && max_artifact_width=17 - # Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | Recent"(9) = artifact_col + 26 + # Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | 11mo"(7) = artifact_col + 24 local fixed_overhead=$((max_artifact_width + 26)) local available_for_path=$((terminal_width - fixed_overhead)) @@ -1251,7 +1645,6 @@ clean_project_artifacts() { [[ $max_path_display_width -lt $min_path_width ]] && max_path_display_width=$min_path_width [[ $available_for_path -lt $max_path_display_width ]] && max_path_display_width=$available_for_path - [[ $max_path_display_width -gt 60 ]] && max_path_display_width=60 # Ensure path width is at least 5 on very narrow terminals [[ $max_path_display_width -lt 5 ]] && max_path_display_width=5 @@ -1291,6 +1684,9 @@ clean_project_artifacts() { local -a sorted_item_sizes=() local -a sorted_item_size_unknown_flags=() local -a sorted_item_recent_flags=() + local -a sorted_item_display_paths=() + local -a sorted_item_age_labels=() + local -a sorted_item_cloud_flags=() for idx in "${sorted_indices[@]}"; do sorted_menu_options+=("${menu_options[idx]}") @@ -1298,6 +1694,9 @@ clean_project_artifacts() { sorted_item_sizes+=("${item_sizes[idx]}") sorted_item_size_unknown_flags+=("${item_size_unknown_flags[idx]}") sorted_item_recent_flags+=("${item_recent_flags[idx]}") + sorted_item_display_paths+=("${item_display_paths[idx]}") + sorted_item_age_labels+=("${item_age_labels[idx]}") + sorted_item_cloud_flags+=("${item_cloud_flags[idx]}") done # Replace original arrays with sorted versions @@ -1306,6 +1705,9 @@ clean_project_artifacts() { item_sizes=("${sorted_item_sizes[@]}") item_size_unknown_flags=("${sorted_item_size_unknown_flags[@]}") item_recent_flags=("${sorted_item_recent_flags[@]}") + item_display_paths=("${sorted_item_display_paths[@]}") + item_age_labels=("${sorted_item_age_labels[@]}") + item_cloud_flags=("${sorted_item_cloud_flags[@]}") fi if [[ -t 1 ]]; then stop_inline_spinner @@ -1327,32 +1729,52 @@ clean_project_artifacts() { IFS=, echo "${item_recent_flags[*]-}" ) + export PURGE_AGE_LABELS=$( + IFS=, + echo "${item_age_labels[*]-}" + ) # Interactive selection (only if terminal is available) PURGE_SELECTION_RESULT="" + PURGE_CATEGORY_FULL_PATHS_ARRAY=("${item_display_paths[@]}") if [[ -t 0 ]]; then if ! select_purge_categories "${menu_options[@]}"; then - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + PURGE_CATEGORY_FULL_PATHS_ARRAY=() + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT return 1 fi else # Non-interactive: select all non-recent items + local skipped_cloud_count=0 for ((i = 0; i < ${#menu_options[@]}; i++)); do + if [[ "${item_cloud_flags[i]:-false}" == "true" && "${MOLE_DRY_RUN:-0}" != "1" ]]; then + skipped_cloud_count=$((skipped_cloud_count + 1)) + continue + fi if [[ ${item_recent_flags[i]} != "true" ]]; then [[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+="," PURGE_SELECTION_RESULT+="$i" fi done + if [[ $skipped_cloud_count -gt 0 ]]; then + local skipped_cloud_text="artifact" + [[ $skipped_cloud_count -ne 1 ]] && skipped_cloud_text="artifacts" + echo "" + echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped ${skipped_cloud_count} cloud-synced ${skipped_cloud_text} in non-interactive mode (confirmation required)" + fi fi if [[ -z "$PURGE_SELECTION_RESULT" ]]; then echo "" echo -e "${GRAY}No items selected${NC}" printf '\n' - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + PURGE_CATEGORY_FULL_PATHS_ARRAY=() + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT return 0 fi IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT" local selected_total_kb=0 local selected_unknown_count=0 + local selected_cloud_count=0 + local -a selected_display_paths=() for idx in "${selected_indices[@]}"; do local selected_size_kb="${item_sizes[idx]:-0}" [[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0 @@ -1360,16 +1782,22 @@ clean_project_artifacts() { if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then selected_unknown_count=$((selected_unknown_count + 1)) fi + if [[ "${item_cloud_flags[idx]:-false}" == "true" ]]; then + selected_cloud_count=$((selected_cloud_count + 1)) + fi + selected_display_paths+=("${item_display_paths[idx]}") done if [[ -t 0 ]]; then - if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then + if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count" "$selected_cloud_count" "${selected_display_paths[@]}"; then echo -e "${GRAY}Purge cancelled${NC}" printf '\n' - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + PURGE_CATEGORY_FULL_PATHS_ARRAY=() + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT return 1 fi fi + PURGE_CATEGORY_FULL_PATHS_ARRAY=() # Clean selected items echo "" @@ -1378,8 +1806,7 @@ clean_project_artifacts() { local dry_run_mode="${MOLE_DRY_RUN:-0}" for idx in "${selected_indices[@]}"; do local item_path="${item_paths[idx]}" - local artifact_type=$(basename "$item_path") - local project_path=$(get_project_path "$item_path") + local display_item_path="${item_display_paths[idx]}" local size_kb="${item_sizes[idx]}" local size_unknown="${item_size_unknown_flags[idx]:-false}" local size_human @@ -1389,11 +1816,16 @@ clean_project_artifacts() { size_human=$(bytes_to_human "$((size_kb * 1024))") fi # Safety checks - if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then + if ! is_safe_configured_purge_artifact "$item_path"; then + debug_log "Skipping purge target outside configured safe roots: ${item_path:-}" + continue + fi + if ! purge_target_activity_still_safe "$item_path" "${item_recent_flags[idx]:-true}"; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped $display_item_path (activity changed after review)" continue fi if [[ -t 1 ]]; then - start_inline_spinner "Cleaning $project_path/$artifact_type..." + start_inline_spinner "Cleaning $display_item_path..." fi local removal_recorded=false if [[ -e "$item_path" ]]; then @@ -1409,16 +1841,16 @@ clean_project_artifacts() { fi if [[ -t 1 ]]; then stop_inline_spinner - if [[ "$removal_recorded" == "true" ]]; then - if [[ "$dry_run_mode" == "1" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" - else - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" - fi + fi + if [[ "$removal_recorded" == "true" ]]; then + if [[ "$dry_run_mode" == "1" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $display_item_path${NC}, ${GREEN}$size_human${NC}" + elif [[ -t 1 ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} $display_item_path${NC}, ${GREEN}$size_human${NC}" fi fi done # Update count echo "$cleaned_count" > "$stats_dir/purge_count" - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT } diff --git a/Resources/mole/lib/clean/purge_shared.sh b/Resources/mole/lib/clean/purge_shared.sh index 91ad19f..54a7721 100644 --- a/Resources/mole/lib/clean/purge_shared.sh +++ b/Resources/mole/lib/clean/purge_shared.sh @@ -8,40 +8,48 @@ if [[ -n "${MOLE_PURGE_SHARED_LOADED:-}" ]]; then fi readonly MOLE_PURGE_SHARED_LOADED=1 +MOLE_PURGE_PHYSICAL_HOME="$HOME" +if [[ -d "$HOME" ]]; then + MOLE_PURGE_PHYSICAL_HOME=$(cd "$HOME" 2> /dev/null && pwd -P) || MOLE_PURGE_PHYSICAL_HOME="$HOME" +fi +readonly MOLE_PURGE_PHYSICAL_HOME + # Canonical purge targets (heavy project build artifacts). readonly MOLE_PURGE_TARGETS=( "node_modules" - "target" # Rust, Maven - "build" # Gradle, various - "dist" # JS builds - "venv" # Python - ".venv" # Python - ".pytest_cache" # Python (pytest) - ".mypy_cache" # Python (mypy) - ".tox" # Python (tox virtualenvs) - ".nox" # Python (nox virtualenvs) - ".ruff_cache" # Python (ruff) - ".gradle" # Gradle local - "__pycache__" # Python - ".next" # Next.js - ".nuxt" # Nuxt.js - ".output" # Nuxt.js - "vendor" # PHP Composer - "bin" # .NET build output (guarded; see is_protected_purge_artifact) - "obj" # C# / Unity - ".turbo" # Turborepo cache - ".parcel-cache" # Parcel bundler - ".dart_tool" # Flutter/Dart build cache - ".zig-cache" # Zig - "zig-out" # Zig - ".angular" # Angular - ".svelte-kit" # SvelteKit - ".astro" # Astro - "coverage" # Code coverage reports - "DerivedData" # Xcode - "Pods" # CocoaPods - ".cxx" # React Native Android NDK build cache - ".expo" # Expo + "target" # Rust, Maven + "build" # Gradle, various + "dist" # JS builds + "venv" # Python + ".venv" # Python + ".pytest_cache" # Python (pytest) + ".mypy_cache" # Python (mypy) + ".tox" # Python (tox virtualenvs) + ".nox" # Python (nox virtualenvs) + ".ruff_cache" # Python (ruff) + ".gradle" # Gradle local + ".terragrunt-cache" # Terragrunt downloaded modules/providers + "__pycache__" # Python + ".next" # Next.js + ".nuxt" # Nuxt.js + ".output" # Nuxt.js + "vendor" # PHP Composer + "bin" # .NET build output (guarded; see is_protected_purge_artifact) + "obj" # C# / Unity + ".turbo" # Turborepo cache + ".parcel-cache" # Parcel bundler + ".dart_tool" # Flutter/Dart build cache + ".zig-cache" # Zig + "zig-out" # Zig + ".angular" # Angular + ".svelte-kit" # SvelteKit + ".astro" # Astro + "coverage" # Code coverage reports + "DerivedData" # Xcode + "Pods" # CocoaPods + ".cxx" # React Native Android NDK build cache + ".expo" # Expo + ".build" # Swift Package Manager ) readonly MOLE_PURGE_DEFAULT_SEARCH_PATHS=( @@ -53,6 +61,15 @@ readonly MOLE_PURGE_DEFAULT_SEARCH_PATHS=( "$HOME/Workspace" "$HOME/Repos" "$HOME/Development" + "$HOME/Library/CloudStorage" + # AI agent worktree containers. These sit under dot directories, which + # discover_project_dirs cannot reach: it globs "$HOME"/*/ and + # is_project_container rejects any basename starting with a dot. Listing + # the exact containers keeps the checkouts inside them in scope for + # rebuildable-artifact cleanup without widening discovery to dot + # directories in general. The worktrees themselves are never removed. + "$HOME/.codex/worktrees" + "$HOME/.claude/worktrees" ) readonly MOLE_PURGE_MONOREPO_INDICATORS=( @@ -70,21 +87,40 @@ readonly MOLE_PURGE_PROJECT_INDICATORS=( "requirements.txt" "pom.xml" "build.gradle" + "terragrunt.hcl" "Gemfile" "composer.json" "pubspec.yaml" + "Package.swift" # Swift Package Manager "Makefile" "build.zig" "build.zig.zon" ".git" ) +readonly MOLE_CACHEDIR_TAG_NAME="CACHEDIR.TAG" +readonly MOLE_CACHEDIR_TAG_SIGNATURE="Signature: 8a477f597d28d172789f06886806bc55" + # High-noise targets intentionally excluded from quick hint scans in mo clean. readonly MOLE_PURGE_QUICK_HINT_EXCLUDED_TARGETS=( "bin" "vendor" ) +mole_purge_is_cloud_synced_path() { + local path="${1:-}" + [[ -n "$path" ]] || return 1 + + case "$path" in + "$HOME/Library/CloudStorage" | "$HOME/Library/CloudStorage/"* | "$HOME/Library/Mobile Documents" | "$HOME/Library/Mobile Documents/"* | \ + "$MOLE_PURGE_PHYSICAL_HOME/Library/CloudStorage" | "$MOLE_PURGE_PHYSICAL_HOME/Library/CloudStorage/"* | "$MOLE_PURGE_PHYSICAL_HOME/Library/Mobile Documents" | "$MOLE_PURGE_PHYSICAL_HOME/Library/Mobile Documents/"*) + return 0 + ;; + esac + + return 1 +} + mole_purge_is_project_root() { local dir="$1" local indicator @@ -104,6 +140,16 @@ mole_purge_is_project_root() { return 1 } +mole_dir_has_cachedir_tag() { + local dir="$1" + local tag="$dir/$MOLE_CACHEDIR_TAG_NAME" + [[ -f "$tag" && ! -L "$tag" ]] || return 1 + + local signature + signature=$(LC_ALL=C dd bs=${#MOLE_CACHEDIR_TAG_SIGNATURE} count=1 < "$tag" 2> /dev/null || true) + [[ "$signature" == "$MOLE_CACHEDIR_TAG_SIGNATURE" ]] +} + mole_purge_quick_hint_target_names() { local target local excluded @@ -122,6 +168,19 @@ mole_purge_quick_hint_target_names() { done } +# Resolve a directory path to its canonical filesystem casing. +# On case-insensitive macOS (APFS), ~/Code and ~/code point to the same +# directory but with different display names. This function returns the +# real (on-disk) path so that string comparisons work correctly for dedup. +mole_purge_resolve_path_case() { + local path="$1" + if [[ -d "$path" ]]; then + (cd "$path" 2> /dev/null && pwd -P) || printf '%s\n' "$path" + else + printf '%s\n' "$path" + fi +} + mole_purge_read_paths_config() { local config_file="${1:-$HOME/.config/mole/purge_paths}" [[ -f "$config_file" ]] || return 0 @@ -132,6 +191,7 @@ mole_purge_read_paths_config() { line="${line%"${line##*[![:space:]]}"}" [[ -z "$line" || "$line" =~ ^# ]] && continue line="${line/#\~/$HOME}" + line=$(mole_purge_resolve_path_case "$line") printf '%s\n' "$line" done < "$config_file" } diff --git a/Resources/mole/lib/clean/system.sh b/Resources/mole/lib/clean/system.sh index 817964e..4a9403c 100644 --- a/Resources/mole/lib/clean/system.sh +++ b/Resources/mole/lib/clean/system.sh @@ -1,206 +1,912 @@ #!/bin/bash # System-Level Cleanup Module (requires sudo). set -euo pipefail + +is_rebuildable_gpu_cache_dir() { + local cache_dir="$1" + + # Only match current-user-accessible Darwin cache shards under C/. Do not + # match T/ temp folders, generic /private/var/folders entries, or arbitrary + # system paths: these Metal/GPU caches are rebuildable, but deleting active + # caches can force live apps to recompile shaders and momentarily stutter. + case "$cache_dir" in + /private/var/folders/*/*/C/*/com.apple.gpuarchiver | \ + /private/var/folders/*/*/C/*/com.apple.metal | \ + /private/var/folders/*/*/C/*/com.apple.metalfe | \ + /var/folders/*/*/C/*/com.apple.gpuarchiver | \ + /var/folders/*/*/C/*/com.apple.metal | \ + /var/folders/*/*/C/*/com.apple.metalfe) + return 0 + ;; + esac + + return 1 +} + +gpu_cache_dir_is_stale() { + local cache_dir="$1" + local age_days="${2:-${MOLE_GPU_CACHE_AGE_DAYS:-1}}" + + [[ "$age_days" =~ ^[0-9]+$ ]] || age_days=1 + [[ -d "$cache_dir" ]] || return 1 + [[ -L "$cache_dir" ]] && return 1 + + # Directory mtime only changes when entries are added/removed/renamed. + # Treat a cache as stale only when no contained file was modified inside + # the retention window, so live apps that rewrite existing Metal cache + # files do not lose their active shader/GPU cache on every cleanup run. + local recent_file="" + local probe_rc=0 + recent_file=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + /usr/bin/find "$cache_dir" -type f -mtime "-$age_days" -print -quit 2> /dev/null) || probe_rc=$? + if [[ $probe_rc -ge 128 ]]; then + return "$probe_rc" + fi + [[ $probe_rc -eq 0 ]] || return 1 + [[ -z "$recent_file" ]] +} + +# Fail-closed Software Update probe for macOS installer cleanup. Returns 0 +# (treat as pending) when recommended updates are queued, and also when the +# plist is unreadable, plutil fails, or the key is missing: removing staged +# update payloads on a wrong "no updates" answer left a Mac unbootable on a +# macOS 27 beta, so an unknown state must block cleanup. +software_update_pending_or_unknown() { + local plist="${1:-/Library/Preferences/com.apple.SoftwareUpdate.plist}" + local deadline_seconds="${2:-}" + [[ -f "$plist" ]] || return 0 + local recommended="" + local probe_timeout="$MOLE_TIMEOUT_QUICK_DETECT_SEC" + if [[ -n "$deadline_seconds" ]]; then + probe_timeout=$(_mole_timeout_with_deadline "$probe_timeout" \ + "$deadline_seconds") || return 0 + fi + local probe_rc=0 + recommended=$(run_with_timeout "$probe_timeout" plutil \ + -extract RecommendedUpdates json -o - "$plist" < /dev/null 2> /dev/null) || probe_rc=$? + if [[ $probe_rc -ge 128 ]]; then + return "$probe_rc" + elif [[ $probe_rc -ne 0 ]]; then + return 0 + fi + recommended=$(printf '%s' "$recommended" | tr -d '[:space:]') + # Only a readable, explicitly empty RecommendedUpdates array means "no + # pending updates"; anything else stays fail-closed. + if [[ "$recommended" == "[]" ]]; then + return 1 + fi + return 0 +} + +# Report the tens-of-GB runaway shape from #1283 without mutating the active +# database. A 10 GiB threshold reserves the warning for the reported +# tens-of-GB runaway shape; it is never permission to delete or vacuum. +show_large_active_powerlog_notice() { + local warning_threshold_bytes=$((10 * 1024 * 1024 * 1024)) + local size_bytes="" + + local probe_rc=0 + size_bytes=$(_mole_bounded_sudo "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n "$STAT_BSD" -f%z "$MOLE_ACTIVE_POWERLOG_DB_PATH" < /dev/null 2> /dev/null) || probe_rc=$? + if [[ $probe_rc -ge 128 ]]; then + return "$probe_rc" + fi + [[ $probe_rc -eq 0 ]] || return 0 + [[ "$size_bytes" =~ ^[0-9]+$ ]] || return 0 + [[ "$size_bytes" -ge "$warning_threshold_bytes" ]] || return 0 + + local size_human + size_human=$(bytes_to_human "$size_bytes") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Power telemetry database · ${GREEN}${size_human}${NC} · ${GRAY}active, kept · $(format_path_link "$MOLE_ACTIVE_POWERLOG_DB_PATH")${NC}" +} + +report_system_cleanup_incomplete() { + local label="$1" + local status="${2:-1}" + + if [[ "$status" -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} ${label} · ${GRAY}timed out, cleanup may be partial${NC}" + if declare -F note_activity > /dev/null 2>&1; then + note_activity + fi + else + debug_log "$label cleanup incomplete (status $status)" + fi +} + +system_cleanup_budget_reached() { + local deadline="$1" + [[ "$SECONDS" -ge "$deadline" ]] +} + +report_system_cleanup_budget_reached() { + echo -e " ${YELLOW}${ICON_WARNING}${NC} System cleanup · ${GRAY}time limit reached, skipped remaining slow scans${NC}" + if declare -F note_activity > /dev/null 2>&1; then + note_activity + fi +} + +# A timed-out producer must not feed its partial prefix into a deletion loop. +# Materialize only completed scans; callers discard the file on every failure. +materialize_completed_system_scan() { + local output_file="$1" + local duration="$2" + shift 2 + + : > "$output_file" || return 1 + local scan_rc=0 + run_with_timeout "$duration" "$@" > "$output_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + : > "$output_file" || true + return "$scan_rc" + fi + return 0 +} + +macos_installer_candidate_identity() { + local path="$1" + local deadline_seconds="$2" + local identity_timeout="" + identity_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || return $? + run_with_timeout "$identity_timeout" "$STAT_BSD" \ + -f%d:%i:%m "$path" < /dev/null 2> /dev/null +} + +macos_installer_process_is_idle() { + local path="$1" + local deadline_seconds="$2" + local process_timeout="" + process_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || return $? + local process_rc=0 + run_with_timeout "$process_timeout" pgrep -f "$path" \ + < /dev/null > /dev/null 2>&1 || process_rc=$? + [[ $process_rc -eq 1 ]] && return 0 + [[ $process_rc -ge 128 ]] && return "$process_rc" + return 1 +} + +macos_installer_candidate_still_eligible() { + local path="$1" + local expected_identity="$2" + local current_macos_version="$3" + local deadline_seconds="$4" + + [[ -d "$path" && ! -L "$path" ]] || return 1 + local current_identity="" + current_identity=$(macos_installer_candidate_identity "$path" \ + "$deadline_seconds") || return $? + [[ "$current_identity" == "$expected_identity" ]] || return 1 + + local current_mtime="${current_identity##*:}" + [[ "$current_mtime" =~ ^[0-9]+$ && "$current_mtime" -gt 0 ]] || return 1 + local now + now=$(get_epoch_seconds) + [[ "$now" =~ ^[0-9]+$ && $now -ge $current_mtime ]] || return 1 + [[ $(((now - current_mtime) / 86400)) -ge 14 ]] || return 1 + + local update_state_rc=0 + software_update_pending_or_unknown \ + /Library/Preferences/com.apple.SoftwareUpdate.plist "$deadline_seconds" || update_state_rc=$? + [[ $update_state_rc -ge 128 ]] && return "$update_state_rc" + [[ $update_state_rc -eq 0 ]] && return 1 + macos_installer_process_is_idle "$path" "$deadline_seconds" || return $? + + if [[ -n "$current_macos_version" ]]; then + local installer_plist="$path/Contents/Info.plist" + [[ -f "$installer_plist" ]] || return 1 + local version_timeout="" + version_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || return $? + local installer_version="" + installer_version=$(run_with_timeout "$version_timeout" /usr/libexec/PlistBuddy \ + -c "Print :DTPlatformVersion" "$installer_plist" < /dev/null 2> /dev/null) || return $? + installer_version="${installer_version%%.*}" + [[ -n "$installer_version" && "$installer_version" != "$current_macos_version" ]] || return 1 + fi + + # The size probe between the caller's two eligibility checks can race with + # Software Update or an installer launch. Repeat active-state checks and + # finish on the exact app identity handed to safe_sudo_remove. + update_state_rc=0 + software_update_pending_or_unknown \ + /Library/Preferences/com.apple.SoftwareUpdate.plist "$deadline_seconds" || update_state_rc=$? + [[ $update_state_rc -ge 128 ]] && return "$update_state_rc" + [[ $update_state_rc -eq 0 ]] && return 1 + macos_installer_process_is_idle "$path" "$deadline_seconds" || return $? + current_identity=$(macos_installer_candidate_identity "$path" \ + "$deadline_seconds") || return $? + [[ "$current_identity" == "$expected_identity" ]] +} + # System caches, logs, and temp files. clean_deep_system() { stop_section_spinner + # Keep the section bounded as a whole as well as at each inner scan. A + # single slow filesystem may consume one inner timeout, but later families + # stop once the two-minute section budget is exhausted. + local system_cleanup_deadline=$((SECONDS + 120)) local cache_cleaned=0 + local cache_status=0 start_section_spinner "Cleaning system caches..." - # Optimized: Single pass for /Library/Caches (3 patterns in 1 scan) - if sudo test -d "/Library/Caches" 2> /dev/null; then - while IFS= read -r -d '' file; do - if should_protect_path "$file"; then - continue - fi - if safe_sudo_remove "$file"; then - cache_cleaned=1 - fi - done < <(sudo find "/Library/Caches" -maxdepth 5 -type f \( \ - \( -name "*.cache" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" \) -o \ - \( -name "*.tmp" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" \) -o \ - \( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \ - \) -print0 2> /dev/null || true) + local -a cache_extra_patterns=("*.tmp") + if [[ "$MOLE_LOG_AGE_DAYS" -eq "$MOLE_TEMP_FILE_AGE_DAYS" ]]; then + cache_extra_patterns+=("*.log") fi - stop_section_spinner - [[ $cache_cleaned -eq 1 ]] && log_success "System caches" - start_section_spinner "Cleaning system temporary files..." - local tmp_cleaned=0 - local -a sys_temp_dirs=("/private/tmp" "/private/var/tmp") - for tmp_dir in "${sys_temp_dirs[@]}"; do - if sudo find "$tmp_dir" -maxdepth 1 -type f -mtime "+${MOLE_TEMP_FILE_AGE_DAYS}" -print -quit 2> /dev/null | grep -q .; then - if safe_sudo_find_delete "$tmp_dir" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f"; then - tmp_cleaned=1 - fi + local cache_rc=0 + safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" "5" \ + "$system_cleanup_deadline" "${cache_extra_patterns[@]}" || cache_rc=$? + if [[ $cache_rc -ge 128 ]]; then + stop_section_spinner + return "$cache_rc" + fi + if [[ $cache_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + cache_cleaned=1 + elif [[ $cache_rc -ne 0 ]]; then + cache_status=$cache_rc + fi + # Preserve independent retention semantics if these constants ever diverge; + # with today's equal values the common directory is traversed only once. + if [[ "$MOLE_LOG_AGE_DAYS" -ne "$MOLE_TEMP_FILE_AGE_DAYS" && $cache_rc -eq 0 ]] && + ! system_cleanup_budget_reached "$system_cleanup_deadline"; then + cache_rc=0 + safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" "5" \ + "$system_cleanup_deadline" || cache_rc=$? + if [[ $cache_rc -ge 128 ]]; then + stop_section_spinner + return "$cache_rc" + elif [[ $cache_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + cache_cleaned=1 + elif [[ $cache_rc -ne 0 && $cache_status -eq 0 ]]; then + cache_status=$cache_rc fi - done + fi stop_section_spinner - [[ $tmp_cleaned -eq 1 ]] && log_success "System temp files" + if [[ $cache_status -ne 0 ]]; then + report_system_cleanup_incomplete "System caches" "$cache_status" + fi + if [[ $cache_cleaned -eq 1 ]]; then + log_success "System caches" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi + # Do not sweep generic /private/tmp or /private/var/tmp contents here. + # Age and a bounded scan do not prove third-party runtime state is + # disposable, and large shared temp roots made this section look hung. start_section_spinner "Cleaning system crash reports..." - if sudo find "/Library/Logs/DiagnosticReports" -maxdepth 1 -type f -mtime "+$MOLE_CRASH_REPORT_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then - safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true + local crash_rc=0 + safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" \ + "$MOLE_CRASH_REPORT_AGE_DAYS" "f" "1" "$system_cleanup_deadline" || crash_rc=$? + if [[ $crash_rc -ge 128 ]]; then + stop_section_spinner + return "$crash_rc" fi + local crash_cleaned=${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} stop_section_spinner - log_success "System crash reports" + if [[ $crash_rc -ne 0 ]]; then + report_system_cleanup_incomplete "System crash reports" "$crash_rc" + fi + if [[ $crash_rc -eq 0 && $crash_cleaned -gt 0 ]]; then + log_success "System crash reports" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi start_section_spinner "Cleaning system logs..." - if sudo find "/private/var/log" -maxdepth 3 -type f \( -name "*.log" -o -name "*.gz" -o -name "*.asl" \) -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then - safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/log" "*.asl" "$MOLE_LOG_AGE_DAYS" "f" || true + local system_logs_cleaned=0 + local system_logs_status=0 + local system_log_rc=0 + safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" \ + "f" "3" "$system_cleanup_deadline" "*.gz" "*.asl" || system_log_rc=$? + if [[ $system_log_rc -ge 128 ]]; then + stop_section_spinner + return "$system_log_rc" + fi + if [[ $system_log_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + system_logs_cleaned=1 + elif [[ $system_log_rc -ne 0 ]]; then + system_logs_status=$system_log_rc fi stop_section_spinner - log_success "System logs" + if [[ $system_logs_status -ne 0 ]]; then + report_system_cleanup_incomplete "System logs" "$system_logs_status" + fi + if [[ $system_logs_cleaned -eq 1 ]]; then + log_success "System logs" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi start_section_spinner "Cleaning third-party system logs..." local -a third_party_log_dirs=( "/Library/Logs/Adobe" "/Library/Logs/CreativeCloud" ) local third_party_logs_cleaned=0 + local third_party_logs_status=0 local third_party_log_dir="" for third_party_log_dir in "${third_party_log_dirs[@]}"; do - if sudo test -d "$third_party_log_dir" 2> /dev/null; then - if sudo find "$third_party_log_dir" -maxdepth 5 -type f -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then - if safe_sudo_find_delete "$third_party_log_dir" "*" "$MOLE_LOG_AGE_DAYS" "f"; then - third_party_logs_cleaned=1 - fi - fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + third_party_logs_status=124 + break + fi + local third_party_rc=0 + safe_sudo_find_delete "$third_party_log_dir" "*" "$MOLE_LOG_AGE_DAYS" "f" "5" \ + "$system_cleanup_deadline" || third_party_rc=$? + if [[ $third_party_rc -ge 128 ]]; then + stop_section_spinner + return "$third_party_rc" + fi + if [[ $third_party_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + third_party_logs_cleaned=1 + elif [[ $third_party_rc -ne 0 && $third_party_logs_status -eq 0 ]]; then + third_party_logs_status=$third_party_rc fi + [[ $third_party_rc -eq 124 ]] && break done - if sudo find "/Library/Logs" -maxdepth 1 -type f -name "adobegc.log" -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then - if safe_sudo_remove "/Library/Logs/adobegc.log"; then + if [[ $third_party_logs_status -eq 124 ]] || system_cleanup_budget_reached "$system_cleanup_deadline"; then + if [[ $third_party_logs_status -eq 0 ]]; then + third_party_logs_status=124 + fi + else + local adobegc_rc=0 + safe_sudo_find_delete "/Library/Logs" "adobegc.log" "$MOLE_LOG_AGE_DAYS" "f" "1" \ + "$system_cleanup_deadline" || adobegc_rc=$? + if [[ $adobegc_rc -ge 128 ]]; then + stop_section_spinner + return "$adobegc_rc" + fi + if [[ $adobegc_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then third_party_logs_cleaned=1 + elif [[ $adobegc_rc -ne 0 && $third_party_logs_status -eq 0 ]]; then + third_party_logs_status=$adobegc_rc fi fi stop_section_spinner - [[ $third_party_logs_cleaned -eq 1 ]] && log_success "Third-party system logs" - start_section_spinner "Scanning system library updates..." - if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then - local updates_cleaned=0 - while IFS= read -r -d '' item; do - if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then - debug_log "Skipping malformed path: $item" - continue - fi - local item_flags - item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") - if [[ "$item_flags" == *"restricted"* ]]; then - continue - fi - if safe_sudo_remove "$item"; then - updates_cleaned=$((updates_cleaned + 1)) - fi - done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - stop_section_spinner - [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" - else - stop_section_spinner + if [[ $third_party_logs_status -ne 0 ]]; then + report_system_cleanup_incomplete "Third-party system logs" "$third_party_logs_status" + fi + if [[ $third_party_logs_cleaned -eq 1 ]]; then + log_success "Third-party system logs" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi + # Software Update owns /Library/Updates and /macOS Install Data. Age, + # process, and plist probes cannot prove those staging trees are inactive + # across the full scan-to-delete window, so clean never removes them. + if [[ -d "/Library/Updates" ]]; then + debug_log "Keeping /Library/Updates: managed by Software Update" fi - start_section_spinner "Scanning macOS installer files..." if [[ -d "/macOS Install Data" ]]; then - local mtime - mtime=$(get_file_mtime "/macOS Install Data") - local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) - debug_log "Found macOS Install Data, age ${age_days} days" - if [[ $age_days -ge 14 ]]; then - local size_kb - size_kb=$(get_path_size_kb "/macOS Install Data") - if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then - local size_human - size_human=$(bytes_to_human "$((size_kb * 1024))") - debug_log "Cleaning macOS Install Data: $size_human, ${age_days} days old" - if safe_sudo_remove "/macOS Install Data"; then - log_success "macOS Install Data, $size_human" - fi - fi - else - debug_log "Keeping macOS Install Data, only ${age_days} days old, needs 14+" - fi + debug_log "Keeping macOS Install Data: managed by Software Update" fi + + start_section_spinner "Scanning macOS installer files..." # Clean macOS installer apps (e.g., "Install macOS Sequoia.app") - # Only remove installers older than 14 days and not currently running + # Only remove installers older than 14 days, not currently running, + # and not matching the currently installed macOS version (recovery safety). local installer_cleaned=0 + local installer_status=0 + local current_macos_version="" + local current_macos_version_output="" + local current_macos_version_rc=0 + local current_macos_version_timeout="" + current_macos_version_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$system_cleanup_deadline") || current_macos_version_rc=$? + if [[ $current_macos_version_rc -eq 0 ]]; then + current_macos_version_output=$(run_with_timeout "$current_macos_version_timeout" \ + sw_vers -productVersion < /dev/null 2> /dev/null) || current_macos_version_rc=$? + fi + if [[ $current_macos_version_rc -eq 0 ]]; then + current_macos_version="${current_macos_version_output%%.*}" + elif [[ $current_macos_version_rc -ge 128 ]]; then + stop_section_spinner + return "$current_macos_version_rc" + else + installer_status=$current_macos_version_rc + fi for installer_app in /Applications/Install\ macOS*.app; do + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + break + fi [[ -d "$installer_app" ]] || continue + if [[ $installer_status -ne 0 || ! "$current_macos_version" =~ ^[0-9]+$ ]]; then + debug_log "Keeping macOS installer apps: current system version is unavailable" + break + fi local app_name app_name=$(basename "$installer_app") - # Skip if installer is currently running - if pgrep -f "$installer_app" > /dev/null 2>&1; then - debug_log "Skipping $app_name: currently running" + local installer_identity="" + local installer_identity_rc=0 + installer_identity=$(macos_installer_candidate_identity "$installer_app" \ + "$system_cleanup_deadline") || installer_identity_rc=$? + if [[ $installer_identity_rc -eq 124 ]]; then + installer_status=124 + break + elif [[ $installer_identity_rc -ge 128 ]]; then + stop_section_spinner + return "$installer_identity_rc" + elif [[ $installer_identity_rc -ne 0 ]]; then continue fi - # Check age (same 14-day threshold as /macOS Install Data) - local mtime - mtime=$(get_file_mtime "$installer_app") - local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) - if [[ $age_days -lt 14 ]]; then - debug_log "Keeping $app_name: only ${age_days} days old, needs 14+" + local installer_eligibility_rc=0 + macos_installer_candidate_still_eligible "$installer_app" "$installer_identity" \ + "$current_macos_version" "$system_cleanup_deadline" || installer_eligibility_rc=$? + if [[ $installer_eligibility_rc -eq 124 ]]; then + installer_status=124 + break + elif [[ $installer_eligibility_rc -ge 128 ]]; then + stop_section_spinner + return "$installer_eligibility_rc" + elif [[ $installer_eligibility_rc -ne 0 ]]; then + debug_log "Keeping $app_name: active, current, recent, changed, or update state unknown" continue fi + local installer_mtime="${installer_identity##*:}" + local age_days=$((($(get_epoch_seconds) - installer_mtime) / 86400)) local size_kb - size_kb=$(get_path_size_kb "$installer_app") + local installer_size_timeout="" + if ! installer_size_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$system_cleanup_deadline"); then + installer_status=124 + break + fi + local installer_size_rc=0 + size_kb=$(get_path_size_kb "$installer_app" \ + "$installer_size_timeout") || installer_size_rc=$? + if [[ $installer_size_rc -eq 124 ]]; then + installer_status=124 + break + elif [[ $installer_size_rc -ge 128 ]]; then + stop_section_spinner + return "$installer_size_rc" + elif [[ $installer_size_rc -ne 0 ]]; then + continue + fi if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then + installer_eligibility_rc=0 + macos_installer_candidate_still_eligible "$installer_app" "$installer_identity" \ + "$current_macos_version" "$system_cleanup_deadline" || installer_eligibility_rc=$? + if [[ $installer_eligibility_rc -eq 124 ]]; then + installer_status=124 + break + elif [[ $installer_eligibility_rc -ge 128 ]]; then + stop_section_spinner + return "$installer_eligibility_rc" + elif [[ $installer_eligibility_rc -ne 0 ]]; then + debug_log "Keeping $app_name: eligibility changed during size probe" + continue + fi local size_human size_human=$(bytes_to_human "$((size_kb * 1024))") debug_log "Cleaning macOS installer: $app_name, $size_human, ${age_days} days old" - if safe_sudo_remove "$installer_app"; then + local installer_remove_rc=0 + safe_sudo_remove "$installer_app" "$size_kb" \ + "$system_cleanup_deadline" || installer_remove_rc=$? + if [[ $installer_remove_rc -eq 124 ]]; then + installer_status=124 + break + fi + if [[ $installer_remove_rc -ge 128 ]]; then + stop_section_spinner + return "$installer_remove_rc" + fi + if [[ $installer_remove_rc -eq 0 ]]; then log_success "$app_name, $size_human" installer_cleaned=$((installer_cleaned + 1)) fi fi done stop_section_spinner - [[ $installer_cleaned -gt 0 ]] && debug_log "Cleaned $installer_cleaned macOS installer(s)" + if [[ $installer_cleaned -gt 0 ]]; then + debug_log "Cleaned $installer_cleaned macOS installer(s)" + fi + if [[ $installer_status -ne 0 ]]; then + report_system_cleanup_incomplete "macOS installer files" "$installer_status" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi start_section_spinner "Scanning browser code signature caches..." local code_sign_cleaned=0 - while IFS= read -r -d '' cache_dir; do - if safe_sudo_remove "$cache_dir"; then - code_sign_cleaned=$((code_sign_cleaned + 1)) + local code_sign_scan_file="" + local code_sign_scan_rc=0 + if code_sign_scan_file=$(create_temp_file 2> /dev/null); then + local code_sign_scan_timeout="" + code_sign_scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$system_cleanup_deadline") || code_sign_scan_rc=$? + if [[ $code_sign_scan_rc -eq 0 ]]; then + # -path is a test, not a prune: without the container-level prune + # find still walks every C/ and T/ tree even though only X/ can + # match. Depth 3 is the /var/folders/// level. + materialize_completed_system_scan "$code_sign_scan_file" \ + "$code_sign_scan_timeout" /usr/bin/find /private/var/folders \ + -maxdepth 5 -type d \( -depth 3 ! -name X \) -prune \ + -o -type d -name "*.code_sign_clone" -path "*/X/*" -print0 || code_sign_scan_rc=$? + fi + if [[ $code_sign_scan_rc -eq 0 ]]; then + while IFS= read -r -d '' cache_dir; do + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + code_sign_scan_rc=124 + break + fi + # Never delete an EDR agent's code-signature clone -- same + # sensor-tamper risk as its caches below. Browsers are the target. + if is_endpoint_security_cache_path "$cache_dir"; then + continue + fi + local code_sign_remove_rc=0 + safe_sudo_remove "$cache_dir" "" "$system_cleanup_deadline" || code_sign_remove_rc=$? + if [[ $code_sign_remove_rc -eq 124 || $code_sign_remove_rc -ge 128 ]]; then + code_sign_scan_rc=$code_sign_remove_rc + break + fi + if [[ $code_sign_remove_rc -eq 0 ]]; then + code_sign_cleaned=$((code_sign_cleaned + 1)) + fi + done < "$code_sign_scan_file" + fi + rm -f -- "$code_sign_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + else + code_sign_scan_rc=1 + fi + stop_section_spinner + if [[ $code_sign_scan_rc -ne 0 ]]; then + if [[ $code_sign_scan_rc -ge 128 ]]; then + return "$code_sign_scan_rc" + fi + report_system_cleanup_incomplete "Browser code signature caches" "$code_sign_scan_rc" + fi + if [[ $code_sign_cleaned -gt 0 ]]; then + log_success "Browser code signature caches, $code_sign_cleaned items" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi + + start_section_spinner "Cleaning rebuildable system service caches..." + local rebuildable_cache_cleaned=0 + local rebuildable_cache_status=0 + local -a rebuildable_cache_dirs=( + "/Library/Caches/com.apple.iconservices.store" + ) + local rebuildable_cache_dir="" + for rebuildable_cache_dir in "${rebuildable_cache_dirs[@]}"; do + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + break + fi + local rebuildable_exists_rc=0 + local rebuildable_probe_timeout="" + rebuildable_probe_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$system_cleanup_deadline") || rebuildable_exists_rc=$? + if [[ $rebuildable_exists_rc -eq 0 ]]; then + _mole_bounded_sudo "$rebuildable_probe_timeout" \ + -n test -e "$rebuildable_cache_dir" < /dev/null 2> /dev/null || rebuildable_exists_rc=$? + fi + if [[ $rebuildable_exists_rc -eq 124 ]]; then + rebuildable_cache_status=124 + break + fi + if [[ $rebuildable_exists_rc -ge 128 ]]; then + stop_section_spinner + return "$rebuildable_exists_rc" fi - done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) + [[ $rebuildable_exists_rc -eq 0 ]] || continue + local rebuildable_remove_rc=0 + safe_sudo_remove "$rebuildable_cache_dir" "" "$system_cleanup_deadline" || rebuildable_remove_rc=$? + if [[ $rebuildable_remove_rc -eq 124 ]]; then + rebuildable_cache_status=124 + break + fi + if [[ $rebuildable_remove_rc -ge 128 ]]; then + stop_section_spinner + return "$rebuildable_remove_rc" + fi + if [[ $rebuildable_remove_rc -eq 0 ]]; then + rebuildable_cache_cleaned=$((rebuildable_cache_cleaned + 1)) + fi + done + stop_section_spinner + if [[ $rebuildable_cache_cleaned -gt 0 ]]; then + local rebuildable_cache_label="items" + if [[ $rebuildable_cache_cleaned -eq 1 ]]; then + rebuildable_cache_label="item" + fi + log_success "Rebuildable system caches, $rebuildable_cache_cleaned $rebuildable_cache_label" + fi + if [[ $rebuildable_cache_status -ne 0 ]]; then + report_system_cleanup_incomplete "Rebuildable system caches" "$rebuildable_cache_status" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi + + start_section_spinner "Scanning accessible rebuildable GPU caches..." + local gpu_cache_cleaned=0 + local gpu_cache_dir="" + local gpu_scan_file="" + local gpu_scan_rc=0 + if gpu_scan_file=$(create_temp_file 2> /dev/null); then + local gpu_scan_timeout="" + gpu_scan_timeout=$(_mole_timeout_with_deadline 8 "$system_cleanup_deadline") || gpu_scan_rc=$? + if [[ $gpu_scan_rc -eq 0 ]]; then + # -path "*/C/*" is a test, not a prune: without the container-level + # prune find walks the entire T/ temp tree to depth 8 even though + # nothing there can match (measured 217k dirs / 19s on a dev + # machine, 99.6% of them under T/; pruned: 0.09s, same results). + materialize_completed_system_scan "$gpu_scan_file" "$gpu_scan_timeout" /usr/bin/find \ + /private/var/folders -maxdepth 8 \ + -type d \( -depth 3 ! -name C \) -prune \ + -o -type d \( \ + -name "com.apple.gpuarchiver" -o \ + -name "com.apple.metal" -o \ + -name "com.apple.metalfe" \ + \) -path "*/C/*" -print0 || gpu_scan_rc=$? + fi + if [[ $gpu_scan_rc -eq 0 ]]; then + while IFS= read -r -d '' gpu_cache_dir; do + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + gpu_scan_rc=124 + break + fi + is_rebuildable_gpu_cache_dir "$gpu_cache_dir" || continue + # Endpoint-security/EDR agents tamper-protect their cache + # container. Skip only those; other app GPU caches stay cleanable. + if is_endpoint_security_cache_path "$gpu_cache_dir"; then + continue + fi + local gpu_stale_rc=0 + gpu_cache_dir_is_stale "$gpu_cache_dir" "$MOLE_GPU_CACHE_AGE_DAYS" || gpu_stale_rc=$? + if [[ $gpu_stale_rc -eq 124 ]]; then + gpu_scan_rc=124 + break + elif [[ $gpu_stale_rc -ge 128 ]]; then + gpu_scan_rc=$gpu_stale_rc + break + fi + [[ $gpu_stale_rc -eq 0 ]] || continue + local gpu_remove_rc=0 + safe_sudo_remove "$gpu_cache_dir" "" "$system_cleanup_deadline" || gpu_remove_rc=$? + if [[ $gpu_remove_rc -eq 124 || $gpu_remove_rc -ge 128 ]]; then + gpu_scan_rc=$gpu_remove_rc + break + fi + if [[ $gpu_remove_rc -eq 0 ]]; then + gpu_cache_cleaned=$((gpu_cache_cleaned + 1)) + fi + done < "$gpu_scan_file" + fi + rm -f -- "$gpu_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + else + gpu_scan_rc=1 + fi stop_section_spinner - [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" + if [[ $gpu_scan_rc -ne 0 ]]; then + if [[ $gpu_scan_rc -ge 128 ]]; then + return "$gpu_scan_rc" + fi + report_system_cleanup_incomplete "Accessible rebuildable GPU caches" "$gpu_scan_rc" + fi + if [[ $gpu_cache_cleaned -gt 0 ]]; then + local gpu_cache_label="items" + if [[ $gpu_cache_cleaned -eq 1 ]]; then + gpu_cache_label="item" + fi + log_success "Accessible rebuildable GPU caches, $gpu_cache_cleaned $gpu_cache_label" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi + + # Aborted Aerial / dynamic-wallpaper downloads. com.apple.idleassetsd runs + # as root, so its per-user Darwin temp dir sits under root's + # /private/var/folders tree (mode 700) and is invisible to an unprivileged + # scan: macOS buries the bytes in the opaque "System Data" bucket, and a + # stuck (re)download can leave hundreds of GB of ~1GB CFNetworkDownload_*.tmp + # files behind (#1253). Scope the removal to the idleassetsd temp dir and to + # that exact aborted-download name, older than the temp-file retention + # window, so an in-progress download (recent mtime) is never touched. macOS + # re-fetches assets on demand, so the removal is non-destructive. The locator + # needs sudo because the whole tree is root-owned; safe_sudo_find_delete then + # re-applies the shared protection and whitelist gates per file. + start_section_spinner "Scanning stale wallpaper downloads..." + local idle_tmp_cleaned=0 + local idle_tmp_status=0 + local idle_tmp_dir="" + local idle_scan_file="" + if idle_scan_file=$(create_temp_file 2> /dev/null); then + local idle_scan_timeout="" + idle_scan_timeout=$(_mole_timeout_with_deadline 8 "$system_cleanup_deadline") || idle_tmp_status=$? + if [[ $idle_tmp_status -eq 0 ]]; then + _mole_materialize_bounded_sudo_find "$idle_scan_file" "$idle_scan_timeout" \ + /private/var/folders -maxdepth 5 -type d -name "com.apple.idleassetsd" \ + -path "*/T/*" -print0 || idle_tmp_status=$? + fi + if [[ $idle_tmp_status -eq 0 ]]; then + while IFS= read -r -d '' idle_tmp_dir; do + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + idle_tmp_status=124 + break + fi + local idle_tmp_rc=0 + safe_sudo_find_delete "$idle_tmp_dir" "CFNetworkDownload_*.tmp" \ + "$MOLE_TEMP_FILE_AGE_DAYS" "f" "5" "$system_cleanup_deadline" || idle_tmp_rc=$? + if [[ $idle_tmp_rc -ge 128 ]]; then + idle_tmp_status=$idle_tmp_rc + break + fi + if [[ $idle_tmp_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + idle_tmp_cleaned=$((idle_tmp_cleaned + 1)) + elif [[ $idle_tmp_rc -ne 0 && $idle_tmp_status -eq 0 ]]; then + idle_tmp_status=$idle_tmp_rc + fi + done < "$idle_scan_file" + fi + rm -f -- "$idle_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + else + idle_tmp_status=1 + fi + stop_section_spinner + if [[ $idle_tmp_status -ge 128 ]]; then + return "$idle_tmp_status" + fi + if [[ $idle_tmp_status -ne 0 ]]; then + report_system_cleanup_incomplete "Stale wallpaper downloads" "$idle_tmp_status" + fi + if [[ $idle_tmp_cleaned -gt 0 ]]; then + log_success "Stale wallpaper downloads" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi local diag_base="/private/var/db/diagnostics" start_section_spinner "Cleaning system diagnostic logs..." - safe_sudo_find_delete "$diag_base" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "$diag_base" "*.tracev3" "30" "f" || true - safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true + local diag_cleaned=0 + local diag_status=0 + local diag_rc=0 + safe_sudo_find_delete "$diag_base" "*" "$MOLE_LOG_AGE_DAYS" "f" "5" \ + "$system_cleanup_deadline" || diag_rc=$? + if [[ $diag_rc -ge 128 ]]; then + stop_section_spinner + return "$diag_rc" + fi + if [[ $diag_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + diag_cleaned=1 + elif [[ $diag_rc -ne 0 ]]; then + diag_status=$diag_rc + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + if [[ $diag_status -eq 0 ]]; then + diag_status=124 + fi + else + diag_rc=0 + safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" \ + "$MOLE_LOG_AGE_DAYS" "f" "5" "$system_cleanup_deadline" || diag_rc=$? + if [[ $diag_rc -ge 128 ]]; then + stop_section_spinner + return "$diag_rc" + fi + if [[ $diag_rc -eq 0 && ${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} -gt 0 ]]; then + diag_cleaned=1 + elif [[ $diag_rc -ne 0 && $diag_status -eq 0 ]]; then + diag_status=$diag_rc + fi + fi stop_section_spinner - log_success "System diagnostic logs" + if [[ $diag_status -ne 0 ]]; then + report_system_cleanup_incomplete "System diagnostic logs" "$diag_status" + fi + if [[ $diag_cleaned -eq 1 ]]; then + log_success "System diagnostic logs" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi start_section_spinner "Cleaning power logs..." - safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true + local power_rc=0 + safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" "5" \ + "$system_cleanup_deadline" || power_rc=$? + if [[ $power_rc -ge 128 ]]; then + stop_section_spinner + return "$power_rc" + fi + local power_cleaned=${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} stop_section_spinner - log_success "Power logs" + if [[ $power_rc -ne 0 ]]; then + report_system_cleanup_incomplete "Power logs" "$power_rc" + fi + if [[ $power_rc -eq 0 && $power_cleaned -gt 0 ]]; then + log_success "Power logs" + fi + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + report_system_cleanup_budget_reached + return 0 + fi + local power_notice_rc=0 + show_large_active_powerlog_notice || power_notice_rc=$? + if [[ $power_notice_rc -ge 128 ]]; then + return "$power_notice_rc" + fi start_section_spinner "Cleaning memory exception reports..." local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations" local mem_cleaned=0 - if sudo test -d "$mem_reports_dir" 2> /dev/null; then - # Count and size old files before deletion - local file_count=0 - local total_size_kb=0 - local total_bytes=0 - local stats_out - stats_out=$(sudo find "$mem_reports_dir" -type f -mtime +30 -exec stat -f "%z" {} + 2> /dev/null | awk '{c++; s+=$1} END {print c+0, s+0}' || true) - if [[ -n "$stats_out" ]]; then - read -r file_count total_bytes <<< "$stats_out" - total_size_kb=$((total_bytes / 1024)) - fi - - if [[ "$file_count" -gt 0 ]]; then - if [[ "${DRY_RUN:-}" != "true" ]]; then - if safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f"; then - mem_cleaned=1 - fi - # Log summary to operations.log - if [[ $mem_cleaned -eq 1 ]] && oplog_enabled && [[ "$total_size_kb" -gt 0 ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size_kb * 1024))") - log_operation "clean" "REMOVED" "$mem_reports_dir" "$file_count files, $size_human" - fi - else - log_info "[DRY-RUN] Would remove $file_count old memory exception reports ($total_size_kb KB)" + # Count and size old files before deletion. The sizing result is advisory; + # a timeout discards it and the independently bounded deletion scan decides + # whether any target is eligible. + local file_count=0 + local total_size_kb=0 + local total_bytes=0 + local stats_out="" + local stats_rc=0 + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + stats_rc=124 + else + local stats_timeout="" + stats_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$system_cleanup_deadline") || stats_rc=$? + if [[ $stats_rc -eq 0 ]]; then + stats_out=$(_mole_bounded_sudo_find "$stats_timeout" \ + "$mem_reports_dir" -maxdepth 5 -type f -mtime +30 -exec stat -f "%z" {} + 2> /dev/null | + awk '{c++; s+=$1} END {print c+0, s+0}') || stats_rc=$? + fi + fi + if [[ $stats_rc -ge 128 ]]; then + stop_section_spinner + return "$stats_rc" + fi + if [[ $stats_rc -eq 0 && -n "$stats_out" ]]; then + read -r file_count total_bytes <<< "$stats_out" + total_size_kb=$((total_bytes / 1024)) + fi + + local mem_rc=0 + if system_cleanup_budget_reached "$system_cleanup_deadline"; then + mem_rc=124 + else + safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f" "5" \ + "$system_cleanup_deadline" || mem_rc=$? + fi + if [[ $mem_rc -ge 128 ]]; then + stop_section_spinner + return "$mem_rc" + fi + local mem_removed_count=${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-0} + if [[ $mem_rc -eq 0 && $mem_removed_count -gt 0 ]]; then + if [[ "${DRY_RUN:-}" != "true" ]]; then + mem_cleaned=1 + # Only attach the pre-scan size when it covered exactly the same + # completed candidate set as the deletion helper. + if [[ $stats_rc -eq 0 && $file_count -eq $mem_removed_count ]] && + oplog_enabled && [[ "$total_size_kb" -gt 0 ]]; then + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + log_operation "clean" "REMOVED" "$mem_reports_dir" "$mem_removed_count files, $size_human" fi + elif [[ $stats_rc -eq 0 && $file_count -eq $mem_removed_count ]]; then + log_info "[DRY-RUN] Would remove $mem_removed_count old memory exception reports ($total_size_kb KB)" + else + log_info "[DRY-RUN] Would remove $mem_removed_count old memory exception reports" fi + elif [[ $mem_rc -ne 0 ]]; then + report_system_cleanup_incomplete "Memory exception reports" "$mem_rc" + elif [[ $stats_rc -eq 124 ]]; then + report_system_cleanup_incomplete "Memory exception report sizing" "$stats_rc" fi stop_section_spinner if [[ $mem_cleaned -eq 1 ]]; then @@ -208,41 +914,124 @@ clean_deep_system() { fi return 0 } + +time_machine_candidate_identity() { + local path="$1" + local deadline_seconds="$2" + local identity_timeout="" + identity_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || return $? + run_with_timeout "$identity_timeout" "$STAT_BSD" -f%d:%i:%m "$path" < /dev/null 2> /dev/null +} + +# Recheck every destructive predicate immediately before tmutil sees the path. +# tmutil remains the owner of backup deletion, but Mole must not hand it a path +# that changed identity/age or became active after the completed scan. +time_machine_candidate_still_eligible() { + local path="$1" + local expected_identity="$2" + local minimum_hours="$3" + local deadline_seconds="$4" + + [[ -d "$path" && ! -L "$path" ]] || return 1 + + local running_rc=0 + tm_is_running "$deadline_seconds" || running_rc=$? + if [[ $running_rc -ge 128 ]]; then + return "$running_rc" + fi + # Only the explicit idle status authorizes a delete. + [[ $running_rc -eq 1 ]] || return 1 + + local current_identity="" + local identity_rc=0 + current_identity=$(time_machine_candidate_identity "$path" "$deadline_seconds") || identity_rc=$? + if [[ $identity_rc -ne 0 ]]; then + return "$identity_rc" + fi + [[ "$current_identity" == "$expected_identity" ]] || return 1 + + local current_mtime="${current_identity##*:}" + [[ "$current_mtime" =~ ^[0-9]+$ && "$current_mtime" -gt 0 ]] || return 1 + local now + now=$(get_epoch_seconds) + [[ $(((now - current_mtime) / 3600)) -ge $minimum_hours ]] || return 1 + + # A backup may start while the path checks are running. Recheck both the + # Time Machine state and path identity at the edge of the tmutil call. + running_rc=0 + tm_is_running "$deadline_seconds" || running_rc=$? + if [[ $running_rc -ge 128 ]]; then + return "$running_rc" + fi + [[ $running_rc -eq 1 ]] || return 1 + current_identity=$(time_machine_candidate_identity "$path" "$deadline_seconds") || return $? + [[ "$current_identity" == "$expected_identity" ]] +} + # Incomplete Time Machine backups. clean_time_machine_failed_backups() { local tm_cleaned=0 if ! command -v tmutil > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + debug_log "Time Machine: no incomplete backups found" return 0 fi # Fast pre-check: skip entirely if Time Machine is not configured (no tmutil needed) if ! defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + debug_log "Time Machine: no incomplete backups found" return 0 fi start_section_spinner "Checking Time Machine configuration..." local spinner_active=true - local tm_info - tm_info=$(run_with_timeout 2 tmutil destinationinfo 2>&1 || echo "failed") - if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; then + local tm_info="" + local tm_info_rc=0 + tm_info=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" tmutil destinationinfo 2>&1) || tm_info_rc=$? + if [[ $tm_info_rc -ge 128 ]]; then + stop_section_spinner + return "$tm_info_rc" + fi + if [[ $tm_info_rc -eq 124 ]]; then + stop_section_spinner + echo -e " ${YELLOW}!${NC} Time Machine cleanup · skipped (configuration check timed out)" + note_activity + return 0 + fi + if [[ "$tm_info" == *"No destinations configured"* || $tm_info_rc -ne 0 ]]; then if [[ "$spinner_active" == "true" ]]; then stop_section_spinner fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + debug_log "Time Machine: no incomplete backups found" return 0 fi if [[ ! -d "/Volumes" ]]; then if [[ "$spinner_active" == "true" ]]; then stop_section_spinner fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + debug_log "Time Machine: no incomplete backups found" return 0 fi - if tm_is_running; then + # tm_is_running is tri-state: 0 running, 1 idle, 2 status-unknown. Treat + # both running and unknown as "do not touch backups", the same idiom + # clean_local_snapshots uses. A bare `if tm_is_running` would let a + # transient tmutil error (rc 2) fall through and delete an in-progress + # backup. + local rc_tm_running=0 + tm_is_running || rc_tm_running=$? + if [[ $rc_tm_running -ge 128 ]]; then + stop_section_spinner + return "$rc_tm_running" + fi + if [[ $rc_tm_running -eq 0 || $rc_tm_running -eq 2 || $rc_tm_running -eq 124 ]]; then if [[ "$spinner_active" == "true" ]]; then stop_section_spinner fi - echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup" + if [[ $rc_tm_running -eq 2 || $rc_tm_running -eq 124 ]]; then + echo -e " ${YELLOW}!${NC} Time Machine cleanup · skipped (status unknown)" + note_activity + else + echo -e " ${YELLOW}!${NC} Time Machine cleanup · skipped (backup in progress)" + note_activity + fi return 0 fi if [[ "$spinner_active" == "true" ]]; then @@ -262,25 +1051,87 @@ clean_time_machine_failed_backups() { if [[ "$spinner_active" == "true" ]]; then stop_section_spinner fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + debug_log "Time Machine: no incomplete backups found" return 0 fi if [[ "$spinner_active" == "true" ]]; then start_section_spinner "Scanning backup volumes..." fi + local tm_scan_file="" + if ! tm_scan_file=$(create_temp_file 2> /dev/null); then + stop_section_spinner + echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine backups · ${GRAY}scan unavailable, skipped cleanup${NC}" + note_activity + return 0 + fi + local tm_scan_deadline=$((SECONDS + 60)) + local tm_scan_incomplete=false + local tm_scan_timed_out=false + local tm_interrupt_rc=0 for volume in "${backup_volumes[@]}"; do - local fs_type - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") + if system_cleanup_budget_reached "$tm_scan_deadline"; then + tm_scan_timed_out=true + break + fi + local fs_type="unknown" + local fs_probe="" + local fs_probe_rc=0 + fs_probe=$(run_with_timeout 1 /bin/df -T "$volume" 2> /dev/null) || fs_probe_rc=$? # 1s: volume FS-type probe, see lib/core/timeouts.sh + if [[ $fs_probe_rc -ge 128 ]]; then + tm_interrupt_rc=$fs_probe_rc + break + elif [[ $fs_probe_rc -eq 124 ]]; then + tm_scan_incomplete=true + tm_scan_timed_out=true + break + elif [[ $fs_probe_rc -eq 0 ]]; then + fs_type=$(printf '%s\n' "$fs_probe" | tail -1 | awk '{print $2}') + fi case "$fs_type" in nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; esac local backupdb_dir="$volume/Backups.backupdb" if [[ -d "$backupdb_dir" ]]; then - while IFS= read -r inprogress_file; do + local backupdb_scan_rc=0 + local backupdb_scan_timeout="" + backupdb_scan_timeout=$(_mole_timeout_with_deadline 15 "$tm_scan_deadline") || backupdb_scan_rc=$? + if [[ $backupdb_scan_rc -eq 0 ]]; then + materialize_completed_system_scan "$tm_scan_file" "$backupdb_scan_timeout" find "$backupdb_dir" \ + -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) \ + -print0 || backupdb_scan_rc=$? + fi + if [[ $backupdb_scan_rc -ge 128 ]]; then + tm_interrupt_rc=$backupdb_scan_rc + break + fi + if [[ $backupdb_scan_rc -ne 0 ]]; then + debug_log "Skipping incomplete backups in $backupdb_dir: scan status $backupdb_scan_rc" + tm_scan_incomplete=true + [[ $backupdb_scan_rc -eq 124 ]] && tm_scan_timed_out=true + continue + fi + while IFS= read -r -d '' inprogress_file; do [[ -d "$inprogress_file" ]] || continue + [[ -L "$inprogress_file" ]] && continue # Only delete old incomplete backups (safety window). - local file_mtime - file_mtime=$(get_file_mtime "$inprogress_file") + local candidate_identity="" + local candidate_identity_rc=0 + candidate_identity=$(time_machine_candidate_identity "$inprogress_file" \ + "$tm_scan_deadline") || candidate_identity_rc=$? + if [[ $candidate_identity_rc -ge 128 ]]; then + tm_interrupt_rc=$candidate_identity_rc + break + elif [[ $candidate_identity_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + elif [[ $candidate_identity_rc -ne 0 ]]; then + continue + fi + local file_mtime="${candidate_identity##*:}" + # get_file_mtime returns 0 when stat fails. A 0 here would make + # the backup look ancient and clear the safety window, so treat + # "cannot read mtime" as "too recent to touch" and keep it. + [[ "$file_mtime" =~ ^[0-9]+$ && "$file_mtime" -gt 0 ]] || continue local current_time current_time=$(get_epoch_seconds) local hours_old=$(((current_time - file_mtime) / 3600)) @@ -288,7 +1139,23 @@ clean_time_machine_failed_backups() { continue fi local size_kb - size_kb=$(get_path_size_kb "$inprogress_file") + local size_timeout="" + size_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$tm_scan_deadline") || { + tm_scan_timed_out=true + break + } + local size_rc=0 + size_kb=$(get_path_size_kb "$inprogress_file" "$size_timeout") || size_rc=$? + if [[ $size_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + elif [[ $size_rc -ge 128 ]]; then + tm_interrupt_rc=$size_rc + break + elif [[ $size_rc -ne 0 ]]; then + continue + fi [[ "$size_kb" -le 0 ]] && continue if [[ "$spinner_active" == "true" ]]; then stop_section_spinner @@ -299,17 +1166,43 @@ clean_time_machine_failed_backups() { local size_human size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$inprogress_file" "$size_kb" 1 true || continue + fi + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC} · $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" tm_cleaned=$((tm_cleaned + 1)) note_activity continue fi if ! command -v tmutil > /dev/null 2>&1; then - echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name" + echo -e " ${YELLOW}!${NC} Incomplete backup: $backup_name · skipped (tmutil unavailable)" + note_activity continue fi - if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}" + local eligibility_rc=0 + time_machine_candidate_still_eligible "$inprogress_file" "$candidate_identity" \ + "$MOLE_TM_BACKUP_SAFE_HOURS" "$tm_scan_deadline" || eligibility_rc=$? + if [[ $eligibility_rc -ge 128 ]]; then + tm_interrupt_rc=$eligibility_rc + break + elif [[ $eligibility_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + elif [[ $eligibility_rc -ne 0 ]]; then + debug_log "Keeping changed or active Time Machine candidate: $inprogress_file" + continue + fi + local tm_delete_timeout="" + local tm_delete_rc=0 + tm_delete_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$tm_scan_deadline") || tm_delete_rc=$? + if [[ $tm_delete_rc -eq 0 ]]; then + run_with_timeout "$tm_delete_timeout" tmutil delete "$inprogress_file" 2> /dev/null || tm_delete_rc=$? + fi + if [[ $tm_delete_rc -eq 0 ]]; then + local line_color + line_color=$(cleanup_result_color_kb "$size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC} · ${line_color}$size_human${NC}" tm_cleaned=$((tm_cleaned + 1)) files_cleaned=$((files_cleaned + 1)) total_size_cleaned=$((total_size_cleaned + size_kb)) @@ -317,22 +1210,84 @@ clean_time_machine_failed_backups() { note_activity else echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" + # Mark activity so the idle-section erase in end_section + # never wipes this failure warning off the terminal. + note_activity + if [[ $tm_delete_rc -ge 128 ]]; then + tm_interrupt_rc=$tm_delete_rc + break + elif [[ $tm_delete_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + fi fi - done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < "$tm_scan_file" + fi + if [[ $tm_interrupt_rc -ne 0 || "$tm_scan_timed_out" == "true" ]]; then + break fi # APFS bundles. for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do + if system_cleanup_budget_reached "$tm_scan_deadline"; then + tm_scan_timed_out=true + break + fi [[ -e "$bundle" ]] || continue [[ -d "$bundle" ]] || continue local bundle_name bundle_name=$(basename "$bundle") - local mounted_path - mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") + local mounted_path="" + local hdiutil_info="" + local hdiutil_rc=0 + hdiutil_info=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" hdiutil info 2> /dev/null) || hdiutil_rc=$? + if [[ $hdiutil_rc -ge 128 ]]; then + tm_interrupt_rc=$hdiutil_rc + break + elif [[ $hdiutil_rc -eq 124 ]]; then + tm_scan_incomplete=true + tm_scan_timed_out=true + break + elif [[ $hdiutil_rc -eq 0 ]]; then + mounted_path=$(printf '%s\n' "$hdiutil_info" | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || true) + fi if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then - while IFS= read -r inprogress_file; do + local mounted_scan_rc=0 + local mounted_scan_timeout="" + mounted_scan_timeout=$(_mole_timeout_with_deadline 15 "$tm_scan_deadline") || mounted_scan_rc=$? + if [[ $mounted_scan_rc -eq 0 ]]; then + materialize_completed_system_scan "$tm_scan_file" "$mounted_scan_timeout" find "$mounted_path" \ + -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) \ + -print0 || mounted_scan_rc=$? + fi + if [[ $mounted_scan_rc -ge 128 ]]; then + tm_interrupt_rc=$mounted_scan_rc + break + fi + if [[ $mounted_scan_rc -ne 0 ]]; then + debug_log "Skipping incomplete backups in $mounted_path: scan status $mounted_scan_rc" + tm_scan_incomplete=true + [[ $mounted_scan_rc -eq 124 ]] && tm_scan_timed_out=true + continue + fi + while IFS= read -r -d '' inprogress_file; do [[ -d "$inprogress_file" ]] || continue - local file_mtime - file_mtime=$(get_file_mtime "$inprogress_file") + [[ -L "$inprogress_file" ]] && continue + local candidate_identity="" + local candidate_identity_rc=0 + candidate_identity=$(time_machine_candidate_identity "$inprogress_file" \ + "$tm_scan_deadline") || candidate_identity_rc=$? + if [[ $candidate_identity_rc -ge 128 ]]; then + tm_interrupt_rc=$candidate_identity_rc + break + elif [[ $candidate_identity_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + elif [[ $candidate_identity_rc -ne 0 ]]; then + continue + fi + local file_mtime="${candidate_identity##*:}" + # Keep the backup if its mtime cannot be read (see above). + [[ "$file_mtime" =~ ^[0-9]+$ && "$file_mtime" -gt 0 ]] || continue local current_time current_time=$(get_epoch_seconds) local hours_old=$(((current_time - file_mtime) / 3600)) @@ -340,7 +1295,23 @@ clean_time_machine_failed_backups() { continue fi local size_kb - size_kb=$(get_path_size_kb "$inprogress_file") + local size_timeout="" + size_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$tm_scan_deadline") || { + tm_scan_timed_out=true + break + } + local size_rc=0 + size_kb=$(get_path_size_kb "$inprogress_file" "$size_timeout") || size_rc=$? + if [[ $size_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + elif [[ $size_rc -ge 128 ]]; then + tm_interrupt_rc=$size_rc + break + elif [[ $size_rc -ne 0 ]]; then + continue + fi [[ "$size_kb" -le 0 ]] && continue if [[ "$spinner_active" == "true" ]]; then stop_section_spinner @@ -351,7 +1322,10 @@ clean_time_machine_failed_backups() { local size_human size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$inprogress_file" "$size_kb" 1 true || continue + fi + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC} · $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" tm_cleaned=$((tm_cleaned + 1)) note_activity continue @@ -359,8 +1333,30 @@ clean_time_machine_failed_backups() { if ! command -v tmutil > /dev/null 2>&1; then continue fi - if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}" + local eligibility_rc=0 + time_machine_candidate_still_eligible "$inprogress_file" "$candidate_identity" \ + "$MOLE_TM_BACKUP_SAFE_HOURS" "$tm_scan_deadline" || eligibility_rc=$? + if [[ $eligibility_rc -ge 128 ]]; then + tm_interrupt_rc=$eligibility_rc + break + elif [[ $eligibility_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + elif [[ $eligibility_rc -ne 0 ]]; then + debug_log "Keeping changed or active Time Machine candidate: $inprogress_file" + continue + fi + local tm_delete_timeout="" + local tm_delete_rc=0 + tm_delete_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$tm_scan_deadline") || tm_delete_rc=$? + if [[ $tm_delete_rc -eq 0 ]]; then + run_with_timeout "$tm_delete_timeout" tmutil delete "$inprogress_file" 2> /dev/null || tm_delete_rc=$? + fi + if [[ $tm_delete_rc -eq 0 ]]; then + local line_color + line_color=$(cleanup_result_color_kb "$size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC} · ${line_color}$size_human${NC}" tm_cleaned=$((tm_cleaned + 1)) files_cleaned=$((files_cleaned + 1)) total_size_cleaned=$((total_size_cleaned + size_kb)) @@ -368,24 +1364,58 @@ clean_time_machine_failed_backups() { note_activity else echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" + # Keep the warning visible past the idle-section erase. + note_activity + if [[ $tm_delete_rc -ge 128 ]]; then + tm_interrupt_rc=$tm_delete_rc + break + elif [[ $tm_delete_rc -eq 124 ]]; then + tm_scan_timed_out=true + break + fi fi - done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < "$tm_scan_file" + fi + if [[ $tm_interrupt_rc -ne 0 || "$tm_scan_timed_out" == "true" ]]; then + break fi done + if [[ $tm_interrupt_rc -ne 0 || "$tm_scan_timed_out" == "true" ]]; then + break + fi done + rm -f -- "$tm_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above if [[ "$spinner_active" == "true" ]]; then stop_section_spinner fi if [[ $tm_cleaned -eq 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + debug_log "Time Machine: no incomplete backups found" + fi + if [[ $tm_interrupt_rc -ne 0 ]]; then + return "$tm_interrupt_rc" + fi + if [[ "$tm_scan_timed_out" == "true" || "$tm_scan_incomplete" == "true" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine backups · ${GRAY}scan incomplete, skipped remaining cleanup${NC}" + note_activity fi } # Returns 0 if a backup is actively running. # Returns 1 if not running. # Returns 2 if status cannot be determined tm_is_running() { - local st - st="$(tmutil status 2> /dev/null)" || return 2 + local st="" + local status_rc=0 + local deadline_seconds="${1:-}" + local status_timeout="" + status_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || status_rc=$? + if [[ $status_rc -eq 0 ]]; then + st="$(run_with_timeout "$status_timeout" tmutil status 2> /dev/null)" || status_rc=$? + fi + if [[ $status_rc -ge 128 ]]; then + return "$status_rc" + fi + [[ $status_rc -eq 0 ]] || return 2 # If we can't find a Running field at all, treat as unknown. if ! grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=' <<< "$st"; then @@ -410,29 +1440,39 @@ clean_local_snapshots() { local rc_running=0 tm_is_running || rc_running=$? + if [[ $rc_running -ge 128 ]]; then + stop_section_spinner + return "$rc_running" + fi + if [[ $rc_running -eq 2 ]]; then stop_section_spinner - echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot check" + echo -e " ${YELLOW}!${NC} Snapshot check · skipped (Time Machine status unknown)" + note_activity return 0 fi if [[ $rc_running -eq 0 ]]; then stop_section_spinner - echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot check" + echo -e " ${YELLOW}!${NC} Snapshot check · skipped (backup in progress)" + note_activity return 0 fi start_section_spinner "Checking local snapshots..." - local snapshot_list - snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) + local snapshot_list="" + local snapshot_rc=0 + snapshot_list=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" \ + tmutil listlocalsnapshots / 2> /dev/null) || snapshot_rc=$? stop_section_spinner + [[ $snapshot_rc -ge 128 ]] && return "$snapshot_rc" + [[ $snapshot_rc -eq 0 ]] || return 0 [[ -z "$snapshot_list" ]] && return 0 local snapshot_count snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}" - echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Review: tmutil listlocalsnapshots /${NC}" + echo -e " ${YELLOW}${ICON_REVIEW}${NC} Time Machine local snapshots · ${GREEN}${snapshot_count}${NC} ${GRAY}(review: tmutil listlocalsnapshots /)${NC}" note_activity fi } diff --git a/Resources/mole/lib/clean/user.sh b/Resources/mole/lib/clean/user.sh index 60e2af9..f8bc8b8 100644 --- a/Resources/mole/lib/clean/user.sh +++ b/Resources/mole/lib/clean/user.sh @@ -1,47 +1,120 @@ #!/bin/bash # User Data Cleanup Module set -euo pipefail -clean_user_essentials() { - start_section_spinner "Scanning caches..." - safe_clean ~/Library/Caches/* "User app cache" - stop_section_spinner - safe_clean ~/Library/Logs/* "User app logs" +_user_process_delete_guard_allows() { + mole_clean_process_guard "$_MOLE_USER_PROCESS_GUARD_PROBE" "$_MOLE_USER_PROCESS_GUARD_FAMILY started" +} - if ! is_path_whitelisted "$HOME/.Trash"; then - local trash_count - local trash_count_status=0 - trash_count=$(run_with_timeout 3 osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null) || trash_count_status=$? - if [[ $trash_count_status -eq 124 ]]; then - debug_log "Finder trash count timed out, using direct .Trash scan" - trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -exec printf '.' ';' 2> /dev/null | - wc -c | awk '{print $1}' || echo "0") +_user_safe_clean_process_guarded() { + local probe="$1" + local family="$2" + local display_name="$3" + shift 3 + local _MOLE_USER_PROCESS_GUARD_PROBE="$probe" + local _MOLE_USER_PROCESS_GUARD_FAMILY="$family" + local _MOLE_CLEAN_GUARD_REASON="${family} started" + + if ! declare -f safe_clean_guarded > /dev/null 2>&1; then + if ! _user_process_delete_guard_allows; then + mole_report_guard_stop "$display_name" mole_defer_cleanup_family "$family" + return 1 fi - [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" + safe_clean "$@" + return $? + fi - if [[ "$DRY_RUN" == "true" ]]; then - [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" - elif [[ $trash_count -gt 0 ]]; then - if run_with_timeout 5 osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items" - note_activity - else - debug_log "Finder trash empty failed or timed out, falling back to direct deletion" - local cleaned_count=0 - while IFS= read -r -d '' item; do - if safe_remove "$item" true; then - cleaned_count=$((cleaned_count + 1)) - fi - done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - if [[ $cleaned_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $cleaned_count items" - note_activity + local guarded_rc=0 + safe_clean_guarded _user_process_delete_guard_allows "$@" || guarded_rc=$? + if [[ $guarded_rc -eq 75 ]]; then + mole_report_guard_stop "$display_name" mole_defer_cleanup_family "$family" + return 1 + fi + return "$guarded_rc" +} + +clean_trash() { + if is_path_whitelisted "$HOME/.Trash"; then + return 0 + fi + stop_section_spinner + + # Always count and delete directly. The previous Finder AppleScript path + # triggered macOS's "Show warning before emptying the Trash" dialog and + # blocked mo clean on user confirmation. Volume Trashes + # (/Volumes/*/.Trashes//) are not handled here; mo clean only manages + # the user's home Trash. + local trash_count + trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null | + tr -dc '\0' | wc -c | tr -d ' ' || echo "0") + [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" + + if [[ "$DRY_RUN" == "true" ]]; then + if [[ $trash_count -gt 0 ]]; then + local preview_count=0 + local trash_item + while IFS= read -r -d '' trash_item; do + [[ -e "$trash_item" ]] || continue + if should_protect_path "$trash_item" 2> /dev/null || + is_path_whitelisted "$trash_item" 2> /dev/null || + (declare -f holds_compiled_model_cache > /dev/null 2>&1 && + holds_compiled_model_cache "$trash_item" 2> /dev/null); then + continue + fi + local trash_item_kb + local size_rc=0 + trash_item_kb=$(get_path_size_kb "$trash_item" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$trash_item_kb" =~ ^[0-9]+$ ]] || trash_item_kb=0 + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$trash_item" "$trash_item_kb" 1 true || continue fi + preview_count=$((preview_count + 1)) + done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + if [[ $preview_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $preview_count items" + note_activity fi - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" fi + return 0 + fi + + if [[ $trash_count -eq 0 ]]; then + debug_log "Trash already empty" + return 0 + fi + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Emptying trash..." + fi + + local cleaned_count=0 + while IFS= read -r -d '' item; do + if safe_remove "$item" true; then + cleaned_count=$((cleaned_count + 1)) + fi + done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + + [[ -t 1 ]] && stop_inline_spinner + + if [[ $cleaned_count -gt 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $cleaned_count items" + note_activity + fi +} + +clean_user_essentials() { + start_section_spinner "Scanning caches..." + safe_clean ~/Library/Caches/* "User app cache" + stop_section_spinner + + safe_clean ~/Library/Logs/* "User app logs" + + if [[ "${MOLE_SKIP_TRASH_CLEANUP:-0}" != "1" ]]; then + clean_trash fi + stop_section_spinner # Recent items _clean_recent_items @@ -71,12 +144,42 @@ _clean_recent_items() { safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true } +# Internal: Clean incomplete browser downloads, skipping files currently open. +_clean_incomplete_downloads() { + local -a patterns=( + "$HOME/Downloads/*.download" + "$HOME/Downloads/*.crdownload" + "$HOME/Downloads/*.part" + ) + local labels=("Safari incomplete downloads" "Chrome incomplete downloads" "Partial incomplete downloads") + local i=0 + for pattern in "${patterns[@]}"; do + local label="${labels[$i]}" + i=$((i + 1)) + for f in $pattern; do + [[ -e "$f" ]] || continue + if lsof -F n -- "$f" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipping active download: $(basename "$f")" + note_activity + continue + fi + safe_clean "$f" "$label" || true + done + done +} + # Internal: Clean old mail downloads. _clean_mail_downloads() { local mail_age_days=${MOLE_MAIL_AGE_DAYS:-} if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then mail_age_days=30 fi + + if pgrep -x "Mail" > /dev/null 2>&1; then + debug_log "Mail is running, skipping Mail Downloads cleanup" + return 0 + fi + local -a mail_dirs=( "$HOME/Library/Mail Downloads" "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" @@ -84,6 +187,8 @@ _clean_mail_downloads() { local count=0 local cleaned_kb=0 local spinner_active=false + local dry_run_mode=false + [[ "${DRY_RUN:-false}" == "true" || "${MOLE_DRY_RUN:-0}" == "1" ]] && dry_run_mode=true for target_path in "${mail_dirs[@]}"; do if [[ -d "$target_path" ]]; then if [[ "$spinner_active" == "false" && -t 1 ]]; then @@ -91,7 +196,26 @@ _clean_mail_downloads() { spinner_active=true fi local dir_size_kb=0 - dir_size_kb=$(get_path_size_kb "$target_path") + local size_rc=0 + dir_size_kb=$(get_path_size_kb "$target_path") || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + if [[ $size_rc -lt 128 ]]; then + # A Mail Downloads directory that cannot be sized must not + # end the whole run, whether the probe stalled past its + # timeout (124) or the protected container refused it + # outright (du exits 1 immediately under TCC, the #1366 + # shape): skip this one target and keep cleaning. Only a + # signal keeps its cancellation semantics. + [[ "$spinner_active" == "true" ]] && stop_section_spinner + spinner_active=false + echo -e " ${GRAY}${ICON_WARNING}${NC} Mail Downloads · skipped (sizing unavailable)" + note_activity + continue + fi + _mole_record_clean_cancellation "$size_rc" + [[ "$spinner_active" == "true" ]] && stop_section_spinner + return "$size_rc" + fi if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then dir_size_kb=0 fi @@ -105,8 +229,27 @@ _clean_mail_downloads() { while IFS= read -r -d '' file_path; do if [[ -f "$file_path" ]]; then local file_size_kb - file_size_kb=$(get_path_size_kb "$file_path") - if safe_remove "$file_path" true; then + size_rc=0 + file_size_kb=$(get_path_size_kb "$file_path") || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + if [[ $size_rc -lt 128 ]]; then + debug_log "Mail attachment sizing failed (rc=$size_rc), skipping: $file_path" + continue + fi + _mole_record_clean_cancellation "$size_rc" + [[ "$spinner_active" == "true" ]] && stop_section_spinner + return "$size_rc" + fi + local remove_rc=1 + if [[ "$dry_run_mode" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$file_path" "$file_size_kb" 1 true || continue + fi + MOLE_DRY_RUN=1 safe_remove "$file_path" true "$file_size_kb" && remove_rc=0 + elif safe_remove "$file_path" true "$file_size_kb"; then + remove_rc=0 + fi + if [[ $remove_rc -eq 0 ]]; then count=$((count + 1)) cleaned_kb=$((cleaned_kb + file_size_kb)) fi @@ -120,94 +263,295 @@ _clean_mail_downloads() { if [[ $count -gt 0 ]]; then local cleaned_mb cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0") - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments, about ${cleaned_mb}MB" + if [[ "$dry_run_mode" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would clean $count mail attachments older than ${mail_age_days}d, about ${cleaned_mb}MB" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments older than ${mail_age_days}d, about ${cleaned_mb}MB" + fi note_activity fi } -# Remove old Google Chrome versions while keeping Current. -clean_chrome_old_versions() { - local -a app_paths=( - "/Applications/Google Chrome.app" - "$HOME/Applications/Google Chrome.app" - ) - - # Match the exact Chrome process name to avoid false positives - if pgrep -x "Google Chrome" > /dev/null 2>&1; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" - return 0 - fi - +# Chrome, Edge, and Brave are all Chromium: same versioned framework layout +# (Contents/Frameworks/.framework/Versions with a Current symlink), same +# keep-Current + keep-newer-staged-update rules, same removal and accounting. +# Only four facts differ per browser, so they are parameters here and the three +# public functions below are thin wrappers. +# +# clean_edge_updater_old_versions is deliberately NOT routed through this: it +# keeps the latest by `sort -V` (no Current symlink at all) and never escalates +# to a sudo removal. Merging it would change its semantics. +_clean_chromium_old_versions() { + local label="$1" + local framework="$2" + local running_probe="$3" + shift 3 + local -a app_paths=("$@") + + local app_path versions_dir local cleaned_count=0 local total_size=0 local cleaned_any=false - + local stopped_reason="" for app_path in "${app_paths[@]}"; do [[ -d "$app_path" ]] || continue - local versions_dir="$app_path/Contents/Frameworks/Google Chrome Framework.framework/Versions" - [[ -d "$versions_dir" ]] || continue + # Every silent skip below logs its reason: "old versions not removed" + # reports are undiagnosable without knowing which gate bailed (#1216). + versions_dir="$app_path/Contents/Frameworks/$framework/Versions" + if [[ ! -d "$versions_dir" ]]; then + debug_log "${label} old versions: no Versions dir at $versions_dir" + continue + fi local current_link="$versions_dir/Current" - [[ -L "$current_link" ]] || continue + if [[ ! -L "$current_link" ]]; then + debug_log "${label} old versions: no Current symlink in $versions_dir" + continue + fi local current_version current_version=$(readlink "$current_link" 2> /dev/null || true) current_version="${current_version##*/}" - [[ -n "$current_version" ]] || continue + if [[ -z "$current_version" ]]; then + debug_log "${label} old versions: Current symlink unreadable" + continue + fi + + # Verify the Current symlink target exists. If broken, skip to avoid + # accidentally deleting the active browser version. + if [[ ! -d "$versions_dir/$current_version" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${label} old versions · skipped (Current symlink broken)" + note_activity + continue + fi + + # Keep a version newer than Current: it is a freshly staged auto-update + # that Current will point at on next launch. + local newest_version="" + local newest_mtime=0 + local current_mtime + current_mtime=$(stat -f%m "$versions_dir/$current_version" 2> /dev/null || echo "0") + [[ "$current_mtime" =~ ^[0-9]+$ ]] || current_mtime=0 local -a old_versions=() local dir name for dir in "$versions_dir"/*; do - [[ -d "$dir" ]] || continue + [[ -d "$dir" && ! -L "$dir" ]] || continue + name=$(basename "$dir") + [[ "$name" == "Current" ]] && continue + local mtime + mtime=$(stat -f%m "$dir" 2> /dev/null || echo "0") + if [[ "$mtime" =~ ^[0-9]+$ ]] && [[ "$mtime" -gt "$newest_mtime" ]]; then + newest_mtime="$mtime" + newest_version="$name" + fi + done + if [[ "$newest_mtime" -le "$current_mtime" ]]; then + newest_version="" + elif [[ -n "$newest_version" ]]; then + debug_log "${label} old versions: keeping $newest_version (staged auto-update newer than Current=$current_version)" + fi + + for dir in "$versions_dir"/*; do + [[ -d "$dir" && ! -L "$dir" ]] || continue name=$(basename "$dir") [[ "$name" == "Current" ]] && continue [[ "$name" == "$current_version" ]] && continue - if is_path_whitelisted "$dir"; then + [[ -n "$newest_version" && "$name" == "$newest_version" ]] && continue + if should_protect_path "$dir" || is_path_whitelisted "$dir" || holds_compiled_model_cache "$dir"; then continue fi old_versions+=("$dir") done if [[ ${#old_versions[@]} -eq 0 ]]; then + debug_log "${label} old versions: nothing to remove in $versions_dir (Current=$current_version)" continue fi + local process_state=0 + "$running_probe" || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${label} old versions · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "$label" + fi + return 0 + fi + for dir in "${old_versions[@]}"; do - local size_kb - size_kb=$(get_path_size_kb "$dir" || echo 0) + process_state=0 + "$running_probe" || process_state=$? + if [[ $process_state -ne 1 ]]; then + stopped_reason="${label} started" + [[ $process_state -eq 2 ]] && stopped_reason="process state unknown" + break + fi + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$dir") || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" size_kb="${size_kb:-0}" - total_size=$((total_size + size_kb)) - cleaned_count=$((cleaned_count + 1)) - cleaned_any=true - if [[ "$DRY_RUN" != "true" ]]; then - if has_sudo_session; then - safe_sudo_remove "$dir" > /dev/null 2>&1 || true - else - safe_remove "$dir" true > /dev/null 2>&1 || true + process_state=0 + "$running_probe" || process_state=$? + if [[ $process_state -ne 1 ]]; then + stopped_reason="${label} started" + [[ $process_state -eq 2 ]] && stopped_reason="process state unknown" + break + fi + + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$dir" "$size_kb" 1 true || continue fi + total_size=$((total_size + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + continue + fi + + local removed=false + if has_sudo_session; then + safe_sudo_remove "$dir" "$size_kb" > /dev/null 2>&1 && removed=true + else + safe_remove "$dir" true "$size_kb" > /dev/null 2>&1 && removed=true + fi + if [[ "$removed" == "true" ]]; then + total_size=$((total_size + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + else + debug_log "${label} old version removal failed: $dir" fi done + [[ -n "$stopped_reason" ]] && break done if [[ "$cleaned_any" == "true" ]]; then local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${label} old versions${NC} · ${YELLOW}${cleaned_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} ${label} old versions${NC} · ${line_color}${cleaned_count} dirs, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) total_items=$((total_items + 1)) note_activity fi + if [[ -n "$stopped_reason" ]]; then + if [[ "$stopped_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${label} old versions · stopped (${stopped_reason})" + note_activity + else + mole_defer_cleanup_family "$label" + fi + fi +} + +# Chrome also runs under a helper process name, so the probe is wider than pgrep -x. +is_google_chrome_running() { + mole_pgrep_any \ + -x "Google Chrome" \ + -x "Google Chrome Helper" \ + -f "/Google Chrome.app/" +} + +# Exact process names only: "Microsoft Edge" must not match Microsoft Teams. +is_microsoft_edge_running() { + mole_pgrep_any -x "Microsoft Edge" +} + +is_brave_browser_running() { + mole_pgrep_any -x "Brave Browser" +} + +_firefox_process_state() { + mole_pgrep_any -x "Firefox" +} + +_dropbox_process_state() { + mole_pgrep_any -x "Dropbox" +} + +_google_drive_process_state() { + mole_pgrep_any -x "Google Drive" +} + +_onedrive_process_state() { + mole_pgrep_any -x "OneDrive" +} + +_clean_chrome_profile_caches_guarded() { + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome app cache" \ + ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome code cache" \ + ~/Library/Application\ Support/Google/Chrome/*/Code\ Cache/* "Chrome code cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome GPU cache" \ + ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome Dawn cache" \ + ~/Library/Application\ Support/Google/Chrome/*/DawnCache/* "Chrome Dawn cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome GR shader cache" \ + ~/Library/Application\ Support/Google/Chrome/*/GrShaderCache/* "Chrome GR shader cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome Graphite Dawn cache" \ + ~/Library/Application\ Support/Google/Chrome/*/GraphiteDawnCache/* "Chrome Graphite Dawn cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome component CRX cache" \ + ~/Library/Application\ Support/Google/Chrome/component_crx_cache/* "Chrome component CRX cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome shader cache" \ + ~/Library/Application\ Support/Google/Chrome/ShaderCache/* "Chrome shader cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome GR shader cache" \ + ~/Library/Application\ Support/Google/Chrome/GrShaderCache/* "Chrome GR shader cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome Dawn cache" \ + ~/Library/Application\ Support/Google/Chrome/GraphiteDawnCache/* "Chrome Dawn cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome crash reports" \ + ~/Library/Application\ Support/Google/Chrome/Crashpad/completed/* "Chrome crash reports" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome on-device model cache" \ + ~/Library/Application\ Support/Google/Chrome/OptGuideOnDeviceModel/* "Chrome on-device model cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome on-device classifier cache" \ + ~/Library/Application\ Support/Google/Chrome/OptGuideOnDeviceClassifierModel/* "Chrome on-device classifier cache" || return 1 + _user_safe_clean_process_guarded is_google_chrome_running "Chrome" "Chrome optimization guide models" \ + ~/Library/Application\ Support/Google/Chrome/optimization_guide_model_store/* "Chrome optimization guide models" || return 1 +} + +_clean_firefox_caches_guarded() { + _user_safe_clean_process_guarded _firefox_process_state "Firefox" "Firefox cache" \ + ~/Library/Caches/Firefox/* "Firefox cache" || return 1 + _user_safe_clean_process_guarded _firefox_process_state "Firefox" "Firefox profile cache" \ + ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" || return 1 +} + +_clean_dropbox_caches_guarded() { + _user_safe_clean_process_guarded _dropbox_process_state "Dropbox" "Dropbox cache" \ + ~/Library/Caches/com.dropbox.* "Dropbox cache" || return 1 + _user_safe_clean_process_guarded _dropbox_process_state "Dropbox" "Dropbox cache" \ + ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" || return 1 +} + +# Remove old Google Chrome versions while keeping Current. +clean_chrome_old_versions() { + local -a app_paths + if [[ -n "${MOLE_CHROME_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_CHROME_APP_PATHS" + else + app_paths=( + "/Applications/Google Chrome.app" + "$HOME/Applications/Google Chrome.app" + ) + fi + + _clean_chromium_old_versions "Chrome" "Google Chrome Framework.framework" \ + is_google_chrome_running "${app_paths[@]}" } # Remove old Microsoft Edge versions while keeping Current. clean_edge_old_versions() { - # Allow override for testing local -a app_paths if [[ -n "${MOLE_EDGE_APP_PATHS:-}" ]]; then IFS=':' read -ra app_paths <<< "$MOLE_EDGE_APP_PATHS" @@ -218,77 +562,8 @@ clean_edge_old_versions() { ) fi - # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams) - if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" - return 0 - fi - - local cleaned_count=0 - local total_size=0 - local cleaned_any=false - - for app_path in "${app_paths[@]}"; do - [[ -d "$app_path" ]] || continue - - local versions_dir="$app_path/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" - [[ -d "$versions_dir" ]] || continue - - local current_link="$versions_dir/Current" - [[ -L "$current_link" ]] || continue - - local current_version - current_version=$(readlink "$current_link" 2> /dev/null || true) - current_version="${current_version##*/}" - [[ -n "$current_version" ]] || continue - - local -a old_versions=() - local dir name - for dir in "$versions_dir"/*; do - [[ -d "$dir" ]] || continue - name=$(basename "$dir") - [[ "$name" == "Current" ]] && continue - [[ "$name" == "$current_version" ]] && continue - if is_path_whitelisted "$dir"; then - continue - fi - old_versions+=("$dir") - done - - if [[ ${#old_versions[@]} -eq 0 ]]; then - continue - fi - - for dir in "${old_versions[@]}"; do - local size_kb - size_kb=$(get_path_size_kb "$dir" || echo 0) - size_kb="${size_kb:-0}" - total_size=$((total_size + size_kb)) - cleaned_count=$((cleaned_count + 1)) - cleaned_any=true - if [[ "$DRY_RUN" != "true" ]]; then - if has_sudo_session; then - safe_sudo_remove "$dir" > /dev/null 2>&1 || true - else - safe_remove "$dir" true > /dev/null 2>&1 || true - fi - fi - done - done - - if [[ "$cleaned_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" - fi - files_cleaned=$((files_cleaned + cleaned_count)) - total_size_cleaned=$((total_size_cleaned + total_size)) - total_items=$((total_items + 1)) - note_activity - fi + _clean_chromium_old_versions "Edge" "Microsoft Edge Framework.framework" \ + is_microsoft_edge_running "${app_paths[@]}" } # Remove old Microsoft EdgeUpdater versions while keeping latest. @@ -296,45 +571,106 @@ clean_edge_updater_old_versions() { local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" [[ -d "$updater_dir" ]] || return 0 - if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" - return 0 - fi - local -a version_dirs=() local dir for dir in "$updater_dir"/*; do - [[ -d "$dir" ]] || continue + [[ -d "$dir" && ! -L "$dir" ]] || continue version_dirs+=("$dir") done - if [[ ${#version_dirs[@]} -lt 2 ]]; then + if [[ ${#version_dirs[@]} -eq 0 ]]; then return 0 fi - local latest_version - latest_version=$(printf '%s\n' "${version_dirs[@]##*/}" | sort -V | tail -n 1) - [[ -n "$latest_version" ]] || return 0 + # A staged payload is only worth keeping while it is at least as new as + # the installed Edge. After Edge updates itself the updater leaves the + # previously staged copy behind, and a bare keep-the-newest rule keeps + # that stale copy forever when it is the only directory (#1216). With a + # known installed version, anything strictly older is removable; without + # one, fall back to the original conservative keep-latest rule. + local installed_version="" edge_app + for edge_app in "/Applications/Microsoft Edge.app" "$HOME/Applications/Microsoft Edge.app"; do + if [[ -f "$edge_app/Contents/Info.plist" ]]; then + installed_version=$(plutil -extract CFBundleShortVersionString raw "$edge_app/Contents/Info.plist" 2> /dev/null || echo "") + [[ -n "$installed_version" ]] && break + fi + done - local cleaned_count=0 - local total_size=0 - local cleaned_any=false + local latest_version="" + if [[ -z "$installed_version" ]]; then + if [[ ${#version_dirs[@]} -lt 2 ]]; then + return 0 + fi + latest_version=$(printf '%s\n' "${version_dirs[@]##*/}" | sort -V | tail -n 1) + [[ -n "$latest_version" ]] || return 0 + fi + local -a cleanable_dirs=() for dir in "${version_dirs[@]}"; do local name name=$(basename "$dir") - [[ "$name" == "$latest_version" ]] && continue - if is_path_whitelisted "$dir"; then + if [[ -n "$installed_version" ]]; then + # Keep any payload not strictly older than the installed Edge: + # an equal or newer copy is a pending update, not a leftover. + if [[ "$name" == "$installed_version" ]] || + [[ "$(printf '%s\n%s\n' "$name" "$installed_version" | sort -V | head -n 1)" != "$name" ]]; then + continue + fi + else + [[ "$name" == "$latest_version" ]] && continue + fi + if should_protect_path "$dir" || is_path_whitelisted "$dir" || holds_compiled_model_cache "$dir"; then continue fi - local size_kb - size_kb=$(get_path_size_kb "$dir" || echo 0) + cleanable_dirs+=("$dir") + done + [[ ${#cleanable_dirs[@]} -gt 0 ]] || return 0 + + local process_state=0 + is_microsoft_edge_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + if [[ $process_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Edge updater old versions · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Edge" + fi + return 0 + fi + + local cleaned_count=0 + local total_size=0 + local cleaned_any=false + local stopped_reason="" + for dir in "${cleanable_dirs[@]}"; do + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$dir") || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" size_kb="${size_kb:-0}" - total_size=$((total_size + size_kb)) - cleaned_count=$((cleaned_count + 1)) - cleaned_any=true - if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$dir" true > /dev/null 2>&1 || true + process_state=0 + is_microsoft_edge_running || process_state=$? + if [[ $process_state -ne 1 ]]; then + stopped_reason="Edge started" + [[ $process_state -eq 2 ]] && stopped_reason="process state unknown" + break + fi + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$dir" "$size_kb" 1 true || continue + fi + total_size=$((total_size + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + continue + fi + if safe_remove "$dir" true "$size_kb" > /dev/null 2>&1; then + total_size=$((total_size + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + else + debug_log "Edge updater old version removal failed: $dir" fi done @@ -342,65 +678,41 @@ clean_edge_updater_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions${NC} · ${YELLOW}${cleaned_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Edge updater old versions${NC} · ${line_color}${cleaned_count} dirs, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) total_items=$((total_items + 1)) note_activity fi -} - -scan_external_volumes() { - [[ -d "/Volumes" ]] || return 0 - local -a candidate_volumes=() - local -a network_volumes=() - for volume in /Volumes/*; do - [[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue - [[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue - local protocol="" - protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") - case "$protocol" in - SMB | NFS | AFP | CIFS | WebDAV) - network_volumes+=("$volume") - continue - ;; - esac - local fs_type="" - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "") - case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) - network_volumes+=("$volume") - continue - ;; - esac - candidate_volumes+=("$volume") - done - local volume_count=${#candidate_volumes[@]} - local network_count=${#network_volumes[@]} - if [[ $volume_count -eq 0 ]]; then - if [[ $network_count -gt 0 ]]; then - echo -e " ${GRAY}${ICON_LIST}${NC} External volumes, ${network_count} network volumes skipped" + if [[ -n "$stopped_reason" ]]; then + if [[ "$stopped_reason" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Edge updater old versions · stopped (${stopped_reason})" note_activity + else + mole_defer_cleanup_family "Edge" fi - return 0 fi - start_section_spinner "Scanning $volume_count external volumes..." - for volume in "${candidate_volumes[@]}"; do - [[ -d "$volume" && -r "$volume" ]] || continue - local volume_trash="$volume/.Trashes" - if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then - while IFS= read -r -d '' item; do - safe_remove "$item" true || true - done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - fi - if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then - clean_ds_store_tree "$volume" "$(basename "$volume") volume, .DS_Store" - fi - done - stop_section_spinner +} + +# Remove old Brave Browser versions while keeping Current. +clean_brave_old_versions() { + local -a app_paths + if [[ -n "${MOLE_BRAVE_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_BRAVE_APP_PATHS" + else + app_paths=( + "/Applications/Brave Browser.app" + "$HOME/Applications/Brave Browser.app" + ) + fi + + _clean_chromium_old_versions "Brave" "Brave Browser Framework.framework" \ + is_brave_browser_running "${app_paths[@]}" } # Finder metadata (.DS_Store). @@ -421,26 +733,72 @@ clean_support_app_data() { safe_find_delete "$crash_reporter_dir" "*" "$support_age_days" "f" || true fi - # Keep recent wallpaper assets to avoid large re-downloads. - local idle_assets_dir="$HOME/Library/Application Support/com.apple.idleassetsd" - if [[ -d "$idle_assets_dir" && ! -L "$idle_assets_dir" ]]; then - safe_find_delete "$idle_assets_dir" "*" "$support_age_days" "f" || true - fi - - # Clean old aerial wallpaper videos (can be large, safe to remove). - safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/videos/* "Aerial wallpaper videos" + # Do not sweep com.apple.idleassetsd here. It stores the aerial screen saver + # and dynamic wallpaper videos selected in System Settings. Those files are + # written at download time rather than touched while in use, so age cannot + # distinguish an active wallpaper from stale data. The shared path guard + # protects these assets alongside com.apple.wallpaper (#1118). # Do not touch Messages attachments, only preview/sticker caches. - if pgrep -x "Messages" > /dev/null 2>&1; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Messages is running · preview cache cleanup skipped" - else - safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache" - safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache" - safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache" - fi + safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache" + safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache" + safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache" } # App caches (merged: macOS system caches + Sandboxed apps). +cache_top_level_entry_count_capped() { + local dir="$1" + local cap="${2:-101}" + local count=0 + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + local item + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + count=$((count + 1)) + if ((count >= cap)); then + break + fi + done + + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + + [[ "$count" =~ ^[0-9]+$ ]] || count=0 + printf '%s\n' "$count" +} + +directory_has_entries() { + local dir="$1" + [[ -d "$dir" ]] || return 1 + + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + local item + for item in "$dir"/*; do + if [[ -e "$item" ]]; then + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + return 0 + fi + done + + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + return 1 +} + clean_app_caches() { start_section_spinner "Scanning app caches..." @@ -453,10 +811,9 @@ clean_app_caches() { safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" || true safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" || true safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache" || true - safe_clean ~/Downloads/*.download "Safari incomplete downloads" || true - safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads" || true - safe_clean ~/Downloads/*.part "Partial incomplete downloads" || true - safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true + _clean_incomplete_downloads + # Do not clean ~/Library/Autosave Information by default: it can contain + # recoverable user documents, not only disposable cache data. safe_clean ~/Library/IdentityCaches/* "Identity caches" || true safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache" || true @@ -469,31 +826,75 @@ clean_app_caches() { # Sandboxed app caches safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache" safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache" + safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/tmp/* "Media analysis temp files" safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache" safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files" + safe_clean ~/Library/Containers/com.apple.wallpaper.extension.aerials/Data/tmp/* "Wallpaper aerials temp files" + safe_clean ~/Library/Containers/com.apple.geod/Data/tmp/* "Geod temp files" + safe_clean ~/Library/Containers/com.apple.stocks/Data/Library/Caches/* "Stocks cache" + # Do NOT clean ~/Library/Application Support/com.apple.wallpaper/aerials/ + # thumbnails: those ~50KB PNGs are the wallpaper "cover" previews shown in + # System Settings > Wallpaper. Deleting them reclaims almost nothing yet + # blanks every cover into a cloud-download placeholder and forces a + # re-download on the next open (issue #1118). + safe_clean ~/Library/Caches/com.apple.helpd/* "macOS Help system cache" + safe_clean ~/Library/Caches/GeoServices/* "Maps geo tile cache" + safe_clean ~/Library/Containers/com.apple.AvatarUI.AvatarPickerMemojiPicker/Data/Library/Caches/* "Memoji picker cache" + safe_clean ~/Library/Containers/com.apple.AMPArtworkAgent/Data/Library/Caches/* "Music album art cache" + safe_clean ~/Library/Containers/com.apple.CoreDevice.CoreDeviceService/Data/Library/Caches/* "CoreDevice service cache" + safe_clean ~/Library/Containers/com.apple.NeptuneOneExtension/Data/Library/Caches/* "Apple Intelligence extension cache" + safe_clean ~/Library/Containers/com.apple.AppleMediaServicesUI.UtilityExtension/Data/tmp/* "Apple Media Services temp files" + safe_clean ~/Library/Caches/com.apple.AppleMediaServices/* "Apple Media Services cache" + safe_clean ~/Library/Caches/com.apple.duetexpertd/* "Duet Expert cache" + safe_clean ~/Library/Caches/com.apple.parsecd/* "Parsecd cache" + safe_clean ~/Library/Caches/com.apple.python/* "Apple Python cache" + # The E5RT bundle cache used to be cleaned here. It is now protected by + # holds_compiled_model_cache(): wiping it under a running daemon breaks + # recognition until that daemon restarts, for a few MB. local containers_dir="$HOME/Library/Containers" [[ ! -d "$containers_dir" ]] && return 0 start_section_spinner "Scanning sandboxed apps..." local total_size=0 + local total_size_partial=false local cleaned_count=0 local found_any=false + local precise_size_limit="${MOLE_CONTAINER_CACHE_PRECISE_SIZE_LIMIT:-64}" + [[ "$precise_size_limit" =~ ^[0-9]+$ ]] || precise_size_limit=64 + local precise_size_used=0 local _ng_state _ng_state=$(shopt -p nullglob || true) shopt -s nullglob + local container_rc=0 for container_dir in "$containers_dir"/*; do - process_container_cache "$container_dir" + [[ -d "$container_dir/Data/Library/Caches" ]] || continue + process_container_cache "$container_dir" || container_rc=$? + [[ $container_rc -eq 0 ]] || break done + # eval: restore shopt state captured by $(shopt -p) eval "$_ng_state" stop_section_spinner + [[ $container_rc -eq 0 ]] || return "$container_rc" if [[ "$found_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC} · ${YELLOW}dry${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC} · $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC} · ${GREEN}cleaned${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Sandboxed app caches${NC} · ${line_color}$size_human${NC}" + fi fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) @@ -501,7 +902,67 @@ clean_app_caches() { note_activity fi - clean_group_container_caches + clean_group_container_caches || return $? + clean_handoff_pasteboard_cache || return $? +} + +# Handoff / Universal Clipboard staging cache. useractivityd is supposed to +# prune shared-pasteboard items itself but can leave hundreds of GB behind +# after heavy Command+C use (#1178). Items are ephemeral transfer buffers by +# design; anything modified within the last hour is kept so an in-flight +# clipboard sync between devices is never cut off. +clean_handoff_pasteboard_cache() { + local pasteboard_dir="$HOME/Library/Group Containers/group.com.apple.coreservices.useractivityd/shared-pasteboard" + [[ -d "$pasteboard_dir" ]] || return 0 + [[ -L "$pasteboard_dir" ]] && return 0 + if is_path_whitelisted "$pasteboard_dir" 2> /dev/null; then + return 0 + fi + + local cleaned_count=0 + local total_kb=0 + local item + while IFS= read -r -d '' item; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + local item_kb="" + local size_rc=0 + item_kb=$(get_path_size_kb "$item" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$item_kb" =~ ^[0-9]+$ ]] || item_kb=0 + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" "$item_kb" 1 true || continue + fi + cleaned_count=$((cleaned_count + 1)) + total_kb=$((total_kb + item_kb)) + continue + fi + if safe_remove "$item" true 2> /dev/null; then + cleaned_count=$((cleaned_count + 1)) + total_kb=$((total_kb + item_kb)) + fi + done < <(command find "$pasteboard_dir" -mindepth 1 -maxdepth 1 -mmin +60 -print0 2> /dev/null || true) + + [[ $cleaned_count -gt 0 ]] || return 0 + + local size_human + size_human=$(bytes_to_human "$((total_kb * 1024))") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Handoff clipboard cache${NC} · $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$total_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Handoff clipboard cache${NC} · ${line_color}$size_human${NC}" + fi + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_kb)) + total_items=$((total_items + 1)) + note_activity } # Process a single container cache directory. @@ -509,55 +970,140 @@ process_container_cache() { local container_dir="$1" [[ -d "$container_dir" ]] || return 0 [[ -L "$container_dir" ]] && return 0 - local bundle_id - bundle_id=$(basename "$container_dir") + local bundle_id="${container_dir##*/}" if is_critical_system_component "$bundle_id"; then return 0 fi - if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]')"; then + if should_protect_data "$bundle_id"; then return 0 fi local cache_dir="$container_dir/Data/Library/Caches" [[ -d "$cache_dir" ]] || return 0 [[ -L "$cache_dir" ]] && return 0 - # Fast non-empty check. - if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then - local size - size=$(get_path_size_kb "$cache_dir") + local item_count + item_count=$(cache_top_level_entry_count_capped "$cache_dir" 101) + [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0 + [[ "$item_count" -eq 0 ]] && return 0 + + if [[ "$DRY_RUN" == "true" ]]; then + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + local item + for item in "$cache_dir"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if holds_compiled_model_cache "$item"; then + continue + fi + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + local item_size_kb=0 + local size_known=false + if [[ "$precise_size_used" -lt "$precise_size_limit" ]]; then + local size_rc=0 + item_size_kb=$(get_path_size_kb "$item" 2> /dev/null) || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + _mole_record_clean_cancellation "$size_rc" + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + return "$size_rc" + fi + [[ "$item_size_kb" =~ ^[0-9]+$ ]] || item_size_kb=0 + precise_size_used=$((precise_size_used + 1)) + size_known=true + else + total_size_partial=true + fi + + if declare -f register_dry_run_cleanup_target > /dev/null 2>&1; then + register_dry_run_cleanup_target "$item" || continue + fi + + if declare -f append_dry_run_cleanup_target > /dev/null 2>&1; then + append_dry_run_cleanup_target "$item" "$item_size_kb" 1 "$size_known" + fi + total_size=$((total_size + item_size_kb)) + cleaned_count=$((cleaned_count + 1)) + found_any=true + done + + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + return 0 + fi + + if [[ "$item_count" -le 100 && "$precise_size_used" -lt "$precise_size_limit" ]]; then + local size="" + local size_rc=0 + size=$(get_path_size_kb "$cache_dir" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$size" =~ ^[0-9]+$ ]] || size=0 total_size=$((total_size + size)) - found_any=true - cleaned_count=$((cleaned_count + 1)) - if [[ "$DRY_RUN" != "true" ]]; then - local item - while IFS= read -r -d '' item; do - [[ -e "$item" ]] || continue - safe_remove "$item" true || true - done < <(command find "$cache_dir" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - fi + precise_size_used=$((precise_size_used + 1)) + else + total_size_partial=true fi + + found_any=true + cleaned_count=$((cleaned_count + 1)) + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + local item + for item in "$cache_dir"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if holds_compiled_model_cache "$item"; then + continue + fi + # Re-check each item, not just the parent bundle: a user may have + # whitelisted a specific cache path, and should_protect_path may + # cover a nested entry. Mirrors clean_group_container_caches. + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + safe_remove "$item" true || true + done + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" } # Group Containers safe cleanup (logs for protected apps, caches/tmp for non-protected apps). clean_group_container_caches() { local group_containers_dir="$HOME/Library/Group Containers" [[ -d "$group_containers_dir" ]] || return 0 - if ! find "$group_containers_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + if ! directory_has_entries "$group_containers_dir"; then return 0 fi start_section_spinner "Scanning Group Containers..." local total_size=0 + local total_size_partial=false local cleaned_count=0 local found_any=false - # Collect all non-Apple container directories first - local -a containers=() local container_dir + local _nullglob_state + _nullglob_state=$(shopt -p nullglob || true) + shopt -s nullglob + for container_dir in "$group_containers_dir"/*; do [[ -d "$container_dir" ]] || continue [[ -L "$container_dir" ]] && continue - local container_id - container_id=$(basename "$container_dir") + # Skip containers we cannot read (avoids repeated TCC/privacy prompts on macOS). + [[ -r "$container_dir" ]] || continue + local container_id="${container_dir##*/}" # Skip Apple-owned shared containers entirely. case "$container_id" in @@ -565,86 +1111,312 @@ clean_group_container_caches() { continue ;; esac - containers+=("$container_dir") + + # Skip Safari Web Extension containers: cleaning their caches triggers + # extension reinitialization and can launch Safari unexpectedly. + if [[ -d "$HOME/Library/Containers/$container_id" ]]; then + local _ext_match=false + local _ext_entry + for _ext_entry in "$HOME/Library/Containers/$container_id/"*Safari* \ + "$HOME/Library/Containers/$container_id/"*safari*; do + if [[ -e "$_ext_entry" ]]; then + _ext_match=true + break + fi + done + if [[ "$_ext_match" == "true" ]]; then + continue + fi + fi + local normalized_id="$container_id" + [[ "$normalized_id" == group.* ]] && normalized_id="${normalized_id#group.}" + + local protected_container=false + if should_protect_data "$container_id" 2> /dev/null || should_protect_data "$normalized_id" 2> /dev/null; then + protected_container=true + fi + + local -a candidates=( + "$container_dir/Logs" + "$container_dir/Library/Logs" + ) + if [[ "$protected_container" != "true" ]]; then + candidates+=( + "$container_dir/tmp" + "$container_dir/Library/tmp" + "$container_dir/Caches" + "$container_dir/Library/Caches" + ) + fi + + local candidate + for candidate in "${candidates[@]}"; do + [[ -d "$candidate" ]] || continue + [[ -L "$candidate" ]] && continue + if is_path_whitelisted "$candidate" 2> /dev/null; then + continue + fi + + local item + local quick_count + quick_count=$(cache_top_level_entry_count_capped "$candidate" 101) + [[ "$quick_count" =~ ^[0-9]+$ ]] || quick_count=0 + [[ "$quick_count" -eq 0 ]] && continue + + local candidate_size_kb=0 + local candidate_changed=false + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + if [[ "$quick_count" -gt 100 ]]; then + total_size_partial=true + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + if [[ "$DRY_RUN" == "true" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" 0 1 false || continue + fi + candidate_changed=true + if [[ "$DRY_RUN" != "true" ]]; then + safe_remove "$item" true 2> /dev/null || true + fi + done + else + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + local item_size="" + local size_rc=0 + item_size=$(get_path_size_kb "$item" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" "$item_size" 1 true || continue + fi + candidate_changed=true + candidate_size_kb=$((candidate_size_kb + item_size)) + continue + fi + if safe_remove "$item" true 2> /dev/null; then + candidate_changed=true + candidate_size_kb=$((candidate_size_kb + item_size)) + fi + done + fi + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + + if [[ "$candidate_changed" == "true" ]]; then + total_size=$((total_size + candidate_size_kb)) + cleaned_count=$((cleaned_count + 1)) + found_any=true + fi + done done + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + + stop_section_spinner + + if [[ "$found_any" == "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC} · ${YELLOW}dry${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC} · $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + fi + else + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC} · ${GREEN}cleaned${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC} · ${line_color}$size_human${NC}" + fi + fi + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) + note_activity + fi +} + +resolve_existing_path() { + local path="$1" + [[ -e "$path" ]] || return 1 + + if command -v realpath > /dev/null 2>&1; then + realpath "$path" 2> /dev/null && return 0 + fi + + local dir base + dir=$(cd -P "$(dirname "$path")" 2> /dev/null && pwd) || return 1 + base=$(basename "$path") + printf '%s/%s\n' "$dir" "$base" +} + +external_volume_root() { + printf '%s\n' "${MOLE_EXTERNAL_VOLUMES_ROOT:-/Volumes}" +} + +validate_external_volume_target() { + local target="$1" + local root + root=$(external_volume_root) + local resolved_root="$root" + if [[ -e "$root" ]]; then + resolved_root=$(resolve_existing_path "$root" 2> /dev/null || printf '%s\n' "$root") + fi + resolved_root="${resolved_root%/}" + + if [[ -z "$target" ]]; then + echo "Missing external volume path" >&2 + return 1 + fi + if [[ "$target" != /* ]]; then + echo "External volume path must be absolute: $target" >&2 + return 1 + fi + if [[ "$target" == "$root" || "$target" == "$resolved_root" ]]; then + echo "Refusing to clean the volumes root directly: $resolved_root" >&2 + return 1 + fi + if [[ -L "$target" ]]; then + echo "Refusing to clean symlinked volume path: $target" >&2 + return 1 + fi + + local resolved + resolved=$(resolve_existing_path "$target") || { + echo "External volume path does not exist: $target" >&2 + return 1 + } + + if [[ "$resolved" != "$resolved_root/"* ]]; then + echo "External volume path must be under $resolved_root: $resolved" >&2 + return 1 + fi + + local relative_path="${resolved#"$resolved_root"/}" + if [[ -z "$relative_path" || "$relative_path" == "$resolved" || "$relative_path" == */* ]]; then + echo "External cleanup only supports mounted paths directly under $resolved_root: $resolved" >&2 + return 1 + fi + + local disk_info="" + disk_info=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" command diskutil info "$resolved" 2> /dev/null || echo "") + if [[ -n "$disk_info" ]]; then + if echo "$disk_info" | grep -Eq 'Internal:[[:space:]]+Yes'; then + echo "Refusing to clean an internal volume: $resolved" >&2 + return 1 + fi + + local protocol="" + protocol=$(echo "$disk_info" | awk -F: '/Protocol:/ {gsub(/^[[:space:]]+/, "", $2); print $2; exit}') + case "$protocol" in + SMB | NFS | AFP | CIFS | WebDAV) + echo "Refusing to clean network volume protocol $protocol: $resolved" >&2 + return 1 + ;; + esac + fi + + printf '%s\n' "$resolved" +} + +clean_external_volume_target() { + local volume="$1" + [[ -d "$volume" ]] || return 1 + [[ -L "$volume" ]] && return 1 + + local -a top_level_targets=( + "$volume/.TemporaryItems" + "$volume/.Trashes" + ) + local cleaned_count=0 + local total_size=0 + local found_any=false + local volume_name="${volume##*/}" - # Process each container's candidate directories - for container_dir in "${containers[@]}"; do - local container_id - container_id=$(basename "$container_dir") - local normalized_id="$container_id" - [[ "$normalized_id" == group.* ]] && normalized_id="${normalized_id#group.}" + start_section_spinner "Scanning external volume..." - local protected_container=false - if should_protect_data "$container_id" 2> /dev/null || should_protect_data "$normalized_id" 2> /dev/null; then - protected_container=true + local target_path + for target_path in "${top_level_targets[@]}"; do + [[ -e "$target_path" ]] || continue + [[ -L "$target_path" ]] && continue + if should_protect_path "$target_path" 2> /dev/null || is_path_whitelisted "$target_path" 2> /dev/null; then + continue fi - local -a candidates=( - "$container_dir/Logs" - "$container_dir/Library/Logs" - ) - if [[ "$protected_container" != "true" ]]; then - candidates+=( - "$container_dir/tmp" - "$container_dir/Library/tmp" - "$container_dir/Caches" - "$container_dir/Library/Caches" - ) - fi + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$target_path" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 - local candidate - for candidate in "${candidates[@]}"; do - [[ -d "$candidate" ]] || continue - [[ -L "$candidate" ]] && continue - if is_path_whitelisted "$candidate" 2> /dev/null; then - continue + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$target_path" "$size_kb" 1 true || continue fi + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + elif safe_remove "$target_path" true > /dev/null 2>&1; then + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + fi + done - # Build non-protected candidate items for cleanup. - local -a items_to_clean=() - local item - while IFS= read -r -d '' item; do - [[ -e "$item" ]] || continue - [[ -L "$item" ]] && continue - if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then - continue - else - items_to_clean+=("$item") - fi - done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then + clean_ds_store_tree "$volume" "${volume_name} volume, .DS_Store" + fi - [[ ${#items_to_clean[@]} -gt 0 ]] || continue + local metadata_scan_timeout="${MOLE_EXTERNAL_VOLUME_SCAN_TIMEOUT:-15}" + [[ "$metadata_scan_timeout" =~ ^[0-9]+$ ]] || metadata_scan_timeout=15 + while IFS= read -r -d '' metadata_file; do + [[ -e "$metadata_file" ]] || continue + if should_protect_path "$metadata_file" 2> /dev/null || is_path_whitelisted "$metadata_file" 2> /dev/null; then + continue + fi - local candidate_size_kb=0 - local candidate_changed=false - if [[ "$DRY_RUN" == "true" ]]; then - for item in "${items_to_clean[@]}"; do - local item_size - item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 - [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 - candidate_changed=true - candidate_size_kb=$((candidate_size_kb + item_size)) - done - else - for item in "${items_to_clean[@]}"; do - local item_size - item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 - [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 - if safe_remove "$item" true 2> /dev/null; then - candidate_changed=true - candidate_size_kb=$((candidate_size_kb + item_size)) - fi - done - fi + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$metadata_file" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 - if [[ "$candidate_changed" == "true" ]]; then - total_size=$((total_size + candidate_size_kb)) - cleaned_count=$((cleaned_count + 1)) - found_any=true + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$metadata_file" "$size_kb" 1 true || continue fi - done - done + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + elif safe_remove "$metadata_file" true > /dev/null 2>&1; then + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + fi + done < <(run_with_timeout "$metadata_scan_timeout" find -P "$volume" -xdev -type f -name "._*" -print0 2> /dev/null || true) stop_section_spinner @@ -652,15 +1424,19 @@ clean_group_container_caches() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} External volume cleanup${NC} · ${YELLOW}${volume_name}, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} External volume cleanup${NC} · ${line_color}${volume_name}, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) total_items=$((total_items + 1)) note_activity fi + + return 0 } # Browser caches (Safari/Chrome/Edge/Firefox). @@ -668,71 +1444,305 @@ clean_browsers() { safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" # Chrome/Chromium. safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache" - safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" - safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" - safe_clean ~/Library/Application\ Support/Google/Chrome/component_crx_cache/* "Chrome component CRX cache" + # Do not clean Chromium Service Worker ScriptCache. Even when the browser is + # closed, removing MV3 extension bytecode can break extension service + # workers and trigger security warnings during dry-run scans. See #785, + # #964, and #968. + local chrome_support_has_targets=false + if mole_cleanup_targets_exist \ + "$HOME/Library/Application Support/Google/Chrome"/*/Application\ Cache/* \ + "$HOME/Library/Application Support/Google/Chrome"/*/Code\ Cache/* \ + "$HOME/Library/Application Support/Google/Chrome"/*/GPUCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/*/DawnCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/*/GrShaderCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/*/GraphiteDawnCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/component_crx_cache/* \ + "$HOME/Library/Application Support/Google/Chrome"/ShaderCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/GrShaderCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/GraphiteDawnCache/* \ + "$HOME/Library/Application Support/Google/Chrome"/Crashpad/completed/* \ + "$HOME/Library/Application Support/Google/Chrome"/OptGuideOnDeviceModel/* \ + "$HOME/Library/Application Support/Google/Chrome"/OptGuideOnDeviceClassifierModel/* \ + "$HOME/Library/Application Support/Google/Chrome"/optimization_guide_model_store/*; then + chrome_support_has_targets=true + fi + + local chrome_state=0 + is_google_chrome_running || chrome_state=$? + if [[ $chrome_state -eq 1 ]]; then + # On-device AI model stores managed by Chrome's component updater; + # re-downloaded on demand and often multiple GB (#1179). + _clean_chrome_profile_caches_guarded || true + elif [[ $chrome_state -eq 0 && "$chrome_support_has_targets" == "true" ]]; then + mole_defer_cleanup_family "Chrome" + elif [[ "$chrome_support_has_targets" == "true" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome profile caches · skipped (process state unknown)" + note_activity + fi + local _chrome_profile + for _chrome_profile in "$HOME/Library/Application Support/Google/Chrome"/*/; do + clean_service_worker_cache "Chrome" "${_chrome_profile%/}/Service Worker/CacheStorage" + done safe_clean ~/Library/Application\ Support/Google/GoogleUpdater/crx_cache/* "GoogleUpdater CRX cache" safe_clean ~/Library/Application\ Support/Google/GoogleUpdater/*.old "GoogleUpdater old files" safe_clean ~/Library/Caches/Chromium/* "Chromium cache" safe_clean ~/.cache/puppeteer/* "Puppeteer browser cache" safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache" - safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" + # Arc Browser. + if [[ -d ~/Library/Application\ Support/Arc ]]; then + safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" + local _arc_profile + local _arc_running=false + pgrep -x "Arc" > /dev/null 2>&1 && _arc_running=true + if [[ "$_arc_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/Arc/*/Code\ Cache/* "Arc code cache" + safe_clean ~/Library/Application\ Support/Arc/*/GPUCache/* "Arc GPU cache" + safe_clean ~/Library/Application\ Support/Arc/*/DawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/*/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/*/GraphiteDawnCache/* "Arc Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/ShaderCache/* "Arc shader cache" + safe_clean ~/Library/Application\ Support/Arc/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/GraphiteDawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/Crashpad/completed/* "Arc crash reports" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/Code\ Cache/* "Arc code cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/GPUCache/* "Arc GPU cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/DawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/GraphiteDawnCache/* "Arc Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/ShaderCache/* "Arc shader cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/GraphiteDawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/component_crx_cache/* "Arc component CRX cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/extensions_crx_cache/* "Arc extensions CRX cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/Crashpad/completed/* "Arc crash reports" + fi + for _arc_profile in "$HOME/Library/Application Support/Arc"/*/; do + clean_service_worker_cache "Arc" "${_arc_profile%/}/Service Worker/CacheStorage" + done + for _arc_profile in "$HOME/Library/Application Support/Arc/User Data"/*/; do + [[ -d "$_arc_profile" ]] || continue + clean_service_worker_cache "Arc" "${_arc_profile%/}/Service Worker/CacheStorage" + done + fi + # Dia Browser. company.thebrowser.dia only holds Sentry crash state; the real + # caches are the Chromium ones under ~/Library/Caches/Dia/User Data (HTTP and + # code cache) and ~/Library/Application Support/Dia/User Data (GPU and CRX + # caches). Dia has no ShaderCache / GrShaderCache / DawnCache / Crashpad tree + # like Arc, so those rows are intentionally absent. safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" - safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" + if [[ -d ~/Library/Application\ Support/Dia ]]; then + local _dia_profile + local _dia_process_state=2 + if command -v pgrep > /dev/null 2>&1; then + if pgrep -x "Dia" > /dev/null 2>&1; then + _dia_process_state=0 + else + _dia_process_state=$? + fi + fi + if [[ $_dia_process_state -eq 1 ]]; then + safe_clean ~/Library/Caches/Dia/User\ Data/*/Cache/* "Dia HTTP cache" + safe_clean ~/Library/Caches/Dia/User\ Data/*/Code\ Cache/* "Dia code cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/GraphiteDawnCache/* "Dia Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/GPUPersistentCache/* "Dia GPU cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/component_crx_cache/* "Dia component CRX cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/extensions_crx_cache/* "Dia extensions CRX cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/*/DawnGraphiteCache/* "Dia Dawn Graphite cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/*/DawnWebGPUCache/* "Dia Dawn WebGPU cache" + safe_clean ~/Library/Application\ Support/Dia/User\ Data/*/GPUCache/* "Dia GPU cache" + else + local _dia_skip_reason="Dia running" + [[ $_dia_process_state -gt 1 ]] && _dia_skip_reason="process state unknown" + echo -e " ${GRAY}${ICON_WARNING}${NC} Dia Application Support cache · skipped ($_dia_skip_reason)" + note_activity + fi + for _dia_profile in "$HOME/Library/Application Support/Dia/User Data"/*/; do + [[ -d "$_dia_profile" ]] || continue + clean_service_worker_cache "Dia" "${_dia_profile%/}/Service Worker/CacheStorage" + done + fi + if [[ -d ~/Library/Application\ Support/BraveSoftware ]]; then + safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" + local _brave_profile + local _brave_running=false + pgrep -x "Brave Browser" > /dev/null 2>&1 && _brave_running=true + if [[ "$_brave_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/Application\ Cache/* "Brave app cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/Code\ Cache/* "Brave code cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GPUCache/* "Brave GPU cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/DawnCache/* "Brave Dawn cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GrShaderCache/* "Brave GR shader cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GraphiteDawnCache/* "Brave Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/component_crx_cache/* "Brave component CRX cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/ShaderCache/* "Brave shader cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/GrShaderCache/* "Brave GR shader cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/GraphiteDawnCache/* "Brave Dawn cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/Crashpad/completed/* "Brave crash reports" + fi + for _brave_profile in "$HOME/Library/Application Support/BraveSoftware/Brave-Browser"/*/; do + clean_service_worker_cache "Brave" "${_brave_profile%/}/Service Worker/CacheStorage" + done + fi # Helium Browser. - safe_clean ~/Library/Caches/net.imput.helium/* "Helium cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/*/GPUCache/* "Helium GPU cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/component_crx_cache/* "Helium component cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/extensions_crx_cache/* "Helium extensions cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/GrShaderCache/* "Helium shader cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/GraphiteDawnCache/* "Helium Dawn cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/ShaderCache/* "Helium shader cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/*/Application\ Cache/* "Helium app cache" + if [[ -d ~/Library/Application\ Support/net.imput.helium ]]; then + safe_clean ~/Library/Caches/net.imput.helium/* "Helium cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/*/GPUCache/* "Helium GPU cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/component_crx_cache/* "Helium component cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/extensions_crx_cache/* "Helium extensions cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/GrShaderCache/* "Helium shader cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/GraphiteDawnCache/* "Helium Dawn cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/ShaderCache/* "Helium shader cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/*/Application\ Cache/* "Helium app cache" + fi # Yandex Browser. - safe_clean ~/Library/Caches/Yandex/YandexBrowser/* "Yandex cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/ShaderCache/* "Yandex shader cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GrShaderCache/* "Yandex GR shader cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GraphiteDawnCache/* "Yandex Dawn cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/*/GPUCache/* "Yandex GPU cache" - local firefox_running=false - if pgrep -x "Firefox" > /dev/null 2>&1; then - firefox_running=true - fi - if [[ "$firefox_running" == "true" ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped" - else - safe_clean ~/Library/Caches/Firefox/* "Firefox cache" + if [[ -d ~/Library/Application\ Support/Yandex ]]; then + safe_clean ~/Library/Caches/Yandex/YandexBrowser/* "Yandex cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/ShaderCache/* "Yandex shader cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GrShaderCache/* "Yandex GR shader cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GraphiteDawnCache/* "Yandex Dawn cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/*/GPUCache/* "Yandex GPU cache" + fi + local firefox_state=0 + _firefox_process_state || firefox_state=$? + local firefox_cache_targets=false + local firefox_profile_cache_targets=false + mole_cleanup_targets_exist "$HOME/Library/Caches/Firefox"/* && firefox_cache_targets=true + mole_cleanup_targets_exist "$HOME/Library/Application Support/Firefox/Profiles"/*/cache2/* && firefox_profile_cache_targets=true + if [[ $firefox_state -eq 0 ]]; then + if [[ "$firefox_cache_targets" == "true" || "$firefox_profile_cache_targets" == "true" ]]; then + mole_defer_cleanup_family "Firefox" + fi + elif [[ $firefox_state -eq 1 ]]; then + _clean_firefox_caches_guarded || true + elif [[ "$firefox_cache_targets" == "true" || "$firefox_profile_cache_targets" == "true" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox caches · skipped (process state unknown)" + note_activity fi safe_clean ~/Library/Caches/com.operasoftware.Opera/* "Opera cache" - safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache" + # Vivaldi Browser. + if [[ -d ~/Library/Application\ Support/Vivaldi ]]; then + safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache" + local _vivaldi_profile + local _vivaldi_running=false + pgrep -x "Vivaldi" > /dev/null 2>&1 && _vivaldi_running=true + if [[ "$_vivaldi_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/Vivaldi/*/Code\ Cache/* "Vivaldi code cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GPUCache/* "Vivaldi GPU cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/DawnCache/* "Vivaldi Dawn cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GrShaderCache/* "Vivaldi GR shader cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GraphiteDawnCache/* "Vivaldi Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/Vivaldi/ShaderCache/* "Vivaldi shader cache" + safe_clean ~/Library/Application\ Support/Vivaldi/GrShaderCache/* "Vivaldi GR shader cache" + safe_clean ~/Library/Application\ Support/Vivaldi/GraphiteDawnCache/* "Vivaldi Dawn cache" + safe_clean ~/Library/Application\ Support/Vivaldi/Crashpad/completed/* "Vivaldi crash reports" + fi + for _vivaldi_profile in "$HOME/Library/Application Support/Vivaldi"/*/; do + clean_service_worker_cache "Vivaldi" "${_vivaldi_profile%/}/Service Worker/CacheStorage" + done + fi safe_clean ~/Library/Caches/Comet/* "Comet cache" safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/zen/* "Zen cache" - if [[ "$firefox_running" == "true" ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox is running · profile cache cleanup skipped" - else - safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" + clean_chrome_old_versions || return $? + clean_edge_old_versions || return $? + clean_edge_updater_old_versions || return $? + clean_brave_old_versions || return $? + # QQ Browser 3 (Chromium-based). + if [[ -d ~/Library/Application\ Support/QQBrowser3 ]]; then + safe_clean ~/Library/Caches/com.tencent.QQBrowser3/* "QQ Browser cache" + local _qqbrowser_running=false + pgrep -x "QQBrowser3" > /dev/null 2>&1 && _qqbrowser_running=true + if [[ "$_qqbrowser_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/QQBrowser3/*/Code\ Cache/* "QQ Browser code cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/*/GPUCache/* "QQ Browser GPU cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/ShaderCache/* "QQ Browser shader cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/GrShaderCache/* "QQ Browser GR shader cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/GraphiteDawnCache/* "QQ Browser Dawn cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/component_crx_cache/* "QQ Browser component cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/Crashpad/completed/* "QQ Browser crash reports" + fi fi - clean_chrome_old_versions - clean_edge_old_versions - clean_edge_updater_old_versions } # Cloud storage caches. clean_cloud_storage() { - safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" - safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" - safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning cloud storage caches..." >&2 + fi + local dropbox_state=0 + _dropbox_process_state || dropbox_state=$? + if [[ $dropbox_state -eq 0 ]]; then + if mole_cleanup_targets_exist \ + "$HOME/Library/Caches/com.getdropbox.dropbox" \ + "$HOME/Library/Caches"/com.dropbox.*; then + mole_defer_cleanup_family "Dropbox" + fi + elif [[ $dropbox_state -eq 1 ]]; then + _clean_dropbox_caches_guarded || true + elif mole_cleanup_targets_exist \ + "$HOME/Library/Caches/com.getdropbox.dropbox" \ + "$HOME/Library/Caches"/com.dropbox.*; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Dropbox cache · skipped (process state unknown)" + note_activity + fi + local google_drive_state=0 + _google_drive_process_state || google_drive_state=$? + if [[ $google_drive_state -eq 0 ]]; then + if mole_cleanup_targets_exist "$HOME/Library/Caches/com.google.GoogleDrive"; then + mole_defer_cleanup_family "Google Drive" + fi + elif [[ $google_drive_state -eq 1 ]]; then + _user_safe_clean_process_guarded \ + _google_drive_process_state \ + "Google Drive" \ + "Google Drive cache" \ + ~/Library/Caches/com.google.GoogleDrive \ + "Google Drive cache" || true + elif mole_cleanup_targets_exist "$HOME/Library/Caches/com.google.GoogleDrive"; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Google Drive cache · skipped (process state unknown)" + note_activity + fi safe_clean ~/Library/Caches/com.baidu.netdisk "Baidu Netdisk cache" safe_clean ~/Library/Caches/com.alibaba.teambitiondisk "Alibaba Cloud cache" safe_clean ~/Library/Caches/com.box.desktop "Box cache" - safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" + local onedrive_state=0 + _onedrive_process_state || onedrive_state=$? + if [[ $onedrive_state -eq 0 ]]; then + if mole_cleanup_targets_exist "$HOME/Library/Caches/com.microsoft.OneDrive"; then + mole_defer_cleanup_family "OneDrive" + fi + elif [[ $onedrive_state -eq 1 ]]; then + _user_safe_clean_process_guarded \ + _onedrive_process_state \ + "OneDrive" \ + "OneDrive cache" \ + ~/Library/Caches/com.microsoft.OneDrive \ + "OneDrive cache" || true + elif mole_cleanup_targets_exist "$HOME/Library/Caches/com.microsoft.OneDrive"; then + echo -e " ${GRAY}${ICON_WARNING}${NC} OneDrive cache · skipped (process state unknown)" + note_activity + fi } # Office app caches. clean_office_applications() { + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning office application caches..." >&2 + fi safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning Word container cache..." >&2 + fi + safe_clean ~/Library/Containers/com.microsoft.Word/Data/Library/Caches/* "Microsoft Word container cache" + safe_clean ~/Library/Containers/com.microsoft.Word/Data/tmp/* "Microsoft Word temp files" + safe_clean ~/Library/Containers/com.microsoft.Word/Data/Library/Logs/* "Microsoft Word container logs" safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning Excel container cache..." >&2 + fi + safe_clean ~/Library/Containers/com.microsoft.Excel/Data/Library/Caches/* "Microsoft Excel container cache" + safe_clean ~/Library/Containers/com.microsoft.Excel/Data/tmp/* "Microsoft Excel temp files" + safe_clean ~/Library/Containers/com.microsoft.Excel/Data/Library/Logs/* "Microsoft Excel container logs" safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache" safe_clean ~/Library/Caches/com.microsoft.Outlook/* "Microsoft Outlook cache" safe_clean ~/Library/Caches/com.apple.iWork.* "Apple iWork cache" @@ -742,12 +1752,112 @@ clean_office_applications() { } # Virtualization caches. +clean_utm_caches() { + if pgrep -x "UTM" > /dev/null 2>&1; then + debug_log "Skipping UTM caches while UTM is running" + return 0 + fi + + safe_clean ~/Library/Caches/com.utmapp.UTM/* "UTM app cache" + safe_clean ~/Library/Containers/com.utmapp.UTM/Data/Library/Caches/* "UTM sandbox cache" + safe_clean ~/Library/Containers/com.utmapp.UTM/Data/tmp/* "UTM temporary files" +} + +clean_tart_caches() { + local cache_root="$HOME/.tart/cache" + [[ -d "$cache_root" ]] || return 0 + command -v tart > /dev/null 2>&1 || return 0 + + local cache_size_kb=0 + local size_rc=0 + cache_size_kb=$(get_path_size_kb "$cache_root" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$cache_size_kb" =~ ^[0-9]+$ ]] || cache_size_kb=0 + [[ "$cache_size_kb" -gt 0 ]] || return 0 + + if is_path_whitelisted "$cache_root"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Tart caches · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Tart caches · skipped (whitelist)" + fi + note_activity + return 0 + fi + + local tart_state=0 + mole_pgrep_any -x "tart" || tart_state=$? + if [[ $tart_state -ne 1 ]]; then + if [[ $tart_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Tart caches · skipped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Tart" + fi + return 0 + fi + + local cache_size_human + cache_size_human=$(bytes_to_human "$((cache_size_kb * 1024))") + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Tart caches · would prune items older than ${MOLE_ORPHAN_AGE_DAYS} days (${cache_size_human})" + echo -e " ${GRAY}tart prune --entries caches --older-than ${MOLE_ORPHAN_AGE_DAYS}${NC}" + note_activity + return 0 + fi + + if [[ -t 1 ]]; then + start_section_spinner "Pruning Tart caches..." + fi + local prune_succeeded=false + tart_state=0 + mole_pgrep_any -x "tart" || tart_state=$? + if [[ $tart_state -ne 1 ]]; then + [[ -t 1 ]] && stop_section_spinner + if [[ $tart_state -eq 2 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Tart caches · stopped (process state unknown)" + note_activity + else + mole_defer_cleanup_family "Tart" + fi + return 0 + elif run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" tart prune --entries caches --older-than "$MOLE_ORPHAN_AGE_DAYS" > /dev/null 2>&1; then + prune_succeeded=true + fi + if [[ -t 1 ]]; then + stop_section_spinner + fi + + if [[ "$prune_succeeded" != "true" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Tart caches · prune failed" + debug_log "tart prune failed for cache-only ${MOLE_ORPHAN_AGE_DAYS}-day policy" + note_activity + return 0 + fi + + local remaining_kb=0 + size_rc=0 + remaining_kb=$(get_path_size_kb "$cache_root" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$remaining_kb" =~ ^[0-9]+$ ]] || remaining_kb=0 + local reclaimed_kb=$((cache_size_kb - remaining_kb)) + [[ "$reclaimed_kb" -ge 0 ]] || reclaimed_kb=0 + + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Tart caches · pruned, $(bytes_to_human "$((reclaimed_kb * 1024))") reclaimed" + note_activity +} + clean_virtualization_tools() { stop_section_spinner safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache" safe_clean ~/Library/Caches/com.parallels.* "Parallels cache" + clean_utm_caches safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache" + safe_clean ~/Library/Caches/lima/download/by-url-sha256/* "Lima download cache" safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files" + clean_tart_caches } # Estimate item size for Application Support cleanup. @@ -791,24 +1901,13 @@ app_support_item_size_bytes() { return 1 fi - local du_tmp - du_tmp=$(mktemp) - local du_status=0 + local du_output # Use stricter timeout for directories - if run_with_timeout "$timeout_seconds" du -skP "$item" > "$du_tmp" 2> /dev/null; then - du_status=0 - else - du_status=$? - fi - - if [[ $du_status -ne 0 ]]; then - rm -f "$du_tmp" + if ! du_output=$(run_with_timeout "$timeout_seconds" du -skP "$item" 2> /dev/null); then return 1 fi - local size_kb - size_kb=$(awk 'NR==1 {print $1; exit}' "$du_tmp") - rm -f "$du_tmp" + local size_kb="${du_output%%[^0-9]*}" [[ "$size_kb" =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "$((size_kb * 1024))" return 0 @@ -817,6 +1916,25 @@ app_support_item_size_bytes() { return 1 } +app_support_dir_has_regenerable_cache_markers() { + local app_dir="$1" + local marker + + for marker in \ + "$app_dir/Code Cache" \ + "$app_dir/GPUCache" \ + "$app_dir/DawnCache" \ + "$app_dir/GrShaderCache" \ + "$app_dir/GraphiteDawnCache" \ + "$app_dir/DawnGraphiteCache" \ + "$app_dir/DawnWebGPUCache" \ + "$app_dir/Crashpad"; do + [[ -e "$marker" ]] && return 0 + done + + return 1 +} + # Application Support logs/caches. clean_application_support_logs() { if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then @@ -853,17 +1971,22 @@ clean_application_support_logs() { last_progress_update=$(get_epoch_seconds) for app_dir in ~/Library/Application\ Support/*; do [[ -d "$app_dir" ]] || continue - local app_name - app_name=$(basename "$app_dir") + local app_name="${app_dir##*/}" app_count=$((app_count + 1)) update_progress_if_needed "$app_count" "$total_apps" last_progress_update 1 || true - local app_name_lower - app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') local is_protected=false - if should_protect_data "$app_name"; then + if is_path_whitelisted "$app_dir" 2> /dev/null; then is_protected=true - elif should_protect_data "$app_name_lower"; then + elif should_protect_path "$app_dir" 2> /dev/null; then is_protected=true + elif should_protect_data "$app_name"; then + is_protected=true + else + local app_name_lower + app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + if should_protect_data "$app_name_lower"; then + is_protected=true + fi fi if [[ "$is_protected" == "true" ]]; then continue @@ -871,9 +1994,31 @@ clean_application_support_logs() { if is_critical_system_component "$app_name"; then continue fi - local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed") + # Application Support can hold licenses, databases, offline assets and + # session state. Keep this generic pass to explicit, regenerable cache + # subtrees only; app-specific log/cache cleanup belongs in allowlisted + # app modules above. + local -a start_candidates=( + "$app_dir/Code Cache" + "$app_dir/GPUCache" + "$app_dir/DawnCache" + "$app_dir/GrShaderCache" + "$app_dir/GraphiteDawnCache" + "$app_dir/DawnGraphiteCache" + "$app_dir/DawnWebGPUCache" + "$app_dir/Crashpad/completed" + ) + if app_support_dir_has_regenerable_cache_markers "$app_dir"; then + start_candidates+=( + "$app_dir/Cache" + "$app_dir/CachedData" + ) + fi for candidate in "${start_candidates[@]}"; do if [[ -d "$candidate" ]]; then + if should_protect_path "$candidate" 2> /dev/null || is_path_whitelisted "$candidate" 2> /dev/null; then + continue + fi # Quick count check - skip if too many items to avoid hanging local quick_count quick_count=$(app_support_entry_count_capped "$candidate" 1 101) @@ -885,7 +2030,11 @@ clean_application_support_logs() { fi stop_section_spinner start_section_spinner "Scanning Application Support... $app_count/$total_apps [$app_label, bulk clean]" - if [[ "$DRY_RUN" != "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$candidate" 0 1 false || continue + fi + else # Remove entire candidate directory in one go safe_remove "$candidate" true > /dev/null 2>&1 || true fi @@ -901,13 +2050,17 @@ clean_application_support_logs() { local candidate_item_count=0 while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue - item_found=true - candidate_item_count=$((candidate_item_count + 1)) + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + local item_size_known=false + local item_size_kb=0 + local item_size_bytes="" if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then - local item_size_bytes="" if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then - candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) + item_size_kb=$(((item_size_bytes + 1023) / 1024)) + item_size_known=true else candidate_size_partial=true fi @@ -915,6 +2068,14 @@ clean_application_support_logs() { candidate_size_partial=true fi fi + if [[ "$DRY_RUN" == "true" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" "$item_size_kb" 1 "$item_size_known" || continue + fi + if [[ "$item_size_known" == "true" ]]; then + candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) + fi + item_found=true + candidate_item_count=$((candidate_item_count + 1)) if ((candidate_item_count % 250 == 0)); then local current_time current_time=$(get_epoch_seconds) @@ -960,7 +2121,11 @@ clean_application_support_logs() { fi stop_section_spinner start_section_spinner "Scanning Application Support... group [$container_label, bulk clean]" - if [[ "$DRY_RUN" != "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$candidate" 0 1 false || continue + fi + else safe_remove "$candidate" true > /dev/null 2>&1 || true fi found_any=true @@ -975,13 +2140,14 @@ clean_application_support_logs() { local candidate_item_count=0 while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue - item_found=true - candidate_item_count=$((candidate_item_count + 1)) + local item_size_known=false + local item_size_kb=0 + local item_size_bytes="" if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then - local item_size_bytes="" if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then - candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) + item_size_kb=$(((item_size_bytes + 1023) / 1024)) + item_size_known=true else candidate_size_partial=true fi @@ -989,6 +2155,14 @@ clean_application_support_logs() { candidate_size_partial=true fi fi + if [[ "$DRY_RUN" == "true" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" "$item_size_kb" 1 "$item_size_known" || continue + fi + if [[ "$item_size_known" == "true" ]]; then + candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) + fi + item_found=true + candidate_item_count=$((candidate_item_count + 1)) if ((candidate_item_count % 250 == 0)); then local current_time current_time=$(get_epoch_seconds) @@ -1019,6 +2193,7 @@ clean_application_support_logs() { if [[ "$pipefail_was_set" == "true" ]]; then set -o pipefail fi + # eval: restore shopt state captured by $(shopt -p) eval "$_ng_state" stop_section_spinner if [[ "$found_any" == "true" ]]; then @@ -1027,15 +2202,17 @@ clean_application_support_logs() { local total_size_kb=$(((total_size_bytes + 1023) / 1024)) if [[ "$DRY_RUN" == "true" ]]; then if [[ "$total_size_partial" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}at least $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC} · ${YELLOW}at least $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC} · $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" fi else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") if [[ "$total_size_partial" == "true" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}at least $size_human${NC}" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Application Support logs/caches${NC} · ${line_color}at least $size_human${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Application Support logs/caches${NC} · ${line_color}$size_human${NC}" fi fi files_cleaned=$((files_cleaned + cleaned_count)) @@ -1044,22 +2221,147 @@ clean_application_support_logs() { note_activity fi } -# iOS device backup info. -check_ios_device_backups() { - local backup_dir="$HOME/Library/Application Support/MobileSync/Backup" - # Simplified check without find to avoid hanging. - if [[ -d "$backup_dir" ]]; then - local backup_kb - backup_kb=$(get_path_size_kb "$backup_dir") - if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then - local backup_human - backup_human=$(command du -shP "$backup_dir" 2> /dev/null | awk '{print $1}') - if [[ -n "$backup_human" ]]; then - note_activity - echo -e " ${YELLOW}${ICON_WARNING}${NC} iOS backups: ${GREEN}${backup_human}${NC}${GRAY}, Path: $backup_dir${NC}" +# Remove cached device firmware (.ipsw) from iTunes, Finder, and Apple Configurator 2. +# These are installers for firmware already applied (or superseded); macOS will +# re-download them on demand. Typical size: 5-8GB per file. Never touches backups. +clean_cached_device_firmware() { + local -a shallow_dirs=( + "$HOME/Library/iTunes/iPhone Software Updates" + "$HOME/Library/iTunes/iPad Software Updates" + "$HOME/Library/iTunes/iPod Software Updates" + ) + + # Apple Configurator 2 nests firmware under per-team-id group containers. + local -a configurator_dirs=() + local gc + for gc in "$HOME/Library/Group Containers"/*.group.com.apple.configurator; do + [[ -d "$gc" ]] || continue + configurator_dirs+=("$gc") + done + + local cleaned_count=0 + local total_size_kb=0 + local cleaned_any=false + + _process_ipsw_file() { + local ipsw="$1" + [[ -f "$ipsw" ]] || return 0 + if is_path_whitelisted "$ipsw"; then + return 0 + fi + local size_kb="" + local size_rc=0 + size_kb=$(get_path_size_kb "$ipsw") || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + size_kb="${size_kb:-0}" + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$ipsw" "$size_kb" 1 true || return 0 fi + total_size_kb=$((total_size_kb + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + return 0 + fi + + if safe_remove "$ipsw" true > /dev/null 2>&1; then + total_size_kb=$((total_size_kb + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + fi + } + + local dir ipsw + for dir in "${shallow_dirs[@]}"; do + [[ -d "$dir" ]] || continue + while IFS= read -r -d '' ipsw; do + _process_ipsw_file "$ipsw" || return $? + done < <(command find "$dir" -maxdepth 1 -type f -name "*.ipsw" -print0 2> /dev/null) + done + + if [[ ${#configurator_dirs[@]} -gt 0 ]]; then + for dir in "${configurator_dirs[@]}"; do + [[ -d "$dir" ]] || continue + while IFS= read -r -d '' ipsw; do + _process_ipsw_file "$ipsw" || return $? + done < <(command find "$dir" -type f -name "*.ipsw" -print0 2> /dev/null) + done + fi + + unset -f _process_ipsw_file + + if [[ "$cleaned_any" == "true" ]]; then + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Cached device firmware${NC} · ${YELLOW}${cleaned_count} files, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Cached device firmware${NC} · ${line_color}${cleaned_count} files, $size_human${NC}" fi + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) + total_items=$((total_items + 1)) + note_activity fi +} + +# List JetBrains per-version data dirs that are not the newest version of +# their IDE (e.g. GoLand2025.1 when GoLand2025.2 exists). Prints one dir name +# per line; never prints the newest version, unversioned dirs, or Toolbox. +# Data source for the review-only large-dir report (#1179). +jetbrains_stale_version_dirs() { + local jetbrains_support="$1" + [[ -d "$jetbrains_support" ]] || return 0 + command find "$jetbrains_support" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | + awk -F'/' ' + { + name = $NF + if (match(name, /[0-9][0-9][0-9][0-9]\.[0-9]+$/) && RSTART > 1) { + base = substr(name, 1, RSTART - 1) + split(substr(name, RSTART), v, ".") + key = v[1] * 100 + v[2] + n[base]++ + names[base, n[base]] = name + keys[base, n[base]] = key + if (key > maxk[base]) maxk[base] = key + } + } + END { + for (b in n) + for (i = 1; i <= n[b]; i++) + if (keys[b, i] < maxk[b]) print names[b, i] + } + ' +} + +# AI coding agents (Claude Code and similar) create full checkouts under +# /.claude/worktrees/ that accumulate silently across repos. Report +# only, same 1GB bar as other large candidates; removal stays a manual +# `git worktree remove` decision because a worktree may hold agent work. +report_agent_worktree_candidates() { + local threshold_kb=$((1024 * 1024)) # 1GB + local -a roots=( + "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/Projects" + "$HOME/GitHub" "$HOME/Workspace" "$HOME/Repos" + "$HOME/Development" "$HOME/www" "$HOME/src" + ) + local root container size_kb size_rc + for root in "${roots[@]}"; do + [[ -d "$root" ]] || continue + while IFS= read -r -d '' container; do + size_rc=0 + size_kb=$(get_path_size_kb "$container" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 0 ]] || _mole_record_clean_cancellation "$size_rc" + [[ $size_rc -eq 0 ]] || return "$size_rc" + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + [[ "$size_kb" -ge "$threshold_kb" ]] || continue + echo -e " ${YELLOW}${ICON_REVIEW}${NC} AI agent worktrees · ${GREEN}$(bytes_to_human "$((size_kb * 1024))")${NC} · ${GRAY}$(format_path_link "$container")${NC}" + note_activity + done < <(run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" command find "$root" -maxdepth 6 -type d -path "*/.claude/worktrees" -prune -print0 2> /dev/null) + done return 0 } @@ -1067,28 +2369,112 @@ check_ios_device_backups() { check_large_file_candidates() { local threshold_kb=$((1024 * 1024)) # 1GB local found_any=false + local size_rc=0 + + _large_candidate_size_kb() { + local path="$1" + local timeout_seconds="${2:-${MOLE_LARGE_CANDIDATE_SIZE_TIMEOUT:-3}}" + [[ "$timeout_seconds" =~ ^[0-9]+$ ]] || timeout_seconds=3 + local du_output="" + du_output=$(run_with_timeout "$timeout_seconds" du -skP "$path" 2> /dev/null || true) + local size_kb="${du_output%%[^0-9]*}" + [[ "$size_kb" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$size_kb" + } + + # Date of the newest immediate child. Only for rows holding irreplaceable + # data that goes stale, where size alone cannot decide: 100GB of last + # month's phone backup is the only copy of that phone, and 100GB from a + # device sold two years ago is dead weight. Rebuildable caches get no date + # because their age never changes the answer. + # One bounded command, materialized whole: a partial listing would report + # an older date than the truth, which is worse than reporting none. On + # timeout or any nonzero status the row falls back to no date. + _large_dir_newest_date() { + local path="$1" + local mtimes="" newest="" + mtimes=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" \ + command find "$path" -mindepth 1 -maxdepth 1 -exec stat -f '%m' {} + 2> /dev/null) || return 1 + [[ -n "$mtimes" ]] || return 1 + newest=$(printf '%s\n' "$mtimes" | sort -n | tail -1) + [[ "$newest" =~ ^[0-9]+$ ]] || return 1 + date -r "$newest" '+%Y-%m-%d' 2> /dev/null || return 1 + } + + # One row per large item: "label · size · path", with an optional date + # between size and path. Bare date, no leading word: it sits right after a + # short size field, so it lands in a stable column and reads as a date on + # its own. The review icon carries the review-only semantics; + # format_path_link keeps the path clickable even with spaces (OSC 8 link, + # not terminal auto-linking). + _report_large_review_row() { + local label="$1" + local size_human="$2" + local path="$3" + local newest_date="${4:-}" + local date_part="" + [[ -n "$newest_date" ]] && date_part=" · ${GRAY}${newest_date}${NC}" + stop_section_spinner + echo -e " ${YELLOW}${ICON_REVIEW}${NC} ${label} · ${GREEN}${size_human}${NC}${date_part} · ${GRAY}$(format_path_link "$path")${NC}" + found_any=true + start_section_spinner "Scanning large files..." + } + + # Pass "date" as $4 on rows where staleness decides the action. Rows left + # without it stay two fields wide. + _report_large_review_dir() { + local label="$1" + local path="$2" + local probe_timeout="${3:-}" + local want_date="${4:-}" + [[ -d "$path" ]] || return 0 + local size_kb="" + size_kb=$(_large_candidate_size_kb "$path" "$probe_timeout") || return 0 + [[ "$size_kb" -ge "$threshold_kb" ]] || return 0 + local size_human + size_human=$(bytes_to_human "$((size_kb * 1024))") + local detail="" + if [[ "$want_date" == "date" ]]; then + detail=$(_large_dir_newest_date "$path") || detail="" + fi + _report_large_review_row "$label" "$size_human" "$path" "$detail" + } + + # The du probes below (Mail, backups, package stores) take seconds in + # total; keep loading feedback on screen between rows. + start_section_spinner "Scanning large files..." local mail_dir="$HOME/Library/Mail" if [[ -d "$mail_dir" ]]; then local mail_kb - mail_kb=$(get_path_size_kb "$mail_dir") + size_rc=0 + mail_kb=$(get_path_size_kb "$mail_dir") || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + _mole_record_clean_cancellation "$size_rc" + stop_section_spinner + return "$size_rc" + fi if [[ "$mail_kb" -ge "$threshold_kb" ]]; then local mail_human mail_human=$(bytes_to_human "$((mail_kb * 1024))") - echo -e " ${YELLOW}${ICON_WARNING}${NC} Mail data: ${GREEN}${mail_human}${NC}${GRAY}, Path: $mail_dir${NC}" - found_any=true + _report_large_review_row "Mail data" "$mail_human" "$mail_dir" fi fi local mail_downloads="$HOME/Library/Mail Downloads" if [[ -d "$mail_downloads" ]]; then local downloads_kb - downloads_kb=$(get_path_size_kb "$mail_downloads") + size_rc=0 + downloads_kb=$(get_path_size_kb "$mail_downloads") || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + _mole_record_clean_cancellation "$size_rc" + stop_section_spinner + return "$size_rc" + fi if [[ "$downloads_kb" -ge "$threshold_kb" ]]; then local downloads_human downloads_human=$(bytes_to_human "$((downloads_kb * 1024))") - echo -e " ${YELLOW}${ICON_WARNING}${NC} Mail downloads: ${GREEN}${downloads_human}${NC}${GRAY}, Path: $mail_downloads${NC}" - found_any=true + _report_large_review_row "Mail downloads" "$downloads_human" "$mail_downloads" fi fi @@ -1096,12 +2482,17 @@ check_large_file_candidates() { for installer_path in /Applications/Install\ macOS*.app; do if [[ -e "$installer_path" ]]; then local installer_kb - installer_kb=$(get_path_size_kb "$installer_path") + size_rc=0 + installer_kb=$(get_path_size_kb "$installer_path") || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + _mole_record_clean_cancellation "$size_rc" + stop_section_spinner + return "$size_rc" + fi if [[ "$installer_kb" -gt 0 ]]; then local installer_human installer_human=$(bytes_to_human "$((installer_kb * 1024))") - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS installer: ${GREEN}${installer_human}${NC}${GRAY}, Path: $installer_path${NC}" - found_any=true + _report_large_review_row "macOS installer" "$installer_human" "$installer_path" fi fi done @@ -1109,54 +2500,113 @@ check_large_file_candidates() { local updates_dir="$HOME/Library/Updates" if [[ -d "$updates_dir" ]]; then local updates_kb - updates_kb=$(get_path_size_kb "$updates_dir") + size_rc=0 + updates_kb=$(get_path_size_kb "$updates_dir") || size_rc=$? + if [[ $size_rc -ne 0 ]]; then + _mole_record_clean_cancellation "$size_rc" + stop_section_spinner + return "$size_rc" + fi if [[ "$updates_kb" -ge "$threshold_kb" ]]; then local updates_human updates_human=$(bytes_to_human "$((updates_kb * 1024))") - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS updates cache: ${GREEN}${updates_human}${NC}${GRAY}, Path: $updates_dir${NC}" - found_any=true + _report_large_review_row "macOS updates cache" "$updates_human" "$updates_dir" fi fi if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1 && defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then local snapshot_list snapshot_count - snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) + snapshot_list=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" tmutil listlocalsnapshots / 2> /dev/null || true) if [[ -n "$snapshot_list" ]]; then snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}" - echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Review: tmutil listlocalsnapshots /${NC}" + stop_section_spinner + echo -e " ${YELLOW}${ICON_REVIEW}${NC} Time Machine local snapshots · ${GREEN}${snapshot_count}${NC}" found_any=true + start_section_spinner "Scanning large files..." fi fi fi + local docker_reported=false if command -v docker > /dev/null 2>&1; then local docker_output - docker_output=$(run_with_timeout 3 docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2> /dev/null || true) + docker_output=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2> /dev/null || true) if [[ -n "$docker_output" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" + local docker_detail="" while IFS=$'\t' read -r dtype dsize dreclaim; do [[ -z "$dtype" ]] && continue - echo -e " ${GRAY}${ICON_LIST} $dtype: $dsize, Reclaimable: $dreclaim${NC}" + docker_detail+="${docker_detail:+ · }${dtype} ${dsize} (${dreclaim} reclaimable)" done <<< "$docker_output" - found_any=true + if [[ -n "$docker_detail" ]]; then + stop_section_spinner + echo -e " ${YELLOW}${ICON_REVIEW}${NC} Docker storage · ${GRAY}${docker_detail}${NC}" + found_any=true + docker_reported=true + start_section_spinner "Scanning large files..." + fi else - docker_output=$(run_with_timeout 3 docker system df 2> /dev/null || true) + docker_output=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" docker system df 2> /dev/null || true) if [[ -n "$docker_output" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" - echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Run: docker system df${NC}" + stop_section_spinner + echo -e " ${YELLOW}${ICON_REVIEW}${NC} Docker storage · ${GRAY}docker system df${NC}" found_any=true + docker_reported=true + start_section_spinner "Scanning large files..." fi fi fi - if [[ "$found_any" == "false" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No large items detected in common locations" + _report_large_review_dir "Xcode DerivedData" "$HOME/Library/Developer/Xcode/DerivedData" + # Archives hold the dSYMs that symbolicate crashes from shipped builds, so + # the newest date separates the releases still worth keeping from repeated + # export attempts left behind on one afternoon. + _report_large_review_dir "Xcode archives" "$HOME/Library/Developer/Xcode/Archives" "" "date" + _report_large_review_dir "Simulator data" "$HOME/Library/Developer/CoreSimulator/Devices" + if [[ "$docker_reported" != "true" ]]; then + _report_large_review_dir "Docker Desktop data" "$HOME/Library/Containers/com.docker.docker/Data" fi + # Device backups reach 100GB+ with millions of small files; the default + # 3s du budget times out cold and silently drops the most valuable row, + # so give this probe the hint-scan budget instead. + _report_large_review_dir "iOS backups" "$HOME/Library/Application Support/MobileSync/Backup" "$MOLE_TIMEOUT_HINT_SCAN_SEC" "date" + _report_large_review_dir "LM Studio models" "$HOME/.lmstudio/models" + local orbstack_data + for orbstack_data in "$HOME"/Library/Group\ Containers/*dev.orbstack/data "$HOME/OrbStack"; do + _report_large_review_dir "OrbStack data" "$orbstack_data" + done + _report_large_review_dir "Lima data" "$HOME/.lima" + _report_large_review_dir "Maven local repository" "$HOME/.m2/repository" + _report_large_review_dir "pnpm store" "$HOME/Library/pnpm/store" + _report_large_review_dir "Conda packages" "$HOME/.conda/pkgs" + _report_large_review_dir "Anaconda packages" "$HOME/anaconda3/pkgs" + + # JetBrains keeps one data dir per IDE version (GoLand2025.1, ...). After + # an upgrade the previous version's dir lingers forever with plugins and + # settings inside. Report every dir that is not the newest version of its + # IDE, review-only: these enable downgrades and must never be auto-deleted + # (#1179). + local jetbrains_support="$HOME/Library/Application Support/JetBrains" + local jb_stale + while IFS= read -r jb_stale; do + [[ -n "$jb_stale" ]] || continue + _report_large_review_dir "JetBrains old version data" "$jetbrains_support/$jb_stale" + done < <(jetbrains_stale_version_dirs "$jetbrains_support") + + report_agent_worktree_candidates - note_activity + stop_section_spinner + + unset -f _large_candidate_size_kb _large_dir_newest_date _report_large_review_dir _report_large_review_row + + # Only mark activity when something was reported so an empty section can + # collapse instead of printing a reassurance row. + if [[ "$found_any" == "true" ]]; then + note_activity + else + debug_log "Large files: no candidates above threshold" + fi return 0 } diff --git a/Resources/mole/lib/core/app_protection.sh b/Resources/mole/lib/core/app_protection.sh old mode 100755 new mode 100644 index 144aac4..f6ac624 --- a/Resources/mole/lib/core/app_protection.sh +++ b/Resources/mole/lib/core/app_protection.sh @@ -11,551 +11,41 @@ readonly MOLE_APP_PROTECTION_LOADED=1 _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" +if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then + # shellcheck source=lib/core/timeout.sh + source "$_MOLE_CORE_DIR/timeout.sh" +fi +if [[ -z "${MOLE_TIMEOUTS_LOADED:-}" ]]; then + # shellcheck source=lib/core/timeouts.sh + source "$_MOLE_CORE_DIR/timeouts.sh" +fi # Declare WHITELIST_PATTERNS if not already set (used by is_path_whitelisted) if ! declare -p WHITELIST_PATTERNS &> /dev/null; then declare -a WHITELIST_PATTERNS=() fi -# Application Management - -# ============================================================================ -# Performance Note: -# - SYSTEM_CRITICAL_BUNDLES_FAST: Fast wildcard patterns for cleanup operations -# - SYSTEM_CRITICAL_BUNDLES: Detailed list for uninstall protection (lazy-loaded) -# ============================================================================ - -# Fast patterns for cleanup operations (used by should_protect_data) -# These wildcards provide adequate protection with minimal performance impact -readonly SYSTEM_CRITICAL_BUNDLES_FAST=( - "com.apple.*" - "loginwindow" - "dock" - "systempreferences" - "finder" - "safari" - "backgroundtaskmanagement*" - "keychain*" - "security*" - "bluetooth*" - "wifi*" - "network*" - "tcc" - "notification*" - "accessibility*" - "universalaccess*" - "HIToolbox*" - "textinput*" - "TextInput*" - "keyboard*" - "Keyboard*" - "inputsource*" - "InputSource*" - "keylayout*" - "KeyLayout*" - "GlobalPreferences" - ".GlobalPreferences" - "org.pqrs.Karabiner*" -) - -# Detailed list for uninstall protection -# Critical system components protected from uninstallation -# Note: We explicitly list system components instead of using "com.apple.*" wildcard -# to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.) -readonly SYSTEM_CRITICAL_BUNDLES=( - # Core system applications (in /System/Applications/) - "com.apple.finder" - "com.apple.dock" - "com.apple.Safari" - "com.apple.mail" - "com.apple.systempreferences" - "com.apple.SystemSettings" - "com.apple.Settings*" - "com.apple.controlcenter*" - "com.apple.Spotlight" - "com.apple.notificationcenterui" - "com.apple.loginwindow" - "com.apple.Preview" - "com.apple.TextEdit" - "com.apple.Notes" - "com.apple.reminders" - "com.apple.iCal" - "com.apple.AddressBook" - "com.apple.Photos" - "com.apple.AppStore" - "com.apple.calculator" - "com.apple.Dictionary" - "com.apple.ScreenSharing" - "com.apple.ActivityMonitor" - "com.apple.Console" - "com.apple.DiskUtility" - "com.apple.KeychainAccess" - "com.apple.DigitalColorMeter" - "com.apple.grapher" - "com.apple.Terminal" - "com.apple.ScriptEditor2" - "com.apple.VoiceOverUtility" - "com.apple.BluetoothFileExchange" - "com.apple.print.PrinterProxy" - "com.apple.systempreferences*" - "com.apple.SystemProfiler" - "com.apple.FontBook" - "com.apple.ColorSyncUtility" - "com.apple.audio.AudioMIDISetup" - "com.apple.DirectoryUtility" - "com.apple.NetworkUtility" - "com.apple.exposelauncher" - "com.apple.MigrateAssistant" - "com.apple.RAIDUtility" - "com.apple.BootCampAssistant" - - # System services and daemons - "com.apple.SecurityAgent" - "com.apple.CoreServices*" - "com.apple.SystemUIServer" - "com.apple.backgroundtaskmanagement*" - "com.apple.loginitems*" - "com.apple.sharedfilelist*" - "com.apple.sfl*" - "com.apple.coreservices*" - "com.apple.metadata*" - "com.apple.MobileSoftwareUpdate*" - "com.apple.SoftwareUpdate*" - "com.apple.installer*" - "com.apple.frameworks*" - "com.apple.security*" - "com.apple.keychain*" - "com.apple.trustd*" - "com.apple.securityd*" - "com.apple.cloudd*" - "com.apple.iCloud*" - "com.apple.WiFi*" - "com.apple.airport*" - "com.apple.Bluetooth*" - - # Input methods (system built-in) - "com.apple.inputmethod.*" - "com.apple.inputsource*" - "com.apple.TextInput*" - "com.apple.CharacterPicker*" - "com.apple.PressAndHold*" - - # Legacy pattern-based entries (non com.apple.*) - "loginwindow" - "dock" - "systempreferences" - "finder" - "safari" - "backgroundtaskmanagementagent" - "keychain*" - "security*" - "bluetooth*" - "wifi*" - "network*" - "tcc" - "notification*" - "accessibility*" - "universalaccess*" - "HIToolbox*" - "textinput*" - "TextInput*" - "keyboard*" - "Keyboard*" - "inputsource*" - "InputSource*" - "keylayout*" - "KeyLayout*" - "GlobalPreferences" - ".GlobalPreferences" - "org.pqrs.Karabiner*" -) - -# Apple apps that CAN be uninstalled (from App Store or developer.apple.com) -readonly APPLE_UNINSTALLABLE_APPS=( - "com.apple.dt.*" # Xcode, Instruments, FileMerge - "com.apple.FinalCut*" # Final Cut Pro - "com.apple.Motion" - "com.apple.Compressor" - "com.apple.logic*" # Logic Pro - "com.apple.garageband*" # GarageBand - "com.apple.iMovie" - "com.apple.iWork.*" # Pages, Numbers, Keynote - "com.apple.MainStage*" - "com.apple.server.*" # macOS Server - "com.apple.Playgrounds" # Swift Playgrounds -) - -# Applications with sensitive data; protected during cleanup but removable -readonly DATA_PROTECTED_BUNDLES=( - # Input Methods (protected during cleanup, uninstall allowed) - "com.tencent.inputmethod.QQInput" - "com.sogou.inputmethod.*" - "com.baidu.inputmethod.*" - "com.googlecode.rimeime.*" - "im.rime.*" - "*.inputmethod" - "*.InputMethod" - "*IME" - - # System Utilities & Cleanup - "com.nektony.*" - "com.macpaw.*" - "com.freemacsoft.AppCleaner" - "com.omnigroup.omnidisksweeper" - "com.daisydiskapp.*" - "com.tunabellysoftware.*" - "com.grandperspectiv.*" - "com.binaryfruit.*" - - # Password Managers - "com.1password.*" - "com.agilebits.*" - "com.lastpass.*" - "com.dashlane.*" - "com.bitwarden.*" - "com.keepassx.*" - "org.keepassx.*" - "org.keepassxc.*" - "com.authy.*" - "com.yubico.*" - - # IDEs & Editors - "com.jetbrains.*" - "JetBrains*" - "com.microsoft.VSCode" - "com.visualstudio.code.*" - "com.sublimetext.*" - "com.sublimehq.*" - "com.microsoft.VSCodeInsiders" - "com.apple.dt.Xcode" - "com.coteditor.CotEditor" - "com.macromates.TextMate" - "com.panic.Nova" - "abnerworks.Typora" - "com.uranusjr.macdown" - - # AI & LLM Tools - "com.todesktop.*" - "Cursor" - "com.anthropic.claude*" - "Claude" - "com.openai.chat*" - "ChatGPT" - "com.ollama.ollama" - "Ollama" - "com.lmstudio.lmstudio" - "LM Studio" - "co.supertool.chatbox" - "page.jan.jan" - "com.huggingface.huggingchat" - "Gemini" - "com.perplexity.Perplexity" - "com.drawthings.DrawThings" - "com.divamgupta.diffusionbee" - "com.exafunction.windsurf" - "com.quora.poe.electron" - "chat.openai.com.*" - - # Database Clients - "com.sequelpro.*" - "com.sequel-ace.*" - "com.tinyapp.*" - "com.dbeaver.*" - "com.navicat.*" - "com.mongodb.compass" - "com.redis.RedisInsight" - "com.pgadmin.pgadmin4" - "com.eggerapps.Sequel-Pro" - "com.valentina-db.Valentina-Studio" - "com.dbvis.DbVisualizer" - - # API & Network Tools - "com.postmanlabs.mac" - "com.konghq.insomnia" - "com.CharlesProxy.*" - "com.proxyman.*" - "com.getpaw.*" - "com.luckymarmot.Paw" - "com.charlesproxy.charles" - "com.telerik.Fiddler" - "com.usebruno.app" - - # Network Proxy & VPN Tools (Clash variants - use specific patterns to avoid false positives) - "com.clash.*" - "ClashX*" - "clash-*" - "Clash-*" - "*-clash" - "*-Clash" - "clash.*" - "Clash.*" - "clash_*" - "clashverge*" - "ClashVerge*" - "com.nssurge.surge-mac" - "*surge*" - "*Surge*" - "mihomo*" - "*openvpn*" - "*OpenVPN*" - "net.openvpn.*" - - # Proxy Clients - "*ShadowsocksX-NG*" - "com.qiuyuzhou.*" - "*v2ray*" - "*V2Ray*" - "*v2box*" - "*V2Box*" - "*nekoray*" - "*sing-box*" - "*OneBox*" - "*hiddify*" - "*Hiddify*" - "*loon*" - "*Loon*" - "*quantumult*" - - # Mesh & Corporate VPNs - "*tailscale*" - "io.tailscale.*" - "*zerotier*" - "com.zerotier.*" - "*1dot1dot1dot1*" # Cloudflare WARP - "*cloudflare*warp*" - - # Commercial VPNs - "*nordvpn*" - "*expressvpn*" - "*protonvpn*" - "*surfshark*" - "*windscribe*" - "*mullvad*" - "*privateinternetaccess*" - - # Screensaver & Wallpaper - "*Aerial.saver*" - "com.JohnCoates.Aerial*" - "*Fliqlo*" - "*fliqlo*" - - # Git & Version Control - "com.github.GitHubDesktop" - "com.sublimemerge" - "com.torusknot.SourceTreeNotMAS" - "com.git-tower.Tower*" - "com.gitfox.GitFox" - "com.github.Gitify" - "com.fork.Fork" - "com.axosoft.gitkraken" - - # Terminal & Shell - "com.googlecode.iterm2" - "net.kovidgoyal.kitty" - "io.alacritty" - "com.github.wez.wezterm" - "com.hyper.Hyper" - "com.mizage.divvy" - "com.fig.Fig" - "dev.warp.Warp-Stable" - "com.termius-dmg" - - # Docker & Virtualization - "com.docker.docker" - "com.getutm.UTM" - "com.vmware.fusion" - "com.parallels.desktop.*" - "org.virtualbox.app.VirtualBox" - "com.vagrant.*" - "com.orbstack.OrbStack" - - # System Monitoring - "com.bjango.istatmenus*" - "eu.exelban.Stats" - "com.monitorcontrol.*" - "com.bresink.system-toolkit.*" - "com.mediaatelier.MenuMeters" - "com.activity-indicator.app" - "net.cindori.sensei" - - # Window Management - "com.macitbetter.*" # BetterTouchTool, BetterSnapTool - "com.hegenberg.*" - "com.manytricks.*" # Moom, Witch, etc. - "com.divisiblebyzero.*" - "com.koingdev.*" - "com.if.Amphetamine" - "com.lwouis.alt-tab-macos" - "net.matthewpalmer.Vanilla" - "com.lightheadsw.Caffeine" - "com.contextual.Contexts" - "com.amethyst.Amethyst" - "com.knollsoft.Rectangle" - "com.knollsoft.Hookshot" - "com.surteesstudios.Bartender" - "com.gaosun.eul" - "com.pointum.hazeover" - - # Launcher & Automation - "com.runningwithcrayons.Alfred" - "com.raycast.macos" - "com.blacktree.Quicksilver" - "com.stairways.keyboardmaestro.*" - "com.manytricks.Butler" - "com.happenapps.Quitter" - "com.pilotmoon.scroll-reverser" - "org.pqrs.Karabiner-Elements" - "com.apple.Automator" - - # Note-Taking - "com.bear-writer.*" - "com.typora.*" - "com.ulyssesapp.*" - "com.literatureandlatte.*" - "com.dayoneapp.*" - "notion.id" - "md.obsidian" - "com.logseq.logseq" - "com.evernote.Evernote" - "com.onenote.mac" - "com.omnigroup.OmniOutliner*" - "net.shinyfrog.bear" - "com.goodnotes.GoodNotes" - "com.marginnote.MarginNote*" - "com.roamresearch.*" - "com.reflect.ReflectApp" - "com.inkdrop.*" - - # Design & Creative - "com.adobe.*" - "com.bohemiancoding.*" - "com.figma.*" - "com.framerx.*" - "com.zeplin.*" - "com.invisionapp.*" - "com.principle.*" - "com.pixelmatorteam.*" - "com.affinitydesigner.*" - "com.affinityphoto.*" - "com.affinitypublisher.*" - "com.linearity.curve" - "com.canva.CanvaDesktop" - "com.maxon.cinema4d" - "com.autodesk.*" - "com.sketchup.*" - - # Communication - "com.tencent.xinWeChat" - "com.tencent.qq" - "com.alibaba.DingTalkMac" - "com.alibaba.AliLang.osx" - "com.alibaba.alilang3.osx.ShipIt" - "com.alibaba.AlilangMgr.QueryNetworkInfo" - "us.zoom.xos" - "com.microsoft.teams*" - "com.slack.Slack" - "com.hnc.Discord" - "app.legcord.Legcord" - "org.telegram.desktop" - "ru.keepcoder.Telegram" - "net.whatsapp.WhatsApp" - "com.skype.skype" - "com.cisco.webexmeetings" - "com.ringcentral.RingCentral" - "com.readdle.smartemail-Mac" - "com.airmail.*" - "com.postbox-inc.postbox" - "com.tinyspeck.slackmacgap" - - # Task Management - "com.omnigroup.OmniFocus*" - "com.culturedcode.*" - "com.todoist.*" - "com.any.do.*" - "com.ticktick.*" - "com.microsoft.to-do" - "com.trello.trello" - "com.asana.nativeapp" - "com.clickup.*" - "com.monday.desktop" - "com.airtable.airtable" - "com.notion.id" - "com.linear.linear" - - # File Transfer & Sync - "com.panic.transmit*" - "com.binarynights.ForkLift*" - "com.noodlesoft.Hazel" - "com.cyberduck.Cyberduck" - "io.filezilla.FileZilla" - "com.apple.Xcode.CloudDocuments" - "com.synology.*" - - # Cloud Storage & Backup - "com.dropbox.*" - "com.getdropbox.*" - "*dropbox*" - "ws.agile.*" - "com.backblaze.*" - "*backblaze*" - "com.box.desktop*" - "*box.desktop*" - "com.microsoft.OneDrive*" - "com.microsoft.SyncReporter" - "*OneDrive*" - "com.google.GoogleDrive" - "com.google.keystone*" - "*GoogleDrive*" - "com.amazon.drive" - "com.apple.bird" - "com.apple.CloudDocs*" - "com.displaylink.*" - "com.fujitsu.pfu.ScanSnap*" - "com.citrix.*" - "org.xquartz.*" - "us.zoom.updater*" - "com.DigiDNA.iMazing*" - "com.shirtpocket.*" - "homebrew.mxcl.*" - - # Screenshot & Recording - "com.cleanshot.*" - "com.xnipapp.xnip" - "com.reincubate.camo" - "com.tunabellysoftware.ScreenFloat" - "net.telestream.screenflow*" - "com.techsmith.snagit*" - "com.techsmith.camtasia*" - "com.obsidianapp.screenrecorder" - "com.kap.Kap" - "com.getkap.*" - "com.linebreak.CloudApp" - "com.droplr.droplr-mac" - - # Media & Entertainment - "com.spotify.client" - "com.apple.Music" - "com.apple.podcasts" - "com.apple.BKAgentService" - "com.apple.iBooksX" - "com.apple.iBooks" - "com.blackmagic-design.*" - "com.colliderli.iina" - "org.videolan.vlc" - "io.mpv" - "tv.plex.player.desktop" - "com.netease.163music" - - # Web Browsers - "Firefox" - "org.mozilla.*" - - # License & App Stores - "com.paddle.Paddle*" - "com.setapp.DesktopClient" - "com.devmate.*" - "org.sparkle-project.Sparkle" -) +# Bundle ID / pattern data is sourced from a sibling file so this file +# stays focused on logic. See app_protection_data.sh for the lists. +# shellcheck source=lib/core/app_protection_data.sh +source "$_MOLE_CORE_DIR/app_protection_data.sh" + +# Return 0 when Xcode/build tooling is active, 1 after reliable no-match +# results, and 2 when process ownership cannot be established. +xcode_build_tooling_process_state() { + command -v pgrep > /dev/null 2>&1 || return 2 + + local process probe_status + for process in Xcode xcodebuild xctest XCTRunner XCBBuildService swift-frontend; do + if pgrep -x "$process" > /dev/null 2>&1; then + return 0 + else + probe_status=$? + [[ $probe_status -eq 1 ]] || return 2 + fi + done + return 1 +} # Centralized check for critical system components (case-insensitive) is_critical_system_component() { @@ -575,10 +65,6 @@ is_critical_system_component() { esac } -# Legacy function - preserved for backward compatibility -# Use should_protect_from_uninstall() or should_protect_data() instead -readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") - # Check if bundle ID matches pattern (glob support) bundle_matches_pattern() { local bundle_id="$1" @@ -615,14 +101,13 @@ build_regex_var() { regex="$regex|$p" fi done + # eval: indirect write by name; bash 3.2 has no nameref eval "$var_name=\"\$regex\"" } # Lazy-loaded regex (only built when needed) APPLE_UNINSTALLABLE_REGEX="" SYSTEM_CRITICAL_REGEX="" -SYSTEM_CRITICAL_FAST_REGEX="" -DATA_PROTECTED_REGEX="" _ensure_uninstall_regex() { if [[ -z "$SYSTEM_CRITICAL_REGEX" ]]; then @@ -631,13 +116,6 @@ _ensure_uninstall_regex() { fi } -_ensure_data_protection_regex() { - if [[ -z "$SYSTEM_CRITICAL_FAST_REGEX" ]]; then - build_regex_var SYSTEM_CRITICAL_FAST_REGEX "${SYSTEM_CRITICAL_BUNDLES_FAST[@]}" - build_regex_var DATA_PROTECTED_REGEX "${DATA_PROTECTED_BUNDLES[@]}" - fi -} - # Check if application is a protected system component should_protect_from_uninstall() { local bundle_id="$1" @@ -655,6 +133,42 @@ should_protect_from_uninstall() { return 1 } +# Print the vendor name when an app must be removed through its official +# uninstaller instead of Mole's generic Trash/delete path. +official_uninstaller_vendor() { + local bundle_id="${1:-}" + local display_name="${2:-}" + local app_path="${3:-}" + local normalized_bundle normalized_name normalized_path + normalized_bundle=$(printf '%s' "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]') + normalized_name=$(printf '%s' "$display_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + normalized_path=$(basename "${app_path:-}" .app | LC_ALL=C tr '[:upper:]' '[:lower:]') + + local rule vendor prefixes fragments prefix fragment + local -a _prefixes _fragments + for rule in "${OFFICIAL_UNINSTALLER_RULES[@]}"; do + IFS='|' read -r vendor prefixes fragments <<< "$rule" + IFS=',' read -r -a _prefixes <<< "$prefixes" + for prefix in "${_prefixes[@]}"; do + [[ -n "$prefix" && "$normalized_bundle" == "$prefix"* ]] && { + printf '%s\n' "$vendor" + return 0 + } + done + + IFS=',' read -r -a _fragments <<< "$fragments" + for fragment in "${_fragments[@]}"; do + if [[ -n "$fragment" ]] && + { [[ "$normalized_name" == *"$fragment"* ]] || [[ "$normalized_path" == *"$fragment"* ]]; }; then + printf '%s\n' "$vendor" + return 0 + fi + done + done + + return 1 +} + # Check if application data should be protected during cleanup should_protect_data() { local bundle_id="$1" @@ -663,6 +177,12 @@ should_protect_data() { com.apple.* | loginwindow | dock | systempreferences | finder | safari) return 0 ;; + # CUPS is an OS-provided subsystem with no user-facing app; without this + # guard `~/Library/Preferences/org.cups.PrintingPrefs.plist` (which holds + # the default printer and recent printers) looks orphaned. See #731. + org.cups.*) + return 0 + ;; backgroundtaskmanagement* | keychain* | security* | bluetooth* | wifi* | network* | tcc) return 0 ;; @@ -684,7 +204,7 @@ should_protect_data() { com.jetbrains.* | JetBrains* | com.microsoft.* | com.visualstudio.*) return 0 ;; - com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama) + com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | com.openai.codex | Codex | codex-runtimes | Ollama) return 0 ;; # Specific match to avoid ShellCheck redundancy warning with com.clash.* @@ -694,7 +214,7 @@ should_protect_data() { com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) return 0 ;; - clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*) + clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | *clash-verge* | *Clash-Verge* | clashverge* | ClashVerge*) return 0 ;; com.docker.* | com.getpostman.* | com.insomnia.*) @@ -721,6 +241,88 @@ should_protect_data() { return 1 } +# Endpoint security / EDR / MDM agents (CrowdStrike Falcon, SentinelOne, ESET, +# Jamf, GlobalProtect, Cisco Secure Client) tamper-protect their on-disk state. +# Deleting anything that belongs to them under the per-user Darwin folder -- a +# rebuildable Metal/GPU shader cache (.../C/...), a code-signature clone +# (.../X/.code_sign_clone), or temp (.../T/...) -- trips sensor tamper +# detection (e.g. CrowdStrike "MacFalconSensorTamper", MITRE T1562.001) that +# corporate security reports as malware. Reclaim is only a few MB, so never touch +# these. The vendor prefixes live in ENDPOINT_SECURITY_BUNDLE_PREFIXES +# (app_protection_data.sh); matching a vendor id anywhere under var/folders is +# protection-only, so a wide match is safe. Shared by should_protect_path() and +# the cache/clone sweeps in lib/clean/system.sh and lib/clean/user.sh. +is_endpoint_security_cache_path() { + local path="$1" + # Fast reject: only the per-user Darwin folders are in scope (the real + # /private/var/folders and its /var/folders symlink form). Anchored to the + # absolute root so an unrelated ".../var/folders/..." path cannot match. + case "$path" in + /private/var/folders/* | /var/folders/*) ;; + *) + if ! declare -f _mole_path_is_within_existing_root > /dev/null 2>&1 || + ! _mole_path_is_within_existing_root "$path" "/private/var/folders"; then + return 1 + fi + ;; + esac + local restore_nocasematch=false + if ! shopt -q nocasematch; then + shopt -s nocasematch + restore_nocasematch=true + fi + local prefix + for prefix in "${ENDPOINT_SECURITY_BUNDLE_PREFIXES[@]}"; do + if [[ "$path" == *"$prefix"* ]]; then + [[ "$restore_nocasematch" == "true" ]] && shopt -u nocasematch + return 0 + fi + done + [[ "$restore_nocasematch" == "true" ]] && shopt -u nocasematch + return 1 +} + +is_orbstack_runtime_path() { + local path="$1" + local restore_nocasematch=false + if ! shopt -q nocasematch; then + shopt -s nocasematch + restore_nocasematch=true + fi + + local matched=false + case "$path" in + */Library/Group\ Containers/*dev.orbstack | */Library/Group\ Containers/*dev.orbstack/* | */.orbstack | */.orbstack/*) + matched=true + ;; + esac + + [[ "$restore_nocasematch" == "true" ]] && shopt -u nocasematch + [[ "$matched" == "true" ]] +} + +# E5RT (Apple's Espresso runtime, behind Vision/TextRecognition) keeps compiled +# model bundles in a com.apple.e5rt.e5bundlecache directory, usually one level +# below the owning app's or daemon's cache directory. A process that already +# resolved that cache does not rebuild it: deleting it under a running app makes +# every later recognition call fail with E5RT Code 13 until the app restarts. +# Reclaims little, breaks visibly, so treat the directory and its immediate +# parent as off limits. Shared by safe_clean() and process_container_cache(). +# +# Args: $1 - path to check +# Returns: 0 if the path is or directly holds a compiled model cache +holds_compiled_model_cache() { + local path="$1" + [[ -z "$path" ]] && return 1 + if [[ "${path%/}" == *"/com.apple.e5rt.e5bundlecache" ]]; then + return 0 + fi + if [[ -d "$path/com.apple.e5rt.e5bundlecache" ]]; then + return 0 + fi + return 1 +} + # Check if a path is protected from deletion # Centralized logic to protect system settings, control center, and critical apps # @@ -733,25 +335,39 @@ should_protect_path() { local path="$1" [[ -z "$path" ]] && return 1 - local path_lower - path_lower=$(echo "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]') + local _container_cache_path=false + local _known_rebuildable_cache_path=false - # 1. Keyword-based matching for system components - # Protect System Settings, Preferences, Control Center, and related XPC services - # Also protect "Settings" (used in macOS Sequoia) and savedState files - if [[ "$path_lower" =~ systemsettings || "$path_lower" =~ systempreferences || "$path_lower" =~ controlcenter ]]; then - return 0 - fi + # Codex Desktop keeps durable state under Application Support, but these + # exact Chromium cache leaves under Library/Caches are rebuildable. Only + # their children are eligible; the profile and leaf directories stay. + case "$path" in + "$HOME/Library/Caches/Codex/Default/Cache/"* | \ + "$HOME/Library/Caches/Codex/Default/Code Cache/"* | \ + "$HOME/Library/Caches/Codex/Default/Partitions/codex-browser-app/Cache/"* | \ + "$HOME/Library/Caches/Codex/Default/Partitions/codex-browser-app/Code Cache/"* | \ + "$HOME/Library/Caches/Codex/codex-browser-app/Cache/"* | \ + "$HOME/Library/Caches/Codex/codex-browser-app/Code Cache/"*) + _known_rebuildable_cache_path=true + ;; + esac - # Additional check for com.apple.Settings (macOS Sequoia System Settings) - if [[ "$path_lower" =~ com\.apple\.settings ]]; then + if is_orbstack_runtime_path "$path"; then return 0 fi - # Protect Notes cache (search index issues) - if [[ "$path_lower" =~ com\.apple\.notes ]]; then - return 0 - fi + # 1. Keyword-based matching for system components (case-insensitive via character classes) + case "$path" in + *[Ss]ystem[Ss]ettings* | *[Ss]ystem[Pp]references* | *[Cc]ontrol[Cc]enter*) + return 0 + ;; + *com.apple.[Ss]ettings* | *com.apple.[Ss]ETTINGS*) + return 0 + ;; + *com.apple.[Nn]otes* | *com.apple.[Nn]OTES*) + return 0 + ;; + esac # 2. Protect caches critical for system UI rendering # These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering @@ -771,6 +387,10 @@ should_protect_path() { */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) return 0 ;; + # OrbStack group containers hold live container filesystem images. + */Library/Group\ Containers/*dev.orbstack | */Library/Group\ Containers/*dev.orbstack/* | */.orbstack | */.orbstack/*) + return 0 + ;; # Shared file lists for System Settings (macOS Sequoia) - Issue #136 */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) return 0 @@ -782,8 +402,12 @@ should_protect_path() { # Matches: .../Library/Group Containers/group.id/... if [[ "$path" =~ /Library/Containers/([^/]+) ]] || [[ "$path" =~ /Library/Group\ Containers/([^/]+) ]]; then local bundle_id="${BASH_REMATCH[1]}" - # In uninstall mode, only system components are protected; skip data protection - if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]] && should_protect_data "$bundle_id"; then + # Cache and tmp directories inside containers are regenerable by definition. + # safe_clean calls explicitly target these; let them through instead of + # blocking on the blanket com.apple.* match in should_protect_data. + if [[ "$path" == */Data/Library/Caches/* || "$path" == */Data/tmp/* ]]; then + _container_cache_path=true + elif [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]] && should_protect_data "$bundle_id"; then return 0 fi fi @@ -795,53 +419,140 @@ should_protect_path() { ;; esac + # 4b. Endpoint security / EDR agent caches (CrowdStrike Falcon, SentinelOne, + # etc.). Dedicated predicate so the same vendor list is reused by the + # GPU-cache sweep in lib/clean/system.sh and matches deterministically. + if is_endpoint_security_cache_path "$path"; then + return 0 + fi + # 5. Protect critical preference files and user data case "$path" in */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) return 0 ;; + # Protect Mole's own runtime logs so cleanup cannot delete its active log targets. + */Library/Logs/mole | */Library/Logs/mole/ | */Library/Logs/mole/*) + return 0 + ;; + # Codex Desktop and CLI keep conversation indexes and app state in cache- + # shaped paths. Default cleanup must not remove those records. + */Library/Application\ Support/Codex | */Library/Application\ Support/Codex/* | \ + */Library/Logs/com.openai.codex | */Library/Logs/com.openai.codex/* | \ + */.codex/sessions | */.codex/sessions/* | \ + */.codex/auth.json | */.codex/history.jsonl | \ + */.codex/state_*.sqlite | */.codex/logs_*.sqlite | \ + */.codex/session_index.jsonl | */.codex/cache/session_index.jsonl | \ + */.codex/cache/codex_app_directory | */.codex/cache/codex_app_directory/*) + return 0 + ;; # Bluetooth and WiFi configurations */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) return 0 ;; + # NetworkExtension stores VPN tunnel state and provider preferences. + */Library/Preferences/com.apple.networkextension*.plist) + return 0 + ;; # iCloud Drive - protect user's cloud synced data */Library/Mobile\ Documents* | */Mobile\ Documents*) return 0 ;; + # High-risk cleanup denylist: these cache/preferences paths are known + # to contain license, account, plugin, MDM, or system-service state + # despite cache-like names. Keep this as a protection overlay only; it + # is not a cleanup allowlist. + */Library/Accounts | */Library/Accounts/* | \ + */Library/Keychains | */Library/Keychains/* | \ + */Library/Mail | */Library/Mail/* | \ + */Library/Calendars | \ + */Library/Contacts | */Library/Contacts/*) + return 0 + ;; + /Library/Audio/Plug-Ins/Components | /Library/Audio/Plug-Ins/Components/* | \ + /Library/Audio/Plug-Ins/VST | /Library/Audio/Plug-Ins/VST/* | \ + /Library/Audio/Plug-Ins/VST3 | /Library/Audio/Plug-Ins/VST3/* | \ + /Library/Application\ Support/iZotope | /Library/Application\ Support/iZotope/* | \ + */Library/Application\ Support/iZotope | */Library/Application\ Support/iZotope/* | \ + /Library/Application\ Support/LaserSoft\ Imaging | /Library/Application\ Support/LaserSoft\ Imaging/*) + return 0 + ;; + */Library/Preferences/com.native-instruments* | \ + */Library/Preferences/com.avid.mediacomposer*.plist | \ + */Library/Preferences/com.fabfilter.*.[0-9].plist | \ + */Library/Preferences/com.fabfilter.*.[0-9][0-9].plist | \ + */Library/Preferences/com.paceap.*.plist) + return 0 + ;; + /private/var/folders/*/C/com.native-instruments* | \ + /private/var/folders/*/C/com.avid.mediacomposer* | \ + /private/var/folders/*/C/com.paceap.eden.iLokLicenseManager*) + return 0 + ;; + */Library/Caches/ms-playwright | */Library/Caches/ms-playwright/* | \ + */Library/Caches/app.cotypist.Cotypist | */Library/Caches/app.cotypist.Cotypist/* | \ + */Library/Caches/com.displaylink.DisplayLinkUserAgent | */Library/Caches/com.displaylink.DisplayLinkUserAgent/* | \ + */Library/Caches/com.lasersoft-imaging.SilverFast9 | */Library/Caches/com.lasersoft-imaging.SilverFast9/* | \ + */Library/Caches/com.lasersoft-imaging.SilverFast-9-Installer | */Library/Caches/com.lasersoft-imaging.SilverFast-9-Installer/* | \ + */Library/Caches/Adobe\ * | \ + */Library/Caches/*\ Adobe* | \ + */Library/Caches/com.apple.containermanagerd | */Library/Caches/com.apple.containermanagerd/* | \ + */Library/Caches/com.apple.homed | */Library/Caches/com.apple.homed/* | \ + */Library/Caches/com.apple.ap.adprivacyd | */Library/Caches/com.apple.ap.adprivacyd/* | \ + */Library/Caches/FamilyCircle | */Library/Caches/FamilyCircle/* | \ + */Library/Caches/com.apple.HomeKit | */Library/Caches/com.apple.HomeKit/* | \ + */Library/Caches/com.apple.WorkflowKit.BackgroundShortcutRunner.ShortcutsSandboxCache | */Library/Caches/com.apple.WorkflowKit.BackgroundShortcutRunner.ShortcutsSandboxCache/* | \ + */Library/Caches/com.apple.siriactionsd.ShortcutsSandboxCache | */Library/Caches/com.apple.siriactionsd.ShortcutsSandboxCache/*) + return 0 + ;; + # Wallpaper and aerial screen saver assets are user-selected content. + # Their download-time mtime does not indicate whether they are active, + # and deleting them forces a large re-download and selection reset. + */Library/Application\ Support/com.apple.idleassetsd | */Library/Application\ Support/com.apple.idleassetsd/* | \ + */Library/Application\ Support/com.apple.wallpaper | */Library/Application\ Support/com.apple.wallpaper/*) + return 0 + ;; + # CoreAudio and audio subsystem caches (issue #553) + # Cleaning these can cause audio output loss on Intel Macs + *com.apple.coreaudio* | *com.apple.audio.* | *coreaudiod*) + return 0 + ;; esac # 6. Match full path against protected patterns # This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude* - # In uninstall mode, only check system-critical bundles (user explicitly chose to uninstall) - if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then - # Uninstall mode: first check if it's an uninstallable Apple app - for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then - return 1 # Can be uninstalled - fi - done - # Then check system-critical components - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then - return 0 - fi - done - else - # Normal mode (cleanup): protect both system-critical and data-protected bundles - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then + # Skip for container cache/tmp paths: bundle ID was already checked in step 3, + # and critical containers are caught by steps 1/4/5. + if [[ "$_container_cache_path" != "true" && "$_known_rebuildable_cache_path" != "true" ]]; then + if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then + # Uninstall mode: first check if it's an uninstallable Apple app + for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 1 # Can be uninstalled + fi + done + # Then check system-critical components + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + else + # Normal mode (cleanup): protect both system-critical and data-protected bundles + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + fi + + # 7. Check if the filename itself matches any protected patterns + # Skip in uninstall mode - user explicitly chose to remove this app + if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then + local filename="${path##*/}" + if should_protect_data "$filename"; then return 0 fi - done - fi - - # 7. Check if the filename itself matches any protected patterns - # Skip in uninstall mode - user explicitly chose to remove this app - if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then - local filename - filename=$(basename "$path") - if should_protect_data "$filename"; then - return 0 fi fi @@ -855,8 +566,20 @@ is_path_whitelisted() { local target_path="$1" [[ -z "$target_path" ]] && return 1 - # Normalize path (remove trailing slash) + # Normalize path (remove trailing slash, collapse consecutive slashes). + # Callers sometimes concat a glob expansion that already ends in `/` + # with a sub-path that begins with `/`, producing `.../Default//Service + # Worker/...`. Without collapsing, those never match a whitelist entry + # written with single separators. See #724. + # + # Note: on bash 3.2 (macOS default), `${var//\/\//\/}` leaves a literal + # backslash in the replacement. Indirect variables sidestep that. + local _slash_single="/" + local _slash_double="//" local normalized_target="${target_path%/}" + while [[ "$normalized_target" == *"$_slash_double"* ]]; do + normalized_target="${normalized_target//$_slash_double/$_slash_single}" + done # Empty whitelist means nothing is protected [[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]] && return 1 @@ -864,6 +587,9 @@ is_path_whitelisted() { for pattern in "${WHITELIST_PATTERNS[@]}"; do # Pattern is already expanded/normalized in bin/clean.sh local check_pattern="${pattern%/}" + while [[ "$check_pattern" == *"$_slash_double"* ]]; do + check_pattern="${check_pattern//$_slash_double/$_slash_single}" + done local has_glob="false" case "$check_pattern" in *\** | *\?* | *\[*) @@ -894,10 +620,300 @@ is_path_whitelisted() { return 1 } +_mole_uninstall_lower() { + printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' +} + +_mole_uninstall_is_common_app_name() { + local lower_name + lower_name=$(_mole_uninstall_lower "${1:-}") + case "$lower_name" in + music | notes | photos | finder | safari | preview | calendar | contacts | messages | \ + reminders | clock | weather | stocks | books | news | podcasts | voice | files | \ + store | system | helper | agent | daemon | service | update | sync | backup | \ + cloud | manager | monitor | server | client | worker | runner | launcher | \ + driver | plugin | extension | widget | utility) + return 0 + ;; + esac + return 1 +} + +_mole_uninstall_vendor_product_tokens() { + local bundle_id="${1:-}" + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + + local product_token="${bundle_id##*.}" + local without_product="${bundle_id%.*}" + local vendor_token="${without_product##*.}" + + [[ "$vendor_token" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$ ]] || return 1 + [[ "$product_token" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$ ]] || return 1 + + printf '%s|%s\n' "$vendor_token" "$product_token" +} + +_mole_uninstall_name_variant_matches() { + local candidate_lower="$1" + shift + + local variant + for variant in "$@"; do + [[ -n "$variant" ]] || continue + if [[ "$candidate_lower" == "$variant" || + "$candidate_lower" == "$variant "* || + "$candidate_lower" == "$variant-"* || + "$candidate_lower" == "${variant}_"* || + "$candidate_lower" == "$variant."* ]]; then + return 0 + fi + done + + return 1 +} + +# Materialize a completed, bounded find before any uninstall plan consumes it. +# Process substitution hides the producer status and can expose a partial +# prefix after timeout. A failed ordinary find therefore contributes no +# candidates for that root, while timeout/signal cancellation still aborts the +# whole plan. This keeps an unreadable best-effort leftovers root from blocking +# removal of the exact app the user selected without ever consuming partial +# discovery output. +_mole_uninstall_materialize_find0() { + local output_file="$1" + shift + + : > "$output_file" || return 1 + local scan_timeout="$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" + if [[ -n "${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-}" ]]; then + scan_timeout=$(_mole_timeout_with_deadline "$scan_timeout" \ + "$_MOLE_UNINSTALL_DISCOVERY_DEADLINE") || return $? + fi + local scan_rc=0 + run_with_timeout "$scan_timeout" find \ + "$@" < /dev/null > "$output_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + : > "$output_file" || true + if [[ $scan_rc -eq 124 || $scan_rc -ge 128 ]]; then + return "$scan_rc" + fi + debug_log "Skipping incomplete uninstall discovery root: ${1:-unknown}" + return 0 + fi + return 0 +} + +find_vendor_nested_app_paths() { + local bundle_id="$1" + local app_name="$2" + shift 2 + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" + + [[ -n "$app_name" && ${#app_name} -ge 4 ]] || return 0 + _mole_uninstall_is_common_app_name "$app_name" && return 0 + + local token_pair + token_pair=$(_mole_uninstall_vendor_product_tokens "$bundle_id" 2> /dev/null) || return 0 + local vendor_token product_token + IFS='|' read -r vendor_token product_token <<< "$token_pair" + + local vendor_lower product_lower app_lower nospace_lower hyphen_lower underscore_lower + vendor_lower=$(_mole_uninstall_lower "$vendor_token") + product_lower=$(_mole_uninstall_lower "$product_token") + app_lower=$(_mole_uninstall_lower "$app_name") + nospace_lower=$(_mole_uninstall_lower "${app_name// /}") + hyphen_lower=$(_mole_uninstall_lower "${app_name// /-}") + underscore_lower=$(_mole_uninstall_lower "${app_name// /_}") + + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local -a matched_paths=() + local root candidate parent_dir parent_base parent_lower child_base child_lower + for root in "$@"; do + [[ -d "$root" ]] || continue + local scan_rc=0 + _mole_uninstall_materialize_find0 "$scan_file" "$root" \ + -mindepth 2 -maxdepth 2 -type d -print0 || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + while IFS= read -r -d '' candidate; do + parent_dir="${candidate%/*}" + parent_base="${parent_dir##*/}" + parent_lower=$(_mole_uninstall_lower "$parent_base") + [[ "$parent_lower" == "$vendor_lower" ]] || continue + + child_base="${candidate##*/}" + child_lower=$(_mole_uninstall_lower "$child_base") + if _mole_uninstall_name_variant_matches "$child_lower" \ + "$app_lower" "$nospace_lower" "$hyphen_lower" "$underscore_lower" "$product_lower"; then + matched_paths+=("$candidate") + fi + done < "$scan_file" + done + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ ${#matched_paths[@]} -gt 0 ]]; then + printf '%s\n' "${matched_paths[@]}" | sort -u + fi +} + +find_shared_app_paths() { + local bundle_id="$1" + local app_name="$2" + shift 2 + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" + + [[ -n "$app_name" && ${#app_name} -ge 5 ]] || return 0 + _mole_uninstall_is_common_app_name "$app_name" && return 0 + + local product_token="" + local token_pair + if token_pair=$(_mole_uninstall_vendor_product_tokens "$bundle_id" 2> /dev/null); then + IFS='|' read -r _ product_token <<< "$token_pair" + fi + + local app_lower nospace_lower hyphen_lower underscore_lower product_lower + app_lower=$(_mole_uninstall_lower "$app_name") + nospace_lower=$(_mole_uninstall_lower "${app_name// /}") + hyphen_lower=$(_mole_uninstall_lower "${app_name// /-}") + underscore_lower=$(_mole_uninstall_lower "${app_name// /_}") + product_lower=$(_mole_uninstall_lower "$product_token") + + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local -a matched_paths=() + local root candidate base lower_base + for root in "$@"; do + [[ -d "$root" ]] || continue + local scan_rc=0 + _mole_uninstall_materialize_find0 "$scan_file" "$root" \ + -mindepth 1 -maxdepth 1 -print0 || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + while IFS= read -r -d '' candidate; do + base="${candidate##*/}" + lower_base=$(_mole_uninstall_lower "$base") + if _mole_uninstall_name_variant_matches "$lower_base" \ + "$app_lower" "$nospace_lower" "$hyphen_lower" "$underscore_lower" "$product_lower"; then + matched_paths+=("$candidate") + fi + done < "$scan_file" + done + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ ${#matched_paths[@]} -gt 0 ]]; then + printf '%s\n' "${matched_paths[@]}" | sort -u + fi +} + +# Return 0 when `path` looks like a dotdir / XDG state directory belonging to +# a standalone CLI tool shipped independently of any same-named GUI app. +# find_app_files uses this to skip candidates that would otherwise nuke +# unrelated CLI state when uninstalling a same-named GUI app (#993). +# +# Lowercase comparison so case-insensitive APFS (~/.Claude vs ~/.claude) is +# handled. Scope is restricted to four well-known parents so we never skip +# legitimate non-dotdir locations. +# +# The deny-list is inlined rather than read from an array because bats 1.x +# does not carry readonly arrays from setup() into the @test body, and a +# regression in any of these names is destructive enough that we never want +# the safeguard to silently no-op in a fresh subshell. +_path_belongs_to_independent_cli() { + local path="$1" + [[ -z "$path" ]] && return 1 + + local base parent lc_name + base="${path##*/}" + parent="${path%/*}" + lc_name=$(printf '%s' "${base#.}" | tr '[:upper:]' '[:lower:]') + [[ -z "$lc_name" ]] && return 1 + + case "$lc_name" in + # Standalone CLI tools shipped independently of a same-named GUI app: + # never delete their dotdirs when uninstalling the GUI namesake. + # Issue #993: uninstalling Claude.app wiped ~/.claude (Claude Code CLI), + # OpenCode.app wiped ~/.local/share/opencode. Case-insensitive APFS makes + # the collision worse. Add new GUI/CLI namesakes here. + claude | opencode | codex | gemini) ;; + *) return 1 ;; + esac + + case "$parent" in + "$HOME" | "$HOME/.config" | "$HOME/.local/share" | "$HOME/.cache") + return 0 + ;; + esac + return 1 +} + +_mole_uninstall_embedded_bundle_ids() { + local app_path="${1:-}" + local primary_bundle_id="${2:-}" + local max_info_plists=128 + local scanned=0 + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" + + [[ -n "$app_path" && -d "$app_path/Contents" ]] || return 0 + [[ "$app_path" == /* && "$app_path" != *$'\n'* && "$app_path" != *"/.."* ]] || return 0 + + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + _mole_uninstall_materialize_find0 "$scan_file" "$app_path/Contents" \ + -maxdepth 12 -type f -path "*/Contents/Info.plist" -print0 || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + + local -a embedded_ids=() + local info bundle_root bundle_name ext embedded_id + while IFS= read -r -d '' info; do + scanned=$((scanned + 1)) + [[ $scanned -le $max_info_plists ]] || break + + bundle_root="${info%/Contents/Info.plist}" + [[ "$bundle_root" != "$app_path" ]] || continue + bundle_name="${bundle_root##*/}" + ext=$(printf '%s' "${bundle_name##*.}" | tr '[:upper:]' '[:lower:]') + + case "$ext" in + xpc | appex) ;; + app) + case "$bundle_root" in + "$app_path/Contents/Library/LoginItems/"*) ;; + *) continue ;; + esac + ;; + *) continue ;; + esac + + embedded_id=$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || true) + mole_is_reverse_dns_bundle_id "$embedded_id" || continue + [[ "$embedded_id" == "$primary_bundle_id" ]] && continue + # Shared framework services are not owned by every app that embeds them. + case "$embedded_id" in + org.sparkle-project.*) + continue + ;; + esac + embedded_ids+=("$embedded_id") + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ ${#embedded_ids[@]} -gt 0 ]]; then + printf '%s\n' "${embedded_ids[@]}" | sort -u + fi +} + # Locate files associated with an application find_app_files() { local bundle_id="$1" local app_name="$2" + local app_path="${3:-}" + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" # Early validation: require at least one valid identifier # Skip scanning if both bundle_id and app_name are invalid @@ -907,6 +923,9 @@ find_app_files() { fi local -a files_to_clean=() + local discovery_scan_file="" + discovery_scan_file=$(create_temp_file) || return 1 + local discovery_scan_rc=0 # Normalize app name for matching - generate all common naming variants # Apps use inconsistent naming: "Maestro Studio" vs "maestro-studio" vs "MaestroStudio" @@ -929,45 +948,114 @@ find_app_files() { fi local base_lowercase=$(echo "$base_name" | tr '[:upper:]' '[:lower:]') # "Zed" -> "zed" - # Standard path patterns for user-level files - local -a user_patterns=( - "$HOME/Library/Application Support/$app_name" - "$HOME/Library/Application Support/$bundle_id" - "$HOME/Library/Caches/$bundle_id" - "$HOME/Library/Caches/$app_name" - "$HOME/Library/Logs/$app_name" - "$HOME/Library/Logs/$bundle_id" - "$HOME/Library/Application Support/CrashReporter/$app_name" - "$HOME/Library/Saved Application State/$bundle_id.savedState" - "$HOME/Library/Containers/$bundle_id" - "$HOME/Library/WebKit/$bundle_id" - "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" - "$HOME/Library/HTTPStorages/$bundle_id" - "$HOME/Library/Cookies/$bundle_id.binarycookies" - "$HOME/Library/LaunchAgents/$bundle_id.plist" - "$HOME/Library/Application Scripts/$bundle_id" - "$HOME/Library/Services/$app_name.workflow" - "$HOME/Library/QuickLook/$app_name.qlgenerator" - "$HOME/Library/Internet Plug-Ins/$app_name.plugin" - "$HOME/Library/Audio/Plug-Ins/Components/$app_name.component" - "$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst" - "$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3" - "$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" - "$HOME/Library/PreferencePanes/$app_name.prefPane" - "$HOME/Library/Input Methods/$app_name.app" - "$HOME/Library/Input Methods/$bundle_id.app" - "$HOME/Library/Screen Savers/$app_name.saver" - "$HOME/Library/Frameworks/$app_name.framework" - "$HOME/Library/Autosave Information/$bundle_id" - "$HOME/Library/Contextual Menu Items/$app_name.plugin" - "$HOME/Library/Spotlight/$app_name.mdimporter" - "$HOME/Library/ColorPickers/$app_name.colorPicker" - "$HOME/Library/Workflows/$app_name.workflow" - "$HOME/.config/$app_name" - "$HOME/.local/share/$app_name" - "$HOME/.$app_name" - "$HOME/.$app_name"rc - ) + # Only use bundle_id in literal paths or find patterns after reverse-DNS + # validation. A malformed Info.plist should not be able to traverse out of + # Library subtrees or broaden matches with glob metacharacters. + local bundle_id_valid="false" + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + bundle_id_valid="true" + fi + + # Standard path patterns for user-level files. App-name templates must never + # be built from an empty display name, otherwise dotdir/XDG paths collapse to + # broad roots like "$HOME/." or "$HOME/.config/". + local -a user_patterns=() + if [[ -n "$app_name" && ${#app_name} -ge 2 ]]; then + user_patterns+=( + "$HOME/Library/Application Support/$app_name" + "$HOME/Library/Caches/$app_name" + "$HOME/Library/Logs/$app_name" + "$HOME/Library/Preferences/$app_name" + "$HOME/Library/Preferences/$app_name.plist" + "$HOME/Library/Saved Application State/$app_name.savedState" + + "$HOME/Library/Services/$app_name.workflow" + "$HOME/Library/QuickLook/$app_name.qlgenerator" + "$HOME/Library/Internet Plug-Ins/$app_name.plugin" + "$HOME/Library/Audio/Plug-Ins/Components/$app_name.component" + "$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst" + "$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3" + "$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" + "$HOME/Library/PreferencePanes/$app_name.prefPane" + "$HOME/Library/Input Methods/$app_name.app" + "$HOME/Library/Screen Savers/$app_name.saver" + "$HOME/Library/Frameworks/$app_name.framework" + "$HOME/Library/Contextual Menu Items/$app_name.plugin" + "$HOME/Library/Spotlight/$app_name.mdimporter" + "$HOME/Library/ColorPickers/$app_name.colorPicker" + "$HOME/Library/Workflows/$app_name.workflow" + "$HOME/.config/$app_name" + "$HOME/.cache/$app_name" + "$HOME/.cache/$lowercase_name" + "$HOME/.local/share/$app_name" + "$HOME/.$app_name" + "$HOME/.$app_name"rc + "$HOME/Library/Address Book Plug-Ins/$app_name.bundle" + "$HOME/Library/Accessibility/$app_name.bundle" + "$HOME/Library/Mail/Bundles/$app_name.mailbundle" + ) + fi + + if [[ "$bundle_id_valid" == "true" ]]; then + user_patterns+=( + "$HOME/Library/Application Support/$bundle_id" + "$HOME/Library/Caches/$bundle_id" + "$HOME/Library/Logs/$bundle_id" + "$HOME/Library/Saved Application State/$bundle_id.savedState" + "$HOME/Library/Containers/$bundle_id" + "$HOME/Library/WebKit/$bundle_id" + "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" + "$HOME/Library/HTTPStorages/$bundle_id" + "$HOME/Library/HTTPStorages/$bundle_id.binarycookies" + "$HOME/Library/Cookies/$bundle_id.binarycookies" + "$HOME/Library/Application Scripts/$bundle_id" + "$HOME/Library/Input Methods/$bundle_id.app" + "$HOME/Library/Autosave Information/$bundle_id" + "$HOME/Library/SyncedPreferences/$bundle_id.plist" + ) + fi + + # A bundle id's last segment often names the data directory more precisely + # than the display name: tdesktop forks ship as "AyuGram" with bundle + # one.ayugram.AyuGramDesktop and write to "Application Support/AyuGram + # Desktop", which no display-name variant reaches. The leaf alone is NOT + # enough evidence: a wrapper carrying com.wrapper.GoogleChrome or a fork + # named 64Gram carrying org.fork.TelegramDesktop would synthesize another + # product's live data directory. So the derivation requires the leaf to + # EXTEND the display name itself: a valid reverse-DNS id, a leaf of eight + # or more characters with a camel transition, starting with the no-space + # display name (case-insensitive, APFS is too) and continuing at an + # uppercase-or-digit word boundary. Only two literal-path variants come + # out: the raw leaf and the split that keeps the display name intact. + if [[ "$bundle_id_valid" == "true" && ${#app_name} -ge 3 ]]; then + local bundle_leaf="${bundle_id##*.}" + local app_name_nospace="${app_name// /}" + local bundle_leaf_lower app_name_nospace_lower + bundle_leaf_lower=$(printf '%s' "$bundle_leaf" | tr '[:upper:]' '[:lower:]') + app_name_nospace_lower=$(printf '%s' "$app_name_nospace" | tr '[:upper:]' '[:lower:]') + if [[ ${#bundle_leaf} -ge 8 && ${#app_name_nospace} -ge 3 && + "$bundle_leaf" != "$app_name" && + "$bundle_leaf" =~ [a-z][A-Z] && + "$bundle_leaf_lower" == "$app_name_nospace_lower"?* ]]; then + local bundle_leaf_rest="${bundle_leaf:${#app_name_nospace}}" + if [[ "$bundle_leaf_rest" =~ ^[A-Z0-9] ]]; then + local bundle_leaf_rest_spaced + bundle_leaf_rest_spaced=$(printf '%s' "$bundle_leaf_rest" | + sed -E 's/([A-Z]+)([A-Z][a-z])/\1 \2/g; s/([a-z0-9])([A-Z])/\1 \2/g') + local bundle_leaf_variant + for bundle_leaf_variant in "$bundle_leaf" "$app_name $bundle_leaf_rest_spaced"; do + [[ "$bundle_leaf_variant" != "$app_name" ]] || continue + user_patterns+=( + "$HOME/Library/Application Support/$bundle_leaf_variant" + "$HOME/Library/Caches/$bundle_leaf_variant" + "$HOME/Library/Logs/$bundle_leaf_variant" + "$HOME/Library/Preferences/$bundle_leaf_variant.plist" + "$HOME/Library/Saved Application State/$bundle_leaf_variant.savedState" + ) + done + fi + fi + fi # Add all naming variants to cover inconsistent app directory naming # Issue #377: Apps create directories with various naming conventions @@ -977,12 +1065,22 @@ find_app_files() { "$HOME/Library/Application Support/$nospace_name" "$HOME/Library/Caches/$nospace_name" "$HOME/Library/Logs/$nospace_name" + "$HOME/Library/Preferences/$nospace_name" + "$HOME/Library/Preferences/$nospace_name.plist" + "$HOME/Library/Saved Application State/$nospace_name.savedState" "$HOME/Library/Application Support/$underscore_name" "$HOME/Library/Application Support/$hyphen_name" + "$HOME/Library/Preferences/$underscore_name" + "$HOME/Library/Preferences/$underscore_name.plist" + "$HOME/Library/Preferences/$hyphen_name" + "$HOME/Library/Preferences/$hyphen_name.plist" # Lowercase variants (maestrostudio, maestro-studio, maestro_studio) "$HOME/.config/$lowercase_nospace" "$HOME/.config/$lowercase_hyphen" "$HOME/.config/$lowercase_underscore" + "$HOME/.cache/$lowercase_nospace" + "$HOME/.cache/$lowercase_hyphen" + "$HOME/.cache/$lowercase_underscore" "$HOME/.local/share/$lowercase_nospace" "$HOME/.local/share/$lowercase_hyphen" "$HOME/.local/share/$lowercase_underscore" @@ -995,7 +1093,11 @@ find_app_files() { "$HOME/Library/Application Support/$base_name" "$HOME/Library/Caches/$base_name" "$HOME/Library/Logs/$base_name" + "$HOME/Library/Preferences/$base_name" + "$HOME/Library/Preferences/$base_name.plist" + "$HOME/Library/Saved Application State/$base_name.savedState" "$HOME/.config/$base_lowercase" + "$HOME/.cache/$base_lowercase" "$HOME/.local/share/$base_lowercase" "$HOME/.$base_lowercase" ) @@ -1003,14 +1105,24 @@ find_app_files() { # Issue #422: Zed channel builds can leave data under another channel bundle id. # Example: uninstalling dev.zed.Zed-Nightly should also detect dev.zed.Zed-Preview leftovers. - if [[ "$bundle_id" =~ ^dev\.zed\.Zed- ]] && [[ -d "$HOME/Library/HTTPStorages" ]]; then + if [[ "$bundle_id_valid" == "true" && "$bundle_id" =~ ^dev\.zed\.Zed- ]] && [[ -d "$HOME/Library/HTTPStorages" ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/HTTPStorages" -maxdepth 1 \ + -name "dev.zed.Zed-*" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi while IFS= read -r -d '' zed_http_storage; do files_to_clean+=("$zed_http_storage") - done < <(command find "$HOME/Library/HTTPStorages" -maxdepth 1 -name "dev.zed.Zed-*" -print0 2> /dev/null) + done < "$discovery_scan_file" fi - # Process standard patterns - for p in "${user_patterns[@]}"; do + # Process standard patterns. user_patterns can be empty when app_name is + # too short and bundle_id is invalid; bash 3.2 under set -u treats an empty + # "${arr[@]}" expansion as an unbound variable, so use the +-guard idiom. + for p in "${user_patterns[@]+"${user_patterns[@]}"}"; do local expanded_path="${p/#\~/$HOME}" # Skip if path doesn't exist [[ ! -e "$expanded_path" ]] && continue @@ -1021,32 +1133,210 @@ find_app_files() { */Library/Application\ Support | */Library/Application\ Support/ | \ */Library/Caches | */Library/Caches/ | \ */Library/Logs | */Library/Logs/ | \ + */Library/Preferences | */Library/Preferences/ | \ + */Library/Preferences/ByHost | */Library/Preferences/ByHost/ | \ */Library/Containers | */Library/Containers/ | \ */Library/WebKit | */Library/WebKit/ | \ */Library/HTTPStorages | */Library/HTTPStorages/ | \ */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ - */Library/Group\ Containers | */Library/Group\ Containers/) + */Library/Group\ Containers | */Library/Group\ Containers/ | \ + */.config | */.config/ | \ + */.cache | */.cache/ | \ + */.local/share | */.local/share/ | \ + "$HOME" | "$HOME"/ | "$HOME"/.) continue ;; esac + # Skip XDG dotdirs that belong to independent CLI tools sharing a name + # with the GUI app being uninstalled (issue #993). + if _path_belongs_to_independent_cli "$expanded_path"; then + debug_log "Skipping independent CLI dotdir: $expanded_path" + continue + fi + files_to_clean+=("$expanded_path") done - # Handle Preferences and ByHost variants (only if bundle_id is valid) - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then + # Vendor-nested support directories, e.g.: + # ~/Library/Application Support/Avid/Sibelius + # Many professional apps store the product under a vendor folder rather + # than directly under Application Support. Match only when the vendor token + # comes from the bundle id to avoid broad name-only deletion. + if [[ "$bundle_id_valid" == "true" ]]; then + local vendor_nested_path vendor_nested_output="" + discovery_scan_rc=0 + vendor_nested_output=$(find_vendor_nested_app_paths "$bundle_id" "$app_name" \ + "$HOME/Library/Application Support" \ + "$HOME/Library/Caches" \ + "$HOME/Library/Logs") || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r vendor_nested_path; do + [[ -n "$vendor_nested_path" && -e "$vendor_nested_path" ]] && files_to_clean+=("$vendor_nested_path") + done <<< "$vendor_nested_output" + fi + + # Handle Preferences and ByHost variants (only if bundle_id is valid). + # Reverse-DNS check rejects malformed bundle ids before they reach any + # find -name pattern. Without this, a bundle id containing glob metachars + # (* ? [) or path separators could over-match unrelated user containers. + if [[ "$bundle_id_valid" == "true" ]]; then [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") - [[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do - files_to_clean+=("$pref") - done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) + [[ -d ~/Library/Preferences/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id") + if [[ -d ~/Library/Preferences/ByHost ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f \ + -name "*.plist" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' pref; do + if mole_name_starts_with_bundle_id_boundary "$pref" "$bundle_id"; then + files_to_clean+=("$pref") + fi + done < "$discovery_scan_file" + fi + + # User LaunchAgents: wildcard scan for helper plists (e.g., com.example.app.helper.plist) + if [[ -d ~/Library/LaunchAgents ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/LaunchAgents" -maxdepth 1 \ + \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) \ + -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' plist; do + files_to_clean+=("$plist") + done < "$discovery_scan_file" + fi + + # NSURLSession download caches + local nsurlsession_dl="$HOME/Library/Caches/com.apple.nsurlsessiond/Downloads/$bundle_id" + [[ -d "$nsurlsession_dl" ]] && files_to_clean+=("$nsurlsession_dl") # Group Containers (special handling) if [[ -d ~/Library/Group\ Containers ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/Group Containers" -maxdepth 1 -type d \ + -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi while IFS= read -r -d '' container; do - files_to_clean+=("$container") - done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) + if mole_name_has_bundle_id_boundary "$container" "$bundle_id"; then + files_to_clean+=("$container") + fi + done < "$discovery_scan_file" fi + + # App extensions often use bundle-id-derived directories rather than the + # main bundle id exactly, for example share extensions or file providers. + local -a derived_bundle_roots=( + "$HOME/Library/Application Scripts" + "$HOME/Library/Containers" + "$HOME/Library/Application Support/FileProvider" + ) + local derived_root="" + local derived_path="" + local existing_path="" + local already_added=false + for derived_root in "${derived_bundle_roots[@]}"; do + [[ -d "$derived_root" ]] || continue + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$derived_root" -maxdepth 1 -type d -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' derived_path; do + mole_name_has_bundle_id_boundary "$derived_path" "$bundle_id" || continue + already_added=false + if [[ ${#files_to_clean[@]} -gt 0 ]]; then + for existing_path in "${files_to_clean[@]}"; do + if [[ "$existing_path" == "$derived_path" ]]; then + already_added=true + break + fi + done + fi + [[ "$already_added" == "true" ]] || files_to_clean+=("$derived_path") + done < "$discovery_scan_file" + done + fi + + # Shared file lists (recent documents etc.). Keep the root exact: only + # per-app ApplicationRecentDocuments files named by bundle id are owned by + # the uninstall target. + if [[ "$bundle_id_valid" == "true" ]] && + [[ -d "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments" ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments" \ + -maxdepth 1 -type f \ + \( -name "${bundle_id}.sfl2" -o -name "${bundle_id}.sfl3" -o -name "${bundle_id}.sfl4" \) \ + -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' sfl_file; do + files_to_clean+=("$sfl_file") + done < "$discovery_scan_file" + fi + + # Helper extensions and XPC services can persist their own bundle-id keyed + # user data. Read only bounded embedded bundle ids from the selected app, + # then map them to exact ~/Library paths. Keep this stricter than the Mac + # app's review-only scanner because CLI leftovers are deletable after the + # single uninstall confirmation. + if [[ "$bundle_id_valid" == "true" && -n "$app_path" ]]; then + local embedded_id embedded_candidate embedded_ids_output="" + discovery_scan_rc=0 + embedded_ids_output=$(_mole_uninstall_embedded_bundle_ids \ + "$app_path" "$bundle_id") || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r embedded_id; do + [[ -n "$embedded_id" ]] || continue + for embedded_candidate in \ + "$HOME/Library/Application Scripts/$embedded_id" \ + "$HOME/Library/Application Support/FileProvider/$embedded_id" \ + "$HOME/Library/Caches/$embedded_id" \ + "$HOME/Library/Containers/$embedded_id" \ + "$HOME/Library/HTTPStorages/$embedded_id" \ + "$HOME/Library/HTTPStorages/$embedded_id.binarycookies" \ + "$HOME/Library/Preferences/$embedded_id.plist" \ + "$HOME/Library/WebKit/$embedded_id"; do + [[ -e "$embedded_candidate" ]] && files_to_clean+=("$embedded_candidate") + done + if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f \ + -name "${embedded_id}.*.plist" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' embedded_candidate; do + files_to_clean+=("$embedded_candidate") + done < "$discovery_scan_file" + fi + done <<< "$embedded_ids_output" fi # Launch Agents by name (special handling) @@ -1055,12 +1345,20 @@ find_app_files() { # Short-name apps (e.g., Zoom, Arc) are still cleaned via bundle_id matching above # Security: Common words are excluded to prevent matching unrelated plist files if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then - # Skip common words that could match many unrelated LaunchAgents - # These are either generic terms or names that overlap with system/common utilities - local common_words="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility" - if [[ "$app_name" =~ ^($common_words)$ ]]; then + # Skip generic words that collide with many unrelated LaunchAgents. + # Shared with the system-level scan in find_app_system_files(); + # defined in app_protection_data.sh. + if [[ "$app_name" =~ ^(${LAUNCH_AGENT_NAME_COMMON_WORDS})$ ]]; then debug_log "Skipping LaunchAgent name search for common word: $app_name" else + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/LaunchAgents" -maxdepth 1 \ + -name "*$app_name*.plist" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi while IFS= read -r -d '' plist; do local plist_name=$(basename "$plist") # Skip Apple's LaunchAgents @@ -1068,57 +1366,149 @@ find_app_files() { continue fi files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2> /dev/null) + done < "$discovery_scan_file" fi fi - # Handle specialized toolchains and development environments + # Handle specialized toolchains and development environments. + # IMPORTANT: never auto-collect user project source, signing keys, OAuth + # tokens, AVD images, SDK installs, or other manually-curated data. Only + # regenerable cache/derived paths belong here. If a toolchain dir is mixed + # (config + cache), skip the whole tree rather than guess. + # + # MOLE_UNINSTALL_SIBLING_SURVIVES=1 skips the whole heuristic section + # below (through Raycast). The batch uninstall sibling guard sets it when + # another install sharing this bundle id stays on disk (Xcode.app vs + # Xcode-beta.app): these blocks match by regex substring, so the demoted + # bundle id alone does not stop them, and every path they collect is a + # toolchain cache the surviving install still uses. + local collect_toolchain_leftovers=true + if [[ "${MOLE_UNINSTALL_SIBLING_SURVIVES:-0}" == "1" ]]; then + collect_toolchain_leftovers=false + fi + # 1. DevEco-Studio (Huawei) - if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then - for d in ~/DevEcoStudioProjects ~/DevEco-Studio ~/Library/Application\ Support/Huawei ~/Library/Caches/Huawei ~/Library/Logs/Huawei ~/Library/Huawei ~/Huawei ~/HarmonyOS ~/.huawei ~/.ohos; do + if [[ "$collect_toolchain_leftovers" == "true" ]] && + { [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; }; then + # Skipped: ~/DevEcoStudioProjects, ~/HarmonyOS, ~/Huawei (project + # source); ~/DevEco-Studio (IDE config + license state); ~/Library/ + # Application Support/Huawei, ~/Library/Huawei, ~/.huawei, ~/.ohos + # (Huawei account tokens, signed device profiles, SDK config). Only + # sweep cache and log roots; everything else is opt-in. + for d in ~/Library/Caches/Huawei ~/Library/Logs/Huawei; do [[ -d "$d" ]] && files_to_clean+=("$d") done fi # 2. Android Studio (Google) - if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then - for d in ~/AndroidStudioProjects ~/Library/Android ~/.android; do + if [[ "$collect_toolchain_leftovers" == "true" ]] && + { [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; }; then + # Skipped: ~/AndroidStudioProjects (project source), ~/Library/Android + # (SDK installs, multi-GB), ~/.android root (debug.keystore signing + # key, adbkey device pairing, avd/ images). Only sweep regenerable + # caches under ~/.android. + for d in ~/.android/cache ~/.android/build-cache ~/.android/breakpad; do [[ -d "$d" ]] && files_to_clean+=("$d") done - [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) + if [[ -d ~/Library/Application\ Support/Google ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/Application Support/Google" -maxdepth 1 \ + -name "AndroidStudio*" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' d; do + files_to_clean+=("$d") + done < "$discovery_scan_file" + fi fi # 3. Xcode (Apple) - if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then - [[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer") + if [[ "$collect_toolchain_leftovers" == "true" ]] && + { [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; }; then + # Skipped: ~/Library/Developer root (Toolchains, Archives, UserData, + # CoreSimulator/Devices, provisioning profiles). Only sweep + # regenerable build/device caches. + for d in \ + "$HOME/Library/Developer/Xcode/DerivedData" \ + "$HOME/Library/Developer/Xcode/iOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/macOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/watchOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/tvOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/xrOS DeviceSupport" \ + "$HOME/Library/Developer/CoreSimulator/Caches"; do + [[ -d "$d" ]] && files_to_clean+=("$d") + done [[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode") fi # 4. JetBrains (IDE settings) - if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then + if [[ "$collect_toolchain_leftovers" == "true" ]] && + { [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; }; then for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do - [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null) + [[ -d "$base" ]] || continue + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$base" -maxdepth 1 -name "${app_name}*" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' d; do + files_to_clean+=("$d") + done < "$discovery_scan_file" done fi # 5. Unity / Unreal / Godot - [[ "$app_name" =~ Unity|unity ]] && [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity") - [[ "$app_name" =~ Unreal|unreal ]] && [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic") - [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") + if [[ "$collect_toolchain_leftovers" == "true" ]]; then + [[ "$app_name" =~ Unity|unity ]] && [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity") + [[ "$app_name" =~ Unreal|unreal ]] && [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic") + [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") + fi # 6. Tools + # VS Code stores user data under folder names that don't match the app name + # ("Visual Studio Code") or bundle id ("com.microsoft.VSCode"). The folder is + # named "Code" (stable) or "Code - Insiders". Cover both channels explicitly + # so uninstall removes them. Issue #850. if [[ "$bundle_id" =~ microsoft.*[vV][sS][cC]ode ]]; then - [[ -d "$HOME/.vscode" ]] && files_to_clean+=("$HOME/.vscode") [[ -d "$HOME/Library/Caches/com.microsoft.VSCode.ShipIt" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCode.ShipIt") [[ -d "$HOME/Library/Caches/com.microsoft.VSCodeInsiders.ShipIt" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCodeInsiders.ShipIt") + if [[ "$bundle_id" =~ [iI]nsiders ]]; then + [[ -d "$HOME/.vscode-insiders" ]] && files_to_clean+=("$HOME/.vscode-insiders") + [[ -d "$HOME/Library/Application Support/Code - Insiders" ]] && files_to_clean+=("$HOME/Library/Application Support/Code - Insiders") + [[ -d "$HOME/Library/Caches/com.microsoft.VSCodeInsiders" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCodeInsiders") + else + [[ -d "$HOME/.vscode" ]] && files_to_clean+=("$HOME/.vscode") + [[ -d "$HOME/Library/Application Support/Code" ]] && files_to_clean+=("$HOME/Library/Application Support/Code") + [[ -d "$HOME/Library/Caches/com.microsoft.VSCode" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCode") + fi + fi + # Docker: ~/.docker holds config.json (Docker Hub auth tokens), contexts/ + # (kubeconfig-style endpoints, possibly with credentials), and cli-plugins. + # Only sweep regenerable cache subtrees, never the whole tree. + if [[ "$collect_toolchain_leftovers" == "true" ]] && [[ "$app_name" =~ Docker ]]; then + for d in ~/.docker/buildx ~/.docker/scan; do + [[ -d "$d" ]] && files_to_clean+=("$d") + done fi - [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") # 6.1 Maestro Studio - if [[ "$bundle_id" == "com.maestro.studio" ]] || [[ "$lowercase_name" =~ maestro[[:space:]]*studio ]]; then + if [[ "$collect_toolchain_leftovers" == "true" ]] && + { [[ "$bundle_id" == "com.maestro.studio" ]] || [[ "$lowercase_name" =~ maestro[[:space:]]*studio ]]; }; then [[ -d ~/.mobiledev ]] && files_to_clean+=("$HOME/.mobiledev") fi + # Anki's profile directory (Anki2) contains decks, media, and backups. + # Only collect the launcher-managed support files here. + if [[ "$collect_toolchain_leftovers" == "true" ]] && + { [[ "$bundle_id" == "net.ankiweb.anki" ]] || [[ "$app_name" == "Anki" ]]; }; then + [[ -d "$HOME/Library/Application Support/AnkiProgramFiles" ]] && files_to_clean+=("$HOME/Library/Application Support/AnkiProgramFiles") + fi + # 7. Raycast if [[ "$bundle_id" == "com.raycast.macos" ]]; then # Standard user directories @@ -1127,10 +1517,21 @@ find_app_files() { "$HOME/Library/Application Scripts" "$HOME/Library/Containers" ) + # Raycast v2 ships as a separate app (bundle id com.raycast-x.macos); + # exclude its directories from every v1 "*raycast*" sweep (#1202). for dir in "${raycast_dirs[@]}"; do - [[ -d "$dir" ]] && while IFS= read -r -d '' p; do + [[ -d "$dir" ]] || continue + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$dir" -maxdepth 1 -type d -iname "*raycast*" \ + ! -iname "*raycast-x*" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' p; do files_to_clean+=("$p") - done < <(command find "$dir" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + done < "$discovery_scan_file" done # Explicit Raycast container directories (hardcoded leftovers) @@ -1138,21 +1539,77 @@ find_app_files() { [[ -d "$HOME/Library/Containers/com.raycast.macos.RaycastAppIntents" ]] && files_to_clean+=("$HOME/Library/Containers/com.raycast.macos.RaycastAppIntents") # Cache (deeper search) - [[ -d "$HOME/Library/Caches" ]] && while IFS= read -r -d '' p; do - files_to_clean+=("$p") - done < <(command find "$HOME/Library/Caches" -maxdepth 2 -type d -iname "*raycast*" -print0 2> /dev/null) + if [[ -d "$HOME/Library/Caches" ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$HOME/Library/Caches" -maxdepth 2 -type d -iname "*raycast*" \ + ! -iname "*raycast-x*" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < "$discovery_scan_file" + fi # VSCode extension storage local vscode_global="$HOME/Library/Application Support/Code/User/globalStorage" - [[ -d "$vscode_global" ]] && while IFS= read -r -d '' p; do - files_to_clean+=("$p") - done < <(command find "$vscode_global" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + if [[ -d "$vscode_global" ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$vscode_global" -maxdepth 1 -type d -iname "*raycast*" \ + ! -iname "*raycast-x*" -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < "$discovery_scan_file" + fi + fi + + # CrashReporter plists: named AppName_UUID.plist (not subdirectories) + local crash_reporter_dir="$HOME/Library/Application Support/CrashReporter" + if [[ -d "$crash_reporter_dir" && ${#nospace_name} -ge 3 ]]; then + discovery_scan_rc=0 + _mole_uninstall_materialize_find0 "$discovery_scan_file" \ + "$crash_reporter_dir" -maxdepth 1 -type f \ + \( -name "${app_name}_*.plist" -o -name "${nospace_name}_*.plist" \) \ + -print0 || discovery_scan_rc=$? + if [[ $discovery_scan_rc -ne 0 ]]; then + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$discovery_scan_rc" + fi + while IFS= read -r -d '' cr; do + files_to_clean+=("$cr") + done < "$discovery_scan_file" fi - # Output results + # Preserve discovery order while collapsing exact duplicates. A leftover + # can be found first through a bundle-id prefix and again through an + # embedded extension id; it should be previewed and removed only once. if [[ ${#files_to_clean[@]} -gt 0 ]]; then - printf '%s\n' "${files_to_clean[@]}" + local -a unique_files_to_clean=() + local candidate_path="" + local seen_path="" + local duplicate_path=false + for candidate_path in "${files_to_clean[@]}"; do + duplicate_path=false + if [[ ${#unique_files_to_clean[@]} -gt 0 ]]; then + for seen_path in "${unique_files_to_clean[@]}"; do + if [[ "$candidate_path" == "$seen_path" ]]; then + duplicate_path=true + break + fi + done + fi + [[ "$duplicate_path" == "true" ]] || unique_files_to_clean+=("$candidate_path") + done + printf '%s\n' "${unique_files_to_clean[@]}" fi + rm -f -- "$discovery_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above return 0 } @@ -1160,6 +1617,7 @@ get_diagnostic_report_paths_for_app() { local app_path="$1" local app_name="$2" local directory="$3" + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" local prefix="" local exec_name="" local nospace_name="${app_name// /}" @@ -1168,34 +1626,54 @@ get_diagnostic_report_paths_for_app() { [[ ! -d "$directory" ]] && return 0 if [[ -f "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + # plutil -extract reads one key; defaults read deserializes the whole + # plist and routes through cfprefsd. Measured on this plist: ~4.1ms vs + # ~2.3ms per call, and this runs per app inside protection loops. + exec_name=$(plutil -extract CFBundleExecutable raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") if [[ -z "$exec_name" ]]; then exec_name=$(grep -A1 "CFBundleExecutable" "$app_path/Contents/Info.plist" 2> /dev/null | grep "" | sed -n 's/.*\([^<]*\)<\/string>.*/\1/p' | head -1) fi fi prefix="${exec_name:-$nospace_name}" [[ -z "$prefix" || ${#prefix} -lt 3 ]] && return 0 + local -a prefixes=("$prefix") + if [[ "$prefix" != *" Helper" ]]; then + prefixes+=("$prefix Helper") + fi local dir_abs dir_abs=$(cd "$directory" 2> /dev/null && pwd -P 2> /dev/null) || return 0 + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + _mole_uninstall_materialize_find0 "$scan_file" "$dir_abs" \ + -maxdepth 1 -type f -print0 || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi while IFS= read -r -d '' f; do [[ -z "$f" ]] && continue local base base=$(basename "$f" 2> /dev/null) + local matched_prefix=false + local report_prefix + for report_prefix in "${prefixes[@]}"; do + case "$base" in + "$report_prefix".* | "$report_prefix"_* | "$report_prefix"-*) + matched_prefix=true + break + ;; + esac + done + [[ "$matched_prefix" == "true" ]] || continue case "$base" in - "$prefix".* | "$prefix"_* | "$prefix"-*) ;; - *) continue ;; - esac - case "$base" in - *.ips | *.crash | *.spin) ;; + *.ips | *.crash | *.spin | *.diag) ;; *) continue ;; esac printf '%s\n' "$f" - done < <( - find "$dir_abs" -maxdepth 1 -type f \ - \( -name "${prefix}.*" -o -name "${prefix}_*" -o -name "${prefix}-*" \) \ - -print0 2> /dev/null || true - ) + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above return 0 } @@ -1203,13 +1681,20 @@ get_diagnostic_report_paths_for_app() { find_app_system_files() { local bundle_id="$1" local app_name="$2" + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" local -a system_files=() + local system_scan_file="" + system_scan_file=$(create_temp_file) || return 1 + local system_scan_rc=0 # Generate all naming variants (same as find_app_files for consistency) local nospace_name="${app_name// /}" local underscore_name="${app_name// /_}" local hyphen_name="${app_name// /-}" + local lowercase_name=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + local lowercase_nospace=$(echo "$nospace_name" | tr '[:upper:]' '[:lower:]') local lowercase_hyphen=$(echo "$hyphen_name" | tr '[:upper:]' '[:lower:]') + local lowercase_underscore=$(echo "$underscore_name" | tr '[:upper:]' '[:lower:]') # Standard system path patterns local -a system_patterns=( @@ -1218,6 +1703,8 @@ find_app_system_files() { "/Library/LaunchAgents/$bundle_id.plist" "/Library/LaunchDaemons/$bundle_id.plist" "/Library/Preferences/$bundle_id.plist" + "/Library/Preferences/$app_name" + "/Library/Preferences/$app_name.plist" "/Library/Receipts/$bundle_id.bom" "/Library/Receipts/$bundle_id.plist" "/Library/Frameworks/$app_name.framework" @@ -1233,6 +1720,10 @@ find_app_system_files() { "/Library/Screen Savers/$app_name.saver" "/Library/Caches/$bundle_id" "/Library/Caches/$app_name" + "/Library/Extensions/$app_name.kext" + "/Library/StartupItems/$app_name" + "/Library/Logs/$app_name" + "/Library/Logs/$bundle_id" ) # Add all naming variants for apps with spaces in name @@ -1243,6 +1734,12 @@ find_app_system_files() { "/Library/Logs/$nospace_name" "/Library/Application Support/$underscore_name" "/Library/Application Support/$hyphen_name" + "/Library/Preferences/$nospace_name" + "/Library/Preferences/$nospace_name.plist" + "/Library/Preferences/$underscore_name" + "/Library/Preferences/$underscore_name.plist" + "/Library/Preferences/$hyphen_name" + "/Library/Preferences/$hyphen_name.plist" "/Library/Caches/$hyphen_name" "/Library/Caches/$lowercase_hyphen" ) @@ -1256,7 +1753,8 @@ find_app_system_files() { case "$p" in /Library/Application\ Support | /Library/Application\ Support/ | \ /Library/Caches | /Library/Caches/ | \ - /Library/Logs | /Library/Logs/) + /Library/Logs | /Library/Logs/ | \ + /Library/Preferences | /Library/Preferences/) continue ;; esac @@ -1264,36 +1762,174 @@ find_app_system_files() { system_files+=("$p") done - # System LaunchAgents/LaunchDaemons by name - if [[ ${#app_name} -gt 3 ]]; then + # Vendor-nested system support directories, e.g.: + # /Library/Application Support/Avid/Sibelius + local vendor_nested_system_path vendor_nested_system_output="" + system_scan_rc=0 + vendor_nested_system_output=$(find_vendor_nested_app_paths "$bundle_id" "$app_name" \ + "/Library/Application Support" \ + "/Library/Caches" \ + "/Library/Logs") || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r vendor_nested_system_path; do + [[ -n "$vendor_nested_system_path" && -e "$vendor_nested_system_path" ]] && system_files+=("$vendor_nested_system_path") + done <<< "$vendor_nested_system_output" + + # Shared sample/support files are usually outside the user's Library but + # are app-owned data (for example /Users/Shared/Sibelius ...). + local shared_app_path shared_app_output="" + system_scan_rc=0 + shared_app_output=$(find_shared_app_paths "$bundle_id" "$app_name" \ + "/Users/Shared") || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r shared_app_path; do + [[ -n "$shared_app_path" && -e "$shared_app_path" ]] && system_files+=("$shared_app_path") + done <<< "$shared_app_output" + + # System LaunchAgents/LaunchDaemons often use bundle-id-derived helper + # labels (for example ".ProxyConfigHelper.plist"), so scan for + # validated reverse-DNS bundle-id prefixes before falling back to app name. + # The two -name patterns are anchored at the dot boundary so that, e.g., + # bundle "com.foo" matches "com.foo.plist" and "com.foo.helper.plist" but + # NOT "com.foobar.plist" from an unrelated vendor. + if mole_is_reverse_dns_bundle_id "$bundle_id"; then for base in /Library/LaunchAgents /Library/LaunchDaemons; do - [[ -d "$base" ]] && while IFS= read -r -d '' plist; do + [[ -d "$base" ]] || continue + system_scan_rc=0 + _mole_uninstall_materialize_find0 "$system_scan_file" "$base" \ + -maxdepth 1 \( -name "${bundle_id}.plist" -o \ + -name "${bundle_id}.*.plist" \) -print0 || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r -d '' plist; do + system_files+=("$plist") + done < "$system_scan_file" + done + fi + + # System LaunchAgents/LaunchDaemons by name. These live under /Library and + # are removed with sudo, so mirror the (stricter) user-level guard above: + # require >=5 chars, skip generic collision words, and skip Apple's own + # plists. A short or generic app name must not match unrelated system agents. + if [[ ${#app_name} -ge 5 ]] && ! [[ "$app_name" =~ ^(${LAUNCH_AGENT_NAME_COMMON_WORDS})$ ]]; then + for base in /Library/LaunchAgents /Library/LaunchDaemons; do + [[ -d "$base" ]] || continue + system_scan_rc=0 + _mole_uninstall_materialize_find0 "$system_scan_file" "$base" \ + -maxdepth 1 -name "*$app_name*.plist" -print0 || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r -d '' plist; do + local plist_name + plist_name=$(basename "$plist") + [[ "$plist_name" =~ ^com\.apple\. ]] && continue system_files+=("$plist") - done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) + done < "$system_scan_file" done fi # Privileged Helper Tools and Receipts (special handling) # Only search with bundle_id if it's valid (not empty and not "unknown") - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then - [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do - system_files+=("$helper") - done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null) + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + if [[ -d /Library/PrivilegedHelperTools ]]; then + system_scan_rc=0 + _mole_uninstall_materialize_find0 "$system_scan_file" \ + /Library/PrivilegedHelperTools -maxdepth 1 -print0 || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r -d '' helper; do + if mole_name_starts_with_bundle_id_boundary "$helper" "$bundle_id"; then + system_files+=("$helper") + fi + done < "$system_scan_file" + fi - [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do - system_files+=("$receipt") - done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) + if [[ -d /private/var/db/receipts ]]; then + system_scan_rc=0 + _mole_uninstall_materialize_find0 "$system_scan_file" \ + /private/var/db/receipts -maxdepth 1 -print0 || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r -d '' receipt; do + if mole_name_starts_with_bundle_id_boundary "$receipt" "$bundle_id"; then + system_files+=("$receipt") + fi + done < "$system_scan_file" + fi fi - # Raycast system-level files + # Some vendors name privileged helpers after the product rather than the + # bundle id. System remnants are review-only in the CLI, but keep + # conservative name guards to avoid noisy system matches: reject common app + # words case-insensitively and require each matched variant to be at least + # 5 characters, since nospace variants can be shorter than app_name itself. + local -a helper_name_variants=() + if ! _mole_uninstall_is_common_app_name "$app_name"; then + local name_variant + for name_variant in "$lowercase_name" "$lowercase_nospace" "$lowercase_hyphen" "$lowercase_underscore"; do + if [[ ${#name_variant} -ge 5 ]]; then + helper_name_variants+=("$name_variant") + fi + done + fi + if [[ ${#helper_name_variants[@]} -gt 0 && -d /Library/PrivilegedHelperTools ]]; then + system_scan_rc=0 + _mole_uninstall_materialize_find0 "$system_scan_file" \ + /Library/PrivilegedHelperTools -maxdepth 1 -print0 || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r -d '' helper; do + local helper_name + local helper_lower + helper_name=$(basename "$helper") + [[ "$helper_name" =~ ^com\.apple\. ]] && continue + helper_lower=$(_mole_uninstall_lower "$helper_name") + if _mole_uninstall_name_variant_matches "$helper_lower" "${helper_name_variants[@]}"; then + system_files+=("$helper") + fi + done < "$system_scan_file" + fi + + # Raycast system-level files (v2 dirs excluded, see find_app_files) if [[ "$bundle_id" == "com.raycast.macos" ]]; then - [[ -d "/Library/Application Support" ]] && while IFS= read -r -d '' p; do - system_files+=("$p") - done < <(command find "/Library/Application Support" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + if [[ -d "/Library/Application Support" ]]; then + system_scan_rc=0 + _mole_uninstall_materialize_find0 "$system_scan_file" \ + "/Library/Application Support" -maxdepth 1 -type d \ + -iname "*raycast*" ! -iname "*raycast-x*" -print0 || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi + while IFS= read -r -d '' p; do + system_files+=("$p") + done < "$system_scan_file" + fi fi local receipt_files="" - receipt_files=$(find_app_receipt_files "$bundle_id") + system_scan_rc=0 + receipt_files=$(find_app_receipt_files "$bundle_id") || system_scan_rc=$? + if [[ $system_scan_rc -ne 0 ]]; then + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$system_scan_rc" + fi local combined_files="" if [[ ${#system_files[@]} -gt 0 ]]; then @@ -1310,31 +1946,45 @@ find_app_system_files() { if [[ -n "$combined_files" ]]; then printf '%s\n' "$combined_files" | sort -u fi + rm -f -- "$system_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return 0 } # Locate files using installation receipts (BOM) find_app_receipt_files() { local bundle_id="$1" + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" # Skip if no bundle ID [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 - # Validate bundle_id format to prevent wildcard injection - # Only allow alphanumeric characters, dots, hyphens, and underscores - if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]]; then + # Validate bundle_id format to prevent wildcard or defaults-domain abuse. + if ! mole_is_reverse_dns_bundle_id "$bundle_id"; then debug_log "Invalid bundle_id format: $bundle_id" return 0 fi local -a receipt_files=() local -a bom_files=() + local receipt_scan_file="" + receipt_scan_file=$(create_temp_file) || return 1 # Find receipts matching the bundle ID # Usually in /var/db/receipts/ if [[ -d /private/var/db/receipts ]]; then + local receipt_scan_rc=0 + _mole_uninstall_materialize_find0 "$receipt_scan_file" \ + /private/var/db/receipts -maxdepth 1 -name "*.bom" \ + -print0 || receipt_scan_rc=$? + if [[ $receipt_scan_rc -ne 0 ]]; then + rm -f -- "$receipt_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$receipt_scan_rc" + fi while IFS= read -r -d '' bom; do - bom_files+=("$bom") - done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null) + if mole_name_starts_with_bundle_id_boundary "$bom" "$bundle_id"; then + bom_files+=("$bom") + fi + done < "$receipt_scan_file" fi # Process bom files if any found @@ -1345,8 +1995,23 @@ find_app_receipt_files() { # Parse bom file # lsbom -f: file paths only # -s: suppress output (convert to text) - local bom_content - bom_content=$(lsbom -f -s "$bom_file" 2> /dev/null) + local bom_content="" + local bom_rc=0 + local bom_timeout="" + bom_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$_MOLE_UNINSTALL_DISCOVERY_DEADLINE") || bom_rc=$? + if [[ $bom_rc -eq 0 ]]; then + bom_content=$(run_with_timeout "$bom_timeout" lsbom \ + -f -s "$bom_file" < /dev/null 2> /dev/null) || bom_rc=$? + fi + if [[ $bom_rc -ne 0 ]]; then + if [[ $bom_rc -eq 124 || $bom_rc -ge 128 ]]; then + rm -f -- "$receipt_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$bom_rc" + fi + debug_log "Skipping unreadable uninstall receipt: $bom_file" + continue + fi while IFS= read -r file_path; do # Standardize path (remove leading dot) @@ -1366,31 +2031,7 @@ find_app_receipt_files() { # Normalize path (remove duplicate slashes) clean_path=$(tr -s "/" <<< "$clean_path") - # ------------------------------------------------------------------------ - # Safety check: restrict removal to trusted paths - # ------------------------------------------------------------------------ - local is_safe=false - - # Whitelisted prefixes (exclude /Users, /usr, /opt) - case "$clean_path" in - /Applications/*) is_safe=true ;; - /Library/Application\ Support/*) is_safe=true ;; - /Library/Caches/*) is_safe=true ;; - /Library/Logs/*) is_safe=true ;; - /Library/Preferences/*) is_safe=true ;; - /Library/LaunchAgents/*) is_safe=true ;; - /Library/LaunchDaemons/*) is_safe=true ;; - /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/Extensions/*) is_safe=false ;; - *) is_safe=false ;; - esac - - # Hard blocks - case "$clean_path" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; - esac - - if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then + if receipt_payload_path_is_allowlisted "$clean_path" "$bundle_id" && [[ -e "$clean_path" ]]; then # Skip top-level directories if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" ]]; then continue @@ -1411,11 +2052,45 @@ find_app_receipt_files() { if [[ ${#receipt_files[@]} -gt 0 ]]; then printf '%s\n' "${receipt_files[@]}" fi + rm -f -- "$receipt_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return 0 } -# Terminate a running application +receipt_payload_path_is_allowlisted() { + local clean_path="$1" + local bundle_id="$2" + local base + base=$(basename "$clean_path") + + [[ -n "$clean_path" && -n "$bundle_id" ]] || return 1 + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + + case "$clean_path" in + /Library/LaunchAgents/*.plist | /Library/LaunchDaemons/*.plist) + [[ "$base" == "$bundle_id.plist" || "$base" == "$bundle_id."*.plist ]] + return + ;; + /Library/PrivilegedHelperTools/*) + mole_name_starts_with_bundle_id_boundary "$base" "$bundle_id" + return + ;; + /private/var/db/receipts/*) + [[ "$base" == "$bundle_id.bom" || "$base" == "$bundle_id.plist" || "$base" == "$bundle_id."* ]] + return + ;; + esac + + return 1 +} + +# Terminate a running application during uninstall. +# The user has already confirmed removal, so after the graceful Quit Apple +# Event we escalate through SIGTERM and SIGKILL (and one sudo retry when +# non-interactive sudo is already cached) to avoid leaving a zombie process +# after "Uninstall complete". Apps that need to flush state get the graceful +# Quit window first; apps that stall past it lose unsaved work, which the +# user has implicitly accepted by confirming. force_kill_app() { - # Gracefully terminates or force-kills an application local app_name="$1" local app_path="${2:-""}" @@ -1424,53 +2099,154 @@ force_kill_app() { return 0 fi - # Get the executable name from bundle if app_path is provided + # Get the executable name and bundle id from Info.plist when available. + # bundle id is preferred for the AppleScript Quit step because it is more + # precise than the display name (which may be localized). local exec_name="" + local bundle_id="" if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + # Targeted key reads (see the CFBundleExecutable note above): defaults + # read parses the entire plist for one value. + exec_name=$(plutil -extract CFBundleExecutable raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") + bundle_id=$(plutil -extract CFBundleIdentifier raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") fi # Use executable name for precise matching, fallback to app name local match_pattern="${exec_name:-$app_name}" + # Defensive guard: even though should_protect_from_uninstall (bin/uninstall.sh) + # filters protected bundle IDs out of the selection list, match_pattern comes + # from CFBundleExecutable (a string a third-party .app can set freely). Refuse + # to AppleScript-Quit or pkill any pattern that exactly matches a critical + # system process name. force_kill_app is a public function; future callers + # must not be able to weaponise a spoofed executable name to take down Finder, + # Dock, loginwindow, etc. + case "$match_pattern" in + Finder | Dock | loginwindow | WindowServer | SystemUIServer | launchd | coreaudiod | NotificationCenter | ControlCenter | Spotlight) + debug_log "force_kill_app: refusing to operate on system process name '$match_pattern'" + return 1 + ;; + esac + # Check if process is running using exact match only - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + local process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + if [[ $process_probe_rc -ne 0 ]]; then return 0 fi - # Try graceful termination first - pkill -x "$match_pattern" 2> /dev/null || true - sleep 2 + # Send a graceful Quit Apple Event first. Many Tauri/Electron/SwiftUI GUI + # apps install an event loop that ignores SIGTERM but responds to the + # standard "quit" Apple Event by going through their normal terminate + # flow (including unsaved-state prompts). osascript is best-effort: we + # cap the wait so a hung app, an automation-permission dialog, or a + # missing osascript binary can never stall the uninstall. + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]] && + command -v osascript > /dev/null 2>&1; then + local quit_target="" + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + quit_target="id \"$bundle_id\"" + else + # Escape embedded double quotes in app_name before passing into + # the AppleScript literal. + local escaped_name="${app_name//\\/\\\\}" + escaped_name="${escaped_name//\"/\\\"}" + quit_target="\"$escaped_name\"" + fi + run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" osascript -e "tell application $quit_target to quit" > /dev/null 2>&1 < /dev/null & + local quit_pid=$! + # Poll briefly so the kill ladder skips when the app exits cleanly. + local quit_wait=20 + while [[ $quit_wait -gt 0 ]]; do + process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + [[ $process_probe_rc -eq 0 ]] || break + local sleep_rc=0 + sleep 0.1 || sleep_rc=$? + [[ $sleep_rc -ge 128 ]] && return "$sleep_rc" + ((quit_wait--)) + done + local quit_rc=0 + wait "$quit_pid" 2> /dev/null || quit_rc=$? + [[ $quit_rc -ge 128 ]] && return "$quit_rc" + fi - # Check again after graceful kill - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + # Graceful Quit landed: skip the kill ladder entirely. + process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + if [[ $process_probe_rc -ne 0 ]]; then return 0 fi - # Force kill if still running - pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 + # Escalate: SIGTERM, then SIGKILL, then one sudo SIGKILL retry when a + # cached sudo session is already available (no new prompt). The user + # confirmed uninstall, so a still-running process at this point is + # blocking a clean result and we trade unsaved state for that. + local kill_rc=0 + pkill -x "$match_pattern" 2> /dev/null || kill_rc=$? + [[ $kill_rc -ge 128 ]] && return "$kill_rc" + local sleep_rc=0 + sleep 2 || sleep_rc=$? + [[ $sleep_rc -ge 128 ]] && return "$sleep_rc" + process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + if [[ $process_probe_rc -ne 0 ]]; then + return 0 + fi - # If still running and sudo is available, try with sudo - if pgrep -x "$match_pattern" > /dev/null 2>&1; then - if sudo -n true 2> /dev/null; then - sudo pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 - fi + kill_rc=0 + pkill -9 -x "$match_pattern" 2> /dev/null || kill_rc=$? + [[ $kill_rc -ge 128 ]] && return "$kill_rc" + sleep_rc=0 + sleep 2 || sleep_rc=$? + [[ $sleep_rc -ge 128 ]] && return "$sleep_rc" + process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + if [[ $process_probe_rc -ne 0 ]]; then + return 0 fi - # Final check with longer timeout for stubborn processes + local sudo_probe_rc=1 + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + sudo_probe_rc=0 + sudo -n true 2> /dev/null || sudo_probe_rc=$? + [[ $sudo_probe_rc -ge 128 ]] && return "$sudo_probe_rc" + fi + if [[ $sudo_probe_rc -eq 0 ]]; then + kill_rc=0 + sudo pkill -9 -x "$match_pattern" 2> /dev/null || kill_rc=$? + [[ $kill_rc -ge 128 ]] && return "$kill_rc" + sleep_rc=0 + sleep 2 || sleep_rc=$? + [[ $sleep_rc -ge 128 ]] && return "$sleep_rc" + fi + + # Final retries for stubborn processes (e.g. apps mid-fsync that need a + # moment to fully exit after SIGKILL). local retries=3 while [[ $retries -gt 0 ]]; do - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + if [[ $process_probe_rc -ne 0 ]]; then return 0 fi - sleep 1 + sleep_rc=0 + sleep 1 || sleep_rc=$? + [[ $sleep_rc -ge 128 ]] && return "$sleep_rc" ((retries--)) done - # Still running after all attempts - pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 + process_probe_rc=0 + pgrep -x "$match_pattern" > /dev/null 2>&1 || process_probe_rc=$? + [[ $process_probe_rc -ge 128 ]] && return "$process_probe_rc" + [[ $process_probe_rc -eq 0 ]] && return 1 + return 0 } # Note: calculate_total_size() is defined in lib/core/file_ops.sh diff --git a/Resources/mole/lib/core/app_protection_data.sh b/Resources/mole/lib/core/app_protection_data.sh new file mode 100644 index 0000000..f81a639 --- /dev/null +++ b/Resources/mole/lib/core/app_protection_data.sh @@ -0,0 +1,627 @@ +#!/bin/bash +# Mole - Application Protection Data +# Static bundle ID and pattern lists, sourced by lib/core/app_protection.sh. +# Keep this file data-only. Logic belongs in app_protection.sh. + +set -euo pipefail + +if [[ -n "${MOLE_APP_PROTECTION_DATA_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_APP_PROTECTION_DATA_LOADED=1 + +# Application Management + +# Detailed list for uninstall protection (lazy-loaded into SYSTEM_CRITICAL_REGEX). +# Critical system components protected from uninstallation +# Note: We explicitly list system components instead of using "com.apple.*" wildcard +# to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.) +readonly SYSTEM_CRITICAL_BUNDLES=( + # Core system applications (in /System/Applications/) + "com.apple.finder" + "com.apple.dock" + "com.apple.Safari" + "com.apple.mail" + "com.apple.systempreferences" + "com.apple.SystemSettings" + "com.apple.Settings*" + "com.apple.controlcenter*" + "com.apple.Spotlight" + "com.apple.notificationcenterui" + "com.apple.loginwindow" + "com.apple.Preview" + "com.apple.TextEdit" + "com.apple.Notes" + "com.apple.reminders" + "com.apple.iCal" + "com.apple.AddressBook" + "com.apple.Photos" + "com.apple.AppStore" + "com.apple.calculator" + "com.apple.Dictionary" + "com.apple.ScreenSharing" + "com.apple.ActivityMonitor" + "com.apple.Console" + "com.apple.DiskUtility" + "com.apple.KeychainAccess" + "com.apple.DigitalColorMeter" + "com.apple.grapher" + "com.apple.Terminal" + "com.apple.ScriptEditor2" + "com.apple.VoiceOverUtility" + "com.apple.BluetoothFileExchange" + "com.apple.print.PrinterProxy" + "com.apple.systempreferences*" + "com.apple.SystemProfiler" + "com.apple.FontBook" + "com.apple.ColorSyncUtility" + "com.apple.audio.AudioMIDISetup" + "com.apple.DirectoryUtility" + "com.apple.NetworkUtility" + "com.apple.exposelauncher" + "com.apple.MigrateAssistant" + "com.apple.RAIDUtility" + "com.apple.BootCampAssistant" + + # /System/Applications additions surfaced by the 2026-08 bundle drift + # audit on macOS 26. IDs are matched case-sensitively, so the lowercase + # variants below are the exact values those bundles report. + "com.apple.apps.launcher" + "com.apple.backup.launcher" + "com.apple.bootcampassistant" + "com.apple.Chess" + "com.apple.clock" + "com.apple.FaceTime" + "com.apple.findmy" + "com.apple.freeform" + "com.apple.games" + "com.apple.GenerativePlaygroundApp" + "com.apple.helpviewer" + "com.apple.Home" + "com.apple.Image_Capture" + "com.apple.journal" + "com.apple.Magnifier" + "com.apple.Maps" + "com.apple.mobilephone" + "com.apple.MobileSMS" + "com.apple.news" + "com.apple.Passwords" + "com.apple.PhotoBooth" + "com.apple.printcenter" + "com.apple.QuickTimePlayerX" + "com.apple.ScreenContinuity" + "com.apple.screenshot.launcher" + "com.apple.shortcuts" + "com.apple.siri.launcher" + "com.apple.Stickies" + "com.apple.stocks" + "com.apple.TV" + "com.apple.VoiceMemos" + "com.apple.weather" + + # System services and daemons + "com.apple.SecurityAgent" + "com.apple.CoreServices*" + "com.apple.SystemUIServer" + "com.apple.backgroundtaskmanagement*" + "com.apple.loginitems*" + "com.apple.sharedfilelist*" + "com.apple.sfl*" + "com.apple.coreservices*" + "com.apple.metadata*" + "com.apple.MobileSoftwareUpdate*" + "com.apple.SoftwareUpdate*" + "com.apple.installer*" + "com.apple.frameworks*" + "com.apple.security*" + "com.apple.keychain*" + "com.apple.trustd*" + "com.apple.securityd*" + "com.apple.cloudd*" + "com.apple.iCloud*" + "com.apple.WiFi*" + "com.apple.airport*" + "com.apple.Bluetooth*" + + # Input methods (system built-in) + "com.apple.inputmethod.*" + "com.apple.inputsource*" + "com.apple.TextInput*" + "com.apple.CharacterPicker*" + "com.apple.PressAndHold*" + + # Legacy pattern-based entries (non com.apple.*) + "loginwindow" + "dock" + "systempreferences" + "finder" + "safari" + "backgroundtaskmanagementagent" + "keychain*" + "security*" + "bluetooth*" + "wifi*" + "network*" + "tcc" + "notification*" + "accessibility*" + "universalaccess*" + "HIToolbox*" + "textinput*" + "TextInput*" + "keyboard*" + "Keyboard*" + "inputsource*" + "InputSource*" + "keylayout*" + "KeyLayout*" + "GlobalPreferences" + ".GlobalPreferences" +) + +# Apple apps that CAN be uninstalled (from App Store or developer.apple.com) +readonly APPLE_UNINSTALLABLE_APPS=( + "com.apple.dt.*" # Xcode, Instruments, FileMerge + "com.apple.FinalCut*" # Final Cut Pro + "com.apple.Motion" + "com.apple.Compressor" + "com.apple.logic*" # Logic Pro + "com.apple.garageband*" # GarageBand + "com.apple.iMovie" + "com.apple.iWork.*" # Pages, Numbers, Keynote + "com.apple.MainStage*" + "com.apple.server.*" # macOS Server + "com.apple.Playgrounds" # Swift Playgrounds +) + +# Vendor-managed security / MDM apps must use their official uninstallers. +# Shape: vendor|bundle-prefixes-comma-separated|name-fragments-comma-separated +readonly OFFICIAL_UNINSTALLER_RULES=( + "ESET|com.eset.|eset management agent,eset remote administrator agent,eset endpoint security,eset endpoint antivirus" + "Jamf|com.jamf.,com.jamfsoftware.|jamf connect,jamf protect,jamf self service" + "CrowdStrike|com.crowdstrike.|crowdstrike,falcon" + "SentinelOne|com.sentinelone.,com.sentinel-labs.|sentinelone,sentinel agent" + "GlobalProtect|com.paloaltonetworks.|globalprotect" + "Cisco|com.cisco.anyconnect,com.cisco.secureclient|cisco secure client,cisco anyconnect" +) + +# Endpoint-security / EDR / MDM agent bundle-id prefixes. Their per-user Darwin +# caches under /private/var/folders must never be deleted: removing anything +# inside a sensor's container trips tamper detection (e.g. CrowdStrike +# "MacFalconSensorTamper", MITRE T1562.001) that corporate security reports as +# malware. Keep in sync with the vendors in OFFICIAL_UNINSTALLER_RULES above. +# Consumed by is_endpoint_security_cache_path() in app_protection.sh. +readonly ENDPOINT_SECURITY_BUNDLE_PREFIXES=( + "com.crowdstrike." + "com.sentinelone." + "com.sentinel-labs." + "com.eset." + "com.jamf." + "com.jamfsoftware." + "com.paloaltonetworks." + "com.cisco.anyconnect" + "com.cisco.secureclient" +) + +# Applications with sensitive data; protected during cleanup but removable +readonly DATA_PROTECTED_BUNDLES=( + # Input Methods (protected during cleanup, uninstall allowed) + "com.tencent.inputmethod.QQInput" + "com.sogou.inputmethod.*" + "com.baidu.inputmethod.*" + "com.googlecode.rimeime.*" + "im.rime.*" + "*.inputmethod" + "*.InputMethod" + "*IME" + + # System Utilities & Cleanup + "com.nektony.*" + "com.macpaw.*" + "com.freemacsoft.AppCleaner" + "com.omnigroup.omnidisksweeper" + "com.daisydiskapp.*" + "com.tunabellysoftware.*" + "com.grandperspectiv.*" + "com.binaryfruit.*" + + # Password Managers + "com.1password.*" + "com.agilebits.*" + "com.lastpass.*" + "com.dashlane.*" + "com.bitwarden.*" + "com.keepassx.*" + "org.keepassx.*" + "org.keepassxc.*" + "com.authy.*" + "com.yubico.*" + + # IDEs & Editors + "com.jetbrains.*" + "JetBrains*" + "com.microsoft.VSCode" + "com.visualstudio.code.*" + "com.sublimetext.*" + "com.sublimehq.*" + "com.microsoft.VSCodeInsiders" + "com.apple.dt.Xcode" + "com.coteditor.CotEditor" + "com.macromates.TextMate" + "com.panic.Nova" + "abnerworks.Typora" + "com.uranusjr.macdown" + + # AI & LLM Tools + "com.todesktop.*" + "Cursor" + "com.anthropic.claude*" + "Claude" + "com.openai.chat*" + "ChatGPT" + "com.openai.codex" + "Codex" + "codex-runtimes" + "com.ollama.ollama" + "Ollama" + "com.lmstudio.lmstudio" + "LM Studio" + "co.supertool.chatbox" + "page.jan.jan" + "com.huggingface.huggingchat" + "Gemini" + "com.perplexity.Perplexity" + "com.drawthings.DrawThings" + "com.divamgupta.diffusionbee" + "com.exafunction.windsurf" + "com.quora.poe.electron" + "chat.openai.com.*" + + # Database Clients + "com.sequelpro.*" + "com.sequel-ace.*" + "com.tinyapp.*" + "com.dbeaver.*" + "com.navicat.*" + "com.mongodb.compass" + "com.redis.RedisInsight" + "com.pgadmin.pgadmin4" + "com.eggerapps.Sequel-Pro" + "com.valentina-db.Valentina-Studio" + "com.dbvis.DbVisualizer" + + # API & Network Tools + "com.postmanlabs.mac" + "com.konghq.insomnia" + "com.CharlesProxy.*" + "com.proxyman.*" + "com.getpaw.*" + "com.luckymarmot.Paw" + "com.charlesproxy.charles" + "com.telerik.Fiddler" + "com.usebruno.app" + + # Network Proxy & VPN Tools (Clash variants - use specific patterns to avoid false positives) + "com.clash.*" + "ClashX*" + "clash-*" + "Clash-*" + "*-clash" + "*-Clash" + "clash.*" + "Clash.*" + "clash_*" + "*clash-verge*" + "*Clash-Verge*" + "clashverge*" + "ClashVerge*" + "com.nssurge.surge-mac" + "*surge*" + "*Surge*" + "mihomo*" + "*openvpn*" + "*OpenVPN*" + "net.openvpn.*" + + # Proxy Clients + "*ShadowsocksX-NG*" + "com.qiuyuzhou.*" + "*v2ray*" + "*V2Ray*" + "*v2box*" + "*V2Box*" + "*nekoray*" + "*sing-box*" + "*OneBox*" + "*hiddify*" + "*Hiddify*" + "*loon*" + "*Loon*" + "*quantumult*" + + # Mesh & Corporate VPNs + "*tailscale*" + "io.tailscale.*" + "*zerotier*" + "com.zerotier.*" + "*1dot1dot1dot1*" # Cloudflare WARP + "*cloudflare*warp*" + "org.amnezia.*" + "*amnezia*" + "*Amnezia*" + "com.wireguard.*" + "*wireguard*" + "*WireGuard*" + + # Commercial VPNs + "*nordvpn*" + "*expressvpn*" + "*protonvpn*" + "*surfshark*" + "*windscribe*" + "*mullvad*" + "*privateinternetaccess*" + + # Screensaver & Wallpaper + "*Aerial.saver*" + "com.JohnCoates.Aerial*" + "*Fliqlo*" + "*fliqlo*" + + # Git & Version Control + "com.github.GitHubDesktop" + "com.sublimemerge" + "com.torusknot.SourceTreeNotMAS" + "com.git-tower.Tower*" + "com.gitfox.GitFox" + "com.github.Gitify" + "com.fork.Fork" + "com.axosoft.gitkraken" + + # Terminal & Shell + "com.googlecode.iterm2" + "net.kovidgoyal.kitty" + "io.alacritty" + "com.github.wez.wezterm" + "com.hyper.Hyper" + "com.mizage.divvy" + "com.fig.Fig" + "dev.warp.Warp-Stable" + "com.termius-dmg" + + # Docker & Virtualization + "com.docker.docker" + "dev.orbstack.OrbStack" + "dev.orbstack.*" + "dev.kdrag0n.MacVirt" + "com.getutm.UTM" + "com.vmware.fusion" + "com.parallels.desktop.*" + "org.virtualbox.app.VirtualBox" + "com.vagrant.*" + "com.orbstack.OrbStack" + + # System Monitoring + "com.bjango.istatmenus*" + "eu.exelban.Stats" + "com.monitorcontrol.*" + "com.bresink.system-toolkit.*" + "com.mediaatelier.MenuMeters" + "com.activity-indicator.app" + "net.cindori.sensei" + + # Window Management + "com.macitbetter.*" # BetterTouchTool, BetterSnapTool + "com.hegenberg.*" + "com.manytricks.*" # Moom, Witch, etc. + "com.divisiblebyzero.*" + "com.koingdev.*" + "com.if.Amphetamine" + "com.lwouis.alt-tab-macos" + "net.matthewpalmer.Vanilla" + "com.lightheadsw.Caffeine" + "com.contextual.Contexts" + "com.amethyst.Amethyst" + "com.knollsoft.Rectangle" + "com.knollsoft.Hookshot" + "com.surteesstudios.Bartender" + "com.gaosun.eul" + "com.pointum.hazeover" + + # Launcher & Automation + "com.runningwithcrayons.Alfred" + "com.raycast.*" + "com.raycast-x.*" + "com.blacktree.Quicksilver" + "com.stairways.keyboardmaestro.*" + "com.manytricks.Butler" + "com.happenapps.Quitter" + "com.pilotmoon.scroll-reverser" + "org.pqrs.Karabiner-Elements" + "com.apple.Automator" + + # Note-Taking + "com.bear-writer.*" + "com.typora.*" + "com.ulyssesapp.*" + "com.literatureandlatte.*" + "com.dayoneapp.*" + "notion.id" + "md.obsidian" + "com.logseq.logseq" + "com.evernote.Evernote" + "com.onenote.mac" + "com.omnigroup.OmniOutliner*" + "net.shinyfrog.bear" + "com.goodnotes.GoodNotes" + "com.marginnote.MarginNote*" + "com.roamresearch.*" + "com.reflect.ReflectApp" + "com.inkdrop.*" + + # Design & Creative + "com.adobe.*" + "com.avid.mediacomposer*" + "com.bohemiancoding.*" + "com.figma.*" + "com.framerx.*" + "com.zeplin.*" + "com.invisionapp.*" + "com.principle.*" + "com.pixelmatorteam.*" + "com.affinitydesigner.*" + "com.affinityphoto.*" + "com.affinitypublisher.*" + "com.linearity.curve" + "com.canva.CanvaDesktop" + "com.maxon.cinema4d" + "com.autodesk.*" + "com.sketchup.*" + "com.native-instruments.*" + "com.fabfilter.*" + "com.paceap.*" + "com.izotope.*" + "iZotope" + "com.lasersoft-imaging.*" + "app.cotypist.Cotypist" + + # Communication + "com.tencent.xinWeChat" + "com.tencent.qq" + "com.alibaba.DingTalkMac" + "com.alibaba.AliLang.osx" + "com.alibaba.alilang3.osx.ShipIt" + "com.alibaba.AlilangMgr.QueryNetworkInfo" + "us.zoom.xos" + "com.microsoft.teams*" + "com.slack.Slack" + "com.hnc.Discord" + "app.legcord.Legcord" + "org.telegram.desktop" + "ru.keepcoder.Telegram" + "net.whatsapp.WhatsApp" + "com.skype.skype" + "com.cisco.webexmeetings" + "com.ringcentral.RingCentral" + "com.readdle.smartemail-Mac" + "com.airmail.*" + "com.postbox-inc.postbox" + "com.tinyspeck.slackmacgap" + + # Task Management + "com.omnigroup.OmniFocus*" + "com.culturedcode.*" + "com.todoist.*" + "com.any.do.*" + "com.ticktick.*" + "com.microsoft.to-do" + "com.trello.trello" + "com.asana.nativeapp" + "com.clickup.*" + "com.monday.desktop" + "com.airtable.airtable" + "com.notion.id" + "com.linear.linear" + + # File Transfer & Sync + "com.panic.transmit*" + "com.binarynights.ForkLift*" + "com.noodlesoft.Hazel" + "com.cyberduck.Cyberduck" + "io.filezilla.FileZilla" + "com.apple.Xcode.CloudDocuments" + "com.synology.*" + + # Cloud Storage & Backup + "com.dropbox.*" + "com.getdropbox.*" + "*dropbox*" + "ws.agile.*" + "com.backblaze.*" + "*backblaze*" + "com.box.desktop*" + "*box.desktop*" + "com.microsoft.OneDrive*" + "com.microsoft.SyncReporter" + "*OneDrive*" + "com.google.GoogleDrive" + "com.google.keystone*" + "*GoogleDrive*" + "com.amazon.drive" + "com.apple.bird" + "com.apple.CloudDocs*" + "com.displaylink.*" + "com.fujitsu.pfu.ScanSnap*" + "com.citrix.*" + "org.xquartz.*" + "us.zoom.updater*" + "com.DigiDNA.iMazing*" + "com.shirtpocket.*" + "homebrew.mxcl.*" + + # Remote Desktop / Remote Access + "org.chromium.chromoting*" + "com.google.chrome_remote_desktop*" + "com.teamviewer.*" + "com.realvnc.*" + "com.logmein.*" + "com.anydesk.*" + + # Screenshot & Recording + "com.cleanshot.*" + "com.xnipapp.xnip" + "com.reincubate.camo" + "com.tunabellysoftware.ScreenFloat" + "net.telestream.screenflow*" + "com.techsmith.snagit*" + "com.techsmith.camtasia*" + "com.obsidianapp.screenrecorder" + "com.kap.Kap" + "com.getkap.*" + "com.linebreak.CloudApp" + "com.droplr.droplr-mac" + + # Media & Entertainment + "com.spotify.client" + "com.apple.Music" + "com.apple.podcasts" + "com.apple.BKAgentService" + "com.apple.iBooksX" + "com.apple.iBooks" + "com.blackmagic-design.*" + "com.colliderli.iina" + "org.videolan.vlc" + "io.mpv" + "tv.plex.player.desktop" + "com.netease.163music" + + # Web Browsers + "Firefox" + "org.mozilla.*" + + # Scientific & Professional Software + "com.crowdstrike.*" + "com.kolide.*" + "com.sas.*" + "com.mathworks.*" + "com.ibm.spss.*" + "com.wolfram.*" + "com.stata.*" + "org.rstudio.*" + "com.tableausoftware.*" + + # License & App Stores + "com.paddle.Paddle*" + "com.quicken.*" + "com.setapp.DesktopClient" + "com.devmate.*" + "org.sparkle-project.Sparkle*" +) + +# Generic app-name words that collide with many unrelated LaunchAgents/Daemons. +# When an app's display name is exactly one of these, name-based plist matching +# is skipped (bundle-id matching still applies) so we never delete third-party or +# system agents that merely share the word. Shared by find_app_files() (user +# LaunchAgents) and find_app_system_files() (system LaunchAgents/Daemons) so the +# two scans stay symmetric. +readonly LAUNCH_AGENT_NAME_COMMON_WORDS="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility" diff --git a/Resources/mole/lib/core/base.sh b/Resources/mole/lib/core/base.sh index 5479fa3..4460aaa 100644 --- a/Resources/mole/lib/core/base.sh +++ b/Resources/mole/lib/core/base.sh @@ -10,19 +10,69 @@ if [[ -n "${MOLE_BASE_LOADED:-}" ]]; then fi readonly MOLE_BASE_LOADED=1 +# Cleanup libraries read "$DRY_RUN" in 70+ places without a default, and only the +# command entry points (bin/clean.sh and friends) assign it. Anything that sources +# a lib directly then calls into it therefore aborts on "unbound variable" under +# set -u, in branches that are only reached with specific fixtures. Default it +# once here rather than at each read site; entry points still assign over it. +: "${DRY_RUN:=false}" + # ============================================================================ # Color Definitions +# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes. # ============================================================================ -readonly ESC=$'\033' -readonly GREEN="${ESC}[0;32m" -readonly BLUE="${ESC}[1;34m" -readonly CYAN="${ESC}[0;36m" -readonly YELLOW="${ESC}[0;33m" -readonly PURPLE="${ESC}[0;35m" -readonly PURPLE_BOLD="${ESC}[1;35m" -readonly RED="${ESC}[0;31m" -readonly GRAY="${ESC}[0;90m" -readonly NC="${ESC}[0m" +if [[ -n "${NO_COLOR:-}" ]]; then + readonly ESC="" + readonly GREEN="" + readonly BLUE="" + readonly CYAN="" + readonly YELLOW="" + readonly PURPLE="" + readonly PURPLE_BOLD="" + readonly RED="" + readonly GRAY="" + readonly NC="" +else + readonly ESC=$'\033' + readonly GREEN="${ESC}[0;32m" + readonly BLUE="${ESC}[1;34m" + readonly CYAN="${ESC}[0;36m" + readonly YELLOW="${ESC}[0;33m" + readonly PURPLE="${ESC}[0;35m" + readonly PURPLE_BOLD="${ESC}[1;35m" + readonly RED="${ESC}[0;31m" + readonly GRAY="${ESC}[0;38;5;244m" + readonly NC="${ESC}[0m" +fi + +# Probe several process patterns without collapsing pgrep errors into "not +# running". Arguments are selector/pattern pairs, for example: +# mole_pgrep_any -x Xcode -f com.apple.dt.XCTest +# Returns 0 when any pattern matches, 1 only when every probe reports no match, +# and 2 when no pattern matches but at least one probe could not be completed. +mole_pgrep_any() { + if [[ $# -eq 0 || $(($# % 2)) -ne 0 ]] || ! command -v pgrep > /dev/null 2>&1; then + return 2 + fi + + local aggregate_rc=1 + local selector pattern probe_rc + while [[ $# -gt 0 ]]; do + selector="$1" + pattern="$2" + shift 2 + + probe_rc=0 + if pgrep "$selector" "$pattern" > /dev/null 2>&1; then + return 0 + else + probe_rc=$? + fi + [[ $probe_rc -eq 1 ]] || aggregate_rc=2 + done + + return "$aggregate_rc" +} # ============================================================================ # Icon Definitions @@ -38,7 +88,7 @@ readonly ICON_LIST="•" readonly ICON_SUBLIST="↳" readonly ICON_ARROW="➤" readonly ICON_DRY_RUN="→" -readonly ICON_REVIEW="☞" +readonly ICON_REVIEW="⊙" readonly ICON_NAV_UP="↑" readonly ICON_NAV_DOWN="↓" readonly ICON_INFO="ℹ" @@ -48,7 +98,16 @@ readonly ICON_INFO="ℹ" # ============================================================================ # Locate the lsregister binary (path varies across macOS versions). +# MOLE_LSREGISTER_PATH overrides the lookup when it is set, including when it +# is set empty, which disables every lsregister-backed scan. Tests use the +# empty form to keep a multi-second LaunchServices dump out of assertions that +# have nothing to do with launch services. get_lsregister_path() { + if [[ -n "${MOLE_LSREGISTER_PATH+x}" ]]; then + echo "$MOLE_LSREGISTER_PATH" + return 0 + fi + local -a candidates=( "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" "/System/Library/CoreServices/Frameworks/LaunchServices.framework/Support/lsregister" @@ -69,15 +128,19 @@ get_lsregister_path() { # ============================================================================ readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file retention (days) readonly MOLE_ORPHAN_AGE_DAYS=30 # Orphaned data retention (days) +readonly MOLE_DOTDIR_ORPHAN_AGE_DAYS=60 # Orphan dotfile hint threshold (days) readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachment size threshold readonly MOLE_MAIL_AGE_DAYS=30 # Mail attachment retention (days) readonly MOLE_LOG_AGE_DAYS=7 # Log retention (days) readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention (days) readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increased for safety +readonly MOLE_GPU_CACHE_AGE_DAYS=1 # Rebuildable GPU cache retention (days) readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # TM backup safety window (hours) readonly MOLE_MAX_DS_STORE_FILES=500 # Max .DS_Store files to clean per scan readonly MOLE_MAX_ORPHAN_ITERATIONS=100 # Max iterations for orphaned app data scan +readonly MOLE_ONE_GIB_KB=$((1024 * 1024)) +readonly MOLE_ONE_GB_BYTES=1000000000 # ============================================================================ # Whitelist Configuration @@ -96,6 +159,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( "$HOME/Library/Caches/pypoetry/virtualenvs*" "$HOME/Library/Caches/JetBrains*" "$HOME/Library/Caches/com.jetbrains.toolbox*" + "$HOME/Library/Caches/tealdeer/tldr-pages" "$HOME/Library/Application Support/JetBrains*" "$HOME/Library/Caches/com.apple.finder" "$HOME/Library/Mobile Documents*" @@ -109,11 +173,54 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( ) declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=( - "check_brew_health" - "check_touchid" - "check_git_config" ) +# Safety patterns always merge into an existing user whitelist file. +# Replacement semantics (V1.7.5+) treat the file as the complete set, so +# protections added later (FINDER_METADATA in V1.9.9) never reached users who +# already had a whitelist. Only hard safety belongs here; optional convenience +# defaults stay in DEFAULT_WHITELIST_PATTERNS and remain fully replaceable. +declare -a SAFETY_WHITELIST_PATTERNS=( + "$FINDER_METADATA_SENTINEL" +) + +# Append any missing SAFETY_WHITELIST_PATTERNS to WHITELIST_PATTERNS. +# When CURRENT_WHITELIST_PATTERNS is declared (manage UI), keep it in sync. +ensure_safety_whitelist_patterns() { + local safety existing found + [[ ${#SAFETY_WHITELIST_PATTERNS[@]} -eq 0 ]] && return 0 + + for safety in "${SAFETY_WHITELIST_PATTERNS[@]}"; do + found=false + if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + for existing in "${WHITELIST_PATTERNS[@]}"; do + if [[ "$existing" == "$safety" ]]; then + found=true + break + fi + done + fi + if [[ "$found" == "false" ]]; then + WHITELIST_PATTERNS+=("$safety") + fi + + if declare -p CURRENT_WHITELIST_PATTERNS &> /dev/null 2>&1; then + found=false + if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + for existing in "${CURRENT_WHITELIST_PATTERNS[@]}"; do + if [[ "$existing" == "$safety" ]]; then + found=true + break + fi + done + fi + if [[ "$found" == "false" ]]; then + CURRENT_WHITELIST_PATTERNS+=("$safety") + fi + fi + done +} + # ============================================================================ # BSD Stat Compatibility # ============================================================================ @@ -171,23 +278,6 @@ get_file_owner() { # System Utilities # ============================================================================ -# Check if System Integrity Protection is enabled -# Returns: 0 if SIP is enabled, 1 if disabled or cannot determine -is_sip_enabled() { - if ! command -v csrutil > /dev/null 2>&1; then - return 0 - fi - - local sip_status - sip_status=$(csrutil status 2> /dev/null || echo "") - - if echo "$sip_status" | grep -qi "enabled"; then - return 0 - else - return 1 - fi -} - # Detect CPU architecture # Returns: "Apple Silicon" or "Intel" detect_architecture() { @@ -204,42 +294,50 @@ detect_architecture() { echo "$MOLE_ARCH_CACHE" } -# Get free disk space on root volume -# Returns: human-readable string (e.g., "100G") -get_free_space() { +get_free_space_target() { local target="/" if [[ -d "/System/Volumes/Data" ]]; then target="/System/Volumes/Data" fi - df -h "$target" | awk 'NR==2 {print $4}' + printf '%s\n' "$target" } -# Get Darwin kernel major version (e.g., 24 for 24.2.0) -# Returns 999 on failure to adopt conservative behavior (assume modern system) -get_darwin_major() { - if [[ -n "${MOLE_DARWIN_MAJOR_CACHE:-}" ]]; then - echo "$MOLE_DARWIN_MAJOR_CACHE" +# Get free disk space on root volume in 1K blocks. +get_free_space_kb() { + local target + target=$(get_free_space_target) + + local available_kb + available_kb=$(command df -Pk "$target" 2> /dev/null | awk 'NR==2 {print $4}' || true) + if [[ "$available_kb" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$available_kb" return 0 fi - local kernel - kernel=$(uname -r 2> /dev/null || true) - local major="${kernel%%.*}" - if [[ ! "$major" =~ ^[0-9]+$ ]]; then - # Return high number to skip potentially dangerous operations on unknown systems - major=999 + return 1 +} + +format_free_space_kb() { + local free_kb="${1:-}" + if [[ "$free_kb" =~ ^[0-9]+$ ]]; then + bytes_to_human_kb "$free_kb" + return 0 fi - export MOLE_DARWIN_MAJOR_CACHE="$major" - echo "$major" + + echo "Unknown" } -# Check if Darwin kernel major version meets minimum -is_darwin_ge() { - local minimum="$1" - local major - major=$(get_darwin_major) - [[ "$major" -ge "$minimum" ]] +# Get free disk space on root volume. +# Returns: human-readable decimal string (e.g., "100.00GB") +get_free_space() { + local free_kb + if free_kb=$(get_free_space_kb) && [[ "$free_kb" =~ ^[0-9]+$ ]]; then + format_free_space_kb "$free_kb" + return $? + fi + + echo "Unknown" } # Get optimal parallel jobs for operation type (scan|io|compute|default) @@ -270,23 +368,6 @@ is_root_user() { [[ "$(id -u)" == "0" ]] } -get_invoking_user() { - if [[ -n "${_MOLE_INVOKING_USER_CACHE:-}" ]]; then - echo "$_MOLE_INVOKING_USER_CACHE" - return 0 - fi - - local user - if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then - user="$SUDO_USER" - else - user="${USER:-}" - fi - - export _MOLE_INVOKING_USER_CACHE="$user" - echo "$user" -} - get_invoking_uid() { if [[ -n "${SUDO_UID:-}" ]]; then echo "$SUDO_UID" @@ -459,62 +540,6 @@ ensure_user_file() { # Formatting Utilities # ============================================================================ -# Get brand-friendly localized name for an application -get_brand_name() { - local name="$1" - - # Detect if system primary language is Chinese (Cached) - if [[ -z "${MOLE_IS_CHINESE_SYSTEM:-}" ]]; then - local sys_lang - sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "") - if [[ -n "$sys_lang" ]]; then - export MOLE_IS_CHINESE_SYSTEM="true" - else - export MOLE_IS_CHINESE_SYSTEM="false" - fi - fi - - local is_chinese="${MOLE_IS_CHINESE_SYSTEM}" - - # Return localized names based on system language - if [[ "$is_chinese" == true ]]; then - # Chinese system - prefer Chinese names - case "$name" in - "qiyimac" | "iQiyi") echo "爱奇艺" ;; - "wechat" | "WeChat") echo "微信" ;; - "QQ") echo "QQ" ;; - "VooV Meeting") echo "腾讯会议" ;; - "dingtalk" | "DingTalk") echo "钉钉" ;; - "NeteaseMusic" | "NetEase Music") echo "网易云音乐" ;; - "BaiduNetdisk" | "Baidu NetDisk") echo "百度网盘" ;; - "alipay" | "Alipay") echo "支付宝" ;; - "taobao" | "Taobao") echo "淘宝" ;; - "futunn" | "Futu NiuNiu") echo "富途牛牛" ;; - "tencent lemon" | "Tencent Lemon Cleaner" | "Tencent Lemon") echo "腾讯柠檬清理" ;; - *) echo "$name" ;; - esac - else - # Non-Chinese system - use English names - case "$name" in - "qiyimac" | "爱奇艺") echo "iQiyi" ;; - "wechat" | "微信") echo "WeChat" ;; - "QQ") echo "QQ" ;; - "腾讯会议") echo "VooV Meeting" ;; - "dingtalk" | "钉钉") echo "DingTalk" ;; - "网易云音乐") echo "NetEase Music" ;; - "百度网盘") echo "Baidu NetDisk" ;; - "alipay" | "支付宝") echo "Alipay" ;; - "taobao" | "淘宝") echo "Taobao" ;; - "富途牛牛") echo "Futu NiuNiu" ;; - "腾讯柠檬清理" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;; - "keynote" | "Keynote") echo "Keynote" ;; - "pages" | "Pages") echo "Pages" ;; - "numbers" | "Numbers") echo "Numbers" ;; - *) echo "$name" ;; - esac - fi -} - # Convert bytes to human-readable format (e.g., 1.5GB) # macOS (since Snow Leopard) uses Base-10 calculation (1 KB = 1000 bytes) bytes_to_human() { @@ -547,6 +572,111 @@ bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))" } +format_free_space_delta_kb() { + local delta_kb="${1:-0}" + [[ "$delta_kb" =~ ^-?[0-9]+$ ]] || delta_kb=0 + + local sign="" + local abs_kb="$delta_kb" + if ((delta_kb > 0)); then + sign="+" + elif ((delta_kb < 0)); then + sign="-" + abs_kb=$((-delta_kb)) + fi + + printf '%s%s\n' "$sign" "$(bytes_to_human_kb "$abs_kb")" +} + +mole_is_reverse_dns_bundle_id() { + local bundle_id="${1:-}" + + [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] || return 1 + [[ "$bundle_id" =~ ^[A-Za-z0-9][-A-Za-z0-9]*(\.[A-Za-z0-9][-A-Za-z0-9]*)+$ ]] +} + +mole_name_starts_with_bundle_id_boundary() { + local name="${1##*/}" + local bundle_id="${2:-}" + + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + [[ "$name" == "$bundle_id" || + "$name" == "$bundle_id".* ]] +} + +mole_name_has_bundle_id_boundary() { + local name="${1##*/}" + local bundle_id="${2:-}" + + mole_name_starts_with_bundle_id_boundary "$name" "$bundle_id" && return 0 + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + [[ "$name" == *."$bundle_id" || + "$name" == *."$bundle_id".* ]] +} + +# Colorize an already-formatted human size string by unit. +colorize_human_size() { + local size_human="$1" + + local size_color="" + case "$size_human" in + *GB) size_color="$RED" ;; + *MB) size_color="$YELLOW" ;; + *KB) size_color="$GREEN" ;; + *B) size_color="$GRAY" ;; + *) + printf '%s' "$size_human" + return 0 + ;; + esac + + printf '%s%s%s' "$size_color" "$size_human" "$NC" +} + +# Cleanup result lines are always shown in green. Kept as a function (callers +# still pass a size in KB) so per-size coloring can be reintroduced in one place +# if ever wanted. +cleanup_result_color_kb() { + printf '%s' "$GREEN" +} + +# Percent-encode a filesystem path for use in a file:// URL. Byte-wise loop +# under LC_ALL=C so multibyte characters are encoded per byte (bash 3.2 has +# no built-in encoder). +percent_encode_path() { + local LC_ALL=C + local input="$1" + local out="" ch i val + for ((i = 0; i < ${#input}; i++)); do + ch="${input:i:1}" + case "$ch" in + [a-zA-Z0-9/._~-]) out+="$ch" ;; + *) + # bash 3.2 returns negative values for bytes >= 128; mask to a byte. + val=$(printf '%d' "'$ch") + out+=$(printf '%%%02X' $((val & 255))) + ;; + esac + done + printf '%s' "$out" +} + +# Print a path as an OSC 8 file:// hyperlink so terminals keep it clickable +# even when it contains spaces (auto-detection breaks on whitespace). Shows +# the ~-abbreviated path; piped output and non-ANSI terminals get plain text. +format_path_link() { + local path="$1" + local display="${path/#$HOME/~}" + if ! is_ansi_supported 2> /dev/null; then + printf '%s' "$display" + return 0 + fi + # ESC-backslash is the OSC 8 string terminator; kept in a variable since + # a single-quoted printf format ending in \\ trips ShellCheck SC1003. + local st=$'\033\\' + printf '\033]8;;file://%s%s%s\033]8;;%s' "$(percent_encode_path "$path")" "$st" "$display" "$st" +} + # ============================================================================ # Temporary File Management # ============================================================================ @@ -555,10 +685,189 @@ bytes_to_human_kb() { declare -a MOLE_TEMP_FILES=() declare -a MOLE_TEMP_DIRS=() +normalize_temp_root() { + local path="${1:-}" + [[ -z "$path" ]] && return 1 + + if [[ "$path" == "~"* ]]; then + path="${path/#\~/$HOME}" + fi + + while [[ "$path" != "/" && "$path" == */ ]]; do + path="${path%/}" + done + + [[ -n "$path" ]] || return 1 + printf '%s\n' "$path" +} + +probe_temp_root() { + local raw_path="$1" + local allow_create="${2:-false}" + local path + local probe="" + + path=$(normalize_temp_root "$raw_path") || return 1 + + if [[ "$allow_create" == "true" ]]; then + ensure_user_dir "$path" + fi + + [[ -d "$path" ]] || return 1 + + probe=$(mktemp "$path/mole.probe.XXXXXX" 2> /dev/null) || return 1 + rm -f "$probe" 2> /dev/null || true + + printf '%s\n' "$path" +} + +# Remove abandoned files only from Mole's dedicated fallback temp directory. +# Persistent cache files live one level above this directory and are never +# included. A one-day grace period avoids racing with concurrent long-running +# Mole processes while bounding leftovers from interrupted runs. +prune_stale_mole_temp_files() { + local root="${1:-}" + local invoking_home="" + local max_age_minutes="${MOLE_TEMP_STALE_MINUTES:-1440}" + + [[ "$max_age_minutes" =~ ^[0-9]+$ ]] || max_age_minutes=1440 + [[ -n "$root" && -d "$root" && ! -L "$root" ]] || return 0 + + if is_root_user; then + [[ "$root" == "/private/var/root/.cache/mole/tmp" ]] || return 0 + else + invoking_home=$(get_invoking_home) + [[ -n "$invoking_home" ]] || return 0 + [[ "$root" == "${invoking_home%/}/.cache/mole/tmp" ]] || return 0 + fi + + find "$root" -mindepth 1 -maxdepth 1 \( -type f -o -type l \) \ + -mmin "+$max_age_minutes" -exec rm -f -- {} + 2> /dev/null || true # SAFE: dedicated Mole temp root only + + # Spinner control directories contain only flat control files. Remove + # their contents without recursive deletion, then rmdir the now-empty + # directory. Unexpected nested content makes rmdir fail closed. + local stale_dir + while IFS= read -r -d '' stale_dir; do + case "$stale_dir" in + "$root"/.mole-spinner.*) ;; + *) continue ;; + esac + [[ -d "$stale_dir" && ! -L "$stale_dir" && -O "$stale_dir" ]] || continue + find "$stale_dir" -mindepth 1 -maxdepth 1 \( -type f -o -type l \) \ + -exec rm -f -- {} + 2> /dev/null || true # SAFE: validated spinner control dir only + rmdir "$stale_dir" 2> /dev/null || true + done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -name '.mole-spinner.*' \ + -mmin "+$max_age_minutes" -print0 2> /dev/null) +} + +initialize_mole_temp_registry_path() { + [[ -n "${MOLE_RESOLVED_TMPDIR:-}" ]] || return 1 + + # Bash keeps $$ stable inside command substitutions and across exec, so the + # parent, its subshells, and an exec'd bin/*.sh all derive the same registry + # path. A forked child gets a different $$: the registry is exported, so an + # inherited value that no longer matches belongs to the parent process, and + # adopting it would make the child's exit cleanup delete the parent's live + # temp files. `mo update` lost its downloaded installer exactly this way, + # because install.sh runs the freshly installed `mole --version`. + local owned="${MOLE_RESOLVED_TMPDIR%/}/mole.registry.$$" + [[ "${MOLE_TEMP_REGISTRY_FILE:-}" == "$owned" ]] && return 0 + + MOLE_TEMP_REGISTRY_FILE="$owned" + export MOLE_TEMP_REGISTRY_FILE +} + +ensure_mole_temp_registry_file() { + initialize_mole_temp_registry_path || return 1 + + case "$MOLE_TEMP_REGISTRY_FILE" in + "${MOLE_RESOLVED_TMPDIR%/}"/mole.registry.*) ;; + *) return 1 ;; + esac + + if [[ ! -e "$MOLE_TEMP_REGISTRY_FILE" ]]; then + (umask 077 && set -C && : > "$MOLE_TEMP_REGISTRY_FILE") 2> /dev/null || true + fi + + [[ -f "$MOLE_TEMP_REGISTRY_FILE" && ! -L "$MOLE_TEMP_REGISTRY_FILE" && -O "$MOLE_TEMP_REGISTRY_FILE" ]] +} + +ensure_mole_temp_root() { + if is_root_user; then + # Whole-command sudo must not reuse TMPDIR or the invoking user's cache + # for root-written registries and command output. Keep all root temp + # state below root's private home so a lower-trust user cannot rename a + # checked file between validation and append/read operations. + local root_home="/private/var/root" + [[ -d "$root_home" && ! -L "$root_home" && -O "$root_home" ]] || root_home="/var/root" + [[ -d "$root_home" && ! -L "$root_home" && -O "$root_home" ]] || return 1 + + local root_temp="$root_home/.cache/mole/tmp" + mkdir -p "$root_temp" 2> /dev/null || return 1 + chmod 700 "$root_home/.cache" "$root_home/.cache/mole" "$root_temp" 2> /dev/null || true + root_temp=$(cd -P "$root_temp" 2> /dev/null && pwd) || return 1 + [[ "$root_temp" == "$root_home/.cache/mole/tmp" && -d "$root_temp" && ! -L "$root_temp" && -O "$root_temp" ]] || return 1 + + MOLE_RESOLVED_TMPDIR="$root_temp" + export MOLE_RESOLVED_TMPDIR + prune_stale_mole_temp_files "$MOLE_RESOLVED_TMPDIR" + case "${MOLE_TEMP_REGISTRY_FILE:-}" in + "$root_temp"/mole.registry.*) ;; + *) unset MOLE_TEMP_REGISTRY_FILE ;; + esac + initialize_mole_temp_registry_path || true + return 0 + fi + + if [[ -n "${MOLE_RESOLVED_TMPDIR:-}" ]]; then + initialize_mole_temp_registry_path || true + return 0 + fi + + local resolved="" + local candidate="${TMPDIR:-}" + local invoking_home="" + + if [[ -n "$candidate" ]]; then + resolved=$(probe_temp_root "$candidate" false || true) + fi + + if [[ -z "$resolved" ]]; then + invoking_home=$(get_invoking_home) + if [[ -n "$invoking_home" ]]; then + resolved=$(probe_temp_root "$invoking_home/.cache/mole/tmp" true || true) + fi + fi + + if [[ -z "$resolved" ]]; then + resolved=$(probe_temp_root "/tmp" false || true) + fi + + [[ -n "$resolved" ]] || resolved="/tmp" + MOLE_RESOLVED_TMPDIR="$resolved" + export MOLE_RESOLVED_TMPDIR + initialize_mole_temp_registry_path || true + prune_stale_mole_temp_files "$MOLE_RESOLVED_TMPDIR" +} + +prepare_mole_tmpdir() { + ensure_mole_temp_root + export TMPDIR="$MOLE_RESOLVED_TMPDIR" + printf '%s\n' "$MOLE_RESOLVED_TMPDIR" +} + +mole_temp_path_template() { + local prefix="${1:-mole}" + ensure_mole_temp_root + printf '%s/%s.XXXXXX\n' "$MOLE_RESOLVED_TMPDIR" "$prefix" +} + # Create tracked temporary file create_temp_file() { local temp - temp=$(mktemp) || return 1 + ensure_mole_temp_root + temp=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole.XXXXXX") || return 1 register_temp_file "$temp" echo "$temp" } @@ -566,7 +875,8 @@ create_temp_file() { # Create tracked temporary directory create_temp_dir() { local temp - temp=$(mktemp -d) || return 1 + ensure_mole_temp_root + temp=$(mktemp -d "$MOLE_RESOLVED_TMPDIR/mole.XXXXXX") || return 1 register_temp_dir "$temp" echo "$temp" } @@ -574,11 +884,17 @@ create_temp_dir() { # Register existing file for cleanup register_temp_file() { MOLE_TEMP_FILES+=("$1") + if ensure_mole_temp_registry_file; then + printf '%s\n' "$1" >> "$MOLE_TEMP_REGISTRY_FILE" 2> /dev/null || true + fi } # Register existing directory for cleanup register_temp_dir() { MOLE_TEMP_DIRS+=("$1") + if ensure_mole_temp_registry_file; then + printf '%s\n' "$1" >> "$MOLE_TEMP_REGISTRY_FILE" 2> /dev/null || true + fi } # Create temp file with prefix (for analyze.sh compatibility) @@ -587,9 +903,8 @@ mktemp_file() { local prefix="${1:-mole}" local temp local error_msg - # Use TMPDIR if set, otherwise /tmp # Add .XXXXXX suffix to work with both BSD and GNU mktemp - if ! error_msg=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" 2>&1); then + if ! error_msg=$(mktemp "$(mole_temp_path_template "$prefix")" 2>&1); then echo "Error: Failed to create temporary file: $error_msg" >&2 return 1 fi @@ -600,7 +915,9 @@ mktemp_file() { # Cleanup all tracked temp files and directories cleanup_temp_files() { - stop_inline_spinner || true + if declare -F stop_inline_spinner > /dev/null 2>&1; then + stop_inline_spinner || true + fi local file if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then for file in "${MOLE_TEMP_FILES[@]}"; do @@ -614,6 +931,25 @@ cleanup_temp_files() { done fi + # Command substitutions run mktemp_file/create_temp_* in a child shell, so + # their in-memory array updates cannot reach this parent. The registry is + # shared across those shells and closes that cleanup gap. See #1203. + if ensure_mole_temp_registry_file; then + local registered_path + while IFS= read -r registered_path; do + [[ -n "$registered_path" ]] || continue + [[ "$registered_path" == "${MOLE_RESOLVED_TMPDIR%/}/"* ]] || continue + [[ ! "$registered_path" =~ (^|/)\.\.(\/|$) ]] || continue + + if [[ -d "$registered_path" && ! -L "$registered_path" ]]; then + rm -rf "$registered_path" 2> /dev/null || true # SAFE: mktemp dir registered under resolved Mole temp root + else + rm -f "$registered_path" 2> /dev/null || true + fi + done < "$MOLE_TEMP_REGISTRY_FILE" + rm -f "$MOLE_TEMP_REGISTRY_FILE" 2> /dev/null || true + fi + MOLE_TEMP_FILES=() MOLE_TEMP_DIRS=() } @@ -626,6 +962,26 @@ cleanup_temp_files() { TRACK_SECTION=0 SECTION_ACTIVITY=0 +# IMPORTANT: There are intentionally three start_section / end_section / +# note_activity implementations across the codebase. The one that wins is the +# one loaded last, and each variant has product-level differences (color, +# fallback wording, dry-run export behavior). Before changing any of them, +# read the cross references first: +# +# - lib/core/base.sh (this file): purple arrow header, "Nothing to tidy" +# fallback, no dry-run export. +# - bin/clean.sh: purple arrow header, erases the header of idle +# sections on ANSI TTYs ("Nothing to clean" fallback +# when piped or under MO_DEBUG), appends '=== title ===' +# to EXPORT_LIST_FILE under DRY_RUN, stops the section +# spinner on close. +# - bin/purge.sh: blue ━━━ box header, no fallback message, writes +# each note_activity line directly to EXPORT_LIST_FILE. +# +# Treat this file's version as the default for everything outside the clean +# and purge entry points. Do not unify the three blindly; the wording and +# export semantics are user-visible. + # Start a new section # Args: $1 - section title start_section() { @@ -651,26 +1007,31 @@ note_activity() { fi } -# Start a section spinner with optional message +# Start a section spinner with optional message. When a spinner is already +# running, swap its text in place instead of restarting the subprocess: the +# stop/start cycle blanks the line for a frame and reads as flicker. # Usage: start_section_spinner "message" start_section_spinner() { local message="${1:-Scanning...}" - stop_inline_spinner || true if [[ -t 1 ]]; then + if declare -F update_inline_spinner_message > /dev/null 2>&1 && + update_inline_spinner_message "$message"; then + return 0 + fi + stop_inline_spinner || true MOLE_SPINNER_PREFIX=" " start_inline_spinner "$message" + else + stop_inline_spinner || true fi } # Stop spinner and clear the line # Usage: stop_section_spinner stop_section_spinner() { - # Always try to stop spinner (function handles empty PID gracefully) + # stop_inline_spinner clears the line itself when a spinner was running; + # a second unconditional clear here only blanked the row an extra frame + # right before result rows printed. stop_inline_spinner || true - # Always clear line to handle edge cases where spinner output remains - # (e.g., spinner was stopped elsewhere but line not cleared) - if [[ -t 1 ]]; then - printf "\r\033[2K" >&2 || true - fi } # Safe terminal line clearing with terminal type detection @@ -684,11 +1045,17 @@ safe_clear_lines() { # Note: This forward reference works because functions are parsed before execution is_ansi_supported 2> /dev/null || return 1 - # Clear lines one by one (more reliable than multi-line sequences) + [[ "$lines" =~ ^[0-9]+$ && "$lines" -gt 0 ]] || return 0 + + # Emit the whole erase as one write so the terminal renders it in a + # single frame; per-line writes flash intermediate states. + local sequence="" local i for ((i = 0; i < lines; i++)); do - printf "\033[1A\r\033[2K" > "$tty_device" 2> /dev/null || return 1 + sequence+="\033[1A\r\033[2K" done + # shellcheck disable=SC2059 + printf "$sequence" > "$tty_device" 2> /dev/null || return 1 return 0 } @@ -721,16 +1088,18 @@ update_progress_if_needed() { # Get last update time from variable local last_time + # eval: indirect read by name; bash 3.2 has no nameref (declare -n) eval "last_time=\${$last_update_var:-0}" [[ "$last_time" =~ ^[0-9]+$ ]] || last_time=0 # Check if enough time has elapsed if [[ $((current_time - last_time)) -ge $interval ]]; then - # Update the spinner with progress - stop_section_spinner + # Update the spinner text in place; restarting it here blinked the + # line on every progress tick. start_section_spinner "Scanning items... $completed/$total" # Update the last_update_time variable + # eval: indirect write by name; bash 3.2 has no nameref eval "$last_update_var=$current_time" return 0 fi @@ -787,3 +1156,105 @@ is_ansi_supported() { ;; esac } + +# Record that a cleanup family was skipped because the app was running, so the +# clean summary can tell the user which apps to quit and re-run. +# +# `defer_cleanup_family` is the real ledger and lives in bin/clean.sh, which is +# the only production entry point that sources lib/clean/*. A cleanup lib +# sourced on its own (every standalone Bats case) has no ledger, so this drops +# the family into the debug log instead of failing. +# +# This is NOT one of the three-way-forked helpers documented above start_section: +# there is exactly one implementation and callers must not fork their own. Three +# byte-identical copies of it grew in lib/clean/{dev,user,app_caches}.sh before +# it landed here, which is the reason it is a shared function rather than a +# convention. `tests/clean_core.bats` pins that they do not come back. +mole_defer_cleanup_family() { + if declare -f defer_cleanup_family > /dev/null 2>&1; then + defer_cleanup_family "$1" + else + debug_log "Deferred cleanup while active: $1" + fi +} + +# Why a cleanup delete guard refused, read by the caller right after a denial. +# Dynamically scoped rather than returned on stdout on purpose: guards run at +# the delete boundary, where a command substitution would fork per candidate. +# Callers that need it isolated declare `local _MOLE_CLEAN_GUARD_REASON` in the +# wrapper that owns the cleanup. +_MOLE_CLEAN_GUARD_REASON="" + +# Turn a tri-state process probe into an allow/deny plus that reason. +# +# Probe contract: 0 = the app is running, 1 = it is not, 2 = could not tell. +# State 2 must deny. An unreadable process table is not evidence the app is +# closed, and a copy of this block that folds 2 into "not running" silently +# turns "unknown" into "safe to delete" on a path that then removes the files. +# Nine guards across dev.sh, user.sh, and app_caches.sh open-coded these six +# lines before they landed here; one transcription slip in any of them was a +# deletion while the owning app was live. +# +# Compound guards (Codex runtime/staging, Claude Desktop, versioned agents) call +# this for the process question and then add their own evidence. +# The optional third argument overrides the unknown-state wording. Only the +# default "process state unknown" is echoed against the item by +# mole_report_guard_stop; a guard that supplies its own wording (the Codex +# Sparkle updater probe) is deliberately routed to the deferred-family list +# instead, so keep the two in step when changing either. +mole_clean_process_guard() { + local probe="$1" + local busy_reason="$2" + local unknown_reason="${3:-process state unknown}" + local process_state=0 + "$probe" || process_state=$? + if [[ $process_state -eq 1 ]]; then + return 0 + fi + + _MOLE_CLEAN_GUARD_REASON="$busy_reason" + [[ $process_state -eq 2 ]] && _MOLE_CLEAN_GUARD_REASON="$unknown_reason" + return 1 +} + +# Report a guard refusal. An unknown process state is the user's problem to see +# now (it means Mole could not tell, not that it found something running), so it +# prints against the item. A known-running app is ordinary and goes to the +# end-of-run "Skipped while active" list instead of a line per cache. +# Usage: mole_report_guard_stop "Xcode cache" mole_defer_cleanup_family "Xcode" +mole_report_guard_stop() { + local display_name="$1" + shift + if [[ "$_MOLE_CLEAN_GUARD_REASON" == "process state unknown" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${display_name} · stopped (${_MOLE_CLEAN_GUARD_REASON})" + note_activity + else + "$@" + fi +} + +# Does any of these targets survive the eligibility filter, i.e. would a real +# cleanup have anything to do? +# +# Callers use it to decide whether an active app is worth reporting as skipped: +# deferring "Xcode" when every candidate was already whitelisted tells the user +# to quit an app for no reason. The predicate list mirrors the one +# `_safe_clean_impl` applies before it consults the delete guard, so the two +# agree on what "eligible" means; broken symlinks are excluded there too. +mole_cleanup_targets_exist() { + local target + for target in "$@"; do + [[ -e "$target" ]] || continue + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$target" 2> /dev/null; then + continue + fi + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$target" 2> /dev/null; then + continue + fi + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$target" 2> /dev/null; then + continue + fi + return 0 + done + return 1 +} diff --git a/Resources/mole/lib/core/bundle_resolver.sh b/Resources/mole/lib/core/bundle_resolver.sh new file mode 100644 index 0000000..1bb666d --- /dev/null +++ b/Resources/mole/lib/core/bundle_resolver.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# Mole - Bundle ID resolution. +# Resolves whether a bundle ID belongs to an installed application on this system. +# Spotlight (mdfind) is unreliable: indexing can be off for /Applications, Homebrew +# installs sometimes skip metadata importers, and Spotlight rarely indexes helpers +# embedded inside .app bundles. This resolver falls back to a direct filesystem +# scan that reads each app's Info.plist and checks SMJobBless-registered helpers. + +if [[ -n "${_MOLE_BUNDLE_RESOLVER_LOADED:-}" ]]; then + return 0 +fi +readonly _MOLE_BUNDLE_RESOLVER_LOADED=1 + +_MOLE_BUNDLE_RESOLVER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "${MOLE_TIMEOUTS_LOADED:-}" ]]; then + # shellcheck source=lib/core/timeouts.sh + source "$_MOLE_BUNDLE_RESOLVER_DIR/timeouts.sh" +fi + +# Standard locations for installed apps on macOS. Overridable from tests. +_MOLE_BUNDLE_RESOLVER_APP_ROOTS=( + "/Applications" + "/Applications/Setapp" + "/Applications/Utilities" + "/System/Applications" + "/System/Applications/Utilities" + "$HOME/Applications" + "$HOME/Library/Application Support/Setapp/Applications" + "/opt/homebrew/Caskroom" + "/usr/local/Caskroom" + "/Library/Input Methods" + "$HOME/Library/Input Methods" +) + +# Return 0 if some installed app either has the given CFBundleIdentifier, or +# registers a privileged helper with that ID via SMJobBless +# (Contents/Library/LaunchServices/). Return 1 otherwise. +# +# Intended for orphan/stale detection: answering "is this launchagent or +# privileged helper associated with an app that still exists on disk?" +bundle_has_installed_app() { + local bundle_id="$1" + local deadline_seconds="${2:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" + [[ -z "$bundle_id" ]] && return 1 + + # Reject malformed IDs to avoid feeding junk into mdfind/find. + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + + # Fast path: Spotlight. Gated with a timeout because mdfind has been known + # to wedge on misconfigured indexes. + if command -v mdfind > /dev/null 2>&1; then + # Ordinary lookup failures fall through to the filesystem scan below; + # signals are propagated so a destructive caller cannot ignore Ctrl-C. + local hit="" + if declare -f run_with_timeout > /dev/null 2>&1; then + local mdfind_timeout="$MOLE_TIMEOUT_QUICK_DETECT_SEC" + if [[ -n "$deadline_seconds" ]] && declare -f _mole_timeout_with_deadline > /dev/null 2>&1; then + mdfind_timeout=$(_mole_timeout_with_deadline "$mdfind_timeout" "$deadline_seconds") || return 0 + fi + local mdfind_rc=0 + hit=$(run_with_timeout "$mdfind_timeout" mdfind \ + "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null) || mdfind_rc=$? + [[ $mdfind_rc -ge 128 ]] && return "$mdfind_rc" + hit="${hit%%$'\n'*}" + else + local mdfind_rc=0 + hit=$(mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null) || mdfind_rc=$? + [[ $mdfind_rc -ge 128 ]] && return "$mdfind_rc" + hit="${hit%%$'\n'*}" + fi + [[ -n "$hit" ]] && return 0 + fi + + # Slow path: walk known app roots. Reads each Info.plist CFBundleIdentifier + # and checks for an SMJobBless helper registered under this bundle ID. This + # covers the two classes of false positive we saw: + # - App-owned launch agents whose bundle ID Spotlight failed to index + # (e.g. org.keepassxc.KeePassXC from Homebrew) -- issue #732 + # - Privileged helpers embedded in a parent .app under + # Contents/Library/LaunchServices/ (e.g. the Adobe + # ARMDC helpers shipped inside Adobe Acrobat DC.app) -- issue #733 + # Bundle IDs are case-insensitive on macOS and helper suffixes ship in both + # cases (com.foo.app.helper, com.stclairsoft.AppTamer.Helper), so derive the + # parent ID and compare identifiers on lowercased copies. See #1210. + local bundle_id_lower + bundle_id_lower=$(printf '%s' "$bundle_id" | tr '[:upper:]' '[:lower:]') + + local parent_id_lower="" + local suffix + for suffix in ".helper" ".daemon" ".agent" ".xpc" ".service"; do + if [[ "$bundle_id_lower" == *"$suffix" ]]; then + parent_id_lower="${bundle_id_lower%"$suffix"}" + break + fi + done + + local -a mapped_app_bundles=() + case "$bundle_id" in + com.microsoft.autoupdate.helper | com.microsoft.office.licensingV2.helper) + mapped_app_bundles=( + "com.microsoft.Word" + "com.microsoft.Excel" + "com.microsoft.Powerpoint" + "com.microsoft.Outlook" + "com.microsoft.OneNote" + ) + ;; + esac + + local app_root app info app_bundle + for app_root in "${_MOLE_BUNDLE_RESOLVER_APP_ROOTS[@]}"; do + [[ -d "$app_root" ]] || continue + if [[ $SECONDS -ge $deadline_seconds ]]; then + return 0 + fi + local app_scan_file="" + if ! app_scan_file=$(create_temp_file 2> /dev/null); then + return 0 + fi + local app_scan_timeout="$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" + local app_scan_rc=0 + local app_scan_depth=1 + case "$app_root" in + */Caskroom | */Setapp/Applications) + app_scan_depth=3 + ;; + esac + app_scan_timeout=$(_mole_timeout_with_deadline "$app_scan_timeout" \ + "$deadline_seconds") || app_scan_rc=$? + if [[ $app_scan_rc -eq 0 ]]; then + run_with_timeout "$app_scan_timeout" /usr/bin/find "$app_root" \ + -maxdepth "$app_scan_depth" -name "*.app" -print0 \ + < /dev/null > "$app_scan_file" 2> /dev/null || app_scan_rc=$? + fi + if [[ $app_scan_rc -ne 0 ]]; then + rm -f -- "$app_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + [[ $app_scan_rc -ge 128 ]] && return "$app_scan_rc" + return 0 + fi + local app_scan_found_or_unknown=false + local app_scan_interrupt_rc=0 + while IFS= read -r -d '' app; do + [[ $SECONDS -lt $deadline_seconds ]] || { + app_scan_found_or_unknown=true + break + } + if [[ -e "$app/Contents/Library/LaunchServices/$bundle_id" ]]; then + app_scan_found_or_unknown=true + break + fi + info="$app/Contents/Info.plist" + [[ -f "$info" ]] || continue + local plist_timeout="" + plist_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || { + app_scan_found_or_unknown=true + break + } + local plist_probe_rc=0 + app_bundle=$(run_with_timeout "$plist_timeout" plutil \ + -extract CFBundleIdentifier raw "$info" 2> /dev/null) || plist_probe_rc=$? + if [[ $plist_probe_rc -ne 0 || -z "$app_bundle" ]]; then + if [[ $plist_probe_rc -ge 128 ]]; then + app_scan_interrupt_rc=$plist_probe_rc + break + fi + app_scan_found_or_unknown=true + break + fi + local app_bundle_lower + app_bundle_lower=$(printf '%s' "$app_bundle" | tr '[:upper:]' '[:lower:]') + if [[ "$app_bundle_lower" == "$bundle_id_lower" || + -n "$parent_id_lower" && "$app_bundle_lower" == "$parent_id_lower" ]]; then + app_scan_found_or_unknown=true + break + fi + if ((${#mapped_app_bundles[@]} > 0)); then + local mapped_bundle + for mapped_bundle in "${mapped_app_bundles[@]}"; do + if [[ "$app_bundle" == "$mapped_bundle" ]]; then + app_scan_found_or_unknown=true + break + fi + done + [[ "$app_scan_found_or_unknown" == "true" ]] && break + fi + done < "$app_scan_file" + rm -f -- "$app_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + [[ $app_scan_interrupt_rc -ne 0 ]] && return "$app_scan_interrupt_rc" + [[ "$app_scan_found_or_unknown" == "true" ]] && return 0 + done + + return 1 +} diff --git a/Resources/mole/lib/core/commands.sh b/Resources/mole/lib/core/commands.sh index 3d2559e..87a770d 100644 --- a/Resources/mole/lib/core/commands.sh +++ b/Resources/mole/lib/core/commands.sh @@ -4,9 +4,10 @@ MOLE_COMMANDS=( "clean:Free up disk space" "uninstall:Remove apps completely" - "optimize:Check and maintain system" + "optimize:Refresh caches and services" "analyze:Explore disk usage" "status:Monitor system health" + "history:Review cleanup activity" "purge:Remove old project artifacts" "installer:Find and remove installer files" "touchid:Configure Touch ID for sudo" diff --git a/Resources/mole/lib/core/common.sh b/Resources/mole/lib/core/common.sh index 38f7640..dfa74d7 100755 --- a/Resources/mole/lib/core/common.sh +++ b/Resources/mole/lib/core/common.sh @@ -14,19 +14,62 @@ _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load core modules source "$_MOLE_CORE_DIR/base.sh" +prepare_mole_tmpdir > /dev/null source "$_MOLE_CORE_DIR/log.sh" source "$_MOLE_CORE_DIR/timeout.sh" +source "$_MOLE_CORE_DIR/timeouts.sh" source "$_MOLE_CORE_DIR/file_ops.sh" source "$_MOLE_CORE_DIR/help.sh" source "$_MOLE_CORE_DIR/ui.sh" source "$_MOLE_CORE_DIR/app_protection.sh" +source "$_MOLE_CORE_DIR/bundle_resolver.sh" +source "$_MOLE_CORE_DIR/pkg_receipts.sh" # Load sudo management if available if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then source "$_MOLE_CORE_DIR/sudo.sh" fi +# Normalize a path for comparisons while preserving root. +mole_normalize_path() { + local path="$1" + local normalized="${path%/}" + [[ -n "$normalized" ]] && printf '%s\n' "$normalized" || printf '%s\n' "$path" +} + +# Return a stable identity for an existing path. Prefer dev+inode so aliased +# paths on case-insensitive filesystems or symlinks collapse to one identity. +mole_path_identity() { + local path="$1" + local normalized + normalized=$(mole_normalize_path "$path") + + if [[ -e "$normalized" || -L "$normalized" ]]; then + if command -v stat > /dev/null 2>&1; then + local fs_id="" + fs_id=$(stat -L -f '%d:%i' "$normalized" 2> /dev/null || stat -f '%d:%i' "$normalized" 2> /dev/null || true) + if [[ "$fs_id" =~ ^[0-9]+:[0-9]+$ ]]; then + printf 'inode:%s\n' "$fs_id" + return 0 + fi + fi + fi + + printf 'path:%s\n' "$normalized" +} + +mole_identity_in_list() { + local needle="$1" + shift + + local existing + for existing in "$@"; do + [[ "$existing" == "$needle" ]] && return 0 + done + return 1 +} + # Update via Homebrew update_via_homebrew() { local current_version="$1" @@ -44,9 +87,9 @@ update_via_homebrew() { echo "Updating Homebrew..." fi - brew update > "$temp_update" 2>&1 & - local update_pid=$! - wait $update_pid 2> /dev/null || true # Continue even if brew update fails + local brew_update_timeout="${MOLE_HOMEBREW_UPDATE_TIMEOUT:-120}" + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$brew_update_timeout" brew update > "$temp_update" 2>&1 || true if [[ -t 1 ]]; then stop_inline_spinner @@ -59,9 +102,10 @@ update_via_homebrew() { echo "Upgrading Mole..." fi - brew upgrade mole > "$temp_upgrade" 2>&1 & - local upgrade_pid=$! - wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails + local brew_upgrade_timeout="${MOLE_HOMEBREW_UPGRADE_TIMEOUT:-120}" + local upgrade_status=0 + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$brew_upgrade_timeout" brew upgrade mole > "$temp_upgrade" 2>&1 || upgrade_status=$? local upgrade_output upgrade_output=$(cat "$temp_upgrade") @@ -77,22 +121,30 @@ update_via_homebrew() { safe_remove "$temp_update" true safe_remove "$temp_upgrade" true - if echo "$upgrade_output" | grep -q "already installed"; then + if [[ "$upgrade_status" -ne 0 ]]; then + log_error "Homebrew upgrade failed" + if [[ -n "$upgrade_output" ]]; then + printf '%s\n' "$upgrade_output" >&2 + else + printf 'brew upgrade mole exited with status %s\n' "$upgrade_status" >&2 + fi + return 1 + elif echo "$upgrade_output" | grep -q "already installed"; then local installed_version - installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') - [[ -z "$installed_version" ]] && installed_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') + installed_version=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew list --versions mole 2> /dev/null | awk '{print $2}') + [[ -z "$installed_version" ]] && installed_version=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}' || true) echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${installed_version:-$current_version}" echo "" - elif echo "$upgrade_output" | grep -q "Error:"; then - log_error "Homebrew upgrade failed" - echo "$upgrade_output" | grep "Error:" >&2 - return 1 else echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version - new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') - [[ -z "$new_version" ]] && new_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') + new_version=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew list --versions mole 2> /dev/null | awk '{print $2}') + [[ -z "$new_version" ]] && new_version=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}' || true) echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-$current_version}" echo "" @@ -128,8 +180,14 @@ remove_apps_from_dock() { local changed=false for target in "${targets[@]}"; do local app_path="$target" + local bundle_id="" local full_path="" + if [[ "$target" == *"|"* ]]; then + app_path="${target%%|*}" + bundle_id="${target#*|}" + fi + if [[ "$app_path" =~ [[:cntrl:]] ]]; then debug_log "Skipping dock removal for path with control chars: $app_path" continue @@ -152,30 +210,31 @@ remove_apps_from_dock() { [[ -z "$full_path" ]] && continue local encoded_path="${full_path// /%20}" - - # Find the index of the app in persistent-apps - local i=0 - while true; do - local label - label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2> /dev/null || echo "") - [[ -z "$label" ]] && break - - local url - url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") - [[ -z "$url" ]] && { - i=$((i + 1)) - continue - } - - # Match by URL-encoded path to handle spaces in app names - if [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]]; then - if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2> /dev/null; then - changed=true - # After deletion, current index i now points to the next item - continue + local raw_path="$full_path" + + local dock_array + for dock_array in persistent-apps persistent-others recent-apps; do + local i=0 + while true; do + local tile_type + tile_type=$(/usr/libexec/PlistBuddy -c "Print :$dock_array:$i:tile-type" "$plist" 2> /dev/null || echo "") + [[ -z "$tile_type" ]] && break + + local url dock_bundle_id + url=$(/usr/libexec/PlistBuddy -c "Print :$dock_array:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") + dock_bundle_id=$(/usr/libexec/PlistBuddy -c "Print :$dock_array:$i:tile-data:bundle-identifier" "$plist" 2> /dev/null || echo "") + + if { [[ -n "$bundle_id" && "$dock_bundle_id" == "$bundle_id" ]] || + [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]] || + [[ -n "$raw_path" && "$url" == *"$raw_path"* ]]; }; then + if /usr/libexec/PlistBuddy -c "Delete :$dock_array:$i" "$plist" 2> /dev/null; then + changed=true + # After deletion, current index i now points to the next item. + continue + fi fi - fi - i=$((i + 1)) + i=$((i + 1)) + done done done diff --git a/Resources/mole/lib/core/file_ops.sh b/Resources/mole/lib/core/file_ops.sh index 5c41618..a34b62a 100644 --- a/Resources/mole/lib/core/file_ops.sh +++ b/Resources/mole/lib/core/file_ops.sh @@ -14,6 +14,9 @@ readonly MOLE_FILE_OPS_LOADED=1 readonly MOLE_ERR_SIP_PROTECTED=10 readonly MOLE_ERR_AUTH_FAILED=11 readonly MOLE_ERR_READONLY_FS=12 +readonly MOLE_ERR_PROTECTED_PATH=13 +readonly MOLE_ERR_PRIVACY_DENIED=14 +readonly MOLE_ERR_MUTABLE_PARENT=15 # Ensure dependencies are loaded _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -29,6 +32,41 @@ if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then # shellcheck source=lib/core/timeout.sh source "$_MOLE_CORE_DIR/timeout.sh" fi +if [[ -z "${MOLE_TIMEOUTS_LOADED:-}" ]]; then + # shellcheck source=lib/core/timeouts.sh + source "$_MOLE_CORE_DIR/timeouts.sh" +fi + +# Bound production sudo commands while keeping shell-function mocks observable +# in tests. Timeout behavior itself must use a PATH stub so it exercises the +# same external-command branch that users run. +_mole_bounded_sudo() { + local duration="${1:-${MOLE_TIMEOUT_DISK_VERIFY_SEC:-30}}" + shift || true + [[ $# -gt 0 ]] || return 2 + if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ || "$duration" =~ ^0+(\.0+)?$ ]]; then + duration=30 + fi + + if declare -F sudo > /dev/null 2>&1; then + sudo "$@" + return $? + fi + + local sudo_bin="" + sudo_bin=$(command -v sudo 2> /dev/null || true) + [[ -n "$sudo_bin" ]] || return 127 + run_with_timeout "$duration" "$sudo_bin" "$@" +} + +_mole_bounded_sudo_until() { + local deadline="$1" + local requested="$2" + shift 2 + local duration="" + duration=$(_mole_timeout_with_deadline "$requested" "$deadline") || return $? + _mole_bounded_sudo "$duration" "$@" +} # ============================================================================ # Utility Functions @@ -63,6 +101,507 @@ format_duration_human() { # Path Validation # ============================================================================ +_mole_normalize_deletion_policy_path() { + local path="$1" + local slash="/" + local double_slash="//" + + while [[ "$path" == *"$double_slash"* ]]; do + path="${path//$double_slash/$slash}" + done + + while [[ "$path" == *"/./"* ]]; do + path="${path//\/\.\//$slash}" + done + while [[ "$path" == */. ]]; do + path="${path%/.}" + [[ -n "$path" ]] || path="/" + done + + local trimmed="${path%/}" + [[ -n "$trimmed" ]] && printf '%s\n' "$trimmed" || printf '%s\n' "$path" +} + +# This is a live Apple SQLite database. The main file and its WAL companions +# must stay together while PerfPowerServices is running; unlinking or truncating +# any member can split the active database state. Keep the exact path in one +# place so deletion policy and the read-only System Data hint cannot drift. +readonly MOLE_ACTIVE_POWERLOG_DB_PATH="/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/CurrentBackgroundProcessingDB.BGSQL" + +_mole_is_active_powerlog_database_path() { + local restore_nocasematch=false + local result=1 + + if ! shopt -q nocasematch; then + shopt -s nocasematch + restore_nocasematch=true + fi + + case "$1" in + "$MOLE_ACTIVE_POWERLOG_DB_PATH" | \ + "$MOLE_ACTIVE_POWERLOG_DB_PATH-wal" | \ + "$MOLE_ACTIVE_POWERLOG_DB_PATH-shm") + result=0 + ;; + esac + + [[ "$restore_nocasematch" == "true" ]] && shopt -u nocasematch + return "$result" +} + +# Live reverse-DNS user-cache guard (#1390). +# Unlinking an open Cache.db (or its -wal/-shm companions) while the owning +# helper still holds the database can send that process into an unbounded write +# loop on unlinked temp files and fill the volume (Autodesk Fusion's +# AcCoreConsole). Size and mtime are never authority for these trees: only a +# conclusive "no matching process" result, plus an open-file check for SQLite +# families, may authorize deletion. +# +# Process-state results are memoized for the current clean process so a batch +# under one cache directory does not fork pgrep once per leaf file. Bash 3.2 +# has no associative arrays, so the cache is a pipe-delimited string. + +_mole_user_library_caches_prefix() { + printf '%s\n' "${HOME%/}/Library/Caches" +} + +_mole_user_cache_owner_component() { + local path="$1" + local prefix + prefix=$(_mole_user_library_caches_prefix) + local normalized="${path%/}" + case "$normalized" in + "$prefix"/*) ;; + *) return 1 ;; + esac + local remainder="${normalized#"$prefix"/}" + local component="${remainder%%/*}" + # Reverse-DNS style only (com.vendor.app). Named trees such as Homebrew + # stay outside this gate; they have their own process probes. + [[ "$component" == *.* && "$component" != .* ]] || return 1 + printf '%s\n' "$component" + return 0 +} + +# SQLite main file or -wal / -shm / -journal companion. Case-insensitive so a +# cache sweep cannot delete a database through a case variant of its name +# (contributor PR #1391 + main reverse-DNS gate). +_mole_is_sqlite_database_path() { + local restore_nocasematch=false + local result=1 + local path="$1" + local base="${path%-wal}" + base="${base%-shm}" + base="${base%-journal}" + + if ! shopt -q nocasematch; then + shopt -s nocasematch + restore_nocasematch=true + fi + + case "$base" in + *.db | *.sqlite | *.sqlite3) + result=0 + ;; + esac + + [[ "$restore_nocasematch" == "true" ]] && shopt -u nocasematch + return "$result" +} + +_mole_is_user_cache_sqlite_family_path() { + _mole_is_sqlite_database_path "$1" +} + +_mole_user_cache_sqlite_main_path() { + local path="$1" + case "$path" in + *-wal) printf '%s\n' "${path%-wal}" ;; + *-shm) printf '%s\n' "${path%-shm}" ;; + *-journal) printf '%s\n' "${path%-journal}" ;; + *) printf '%s\n' "$path" ;; + esac +} + +_mole_sqlite_family_base_path() { + _mole_user_cache_sqlite_main_path "$1" +} + +# One process-table snapshot per run, mirroring the Mac app's +# ProcessGuard.cachedProcessTable. The answer cannot change between candidates +# inside a single sweep, and the old code forked pgrep up to three times per +# cache directory. +# +# Lines that must not vote are dropped here: Mole's own process, and the +# measurement tools it forks over the very path being judged. `du -skPx +# ~/Library/Caches/` puts in the table purely because Mole is looking +# at it, which would make every slowly-measured cache report its owner as live. +_MOLE_PROCESS_TABLE="" +_MOLE_PROCESS_TABLE_STATE="" + +_mole_process_table() { + if [[ -n "${_MOLE_PROCESS_TABLE_STATE:-}" ]]; then + [[ "$_MOLE_PROCESS_TABLE_STATE" == "ok" ]] || return 1 + printf '%s\n' "$_MOLE_PROCESS_TABLE" + return 0 + fi + + local raw="" + if ! raw=$(ps -axo pid,ppid,comm,args 2> /dev/null) || [[ -z "$raw" ]]; then + _MOLE_PROCESS_TABLE_STATE="unavailable" + return 1 + fi + + # Every text tool below runs under LC_ALL=C so it compares BYTES. A process + # table is not guaranteed to be UTF-8: an app named 富途牛牛 makes awk and + # grep abort with "illegal byte sequence" in a UTF-8 locale, and an aborted + # filter would silently shorten the table into a false "owner is idle". + local filtered="" + + # Mole must not vote on itself. `pgrep -f` skipped the caller for free; + # a raw table does not, and every candidate id reaches this code as an + # argument, so the shell running `mo clean` (and any wrapper above it) + # carries that id in its own argv. Walking the ppid chain drops the whole + # invoking tree, which is also what excludes the `du` and `find` children + # forked to MEASURE the very directory being judged. + if ! filtered=$(printf '%s\n' "$raw" | LC_ALL=C awk -v self="$$" ' + NR > 1 { + pid = $1 + parent[pid] = $2 + order[++count] = pid + # Drop the pid/ppid columns back off the line. + sub(/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+[[:space:]]+/, "") + text[pid] = $0 + comm[pid] = $1 + } + END { + for (p = self; p != "" && p != "0" && p != "1" && !(p in seen); p = parent[p]) { + seen[p] = 1 + mine[p] = 1 + } + # Descendants too: a command substitution forks a child that + # inherits our argv verbatim, so the id would come back through + # the copy even after the ancestor chain is gone. + for (i = 1; i <= count; i++) { + pid = order[i] + depth = 0 + for (p = pid; p != "" && p != "0" && p != "1" && depth < 64; p = parent[p]) { + if (p == self) { mine[pid] = 1; break } + depth++ + } + } + for (i = 1; i <= count; i++) { + pid = order[i] + if (pid in mine) continue + n = split(comm[pid], parts, "/") + base = parts[n] + if (base == "du" || base == "find" || base == "mdfind" || + base == "ps" || base == "grep" || base == "stat" || + base == "ls" || base == "rm") continue + if (index(tolower(text[pid]), "com.tw93.mole") > 0) continue + print text[pid] + } + }'); then + # A filter that died mid-table would leave a SHORT table, which reads + # as "nothing owns this cache". Refuse to answer instead. + _MOLE_PROCESS_TABLE_STATE="unavailable" + return 1 + fi + + _MOLE_PROCESS_TABLE="$filtered" + _MOLE_PROCESS_TABLE_STATE="ok" + printf '%s\n' "$_MOLE_PROCESS_TABLE" + return 0 +} + +# Escape every non-alphanumeric byte so a cache-dir component is matched as a +# literal inside an ERE. +_mole_regex_escape() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9_]/\\&/g' +} + +# Tri-state process probe for a reverse-DNS cache owner: +# 0 = a matching process is running, 1 = none matched, 2 = could not tell. +# +# Two acceptance shapes, deliberately asymmetric (parity with the Mac app's +# ProcessGuard.processListMentionsCacheOwner): +# 1. The full reverse-DNS id appears in the line. Self-identifying, so a +# plain substring is enough. +# 2. The last DNS label appears as a DELIMITED token AND the same line +# independently names another component of the id. Corroboration is what +# makes a shared binary name usable: Claude and VS Code both ship a +# Squirrel binary called ShipIt, so `pgrep -x ShipIt` attributed VS Code's +# cache to a running Claude. Measured on this machine before the change, +# 34 of 59 idle caches were called busy; the plain-substring shapes it +# relied on also read "default" out of syncdefaultsd and "data" out of +# dataaccessd. +_mole_user_cache_owner_process_state() { + local owner="$1" + [[ -n "$owner" ]] || return 2 + + local cache_token="|${owner}:" + case "${_MOLE_USER_CACHE_OWNER_STATE_CACHE:-}" in + *"${cache_token}0|"*) return 0 ;; + *"${cache_token}1|"*) return 1 ;; + *"${cache_token}2|"*) return 2 ;; + esac + + local table="" + if ! table=$(_mole_process_table); then + # An unreadable process table is not proof the owner is idle. + return 2 + fi + + # Feed the table by here-string, never through a pipe. `grep -q` exits on + # its first match, and the printf still writing into that closed pipe takes + # SIGPIPE, which bash reports as "printf: write error: Broken pipe" on + # stderr; during `mo clean` that lands in the middle of the user's output. + local state=1 + if LC_ALL=C grep -qiF -- "$owner" <<< "$table"; then + state=0 + fi + + local leaf="${owner##*.}" + if [[ $state -eq 1 && -n "$leaf" && "$leaf" != "$owner" && ${#leaf} -ge 4 ]]; then + # "com" is in every reverse-DNS id and corroborates nothing. + local -a corroborators=() + local old_ifs="$IFS" + local -a components=() + IFS='.' read -r -a components <<< "$owner" + IFS="$old_ifs" + local index=0 + local last_index=$((${#components[@]} - 1)) + local component + for component in "${components[@]}"; do + if [[ $index -lt $last_index && ${#component} -ge 4 ]]; then + case "$component" in + [cC][oO][mM]) ;; + *) corroborators+=("$(_mole_regex_escape "$component")") ;; + esac + fi + index=$((index + 1)) + done + + if [[ ${#corroborators[@]} -gt 0 ]]; then + local escaped_leaf + escaped_leaf=$(_mole_regex_escape "$leaf") + local alternation="" + for component in "${corroborators[@]}"; do + alternation="${alternation:+$alternation|}$component" + done + local boundary_open='(^|[^A-Za-z0-9])' + local boundary_close='([^A-Za-z0-9]|$)' + # Two passes, so both tokens must land on the SAME line. Materialize + # the first result instead of piping into a `grep -q`, for the same + # broken-pipe reason as the substring check above. + local leaf_lines="" + leaf_lines=$(LC_ALL=C grep -iE -- \ + "${boundary_open}${escaped_leaf}${boundary_close}" <<< "$table") || leaf_lines="" + if [[ -n "$leaf_lines" ]] && LC_ALL=C grep -qiE -- \ + "${boundary_open}(${alternation})${boundary_close}" <<< "$leaf_lines"; then + state=0 + fi + fi + fi + + _MOLE_USER_CACHE_OWNER_STATE_CACHE="${_MOLE_USER_CACHE_OWNER_STATE_CACHE-}${cache_token}${state}|" + return "$state" +} + +# Is the database family live? 0 = in use, 1 = idle, 2 = could not tell. +# WAL-mode -shm only exists while at least one connection is open (PR #1391). +_mole_sqlite_database_in_use() { + local path="$1" + local base + base=$(_mole_sqlite_family_base_path "$path") + + if [[ -f "${base}-shm" ]]; then + return 0 + fi + + command -v lsof > /dev/null 2>&1 || return 2 + + local candidate + for candidate in "$base" "${base}-wal" "${base}-shm"; do + [[ -e "$candidate" ]] || continue + local lsof_rc=0 + if declare -f run_with_timeout > /dev/null 2>&1; then + run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" lsof -F n -- "$candidate" \ + > /dev/null 2>&1 || lsof_rc=$? + else + lsof -F n -- "$candidate" > /dev/null 2>&1 || lsof_rc=$? + fi + if [[ $lsof_rc -eq 0 ]]; then + return 0 + fi + if [[ $lsof_rc -ne 1 ]]; then + return 2 + fi + done + return 1 +} + +_mole_user_cache_sqlite_has_open_handle() { + local path="$1" + local state=0 + _mole_sqlite_database_in_use "$path" || state=$? + return "$state" +} + +# Return 0 when deletion must be refused (live owner or open SQLite handle). +_mole_should_refuse_live_user_cache_path() { + local path="$1" + local owner="" + owner=$(_mole_user_cache_owner_component "$path") || return 1 + + local process_state=0 + _mole_user_cache_owner_process_state "$owner" || process_state=$? + if [[ $process_state -eq 0 ]]; then + debug_log "Live user cache owner running, keep: $path ($owner)" + return 0 + fi + if [[ $process_state -eq 2 ]]; then + debug_log "Live user cache owner state unknown, keep: $path ($owner)" + return 0 + fi + + # Process is conclusively idle. Still refuse SQLite family members that + # another process has open, or that still expose a WAL -shm (PR #1391). + if _mole_is_user_cache_sqlite_family_path "$path"; then + local open_state=0 + _mole_user_cache_sqlite_has_open_handle "$path" || open_state=$? + if [[ $open_state -eq 0 ]]; then + debug_log "Open SQLite user cache handle, keep: $path" + return 0 + fi + # open_state 2 (lsof missing) with no -shm: process probe already idle. + elif [[ -d "$path" ]]; then + local candidate open_state + for candidate in "$path/Cache.db" "$path"/*.db; do + [[ -f "$candidate" ]] || continue + open_state=0 + _mole_user_cache_sqlite_has_open_handle "$candidate" || open_state=$? + if [[ $open_state -eq 0 ]]; then + debug_log "Open SQLite under user cache dir, keep: $path ($candidate)" + return 0 + fi + done + fi + + return 1 +} + +_mole_path_is_same_existing_file() { + local path="$1" + local protected_path="$2" + [[ -e "$path" && -e "$protected_path" && "$path" -ef "$protected_path" ]] +} + +_mole_path_is_within_existing_root() { + local path="$1" + local protected_root="$2" + [[ -e "$protected_root" ]] || return 1 + + local probe="$path" + while [[ "$probe" == /* ]]; do + if _mole_path_is_same_existing_file "$probe" "$protected_root"; then + return 0 + fi + [[ "$probe" == "/" ]] && break + probe="${probe%/*}" + [[ -n "$probe" ]] || probe="/" + done + return 1 +} + +# Deletion policy only. App/data protection stays in app_protection.sh. +_mole_is_critical_deletion_path() { + local path="$1" + + case "$path" in + # Homebrew (Intel) and user-installed software live here; individual + # entries stay deletable. The Homebrew roots themselves are still + # critical roots and must fall through to the deny arms below. + /usr/local/* | /opt/homebrew/*) + return 1 + ;; + / | \ + /bin | /bin/* | \ + /dev | /dev/* | \ + /sbin | /sbin/* | \ + /usr | /usr/* | \ + /System | /System/* | \ + /Library | /Library/Apple | /Library/Apple/* | \ + /Library/Application\ Support | \ + /Library/Extensions | /Library/Extensions/* | \ + /Library/Keychains | /Library/Keychains/* | \ + /Applications | \ + /Applications/Finder.app | /Applications/Finder.app/* | \ + /Applications/Safari.app | /Applications/Safari.app/* | \ + /Volumes | \ + /opt | /opt/homebrew | \ + /Users | /Users/Shared | /Users/Guest | /Users/Guest/*) + return 0 + ;; + /private | /private/tmp) + return 0 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + return 0 + ;; + /var | /var/db | /var/db/* | /var/audit | /var/audit/* | /var/root | \ + /private/var | /private/var/tmp | /private/var/folders | \ + /private/var/db | /private/var/db/* | /private/var/audit | /private/var/audit/* | /private/var/root) + return 0 + ;; + esac + + # Reject a user home root (/Users/) while keeping its children + # deletable. A single case glob cannot express "exactly one component + # under /Users", so match one level here: this catches the empty-variable + # collapse "/Users/$user/$leaf" -> "/Users/" that would otherwise + # hand rm -rf an entire home directory. + if [[ "$path" == /Users/* && "$path" != /Users/*/* ]]; then + return 0 + fi + + # APFS is normally case-insensitive but case-preserving. Uppercase aliases + # such as /SYSTEM and /OPT/HOMEBREW are not symlinks, so string policy + # checks alone can miss that they are the same inode as protected roots. + local protected_root + local -a exact_roots=( + / /Applications /Library /Volumes /Network /cores /etc /home /net + /tmp /var /private /private/tmp /private/var /private/var/tmp + /private/var/folders /Users /opt /opt/homebrew + ) + for protected_root in "${exact_roots[@]}"; do + if _mole_path_is_same_existing_file "$path" "$protected_root"; then + return 0 + fi + done + + local -a protected_trees=( + /bin /dev /sbin /usr /System /private/etc /private/var/audit + /private/var/db /private/var/root /Library/Apple /Library/Extensions + /Library/Keychains /Applications/Finder.app /Applications/Safari.app + ) + for protected_root in "${protected_trees[@]}"; do + if _mole_path_is_within_existing_root "$path" "$protected_root"; then + return 0 + fi + done + + # Protect every account root even when a caller changes only component + # casing (for example /USERS/SHARED on a case-insensitive volume). + local parent_path="${path%/*}" + [[ -n "$parent_path" ]] || parent_path="/" + if _mole_path_is_same_existing_file "$parent_path" "/Users"; then + return 0 + fi + + return 1 +} + # Validate path for deletion (absolute, no traversal, not system dir) validate_path_for_deletion() { local path="$1" @@ -73,6 +612,29 @@ validate_path_for_deletion() { return 1 fi + # Check path is absolute + if [[ "$path" != /* ]]; then + log_error "Path validation failed: path must be absolute: $path" + return 1 + fi + + # Check for path traversal attempts + # Only reject .. when it appears as a complete path component (/../ or /.. or ../) + # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") + if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then + log_error "Path validation failed: path traversal not allowed: $path" + return 1 + fi + + # Check path doesn't contain dangerous characters + if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then + log_error "Path validation failed: contains control characters: $path" + return 1 + fi + + local policy_path + policy_path=$(_mole_normalize_deletion_policy_path "$path") + # Check symlink target if path is a symbolic link if [[ -L "$path" ]]; then local link_target @@ -91,48 +653,113 @@ validate_path_for_deletion() { # Validate resolved target against protected paths if [[ -n "$resolved_target" ]]; then - case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - log_error "Symlink points to protected system path: $path -> $resolved_target" - return 1 - ;; - esac + resolved_target=$(_mole_normalize_deletion_policy_path "$resolved_target") + if _mole_is_critical_deletion_path "$resolved_target"; then + log_error "Symlink points to protected system path: $path -> $resolved_target" + return 1 + fi fi fi - # Check path is absolute - if [[ "$path" != /* ]]; then - log_error "Path validation failed: path must be absolute: $path" - return 1 - fi - - # Check for path traversal attempts - # Only reject .. when it appears as a complete path component (/../ or /.. or ../) - # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") - if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then - log_error "Path validation failed: path traversal not allowed: $path" - return 1 + # Ancestor-symlink guard. The checks above (deny list, protection policy) + # match on the LITERAL path string, and the -L test above only inspects the + # leaf. If any ANCESTOR component is a symlink, the string matches nothing + # dangerous while the actual rm follows the link into the real target: + # a redirected "~/Library/Caches" would let a cache sweep walk into + # ~/Documents or a system tree. Canonicalize the parent (physical cd + # resolves every ancestor link) and re-run the deny predicates on the + # resolved leaf. Deny-only: a resolved path never grants permission the + # literal path lacked, so legitimate targets keep their existing verdict. + # Runs BEFORE the allowlists below, which would otherwise early-return past + # this gate for /private/* paths. + # + # This sits on the hot path (every deletion candidate), so the common case + # must stay fork-free: walk the ancestors with the [[ -L ]] builtin and only + # pay for the canonicalizing subshell when one of them really is a symlink. + # A dirname + `cd -P` on every call cost ~2ms, about +23% on validation. + local parent_dir resolved_parent probe + local ancestor_is_link=false + parent_dir="${policy_path%/*}" + [[ -z "$parent_dir" ]] && parent_dir="/" + if [[ -d "$parent_dir" ]]; then + probe="$parent_dir" + while [[ "$probe" == /?* ]]; do + if [[ -L "$probe" ]]; then + ancestor_is_link=true + break + fi + probe="${probe%/*}" + done fi - - # Check path doesn't contain dangerous characters - if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then - log_error "Path validation failed: contains control characters: $path" - return 1 + if [[ "$ancestor_is_link" == "true" ]]; then + resolved_parent=$(cd -P "$parent_dir" 2> /dev/null && pwd -P) || resolved_parent="" + if [[ -n "$resolved_parent" && "$resolved_parent" != "$parent_dir" ]]; then + local resolved_path + resolved_path=$(_mole_normalize_deletion_policy_path "${resolved_parent}/${policy_path##*/}") + if _mole_is_critical_deletion_path "$resolved_path"; then + log_error "Path validation failed: resolves into a critical system path: $path -> $resolved_path" + return 1 + fi + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$resolved_path"; then + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + log_warning "Path validation: resolves into a protected path: $path -> $resolved_path" + fi + return 1 + fi + fi fi # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) - case "$path" in + case "$policy_path" in /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) return 0 ;; esac + # Reject the active power telemetry database before the broad powerlog + # allowlist below. Size and mtime are diagnostic signals, never deletion + # authority for a database that a KeepAlive system service can reopen. + if _mole_is_active_powerlog_database_path "$policy_path"; then + debug_log "Path validation: active powerlog database kept: $policy_path" + return 1 + fi + + # Live reverse-DNS user caches (process + SQLite handle). Covers Autodesk + # helpers and every other com.vendor tree under ~/Library/Caches (#1390). + if _mole_should_refuse_live_user_cache_path "$policy_path"; then + debug_log "Path validation: live user cache kept: $policy_path" + return 1 + fi + + # General SQLite family gate from PR #1391: refuse any in-use database even + # outside reverse-DNS cache dirs (fail closed when lsof cannot answer). + if _mole_is_sqlite_database_path "$policy_path"; then + local sqlite_state=0 + _mole_sqlite_database_in_use "$policy_path" || sqlite_state=$? + if [[ $sqlite_state -eq 0 || $sqlite_state -eq 2 ]]; then + debug_log "Path validation: in-use SQLite database kept: $policy_path" + return 1 + fi + fi + + # Endpoint-security/EDR agent caches under var/folders look like ordinary + # rebuildable caches, but deleting anything in a sensor's container trips + # tamper detection (reported as malware). Reject here, before the + # /private/var/folders allowlist below, so every deletion caller is covered, + # not only the cleanup sweeps that pre-check the predicate. + if declare -f is_endpoint_security_cache_path > /dev/null 2>&1 && is_endpoint_security_cache_path "$policy_path"; then + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + log_warning "Path validation: endpoint-security agent cache skipped: $policy_path" + fi + return 1 + fi + # Allow known safe paths under /private - case "$path" in - /private/tmp | /private/tmp/* | \ - /private/var/tmp | /private/var/tmp/* | \ + case "$policy_path" in + /private/tmp/* | \ + /private/var/tmp/* | \ /private/var/log | /private/var/log/* | \ - /private/var/folders | /private/var/folders/* | \ + /private/var/folders/* | \ /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ /private/var/db/powerlog | /private/var/db/powerlog/* | \ @@ -143,30 +770,16 @@ validate_path_for_deletion() { esac # Check path isn't critical system directory - case "$path" in - / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /private) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /etc | /etc/* | /private/etc | /private/etc/*) - log_error "Path validation failed: /etc contains critical system files: $path" - return 1 - ;; - /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) - log_error "Path validation failed: /var/db contains system databases: $path" - return 1 - ;; - esac + if _mole_is_critical_deletion_path "$policy_path"; then + log_error "Path validation failed: critical system path: $path" + return 1 + fi # Check if path is protected (keychains, system settings, etc) if declare -f should_protect_path > /dev/null 2>&1; then - if should_protect_path "$path"; then + if should_protect_path "$policy_path"; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then - log_warning "Path validation: protected path skipped: $path" + log_warning "Path validation: protected path skipped: $policy_path" fi return 1 fi @@ -179,13 +792,90 @@ validate_path_for_deletion() { # Safe Removal Operations # ============================================================================ +_record_file_ops_dry_run_target() { + local path="$1" + local precomputed_size_kb="${2:-}" + + declare -f record_dry_run_cleanup_target > /dev/null 2>&1 || return 0 + + local size_kb=0 + local size_known=true + if [[ -n "$precomputed_size_kb" && "$precomputed_size_kb" =~ ^[0-9]+$ ]]; then + size_kb="$precomputed_size_kb" + else + local measured_size="" + local measure_rc=0 + measured_size=$(get_path_size_kb "$path" 2> /dev/null) || measure_rc=$? + if [[ $measure_rc -ge 128 ]]; then + return "$measure_rc" + fi + if [[ $measure_rc -eq 124 ]]; then + # Sizing budget exhausted: preview the item as size-unknown rather + # than cancelling the whole dry run. + MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + fi + if [[ $measure_rc -eq 0 && "$measured_size" =~ ^[0-9]+$ ]]; then + size_kb="$measured_size" + else + size_known=false + fi + fi + + local record_rc=0 + record_dry_run_cleanup_target \ + "$path" "$size_kb" 1 "$size_known" || record_rc=$? + [[ $record_rc -eq 124 || $record_rc -ge 128 ]] && return "$record_rc" + return 0 +} + +# Preserve the first timeout or signal observed by a clean deletion sink. Some +# older cleanup families intentionally treat ordinary item failures as +# best-effort; this sticky status prevents those `|| true` paths from turning a +# user interrupt into permission to continue deleting later targets. +_mole_record_clean_cancellation() { + local status="$1" + if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" && + ("$status" -eq 124 || "$status" -ge 128) ]]; then + local existing="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ $existing -ne 124 && $existing -lt 128 ]]; then + MOLE_CLEAN_CANCEL_STATUS=$status + export MOLE_CLEAN_CANCEL_STATUS + fi + fi +} + # Safe wrapper around rm -rf with validation safe_remove() { local path="$1" local silent="${2:-false}" + local precomputed_size_kb="${3:-}" + local deadline_seconds="${4:-}" + local expected_parent="${5:-}" + local expected_parent_id="${6:-}" + local expected_target_id="${7:-}" + + local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" && + ("$pending_clean_cancel" -eq 124 || "$pending_clean_cancel" -ge 128) ]]; then + return "$pending_clean_cancel" + fi - # Validate path - if ! validate_path_for_deletion "$path"; then + # Validate path. Silent cleanup callers still need the same policy result, + # but should not print one validation warning per skipped cache item. + if [[ "$silent" == "true" ]]; then + validate_path_for_deletion "$path" 2> /dev/null || return 1 + elif ! validate_path_for_deletion "$path"; then + return 1 + fi + + # Honor the user whitelist here, not just in safe_clean. safe_remove is + # called directly by several clean/optimize flows (Xcode DerivedData, + # mail downloads, deep-system caches, broken LaunchAgents) that would + # otherwise ignore a user's whitelist selection. is_path_whitelisted is + # a no-op when the whitelist is empty, and uninstall does not route + # through safe_remove, so this stays scoped to clean/optimize. See #710. + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$path"; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "whitelist" return 1 fi @@ -194,8 +884,32 @@ safe_remove() { return 0 fi + # Keep preview eligibility identical to real cleanup. This first check + # rejects an already-present compiled model cache before the dry-run return; + # the final-sink check below still catches one created during size probing. + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$path" 2> /dev/null; then + debug_log "Skipped removal for compiled model cache: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "compiled model cache" + return 1 + fi + + # Live reverse-DNS caches under ~/Library/Caches (#1390): refuse while the + # owning process is running or an open SQLite handle is proven. Final sink + # so both safe_clean and direct safe_remove callers are covered. + if _mole_should_refuse_live_user_cache_path "$path"; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "live user cache" + return 1 + fi + # Dry-run mode: log but don't delete if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + local dry_record_rc=0 + _record_file_ops_dry_run_target \ + "$path" "$precomputed_size_kb" || dry_record_rc=$? + if [[ $dry_record_rc -eq 124 || $dry_record_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$dry_record_rc" + return "$dry_record_rc" + fi if [[ "${MO_DEBUG:-}" == "1" ]]; then local file_type="file" [[ -d "$path" ]] && file_type="directory" @@ -205,15 +919,27 @@ safe_remove() { local file_age="" if [[ -e "$path" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") + local size_kb=0 + local size_rc=0 + size_kb=$(get_path_size_kb "$path" 2> /dev/null) || size_rc=$? + if [[ $size_rc -eq 124 || $size_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$size_rc" + return "$size_rc" + fi + [[ $size_rc -eq 0 ]] || size_kb=0 if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then - local mod_time - mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0") + local mod_time=0 + local stat_rc=0 + mod_time=$(stat -f%m "$path" 2> /dev/null) || stat_rc=$? + if [[ $stat_rc -eq 124 || $stat_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$stat_rc" + return "$stat_rc" + fi + [[ $stat_rc -eq 0 ]] || mod_time=0 local now now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then @@ -231,26 +957,103 @@ safe_remove() { debug_log "Removing: $path" - # Calculate size before deletion for logging + # Calculate size before deletion for logging. + # Accept pre-computed size to skip redundant I/O when the caller already measured. local size_kb=0 local size_human="" if oplog_enabled; then - if [[ -e "$path" ]]; then - size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") - if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") + if [[ -n "$precomputed_size_kb" ]]; then + if [[ "$precomputed_size_kb" =~ ^[0-9]+$ ]]; then + size_kb="$precomputed_size_kb" + fi + elif [[ -e "$path" ]]; then + local size_probe_rc=0 + local size_probe_timeout="" + size_probe_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds") || size_probe_rc=$? + if [[ $size_probe_rc -eq 0 ]]; then + size_kb=$(get_path_size_kb "$path" "$size_probe_timeout" 2> /dev/null) || size_probe_rc=$? fi + if [[ $size_probe_rc -eq 124 ]]; then + # Sizing budget exhausted: still remove the item, with the + # freed total under-reported, matching the batch-sizing policy. + MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + fi + if [[ $size_probe_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$size_probe_rc" + return "$size_probe_rc" + fi + [[ $size_probe_rc -eq 0 && "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + fi + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi fi - # Perform the deletion - # Use || to capture the exit code so set -e won't abort on rm failures - local error_msg - local rm_exit=0 - error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove - + # Recheck at the final sink. A daemon can create this compiled-model cache + # while the preceding size probe walks the target, and deleting its parent + # then breaks recognition until the owning process restarts. + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$path" 2> /dev/null; then + debug_log "Skipped removal after compiled model cache appeared: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "compiled model cache" + return 1 + fi + + # Recheck live-owner / open-SQLite state after size probing: a helper can + # launch while du is walking the tree (same race class as the compiled + # model cache check above). + if _mole_should_refuse_live_user_cache_path "$path"; then + debug_log "Skipped removal after live user cache appeared: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "live user cache" + return 1 + fi + + if [[ -n "$expected_parent" ]] && ! _mole_path_matches_identity \ + "$path" "$expected_parent" "$expected_parent_id" "$expected_target_id"; then + debug_log "Refusing removal after final path identity changed: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "identity changed" + return 1 + fi + + # Perform the deletion + # Use || to capture the exit code so set -e won't abort on rm failures + local error_msg + local rm_exit=0 + local section_deadline_spent=0 + if declare -F rm > /dev/null 2>&1; then + error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove + else + local rm_timeout="" + rm_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds") || rm_exit=$? + if [[ $rm_exit -eq 0 ]]; then + error_msg=$(run_with_timeout "$rm_timeout" rm -rf "$path" < /dev/null 2>&1) || rm_exit=$? # safe_remove + else + # The section's own wall-clock budget ran out, so rm never started. + section_deadline_spent=1 + fi + fi + + if [[ $rm_exit -eq 124 ]]; then + debug_log "Removal timed out: $path" + if [[ $section_deadline_spent -eq 1 ]]; then + # Not a slow removal: the caller's section deadline expired before + # rm ran, and that section reports its own stop. Counting it here + # would point the user at the per-item removal budget instead. + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "section time limit reached" + else + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "removal timed out" + # A slow disk can exceed the per-item removal budget. That is a + # failed removal, not a user interrupt: count it and keep going so + # one slow cache never cancels the remaining cleanup. + MOLE_CLEAN_REMOVAL_TIMEOUTS=$((${MOLE_CLEAN_REMOVAL_TIMEOUTS:-0} + 1)) + fi + return 124 + fi + # Preserve interrupt semantics so callers can abort long-running deletions. if [[ $rm_exit -ge 128 ]]; then + _mole_record_clean_cancellation "$rm_exit" return "$rm_exit" fi @@ -277,126 +1080,1299 @@ safe_remove() { # Safe symlink removal (for pre-validated symlinks only) safe_remove_symlink() { local path="$1" - local use_sudo="${2:-false}" + local use_sudo="${2:-false}" + local expected_parent="${3:-}" + local expected_parent_id="${4:-}" + local expected_target_id="${5:-}" + + local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" && + ("$pending_clean_cancel" -eq 124 || "$pending_clean_cancel" -ge 128) ]]; then + return "$pending_clean_cancel" + fi + + if [[ ! -L "$path" ]]; then + return 1 + fi + + if ! validate_path_for_deletion "$path"; then + return 1 + fi + + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$path"; then + debug_log "Skipped symlink removal for whitelisted path: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "whitelist" + return 1 + fi + + if [[ "$use_sudo" == "true" ]] && _mole_privileged_path_has_mutable_ancestor "$path"; then + if [[ ${EUID:-0} -ne 0 ]]; then + use_sudo=false + else + debug_log "Refusing privileged symlink removal below mutable parent: $path" + return 1 + fi + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + local dry_record_rc=0 + _record_file_ops_dry_run_target "$path" || dry_record_rc=$? + if [[ $dry_record_rc -eq 124 || $dry_record_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$dry_record_rc" + return "$dry_record_rc" + fi + debug_log "[DRY RUN] Would remove symlink: $path" + return 0 + fi + + if [[ -n "$expected_parent" ]] && ! _mole_path_matches_identity \ + "$path" "$expected_parent" "$expected_parent_id" "$expected_target_id"; then + debug_log "Refusing symlink removal after final path identity changed: $path" + return 1 + fi + + local rm_exit=0 + if [[ "$use_sudo" == "true" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo blocked in test mode" + return 1 + fi + sudo -n rm "$path" 2> /dev/null || rm_exit=$? + else + rm "$path" 2> /dev/null || rm_exit=$? + fi + + if [[ $rm_exit -eq 0 ]]; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "symlink" + return 0 + else + if [[ $rm_exit -eq 124 || $rm_exit -ge 128 ]]; then + _mole_record_clean_cancellation "$rm_exit" + return "$rm_exit" + fi + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "symlink removal failed" + return 1 + fi +} + +# A privileged path operation is only safe when every parent directory is +# immutable to unprivileged users. Checking `-w` alone is insufficient: a +# directory owner can chmod a 0555 parent after validation, replace a component, +# and redirect a later sudo rm/mv. Fail closed on non-root ownership, writable +# mode bits, symlinks, unreadable metadata, and effective ACL write access. +_mole_privileged_path_has_mutable_ancestor() { + local path="$1" + local probe="${path%/*}" + local invoking_uid="" + [[ -n "$probe" ]] || probe="/" + invoking_uid=$(get_invoking_uid 2> /dev/null || true) + [[ "$invoking_uid" =~ ^[0-9]+$ ]] || return 0 + + while true; do + if [[ -L "$probe" ]]; then + return 0 + fi + + local owner_uid="" + local mode="" + owner_uid=$($STAT_BSD -f%u "$probe" 2> /dev/null || true) + mode=$($STAT_BSD -f%Lp "$probe" 2> /dev/null || true) + if [[ ! "$owner_uid" =~ ^[0-9]+$ || ! "$mode" =~ ^[0-7]+$ ]]; then + return 0 + fi + if [[ "$owner_uid" -ne 0 ]] || (((8#$mode & 0022) != 0)); then + return 0 + fi + if [[ "$invoking_uid" -ne 0 ]]; then + if [[ ${EUID:-0} -eq "$invoking_uid" ]]; then + [[ -w "$probe" ]] && return 0 + elif [[ ${EUID:-0} -eq 0 ]]; then + # Under `sudo mo`, the shell's -w probe reflects root rather + # than the invoking user. Drop authority for the ACL check so + # immutable system parents do not become false positives. + local acl_probe_rc=0 + _mole_bounded_sudo "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n -u "#$invoking_uid" /bin/test -w "$probe" < /dev/null 2> /dev/null || acl_probe_rc=$? + if [[ $acl_probe_rc -eq 0 ]]; then + return 0 + fi + # Only test's ordinary false status proves the invoking user + # cannot write here. Timeout, auth, and execution failures are + # unknown and must classify the ancestor as mutable. + if [[ $acl_probe_rc -ne 1 ]]; then + return 0 + fi + else + return 0 + fi + fi + + [[ "$probe" == "/" ]] && break + probe="${probe%/*}" + [[ -n "$probe" ]] || probe="/" + done + return 1 +} + +# Safe sudo removal with symlink and parent-component protection +safe_sudo_remove() { + local path="$1" + local precomputed_size_kb="${2:-}" + local deadline_seconds="${3:-}" + local expected_parent="${4:-}" + local expected_parent_id="${5:-}" + local expected_target_id="${6:-}" + + local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" + if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" && + ("$pending_clean_cancel" -eq 124 || "$pending_clean_cancel" -ge 128) ]]; then + return "$pending_clean_cancel" + fi + + if ! validate_path_for_deletion "$path"; then + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$path"; then + debug_log "Skipped sudo remove for protected path: $path" + return "$MOLE_ERR_PROTECTED_PATH" + else + log_error "Path validation failed for sudo remove: $path" + fi + return 1 + fi + + if [[ ! -e "$path" ]]; then + return 0 + fi + + if [[ -L "$path" ]]; then + log_error "Refusing to sudo remove symlink: $path" + return 1 + fi + + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$path"; then + debug_log "Skipped sudo remove for whitelisted path: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "whitelist" + return 1 + fi + + # This policy must run before dry-run/test-mode returns so preview and real + # privileged cleanup agree on the eligible target set. + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$path" 2> /dev/null; then + debug_log "Skipped sudo removal for compiled model cache: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "compiled model cache" + return "$MOLE_ERR_PROTECTED_PATH" + fi + + if _mole_privileged_path_has_mutable_ancestor "$path"; then + if [[ ${EUID:-0} -ne 0 ]]; then + debug_log "Downgrading sudo remove below mutable parent: $path" + safe_remove "$path" true "" "$deadline_seconds" \ + "$expected_parent" "$expected_parent_id" "$expected_target_id" + return $? + fi + debug_log "Refusing sudo remove below mutable parent: $path" + return 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + local dry_record_rc=0 + _record_file_ops_dry_run_target \ + "$path" "$precomputed_size_kb" || dry_record_rc=$? + if [[ $dry_record_rc -eq 124 || $dry_record_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$dry_record_rc" + return "$dry_record_rc" + fi + fi + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + log_info "[DRY-RUN] Would sudo remove: $path" + return 0 + fi + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo blocked in test mode" + return 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + if [[ "${MO_DEBUG:-}" == "1" ]]; then + local file_type="file" + [[ -d "$path" ]] && file_type="directory" + + local file_size="" + local file_age="" + + local exists_rc=0 + _mole_bounded_sudo "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n test -e "$path" < /dev/null 2> /dev/null || exists_rc=$? + if [[ $exists_rc -eq 124 || $exists_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$exists_rc" + return "$exists_rc" + fi + if [[ $exists_rc -eq 0 ]]; then + local size_kb=0 + if [[ -n "$precomputed_size_kb" ]]; then + if [[ "$precomputed_size_kb" =~ ^[0-9]+$ ]]; then + size_kb="$precomputed_size_kb" + fi + else + local size_rc=0 + size_kb=$(_mole_bounded_sudo "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + -n du -skP "$path" < /dev/null 2> /dev/null | awk '{print $1}') || size_rc=$? + if [[ $size_rc -eq 124 || $size_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$size_rc" + return "$size_rc" + fi + [[ $size_rc -eq 0 ]] || size_kb=0 + fi + if [[ "$size_kb" -gt 0 ]]; then + file_size=$(bytes_to_human "$((size_kb * 1024))") + fi + + local type_probe_rc=0 + _mole_bounded_sudo "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n test -f "$path" < /dev/null 2> /dev/null || type_probe_rc=$? + if [[ $type_probe_rc -eq 124 || $type_probe_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$type_probe_rc" + return "$type_probe_rc" + fi + if [[ $type_probe_rc -ne 0 ]]; then + type_probe_rc=0 + _mole_bounded_sudo "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n test -d "$path" < /dev/null 2> /dev/null || type_probe_rc=$? + if [[ $type_probe_rc -eq 124 || $type_probe_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$type_probe_rc" + return "$type_probe_rc" + fi + fi + if [[ $type_probe_rc -eq 0 ]]; then + local mod_time=0 + local stat_rc=0 + mod_time=$(_mole_bounded_sudo "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n stat -f%m "$path" < /dev/null 2> /dev/null) || stat_rc=$? + if [[ $stat_rc -eq 124 || $stat_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$stat_rc" + return "$stat_rc" + fi + [[ $stat_rc -eq 0 ]] || mod_time=0 + local now + now=$(date +%s 2> /dev/null || echo "0") + if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then + local age_seconds=$((now - mod_time)) + file_age=$(format_duration_human "$age_seconds") + fi + fi + fi + + log_info "[DRY-RUN] Would sudo remove: $file_type $path" + [[ -n "$file_size" ]] && log_info " Size: $file_size" + [[ -n "$file_age" ]] && log_info " Age: $file_age" + else + log_info "[DRY-RUN] Would sudo remove: $path" + fi + return 0 + fi + + local size_kb=0 + local size_human="" + if oplog_enabled; then + if [[ -n "$precomputed_size_kb" ]]; then + if [[ "$precomputed_size_kb" =~ ^[0-9]+$ ]]; then + size_kb="$precomputed_size_kb" + fi + else + local exists_probe_rc=0 + local exists_probe_timeout="" + exists_probe_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || exists_probe_rc=$? + if [[ $exists_probe_rc -eq 0 ]]; then + _mole_bounded_sudo "$exists_probe_timeout" \ + -n test -e "$path" < /dev/null 2> /dev/null || exists_probe_rc=$? + fi + if [[ $exists_probe_rc -eq 124 ]]; then + _mole_record_clean_cancellation 124 + return 124 + fi + if [[ $exists_probe_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$exists_probe_rc" + return "$exists_probe_rc" + fi + if [[ $exists_probe_rc -eq 0 ]]; then + local size_probe_rc=0 + local size_probe_timeout="" + size_probe_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds") || size_probe_rc=$? + if [[ $size_probe_rc -eq 0 ]]; then + size_kb=$(_mole_bounded_sudo "$size_probe_timeout" \ + -n du -skP "$path" < /dev/null 2> /dev/null | awk '{print $1}') || size_probe_rc=$? + fi + if [[ $size_probe_rc -eq 124 ]]; then + MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) + fi + if [[ $size_probe_rc -ge 128 ]]; then + _mole_record_clean_cancellation "$size_probe_rc" + return "$size_probe_rc" + fi + [[ $size_probe_rc -eq 0 && "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + fi + fi + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") + fi + fi + + # Keep the same last-mile policy as safe_remove: privileged cleanup must + # also fail closed if a compiled-model cache appears during size probing. + if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$path" 2> /dev/null; then + debug_log "Skipped sudo removal after compiled model cache appeared: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "compiled model cache" + return "$MOLE_ERR_PROTECTED_PATH" + fi + + local output + local ret=0 + if [[ -n "$expected_parent" ]] && ! _mole_path_matches_identity \ + "$path" "$expected_parent" "$expected_parent_id" "$expected_target_id"; then + debug_log "Refusing privileged removal after final path identity changed: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "identity changed" + return 1 + fi + local remove_timeout="" + local section_deadline_spent=0 + remove_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds") || ret=$? + if [[ $ret -eq 0 ]]; then + output=$(_mole_bounded_sudo "$remove_timeout" \ + -n rm -rf "$path" < /dev/null 2>&1) || ret=$? # safe_remove + else + # The section's own wall-clock budget ran out, so rm never started. + section_deadline_spent=1 + fi + + if [[ $ret -eq 0 ]]; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" + return 0 + fi + + if [[ $ret -eq 124 ]]; then + if [[ $section_deadline_spent -eq 1 ]]; then + # The section prints its own "time limit reached" line; counting it + # here too would tell the user to raise the per-item removal budget, + # which is not the budget that ran out. + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$path" "section time limit reached" + else + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "removal timed out" + MOLE_CLEAN_REMOVAL_TIMEOUTS=$((${MOLE_CLEAN_REMOVAL_TIMEOUTS:-0} + 1)) + fi + return 124 + fi + if [[ $ret -ge 128 ]]; then + _mole_record_clean_cancellation "$ret" + return "$ret" + fi + + case "$output" in + *"a password is required"* | *"a terminal is required"* | *"Password:"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth required" + return "$MOLE_ERR_AUTH_FAILED" + ;; + *"Operation not permitted"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sip/mdm protected" + return "$MOLE_ERR_SIP_PROTECTED" + ;; + *"Read-only file system"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "readonly filesystem" + return "$MOLE_ERR_READONLY_FS" + ;; + *"Sorry, try again"* | *"incorrect passphrase"* | *"incorrect credentials"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth failed" + return "$MOLE_ERR_AUTH_FAILED" + ;; + *) + log_error "Failed to remove, sudo: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error" + return 1 + ;; + esac +} + +# ============================================================================ +# Unified deletion helper (Trash + permanent routing with forensic log) +# ============================================================================ + +# Route a deletion through either macOS Trash or permanent rm, while logging +# every call for forensic review. Designed for destructive paths where undo +# matters (e.g. uninstall). Not used by cache-clean paths. +# +# Usage: mole_delete [needs_sudo=false] [expected_dev_inode_mtime] +# +# Environment: +# MOLE_DELETE_MODE "permanent" (default) or "trash"; other values fail +# MOLE_DRY_RUN=1 Log intent, do not delete +# MOLE_TEST_TRASH_DIR Test-only override; Trash moves go here via `mv` +# instead of Finder/trash CLI. Required for bats. +# MOLE_DELETE_LOG Override the log file path (default: +# ~/Library/Logs/mole/deletions.log) +# +# Returns 0 on success and a nonzero MOLE_ERR_* code on failure. Always appends a tab-separated line to +# the deletions log: \t\t\t\t. +# size_kb is "unknown" when du could not measure the path (permission denied, +# disappeared mid-call); never silently coerced to 0KB so post-hoc forensics +# can tell measured-zero from measurement-failure. +mole_delete() { + local path="$1" + local needs_sudo="${2:-false}" + local expected_identity="${3:-}" + local mode="${MOLE_DELETE_MODE:-permanent}" + + [[ -z "$path" ]] && return 1 + + case "$mode" in + permanent | trash) ;; + *) + _mole_delete_log "$mode" "unknown" "invalid-mode" "$path" + if [[ -z "${_MOLE_INVALID_MODE_WARNED:-}" ]]; then + _MOLE_INVALID_MODE_WARNED=1 + export _MOLE_INVALID_MODE_WARNED + printf 'Error: invalid MOLE_DELETE_MODE: %s (expected "permanent" or "trash")\n' "$mode" >&2 + fi + return 1 + ;; + esac + + # Nothing to do if path does not exist (but a broken symlink still counts). + if [[ ! -e "$path" && ! -L "$path" ]]; then + return 0 + fi + + # Validation is delegated to the underlying safe_* helpers (which call + # validate_path_for_deletion). Trash routing only applies to paths the + # user could legitimately restore from, so we short-circuit invalid paths + # up front to avoid a no-op Trash move followed by a validation failure. + # The rejection itself is recorded in the forensic log so audit trails + # can distinguish refused-by-policy from never-attempted. + if ! validate_path_for_deletion "$path"; then + _mole_delete_log "$mode" "0" "rejected" "$path" + return 1 + fi + + if [[ "$needs_sudo" == "true" ]] && _mole_privileged_path_has_mutable_ancestor "$path"; then + # Neither sudo rm/mv nor Finder authorization is safe here: both receive + # a pathname that a non-root invoking user can replace after validation. + # Finder also fails for some package-installed apps even after its native + # authorization dialog (#1266). Keep both Trash and permanent modes + # fail-closed and direct the user to perform the app move themselves. + _mole_delete_log "$mode" "unknown" "mutable-parent" "$path" + debug_log "Refusing privileged delete below mutable parent: $path" + return "$MOLE_ERR_MUTABLE_PARENT" + fi + + # Capture size before the delete so the log line is still useful when the + # path is gone afterwards. Use "unknown" (not 0) on failure so the log + # never lies about a multi-GB delete by recording it as 0KB. + local size_kb="unknown" + if [[ -e "$path" ]]; then + local raw_size="" + local du_rc=0 + if [[ "$needs_sudo" == "true" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + du_rc=1 + else + raw_size=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + sudo -n du -skP "$path" 2> /dev/null | + awk '{print $1; exit}') || du_rc=$? + fi + else + raw_size=$(get_path_size_kb "$path" 2> /dev/null) || du_rc=$? + fi + if [[ "$du_rc" -eq 0 && "$raw_size" =~ ^[0-9]+$ ]]; then + size_kb="$raw_size" + fi + # Ctrl-C and other signals are cancellation, not an unknown-size + # measurement. Stop before any dry-run registration, Trash move, or + # permanent removal so the user's interrupt cannot be ignored. + if [[ $du_rc -eq 124 || $du_rc -ge 128 ]]; then + local cancel_status="interrupted" + [[ $du_rc -eq 124 ]] && cancel_status="timed-out" + _mole_delete_log "$mode" "$size_kb" "$cancel_status" "$path" + return "$du_rc" + fi + fi + + local expected_parent="" + local expected_parent_id="" + local expected_target_id="" + if [[ -n "$expected_identity" ]]; then + if ! _mole_snapshot_path_identity "$path"; then + _mole_delete_log "$mode" "$size_kb" "identity-changed" "$path" + return 1 + fi + expected_parent="$_MOLE_PATH_SNAPSHOT_PARENT" + expected_parent_id="$_MOLE_PATH_SNAPSHOT_PARENT_ID" + expected_target_id="$_MOLE_PATH_SNAPSHOT_TARGET_ID" + local current_identity="" + local identity_rc=0 + current_identity=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$STAT_BSD" -f%d:%i:%m "$path" 2> /dev/null) || identity_rc=$? + if [[ $identity_rc -eq 124 || $identity_rc -ge 128 ]]; then + local identity_status="interrupted" + [[ $identity_rc -eq 124 ]] && identity_status="timed-out" + _mole_delete_log "$mode" "$size_kb" "$identity_status" "$path" + return "$identity_rc" + fi + if [[ $identity_rc -ne 0 || "$current_identity" != "$expected_identity" || + "$expected_target_id" != "${expected_identity%:*}" || + "$expected_target_id" != "${current_identity%:*}" ]]; then + _mole_delete_log "$mode" "$size_kb" "identity-changed" "$path" + debug_log "Refusing deletion after selected path identity changed: $path" + return 1 + fi + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + local preview_rc=0 + if [[ "$size_kb" =~ ^[0-9]+$ ]]; then + _record_file_ops_dry_run_target "$path" "$size_kb" || preview_rc=$? + else + _record_file_ops_dry_run_target "$path" || preview_rc=$? + fi + if [[ $preview_rc -eq 124 || $preview_rc -ge 128 ]]; then + local preview_status="interrupted" + [[ $preview_rc -eq 124 ]] && preview_status="timed-out" + _mole_delete_log "$mode" "$size_kb" "$preview_status" "$path" + return "$preview_rc" + fi + debug_log "[DRY RUN] Would delete ($mode): $path" + _mole_delete_log "$mode" "$size_kb" "dry-run" "$path" + return 0 + fi + + if [[ "$needs_sudo" == "true" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + _mole_delete_log "$mode" "$size_kb" "sudo-blocked-test-mode" "$path" + return 1 + fi + fi + + # Trash mode is a recoverable-delete contract. If Trash is unavailable, + # fail closed instead of silently switching to permanent removal. + if [[ "$mode" == "trash" ]]; then + local trash_rc=0 + _mole_move_to_trash "$path" "$needs_sudo" \ + "$expected_parent" "$expected_parent_id" \ + "$expected_target_id" || trash_rc=$? + if [[ $trash_rc -eq 0 ]]; then + _mole_delete_log "trash" "$size_kb" "ok" "$path" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "TRASHED" "$path" "${size_kb}KB" + return 0 + fi + if [[ $trash_rc -eq $MOLE_ERR_PRIVACY_DENIED ]]; then + _mole_delete_log "trash" "$size_kb" "privacy-denied" "$path" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "SKIPPED" "$path" "privacy permission denied" + if [[ -z "${_MOLE_PRIVACY_DENIED_WARNED:-}" ]]; then + _MOLE_PRIVACY_DENIED_WARNED=1 + export _MOLE_PRIVACY_DENIED_WARNED + printf 'Error: macOS could not authorize Trash access. Review App Management, App Data, or Full Disk Access for your terminal in System Settings, then retry.\n' >&2 + fi + debug_log "macOS privacy permission denied while moving to Trash: $path" + return "$MOLE_ERR_PRIVACY_DENIED" + fi + if [[ $trash_rc -eq $MOLE_ERR_MUTABLE_PARENT ]]; then + _mole_delete_log "trash" "$size_kb" "mutable-parent" "$path" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "SKIPPED" "$path" "mutable-parent" + debug_log "Trash move stopped because a mutable parent was detected: $path" + return "$MOLE_ERR_MUTABLE_PARENT" + fi + if [[ $trash_rc -eq 124 || $trash_rc -ge 128 ]]; then + local trash_status="interrupted" + [[ $trash_rc -eq 124 ]] && trash_status="timed-out" + _mole_delete_log "trash" "$size_kb" "$trash_status" "$path" + return "$trash_rc" + fi + _mole_delete_log "trash" "$size_kb" "trash-failed" "$path" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "SKIPPED" "$path" "trash-failed" + if [[ -z "${_MOLE_TRASH_UNAVAILABLE_WARNED:-}" ]]; then + _MOLE_TRASH_UNAVAILABLE_WARNED=1 + export _MOLE_TRASH_UNAVAILABLE_WARNED + printf 'Error: Trash unavailable; refusing permanent delete. Use --permanent to delete immediately.\n' >&2 + fi + debug_log "Trash move failed, refusing permanent delete: $path" + return 1 + fi + + # Permanent path. Delegate to the existing safe_* helpers so path + # validation, sudo handling, and existing log_operation calls remain + # unchanged for callers that have always gone through rm -rf. + local rc=0 + if [[ "$needs_sudo" == "true" ]] && _mole_privileged_path_has_mutable_ancestor "$path"; then + # Recheck at the permanent-delete sink. The parent may have become + # mutable while size accounting was running. This check covers both + # regular paths and symlinks before either helper can downgrade. + rc=$MOLE_ERR_MUTABLE_PARENT + elif [[ -L "$path" ]]; then + safe_remove_symlink "$path" "$needs_sudo" \ + "$expected_parent" "$expected_parent_id" \ + "$expected_target_id" || rc=$? + elif [[ "$needs_sudo" == "true" ]]; then + safe_sudo_remove "$path" "$size_kb" "" \ + "$expected_parent" "$expected_parent_id" \ + "$expected_target_id" || rc=$? + else + safe_remove "$path" "true" "$size_kb" "" \ + "$expected_parent" "$expected_parent_id" \ + "$expected_target_id" || rc=$? + fi + + local status_label="ok" + if [[ $rc -eq $MOLE_ERR_MUTABLE_PARENT ]]; then + status_label="mutable-parent" + elif [[ $rc -eq 124 ]]; then + status_label="timed-out" + elif [[ $rc -ge 128 ]]; then + status_label="interrupted" + elif [[ $rc -ne 0 ]]; then + status_label="error" + fi + _mole_delete_log "$mode" "$size_kb" "$status_label" "$path" + return "$rc" +} + +_mole_valid_invoking_home() { + local user_home="" + if declare -f get_invoking_home > /dev/null 2>&1; then + user_home=$(get_invoking_home) + else + user_home="${MOLE_USER_HOME:-${HOME:-}}" + fi + + if [[ -z "$user_home" || "$user_home" != /* || "$user_home" == "/" || "$user_home" == "/var/root" ]]; then + debug_log "Refusing direct Trash move: invalid invoking user home: ${user_home:-}" + return 1 + fi + + printf '%s\n' "${user_home%/}" +} + +_mole_path_is_immediate_child_of() { + local path="${1%/}" + local parent="${2%/}" + [[ "$path" == "$parent/"* ]] || return 1 + + local child="${path#"$parent"/}" + [[ -n "$child" && "$child" != */* ]] +} + +_mole_path_is_application_bundle() { + local path="${1%/}" + _mole_path_is_immediate_child_of "$path" "/Applications" && + [[ "${path##*/}" == *.app ]] +} + +# Finder and third-party Trash helpers can fail on app bundles and TCC-managed +# app data even after authentication. Route only these exact one-level targets +# through the direct, recoverable Trash mover. +_mole_path_requires_direct_trash() { + local path="${1%/}" + if _mole_path_is_application_bundle "$path"; then + return 0 + fi + + local user_home + user_home=$(_mole_valid_invoking_home) || return 1 + _mole_path_is_immediate_child_of "$path" "$user_home/Library/Containers" && return 0 + _mole_path_is_immediate_child_of "$path" "$user_home/Library/Group Containers" && return 0 + _mole_path_is_immediate_child_of "$path" "$user_home/Library/Application Scripts" && return 0 + return 1 +} + +# Finder's Trash API can move package-installed app bundles that macOS App +# Management blocks from a direct mv. Run it only as the invoking user and only +# for an exact one-level /Applications/*.app target selected above. +_mole_move_app_to_trash_via_finder() { + local path="$1" + local expected_parent="${2:-}" + local expected_parent_id="${3:-}" + local expected_target_id="${4:-}" + local finder_rc=0 + + _mole_path_is_application_bundle "$path" || return 1 + _mole_bound_path_matches "$path" "$expected_parent" \ + "$expected_parent_id" "$expected_target_id" || return 1 + + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" osascript - "$path" > /dev/null 2>&1 << 'APPLESCRIPT' || finder_rc=$? +on run argv + set p to POSIX file (item 1 of argv) + tell application "Finder" + delete p + end tell +end run +APPLESCRIPT + + if [[ $finder_rc -eq 124 || $finder_rc -ge 128 ]]; then + return "$finder_rc" + elif [[ $finder_rc -ne 0 ]] || [[ -e "$path" || -L "$path" ]]; then + debug_log "Finder failed to move application to Trash: $path" + return 1 + fi + + debug_log "Finder moved application to Trash: $path" + return 0 +} + +# Move a path to the macOS Trash. Test harnesses set MOLE_TEST_TRASH_DIR to +# redirect the move to a tmpdir, avoiding any Finder/osascript interaction. +_mole_move_to_trash() { + local path="$1" + local needs_sudo="${2:-false}" + local expected_parent="${3:-}" + local expected_parent_id="${4:-}" + local expected_target_id="${5:-}" + + if [[ -n "${MOLE_TEST_TRASH_DIR:-}" ]]; then + mkdir -p "$MOLE_TEST_TRASH_DIR" 2> /dev/null || return 1 + local dest="$MOLE_TEST_TRASH_DIR/$(basename "$path").$$.$(date +%s 2> /dev/null || echo 0)" + _mole_bound_path_matches "$path" "$expected_parent" \ + "$expected_parent_id" "$expected_target_id" || return 1 + mv "$path" "$dest" 2> /dev/null + return $? + fi + + # Blocked in test mode so uninstall tests never hit Finder/AppleScript. + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + if [[ "$needs_sudo" == "true" ]]; then + _mole_move_path_to_user_trash "$path" "$needs_sudo" \ + "$expected_parent" "$expected_parent_id" "$expected_target_id" + return $? + fi + + if _mole_path_requires_direct_trash "$path"; then + local direct_rc=0 + _mole_move_path_to_user_trash "$path" false \ + "$expected_parent" "$expected_parent_id" \ + "$expected_target_id" || direct_rc=$? + if [[ $direct_rc -eq $MOLE_ERR_PRIVACY_DENIED ]] && + _mole_path_is_application_bundle "$path"; then + debug_log "Direct Trash move was denied; retrying application through Finder: $path" + local finder_rc=0 + _mole_move_app_to_trash_via_finder "$path" \ + "$expected_parent" "$expected_parent_id" \ + "$expected_target_id" || finder_rc=$? + [[ $finder_rc -eq 0 ]] && return 0 + [[ $finder_rc -eq 124 || $finder_rc -ge 128 ]] && return "$finder_rc" + fi + return "$direct_rc" + fi + + # Prefer the `trash` CLI (Homebrew formula) for normal user-owned paths. + if command -v trash > /dev/null 2>&1; then + local trash_rc=0 + _mole_bound_path_matches "$path" "$expected_parent" \ + "$expected_parent_id" "$expected_target_id" || return 1 + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + trash "$path" > /dev/null 2>&1 || trash_rc=$? + [[ $trash_rc -eq 0 ]] && return 0 + [[ $trash_rc -eq 124 || $trash_rc -ge 128 ]] && return "$trash_rc" + fi + + # AppleScript fallback. Pass the path via argv so special chars (quotes, + # backslashes) cannot break out of the quoted string. + _mole_bound_path_matches "$path" "$expected_parent" \ + "$expected_parent_id" "$expected_target_id" || return 1 + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + osascript - "$path" > /dev/null 2>&1 << 'APPLESCRIPT' +on run argv + set p to POSIX file (item 1 of argv) + tell application "Finder" + delete p + end tell +end run +APPLESCRIPT +} + +# /Library/Caches is mode 0777 on supported macOS releases, so it cannot anchor a +# privileged path operation. This prepares an exact root-owned parent under +# immutable /Library; mode 0711 lets the invoking user traverse into only the +# randomized staging directory handed to them later. +# +# Takes the root as an argument so the concurrency behaviour is reachable from a +# test without a real /Library write. +_mole_prepare_privileged_trash_stage_root() { + local stage_root="$1" + + # Refuse an existing symlink before any ownership or mode operation. Only + # root can mutate /Library, but a stale privileged symlink must not make + # chown/chmod follow into an unrelated tree. Then mkdir -p rather than + # test-then-mkdir: two concurrent Mole processes can both see a missing root, + # and the loser of a plain mkdir would abort a Trash move that was safe. + # Tolerating EEXIST costs nothing, because the verification below is what + # actually decides whether this root can anchor the operation. + if [[ -L "$stage_root" ]]; then + return 1 + fi + sudo -n /bin/mkdir -p "$stage_root" 2> /dev/null || return 1 + sudo -n /usr/sbin/chown 0:0 "$stage_root" 2> /dev/null || return 1 + sudo -n /bin/chmod -N "$stage_root" 2> /dev/null || return 1 + sudo -n /bin/chmod 711 "$stage_root" 2> /dev/null || return 1 + + local root_uid="" + local root_mode="" + root_uid=$($STAT_BSD -f%u "$stage_root" 2> /dev/null || true) + root_mode=$($STAT_BSD -f%Lp "$stage_root" 2> /dev/null || true) + if [[ -L "$stage_root" || "$root_uid" != "0" || "$root_mode" != "711" ]]; then + return 1 + fi +} + +_mole_create_privileged_trash_stage() { + local stage_root="/Library/MoleTrashStaging" + local stage_dir="" + + _mole_prepare_privileged_trash_stage_root "$stage_root" || return 1 + + stage_dir=$(sudo -n /usr/bin/mktemp -d "$stage_root/item.XXXXXX" 2> /dev/null) || return 1 + if [[ "$stage_dir" != "$stage_root"/item.* || ! -d "$stage_dir" || -L "$stage_dir" ]]; then + return 1 + fi + if _mole_privileged_path_has_mutable_ancestor "$stage_dir/item"; then + sudo -n /bin/rm -rf "$stage_dir" 2> /dev/null || true # SAFE: exact empty staging directory created by mktemp above + return 1 + fi + printf '%s\n' "$stage_dir" +} + +# The staging root is deliberately persistent. Removing it when it looked empty +# raced with a concurrent Mole process that had just validated it and was about +# to mktemp inside, turning a safe Trash move into a spurious failure. +# +# `mo remove` deliberately leaves it behind too. It is an empty root-owned +# directory, and removing it would add a privileged step to an uninstall that +# may need no privileges at all: a ~/.local-only install would meet a sudo +# prompt for nothing. Any non-empty state is a payload a failed Trash move +# preserved for the user, which uninstall must not touch either. + +_mole_move_path_to_user_trash() { + local path="$1" + local needs_sudo="${2:-false}" + local expected_parent="${3:-}" + local expected_parent_id="${4:-}" + local expected_target_id="${5:-}" - if [[ ! -L "$path" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then return 1 fi - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - debug_log "[DRY RUN] Would remove symlink: $path" - return 0 + local user_home + user_home=$(_mole_valid_invoking_home) || return 1 + + if [[ -z "$path" ]] || [[ ! -e "$path" && ! -L "$path" ]]; then + debug_log "Refusing direct Trash move: path does not exist: ${path:-}" + return 1 fi - local rm_exit=0 - if [[ "$use_sudo" == "true" ]]; then - sudo rm "$path" 2> /dev/null || rm_exit=$? - else - rm "$path" 2> /dev/null || rm_exit=$? + if [[ "$needs_sudo" == "true" ]] && _mole_privileged_path_has_mutable_ancestor "$path"; then + debug_log "Refusing direct privileged Trash move below mutable parent: $path" + return "$MOLE_ERR_MUTABLE_PARENT" fi - if [[ $rm_exit -eq 0 ]]; then - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "symlink" - return 0 - else - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "symlink removal failed" + local trash_dir="${user_home%/}/.Trash" + local owner_uid="" owner_gid="" + if declare -f get_invoking_uid > /dev/null 2>&1; then + owner_uid=$(get_invoking_uid) + fi + if declare -f get_invoking_gid > /dev/null 2>&1; then + owner_gid=$(get_invoking_gid) + fi + if [[ ! "$owner_uid" =~ ^[0-9]+$ || ! "$owner_gid" =~ ^[0-9]+$ ]]; then + debug_log "Failed to resolve invoking user ownership for Trash" return 1 fi -} - -# Safe sudo removal with symlink protection -safe_sudo_remove() { - local path="$1" - if ! validate_path_for_deletion "$path"; then - log_error "Path validation failed for sudo remove: $path" + # The destination must be the invoking user's Trash, even though sudo is + # needed to unlink the original protected path. + if [[ -L "$trash_dir" ]]; then + debug_log "Refusing direct Trash move: invoking user Trash is a symlink: $trash_dir" return 1 fi - - if [[ ! -e "$path" ]]; then - return 0 + if [[ ${EUID:-0} -eq 0 ]]; then + sudo -n -u "#$owner_uid" mkdir -p "$trash_dir" 2> /dev/null || { + debug_log "Failed to create invoking user Trash: $trash_dir" + return 1 + } + elif ! mkdir -p "$trash_dir" 2> /dev/null; then + debug_log "Failed to create invoking user Trash: $trash_dir" + return 1 + fi + if [[ ! -d "$trash_dir" || -L "$trash_dir" ]]; then + debug_log "Refusing direct Trash move: invoking user Trash is not a normal directory: $trash_dir" + return 1 fi - if [[ -L "$path" ]]; then - log_error "Refusing to sudo remove symlink: $path" + local trash_owner_uid="" + trash_owner_uid=$($STAT_BSD -f%u "$trash_dir" 2> /dev/null || true) + if [[ "$trash_owner_uid" != "$owner_uid" ]]; then + debug_log "Refusing direct Trash move: invoking user does not own Trash: $trash_dir" + return 1 + fi + if [[ ${EUID:-0} -eq 0 ]]; then + if ! sudo -n -u "#$owner_uid" chmod 700 "$trash_dir" 2> /dev/null; then + debug_log "Failed to set invoking user Trash permissions: $trash_dir" + return 1 + fi + elif ! chmod 700 "$trash_dir" 2> /dev/null; then + debug_log "Failed to set invoking user Trash permissions: $trash_dir" return 1 fi - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - if [[ "${MO_DEBUG:-}" == "1" ]]; then - local file_type="file" - [[ -d "$path" ]] && file_type="directory" + # Avoid Finder-style ':' path weirdness and keep generated names filesystem-safe. + local base + base=$(basename "$path") + base="${base//:/__}" + base="${base//\//__}" + [[ -n "$base" && "$base" != "." && "$base" != ".." ]] || base="mole-trash-item" + + local dest="$trash_dir/$base" + local ts suffix + ts=$(date +%s 2> /dev/null || echo 0) + suffix=0 + + while [[ -e "$dest" || -L "$dest" ]]; do + suffix=$((suffix + 1)) + if [[ $suffix -gt 100 ]]; then + debug_log "Failed to choose unique Trash destination for: $path" + return 1 + fi + dest="$trash_dir/$base.$ts.$$.$suffix" + done - local file_size="" - local file_age="" + if [[ -n "$expected_parent" ]]; then + if ! _mole_path_matches_identity \ + "$path" "$expected_parent" "$expected_parent_id" "$expected_target_id"; then + debug_log "Refusing Trash move after selected path identity changed: $path" + return 1 + fi + elif [[ -n "${_MOLE_TRASH_MOVE_EXPECTED_PATH:-}" && "$_MOLE_TRASH_MOVE_EXPECTED_PATH" == "$path" ]]; then + if ! _mole_path_matches_identity \ + "$path" \ + "$_MOLE_TRASH_MOVE_EXPECTED_PARENT" \ + "$_MOLE_TRASH_MOVE_EXPECTED_PARENT_ID" \ + "$_MOLE_TRASH_MOVE_EXPECTED_TARGET_ID"; then + debug_log "Refusing Trash move after source path identity changed: $path" + return 1 + fi + fi - if sudo test -e "$path" 2> /dev/null; then - local size_kb - size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") - if [[ "$size_kb" -gt 0 ]]; then - file_size=$(bytes_to_human "$((size_kb * 1024))") + local move_output="" + local move_rc=0 + if [[ "$needs_sudo" == "true" ]]; then + # Never point a root mv directly into ~/.Trash: that directory is + # intentionally controlled by the invoking user and can be replaced + # after validation. First cross the privileged boundary into a + # root-owned staging directory, then perform the final Trash move with + # only the invoking user's authority. + local stage_dir="" + local stage_path="" + stage_dir=$(_mole_create_privileged_trash_stage) || { + debug_log "Failed to create immutable Trash staging directory" + return 1 + } + stage_path="$stage_dir/item" + + # The first move must be a same-filesystem rename. A cross-volume mv + # degrades into copy-then-delete and can leave the only payload split + # between source and staging if it fails midway. + local source_device="" + local stage_device="" + local device_rc=0 + source_device=$($STAT_BSD -f%d "$path" 2> /dev/null) || device_rc=$? + [[ $device_rc -eq 124 || $device_rc -ge 128 ]] && return "$device_rc" + if [[ $device_rc -eq 0 ]]; then + stage_device=$($STAT_BSD -f%d "$stage_dir" 2> /dev/null) || device_rc=$? + fi + [[ $device_rc -eq 124 || $device_rc -ge 128 ]] && return "$device_rc" + if [[ ! "$source_device" =~ ^[0-9]+$ || "$source_device" != "$stage_device" ]]; then + sudo -n /bin/rm -rf "$stage_dir" 2> /dev/null || true # SAFE: exact empty staging directory created by mktemp above + debug_log "Refusing cross-volume privileged Trash staging: $path" + return 1 + fi + + local stage_move_rc=0 + _mole_bound_path_matches "$path" "$expected_parent" \ + "$expected_parent_id" "$expected_target_id" || return 1 + sudo -n /bin/mv "$path" "$stage_path" 2> /dev/null || stage_move_rc=$? + if [[ $stage_move_rc -ne 0 ]]; then + if [[ $stage_move_rc -eq 124 || $stage_move_rc -ge 128 ]]; then + if [[ -e "$stage_path" || -L "$stage_path" ]]; then + log_error "Trash move interrupted; item preserved for recovery at: $stage_path" fi + return "$stage_move_rc" + fi + sudo -n /bin/rm -rf "$stage_dir" 2> /dev/null || true # SAFE: exact root-owned directory created by mktemp above + debug_log "Failed to move path into immutable Trash staging: $path" + return 1 + fi + # Keep the stage root-owned while ownership is repaired. `-h` keeps chown + # off symlink targets, and `-x` keeps it from descending into a nested + # mount: the same-device gate above only compares the payload root with + # the stage, so a disk image or FUSE volume mounted *inside* the payload + # is still reachable, and chown -R would rewrite ownership on that + # filesystem and can block on it. Once either payload ownership or stage + # ownership changes, preserve on any later failure rather than + # reintroducing user-owned content into the privileged source path. + local handoff_rc=0 + sudo -n /bin/chmod 700 "$stage_dir" 2> /dev/null || handoff_rc=$? + if [[ $handoff_rc -eq 0 ]]; then + sudo -n /usr/sbin/chown -Rhx "$owner_uid:$owner_gid" \ + "$stage_path" 2> /dev/null || handoff_rc=$? + fi + if [[ $handoff_rc -eq 0 ]]; then + sudo -n /usr/sbin/chown "$owner_uid:$owner_gid" \ + "$stage_dir" 2> /dev/null || handoff_rc=$? + fi + if [[ $handoff_rc -ne 0 ]]; then + if [[ $handoff_rc -eq 124 || $handoff_rc -ge 128 ]]; then + log_error "Trash move interrupted; item preserved for recovery at: $stage_path" + return "$handoff_rc" + fi + log_error "Trash move failed; item preserved for recovery at: $stage_path" + debug_log "Failed to hand Trash staging directory to invoking user" + return 1 + fi - if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then - local mod_time - mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") - local now - now=$(date +%s 2> /dev/null || echo "0") - if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then - local age_seconds=$((now - mod_time)) - file_age=$(format_duration_human "$age_seconds") - fi + if [[ ${EUID:-0} -eq 0 ]]; then + move_output=$(sudo -n -u "#$owner_uid" /bin/mv -n "$stage_path" "$dest" 2>&1) || move_rc=$? + else + move_output=$(/bin/mv -n "$stage_path" "$dest" 2>&1) || move_rc=$? + fi + + if [[ $move_rc -ne 0 || -e "$stage_path" || -L "$stage_path" ]]; then + if [[ $move_rc -eq 124 || $move_rc -ge 128 ]]; then + if [[ -e "$stage_path" || -L "$stage_path" ]]; then + log_error "Trash move interrupted; item preserved for recovery at: $stage_path" + elif [[ -e "$dest" || -L "$dest" ]]; then + debug_log "Trash move completed before interruption was observed: $dest" fi + return "$move_rc" fi + move_rc=1 + # stage_dir is user-controlled after the ownership handoff above. + # Never let root resolve stage_path again: it may have been replaced + # between the failed user move and this branch. Preserve the item + # in staging and report the exact recovery location instead. + log_error "Trash move failed; item preserved for recovery at: $stage_path" + else + # The invoking user owns stage_dir but has no write bit on the + # root-owned 0711 parent, so only a privileged rmdir can unlink it. + sudo -n /bin/rmdir "$stage_dir" 2> /dev/null || true + fi + else + _mole_bound_path_matches "$path" "$expected_parent" \ + "$expected_parent_id" "$expected_target_id" || return 1 + move_output=$(mv -n "$path" "$dest" 2>&1) || move_rc=$? + fi + if [[ $move_rc -ne 0 ]]; then + [[ $move_rc -eq 124 || $move_rc -ge 128 ]] && return "$move_rc" + debug_log "Failed to move path directly to invoking user Trash: $path -> $dest: $move_output" + case "$move_output" in + *"Operation not permitted"* | *"operation not permitted"* | \ + *"Permission denied"* | *"permission denied"*) + return "$MOLE_ERR_PRIVACY_DENIED" + ;; + esac + return 1 + fi + if [[ -e "$path" || -L "$path" ]] || [[ ! -e "$dest" && ! -L "$dest" ]]; then + debug_log "Failed to move path directly without overwriting destination: $path -> $dest" + return 1 + fi - log_info "[DRY-RUN] Would sudo remove: $file_type $path" - [[ -n "$file_size" ]] && log_info " Size: $file_size" - [[ -n "$file_age" ]] && log_info " Age: $file_age" + debug_log "Moved path directly to invoking user Trash: $path -> $dest" + return 0 +} + +# Batched Trash move for non-sudo, non-symlink paths. Removes the per-file +# Finder/AppleScript fan-out that made uninstalls feel frozen. The caller binds +# each item to its original physical parent and inode through the snapshot +# arrays below; this helper rechecks that identity before every direct move. +_MOLE_TRASH_BATCH_SNAPSHOT_PATHS=() +_MOLE_TRASH_BATCH_SNAPSHOT_PARENTS=() +_MOLE_TRASH_BATCH_SNAPSHOT_PARENT_IDS=() +_MOLE_TRASH_BATCH_SNAPSHOT_TARGET_IDS=() +_MOLE_TRASH_BATCH_MOVED_PATHS=() +_MOLE_TRASH_MOVE_EXPECTED_PATH="" +_MOLE_TRASH_MOVE_EXPECTED_PARENT="" +_MOLE_TRASH_MOVE_EXPECTED_PARENT_ID="" +_MOLE_TRASH_MOVE_EXPECTED_TARGET_ID="" + +_MOLE_PATH_SNAPSHOT_PARENT="" +_MOLE_PATH_SNAPSHOT_PARENT_ID="" +_MOLE_PATH_SNAPSHOT_TARGET_ID="" + +_mole_snapshot_path_identity() { + local path="$1" + _MOLE_PATH_SNAPSHOT_PARENT="" + _MOLE_PATH_SNAPSHOT_PARENT_ID="" + _MOLE_PATH_SNAPSHOT_TARGET_ID="" + + [[ -e "$path" || -L "$path" ]] || return 1 + local lexical_parent="${path%/*}" + [[ -n "$lexical_parent" && "$lexical_parent" != "$path" ]] || lexical_parent="/" + + local physical_parent="" + physical_parent=$(cd -P "$lexical_parent" 2> /dev/null && pwd -P) || return 1 + local parent_id="" + local target_id="" + parent_id=$($STAT_BSD -f '%d:%i' "$physical_parent" 2> /dev/null || true) + target_id=$($STAT_BSD -f '%d:%i' "$path" 2> /dev/null || true) + [[ "$parent_id" =~ ^[0-9]+:[0-9]+$ && "$target_id" =~ ^[0-9]+:[0-9]+$ ]] || return 1 + + _MOLE_PATH_SNAPSHOT_PARENT="$physical_parent" + _MOLE_PATH_SNAPSHOT_PARENT_ID="$parent_id" + _MOLE_PATH_SNAPSHOT_TARGET_ID="$target_id" +} + +_mole_path_matches_identity() { + local path="$1" + local expected_parent="$2" + local expected_parent_id="$3" + local expected_target_id="$4" + + _mole_snapshot_path_identity "$path" || return 1 + [[ "$_MOLE_PATH_SNAPSHOT_PARENT" == "$expected_parent" ]] || return 1 + [[ "$_MOLE_PATH_SNAPSHOT_PARENT_ID" == "$expected_parent_id" ]] || return 1 + [[ "$_MOLE_PATH_SNAPSHOT_TARGET_ID" == "$expected_target_id" ]] +} + +_mole_bound_path_matches() { + local path="$1" + local expected_parent="${2:-}" + local expected_parent_id="${3:-}" + local expected_target_id="${4:-}" + [[ -z "$expected_parent" ]] && return 0 + _mole_path_matches_identity \ + "$path" "$expected_parent" "$expected_parent_id" "$expected_target_id" +} + +_mole_move_to_trash_batch() { + local -a paths=("$@") + [[ ${#paths[@]} -eq 0 ]] && return 0 + _MOLE_TRASH_BATCH_MOVED_PATHS=() + + local use_bound_snapshots=false + if [[ ${#_MOLE_TRASH_BATCH_SNAPSHOT_PATHS[@]} -eq ${#paths[@]} && + ${#_MOLE_TRASH_BATCH_SNAPSHOT_PARENTS[@]} -eq ${#paths[@]} && + ${#_MOLE_TRASH_BATCH_SNAPSHOT_PARENT_IDS[@]} -eq ${#paths[@]} && + ${#_MOLE_TRASH_BATCH_SNAPSHOT_TARGET_IDS[@]} -eq ${#paths[@]} ]]; then + use_bound_snapshots=true + fi + + local -a expected_parents=() + local -a expected_parent_ids=() + local -a expected_target_ids=() + local index p + for ((index = 0; index < ${#paths[@]}; index++)); do + p="${paths[$index]}" + if [[ "$use_bound_snapshots" == "true" ]]; then + [[ "${_MOLE_TRASH_BATCH_SNAPSHOT_PATHS[$index]}" == "$p" ]] || return 1 + expected_parents+=("${_MOLE_TRASH_BATCH_SNAPSHOT_PARENTS[$index]}") + expected_parent_ids+=("${_MOLE_TRASH_BATCH_SNAPSHOT_PARENT_IDS[$index]}") + expected_target_ids+=("${_MOLE_TRASH_BATCH_SNAPSHOT_TARGET_IDS[$index]}") else - log_info "[DRY-RUN] Would sudo remove: $path" + _mole_snapshot_path_identity "$p" || return 1 + expected_parents+=("$_MOLE_PATH_SNAPSHOT_PARENT") + expected_parent_ids+=("$_MOLE_PATH_SNAPSHOT_PARENT_ID") + expected_target_ids+=("$_MOLE_PATH_SNAPSHOT_TARGET_ID") fi + done + + if [[ -n "${MOLE_TEST_TRASH_DIR:-}" ]]; then + mkdir -p "$MOLE_TEST_TRASH_DIR" 2> /dev/null || return 1 + local ts + ts=$(date +%s 2> /dev/null || echo 0) + local dest + for ((index = 0; index < ${#paths[@]}; index++)); do + p="${paths[$index]}" + _mole_path_matches_identity \ + "$p" \ + "${expected_parents[$index]}" \ + "${expected_parent_ids[$index]}" \ + "${expected_target_ids[$index]}" || return 1 + dest="$MOLE_TEST_TRASH_DIR/$(basename "$p").$$.${ts}.$RANDOM" + /bin/mv "$p" "$dest" 2> /dev/null || return 1 + _MOLE_TRASH_BATCH_MOVED_PATHS+=("$p") + done return 0 fi - local size_kb=0 - local size_human="" - if oplog_enabled; then - if sudo test -e "$path" 2> /dev/null; then - size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") - if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") - fi - fi + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 fi - local output - local ret=0 - output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove + # Avoid handing a stale lexical batch to a third-party Trash CLI or Finder. + # Direct per-item renames keep the helper in one shell process and let us + # recheck the bound parent/inode immediately before every move. + local failed=0 + for ((index = 0; index < ${#paths[@]}; index++)); do + p="${paths[$index]}" + if ! _mole_path_matches_identity \ + "$p" \ + "${expected_parents[$index]}" \ + "${expected_parent_ids[$index]}" \ + "${expected_target_ids[$index]}"; then + failed=1 + continue + fi + _MOLE_TRASH_MOVE_EXPECTED_PATH="$p" + _MOLE_TRASH_MOVE_EXPECTED_PARENT="${expected_parents[$index]}" + _MOLE_TRASH_MOVE_EXPECTED_PARENT_ID="${expected_parent_ids[$index]}" + _MOLE_TRASH_MOVE_EXPECTED_TARGET_ID="${expected_target_ids[$index]}" + if _mole_move_path_to_user_trash "$p" false; then + _MOLE_TRASH_BATCH_MOVED_PATHS+=("$p") + else + failed=1 + fi + _MOLE_TRASH_MOVE_EXPECTED_PATH="" + _MOLE_TRASH_MOVE_EXPECTED_PARENT="" + _MOLE_TRASH_MOVE_EXPECTED_PARENT_ID="" + _MOLE_TRASH_MOVE_EXPECTED_TARGET_ID="" + done + [[ $failed -eq 0 ]] +} - if [[ $ret -eq 0 ]]; then - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" +_mole_delete_log() { + local mode="$1" + local size_kb="$2" + local status="$3" + local target="$4" + + local log_file="${MOLE_DELETE_LOG:-$HOME/Library/Logs/mole/deletions.log}" + local log_dir + log_dir=$(dirname "$log_file") + + # Surface log-write failures once per session. The deletions log is the + # only audit trail for Trash-routed removals; silently no-oping when the + # log dir is unwritable (root-owned from prior sudo, ENOSPC, read-only + # volume) defeats the design. + if ! mkdir -p "$log_dir" 2> /dev/null; then + _mole_warn_log_broken "create directory: $log_dir" return 0 fi - case "$output" in - *"Operation not permitted"*) - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sip/mdm protected" - return "$MOLE_ERR_SIP_PROTECTED" - ;; - *"Read-only file system"*) - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "readonly filesystem" - return "$MOLE_ERR_READONLY_FS" - ;; - *"Sorry, try again"* | *"incorrect passphrase"* | *"incorrect credentials"*) - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth failed" - return "$MOLE_ERR_AUTH_FAILED" - ;; - *) - log_error "Failed to remove, sudo: $path" - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error" - return 1 - ;; - esac + local ts + ts=$(date '+%Y-%m-%dT%H:%M:%S%z' 2> /dev/null || echo "unknown") + + if ! printf '%s\t%s\t%s\t%s\t%s\n' \ + "$ts" "$mode" "$size_kb" "$status" "$target" \ + >> "$log_file" 2> /dev/null; then + _mole_warn_log_broken "write to: $log_file" + fi +} + +_mole_warn_log_broken() { + [[ -n "${_MOLE_DELETE_LOG_WARNED:-}" ]] && return 0 + _MOLE_DELETE_LOG_WARNED=1 + export _MOLE_DELETE_LOG_WARNED + printf 'Warning: deletions audit log unavailable (%s). Forensic trail incomplete this session.\n' "$1" >&2 } # ============================================================================ @@ -434,62 +2410,505 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path + local scan_file="" + if ! scan_file=$(create_temp_file 2> /dev/null); then + return 1 + fi + local scan_rc=0 + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" find \ + "$base_dir" "${find_args[@]}" -print0 < /dev/null > "$scan_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + debug_log "Cleanup scan failed for $base_dir (status $scan_rc)" + return "$scan_rc" + fi + + # Iterate only a completed result set so a timeout cannot authorize + # deletion of the partial prefix. + # Per-caller whitelist gates were missed in past releases (see #710, #724, + # #738, #744, #757); enforcing here makes the protection structural so + # new clean_* functions get whitelist enforcement for free. + local delete_rc=0 while IFS= read -r -d '' match; do - if should_protect_path "$match"; then + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$match"; then continue fi - safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$match"; then + continue + fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + local match_size_kb=0 + local match_size_rc=0 + match_size_kb=$(get_path_size_kb "$match" 2> /dev/null) || match_size_rc=$? + if [[ $match_size_rc -eq 124 || $match_size_rc -ge 128 ]]; then + delete_rc=$match_size_rc + break + fi + [[ $match_size_rc -eq 0 ]] || match_size_kb=0 + [[ "$match_size_kb" =~ ^[0-9]+$ ]] || match_size_kb=0 + record_dry_run_cleanup_target "$match" "$match_size_kb" 1 true || continue + fi + local remove_rc=0 + safe_remove "$match" true || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + delete_rc=$remove_rc + break + fi + if [[ $remove_rc -ne 0 && $delete_rc -eq 0 ]]; then + delete_rc=$remove_rc + fi + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + + return "$delete_rc" +} +# Run privileged find with a wall-clock budget and closed stdin. +_mole_bounded_sudo_find() { + local duration="${1:-${MOLE_TIMEOUT_DISK_VERIFY_SEC:-30}}" + shift || true + [[ $# -gt 0 ]] || return 2 + _mole_bounded_sudo "$duration" -n find "$@" < /dev/null +} + +# Store a privileged scan only after the producer completed successfully. A +# timeout or any other failure truncates the destination so callers cannot act +# on a partial prefix. +_mole_materialize_bounded_sudo_find() { + local output_file="$1" + local duration="$2" + shift 2 + [[ $# -gt 0 ]] || return 2 + + : > "$output_file" || return 1 + local scan_rc=0 + _mole_bounded_sudo_find "$duration" "$@" > "$output_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + : > "$output_file" || true + return "$scan_rc" + fi return 0 } +# Keep privileged batch state bounded in Bash. This is a function rather than a +# public setting so tests can exercise the limit without exposing another user +# knob or constructing thousands of command-substitution probes. +_mole_privileged_batch_max_items() { + printf '4096\n' +} + # Safe sudo discovery and deletion safe_sudo_find_delete() { local base_dir="$1" local pattern="$2" local age_days="${3:-7}" local type_filter="${4:-f}" + local max_depth="${5:-5}" + local deadline_seconds="${6:-}" + local -a name_patterns=("$pattern") + if [[ $# -gt 6 ]]; then + name_patterns+=("${@:7}") + fi + + # Callers use this count to distinguish a completed empty scan from actual + # cleanup. It is reset for every invocation and updated only for confirmed + # removals or accepted dry-run previews. + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + + if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then + log_error "Invalid type filter: $type_filter, must be 'f' or 'd'" + return 1 + fi + if [[ ! "$age_days" =~ ^[0-9]+$ ]]; then + log_error "Invalid age: $age_days, must be a non-negative integer" + return 1 + fi + if [[ ! "$max_depth" =~ ^[1-5]$ ]]; then + log_error "Invalid max depth: $max_depth, must be between 1 and 5" + return 1 + fi + if [[ -n "$deadline_seconds" && ! "$deadline_seconds" =~ ^[0-9]+$ ]]; then + log_error "Invalid cleanup deadline: $deadline_seconds" + return 1 + fi + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + debug_log "Skipping sudo find/delete in test mode: $base_dir" + return 0 + fi + + # An already-expired overall budget authorizes no privileged probe at all. + # Re-clamp every later probe because any preceding command can consume the + # final second of the caller's section budget. + _mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds" > /dev/null || return $? + + # Keep the entire sudo-probing body independent of the caller's errexit + # state. macOS 14's /bin/bash build fires the caller's errexit when a + # sudo shell-function mock returns nonzero inside an if condition (the + # same bash 3.2.57 on macOS 15+ does not), so the guard must sit above + # the first sudo probe, not just around the scan/batch loop. The + # predicates below intentionally return 1 for ordinary "not protected / + # not whitelisted / no oplog" cases; callers still get explicit nonzero + # returns from the validation gates, restored to their errexit state. + local restore_errexit=0 + case $- in + *e*) + restore_errexit=1 + set +e + ;; + esac - # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2> /dev/null; then + # Confirm noninteractive authorization before interpreting a failed path + # predicate as "not present". Otherwise an expired credential looks like an + # empty successful scan and callers can print a false cleanup result. + local sudo_rc=0 + _mole_bounded_sudo_until "$deadline_seconds" "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n true < /dev/null > /dev/null 2>&1 || sudo_rc=$? + if [[ $sudo_rc -ne 0 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + [[ $sudo_rc -ge 128 ]] && return "$sudo_rc" + [[ $sudo_rc -eq 124 ]] && return 124 + return "$MOLE_ERR_AUTH_FAILED" + fi + + # Validate base directory (use sudo for permission-restricted dirs). + local base_rc=0 + _mole_bounded_sudo_until "$deadline_seconds" "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n test -d "$base_dir" < /dev/null 2> /dev/null || base_rc=$? + if [[ $base_rc -ne 0 ]]; then + if [[ $base_rc -ge 128 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + return "$base_rc" + fi + if [[ $base_rc -eq 124 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + return 124 + fi + # `sudo test` uses status 1 both for a false predicate and for some + # authorization failures. Recheck credentials before calling this a + # missing directory; a credential may expire after the initial probe. + local base_auth_rc=0 + _mole_bounded_sudo_until "$deadline_seconds" "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n true < /dev/null > /dev/null 2>&1 || base_auth_rc=$? + if [[ $base_auth_rc -ne 0 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + [[ $base_auth_rc -ge 128 ]] && return "$base_auth_rc" + [[ $base_auth_rc -eq 124 ]] && return 124 + return "$MOLE_ERR_AUTH_FAILED" + fi debug_log "Directory does not exist, skipping: $base_dir" + [[ $restore_errexit -eq 1 ]] && set -e return 0 fi - if sudo test -L "$base_dir" 2> /dev/null; then + local link_rc=0 + _mole_bounded_sudo_until "$deadline_seconds" "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n test -L "$base_dir" < /dev/null 2> /dev/null || link_rc=$? + if [[ $link_rc -eq 0 ]]; then log_error "Refusing to search symlinked directory: $base_dir" + [[ $restore_errexit -eq 1 ]] && set -e return 1 fi - - # Validate type filter - if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter, must be 'f' or 'd'" - return 1 + if [[ $link_rc -eq 124 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + return 124 + fi + if [[ $link_rc -ge 128 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + return "$link_rc" + fi + if [[ $link_rc -eq 1 ]]; then + local link_auth_rc=0 + _mole_bounded_sudo_until "$deadline_seconds" "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + -n true < /dev/null > /dev/null 2>&1 || link_auth_rc=$? + if [[ $link_auth_rc -ne 0 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + [[ $link_auth_rc -ge 128 ]] && return "$link_auth_rc" + [[ $link_auth_rc -eq 124 ]] && return 124 + return "$MOLE_ERR_AUTH_FAILED" + fi + fi + if [[ $link_rc -ne 1 ]]; then + [[ $restore_errexit -eq 1 ]] && set -e + return "$link_rc" fi debug_log "Finding, sudo, in $base_dir: $pattern, age: ${age_days}d, type: $type_filter" - local find_args=("-maxdepth" "5") - # Skip -name if pattern is "*" (matches everything anyway, but adds overhead) - if [[ "$pattern" != "*" ]]; then - find_args+=("-name" "$pattern") + local find_args=("-maxdepth" "$max_depth") + local match_all_names=false + local name_pattern="" + for name_pattern in "${name_patterns[@]}"; do + if [[ "$name_pattern" == "*" ]]; then + match_all_names=true + break + fi + done + if [[ "$match_all_names" != "true" ]]; then + if [[ ${#name_patterns[@]} -eq 1 ]]; then + find_args+=("-name" "${name_patterns[0]}") + else + find_args+=("(") + local pattern_index=0 + for ((pattern_index = 0; pattern_index < ${#name_patterns[@]}; pattern_index++)); do + [[ $pattern_index -gt 0 ]] && find_args+=("-o") + find_args+=("-name" "${name_patterns[$pattern_index]}") + done + find_args+=(")") + fi fi find_args+=("-type" "$type_filter") if [[ "$age_days" -gt 0 ]]; then find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path + # Materialize the completed scan before deleting anything. Process + # substitution cannot expose the producer's exit status, so it previously + # turned timeout 124 into an empty/partial successful scan and could delete + # the partial prefix. A failed scan now authorizes no deletion. + local scan_file="" + if ! scan_file=$(create_temp_file 2> /dev/null); then + [[ $restore_errexit -eq 1 ]] && set -e + return 1 + fi + local scan_rc=0 + local scan_timeout="" + scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds") || scan_rc=$? + if [[ $scan_rc -eq 0 ]]; then + _mole_materialize_bounded_sudo_find "$scan_file" "$scan_timeout" \ + "$base_dir" "${find_args[@]}" -print0 || scan_rc=$? + fi + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + debug_log "Privileged cleanup scan failed for $base_dir (status $scan_rc)" + [[ $restore_errexit -eq 1 ]] && set -e + return "$scan_rc" + fi + + # Iterate results to respect both system protection and user whitelist. + # See safe_find_delete for rationale (#757). + # + # Regular files are removed in one privileged xargs batch instead of one + # safe_sudo_remove per match: the single-file path costs three sudo forks + # per file (test -e, du, rm), which turns a sweep over a stale .logarchive + # bundle (1000+ tracev3 files) into minutes. Every path still passes the + # same per-file gates before entering the batch; only the rm is batched. + # Directories and dry-run keep the single-file path so rm -rf handling + # and preview output stay unchanged. + local -a batch_files=() + local -a batch_identities=() + local max_batch_items="" + max_batch_items=$(_mole_privileged_batch_max_items) + [[ "$max_batch_items" =~ ^[1-9][0-9]*$ ]] || max_batch_items=4096 + local removed_count=0 + local delete_rc=0 + local deadline_reached=false + local batch_aborted=false while IFS= read -r -d '' match; do + if [[ -n "$deadline_seconds" && $SECONDS -ge $deadline_seconds ]]; then + deadline_reached=true + delete_rc=124 + break + fi if should_protect_path "$match"; then continue fi - safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + # Fast-path the active family before the general validator below. The + # validator remains authoritative and also normalizes dot aliases so + # preview and real cleanup cannot diverge. + if _mole_is_active_powerlog_database_path "$match"; then + continue + fi + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$match"; then + continue + fi + # Run the same final path policy before preview accounting and real + # removal. This keeps aliases such as /./ and case variants out of + # both surfaces instead of relying on a raw-string precheck. + if ! validate_path_for_deletion "$match"; then + continue + fi + if _mole_privileged_path_has_mutable_ancestor "$match"; then + # A privileged path-based delete cannot safely cross a directory + # the invoking user can rename or replace. Delete only with the + # caller's own permissions; root invocations skip the path because + # they have no unprivileged identity to fall back to. + if [[ ${EUID:-0} -ne 0 ]]; then + local mutable_rc=0 + safe_remove "$match" true "" "$deadline_seconds" || mutable_rc=$? + if [[ $mutable_rc -eq 0 ]]; then + removed_count=$((removed_count + 1)) + elif [[ $mutable_rc -eq 124 || $mutable_rc -ge 128 ]]; then + delete_rc=$mutable_rc + break + elif [[ $delete_rc -eq 0 ]]; then + delete_rc=$mutable_rc + fi + else + debug_log "Skipping sudo delete below mutable parent: $match" + [[ $delete_rc -eq 0 ]] && delete_rc=1 + fi + continue + fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + local match_size_kb=0 + local match_size_known=false + local raw_match_size="" + local size_timeout="" + local dry_size_rc=0 + size_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds") || dry_size_rc=$? + if [[ $dry_size_rc -eq 0 ]]; then + raw_match_size=$(_mole_bounded_sudo "$size_timeout" \ + -n du -skP "$match" < /dev/null 2> /dev/null | awk '{print $1; exit}') || dry_size_rc=$? + fi + if [[ $dry_size_rc -eq 124 || $dry_size_rc -ge 128 ]]; then + delete_rc=$dry_size_rc + break + elif [[ $dry_size_rc -eq 0 ]]; then + if [[ "$raw_match_size" =~ ^[0-9]+$ ]]; then + match_size_kb="$raw_match_size" + match_size_known=true + fi + fi + record_dry_run_cleanup_target "$match" "$match_size_kb" 1 "$match_size_known" || continue + fi + # -type f never emits symlinks; a path that is one now was swapped + # after find saw it, and the single-file path refuses those. + if [[ "$type_filter" == "f" && "${MOLE_DRY_RUN:-0}" != "1" && ! -L "$match" ]]; then + local identity_timeout="" + local match_identity="" + local identity_rc=0 + identity_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$deadline_seconds") || identity_rc=$? + if [[ $identity_rc -eq 0 ]]; then + match_identity=$(_mole_bounded_sudo "$identity_timeout" \ + -n "$STAT_BSD" -f%d:%i:%m "$match" < /dev/null 2> /dev/null) || identity_rc=$? + fi + if [[ $identity_rc -eq 124 || $identity_rc -ge 128 ]]; then + delete_rc=$identity_rc + break + fi + if [[ $identity_rc -eq 0 && "$match_identity" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]]; then + if [[ ${#batch_files[@]} -ge $max_batch_items ]]; then + debug_log "Privileged cleanup candidate limit reached for $base_dir" + delete_rc=1 + batch_aborted=true + break + fi + batch_files+=("$match") + batch_identities+=("$match_identity") + elif [[ $delete_rc -eq 0 ]]; then + delete_rc=${identity_rc:-1} + [[ $delete_rc -ne 0 ]] || delete_rc=1 + fi + continue + fi + local single_rc=0 + safe_sudo_remove "$match" "" "$deadline_seconds" || single_rc=$? + if [[ $single_rc -eq 0 ]]; then + removed_count=$((removed_count + 1)) + elif [[ $single_rc -eq 124 || $single_rc -ge 128 ]]; then + delete_rc=$single_rc + break + elif [[ $delete_rc -eq 0 ]]; then + delete_rc=$single_rc + fi + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above - return 0 + if [[ -n "$deadline_seconds" && $SECONDS -ge $deadline_seconds ]]; then + deadline_reached=true + if [[ $delete_rc -lt 128 ]]; then + delete_rc=124 + fi + fi + + if [[ ${#batch_files[@]} -gt 0 && "$deadline_reached" != "true" && "$batch_aborted" != "true" && $delete_rc -ne 124 && $delete_rc -lt 128 ]]; then + local batch_rc=0 + local batch_result_file="" + local batch_timeout="" + if ! batch_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + "$deadline_seconds"); then + batch_rc=124 + elif ! batch_result_file=$(create_temp_file 2> /dev/null); then + batch_rc=1 + else + # The privileged worker acknowledges each completed unlink with a + # NUL-delimited path. A later failure or timeout therefore cannot + # erase the forensic record for an already completed prefix. + local -a batch_worker_records=() + local batch_index=0 + for ((batch_index = 0; batch_index < ${#batch_files[@]}; batch_index++)); do + batch_worker_records+=("${batch_identities[$batch_index]}:${batch_files[$batch_index]}") + done + # shellcheck disable=SC2016 # $path expands inside privileged /bin/sh. + printf '%s\0' "${batch_worker_records[@]}" | + _mole_bounded_sudo "$batch_timeout" \ + -n xargs -0 /bin/sh -c ' + stat_tool=$1 + age_days=$2 + shift 2 + for record do + dev=${record%%:*} + rest=${record#*:} + ino=${rest%%:*} + rest=${rest#*:} + mtime=${rest%%:*} + path=${rest#*:} + expected=$dev:$ino:$mtime + if [ "$age_days" -gt 0 ]; then + actual=$(/usr/bin/find "$path" -maxdepth 0 -type f -mtime "+$age_days" \ + -exec "$stat_tool" -f%d:%i:%m {} \; 2>/dev/null) || continue + else + actual=$($stat_tool -f%d:%i:%m "$path" 2>/dev/null) || continue + fi + [ "$actual" = "$expected" ] || continue + /bin/rm -f -- "$path" || exit 1 + printf "%s\0" "$path" + done + ' sh "$STAT_BSD" "$age_days" > "$batch_result_file" 2> /dev/null || batch_rc=$? + + local batch_ts="" + if oplog_enabled; then + batch_ts=$(get_timestamp) + fi + local batch_ack_count=0 + local -a removed_lines=() + local batch_file="" + while IFS= read -r -d '' batch_file; do + batch_ack_count=$((batch_ack_count + 1)) + if [[ -n "$batch_ts" ]]; then + removed_lines+=("[$batch_ts] [${MOLE_CURRENT_COMMAND:-clean}] REMOVED $batch_file (batch)") + fi + done < "$batch_result_file" + rm -f -- "$batch_result_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + + removed_count=$((removed_count + batch_ack_count)) + if [[ ${#removed_lines[@]} -gt 0 ]]; then + append_log_lines "$OPERATIONS_LOG_FILE" "${removed_lines[@]}" + fi + if [[ $batch_rc -eq 0 && $batch_ack_count -ne ${#batch_files[@]} ]]; then + batch_rc=1 + fi + fi + + if [[ $batch_rc -ne 0 && $delete_rc -eq 0 ]]; then + delete_rc=$batch_rc + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$base_dir" \ + "batch removal incomplete (status $batch_rc)" + fi + fi + + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=$removed_count + + if [[ $restore_errexit -eq 1 ]]; then + set -e + fi + + return "$delete_rc" } # ============================================================================ @@ -497,18 +2916,48 @@ safe_sudo_find_delete() { # ============================================================================ # Get path size in KB (returns 0 if not found) +# +# For regular files and symlinks, prefer 'stat' over 'du': it avoids the +# fork+pipe cost of 'du | awk' on every call, which adds up in tight loops +# (e.g. external-volume ._* sweeps, Application Support log scans). 'du -skP' +# and 'stat -f%z' both report logical size without following symlinks on +# macOS, and the 1KB-rounded outputs match for the file types we encounter +# (logs, caches, leftovers). Directories still go through 'du' because 'stat' +# only reports a single directory entry, not recursive content size. .app +# bundles continue to go through mdls because APFS clones make 'du' +# under-report large bundles like Xcode. get_path_size_kb() { local path="$1" + local size_timeout="${2:-$MOLE_TIMEOUT_DISK_VERIFY_SEC}" [[ -z "$path" || ! -e "$path" ]] && { echo "0" return } + if [[ ! "$size_timeout" =~ ^[0-9]+(\.[0-9]+)?$ || "$size_timeout" =~ ^0+(\.0+)?$ ]]; then + size_timeout="$MOLE_TIMEOUT_DISK_VERIFY_SEC" + fi + local timeout_whole="${size_timeout%%.*}" + local timeout_budget=$((10#$timeout_whole)) + if [[ "$size_timeout" == *.* && "${size_timeout#*.}" =~ [1-9] ]]; then + timeout_budget=$((timeout_budget + 1)) + fi + [[ $timeout_budget -gt 0 ]] || timeout_budget=1 + local size_deadline=$((SECONDS + timeout_budget)) + # For .app bundles, prefer mdls logical size as it matches Finder # (APFS clone/sparse files make 'du' severely underreport apps like Xcode) if [[ "$path" == *.app || "$path" == *.app/ ]]; then local mdls_size - mdls_size=$(mdls -name kMDItemLogicalSize -raw "$path" 2> /dev/null || true) + local mdls_timeout="" + local mdls_deadline_rc=0 + mdls_timeout=$(_mole_timeout_with_deadline \ + "$size_timeout" "$size_deadline") || mdls_deadline_rc=$? + [[ $mdls_deadline_rc -eq 0 ]] || return "$mdls_deadline_rc" + local mdls_rc=0 + mdls_size=$(run_with_timeout "$mdls_timeout" mdls \ + -name kMDItemLogicalSize -raw "$path" < /dev/null 2> /dev/null) || mdls_rc=$? + [[ $mdls_rc -eq 124 || $mdls_rc -ge 128 ]] && return "$mdls_rc" if [[ "$mdls_size" =~ ^[0-9]+$ && "$mdls_size" -gt 0 ]]; then # Return in KB echo "$((mdls_size / 1024))" @@ -516,8 +2965,39 @@ get_path_size_kb() { fi fi + # Fast path for regular files and symlinks: avoid forking 'du'. + if [[ -f "$path" || -L "$path" ]]; then + local bytes + local stat_timeout="" + local stat_deadline_rc=0 + stat_timeout=$(_mole_timeout_with_deadline \ + "$size_timeout" "$size_deadline") || stat_deadline_rc=$? + [[ $stat_deadline_rc -eq 0 ]] || return "$stat_deadline_rc" + local stat_rc=0 + bytes=$(run_with_timeout "$stat_timeout" stat \ + -f%z "$path" < /dev/null 2> /dev/null) || stat_rc=$? + [[ $stat_rc -eq 124 || $stat_rc -ge 128 ]] && return "$stat_rc" + if [[ "$bytes" =~ ^[0-9]+$ ]]; then + # Round up to whole KB to match 'du -skP' semantics. + echo $(((bytes + 1023) / 1024)) + return + fi + fi + + # Bounded like every other du call site (hints/project/caches): an + # unbounded walk here wedges one parallel sizing worker forever on a + # stalled SMB/FUSE mount. A timeout is cancellation, not a zero-byte + # measurement, because callers may feed the result into a deletion plan. local size - size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) + local du_timeout="" + local du_deadline_rc=0 + du_timeout=$(_mole_timeout_with_deadline \ + "$size_timeout" "$size_deadline") || du_deadline_rc=$? + [[ $du_deadline_rc -eq 0 ]] || return "$du_deadline_rc" + local du_rc=0 + size=$(run_with_timeout "$du_timeout" du -skP "$path" 2> /dev/null | + awk 'NR==1 {print $1; exit}') || du_rc=$? + [[ $du_rc -eq 124 || $du_rc -ge 128 ]] && return "$du_rc" if [[ "$size" =~ ^[0-9]+$ ]]; then echo "$size" @@ -531,15 +3011,43 @@ get_path_size_kb() { calculate_total_size() { local files="$1" local total_kb=0 + local -a unique_paths=() while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$file") - total_kb=$((total_kb + size_kb)) + local normalized_file="${file%/}" + [[ -n "$normalized_file" ]] || normalized_file="$file" + + local skip_file=false + local -a filtered_paths=() + local existing_file + for existing_file in "${unique_paths[@]+"${unique_paths[@]}"}"; do + if [[ "$normalized_file" == "$existing_file" || "$normalized_file" == "$existing_file"/* ]]; then + skip_file=true + break + fi + if [[ "$existing_file" == "$normalized_file"/* ]]; then + continue + fi + filtered_paths+=("$existing_file") + done + + if [[ "$skip_file" == "false" ]]; then + unique_paths=("${filtered_paths[@]+"${filtered_paths[@]}"}") + unique_paths+=("$normalized_file") + fi fi done <<< "$files" + for file in "${unique_paths[@]+"${unique_paths[@]}"}"; do + local size_kb=0 + local size_rc=0 + size_kb=$(get_path_size_kb "$file") || size_rc=$? + [[ $size_rc -eq 124 || $size_rc -ge 128 ]] && return "$size_rc" + [[ $size_rc -eq 0 && "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + total_kb=$((total_kb + size_kb)) + done + echo "$total_kb" } @@ -549,7 +3057,6 @@ diagnose_removal_failure() { local reason="" local suggestion="" - local touchid_file="/etc/pam.d/sudo" case "$exit_code" in "$MOLE_ERR_SIP_PROTECTED") @@ -557,7 +3064,7 @@ diagnose_removal_failure() { ;; "$MOLE_ERR_AUTH_FAILED") reason="authentication failed" - if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then + if declare -F check_touchid_support > /dev/null 2>&1 && check_touchid_support > /dev/null 2>&1; then suggestion="Check your credentials or restart Terminal" else suggestion="Try 'mole touchid' to enable fingerprint auth" @@ -567,9 +3074,20 @@ diagnose_removal_failure() { reason="filesystem is read-only" suggestion="Check if disk needs repair" ;; + "$MOLE_ERR_PROTECTED_PATH") + reason="protected by Mole safety rules" + ;; + "$MOLE_ERR_PRIVACY_DENIED") + reason="macOS could not authorize Trash access" + suggestion="Review App Management, App Data, or Full Disk Access for your terminal in System Settings" + ;; + "$MOLE_ERR_MUTABLE_PARENT") + reason="Mole cannot safely use elevated deletion below a user-writable parent" + suggestion="Move the app to Trash in Finder; Mole will leave protected containers and app data untouched" + ;; *) reason="permission denied" - if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then + if declare -F check_touchid_support > /dev/null 2>&1 && check_touchid_support > /dev/null 2>&1; then suggestion="Try running again or check file ownership" else suggestion="Try 'mole touchid' or check with 'ls -l'" diff --git a/Resources/mole/lib/core/help.sh b/Resources/mole/lib/core/help.sh index 6deb945..0c045d5 100644 --- a/Resources/mole/lib/core/help.sh +++ b/Resources/mole/lib/core/help.sh @@ -3,10 +3,11 @@ show_clean_help() { echo "Usage: mo clean [OPTIONS]" echo "" - echo "Clean up disk space by removing caches, logs, and temporary files." + echo "Clean up disk space by removing caches, logs, temporary files, and app leftovers from already-uninstalled apps." echo "" echo "Options:" echo " --dry-run, -n Preview cleanup without making changes" + echo " --external PATH Clean OS metadata from a mounted external volume" echo " --whitelist Manage protected paths" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" @@ -26,7 +27,7 @@ show_installer_help() { show_optimize_help() { echo "Usage: mo optimize [OPTIONS]" echo "" - echo "Check and maintain system health, apply optimizations." + echo "Refresh system caches and services, repair safe maintenance issues." echo "" echo "Options:" echo " --dry-run Preview optimization without making changes" @@ -53,13 +54,27 @@ show_touchid_help() { } show_uninstall_help() { - echo "Usage: mo uninstall [OPTIONS]" + echo "Usage: mo uninstall [OPTIONS] [APP_NAME ...]" echo "" echo "Interactively remove applications and their leftover files." + echo "Optionally specify one or more app names to uninstall directly." + echo "For leftovers from apps that are already gone, use mo clean." + echo "" + echo "Examples:" + echo " mo uninstall Open interactive app selector" + echo " mo uninstall slack Uninstall Slack" + echo " mo uninstall slack zoom Uninstall Slack and Zoom" + echo " mo uninstall --dry-run slack Preview Slack uninstallation" + echo " mo uninstall --list Show installed apps and the names mo uninstall accepts" echo "" echo "Options:" + echo " --list List installed apps with the exact name mo uninstall accepts" echo " --dry-run Preview app uninstallation without making changes" + echo " --permanent Bypass macOS Trash and rm -rf immediately" echo " --whitelist Not supported for uninstall (use clean/optimize)" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" + echo "" + echo "By default, uninstalled files go to the macOS Trash so they can be" + echo "recovered. Use --permanent to skip the Trash step." } diff --git a/Resources/mole/lib/core/history.sh b/Resources/mole/lib/core/history.sh new file mode 100644 index 0000000..d84ce3e --- /dev/null +++ b/Resources/mole/lib/core/history.sh @@ -0,0 +1,564 @@ +#!/bin/bash +# Mole - History parsing and rendering. + +set -euo pipefail + +if [[ -n "${MOLE_HISTORY_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_HISTORY_LOADED=1 + +if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then + _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck source=lib/core/base.sh + source "$_MOLE_CORE_DIR/base.sh" +fi + +readonly MOLE_HISTORY_DEFAULT_LIMIT=20 +readonly MOLE_HISTORY_MAX_LIMIT=200 + +declare -a HISTORY_SESSION_COMMANDS=() +declare -a HISTORY_SESSION_STARTED_AT=() +declare -a HISTORY_SESSION_ENDED_AT=() +declare -a HISTORY_SESSION_ITEMS=() +declare -a HISTORY_SESSION_SIZE=() +declare -a HISTORY_SESSION_REMOVED=() +declare -a HISTORY_SESSION_TRASHED=() +declare -a HISTORY_SESSION_SKIPPED=() +declare -a HISTORY_SESSION_FAILED=() +declare -a HISTORY_SESSION_REBUILT=() +declare -a HISTORY_SESSION_OTHER=() +declare -a HISTORY_SESSION_OPERATIONS=() +declare -a HISTORY_SESSION_FAILED_TASKS=() + +declare -a HISTORY_DELETE_TIMESTAMPS=() +declare -a HISTORY_DELETE_MODES=() +declare -a HISTORY_DELETE_SIZE_KB=() +declare -a HISTORY_DELETE_STATUSES=() +declare -a HISTORY_DELETE_PATHS=() + +HISTORY_ACTIVE_COMMAND="" +HISTORY_ACTIVE_STARTED_AT="" +HISTORY_ACTIVE_ENDED_AT="" +HISTORY_ACTIVE_ITEMS=0 +HISTORY_ACTIVE_SIZE="0B" +HISTORY_ACTIVE_REMOVED=0 +HISTORY_ACTIVE_TRASHED=0 +HISTORY_ACTIVE_SKIPPED=0 +HISTORY_ACTIVE_FAILED=0 +HISTORY_ACTIVE_REBUILT=0 +HISTORY_ACTIVE_OTHER=0 +HISTORY_ACTIVE_OPERATIONS=0 +HISTORY_ACTIVE_FAILED_TASKS=0 + +history_operations_log_file() { + printf '%s\n' "${MOLE_OPERATIONS_LOG:-${OPERATIONS_LOG_FILE:-$HOME/Library/Logs/mole/operations.log}}" +} + +history_deletions_log_file() { + printf '%s\n' "${MOLE_DELETE_LOG:-$HOME/Library/Logs/mole/deletions.log}" +} + +history_normalize_limit() { + local value="${1:-$MOLE_HISTORY_DEFAULT_LIMIT}" + local normalized max_digits + + if ! normalized=$(history_normalize_decimal "$value"); then + printf '%s\n' "$MOLE_HISTORY_DEFAULT_LIMIT" + return 0 + fi + if [[ "$normalized" == "0" ]]; then + printf '%s\n' "$MOLE_HISTORY_DEFAULT_LIMIT" + return 0 + fi + max_digits=${#MOLE_HISTORY_MAX_LIMIT} + if [[ "${#normalized}" -gt "$max_digits" ]]; then + printf '%s\n' "$MOLE_HISTORY_MAX_LIMIT" + return 0 + fi + if [[ "$normalized" -gt "$MOLE_HISTORY_MAX_LIMIT" ]]; then + printf '%s\n' "$MOLE_HISTORY_MAX_LIMIT" + return 0 + fi + printf '%s\n' "$normalized" +} + +history_normalize_decimal() { + local value="${1:-}" + + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + while [[ "$value" != "0" && "${value#0}" != "$value" ]]; do + value="${value#0}" + done + printf '%s\n' "$value" +} + +history_parse_limit() { + local value="$1" + local normalized max_digits + + normalized=$(history_normalize_decimal "$value") || return 1 + [[ "$normalized" != "0" ]] || return 1 + max_digits=${#MOLE_HISTORY_MAX_LIMIT} + [[ "${#normalized}" -le "$max_digits" ]] || return 1 + [[ "$normalized" -le "$MOLE_HISTORY_MAX_LIMIT" ]] || return 1 + printf '%s\n' "$normalized" +} + +history_reset_active_session() { + HISTORY_ACTIVE_COMMAND="" + HISTORY_ACTIVE_STARTED_AT="" + HISTORY_ACTIVE_ENDED_AT="" + HISTORY_ACTIVE_ITEMS=0 + HISTORY_ACTIVE_SIZE="0B" + HISTORY_ACTIVE_REMOVED=0 + HISTORY_ACTIVE_TRASHED=0 + HISTORY_ACTIVE_SKIPPED=0 + HISTORY_ACTIVE_FAILED=0 + HISTORY_ACTIVE_REBUILT=0 + HISTORY_ACTIVE_OTHER=0 + HISTORY_ACTIVE_OPERATIONS=0 + HISTORY_ACTIVE_FAILED_TASKS=0 +} + +history_start_session() { + local command="$1" + local started_at="$2" + + if [[ -n "$HISTORY_ACTIVE_COMMAND" ]]; then + history_finish_session + fi + + history_reset_active_session + HISTORY_ACTIVE_COMMAND="$command" + HISTORY_ACTIVE_STARTED_AT="$started_at" +} + +history_finish_session() { + [[ -z "$HISTORY_ACTIVE_COMMAND" ]] && return 0 + + HISTORY_SESSION_COMMANDS+=("$HISTORY_ACTIVE_COMMAND") + HISTORY_SESSION_STARTED_AT+=("$HISTORY_ACTIVE_STARTED_AT") + HISTORY_SESSION_ENDED_AT+=("$HISTORY_ACTIVE_ENDED_AT") + HISTORY_SESSION_ITEMS+=("$HISTORY_ACTIVE_ITEMS") + HISTORY_SESSION_SIZE+=("$HISTORY_ACTIVE_SIZE") + HISTORY_SESSION_REMOVED+=("$HISTORY_ACTIVE_REMOVED") + HISTORY_SESSION_TRASHED+=("$HISTORY_ACTIVE_TRASHED") + HISTORY_SESSION_SKIPPED+=("$HISTORY_ACTIVE_SKIPPED") + HISTORY_SESSION_FAILED+=("$HISTORY_ACTIVE_FAILED") + HISTORY_SESSION_REBUILT+=("$HISTORY_ACTIVE_REBUILT") + HISTORY_SESSION_OTHER+=("$HISTORY_ACTIVE_OTHER") + HISTORY_SESSION_OPERATIONS+=("$HISTORY_ACTIVE_OPERATIONS") + HISTORY_SESSION_FAILED_TASKS+=("$HISTORY_ACTIVE_FAILED_TASKS") + + history_reset_active_session +} + +history_record_operation() { + local command="$1" + local action="$2" + local timestamp="$3" + + if [[ -z "$HISTORY_ACTIVE_COMMAND" ]]; then + history_start_session "$command" "$timestamp" + fi + + HISTORY_ACTIVE_OPERATIONS=$((HISTORY_ACTIVE_OPERATIONS + 1)) + case "$action" in + REMOVED) HISTORY_ACTIVE_REMOVED=$((HISTORY_ACTIVE_REMOVED + 1)) ;; + TRASHED) HISTORY_ACTIVE_TRASHED=$((HISTORY_ACTIVE_TRASHED + 1)) ;; + SKIPPED) HISTORY_ACTIVE_SKIPPED=$((HISTORY_ACTIVE_SKIPPED + 1)) ;; + FAILED) HISTORY_ACTIVE_FAILED=$((HISTORY_ACTIVE_FAILED + 1)) ;; + TASK_FAILED) HISTORY_ACTIVE_FAILED_TASKS=$((HISTORY_ACTIVE_FAILED_TASKS + 1)) ;; + REBUILT) HISTORY_ACTIVE_REBUILT=$((HISTORY_ACTIVE_REBUILT + 1)) ;; + *) HISTORY_ACTIVE_OTHER=$((HISTORY_ACTIVE_OTHER + 1)) ;; + esac +} + +history_parse_session_start() { + local line="$1" + local inner command started_at + + case "$line" in + "# ========== "*" session started at "*" ==========") ;; + *) return 1 ;; + esac + inner="${line#"# ========== "}" + command="${inner%% session started at *}" + started_at="${inner#* session started at }" + started_at="${started_at%" =========="}" + history_start_session "$command" "$started_at" + return 0 +} + +history_parse_session_end() { + local line="$1" + local inner command rest ended_at tail items size + + case "$line" in + "# ========== "*" session ended at "*" ==========") ;; + *) return 1 ;; + esac + inner="${line#"# ========== "}" + command="${inner%% session ended at *}" + rest="${inner#* session ended at }" + rest="${rest%" =========="}" + ended_at="$rest" + items="" + size="" + if [[ "$rest" == *", "* ]]; then + ended_at="${rest%%, *}" + tail="${rest#"$ended_at, "}" + if [[ "$tail" == *" items, "* ]]; then + items="${tail%% items,*}" + size="${tail#*, }" + fi + fi + + if [[ -z "$HISTORY_ACTIVE_COMMAND" ]]; then + history_start_session "$command" "$ended_at" + fi + + HISTORY_ACTIVE_ENDED_AT="$ended_at" + [[ "$items" =~ ^[0-9]+$ ]] && HISTORY_ACTIVE_ITEMS="$items" + [[ -n "$size" ]] && HISTORY_ACTIVE_SIZE="$size" + history_finish_session + return 0 +} + +history_parse_operation_line() { + local line="$1" + local timestamp rest command rest_after_command action + + [[ "$line" == "["*"] ["*"] "* ]] || return 1 + + timestamp="${line#\[}" + timestamp="${timestamp%%]*}" + rest="${line#*\] }" + command="${rest#\[}" + command="${command%%]*}" + rest_after_command="${rest#*\] }" + action="${rest_after_command%% *}" + + [[ -n "$timestamp" && -n "$command" && -n "$action" ]] || return 1 + history_record_operation "$command" "$action" "$timestamp" + return 0 +} + +history_reset_sessions() { + history_reset_active_session + HISTORY_SESSION_COMMANDS=() + HISTORY_SESSION_STARTED_AT=() + HISTORY_SESSION_ENDED_AT=() + HISTORY_SESSION_ITEMS=() + HISTORY_SESSION_SIZE=() + HISTORY_SESSION_REMOVED=() + HISTORY_SESSION_TRASHED=() + HISTORY_SESSION_SKIPPED=() + HISTORY_SESSION_FAILED=() + HISTORY_SESSION_REBUILT=() + HISTORY_SESSION_OTHER=() + HISTORY_SESSION_OPERATIONS=() + HISTORY_SESSION_FAILED_TASKS=() +} + +history_reset_deletions() { + HISTORY_DELETE_TIMESTAMPS=() + HISTORY_DELETE_MODES=() + HISTORY_DELETE_SIZE_KB=() + HISTORY_DELETE_STATUSES=() + HISTORY_DELETE_PATHS=() +} + +history_load_operations() { + local log_file="$1" + local line + + history_reset_sessions + + [[ -f "$log_file" ]] || return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + history_parse_session_start "$line" && continue + history_parse_session_end "$line" && continue + history_parse_operation_line "$line" && continue + done < "$log_file" + + if [[ -n "$HISTORY_ACTIVE_COMMAND" ]]; then + history_finish_session + fi +} + +history_load_deletions() { + local log_file="$1" + local line timestamp mode size_kb status path + + history_reset_deletions + + [[ -f "$log_file" ]] || return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" ]] && continue + IFS=$'\t' read -r timestamp mode size_kb status path <<< "$line" + [[ -n "${timestamp:-}" && -n "${mode:-}" && -n "${status:-}" ]] || continue + HISTORY_DELETE_TIMESTAMPS+=("$timestamp") + HISTORY_DELETE_MODES+=("$mode") + HISTORY_DELETE_SIZE_KB+=("${size_kb:-unknown}") + HISTORY_DELETE_STATUSES+=("$status") + HISTORY_DELETE_PATHS+=("${path:-}") + done < "$log_file" +} + +history_join_counts() { + local -a parts=() + local removed="$1" + local trashed="$2" + local skipped="$3" + local failed="$4" + local rebuilt="$5" + local other="$6" + + [[ "$removed" -gt 0 ]] && parts+=("removed $removed") + [[ "$trashed" -gt 0 ]] && parts+=("trashed $trashed") + [[ "$skipped" -gt 0 ]] && parts+=("skipped $skipped") + [[ "$failed" -gt 0 ]] && parts+=("failed $failed") + [[ "$rebuilt" -gt 0 ]] && parts+=("rebuilt $rebuilt") + [[ "$other" -gt 0 ]] && parts+=("other $other") + + if [[ ${#parts[@]} -eq 0 ]]; then + printf 'no file actions' + return 0 + fi + + local output="${parts[0]}" + local idx=1 + while [[ $idx -lt ${#parts[@]} ]]; do + output+=", ${parts[$idx]}" + idx=$((idx + 1)) + done + printf '%s' "$output" +} + +history_size_label() { + local size_kb="$1" + + if [[ "$size_kb" =~ ^[0-9]+$ ]]; then + bytes_to_human_kb "$size_kb" + else + printf 'unknown' + fi +} + +history_json_escape() { + local value="${1:-}" + local LC_ALL=C + local char code idx + + idx=0 + while [[ "$idx" -lt "${#value}" ]]; do + char="${value:$idx:1}" + case "$char" in + "\\") printf '%s' "\\\\" ;; + "\"") printf '%s' "\\\"" ;; + $'\b') printf '%s' "\\b" ;; + $'\f') printf '%s' "\\f" ;; + $'\n') printf '%s' "\\n" ;; + $'\r') printf '%s' "\\r" ;; + $'\t') printf '%s' "\\t" ;; + *) + printf -v code '%d' "'$char" + if [[ "$code" -lt 0 ]]; then + code=$((code + 256)) + fi + if [[ "$code" -lt 32 ]]; then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + ;; + esac + idx=$((idx + 1)) + done +} + +history_json_string() { + printf '"' + history_json_escape "${1:-}" + printf '"' +} + +history_json_string_field() { + local indent="$1" + local key="$2" + local value="${3:-}" + local suffix="${4-,}" + + printf '%s"%s": ' "$indent" "$key" + history_json_string "$value" + printf '%s\n' "$suffix" +} + +history_json_number_field() { + local indent="$1" + local key="$2" + local value="$3" + local suffix="${4-,}" + + printf '%s"%s": %s%s\n' "$indent" "$key" "$value" "$suffix" +} + +history_render_text() { + local limit + limit=$(history_normalize_limit "${1:-$MOLE_HISTORY_DEFAULT_LIMIT}") + + local operations_log deletions_log session_count deletion_count + operations_log=$(history_operations_log_file) + deletions_log=$(history_deletions_log_file) + session_count=${#HISTORY_SESSION_COMMANDS[@]} + deletion_count=${#HISTORY_DELETE_TIMESTAMPS[@]} + + printf '\n%sMole History%s\n\n' "$BLUE" "$NC" + + if [[ "$session_count" -eq 0 ]]; then + printf 'Recent sessions\n' + printf ' No operation history yet.\n' + else + printf 'Recent sessions\n' + local start=$((session_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + local idx=$((session_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + local command="${HISTORY_SESSION_COMMANDS[$idx]}" + local started="${HISTORY_SESSION_STARTED_AT[$idx]}" + local ended="${HISTORY_SESSION_ENDED_AT[$idx]}" + local items="${HISTORY_SESSION_ITEMS[$idx]}" + local size="${HISTORY_SESSION_SIZE[$idx]}" + local removed="${HISTORY_SESSION_REMOVED[$idx]}" + local trashed="${HISTORY_SESSION_TRASHED[$idx]}" + local skipped="${HISTORY_SESSION_SKIPPED[$idx]}" + local failed="${HISTORY_SESSION_FAILED[$idx]}" + local rebuilt="${HISTORY_SESSION_REBUILT[$idx]}" + local other="${HISTORY_SESSION_OTHER[$idx]}" + local failed_tasks="${HISTORY_SESSION_FAILED_TASKS[$idx]}" + local count_text + count_text=$(history_join_counts "$removed" "$trashed" "$skipped" "$failed" "$rebuilt" "$other") + if [[ "$failed_tasks" -gt 0 ]]; then + count_text+=", $failed_tasks optimize tasks failed" + fi + [[ -z "$ended" ]] && ended="not ended" + printf ' %-10s %s, %s items, %s\n' "$command" "$started" "$items" "$size" + printf ' %s, ended %s\n' "$count_text" "$ended" + idx=$((idx - 1)) + done + fi + + printf '\nDeletion audit\n' + if [[ "$deletion_count" -eq 0 ]]; then + printf ' No deletion audit entries yet.\n' + else + local start=$((deletion_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + local idx=$((deletion_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + local timestamp="${HISTORY_DELETE_TIMESTAMPS[$idx]}" + local mode="${HISTORY_DELETE_MODES[$idx]}" + local size_kb="${HISTORY_DELETE_SIZE_KB[$idx]}" + local status="${HISTORY_DELETE_STATUSES[$idx]}" + local path="${HISTORY_DELETE_PATHS[$idx]}" + local size_label + size_label=$(history_size_label "$size_kb") + printf ' %-24s %-9s %-16s %8s %s\n' "$timestamp" "$mode" "$status" "$size_label" "$path" + idx=$((idx - 1)) + done + fi + + printf '\nLogs\n' + printf ' operations: %s\n' "$operations_log" + printf ' deletions: %s\n\n' "$deletions_log" +} + +history_render_json_sessions() { + local limit="$1" + local session_count=${#HISTORY_SESSION_COMMANDS[@]} + local start=$((session_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + + printf ' "sessions": [\n' + local emitted=0 + if [[ "$session_count" -gt 0 ]]; then + local idx=$((session_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + [[ "$emitted" -gt 0 ]] && printf ',\n' + printf ' {\n' + history_json_string_field " " "command" "${HISTORY_SESSION_COMMANDS[$idx]}" + history_json_string_field " " "started_at" "${HISTORY_SESSION_STARTED_AT[$idx]}" + history_json_string_field " " "ended_at" "${HISTORY_SESSION_ENDED_AT[$idx]}" + history_json_number_field " " "items" "${HISTORY_SESSION_ITEMS[$idx]}" + history_json_string_field " " "size" "${HISTORY_SESSION_SIZE[$idx]}" + history_json_number_field " " "operation_count" "${HISTORY_SESSION_OPERATIONS[$idx]}" + history_json_number_field " " "failed_tasks" "${HISTORY_SESSION_FAILED_TASKS[$idx]}" + printf ' "actions": {"removed": %s, "trashed": %s, "skipped": %s, "failed": %s, "rebuilt": %s, "other": %s}\n' \ + "${HISTORY_SESSION_REMOVED[$idx]}" \ + "${HISTORY_SESSION_TRASHED[$idx]}" \ + "${HISTORY_SESSION_SKIPPED[$idx]}" \ + "${HISTORY_SESSION_FAILED[$idx]}" \ + "${HISTORY_SESSION_REBUILT[$idx]}" \ + "${HISTORY_SESSION_OTHER[$idx]}" + printf ' }' + emitted=$((emitted + 1)) + idx=$((idx - 1)) + done + fi + printf '\n ]' +} + +history_render_json_deletions() { + local limit="$1" + local deletion_count=${#HISTORY_DELETE_TIMESTAMPS[@]} + local start=$((deletion_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + + printf ' "deletions": [\n' + local emitted=0 + if [[ "$deletion_count" -gt 0 ]]; then + local idx=$((deletion_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + [[ "$emitted" -gt 0 ]] && printf ',\n' + printf ' {\n' + history_json_string_field " " "timestamp" "${HISTORY_DELETE_TIMESTAMPS[$idx]}" + history_json_string_field " " "mode" "${HISTORY_DELETE_MODES[$idx]}" + history_json_string_field " " "status" "${HISTORY_DELETE_STATUSES[$idx]}" + if [[ "${HISTORY_DELETE_SIZE_KB[$idx]}" =~ ^[0-9]+$ ]]; then + history_json_number_field " " "size_kb" "${HISTORY_DELETE_SIZE_KB[$idx]}" + else + printf ' "size_kb": null,\n' + fi + history_json_string_field " " "path" "${HISTORY_DELETE_PATHS[$idx]}" "" + printf ' }' + emitted=$((emitted + 1)) + idx=$((idx - 1)) + done + fi + printf '\n ]' +} + +history_render_json() { + local limit + limit=$(history_normalize_limit "${1:-$MOLE_HISTORY_DEFAULT_LIMIT}") + + local operations_log deletions_log + operations_log=$(history_operations_log_file) + deletions_log=$(history_deletions_log_file) + + printf '{\n' + printf ' "logs": {"operations": ' + history_json_string "$operations_log" + printf ', "deletions": ' + history_json_string "$deletions_log" + printf '},\n' + printf ' "limit": %s,\n' "$limit" + history_render_json_sessions "$limit" + printf ',\n' + history_render_json_deletions "$limit" + printf '\n}\n' +} diff --git a/Resources/mole/lib/core/log.sh b/Resources/mole/lib/core/log.sh index 95d92ce..b7a2348 100644 --- a/Resources/mole/lib/core/log.sh +++ b/Resources/mole/lib/core/log.sh @@ -21,9 +21,9 @@ fi # Logging Configuration # ============================================================================ -readonly LOG_FILE="${HOME}/.config/mole/mole.log" -readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log" -readonly OPERATIONS_LOG_FILE="${HOME}/.config/mole/operations.log" +readonly LOG_FILE="${HOME}/Library/Logs/mole/mole.log" +readonly DEBUG_LOG_FILE="${HOME}/Library/Logs/mole/mole_debug_session.log" +readonly OPERATIONS_LOG_FILE="${HOME}/Library/Logs/mole/operations.log" readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB readonly OPLOG_MAX_SIZE_DEFAULT=5242880 # 5MB @@ -37,6 +37,22 @@ fi # Log Rotation # ============================================================================ +append_log_line() { + local file_path="$1" + local line="${2:-}" + + ensure_user_file "$file_path" + printf '%s\n' "$line" >> "$file_path" 2> /dev/null || true +} + +append_log_lines() { + local file_path="$1" + shift + + ensure_user_file "$file_path" + printf '%s\n' "$@" >> "$file_path" 2> /dev/null || true +} + # Rotate log file if it exceeds maximum size rotate_log_once() { # Skip if already checked this session @@ -76,60 +92,110 @@ get_timestamp() { date '+%Y-%m-%d %H:%M:%S' } +# Any log row printed inside a section counts as section activity; without +# this, a section whose rows come only from log_* helpers is misdetected as +# idle and end_section/start_section reclaim lines that hold real output. +_log_mark_section_activity() { + # Purge deliberately gives note_activity a different export side effect. + # Update the stable section state directly so logging never dispatches to + # a source-order-selected command implementation. + if [[ "${TRACK_SECTION:-0}" == "1" ]]; then + SECTION_ACTIVITY=1 + fi +} + # Log informational message log_info() { echo -e "${BLUE}$1${NC}" + _log_mark_section_activity local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] INFO: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] INFO: $1" fi } # Log success message log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" + _log_mark_section_activity local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] SUCCESS: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] SUCCESS: $1" fi } # shellcheck disable=SC2329 log_warning() { echo -e "${YELLOW}$1${NC}" + _log_mark_section_activity local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] WARNING: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] WARNING: $1" fi } # shellcheck disable=SC2329 log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 + _log_mark_section_activity local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] ERROR: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] ERROR: $1" fi } # shellcheck disable=SC2329 debug_log() { if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo -e "${GRAY}[DEBUG]${NC} $*" >&2 + printf '%b%s\n' "${GRAY}[DEBUG]${NC} " "$*" >&2 local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] DEBUG: $*" fi } +mole_terminal_safe_text() { + local value="${1:-}" + value=$(printf '%s' "$value" | + LC_ALL=C tr '\r\n\t' ' ' | + LC_ALL=C tr '\001-\010\013\014\016-\037\177' '?') || value="" + printf '%s' "$value" +} + +# Phase-level performance timing, gated behind MO_DEBUG=1. +# Uses perl for millisecond precision; falls back to date +%s. +debug_timer_start() { + [[ "${MO_DEBUG:-}" != "1" ]] && return 0 + local varname="$1" + local ts + ts=$(perl -MTime::HiRes -e 'printf "%.3f\n", Time::HiRes::time()' 2> /dev/null || date +%s) + # eval: indirect write by name; bash 3.2 has no nameref + eval "$varname=$ts" +} + +debug_timer_end() { + [[ "${MO_DEBUG:-}" != "1" ]] && return 0 + local label="$1" + local start_var="$2" + local start_ts + # eval: indirect read by name; bash 3.2 has no nameref + eval "start_ts=\$$start_var" + [[ -z "$start_ts" ]] && return 0 + local end_ts + end_ts=$(perl -MTime::HiRes -e 'printf "%.3f\n", Time::HiRes::time()' 2> /dev/null || date +%s) + local elapsed + elapsed=$(perl -e "printf '%.3f', $end_ts - $start_ts" 2> /dev/null || echo "$((end_ts - start_ts))") + debug_log "PERF [$label] ${elapsed}s" +} + # ============================================================================ # Operation Logging (Enabled by default) # ============================================================================ @@ -163,7 +229,7 @@ log_operation() { local log_line="[$timestamp] [$command] $action $path" [[ -n "$detail" ]] && log_line+=" ($detail)" - echo "$log_line" >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + append_log_line "$OPERATIONS_LOG_FILE" "$log_line" } # Log session start marker @@ -175,10 +241,10 @@ log_operation_session_start() { local timestamp timestamp=$(get_timestamp) - { - echo "" - echo "# ========== $command session started at $timestamp ==========" - } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + append_log_lines \ + "$OPERATIONS_LOG_FILE" \ + "" \ + "# ========== $command session started at $timestamp ==========" } # shellcheck disable=SC2329 @@ -198,9 +264,9 @@ log_operation_session_end() { size_human="0B" fi - { - echo "# ========== $command session ended at $timestamp, $items items, $size_human ==========" - } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + append_log_line \ + "$OPERATIONS_LOG_FILE" \ + "# ========== $command session ended at $timestamp, $items items, $size_human ==========" } # Enhanced debug logging for operations @@ -214,11 +280,18 @@ debug_operation_start() { [[ -n "$operation_desc" ]] && echo -e "${GRAY}[DEBUG] $operation_desc${NC}" >&2 # Also log to file - { - echo "" - echo "=== $operation_name ===" - [[ -n "$operation_desc" ]] && echo "Description: $operation_desc" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true + if [[ -n "$operation_desc" ]]; then + append_log_lines \ + "$DEBUG_LOG_FILE" \ + "" \ + "=== $operation_name ===" \ + "Description: $operation_desc" + else + append_log_lines \ + "$DEBUG_LOG_FILE" \ + "" \ + "=== $operation_name ===" + fi fi } @@ -232,7 +305,7 @@ debug_operation_detail() { echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2 # Also log to file - echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "$detail_type: $detail_value" fi } @@ -252,7 +325,7 @@ debug_file_action() { echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 # Also log to file - echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "$action: $msg" fi } @@ -303,8 +376,10 @@ log_system_info() { fi echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" - # Check sudo status non-interactively - if sudo -n true 2> /dev/null; then + # Check sudo status non-interactively (skip in test mode) + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + echo "Sudo Access: Skipped (test mode)" + elif sudo -n true 2> /dev/null; then echo "Sudo Access: Active" else echo "Sudo Access: Required" @@ -316,33 +391,6 @@ log_system_info() { echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 } -# ============================================================================ -# Command Execution Wrappers -# ============================================================================ - -# Run command silently (ignore errors) -run_silent() { - "$@" > /dev/null 2>&1 || true -} - -# Run command with error logging -run_logged() { - local cmd="$1" - # Log to main file, and also to debug file if enabled - if [[ "${MO_DEBUG:-}" == "1" ]]; then - if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - else - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - fi - return 0 -} - # ============================================================================ # Formatted Output # ============================================================================ diff --git a/Resources/mole/lib/core/pkg_receipts.sh b/Resources/mole/lib/core/pkg_receipts.sh new file mode 100644 index 0000000..b896301 --- /dev/null +++ b/Resources/mole/lib/core/pkg_receipts.sh @@ -0,0 +1,199 @@ +#!/bin/bash +# Mole - pkgutil receipt helpers. +# Finds package-installed app bundles outside the standard app locations. + +set -euo pipefail + +if [[ -n "${MOLE_PKG_RECEIPTS_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_PKG_RECEIPTS_LOADED=1 + +pkg_receipt_nonstandard_app_paths() { + if ! command -v pkgutil > /dev/null 2>&1; then + return 0 + fi + + local require_complete=0 + [[ "${1:-}" == "--require-complete" ]] && require_complete=1 + local cache_file="${MOLE_PKG_RECEIPT_CACHE_FILE:-$HOME/.cache/mole/pkg_receipt_apps_v1}" + local cache_ttl="${MOLE_PKG_RECEIPT_CACHE_TTL:-3600}" + local now_epoch=0 + if declare -f get_epoch_seconds > /dev/null 2>&1; then + now_epoch=$(get_epoch_seconds) + else + now_epoch=$(date +%s 2> /dev/null || echo 0) + fi + + # `pkgutil --pkgs` is one cheap call; what costs seconds on an Xcode Mac is + # the per-receipt `--files` walk below (#1383). So the receipt list is read + # first and doubles as the cache key: installing a package always adds a + # receipt, which changes the fingerprint and forces a rescan. + local pkgs_output="" + local pkgs_rc=0 + if declare -f run_with_timeout > /dev/null 2>&1; then + pkgs_output=$(run_with_timeout "${MOLE_PKG_RECEIPT_LIST_TIMEOUT:-3}" \ + pkgutil --pkgs 2> /dev/null) || pkgs_rc=$? + else + pkgs_output=$(pkgutil --pkgs 2> /dev/null) || pkgs_rc=$? + fi + if [[ $pkgs_rc -ne 0 ]]; then + if [[ "$require_complete" == "1" ]]; then + [[ $pkgs_rc -eq 124 || $pkgs_rc -ge 128 ]] && return "$pkgs_rc" + return 2 + fi + return 0 + fi + [[ -n "$pkgs_output" ]] || return 0 + + local receipts_fingerprint="" + receipts_fingerprint=$(printf '%s\n' "$pkgs_output" | LC_ALL=C cksum 2> /dev/null | + LC_ALL=C tr -cd '0-9 ' | LC_ALL=C tr ' ' '-') || receipts_fingerprint="" + receipts_fingerprint="${receipts_fingerprint%-}" + + # A caller asking for a complete answer is asking for proof, and the TTL + # alone cannot supply it: a sibling installed since the last write would be + # missing from a cache that still looks fresh, and uninstall reads that + # absence as "no other install owns these leftovers". The fingerprint closes + # that window, so an unfingerprintable cache is unusable to those callers. + if [[ "${MOLE_PKG_RECEIPT_CACHE_DISABLE:-0}" != "1" && -r "$cache_file" ]] && + [[ -n "$receipts_fingerprint" || "$require_complete" != "1" ]]; then + local cache_mtime=0 + if declare -f get_file_mtime > /dev/null 2>&1; then + cache_mtime=$(get_file_mtime "$cache_file") + else + cache_mtime=$(stat -f "%m" "$cache_file" 2> /dev/null || echo 0) + fi + if [[ "$cache_ttl" =~ ^[0-9]+$ && "$cache_mtime" =~ ^[0-9]+$ && + "$now_epoch" =~ ^[0-9]+$ && $cache_ttl -gt 0 && + $((now_epoch - cache_mtime)) -lt $cache_ttl ]]; then + local cache_header="" + IFS= read -r cache_header < "$cache_file" || cache_header="" + if [[ "$cache_header" == "#receipts:$receipts_fingerprint" ]]; then + local cache_line_no=0 + while IFS= read -r cached_app_path; do + cache_line_no=$((cache_line_no + 1)) + [[ $cache_line_no -eq 1 ]] && continue + [[ -n "$cached_app_path" && -d "$cached_app_path" ]] && printf '%s\n' "$cached_app_path" + done < "$cache_file" + return 0 + fi + fi + fi + + local -a seen_apps=() + local scan_start=$SECONDS + local scan_timeout="${MOLE_PKG_RECEIPT_SCAN_TIMEOUT:-8}" + local scan_deadline=0 + if [[ "$scan_timeout" =~ ^[0-9]+$ && $scan_timeout -gt 0 ]]; then + scan_deadline=$((scan_start + scan_timeout)) + elif [[ "$require_complete" == "1" ]]; then + return 2 + fi + local pkg_id + while IFS= read -r pkg_id; do + if [[ "$scan_timeout" =~ ^[0-9]+$ && $scan_timeout -gt 0 && $((SECONDS - scan_start)) -ge $scan_timeout ]]; then + [[ "$require_complete" == "1" ]] && return 124 + break + fi + + [[ -n "$pkg_id" ]] || continue + [[ "$pkg_id" =~ ^com\.apple\. ]] && continue + + local pkg_files="" + local pkg_files_rc=0 + if [[ "$require_complete" == "1" ]]; then + local remaining=$((scan_deadline - SECONDS)) + [[ $remaining -gt 0 ]] || return 124 + if declare -f run_with_timeout > /dev/null 2>&1; then + pkg_files=$(run_with_timeout "$remaining" \ + pkgutil --files "$pkg_id" 2> /dev/null) || pkg_files_rc=$? + else + pkg_files=$(pkgutil --files "$pkg_id" 2> /dev/null) || pkg_files_rc=$? + fi + if [[ $pkg_files_rc -ne 0 ]]; then + [[ $pkg_files_rc -eq 124 || $pkg_files_rc -ge 128 ]] && return "$pkg_files_rc" + return 2 + fi + else + if [[ $scan_deadline -gt 0 ]]; then + local remaining=$((scan_deadline - SECONDS)) + [[ $remaining -gt 0 ]] || break + if declare -f run_with_timeout > /dev/null 2>&1; then + pkg_files=$(run_with_timeout "$remaining" \ + pkgutil --files "$pkg_id" 2> /dev/null || true) + else + pkg_files=$(pkgutil --files "$pkg_id" 2> /dev/null || true) + fi + else + pkg_files=$(pkgutil --files "$pkg_id" 2> /dev/null || true) + fi + fi + [[ -n "$pkg_files" ]] || continue + + local rel_path app_path duplicate + while IFS= read -r rel_path; do + if [[ "$scan_timeout" =~ ^[0-9]+$ && $scan_timeout -gt 0 && $((SECONDS - scan_start)) -ge $scan_timeout ]]; then + [[ "$require_complete" == "1" ]] && return 124 + break 2 + fi + + local stripped="${rel_path#/}" + [[ -n "$stripped" ]] || continue + local candidate="/$stripped" + + case "$candidate" in + /usr/local/*.app) app_path="$candidate" ;; + /opt/*.app) app_path="$candidate" ;; + /usr/local/*.app/*) app_path="${candidate%%.app/*}.app" ;; + /opt/*.app/*) app_path="${candidate%%.app/*}.app" ;; + *) continue ;; + esac + + [[ -n "$app_path" && -d "$app_path" ]] || continue + + duplicate=false + local seen + # First candidate runs with seen_apps still empty, and bash 3.2 + # under set -u aborts on an empty-array expansion (#1354). + for seen in ${seen_apps[@]+"${seen_apps[@]}"}; do + if [[ "$seen" == "$app_path" ]]; then + duplicate=true + break + fi + done + [[ "$duplicate" == "true" ]] && continue + + seen_apps+=("$app_path") + done <<< "$pkg_files" + done <<< "$pkgs_output" + + if [[ ${#seen_apps[@]} -gt 0 ]]; then + if ! printf '%s\n' "${seen_apps[@]}" | sort -u; then + [[ "$require_complete" == "1" ]] && return 2 + fi + fi + + # No fingerprint means no verifiable cache entry, so skip the write rather + # than leave a file that can never match on read. + if [[ "${MOLE_PKG_RECEIPT_CACHE_DISABLE:-0}" != "1" && -n "$cache_file" && + -n "$receipts_fingerprint" ]]; then + local cache_dir="${cache_file%/*}" + if [[ -n "$cache_dir" && "$cache_dir" != "$cache_file" ]]; then + if declare -f ensure_user_dir > /dev/null 2>&1; then + ensure_user_dir "$cache_dir" + else + mkdir -p "$cache_dir" 2> /dev/null || true + fi + fi + local cache_tmp + cache_tmp=$(mktemp "${TMPDIR:-/tmp}/mole.pkg_receipts.XXXXXX" 2> /dev/null || true) + if [[ -n "$cache_tmp" ]]; then + printf '#receipts:%s\n' "$receipts_fingerprint" > "$cache_tmp" + if [[ ${#seen_apps[@]} -gt 0 ]]; then + printf '%s\n' "${seen_apps[@]}" | sort -u >> "$cache_tmp" + fi + mv -f "$cache_tmp" "$cache_file" 2> /dev/null || rm -f "$cache_tmp" 2> /dev/null || true + fi + fi +} diff --git a/Resources/mole/lib/core/sudo.sh b/Resources/mole/lib/core/sudo.sh index 483497d..a54f557 100644 --- a/Resources/mole/lib/core/sudo.sh +++ b/Resources/mole/lib/core/sudo.sh @@ -9,15 +9,17 @@ set -euo pipefail # ============================================================================ check_touchid_support() { + local pam_sudo_file="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}" + local pam_sudo_local_file="${MOLE_PAM_SUDO_LOCAL_FILE:-$(dirname "$pam_sudo_file")/sudo_local}" + # Check sudo_local first (Sonoma+) - if [[ -f /etc/pam.d/sudo_local ]]; then - grep -q "pam_tid.so" /etc/pam.d/sudo_local 2> /dev/null - return $? + if [[ -f "$pam_sudo_local_file" ]] && grep -q "pam_tid.so" "$pam_sudo_local_file" 2> /dev/null; then + return 0 fi # Fallback to checking sudo directly - if [[ -f /etc/pam.d/sudo ]]; then - grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null + if [[ -f "$pam_sudo_file" ]]; then + grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null return $? fi return 1 @@ -44,58 +46,24 @@ is_clamshell_mode() { _request_password() { local tty_path="$1" - local attempts=0 - local show_hint=true - # Extra safety: ensure sudo cache is cleared before password input sudo -k 2> /dev/null - # Save original terminal settings and ensure they're restored on exit local stty_orig stty_orig=$(stty -g < "$tty_path" 2> /dev/null || echo "") trap '[[ -n "${stty_orig:-}" ]] && stty "${stty_orig:-}" < "$tty_path" 2> /dev/null || true' RETURN - while ((attempts < 3)); do - local password="" - - # Show hint on first attempt about Touch ID appearing again - if [[ $show_hint == true ]] && check_touchid_support; then - echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" - show_hint=false - fi - - printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" - - # Disable terminal echo to hide password input (keep canonical mode for reliable input) - stty -echo < "$tty_path" 2> /dev/null || true - IFS= read -r password < "$tty_path" || password="" - # Restore terminal echo immediately - stty echo < "$tty_path" 2> /dev/null || true - - printf "\n" > "$tty_path" + if check_touchid_support; then + echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" + fi - if [[ -z "$password" ]]; then - unset password - attempts=$((attempts + 1)) - if [[ $attempts -lt 3 ]]; then - echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" - fi - continue - fi + echo -e "${PURPLE}${ICON_ARROW}${NC} Enter your credentials:" > "$tty_path" - # Verify password with sudo - # NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior - if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then - unset password - return 0 - fi - - unset password - attempts=$((attempts + 1)) - if [[ $attempts -lt 3 ]]; then - echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" - fi - done + # shellcheck disable=SC2024,SC2094 + # Intentionally route sudo's native prompt to the same TTY device it reads from. + if sudo -v < "$tty_path" > /dev/null 2> "$tty_path"; then + return 0 + fi return 1 } @@ -103,6 +71,11 @@ _request_password() { request_sudo_access() { local prompt_msg="${1:-Admin access required}" + # Tests must never trigger real password or Touch ID prompts. + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + # Check if already have sudo access if sudo -n true 2> /dev/null; then return 0 @@ -124,9 +97,16 @@ request_sudo_access() { # Clear sudo cache before attempting authentication sudo -k 2> /dev/null - # Display native macOS password dialog + # Display native macOS password dialog. prompt_msg can carry on-disk + # app display names (batch uninstall builds it from ${sudo_apps[*]}), + # so escape backslashes and double quotes before embedding it in the + # AppleScript string literal, or an app named with an embedded quote + # could break out and run `do shell script`. Same escaping as + # force_kill_app and remove_login_item. + local escaped_msg="${prompt_msg//\\/\\\\}" + escaped_msg="${escaped_msg//\"/\\\"}" local password - password=$(osascript -e "display dialog \"$prompt_msg\" default answer \"\" with title \"Mole\" with icon caution with hidden answer" -e 'text returned of result' 2> /dev/null) + password=$(osascript -e "display dialog \"$escaped_msg\" default answer \"\" with title \"Mole\" with icon caution with hidden answer" -e 'text returned of result' 2> /dev/null) if [[ -z "$password" ]]; then # User cancelled the dialog @@ -149,10 +129,14 @@ request_sudo_access() { # Check if in clamshell mode - if yes, skip Touch ID entirely if is_clamshell_mode; then + local clear_lines=3 + if check_touchid_support; then + clear_lines=4 + fi echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" if _request_password "$tty_path"; then # Clear all prompt lines (use safe clearing method) - safe_clear_lines 3 "$tty_path" + safe_clear_lines "$clear_lines" "$tty_path" return 0 fi return 1 @@ -223,6 +207,31 @@ request_sudo_access() { return 1 } +request_sudo_access_with_password() { + local password="$1" + local prompt_msg="${2:-Admin access required}" + + # Tests must never trigger real password or Touch ID prompts. + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + if [[ -z "$password" ]]; then + request_sudo_access "$prompt_msg" + return $? + fi + + sudo -k 2> /dev/null + + if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then + unset password + return 0 + fi + + unset password + return 1 +} + # ============================================================================ # Sudo Session Management # ============================================================================ @@ -240,17 +249,17 @@ _start_sudo_keepalive() { # This prevents immediately triggering Touch ID again sleep 2 - local retry_count=0 while true; do if ! sudo -n -v 2> /dev/null; then - retry_count=$((retry_count + 1)) - if [[ $retry_count -ge 3 ]]; then - exit 1 - fi + # A failed refresh is harmless and often transient (authd + # busy, machine waking). Giving up after a few misses is what + # let the timestamp lapse minutes into a long install and + # forced a second authentication prompt; `-n` never prompts, + # so retrying costs nothing. Exit only with the parent. + kill -0 "$$" 2> /dev/null || exit sleep 5 continue fi - retry_count=0 sleep 30 kill -0 "$$" 2> /dev/null || exit done @@ -271,9 +280,43 @@ _stop_sudo_keepalive() { # Check if sudo session is active has_sudo_session() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + sudo -n true 2> /dev/null } +adopt_sudo_session() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + MOLE_SUDO_ESTABLISHED="false" + return 1 + fi + + if [[ "$MOLE_SUDO_ESTABLISHED" == "true" && -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then + if has_sudo_session; then + return 0 + fi + _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" + MOLE_SUDO_KEEPALIVE_PID="" + MOLE_SUDO_ESTABLISHED="false" + fi + + if ! sudo -n -v 2> /dev/null; then + MOLE_SUDO_ESTABLISHED="false" + return 1 + fi + + if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then + _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" + MOLE_SUDO_KEEPALIVE_PID="" + fi + + MOLE_SUDO_KEEPALIVE_PID=$(_start_sudo_keepalive) + MOLE_SUDO_ESTABLISHED="true" + return 0 +} + # Request administrative access request_sudo() { local prompt_msg="${1:-Admin access required}" @@ -299,6 +342,11 @@ ensure_sudo_session() { return 0 fi + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + MOLE_SUDO_ESTABLISHED="false" + return 1 + fi + # Stop old keepalive if exists if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" @@ -318,29 +366,49 @@ ensure_sudo_session() { return 0 } -# Stop sudo session and cleanup -stop_sudo_session() { +ensure_sudo_session_with_password() { + local password="$1" + local prompt="${2:-Admin access required}" + + # Check if already established + if has_sudo_session && [[ "$MOLE_SUDO_ESTABLISHED" == "true" ]]; then + unset password + return 0 + fi + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + MOLE_SUDO_ESTABLISHED="false" + unset password + return 1 + fi + + # Stop old keepalive if exists if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" MOLE_SUDO_KEEPALIVE_PID="" fi - MOLE_SUDO_ESTABLISHED="false" -} -# Register cleanup on script exit -register_sudo_cleanup() { - trap stop_sudo_session EXIT INT TERM + # Request sudo access + if ! request_sudo_access_with_password "$password" "$prompt"; then + MOLE_SUDO_ESTABLISHED="false" + unset password + return 1 + fi + + unset password + + # Start keepalive + MOLE_SUDO_KEEPALIVE_PID=$(_start_sudo_keepalive) + + MOLE_SUDO_ESTABLISHED="true" + return 0 } -# Predict if operation requires administrative access -will_need_sudo() { - local -a operations=("$@") - for op in "${operations[@]}"; do - case "$op" in - system_update | appstore_update | macos_update | firewall | touchid | rosetta | system_fix) - return 0 - ;; - esac - done - return 1 +# Stop sudo session and cleanup +stop_sudo_session() { + if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then + _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" + MOLE_SUDO_KEEPALIVE_PID="" + fi + MOLE_SUDO_ESTABLISHED="false" } diff --git a/Resources/mole/lib/core/timeout.sh b/Resources/mole/lib/core/timeout.sh index edd7051..35eceb4 100644 --- a/Resources/mole/lib/core/timeout.sh +++ b/Resources/mole/lib/core/timeout.sh @@ -34,9 +34,9 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then MO_TIMEOUT_PERL_BIN="" for candidate in gtimeout timeout; do if command -v "$candidate" > /dev/null 2>&1; then - MO_TIMEOUT_BIN="$candidate" + MO_TIMEOUT_BIN="$(command -v "$candidate")" if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Using command: $candidate" >&2 + echo "[TIMEOUT] Using command: $MO_TIMEOUT_BIN" >&2 fi break fi @@ -55,6 +55,11 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2 fi + # Export so child processes inherit detected values and skip re-detection. + # Without this, children that inherit MO_TIMEOUT_INITIALIZED=1 skip the init + # block but have empty bin vars, forcing the slow shell fallback. + export MO_TIMEOUT_BIN + export MO_TIMEOUT_PERL_BIN export MO_TIMEOUT_INITIALIZED=1 fi @@ -62,6 +67,50 @@ fi # Timeout Execution # ============================================================================ +_mole_cleanup_timeout_killer() { + local killer_pid="${1:-}" + [[ "$killer_pid" =~ ^[0-9]+$ ]] || return 0 + + local child_pids="" + if command -v pgrep > /dev/null 2>&1; then + child_pids=$(pgrep -P "$killer_pid" 2> /dev/null || true) + fi + + kill "$killer_pid" 2> /dev/null || true + + if [[ -n "$child_pids" ]]; then + local child_pid + while IFS= read -r child_pid; do + [[ "$child_pid" =~ ^[0-9]+$ ]] || continue + kill "$child_pid" 2> /dev/null || true + done <<< "$child_pids" + fi + + wait "$killer_pid" 2> /dev/null || true +} + +# Return success when Mole's process group still owns the controlling terminal. +# Checking this before a prompt avoids SIGTTIN if a nested interactive command +# returned the tty to Mole's parent shell instead of restoring it to Mole. +mole_tty_is_foreground() { + # Non-terminal input cannot trigger SIGTTIN; preserve scripted/test flows. + [[ -t 0 ]] || return 0 + + local perl_bin="${MO_TIMEOUT_PERL_BIN:-}" + if [[ -z "$perl_bin" || ! -x "$perl_bin" ]]; then + perl_bin=$(command -v perl 2> /dev/null || true) + fi + [[ -n "$perl_bin" && -x "$perl_bin" ]] || return 0 + + # shellcheck disable=SC2016 # Embedded Perl variables are intentionally single-quoted. + "$perl_bin" -MPOSIX=tcgetpgrp -e ' + my $foreground_pgrp = tcgetpgrp(fileno(STDIN)); + my $current_pgrp = getpgrp(); + exit((defined($foreground_pgrp) && $foreground_pgrp >= 0 && + $foreground_pgrp == $current_pgrp) ? 0 : 1); + ' 2> /dev/null +} + # Run command with timeout # Uses gtimeout/timeout if available, falls back to shell-based implementation # @@ -93,18 +142,41 @@ run_with_timeout() { local duration="${1:-0}" shift || true - # No timeout if duration is invalid or zero - if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ $(echo "$duration <= 0" | bc -l 2> /dev/null) -eq 1 ]]; then + # No timeout if duration is invalid or zero. The regex already forbids a + # leading sign, so "<= 0" reduces to "is zero"; match that in pure bash + # rather than shelling out to bc, which is not guaranteed on macOS. + if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$duration" =~ ^0+(\.0+)?$ ]]; then "$@" return $? fi # Use timeout command if available (preferred path) + # + # This backend has no owner-death detection, unlike the perl fallback below, + # and that asymmetry is deliberate. Measured on macOS with coreutils + # gtimeout: SIGKILLing the calling worker leaves gtimeout and its child + # alive, but both still die when gtimeout's own deadline fires, so the orphan + # window is capped by the caller's timeout budget (2-30s here) rather than + # unbounded. Ctrl-C already reaches them, since the signal goes to the whole + # foreground process group. Closing the remaining SIGKILL gap would mean + # either routing every non-TTY run through perl, which is every CI run and + # every piped run, or backgrounding the backend and polling it, which + # reopens the terminal-handoff class behind #1222/#1218. Neither is worth a + # window the deadline already bounds. if [[ -n "${MO_TIMEOUT_BIN:-}" ]]; then + local timeout_bin="$MO_TIMEOUT_BIN" + if [[ "$timeout_bin" != */* ]]; then + timeout_bin=$(command -v "$timeout_bin" 2> /dev/null || true) + fi + if [[ -z "$timeout_bin" || ! -x "$timeout_bin" ]]; then + timeout_bin="" + fi + fi + if [[ -n "${timeout_bin:-}" ]]; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then echo "[TIMEOUT] Running with ${duration}s timeout: $*" >&2 fi - "$MO_TIMEOUT_BIN" "$duration" "$@" + "$timeout_bin" "$duration" "$@" return $? fi @@ -117,49 +189,124 @@ run_with_timeout() { "$MO_TIMEOUT_PERL_BIN" -e ' use strict; use warnings; - use POSIX qw(:sys_wait_h setsid); + use POSIX qw(:sys_wait_h setpgid tcgetpgrp tcsetpgrp); use Time::HiRes qw(time sleep); my $duration = 0 + shift @ARGV; $duration = 1 if $duration <= 0; + my $caller_pid = getppid(); + + # Only the process group that currently owns the terminal may hand it + # to a child. Mole runs these helpers concurrently inside one process + # group (parallel scan workers), so a helper that started while a + # sibling held the terminal would capture the sibling child as the + # "original" owner and later restore the terminal to that already + # dead process group. Mole then no longer owns the terminal and the + # next prompt read stops on SIGTTIN (issue #1222/#1218). + my $my_pgrp = getpgrp(); + my $tty_fd = -t STDIN ? fileno(STDIN) : undef; + my $original_pgrp; + if (defined $tty_fd) { + $original_pgrp = tcgetpgrp($tty_fd); + undef $original_pgrp + if !defined $original_pgrp + || $original_pgrp < 0 + || $original_pgrp != $my_pgrp; + } my $pid = fork(); defined $pid or exit 125; if ($pid == 0) { - setsid() or exit 125; + # New process group, NOT a new session: keep the controlling + # terminal so nested sudo inside the wrapped command can reuse + # the cached credential. setsid() would detach the tty and break + # brew cask uninstall scripts that call sudo (issue #1003). + # setpgid returns 0 on success (falsy in Perl), so it must not be + # guarded with `or exit`; a rare failure only degrades group-kill. + setpgid(0, 0); exec @ARGV; exit 127; } + # Perl defers these handlers until a safe point. Record the desired + # exit status here and let the normal wait loop terminate and reap + # the whole timed process group. + my $shutdown_status = 0; + $SIG{INT} = sub { $shutdown_status ||= 130; }; + $SIG{TERM} = sub { $shutdown_status ||= 143; }; + $SIG{HUP} = sub { $shutdown_status ||= 129; }; + + # The child is a separate process group so timeout cleanup can kill + # its descendants. A tty only permits its foreground process group + # to read, however, so hand the terminal to the child while it runs. + # Without this, nested sudo prints Password: and then stops on + # SIGTTIN until the timeout expires (issue #1201). + setpgid($pid, $pid); + my $tty_handed_off = 0; + if (defined $tty_fd && defined $original_pgrp) { + local $SIG{TTOU} = "IGNORE"; + $tty_handed_off = tcsetpgrp($tty_fd, $pid) == 0 ? 1 : 0; + kill "CONT", -$pid if $tty_handed_off; + } + + my $restore_tty = sub { + return unless $tty_handed_off && defined $tty_fd && defined $original_pgrp; + $tty_handed_off = 0; + # Give the terminal back only while our own child still owns it. + # If something else took over meanwhile, restoring would revoke + # the current owner instead. + my $owner = tcgetpgrp($tty_fd); + return unless defined $owner && $owner == $pid; + local $SIG{TTOU} = "IGNORE"; + tcsetpgrp($tty_fd, $original_pgrp); + }; + + my $terminate_child = sub { + my $sent = kill "TERM", -$pid; + kill "TERM", $pid unless $sent; + + my $grace_deadline = time() + 2; + while (time() < $grace_deadline) { + my $result = waitpid($pid, WNOHANG); + return if $result == $pid || $result == -1; + sleep 0.1; + } + + $sent = kill "KILL", -$pid; + kill "KILL", $pid unless $sent; + waitpid($pid, 0); + }; + my $deadline = time() + $duration; while (1) { my $result = waitpid($pid, WNOHANG); if ($result == $pid) { - if (WIFEXITED($?)) { - exit WEXITSTATUS($?); + my $status = $?; + $restore_tty->(); + if (WIFEXITED($status)) { + exit WEXITSTATUS($status); } - if (WIFSIGNALED($?)) { - exit 128 + WTERMSIG($?); + if (WIFSIGNALED($status)) { + exit 128 + WTERMSIG($status); } exit 1; } - if (time() >= $deadline) { - kill "TERM", -$pid; - sleep 0.5; - - for (1 .. 6) { - $result = waitpid($pid, WNOHANG); - if ($result == $pid) { - exit 124; - } - sleep 0.25; - } + # A background shell worker can be killed without forwarding + # TERM to this Perl child. Detect reparenting so the timed + # command never outlives the worker that owned its deadline. + if ($shutdown_status || getppid() != $caller_pid) { + my $status = $shutdown_status || 143; + $terminate_child->(); + $restore_tty->(); + exit $status; + } - kill "KILL", -$pid; - waitpid($pid, 0); + if (time() >= $deadline) { + $terminate_child->(); + $restore_tty->(); exit 124; } @@ -181,7 +328,10 @@ run_with_timeout() { "$@" & local cmd_pid=$! - # Start timeout killer in background + # Start timeout killer in background. + # Redirect all FDs to /dev/null so orphaned child processes (e.g. sleep $duration) + # do not inherit open file descriptors from the caller and block output pipes + # (notably bats output capture pipes that wait for all writers to close). ( # Wait for timeout duration sleep "$duration" @@ -200,9 +350,16 @@ run_with_timeout() { kill -KILL -"$cmd_pid" 2> /dev/null || kill -KILL "$cmd_pid" 2> /dev/null || true fi fi - ) & + ) < /dev/null > /dev/null 2>&1 & local killer_pid=$! + local interrupted=0 + local previous_int_trap + previous_int_trap=$(trap -p INT || true) + + # Forward SIGINT to the command while preserving the caller's trap. + trap 'interrupted=1; kill -INT "$cmd_pid" 2>/dev/null || true; _mole_cleanup_timeout_killer "$killer_pid"' INT + # Wait for command to complete local exit_code=0 set +e @@ -210,10 +367,17 @@ run_with_timeout() { exit_code=$? set -e - # Clean up killer process - if kill -0 "$killer_pid" 2> /dev/null; then - kill "$killer_pid" 2> /dev/null || true - wait "$killer_pid" 2> /dev/null || true + if [[ -n "$previous_int_trap" ]]; then + # eval: restore previous trap captured by $(trap -p INT) + eval "$previous_int_trap" + else + trap - INT + fi + + _mole_cleanup_timeout_killer "$killer_pid" + + if [[ $interrupted -eq 1 ]]; then + return 130 fi # Check if command was killed by timeout (exit codes 143=SIGTERM, 137=SIGKILL) diff --git a/Resources/mole/lib/core/timeouts.sh b/Resources/mole/lib/core/timeouts.sh new file mode 100644 index 0000000..f30bfe3 --- /dev/null +++ b/Resources/mole/lib/core/timeouts.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Mole - Centralized timeout constants for run_with_timeout calls. +# +# Goal: when someone needs to tune "all quick command-availability probes" +# or "all package-manager cleanup ceilings", they edit ONE place instead +# of grepping 100+ call sites. +# +# Naming: MOLE_TIMEOUT__SEC. All values are seconds (integer or +# fractional). All are overridable via the same-named env var so operators +# can lengthen them for slow disks / cold Spotlight / etc. +# +# Categories (with rationale, not "what they happen to be tuned to"): +# +# QUICK_DETECT command -v + version-check style probes. Should fail +# fast when the tool is missing or wedged. ~2s. +# SHORT_QUERY Lightweight subprocess query (df, tmutil status). ~3s. +# MEDIUM_PROBE Heavier probe that occasionally talks to the network +# or scans a directory tree. ~5s. +# PKG_LIST Package manager listing (brew list, simctl list). ~10s. +# PKG_CLEANUP Cache cleanup commands that walk disks. ~20s. +# DISK_VERIFY Filesystem-level verify/repair operations. ~30s. +# HINT_SCAN Non-destructive scan that walks an unbounded user +# directory tree (project-artifact discovery, preference +# plist lint). Per-listing finds are already capped; this is +# the cumulative wall-clock ceiling for the whole walk so it +# can never appear hung. ~15s. +# +# Migration: new code should use these constants. Existing call sites can +# be migrated incrementally; the script `grep 'run_with_timeout [0-9]'` lists +# remaining literal-timeout calls. +# +# Intentionally NOT in this table (values that appear hardcoded in lib/): +# +# 1s Volume/filesystem type probes that should be near-instant on a +# healthy disk: `df -T`, `diskutil info`, `find -maxdepth 1`. A +# wedge here usually means the volume itself is sick; failing fast +# is the right behavior. +# 8s External tool calls that are too slow for MEDIUM_PROBE (5s) but +# shouldn't pay the PKG_LIST (10s) ceiling: `hdiutil info`, +# `brew outdated`, `simctl list` warm-up retry. Also the deep +# `find /private/var/folders -maxdepth 8` GPU-cache scan in +# lib/clean/system.sh - same "occasionally slow disk probe" shape. +# 15s Long-running maintenance ops on user-selected targets: +# `hdiutil detach`, `lsregister -r -f`, Time Machine backupdb +# `find`. Different shape from PKG_CLEANUP (20s, brew/conda) - +# keep them apart so tuning one doesn't move the other. +# 0.2s Per-app inline mdls probe in the uninstall scan tight loop. Tens +# to hundreds of invocations per scan; bucket constants would +# imply this is reusable elsewhere, which it isn't. +# +# If you find yourself adding a new use of one of these literals, consider +# whether a bucket actually exists for it before copying the magic number. + +set -euo pipefail + +if [[ -n "${MOLE_TIMEOUTS_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_TIMEOUTS_LOADED=1 + +readonly MOLE_TIMEOUT_QUICK_DETECT_SEC="${MOLE_TIMEOUT_QUICK_DETECT_SEC:-2}" +readonly MOLE_TIMEOUT_SHORT_QUERY_SEC="${MOLE_TIMEOUT_SHORT_QUERY_SEC:-3}" +readonly MOLE_TIMEOUT_MEDIUM_PROBE_SEC="${MOLE_TIMEOUT_MEDIUM_PROBE_SEC:-5}" +readonly MOLE_TIMEOUT_PKG_LIST_SEC="${MOLE_TIMEOUT_PKG_LIST_SEC:-10}" +readonly MOLE_TIMEOUT_PKG_CLEANUP_SEC="${MOLE_TIMEOUT_PKG_CLEANUP_SEC:-20}" +readonly MOLE_TIMEOUT_DISK_VERIFY_SEC="${MOLE_TIMEOUT_DISK_VERIFY_SEC:-30}" +readonly MOLE_TIMEOUT_HINT_SCAN_SEC="${MOLE_TIMEOUT_HINT_SCAN_SEC:-15}" + +# Clamp a per-command timeout to an overall wall-clock deadline. Kept beside +# the timeout policy constants because cleanup, uninstall, and file operations +# all need the same cumulative-budget behavior, including when those modules +# are sourced independently in tests or integrations. +# +# Deadlines are counted in SECONDS, which advances in whole seconds. A caller +# that builds one as `SECONDS + 1` is really asking for "until the next second +# boundary", so the budget can collapse to almost nothing and this returns 124 +# before the command ever runs. Every constant above is >= 2 for that reason; +# keep new budgets there too, and never assert on a one-second bound in tests. +_mole_timeout_with_deadline() { + local requested="$1" + local deadline="${2:-}" + if [[ -z "$deadline" ]]; then + printf '%s\n' "$requested" + return 0 + fi + + local remaining=$((deadline - SECONDS)) + [[ $remaining -gt 0 ]] || return 124 + if [[ ! "$requested" =~ ^[0-9]+(\.[0-9]+)?$ || "$requested" =~ ^0+(\.0+)?$ ]]; then + printf '%s\n' "$remaining" + return 0 + fi + local requested_whole="${requested%%.*}" + local requested_whole_decimal=$((10#$requested_whole)) + if [[ $requested_whole_decimal -ge $remaining ]]; then + printf '%s\n' "$remaining" + else + printf '%s\n' "$requested" + fi +} diff --git a/Resources/mole/lib/core/ui.sh b/Resources/mole/lib/core/ui.sh index 421d29a..b5b4924 100755 --- a/Resources/mole/lib/core/ui.sh +++ b/Resources/mole/lib/core/ui.sh @@ -12,6 +12,17 @@ readonly MOLE_UI_LOADED=1 _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" +# Timeout for the second key of a multi-key sequence (e.g. gg -> jump to top). +# Bash 4.0+ accepts fractional `read -t` values; macOS default Bash 3.2 rejects +# them ("invalid timeout specification") and the read fails instantly, which +# silently disabled the gg shortcut. Use a snappy sub-second wait where it is +# supported and fall back to a 1s integer timeout on Bash 3.2. +if [[ "${BASH_VERSINFO[0]:-0}" -ge 4 ]]; then + readonly MOLE_KEY_SEQ_TIMEOUT="0.3" +else + readonly MOLE_KEY_SEQ_TIMEOUT="1" +fi + # Cursor control clear_screen() { printf '\033[2J\033[H'; } hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; } @@ -207,6 +218,7 @@ read_key() { fi ;; ' ') echo "SPACE" ;; # Allow space in filter mode for selection + $'\x03') echo "QUIT" ;; [[:print:]]) echo "CHAR:$key" ;; *) echo "OTHER" ;; esac @@ -223,12 +235,25 @@ read_key() { 'q' | 'Q') echo "QUIT" ;; 'R') echo "RETRY" ;; 'm' | 'M') echo "MORE" ;; + 'v' | 'V') echo "VERSION" ;; 'u' | 'U') echo "UPDATE" ;; 't' | 'T') echo "TOUCHID" ;; 'j' | 'J') echo "DOWN" ;; 'k' | 'K') echo "UP" ;; 'h' | 'H') echo "LEFT" ;; 'l' | 'L') echo "RIGHT" ;; + 'G') echo "BOTTOM" ;; + 'g') + if IFS= read -r -s -n 1 -t "$MOLE_KEY_SEQ_TIMEOUT" rest 2> /dev/null; then + if [[ "$rest" == "g" ]]; then + echo "TOP" + else + echo "OTHER" + fi + else + echo "OTHER" + fi + ;; $'\x03') echo "QUIT" ;; $'\x7f' | $'\x08') echo "DELETE" ;; $'\x15') echo "CLEAR_LINE" ;; # Ctrl+U @@ -263,11 +288,14 @@ read_key() { } drain_pending_input() { + local idle_timeout="${1:-0.01}" local drained=0 - while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do + while IFS= read -r -s -n 1 -t "$idle_timeout" _ 2> /dev/null; do drained=$((drained + 1)) [[ $drained -gt 100 ]] && break + idle_timeout="0.01" done + return 0 } # Format menu option display @@ -286,6 +314,18 @@ show_menu_option() { # Background spinner implementation INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" +INLINE_SPINNER_MSG_FILE="" +INLINE_SPINNER_CONTROL_DIR="" + +create_inline_spinner_control_dir() { + ensure_mole_temp_root || return 1 + local control_root="$MOLE_RESOLVED_TMPDIR" + + [[ -d "$control_root" && ! -L "$control_root" ]] || return 1 + INLINE_SPINNER_CONTROL_DIR=$(umask 077 && mktemp -d "$control_root/.mole-spinner.XXXXXX") || return 1 + [[ -d "$INLINE_SPINNER_CONTROL_DIR" && ! -L "$INLINE_SPINNER_CONTROL_DIR" && -O "$INLINE_SPINNER_CONTROL_DIR" ]] || return 1 + MOLE_TEMP_DIRS+=("$INLINE_SPINNER_CONTROL_DIR") +} # Keep spinner message on one line and avoid wrapping/noisy output on narrow terminals. format_spinner_message() { @@ -323,15 +363,33 @@ start_inline_spinner() { display_message=$(format_spinner_message "$message") if [[ -t 1 ]]; then - # Create unique stop flag file for this spinner instance - INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop" + if ! create_inline_spinner_control_dir; then + echo -n " ${BLUE}|${NC} $display_message" >&2 || true + return 0 + fi + + INLINE_SPINNER_STOP_FILE="$INLINE_SPINNER_CONTROL_DIR/stop" + # Message file lets callers swap the text in place; a stop/start cycle + # blanks the line for a frame and reads as flicker. + INLINE_SPINNER_MSG_FILE="$INLINE_SPINNER_CONTROL_DIR/message" + if ! (umask 077 && set -C && printf '%s\n' "$display_message" > "$INLINE_SPINNER_MSG_FILE") 2> /dev/null; then + rmdir "$INLINE_SPINNER_CONTROL_DIR" 2> /dev/null || true + INLINE_SPINNER_CONTROL_DIR="" + INLINE_SPINNER_STOP_FILE="" + INLINE_SPINNER_MSG_FILE="" + echo -n " ${BLUE}|${NC} $display_message" >&2 || true + return 0 + fi ( local stop_file="$INLINE_SPINNER_STOP_FILE" + local msg_file="$INLINE_SPINNER_MSG_FILE" local chars chars="$(mo_spinner_chars)" [[ -z "$chars" ]] && chars="|/-\\" local i=0 + local current_message="$display_message" + local next_message="" # Clear line on first output to prevent text remnants from previous messages printf "\r\033[2K" >&2 || true @@ -339,10 +397,21 @@ start_inline_spinner() { # Cooperative exit: check for stop file instead of relying on signals while [[ ! -f "$stop_file" ]]; do local c="${chars:$((i % ${#chars})):1}" + # Re-read the message each frame; erase the line only when the + # text changed (a shorter message would leave remnants), and + # keep erase + redraw in one write so no blank frame shows. + local frame_lead="\r" + if [[ -f "$msg_file" && ! -L "$msg_file" && -r "$msg_file" ]]; then + IFS= read -r next_message < "$msg_file" 2> /dev/null || next_message="" + if [[ -n "$next_message" && "$next_message" != "$current_message" ]]; then + current_message="$next_message" + frame_lead="\r\033[2K" + fi + fi # Output to stderr to avoid interfering with stdout - printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break + printf "${frame_lead}${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$current_message" >&2 || break i=$((i + 1)) - sleep 0.05 + /bin/sleep 0.05 done # Clean up stop file before exiting @@ -366,7 +435,7 @@ stop_inline_spinner() { # Wait briefly for cooperative exit local wait_count=0 while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do - sleep 0.05 2> /dev/null || true + /bin/sleep 0.05 2> /dev/null || true wait_count=$((wait_count + 1)) done @@ -379,19 +448,48 @@ stop_inline_spinner() { # Cleanup rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true + rm -f "$INLINE_SPINNER_MSG_FILE" 2> /dev/null || true + if [[ -n "$INLINE_SPINNER_CONTROL_DIR" ]]; then + rmdir "$INLINE_SPINNER_CONTROL_DIR" 2> /dev/null || true + fi INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" + INLINE_SPINNER_MSG_FILE="" + INLINE_SPINNER_CONTROL_DIR="" # Clear the line - use \033[2K to clear entire line, not just to end [[ -t 1 ]] && printf "\r\033[2K" >&2 || true fi } +# Swap the text of a running inline spinner without restarting it. +# Returns 1 when no spinner is active so callers can fall back to starting one. +update_inline_spinner_message() { + local message="$1" + [[ -n "$INLINE_SPINNER_PID" && -n "$INLINE_SPINNER_MSG_FILE" && -n "$INLINE_SPINNER_CONTROL_DIR" ]] || return 1 + kill -0 "$INLINE_SPINNER_PID" 2> /dev/null || return 1 + [[ -f "$INLINE_SPINNER_MSG_FILE" && ! -L "$INLINE_SPINNER_MSG_FILE" && -O "$INLINE_SPINNER_MSG_FILE" ]] || return 1 + + local display_message + display_message=$(format_spinner_message "$message") + + # Write-then-rename so the spinner never reads a half-truncated file. + local tmp_file + tmp_file=$(umask 077 && mktemp "$INLINE_SPINNER_CONTROL_DIR/message.XXXXXX") || return 1 + if ! printf '%s\n' "$display_message" > "$tmp_file" 2> /dev/null; then + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi + if ! mv -f "$tmp_file" "$INLINE_SPINNER_MSG_FILE" 2> /dev/null; then + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi + return 0 +} + # Get spinner characters mo_spinner_chars() { - local chars="|/-\\" - [[ -z "$chars" ]] && chars="|/-\\" - printf "%s" "$chars" + printf "%s" "|/-\\" } # Format relative time for compact display (e.g., 3d ago) diff --git a/Resources/mole/lib/manage/autofix.sh b/Resources/mole/lib/manage/autofix.sh deleted file mode 100644 index eb76fb4..0000000 --- a/Resources/mole/lib/manage/autofix.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/bin/bash -# Auto-fix Manager -# Unified auto-fix suggestions and execution - -set -euo pipefail - -# Show system suggestions with auto-fix markers -show_suggestions() { - local has_suggestions=false - local can_auto_fix=false - local -a auto_fix_items=() - local -a manual_items=() - local skip_security_autofix=false - if [[ "${MOLE_SECURITY_FIXES_SHOWN:-}" == "true" ]]; then - skip_security_autofix=true - fi - - # Security suggestions - if [[ "$skip_security_autofix" == "false" && -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then - auto_fix_items+=("Enable Firewall for better security") - has_suggestions=true - can_auto_fix=true - fi - - if [[ -n "${FILEVAULT_DISABLED:-}" && "${FILEVAULT_DISABLED}" == "true" ]]; then - manual_items+=("Enable FileVault|System Settings → Privacy & Security → FileVault") - has_suggestions=true - fi - - # Configuration suggestions - if [[ "$skip_security_autofix" == "false" && -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then - auto_fix_items+=("Enable Touch ID for sudo") - has_suggestions=true - can_auto_fix=true - fi - - if [[ -n "${ROSETTA_NOT_INSTALLED:-}" && "${ROSETTA_NOT_INSTALLED}" == "true" ]]; then - auto_fix_items+=("Install Rosetta 2 for Intel app support") - has_suggestions=true - can_auto_fix=true - fi - - # Health suggestions - if [[ -n "${CACHE_SIZE_GB:-}" ]]; then - local cache_gb="${CACHE_SIZE_GB:-0}" - if (($(echo "$cache_gb > 5" | bc -l 2> /dev/null || echo 0))); then - manual_items+=("Free up ${cache_gb}GB by cleaning caches|Run: mo clean") - has_suggestions=true - fi - fi - - if [[ -n "${BREW_HAS_WARNINGS:-}" && "${BREW_HAS_WARNINGS}" == "true" ]]; then - manual_items+=("Fix Homebrew warnings|Run: brew doctor to see details") - has_suggestions=true - fi - - if [[ -n "${DISK_FREE_GB:-}" && "${DISK_FREE_GB:-0}" -lt 50 ]]; then - if [[ -z "${CACHE_SIZE_GB:-}" ]] || (($(echo "${CACHE_SIZE_GB:-0} <= 5" | bc -l 2> /dev/null || echo 1))); then - manual_items+=("Low disk space, ${DISK_FREE_GB}GB free|Run: mo analyze to find large files") - has_suggestions=true - fi - fi - - # Display suggestions - echo -e "${BLUE}${ICON_ARROW}${NC} Suggestions" - - if [[ "$has_suggestions" == "false" ]]; then - echo -e " ${GREEN}✓${NC} All looks good" - export HAS_AUTO_FIX_SUGGESTIONS="false" - return - fi - - # Show auto-fix items - if [[ ${#auto_fix_items[@]} -gt 0 ]]; then - for item in "${auto_fix_items[@]}"; do - echo -e " ${GRAY}${ICON_WARNING}${NC} ${item} ${GREEN}[auto]${NC}" - done - fi - - # Show manual items - if [[ ${#manual_items[@]} -gt 0 ]]; then - for item in "${manual_items[@]}"; do - local title="${item%%|*}" - local hint="${item#*|}" - echo -e " ${GRAY}${ICON_WARNING}${NC} ${title}" - echo -e " ${GRAY}${hint}${NC}" - done - fi - - # Export for use in auto-fix - export HAS_AUTO_FIX_SUGGESTIONS="$can_auto_fix" -} - -# Ask user if they want to auto-fix -# Returns: 0 if yes, 1 if no -ask_for_auto_fix() { - if [[ "${HAS_AUTO_FIX_SUGGESTIONS:-false}" != "true" ]]; then - return 1 - fi - - echo -ne "${PURPLE}${ICON_ARROW}${NC} Auto-fix issues now? ${GRAY}Enter confirm / Space cancel${NC}: " - - local key - if ! key=$(read_key); then - echo "no" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "yes" - echo "" - return 0 - else - echo "no" - echo "" - return 1 - fi -} - -# Perform auto-fixes -# Returns: number of fixes applied -perform_auto_fix() { - local fixed_count=0 - local -a fixed_items=() - - # Ensure sudo access - if ! has_sudo_session; then - if ! ensure_sudo_session "System fixes require admin access"; then - echo -e "${YELLOW}Skipping auto fixes, admin authentication required${NC}" - echo "" - return 0 - fi - fi - - # Fix Firewall - if [[ -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then - echo -e "${BLUE}Enabling Firewall...${NC}" - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then - echo -e "${GREEN}✓${NC} Firewall enabled" - fixed_count=$((fixed_count + 1)) - fixed_items+=("Firewall enabled") - else - echo -e "${RED}✗${NC} Failed to enable Firewall" - fi - echo "" - fi - - # Fix Touch ID - if [[ -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then - echo -e "${BLUE}${ICON_ARROW}${NC} Configuring Touch ID for sudo..." - local pam_file="/etc/pam.d/sudo" - if sudo bash -c "grep -q 'pam_tid.so' '$pam_file' 2>/dev/null || sed -i '' '2i\\ -auth sufficient pam_tid.so -' '$pam_file'" 2> /dev/null; then - echo -e "${GREEN}✓${NC} Touch ID configured" - fixed_count=$((fixed_count + 1)) - fixed_items+=("Touch ID configured for sudo") - else - echo -e "${RED}✗${NC} Failed to configure Touch ID" - fi - echo "" - fi - - # Install Rosetta 2 - if [[ -n "${ROSETTA_NOT_INSTALLED:-}" && "${ROSETTA_NOT_INSTALLED}" == "true" ]]; then - echo -e "${BLUE}Installing Rosetta 2...${NC}" - if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then - echo -e "${GREEN}✓${NC} Rosetta 2 installed" - fixed_count=$((fixed_count + 1)) - fixed_items+=("Rosetta 2 installed") - else - echo -e "${RED}✗${NC} Failed to install Rosetta 2" - fi - echo "" - fi - - if [[ $fixed_count -gt 0 ]]; then - AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issues" - if [[ ${#fixed_items[@]} -gt 0 ]]; then - AUTO_FIX_DETAILS=$(printf '%s\n' "${fixed_items[@]}") - else - AUTO_FIX_DETAILS="" - fi - else - AUTO_FIX_SUMMARY="Auto fixes skipped: No changes were required" - AUTO_FIX_DETAILS="" - fi - export AUTO_FIX_SUMMARY AUTO_FIX_DETAILS - return 0 -} diff --git a/Resources/mole/lib/manage/purge_paths.sh b/Resources/mole/lib/manage/purge_paths.sh index aa34819..8ffb34b 100644 --- a/Resources/mole/lib/manage/purge_paths.sh +++ b/Resources/mole/lib/manage/purge_paths.sh @@ -12,15 +12,13 @@ if [[ -z "${PURGE_TARGETS:-}" ]]; then source "$_MOLE_MANAGE_DIR/../clean/project.sh" fi -# Config file path (use :- to avoid re-declaration if already set) -PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-$HOME/.config/mole/purge_paths}" +# Config file path (prefer the shared project constant when available) +PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-${PURGE_CONFIG_FILE:-$HOME/.config/mole/purge_paths}}" # Ensure config file exists with helpful template ensure_config_template() { if [[ ! -f "$PURGE_PATHS_CONFIG" ]]; then - ensure_user_dir "$(dirname "$PURGE_PATHS_CONFIG")" - cat > "$PURGE_PATHS_CONFIG" << 'EOF' -# Mole Purge Paths - Directories to scan for project artifacts + if ! write_purge_config "# Mole Purge Paths - Directories to scan for project artifacts # Add one path per line (supports ~ for home directory) # Delete all paths or this file to use defaults # @@ -28,7 +26,9 @@ ensure_config_template() { # ~/Documents/MyProjects # ~/Work/ClientA # ~/Work/ClientB -EOF +"; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Could not initialize ${PURGE_PATHS_CONFIG/#$HOME/~}" >&2 + fi fi } diff --git a/Resources/mole/lib/manage/remove.sh b/Resources/mole/lib/manage/remove.sh new file mode 100755 index 0000000..b0515eb --- /dev/null +++ b/Resources/mole/lib/manage/remove.sh @@ -0,0 +1,261 @@ +#!/bin/bash +# Mole self-removal: Homebrew formula, manual binaries, config/cache/logs. +# Extracted from the `mole` dispatcher, which now only routes. + +set -euo pipefail + +if [[ -n "${MOLE_MANAGE_REMOVE_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_MANAGE_REMOVE_LOADED=1 + +# Remove flow (Homebrew + manual + config/cache). +remove_mole() { + local dry_run_mode="${1:-false}" + local test_mode=false + if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then + test_mode=true + fi + + if [[ -t 1 ]]; then + start_inline_spinner "Detecting Mole installations..." + else + echo "Detecting installations..." + fi + + local is_homebrew=false + local brew_cmd="" + local brew_has_mole="false" + local -a manual_installs=() + local -a alias_installs=() + + if [[ "$test_mode" != "true" ]]; then + if command -v brew > /dev/null 2>&1; then + brew_cmd="brew" + elif [[ -x "/opt/homebrew/bin/brew" ]]; then + brew_cmd="/opt/homebrew/bin/brew" + elif [[ -x "/usr/local/bin/brew" ]]; then + brew_cmd="/usr/local/bin/brew" + fi + + if [[ -n "$brew_cmd" ]]; then + if brew_mole_formula_installed "$brew_cmd"; then + brew_has_mole="true" + fi + fi + + if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then + is_homebrew=true + fi + fi + + local found_mole + found_mole="" + if [[ "$test_mode" != "true" ]]; then + found_mole=$(command -v mole 2> /dev/null || true) + if [[ -n "$found_mole" && -f "$found_mole" ]]; then + if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then + manual_installs+=("$found_mole") + fi + fi + fi + + local -a fallback_paths=() + if [[ "$test_mode" == "true" ]]; then + fallback_paths=("$HOME/.local/bin/mole") + else + fallback_paths=( + "/usr/local/bin/mole" + "$HOME/.local/bin/mole" + "/opt/local/bin/mole" + ) + fi + + for path in "${fallback_paths[@]}"; do + if [[ -f "$path" && "$path" != "$found_mole" ]]; then + if [[ ! -L "$path" ]] || ! readlink "$path" | grep -q "Cellar/mole"; then + manual_installs+=("$path") + fi + fi + done + + local found_mo + found_mo="" + if [[ "$test_mode" != "true" ]]; then + found_mo=$(command -v mo 2> /dev/null || true) + if [[ -n "$found_mo" && -f "$found_mo" ]]; then + if [[ ! -L "$found_mo" ]] || ! readlink "$found_mo" | grep -q "Cellar/mole"; then + alias_installs+=("$found_mo") + fi + fi + fi + + local -a alias_fallback=() + if [[ "$test_mode" == "true" ]]; then + alias_fallback=("$HOME/.local/bin/mo") + else + alias_fallback=( + "/usr/local/bin/mo" + "$HOME/.local/bin/mo" + "/opt/local/bin/mo" + ) + fi + + for alias in "${alias_fallback[@]}"; do + if [[ -f "$alias" && "$alias" != "$found_mo" ]]; then + if [[ ! -L "$alias" ]] || ! readlink "$alias" | grep -q "Cellar/mole"; then + alias_installs+=("$alias") + fi + fi + done + + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + printf '\n' + + local manual_count=${#manual_installs[@]} + local alias_count=${#alias_installs[@]} + if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then + printf '%s\n\n' "${YELLOW}No Mole installation detected${NC}" + exit 0 + fi + + # Dry-run mode: show preview and exit without confirmation + if [[ "$dry_run_mode" == "true" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed" + echo "" + echo -e "${YELLOW}Remove Mole${NC}, would delete the following:" + if [[ "$is_homebrew" == "true" ]]; then + echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}" + fi + if [[ ${manual_count:-0} -gt 0 ]]; then + for install in "${manual_installs[@]}"; do + [[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}" + done + fi + if [[ ${alias_count:-0} -gt 0 ]]; then + for alias in "${alias_installs[@]}"; do + [[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}" + done + fi + [[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}" + [[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would move to Trash: $HOME/.config/mole${NC}" + [[ -d "$HOME/Library/Logs/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/Library/Logs/mole${NC}" + + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + + echo -e "${YELLOW}Remove Mole${NC}, will delete the following:" + if [[ "$is_homebrew" == "true" ]]; then + echo " ${ICON_LIST} Mole via Homebrew" + fi + for install in ${manual_installs[@]+"${manual_installs[@]}"} ${alias_installs[@]+"${alias_installs[@]}"}; do + echo " ${ICON_LIST} $install" + done + echo " ${ICON_LIST} ~/.config/mole (to Trash)" + echo " ${ICON_LIST} ~/.cache/mole" + echo " ${ICON_LIST} ~/Library/Logs/mole" + echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: " + + IFS= read -r -s -n1 key || key="" + drain_pending_input # Clean up any escape sequence remnants + case "$key" in + $'\e') + exit 0 + ;; + "" | $'\n' | $'\r') + printf "\r\033[K" # Clear the prompt line + ;; + *) + exit 0 + ;; + esac + + local has_error=false + if [[ "$is_homebrew" == "true" ]]; then + if [[ -z "$brew_cmd" ]]; then + log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." + log_warning "Manual step: brew uninstall --force mole" + exit 1 + fi + + log_info "Attempting to uninstall Mole via Homebrew..." + local brew_uninstall_output + if ! brew_uninstall_output=$("$brew_cmd" uninstall --force mole 2>&1); then + has_error=true + log_error "Homebrew uninstallation failed:" + printf "%s\n" "$brew_uninstall_output" | sed "s/^/${RED} | ${NC}/" >&2 + log_warning "Manual step: ${YELLOW}brew uninstall --force mole${NC}" + echo "" # Add a blank line for readability + else + log_success "Mole uninstalled via Homebrew." + fi + fi + if [[ ${manual_count:-0} -gt 0 ]]; then + for install in "${manual_installs[@]}"; do + if [[ -f "$install" ]]; then + if [[ ! -w "$(dirname "$install")" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! sudo rm -f "$install" 2> /dev/null; then + has_error=true + fi + else + if ! rm -f "$install" 2> /dev/null; then + has_error=true + fi + fi + fi + done + fi + if [[ ${alias_count:-0} -gt 0 ]]; then + for alias in "${alias_installs[@]}"; do + if [[ -f "$alias" ]]; then + if [[ ! -w "$(dirname "$alias")" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! sudo rm -f "$alias" 2> /dev/null; then + has_error=true + fi + else + if ! rm -f "$alias" 2> /dev/null; then + has_error=true + fi + fi + fi + done + fi + if [[ -d "$HOME/.cache/mole" ]]; then + rm -rf "$HOME/.cache/mole" 2> /dev/null || true # SAFE: hardcoded Mole-owned dir, -d guarded + fi + if [[ -d "$HOME/.config/mole" ]]; then + # The config dir holds user-authored state (whitelist, purge config), + # which is the one thing here a reinstall cannot rebuild. Move it to + # Trash so it stays recoverable (#1346); cache and logs around it are + # rebuildable and stay permanent removals. On failure leave it in + # place rather than falling back to deletion. + local config_trash="$HOME/.Trash/mole-config" + local config_trash_n=1 + while [[ -e "$config_trash" || -L "$config_trash" ]]; do + config_trash="$HOME/.Trash/mole-config-$config_trash_n" + config_trash_n=$((config_trash_n + 1)) + done + if ! mkdir -p "$HOME/.Trash" 2> /dev/null || + ! mv -f "$HOME/.config/mole" "$config_trash" 2> /dev/null; then + has_error=true + log_warning "Could not move ~/.config/mole to Trash; left in place" + fi + fi + if [[ -d "$HOME/Library/Logs/mole" ]]; then + rm -rf "$HOME/Library/Logs/mole" 2> /dev/null || true # SAFE: hardcoded Mole-owned dir, -d guarded + fi + + local final_message + if [[ "$has_error" == "true" ]]; then + final_message="${YELLOW}${ICON_ERROR} Mole uninstalled with some errors, thank you for using Mole!${NC}" + else + final_message="${GREEN}${ICON_SUCCESS} Mole uninstalled successfully, thank you for using Mole!${NC}" + fi + printf '\n%s\n\n' "$final_message" + + exit 0 +} diff --git a/Resources/mole/lib/manage/update.sh b/Resources/mole/lib/manage/update.sh old mode 100644 new mode 100755 index 2dc4027..4d61430 --- a/Resources/mole/lib/manage/update.sh +++ b/Resources/mole/lib/manage/update.sh @@ -1,169 +1,1347 @@ #!/bin/bash -# Update Manager -# Unified update execution for all update types +# Mole self-update: version discovery (GitHub + Homebrew), install-channel +# detection, the update-available banner cache, and the update flow itself. +# Extracted from the `mole` dispatcher, which now only routes. +# +# VERSION lives in `mole` (install.sh reads it from there); these functions +# read it at call time, so this file must be sourced after it is set. set -euo pipefail -# Format Homebrew update details for display -format_brew_update_detail() { - local total="${BREW_OUTDATED_COUNT:-0}" - if [[ -z "$total" || "$total" -le 0 ]]; then - return +# The `mole` dispatcher assigns VERSION before sourcing this file, so the +# linter cannot see the assignment from here; declare it as an inherited value. +: "${VERSION:=}" + +if [[ -n "${MOLE_MANAGE_UPDATE_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_MANAGE_UPDATE_LOADED=1 + +curl_download_with_retry() { + local url="$1" + local output_file="$2" + local attempt=1 + local max_attempts=3 + local curl_exit=0 + + while true; do + if curl -fsSL --connect-timeout 10 --max-time 60 "$url" -o "$output_file"; then + return 0 + else + curl_exit=$? + fi + + rm -f "$output_file" 2> /dev/null || true + case "$curl_exit" in + 6 | 7 | 18 | 28 | 35 | 52 | 55 | 56) ;; + *) return "$curl_exit" ;; + esac + + if [[ "$attempt" -ge "$max_attempts" ]]; then + return "$curl_exit" + fi + sleep 1 || return "$curl_exit" + attempt=$((attempt + 1)) + done +} + +_update_lock_path() { + local install_dir="$1" + printf '%s/.mole-update.lock/kernel.lock\n' "$install_dir" +} + +_update_lock_process_start() { + local pid="$1" + LC_ALL=C /bin/ps -p "$pid" -o lstart= 2> /dev/null | + /usr/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | /usr/bin/head -1 +} + +_update_lock_current_shell_pid() { + local variable_name="$1" + local pid_file current_pid="" + pid_file=$(/usr/bin/mktemp /tmp/mole-update-pid.XXXXXX) || return 1 + if ! /bin/sh -c 'printf "%s\n" "$PPID" > "$1"' sh "$pid_file"; then + /bin/rm -f "$pid_file" 2> /dev/null || true # SAFE: exact mktemp-created PID probe file. + return 1 + fi + IFS= read -r current_pid < "$pid_file" || true + /bin/rm -f "$pid_file" 2> /dev/null || true # SAFE: exact mktemp-created PID probe file. + [[ "$current_pid" =~ ^[0-9]+$ ]] || return 1 + printf -v "$variable_name" '%s' "$current_pid" +} + +_update_lock_mode_for_install_dir() { + local install_dir="$1" + local use_sudo=false + [[ -d "$install_dir" && ! -L "$install_dir" ]] || return 1 + + if [[ ${EUID:-0} -eq 0 ]]; then + use_sudo=false + elif [[ -w "$install_dir" ]]; then + use_sudo=false + else + use_sudo=true fi + _update_lock_path_has_unsafe_ancestor "$install_dir" "$use_sudo" && return 1 + printf '%s\n' "$use_sudo" +} - local -a details=() - local formulas="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local casks="${BREW_CASK_OUTDATED_COUNT:-0}" +_update_lock_path_has_unsafe_ancestor() { + local probe="$1" + local use_sudo="$2" + local current_uid owner_uid mode acl_listing + current_uid=$(id -u 2> /dev/null || true) + [[ "$current_uid" =~ ^[0-9]+$ ]] || return 0 + + while true; do + [[ ! -L "$probe" ]] || return 0 + owner_uid=$(/usr/bin/stat -f%u "$probe" 2> /dev/null || true) + mode=$(/usr/bin/stat -f%Lp "$probe" 2> /dev/null || true) + [[ "$owner_uid" =~ ^[0-9]+$ && "$mode" =~ ^[0-7]+$ ]] || return 0 + if [[ "$use_sudo" == "true" || ${EUID:-0} -eq 0 ]]; then + [[ "$owner_uid" -eq 0 ]] || return 0 + (((8#$mode & 0022) == 0)) || return 0 + elif [[ "$owner_uid" -ne 0 && "$owner_uid" -ne "$current_uid" ]]; then + return 0 + else + # A regular updater already writes through this tree. Accept + # group-writable prefixes, but keep world-writable paths closed. + (((8#$mode & 0002) == 0)) || return 0 + fi + acl_listing=$(/bin/ls -lde "$probe" 2> /dev/null) || return 0 + if printf '%s\n' "$acl_listing" | + /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:.*[[:space:]]allow[[:space:]]'; then + return 0 + fi + [[ "$probe" == "/" ]] && break + local parent_probe="${probe%/*}" + [[ "$parent_probe" != "$probe" ]] || return 0 + probe="$parent_probe" + [[ -n "$probe" ]] || probe="/" + done + return 1 +} - ((formulas > 0)) && details+=("${formulas} formula") - ((casks > 0)) && details+=("${casks} cask") +_update_lock_prepare_dir() { + local lock_path="$1" + local use_sudo="$2" + local lock_dir expected_uid owner_uid mode acl_listing + lock_dir=$(dirname "$lock_path") + expected_uid=$(id -u 2> /dev/null || true) + [[ "$expected_uid" =~ ^[0-9]+$ ]] || return 1 - local detail_str="${total} updates" - if ((${#details[@]} > 0)); then - detail_str="$( - IFS=', ' - printf '%s' "${details[*]}" - )" + if [[ ! -e "$lock_dir" && ! -L "$lock_dir" ]]; then + if [[ "$use_sudo" == "true" ]]; then + _update_lock_sudo mkdir -m 0700 "$lock_dir" 2> /dev/null || return 1 + else + mkdir -m 0700 "$lock_dir" 2> /dev/null || return 1 + fi + fi + [[ -d "$lock_dir" && ! -L "$lock_dir" ]] || return 1 + # macOS preserves inherited ACLs across mkdir -m 0700. Clear them before + # opening the kernel lock and verify that no non-mode ACL entry remains. + if [[ "$use_sudo" == "true" ]]; then + _update_lock_sudo /bin/chmod -N "$lock_dir" 2> /dev/null || return 1 + acl_listing=$(_update_lock_sudo /bin/ls -lde "$lock_dir" 2> /dev/null) || return 1 + else + /bin/chmod -N "$lock_dir" 2> /dev/null || return 1 + acl_listing=$(/bin/ls -lde "$lock_dir" 2> /dev/null) || return 1 + fi + if printf '%s\n' "$acl_listing" | /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:'; then + return 1 + fi + if [[ "$use_sudo" == "true" ]]; then + expected_uid=0 fi - printf "%s" "$detail_str" + if [[ "$use_sudo" == "true" ]]; then + owner_uid=$(_update_lock_sudo /usr/bin/stat -f%u "$lock_dir" 2> /dev/null || true) + mode=$(_update_lock_sudo /usr/bin/stat -f%Lp "$lock_dir" 2> /dev/null || true) + else + owner_uid=$(/usr/bin/stat -f%u "$lock_dir" 2> /dev/null || true) + mode=$(/usr/bin/stat -f%Lp "$lock_dir" 2> /dev/null || true) + fi + [[ "$owner_uid" == "$expected_uid" && "$mode" =~ ^[0-7]+$ ]] || return 1 + (((8#$mode & 0077) == 0)) || return 1 } -# Keep for compatibility with existing callers/tests. -format_brew_update_label() { - local detail - detail=$(format_brew_update_detail || true) - [[ -n "$detail" ]] && printf "Homebrew, %s" "$detail" +_update_lock_sudo() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + sudo -n "$@" } -populate_brew_update_counts_if_unset() { - local need_probe=false - [[ -z "${BREW_OUTDATED_COUNT:-}" ]] && need_probe=true - [[ -z "${BREW_FORMULA_OUTDATED_COUNT:-}" ]] && need_probe=true - [[ -z "${BREW_CASK_OUTDATED_COUNT:-}" ]] && need_probe=true +# Whether the installer child will need sudo for this install dir. This MUST +# be at least as broad as install.sh's needs_sudo(), which checks the PARENT +# directory: on a machine where /usr/local/bin is user-writable but /usr/local +# is root's, the old narrower check here said "no sudo", so no pre-auth and no +# keepalive ran, MOLE_ASSUME_SUDO_AUTH stayed 0, and the installer then asked +# interactively at each privileged stretch: two password prompts per slow +# update, with the keepalive never in play. One predicate, one answer. +update_install_requires_sudo() { + local install_dir="$1" + # Mirror install.sh needs_sudo VERBATIM: when the install dir exists, + # its own writability alone decides, and the parent matters only when + # the dir is missing and must be created. The entry script's own file + # permission is deliberately not consulted: the installer replaces it + # through a same-directory temp file and an atomic mv, and a rename + # needs directory write, never target-file write, so a read-only mole + # in a writable dir updates fine without sudo. + if [[ -e "$install_dir" ]]; then + [[ ! -w "$install_dir" ]] + return + fi + local parent_dir + parent_dir="$(dirname "$install_dir")" + [[ ! -w "$parent_dir" ]] +} - if [[ "$need_probe" == "false" ]]; then - return 0 +# The installer, and the lock helpers above, reuse this shell's sudo session +# through `sudo -n`. Ask a real child process whether that session reaches it +# rather than inferring it from a TTY check: the timestamp scope is sudo's +# decision, not ours, and the answer is the only thing that matters here. +_update_sudo_reaches_subprocess() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 fi + /bin/sh -c 'sudo -n true' 2> /dev/null +} - local formula_count="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local cask_count="${BREW_CASK_OUTDATED_COUNT:-0}" +_update_lock_read_owner() { + local lock_path="$1" + local use_sudo="$2" + if [[ "$use_sudo" == "true" ]]; then + _update_lock_sudo /bin/test -f "$lock_path" 2> /dev/null || return 1 + ! _update_lock_sudo /bin/test -L "$lock_path" 2> /dev/null || return 1 + _update_lock_sudo cat "$lock_path" 2> /dev/null + else + [[ -f "$lock_path" && ! -L "$lock_path" ]] || return 1 + cat "$lock_path" 2> /dev/null + fi +} - if command -v brew > /dev/null 2>&1; then - local formula_outdated="" - local cask_outdated="" +_update_lock_remove_control() { + local control_path="$1" + local use_sudo="$2" + local lock_path="$3" + local control_prefix control_suffix + [[ -n "$control_path" ]] || return 0 + control_prefix="$(dirname "$lock_path")/control." + [[ "$control_path" == "$control_prefix"* ]] || return 1 + control_suffix="${control_path#"$control_prefix"}" + [[ -n "$control_suffix" && "$control_suffix" != */* ]] || return 1 + if [[ "$use_sudo" == "true" ]]; then + _update_lock_sudo /bin/test -f "$control_path" 2> /dev/null || return 1 + ! _update_lock_sudo /bin/test -L "$control_path" 2> /dev/null || return 1 + _update_lock_sudo /bin/rm -f "$control_path" 2> /dev/null # SAFE: exact mktemp-created update lock control file. + else + [[ -f "$control_path" && ! -L "$control_path" ]] || return 1 + command rm -f "$control_path" 2> /dev/null # SAFE: exact mktemp-created update lock control file. + fi +} - formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null || true) - cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null || true) +_update_acquire_lock() { + local lock_path="$1" + local use_sudo="${2:-false}" + local control_path holder_pid owner_pid owner_start token owner_value="" attempt=0 + _update_lock_prepare_dir "$lock_path" "$use_sudo" || return 1 + if [[ "$use_sudo" == "true" ]]; then + if _update_lock_sudo /bin/test -e "$lock_path" 2> /dev/null || + _update_lock_sudo /bin/test -L "$lock_path" 2> /dev/null; then + _update_lock_sudo /bin/test -f "$lock_path" 2> /dev/null || return 1 + ! _update_lock_sudo /bin/test -L "$lock_path" 2> /dev/null || return 1 + fi + elif [[ -e "$lock_path" || -L "$lock_path" ]]; then + [[ -f "$lock_path" && ! -L "$lock_path" ]] || return 1 + fi + # Same mutex choice as install.sh: prefer the kernel lock, fall back to an + # atomic mkdir where /usr/bin/lockf was never shipped (#1348). Requiring it + # made `mo update` unusable on every macOS before it existed. + local mutex_dir="" + if [[ ! -x /usr/bin/lockf ]]; then + [[ -x /bin/mkdir ]] || return 1 + mutex_dir="$(dirname "$lock_path")/holder" + fi + _update_lock_current_shell_pid owner_pid || return 1 + owner_start=$(_update_lock_process_start "$owner_pid") + [[ -n "$owner_start" ]] || return 1 + if [[ "$use_sudo" == "true" ]]; then + control_path=$(_update_lock_sudo /usr/bin/mktemp "$(dirname "$lock_path")/control.XXXXXX") || return 1 + else + control_path=$(/usr/bin/mktemp "$(dirname "$lock_path")/control.XXXXXX") || return 1 + fi + token="$owner_pid|$owner_start|${control_path##*.}" - formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}') - cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}') + # shellcheck disable=SC2016 # The lock-holder shell expands these values. + local holder_script=' + token="$1"; owner_pid="$2"; owner_start="$3"; lock_path="$4"; control_path="$5"; mutex_dir="$6" + [ -f "$control_path" ] || exit 1 + if [ -n "$mutex_dir" ] && ! /bin/mkdir "$mutex_dir" 2>/dev/null; then + previous=$(/bin/cat "$lock_path" 2>/dev/null || true) + previous_pid=${previous%%|*} + previous_rest=${previous#*|} + previous_start=${previous_rest%%|*} + owner_gone=1 + if [ -n "$previous_pid" ] && kill -0 "$previous_pid" 2>/dev/null; then + current_start=$(LC_ALL=C /bin/ps -p "$previous_pid" -o lstart= 2>/dev/null | + /usr/bin/sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" | /usr/bin/head -1) + if [ "$current_start" = "$previous_start" ]; then + owner_gone=0 + fi + fi + [ "$owner_gone" = 1 ] || exit 1 + /bin/rmdir "$mutex_dir" 2>/dev/null || exit 1 + /bin/mkdir "$mutex_dir" 2>/dev/null || exit 1 + fi + if ! printf "%s\n" "$token" > "$lock_path"; then + [ -n "$mutex_dir" ] && /bin/rmdir "$mutex_dir" 2>/dev/null + exit 1 + fi + while [ -f "$control_path" ]; do + kill -0 "$owner_pid" 2>/dev/null || break + current_start=$(LC_ALL=C /bin/ps -p "$owner_pid" -o lstart= 2>/dev/null | + /usr/bin/sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" | /usr/bin/head -1) + [ "$current_start" = "$owner_start" ] || break + /bin/sleep 0.1 + done + /bin/rm -f "$control_path" # SAFE: exact mktemp-created update lock control file. + [ -n "$mutex_dir" ] && /bin/rmdir "$mutex_dir" 2>/dev/null + exit 0 + ' + # Spelled out rather than built as a command array: an empty array expanded + # under `set -u` is an unbound-variable error on the bash 3.2 macOS ships, + # and the empty one would have been the mkdir path this exists to enable. + if [[ -n "$mutex_dir" ]]; then + if [[ "$use_sudo" == "true" ]]; then + _update_lock_sudo /bin/sh -c "$holder_script" \ + sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "$mutex_dir" & + else + /bin/sh -c "$holder_script" \ + sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "$mutex_dir" & + fi + elif [[ "$use_sudo" == "true" ]]; then + _update_lock_sudo /usr/bin/lockf -k -s -t 0 -w "$lock_path" /bin/sh -c "$holder_script" \ + sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "" & + else + /usr/bin/lockf -k -s -t 0 -w "$lock_path" /bin/sh -c "$holder_script" \ + sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "" & fi + holder_pid=$! + + while [[ "$attempt" -lt 100 ]]; do + if ! kill -0 "$holder_pid" 2> /dev/null; then + wait "$holder_pid" 2> /dev/null || true + _update_lock_remove_control "$control_path" "$use_sudo" "$lock_path" || true + return 1 + fi + owner_value=$(_update_lock_read_owner "$lock_path" "$use_sudo" || true) + if [[ "$owner_value" == "$token" ]]; then + UPDATE_LOCK_CONTROL="$control_path" + UPDATE_LOCK_HOLDER_PID="$holder_pid" + UPDATE_LOCK_ACQUIRED=true + return 0 + fi + /bin/sleep 0.05 + attempt=$((attempt + 1)) + done + + _update_lock_remove_control "$control_path" "$use_sudo" "$lock_path" || true + wait "$holder_pid" 2> /dev/null || true + return 1 +} - BREW_FORMULA_OUTDATED_COUNT="$formula_count" - BREW_CASK_OUTDATED_COUNT="$cask_count" - BREW_OUTDATED_COUNT="$((formula_count + cask_count))" +_update_release_lock() { + local lock_path="${1:-}" + local use_sudo="${2:-false}" + local control_path="${3:-}" + local holder_pid="${4:-}" + local acquired="${5:-false}" + [[ "$acquired" == "true" ]] || return 0 + _update_lock_remove_control "$control_path" "$use_sudo" "$lock_path" || true + [[ "$holder_pid" =~ ^[0-9]+$ ]] && wait "$holder_pid" 2> /dev/null || true + UPDATE_LOCK_CONTROL="" + UPDATE_LOCK_HOLDER_PID="" + UPDATE_LOCK_ACQUIRED=false } -brew_has_outdated() { - local kind="${1:-formula}" - command -v brew > /dev/null 2>&1 || return 1 +_update_new_install_receipt() { + local prefix="${1:-update}" + printf '%s-%s-%s-%s\n' "$prefix" "$(date +%s)" "$$" "${RANDOM:-0}" +} + +_MOLE_UPDATE_VERIFIED_VERSION="" +_MOLE_UPDATE_VERIFIED_COMMIT="" + +# An installer exit status is not release proof. Re-open the installed +# generation under the same target-adjacent lock and bind its metadata to this +# exact attempt before any success message is shown. +_update_verify_installed_generation() { + local update_ref="$1" + local install_dir="$2" + local config_dir="$3" + local mole_path="$4" + local expected_commit="${5:-}" + local expected_receipt="$6" + + _MOLE_UPDATE_VERIFIED_VERSION="" + _MOLE_UPDATE_VERIFIED_COMMIT="" + + local verification_lock="" + local lock_uses_sudo="false" + lock_uses_sudo=$(_update_lock_mode_for_install_dir "$install_dir") || return 1 + verification_lock=$(_update_lock_path "$install_dir") + _update_acquire_lock "$verification_lock" "$lock_uses_sudo" || return 1 + + local lock_control="$UPDATE_LOCK_CONTROL" + local lock_holder_pid="$UPDATE_LOCK_HOLDER_PID" + local lock_acquired="$UPDATE_LOCK_ACQUIRED" + local verification_status=0 + local installed_channel="" + local installed_receipt="" + local repair_reason="" + installed_channel=$(MOLE_CONFIG_DIR="$config_dir" get_install_channel 2> /dev/null || true) + installed_receipt=$(MOLE_CONFIG_DIR="$config_dir" get_install_receipt 2> /dev/null || true) + repair_reason=$(MOLE_CONFIG_DIR="$config_dir" manual_install_repair_reason 2> /dev/null || true) + + if [[ "$installed_receipt" != "$expected_receipt" || -n "$repair_reason" ]]; then + verification_status=1 + elif [[ "$update_ref" == "main" ]]; then + local installed_commit="" + local version_output="" + local probe_rc=0 + installed_commit=$(MOLE_CONFIG_DIR="$config_dir" get_install_commit 2> /dev/null || true) + version_output=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$mole_path" --version 2> /dev/null) || probe_rc=$? + if [[ "$installed_channel" != "nightly" || $probe_rc -ne 0 || -z "$version_output" ]]; then + verification_status=1 + fi + if [[ $verification_status -eq 0 && "$expected_commit" =~ ^[0-9a-f]{40}$ ]]; then + if [[ ! "$installed_commit" =~ ^[0-9a-f]{7,40}$ || + "${installed_commit:0:7}" != "${expected_commit:0:7}" ]]; then + verification_status=1 + fi + fi + if [[ $verification_status -eq 0 && "$installed_commit" =~ ^[0-9a-f]{7,40}$ ]]; then + _MOLE_UPDATE_VERIFIED_COMMIT="${installed_commit:0:7}" + fi + else + local installed_version="" + local version_output="" + local probe_rc=0 + version_output=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$mole_path" --version 2> /dev/null) || probe_rc=$? + if [[ $probe_rc -eq 0 ]]; then + installed_version=$(printf '%s\n' "$version_output" | awk 'NF {print $NF; exit}') + fi + if [[ "$installed_channel" != "stable" || "$installed_version" != "${update_ref#V}" ]]; then + verification_status=1 + else + _MOLE_UPDATE_VERIFIED_VERSION="$installed_version" + fi + fi + + _update_release_lock "$verification_lock" "$lock_uses_sudo" \ + "$lock_control" "$lock_holder_pid" "$lock_acquired" + return "$verification_status" +} - if [[ "$kind" == "cask" ]]; then - brew outdated --cask --quiet 2> /dev/null | grep -q . +_update_print_verified_success() { + local update_ref="$1" + local success_label="$2" + if [[ "$update_ref" == "main" && -n "$_MOLE_UPDATE_VERIFIED_COMMIT" ]]; then + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to ${success_label}, ${_MOLE_UPDATE_VERIFIED_COMMIT}" + elif [[ "$update_ref" == "main" ]]; then + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to ${success_label}" else - brew outdated --quiet 2> /dev/null | grep -q . + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to ${success_label}, ${_MOLE_UPDATE_VERIFIED_VERSION}" fi } -# Ask user if they want to update -# Returns: 0 if yes, 1 if no -ask_for_updates() { - populate_brew_update_counts_if_unset +# Last-resort self-heal for a failed staged install. The staged path runs +# through local bootstrap code (temp file, registry, exec) that is frozen on +# the user's machine, which is exactly what a broken installed version cannot +# fix by itself (#1297). Streaming install.sh from main straight into bash +# skips all of it, so a server-side install.sh fix reaches every stuck install +# on its next `mo update`. install.sh only dispatches on its final line, and +# pipefail surfaces a truncated download, so a partial script runs nothing. +_update_self_heal_reinstall() { + local assume_sudo="$1" + local update_ref="$2" + local install_dir="$3" + local config_dir="$4" + local mole_path="$5" + local success_label="$6" + local expected_commit="${7:-}" + local install_commit="" + local install_receipt="" + install_receipt=$(_update_new_install_receipt heal) + + if [[ "$expected_commit" =~ ^[0-9a-f]{40}$ ]]; then + install_commit="$expected_commit" + fi - local has_updates=false - if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then - has_updates=true + echo "Retrying with a direct reinstall from GitHub..." + + local heal_output="" + if command -v curl > /dev/null 2>&1; then + heal_output=$( + set -o pipefail + curl -fsSL --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/tw93/mole/main/install.sh" | + MOLE_ASSUME_SUDO_AUTH="$assume_sudo" MOLE_VERSION="$update_ref" \ + MOLE_INSTALL_COMMIT="$install_commit" MOLE_INSTALL_RECEIPT="$install_receipt" \ + bash -s -- --prefix "$install_dir" --config "$config_dir" 2>&1 + ) || { + [[ -n "$heal_output" ]] && printf '%s\n' "$heal_output" | tail -5 >&2 + return 1 + } + elif command -v wget > /dev/null 2>&1; then + heal_output=$( + set -o pipefail + wget --timeout=10 --tries=3 -qO- \ + "https://raw.githubusercontent.com/tw93/mole/main/install.sh" | + MOLE_ASSUME_SUDO_AUTH="$assume_sudo" MOLE_VERSION="$update_ref" \ + MOLE_INSTALL_COMMIT="$install_commit" MOLE_INSTALL_RECEIPT="$install_receipt" \ + bash -s -- --prefix "$install_dir" --config "$config_dir" 2>&1 + ) || { + [[ -n "$heal_output" ]] && printf '%s\n' "$heal_output" | tail -5 >&2 + return 1 + } + else + return 1 fi - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - has_updates=true + _update_verify_installed_generation \ + "$update_ref" "$install_dir" "$config_dir" "$mole_path" \ + "$install_commit" "$install_receipt" || return 1 + _update_print_verified_success "$update_ref" "$success_label" +} + +_update_print_manual_reinstall() { + local update_ref="$1" + local install_dir="$2" + local config_dir="$3" + local quoted_ref quoted_install_dir quoted_config_dir + printf -v quoted_ref '%q' "$update_ref" + printf -v quoted_install_dir '%q' "$install_dir" + printf -v quoted_config_dir '%q' "$config_dir" + printf '%s Reinstall manually: curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | MOLE_VERSION=%s bash -s -- --prefix %s --config %s\n' \ + "${ICON_REVIEW}" "$quoted_ref" "$quoted_install_dir" "$quoted_config_dir" +} + +# Version discovery must report "unknown" by returning empty, never by failing. +# These run inside `latest=$(...)` command substitutions in a shell with +# `set -euo pipefail`, so a nonzero pipeline (curl refused by a flaky proxy, or +# grep finding no match) would kill the whole command before the caller's own +# fallback and error message could run. `mo update` exited 1 with no output at +# all that way. The trailing `|| true` is what keeps the failure recoverable. +get_latest_version() { + curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \ + "https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null | + grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/' || true +} + +get_latest_version_from_github() { + local version + version=$(curl -fsSL --connect-timeout 2 --max-time 3 \ + "https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null | + grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true) + version="${version#v}" + version="${version#V}" + echo "$version" +} + +# Foreground `mo update` version discovery. The single-shot helpers above stay +# fast because the update-available banner calls them on every command; an +# explicit update is worth a bounded retry instead, since the same proxy reset +# that breaks the installer download also breaks this request. +resolve_latest_stable_version() { + local attempt=1 + local max_attempts=3 + local candidate="" + + while true; do + candidate=$(get_latest_version_from_github) + [[ -z "$candidate" ]] && candidate=$(get_latest_version) + if [[ -n "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + [[ "$attempt" -ge "$max_attempts" ]] && return 0 + sleep 1 || return 0 + attempt=$((attempt + 1)) + done +} + +run_brew_command() { + local timeout_seconds="$1" + shift + + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$timeout_seconds" "$@" +} + +run_brew_detect() { + run_brew_command "${MOLE_HOMEBREW_DETECT_TIMEOUT:-2}" "$@" +} + +run_brew_query() { + run_brew_command "${MOLE_HOMEBREW_QUERY_TIMEOUT:-5}" "$@" +} + +brew_mole_formula_installed() { + local brew_cmd="${1:-brew}" + run_brew_detect "$brew_cmd" list mole > /dev/null 2>&1 +} + +get_homebrew_latest_version() { + command -v brew > /dev/null 2>&1 || return 1 + + local line candidate="" + + # Prefer local tap outdated info to avoid notifying before formula is available. + line=$(run_brew_query brew outdated --formula --verbose mole 2> /dev/null | head -1 || true) + if [[ "$line" == *"< "* ]]; then + candidate="${line##*< }" + candidate="${candidate%% *}" fi - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true + # Fallback for environments where outdated output is unavailable. + if [[ -z "$candidate" ]]; then + line=$(run_brew_query brew info mole 2> /dev/null | awk 'NR==1 { print; exit }' || true) + line="${line#==> }" + line="${line#*: }" + if [[ "$line" == stable* ]]; then + candidate=$(printf '%s\n' "$line" | awk '{print $2}') + fi fi - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true + [[ -n "$candidate" ]] && printf '%s\n' "$candidate" +} +resolve_mole_source_path() { + # MOLE_ENTRY_SCRIPT is set by the `mole` entrypoint before this file is + # sourced. Do NOT fall back to BASH_SOURCE[0] first: in here it names this + # lib file, so the update would target lib/manage/update.sh instead of the + # mole binary the user invoked. + local mole_path="${MOLE_ENTRY_SCRIPT:-${BASH_SOURCE[0]:-$0}}" + if [[ "$mole_path" != /* ]]; then + if [[ "$mole_path" == */* ]]; then + mole_path="$(cd "$(dirname "$mole_path")" 2> /dev/null && pwd)/${mole_path##*/}" + else + mole_path=$(command -v "$mole_path" 2> /dev/null || true) + fi fi + [[ -n "$mole_path" ]] && printf '%s\n' "$mole_path" +} - if [[ "$has_updates" == "false" ]]; then - return 1 +manual_install_repair_reason() { + local config_root="${MOLE_CONFIG_DIR:-$SCRIPT_DIR}" + local reason="" + local helper + + if [[ -f "$config_root/.helper_install_incomplete" ]]; then + reason="incomplete helper install" fi - if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then - echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " + for helper in analyze status; do + if [[ ! -x "$config_root/bin/${helper}-go" ]]; then + [[ -n "$reason" ]] && reason+=", " + reason+="missing ${helper}-go" + fi + done + + [[ -n "$reason" ]] && printf '%s\n' "$reason" +} + +is_homebrew_mole_path() { + local mole_path="$1" + local has_brew="$2" + local link_target="" + [[ -n "$mole_path" ]] || return 1 - local key - if ! key=$(read_key); then - echo "skip" + if [[ -L "$mole_path" ]]; then + link_target=$(readlink "$mole_path" 2> /dev/null) || true + if [[ "$link_target" == *"Cellar/mole"* ]]; then + if $has_brew; then + brew_mole_formula_installed brew && return 0 + fi return 1 fi + return 1 + fi - if [[ "$key" == "ENTER" ]]; then - echo "yes" - return 0 + if [[ -f "$mole_path" ]]; then + # Paths are quoted so Homebrew bottle relocation cannot break parsing + # when the prefix contains spaces (e.g. Applite under "Application Support"). + case "$mole_path" in + "/opt/homebrew/bin/mole" | "/usr/local/bin/mole") + if [[ -d "/opt/homebrew/Cellar/mole" ]] || [[ -d "/usr/local/Cellar/mole" ]]; then + if $has_brew; then + brew_mole_formula_installed brew && return 0 + else + return 0 # Cellar exists, probably Homebrew install + fi + fi + ;; + esac + fi + + return 1 +} + +# Install detection (Homebrew vs manual). +# Always follows the invoked Mole script, never PATH, so update and remove act +# on the Mole the user actually ran instead of another copy earlier in PATH. +is_homebrew_install() { + local has_brew=false + if command -v brew > /dev/null 2>&1; then + has_brew=true + fi + + local mole_path + mole_path=$(resolve_mole_source_path || true) + is_homebrew_mole_path "$mole_path" "$has_brew" +} + +get_install_channel() { + # Try user config dir first (matches install.sh behavior), fallback to SCRIPT_DIR + local channel_file="${MOLE_CONFIG_DIR:-$HOME/.config/mole}/install_channel" + if [[ ! -f "$channel_file" ]]; then + channel_file="$SCRIPT_DIR/install_channel" + fi + local channel="stable" + if [[ -f "$channel_file" ]]; then + channel=$(sed -n 's/^CHANNEL=\(.*\)$/\1/p' "$channel_file" | head -1) + fi + case "$channel" in + nightly | dev | stable) printf '%s\n' "$channel" ;; + *) printf 'stable\n' ;; + esac +} + +# Read one field out of the install channel receipt, empty when absent. +# User config dir first (matches install.sh), then the install directory. +_read_install_channel_field() { + local key="$1" + local channel_file="${MOLE_CONFIG_DIR:-$HOME/.config/mole}/install_channel" + if [[ ! -f "$channel_file" ]]; then + channel_file="$SCRIPT_DIR/install_channel" + fi + if [[ -f "$channel_file" ]]; then + sed -n "s/^${key}=\(.*\)$/\1/p" "$channel_file" | head -1 + fi +} + +get_install_commit() { + _read_install_channel_field COMMIT_HASH +} + +get_install_receipt() { + _read_install_channel_field INSTALL_RECEIPT +} + +get_latest_commit_from_github() { + local lookup_scope="${1:-allow-git-fallback}" + local response="" + local sha="" + if command -v curl > /dev/null 2>&1; then + response=$(curl -fsSL --connect-timeout 2 --max-time 3 \ + "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null || true) + elif command -v wget > /dev/null 2>&1; then + response=$(wget --timeout=3 --tries=1 -qO- \ + "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null || true) + fi + sha=$(printf '%s\n' "$response" | + grep '"sha"[[:space:]]*:[[:space:]]*"[0-9a-f]\{40\}"' | head -1 | sed -E 's/.*"sha"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') || sha="" + if [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then + printf '%s\n' "$sha" + return 0 + fi + + # Background notices are best-effort and should not spawn a Git process. + # Explicit update paths retain the bounded fallback below. + if [[ "$lookup_scope" == "api-only" ]]; then + printf '\n' + return 0 + fi + + # The unauthenticated API can be rate-limited even while ordinary GitHub + # access works. Keep the fallback bounded and accept only main's exact SHA. + if command -v git > /dev/null 2>&1; then + # Ignore ambient credential helpers and URL rewrites: this public, + # read-only probe must resolve the literal GitHub remote or fail. + if response=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + /usr/bin/env \ + -u GIT_CONFIG_PARAMETERS \ + -u GIT_EXEC_PATH \ + -u GIT_DIR \ + -u GIT_WORK_TREE \ + -u GIT_COMMON_DIR \ + -u GIT_CONFIG \ + -u GIT_PROXY_COMMAND \ + -u GIT_SSH \ + -u GIT_SSH_COMMAND \ + -u GIT_SSL_NO_VERIFY \ + GIT_TERMINAL_PROMPT=0 \ + GIT_ASKPASS=/usr/bin/false \ + SSH_ASKPASS=/usr/bin/false \ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_COUNT=0 \ + LC_ALL=C \ + git -c credential.helper= -c core.askPass=/usr/bin/false \ + -c protocol.allow=never -c protocol.https.allow=always \ + -c http.sslVerify=true -C / \ + ls-remote https://github.com/tw93/mole.git refs/heads/main \ + 2> /dev/null); then + sha=$(printf '%s\n' "$response" | + awk '$2 == "refs/heads/main" { print $1; exit }') + else + sha="" fi fi + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || sha="" + printf '%s\n' "$sha" +} + +mole_update_message_cache_is_current() { + local msg_cache="$1" + [[ -f "$msg_cache" && -s "$msg_cache" ]] || return 1 + + local mole_path + mole_path=$(resolve_mole_source_path || true) + [[ -n "$mole_path" && -e "$mole_path" ]] || return 0 + + local cache_mtime mole_mtime + cache_mtime=$(get_file_mtime "$msg_cache") + mole_mtime=$(get_file_mtime "$mole_path") + + if [[ "$cache_mtime" =~ ^[0-9]+$ && "$mole_mtime" =~ ^[0-9]+$ && + "$cache_mtime" -gt 0 && "$mole_mtime" -gt 0 && + "$cache_mtime" -lt "$mole_mtime" ]]; then + return 1 + fi + + return 0 +} + +read_update_message_cache() { + local msg_cache="$1" + if mole_update_message_cache_is_current "$msg_cache"; then + cat "$msg_cache" 2> /dev/null || echo "" + else + : > "$msg_cache" 2> /dev/null || true + echo "" + fi +} + +# Background update notice +check_for_updates() { + local msg_cache="$HOME/.cache/mole/update_message" + ensure_user_dir "$(dirname "$msg_cache")" + ensure_user_file "$msg_cache" + + ( + ( + local channel + channel=$(get_install_channel) + + if [[ "$channel" == "nightly" ]]; then + # Nightly: compare commit hashes instead of version numbers + local installed_commit latest_commit + installed_commit=$(get_install_commit) + latest_commit=$(get_latest_commit_from_github api-only) + + if [[ -n "$installed_commit" && -n "$latest_commit" && "${installed_commit:0:7}" != "${latest_commit:0:7}" ]]; then + printf "\nNew nightly commit %s available, run %smo update --nightly%s\n\n" "${latest_commit:0:7}" "$GREEN" "$NC" > "$msg_cache" + else + echo -n > "$msg_cache" + fi + else + local latest + + latest=$(get_latest_version_from_github) + if [[ -z "$latest" ]]; then + latest=$(get_latest_version) + fi + + if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then + if is_homebrew_install; then + # For Homebrew, only notify if the brew tap has the new version available locally + local brew_latest + brew_latest=$(get_homebrew_latest_version || true) + if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then + printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache" + else + echo -n > "$msg_cache" + fi + else + printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache" + fi + else + echo -n > "$msg_cache" + fi + fi + ) > /dev/null 2>&1 < /dev/null & + ) +} + +# UI helpers +show_brand_banner() { + cat << EOF +${GREEN} __ __ _ ${NC} +${GREEN}| \/ | ___ | | ___ ${NC} +${GREEN}| |\/| |/ _ \| |/ _ \\${NC} +${GREEN}| | | | (_) | | __/${NC} ${BLUE}https://mole.fit${NC} +${GREEN}|_| |_|\___/|_|\___|${NC} ${GREEN}${MOLE_TAGLINE}${NC} + +EOF +} + +show_version() { + local os_ver + if command -v sw_vers > /dev/null; then + os_ver=$(sw_vers -productVersion) + else + os_ver="Unknown" + fi + + local arch + arch=$(uname -m) + + local kernel + kernel=$(uname -r) - if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then - echo -e " ${GRAY}${ICON_REVIEW}${NC} Run ${GREEN}brew upgrade${NC} to update" + local sip_status + if command -v csrutil > /dev/null; then + sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown") + sip_status="$(LC_ALL=C tr '[:lower:]' '[:upper:]' <<< "${sip_status:0:1}")${sip_status:1}" + else + sip_status="Unknown" fi - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - echo -e " ${GRAY}${ICON_REVIEW}${NC} Open ${GREEN}System Settings${NC} → ${GREEN}General${NC} → ${GREEN}Software Update${NC}" + + local disk_free + disk_free=$(get_free_space) + + local install_method="Manual" + if is_homebrew_install; then + install_method="Homebrew" fi - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - echo -e " ${GRAY}${ICON_REVIEW}${NC} Open ${GREEN}App Store${NC} → ${GREEN}Updates${NC}" + + local channel + channel=$(get_install_channel) + + # A reader like `mo --version | head -1` closes the pipe after the first + # line; the remaining writes then fail with SIGPIPE and bash prints a + # "write error: Broken pipe" the user never asked for. A closed reader + # means "stop", so stop quietly. + printf '\nMole version %s\n' "$VERSION" 2> /dev/null || return 0 + if [[ "$channel" == "nightly" ]]; then + local commit + commit=$(get_install_commit) + if [[ -n "$commit" ]]; then + printf 'Channel: Nightly (%s)\n' "$commit" 2> /dev/null || return 0 + else + printf 'Channel: Nightly\n' 2> /dev/null || return 0 + fi fi + printf 'macOS: %s\n' "$os_ver" 2> /dev/null || return 0 + printf 'Architecture: %s\n' "$arch" 2> /dev/null || return 0 + printf 'Kernel: %s\n' "$kernel" 2> /dev/null || return 0 + printf 'SIP: %s\n' "$sip_status" 2> /dev/null || return 0 + printf 'Disk Free: %s\n' "$disk_free" 2> /dev/null || return 0 + printf 'Install: %s\n' "$install_method" 2> /dev/null || return 0 + printf 'Shell: %s\n\n' "${SHELL:-Unknown}" 2> /dev/null || return 0 +} - return 1 +show_help() { + show_brand_banner + echo + printf "%s%s%s\n" "$BLUE" "COMMANDS" "$NC" + printf " %s%-28s%s %s\n" "$GREEN" "mo" "$NC" "Main menu" + for entry in "${MOLE_COMMANDS[@]}"; do + local name="${entry%%:*}" + local desc="${entry#*:}" + local display="mo $name" + [[ "$name" == "help" ]] && display="mo --help" + [[ "$name" == "version" ]] && display="mo --version" + printf " %s%-28s%s %s\n" "$GREEN" "$display" "$NC" "$desc" + done + echo + printf " %s%-28s%s %s\n" "$GREEN" "mo clean --dry-run" "$NC" "Preview cleanup" + printf " %s%-28s%s %s\n" "$GREEN" "mo clean --whitelist" "$NC" "Manage protected caches" + + printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" + printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" + printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall" + printf " %s%-28s%s %s\n" "$GREEN" "mo history --json" "$NC" "Export cleanup history" + printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge" + printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup" + printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup" + printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits" + printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" + printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" + printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" + printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" + printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal" + echo + printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" + printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" + echo } -# Perform all pending updates -# Returns: 0 if all succeeded, 1 if some failed -perform_updates() { - # Only handle Mole updates here; Homebrew/App Store/macOS are manual (tips shown in ask_for_updates) - local updated_count=0 - local total_count=0 +# Update flow (Homebrew or installer). +update_mole() ( + local force_update="${1:-false}" + local nightly_update="${2:-false}" + local update_interrupted=false + local sudo_keepalive_pid="" + local UPDATE_LOCK_CONTROL="" + local UPDATE_LOCK_HOLDER_PID="" + local UPDATE_LOCK_ACQUIRED=false + + # Cleanup function for sudo keepalive + _update_cleanup() { + [[ -n "$sudo_keepalive_pid" ]] && _stop_sudo_keepalive "$sudo_keepalive_pid" || true + if [[ -n "${verification_lock:-}" && "$UPDATE_LOCK_ACQUIRED" == "true" ]]; then + _update_release_lock "$verification_lock" "${lock_uses_sudo:-false}" \ + "$UPDATE_LOCK_CONTROL" "$UPDATE_LOCK_HOLDER_PID" "$UPDATE_LOCK_ACQUIRED" + fi + } + trap '_update_cleanup; update_interrupted=true; echo ""; exit 130' INT TERM + trap '_update_cleanup' EXIT + + if is_homebrew_install; then + if [[ "$nightly_update" == "true" ]]; then + local review_icon="${ICON_REVIEW:-⊙}" + log_error "Nightly update is only available for script installations. Homebrew installs follow stable releases." + printf '%s Reinstall via script to use: mo update --nightly\n' "$review_icon" + exit 1 + fi + update_via_homebrew "$VERSION" + exit 0 + fi + + # Resolve the invoked Mole up front so the installer targets this manual + # install, not another mole earlier in PATH. Fail before any download. + local mole_path + if ! mole_path=$(resolve_mole_source_path); then + log_error "Unable to resolve current Mole path" + exit 1 + fi + local install_dir + if ! install_dir="$(cd "$(dirname "$mole_path")" && pwd)"; then + log_error "Unable to resolve current Mole install directory" + exit 1 + fi + local latest="" + local latest_commit="" + local download_label="Downloading latest version..." + local install_label="Installing update..." + local final_success_label="latest version" + local switch_to_stable_channel=false + local repair_install=false + local repair_reason="" + + if [[ "$nightly_update" == "true" ]]; then + latest="main" + download_label="Downloading nightly installer..." + install_label="Installing nightly update..." + final_success_label="nightly build" + + latest_commit=$(get_latest_commit_from_github) + if [[ "$force_update" != "true" ]]; then + if [[ ! "$latest_commit" =~ ^[0-9a-f]{40}$ ]]; then + log_error "Unable to resolve latest nightly commit. No update was installed." + echo -e "${ICON_REVIEW} Check GitHub access and try again." + echo -e "${ICON_REVIEW} To explicitly reinstall anyway: ${GRAY}mo update --nightly --force${NC}" + exit 1 + fi + + local installed_commit + installed_commit=$(get_install_commit) + + if [[ "$installed_commit" =~ ^[0-9a-f]{7,40}$ && "$latest_commit" =~ ^[0-9a-f]{40}$ && + "${installed_commit:0:7}" == "${latest_commit:0:7}" ]]; then + repair_reason=$(manual_install_repair_reason || true) + if [[ -n "$repair_reason" ]]; then + repair_install=true + else + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest nightly, ${latest_commit:0:7}" + echo "" + exit 0 + fi + fi + fi + else + # Announce before resolving, never after. The bounded retry can spend + # ~20s across three rounds on a flaky proxy, and an unannounced wait that + # long reads as a hang, which is the report this retry was added for. + local check_label="Checking for updates..." + if [[ -t 1 ]]; then + start_inline_spinner "$check_label" + else + echo "${check_label%...}" + fi + latest=$(resolve_latest_stable_version) + if [[ -t 1 ]]; then stop_inline_spinner; fi + + if [[ -z "$latest" ]]; then + log_error "Unable to check for updates. Check network connection." + echo -e "${ICON_REVIEW} Check if you can access GitHub, https://github.com" + echo -e "${ICON_REVIEW} Try again with: ${GRAY}mo update${NC}" + exit 1 + fi + if [[ ! "$latest" =~ ^[Vv]?[0-9]+(\.[0-9]+)*$ ]]; then + log_error "Invalid version response: $latest" + echo -e "${ICON_REVIEW} Try again later or use: ${GRAY}mo update --nightly${NC}" + exit 1 + fi - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - echo -e "${BLUE}Updating Mole...${NC}" - local mole_bin="${SCRIPT_DIR}/../../mole" - [[ ! -f "$mole_bin" ]] && mole_bin=$(command -v mole 2> /dev/null || echo "") + local install_channel + install_channel=$(get_install_channel) + if [[ "$install_channel" == "nightly" || "$install_channel" == "dev" ]]; then + switch_to_stable_channel=true + fi - if [[ -x "$mole_bin" ]]; then - if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Mole updated" - reset_mole_cache - updated_count=$((updated_count + 1)) + if [[ "$switch_to_stable_channel" == "true" ]]; then + install_label="Switching to stable channel..." + elif [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then + repair_reason=$(manual_install_repair_reason || true) + if [[ -n "$repair_reason" ]]; then + repair_install=true else - echo -e "${RED}✗${NC} Mole update failed" + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${VERSION}" + echo "" + exit 0 + fi + fi + fi + + if [[ "$repair_install" == "true" ]]; then + download_label="Downloading repair installer..." + install_label="Repairing Mole installation..." + log_warning "Mole installation needs repair: $repair_reason" + fi + + if [[ -t 1 ]]; then + start_inline_spinner "$download_label" + else + echo "${download_label%...}" + fi + + local installer_ref="main" + if [[ "$nightly_update" != "true" ]]; then + installer_ref="V${latest#V}" + fi + local installer_url="https://raw.githubusercontent.com/tw93/mole/${installer_ref}/install.sh" + local tmp_installer + tmp_installer="$(mktemp_file)" || { + log_error "Update failed" + exit 1 + } + + local download_error="" + if command -v curl > /dev/null 2>&1; then + download_error=$(curl_download_with_retry "$installer_url" "$tmp_installer" 2>&1) || { + local curl_exit=$? + if [[ -t 1 ]]; then stop_inline_spinner; fi + rm -f "$tmp_installer" + log_error "Update failed, curl error: $curl_exit" + + case $curl_exit in + 6) echo -e "${ICON_REVIEW} Could not resolve host. Check DNS or network connection." ;; + 7) echo -e "${ICON_REVIEW} Failed to connect. Check network or proxy settings." ;; + 22) echo -e "${ICON_REVIEW} HTTP 404 Not Found. The installer may have moved." ;; + 28) echo -e "${ICON_REVIEW} Connection timed out. Try again or check firewall." ;; + 35 | 56) echo -e "${ICON_REVIEW} TLS connection reset. A local proxy or VPN is likely blocking GitHub." ;; + *) echo -e "${ICON_REVIEW} Check network connection and try again." ;; + esac + echo -e "${ICON_REVIEW} URL: $installer_url" + exit 1 + } + elif command -v wget > /dev/null 2>&1; then + download_error=$(wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url" 2>&1) || { + if [[ -t 1 ]]; then stop_inline_spinner; fi + rm -f "$tmp_installer" + log_error "Update failed, wget error" + echo -e "${ICON_REVIEW} Check network connection and try again." + echo -e "${ICON_REVIEW} URL: $installer_url" + exit 1 + } + else + if [[ -t 1 ]]; then stop_inline_spinner; fi + rm -f "$tmp_installer" + log_error "curl or wget required" + echo -e "${ICON_REVIEW} Install curl with: ${GRAY}brew install curl${NC}" + exit 1 + fi + + if [[ -t 1 ]]; then stop_inline_spinner; fi + chmod +x "$tmp_installer" + + local requires_sudo="false" + if update_install_requires_sudo "$install_dir"; then + requires_sudo="true" + fi + + if [[ "$requires_sudo" == "true" ]]; then + if ! request_sudo_access "Mole update requires admin access"; then + log_error "Update aborted, admin access denied" + rm -f "$tmp_installer" + exit 1 + fi + # Start sudo keepalive to prevent cache expiration during install + sudo_keepalive_pid=$(_start_sudo_keepalive) + + # The installer runs as a child process and reuses this session through + # `sudo -n`. macOS scopes the sudo timestamp to the controlling + # terminal, and falls back to the parent PID when there is none, so in a + # terminal-less run (CI, cron, `ssh` without -t, an editor's shell pane) + # the credential this shell just obtained does not reach any child. + # Probe that handoff instead of guessing from `-t 0`; failing here beats + # failing halfway through a privileged install. + if ! _update_sudo_reaches_subprocess; then + _update_cleanup + rm -f "$tmp_installer" + log_error "Admin access cannot be handed to the installer in this environment" + echo -e "${ICON_REVIEW} Run ${GRAY}mo update${NC} from a terminal, or cache credentials first: ${GRAY}sudo -v && mo update${NC}" + exit 1 + fi + fi + + local installer_assume_sudo_auth="0" + if [[ "$requires_sudo" == "true" ]]; then + installer_assume_sudo_auth="1" + fi + + if [[ -t 1 ]]; then + start_inline_spinner "$install_label" + else + echo "${install_label%...}" + fi + + process_install_output() { + local output="$1" + local fallback_version="$2" + local success_label="$3" + if [[ -t 1 ]]; then stop_inline_spinner; fi + + # One blank line opens the block and nothing adds another inside it. + # The installer lines and the result below are one list; a gap between + # them read as if the update had finished twice. + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + printf '\n' + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi + if [[ -z "$new_version" ]]; then + new_version=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$mole_path" --version 2> /dev/null | awk 'NF {print $NF; exit}' || true) + fi + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" fi + printf '%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to ${success_label}, ${new_version:-unknown}" else - echo -e "${RED}✗${NC} Mole executable not found" + printf '\n' fi - echo "" - total_count=1 + } + + local install_output + local update_tag="V${latest#V}" + local config_dir="${MOLE_CONFIG_DIR:-$SCRIPT_DIR}" + if [[ ! -f "$config_dir/lib/core/common.sh" ]]; then + config_dir="$HOME/.config/mole" fi - if [[ $total_count -eq 0 ]]; then - echo -e "${GRAY}No updates to perform${NC}" - return 0 - elif [[ $updated_count -eq $total_count ]]; then - echo -e "${GREEN}All updates completed, ${updated_count}/${total_count}${NC}" - return 0 + _run_staged_installer() { + local update_ref="$1" + local expected_commit="${2:-}" + shift 2 + local install_receipt="" + install_receipt=$(_update_new_install_receipt update) + + local installer_rc=0 + install_output=$(MOLE_ASSUME_SUDO_AUTH="$installer_assume_sudo_auth" \ + MOLE_VERSION="$update_ref" MOLE_INSTALL_COMMIT="$expected_commit" \ + MOLE_INSTALL_RECEIPT="$install_receipt" \ + "$tmp_installer" --prefix "$install_dir" --config "$config_dir" "$@" 2>&1) || installer_rc=$? + [[ $installer_rc -eq 0 ]] || return "$installer_rc" + + _update_verify_installed_generation \ + "$update_ref" "$install_dir" "$config_dir" "$mole_path" \ + "$expected_commit" "$install_receipt" + } + + _print_failed_installer_output() { + printf '%s\n' "$install_output" | + sed '/Updated to latest version/d; /Already on latest version/d' | + tail -10 >&2 + } + + if [[ "$nightly_update" == "true" ]]; then + if _run_staged_installer "main" "$latest_commit"; then + process_install_output "$install_output" "$latest" "$final_success_label" + else + if [[ -t 1 ]]; then stop_inline_spinner; fi + if ! _update_self_heal_reinstall "$installer_assume_sudo_auth" "main" "$install_dir" "$config_dir" "$mole_path" "$final_success_label" "$latest_commit"; then + rm -f "$tmp_installer" + _update_cleanup + log_error "Nightly update failed" + _print_failed_installer_output + _update_print_manual_reinstall "main" "$install_dir" "$config_dir" + exit 1 + fi + fi + elif [[ "$force_update" == "true" || "$switch_to_stable_channel" == "true" || "$repair_install" == "true" ]]; then + if _run_staged_installer "$update_tag" ""; then + process_install_output "$install_output" "$latest" "$final_success_label" + else + if [[ -t 1 ]]; then stop_inline_spinner; fi + if ! _update_self_heal_reinstall "$installer_assume_sudo_auth" "$update_tag" "$install_dir" "$config_dir" "$mole_path" "$final_success_label"; then + rm -f "$tmp_installer" + _update_cleanup + log_error "Update failed" + _print_failed_installer_output + _update_print_manual_reinstall "$update_tag" "$install_dir" "$config_dir" + exit 1 + fi + fi else - echo -e "${RED}Update failed, ${updated_count}/${total_count}${NC}" - return 1 + if _run_staged_installer "$update_tag" "" --update; then + process_install_output "$install_output" "$latest" "$final_success_label" + else + if _run_staged_installer "$update_tag" ""; then + process_install_output "$install_output" "$latest" "$final_success_label" + else + if [[ -t 1 ]]; then stop_inline_spinner; fi + if ! _update_self_heal_reinstall "$installer_assume_sudo_auth" "$update_tag" "$install_dir" "$config_dir" "$mole_path" "$final_success_label"; then + rm -f "$tmp_installer" + _update_cleanup + log_error "Update failed" + _print_failed_installer_output + _update_print_manual_reinstall "$update_tag" "$install_dir" "$config_dir" + exit 1 + fi + fi + fi fi -} + + rm -f "$tmp_installer" + rm -f "$HOME/.cache/mole/update_message" + + # Cleanup and reset trap + _update_cleanup + trap - INT TERM EXIT +) diff --git a/Resources/mole/lib/manage/whitelist.sh b/Resources/mole/lib/manage/whitelist.sh index 41259ac..9b8cf64 100755 --- a/Resources/mole/lib/manage/whitelist.sh +++ b/Resources/mole/lib/manage/whitelist.sh @@ -4,18 +4,27 @@ set -euo pipefail +optimize_whitelist_pattern_is_retired() { + case "$1" in + dock_refresh | memory_pressure_relief) return 0 ;; + *) return 1 ;; + esac +} + # Get script directory and source dependencies _MOLE_MANAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$_MOLE_MANAGE_DIR/../core/common.sh" source "$_MOLE_MANAGE_DIR/../ui/menu_simple.sh" +source "$_MOLE_MANAGE_DIR/../optimize/catalog.sh" # Config file paths readonly WHITELIST_CONFIG_CLEAN="$HOME/.config/mole/whitelist" readonly WHITELIST_CONFIG_OPTIMIZE="$HOME/.config/mole/whitelist_optimize" readonly WHITELIST_CONFIG_OPTIMIZE_LEGACY="$HOME/.config/mole/whitelist_checks" -# Default whitelist patterns defined in lib/core/common.sh: +# Default / safety whitelist patterns defined in lib/core/base.sh: # - DEFAULT_WHITELIST_PATTERNS +# - SAFETY_WHITELIST_PATTERNS (always merged for clean mode) # - FINDER_METADATA_SENTINEL # Save whitelist patterns to config (defaults to "clean" for legacy callers) @@ -51,6 +60,11 @@ save_whitelist_patterns() { if [[ ${#patterns[@]} -gt 0 ]]; then local -a unique_patterns=() for pattern in "${patterns[@]}"; do + # Optimize also accepts path patterns for diagnostic exclusions, so + # migrate only task IDs that this release explicitly retired. + if [[ "$mode" == "optimize" ]] && optimize_whitelist_pattern_is_retired "$pattern"; then + continue + fi local duplicate="false" if [[ ${#unique_patterns[@]} -gt 0 ]]; then for existing in "${unique_patterns[@]}"; do @@ -78,10 +92,10 @@ get_all_cache_items() { # Format: "display_name|pattern|category" cat << 'EOF' Apple Mail cache|$HOME/Library/Caches/com.apple.mail/*|system_cache -Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/*|ide_cache +Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/build-cache-*/*|ide_cache Gradle daemon processes cache|$HOME/.gradle/daemon/*|ide_cache +Gradle worker cache|$HOME/.gradle/workers/*|ide_cache Xcode DerivedData (build outputs, indexes)|$HOME/Library/Developer/Xcode/DerivedData/*|ide_cache -Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide_cache Xcode internal cache files|$HOME/Library/Caches/com.apple.dt.Xcode/*|ide_cache Xcode iOS device support symbols|$HOME/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*|ide_cache Maven local repository (Java dependencies)|$HOME/.m2/repository/*|ide_cache @@ -93,6 +107,10 @@ VS Code runtime cache|$HOME/Library/Application Support/Code/Cache/*|ide_cache VS Code extension and update cache|$HOME/Library/Application Support/Code/CachedData/*|ide_cache VS Code system cache (Cursor, VSCodium)|$HOME/Library/Caches/com.microsoft.VSCode/*|ide_cache Cursor editor cache|$HOME/Library/Caches/com.todesktop.230313mzl4w4u92/*|ide_cache +LM Studio app cache|$HOME/Library/Caches/com.lmstudio.lmstudio/*|ai_ml_cache +Codex Desktop update staging|$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation|ai_ml_cache +Chrome on-device AI models|$HOME/Library/Application Support/Google/Chrome/OptGuideOnDevice*/*|ai_ml_cache +Chrome optimization guide models|$HOME/Library/Application Support/Google/Chrome/optimization_guide_model_store/*|ai_ml_cache Bazel build cache|$HOME/.cache/bazel/*|compiler_cache Go build cache|$HOME/Library/Caches/go-build/*|compiler_cache Go module cache|$HOME/go/pkg/mod/*|compiler_cache @@ -120,13 +138,15 @@ npm package cache|$HOME/.npm/_cacache/*|package_manager pip Python package cache|$HOME/.cache/pip/*|package_manager uv Python package cache|$HOME/.cache/uv/*|package_manager R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager +tealdeer tldr pages cache|$HOME/Library/Caches/tealdeer/tldr-pages|package_manager Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager Yarn package manager cache|$HOME/.cache/yarn/*|package_manager pnpm package store|$HOME/Library/pnpm/store/*|package_manager -Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager +Composer PHP dependencies cache (legacy)|$HOME/.composer/cache/*|package_manager +Composer PHP dependencies cache|$HOME/Library/Caches/composer/*|package_manager RubyGems cache|$HOME/.gem/cache/*|package_manager -Conda packages cache|$HOME/.conda/pkgs/*|package_manager -Anaconda packages cache|$HOME/anaconda3/pkgs/*|package_manager +Conda package metadata/tarball cache|$HOME/.conda/pkgs|package_manager +Anaconda package metadata/tarball cache|$HOME/anaconda3/pkgs|package_manager PyTorch model cache|$HOME/.cache/torch/*|ai_ml_cache TensorFlow model and dataset cache|$HOME/.cache/tensorflow/*|ai_ml_cache HuggingFace models and datasets|$HOME/.cache/huggingface/*|ai_ml_cache @@ -140,12 +160,15 @@ Firefox browser cache|$HOME/Library/Caches/Firefox/*|browser_cache Brave browser cache|$HOME/Library/Caches/BraveSoftware/Brave-Browser/*|browser_cache Surge proxy cache|$HOME/Library/Caches/com.nssurge.surge-mac/*|network_tools Surge configuration and data|$HOME/Library/Application Support/com.nssurge.surge-mac/*|network_tools -Docker Desktop image cache|$HOME/Library/Containers/com.docker.docker/Data/*|container_cache +Docker BuildX cache|$HOME/.docker/buildx/cache/*|container_cache Podman container cache|$HOME/.local/share/containers/cache/*|container_cache +Tart OCI/IPSW cache|$HOME/.tart/cache|container_cache Font cache|$HOME/Library/Caches/com.apple.FontRegistry/*|system_cache Spotlight metadata cache|$HOME/Library/Caches/com.apple.spotlight/*|system_cache CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache Trash|$HOME/.Trash|system_cache +iOS/iPadOS device firmware (.ipsw) from iTunes/Finder|$HOME/Library/iTunes/*Software Updates/*.ipsw|system_cache +Apple Configurator 2 device firmware (.ipsw)|$HOME/Library/Group Containers/*.group.com.apple.configurator/**/*.ipsw|system_cache EOF # Add FINDER_METADATA with constant reference echo "Finder metadata, .DS_Store|$FINDER_METADATA_SENTINEL|system_cache" @@ -154,19 +177,12 @@ EOF # Get all optimize items with their patterns get_optimize_whitelist_items() { # Format: "display_name|pattern|category" - cat << 'EOF' -macOS Firewall check|firewall|security_check -Gatekeeper check|gatekeeper|security_check -macOS system updates check|check_macos_updates|update_check -Mole updates check|check_mole_update|update_check -Homebrew health check (doctor)|check_brew_health|health_check -SIP status check|check_sip|security_check -FileVault status check|check_filevault|security_check -TouchID sudo check|check_touchid|config_check -Rosetta 2 check|check_rosetta|config_check -Git configuration check|check_git_config|config_check -Login items check|check_login_items|config_check -EOF + local index + for ((index = 0; index < ${#MOLE_OPTIMIZE_ACTIONS[@]}; index++)); do + printf '%s|%s|optimize_task\n' \ + "${MOLE_OPTIMIZE_WHITELIST_NAMES[$index]}" \ + "${MOLE_OPTIMIZE_ACTIONS[$index]}" + done } patterns_equivalent() { @@ -207,16 +223,29 @@ load_whitelist() { patterns+=("$line") done < "$config_file" else + # bash 3.2 (default on macOS) raises "unbound variable" under set -u + # when expanding "${arr[@]}" on an empty array, so gate each branch + # on the array length. patterns stays the local empty default when a + # default list is empty, which the downstream dedupe loop handles. if [[ "$mode" == "clean" ]]; then - patterns=("${DEFAULT_WHITELIST_PATTERNS[@]}") + if [[ ${#DEFAULT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + patterns=("${DEFAULT_WHITELIST_PATTERNS[@]}") + fi elif [[ "$mode" == "optimize" ]]; then - patterns=("${DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]}") + if [[ ${#DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + patterns=("${DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]}") + fi fi fi if [[ ${#patterns[@]} -gt 0 ]]; then local -a unique_patterns=() for pattern in "${patterns[@]}"; do + # Preserve custom diagnostic path patterns; only explicit retired + # task IDs are migrated away. + if [[ "$mode" == "optimize" ]] && optimize_whitelist_pattern_is_retired "$pattern"; then + continue + fi local duplicate="false" if [[ ${#unique_patterns[@]} -gt 0 ]]; then for existing in "${unique_patterns[@]}"; do @@ -229,14 +258,30 @@ load_whitelist() { [[ "$duplicate" == "true" ]] && continue unique_patterns+=("$pattern") done - CURRENT_WHITELIST_PATTERNS=("${unique_patterns[@]}") + if [[ ${#unique_patterns[@]} -gt 0 ]]; then + CURRENT_WHITELIST_PATTERNS=("${unique_patterns[@]}") + WHITELIST_PATTERNS=("${unique_patterns[@]}") + else + CURRENT_WHITELIST_PATTERNS=() + WHITELIST_PATTERNS=() + fi # Migrate legacy optimize config to the new path automatically if [[ "$mode" == "optimize" && "$using_legacy" == "true" && "$config_file" != "$WHITELIST_CONFIG_OPTIMIZE" ]]; then - save_whitelist_patterns "$mode" "${CURRENT_WHITELIST_PATTERNS[@]}" + if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + save_whitelist_patterns "$mode" "${CURRENT_WHITELIST_PATTERNS[@]}" + else + save_whitelist_patterns "$mode" + fi fi else CURRENT_WHITELIST_PATTERNS=() + WHITELIST_PATTERNS=() + fi + + # Hard safety defaults always reach existing clean whitelist files (#1396). + if [[ "$mode" == "clean" ]]; then + ensure_safety_whitelist_patterns fi } @@ -284,7 +329,7 @@ manage_whitelist_categories() { items_source=$(get_optimize_whitelist_items) active_config_file="$WHITELIST_CONFIG_OPTIMIZE" local display_config="${active_config_file/#$HOME/~}" - menu_title="Whitelist Manager, Select system checks to ignore + menu_title="Whitelist Manager, Select optimize tasks to ignore ${GRAY}Edit: ${display_config}${NC}" else items_source=$(get_all_cache_items) @@ -368,13 +413,13 @@ ${GRAY}Edit: ${display_config}${NC}" fi MOLE_SELECTION_RESULT="" - paginated_multi_select "$menu_title" "${menu_options[@]}" + local exit_code=0 + paginated_multi_select "$menu_title" "${menu_options[@]}" || exit_code=$? unset MOLE_PRESELECTED_INDICES - local exit_code=$? - # Normal exit or cancel if [[ $exit_code -ne 0 ]]; then - return 1 + echo -e "${GRAY}Cancelled, no changes saved${NC}" + return 0 fi # Convert selected indices to patterns diff --git a/Resources/mole/lib/optimize/catalog.sh b/Resources/mole/lib/optimize/catalog.sh new file mode 100644 index 0000000..68bc54a --- /dev/null +++ b/Resources/mole/lib/optimize/catalog.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# Canonical optimization task metadata and handler ownership. + +set -euo pipefail + +if [[ -n "${MOLE_OPTIMIZE_CATALOG_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_OPTIMIZE_CATALOG_LOADED=1 + +# The catalog uses aligned arrays instead of serialized records. This keeps +# field boundaries explicit on Bash 3.2 and lets each consumer read only the +# projection it owns. Every registered task is safe for automatic execution. +MOLE_OPTIMIZE_ACTIONS=() +MOLE_OPTIMIZE_HANDLERS=() +MOLE_OPTIMIZE_HEALTH_NAMES=() +MOLE_OPTIMIZE_WHITELIST_NAMES=() +MOLE_OPTIMIZE_DESCRIPTIONS=() +MOLE_OPTIMIZE_SAFE_VALUES=() + +_optimize_catalog_register() { + local index=${#MOLE_OPTIMIZE_ACTIONS[@]} + MOLE_OPTIMIZE_ACTIONS[index]="$1" + MOLE_OPTIMIZE_HANDLERS[index]="$2" + MOLE_OPTIMIZE_HEALTH_NAMES[index]="$3" + MOLE_OPTIMIZE_WHITELIST_NAMES[index]="$4" + MOLE_OPTIMIZE_DESCRIPTIONS[index]="$5" + MOLE_OPTIMIZE_SAFE_VALUES[index]="$6" +} + +_optimize_catalog_register system_maintenance opt_system_maintenance \ + "DNS & Spotlight Check" "DNS & Spotlight Check" \ + "Refresh DNS cache & verify Spotlight status" true +_optimize_catalog_register cache_refresh opt_cache_refresh \ + "Finder Cache Refresh" "Finder Cache Refresh" \ + "Refresh QuickLook thumbnails & icon services cache" true +_optimize_catalog_register saved_state_cleanup opt_saved_state_cleanup \ + "App State Cleanup" "App State Cleanup" \ + "Remove old saved application states (30+ days)" true +_optimize_catalog_register fix_broken_configs opt_fix_broken_configs \ + "Broken Config Repair" "Broken Config Repair" \ + "Fix corrupted preferences files" true +_optimize_catalog_register network_optimization opt_network_optimization \ + "Network Cache Refresh" "Network Cache Refresh" \ + "Optimize DNS cache & restart mDNSResponder" true +_optimize_catalog_register sqlite_vacuum opt_sqlite_vacuum \ + "Database Optimization" "Database Optimization" \ + "Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)" true +_optimize_catalog_register launch_services_rebuild opt_launch_services_rebuild \ + "LaunchServices Repair" "LaunchServices Repair" \ + 'Repair "Open with" menu & file associations' true +_optimize_catalog_register prevent_network_dsstore opt_prevent_network_dsstore \ + "Prevent Finder .DS_Store" "Prevent Finder .DS_Store" \ + "Set a persistent Finder preference to stop writing .DS_Store on SMB/AFP/NFS and USB volumes" true +_optimize_catalog_register legacy_overrides_audit opt_legacy_overrides_audit \ + "Legacy Overrides" "Legacy Overrides" \ + "Remove hidden App Nap and disk-image verification overrides left by old tweak tools" true +_optimize_catalog_register network_stack_optimize opt_network_stack_optimize \ + "Network Stack Refresh" "Network Stack Refresh" \ + "Flush routing table and ARP cache to resolve network issues" true +_optimize_catalog_register disk_permissions_repair opt_disk_permissions_repair \ + "Permission Repair" "Permission Repair" \ + "Fix user directory permission issues" true +_optimize_catalog_register spotlight_index_optimize opt_spotlight_index_optimize \ + "Spotlight Optimization" "Spotlight Optimization" \ + "Rebuild index if search is slow (smart detection)" true +_optimize_catalog_register spotlight_orphan_rules_cleanup opt_prune_spotlight_orphan_rules \ + "Spotlight Orphan Rules" "Spotlight Orphan Rules" \ + "Remove Spotlight search-rule entries for apps that are no longer installed" true +_optimize_catalog_register periodic_maintenance opt_periodic_maintenance \ + "Periodic Maintenance" "Periodic Maintenance" \ + "Run macOS daily/weekly/monthly maintenance scripts if stale" true +_optimize_catalog_register shared_file_list_repair opt_shared_file_list_repair \ + "Shared File Lists" "Shared File Lists" \ + "Repair corrupted Finder favorites and recent documents" true +_optimize_catalog_register disk_verify opt_disk_verify \ + "Disk Health" "Disk Health" \ + "Verify filesystem integrity" true +_optimize_catalog_register login_items_audit opt_login_items_audit \ + "Login Items" "Login Items Audit" \ + "Audit login items for broken entries" true +_optimize_catalog_register quarantine_cleanup opt_quarantine_cleanup \ + "Quarantine Database Cleanup" "Quarantine Database Cleanup" \ + "Clear Gatekeeper download tracking history" true +_optimize_catalog_register launch_agents_cleanup opt_launch_agents_cleanup \ + "Launch Agents Cleanup" "Launch Agents Cleanup" \ + "Remove broken LaunchAgents whose binaries no longer exist" true +_optimize_catalog_register notification_cleanup opt_notification_cleanup \ + "Notifications" "Notifications" \ + "Clean old delivered notifications to reduce database bloat" true +_optimize_catalog_register coreduet_cleanup opt_coreduet_cleanup \ + "Usage Data" "Usage Data" \ + "Clean old usage tracking data" true + +optimize_catalog_index_for() { + local requested_action="$1" + local index + for ((index = 0; index < ${#MOLE_OPTIMIZE_ACTIONS[@]}; index++)); do + if [[ "${MOLE_OPTIMIZE_ACTIONS[$index]}" == "$requested_action" ]]; then + printf '%s\n' "$index" + return 0 + fi + done + return 1 +} + +optimize_catalog_handler_for() { + local index + index=$(optimize_catalog_index_for "$1") || return 1 + printf '%s\n' "${MOLE_OPTIMIZE_HANDLERS[$index]}" +} + +optimize_catalog_health_name_for() { + local index + index=$(optimize_catalog_index_for "$1") || return 1 + printf '%s\n' "${MOLE_OPTIMIZE_HEALTH_NAMES[$index]}" +} + +optimize_catalog_validate() { + local count=${#MOLE_OPTIMIZE_ACTIONS[@]} + if [[ $count -eq 0 ]]; then + echo "Optimize task catalog is empty" >&2 + return 1 + fi + if [[ ${#MOLE_OPTIMIZE_HANDLERS[@]} -ne $count || + ${#MOLE_OPTIMIZE_HEALTH_NAMES[@]} -ne $count || + ${#MOLE_OPTIMIZE_WHITELIST_NAMES[@]} -ne $count || + ${#MOLE_OPTIMIZE_DESCRIPTIONS[@]} -ne $count || + ${#MOLE_OPTIMIZE_SAFE_VALUES[@]} -ne $count ]]; then + echo "Optimize task catalog fields are misaligned" >&2 + return 1 + fi + + local seen_actions="|" + local seen_handlers="|" + local index action handler + for ((index = 0; index < count; index++)); do + action=${MOLE_OPTIMIZE_ACTIONS[$index]} + handler=${MOLE_OPTIMIZE_HANDLERS[$index]} + if [[ ! "$action" =~ ^[a-z0-9_]+$ || ! "$handler" =~ ^opt_[a-z0-9_]+$ ]]; then + echo "Invalid optimize task identity: $action|$handler" >&2 + return 1 + fi + if [[ -z "${MOLE_OPTIMIZE_HEALTH_NAMES[$index]}" || + -z "${MOLE_OPTIMIZE_WHITELIST_NAMES[$index]}" || + -z "${MOLE_OPTIMIZE_DESCRIPTIONS[$index]}" ]]; then + echo "Optimize task metadata is incomplete: $action" >&2 + return 1 + fi + if [[ "${MOLE_OPTIMIZE_SAFE_VALUES[$index]}" != "true" ]]; then + echo "Optimize task is not safe for automatic execution: $action" >&2 + return 1 + fi + if [[ "$seen_actions" == *"|$action|"* ]]; then + echo "Duplicate optimize task action: $action" >&2 + return 1 + fi + if [[ "$seen_handlers" == *"|$handler|"* ]]; then + echo "Duplicate optimize task handler: $handler" >&2 + return 1 + fi + seen_actions+="$action|" + seen_handlers+="$handler|" + done +} + +optimize_catalog_validate +readonly -a MOLE_OPTIMIZE_ACTIONS +readonly -a MOLE_OPTIMIZE_HANDLERS +readonly -a MOLE_OPTIMIZE_HEALTH_NAMES +readonly -a MOLE_OPTIMIZE_WHITELIST_NAMES +readonly -a MOLE_OPTIMIZE_DESCRIPTIONS +readonly -a MOLE_OPTIMIZE_SAFE_VALUES +unset -f _optimize_catalog_register diff --git a/Resources/mole/lib/optimize/diagnostics.sh b/Resources/mole/lib/optimize/diagnostics.sh new file mode 100644 index 0000000..7547458 --- /dev/null +++ b/Resources/mole/lib/optimize/diagnostics.sh @@ -0,0 +1,434 @@ +#!/bin/bash +# Optimize performance diagnosis helpers. + +set -euo pipefail + +readonly MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD_DEFAULT=25 +readonly MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY_DEFAULT=1 + +opt_diag_cpu_threshold() { + local threshold="${MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD:-$MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD_DEFAULT}" + if ! [[ "$threshold" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + threshold="$MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD_DEFAULT" + fi + printf '%s\n' "$threshold" +} + +opt_diag_sample_delay() { + local delay="${MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY:-$MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY_DEFAULT}" + if ! [[ "$delay" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + delay="$MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY_DEFAULT" + fi + printf '%s\n' "$delay" +} + +opt_diag_float_ge() { + local left="${1:-0}" + local right="${2:-0}" + awk -v left="$left" -v right="$right" 'BEGIN { exit !((left + 0) >= (right + 0)) }' +} + +opt_diag_float_gt() { + local left="${1:-0}" + local right="${2:-0}" + awk -v left="$left" -v right="$right" 'BEGIN { exit !((left + 0) > (right + 0)) }' +} + +opt_diag_float_avg() { + local left="${1:-0}" + local right="${2:-0}" + awk -v left="$left" -v right="$right" 'BEGIN { printf "%.1f\n", ((left + 0) + (right + 0)) / 2 }' +} + +opt_diag_get_ps_sample() { + local index="$1" + local override="" + + case "$index" in + 1) override="${MOLE_OPTIMIZE_PS_SAMPLE_1:-}" ;; + 2) override="${MOLE_OPTIMIZE_PS_SAMPLE_2:-}" ;; + esac + + if [[ -n "$override" ]]; then + printf '%s\n' "$override" + return 0 + fi + + ps -Aceo pcpu=,command= 2> /dev/null || true +} + +opt_diag_get_spctl_status() { + if [[ -n "${MOLE_OPTIMIZE_SPCTL_STATUS:-}" ]]; then + printf '%s\n' "$MOLE_OPTIMIZE_SPCTL_STATUS" + return 0 + fi + + spctl --status 2> /dev/null || true +} + +opt_diag_get_hdiutil_info() { + if [[ -n "${MOLE_OPTIMIZE_HDIUTIL_INFO:-}" ]]; then + printf '%s\n' "$MOLE_OPTIMIZE_HDIUTIL_INFO" + return 0 + fi + + run_with_timeout 8 hdiutil info 2> /dev/null || true # 8s: hdiutil info, see lib/core/timeouts.sh +} + +opt_diag_family_totals() { + local raw="${1:-}" + awk ' + function classify(cmd, lower) { + lower = tolower(cmd) + if (lower ~ /cloudshell/ || lower ~ /alientsafe/ || lower ~ /aliedr/) return "cloudshell" + if (lower ~ /(^|\/)syspolicyd([[:space:]]|$)/) return "syspolicyd" + if (lower ~ /(^|\/)windowserver([[:space:]]|$)/) return "windowserver" + if (lower ~ /(^|\/)mds([[:space:]]|$)/ || lower ~ /mdworker/ || lower ~ /mds_stores/ || lower ~ /mdbulkimport/) return "spotlight" + if (lower ~ /diskimagesiod/ || lower ~ /simdiskimaged/) return "coresim_disk_images" + return "" + } + { + cpu = $1 + 0 + $1 = "" + sub(/^[[:space:]]+/, "", $0) + family = classify($0) + if (family != "") sums[family] += cpu + } + END { + printf "cloudshell\t%.1f\n", sums["cloudshell"] + 0 + printf "syspolicyd\t%.1f\n", sums["syspolicyd"] + 0 + printf "windowserver\t%.1f\n", sums["windowserver"] + 0 + printf "spotlight\t%.1f\n", sums["spotlight"] + 0 + printf "coresim_disk_images\t%.1f\n", sums["coresim_disk_images"] + 0 + } + ' <<< "$raw" +} + +opt_diag_family_total_for() { + local totals="${1:-}" + local family="$2" + awk -F '\t' -v family="$family" '$1 == family { print $2; found = 1; exit } END { if (!found) print "0.0" }' <<< "$totals" +} + +opt_diag_family_label() { + case "$1" in + cloudshell) printf '%s\n' "CloudShell / AliEntSafe" ;; + syspolicyd) printf '%s\n' "syspolicyd" ;; + windowserver) printf '%s\n' "WindowServer" ;; + spotlight) printf '%s\n' "Spotlight indexing" ;; + coresim_disk_images) printf '%s\n' "CoreSimulator disk images" ;; + *) printf '%s\n' "$1" ;; + esac +} + +opt_diag_family_note() { + case "$1" in + cloudshell) + printf '%s\n' "External enterprise agent pressure detected. Mole will not terminate enterprise security processes; restart or policy checks must happen outside Mole." + ;; + syspolicyd) + printf '%s\n' "Gatekeeper and code-signature assessment activity is elevated." + ;; + windowserver) + printf '%s\n' "Desktop composition is busy. When another family is higher, treat this as a likely symptom rather than the root cause." + ;; + spotlight) + printf '%s\n' "Metadata indexing or import work is consuming CPU." + ;; + coresim_disk_images) + printf '%s\n' "Simulator runtime disk-image services are active." + ;; + *) + printf '%s\n' "" + ;; + esac +} + +opt_diag_parse_image_mount_pairs() { + local info="${1:-}" + awk ' + function extract_mount(line) { + # Only /dev/disk* lines list real mount points. Other fields like + # image-alias / icon-path / shadow-path may contain absolute paths + # but are not mounts and previously produced phantom detach offers. + if (line !~ /^\/dev\/disk/) { + return "" + } + if (line ~ /[[:space:]]\/.*/) { + sub(/^.*[[:space:]]\//, "/", line) + return line + } + return "" + } + function flush_block( i) { + if (image == "") { + mount_count = 0 + delete mounts + return + } + for (i = 1; i <= mount_count; i++) { + if (mounts[i] != "") { + printf "%s\t%s\n", image, mounts[i] + } + } + mount_count = 0 + delete mounts + } + /^=+$/ { + flush_block() + image = "" + next + } + /^image-path[[:space:]]*:/ { + image = $0 + sub(/^image-path[[:space:]]*:[[:space:]]*/, "", image) + next + } + { + mount = extract_mount($0) + if (mount ~ /^\//) { + mounts[++mount_count] = mount + } + } + END { + flush_block() + } + ' <<< "$info" +} + +opt_diag_is_system_managed_mount() { + local image_path="$1" + local mount_path="$2" + + case "$image_path" in + /System/* | /Library/Apple/* | /private/var/run/com.apple.security.cryptexd/*) + return 0 + ;; + esac + + case "$mount_path" in + /Library/Developer/CoreSimulator/Volumes/* | /private/var/run/com.apple.security.cryptexd/*) + return 0 + ;; + esac + + return 1 +} + +opt_diag_is_mount_detach_candidate() { + local image_path="$1" + local mount_path="$2" + + if opt_diag_is_system_managed_mount "$image_path" "$mount_path"; then + return 1 + fi + + case "$mount_path" in + /Volumes/*) ;; + *) return 1 ;; + esac + + case "$image_path" in + *.dmg | *.iso | *.img | *.cdr | *.sparseimage | *.sparsebundle) ;; + *) return 1 ;; + esac + + if should_protect_path "$mount_path" || is_path_whitelisted "$mount_path"; then + return 1 + fi + if [[ -n "$image_path" ]] && (should_protect_path "$image_path" || is_path_whitelisted "$image_path"); then + return 1 + fi + + return 0 +} + +opt_diag_collect_detach_candidates() { + local pairs="${1:-}" + local image_path mount_path + + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$image_path" || -z "$mount_path" ]] && continue + if opt_diag_is_mount_detach_candidate "$image_path" "$mount_path"; then + printf '%s\t%s\n' "$image_path" "$mount_path" + fi + done <<< "$pairs" +} + +opt_diag_count_matches() { + local pairs="${1:-}" + local mode="$2" + local image_path mount_path count=0 + + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$image_path" || -z "$mount_path" ]] && continue + case "$mode" in + system_managed) + if opt_diag_is_system_managed_mount "$image_path" "$mount_path"; then + count=$((count + 1)) + fi + ;; + coresim_only) + if [[ "$mount_path" == /Library/Developer/CoreSimulator/Volumes/* ]]; then + count=$((count + 1)) + fi + ;; + esac + done <<< "$pairs" + + printf '%s\n' "$count" +} + +opt_diag_detach_candidates() { + local candidates="${1:-}" + local detached=0 + local failed=0 + local image_path mount_path + + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$mount_path" ]] && continue + local safe_mount_path + safe_mount_path=$(mole_terminal_safe_text "$mount_path") + if run_with_timeout 15 hdiutil detach "$mount_path" > /dev/null 2>&1; then # 15s: hdiutil detach, see lib/core/timeouts.sh + detached=$((detached + 1)) + printf ' %b Detached %s\n' "${GREEN}${ICON_SUCCESS}${NC}" "$safe_mount_path" + else + failed=$((failed + 1)) + printf ' %b Failed to detach %s\n' "${YELLOW}${ICON_WARNING}${NC}" "$safe_mount_path" + fi + done <<< "$candidates" + + if [[ $detached -gt 1 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Detached ${detached} mounted images" + fi + if [[ $failed -gt 1 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} ${failed} mounted images still need manual review" + fi +} + +opt_diag_offer_detach_candidates() { + local candidates="${1:-}" + [[ -z "$candidates" ]] && return 0 + + local count=0 + local image_path mount_path + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$mount_path" ]] && continue + count=$((count + 1)) + done <<< "$candidates" + + if [[ "$count" -eq 1 ]]; then + echo -e " ${GRAY}${ICON_LIST}${NC} Mounted image adds assessment overhead:" + else + echo -e " ${GRAY}${ICON_LIST}${NC} Mounted images add assessment overhead:" + fi + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$mount_path" ]] && continue + local safe_image_path safe_mount_path + safe_image_path=$(mole_terminal_safe_text "${image_path##*/}") + safe_mount_path=$(mole_terminal_safe_text "$mount_path") + printf ' %s %b→%b %s\n' "$safe_image_path" "$GRAY" "$NC" "$safe_mount_path" + done <<< "$candidates" + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would offer detach for ${count} mounted image(s)" + return 0 + fi + + if [[ ! -t 1 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Review these mounted images and detach any you no longer need" + return 0 + fi + + echo -ne " ${GRAY}${ICON_REVIEW}${NC} ${YELLOW}Detach now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: " + local key="" + if ! key=$(read_key); then + echo -e "\n ${GRAY}${ICON_WARNING}${NC} Kept mounted, whitelist via ${NC}mo optimize --whitelist${GRAY}${NC}" + return 0 + fi + + if [[ "$key" == "ENTER" ]]; then + echo "" + opt_diag_detach_candidates "$candidates" + else + echo -e "\n ${GRAY}${ICON_WARNING}${NC} Kept mounted, whitelist via ${NC}mo optimize --whitelist${GRAY}${NC}" + fi +} + +run_optimize_diagnostics() { + local sample1 sample2 totals1 totals2 threshold delay + sample1=$(opt_diag_get_ps_sample 1) + delay=$(opt_diag_sample_delay) + if [[ -z "${MOLE_OPTIMIZE_PS_SAMPLE_1:-}" || -z "${MOLE_OPTIMIZE_PS_SAMPLE_2:-}" ]]; then + sleep "$delay" + fi + sample2=$(opt_diag_get_ps_sample 2) + totals1=$(opt_diag_family_totals "$sample1") + totals2=$(opt_diag_family_totals "$sample2") + threshold=$(opt_diag_cpu_threshold) + + echo "" + echo -e "${BLUE}PERFORMANCE DIAGNOSIS${NC}" + + local families="cloudshell syspolicyd windowserver spotlight coresim_disk_images" + local sustained_count=0 + local primary_family="" + local primary_avg="0.0" + local sustained_details="" + local family cpu1 cpu2 avg label + + for family in $families; do + cpu1=$(opt_diag_family_total_for "$totals1" "$family") + cpu2=$(opt_diag_family_total_for "$totals2" "$family") + if opt_diag_float_ge "$cpu1" "$threshold" && opt_diag_float_ge "$cpu2" "$threshold"; then + avg=$(opt_diag_float_avg "$cpu1" "$cpu2") + label=$(opt_diag_family_label "$family") + sustained_count=$((sustained_count + 1)) + sustained_details+="${family}"$'\t'"${avg}"$'\t'"${label}"$'\n' + if [[ -z "$primary_family" ]] || opt_diag_float_gt "$avg" "$primary_avg"; then + primary_family="$family" + primary_avg="$avg" + fi + fi + done + + if [[ -z "$primary_family" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No sustained high-CPU bottleneck detected" + else + label=$(opt_diag_family_label "$primary_family") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Likely bottleneck: ${label} (~${primary_avg}% CPU sustained)" + echo -e " ${GRAY}${ICON_REVIEW}${NC} $(opt_diag_family_note "$primary_family")" + + if [[ $sustained_count -gt 1 ]]; then + echo -e " ${GRAY}${ICON_LIST}${NC} Additional sustained pressure:" + while IFS=$'\t' read -r family avg label; do + [[ -z "$family" || "$family" == "$primary_family" ]] && continue + echo -e " ${GRAY}${label}${NC} ~${avg}%" + done <<< "$sustained_details" + fi + fi + + # Mounted-image checks are scoped to sustained syspolicyd pressure: a + # mounted DMG on an otherwise healthy system is not a diagnosis finding, + # so healthy runs end at the summary line without probing spctl/hdiutil. + if [[ "$primary_family" == "syspolicyd" || "$sustained_details" == *$'syspolicyd\t'* ]]; then + local spctl_status hdiutil_info image_pairs detach_candidates + local managed_count coresim_count detach_count + spctl_status=$(opt_diag_get_spctl_status) + hdiutil_info=$(opt_diag_get_hdiutil_info) + image_pairs=$(opt_diag_parse_image_mount_pairs "$hdiutil_info") + detach_candidates=$(opt_diag_collect_detach_candidates "$image_pairs") + managed_count=$(opt_diag_count_matches "$image_pairs" system_managed) + coresim_count=$(opt_diag_count_matches "$image_pairs" coresim_only) + detach_count=$(printf '%s\n' "$detach_candidates" | awk 'NF { count++ } END { print count + 0 }') + + if [[ -n "$spctl_status" ]]; then + echo -e " ${GRAY}${ICON_LIST}${NC} Gatekeeper status: ${spctl_status}" + fi + if [[ "$managed_count" -gt 0 && "$managed_count" == "$coresim_count" && "$detach_count" -eq 0 ]]; then + echo -e " ${GRAY}${ICON_INFO}${NC} Only system-managed CoreSimulator images are mounted, informational only, not a detach target" + fi + + opt_diag_offer_detach_candidates "$detach_candidates" + fi +} diff --git a/Resources/mole/lib/optimize/maintenance.sh b/Resources/mole/lib/optimize/maintenance.sh index a81c9cf..b0bceb7 100644 --- a/Resources/mole/lib/optimize/maintenance.sh +++ b/Resources/mole/lib/optimize/maintenance.sh @@ -4,50 +4,110 @@ set -euo pipefail +_preference_plist_is_protected() { + local plist_file="$1" + local protect_loginwindow="${2:-false}" + local filename="${plist_file##*/}" + + case "$filename" in + com.apple.* | .GlobalPreferences*) + return 0 + ;; + loginwindow.plist) + [[ "$protect_loginwindow" == "true" ]] + return + ;; + esac + + return 1 +} + +_repair_preference_plists_in_dir() { + local search_dir="$1" + local maxdepth="$2" + local protect_loginwindow="${3:-false}" + [[ -d "$search_dir" ]] || { + echo "0" + return 0 + } + + local -a find_args=("$search_dir") + if [[ "$maxdepth" -gt 0 ]]; then + find_args+=("-maxdepth" "$maxdepth") + fi + find_args+=("-name" "*.plist" "-type" "f") + + local -a candidates=() + local plist_file="" + while IFS= read -r plist_file; do + [[ -f "$plist_file" ]] || continue + _preference_plist_is_protected "$plist_file" "$protect_loginwindow" && continue + candidates+=("$plist_file") + done < <(command find "${find_args[@]}" 2> /dev/null || true) + + # Preferences dirs can hold tens of thousands of plists (leaky test + # suites write one per run), so lint in large batches: plutil exits + # non-zero only when a file in the batch fails, and only failing + # batches pay a per-file fallback pass. Protection checks run on + # broken files only, right before removal. The HINT_SCAN budget keeps + # a pathological tree from making the scan appear hung; returns 1 + # when it stopped early so callers can report partial results. + local broken_count=0 + local total=${#candidates[@]} + local batch_size=512 + local start=0 + local deadline=$((SECONDS + ${MOLE_TIMEOUT_HINT_SCAN_SEC:-15})) + local partial=0 + + while [[ $start -lt $total ]]; do + if [[ $SECONDS -ge $deadline ]]; then + partial=1 + break + fi + local -a batch=("${candidates[@]:start:batch_size}") + start=$((start + batch_size)) + if plutil -lint "${batch[@]}" > /dev/null 2>&1; then + continue + fi + local candidate="" + for candidate in "${batch[@]}"; do + [[ -f "$candidate" ]] || continue + plutil -lint "$candidate" > /dev/null 2>&1 && continue + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$candidate"; then + continue + fi + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$candidate"; then + continue + fi + if safe_remove "$candidate" true > /dev/null 2>&1; then + debug_file_action "Removed corrupted preference" "$candidate" + broken_count=$((broken_count + 1)) + fi + done + done + + echo "$broken_count" + return "$partial" +} + # Remove corrupted preference files. +# Prints the repaired count; returns 1 when a scan stopped at its time +# budget and the count is therefore partial. fix_broken_preferences() { local prefs_dir="$HOME/Library/Preferences" [[ -d "$prefs_dir" ]] || return 0 local broken_count=0 + local repaired_count=0 + local partial=0 - while IFS= read -r plist_file; do - [[ -f "$plist_file" ]] || continue + repaired_count=$(_repair_preference_plists_in_dir "$prefs_dir" 1 true) || partial=1 + broken_count=$((broken_count + repaired_count)) - local filename - filename=$(basename "$plist_file") - case "$filename" in - com.apple.* | .GlobalPreferences* | loginwindow.plist) - continue - ;; - esac - - plutil -lint "$plist_file" > /dev/null 2>&1 && continue - - safe_remove "$plist_file" true > /dev/null 2>&1 || true - broken_count=$((broken_count + 1)) - done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) - - # Check ByHost preferences. - local byhost_dir="$prefs_dir/ByHost" - if [[ -d "$byhost_dir" ]]; then - while IFS= read -r plist_file; do - [[ -f "$plist_file" ]] || continue - - local filename - filename=$(basename "$plist_file") - case "$filename" in - com.apple.* | .GlobalPreferences*) - continue - ;; - esac - - plutil -lint "$plist_file" > /dev/null 2>&1 && continue - - safe_remove "$plist_file" true > /dev/null 2>&1 || true - broken_count=$((broken_count + 1)) - done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) - fi + # Check ByHost preferences recursively. + repaired_count=$(_repair_preference_plists_in_dir "$prefs_dir/ByHost" 0 false) || partial=1 + broken_count=$((broken_count + repaired_count)) echo "$broken_count" + return "$partial" } diff --git a/Resources/mole/lib/optimize/outcomes.sh b/Resources/mole/lib/optimize/outcomes.sh new file mode 100644 index 0000000..dfe9b08 --- /dev/null +++ b/Resources/mole/lib/optimize/outcomes.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# Canonical runtime outcomes for optimize tasks. + +set -euo pipefail + +if [[ -n "${MOLE_OPTIMIZE_OUTCOMES_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_OPTIMIZE_OUTCOMES_LOADED=1 + +# A task reports exactly one of these outcomes during a dispatched run: +# applied: a change completed, or would complete in dry-run mode. +# unchanged: inspection completed and no change was needed. +# skipped: policy or run context intentionally prevented execution. +# unavailable: the host does not provide the required capability. +# attention: inspection completed and found an issue requiring user action. +# failed: an eligible operation could not complete. +readonly MOLE_OPTIMIZE_OUTCOME_APPLIED="applied" +readonly MOLE_OPTIMIZE_OUTCOME_UNCHANGED="unchanged" +readonly MOLE_OPTIMIZE_OUTCOME_SKIPPED="skipped" +readonly MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE="unavailable" +readonly MOLE_OPTIMIZE_OUTCOME_ATTENTION="attention" +readonly MOLE_OPTIMIZE_OUTCOME_FAILED="failed" +readonly -a MOLE_OPTIMIZE_OUTCOME_VALUES=( + "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" + "$MOLE_OPTIMIZE_OUTCOME_ATTENTION" + "$MOLE_OPTIMIZE_OUTCOME_FAILED" +) + +declare -a MOLE_OPTIMIZE_RESULT_ACTIONS=() +declare -a MOLE_OPTIMIZE_RESULT_OUTCOMES=() +MOLE_OPTIMIZE_TASK_ACTIVE=0 +MOLE_OPTIMIZE_TASK_OUTCOME="" + +_optimize_outcome_is_valid() { + local candidate + for candidate in "${MOLE_OPTIMIZE_OUTCOME_VALUES[@]}"; do + [[ "$candidate" == "$1" ]] && return 0 + done + return 1 +} + +optimize_outcomes_reset() { + MOLE_OPTIMIZE_RESULT_ACTIONS=() + MOLE_OPTIMIZE_RESULT_OUTCOMES=() + MOLE_OPTIMIZE_TASK_ACTIVE=0 + MOLE_OPTIMIZE_TASK_OUTCOME="" +} + +optimize_task_start() { + if [[ "$MOLE_OPTIMIZE_TASK_ACTIVE" == "1" ]]; then + echo "Previous optimize task was not finished" >&2 + return 1 + fi + MOLE_OPTIMIZE_TASK_ACTIVE=1 + MOLE_OPTIMIZE_TASK_OUTCOME="" +} + +optimize_task_result() { + local outcome="$1" + + if ! _optimize_outcome_is_valid "$outcome"; then + echo "Invalid optimize task outcome: $outcome" >&2 + return 1 + fi + if [[ "$MOLE_OPTIMIZE_TASK_ACTIVE" != "1" ]]; then + echo "Optimize task was not started" >&2 + return 1 + fi + if [[ -n "$MOLE_OPTIMIZE_TASK_OUTCOME" ]]; then + echo "Optimize task outcome is already set: $MOLE_OPTIMIZE_TASK_OUTCOME" >&2 + return 1 + fi + MOLE_OPTIMIZE_TASK_OUTCOME="$outcome" +} + +# Resolve one task-level outcome from sub-operation counts. Any failed eligible +# operation makes the task failed, even when another sub-operation succeeded. +optimize_task_result_from_counts() { + local applied="$1" + local failed="$2" + local skipped="${3:-0}" + local count + + for count in "$applied" "$failed" "$skipped"; do + if [[ ! "$count" =~ ^[0-9]+$ ]]; then + echo "Invalid optimize task count: $count" >&2 + return 1 + fi + done + + if [[ "$failed" -gt 0 ]]; then + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + elif [[ "$applied" -gt 0 ]]; then + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + elif [[ "$skipped" -gt 0 ]]; then + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + else + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + fi +} + +optimize_task_finish() { + local action="$1" + + if [[ "$MOLE_OPTIMIZE_TASK_ACTIVE" != "1" ]]; then + echo "Optimize task was not started: $action" >&2 + return 1 + fi + if [[ ! "$action" =~ ^[a-z0-9_]+$ ]]; then + echo "Invalid optimize task action: $action" >&2 + return 1 + fi + if [[ -z "$MOLE_OPTIMIZE_TASK_OUTCOME" ]]; then + echo "Optimize task did not report an outcome: $action" >&2 + return 1 + fi + + local existing + if [[ ${#MOLE_OPTIMIZE_RESULT_ACTIONS[@]} -gt 0 ]]; then + for existing in "${MOLE_OPTIMIZE_RESULT_ACTIONS[@]}"; do + if [[ "$existing" == "$action" ]]; then + echo "Optimize task outcome is already recorded: $action" >&2 + return 1 + fi + done + fi + + MOLE_OPTIMIZE_RESULT_ACTIONS+=("$action") + MOLE_OPTIMIZE_RESULT_OUTCOMES+=("$MOLE_OPTIMIZE_TASK_OUTCOME") + MOLE_OPTIMIZE_TASK_ACTIVE=0 + MOLE_OPTIMIZE_TASK_OUTCOME="" +} + +optimize_outcome_count() { + local requested="$1" + if ! _optimize_outcome_is_valid "$requested"; then + echo "Invalid optimize task outcome: $requested" >&2 + return 1 + fi + + local count=0 outcome + if [[ ${#MOLE_OPTIMIZE_RESULT_OUTCOMES[@]} -gt 0 ]]; then + for outcome in "${MOLE_OPTIMIZE_RESULT_OUTCOMES[@]}"; do + if [[ "$outcome" == "$requested" ]]; then + count=$((count + 1)) + fi + done + fi + printf '%s\n' "$count" +} + +optimize_outcome_total() { + printf '%s\n' "${#MOLE_OPTIMIZE_RESULT_ACTIONS[@]}" +} + +optimize_failed_actions() { + local index + if [[ ${#MOLE_OPTIMIZE_RESULT_ACTIONS[@]} -eq 0 ]]; then + return 0 + fi + + for ((index = 0; index < ${#MOLE_OPTIMIZE_RESULT_ACTIONS[@]}; index++)); do + if [[ "${MOLE_OPTIMIZE_RESULT_OUTCOMES[$index]}" == "$MOLE_OPTIMIZE_OUTCOME_FAILED" ]]; then + printf '%s\n' "${MOLE_OPTIMIZE_RESULT_ACTIONS[$index]}" + fi + done +} + +optimize_outcomes_succeeded() { + [[ "$(optimize_outcome_count "$MOLE_OPTIMIZE_OUTCOME_FAILED")" -eq 0 ]] +} diff --git a/Resources/mole/lib/optimize/tasks.sh b/Resources/mole/lib/optimize/tasks.sh index 0f69863..351d58e 100644 --- a/Resources/mole/lib/optimize/tasks.sh +++ b/Resources/mole/lib/optimize/tasks.sh @@ -3,6 +3,16 @@ set -euo pipefail +if [[ -n "${MOLE_OPTIMIZE_TASKS_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_OPTIMIZE_TASKS_LOADED=1 + +_MOLE_OPTIMIZE_TASKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly _MOLE_OPTIMIZE_TASKS_DIR +source "$_MOLE_OPTIMIZE_TASKS_DIR/catalog.sh" +source "$_MOLE_OPTIMIZE_TASKS_DIR/outcomes.sh" + # Config constants (override via env). readonly MOLE_TM_THIN_TIMEOUT=180 readonly MOLE_TM_THIN_VALUE=9999999999 @@ -18,6 +28,46 @@ opt_msg() { fi } +opt_numeric_kb() { + local size_kb="${1:-0}" + [[ "$size_kb" =~ ^[0-9]+$ ]] && echo "$size_kb" || echo "0" +} + +# Whether the current optimize run can use sudo without re-prompting. +# Set by bin/optimize.sh after the upfront ensure_sudo_session call. +# Test-mode env vars hard-deny so ad-hoc task calls under MOLE_TEST_NO_AUTH=1 +# (e.g. ./scripts/test.sh, manual repro) cannot reach a real sudo invocation +# even when this helper is invoked outside the optimize entrypoint. +optimize_sudo_available() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + [[ "${MOLE_OPTIMIZE_SUDO_AVAILABLE:-true}" == "true" ]] +} + +opt_existing_path_size_kb() { + local path="$1" + [[ -e "$path" ]] || { + echo "0" + return 0 + } + + local size_kb=0 + local size_rc=0 + size_kb=$(get_path_size_kb "$path" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 124 || $size_rc -ge 128 ]] && return "$size_rc" + [[ $size_rc -eq 0 ]] || size_kb=0 + opt_numeric_kb "$size_kb" +} + +opt_existing_file_size_kb_strict() { + local path="$1" + local bytes="" + bytes=$($STAT_BSD -f%z "$path" 2> /dev/null) || return 1 + [[ "$bytes" =~ ^[0-9]+$ ]] || return 1 + echo "$(((bytes + 1023) / 1024))" +} + run_launchctl_unload() { local plist_file="$1" local need_sudo="${2:-false}" @@ -27,15 +77,27 @@ run_launchctl_unload() { fi if [[ "$need_sudo" == "true" ]]; then - sudo launchctl unload "$plist_file" 2> /dev/null || true + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 0 + fi + if ! optimize_sudo_available; then + return 0 + fi + local unload_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sudo launchctl \ + unload "$plist_file" 2> /dev/null || unload_rc=$? else - launchctl unload "$plist_file" 2> /dev/null || true + local unload_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" launchctl \ + unload "$plist_file" 2> /dev/null || unload_rc=$? fi + [[ $unload_rc -eq 124 || $unload_rc -ge 128 ]] && return "$unload_rc" + return 0 } needs_permissions_repair() { local owner - owner=$(stat -f %Su "$HOME" 2> /dev/null || echo "") + owner=$($STAT_BSD -f %Su "$HOME" 2> /dev/null || echo "") if [[ -n "$owner" && "$owner" != "$USER" ]]; then return 0 fi @@ -55,32 +117,61 @@ needs_permissions_repair() { return 1 } -has_bluetooth_hid_connected() { - local bt_report - bt_report=$(system_profiler SPBluetoothDataType 2> /dev/null || echo "") - if ! echo "$bt_report" | grep -q "Connected: Yes"; then - return 1 - fi - - if echo "$bt_report" | grep -Eiq "Keyboard|Trackpad|Mouse|HID"; then - return 0 - fi - - return 1 -} - is_ac_power() { pmset -g batt 2> /dev/null | grep -q "AC Power" } -is_memory_pressure_high() { - if ! command -v memory_pressure > /dev/null 2>&1; then - return 1 +# Return 0 when a VPN is active, 1 when probes completed without finding one, +# and 2 when the VPN state could not be determined safely. +has_active_vpn_interface() { + case "${MOLE_ASSUME_VPN_ACTIVE:-}" in + 1 | true | TRUE | yes | YES) + return 0 + ;; + 0 | false | FALSE | no | NO) + return 1 + ;; + esac + + # macOS creates utun* interfaces for many non-VPN features (iCloud + # Private Relay, Continuity, Handoff, AirDrop, Apple Watch sync, Personal + # Hotspot). Bare interface presence therefore over-reports active VPNs and + # caused the Network Stack Refresh skip in #959. Use two narrower signals: + # + # 1. scutil --nc list flags Connected for system-managed VPN connections + # (L2TP, IPsec, IKEv2, Cisco IPSec). + # 2. The default route's interface is utun* when a full-tunnel third-party + # VPN (WireGuard, OpenVPN, Tunnelblick, etc.) is routing all traffic. + # + # Split-tunnel third-party VPNs that do not own the default route will not + # be detected; route flushing may briefly disrupt their explicit routes, + # which the VPN client re-establishes on its next reconcile. + if ! command -v scutil > /dev/null 2>&1; then + return 2 + fi + local scutil_output="" + local scutil_status=0 + scutil_output=$(LC_ALL=C run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" scutil --nc list 2> /dev/null) || scutil_status=$? + if [[ $scutil_status -ne 0 ]]; then + return 2 + fi + if echo "$scutil_output" | grep -Eq '^\* \(Connected\)'; then + return 0 fi - local mp_output - mp_output=$(memory_pressure -Q 2> /dev/null || echo "") - if echo "$mp_output" | grep -Eiq "warning|critical"; then + if ! command -v route > /dev/null 2>&1; then + return 2 + fi + local route_output="" + local route_status=0 + route_output=$(LC_ALL=C run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" route -n get default 2> /dev/null) || route_status=$? + if [[ $route_status -ne 0 ]]; then + return 2 + fi + local default_iface + default_iface=$(printf '%s\n' "$route_output" | + awk -F': ' '$1 ~ /^[[:space:]]*interface$/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}') + if [[ "$default_iface" =~ ^utun[0-9]+$ ]]; then return 0 fi @@ -93,6 +184,10 @@ flush_dns_cache() { return 0 fi + if ! optimize_sudo_available; then + return 1 + fi + if sudo dscacheutil -flushcache 2> /dev/null && sudo killall -HUP mDNSResponder 2> /dev/null; then MOLE_DNS_FLUSHED=1 return 0 @@ -102,76 +197,136 @@ flush_dns_cache() { # Basic system maintenance. opt_system_maintenance() { + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]] && ! optimize_sudo_available; then + opt_msg "DNS & Spotlight check skipped (admin access required)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi + + local dns_flushed="false" if flush_dns_cache; then opt_msg "DNS cache flushed" + dns_flushed="true" fi - local spotlight_status - spotlight_status=$(mdutil -s / 2> /dev/null || echo "") - if echo "$spotlight_status" | grep -qi "Indexing disabled"; then + local spotlight_status="" + local spotlight_failed=0 + if ! spotlight_status=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" mdutil -s / 2> /dev/null); then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to verify Spotlight index" + spotlight_failed=1 + elif echo "$spotlight_status" | grep -qi "Indexing disabled"; then echo -e " ${GRAY}${ICON_EMPTY}${NC} Spotlight indexing disabled" else opt_msg "Spotlight index verified" fi + + local applied=0 + local failed="$spotlight_failed" + [[ "$dns_flushed" == "true" ]] && applied=1 || failed=$((failed + 1)) + optimize_task_result_from_counts "$applied" "$failed" } # Refresh Finder caches (QuickLook/icon services). opt_cache_refresh() { - local total_cache_size=0 + local cleaned_cache_size=0 + local removed_count=0 + local remove_failed=0 + local refresh_failed=0 + local quicklook_refreshed=0 + local icons_refreshed=0 + local -a cache_targets=( + "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" + "$HOME/Library/Caches/com.apple.iconservices.store" + "$HOME/Library/Caches/com.apple.iconservices" + ) if [[ "${MO_DEBUG:-}" == "1" ]]; then debug_operation_start "Finder Cache Refresh" "Refresh QuickLook thumbnails and icon services" debug_operation_detail "Method" "Remove cache files and rebuild via qlmanage" debug_operation_detail "Expected outcome" "Faster Finder preview generation, fixed icon display issues" debug_risk_level "LOW" "Caches are automatically rebuilt" - - local -a cache_targets=( - "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - "$HOME/Library/Caches/com.apple.iconservices.store" - "$HOME/Library/Caches/com.apple.iconservices" - ) - - debug_operation_detail "Files to be removed" "" - for target_path in "${cache_targets[@]}"; do - if [[ -e "$target_path" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") - local size_human="unknown" - if [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))") - fi - debug_file_action " Will remove" "$target_path" "$size_human" "" - fi - done fi - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - qlmanage -r cache > /dev/null 2>&1 || true - qlmanage -r > /dev/null 2>&1 || true + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + quicklook_refreshed=1 + icons_refreshed=1 + else + if qlmanage -r cache > /dev/null 2>&1; then + quicklook_refreshed=1 + else + refresh_failed=$((refresh_failed + 1)) + fi + if qlmanage -r > /dev/null 2>&1; then + icons_refreshed=1 + else + refresh_failed=$((refresh_failed + 1)) + fi fi - local -a cache_targets=( - "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - "$HOME/Library/Caches/com.apple.iconservices.store" - "$HOME/Library/Caches/com.apple.iconservices" - ) + local -a removable_targets=() + local -a removable_sizes=() + local target_path="" for target_path in "${cache_targets[@]}"; do - if [[ -e "$target_path" ]]; then - if ! should_protect_path "$target_path"; then - local size_kb - size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") - if [[ "$size_kb" =~ ^[0-9]+$ ]]; then - total_cache_size=$((total_cache_size + size_kb)) + [[ -e "$target_path" ]] || continue + should_protect_path "$target_path" && continue + + local size_kb=0 + local size_rc=0 + size_kb=$(opt_existing_path_size_kb "$target_path") || size_rc=$? + [[ $size_rc -eq 124 || $size_rc -ge 128 ]] && return "$size_rc" + [[ $size_rc -eq 0 ]] || size_kb=0 + removable_targets+=("$target_path") + removable_sizes+=("$size_kb") + done + + if [[ "${MO_DEBUG:-}" == "1" ]]; then + if [[ ${#removable_targets[@]} -eq 0 ]]; then + debug_operation_detail "Files to be removed" "none" + else + debug_operation_detail "Files to be removed" "" + local index + for index in "${!removable_targets[@]}"; do + local size_human="unknown" + if [[ "${removable_sizes[$index]}" -gt 0 ]]; then + size_human=$(bytes_to_human "$((removable_sizes[index] * 1024))") fi - safe_remove "$target_path" true > /dev/null 2>&1 || true - fi + debug_file_action " Will remove" "${removable_targets[$index]}" "$size_human" "" + done + fi + fi + + local index + for index in "${!removable_targets[@]}"; do + local remove_rc=0 + safe_remove "${removable_targets[$index]}" true \ + "${removable_sizes[$index]}" > /dev/null 2>&1 || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + return "$remove_rc" + elif [[ $remove_rc -eq 0 ]]; then + removed_count=$((removed_count + 1)) + cleaned_cache_size=$((cleaned_cache_size + removable_sizes[index])) + else + remove_failed=$((remove_failed + 1)) fi done - export OPTIMIZE_CACHE_CLEANED_KB="${total_cache_size}" - opt_msg "QuickLook thumbnails refreshed" - opt_msg "Icon services cache rebuilt" + export OPTIMIZE_CACHE_CLEANED_KB="${cleaned_cache_size}" + if [[ $quicklook_refreshed -eq 1 ]]; then + opt_msg "QuickLook thumbnails refreshed" + fi + if [[ $icons_refreshed -eq 1 ]]; then + opt_msg "Icon services cache rebuilt" + fi + if [[ $remove_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to remove $remove_failed Finder cache target(s)" + fi + if [[ $refresh_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to rebuild $refresh_failed Finder cache service(s)" + fi + optimize_task_result_from_counts \ + "$((removed_count + quicklook_refreshed + icons_refreshed))" \ + "$((remove_failed + refresh_failed))" } # Removed: opt_maintenance_scripts - macOS handles log rotation automatically via launchd @@ -189,17 +344,53 @@ opt_saved_state_cleanup() { fi local state_dir="$HOME/Library/Saved Application State" + local removed=0 + local scan_failed=0 + local remove_failed=0 if [[ -d "$state_dir" ]]; then + local scan_file="" + if ! scan_file=$(mktemp_file "optimize-saved-states"); then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to prepare saved state scan" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + local scan_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find "$state_dir" \ + -type d -name "*.savedState" \ + -mtime "+$MOLE_SAVED_STATE_AGE_DAYS" -print0 \ + > "$scan_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + : > "$scan_file" || true + [[ $scan_rc -eq 124 || $scan_rc -ge 128 ]] && return "$scan_rc" + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to scan old saved states" + scan_failed=1 + fi while IFS= read -r -d '' state_path; do if should_protect_path "$state_path"; then continue fi - safe_remove "$state_path" true > /dev/null 2>&1 || true - done < <(command find "$state_dir" -type d -name "*.savedState" -mtime "+$MOLE_SAVED_STATE_AGE_DAYS" -print0 2> /dev/null) + local remove_rc=0 + safe_remove "$state_path" true > /dev/null 2>&1 || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + return "$remove_rc" + elif [[ $remove_rc -eq 0 ]]; then + removed=$((removed + 1)) + else + remove_failed=$((remove_failed + 1)) + fi + done < "$scan_file" fi - opt_msg "App saved states optimized" + if [[ $scan_failed -eq 0 && $remove_failed -eq 0 ]]; then + opt_msg "App saved states optimized" + elif [[ $removed -gt 0 ]]; then + opt_msg "Removed $removed old saved state(s)" + fi + if [[ $remove_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to remove $remove_failed old saved state(s)" + fi + optimize_task_result_from_counts "$removed" "$((scan_failed + remove_failed))" } # Removed: opt_swap_cleanup - Direct virtual memory operations pose system crash risk @@ -209,24 +400,45 @@ opt_saved_state_cleanup() { # Removed: opt_local_snapshots - Deletes user Time Machine recovery points, breaks backup continuity opt_fix_broken_configs() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_start "Broken Config Repair" "Detect and reset corrupted preference files" + debug_operation_detail "Method" "Lint third-party plists in ~/Library/Preferences via plutil and remove corrupted ones" + debug_operation_detail "Expected outcome" "Apps reload with fresh preferences instead of failing on a corrupt plist" + debug_risk_level "LOW" "Apps regenerate their preference files on next launch" + fi + local spinner_started="false" if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking preferences..." spinner_started="true" fi - local broken_prefs=$(fix_broken_preferences) + local broken_prefs="" + local prefs_partial=0 + broken_prefs=$(fix_broken_preferences) || prefs_partial=1 + broken_prefs=${broken_prefs:-0} if [[ "$spinner_started" == "true" ]]; then stop_inline_spinner fi + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_detail "Files repaired" "$broken_prefs" + fi + export OPTIMIZE_CONFIGS_REPAIRED="${broken_prefs}" if [[ $broken_prefs -gt 0 ]]; then - opt_msg "Repaired $broken_prefs corrupted preference files" + if [[ $prefs_partial -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Preference scan hit its time budget, repaired ${broken_prefs:-0} so far" + else + opt_msg "Repaired $broken_prefs corrupted preference files" + fi + elif [[ $prefs_partial -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Preference scan hit its time budget, repaired ${broken_prefs:-0} so far" else opt_msg "All preference files valid" fi + optimize_task_result_from_counts "$broken_prefs" "$prefs_partial" } # DNS cache refresh. @@ -241,14 +453,86 @@ opt_network_optimization() { if [[ "${MOLE_DNS_FLUSHED:-0}" == "1" ]]; then opt_msg "DNS cache already refreshed" opt_msg "mDNSResponder already restarted" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]] && ! optimize_sudo_available; then + opt_msg "Network cache refresh skipped (admin access required)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" return 0 fi if flush_dns_cache; then opt_msg "DNS cache refreshed" opt_msg "mDNSResponder restarted" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" else echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to refresh DNS cache" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + fi +} + +# Quarantine database cleanup (Gatekeeper download history). +opt_quarantine_cleanup() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_start "Quarantine Database Cleanup" "Clear Gatekeeper download tracking history" + debug_operation_detail "Method" "DELETE + VACUUM on QuarantineEventsV2 SQLite database" + debug_operation_detail "Safety" "Only clears download tracking metadata, does not affect file quarantine flags" + debug_operation_detail "Expected outcome" "Reduced database size, cleared download tracking history" + debug_risk_level "LOW" "Database is automatically recreated by macOS" + fi + + if ! command -v sqlite3 > /dev/null 2>&1; then + echo -e " ${GRAY}-${NC} Quarantine cleanup skipped, sqlite3 unavailable" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" + return 0 + fi + + local quarantine_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2" + + if [[ ! -f "$quarantine_db" ]]; then + opt_msg "Quarantine database already clean" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + if should_protect_path "$quarantine_db"; then + opt_msg "Quarantine database already clean" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + # Check if database has any entries worth cleaning. + local row_count="" + local count_status=0 + row_count=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sqlite3 "$quarantine_db" "SELECT COUNT(*) FROM LSQuarantineEvent;" 2> /dev/null) || count_status=$? + + if [[ $count_status -ne 0 || ! "$row_count" =~ ^[0-9]+$ ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect quarantine database" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + if [[ "$row_count" -eq 0 ]]; then + opt_msg "Quarantine database already clean" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + local exit_code=0 + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sqlite3 "$quarantine_db" "DELETE FROM LSQuarantineEvent; VACUUM;" 2> /dev/null || exit_code=$? + + if [[ $exit_code -eq 0 ]]; then + opt_msg "Quarantine history cleared ($row_count entries)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clean quarantine database" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + fi + else + opt_msg "Quarantine history cleared ($row_count entries)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" fi } @@ -262,22 +546,37 @@ opt_sqlite_vacuum() { debug_risk_level "LOW" "Only optimizes databases, does not delete data" fi - if ! command -v sqlite3 > /dev/null 2>&1; then - echo -e " ${GRAY}-${NC} Database optimization already optimal, sqlite3 unavailable" + if ! command -v pgrep > /dev/null 2>&1; then + echo -e " ${GRAY}-${NC} Database optimization unavailable, process probe unavailable" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" return 0 fi local -a busy_apps=() local -a check_apps=("Mail" "Safari" "Messages") - local app + local app probe_status for app in "${check_apps[@]}"; do if pgrep -x "$app" > /dev/null 2>&1; then busy_apps+=("$app") + else + probe_status=$? + if [[ $probe_status -ne 1 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect active apps before database optimization" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi fi done if [[ ${#busy_apps[@]} -gt 0 ]]; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Close these apps before database optimization: ${busy_apps[*]}" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi + + if ! command -v sqlite3 > /dev/null 2>&1; then + echo -e " ${GRAY}-${NC} Database optimization already optimal, sqlite3 unavailable" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" return 0 fi @@ -297,7 +596,11 @@ opt_sqlite_vacuum() { local vacuumed=0 local timed_out=0 local failed=0 - local skipped=0 + local policy_skipped=0 + local already_optimal=0 + # Paths held back only by the size ceiling (issue #1367): never claim + # "all already optimized" when this list is non-empty. + local -a policy_skipped_paths=() for pattern in "${db_paths[@]}"; do while IFS= read -r db_file; do @@ -306,28 +609,38 @@ opt_sqlite_vacuum() { should_protect_path "$db_file" && continue - if ! file "$db_file" 2> /dev/null | grep -q "SQLite"; then - continue - fi + case "$(file -b "$db_file" 2> /dev/null || true)" in + *SQLite*) ;; + *) continue ;; + esac # Skip large DBs (>100MB). local file_size file_size=$(get_file_size "$db_file") if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then - skipped=$((skipped + 1)) + policy_skipped=$((policy_skipped + 1)) + policy_skipped_paths+=("$db_file") continue fi # Skip if freelist is tiny (already compact). local page_info="" - page_info=$(run_with_timeout 5 sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null || echo "") + local page_status=0 + page_info=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null) || page_status=$? + if [[ $page_status -ne 0 ]]; then + failed=$((failed + 1)) + continue + fi local page_count="" local freelist_count="" - page_count=$(echo "$page_info" | awk 'NR==1 {print $1}' 2> /dev/null || echo "") - freelist_count=$(echo "$page_info" | awk 'NR==2 {print $1}' 2> /dev/null || echo "") + page_count="${page_info%%$'\n'*}" + if [[ "$page_info" == *$'\n'* ]]; then + freelist_count="${page_info#*$'\n'}" + freelist_count="${freelist_count%%$'\n'*}" + fi if [[ "$page_count" =~ ^[0-9]+$ && "$freelist_count" =~ ^[0-9]+$ && "$page_count" -gt 0 ]]; then if ((freelist_count * 100 < page_count * 5)); then - skipped=$((skipped + 1)) + already_optimal=$((already_optimal + 1)) continue fi fi @@ -335,23 +648,18 @@ opt_sqlite_vacuum() { # Verify integrity before VACUUM. if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then local integrity_check="" - set +e - integrity_check=$(run_with_timeout 10 sqlite3 "$db_file" "PRAGMA integrity_check;" 2> /dev/null) - local integrity_status=$? - set -e + local integrity_status=0 + integrity_check=$(run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sqlite3 "$db_file" "PRAGMA integrity_check;" 2> /dev/null) || integrity_status=$? - if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then - skipped=$((skipped + 1)) + if [[ $integrity_status -ne 0 || "$integrity_check" != "ok" ]]; then + failed=$((failed + 1)) continue fi fi local exit_code=0 if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - set +e - run_with_timeout 20 sqlite3 "$db_file" "VACUUM;" 2> /dev/null - exit_code=$? - set -e + run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" sqlite3 "$db_file" "VACUUM;" 2> /dev/null || exit_code=$? if [[ $exit_code -eq 0 ]]; then vacuumed=$((vacuumed + 1)) @@ -371,16 +679,37 @@ opt_sqlite_vacuum() { fi export OPTIMIZE_DATABASES_COUNT="${vacuumed}" + # Headline must not say "already optimized" when size policy skipped + # anything, or when nothing was even compact enough to claim success + # (issue #1367). if [[ $vacuumed -gt 0 ]]; then opt_msg "Optimized $vacuumed databases for Mail, Safari, Messages" - elif [[ $timed_out -eq 0 && $failed -eq 0 ]]; then + elif [[ $timed_out -ne 0 || $failed -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Database optimization incomplete" + elif [[ $policy_skipped -gt 0 ]]; then + opt_msg "No databases compacted" + elif [[ $already_optimal -gt 0 ]]; then opt_msg "All databases already optimized" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Database optimization incomplete" + opt_msg "No databases found to optimize" + fi + + if [[ $already_optimal -gt 0 ]]; then + opt_msg "Already optimal for $already_optimal databases" fi - if [[ $skipped -gt 0 ]]; then - opt_msg "Already optimal for $skipped databases" + if [[ $policy_skipped -gt 0 ]]; then + opt_msg "Skipped $policy_skipped databases over the 100 MB safety limit" + local skipped_path skipped_size skipped_display + for skipped_path in "${policy_skipped_paths[@]}"; do + skipped_size=$(get_file_size "$skipped_path" 2> /dev/null || echo 0) + if [[ "$skipped_size" =~ ^[0-9]+$ && "$skipped_size" -gt 0 ]]; then + skipped_display=$(bytes_to_human "$skipped_size") + else + skipped_display="unknown size" + fi + echo -e " ${GRAY}${ICON_SUBLIST}${NC} ${skipped_path/#$HOME/~} · ${skipped_display}" + done fi if [[ $timed_out -gt 0 ]]; then @@ -390,6 +719,8 @@ opt_sqlite_vacuum() { if [[ $failed -gt 0 ]]; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed on $failed databases" fi + + optimize_task_result_from_counts "$vacuumed" "$((timed_out + failed))" "$policy_skipped" } # LaunchServices rebuild ("Open with" issues). @@ -413,15 +744,12 @@ opt_launch_services_rebuild() { local success=0 if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - set +e "$lsregister" -gc > /dev/null 2>&1 || true - "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1 - success=$? + "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1 || success=$? if [[ $success -ne 0 ]]; then - "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 - success=$? + success=0 + "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 || success=$? fi - set -e else success=0 fi @@ -433,131 +761,94 @@ opt_launch_services_rebuild() { if [[ $success -eq 0 ]]; then opt_msg "LaunchServices repaired" opt_msg "File associations refreshed" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" else echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to rebuild LaunchServices" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" fi else if [[ -t 1 ]]; then stop_inline_spinner fi echo -e " ${YELLOW}${ICON_WARNING}${NC} lsregister not found" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" fi } -# Font cache rebuild. -opt_font_cache_rebuild() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Font Cache Rebuild" "Clear and rebuild font cache" - debug_operation_detail "Method" "Run atsutil databases -remove" - debug_operation_detail "Safety checks" "Skip when browsers are running to avoid cache rebuild conflicts" - debug_operation_detail "Expected outcome" "Fixed font display issues, removed corrupted font cache" - debug_risk_level "LOW" "System automatically rebuilds font database" - fi - - local success=false +# Removed high-risk optimizations: +# - opt_startup_items_cleanup: Risk of deleting legitimate app helpers +# - opt_dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS +# - opt_system_services_refresh: Risk of data loss when killing system services - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - # Some browsers (notably Firefox) can keep stale GPU/text caches in /var/folders if - # system font databases are reset while browser/helper processes are still running. - local -a running_browsers=() - if pgrep -if "Firefox|org\\.mozilla\\.firefox|firefox-gpu-helper" > /dev/null 2>&1; then - running_browsers+=("Firefox") - fi - - local browser_name - local -a browser_checks=( - "Safari" - "Google Chrome" - "Chromium" - "Brave Browser" - "Microsoft Edge" - "Arc" - "Opera" - "Vivaldi" - "Zen Browser" - "Helium" - ) - for browser_name in "${browser_checks[@]}"; do - if pgrep -ix "$browser_name" > /dev/null 2>&1; then - running_browsers+=("$browser_name") - fi - done +# Network stack reset (route + ARP). +opt_network_stack_optimize() { + local route_flushed="false" + local arp_flushed="false" - if [[ ${#running_browsers[@]} -gt 0 ]]; then - local running_list - running_list=$(printf "%s, " "${running_browsers[@]}") - running_list="${running_list%, }" - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped font cache rebuild because browsers are running: ${running_list}" - echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Quit browsers completely, then rerun optimize if font issues persist${NC}" + local vpn_status=0 + if has_active_vpn_interface; then + vpn_status=0 + else + vpn_status=$? + fi + case "$vpn_status" in + 0) + opt_msg "Network stack refresh skipped, active VPN detected" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" return 0 - fi + ;; + 1) ;; + *) + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect active VPN state" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + ;; + esac - if sudo atsutil databases -remove > /dev/null 2>&1; then - success=true - fi + local route_ok=true + local dns_ok=true + local route_status=0 + local dns_status=0 + + if run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" route -n get default > /dev/null 2>&1; then + route_status=0 else - success=true + route_status=$? fi - - if [[ "$success" == "true" ]]; then - opt_msg "Font cache cleared" - opt_msg "System will rebuild font database automatically" + if run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" dscacheutil -q host -a name "example.com" > /dev/null 2>&1; then + dns_status=0 else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clear font cache" + dns_status=$? fi -} - -# Removed high-risk optimizations: -# - opt_startup_items_cleanup: Risk of deleting legitimate app helpers -# - opt_dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS -# - opt_system_services_refresh: Risk of data loss when killing system services -# Memory pressure relief. -opt_memory_pressure_relief() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Memory Pressure Relief" "Release inactive memory if pressure is high" - debug_operation_detail "Method" "Run purge command to clear inactive memory" - debug_operation_detail "Condition" "Only runs if memory pressure is warning/critical" - debug_operation_detail "Expected outcome" "More available memory, improved responsiveness" - debug_risk_level "LOW" "Safe system command, does not affect active processes" + if [[ $route_status -eq 124 || $dns_status -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Network health check timed out" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + if [[ $route_status -gt 1 || $dns_status -gt 1 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect network health" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 fi - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! is_memory_pressure_high; then - opt_msg "Memory pressure already optimal" - return 0 - fi - - if sudo purge > /dev/null 2>&1; then - opt_msg "Inactive memory released" - opt_msg "System responsiveness improved" - else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to release memory pressure" - fi - else - opt_msg "Inactive memory released" - opt_msg "System responsiveness improved" + if [[ $route_status -ne 0 ]]; then + route_ok=false + fi + if [[ $dns_status -ne 0 ]]; then + dns_ok=false fi -} -# Network stack reset (route + ARP). -opt_network_stack_optimize() { - local route_flushed="false" - local arp_flushed="false" + if [[ "$route_ok" == "true" && "$dns_ok" == "true" ]]; then + opt_msg "Network stack already optimal" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - local route_ok=true - local dns_ok=true - - if ! route -n get default > /dev/null 2>&1; then - route_ok=false - fi - if ! dscacheutil -q host -a name "example.com" > /dev/null 2>&1; then - dns_ok=false - fi - - if [[ "$route_ok" == "true" && "$dns_ok" == "true" ]]; then - opt_msg "Network stack already optimal" + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Network stack refresh · skipped (admin access required)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" return 0 fi @@ -573,17 +864,25 @@ opt_network_stack_optimize() { arp_flushed="true" fi + local applied=0 + local failed=0 if [[ "$route_flushed" == "true" ]]; then opt_msg "Network routing table refreshed" + applied=$((applied + 1)) + else + failed=$((failed + 1)) fi if [[ "$arp_flushed" == "true" ]]; then opt_msg "ARP cache cleared" + applied=$((applied + 1)) else - if [[ "$route_flushed" == "true" ]]; then - return 0 - fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to optimize network stack" + failed=$((failed + 1)) + fi + + if [[ $failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Network stack refresh incomplete ($failed operation(s) failed)" fi + optimize_task_result_from_counts "$applied" "$failed" } # User directory permissions repair. @@ -599,9 +898,16 @@ opt_disk_permissions_repair() { local user_id user_id=$(id -u) + if ! needs_permissions_repair; then + opt_msg "User directory permissions already optimal" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! needs_permissions_repair; then - opt_msg "User directory permissions already optimal" + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk permissions repair · skipped (admin access required)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" return 0 fi @@ -621,197 +927,961 @@ opt_disk_permissions_repair() { if [[ "$success" == "true" ]]; then opt_msg "User directory permissions repaired" opt_msg "File access issues resolved" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" else echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to repair permissions, may not be needed" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" fi else opt_msg "User directory permissions repaired" opt_msg "File access issues resolved" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" fi } -# Bluetooth reset (skip if HID/audio active). -opt_bluetooth_reset() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Bluetooth Reset" "Restart Bluetooth daemon" - debug_operation_detail "Method" "Kill bluetoothd daemon (auto-restarts)" - debug_operation_detail "Safety" "Skips if active Bluetooth keyboard/mouse/audio detected" - debug_operation_detail "Expected outcome" "Fixed Bluetooth connectivity issues" - debug_risk_level "LOW" "Daemon auto-restarts, connections auto-reconnect" +# Spotlight index check/rebuild (only if slow). +opt_spotlight_index_optimize() { + local spotlight_status="" + local spotlight_status_code=0 + spotlight_status=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" mdutil -s / 2> /dev/null) || spotlight_status_code=$? + + if [[ $spotlight_status_code -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect Spotlight index (exit=$spotlight_status_code)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 fi - local spinner_started="false" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Bluetooth..." - spinner_started="true" + if echo "$spotlight_status" | grep -qi "Indexing disabled"; then + echo -e " ${GRAY}${ICON_EMPTY}${NC} Spotlight indexing is disabled" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 fi - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if has_bluetooth_hid_connected; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" + if echo "$spotlight_status" | grep -qi "Indexing enabled" && ! echo "$spotlight_status" | grep -qi "Indexing and searching disabled"; then + # A rebuild is only offered on AC power, so skip the speed probe on + # battery instead of measuring a result that would be discarded. + if ! is_ac_power; then + opt_msg "Spotlight index already optimal" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" return 0 fi - local bt_audio_active=false - - local audio_info - audio_info=$(system_profiler SPAudioDataType 2> /dev/null || echo "") - - local default_output - default_output=$(echo "$audio_info" | awk '/Default Output Device: Yes/,/^$/' 2> /dev/null || echo "") - - if echo "$default_output" | grep -qi "Transport:.*Bluetooth"; then - bt_audio_active=true - fi - - if [[ "$bt_audio_active" == "false" ]]; then - if system_profiler SPBluetoothDataType 2> /dev/null | grep -q "Connected: Yes"; then - local -a media_apps=("Music" "Spotify" "VLC" "QuickTime Player" "TV" "Podcasts" "Safari" "Google Chrome" "Chrome" "Firefox" "Arc" "IINA" "mpv") - for app in "${media_apps[@]}"; do - if pgrep -x "$app" > /dev/null 2>&1; then - bt_audio_active=true - break - fi - done - fi + local slow_threshold="${MOLE_OPTIMIZE_SPOTLIGHT_SLOW_SEC:-3}" + if [[ ! "$slow_threshold" =~ ^-?[0-9]+$ ]]; then + slow_threshold=3 fi - if [[ "$bt_audio_active" == "true" ]]; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - return 0 + local spinner_started="false" + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Spotlight speed..." + spinner_started="true" fi - if sudo pkill -TERM bluetoothd > /dev/null 2>&1; then - sleep 1 - if pgrep -x bluetoothd > /dev/null 2>&1; then - sudo pkill -KILL bluetoothd > /dev/null 2>&1 || true - fi - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner + local slow_count=0 + local probe_failed=0 + local test_start test_end test_duration probe probe_status + for probe in 1 2; do + test_start=$(get_epoch_seconds) + # A timeout counts as slow: an mdfind that cannot answer within + # the probe ceiling is exactly the sluggishness being measured. + probe_status=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemFSName == 'Applications'" > /dev/null 2>&1 || probe_status=$? + test_end=$(get_epoch_seconds) + test_duration=$((test_end - test_start)) + if [[ $probe_status -eq 124 ]]; then + slow_count=$((slow_count + 1)) + elif [[ $probe_status -ne 0 ]]; then + probe_failed=$((probe_failed + 1)) + elif [[ $test_duration -gt $slow_threshold ]]; then + slow_count=$((slow_count + 1)) fi - opt_msg "Bluetooth module restarted" - opt_msg "Connectivity issues resolved" - else - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner + if [[ "$probe" == "1" ]]; then + sleep 1 fi - opt_msg "Bluetooth already optimal" - fi - else + done + if [[ "$spinner_started" == "true" ]]; then stop_inline_spinner fi - opt_msg "Bluetooth module restarted" - opt_msg "Connectivity issues resolved" - fi -} -# Spotlight index check/rebuild (only if slow). -opt_spotlight_index_optimize() { - local spotlight_status - spotlight_status=$(mdutil -s / 2> /dev/null || echo "") - - if echo "$spotlight_status" | grep -qi "Indexing disabled"; then - echo -e " ${GRAY}${ICON_EMPTY}${NC} Spotlight indexing is disabled" - return 0 - fi - - if echo "$spotlight_status" | grep -qi "Indexing enabled" && ! echo "$spotlight_status" | grep -qi "Indexing and searching disabled"; then - local slow_count=0 - local test_start test_end test_duration - for _ in 1 2; do - test_start=$(get_epoch_seconds) - mdfind "kMDItemFSName == 'Applications'" > /dev/null 2>&1 || true - test_end=$(get_epoch_seconds) - test_duration=$((test_end - test_start)) - if [[ $test_duration -gt 3 ]]; then - slow_count=$((slow_count + 1)) - fi - sleep 1 - done + if [[ $probe_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotlight speed check failed ($probe_failed probe(s))" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi if [[ $slow_count -ge 2 ]]; then - if ! is_ac_power; then - opt_msg "Spotlight index already optimal" - return 0 - fi - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotlight index rebuild · skipped (admin access required)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi echo -e " ${BLUE}${ICON_INFO}${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours" if sudo mdutil -E / > /dev/null 2>&1; then opt_msg "Spotlight index rebuild started" echo -e " ${GRAY}Indexing will continue in background${NC}" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" else echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to rebuild Spotlight index" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" fi else opt_msg "Spotlight index rebuild started" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" fi else opt_msg "Spotlight index already optimal" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" fi else opt_msg "Spotlight index verified" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" fi } -# Dock cache refresh. -opt_dock_refresh() { - local dock_support="$HOME/Library/Application Support/Dock" - local refreshed=false +# Remove orphaned Spotlight search-rule entries. +# Uninstalling an app (especially Mac App Store apps that synced via iCloud) +# can leave its bundle id behind in com.apple.spotlight EnabledPreferenceRules, +# showing up as a dead row in System Settings > Spotlight (#1000). macOS never +# prunes these, so we drop entries whose app is no longer installed. +opt_prune_spotlight_orphan_rules() { + local domain="com.apple.spotlight" + local plist="$HOME/Library/Preferences/${domain}.plist" + + if ! defaults read "$domain" EnabledPreferenceRules &> /dev/null; then + opt_msg "Spotlight search rules already clean" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi - if [[ -d "$dock_support" ]]; then - while IFS= read -r db_file; do - if [[ -f "$db_file" ]]; then - safe_remove "$db_file" true > /dev/null 2>&1 && refreshed=true + local -a keep=() removed=() + local i=0 entry + while entry=$(/usr/libexec/PlistBuddy -c "Print :EnabledPreferenceRules:$i" "$plist" 2> /dev/null); do + case "$entry" in + # Never touch system or Apple rules (e.g. System.iphoneApps); these + # pass the reverse-DNS shape check but are not removable app bundles. + System.* | com.apple.*) + keep+=("$entry") + ;; + *) + # Only act on well-formed bundle ids; bundle_has_installed_app + # double-checks with mdfind and a filesystem scan, so a return of + # 1 means the app is genuinely gone. Anything else is kept. + if ! mole_is_reverse_dns_bundle_id "$entry"; then + keep+=("$entry") + else + local resolver_rc=0 + bundle_has_installed_app "$entry" \ + "$((SECONDS + MOLE_TIMEOUT_MEDIUM_PROBE_SEC))" || resolver_rc=$? + if [[ $resolver_rc -eq 1 ]]; then + removed+=("$entry") + elif [[ $resolver_rc -ge 128 ]]; then + return "$resolver_rc" + else + keep+=("$entry") + fi + fi + ;; + esac + i=$((i + 1)) + done + + if [[ ${#removed[@]} -eq 0 ]]; then + opt_msg "Spotlight search rules already clean" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + opt_msg "Would remove ${#removed[@]} orphan Spotlight rule(s)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + return 0 + fi + + # Rewrite the filtered array through cfprefsd (defaults), not by deleting + # plist indices in place: this avoids the cfprefsd cache overwriting a direct + # file edit, and ensures System Settings reflects the change and it persists. + local write_status=0 + if [[ ${#keep[@]} -gt 0 ]]; then + defaults write "$domain" EnabledPreferenceRules -array "${keep[@]}" 2> /dev/null || write_status=$? + else + defaults delete "$domain" EnabledPreferenceRules 2> /dev/null || write_status=$? + fi + + if [[ $write_status -eq 0 ]]; then + opt_msg "Removed ${#removed[@]} orphan Spotlight rule(s)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to remove orphan Spotlight rules" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + fi +} + +# Prevent .DS_Store on network and USB volumes. +# Idempotent: writes two user defaults that stop Finder from creating +# .DS_Store files on SMB/AFP/NFS shares and removable USB volumes. +# Reversible with: defaults delete com.apple.desktopservices DSDontWrite{Network,USB}Stores +opt_prevent_network_dsstore() { + local domain="com.apple.desktopservices" + local -a keys=("DSDontWriteNetworkStores" "DSDontWriteUSBStores") + local changed=0 + local already=0 + local failed=0 + + for key in "${keys[@]}"; do + local current + current=$(defaults read "$domain" "$key" 2> /dev/null || echo "") + if [[ "$current" == "1" ]]; then + already=$((already + 1)) + continue + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + changed=$((changed + 1)) + continue + fi + + if defaults write "$domain" "$key" -bool true 2> /dev/null; then + changed=$((changed + 1)) + else + failed=$((failed + 1)) + fi + done + + if [[ $changed -eq 0 && $already -gt 0 ]]; then + opt_msg ".DS_Store prevention already enabled on network & USB volumes" + fi + + if [[ $changed -gt 0 ]]; then + opt_msg ".DS_Store prevention enabled on network & USB volumes" + elif [[ $failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable .DS_Store prevention" + fi + if [[ $changed -gt 0 && $failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable .DS_Store prevention for $failed volume type(s)" + fi + optimize_task_result_from_counts "$changed" "$failed" +} + +# Legacy override audit (#1242, #1243): old tweak utilities leave behind +# hidden preferences that silently change safe macOS defaults, and current +# System Settings never surfaces them. Covered overrides: the global App Nap +# kill switch (NSAppSleepDisabled) and the DiskImages skip-verify family. +# Silent when the OS defaults are in effect. Repair deletes only the explicit +# override key, restoring automatic macOS behavior; it never writes a +# replacement preference and never touches the plist file itself. +opt_legacy_overrides_audit() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_start "Legacy Overrides" "Detect App Nap and disk-image verification overrides" + debug_operation_detail "Method" "defaults read -g NSAppSleepDisabled; defaults read com.apple.frameworks.diskimages skip-verify*" + debug_operation_detail "Expected outcome" "Overrides removed so macOS defaults apply again" + debug_risk_level "LOW" "Deletes explicit override keys only; macOS falls back to its default behavior" + fi + + local -a found_labels=() + local -a found_domains=() + local -a found_keys=() + local -a found_plists=() + + _opt_defaults_is_truthy() { + [[ "$1" == "1" || "$1" =~ ^([Tt][Rr][Uu][Ee]|[Yy][Ee][Ss])$ ]] + } + + local value + value=$(defaults read -g NSAppSleepDisabled 2> /dev/null || echo "") + if _opt_defaults_is_truthy "$value"; then + found_labels+=("App Nap disabled globally (NSAppSleepDisabled)") + found_domains+=("-g") + found_keys+=("NSAppSleepDisabled") + found_plists+=("$HOME/Library/Preferences/.GlobalPreferences.plist") + fi + + local key + for key in skip-verify skip-verify-locked skip-verify-remote; do + value=$(defaults read com.apple.frameworks.diskimages "$key" 2> /dev/null || echo "") + if _opt_defaults_is_truthy "$value"; then + found_labels+=("Disk-image verification skipped (${key})") + found_domains+=("com.apple.frameworks.diskimages") + found_keys+=("$key") + found_plists+=("$HOME/Library/Preferences/com.apple.frameworks.diskimages.plist") + fi + done + + if [[ ${#found_keys[@]} -eq 0 ]]; then + opt_msg "No legacy App Nap or disk-image overrides found" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + local changed=0 skipped=0 failed=0 idx + for idx in "${!found_keys[@]}"; do + if command -v is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "${found_plists[$idx]}"; then + opt_msg "Skipped (whitelisted): ${found_labels[$idx]}" + skipped=$((skipped + 1)) + continue + fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove override: ${found_labels[$idx]}" + changed=$((changed + 1)) + continue + fi + if defaults delete "${found_domains[$idx]}" "${found_keys[$idx]}" 2> /dev/null; then + opt_msg "Removed override: ${found_labels[$idx]}" + changed=$((changed + 1)) + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not remove override: ${found_labels[$idx]}" + failed=$((failed + 1)) + fi + done + + optimize_task_result_from_counts "$changed" "$failed" "$skipped" +} + +# True unless the path lives on an unmounted /Volumes/. A LaunchAgent +# program on an external or network volume is not broken while that volume is +# simply unplugged, so it must not be deleted. +launch_agent_volume_mounted() { + local path="$1" + case "$path" in + /Volumes/*) + local vol="${path#/Volumes/}" + vol="${vol%%/*}" + [[ -n "$vol" && -d "/Volumes/$vol" ]] + ;; + *) return 0 ;; + esac +} + +# Broken LaunchAgent cleanup. +opt_launch_agents_cleanup() { + local agents_dir="$HOME/Library/LaunchAgents" + + if [[ ! -d "$agents_dir" ]]; then + opt_msg "Launch Agents all healthy" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + local broken_count=0 + local -a broken_plists=() + + for plist in "$agents_dir"/*.plist; do + [[ -f "$plist" ]] || continue + + local binary="" + local plist_rc=0 + binary=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + /usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" \ + "$plist" 2> /dev/null) || plist_rc=$? + [[ $plist_rc -eq 124 || $plist_rc -ge 128 ]] && return "$plist_rc" + if [[ -z "$binary" ]]; then + plist_rc=0 + binary=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + /usr/libexec/PlistBuddy -c "Print :Program" \ + "$plist" 2> /dev/null) || plist_rc=$? + [[ $plist_rc -eq 124 || $plist_rc -ge 128 ]] && return "$plist_rc" + fi + + # Only an absolute path that is genuinely missing counts as broken. + # Bare names (node, python3) resolve via PATH at launch time, and a + # path on an unmounted /Volumes/ just means the drive is + # unplugged -- neither is a broken agent. + if [[ -n "$binary" && "$binary" == /* && ! -e "$binary" ]] && + launch_agent_volume_mounted "$binary"; then + broken_count=$((broken_count + 1)) + broken_plists+=("$plist") + fi + done + + if [[ $broken_count -eq 0 ]]; then + opt_msg "Launch Agents all healthy" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + local removed_count=0 + local failed=0 + for plist in "${broken_plists[@]}"; do + local unload_rc=0 + run_launchctl_unload "$plist" || unload_rc=$? + [[ $unload_rc -eq 124 || $unload_rc -ge 128 ]] && return "$unload_rc" + local remove_rc=0 + safe_remove "$plist" true > /dev/null 2>&1 || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + return "$remove_rc" + elif [[ $remove_rc -eq 0 ]]; then + removed_count=$((removed_count + 1)) + else + failed=$((failed + 1)) + fi + done + + if [[ $removed_count -gt 0 ]]; then + opt_msg "Cleaned $removed_count broken Launch Agent(s)" + fi + if [[ $failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to remove $failed broken Launch Agent(s)" + fi + optimize_task_result_from_counts "$removed_count" "$failed" +} + +# macOS periodic maintenance scripts (daily/weekly/monthly). +# Log path is configurable via MOLE_PERIODIC_LOG for testing; defaults to /var/log/daily.out. +# A missing log file is treated as stale and triggers maintenance. +opt_periodic_maintenance() { + # Check if periodic command exists (removed in macOS 26+) + if ! command -v periodic > /dev/null 2>&1; then + opt_msg "Periodic maintenance skipped (not available on this macOS version)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" + return 0 + fi + + local daily_log="${MOLE_PERIODIC_LOG:-/var/log/daily.out}" + local stale_days=7 + + if [[ -f "$daily_log" ]]; then + local last_mod now age_days + last_mod=$(get_file_mtime "$daily_log") + now=$(get_epoch_seconds) + age_days=$(((now - last_mod) / 86400)) + + if [[ $age_days -lt $stale_days ]]; then + opt_msg "Periodic maintenance already current (${age_days}d ago)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! optimize_sudo_available; then + opt_msg "Periodic maintenance skipped (requires sudo)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi + # Capture stderr so --debug can surface the real failure reason + # (missing /etc/periodic scripts, SIP, broken launchd, etc.). + local periodic_output rc + if periodic_output=$(sudo periodic daily weekly monthly 2>&1); then + opt_msg "Periodic maintenance triggered" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + else + rc=$? + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to run periodic maintenance (exit=$rc)" + if [[ -n "$periodic_output" ]]; then + debug_log "periodic stderr: $periodic_output" + fi + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + fi + else + opt_msg "Periodic maintenance triggered" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + fi +} + +# Repair corrupted shared file list databases (Finder favorites, recent docs). +opt_shared_file_list_repair() { + local sfl_dir="$HOME/Library/Application Support/com.apple.sharedfilelist" + if [[ ! -d "$sfl_dir" ]]; then + opt_msg "Shared file lists directory not found" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + local repaired=0 + local scan_failed=0 + local remove_failed=0 + local scan_file="" + if ! scan_file=$(mktemp_file "optimize-shared-file-lists"); then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to prepare shared file list scan" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + local scan_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find "$sfl_dir" \ + \( -name "*.sfl2" -o -name "*.sfl3" \) -type f \ + ! -path "*ApplicationRecentDocuments*" -print0 \ + > "$scan_file" 2> /dev/null || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + : > "$scan_file" || true + [[ $scan_rc -eq 124 || $scan_rc -ge 128 ]] && return "$scan_rc" + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to scan shared file lists" + scan_failed=1 + fi + while IFS= read -r -d '' sfl_file; do + [[ -f "$sfl_file" ]] || continue + # Skip recent-documents list (user data, not a cache) + [[ "$sfl_file" == *"ApplicationRecentDocuments"* ]] && continue + if ! plutil -lint "$sfl_file" > /dev/null 2>&1; then + local remove_rc=0 + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + safe_remove "$sfl_file" true > /dev/null 2>&1 || remove_rc=$? + fi + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + return "$remove_rc" + elif [[ $remove_rc -eq 0 ]]; then + repaired=$((repaired + 1)) + else + remove_failed=$((remove_failed + 1)) + fi + fi + done < "$scan_file" + + if [[ $repaired -gt 0 ]]; then + opt_msg "Repaired $repaired corrupted shared file list(s)" + elif [[ $scan_failed -eq 0 && $remove_failed -eq 0 ]]; then + opt_msg "Shared file lists all healthy" + fi + if [[ $remove_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to repair $remove_failed corrupted shared file list(s)" + fi + optimize_task_result_from_counts "$repaired" "$((scan_failed + remove_failed))" +} + +# Resolve the live Notification Center SQLite database. +# macOS 15+ (Sequoia and later) stores it under the usernoted group container; +# older systems keep it under DARWIN_USER_DIR. Prefer the path that actually +# exists so we never report "not found" while usernoted holds the real db open +# (issue #1368). +# shellcheck disable=SC2329 +resolve_notification_center_db() { + local group_db="$HOME/Library/Group Containers/group.com.apple.usernoted/db2/db" + if [[ -f "$group_db" ]]; then + printf '%s\n' "$group_db" + return 0 + fi + + local darwin_dir="" + darwin_dir="$(getconf DARWIN_USER_DIR 2> /dev/null || true)" + darwin_dir="${darwin_dir%/}" + if [[ -n "$darwin_dir" && -f "$darwin_dir/com.apple.notificationcenter/db2/db" ]]; then + printf '%s\n' "$darwin_dir/com.apple.notificationcenter/db2/db" + return 0 + fi + return 1 +} + +# Clean old delivered notifications from NotificationCenter database. +opt_notification_cleanup() { + local nc_db="" + if ! nc_db=$(resolve_notification_center_db); then + # Unavailable, not a healthy empty state: the success "not found" line + # made a missed Sequoia path look like a no-op (issue #1368). + echo -e " ${GRAY}-${NC} Notification Center database unavailable (no supported path)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" + return 0 + fi + debug_log "Notification Center database: $nc_db" + + local db_size="" + if ! db_size=$(opt_existing_file_size_kb_strict "$nc_db"); then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect Notification Center database size" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + + # Only clean if database exceeds 50MB (51200 KB) + if [[ $db_size -lt 51200 ]]; then + opt_msg "Notification Center database is healthy ($(bytes_to_human $((db_size * 1024))))" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + if command -v sqlite3 > /dev/null 2>&1; then + local sql_ok=0 + sqlite3 "$nc_db" \ + "DELETE FROM record WHERE delivered_date < strftime('%s','now','-30 days'); VACUUM;" \ + 2> /dev/null || sql_ok=$? + if [[ $sql_ok -eq 0 ]]; then + killall NotificationCenter 2> /dev/null || true + opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Notification Center cleanup skipped (database busy or locked)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" fi - done < <(command find "$dock_support" -name "*.db" -type f 2> /dev/null || true) + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" + fi + else + opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" fi +} - local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist" - if [[ -f "$dock_plist" ]]; then - touch "$dock_plist" 2> /dev/null || true +# Verify filesystem integrity via diskutil. +# Disabled by default: diskutil verifyVolume triggers kernel-level I/O that +# cannot be interrupted by SIGKILL when the volume has APFS inconsistencies, +# causing the system to freeze. Set MOLE_ENABLE_DISK_VERIFY=1 to opt in. +opt_disk_verify() { + if [[ "${MOLE_ENABLE_DISK_VERIFY:-0}" != "1" ]]; then + opt_msg "Disk verify skipped (set MOLE_ENABLE_DISK_VERIFY=1 to enable)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + opt_msg "Disk verify · skipped in dry-run" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Verifying disk filesystem..." + fi + local output="" + local verify_status=0 + output=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" diskutil verifyVolume / 2>&1) || verify_status=$? + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + if [[ $verify_status -eq 124 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk verification timed out" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + elif [[ $verify_status -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk verification failed (exit=$verify_status)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + elif echo "$output" | grep -qi "appears to be OK\|volume appears to be ok"; then + opt_msg "Disk filesystem verified OK" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + elif echo "$output" | grep -qi "error\|corrupt\|invalid"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk issues detected · run: sudo diskutil repairVolume /" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_ATTENTION" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk verification result was not recognized" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + fi +} + +# Clean Knowledge/CoreDuet usage tracking databases. +opt_coreduet_cleanup() { + local knowledge_dir="$HOME/Library/Application Support/Knowledge" + local knowledge_db="$knowledge_dir/knowledgeC.db" + + if [[ ! -f "$knowledge_db" ]]; then + opt_msg "Knowledge database not found" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + # Check combined size of WAL/SHM files + database + local wal_file="$knowledge_db-wal" + local shm_file="$knowledge_db-shm" + local total_size=0 + local -a knowledge_files=() + + for f in "$knowledge_db" "$wal_file" "$shm_file"; do + [[ -f "$f" ]] && knowledge_files+=("$f") + done + + if [[ ${#knowledge_files[@]} -gt 0 ]]; then + local size_status=0 + total_size=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skcP "${knowledge_files[@]}" 2> /dev/null | awk 'END {print $1 + 0}') || size_status=$? + if [[ $size_status -ne 0 || ! "$total_size" =~ ^[0-9]+$ ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect Knowledge database size" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + fi + + # Skip if combined size < 100MB (102400 KB) + if [[ $total_size -lt 102400 ]]; then + opt_msg "Knowledge database is healthy ($(bytes_to_human $((total_size * 1024))))" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - killall Dock 2> /dev/null || true + if ! command -v sqlite3 > /dev/null 2>&1; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" + return 0 + fi + + # Remove WAL and SHM files safely (auto-regenerated by SQLite) + local removed_count=0 + local remove_failed=0 + for f in "$wal_file" "$shm_file"; do + if [[ -f "$f" ]]; then + local remove_rc=0 + safe_remove "$f" true > /dev/null 2>&1 || remove_rc=$? + if [[ $remove_rc -eq 124 || $remove_rc -ge 128 ]]; then + return "$remove_rc" + elif [[ $remove_rc -eq 0 ]]; then + removed_count=$((removed_count + 1)) + else + remove_failed=$((remove_failed + 1)) + fi + fi + done + # Remove ZOBJECT entries older than 90 days (CoreTime is Mac epoch: seconds since 2001-01-01) + local sql_applied=0 + local sql_failed=0 + if sqlite3 "$knowledge_db" \ + "DELETE FROM ZOBJECT WHERE ZCREATIONDATE < (strftime('%s','now','-90 days') - strftime('%s','2001-01-01')); VACUUM;" \ + 2> /dev/null; then + sql_applied=1 + else + sql_failed=1 + fi + + if [[ $sql_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Knowledge database cleanup skipped (database busy or locked)" + elif [[ $remove_failed -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Knowledge database cleanup incomplete" + else + opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))" + fi + optimize_task_result_from_counts \ + "$((removed_count + sql_applied))" \ + "$((remove_failed + sql_failed))" + else + opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" + fi +} + +# Audit login items for broken entries referencing missing apps. +# Return a tab-separated snapshot: login item display name, then best-effort +# POSIX path. Display names can differ from the on-disk bundle name, so the +# audit needs both pieces before deciding an item is broken. +_login_items_snapshot() { + osascript << 'APPLESCRIPT' +set oldDelimiters to AppleScript's text item delimiters +set tabChar to ASCII character 9 +set linefeedChar to ASCII character 10 +set outputLines to {} + +tell application "System Events" + repeat with loginItem in login items + set itemName to "" + set itemPath to "" + + try + set itemName to name of loginItem as text + end try + + try + set itemPath to POSIX path of (path of loginItem as alias) + on error + try + set itemPath to path of loginItem as text + end try + end try + + set end of outputLines to itemName & tabChar & itemPath + end repeat +end tell + +set AppleScript's text item delimiters to linefeedChar +set outputText to outputLines as text +set AppleScript's text item delimiters to oldDelimiters +return outputText +APPLESCRIPT +} + +_login_item_debug() { + if [[ "${MO_DEBUG:-}" == "1" ]] && declare -f debug_log > /dev/null 2>&1; then + debug_log "Login item audit: $*" fi +} + +_login_item_name_matches() { + local actual="$1" + local expected="$2" + local expected_nospace="$3" + local expected_stripped="$4" + + [[ -z "$actual" ]] && return 1 + + local actual_nospace="${actual// /}" + [[ "$actual" == "$expected" ]] && return 0 + [[ "$actual_nospace" == "$expected_nospace" ]] && return 0 + [[ -n "$expected_stripped" && "$actual_nospace" == "$expected_stripped" ]] && return 0 + + return 1 +} + +_login_item_bundle_metadata_matches() { + local app_path="$1" + local name="$2" + local nospace="$3" + local stripped="$4" + local info="$app_path/Contents/Info.plist" + [[ -f "$info" ]] || return 1 + + local key value + for key in CFBundleDisplayName CFBundleName CFBundleExecutable; do + value=$(plutil -extract "$key" raw "$info" 2> /dev/null || echo "") + if _login_item_name_matches "$value" "$name" "$nospace" "$stripped"; then + _login_item_debug "'$name' matched $key '$value' at $app_path" + return 0 + fi + done + + return 1 +} + +# Check if a login item name corresponds to an installed app. +# Login item names often differ from .app bundle names (e.g. "AliLangClient" -> "AliLang.app", +# "Top Calendar" -> "TopCalendar.app"), so we try multiple matching strategies. +_login_item_app_exists() { + local name="$1" + local item_path="${2:-}" - if [[ "$refreshed" == "true" ]]; then - opt_msg "Dock cache cleared" + if [[ -n "$item_path" ]]; then + if [[ -e "$item_path" || -L "$item_path" ]]; then + _login_item_debug "'$name' resolved by login item path: $item_path" + return 0 + fi + _login_item_debug "'$name' login item path is missing: $item_path" + else + _login_item_debug "'$name' has no login item path from System Events" + fi + + # 1. Exact match + if [[ "$name" != *"'"* ]] && mdfind "kMDItemFSName == '${name}.app'" 2> /dev/null | grep -q .; then + _login_item_debug "'$name' resolved by Spotlight exact app name" + return 0 + fi + # 2. Try without spaces (e.g. "Top Calendar" -> "TopCalendar") + local nospace="${name// /}" + if [[ "$name" != *"'"* && "$nospace" != "$name" ]] && mdfind "kMDItemFSName == '${nospace}.app'" 2> /dev/null | grep -q .; then + _login_item_debug "'$name' resolved by Spotlight no-space app name" + return 0 + fi + # 3. Strip common helper suffixes (e.g. "AliLangClient" -> "AliLang") + local stripped + stripped=$(echo "$nospace" | sed -E 's/(Client|Helper|Agent|Launcher|Service)$//') + if [[ "$name" != *"'"* && "$stripped" != "$nospace" ]] && mdfind "kMDItemFSName == '${stripped}.app'" 2> /dev/null | grep -q .; then + _login_item_debug "'$name' resolved by Spotlight stripped helper name" + return 0 + fi + # 4. Recursive filesystem fallback for nested helper apps inside parent + # bundles. Spotlight often misses helpers under Contents/. + local candidate roots app_name app_path + local -a app_names=("${name}.app") + [[ "$nospace" != "$name" ]] && app_names+=("${nospace}.app") + [[ "$stripped" != "$nospace" ]] && app_names+=("${stripped}.app") + for roots in "/Applications" "$HOME/Applications"; do + [[ -d "$roots" ]] || continue + local -a name_expr=() + for app_name in "${app_names[@]}"; do + if [[ ${#name_expr[@]} -gt 0 ]]; then + name_expr+=("-o") + fi + name_expr+=("-name" "$app_name") + done + candidate=$(command find "$roots" -maxdepth 6 -type d \( "${name_expr[@]}" \) -print -quit 2> /dev/null || true) + if [[ -n "$candidate" && -d "$candidate" ]]; then + _login_item_debug "'$name' resolved by filesystem app name: $candidate" + return 0 + fi + + while IFS= read -r -d '' app_path; do + if _login_item_bundle_metadata_matches "$app_path" "$name" "$nospace" "$stripped"; then + return 0 + fi + done < <(command find "$roots" -maxdepth 6 -type d -name "*.app" -print0 2> /dev/null) + done + # 5. Fallback: check sfltool dumpbtm for the actual on-disk path. + # Nested helper apps (e.g. DBnginMenuHelper.app inside DBngin.app) are + # invisible to mdfind but still have a valid URL in the BTM database. + # Root only: unprivileged dumpbtm pops the macOS "sfltool wants to + # make changes" admin-password dialog, so without an active sudo + # session this fallback is skipped rather than prompting. + local btm_path="" + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]] && sudo -n true 2> /dev/null; then + btm_path=$(sudo -n sfltool dumpbtm 2> /dev/null | awk -v item="$name" ' + BEGIN { IGNORECASE = 1 } + index($0, item) { + if (match($0, "/.*\\.app")) { + print substr($0, RSTART, RLENGTH) + exit + } + } + ') + fi + if [[ -n "$btm_path" ]] && [[ -e "$btm_path" ]]; then + _login_item_debug "'$name' resolved by sfltool BTM path: $btm_path" + return 0 + fi + _login_item_debug "'$name' unresolved after path, Spotlight, filesystem, and BTM checks" + return 1 +} + +opt_login_items_audit() { + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + opt_msg "Login items audit · skipped in test mode" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + return 0 + fi + + local items_output="" + local snapshot_status=0 + items_output=$(_login_items_snapshot 2> /dev/null) || snapshot_status=$? + + if [[ $snapshot_status -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to inspect login items" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" + return 0 + fi + + if [[ -z "$items_output" ]]; then + opt_msg "No login items found" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + return 0 + fi + + local broken=0 + local checked=0 + local item item_path + while IFS=$'\t' read -r item item_path; do + [[ -z "$item" ]] && continue + checked=$((checked + 1)) + if _login_item_app_exists "$item" "$item_path"; then + continue + fi + echo -e " ${YELLOW}${ICON_WARNING}${NC} Broken login item: $item (app not found)" + broken=$((broken + 1)) + done <<< "$items_output" + + if [[ $broken -eq 0 ]]; then + opt_msg "Login items all healthy ($checked checked)" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} $broken broken login item(s) · remove via System Settings > General > Login Items" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_ATTENTION" fi - opt_msg "Dock refreshed" } # Dispatch optimization by action name. execute_optimization() { local action="$1" - local path="${2:-}" - - case "$action" in - system_maintenance) opt_system_maintenance ;; - cache_refresh) opt_cache_refresh ;; - saved_state_cleanup) opt_saved_state_cleanup ;; - fix_broken_configs) opt_fix_broken_configs ;; - network_optimization) opt_network_optimization ;; - sqlite_vacuum) opt_sqlite_vacuum ;; - launch_services_rebuild) opt_launch_services_rebuild ;; - font_cache_rebuild) opt_font_cache_rebuild ;; - dock_refresh) opt_dock_refresh ;; - memory_pressure_relief) opt_memory_pressure_relief ;; - network_stack_optimize) opt_network_stack_optimize ;; - disk_permissions_repair) opt_disk_permissions_repair ;; - bluetooth_reset) opt_bluetooth_reset ;; - spotlight_index_optimize) opt_spotlight_index_optimize ;; - *) - echo -e "${YELLOW}${ICON_ERROR}${NC} Unknown action: $action" - return 1 - ;; - esac + + local handler health_name + if ! handler=$(optimize_catalog_handler_for "$action"); then + echo -e "${YELLOW}${ICON_ERROR}${NC} Unknown action: $action" + return 1 + fi + health_name=$(optimize_catalog_health_name_for "$action") + if ! declare -F "$handler" > /dev/null; then + echo -e "${YELLOW}${ICON_ERROR}${NC} Missing optimization handler: $handler" + return 1 + fi + + if command -v is_whitelisted > /dev/null && is_whitelisted "$action"; then + optimize_task_start + opt_msg "Skipped (whitelisted): $health_name" + optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_SKIPPED" + optimize_task_finish "$action" + return 0 + fi + + optimize_task_start + "$handler" + optimize_task_finish "$action" } diff --git a/Resources/mole/lib/ui/app_selector.sh b/Resources/mole/lib/ui/app_selector.sh index add9015..8179e8b 100755 --- a/Resources/mole/lib/ui/app_selector.sh +++ b/Resources/mole/lib/ui/app_selector.sh @@ -17,8 +17,8 @@ format_app_display() { fi # Format size - local size_str="N/A" - [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" + local size_str="--" + [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" && "$size" != "N/A" && "$size" != "--" ]] && size_str="$size" # Calculate available width for app name based on terminal width # Accept pre-calculated max_name_width (5th param) to avoid recalculation in loops @@ -58,10 +58,19 @@ format_app_display() { current_display_width=$(get_display_width "$truncated_name") # Calculate padding needed - # Formula: char_count + (available_width - display_width) = padding to add - local char_count=${#truncated_name} + # printf counts bytes (in LC_ALL=C), not display width or char count. + # Get byte count for printf width calculation. + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local byte_count=${#truncated_name} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding_needed=$((available_width - current_display_width)) - local printf_width=$((char_count + padding_needed)) + local printf_width=$((byte_count + padding_needed)) # Use dynamic column width with corrected padding printf "%-*s %9s | %s" "$printf_width" "$truncated_name" "$size_str" "$compact_last_used" @@ -146,6 +155,8 @@ select_apps_for_uninstall() { fi fi + drain_pending_input 0.2 + # Expose metadata for the paginated menu (optional inputs) # - MOLE_MENU_META_EPOCHS: numeric last_used_epoch per item # - MOLE_MENU_META_SIZEKB: numeric size in KB per item @@ -161,6 +172,7 @@ select_apps_for_uninstall() { unset MOLE_MENU_META_SIZEKB fi export MOLE_MENU_FILTER_NAMES="$names_newline" + export MOLE_MENU_IGNORE_INITIAL_ENTER=1 # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT # Note: paginated_multi_select enters alternate screen and handles clearing @@ -169,7 +181,7 @@ select_apps_for_uninstall() { local exit_code=$? # Clean env leakage for safety - unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB MOLE_MENU_FILTER_NAMES + unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB MOLE_MENU_FILTER_NAMES MOLE_MENU_IGNORE_INITIAL_ENTER # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally if [[ $exit_code -ne 0 ]]; then diff --git a/Resources/mole/lib/ui/menu_paginated.sh b/Resources/mole/lib/ui/menu_paginated.sh index c241fc1..5f3dccb 100755 --- a/Resources/mole/lib/ui/menu_paginated.sh +++ b/Resources/mole/lib/ui/menu_paginated.sh @@ -72,6 +72,11 @@ paginated_multi_select() { local title="$1" shift local -a items=("$@") + # A deliberate quit and a real failure both leave through return 1, and + # callers deciding between "user cancelled" and "selection broke" need to + # tell them apart. Reset here so a stale value from a previous menu can + # never masquerade as this run's answer. + _MOLE_MENU_USER_QUIT=0 local external_alt_screen=false if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then external_alt_screen=true @@ -91,6 +96,7 @@ paginated_multi_select() { local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" local filter_text="" # Filter keyword local filter_text_lower="" + local ignore_initial_enter="${MOLE_MENU_IGNORE_INITIAL_ENTER:-false}" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -100,13 +106,22 @@ paginated_multi_select() { local -a sizekb=() local -a filter_names=() local has_metadata="false" + local has_epoch_metadata="false" + local has_size_metadata="false" local has_filter_names="false" if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then - while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") - has_metadata="true" + while IFS= read -r v; do + epochs+=("${v:-0}") + [[ "${v:-0}" =~ ^[0-9]+$ && "${v:-0}" -gt 0 ]] && has_epoch_metadata="true" + done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") fi if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then - while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") + while IFS= read -r v; do + sizekb+=("${v:-0}") + [[ "${v:-0}" =~ ^[0-9]+$ && "${v:-0}" -gt 0 ]] && has_size_metadata="true" + done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") + fi + if [[ "$has_epoch_metadata" == "true" || "$has_size_metadata" == "true" ]]; then has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then @@ -114,9 +129,49 @@ paginated_multi_select() { has_filter_names="true" fi - # If no metadata, force name sorting and disable sorting controls - if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then + sort_mode_available() { + case "$1" in + date) [[ "$has_epoch_metadata" == "true" ]] ;; + size) [[ "$has_size_metadata" == "true" ]] ;; + name) return 0 ;; + *) return 1 ;; + esac + } + + normalize_sort_mode() { + sort_mode_available "$sort_mode" && return 0 + if [[ "$has_epoch_metadata" == "true" ]]; then + sort_mode="date" + elif [[ "$has_size_metadata" == "true" ]]; then + sort_mode="size" + else + sort_mode="name" + fi + } + + cycle_sort_mode() { + local candidate + case "$sort_mode" in + date) set -- name size date ;; + name) set -- size date name ;; + size) set -- date name size ;; + *) set -- date name size ;; + esac + + for candidate in "$@"; do + if sort_mode_available "$candidate"; then + sort_mode="$candidate" + return 0 + fi + done + sort_mode="name" + } + + # If no metadata, force name sorting and disable sorting controls. + if [[ "$has_metadata" == "false" ]]; then sort_mode="name" + else + normalize_sort_mode fi # Index mappings @@ -179,9 +234,28 @@ paginated_multi_select() { fi } + # Save the caller's EXIT/INT/TERM traps before overriding them. Restoring + # rather than clearing on exit keeps an outer handler alive, e.g. + # bin/uninstall.sh arms `trap cleanup EXIT` whose only job is writing the + # session-end operation log; a bare `trap - EXIT` here dropped it silently. + local _menu_saved_exit _menu_saved_int _menu_saved_term + _menu_saved_exit=$(trap -p EXIT) + _menu_saved_int=$(trap -p INT) + _menu_saved_term=$(trap -p TERM) + # Uses :- defaults: _pm_cleanup() is the EXIT trap and may fire once more at + # shell exit after this function has returned and the saved-trap locals + # are gone. Degrading to `trap -` then is harmless (the caller's trap was + # already restored on the normal-exit path below). + # shellcheck disable=SC2329 + _menu_restore_traps() { + if [[ -n "${_menu_saved_exit:-}" ]]; then eval "${_menu_saved_exit}"; else trap - EXIT; fi + if [[ -n "${_menu_saved_int:-}" ]]; then eval "${_menu_saved_int}"; else trap - INT; fi + if [[ -n "${_menu_saved_term:-}" ]]; then eval "${_menu_saved_term}"; else trap - TERM; fi + } + # Cleanup function - cleanup() { - trap - EXIT INT TERM + _pm_cleanup() { + _menu_restore_traps unset MOLE_READ_KEY_FORCE_CHAR export MOLE_MENU_SORT_MODE="${sort_mode:-name}" export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" @@ -190,13 +264,13 @@ paginated_multi_select() { # Interrupt handler # shellcheck disable=SC2329 - handle_interrupt() { - cleanup + _pm_handle_interrupt() { + _pm_cleanup exit 130 # Standard exit code for Ctrl+C } - trap cleanup EXIT - trap handle_interrupt INT TERM + trap _pm_cleanup EXIT + trap _pm_handle_interrupt INT TERM # Setup terminal - preserve interrupt character stty -echo -icanon intr ^C 2> /dev/null || true @@ -259,7 +333,7 @@ paginated_multi_select() { local -a filter_cache_indices=() ensure_sorted_indices() { - local requested_key="${sort_mode}:${sort_reverse}:${has_metadata}" + local requested_key="${sort_mode}:${sort_reverse}:${has_epoch_metadata}:${has_size_metadata}" if [[ "$requested_key" == "$sort_cache_key" && ${#sorted_indices_cache[@]} -gt 0 ]]; then return fi @@ -400,9 +474,9 @@ paginated_multi_select() { draw_header() { printf "\033[1;1H" >&2 if [[ -n "$filter_text" ]]; then - printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2 + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Search: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2 elif [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2 + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Search: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2 else printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 fi @@ -444,7 +518,7 @@ paginated_multi_select() { for ((i = 0; i < items_per_page; i++)); do printf "${clear_line}\n" >&2 done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter Save | Q Cancel${NC}\n" >&2 printf "${clear_line}" >&2 return fi @@ -502,16 +576,17 @@ paginated_multi_select() { # Common menu items local nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" + local page_ctrl="${GRAY}h/l Page${NC}" local space_select="${GRAY}Space Select${NC}" - local enter="${GRAY}Enter${NC}" - local exit="${GRAY}Q Exit${NC}" + local enter="${GRAY}Enter Save${NC}" + local cancel_label="${GRAY}Q Cancel${NC}" local reverse_arrow="↑" [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" local sort_ctrl="${GRAY}S ${sort_status}${NC}" local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" - local filter_ctrl="${GRAY}/ Filter${NC}" + local filter_ctrl="${GRAY}/ Search${NC}" if [[ -n "$filter_text" ]]; then local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}Ctrl+U Clear${NC}" "${GRAY}ESC Clear${NC}") @@ -523,7 +598,7 @@ paginated_multi_select() { [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + local -a _segs=("$nav" "$page_ctrl" "$space_select" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$cancel_label") # Calculate width local total_len=0 seg_count=${#_segs[@]} @@ -534,7 +609,7 @@ paginated_multi_select() { # Level 1: Remove "Space Select" if too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$page_ctrl" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$cancel_label") total_len=0 seg_count=${#_segs[@]} @@ -543,16 +618,16 @@ paginated_multi_select() { [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) done - # Level 2: Remove sort label if still too wide + # Level 2: Remove sort label and page hint if still too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$enter" "$order_ctrl" "$filter_ctrl" "$cancel_label") fi fi _print_wrapped_controls "$sep" "${_segs[@]}" else # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$filter_ctrl" "$exit") + local -a _segs_simple=("$nav" "$page_ctrl" "$space_select" "$enter" "$filter_ctrl" "$cancel_label") _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 @@ -575,6 +650,12 @@ paginated_multi_select() { local key key=$(read_key) + if [[ "$ignore_initial_enter" == "true" || "$ignore_initial_enter" == "1" ]]; then + ignore_initial_enter=false + if [[ "$key" == "ENTER" ]]; then + continue + fi + fi case "$key" in "QUIT") @@ -587,7 +668,8 @@ paginated_multi_select() { top_index=0 need_full_redraw=true else - cleanup + _MOLE_MENU_USER_QUIT=1 + _pm_cleanup return 1 fi ;; @@ -707,6 +789,51 @@ paginated_multi_select() { fi fi ;; + "TOP") + if [[ ${#view_indices[@]} -gt 0 ]]; then + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "BOTTOM") + if [[ ${#view_indices[@]} -gt 0 ]]; then + local visible_total=${#view_indices[@]} + if [[ $visible_total -gt $items_per_page ]]; then + top_index=$((visible_total - items_per_page)) + cursor_pos=$((items_per_page - 1)) + else + top_index=0 + cursor_pos=$((visible_total - 1)) + fi + need_full_redraw=true + fi + ;; + "LEFT") + if [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $top_index -gt 0 ]]; then + top_index=$((top_index - items_per_page)) + [[ $top_index -lt 0 ]] && top_index=0 + fi + cursor_pos=0 + need_full_redraw=true + fi + ;; + "RIGHT") + if [[ ${#view_indices[@]} -gt 0 ]]; then + local visible_total=${#view_indices[@]} + if [[ $((top_index + items_per_page)) -lt $visible_total ]]; then + top_index=$((top_index + items_per_page)) + local _remaining=$((visible_total - top_index)) + if [[ $_remaining -lt $items_per_page ]]; then + top_index=$((visible_total - items_per_page)) + [[ $top_index -lt 0 ]] && top_index=0 + fi + fi + cursor_pos=0 + need_full_redraw=true + fi + ;; "SPACE") local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then @@ -738,11 +865,7 @@ paginated_multi_select() { if handle_filter_char "${key#CHAR:}"; then : # Handled as filter input elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac + cycle_sort_mode rebuild_view need_full_redraw=true fi @@ -861,7 +984,7 @@ paginated_multi_select() { final_result="${selected_indices[*]}" fi - trap - EXIT INT TERM + _menu_restore_traps MOLE_SELECTION_RESULT="$final_result" unset MOLE_READ_KEY_FORCE_CHAR export MOLE_MENU_SORT_MODE="${sort_mode:-name}" diff --git a/Resources/mole/lib/ui/menu_simple.sh b/Resources/mole/lib/ui/menu_simple.sh index 0dd4607..d3d4a09 100755 --- a/Resources/mole/lib/ui/menu_simple.sh +++ b/Resources/mole/lib/ui/menu_simple.sh @@ -102,9 +102,26 @@ paginated_multi_select() { fi } + # Save the caller's EXIT/INT/TERM traps so cleanup restores rather than + # clears them; a bare `trap - EXIT` here drops an outer handler such as + # bin/uninstall.sh's session-end oplog writer. See menu_paginated.sh. + local _menu_saved_exit _menu_saved_int _menu_saved_term + _menu_saved_exit=$(trap -p EXIT) + _menu_saved_int=$(trap -p INT) + _menu_saved_term=$(trap -p TERM) + # Uses :- defaults: cleanup() is the EXIT trap and may fire once more at + # shell exit after this function returned and the saved-trap locals are + # gone. See menu_paginated.sh. + # shellcheck disable=SC2329 + _menu_restore_traps() { + if [[ -n "${_menu_saved_exit:-}" ]]; then eval "${_menu_saved_exit}"; else trap - EXIT; fi + if [[ -n "${_menu_saved_int:-}" ]]; then eval "${_menu_saved_int}"; else trap - INT; fi + if [[ -n "${_menu_saved_term:-}" ]]; then eval "${_menu_saved_term}"; else trap - TERM; fi + } + # Cleanup function cleanup() { - trap - EXIT INT TERM + _menu_restore_traps restore_terminal } @@ -212,7 +229,7 @@ paginated_multi_select() { # Clear any remaining lines at bottom printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter Save | Q Cancel${NC}\n" >&2 # Clear one more line to ensure no artifacts printf "${clear_line}" >&2 @@ -279,6 +296,13 @@ paginated_multi_select() { selected[i]=false done ;; + "LEFT" | "RIGHT") + # menu_simple is non-paginated; LEFT/RIGHT (h/l) have no + # navigation meaning here. Swallow them silently so set -e in + # callers doesn't trip and so a stray Vim user doesn't see a + # spurious cursor jump. + : + ;; "ENTER") # Store result in global variable instead of returning via stdout local -a selected_indices=() @@ -296,8 +320,8 @@ paginated_multi_select() { final_result="${selected_indices[*]}" fi - # Remove the trap to avoid cleanup on normal exit - trap - EXIT INT TERM + # Restore the caller's traps on normal exit (see above). + _menu_restore_traps # Store result in global variable MOLE_SELECTION_RESULT="$final_result" diff --git a/Resources/mole/lib/uninstall/batch.sh b/Resources/mole/lib/uninstall/batch.sh index 8a22f9a..7ac2bda 100755 --- a/Resources/mole/lib/uninstall/batch.sh +++ b/Resources/mole/lib/uninstall/batch.sh @@ -11,10 +11,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. -get_lsregister_path() { - echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" -} - is_uninstall_dry_run() { [[ "${MOLE_DRY_RUN:-0}" == "1" ]] } @@ -77,44 +73,385 @@ decode_file_list() { echo "$decoded" return 0 } -# Note: find_app_files() and calculate_total_size() are in lib/core/common.sh. -# Stop Launch Agents/Daemons for an app. +# Decode a base64 blob of login-item helper bundle ids. Unlike +# decode_file_list, the lines are bundle ids, not absolute paths, so the +# leading-slash check there would reject every id, print a misleading +# "Invalid path" warning, and blank the whole list, silently skipping the +# launchctl bootout of the app's login item helpers. Per-line validation +# stays in bootout_login_item_helpers via mole_is_reverse_dns_bundle_id. +decode_bundle_id_list() { + local encoded="$1" + local app_name="$2" + local decoded + + # macOS uses -D, GNU uses -d. Always return 0 for set -e safety. + if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then + log_error "Failed to decode helper id list for $app_name" >&2 + echo "" + return 0 + fi + fi + + if [[ "$decoded" =~ $'\0' ]]; then + log_warning "Helper id list for $app_name contains null bytes, rejecting" >&2 + echo "" + return 0 + fi + + echo "$decoded" + return 0 +} +# Note: find_app_files() is in lib/core/app_protection.sh, calculate_total_size() is in lib/core/file_ops.sh. + +# Only a background job that is still loaded in launchd, meaning the bootout +# was missed or failed, deserves a summary warning. BTM registration records +# are kept for uninstalled apps on purpose (they restore the user's +# enable/disable choice on a reinstall) and are pruned at the next login. +# Args: $1 - app bundle id, $2 - newline-separated helper bundle ids. +# Returns 0 when any of the labels is still loaded in the user's launchd +# domain. In test mode report "not loaded" so summaries stay quiet; unit +# tests exercise the real branch with MOLE_TEST_MODE=0 and a launchctl mock. +_uninstall_background_job_loaded() { + local bundle_id="$1" + local helper_ids="${2:-}" + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + local uid label + uid=$(id -u) + while IFS= read -r label; do + [[ -n "$label" ]] || continue + mole_is_reverse_dns_bundle_id "$label" || continue + if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then + return 0 + fi + done <<< "$(printf '%s\n%s\n' "$bundle_id" "$helper_ids")" + + return 1 +} + +# Emit the names of successfully-uninstalled apps that still have a background +# job loaded in launchd, meaning the bootout was missed or failed and the user +# must toggle it off manually. Deliberately does NOT consult sfltool dumpbtm: +# unprivileged dumpbtm pops the macOS "sfltool wants to make changes" +# admin-password dialog on every uninstall batch, and registered-but-unloaded +# BTM records are by-design residue macOS clears at next login. +# Args: ... -- ... +# app_detail follows the pipe-encoded shape used inside batch_uninstall_applications. +_uninstall_match_loaded_background_items() { + local -a details=() + local -a success_paths=() + local sep_seen=false + local arg + for arg in "$@"; do + if [[ "$sep_seen" == false ]]; then + if [[ "$arg" == "--" ]]; then + sep_seen=true + else + details+=("$arg") + fi + else + success_paths+=("$arg") + fi + done + + [[ ${#details[@]} -eq 0 || ${#success_paths[@]} -eq 0 ]] && return 0 + + local detail app_name app_path bundle_id enc_helpers sp matched + local _total_kb _encoded_files _encoded_system_files _has_sensitive_data + local _needs_sudo _is_brew_cask _cask_name _encoded_diag_system + local _encoded_review_system _sibling_guard _expected_app_identity + local _original_bundle_id _encoded_live_sibling_fingerprint _expected_info_identity + for detail in "${details[@]}"; do + IFS='|' read -r app_name app_path bundle_id _total_kb _encoded_files _encoded_system_files \ + _has_sensitive_data _needs_sudo _is_brew_cask _cask_name _encoded_diag_system \ + _encoded_review_system enc_helpers _sibling_guard _expected_app_identity \ + _original_bundle_id _encoded_live_sibling_fingerprint \ + _expected_info_identity <<< "$detail" + matched=false + for sp in "${success_paths[@]}"; do + [[ "$sp" == "$app_path" ]] && matched=true && break + done + [[ "$matched" != true ]] && continue + + # The sibling guard can demote bundle_id to "unknown" while helper ids + # stay valid; _uninstall_background_job_loaded validates each label, + # so no explicit unknown-skip is needed here. + local helper_ids + helper_ids=$(decode_bundle_id_list "${enc_helpers:-}" "$app_name") + if _uninstall_background_job_loaded "$bundle_id" "$helper_ids"; then + printf '%s\n' "$app_name" + fi + done +} + +append_line() { + local current="$1" + local addition="$2" + [[ -z "$addition" ]] && { + printf '%s' "$current" + return 0 + } + if [[ -n "$current" ]]; then + printf '%s\n%s' "$current" "$addition" + else + printf '%s' "$addition" + fi +} + +format_uninstall_preview_path() { + local path="$1" + # Replacement must come from a variable: bash 5.3+ tilde-expands a literal + # unquoted ~ in the patsub replacement, turning this into a no-op. + local tilde='~' + local display_path="${path/#$HOME/$tilde}" + local size_kb="0" + local size_rc=0 + size_kb=$(get_path_size_kb "$path" 2> /dev/null) || size_rc=$? + [[ $size_rc -eq 124 || $size_rc -ge 128 ]] && return "$size_rc" + [[ $size_rc -eq 0 ]] || size_kb="0" + + if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then + printf '%s %s, %s%s' "$display_path" "$GRAY" "$(bytes_to_human "$((size_kb * 1024))")" "$NC" + else + printf '%s' "$display_path" + fi +} + +discover_login_item_helper_bundle_ids() { + local app_path="$1" + local login_items_root="$app_path/Contents/Library/LoginItems" + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" + [[ -d "$login_items_root" ]] || return 0 + + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + _mole_uninstall_materialize_find0 "$scan_file" \ + "$login_items_root" -maxdepth 1 -name "*.app" \ + -print0 || scan_rc=$? + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + + local helper info bundle_id + local result_rc=0 + while IFS= read -r -d '' helper; do + info="$helper/Contents/Info.plist" + [[ -f "$info" ]] || continue + local plist_rc=0 + local plist_timeout="" + plist_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$_MOLE_UNINSTALL_DISCOVERY_DEADLINE") || plist_rc=$? + if [[ $plist_rc -eq 0 ]]; then + bundle_id=$(run_with_timeout "$plist_timeout" plutil \ + -extract CFBundleIdentifier raw "$info" \ + 2> /dev/null) || plist_rc=$? + fi + if [[ $plist_rc -eq 124 || $plist_rc -ge 128 ]]; then + result_rc=$plist_rc + break + fi + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + printf '%s\n' "$bundle_id" + fi + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ $result_rc -ne 0 ]]; then + return "$result_rc" + fi + return 0 +} + +bootout_login_item_helpers() { + local helper_ids="$1" + [[ -n "$helper_ids" ]] || return 0 + if is_uninstall_dry_run || [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would bootout login item helpers" + return 0 + fi + + local uid helper_id + uid=$(id -u) + while IFS= read -r helper_id; do + [[ -n "$helper_id" ]] || continue + mole_is_reverse_dns_bundle_id "$helper_id" || continue + # A third-party helper's Info.plist could claim an Apple label; never + # boot out the protected namespace regardless of what the bundle says. + case "$helper_id" in + com.apple.*) continue ;; + esac + local bootout_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" launchctl \ + bootout "gui/$uid/$helper_id" > /dev/null 2>&1 || bootout_rc=$? + [[ $bootout_rc -eq 124 || $bootout_rc -ge 128 ]] && return "$bootout_rc" + done <<< "$helper_ids" + return 0 +} + +can_unload_launch_plist() { + local plist="$1" + [[ "$plist" == *.plist ]] || return 1 + case "$plist" in + "$HOME"/Library/LaunchAgents/*.plist | /Library/LaunchAgents/*.plist | /Library/LaunchDaemons/*.plist) ;; + *) return 1 ;; + esac + validate_path_for_deletion "$plist" > /dev/null 2>&1 +} + +unload_launch_plist() { + local plist="$1" + local needs_sudo="${2:-false}" + local deadline="${3:-}" + can_unload_launch_plist "$plist" || return 0 + local unload_timeout="$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" + if [[ -n "$deadline" ]]; then + unload_timeout=$(_mole_timeout_with_deadline "$unload_timeout" \ + "$deadline") || return $? + fi + if [[ "$needs_sudo" == "true" ]]; then + local unload_rc=0 + run_with_timeout "$unload_timeout" sudo launchctl \ + unload "$plist" > /dev/null 2>&1 || unload_rc=$? + else + local unload_rc=0 + run_with_timeout "$unload_timeout" launchctl \ + unload "$plist" > /dev/null 2>&1 || unload_rc=$? + fi + [[ $unload_rc -eq 124 || $unload_rc -ge 128 ]] && return "$unload_rc" + return 0 +} + +_uninstall_unload_launch_plists() { + local root="$1" + local needs_sudo="$2" + local bundle_id="${3:-}" + local app_path="${4:-}" + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE="${_MOLE_UNINSTALL_DISCOVERY_DEADLINE:-$((SECONDS + MOLE_TIMEOUT_DISK_VERIFY_SEC))}" + [[ -d "$root" ]] || return 0 + + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_rc=0 + if [[ -n "$bundle_id" ]]; then + _mole_uninstall_materialize_find0 "$scan_file" "$root" \ + -maxdepth 1 \( -name "${bundle_id}.plist" -o \ + -name "${bundle_id}.*.plist" \) -print0 || scan_rc=$? + else + _mole_uninstall_materialize_find0 "$scan_file" "$root" \ + -maxdepth 1 -name '*.plist' -print0 || scan_rc=$? + fi + if [[ $scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi + + local plist + local result_rc=0 + while IFS= read -r -d '' plist; do + if [[ -n "$app_path" ]]; then + local grep_rc=0 + local grep_timeout="" + grep_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$_MOLE_UNINSTALL_DISCOVERY_DEADLINE") || grep_rc=$? + if [[ $grep_rc -eq 0 ]]; then + run_with_timeout "$grep_timeout" grep -qF -- \ + "$app_path" "$plist" 2> /dev/null || grep_rc=$? + fi + [[ $grep_rc -eq 124 || $grep_rc -ge 128 ]] && { + result_rc=$grep_rc + break + } + [[ $grep_rc -eq 0 ]] || continue + fi + local unload_rc=0 + unload_launch_plist "$plist" "$needs_sudo" \ + "$_MOLE_UNINSTALL_DISCOVERY_DEADLINE" || unload_rc=$? + if [[ $unload_rc -eq 124 || $unload_rc -ge 128 ]]; then + result_rc=$unload_rc + break + fi + done < "$scan_file" + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ $result_rc -ne 0 ]]; then + return "$result_rc" + fi + return 0 +} + +# Unload Launch Agents/Daemons for an app. +# Plist deletion is owned by remove_file_list so every removal goes through the +# same validated path list and Trash/permanent deletion mode. # Security: bundle_id is validated to be reverse-DNS format before use in find patterns stop_launch_services() { local bundle_id="$1" local has_system_files="${2:-false}" + local app_path="${3:-}" if is_uninstall_dry_run; then debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id" return 0 fi - [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 - - # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) - # This prevents glob injection attacks if bundle_id contains special characters - if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then + # The bundle-id-keyed unloads below need a valid reverse-DNS id, but the + # app-path scan further down does not, and it must still run when the + # sibling guard demoted the bundle id to "unknown": name-globbed agent + # plists are deleted by remove_file_list, and skipping the unload here + # would leave their jobs loaded in launchd until logout. + local bundle_id_usable=true + if [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]]; then + bundle_id_usable=false + elif ! mole_is_reverse_dns_bundle_id "$bundle_id"; then + # Validate bundle_id format: must be reverse-DNS style (e.g., + # com.example.app). This prevents glob injection attacks if bundle_id + # contains special characters. debug_log "Invalid bundle_id format for LaunchAgent search: $bundle_id" - return 0 + bundle_id_usable=false fi - if [[ -d ~/Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - launchctl unload "$plist" 2> /dev/null || true - done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + if [[ "$bundle_id_usable" == "true" ]] && [[ -d ~/Library/LaunchAgents ]]; then + _uninstall_unload_launch_plists \ + "$HOME/Library/LaunchAgents" false "$bundle_id" || return $? fi - if [[ "$has_system_files" == "true" ]]; then + if [[ "$bundle_id_usable" == "true" && "$has_system_files" == "true" && "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then if [[ -d /Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + _uninstall_unload_launch_plists \ + /Library/LaunchAgents true "$bundle_id" || return $? fi if [[ -d /Library/LaunchDaemons ]]; then - while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + _uninstall_unload_launch_plists \ + /Library/LaunchDaemons true "$bundle_id" || return $? + fi + fi + + # Scan for LaunchAgents whose ProgramArguments reference the app path. + # Catches agents with bundle IDs that don't match the app's bundle ID. + # Enumerate with find -print0 and probe each plist with grep -qF: + # "grep -rlZ" is not portable on macOS (BSD grep treats -Z as + # --decompress and prints newline-separated names), which left this scan + # silently dead inside a NUL-delimited read loop. + if [[ -n "$app_path" ]]; then + if [[ -d ~/Library/LaunchAgents ]]; then + _uninstall_unload_launch_plists \ + "$HOME/Library/LaunchAgents" false "" "$app_path" || return $? + fi + if [[ "$has_system_files" == "true" && "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + if [[ -d /Library/LaunchAgents ]]; then + _uninstall_unload_launch_plists \ + /Library/LaunchAgents true "" "$app_path" || return $? + fi + if [[ -d /Library/LaunchDaemons ]]; then + _uninstall_unload_launch_plists \ + /Library/LaunchDaemons true "" "$app_path" || return $? + fi fi fi } @@ -133,9 +470,11 @@ unregister_app_bundle() { [[ "${MOLE_DRY_RUN:-0}" == "1" ]] && return 0 - set +e - "$lsregister" -u "$app_path" > /dev/null 2>&1 - set -e + local unregister_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" "$lsregister" \ + -u "$app_path" > /dev/null 2>&1 || unregister_rc=$? + [[ $unregister_rc -eq 124 || $unregister_rc -ge 128 ]] && return "$unregister_rc" + return 0 } # Compact and rebuild LaunchServices after uninstall batch to clear stale app metadata. @@ -150,17 +489,17 @@ refresh_launch_services_after_uninstall() { set +e # Add 10s timeout to prevent hanging (gc is usually fast) # run_with_timeout falls back to shell implementation if timeout command unavailable - run_with_timeout 10 "$lsregister" -gc > /dev/null 2>&1 || true - # Add 15s timeout for rebuild (can be slow on some systems) + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -gc > /dev/null 2>&1 || true + # 15s: lsregister rebuild can be slow on some systems, see lib/core/timeouts.sh run_with_timeout 15 "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1 success=$? # 124 = timeout exit code (from run_with_timeout or timeout command) if [[ $success -eq 124 ]]; then debug_log "LaunchServices rebuild timed out, trying lighter version" - run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 success=$? elif [[ $success -ne 0 ]]; then - run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 success=$? fi set -e @@ -186,35 +525,53 @@ remove_login_item() { # Remove from Login Items using index-based deletion (handles broken items) if [[ -n "$clean_name" ]]; then - # Escape double quotes and backslashes for AppleScript - local escaped_name="${clean_name//\\/\\\\}" - escaped_name="${escaped_name//\"/\\\"}" - - osascript <<- EOF > /dev/null 2>&1 || true - tell application "System Events" - try - set itemCount to count of login items - -- Delete in reverse order to avoid index shifting - repeat with i from itemCount to 1 by -1 - try - set itemName to name of login item i - if itemName is "$escaped_name" then - delete login item i - end if - end try - end repeat - end try - end tell - EOF + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + # Escape double quotes and backslashes for AppleScript + local escaped_name="${clean_name//\\/\\\\}" + escaped_name="${escaped_name//\"/\\\"}" + + local login_item_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" osascript \ + > /dev/null 2>&1 <<- EOF || login_item_rc=$? + tell application "System Events" + try + set itemCount to count of login items + -- Delete in reverse order to avoid index shifting + repeat with i from itemCount to 1 by -1 + try + set itemName to name of login item i + if itemName is "$escaped_name" then + delete login item i + end if + end try + end repeat + end try + end tell + EOF + [[ $login_item_rc -eq 124 || $login_item_rc -ge 128 ]] && return "$login_item_rc" + fi fi } # Remove files (handles symlinks, optional sudo). # Security: All paths pass validate_path_for_deletion() before any deletion. +# Performance: when MOLE_DELETE_MODE=trash and the batch is sudo-free and +# symlink-free, eligible paths share one guarded helper invocation. The helper +# binds physical parent/target identities and uses direct Trash renames, avoiding +# Finder/AppleScript startup per item without trusting a stale lexical batch. remove_file_list() { local file_list="$1" local use_sudo="${2:-false}" local count=0 + local mode="${MOLE_DELETE_MODE:-permanent}" + + local -a trash_batch=() + local -a fallback_paths=() + _MOLE_TRASH_BATCH_SNAPSHOT_PATHS=() + _MOLE_TRASH_BATCH_SNAPSHOT_PARENTS=() + _MOLE_TRASH_BATCH_SNAPSHOT_PARENT_IDS=() + _MOLE_TRASH_BATCH_SNAPSHOT_TARGET_IDS=() while IFS= read -r file; do [[ -n "$file" && -e "$file" ]] || continue @@ -223,135 +580,1053 @@ remove_file_list() { continue fi - if [[ -L "$file" ]]; then - safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true - else - if [[ "$use_sudo" == "true" ]]; then - if is_uninstall_dry_run; then - debug_log "[DRY RUN] Would sudo remove: $file" - ((++count)) - else - safe_sudo_remove "$file" && ((++count)) || true - fi + if [[ "$use_sudo" == "true" ]] && is_uninstall_dry_run; then + debug_log "[DRY RUN] Would sudo remove: $file" + ((++count)) + continue + fi + + # Symlinks, sudo-required paths, app bundles, and TCC-managed app data + # stay on the per-file mole_delete path. The latter targets bypass + # third-party Trash tools and Finder inside _mole_move_to_trash. + if [[ "$mode" == "trash" && "$use_sudo" != "true" && ! -L "$file" ]] && + ! _mole_path_requires_direct_trash "$file" && + ! is_uninstall_dry_run; then + if _mole_snapshot_path_identity "$file"; then + trash_batch+=("$file") + _MOLE_TRASH_BATCH_SNAPSHOT_PATHS+=("$file") + _MOLE_TRASH_BATCH_SNAPSHOT_PARENTS+=("$_MOLE_PATH_SNAPSHOT_PARENT") + _MOLE_TRASH_BATCH_SNAPSHOT_PARENT_IDS+=("$_MOLE_PATH_SNAPSHOT_PARENT_ID") + _MOLE_TRASH_BATCH_SNAPSHOT_TARGET_IDS+=("$_MOLE_PATH_SNAPSHOT_TARGET_ID") else - safe_remove "$file" true && ((++count)) || true + debug_log "Skipped Trash batch path with unstable identity: $file" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "SKIPPED" "$file" "path identity unavailable" fi + else + fallback_paths+=("$file") fi done <<< "$file_list" + if [[ ${#trash_batch[@]} -gt 0 ]]; then + local batch_rc=0 + _mole_move_to_trash_batch "${trash_batch[@]}" || batch_rc=$? + if [[ $batch_rc -eq 0 && ${#_MOLE_TRASH_BATCH_MOVED_PATHS[@]} -eq 0 ]]; then + # Test doubles and compatible older helpers report all-or-nothing + # success without populating the optional moved-path ledger. + _MOLE_TRASH_BATCH_MOVED_PATHS=("${trash_batch[@]}") + fi + local _bp _bsize + if [[ ${#_MOLE_TRASH_BATCH_MOVED_PATHS[@]} -gt 0 ]]; then + for _bp in "${_MOLE_TRASH_BATCH_MOVED_PATHS[@]}"; do + _bsize="unknown" + _mole_delete_log "trash" "$_bsize" "ok" "$_bp" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "TRASHED" "$_bp" "batch" + count=$((count + 1)) + done + fi + if [[ $batch_rc -ne 0 ]]; then + # Do not hand paths whose identity changed to a second lexical sink. + # A failed direct move leaves that item in place for manual review. + debug_log "Trash batch stopped; unmoved paths were preserved" + fi + [[ $batch_rc -eq 124 || $batch_rc -ge 128 ]] && return "$batch_rc" + fi + + if [[ ${#fallback_paths[@]} -gt 0 ]]; then + local fb + for fb in "${fallback_paths[@]}"; do + # mole_delete routes through Trash when MOLE_DELETE_MODE=trash + # (uninstall default) and only uses safe_* permanent removal when + # the caller explicitly selected permanent mode. See #723. + local delete_rc=0 + mole_delete "$fb" "$use_sudo" || delete_rc=$? + [[ $delete_rc -eq 124 || $delete_rc -ge 128 ]] && return "$delete_rc" + [[ $delete_rc -eq 0 ]] && count=$((count + 1)) + done + fi + echo "$count" } -# Batch uninstall with single confirmation. -batch_uninstall_applications() { - local total_size_freed=0 +# Distinct installs can share one bundle id (Xcode.app and Xcode-beta.app are +# both com.apple.dt.Xcode). When a sibling install with the same bundle id +# stays on disk and is not part of the current selection, bundle-id-derived +# leftovers (caches, preferences, containers, launch services) still belong to +# the surviving install and must not be touched by this uninstall. +# +# Siblings under /Volumes/* count on purpose. Exact mirror clones never reach +# this check (the scan dedupe collapses same-basename rows and keeps the live +# path), so a /Volumes row here means a same-bundle app the scan considers a +# distinct install. Apps genuinely run from an external volume use the same +# $HOME bundle-id data, and skipping that data is the safe failure mode: worst +# case a few leftover files stay behind, versus deleting state a real install +# still uses. +# Reads apps_data and selected_apps from the caller's scope via dynamic +# scoping; both may be unset when batch.sh is exercised standalone in tests. +# Lowercase a bundle id for sibling comparison. +# +# Bundle ids are case-PRESERVING but not case-SENSITIVE for the paths a cask +# zap stanza and the name-derived cleanup actually touch: on a default APFS +# volume `~/Library/Preferences/com.Foo.Bar.plist` and `com.foo.bar.plist` are +# the same file. Comparing the ids literally therefore let a survivor whose id +# differs only in case slip the guard, and the uninstall then wiped the data +# both apps share. +# +# `LC_ALL=C tr` rather than `${var,,}`: this repo still supports bash 3.2. +uninstall_normalize_bundle_id() { + printf '%s' "$1" | LC_ALL=C tr '[:upper:]' '[:lower:]' +} - # shellcheck disable=SC2154 - if [[ ${#selected_apps[@]} -eq 0 ]]; then - log_warning "No applications selected for uninstallation" +# A preview-time inventory cannot authorize bundle-id teardown: an app may be +# mounted, installed, or copied into place while the confirmation screen is +# open. This bounded scan is deliberately stricter than UI discovery. Every +# root must complete, and every candidate bundle id must be readable, before +# absence is trusted. +_MOLE_UNINSTALL_LIVE_APP_ROOTS=( + "/Applications" + "$HOME/Applications" + "/System/Applications" + "/Library/Input Methods" + "$HOME/Library/Input Methods" + "$HOME/Library/Application Support/Setapp/Applications" + "/opt/homebrew/Caskroom" + "/usr/local/Caskroom" +) +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="/Volumes" + +# A same-bundle scan that ran but could not read every path. Distinct from both +# success and failure on purpose: the listing it produced is real, so it can +# still prove a sibling exists, but it can never prove one does not. Callers +# must treat it as "a sibling may be there" and narrow the plan accordingly. +# 3 is safe to add to the 0/1/124/128+ set these scans already speak. +readonly MOLE_UNINSTALL_SCAN_PARTIAL=3 + +_uninstall_materialize_complete_find0() { + local output_file="$1" + local deadline_seconds="$2" + shift 2 + + : > "$output_file" || return 1 + local scan_timeout="" + local scan_rc=0 + scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$deadline_seconds") || scan_rc=$? + if [[ $scan_rc -eq 0 ]]; then + # Keep find's stderr. It is the only way to tell "could not read one + # path" apart from "did not run": find exits 1 for an unreadable + # subdirectory even though it traversed and printed everything else, + # and macOS 26 hands out that error routinely under TCC. Discarding it + # made every such run look like a dead scan, which aborted the whole + # uninstall over a directory that had nothing to do with the app + # (#1339, #1340). + local scan_errors="" + scan_errors=$(create_temp_file) || return 2 + run_with_timeout "$scan_timeout" find "$@" -print0 \ + < /dev/null > "$output_file" 2> "$scan_errors" || scan_rc=$? + if [[ $scan_rc -eq 1 && -s "$scan_errors" ]]; then + # Partial view: the listing is real but not exhaustive, so it can + # support "something is there" and never "nothing is there". + scan_rc="$MOLE_UNINSTALL_SCAN_PARTIAL" + fi + rm -f -- "$scan_errors" 2> /dev/null || true # SAFE: exact tracked temp file created above + fi + if [[ $scan_rc -ne 0 && $scan_rc -ne $MOLE_UNINSTALL_SCAN_PARTIAL ]]; then + : > "$output_file" || true + return "$scan_rc" + fi + return "$scan_rc" +} + +_uninstall_live_candidate_is_selected() { + local candidate="$1" + local selected_path="$2" + [[ "$candidate" == "$selected_path" ]] && return 0 + if [[ (-e "$candidate" || -L "$candidate") && + (-e "$selected_path" || -L "$selected_path") && + "$candidate" -ef "$selected_path" ]]; then return 0 fi + return 1 +} - local old_trap_int old_trap_term - old_trap_int=$(trap -p INT) - old_trap_term=$(trap -p TERM) +_uninstall_live_candidate_is_nested_app() { + local root="$1" + local candidate="$2" + [[ "$candidate" == "$root" ]] && return 1 + local relative="${candidate#"$root"/}" + [[ "$relative" != "$candidate" ]] || return 0 + local parent="${relative%/*}" + [[ "$parent" != "$relative" ]] || return 1 + + local component + while [[ -n "$parent" && "$parent" != "." ]]; do + component="${parent%%/*}" + [[ "$component" == *.app ]] && return 0 + [[ "$parent" == */* ]] || break + parent="${parent#*/}" + done + return 1 +} - _cleanup_sudo_keepalive() { - if command -v stop_sudo_session > /dev/null 2>&1; then - stop_sudo_session +# The most recent complete sibling scan. The fingerprint is a newline-separated, +# sorted set of base64(path):app-identity:Info.plist-identity records. Paths stay +# separately available so the preview can prove that every live sibling was +# represented in the inventory used to build its deletion plan. +_MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" +_MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + +_uninstall_insert_sorted_live_record() { + local record="$1" + local -a inserted=() + local item + local did_insert=false + + # shellcheck disable=SC2154 # live_records is provided by the caller via dynamic scope. + for item in "${live_records[@]+"${live_records[@]}"}"; do + [[ "$item" == "$record" ]] && return 0 + if [[ "$did_insert" == false && "$record" < "$item" ]]; then + inserted+=("$record") + did_insert=true fi - } + inserted+=("$item") + done + [[ "$did_insert" == false ]] && inserted+=("$record") + live_records=("${inserted[@]}") +} - _restore_uninstall_traps() { - _cleanup_sudo_keepalive - if [[ -n "$old_trap_int" ]]; then - eval "$old_trap_int" - else - trap - INT +_uninstall_live_sibling_path_is_duplicate() { + local candidate="$1" + local existing + # shellcheck disable=SC2154 # live_paths is provided by the caller via dynamic scope. + for existing in "${live_paths[@]+"${live_paths[@]}"}"; do + [[ "$candidate" == "$existing" ]] && return 0 + if [[ (-e "$candidate" || -L "$candidate") && + (-e "$existing" || -L "$existing") && + "$candidate" -ef "$existing" ]]; then + return 0 fi - if [[ -n "$old_trap_term" ]]; then - eval "$old_trap_term" - else - trap - TERM + done + return 1 +} + +_uninstall_live_sibling_record() { + local app="$1" + local info="$2" + local deadline_seconds="$3" + local identity_timeout="" + local identity_rc=0 + identity_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "$deadline_seconds") || identity_rc=$? + + local app_identity="" + local info_identity="" + if [[ $identity_rc -eq 0 ]]; then + app_identity=$(run_with_timeout "$identity_timeout" "$STAT_BSD" \ + -f%d:%i:%m "$app" 2> /dev/null) || identity_rc=$? + fi + if [[ $identity_rc -eq 0 ]]; then + identity_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "$deadline_seconds") || identity_rc=$? + fi + if [[ $identity_rc -eq 0 ]]; then + info_identity=$(run_with_timeout "$identity_timeout" "$STAT_BSD" \ + -f%d:%i:%m "$info" 2> /dev/null) || identity_rc=$? + fi + [[ $identity_rc -eq 0 ]] || return "$identity_rc" + [[ "$app_identity" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]] || return 2 + [[ "$info_identity" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]] || return 2 + + local encoded_path="" + encoded_path=$(printf '%s' "$app" | base64 | tr -d '\n') || return 2 + printf '%s:%s:%s\n' "$encoded_path" "$app_identity" "$info_identity" +} + +_uninstall_materialize_complete_pkg_apps() { + local output_file="$1" + local deadline_seconds="$2" + : > "$output_file" || return 2 + declare -f pkg_receipt_nonstandard_app_paths > /dev/null 2>&1 || return 2 + + local remaining="" + local remaining_rc=0 + remaining=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$deadline_seconds") || remaining_rc=$? + [[ $remaining_rc -eq 0 ]] || return "$remaining_rc" + + # Allow the on-disk receipt cache (#1383). A cold walk of every non-Apple + # package on an Xcode machine can burn the whole discovery budget; the + # cache is keyed by a 1h TTL and only stores nonstandard .app paths, which + # is enough for the sibling check. A stale miss is still fail-closed: + # timeout/incomplete paths degrade to MOLE_UNINSTALL_SCAN_PARTIAL and + # narrow the plan rather than deleting shared leftovers. + local producer_rc=0 + MOLE_PKG_RECEIPT_LIST_TIMEOUT="$remaining" \ + MOLE_PKG_RECEIPT_SCAN_TIMEOUT="$remaining" \ + pkg_receipt_nonstandard_app_paths \ + --require-complete > "$output_file" || producer_rc=$? + if [[ $producer_rc -ne 0 ]]; then + : > "$output_file" || true + return "$producer_rc" + fi + return 0 +} + +_uninstall_collect_live_sibling_candidate() { + local app="$1" + local selected_path="$2" + local bundle_id_lower="$3" + local deadline_seconds="$4" + local missing_info_is_unknown="$5" + + _uninstall_live_candidate_is_selected "$app" "$selected_path" && return 1 + local info="$app/Contents/Info.plist" + if [[ ! -f "$info" ]]; then + # iOS and iPadOS apps installed on Apple Silicon have no Contents/ at + # all: the real plist sits at Wrapper/.app/Info.plist. Reading + # only the Contents/ path classified every one of them as unreadable, + # and a single such app aborted the uninstall of every other app on + # the machine (#1339). They are ordinary installs, not a mystery. + local wrapped="" + for wrapped in "$app"/Wrapper/*.app/Info.plist; do + if [[ -f "$wrapped" ]]; then + info="$wrapped" + break + fi + done + if [[ ! -f "$info" ]]; then + [[ "$missing_info_is_unknown" == true ]] && return 2 + return 1 + fi + fi + + local plist_timeout="" + local plist_rc=0 + plist_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "$deadline_seconds") || plist_rc=$? + local app_bundle="" + if [[ $plist_rc -eq 0 ]]; then + app_bundle=$(run_with_timeout "$plist_timeout" plutil \ + -extract CFBundleIdentifier raw "$info" \ + 2> /dev/null) || plist_rc=$? + fi + if [[ $plist_rc -ne 0 || -z "$app_bundle" ]]; then + [[ $plist_rc -eq 124 || $plist_rc -ge 128 ]] && return "$plist_rc" + # A plist that parses and simply carries no CFBundleIdentifier is a + # complete answer, not a failed probe: vendor uninstallers and Steam + # launchers ship bundles like that, and one with no id cannot share an + # id with the target. Ask plutil whether the file parsed rather than + # reading its exit code, which is 1 for a missing key, a corrupt file, + # and an unreadable file alike (measured), or its message, which is + # prose. Only a file that will not parse stays unknown. + local lint_rc=0 + local lint_timeout="" + if lint_timeout=$(_mole_timeout_with_deadline \ + "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "$deadline_seconds"); then + run_with_timeout "$lint_timeout" plutil -lint "$info" \ + > /dev/null 2>&1 || lint_rc=$? + [[ $lint_rc -eq 124 || $lint_rc -ge 128 ]] && return "$lint_rc" + [[ $lint_rc -eq 0 ]] && return 1 fi + return 2 + fi + [[ "$(uninstall_normalize_bundle_id "$app_bundle")" == "$bundle_id_lower" ]] || return 1 + _uninstall_live_sibling_path_is_duplicate "$app" && return 1 + + local live_record="" + local record_rc=0 + live_record=$(_uninstall_live_sibling_record \ + "$app" "$info" "$deadline_seconds") || record_rc=$? + [[ $record_rc -eq 0 ]] || return "$record_rc" + # shellcheck disable=SC2154 # live_paths/live_records are caller-owned snapshot arrays. + live_paths+=("$app") + _uninstall_insert_sorted_live_record "$live_record" + return 0 +} + +# Return 0 for one or more other live installs, 1 only for a complete +# proof of absence, 2 for incomplete/unknown state, and preserve signals. +# A deadline timeout degrades to MOLE_UNINSTALL_SCAN_PARTIAL: out of budget +# means the scan is incomplete, not that the user cancelled, and machine-wide +# work such as receipt enumeration can outlive the budget on a healthy Mac +# (#1340). A successful scan always refreshes the fingerprint globals. +uninstall_live_bundle_has_other_install() { + local bundle_id="$1" + local selected_path="$2" + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + + local deadline_seconds=$((SECONDS + (2 * MOLE_TIMEOUT_DISK_VERIFY_SEC))) + local bundle_id_lower + bundle_id_lower=$(uninstall_normalize_bundle_id "$bundle_id") + local scan_indeterminate=false + local scan_file="" + scan_file=$(create_temp_file) || return 2 + local pkg_paths_file="" + pkg_paths_file=$(create_temp_file) || { + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return 2 } - # Trap to clean up spinner, sudo keepalive, and uninstall mode on interrupt - trap 'stop_inline_spinner 2>/dev/null; _cleanup_sudo_keepalive; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM + local pkg_scan_rc=0 + _uninstall_materialize_complete_pkg_apps "$pkg_paths_file" \ + "$deadline_seconds" || pkg_scan_rc=$? + if [[ $pkg_scan_rc -eq 124 ]]; then + # Receipt enumeration walks every pkgutil receipt on the machine, and + # a single vendor receipt can hold tens of thousands of paths, so it + # can outlive the budget on a healthy Mac (#1340). That is the same + # doubt as an unreadable path: the receipts we did not reach may name + # a sibling, so carry the doubt forward instead of ending the run. + scan_indeterminate=true + elif [[ $pkg_scan_rc -ne 0 ]]; then + rm -f -- "$scan_file" "$pkg_paths_file" 2> /dev/null || true # SAFE: exact tracked temp files created above + [[ $pkg_scan_rc -ge 128 ]] && return "$pkg_scan_rc" + return 2 + fi - # Pre-scan: running apps, sudo needs, size. - local -a running_apps=() - local -a sudo_apps=() - local total_estimated_size=0 - local -a app_details=() + local -a live_roots=() + local configured_root + for configured_root in "${_MOLE_UNINSTALL_LIVE_APP_ROOTS[@]+"${_MOLE_UNINSTALL_LIVE_APP_ROOTS[@]}"}"; do + live_roots+=("$configured_root") + done + if [[ -d "$_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT" ]]; then + local volume_roots_file="" + volume_roots_file=$(create_temp_file) || { + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return 2 + } + local volume_scan_rc=0 + _uninstall_materialize_complete_find0 "$volume_roots_file" \ + "$deadline_seconds" "$_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT" \ + -mindepth 2 -maxdepth 2 \ + \( \ + \( -type d -name Applications \) -o \ + \( \( -type d -o -type l \) -name '*.app' \) \ + \) || volume_scan_rc=$? + if [[ $volume_scan_rc -eq $MOLE_UNINSTALL_SCAN_PARTIAL || $volume_scan_rc -eq 124 ]]; then + # Some volume was unreadable, or the budget ran out before every + # volume was listed. Keep the roots we did see and carry the + # doubt forward: absence can no longer be proven from here. + scan_indeterminate=true + elif [[ $volume_scan_rc -ne 0 ]]; then + rm -f -- "$volume_roots_file" "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp files created above + [[ $volume_scan_rc -ge 128 ]] && return "$volume_scan_rc" + return 2 + fi + local volume_root + while IFS= read -r -d '' volume_root; do + live_roots+=("$volume_root") + done < "$volume_roots_file" + rm -f -- "$volume_roots_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + fi + + local root app + local -a live_records=() + local -a live_paths=() + local result=1 + for root in "${live_roots[@]+"${live_roots[@]}"}"; do + [[ -e "$root" ]] || continue + if [[ ! -d "$root" || ! -r "$root" ]]; then + result=2 + break + fi + # Match the inventory's bounded app-root traversal. Receipt-backed + # non-standard paths are supplied separately as exact candidates. + local scan_rc=0 + _uninstall_materialize_complete_find0 "$scan_file" \ + "$deadline_seconds" "$root" -maxdepth 3 \ + \( -type d -o -type l \) -name '*.app' || scan_rc=$? + if [[ $scan_rc -eq $MOLE_UNINSTALL_SCAN_PARTIAL ]]; then + # Unreadable subpaths under an app root. The apps this listing did + # find are still real, so keep going and let the doubt decide the + # verdict at the end rather than discarding the whole scan. + scan_indeterminate=true + elif [[ $scan_rc -ne 0 ]]; then + [[ $scan_rc -eq 124 || $scan_rc -ge 128 ]] && result=$scan_rc || result=2 + break + fi + + while IFS= read -r -d '' app; do + # Nested helpers belong to their containing app, not a distinct + # installation root. + _uninstall_live_candidate_is_nested_app "$root" "$app" && continue + local candidate_rc=0 + _uninstall_collect_live_sibling_candidate \ + "$app" "$selected_path" "$bundle_id_lower" \ + "$deadline_seconds" false || candidate_rc=$? + if [[ $candidate_rc -eq 0 ]]; then + result=0 + elif [[ $candidate_rc -ne 1 ]]; then + [[ $candidate_rc -eq 124 || $candidate_rc -ge 128 ]] && result=$candidate_rc || result=2 + break + fi + done < "$scan_file" + [[ $result -eq 2 || $result -eq 124 || $result -ge 128 ]] && break + done + + if [[ $result -ne 2 && $result -ne 124 && $result -lt 128 ]]; then + local pkg_app + while IFS= read -r pkg_app; do + [[ -n "$pkg_app" ]] || continue + local candidate_rc=0 + _uninstall_collect_live_sibling_candidate \ + "$pkg_app" "$selected_path" "$bundle_id_lower" \ + "$deadline_seconds" true || candidate_rc=$? + if [[ $candidate_rc -eq 0 ]]; then + result=0 + elif [[ $candidate_rc -ne 1 ]]; then + [[ $candidate_rc -eq 124 || $candidate_rc -ge 128 ]] && result=$candidate_rc || result=2 + break + fi + done < "$pkg_paths_file" + fi + + rm -f -- "$scan_file" "$pkg_paths_file" 2> /dev/null || true # SAFE: exact tracked temp files created above + if [[ $result -eq 0 ]]; then + local IFS=$'\n' + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="${live_records[*]}" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=("${live_paths[@]}") + fi + # Absence is a claim only an exhaustive scan can make. A partial one that + # found nothing means "not seen", which for a delete decision has to read + # as "may exist" so the caller keeps the narrow plan. + if [[ "$scan_indeterminate" == true && "$result" -eq 1 ]]; then + result="$MOLE_UNINSTALL_SCAN_PARTIAL" + fi + # A per-root or per-candidate probe that ran out of budget is the same + # incomplete scan, not a user cancellation: nothing above maps 124 to a + # key press. Signals returned earlier stay untouched. + if [[ "$result" -eq 124 ]]; then + result="$MOLE_UNINSTALL_SCAN_PARTIAL" + fi + return "$result" +} + +_uninstall_decode_live_sibling_fingerprint() { + local encoded="$1" + [[ -z "$encoded" ]] && return 0 + local decoded="" + if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then + decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null) || return 1 + fi + printf '%s' "$decoded" +} + +_uninstall_live_fingerprint_without_successful_paths() { + local fingerprint="$1" + local record encoded_path decoded_path success_path + local keep + local output="" + while IFS= read -r record; do + [[ -n "$record" ]] || continue + encoded_path="${record%%:*}" + decoded_path="" + if ! decoded_path=$(printf '%s' "$encoded_path" | base64 -D 2> /dev/null); then + decoded_path=$(printf '%s' "$encoded_path" | base64 -d 2> /dev/null) || return 1 + fi + keep=true + # shellcheck disable=SC2154 # success_items is owned by the batch executor via dynamic scope. + for success_path in "${success_items[@]+"${success_items[@]}"}"; do + if [[ "$decoded_path" == "$success_path" && + ! -e "$success_path" && ! -L "$success_path" ]]; then + keep=false + break + fi + done + if [[ "$keep" == true ]]; then + [[ -n "$output" ]] && output+=$'\n' + output+="$record" + fi + done <<< "$fingerprint" + printf '%s' "$output" +} + +uninstall_bundle_id_has_surviving_sibling() { + local bundle_id="$1" + local app_path="$2" + + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 1 + + local bundle_id_lower + bundle_id_lower=$(uninstall_normalize_bundle_id "$bundle_id") + + local row other_path other_bundle other_bundle_lower + # shellcheck disable=SC2154 # apps_data is provided by bin/uninstall.sh via dynamic scope. + for row in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ other_path _ other_bundle _ _ _ <<< "$row" + other_bundle_lower=$(uninstall_normalize_bundle_id "$other_bundle") + [[ "$other_bundle_lower" == "$bundle_id_lower" ]] || continue + [[ "$other_path" == "$app_path" ]] && continue + [[ -d "$other_path" ]] || continue + + local sel selected_path is_selected=false + for sel in "${selected_apps[@]+"${selected_apps[@]}"}"; do + IFS='|' read -r _ selected_path _ _ _ _ <<< "$sel" + if [[ "$selected_path" == "$other_path" ]]; then + is_selected=true + break + fi + done + [[ "$is_selected" == true ]] && continue + + return 0 + done + + return 1 +} + +# Print the lowercased display names and .app basenames of every surviving +# same-bundle sibling (same filter as uninstall_bundle_id_has_surviving_sibling), +# one per line. Used to detect when the selected app's own names collide with +# the survivor's, in which case name-derived cleanup must be suppressed too. +uninstall_surviving_sibling_names() { + local bundle_id="$1" + local app_path="$2" + + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 + + local bundle_id_lower + bundle_id_lower=$(uninstall_normalize_bundle_id "$bundle_id") + + local row other_path other_name other_bundle other_bundle_lower + for row in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ other_path other_name other_bundle _ _ _ <<< "$row" + other_bundle_lower=$(uninstall_normalize_bundle_id "$other_bundle") + [[ "$other_bundle_lower" == "$bundle_id_lower" ]] || continue + [[ "$other_path" == "$app_path" ]] && continue + [[ -d "$other_path" ]] || continue + + local sel selected_path is_selected=false + for sel in "${selected_apps[@]+"${selected_apps[@]}"}"; do + IFS='|' read -r _ selected_path _ _ _ _ <<< "$sel" + if [[ "$selected_path" == "$other_path" ]]; then + is_selected=true + break + fi + done + [[ "$is_selected" == true ]] && continue + + local other_base="${other_path##*/}" + other_base="${other_base%.app}" + + # Emit each identifier plus its version-suffix-stripped base: a + # survivor named "Foo Beta.app" also claims "Foo"-keyed dirs via the + # stripper in find_app_files, so uninstalling "Foo.app" must treat + # "foo" as taken. + local candidate + for candidate in "$other_name" "$other_base"; do + [[ -z "$candidate" ]] && continue + printf '%s\n' "$candidate" | LC_ALL=C tr '[:upper:]' '[:lower:]' + uninstall_strip_version_suffix "$candidate" | LC_ALL=C tr '[:upper:]' '[:lower:]' + done + done + + return 0 +} +# Mirror of the version-suffix stripping inside find_app_files. Needed here +# because find_app_files derives extra patterns from the stripped base name +# ("Zed Nightly" also matches "Zed" paths), so a collision check against the +# survivor must consider the stripped form as well. +uninstall_strip_version_suffix() { + local name="$1" + local version_suffixes="Nightly|Beta|Alpha|Dev|Canary|Preview|Insider|Edge|Stable|Release|RC|LTS" + version_suffixes+="|Developer Edition|Technology Preview" + if [[ "$name" =~ ^(.+)[[:space:]]+(${version_suffixes})$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + else + printf '%s\n' "$name" + fi +} + +# Internal helpers for batch_uninstall_applications. They read and write +# locals declared in the orchestrator's scope via bash dynamic scoping; do +# not call them outside batch_uninstall_applications. + +# Phase 1: scan every selected app, classify into running/sudo/brew/blocked +# buckets, build pipe-encoded app_details records, accumulate the total +# estimated size, and warn about apps that require an official uninstaller or +# a manual Finder removal. +# Reads: selected_apps +# Writes: running_apps, sudo_apps, brew_cask_apps, blocked_apps, +# manual_removal_apps, app_details, total_estimated_size +_batch_refresh_selected_app_bundle_id() { + local app_path="$1" + local fallback_bundle_id="$2" + + [[ -d "$app_path" ]] || return 1 + if declare -f uninstall_resolve_eligible_bundle_id > /dev/null 2>&1; then + uninstall_resolve_eligible_bundle_id "$app_path" "$fallback_bundle_id" + return $? + fi + + # Standalone module tests do not source the inventory resolver. Keep their + # narrow fallback, while production always takes the eligibility path above. + [[ -n "$fallback_bundle_id" ]] || return 1 + printf '%s\n' "$fallback_bundle_id" +} + +_batch_selected_app_identity() { + local app_path="$1" + local identity="" + local identity_rc=0 + identity=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$STAT_BSD" -f%d:%i:%m "$app_path" 2> /dev/null) || identity_rc=$? + [[ $identity_rc -eq 0 ]] || return "$identity_rc" + [[ "$identity" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]] || return 1 + printf '%s\n' "$identity" +} + +_batch_selected_app_info_identity() { + local app_path="$1" + local info="$app_path/Contents/Info.plist" + if [[ ! -e "$info" && ! -L "$info" ]]; then + printf '%s\n' "missing" + return 0 + fi + + local identity="" + local identity_rc=0 + identity=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" \ + "$STAT_BSD" -f%d:%i:%m "$info" 2> /dev/null) || identity_rc=$? + [[ $identity_rc -eq 0 ]] || return "$identity_rc" + [[ "$identity" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]] || return 1 + printf '%s\n' "$identity" +} + +_batch_selected_app_plan_matches() { + local app_path="$1" + local expected_app_identity="$2" + local expected_info_identity="$3" + [[ -n "$expected_app_identity" && -n "$expected_info_identity" ]] || return 1 + + local current_app_identity="" + local current_info_identity="" + local identity_rc=0 + current_app_identity=$(_batch_selected_app_identity \ + "$app_path") || identity_rc=$? + [[ $identity_rc -eq 0 ]] || return "$identity_rc" + current_info_identity=$(_batch_selected_app_info_identity \ + "$app_path") || identity_rc=$? + [[ $identity_rc -eq 0 ]] || return "$identity_rc" + [[ "$current_app_identity" == "$expected_app_identity" && + "$current_info_identity" == "$expected_info_identity" ]] +} + +_batch_scan_app_details() { + # All selected-app discovery shares one wall-clock budget. Individual + # producer probes clamp themselves to this deadline. + local _MOLE_UNINSTALL_DISCOVERY_DEADLINE=$((SECONDS + (2 * MOLE_TIMEOUT_DISK_VERIFY_SEC))) # Cache current user outside loop local current_user=$(whoami) if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi + # shellcheck disable=SC2154 # selected_apps is provided by batch_uninstall_applications via dynamic scope. for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" + local current_bundle_id="" + local refresh_rc=0 + current_bundle_id=$(_batch_refresh_selected_app_bundle_id \ + "$app_path" "$bundle_id") || refresh_rc=$? + if [[ $refresh_rc -ge 128 ]]; then + return "$refresh_rc" + elif [[ $refresh_rc -ne 0 ]]; then + manual_removal_apps+=("$app_name") + continue + fi + bundle_id="$current_bundle_id" + local original_bundle_id="$bundle_id" + + # Bind the confirmation record to the exact bundle object that was + # inspected. A path can be replaced while the preview is open; the + # execution phase must reject that new inode instead of treating the + # same pathname as user approval. + local app_identity="" + local app_identity_rc=0 + app_identity=$(_batch_selected_app_identity "$app_path") || app_identity_rc=$? + if [[ $app_identity_rc -eq 124 || $app_identity_rc -ge 128 ]]; then + return "$app_identity_rc" + elif [[ $app_identity_rc -ne 0 ]]; then + manual_removal_apps+=("$app_name") + continue + fi + local app_info_identity="" + local app_info_identity_rc=0 + app_info_identity=$(_batch_selected_app_info_identity \ + "$app_path") || app_info_identity_rc=$? + if [[ $app_info_identity_rc -eq 124 || $app_info_identity_rc -ge 128 ]]; then + return "$app_info_identity_rc" + elif [[ $app_info_identity_rc -ne 0 ]]; then + manual_removal_apps+=("$app_name") + continue + fi + + # Leftover matching is destructive and must use the current bundle + # basename, not a display name cached when the selection list opened. + local discovery_app_name="${app_path##*/}" + discovery_app_name="${discovery_app_name%.app}" + + local official_vendor="" + if official_vendor=$(official_uninstaller_vendor "$bundle_id" "$app_name" "$app_path" 2> /dev/null); then + blocked_apps+=("$app_name|$official_vendor") + continue + fi + + # Capture the complete same-bundle installation set that this preview + # is based on. If a live sibling exists, current display names cannot + # be trusted from the older inventory that opened the selection UI. + # Narrow the plan to the selected app bundle only: no bundle-id/name + # leftovers, login item, process, helper, or Homebrew zap teardown. + # Execution compares the exact snapshot before its first side effect. + local live_sibling_rc=0 + local live_sibling_present=false + uninstall_live_bundle_has_other_install \ + "$original_bundle_id" "$app_path" || live_sibling_rc=$? + if [[ $live_sibling_rc -eq 0 ]]; then + live_sibling_present=true + elif [[ $live_sibling_rc -eq 1 ]]; then + : # Complete absence proof; the empty fingerprint is authoritative. + elif [[ $live_sibling_rc -eq $MOLE_UNINSTALL_SCAN_PARTIAL ]]; then + # The scan ran but could not read every path, so it cannot rule a + # sibling out. Treat that exactly like finding one: narrow the plan + # and keep going. Aborting here is what left `mo uninstall` exiting + # 1 with a debug-only line for apps whose scan touched anything TCC + # protects (#1339, #1340). + live_sibling_present=true + log_warning "$(printf "%s: some paths could not be read, so shared leftovers are left in place" "$app_name")" + elif [[ $live_sibling_rc -ge 128 ]]; then + return "$live_sibling_rc" + else + # This refusal ends the whole batch, so it must say so on the + # normal screen: the debug-only line left users with a silent + # exit and no way to report the cause (#1340). + log_error "Could not verify whether other installs share ${app_name}'s bundle id; nothing was removed" + debug_log "Could not complete the live same-bundle scan for $app_name" + return 1 + fi + local preview_live_sibling_fingerprint="$_MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT" + + # Receipt enumeration is machine-wide and can consume the whole shared + # discovery budget on an Xcode Mac (#1383). Guarantee a minimum floor + # for the selected-app remnant walk so a long sibling scan cannot + # starve leftover matching and hard-abort the batch with 124. + local remnant_floor=$((SECONDS + MOLE_TIMEOUT_HINT_SCAN_SEC)) + if ((_MOLE_UNINSTALL_DISCOVERY_DEADLINE < remnant_floor)); then + debug_log "Extending uninstall discovery deadline by ${MOLE_TIMEOUT_HINT_SCAN_SEC}s for remnant scan of $app_name" + _MOLE_UNINSTALL_DISCOVERY_DEADLINE=$remnant_floor + fi + + local sibling_guard="none" + if [[ "$live_sibling_present" == true ]]; then + sibling_guard="guard_login" + discovery_app_name="" + debug_log "Bundle id $bundle_id is shared with a live sibling; removing only the selected app bundle for $app_name" + bundle_id="unknown" + elif uninstall_bundle_id_has_surviving_sibling "$bundle_id" "$app_path"; then + sibling_guard="guard" + + local survivor_names + survivor_names=$(uninstall_surviving_sibling_names "$bundle_id" "$app_path") + local discovery_lower discovery_base_lower display_lower + discovery_lower=$(printf '%s' "$discovery_app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + discovery_base_lower=$(uninstall_strip_version_suffix "$discovery_app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + display_lower=$(printf '%s' "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + + local survivor_name login_name_collides=false + while IFS= read -r survivor_name; do + [[ -z "$survivor_name" ]] && continue + # Equality catches the display-name collapse. The substring + # direction catches the inverse case: uninstalling "Foo.app" + # while "Foo-beta.app" survives. Downstream matchers are + # substring-based (the LaunchAgents scan globs + # "**.plist"), so a discovery name contained anywhere + # in a survivor identifier can still reach survivor data. + # Reverse containment (survivor inside discovery) stays + # allowed: patterns keyed on the longer "Foo-beta" cannot + # match the survivor's shorter "Foo"-keyed paths. + if [[ "$discovery_lower" == "$survivor_name" || "$discovery_base_lower" == "$survivor_name" || + "$survivor_name" == *"$discovery_lower"* || "$survivor_name" == *"$discovery_base_lower"* ]]; then + discovery_app_name="" + fi + # Login items are registered under the display name; when that + # string also belongs to the survivor, deleting it by name + # would remove the survivor's login item. + if [[ "$display_lower" == "$survivor_name" ]]; then + login_name_collides=true + fi + done <<< "$survivor_names" + if [[ -z "$discovery_app_name" ]]; then + login_name_collides=true + fi + [[ "$login_name_collides" == true ]] && sibling_guard="guard_login" + + if [[ -n "$discovery_app_name" ]]; then + debug_log "Bundle id $bundle_id shared with a surviving install; restricting $app_name leftovers to name/path matches for '$discovery_app_name'" + else + debug_log "Bundle id $bundle_id shared with a surviving install and names collide; removing only the app bundle for $app_name" + fi + bundle_id="unknown" + fi + # Check running app by bundle executable if available local exec_name="" local info_plist="$app_path/Contents/Info.plist" if [[ -e "$info_plist" ]]; then - exec_name=$(defaults read "$info_plist" CFBundleExecutable 2> /dev/null || echo "") + exec_name=$(plutil -extract CFBundleExecutable raw "$info_plist" 2> /dev/null || echo "") fi if pgrep -qx "${exec_name:-$app_name}" 2> /dev/null; then running_apps+=("$app_name") fi local cask_name="" is_brew_cask="false" - local resolved_path=$(readlink "$app_path" 2> /dev/null || echo "") - if [[ "$resolved_path" == */Caskroom/* ]]; then - # Extract cask name using bash parameter expansion (faster than sed) - local tmp="${resolved_path#*/Caskroom/}" - cask_name="${tmp%%/*}" - [[ -n "$cask_name" ]] && is_brew_cask="true" - elif command -v get_brew_cask_name > /dev/null 2>&1; then - local detected_cask - detected_cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + if command -v get_brew_cask_name > /dev/null 2>&1; then + local detected_cask="" + local cask_detect_rc=0 + detected_cask=$(get_brew_cask_name "$app_path" 2> /dev/null) || cask_detect_rc=$? + if [[ $cask_detect_rc -eq 124 || $cask_detect_rc -ge 128 ]]; then + return "$cask_detect_rc" + elif [[ $cask_detect_rc -ne 0 && $cask_detect_rc -ne 1 ]]; then + return "$cask_detect_rc" + fi if [[ -n "$detected_cask" ]]; then cask_name="$detected_cask" is_brew_cask="true" fi fi - # Check if sudo is needed + if [[ "$is_brew_cask" == "true" ]]; then + brew_cask_apps+=("$app_name") + fi + + # A Trash rename is authorized by the source and destination parents, + # not by the app bundle's owner. Do not elevate solely because a + # package-installed app is root-owned when its parent is user-writable; + # file_ops can retry a TCC-blocked rename through unprivileged Finder. + # Permanent removal still treats foreign ownership as requiring sudo. local needs_sudo=false local app_owner=$(get_file_owner "$app_path") + local delete_mode="${MOLE_DELETE_MODE:-permanent}" if [[ ! -w "$(dirname "$app_path")" ]] || - [[ "$app_owner" == "root" ]] || - [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then + { [[ "$delete_mode" != "trash" ]] && + { [[ "$app_owner" == "root" ]] || + [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; }; }; then needs_sudo=true fi - local app_size_kb=$(get_path_size_kb "$app_path" || echo "0") - local related_files=$(find_app_files "$bundle_id" "$app_name" || true) - local diag_user - diag_user=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "$HOME/Library/Logs/DiagnosticReports" || true) - [[ -n "$diag_user" ]] && related_files=$( - [[ -n "$related_files" ]] && echo "$related_files" - echo "$diag_user" - ) - local related_size_kb=$(calculate_total_size "$related_files" || echo "0") + # A privileged path-based removal below an invoking-user-mutable + # ancestor cannot bind the path we previewed to the object root later + # removes. Reject it before leftover discovery, sudo authorization, or + # any launch/login/process teardown. Homebrew casks stay on their + # package-manager path and never use this direct-app preflight. + if [[ "$needs_sudo" == true && "$is_brew_cask" != "true" ]] && + _mole_privileged_path_has_mutable_ancestor "$app_path"; then + manual_removal_apps+=("$app_name") + continue + fi + + local app_size_kb="0" + local app_size_rc=0 + app_size_kb=$(get_path_size_kb "$app_path") || app_size_rc=$? + [[ $app_size_rc -eq 124 || $app_size_rc -ge 128 ]] && return "$app_size_rc" + [[ $app_size_rc -eq 0 && "$app_size_kb" =~ ^[0-9]+$ ]] || app_size_kb=0 + local related_files="" diag_user="" diag_system="" # system_files is a newline-separated string, not an array. # shellcheck disable=SC2178,SC2128 - local system_files=$(find_app_system_files "$bundle_id" "$app_name" || true) - local diag_system - diag_system=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "/Library/Logs/DiagnosticReports" || true) - # shellcheck disable=SC2128 - local system_size_kb=$(calculate_total_size "$system_files" || echo "0") - local diag_system_size_kb=$(calculate_total_size "$diag_system" || echo "0") - local total_kb=$((app_size_kb + related_size_kb + system_size_kb + diag_system_size_kb)) - total_estimated_size=$((total_estimated_size + total_kb)) - - # shellcheck disable=SC2128 - if [[ -n "$system_files" || -n "$diag_system" ]]; then - needs_sudo=true + local system_files="" + # discovery_app_name is empty only in the sibling-guard name-collision + # case: every name-derived pattern would belong to the survivor, and + # find_app_system_files has no empty-name guard (it would emit root + # dirs like "/Library/Application Support/"). Skip discovery entirely + # and remove just the app bundle. + if [[ -n "$discovery_app_name" ]]; then + # Under the sibling guard, also disable the regex-keyed toolchain + # heuristics in find_app_files (DerivedData, DeviceSupport, ...): + # they match "Xcode-beta" by substring and would still queue + # caches the surviving install uses. + local sibling_survives=0 + [[ "$sibling_guard" != "none" ]] && sibling_survives=1 + local discovery_rc=0 + related_files=$(MOLE_UNINSTALL_SIBLING_SURVIVES="$sibling_survives" \ + find_app_files "$bundle_id" "$discovery_app_name" \ + "$app_path") || discovery_rc=$? + if [[ $discovery_rc -eq 124 ]]; then + # Out of budget after a heavy machine-wide probe (#1383): keep + # the selected app removable and leave leftovers alone rather + # than aborting the whole batch with "nothing was removed". + related_files="" + log_warning "$(printf "%s: leftover scan timed out; only the app bundle will be removed" "$app_name")" + elif [[ $discovery_rc -ne 0 ]]; then + return "$discovery_rc" + fi + # Diagnostic-report discovery prefers CFBundleExecutable from the + # selected bundle, and same-bundle-id siblings ship the same + # executable name ("Xcode" for Xcode-beta.app), so under the + # guard it would collect the survivor's crash reports no matter + # which name is passed in. Leaving crash logs behind is the + # fail-safe direction. Skip follow-on probes when leftover + # discovery already timed out so we do not burn the floor budget. + if [[ "$sibling_guard" == "none" && $discovery_rc -ne 124 ]]; then + local diag_rc=0 + diag_user=$(get_diagnostic_report_paths_for_app "$app_path" \ + "$discovery_app_name" \ + "$HOME/Library/Logs/DiagnosticReports") || diag_rc=$? + if [[ $diag_rc -eq 124 ]]; then + diag_user="" + debug_log "Diagnostic report scan timed out for $app_name" + elif [[ $diag_rc -ne 0 ]]; then + return "$diag_rc" + fi + [[ -n "$diag_user" ]] && related_files=$( + [[ -n "$related_files" ]] && echo "$related_files" + echo "$diag_user" + ) + diag_rc=0 + diag_system=$(get_diagnostic_report_paths_for_app "$app_path" \ + "$discovery_app_name" "/Library/Logs/DiagnosticReports") || diag_rc=$? + if [[ $diag_rc -eq 124 ]]; then + diag_system="" + debug_log "System diagnostic report scan timed out for $app_name" + elif [[ $diag_rc -ne 0 ]]; then + return "$diag_rc" + fi + fi + if [[ $discovery_rc -ne 124 ]]; then + local system_rc=0 + system_files=$(find_app_system_files \ + "$bundle_id" "$discovery_app_name") || system_rc=$? + if [[ $system_rc -eq 124 ]]; then + system_files="" + debug_log "System leftover scan timed out for $app_name" + elif [[ $system_rc -ne 0 ]]; then + return "$system_rc" + fi + fi + fi + local related_size_kb="0" + local related_size_rc=0 + related_size_kb=$(calculate_total_size "$related_files") || related_size_rc=$? + if [[ $related_size_rc -eq 124 ]]; then + # Size is display-only here; keep the leftover plan and under-report. + related_size_kb=0 + debug_log "Related-file size probe timed out for $app_name" + elif [[ $related_size_rc -ge 128 ]]; then + return "$related_size_rc" fi + [[ $related_size_rc -eq 0 && "$related_size_kb" =~ ^[0-9]+$ ]] || related_size_kb=0 + local review_only_system_files="$system_files" + review_only_system_files=$(append_line "$review_only_system_files" "$diag_system") + # System-level remnants are review-only in the CLI: shown in the preview + # via review_only_system_files (encoded into encoded_review_system) but + # never deleted. Blanking system_files/diag_system here is what enforces + # that: _batch_execute_removals decodes the now-empty encoded_system_files + # and encoded_diag_system fields and therefore skips them. Do NOT remove + # this blanking, or system files would become deletable again. + system_files="" + diag_system="" + local total_kb=$((app_size_kb + related_size_kb)) + total_estimated_size=$((total_estimated_size + total_kb)) if [[ "$needs_sudo" == "true" ]]; then sudo_apps+=("$app_name") @@ -359,8 +1634,12 @@ batch_uninstall_applications() { # Check for sensitive user data once. local has_sensitive_data="false" - if has_sensitive_data "$related_files" 2> /dev/null; then + local sensitive_rc=0 + has_sensitive_data "$related_files" 2> /dev/null || sensitive_rc=$? + if [[ $sensitive_rc -eq 0 ]]; then has_sensitive_data="true" + elif [[ $sensitive_rc -eq 124 || $sensitive_rc -ge 128 ]]; then + return "$sensitive_rc" fi # Store details for later use (base64 keeps lists on one line). @@ -370,29 +1649,77 @@ batch_uninstall_applications() { encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n' || echo "") local encoded_diag_system encoded_diag_system=$(printf '%s' "$diag_system" | base64 | tr -d '\n' || echo "") - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name|$encoded_diag_system") + local encoded_review_system + encoded_review_system=$(printf '%s' "$review_only_system_files" | base64 | tr -d '\n' || echo "") + local login_item_helpers="" + local login_helpers_rc=0 + login_item_helpers=$(discover_login_item_helper_bundle_ids \ + "$app_path") || login_helpers_rc=$? + if [[ $login_helpers_rc -eq 124 ]]; then + login_item_helpers="" + debug_log "Login-item helper discovery timed out for $app_name" + elif [[ $login_helpers_rc -ne 0 ]]; then + return "$login_helpers_rc" + fi + local encoded_login_item_helpers + encoded_login_item_helpers=$(printf '%s' "$login_item_helpers" | base64 | tr -d '\n' || echo "") + local encoded_live_sibling_fingerprint + encoded_live_sibling_fingerprint=$(printf '%s' "$preview_live_sibling_fingerprint" | base64 | tr -d '\n') || return 1 + app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name|$encoded_diag_system|$encoded_review_system|$encoded_login_item_helpers|$sibling_guard|$app_identity|$original_bundle_id|$encoded_live_sibling_fingerprint|$app_info_identity") done if [[ -t 1 ]]; then stop_inline_spinner; fi + if [[ ${#blocked_apps[@]} -gt 0 ]]; then + local blocked_detail blocked_name blocked_vendor + for blocked_detail in "${blocked_apps[@]}"; do + IFS='|' read -r blocked_name blocked_vendor <<< "$blocked_detail" + log_warning "$blocked_name requires the official $blocked_vendor uninstaller" + done + fi + + if [[ ${#manual_removal_apps[@]} -gt 0 ]]; then + local manual_name + for manual_name in "${manual_removal_apps[@]}"; do + log_warning "$manual_name cannot be removed safely by Mole from this location" + log_info "Move it to Trash in Finder; Mole left protected containers and app data untouched" + done + fi +} + +# Phase 2+3: render the preview block listing every target with its size +# and per-file breakdown, prompt the user for confirmation, and establish +# a sudo session when admin access is needed. Returns: +# 0 - user confirmed and (if needed) sudo session established +# 2 - user cancelled (ESC / 'q' / unknown key) +# 1 - sudo authorization denied +# Reads: app_details, brew_cask_apps, running_apps, sudo_apps, +# total_estimated_size +_batch_preview_and_confirm() { local size_display=$(bytes_to_human "$((total_estimated_size * 1024))") echo -e "\n${PURPLE_BOLD}Files to be removed:${NC}" - # Warn if brew cask apps are present. - local has_brew_cask=false - for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ _ _ is_brew_cask_flag _ <<< "$detail" - [[ "$is_brew_cask_flag" == "true" ]] && has_brew_cask=true + # Warn if brew cask apps are present. The --zap wording only applies to + # casks that will actually zap; sibling-guarded casks run a plain + # uninstall so their shared configs and data stay. + local has_zap_cask=false + local zap_detail zap_is_brew zap_guard + for zap_detail in "${app_details[@]}"; do + IFS='|' read -r _ _ _ _ _ _ _ _ zap_is_brew _ _ _ _ zap_guard _ <<< "$zap_detail" + if [[ "$zap_is_brew" == "true" && "${zap_guard:-none}" == "none" ]]; then + has_zap_cask=true + break + fi done - if [[ "$has_brew_cask" == "true" ]]; then + if [[ "$has_zap_cask" == "true" ]]; then echo -e "${GRAY}${ICON_WARNING} Homebrew apps will be fully cleaned, --zap removes configs and data${NC}" fi echo "" for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system encoded_review_system encoded_login_item_helpers sibling_guard _expected_app_identity _original_bundle_id _encoded_live_sibling_fingerprint _expected_info_identity <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -404,30 +1731,43 @@ batch_uninstall_applications() { local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local diag_system_display diag_system_display=$(decode_file_list "$encoded_diag_system" "$app_name") + local review_system_display + review_system_display=$(decode_file_list "$encoded_review_system" "$app_name") [[ -n "$diag_system_display" ]] && system_files=$( [[ -n "$system_files" ]] && echo "$system_files" echo "$diag_system_display" ) - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}" + local preview_path="" + preview_path=$(format_uninstall_preview_path "$app_path") || return $? + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $preview_path" # Show all related files so users can fully review before deletion. while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}" + preview_path=$(format_uninstall_preview_path "$file") || return $? + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $preview_path" fi done <<< "$related_files" # Show all system files so users can fully review before deletion. while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then - echo -e " ${BLUE}${ICON_WARNING}${NC} System: $file" + preview_path=$(format_uninstall_preview_path "$file") || return $? + echo -e " ${BLUE}${ICON_WARNING}${NC} System: $preview_path" fi done <<< "$system_files" + + while IFS= read -r file; do + if [[ -n "$file" && -e "$file" ]]; then + preview_path=$(format_uninstall_preview_path "$file") || return $? + echo -e " ${YELLOW}${ICON_WARNING}${NC} Review only: $preview_path" + fi + done <<< "$review_system_display" done # Confirmation before requesting sudo. - local app_total=${#selected_apps[@]} + local app_total=${#app_details[@]} local app_text="app" [[ $app_total -gt 1 ]] && app_text="apps" @@ -446,8 +1786,7 @@ batch_uninstall_applications() { $'\e' | q | Q) echo "" echo "" - _restore_uninstall_traps - return 0 + return 2 ;; "" | $'\n' | $'\r' | y | Y) echo "" # Move to next line @@ -455,8 +1794,7 @@ batch_uninstall_applications() { *) echo "" echo "" - _restore_uninstall_traps - return 0 + return 2 ;; esac @@ -464,34 +1802,59 @@ batch_uninstall_applications() { # that user explicitly chose to uninstall. System-critical components remain protected. export MOLE_UNINSTALL_MODE=1 - # Request sudo if needed for non-Homebrew removal operations. - # Note: Homebrew resets sudo timestamp at process startup, so pre-auth would - # cause duplicate password prompts in cask-only flows. - if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! ensure_sudo_session "Admin required for system apps: ${sudo_apps[*]}"; then + # Establish sudo once before uninstalling apps that need admin access. + # Homebrew cask removal can prompt via sudo during uninstall hooks, which + # does not work reliably under Mole's timed non-interactive execution path. + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]] && + { [[ ${#sudo_apps[@]} -gt 0 ]] || [[ ${#brew_cask_apps[@]} -gt 0 ]]; }; then + local admin_prompt="Admin required to uninstall selected apps" + if [[ ${#sudo_apps[@]} -gt 0 && ${#brew_cask_apps[@]} -eq 0 ]]; then + admin_prompt="Admin required for system apps: ${sudo_apps[*]}" + elif [[ ${#brew_cask_apps[@]} -gt 0 && ${#sudo_apps[@]} -eq 0 ]]; then + admin_prompt="Admin required for Homebrew casks: ${brew_cask_apps[*]}" + fi + + if ! ensure_sudo_session "$admin_prompt"; then echo "" log_error "Admin access denied" - _restore_uninstall_traps return 1 fi fi +} - # Perform uninstallations with per-app progress feedback - local success_count=0 failed_count=0 - local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove - local -a failed_items=() - local -a success_items=() +# Phase 4: iterate app_details and perform the actual removal for each. +# Tracks per-app failures, warnings (system extensions, still-running +# processes, container leftovers), and the total bytes +# actually freed. Per-app failures do not halt the loop; the surrounding +# trap still terminates the whole pass on SIGINT/SIGTERM. +# Reads: app_details +# Writes: success_count, failed_count, failed_items, success_items, +# success_dock_targets, system_extension_warning_apps, +# review_only_system_leftovers, +# review_only_system_leftover_keys, running_at_uninstall_apps, +# total_size_freed, brew_apps_removed, +# files_cleaned, total_items (the latter two via dynamic scope) +_batch_execute_removals() { + # See format_uninstall_preview_path: literal ~ in a patsub replacement is + # tilde-expanded by bash 5.3+, so route it through a variable. + local tilde_display='~' local current_index=0 for detail in "${app_details[@]}"; do current_index=$((current_index + 1)) - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system encoded_review_system encoded_login_item_helpers sibling_guard expected_app_identity original_bundle_id encoded_live_sibling_fingerprint expected_info_identity <<< "$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local diag_system=$(decode_file_list "$encoded_diag_system" "$app_name") + local review_only_system_files=$(decode_file_list "$encoded_review_system" "$app_name") + local login_item_helpers=$(decode_bundle_id_list "$encoded_login_item_helpers" "$app_name") local reason="" local suggestion="" - # Show progress for current app + # Show progress before the pre-teardown verification, not after: the + # same-bundle re-scan below can take tens of seconds on a large + # receipt set, and silence right after the Enter confirm reads as a + # dead prompt (#1340 family). Every downstream path already runs with + # this spinner active and stops it before printing. local brew_tag="" [[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}" if [[ -t 1 ]]; then @@ -502,40 +1865,217 @@ batch_uninstall_applications() { fi fi + local app_plan_rc=0 + _batch_selected_app_plan_matches "$app_path" \ + "$expected_app_identity" "$expected_info_identity" || app_plan_rc=$? + if [[ $app_plan_rc -eq 124 || $app_plan_rc -ge 128 ]]; then + return "$app_plan_rc" + elif [[ $app_plan_rc -ne 0 ]]; then + reason="selected app changed after preview" + suggestion="Select the app again and review the new removal plan" + fi + + # Rebuild the exact same-bundle installation snapshot immediately + # before the first teardown side effect. This uses the original + # resolved id even when the preview demoted bundle_id to "unknown" to + # suppress shared leftovers. Any added, removed, replaced, or modified + # sibling invalidates both the name guard and the reviewed plan. + original_bundle_id="${original_bundle_id:-$bundle_id}" + if [[ -z "$reason" ]] && mole_is_reverse_dns_bundle_id "$original_bundle_id"; then + local preview_live_sibling_fingerprint="" + if ! preview_live_sibling_fingerprint=$( + _uninstall_decode_live_sibling_fingerprint \ + "${encoded_live_sibling_fingerprint:-}" + ); then + reason="unable to verify the reviewed app installation set" + suggestion="Select the app again and review the new removal plan" + elif ! preview_live_sibling_fingerprint=$( + _uninstall_live_fingerprint_without_successful_paths \ + "$preview_live_sibling_fingerprint" + ); then + reason="unable to verify the reviewed app installation set" + suggestion="Select the app again and review the new removal plan" + fi + + local live_sibling_rc=0 + uninstall_live_bundle_has_other_install \ + "$original_bundle_id" "$app_path" || live_sibling_rc=$? + if [[ $live_sibling_rc -eq 0 || $live_sibling_rc -eq 1 ]]; then + if [[ "$preview_live_sibling_fingerprint" != "$_MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT" ]]; then + reason="the app installation set changed after preview" + suggestion="Select the app again and review the new removal plan" + fi + elif [[ $live_sibling_rc -eq $MOLE_UNINSTALL_SCAN_PARTIAL && + "$sibling_guard" == "guard_login" && + -z "$encoded_files" ]]; then + # The preview already narrowed this plan to the selected app + # bundle alone because the scan could not prove absence. The + # re-check hitting the same doubt confirms that state rather + # than contradicting it, and a plan with no shared teardown + # has nothing a live sibling could lose. Refusing here is what + # made a deterministically slow or unreadable machine unable + # to uninstall anything at all (#1340). guard_login alone is + # not that proof: the surviving-sibling name-collision path + # sets it while keeping name-keyed leftovers, so the empty + # deletion list is the evidence that authorizes proceeding. + : + elif [[ $live_sibling_rc -ge 128 ]]; then + return "$live_sibling_rc" + else + reason="unable to verify other apps with the same bundle id" + suggestion="Check mounted volumes and application folders, then try again" + fi + fi + # Stop Launch Agents/Daemons before removal. local has_system_files="false" [[ -n "$system_files" ]] && has_system_files="true" - stop_launch_services "$bundle_id" "$has_system_files" - unregister_app_bundle "$app_path" + if [[ -z "$reason" ]]; then + app_plan_rc=0 + _batch_selected_app_plan_matches "$app_path" \ + "$expected_app_identity" "$expected_info_identity" || app_plan_rc=$? + if [[ $app_plan_rc -eq 124 || $app_plan_rc -ge 128 ]]; then + return "$app_plan_rc" + elif [[ $app_plan_rc -ne 0 ]]; then + reason="selected app changed after preview" + suggestion="Select the app again and review the new removal plan" + fi + fi - # Remove from Login Items - remove_login_item "$app_name" "$bundle_id" + if [[ -z "$reason" ]]; then + local teardown_rc=0 + stop_launch_services \ + "$bundle_id" "$has_system_files" "$app_path" || teardown_rc=$? + [[ $teardown_rc -eq 124 || $teardown_rc -ge 128 ]] && return "$teardown_rc" + teardown_rc=0 + unregister_app_bundle "$app_path" || teardown_rc=$? + [[ $teardown_rc -eq 124 || $teardown_rc -ge 128 ]] && return "$teardown_rc" + fi - if ! force_kill_app "$app_name" "$app_path"; then - reason="still running" + # Remove from Login Items. Skipped when the sibling guard flagged a + # name collision: login items are matched by display name only, and + # deleting "Xcode" by name would take out the surviving install's + # login item along with the beta's. + if [[ -z "$reason" && "${sibling_guard:-none}" != "guard_login" ]]; then + local login_remove_rc=0 + remove_login_item "$app_name" "$bundle_id" || login_remove_rc=$? + [[ $login_remove_rc -eq 124 || $login_remove_rc -ge 128 ]] && return "$login_remove_rc" + elif [[ -z "$reason" ]]; then + debug_log "Skipping login item removal for $app_name: name is shared with a surviving install" fi - # Remove the application only if not running. - # Stop spinner before any removal attempt (avoids mixed output on errors) - [[ -t 1 ]] && stop_inline_spinner + # Best-effort termination. macOS allows removing a running app bundle + # (the running process keeps using its mmap'd code), so a stuck app + # process must NOT block the uninstall. Track it so we can surface a + # warning at the end without scaring the user with a "failed" status. + # Skipped under the sibling guard: force_kill_app quits by bundle id + # and matches processes by CFBundleExecutable, and both identifiers + # can belong to the surviving install (Xcode-beta.app ships the + # executable "Xcode"), so the kill ladder could SIGKILL the + # survivor's running process instead. + if [[ -z "$reason" && "${sibling_guard:-none}" == "none" ]]; then + local kill_rc=0 + force_kill_app "$app_name" "$app_path" || kill_rc=$? + [[ $kill_rc -ge 128 ]] && return "$kill_rc" + if [[ $kill_rc -ne 0 ]]; then + running_at_uninstall_apps+=("$app_name") + fi + elif [[ -z "$reason" ]]; then + debug_log "Skipping process termination for $app_name: identifiers are shared with a surviving install" + fi + + # Keep the spinner alive through the heavy work. For large apps the + # main bundle delete alone can take many seconds; for apps with + # 50-200 leftover files the per-file Trash moves add even more. The + # message is updated so the user sees which phase is running rather + # than a single static spinner. + if [[ -t 1 && -z "$reason" ]]; then + local _phase_size + _phase_size=$(bytes_to_human "$((total_kb * 1024))") + local _phase_prefix="" + if [[ ${#app_details[@]} -gt 1 ]]; then + _phase_prefix="[$current_index/${#app_details[@]}] " + fi + start_inline_spinner "${_phase_prefix}Removing ${app_name} (${_phase_size})..." + fi local used_brew_successfully=false + if [[ -z "$reason" ]]; then + app_plan_rc=0 + _batch_selected_app_plan_matches "$app_path" \ + "$expected_app_identity" "$expected_info_identity" || app_plan_rc=$? + if [[ $app_plan_rc -eq 124 || $app_plan_rc -ge 128 ]]; then + return "$app_plan_rc" + elif [[ $app_plan_rc -ne 0 ]]; then + reason="selected app changed after preview" + suggestion="Select the app again and review the new removal plan" + fi + fi if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then + # Zap stanzas delete bundle-id-keyed prefs/caches. When the + # sibling guard is active those paths still belong to the + # surviving same-bundle install, so run a plain uninstall. + local cask_zap_mode="zap" + [[ "${sibling_guard:-none}" != "none" ]] && cask_zap_mode="nozap" # Use brew_uninstall_cask helper (handles env vars, timeout, verification) - if brew_uninstall_cask "$cask_name" "$app_path"; then + local brew_uninstall_rc=0 + brew_uninstall_cask "$cask_name" "$app_path" \ + "$cask_zap_mode" || brew_uninstall_rc=$? + if [[ $brew_uninstall_rc -eq 0 ]]; then used_brew_successfully=true + elif [[ $brew_uninstall_rc -eq 124 || $brew_uninstall_rc -ge 128 ]]; then + return "$brew_uninstall_rc" else - # Fallback to manual removal if brew fails - if [[ "$needs_sudo" == true ]]; then - if ! safe_sudo_remove "$app_path"; then - reason="brew failed, manual removal failed" + # Only fall back to manual app removal when Homebrew no longer + # tracks the cask. Otherwise we would recreate the mismatch + # where brew still reports the app as installed after Mole + # removes the bundle manually. + local cask_state=2 + if command -v is_brew_cask_installed > /dev/null 2>&1; then + if is_brew_cask_installed "$cask_name"; then + cask_state=0 + else + cask_state=$? fi - else - if ! safe_remove "$app_path" true; then - reason="brew failed, manual removal failed" + fi + [[ $cask_state -ge 128 ]] && return "$cask_state" + + if [[ $cask_state -eq 1 ]]; then + app_plan_rc=0 + _batch_selected_app_plan_matches "$app_path" \ + "$expected_app_identity" "$expected_info_identity" || app_plan_rc=$? + if [[ $app_plan_rc -eq 124 || $app_plan_rc -ge 128 ]]; then + return "$app_plan_rc" + elif [[ $app_plan_rc -ne 0 ]]; then + reason="selected app changed after preview" + suggestion="Select the app again and review the new removal plan" + else + local removal_rc=0 + mole_delete "$app_path" "$needs_sudo" \ + "$expected_app_identity" || removal_rc=$? + [[ $removal_rc -eq 124 || $removal_rc -ge 128 ]] && return "$removal_rc" + if [[ $removal_rc -ne 0 ]]; then + if [[ $removal_rc -eq $MOLE_ERR_MUTABLE_PARENT ]]; then + local diagnosis + diagnosis=$(diagnose_removal_failure "$removal_rc" "$app_name") + IFS='|' read -r reason suggestion <<< "$diagnosis" + else + reason="brew cleanup incomplete, manual removal failed" + fi + fi fi + elif [[ $cask_state -eq 0 ]]; then + reason="brew uninstall failed, package still installed" + if [[ "$cask_zap_mode" == "nozap" ]]; then + suggestion="Run brew uninstall --cask $cask_name" + else + suggestion="Run brew uninstall --cask --zap $cask_name" + fi + else + reason="brew uninstall failed, package state unknown" + suggestion="Run brew uninstall --cask --zap $cask_name" fi fi elif [[ "$needs_sudo" == true ]]; then @@ -554,24 +2094,38 @@ batch_uninstall_applications() { reason="protected system symlink, cannot remove" ;; *) - if ! safe_remove_symlink "$app_path" "true"; then + local removal_rc=0 + mole_delete "$app_path" "true" \ + "$expected_app_identity" || removal_rc=$? + [[ $removal_rc -eq 124 || $removal_rc -ge 128 ]] && return "$removal_rc" + if [[ $removal_rc -ne 0 ]]; then reason="failed to remove symlink" fi ;; esac else - if ! safe_remove_symlink "$app_path" "true"; then + local removal_rc=0 + mole_delete "$app_path" "true" \ + "$expected_app_identity" || removal_rc=$? + [[ $removal_rc -eq 124 || $removal_rc -ge 128 ]] && return "$removal_rc" + if [[ $removal_rc -ne 0 ]]; then reason="failed to remove symlink" fi fi else if is_uninstall_dry_run; then - if ! safe_remove "$app_path" true; then + local removal_rc=0 + mole_delete "$app_path" "false" \ + "$expected_app_identity" || removal_rc=$? + [[ $removal_rc -eq 124 || $removal_rc -ge 128 ]] && return "$removal_rc" + if [[ $removal_rc -ne 0 ]]; then reason="dry-run path validation failed" fi else local ret=0 - safe_sudo_remove "$app_path" || ret=$? + mole_delete "$app_path" "true" \ + "$expected_app_identity" || ret=$? + [[ $ret -eq 124 || $ret -ge 128 ]] && return "$ret" if [[ $ret -ne 0 ]]; then local diagnosis diagnosis=$(diagnose_removal_failure "$ret" "$app_name") @@ -580,7 +2134,11 @@ batch_uninstall_applications() { fi fi else - if ! safe_remove "$app_path" true; then + local removal_rc=0 + mole_delete "$app_path" "false" \ + "$expected_app_identity" || removal_rc=$? + [[ $removal_rc -eq 124 || $removal_rc -ge 128 ]] && return "$removal_rc" + if [[ $removal_rc -ne 0 ]]; then if [[ ! -w "$(dirname "$app_path")" ]]; then reason="parent directory not writable" else @@ -592,10 +2150,55 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then - remove_file_list "$related_files" "false" > /dev/null + if [[ -t 1 ]]; then + local _phase_prefix="" + if [[ ${#app_details[@]} -gt 1 ]]; then + _phase_prefix="[$current_index/${#app_details[@]}] " + fi + start_inline_spinner "${_phase_prefix}Cleaning files for ${app_name}..." + fi + local related_remove_rc=0 + remove_file_list "$related_files" "false" > /dev/null || related_remove_rc=$? + [[ $related_remove_rc -eq 124 || $related_remove_rc -ge 128 ]] && return "$related_remove_rc" + + # Identify leftovers (silent rm failures, e.g. container directories + # macOS protects via com.apple.provenance xattr). Compute their + # total size in a single du invocation rather than walking each + # path; the source paths that DID move to Trash are already gone + # and would just produce stderr noise we discard. + local leftover_kb=0 + local -a leftover_paths=() + if ! is_uninstall_dry_run; then + while IFS= read -r _lf; do + [[ -n "$_lf" && -e "$_lf" ]] || continue + # Skip macOS-managed container stubs: containermanagerd protects + # these directories via com.apple.provenance xattr; rm -rf always + # fails on them by design. User data is already gone at this point. + if [[ "$_lf" == */Library/Containers/* && -f "$_lf/.com.apple.containermanagerd.metadata.plist" ]]; then + continue + fi + leftover_paths+=("$_lf") + done <<< "$related_files" + + if [[ ${#leftover_paths[@]} -gt 0 ]]; then + local _du_total="" + local _du_rc=0 + _du_total=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" \ + du -skcP "${leftover_paths[@]}" 2> /dev/null | awk 'END {print $1}') || _du_rc=$? + [[ $_du_rc -eq 124 || $_du_rc -ge 128 ]] && return "$_du_rc" + if [[ "$_du_total" =~ ^[0-9]+$ ]]; then + leftover_kb=$_du_total + fi + fi + fi + if [[ -t 1 ]]; then + start_inline_spinner "${_phase_prefix}Cleaning system files for ${app_name}..." + fi if [[ "$used_brew_successfully" == "true" ]]; then - remove_file_list "$diag_system" "true" > /dev/null + local system_remove_rc=0 + remove_file_list "$diag_system" "true" > /dev/null || system_remove_rc=$? + [[ $system_remove_rc -eq 124 || $system_remove_rc -ge 128 ]] && return "$system_remove_rc" else local system_all="$system_files" if [[ -n "$diag_system" ]]; then @@ -604,11 +2207,13 @@ batch_uninstall_applications() { fi system_all+="$diag_system" fi - remove_file_list "$system_all" "true" > /dev/null + local system_remove_rc=0 + remove_file_list "$system_all" "true" > /dev/null || system_remove_rc=$? + [[ $system_remove_rc -eq 124 || $system_remove_rc -ge 128 ]] && return "$system_remove_rc" fi # Defaults writes are side effects that should never run in dry-run mode. - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then + if mole_is_reverse_dns_bundle_id "$bundle_id"; then if is_uninstall_dry_run; then debug_log "[DRY RUN] Would clear defaults domain: $bundle_id" else @@ -618,24 +2223,83 @@ batch_uninstall_applications() { fi # ByHost preferences (machine-specific). + # User-owned plists, so route through user-mode mole_delete to + # avoid prompting for sudo when uninstalling a normal app. if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then - if [[ "$bundle_id" =~ ^[A-Za-z0-9._-]+$ ]]; then - while IFS= read -r -d '' plist_file; do - safe_remove "$plist_file" true > /dev/null || true - done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true) - else - debug_log "Skipping ByHost cleanup, invalid bundle id: $bundle_id" + local byhost_scan_file="" + byhost_scan_file=$(create_temp_file) || return 1 + local byhost_scan_rc=0 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find \ + "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f \ + -name "${bundle_id}.*.plist" -print0 > "$byhost_scan_file" \ + 2> /dev/null || byhost_scan_rc=$? + if [[ $byhost_scan_rc -ne 0 ]]; then + rm -f -- "$byhost_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$byhost_scan_rc" + fi + local byhost_delete_rc=0 + while IFS= read -r -d '' plist_file; do + local plist_delete_rc=0 + mole_delete "$plist_file" "false" || plist_delete_rc=$? + if [[ $plist_delete_rc -eq 124 || $plist_delete_rc -ge 128 ]]; then + byhost_delete_rc=$plist_delete_rc + break + fi + done < "$byhost_scan_file" + rm -f -- "$byhost_scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + if [[ $byhost_delete_rc -ne 0 ]]; then + return "$byhost_delete_rc" fi fi fi - # Show success - if [[ -t 1 ]]; then - if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} [$current_index/${#app_details[@]}] ${app_name}" - else - echo -e "${GREEN}${ICON_SUCCESS}${NC} ${app_name}" - fi + # Login item helper ids are read from the selected bundle and are + # identical across same-bundle-id siblings, so booting them out + # under the guard would stop the surviving install's running + # helper. + if [[ "${sibling_guard:-none}" == "none" ]]; then + local bootout_rc=0 + bootout_login_item_helpers "$login_item_helpers" || bootout_rc=$? + [[ $bootout_rc -eq 124 || $bootout_rc -ge 128 ]] && return "$bootout_rc" + else + debug_log "Skipping login item helper bootout for $app_name: helper ids are shared with a surviving install" + fi + + # All per-app side effects done; tear the spinner down before + # any echo so the success line does not collide with the spinner. + [[ -t 1 ]] && stop_inline_spinner + + # Show per-app progress only for multi-app batches. For a single + # app the summary block right below already names it on the + # "Removed 1 app" line, so a standalone success line above the + # box would just duplicate it. + if [[ -t 1 && ${#app_details[@]} -gt 1 ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} [$current_index/${#app_details[@]}] ${app_name}" + fi + + # Warn about files that could not be removed and exclude them from freed total. + if [[ ${#leftover_paths[@]} -gt 0 ]]; then + for _lpath in "${leftover_paths[@]}"; do + echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not remove: ${_lpath/#$HOME/$tilde_display}" + done + total_kb=$((total_kb - leftover_kb)) + ((total_kb < 0)) && total_kb=0 + fi + + # System-level matches stay review-only. Recheck them after the + # app and user-owned files are gone so the final summary names + # only exact paths that still exist. + if ! is_uninstall_dry_run; then + local _review_path _review_key + while IFS= read -r _review_path; do + [[ -n "$_review_path" && (-e "$_review_path" || -L "$_review_path") ]] || continue + _review_key=$(mole_normalize_path "$_review_path") + if [[ ${#review_only_system_leftover_keys[@]} -eq 0 ]] || + ! mole_identity_in_list "$_review_key" "${review_only_system_leftover_keys[@]}"; then + review_only_system_leftover_keys+=("$_review_key") + review_only_system_leftovers+=("$_review_path") + fi + done <<< "$review_only_system_files" fi total_size_freed=$((total_size_freed + total_kb)) @@ -644,7 +2308,25 @@ batch_uninstall_applications() { files_cleaned=$((files_cleaned + 1)) total_items=$((total_items + 1)) success_items+=("$app_path") + success_dock_targets+=("$app_path|$bundle_id") + # Check for orphaned system extensions (camera, network, endpoint security, etc.) + if mole_is_reverse_dns_bundle_id "$bundle_id" && [[ -d /Library/SystemExtensions ]]; then + local system_extension_path="" + local has_bundle_system_extension=false + while IFS= read -r -d '' system_extension_path; do + if mole_name_starts_with_bundle_id_boundary "$system_extension_path" "$bundle_id"; then + has_bundle_system_extension=true + break + fi + done < <(command find /Library/SystemExtensions -maxdepth 3 -name "*.systemextension" -print0 2> /dev/null) + if [[ "$has_bundle_system_extension" == "true" ]]; then + system_extension_warning_apps+=("$app_name") + fi + fi else + # Stop spinner before printing the failure line so the error + # message is not painted over by the spinner's next tick. + [[ -t 1 ]] && stop_inline_spinner if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}, $reason${NC}" @@ -660,7 +2342,16 @@ batch_uninstall_applications() { failed_items+=("$app_name:$reason:${suggestion:-}") fi done +} +# Phase 5+6: assemble the post-removal summary block (success line, failed +# apps, system extension / Background Items / still-running warnings) and emit +# it as a single summary block. +# Reads: success_count, failed_count, failed_items, success_items, +# total_size_freed, system_extension_warning_apps, +# review_only_system_leftovers, +# background_items_warning_apps, running_at_uninstall_apps +_batch_render_summary() { # Summary local freed_display freed_display=$(bytes_to_human "$((total_size_freed * 1024))") @@ -761,6 +2452,55 @@ batch_uninstall_applications() { summary_details+=("No applications were uninstalled.") fi + if [[ ${#review_only_system_leftovers[@]} -gt 0 ]]; then + # Deliberately not a warning, and deliberately one line. The CLI never + # removes system-level paths, so keeping them is the designed outcome + # of a successful uninstall, not an incomplete one; marking the run + # "incomplete" told users something went wrong when nothing had. The + # exact paths were already listed above the confirmation prompt, so + # repeating them plus a generic "review these" line only added noise to + # the block the user reads last, with no action attached to it. + local kept_count=${#review_only_system_leftovers[@]} + local kept_label="paths" + [[ $kept_count -eq 1 ]] && kept_label="path" + summary_details+=("${ICON_REVIEW} Kept ${kept_count} system-level ${kept_label}, which Mole never removes") + fi + + if [[ ${#system_extension_warning_apps[@]} -gt 0 ]]; then + local ext_list="" + local idx + for ((idx = 0; idx < ${#system_extension_warning_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && ext_list+=", " + ext_list+="${system_extension_warning_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} System extensions may remain after removal: ${YELLOW}${ext_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Check ${GRAY}System Settings > General > Login Items & Extensions${NC} to remove leftover extensions") + fi + + if [[ ${#background_items_warning_apps[@]} -gt 0 ]]; then + local bg_list="" + local idx + for ((idx = 0; idx < ${#background_items_warning_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && bg_list+=", " + bg_list+="${background_items_warning_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} Background item still running for ${YELLOW}${bg_list}${NC}, turn it off in ${GRAY}System Settings > Login Items & Extensions${NC}") + fi + + if [[ ${#running_at_uninstall_apps[@]} -gt 0 ]]; then + local running_list="" + local idx + for ((idx = 0; idx < ${#running_at_uninstall_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && running_list+=", " + running_list+="${running_at_uninstall_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} Still running during uninstall, files removed but process kept alive: ${YELLOW}${running_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Quit the app to free its in-memory copy; reinstalling before quitting may behave oddly") + fi + local title="Uninstall complete" if [[ "$summary_status" == "warn" ]]; then title="Uninstall incomplete" @@ -769,28 +2509,189 @@ batch_uninstall_applications() { title="Uninstall dry run complete" fi - echo "" + # No blank line here: print_summary_block already opens with one. print_summary_block "$title" "${summary_details[@]}" printf '\n' +} + +# Batch uninstall with single confirmation. Orchestrates the four phases +# (scan, preview/confirm, execute, summary) and manages the cross-phase +# shared state, the SIGINT/SIGTERM trap, sudo keepalive, and the deferred +# Dock / LaunchServices refresh. +batch_uninstall_applications() { + local total_size_freed=0 + + # shellcheck disable=SC2154 + if [[ ${#selected_apps[@]} -eq 0 ]]; then + log_warning "No applications selected for uninstallation" + return 0 + fi + + local old_trap_int old_trap_term + old_trap_int=$(trap -p INT) + old_trap_term=$(trap -p TERM) + + _cleanup_sudo_keepalive() { + if command -v stop_sudo_session > /dev/null 2>&1; then + stop_sudo_session + fi + } + + _restore_uninstall_traps() { + _cleanup_sudo_keepalive + if [[ -n "$old_trap_int" ]]; then + # eval: restore previous trap captured by $(trap -p INT) + eval "$old_trap_int" + else + trap - INT + fi + if [[ -n "$old_trap_term" ]]; then + # eval: restore previous trap captured by $(trap -p TERM) + eval "$old_trap_term" + else + trap - TERM + fi + } + + _abort_uninstall_batch() { + stop_inline_spinner 2> /dev/null || true + unset MOLE_UNINSTALL_MODE + _restore_uninstall_traps + } + + # SIGINT/SIGTERM during a phase helper would normally `return 130` out of + # the helper only; without an explicit signal flag the orchestrator would + # cheerfully run the next phase. The trap sets _batch_interrupted so the + # orchestrator can check after each helper and bail out the way the + # pre-refactor inline implementation did. + local _batch_interrupted=0 + + # Trap to clean up spinner, sudo keepalive, and uninstall mode on interrupt + trap 'stop_inline_spinner 2>/dev/null; _cleanup_sudo_keepalive; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; _batch_interrupted=1; return 130' INT TERM + + # Pre-scan: running apps, sudo needs, size. + local -a running_apps=() + local -a sudo_apps=() + local -a brew_cask_apps=() + local -a blocked_apps=() + local -a manual_removal_apps=() + local total_estimated_size=0 + local -a app_details=() + + local _scan_rc=0 + _batch_scan_app_details || _scan_rc=$? + if [[ $_batch_interrupted -eq 1 ]]; then + _abort_uninstall_batch + return 130 + fi + if [[ $_scan_rc -eq 124 || $_scan_rc -ge 128 ]]; then + _abort_uninstall_batch + # A signal already echoed through the INT/TERM trap; a timeout has + # said nothing yet, and a silent exit is unreportable (#1340). + if [[ $_scan_rc -eq 124 ]]; then + log_error "The uninstall scan timed out before finishing; nothing was removed" + fi + return "$_scan_rc" + elif [[ $_scan_rc -ne 0 ]]; then + _abort_uninstall_batch + return 1 + fi + + if [[ ${#app_details[@]} -eq 0 ]]; then + _abort_uninstall_batch + return 1 + fi + + local _confirm_rc=0 + _batch_preview_and_confirm || _confirm_rc=$? + if [[ $_batch_interrupted -eq 1 ]]; then + _abort_uninstall_batch + return 130 + fi + if [[ $_confirm_rc -eq 124 || $_confirm_rc -ge 128 ]]; then + _abort_uninstall_batch + return "$_confirm_rc" + fi + case $_confirm_rc in + 0) ;; + 2) + _abort_uninstall_batch + return 0 + ;; + *) + _abort_uninstall_batch + return 1 + ;; + esac + + # Perform uninstallations with per-app progress feedback + local success_count=0 failed_count=0 + local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove + local -a failed_items=() + local -a success_items=() + local -a success_dock_targets=() + local -a system_extension_warning_apps=() + local -a review_only_system_leftovers=() + local -a review_only_system_leftover_keys=() + # Apps whose process was still running after the kill ladder. We do not + # abort the uninstall for these: macOS allows deleting a running bundle + # (the process keeps using its mmap'd code), but we warn the user so they + # know to quit/relaunch the lingering process. + local -a running_at_uninstall_apps=() + + local _execute_rc=0 + _batch_execute_removals || _execute_rc=$? + if [[ $_batch_interrupted -eq 1 ]]; then + _abort_uninstall_batch + return 130 + fi + if [[ $_execute_rc -eq 124 || $_execute_rc -ge 128 ]]; then + _abort_uninstall_batch + return "$_execute_rc" + elif [[ $_execute_rc -ne 0 ]]; then + _abort_uninstall_batch + return 1 + fi + + # Detect background jobs that survived the uninstall (System Settings > + # Login Items & Extensions). Modern SMAppService helpers are not removable + # via osascript and Apple has no public CLI to delete individual BTM + # records, so we only detect + warn. Detection is launchctl-only: it needs + # no privileges, while sfltool dumpbtm pops the macOS "sfltool wants to + # make changes" admin-password dialog on every batch. + local -a background_items_warning_apps=() + if [[ ${#success_items[@]} -gt 0 ]] && ! is_uninstall_dry_run; then + local _bg_line + while IFS= read -r _bg_line; do + [[ -n "$_bg_line" ]] && background_items_warning_apps+=("$_bg_line") + done < <(_uninstall_match_loaded_background_items "${app_details[@]}" -- "${success_items[@]}") + fi + + _batch_render_summary # Run brew autoremove silently in background to avoid interrupting UX. if [[ $brew_apps_removed -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then + # This background job never needs terminal input. Keeping its stdin + # attached lets the Perl timeout fallback hand off the controlling tty + # and suspend the foreground uninstall prompt with SIGTTIN. ( HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout 30 brew autoremove > /dev/null 2>&1 || true - ) & + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" brew autoremove > /dev/null 2>&1 || true + ) > /dev/null 2>&1 < /dev/null & disown $! 2> /dev/null || true fi # Clean up Dock entries for uninstalled apps. - if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then + if [[ $success_count -gt 0 && ${#success_dock_targets[@]} -gt 0 ]]; then if is_uninstall_dry_run; then log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries" else + # LaunchServices refresh uses run_with_timeout. It is best-effort + # background work, so it must never own the tty. ( - remove_apps_from_dock "${success_items[@]}" > /dev/null 2>&1 || true + remove_apps_from_dock "${success_dock_targets[@]}" > /dev/null 2>&1 || true refresh_launch_services_after_uninstall > /dev/null 2>&1 || true - ) & + ) > /dev/null 2>&1 < /dev/null & disown $! 2> /dev/null || true fi fi @@ -801,6 +2702,7 @@ batch_uninstall_applications() { unset MOLE_UNINSTALL_MODE _restore_uninstall_traps + unset -f _abort_uninstall_batch unset -f _restore_uninstall_traps total_size_cleaned=$((total_size_cleaned + total_size_freed)) diff --git a/Resources/mole/lib/uninstall/brew.sh b/Resources/mole/lib/uninstall/brew.sh index 012ca53..2b2d61f 100644 --- a/Resources/mole/lib/uninstall/brew.sh +++ b/Resources/mole/lib/uninstall/brew.sh @@ -35,6 +35,41 @@ is_homebrew_available() { command -v brew > /dev/null 2>&1 } +# Run a read-only Homebrew probe under a hard deadline. The bash wrapper keeps +# exported test doubles working while production still resolves the real brew +# executable from PATH. More importantly, timeout/signal statuses remain +# distinguishable from a legitimate "not installed" result. +_mole_brew_probe() { + local duration="$1" + shift + + if declare -F brew > /dev/null 2>&1; then + export -f brew + fi + + HOMEBREW_NO_ENV_HINTS=1 run_with_timeout "$duration" \ + /bin/bash --noprofile --norc -c 'brew "$@"' mole-brew-probe "$@" +} + +# Check whether a cask is still recorded as installed in Homebrew. +# Exit codes: +# 0 - cask is installed +# 1 - cask is not installed +# 2 - install state could not be determined +is_brew_cask_installed() { + local cask_name="$1" + [[ -n "$cask_name" ]] || return 2 + is_homebrew_available || return 2 + + local cask_list="" + local list_rc=0 + cask_list=$(_mole_brew_probe "$MOLE_TIMEOUT_PKG_LIST_SEC" \ + list --cask 2> /dev/null) || list_rc=$? + [[ $list_rc -eq 124 || $list_rc -ge 128 ]] && return "$list_rc" + [[ $list_rc -eq 0 ]] || return 2 + grep -qxF "$cask_name" <<< "$cask_list" +} + # Extract cask token from a Caskroom path # Args: $1 - path (must be inside Caskroom) # Prints: cask token to stdout @@ -42,9 +77,10 @@ is_homebrew_available() { _extract_cask_token_from_path() { local path="$1" - # Check if path is inside Caskroom + # Check if path is inside Caskroom. Quote literals so Homebrew bottle + # relocation cannot break parsing when the prefix contains spaces. case "$path" in - /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; + "/opt/homebrew/Caskroom/"* | "/usr/local/Caskroom/"*) ;; *) return 1 ;; esac @@ -68,6 +104,7 @@ _detect_cask_via_resolved_path() { local app_path="$1" local resolved if resolved=$(resolve_path "$app_path") && [[ -n "$resolved" ]]; then + [[ "$(basename "$resolved")" == "$(basename "$app_path")" ]] || return 1 _extract_cask_token_from_path "$resolved" && return 0 fi return 1 @@ -78,30 +115,84 @@ _detect_cask_via_resolved_path() { # Only succeeds if exactly one cask matches (avoids wrong uninstall) _detect_cask_via_caskroom_search() { local app_bundle_name="$1" + local app_path="${2:-}" [[ -z "$app_bundle_name" ]] && return 1 local -a tokens=() local room match token + local scan_file="" + scan_file=$(create_temp_file) || return 1 + local scan_deadline=$((SECONDS + MOLE_TIMEOUT_PKG_LIST_SEC)) for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do [[ -d "$room" ]] || continue + local scan_timeout="" + local scan_rc=0 + scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" \ + "$scan_deadline") || scan_rc=$? + if [[ $scan_rc -eq 0 ]]; then + : > "$scan_file" || scan_rc=1 + fi + if [[ $scan_rc -eq 0 ]]; then + run_with_timeout "$scan_timeout" find "$room" -maxdepth 3 \ + -name "$app_bundle_name" < /dev/null > "$scan_file" \ + 2> /dev/null || scan_rc=$? + fi + if [[ $scan_rc -ne 0 ]]; then + : > "$scan_file" || true + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above + return "$scan_rc" + fi while IFS= read -r match; do [[ -n "$match" ]] || continue token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue [[ -n "$token" ]] && tokens+=("$token") - done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null) + done < "$scan_file" done + rm -f -- "$scan_file" 2> /dev/null || true # SAFE: exact tracked temp file created above # Need at least one token ((${#tokens[@]} > 0)) || return 1 # Deduplicate and check count - local -a uniq - IFS=$'\n' read -r -d '' -a uniq < <(printf '%s\n' "${tokens[@]}" | sort -u && printf '\0') || true + local -a uniq=() + local candidate existing seen + for candidate in "${tokens[@]}"; do + seen=false + for existing in "${uniq[@]}"; do + if [[ "$candidate" == "$existing" ]]; then + seen=true + break + fi + done + [[ "$seen" == true ]] || uniq+=("$candidate") + done # Only succeed if exactly one unique token found and it's installed if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 + local cask_list="" + local list_rc=0 + cask_list=$(_mole_brew_probe "$MOLE_TIMEOUT_PKG_LIST_SEC" \ + list --cask 2> /dev/null) || list_rc=$? + [[ $list_rc -eq 124 || $list_rc -ge 128 ]] && return "$list_rc" + [[ $list_rc -eq 0 ]] || return 2 + grep -qxF "${uniq[0]}" <<< "$cask_list" || return 1 + + local info_output="" + local info_rc=0 + info_output=$(_mole_brew_probe "$MOLE_TIMEOUT_PKG_LIST_SEC" \ + info --cask "${uniq[0]}" 2> /dev/null) || info_rc=$? + [[ $info_rc -eq 124 || $info_rc -ge 128 ]] && return "$info_rc" + [[ $info_rc -eq 0 ]] || return 2 + if [[ -n "$app_path" ]]; then + if grep -qF "$app_path" <<< "$info_output"; then + : + elif [[ "$app_path" == "/Applications/$app_bundle_name" ]] && grep -qF "$app_bundle_name" <<< "$info_output"; then + : + else + return 1 + fi + fi echo "${uniq[0]}" return 0 fi @@ -116,6 +207,7 @@ _detect_cask_via_symlink_check() { local target target=$(readlink "$app_path" 2> /dev/null) || return 1 + [[ "$(basename "$target")" == "$(basename "$app_path")" ]] || return 1 _extract_cask_token_from_path "$target" } @@ -126,12 +218,32 @@ _detect_cask_via_brew_list() { local app_name_lower app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]') - local cask_name - cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1 - - # Verify this cask actually owns this app path - HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 - echo "$cask_name" + local cask_list="" + local list_rc=0 + cask_list=$(_mole_brew_probe "$MOLE_TIMEOUT_PKG_LIST_SEC" \ + list --cask 2> /dev/null) || list_rc=$? + [[ $list_rc -eq 124 || $list_rc -ge 128 ]] && return "$list_rc" + [[ $list_rc -eq 0 ]] || return 2 + + local cask_name="" + cask_name=$(grep -Fix "$app_name_lower" <<< "$cask_list") || return 1 + + # Verify this cask actually owns this app path or app bundle. + local info_output="" + local info_rc=0 + info_output=$(_mole_brew_probe "$MOLE_TIMEOUT_PKG_LIST_SEC" \ + info --cask "$cask_name" 2> /dev/null) || info_rc=$? + [[ $info_rc -eq 124 || $info_rc -ge 128 ]] && return "$info_rc" + [[ $info_rc -eq 0 ]] || return 2 + if grep -qF "$app_path" <<< "$info_output"; then + echo "$cask_name" + return 0 + fi + if [[ "$app_path" == "/Applications/$app_bundle_name" ]] && grep -qF "$app_bundle_name" <<< "$info_output"; then + echo "$cask_name" + return 0 + fi + return 1 } # Get Homebrew cask name for an app @@ -153,30 +265,56 @@ get_brew_cask_name() { app_bundle_name=$(basename "$app_path") # Try each detection method in order (fast to slow) - _detect_cask_via_resolved_path "$app_path" && return 0 - _detect_cask_via_caskroom_search "$app_bundle_name" && return 0 - _detect_cask_via_symlink_check "$app_path" && return 0 - _detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0 + local detect_rc=0 + _detect_cask_via_resolved_path "$app_path" || detect_rc=$? + [[ $detect_rc -eq 0 ]] && return 0 + [[ $detect_rc -eq 1 ]] || return "$detect_rc" + + detect_rc=0 + _detect_cask_via_caskroom_search "$app_bundle_name" "$app_path" || detect_rc=$? + [[ $detect_rc -eq 0 ]] && return 0 + [[ $detect_rc -eq 1 ]] || return "$detect_rc" + + detect_rc=0 + _detect_cask_via_symlink_check "$app_path" || detect_rc=$? + [[ $detect_rc -eq 0 ]] && return 0 + [[ $detect_rc -eq 1 ]] || return "$detect_rc" + + detect_rc=0 + _detect_cask_via_brew_list "$app_path" "$app_bundle_name" || detect_rc=$? + [[ $detect_rc -eq 0 ]] && return 0 + [[ $detect_rc -eq 1 ]] || return "$detect_rc" return 1 } # Uninstall a Homebrew cask and verify removal -# Args: $1 - cask_name, $2 - app_path (optional, for verification) -# Returns: 0 on success, 1 on failure +# Args: $1 - cask_name, $2 - app_path (optional, for verification), +# $3 - zap mode: "nozap" runs a plain uninstall without --zap. Used when +# another install shares the cask's bundle id: zap stanzas delete +# bundle-id-keyed prefs/caches that the surviving install still +# uses (iterm2 and iterm2-beta both zap com.googlecode.iterm2). +# Returns: 0 on success, 1 on ordinary failure, 124 on timeout, or the +# original signal-style status when the operation is interrupted. brew_uninstall_cask() { local cask_name="$1" local app_path="${2:-}" + local zap_mode="${3:-zap}" + + local -a uninstall_args=(uninstall --cask) + if [[ "$zap_mode" != "nozap" ]]; then + uninstall_args+=(--zap) + fi if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - debug_log "[DRY RUN] Would brew uninstall --cask --zap $cask_name" + debug_log "[DRY RUN] Would brew ${uninstall_args[*]} $cask_name" return 0 fi is_homebrew_available || return 1 [[ -z "$cask_name" ]] && return 1 - debug_log "Attempting brew uninstall --cask --zap $cask_name" + debug_log "Attempting brew ${uninstall_args[*]} $cask_name" local uninstall_ok=false local brew_exit=0 @@ -184,7 +322,12 @@ brew_uninstall_cask() { # Calculate timeout based on app size (large apps need more time) local timeout=300 # Default 5 minutes if [[ -n "$app_path" && -d "$app_path" ]]; then - local size_gb=$(($(get_path_size_kb "$app_path") / 1048576)) + local size_kb=0 + local size_rc=0 + size_kb=$(get_path_size_kb "$app_path") || size_rc=$? + [[ $size_rc -eq 124 || $size_rc -ge 128 ]] && return "$size_rc" + [[ $size_rc -eq 0 && "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + local size_gb=$((size_kb / 1048576)) if [[ $size_gb -gt 15 ]]; then timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.) elif [[ $size_gb -gt 5 ]]; then @@ -193,25 +336,45 @@ brew_uninstall_cask() { debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" fi - # Run with timeout to prevent hangs from problematic cask scripts - local brew_exit=0 - if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout "$timeout" brew uninstall --cask --zap "$cask_name" 2>&1; then + # Run with timeout to prevent hangs from problematic cask scripts. + if [[ -n "${SUDO_USER:-}" ]]; then + if run_with_timeout "$timeout" sudo -u "$SUDO_USER" env \ + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + brew "${uninstall_args[@]}" "$cask_name" 2>&1; then + uninstall_ok=true + else + brew_exit=$? + fi + elif HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$timeout" brew "${uninstall_args[@]}" "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? + fi + + if [[ "$uninstall_ok" != "true" ]]; then debug_log "brew uninstall timeout or failed with exit code: $brew_exit" - # Exit code 124 indicates timeout from run_with_timeout - # On timeout, fail immediately without verification to avoid inconsistent state + # Timeout and signal statuses are cancellation, not evidence that a + # partially completed cask action can safely fall back to direct app + # deletion. Preserve them before any verification. if [[ $brew_exit -eq 124 ]]; then debug_log "brew uninstall timed out after ${timeout}s, returning failure" - return 1 + return 124 + elif [[ $brew_exit -ge 128 ]]; then + return "$brew_exit" fi fi # Verify removal (only if not timed out) local cask_gone=true app_gone=true - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false + local cask_state=0 + if is_brew_cask_installed "$cask_name"; then + cask_gone=false + else + cask_state=$? + [[ $cask_state -ge 128 ]] && return "$cask_state" + [[ $cask_state -eq 1 ]] || cask_gone=false + fi [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false # Success: uninstall worked and both are gone, or already uninstalled diff --git a/Resources/mole/mole b/Resources/mole/mole index 6f99026..b4eed89 100755 --- a/Resources/mole/mole +++ b/Resources/mole/mole @@ -7,697 +7,80 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/lib/core/common.sh" -source "$SCRIPT_DIR/lib/core/commands.sh" - -trap cleanup_temp_files EXIT INT TERM - -# Version and update helpers -VERSION="1.30.0" -MOLE_TAGLINE="Deep clean and optimize your Mac." +# The path of THIS entrypoint, captured before anything is sourced. The update +# flow reinstalls over the exact `mole` that was invoked, so it must resolve the +# entrypoint, not the lib file it happens to run from. `BASH_SOURCE[0]` inside a +# sourced function names that function's file, so the value has to be taken here. +MOLE_ENTRY_SCRIPT="${BASH_SOURCE[0]}" -is_touchid_configured() { - local pam_sudo_file="/etc/pam.d/sudo" - [[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null -} - -get_latest_version() { - curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \ - "https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null | - grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/' -} - -get_latest_version_from_github() { - local version - version=$(curl -fsSL --connect-timeout 2 --max-time 3 \ - "https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null | - grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - version="${version#v}" - version="${version#V}" - echo "$version" -} +declare -a MOLE_CLI_ARGS=() +MOLE_CLI_DEBUG=false -get_homebrew_latest_version() { - command -v brew > /dev/null 2>&1 || return 1 - - local line candidate="" - - # Prefer local tap outdated info to avoid notifying before formula is available. - line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated --formula --verbose mole 2> /dev/null | head -1 || true) - if [[ "$line" == *"< "* ]]; then - candidate="${line##*< }" - candidate="${candidate%% *}" - fi - - # Fallback for environments where outdated output is unavailable. - if [[ -z "$candidate" ]]; then - line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew info mole 2> /dev/null | awk 'NR==1 { print; exit }' || true) - line="${line#==> }" - line="${line#*: }" - if [[ "$line" == stable* ]]; then - candidate=$(printf '%s\n' "$line" | awk '{print $2}') - fi - fi - - [[ -n "$candidate" ]] && printf '%s\n' "$candidate" -} -# Install detection (Homebrew vs manual). -# Uses variable capture + string matching to avoid SIGPIPE under pipefail. -is_homebrew_install() { - local mole_path link_target has_brew=false - mole_path=$(command -v mole 2> /dev/null) || return 1 - - if command -v brew > /dev/null 2>&1; then - has_brew=true - fi - - if [[ -L "$mole_path" ]]; then - link_target=$(readlink "$mole_path" 2> /dev/null) || true - if [[ "$link_target" == *"Cellar/mole"* ]]; then - if $has_brew; then - brew list mole > /dev/null 2>&1 && return 0 - fi - return 1 - fi - fi +mole_collect_cli_args() { + local arg - if [[ -f "$mole_path" ]]; then - case "$mole_path" in - /opt/homebrew/bin/mole | /usr/local/bin/mole) - if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then - if $has_brew; then - brew list mole > /dev/null 2>&1 && return 0 - else - return 0 # Cellar exists, probably Homebrew install - fi - fi + MOLE_CLI_ARGS=() + MOLE_CLI_DEBUG=false + for arg in "$@"; do + case "$arg" in + --debug) + MOLE_CLI_DEBUG=true + ;; + *) + MOLE_CLI_ARGS+=("$arg") ;; esac - fi - - if $has_brew; then - local brew_prefix - brew_prefix=$(brew --prefix 2> /dev/null) - if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then - brew list mole > /dev/null 2>&1 && return 0 - fi - fi - - return 1 -} - -get_install_channel() { - # Try user config dir first (matches install.sh behavior), fallback to SCRIPT_DIR - local channel_file="${MOLE_CONFIG_DIR:-$HOME/.config/mole}/install_channel" - if [[ ! -f "$channel_file" ]]; then - channel_file="$SCRIPT_DIR/install_channel" - fi - local channel="stable" - if [[ -f "$channel_file" ]]; then - channel=$(sed -n 's/^CHANNEL=\(.*\)$/\1/p' "$channel_file" | head -1) - fi - case "$channel" in - nightly | dev | stable) printf '%s\n' "$channel" ;; - *) printf 'stable\n' ;; - esac -} - -get_install_commit() { - # Try user config dir first (matches install.sh behavior), fallback to SCRIPT_DIR - local channel_file="${MOLE_CONFIG_DIR:-$HOME/.config/mole}/install_channel" - if [[ ! -f "$channel_file" ]]; then - channel_file="$SCRIPT_DIR/install_channel" - fi - if [[ -f "$channel_file" ]]; then - sed -n 's/^COMMIT_HASH=\(.*\)$/\1/p' "$channel_file" | head -1 - fi -} - -# Background update notice -check_for_updates() { - local msg_cache="$HOME/.cache/mole/update_message" - ensure_user_dir "$(dirname "$msg_cache")" - ensure_user_file "$msg_cache" - - ( - ( - local latest - - latest=$(get_latest_version_from_github) - if [[ -z "$latest" ]]; then - latest=$(get_latest_version) - fi - - if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then - if is_homebrew_install; then - # For Homebrew, only notify if the brew tap has the new version available locally - local brew_latest - brew_latest=$(get_homebrew_latest_version || true) - if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then - printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache" - else - echo -n > "$msg_cache" - fi - else - printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache" - fi - else - echo -n > "$msg_cache" - fi - ) > /dev/null 2>&1 < /dev/null & - ) -} - -show_update_notification() { - local msg_cache="$HOME/.cache/mole/update_message" - if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then - cat "$msg_cache" - echo - fi -} - -# UI helpers -show_brand_banner() { - cat << EOF -${GREEN} __ __ _ ${NC} -${GREEN}| \/ | ___ | | ___ ${NC} -${GREEN}| |\/| |/ _ \| |/ _ \\${NC} -${GREEN}| | | | (_) | | __/${NC} ${BLUE}https://github.com/tw93/mole${NC} -${GREEN}|_| |_|\___/|_|\___|${NC} ${GREEN}${MOLE_TAGLINE}${NC} - -EOF -} - -show_version() { - local os_ver - if command -v sw_vers > /dev/null; then - os_ver=$(sw_vers -productVersion) - else - os_ver="Unknown" - fi - - local arch - arch=$(uname -m) - - local kernel - kernel=$(uname -r) - - local sip_status - if command -v csrutil > /dev/null; then - sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown") - sip_status="$(LC_ALL=C tr '[:lower:]' '[:upper:]' <<< "${sip_status:0:1}")${sip_status:1}" - else - sip_status="Unknown" - fi - - local disk_free - disk_free=$(df -h / 2> /dev/null | awk 'NR==2 {print $4}' || echo "Unknown") - - local install_method="Manual" - if is_homebrew_install; then - install_method="Homebrew" - fi - - local channel - channel=$(get_install_channel) - - printf '\nMole version %s\n' "$VERSION" - if [[ "$channel" == "nightly" ]]; then - local commit - commit=$(get_install_commit) - if [[ -n "$commit" ]]; then - printf 'Channel: Nightly (%s)\n' "$commit" - else - printf 'Channel: Nightly\n' - fi - fi - printf 'macOS: %s\n' "$os_ver" - printf 'Architecture: %s\n' "$arch" - printf 'Kernel: %s\n' "$kernel" - printf 'SIP: %s\n' "$sip_status" - printf 'Disk Free: %s\n' "$disk_free" - printf 'Install: %s\n' "$install_method" - printf 'Shell: %s\n\n' "${SHELL:-Unknown}" -} - -show_help() { - show_brand_banner - echo - printf "%s%s%s\n" "$BLUE" "COMMANDS" "$NC" - printf " %s%-28s%s %s\n" "$GREEN" "mo" "$NC" "Main menu" - for entry in "${MOLE_COMMANDS[@]}"; do - local name="${entry%%:*}" - local desc="${entry#*:}" - local display="mo $name" - [[ "$name" == "help" ]] && display="mo --help" - [[ "$name" == "version" ]] && display="mo --version" - printf " %s%-28s%s %s\n" "$GREEN" "$display" "$NC" "$desc" done - echo - printf " %s%-28s%s %s\n" "$GREEN" "mo clean --dry-run" "$NC" "Preview cleanup" - printf " %s%-28s%s %s\n" "$GREEN" "mo clean --whitelist" "$NC" "Manage protected caches" - - printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" - printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" - printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall" - printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge" - printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup" - printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup" - printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits" - printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" - printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" - printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" - printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" - printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal" - echo - printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" - printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" - echo } -# Update flow (Homebrew or installer). -update_mole() { - local force_update="${1:-false}" - local nightly_update="${2:-false}" - local update_interrupted=false - local sudo_keepalive_pid="" - - # Cleanup function for sudo keepalive - _update_cleanup() { - [[ -n "$sudo_keepalive_pid" ]] && _stop_sudo_keepalive "$sudo_keepalive_pid" || true - } - trap '_update_cleanup; update_interrupted=true; echo ""; exit 130' INT TERM - - if is_homebrew_install; then - if [[ "$nightly_update" == "true" ]]; then - log_error "Nightly update is only available for script installations" - echo -e "${ICON_REVIEW} Homebrew installs follow stable releases." - echo -e "${ICON_REVIEW} Reinstall via script to use: ${GRAY}mo update --nightly${NC}" - exit 1 - fi - update_via_homebrew "$VERSION" - exit 0 - fi - - local latest="" - local download_label="Downloading latest version..." - local install_label="Installing update..." - local final_success_label="latest version" - - if [[ "$nightly_update" == "true" ]]; then - latest="main" - download_label="Downloading nightly installer..." - install_label="Installing nightly update..." - final_success_label="nightly build (main)" - else - latest=$(get_latest_version_from_github) - [[ -z "$latest" ]] && latest=$(get_latest_version) - - if [[ -z "$latest" ]]; then - log_error "Unable to check for updates. Check network connection." - echo -e "${ICON_REVIEW} Check if you can access GitHub, https://github.com" - echo -e "${ICON_REVIEW} Try again with: ${GRAY}mo update${NC}" - exit 1 - fi - - if [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then - echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${VERSION}" - echo "" - exit 0 - fi - fi - - if [[ -t 1 ]]; then - start_inline_spinner "$download_label" - else - echo "${download_label%...}" - fi - - local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh" - local tmp_installer - tmp_installer="$(mktemp_file)" || { - log_error "Update failed" - exit 1 - } - - local download_error="" - if command -v curl > /dev/null 2>&1; then - download_error=$(curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer" 2>&1) || { - local curl_exit=$? - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - log_error "Update failed, curl error: $curl_exit" - - case $curl_exit in - 6) echo -e "${ICON_REVIEW} Could not resolve host. Check DNS or network connection." ;; - 7) echo -e "${ICON_REVIEW} Failed to connect. Check network or proxy settings." ;; - 22) echo -e "${ICON_REVIEW} HTTP 404 Not Found. The installer may have moved." ;; - 28) echo -e "${ICON_REVIEW} Connection timed out. Try again or check firewall." ;; - *) echo -e "${ICON_REVIEW} Check network connection and try again." ;; - esac - echo -e "${ICON_REVIEW} URL: $installer_url" - exit 1 - } - elif command -v wget > /dev/null 2>&1; then - download_error=$(wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url" 2>&1) || { - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - log_error "Update failed, wget error" - echo -e "${ICON_REVIEW} Check network connection and try again." - echo -e "${ICON_REVIEW} URL: $installer_url" - exit 1 - } - else - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - log_error "curl or wget required" - echo -e "${ICON_REVIEW} Install curl with: ${GRAY}brew install curl${NC}" - exit 1 - fi - - if [[ -t 1 ]]; then stop_inline_spinner; fi - chmod +x "$tmp_installer" - - local mole_path - mole_path="$(command -v mole 2> /dev/null || echo "$0")" - local install_dir - install_dir="$(cd "$(dirname "$mole_path")" && pwd)" - - local requires_sudo="false" - if [[ ! -w "$install_dir" ]]; then - requires_sudo="true" - elif [[ -e "$install_dir/mole" && ! -w "$install_dir/mole" ]]; then - requires_sudo="true" - fi - - if [[ "$requires_sudo" == "true" ]]; then - if ! request_sudo_access "Mole update requires admin access"; then - log_error "Update aborted, admin access denied" - rm -f "$tmp_installer" - exit 1 - fi - # Start sudo keepalive to prevent cache expiration during install - sudo_keepalive_pid=$(_start_sudo_keepalive) - fi - - if [[ -t 1 ]]; then - start_inline_spinner "$install_label" - else - echo "${install_label%...}" - fi +mole_dispatch_history_early() { + [[ "${MOLE_TEST_MODE:-0}" == "1" && "${MOLE_SKIP_MAIN:-0}" == "1" ]] && return 0 - process_install_output() { - local output="$1" - local fallback_version="$2" - local success_label="$3" - if [[ -t 1 ]]; then stop_inline_spinner; fi + mole_collect_cli_args "$@" + [[ "${MOLE_CLI_ARGS[0]:-}" == "history" ]] || return 0 - local filtered_output - filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') - if [[ -n "$filtered_output" ]]; then - printf '\n%s\n' "$filtered_output" - fi - - if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then - local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) - if [[ -z "$new_version" ]]; then - new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) - fi - if [[ -z "$new_version" ]]; then - new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") - fi - if [[ -z "$new_version" ]]; then - new_version="$fallback_version" - fi - printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to ${success_label}, ${new_version:-unknown}" - else - printf '\n' - fi - } - - local install_output - local update_tag="V${latest#V}" - local config_dir="${MOLE_CONFIG_DIR:-$SCRIPT_DIR}" - if [[ ! -f "$config_dir/lib/core/common.sh" ]]; then - config_dir="$HOME/.config/mole" + [[ "$MOLE_CLI_DEBUG" == "true" ]] && export MO_DEBUG=1 + if [[ "${#MOLE_CLI_ARGS[@]}" -gt 1 ]]; then + exec "$SCRIPT_DIR/bin/history.sh" "${MOLE_CLI_ARGS[@]:1}" fi - - if [[ "$nightly_update" == "true" ]]; then - if install_output=$(MOLE_VERSION="main" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then - process_install_output "$install_output" "$latest" "$final_success_label" - else - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - _update_cleanup - log_error "Nightly update failed" - echo "$install_output" | tail -10 >&2 # Show last 10 lines of error - exit 1 - fi - elif [[ "$force_update" == "true" ]]; then - if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then - process_install_output "$install_output" "$latest" "$final_success_label" - else - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - _update_cleanup - log_error "Update failed" - echo "$install_output" | tail -10 >&2 # Show last 10 lines of error - exit 1 - fi - else - if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then - process_install_output "$install_output" "$latest" "$final_success_label" - else - if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then - process_install_output "$install_output" "$latest" "$final_success_label" - else - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - _update_cleanup - log_error "Update failed" - echo "$install_output" | tail -10 >&2 # Show last 10 lines of error - exit 1 - fi - fi - fi - - rm -f "$tmp_installer" - rm -f "$HOME/.cache/mole/update_message" - - # Cleanup and reset trap - _update_cleanup - trap - INT TERM + exec "$SCRIPT_DIR/bin/history.sh" } -# Remove flow (Homebrew + manual + config/cache). -remove_mole() { - local dry_run_mode="${1:-false}" - - if [[ -t 1 ]]; then - start_inline_spinner "Detecting Mole installations..." - else - echo "Detecting installations..." - fi - - local is_homebrew=false - local brew_cmd="" - local brew_has_mole="false" - local -a manual_installs=() - local -a alias_installs=() - - if command -v brew > /dev/null 2>&1; then - brew_cmd="brew" - elif [[ -x "/opt/homebrew/bin/brew" ]]; then - brew_cmd="/opt/homebrew/bin/brew" - elif [[ -x "/usr/local/bin/brew" ]]; then - brew_cmd="/usr/local/bin/brew" - fi - - if [[ -n "$brew_cmd" ]]; then - if "$brew_cmd" list mole > /dev/null 2>&1; then - brew_has_mole="true" - fi - fi - - if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then - is_homebrew=true - fi - - local found_mole - found_mole=$(command -v mole 2> /dev/null || true) - if [[ -n "$found_mole" && -f "$found_mole" ]]; then - if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then - manual_installs+=("$found_mole") - fi - fi - - local -a fallback_paths=( - "/usr/local/bin/mole" - "$HOME/.local/bin/mole" - "/opt/local/bin/mole" - ) - - for path in "${fallback_paths[@]}"; do - if [[ -f "$path" && "$path" != "$found_mole" ]]; then - if [[ ! -L "$path" ]] || ! readlink "$path" | grep -q "Cellar/mole"; then - manual_installs+=("$path") - fi - fi - done - - local found_mo - found_mo=$(command -v mo 2> /dev/null || true) - if [[ -n "$found_mo" && -f "$found_mo" ]]; then - if [[ ! -L "$found_mo" ]] || ! readlink "$found_mo" | grep -q "Cellar/mole"; then - alias_installs+=("$found_mo") - fi - fi - - local -a alias_fallback=( - "/usr/local/bin/mo" - "$HOME/.local/bin/mo" - "/opt/local/bin/mo" - ) - - for alias in "${alias_fallback[@]}"; do - if [[ -f "$alias" && "$alias" != "$found_mo" ]]; then - if [[ ! -L "$alias" ]] || ! readlink "$alias" | grep -q "Cellar/mole"; then - alias_installs+=("$alias") - fi - fi - done - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - printf '\n' - - local manual_count=${#manual_installs[@]} - local alias_count=${#alias_installs[@]} - if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then - printf '%s\n\n' "${YELLOW}No Mole installation detected${NC}" - exit 0 - fi - - # Dry-run mode: show preview and exit without confirmation - if [[ "$dry_run_mode" == "true" ]]; then - echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed" - echo "" - echo -e "${YELLOW}Remove Mole${NC}, would delete the following:" - if [[ "$is_homebrew" == "true" ]]; then - echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}" - fi - if [[ ${manual_count:-0} -gt 0 ]]; then - for install in "${manual_installs[@]}"; do - [[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}" - done - fi - if [[ ${alias_count:-0} -gt 0 ]]; then - for alias in "${alias_installs[@]}"; do - [[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}" - done - fi - [[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}" - [[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}" - - printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" - exit 0 - fi +mole_dispatch_history_early "$@" - echo -e "${YELLOW}Remove Mole${NC}, will delete the following:" - if [[ "$is_homebrew" == "true" ]]; then - echo " ${ICON_LIST} Mole via Homebrew" - fi - for install in ${manual_installs[@]+"${manual_installs[@]}"} ${alias_installs[@]+"${alias_installs[@]}"}; do - echo " ${ICON_LIST} $install" - done - echo " ${ICON_LIST} ~/.config/mole" - echo " ${ICON_LIST} ~/.cache/mole" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: " - - IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - case "$key" in - $'\e') - exit 0 - ;; - "" | $'\n' | $'\r') - printf "\r\033[K" # Clear the prompt line - ;; - *) - exit 0 - ;; - esac +source "$SCRIPT_DIR/lib/core/common.sh" +source "$SCRIPT_DIR/lib/core/commands.sh" - local has_error=false - if [[ "$is_homebrew" == "true" ]]; then - if [[ -z "$brew_cmd" ]]; then - log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." - log_warning "Manual step: brew uninstall --force mole" - exit 1 - fi +trap cleanup_temp_files EXIT INT TERM - log_info "Attempting to uninstall Mole via Homebrew..." - local brew_uninstall_output - if ! brew_uninstall_output=$("$brew_cmd" uninstall --force mole 2>&1); then - has_error=true - log_error "Homebrew uninstallation failed:" - printf "%s\n" "$brew_uninstall_output" | sed "s/^/${RED} | ${NC}/" >&2 - log_warning "Manual step: ${YELLOW}brew uninstall --force mole${NC}" - echo "" # Add a blank line for readability - else - log_success "Mole uninstalled via Homebrew." - fi - fi - if [[ ${manual_count:-0} -gt 0 ]]; then - for install in "${manual_installs[@]}"; do - if [[ -f "$install" ]]; then - if [[ ! -w "$(dirname "$install")" ]]; then - if ! sudo rm -f "$install" 2> /dev/null; then - has_error=true - fi - else - if ! rm -f "$install" 2> /dev/null; then - has_error=true - fi - fi - fi - done - fi - if [[ ${alias_count:-0} -gt 0 ]]; then - for alias in "${alias_installs[@]}"; do - if [[ -f "$alias" ]]; then - if [[ ! -w "$(dirname "$alias")" ]]; then - if ! sudo rm -f "$alias" 2> /dev/null; then - has_error=true - fi - else - if ! rm -f "$alias" 2> /dev/null; then - has_error=true - fi - fi - fi - done - fi - if [[ -d "$HOME/.cache/mole" ]]; then - rm -rf "$HOME/.cache/mole" 2> /dev/null || true - fi - if [[ -d "$HOME/.config/mole" ]]; then - rm -rf "$HOME/.config/mole" 2> /dev/null || true - fi +# Version and update helpers +VERSION="1.50.0" +MOLE_TAGLINE="Deep clean and optimize your Mac." - local final_message - if [[ "$has_error" == "true" ]]; then - final_message="${YELLOW}${ICON_ERROR} Mole uninstalled with some errors, thank you for using Mole!${NC}" - else - final_message="${GREEN}${ICON_SUCCESS} Mole uninstalled successfully, thank you for using Mole!${NC}" - fi - printf '\n%s\n\n' "$final_message" +is_touchid_configured() { + grep -q "pam_tid.so" /etc/pam.d/sudo /etc/pam.d/sudo_local 2> /dev/null +} - exit 0 +# Update and remove flows live in lib/manage/{update,remove}.sh; this file +# stays a router. They are sourced (not exec'd) because the interactive menu +# and the update banner call them in-process. +source "$SCRIPT_DIR/lib/manage/update.sh" +source "$SCRIPT_DIR/lib/manage/remove.sh" + +# Build the main-menu controls hint line. Pure so the show/hide logic can be +# tested without a tty (the caller only renders it inside `[[ -t 0 ]]`). +# Args: $1 touchid_configured (true/false), $2 show_update (true/false). +_main_menu_controls_line() { + local touchid_configured="$1" + local show_update="$2" + local controls="${GRAY}↑↓ | Enter | M More | V Version" + if [[ "$touchid_configured" != "true" ]]; then + controls="${controls} | T TouchID" + elif [[ "$show_update" == "true" ]]; then + controls="${controls} | U Update" + fi + controls="${controls} | Q Quit${NC}" + printf '%s' "$controls" } # Menu UI @@ -731,19 +114,16 @@ show_main_menu() { printf '\r\033[2K%s\n' "$(show_menu_option 1 "Clean Free up disk space" "$([[ $selected -eq 1 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 2 "Uninstall Remove apps completely" "$([[ $selected -eq 2 ]] && echo true || echo false)")" - printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Check and maintain system" "$([[ $selected -eq 3 ]] && echo true || echo false)")" + printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Refresh caches and services" "$([[ $selected -eq 3 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 4 "Analyze Explore disk usage" "$([[ $selected -eq 4 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 5 "Status Monitor system health" "$([[ $selected -eq 5 ]] && echo true || echo false)")" if [[ -t 0 ]]; then printf '\r\033[2K\n' - local controls="${GRAY}↑↓ | Enter | M More" - if ! is_touchid_configured; then - controls="${controls} | T TouchID" - elif [[ "${MAIN_MENU_SHOW_UPDATE:-false}" == "true" ]]; then - controls="${controls} | U Update" - fi - controls="${controls} | Q Quit${NC}" + local touchid_state=false + is_touchid_configured && touchid_state=true + local controls + controls=$(_main_menu_controls_line "$touchid_state" "${MAIN_MENU_SHOW_UPDATE:-false}") printf '\r\033[2K%s\n' "$controls" printf '\r\033[2K\n' fi @@ -761,9 +141,7 @@ interactive_main_menu() { brand_banner="$(show_brand_banner)" MAIN_MENU_BANNER="$brand_banner" - if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then - update_message="$(cat "$msg_cache" 2> /dev/null || echo "")" - fi + update_message="$(read_update_message_cache "$msg_cache")" MAIN_MENU_UPDATE_MESSAGE="$update_message" MAIN_MENU_SHOW_UPDATE="$([[ -n "$update_message" ]] && echo true || echo false)" @@ -772,6 +150,12 @@ interactive_main_menu() { exit 0 } + launch_menu_command() { + show_cursor + drain_pending_input 0.1 + exec "$@" + } + trap cleanup_and_exit INT hide_cursor @@ -790,34 +174,28 @@ interactive_main_menu() { "UP") ((current_option > 1)) && ((current_option--)) ;; "DOWN") ((current_option < 5)) && ((current_option++)) ;; "ENTER") - show_cursor case $current_option in - 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; - 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; - 3) exec "$SCRIPT_DIR/bin/optimize.sh" ;; - 4) exec "$SCRIPT_DIR/bin/analyze.sh" ;; - 5) exec "$SCRIPT_DIR/bin/status.sh" ;; + 1) launch_menu_command "$SCRIPT_DIR/bin/clean.sh" ;; + 2) launch_menu_command "$SCRIPT_DIR/bin/uninstall.sh" ;; + 3) launch_menu_command "$SCRIPT_DIR/bin/optimize.sh" ;; + 4) launch_menu_command "$SCRIPT_DIR/bin/analyze.sh" ;; + 5) launch_menu_command "$SCRIPT_DIR/bin/status.sh" ;; esac ;; "CHAR:1") - show_cursor - exec "$SCRIPT_DIR/bin/clean.sh" + launch_menu_command "$SCRIPT_DIR/bin/clean.sh" ;; "CHAR:2") - show_cursor - exec "$SCRIPT_DIR/bin/uninstall.sh" + launch_menu_command "$SCRIPT_DIR/bin/uninstall.sh" ;; "CHAR:3") - show_cursor - exec "$SCRIPT_DIR/bin/optimize.sh" + launch_menu_command "$SCRIPT_DIR/bin/optimize.sh" ;; "CHAR:4") - show_cursor - exec "$SCRIPT_DIR/bin/analyze.sh" + launch_menu_command "$SCRIPT_DIR/bin/analyze.sh" ;; "CHAR:5") - show_cursor - exec "$SCRIPT_DIR/bin/status.sh" + launch_menu_command "$SCRIPT_DIR/bin/status.sh" ;; "MORE") show_cursor @@ -852,19 +230,14 @@ interactive_main_menu() { # CLI dispatch main() { local -a args=() - for arg in "$@"; do - case "$arg" in - --debug) - export MO_DEBUG=1 - ;; - *) - args+=("$arg") - ;; - esac - done + mole_collect_cli_args "$@" + [[ "$MOLE_CLI_DEBUG" == "true" ]] && export MO_DEBUG=1 + if [[ "${#MOLE_CLI_ARGS[@]}" -gt 0 ]]; then + args=("${MOLE_CLI_ARGS[@]}") + fi case "${args[0]:-""}" in - "optimize") + "optimize" | "optimise") exec "$SCRIPT_DIR/bin/optimize.sh" "${args[@]:1}" ;; "clean") @@ -873,7 +246,7 @@ main() { "uninstall") exec "$SCRIPT_DIR/bin/uninstall.sh" "${args[@]:1}" ;; - "analyze") + "analyze" | "analyse") exec "$SCRIPT_DIR/bin/analyze.sh" "${args[@]:1}" ;; "status") diff --git a/Resources/mole/scripts/audit_bundle_drift.sh b/Resources/mole/scripts/audit_bundle_drift.sh new file mode 100755 index 0000000..e46c440 --- /dev/null +++ b/Resources/mole/scripts/audit_bundle_drift.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Bundle drift audit. +# +# Enumerates bundle IDs of all apps in /System/Applications and /Applications +# on the current macOS host, then reports any that are NOT covered by +# SYSTEM_CRITICAL_BUNDLES / DATA_PROTECTED_BUNDLES. +# +# Intent: when a new macOS major release adds a system component (e.g. Apple +# Intelligence introduced new daemons), this script surfaces it so we can +# decide whether to add it to the protection list. +# +# This is a HINT, not an enforcement. New bundle IDs in /Applications may be +# legitimately user-installed (Slack, Discord, etc.) and should NOT be in the +# critical list. Review each match by hand. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck source=lib/core/common.sh +source "$PROJECT_ROOT/lib/core/common.sh" + +list_bundle_ids() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + find "$dir" -maxdepth 2 -name '*.app' -print 2> /dev/null | while IFS= read -r app; do + local info="$app/Contents/Info.plist" + [[ -f "$info" ]] || continue + plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || true + done +} + +is_covered() { + local bundle="$1" + local pattern + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle" "$pattern"; then + return 0 + fi + done + return 1 +} + +echo "Mole bundle drift audit" +echo "macOS: $(sw_vers -productVersion 2> /dev/null || echo unknown)" +echo + +declare -a system_uncovered=() +declare -a apps_uncovered=() + +while IFS= read -r bundle; do + [[ -n "$bundle" ]] || continue + if ! is_covered "$bundle"; then + system_uncovered+=("$bundle") + fi +done < <(list_bundle_ids /System/Applications | sort -u) + +while IFS= read -r bundle; do + [[ -n "$bundle" ]] || continue + if ! is_covered "$bundle"; then + apps_uncovered+=("$bundle") + fi +done < <(list_bundle_ids /Applications | sort -u) + +echo "=== /System/Applications NOT in protection lists ===" +if [[ ${#system_uncovered[@]} -gt 0 ]]; then + printf ' %s\n' "${system_uncovered[@]}" + echo + echo "ACTION: Add legitimately-system bundle IDs above to SYSTEM_CRITICAL_BUNDLES" + echo " in lib/core/app_protection_data.sh after review." +else + echo " (none -- all system apps covered)" +fi +echo + +echo "=== /Applications NOT in protection lists (informational) ===" +if [[ ${#apps_uncovered[@]} -gt 0 ]]; then + printf ' %s\n' "${apps_uncovered[@]}" + echo + echo "NOTE: These are user-installed apps. Most should NOT be protected." + echo " Only add if they hold sensitive data that must survive cleanup." +else + echo " (none)" +fi + +if [[ ${#system_uncovered[@]} -gt 0 ]]; then + exit 2 +fi diff --git a/Resources/mole/scripts/audit_function_duplication.py b/Resources/mole/scripts/audit_function_duplication.py new file mode 100755 index 0000000..c163481 --- /dev/null +++ b/Resources/mole/scripts/audit_function_duplication.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Find shell functions that are the same code wearing different names. + +Grepping for a name does not find this class. The copies that matter are the +ones where someone renamed the variables: `_dev_safe_clean_process_guarded` and +`_user_safe_clean_process_guarded` differed only by a `_MOLE_DEV_` vs +`_MOLE_USER_` prefix, and nine delete guards each re-implemented the same +six-line process-state translation. Reviewing by name reads all of them as +distinct helpers. + +So compare structure instead: strip comments, then rewrite local identifiers and +string literals to placeholders, and hash what is left. Functions that collapse +to the same hash are the same function. + +The duplication itself is rarely the bug. The bug is that one copy drifts and +the other copies still read correctly in review, which is how a guard folding +"could not tell" into "not running" survives a careful reading of its eight +siblings. That is why this is a gate and not a report. + +Usage: + scripts/audit_function_duplication.py # gate, exits non-zero on a new group + scripts/audit_function_duplication.py --list # show every group, exit 0 +""" + +from __future__ import annotations + +import hashlib +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Directories worth comparing. Tests legitimately repeat setup shapes. +SEARCH_GLOBS = ("lib/**/*.sh", "bin/*.sh") + +# Below this a match is noise: three-line wrappers around one call collide by +# accident and say nothing about drift risk. +MIN_BODY_LINES = 5 + +# Groups that are the same shape on purpose. Keyed by the member names, sorted. +# A new group is a review question, not automatically a defect: add it here with +# the reason it stays, or collapse it. An entry whose members no longer exist is +# reported too, so this list cannot quietly outlive what it documents. +ALLOWED_GROUPS: dict[tuple[str, ...], str] = { + ("log_info", "log_success", "log_warning"): ( + "one frame per severity; they differ by colour and icon, which is the whole point" + ), + ("_ms_get_terminal_height", "_pm_get_terminal_height"): ( + "menu_simple and menu_paginated are deliberately independent modules; " + "sharing a helper would couple the fallback UI to the paginated one" + ), + ("show_history_help", "show_installer_help"): ( + "same help frame, different command text; the frame is the shared part and already is" + ), +} + +FUNC_START = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\(\)\s*\{\s*$") +FUNC_END = re.compile(r"^\}\s*$") + + +def normalize(body: list[str]) -> str: + """Reduce a body to its structure: control flow, operators, call arity.""" + kept = [] + for line in body: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + kept.append(stripped) + text = "\n".join(kept) + text = re.sub(r"_MOLE_[A-Z0-9_]+", "VAR", text) + text = re.sub(r"\b_[a-z][a-z0-9_]*\b", "FN", text) + text = re.sub(r'"[^"]*"', "S", text) + text = re.sub(r"'[^']*'", "S", text) + return text + + +def collect_functions() -> dict[tuple[str, str], list[str]]: + functions: dict[tuple[str, str], list[str]] = {} + paths: list[Path] = [] + for pattern in SEARCH_GLOBS: + paths.extend(sorted(REPO_ROOT.glob(pattern))) + for path in paths: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + index = 0 + while index < len(lines): + match = FUNC_START.match(lines[index]) + if not match: + index += 1 + continue + name = match.group(1) + cursor = index + 1 + body: list[str] = [] + while cursor < len(lines) and not FUNC_END.match(lines[cursor]): + body.append(lines[cursor]) + cursor += 1 + if len(body) >= MIN_BODY_LINES: + rel = path.relative_to(REPO_ROOT).as_posix() + functions[(rel, name)] = body + index = cursor + 1 + return functions + + +def find_groups() -> list[tuple[tuple[str, ...], list[tuple[str, str]]]]: + buckets: dict[str, list[tuple[str, str]]] = {} + for (rel, name), body in collect_functions().items(): + digest = hashlib.sha256(normalize(body).encode("utf-8")).hexdigest() + buckets.setdefault(digest, []).append((rel, name)) + groups = [] + for members in buckets.values(): + if len(members) < 2: + continue + key = tuple(sorted(name for _, name in members)) + groups.append((key, sorted(members))) + groups.sort(key=lambda item: (-len(item[1]), item[0])) + return groups + + +def main() -> int: + groups = find_groups() + listing = "--list" in sys.argv[1:] + + if listing: + for key, members in groups: + status = "allowed" if key in ALLOWED_GROUPS else "NEW" + print(f"[{len(members)}] {status}") + for rel, name in members: + print(f" {name} ({rel})") + print(f"\n{len(groups)} group(s); {len(ALLOWED_GROUPS)} allowed") + return 0 + + seen = {key for key, _ in groups} + unexpected = [(key, members) for key, members in groups if key not in ALLOWED_GROUPS] + vanished = [key for key in ALLOWED_GROUPS if key not in seen] + + if unexpected: + print("error: shell functions share a body under different names", file=sys.stderr) + for key, members in unexpected: + print("", file=sys.stderr) + for rel, name in members: + print(f" {name} ({rel})", file=sys.stderr) + print( + "\nCollapse them, or add the group to ALLOWED_GROUPS in this script with the\n" + "reason it stays. Duplication here is cheap to write and expensive to keep:\n" + "the copy that drifts still reads correctly next to the ones that did not.", + file=sys.stderr, + ) + return 1 + + if vanished: + print("error: ALLOWED_GROUPS lists groups that no longer exist", file=sys.stderr) + for key in vanished: + print(f" {', '.join(key)}", file=sys.stderr) + print("\nDrop the stale entries so this list keeps describing the code.", file=sys.stderr) + return 1 + + print(f"function-duplication-ok groups={len(groups)}/{len(ALLOWED_GROUPS)} allowed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Resources/mole/scripts/check.sh b/Resources/mole/scripts/check.sh index 20e3dff..6936e84 100755 --- a/Resources/mole/scripts/check.sh +++ b/Resources/mole/scripts/check.sh @@ -20,6 +20,49 @@ Options: EOF } +check_diagnostic_guidance() { + local file + local status=0 + + for file in "$@"; do + [[ -f "$file" ]] || continue + if ! awk ' + function inspect_block() { + normalized = block + gsub(/\$[\047"]/, "", normalized) + gsub(/[\047"]/, "", normalized) + while (match(normalized, /\\[[:alnum:]_]/)) { + normalized = substr(normalized, 1, RSTART - 1) \ + substr(normalized, RSTART + 1, 1) \ + substr(normalized, RSTART + 2) + } + if (normalized ~ /Mole-Diagnose[.]command/ && + normalized ~ /\|[[:space:]]*([^|;&[:space:]]+[[:space:]]+)*([^|;&[:space:]]*\/)?(ba|z|da|k)?sh([^[:alnum:]_]|$)/) { + printf "%s:%d: unsafe diagnostic pipe-to-shell guidance\n", FILENAME, block_start + found = 1 + } + block = "" + } + /^[[:space:]]*$/ { + inspect_block() + next + } + { + if (block == "") block_start = FNR + block = block " " $0 + } + END { + inspect_block() + exit found ? 1 : 0 + } + ' "$file"; then + status=1 + fi + done + + return "$status" +} + while [[ $# -gt 0 ]]; do case "$1" in --format) @@ -44,11 +87,20 @@ done cd "$PROJECT_ROOT" -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' +# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes. +if [[ -n "${NO_COLOR:-}" ]]; then + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +else + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' +fi readonly ICON_SUCCESS="✓" readonly ICON_ERROR="☻" @@ -76,11 +128,11 @@ if [[ "$MODE" == "format" ]]; then if command -v goimports > /dev/null 2>&1; then echo -e "${YELLOW}Formatting Go code, goimports...${NC}" - goimports -w -local github.com/tw93/Mole ./cmd + goimports -w -local github.com/tw93/mole ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" elif command -v go > /dev/null 2>&1; then echo -e "${YELLOW}Formatting Go code, gofmt...${NC}" - gofmt -w ./cmd + gofmt -w ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" else echo -e "${YELLOW}${ICON_WARNING} go not installed, skipping gofmt${NC}\n" @@ -101,11 +153,11 @@ if [[ "$MODE" != "check" ]]; then if command -v goimports > /dev/null 2>&1; then echo -e "${YELLOW}2. Formatting Go code, goimports...${NC}" - goimports -w -local github.com/tw93/Mole ./cmd + goimports -w -local github.com/tw93/mole ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" elif command -v go > /dev/null 2>&1; then echo -e "${YELLOW}2. Formatting Go code, gofmt...${NC}" - gofmt -w ./cmd + gofmt -w ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" fi fi @@ -116,15 +168,17 @@ if command -v golangci-lint > /dev/null 2>&1; then echo -e "${RED}${ICON_ERROR} golangci-lint config invalid${NC}\n" exit 1 fi - if golangci-lint run ./cmd/...; then + if golangci-lint run ./...; then echo -e "${GREEN}${ICON_SUCCESS} golangci-lint passed${NC}\n" else echo -e "${RED}${ICON_ERROR} golangci-lint failed${NC}\n" + echo -e "${YELLOW}If the output points to deleted temporary worktrees or non-existent paths, run:${NC}" + echo -e "${YELLOW} golangci-lint cache clean && golangci-lint run ./...${NC}\n" exit 1 fi elif command -v go > /dev/null 2>&1; then echo -e "${YELLOW}${ICON_WARNING} golangci-lint not installed, falling back to go vet${NC}" - if go vet ./cmd/...; then + if go vet ./...; then echo -e "${GREEN}${ICON_SUCCESS} go vet passed${NC}\n" else echo -e "${RED}${ICON_ERROR} go vet failed${NC}\n" @@ -136,7 +190,7 @@ fi echo -e "${YELLOW}4. Running ShellCheck...${NC}" if command -v shellcheck > /dev/null 2>&1; then - if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then + if shellcheck mole install.sh bin/*.sh lib/*/*.sh scripts/*.sh; then echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n" else echo -e "${RED}${ICON_ERROR} ShellCheck failed${NC}\n" @@ -151,12 +205,22 @@ if ! bash -n mole; then echo -e "${RED}${ICON_ERROR} Syntax check failed, mole${NC}\n" exit 1 fi +if ! bash -n install.sh; then + echo -e "${RED}${ICON_ERROR} Syntax check failed, install.sh${NC}\n" + exit 1 +fi for script in bin/*.sh; do if ! bash -n "$script"; then echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" exit 1 fi done +for script in scripts/*.sh; do + if ! bash -n "$script"; then + echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" + exit 1 + fi +done find lib -name "*.sh" | while read -r script; do if ! bash -n "$script"; then echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" @@ -165,57 +229,31 @@ find lib -name "*.sh" | while read -r script; do done echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n" -echo -e "${YELLOW}6. Checking optimizations...${NC}" -OPTIMIZATION_SCORE=0 -TOTAL_CHECKS=0 - -((TOTAL_CHECKS++)) -if grep -q "read -r -s -n 1 -t 1" lib/core/ui.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Keyboard timeout configured${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Keyboard timeout may be misconfigured${NC}" -fi - -((TOTAL_CHECKS++)) -DRAIN_PASSES=$(grep -c "while IFS= read -r -s -n 1" lib/core/ui.sh 2> /dev/null || true) -DRAIN_PASSES=${DRAIN_PASSES:-0} -if [[ $DRAIN_PASSES -eq 1 ]]; then - echo -e "${GREEN} ${ICON_SUCCESS} drain_pending_input optimized${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} drain_pending_input has multiple passes${NC}" -fi - -((TOTAL_CHECKS++)) -if grep -q "rotate_log_once" lib/core/log.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Log rotation optimized${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Log rotation not optimized${NC}" +# Same body, different name. Grep cannot see this class once the variables have +# been renamed, and review reads the copies as separate helpers, so it needs a +# gate rather than a habit. Run with --list to inspect every group. +# +# python3 is a hard requirement here, not an optional tool like shfmt: six Bats +# files already fail without it, so a developer who can run the suite can run +# this. Mole itself never shells out to python3, so this stays a dev-only need. +if ! command -v python3 > /dev/null 2>&1; then + echo -e "${RED}${ICON_ERROR} python3 not installed; it is required by the test suite and this check${NC}\n" + exit 1 fi - -((TOTAL_CHECKS++)) -if ! grep -q "cache_meta\|cache_dir_mtime" bin/uninstall.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Cache validation simplified${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Cache still uses redundant metadata${NC}" +if ! duplication_output=$(python3 "$SCRIPT_DIR/audit_function_duplication.py" 2>&1); then + printf '%s\n' "$duplication_output" + echo -e "${RED}${ICON_ERROR} Duplicate shell function bodies found${NC}\n" + exit 1 fi +printf '%s\n' "$duplication_output" +echo -e "${GREEN}${ICON_SUCCESS} Function duplication check passed${NC}\n" -((TOTAL_CHECKS++)) -if grep -q "Consecutive slashes" bin/clean.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Path validation enhanced${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Path validation not enhanced${NC}" +diagnostic_guidance_files=(AGENTS.md README.md .claude/skills/*/SKILL.md) +if ! diagnostic_guidance_output=$(check_diagnostic_guidance "${diagnostic_guidance_files[@]}"); then + [[ -n "$diagnostic_guidance_output" ]] && printf '%s\n' "$diagnostic_guidance_output" + echo -e "${RED}${ICON_ERROR} Diagnostic instructions must download for review before execution${NC}\n" + exit 1 fi - -echo -e "${BLUE} Optimization score: $OPTIMIZATION_SCORE/$TOTAL_CHECKS${NC}\n" +echo -e "${GREEN}${ICON_SUCCESS} Diagnostic install guidance passed${NC}\n" echo -e "${GREEN}=== Checks Completed ===${NC}" -if [[ $OPTIMIZATION_SCORE -eq $TOTAL_CHECKS ]]; then - echo -e "${GREEN}${ICON_SUCCESS} All optimizations applied${NC}" -else - echo -e "${YELLOW}${ICON_WARNING} Some optimizations missing${NC}" -fi diff --git a/Resources/mole/scripts/check_release_minos.sh b/Resources/mole/scripts/check_release_minos.sh new file mode 100755 index 0000000..a5b9bbc --- /dev/null +++ b/Resources/mole/scripts/check_release_minos.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Verify release binaries keep an intentional minimum macOS version. + +set -euo pipefail + +MAX_MINOS="${MAX_RELEASE_MINOS:-13.0}" + +if [[ "${1:-}" == "--max" ]]; then + if [[ $# -lt 2 ]]; then + echo "--max requires a version value" >&2 + exit 1 + fi + MAX_MINOS="$2" + shift 2 +fi + +if [[ -z "$MAX_MINOS" ]]; then + echo "MAX_RELEASE_MINOS must not be empty" >&2 + exit 1 +fi + +if ! command -v otool > /dev/null 2>&1; then + echo "otool is required to inspect Mach-O release binaries" >&2 + exit 1 +fi + +version_gt() { + awk -v left="$1" -v right="$2" ' + BEGIN { + split(left, l, ".") + split(right, r, ".") + for (i = 1; i <= 3; i++) { + lv = (l[i] == "" ? 0 : l[i] + 0) + rv = (r[i] == "" ? 0 : r[i] + 0) + if (lv > rv) exit 0 + if (lv < rv) exit 1 + } + exit 1 + } + ' +} + +extract_minos() { + otool -l "$1" | awk ' + $1 == "cmd" && $2 == "LC_BUILD_VERSION" { + in_build = 1 + next + } + in_build && $1 == "minos" { + print $2 + exit + } + in_build && $1 == "cmd" { + in_build = 0 + } + ' +} + +if [[ $# -eq 0 ]]; then + set -- bin/analyze-darwin-* bin/status-darwin-* +fi + +checked=0 +failed=0 + +for binary in "$@"; do + if [[ ! -f "$binary" ]]; then + continue + fi + + minos=$(extract_minos "$binary") + if [[ -z "$minos" ]]; then + echo "ERROR: $binary has no LC_BUILD_VERSION minos" >&2 + failed=1 + continue + fi + + checked=$((checked + 1)) + if version_gt "$minos" "$MAX_MINOS"; then + echo "ERROR: $binary minos $minos exceeds allowed $MAX_MINOS" >&2 + failed=1 + else + echo "OK: $binary minos $minos <= $MAX_MINOS" + fi +done + +if [[ $checked -eq 0 ]]; then + echo "ERROR: no release binaries found to inspect" >&2 + exit 1 +fi + +exit "$failed" diff --git a/Resources/mole/scripts/test.sh b/Resources/mole/scripts/test.sh index 2cf6fab..df54c62 100755 --- a/Resources/mole/scripts/test.sh +++ b/Resources/mole/scripts/test.sh @@ -10,6 +10,63 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" +# Sweep orphaned per-test HOME dirs left behind by killed bats runs. +# Normal teardown removes them; this only catches the ones that escaped. +# 60-minute threshold avoids racing with a long-running test in progress. +if [[ -d "$PROJECT_ROOT/tests" ]]; then + find "$PROJECT_ROOT/tests" -maxdepth 1 -type d -name 'tmp-*' -mmin +60 \ + -exec rm -rf {} + 2> /dev/null || true # SAFE: confined to tests/tmp-* +fi + +# Never allow the scripted test run to trigger real sudo or Touch ID prompts. +export MOLE_TEST_NO_AUTH=1 + +# Tests assert deterministic ANSI escape output. Strip any NO_COLOR the +# developer has set in their shell so the test color-escape assertions +# match regardless of the host environment. +unset NO_COLOR + +TEST_SYSTEM_STUB_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mole-test-stubs.XXXXXX")" +TEST_GO_HELPER_DIR="" +# shellcheck disable=SC2329 # Invoked by trap. +cleanup_test_stubs() { + rm -rf "$TEST_SYSTEM_STUB_DIR" + if [[ -n "$TEST_GO_HELPER_DIR" ]]; then + rm -rf "$TEST_GO_HELPER_DIR" + fi +} +trap cleanup_test_stubs EXIT + +cat > "$TEST_SYSTEM_STUB_DIR/sudo" << 'EOF' +#!/bin/bash +case "${1:-}" in + -k) + exit 0 + ;; + -n) + exit 1 + ;; +esac + +printf 'mole test blocked sudo: %s\n' "$*" >&2 +exit 1 +EOF + +cat > "$TEST_SYSTEM_STUB_DIR/osascript" << 'EOF' +#!/bin/bash +printf 'mole test blocked osascript: %s\n' "$*" >&2 +exit 1 +EOF + +cat > "$TEST_SYSTEM_STUB_DIR/launchctl" << 'EOF' +#!/bin/bash +printf 'mole test blocked launchctl: %s\n' "$*" >&2 +exit 0 +EOF + +chmod +x "$TEST_SYSTEM_STUB_DIR/sudo" "$TEST_SYSTEM_STUB_DIR/osascript" "$TEST_SYSTEM_STUB_DIR/launchctl" +export PATH="$TEST_SYSTEM_STUB_DIR:$PATH" + # shellcheck source=lib/core/file_ops.sh source "$PROJECT_ROOT/lib/core/file_ops.sh" @@ -20,6 +77,33 @@ echo "" FAILED=0 +enforce_timeout_dependency_in_ci() { + if [[ "${CI:-}" != "true" && "${GITHUB_ACTIONS:-}" != "true" ]]; then + return 0 + fi + + if command -v gtimeout > /dev/null 2>&1 || command -v timeout > /dev/null 2>&1; then + return 0 + fi + + printf "${RED}${ICON_ERROR} Missing timeout binary (gtimeout/timeout) in CI${NC}\n" + printf "${YELLOW}${ICON_WARNING} Install coreutils to provide gtimeout${NC}\n" + exit 1 +} + +# Print the slowest test files from the JUnit report written during the run. +# Attribution is per file, which is what a parallel run hides: wall clock is +# set by the single slowest file, not by the total. +report_slowest_test_files() { + local report="${MOLE_TEST_REPORT_DIR:-}/report.xml" + [[ -n "${MOLE_TEST_REPORT_DIR:-}" && -f "$report" ]] || return 0 + + printf "\n%s\n" "Slowest test files (seconds):" + sed -n 's/.* /dev/null 2>&1 || return 0 + + TEST_GO_HELPER_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mole-go-helpers.XXXXXX")" + mkdir -p "$GO_TEST_CACHE" + + if GOCACHE="$GO_TEST_CACHE" go build -o "$TEST_GO_HELPER_DIR/analyze-go" ./cmd/analyze > /dev/null 2>&1 && + GOCACHE="$GO_TEST_CACHE" go build -o "$TEST_GO_HELPER_DIR/status-go" ./cmd/status > /dev/null 2>&1; then + export MOLE_TEST_ANALYZE_BIN="$TEST_GO_HELPER_DIR/analyze-go" + export MOLE_TEST_STATUS_BIN="$TEST_GO_HELPER_DIR/status-go" + else + rm -rf "$TEST_GO_HELPER_DIR" + TEST_GO_HELPER_DIR="" + fi +} + echo "1. Linting test scripts..." if command -v shellcheck > /dev/null 2>&1; then TEST_FILES=() @@ -94,55 +211,128 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then set -- tests fi fi + if test_selection_needs_go_helpers "$@"; then + prepare_go_test_helpers + fi use_color=false if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then use_color=true fi - if bats --help 2>&1 | grep -q -- "--formatter"; then - formatter="${BATS_FORMATTER:-pretty}" - if [[ "$formatter" == "tap" ]]; then - if $use_color; then - esc=$'\033' - if bats --formatter tap "$@" | - sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ - -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then - report_unit_result 0 + + bats_help="$(bats --help 2>&1 || true)" + bats_has_jobs=false + bats_has_formatter=false + if grep -q -- "--jobs" <<< "$bats_help"; then + bats_has_jobs=true + fi + if grep -q -- "--formatter" <<< "$bats_help"; then + bats_has_formatter=true + fi + + # Enable parallel execution across test files when Bats and its backend support it. + # Cap at 6 jobs by default to balance speed vs. system load during CI. + bats_opts=() + if $bats_has_jobs && { command -v parallel > /dev/null 2>&1 || command -v rush > /dev/null 2>&1; }; then + _ncpu="$(sysctl -n hw.logicalcpu 2> /dev/null || nproc 2> /dev/null || echo 4)" + if [[ "${MOLE_TEST_JOBS:-}" =~ ^[0-9]+$ && "${MOLE_TEST_JOBS:-0}" -gt 0 ]]; then + _jobs="$MOLE_TEST_JOBS" + else + _jobs="$((_ncpu > 6 ? 6 : (_ncpu < 2 ? 2 : _ncpu)))" + fi + # --no-parallelize-within-files ensures each test file's tests run + # sequentially (they share a $HOME set by setup_file and are not safe + # to run concurrently). Parallelism is only across files. + bats_opts+=("--jobs" "$_jobs" "--no-parallelize-within-files") + unset _ncpu _jobs + fi + if [[ "${MOLE_TEST_TIMING:-0}" == "1" ]]; then + bats_opts+=("--timing") + fi + + # Per-file timings. Parallel TAP output cannot be attributed back to a file, + # so one slow file is invisible until it dominates the whole run. The JUnit + # report carries one per file; CI sets this and the + # slowest files are printed after the run. + if [[ -n "${MOLE_TEST_REPORT_DIR:-}" ]] && $bats_has_formatter; then + mkdir -p "$MOLE_TEST_REPORT_DIR" + bats_opts+=("--report-formatter" "junit" "--output" "$MOLE_TEST_REPORT_DIR") + fi + + # Some test files include wall-clock timing assertions that are skewed by + # CPU contention from parallel test workers. When parallel mode is active, + # split them out to run sequentially after the parallel batch completes. + _sequential_files=() + if [[ ${#bats_opts[@]} -gt 0 ]]; then + _all=("$@") + _rest=() + if [[ ${#_all[@]} -eq 1 && -d "${_all[0]}" ]]; then + while IFS= read -r _f; do + case "$_f" in + *core_performance.bats | *regression.bats) _sequential_files+=("$_f") ;; + *) _rest+=("$_f") ;; + esac + done < <(find "${_all[0]}" -type f -name '*.bats' | sort) + else + for _f in "${_all[@]}"; do + case "$_f" in + *core_performance.bats | *regression.bats) _sequential_files+=("$_f") ;; + *) _rest+=("$_f") ;; + esac + done + fi + if [[ ${#_rest[@]} -gt 0 ]]; then + set -- "${_rest[@]}" + else + set -- + fi + unset _all _rest _f + fi + + # Accumulate pass/fail across all bats invocations. + _unit_rc=0 + + # Main run (parallel when bats_opts has --jobs, skipped if no files remain). + if [[ $# -gt 0 ]]; then + if $bats_has_formatter; then + formatter="${BATS_FORMATTER:-pretty}" + if [[ "$formatter" == "tap" ]]; then + if $use_color; then + esc=$'\033' + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" | + sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ + -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1 else - report_unit_result 1 + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" || _unit_rc=1 fi else - if bats --formatter tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi + # Pretty format for local development + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter "$formatter" "$@" || _unit_rc=1 fi else - # Pretty format for local development - if bats --formatter "$formatter" "$@"; then - report_unit_result 0 + if $use_color; then + esc=$'\033' + bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" | + sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ + -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1 else - report_unit_result 1 + bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" || _unit_rc=1 fi fi - else - if $use_color; then - esc=$'\033' - if bats --tap "$@" | - sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ - -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then - report_unit_result 0 - else - report_unit_result 1 - fi + fi + + # Post-run: timing-sensitive tests run after parallel workers have finished + # so CPU contention does not skew wall-clock assertions. + for _pf in ${_sequential_files[@]+"${_sequential_files[@]}"}; do + if [[ "${MOLE_TEST_TIMING:-0}" == "1" ]]; then + bats --timing "$_pf" || _unit_rc=1 else - if bats --tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi + bats "$_pf" || _unit_rc=1 fi - fi + done + unset _sequential_files _pf + + report_slowest_test_files + report_unit_result "$_unit_rc" else printf "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n" fi @@ -150,11 +340,10 @@ echo "" echo "3. Running Go tests..." if command -v go > /dev/null 2>&1; then - GO_TEST_CACHE="${MOLE_GO_TEST_CACHE:-/tmp/mole-go-build-cache}" mkdir -p "$GO_TEST_CACHE" if GOCACHE="$GO_TEST_CACHE" go build ./... > /dev/null 2>&1 && - GOCACHE="$GO_TEST_CACHE" go vet ./cmd/... > /dev/null 2>&1 && - GOCACHE="$GO_TEST_CACHE" go test ./cmd/... > /dev/null 2>&1; then + GOCACHE="$GO_TEST_CACHE" go vet ./... > /dev/null 2>&1 && + GOCACHE="$GO_TEST_CACHE" go test ./... > /dev/null 2>&1; then printf "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n" else printf "${RED}${ICON_ERROR} Go tests failed${NC}\n" @@ -185,37 +374,50 @@ fi echo "" echo "6. Testing installation..." -# Skip if Homebrew mole is installed (install.sh will refuse to overwrite) -install_test_home="" -if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then - printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" +# Installation script is macOS-specific; skip this test on non-macOS platforms +if [[ "$(uname -s)" != "Darwin" ]]; then + printf "${YELLOW}${ICON_WARNING} Installation test skipped (non-macOS)${NC}\n" else - install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)" + # Skip if Homebrew mole is installed (install.sh will refuse to overwrite) + install_test_home="" + install_test_prefix="" + if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then + printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" + else + install_test_home="$(mktemp -d "$PROJECT_ROOT/tests/tmp-install-home.XXXXXX" 2> /dev/null || true)" + if [[ -z "$install_test_home" ]]; then + install_test_home="$PROJECT_ROOT/tests/tmp-install-home" + mkdir -p "$install_test_home" + fi + fi if [[ -z "$install_test_home" ]]; then - install_test_home="/tmp/mole-test-home" - mkdir -p "$install_test_home" + : + else + install_test_prefix="$install_test_home/mole-bin" fi -fi -if [[ -z "$install_test_home" ]]; then - : -elif HOME="$install_test_home" \ - XDG_CONFIG_HOME="$install_test_home/.config" \ - XDG_CACHE_HOME="$install_test_home/.cache" \ - MO_NO_OPLOG=1 \ - ./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then - if [ -f /tmp/mole-test/mole ]; then - printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n" + if [[ -z "$install_test_home" ]]; then + : + elif HOME="$install_test_home" \ + XDG_CONFIG_HOME="$install_test_home/.config" \ + XDG_CACHE_HOME="$install_test_home/.cache" \ + MO_NO_OPLOG=1 \ + ./install.sh --prefix "$install_test_prefix" > /dev/null 2>&1; then + if [[ -f "$install_test_prefix/mole" ]]; then + printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n" + else + printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" + ((FAILED++)) + fi else printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" ((FAILED++)) fi -else - printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" - ((FAILED++)) -fi -MO_NO_OPLOG=1 safe_remove "/tmp/mole-test" true || true -if [[ -n "$install_test_home" ]]; then - MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true + if [[ -n "$install_test_prefix" ]]; then + MO_NO_OPLOG=1 safe_remove "$install_test_prefix" true || true + fi + if [[ -n "$install_test_home" ]]; then + MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true + fi fi echo "" diff --git a/Resources/mole/tests/.gitignore b/Resources/mole/tests/.gitignore new file mode 100644 index 0000000..233d20b --- /dev/null +++ b/Resources/mole/tests/.gitignore @@ -0,0 +1,5 @@ +# Per-test temporary HOME directories created by bats setup_file. +# These are cleaned in teardown_file but get left behind when bats is +# killed mid-run (Ctrl-C, OOM, kill -9). scripts/test.sh removes +# orphaned ones older than 60 minutes at startup. +tmp-*/ diff --git a/Resources/mole/tests/brew_uninstall.bats b/Resources/mole/tests/brew_uninstall.bats index d3aab74..14cb05c 100644 --- a/Resources/mole/tests/brew_uninstall.bats +++ b/Resources/mole/tests/brew_uninstall.bats @@ -9,14 +9,27 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-brew-uninstall-home.XXXXXX")" export HOME + + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE } teardown_file() { - rm -rf "$HOME" - export HOME="$ORIGINAL_HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi mkdir -p "$HOME/Applications" mkdir -p "$HOME/Library/Caches" # Create fake Caskroom @@ -29,7 +42,7 @@ setup() { mkdir -p "$HOME/Applications/TestApp.app" ln -s "$HOME/Applications/TestApp.app" "$HOME/Caskroom/test-app/1.0.0/TestApp.app" - run bash <> "$HOME/brew_calls.log" + return 0 +} # Mock brew to track calls brew() { @@ -117,17 +256,92 @@ total_size_cleaned=0 # Simulate 'Enter' for confirmation printf '\n' | batch_uninstall_applications > /dev/null 2>&1 +grep -q "ENSURE_SUDO:Admin required for Homebrew casks: BrewApp" "$HOME/brew_calls.log" grep -q "uninstall --cask --zap brew-app-cask" "$HOME/brew_calls.log" EOF [ "$status" -eq 0 ] } -@test "batch_uninstall_applications does not pre-auth sudo for brew-only casks" { +@test "batch_uninstall_applications drops --zap when a sibling install shares the cask bundle id" { + # iterm2 and iterm2-beta both zap com.googlecode.iterm2. When the stable + # install survives, uninstalling the beta cask must not run the zap + # stanza, or brew deletes the survivor's prefs/caches behind the guard. + mkdir -p "$HOME/Applications/BrewShared.app" "$HOME/Applications/BrewShared-beta.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +remove_apps_from_dock() { :; } +force_kill_app() { return 0; } +run_with_timeout() { shift; "$@"; } +export -f run_with_timeout +ensure_sudo_session() { return 0; } + +brew() { + echo "brew call: $*" >> "$HOME/brew_shared_calls.log" + # Make the uninstall "work" so verification passes and no manual + # fallback path runs. + if [[ "$1" == "uninstall" ]]; then + rm -rf "$HOME/Applications/BrewShared-beta.app" + fi + return 0 +} +export -f brew + +get_brew_cask_name() { echo "brewshared-beta"; return 0; } +export -f get_brew_cask_name + +apps_data=( + "0|$HOME/Applications/BrewShared.app|BrewShared|com.example.brewshared|0|Never|0" + "0|$HOME/Applications/BrewShared-beta.app|BrewShared-beta|com.example.brewshared|0|Never|0" +) +selected_apps=("0|$HOME/Applications/BrewShared-beta.app|BrewShared-beta|com.example.brewshared|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > "$HOME/brew_shared_output.log" 2>&1 + +grep -q "uninstall --cask brewshared-beta" "$HOME/brew_shared_calls.log" || { + echo "WRONG: plain cask uninstall not invoked" + cat "$HOME/brew_shared_calls.log" + exit 1 +} +if grep -q -- "--zap" "$HOME/brew_shared_calls.log"; then + echo "WRONG: --zap used despite surviving same-bundle sibling" + cat "$HOME/brew_shared_calls.log" + exit 1 +fi +if grep -q -- "Homebrew apps will be fully cleaned" "$HOME/brew_shared_output.log"; then + echo "WRONG: preview claims --zap despite surviving same-bundle sibling" + cat "$HOME/brew_shared_output.log" + exit 1 +fi +[[ -d "$HOME/Applications/BrewShared.app" ]] || { + echo "WRONG: surviving install removed" + exit 1 +} +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications pre-auths sudo for brew-only casks" { local app_bundle="$HOME/Applications/BrewPreAuth.app" mkdir -p "$app_bundle" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -145,8 +359,8 @@ run_with_timeout() { shift; "$@"; } export -f run_with_timeout ensure_sudo_session() { - echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/order.log" - return 1 + echo "ENSURE_SUDO:$*" >> "$HOME/order.log" + return 0 } brew() { @@ -165,8 +379,9 @@ total_size_cleaned=0 printf '\n' | batch_uninstall_applications > /dev/null 2>&1 +grep -q "ENSURE_SUDO:Admin required for Homebrew casks: BrewPreAuth" "$HOME/order.log" grep -q "BREW_CALL:uninstall --cask --zap brew-preauth-cask" "$HOME/order.log" -! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/order.log" +[[ "$(sed -n '1p' "$HOME/order.log")" == "ENSURE_SUDO:Admin required for Homebrew casks: BrewPreAuth" ]] EOF [ "$status" -eq 0 ] @@ -176,7 +391,7 @@ EOF local app_bundle="$HOME/Applications/BrewTimeout.app" mkdir -p "$app_bundle" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -192,9 +407,14 @@ print_summary_block() { :; } force_kill_app() { return 0; } remove_apps_from_dock() { :; } refresh_launch_services_after_uninstall() { echo "LS_REFRESH"; } +ensure_sudo_session() { return 0; } get_brew_cask_name() { echo "brew-timeout-cask"; return 0; } brew_uninstall_cask() { return 0; } +brew() { + echo "BREW_CALL:$*" >> "$HOME/timeout_calls.log" + return 0 +} run_with_timeout() { local duration="$1" @@ -220,41 +440,253 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"TIMEOUT_CALL:30:brew autoremove"* ]] + [[ "$output" == *"TIMEOUT_CALL:30:brew autoremove"* ]] || return 1 [[ "$output" != *"Checking brew dependencies"* ]] } -@test "brew_uninstall_cask does not trigger extra sudo pre-auth" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "batch_uninstall_applications keeps brew-managed app intact when brew uninstall fails" { + local app_bundle="$HOME/Applications/BrewBroken.app" + mkdir -p "$app_bundle" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/brew.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" -debug_log() { :; } -get_path_size_kb() { echo "0"; } -run_with_timeout() { local _timeout="$1"; shift; "$@"; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { :; } +run_with_timeout() { shift; "$@"; } +ensure_sudo_session() { return 0; } + +safe_remove() { + echo "SAFE_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +safe_sudo_remove() { + echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +get_brew_cask_name() { echo "brew-broken-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 0; } + +selected_apps=("0|$HOME/Applications/BrewBroken.app|BrewBroken|com.example.brewbroken|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 || true + +[[ -d "$HOME/Applications/BrewBroken.app" ]] +[[ ! -f "$HOME/remove.log" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications finishes cleanup after brew removes cask record" { + local app_bundle="$HOME/Applications/BrewCleanup.app" + mkdir -p "$app_bundle" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { :; } +run_with_timeout() { shift; "$@"; } +ensure_sudo_session() { return 0; } + +safe_remove() { + echo "SAFE_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +safe_sudo_remove() { + echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +get_brew_cask_name() { echo "brew-cleanup-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 1; } + +selected_apps=("0|$HOME/Applications/BrewCleanup.app|BrewCleanup|com.example.brewcleanup|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/BrewCleanup.app" ]] +grep -q "SAFE_REMOVE:$HOME/Applications/BrewCleanup.app" "$HOME/remove.log" +EOF + + [ "$status" -eq 0 ] +} + +@test "brew fallback preserves mutable-parent diagnosis after its cask record disappears" { + local app_bundle="$HOME/Applications/BrewManual.app" + local leftover="$HOME/Library/Application Support/BrewManual" + mkdir -p "$app_bundle" "$leftover" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { echo root; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { printf '%s\n' "$@"; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { printf 'LEFTOVER_DELETE\n' >> "$HOME/brew-manual-side-effects.log"; return 1; } +run_with_timeout() { shift; "$@"; } +ensure_sudo_session() { return 0; } + +get_brew_cask_name() { echo "brew-manual-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 1; } +_mole_privileged_path_has_mutable_ancestor() { return 0; } +mole_delete() { return "$MOLE_ERR_MUTABLE_PARENT"; } + +selected_apps=("0|$HOME/Applications/BrewManual.app|BrewManual|com.example.brewmanual|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications +EOF -sudo() { - echo "UNEXPECTED_SUDO_CALL:$*" - return 1 + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ -d "$app_bundle" ]] || return 1 + [[ -d "$leftover" ]] || return 1 + [[ ! -e "$HOME/brew-manual-side-effects.log" ]] || return 1 + [[ "$output" == *"Mole cannot safely use elevated deletion below a user-writable parent"* ]] || return 1 + [[ "$output" == *"Move the app to Trash in Finder"* ]] || return 1 } +@test "batch_uninstall_applications skips brew sudo pre-auth in dry-run mode" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + brew() { - if [[ "${1:-}" == "uninstall" ]]; then + echo "BREW_CALL:$*" >> "$HOME/dry_run.log" return 0 - fi - if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then +} +export -f brew + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +remove_apps_from_dock() { :; } +force_kill_app() { return 0; } +ensure_sudo_session() { + echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/dry_run.log" + return 1 +} +run_with_timeout() { shift; "$@"; } +export -f run_with_timeout + +get_brew_cask_name() { echo "brew-dry-run-cask"; return 0; } + +export MOLE_DRY_RUN=1 +selected_apps=("0|$HOME/Applications/BrewDryRun.app|BrewDryRun|com.example.brewdryrun|0|Never") +mkdir -p "$HOME/Applications/BrewDryRun.app" +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/dry_run.log" 2> /dev/null +EOF + + [ "$status" -eq 0 ] +} + +@test "brew_uninstall_cask passes cask token as argv without shell evaluation" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/brew.sh" + +debug_log() { :; } +get_path_size_kb() { echo "100"; } +run_with_timeout() { shift; "$@"; } +is_brew_cask_installed() { return 1; } + +brew() { + printf '<%s>\n' "$@" >> "$HOME/brew_argv.log" return 0 - fi - return 0 } -export -f sudo brew +export -f brew + +cask_name='bad"; touch "$HOME/pwned"; #' +brew_uninstall_cask "$cask_name" -brew_uninstall_cask "mock-cask" -echo "DONE" +[[ ! -e "$HOME/pwned" ]] +grep -Fx '' "$HOME/brew_argv.log" EOF [ "$status" -eq 0 ] - [[ "$output" == *"DONE"* ]] - [[ "$output" != *"UNEXPECTED_SUDO_CALL:"* ]] } diff --git a/Resources/mole/tests/bundle_resolver.bats b/Resources/mole/tests/bundle_resolver.bats new file mode 100644 index 0000000..99044aa --- /dev/null +++ b/Resources/mole/tests/bundle_resolver.bats @@ -0,0 +1,392 @@ +#!/usr/bin/env bats + +# Tests for lib/core/bundle_resolver.sh. Validates the filesystem-fallback path: +# we cannot rely on Spotlight indexing a fake /Applications under a tmpdir, +# so each test forces the Spotlight path to miss (no binary or empty result) +# and asserts the filesystem scan finds the app. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + FAKE_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-bundle-home.XXXXXX")" + export FAKE_HOME + # Safety: refuse to operate if mktemp failed. + if [[ "$FAKE_HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: FAKE_HOME is not a test temp dir: %s\n' "$FAKE_HOME" >&2 + return 1 + fi + mkdir -p "$FAKE_HOME/Applications" + + # Stage a fake /Applications tree inside the tmp area. bundle_has_installed_app + # hardcodes the real /Applications roots, so we patch _MOLE_BUNDLE_RESOLVER_APP_ROOTS + # from the test harness itself. + FAKE_APPS="$FAKE_HOME/FakeApplications" + export FAKE_APPS + mkdir -p "$FAKE_APPS" +} + +teardown() { + if [[ "$FAKE_HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$FAKE_HOME" + fi +} + +# Shared prelude: source base + resolver, disable mdfind, point resolver at FAKE_APPS. +prelude() { + cat <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/timeout.sh" +source "$PROJECT_ROOT/lib/core/bundle_resolver.sh" + +# Force Spotlight miss so we test only the filesystem fallback. +mdfind() { return 0; } +export -f mdfind + +# Override the hardcoded app roots for the test. +_MOLE_BUNDLE_RESOLVER_APP_ROOTS=("$FAKE_APPS") +EOF +} + +make_app() { + local app_dir="$1" + local bundle_id="$2" + mkdir -p "$app_dir/Contents" + cat > "$app_dir/Contents/Info.plist" < + + + + CFBundleIdentifier + $bundle_id + + +EOF +} + +@test "bundle_has_installed_app finds an app by CFBundleIdentifier (Spotlight miss)" { + make_app "$FAKE_APPS/KeePassXC.app" "org.keepassxc.KeePassXC" + + run env FAKE_APPS="$FAKE_APPS" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc < "$app/Contents/Library/LaunchServices/com.adobe.ARMDC.SMJobBlessHelper" + + run env FAKE_APPS="$FAKE_APPS" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc < "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code old version|$claude_support/claude-code/2.1.140" + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code VM old version|$claude_support/claude-code-vm/2.1.140" + assert_output_not_contains "$claude_support/claude-code/2.1.142" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.142" + assert_output_not_contains "$claude_support/claude-code/2.1.150" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.150" +} + +@test "clean_dev_ai_agents keeps active Claude Desktop bundled version even when it is older" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.140" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code old version|$claude_support/claude-code/2.1.142" + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code VM old version|$claude_support/claude-code-vm/2.1.142" + assert_output_not_contains "$claude_support/claude-code/2.1.140" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.140" + assert_output_not_contains "$claude_support/claude-code/2.1.150" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.150" +} + +@test "clean_dev_ai_agents leaves single Claude Desktop bundled version alone" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_not_contains "Claude Desktop bundled Claude Code" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop bundled versions when active version is unknown" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "· skipped (active version unknown)" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_claude_desktop_bundled_versions validates the active version before deferring" { + local isolated_home="$HOME/claude-missing-sdk-active" + local claude_support="$isolated_home/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$1"; } +clean_claude_desktop_bundled_versions 1 +EOF + + assert_run_success + assert_output_contains "skipped (active version unknown)" + assert_output_not_contains "UNEXPECTED_DEFER" + assert_output_not_contains "UNEXPECTED_CLEAN" +} + +@test "clean_claude_desktop_bundled_versions does not defer whitelist-only stale versions" { + local isolated_home="$HOME/claude-whitelist-only-active" + local claude_support="$isolated_home/Library/Application Support/Claude" + local old_cli="$claude_support/claude-code/2.1.140" + local old_vm="$claude_support/claude-code-vm/2.1.140" + mkdir -p "$old_cli" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$old_vm" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$old_cli" "$old_vm" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +old_cli="$HOME/Library/Application Support/Claude/claude-code/2.1.140" +old_vm="$HOME/Library/Application Support/Claude/claude-code-vm/2.1.140" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$old_cli" || "$1" == "$old_vm" ]]; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_claude_desktop_bundled_versions 1 +printf 'SKIPPED:%s\n' "$whitelist_skipped_count" +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DEFER" + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "SKIPPED:2" +} + +@test "versioned agent retention preserves newline-containing pathnames" { + local isolated_home="$HOME/agent-newline-path" + local versions_root="$isolated_home/versions" + local newline_version=$'2.0\njunk' + mkdir -p "$versions_root/1.0" "$versions_root/2.0" "$versions_root/3.0" "$versions_root/$newline_version" + touch -t 202604010000 "$versions_root/1.0" + touch -t 202604300000 "$versions_root/2.0" + touch -t 202604200000 "$versions_root/3.0" + touch -t 202604100000 "$versions_root/$newline_version" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +root="$HOME/versions" +newline_version=$'2.0\njunk' +_plan_versioned_agent_cleanup_targets "$root" 1 "$root/3.0" +[[ ${#_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]} -eq 2 ]] || exit 1 +found_old=false +found_newline=false +for target in "${_MOLE_VERSIONED_AGENT_RETENTION_TARGETS[@]}"; do + [[ "$target" != "$root/2.0" ]] || { echo "WRONG_PLANNED_KEEP:$target"; exit 1; } + [[ "$target" == "$root/1.0" ]] && found_old=true + [[ "$target" == "$root/$newline_version" ]] && found_newline=true +done +[[ "$found_old" == "true" && "$found_newline" == "true" ]] +EOF + + assert_run_success + assert_output_not_contains "WRONG_PLANNED_KEEP" +} + +@test "versioned agent cleanup discards a partial inventory when find fails" { + local isolated_home="$HOME/agent-partial-inventory" + local versions_root="$isolated_home/versions" + local fake_bin="$isolated_home/fake-bin" + mkdir -p "$versions_root/1.0" "$versions_root/2.0" "$versions_root/3.0" "$fake_bin" + touch -t 202604010000 "$versions_root/1.0" + touch -t 202604100000 "$versions_root/2.0" + touch -t 202604200000 "$versions_root/3.0" + cat > "$fake_bin/find" <<'EOF' +#!/bin/bash +root="$1" +printf '%s\0' "$root/1.0" "$root/2.0" "$root/3.0" +exit 73 +EOF + chmod +x "$fake_bin/find" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" \ + PATH="$fake_bin:/usr/bin:/bin" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "UNEXPECTED_DELETE:$1"; } +set +e +clean_versioned_agent_root "$HOME/versions" "Agent old version" 1 +rc=$? +set -e +printf 'SCAN_RC:%s\n' "$rc" +[[ $rc -eq 73 ]] +EOF + + assert_run_success + assert_output_contains "SCAN_RC:73" + assert_output_not_contains "UNEXPECTED_DELETE" +} + +@test "versioned agent delete guard rejects repeated partial active inventories" { + local isolated_home="$HOME/agent-partial-active-inventory" + local versions_root="$isolated_home/versions" + local bin_dir="$isolated_home/bin" + mkdir -p "$versions_root/1.0" "$versions_root/2.0" "$versions_root/3.0" "$bin_dir" + touch "$versions_root/1.0/agent" "$versions_root/2.0/agent" "$versions_root/3.0/agent" + touch -t 202604010000 "$versions_root/1.0" + touch -t 202604100000 "$versions_root/2.0" + touch -t 202604200000 "$versions_root/3.0" + ln -s "$versions_root/3.0/agent" "$bin_dir/agent" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +root="$HOME/versions" +scan_count=0 +_materialize_versioned_agent_entries() { + local versions_root="$1" + local output_file="$2" + scan_count=$((scan_count + 1)) + if [[ $scan_count -eq 1 || $scan_count -eq 3 ]]; then + printf '%s\0' "$versions_root/1.0" "$versions_root/2.0" > "$output_file" + return 73 + fi + command find "$versions_root" -mindepth 1 -maxdepth 1 \ + \( -type f -o -type d \) -print0 > "$output_file" +} +_MOLE_VERSIONED_AGENT_GUARD_ROOT="$root" +_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_SYMLINK="$HOME/bin/agent" +_MOLE_VERSIONED_AGENT_GUARD_ACTIVE_REQUIRED=false +_MOLE_VERSIONED_AGENT_GUARD_KEEP=1 +set +e +_versioned_agent_delete_guard_allows "$root/1.0" +rc=$? +set -e +printf 'GUARD_RC:%s\n' "$rc" +[[ $rc -eq 73 ]] +EOF + + assert_run_success + assert_output_contains "GUARD_RC:73" +} + +@test "versioned agent inventory has a wall-clock timeout" { + local isolated_home="$HOME/agent-inventory-timeout" + local versions_root="$isolated_home/versions" + local fake_bin="$isolated_home/fake-bin" + mkdir -p "$versions_root/1.0" "$fake_bin" + cat > "$fake_bin/find" <<'EOF' +#!/bin/bash +sleep 30 +EOF + chmod +x "$fake_bin/find" + + local started=$SECONDS + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" \ + PATH="$fake_bin:/usr/bin:/bin" MOLE_TIMEOUT_DISK_VERIFY_SEC=1 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +set +e +_plan_versioned_agent_cleanup_targets "$HOME/versions" 1 +rc=$? +set -e +printf 'INVENTORY_RC:%s\n' "$rc" +[[ $rc -eq 124 ]] +EOF + local elapsed=$((SECONDS - started)) + + assert_run_success + assert_output_contains "INVENTORY_RC:124" + [ "$elapsed" -lt 10 ] +} + +@test "versioned agent stat probes share the inventory deadline" { + local isolated_home="$HOME/agent-stat-timeout" + local versions_root="$isolated_home/versions" + local fake_bin="$isolated_home/fake-bin" + mkdir -p "$versions_root/1.0" "$fake_bin" + cat > "$fake_bin/stat" <<'EOF' +#!/bin/bash +sleep 30 +EOF + chmod +x "$fake_bin/stat" + + local started=$SECONDS + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" \ + PATH="$fake_bin:/usr/bin:/bin" MOLE_TIMEOUT_DISK_VERIFY_SEC=1 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +set +e +_plan_versioned_agent_cleanup_targets "$HOME/versions" 1 +rc=$? +set -e +printf 'STAT_RC:%s\n' "$rc" +[[ $rc -eq 124 ]] +EOF + local elapsed=$((SECONDS - started)) + + assert_run_success + assert_output_contains "STAT_RC:124" + [ "$elapsed" -lt 10 ] +} + +@test "versioned agent cleanup rechecks the active symlink after sizing" { + local isolated_home="$HOME/agent-active-symlink-race" + local versions_root="$isolated_home/.local/share/claude/versions" + local bin_dir="$isolated_home/.local/bin" + mkdir -p "$versions_root/1.0" "$versions_root/2.0" "$versions_root/3.0" "$bin_dir" + touch "$versions_root/1.0/claude" "$versions_root/2.0/claude" "$versions_root/3.0/claude" + touch -t 202604010000 "$versions_root/1.0" + touch -t 202604100000 "$versions_root/2.0" + touch -t 202604200000 "$versions_root/3.0" + ln -s "$versions_root/3.0/claude" "$bin_dir/claude" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +get_cleanup_path_size_kb() { + rm -f "$HOME/.local/bin/claude" + ln -s "$HOME/.local/share/claude/versions/1.0/claude" "$HOME/.local/bin/claude" + echo 1 +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_dev_ai_agents +[[ -d "$HOME/.local/share/claude/versions/1.0" ]] || exit 1 +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "Claude Code old version · stopped (retention changed)" +} + +@test "versioned agent cleanup rechecks the active symlink after retention planning" { + local isolated_home="$HOME/agent-active-plan-race" + local versions_root="$isolated_home/.local/share/claude/versions" + local bin_dir="$isolated_home/.local/bin" + mkdir -p "$versions_root/1.0" "$versions_root/2.0" "$versions_root/3.0" "$bin_dir" + touch "$versions_root/1.0/claude" "$versions_root/2.0/claude" "$versions_root/3.0/claude" + touch -t 202604010000 "$versions_root/1.0" + touch -t 202604100000 "$versions_root/2.0" + touch -t 202604200000 "$versions_root/3.0" + ln -s "$versions_root/3.0/claude" "$bin_dir/claude" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +get_cleanup_path_size_kb() { + : > "$HOME/arm-active-plan-race" + echo 1 +} +_versioned_agent_entry_mtime() { + if [[ -e "$HOME/arm-active-plan-race" && ! -e "$HOME/flipped-active-plan-race" ]]; then + : > "$HOME/flipped-active-plan-race" + rm -f "$HOME/.local/bin/claude" + ln -s "$HOME/.local/share/claude/versions/1.0/claude" "$HOME/.local/bin/claude" + fi + command stat -f%m "$1" +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_dev_ai_agents +[[ -d "$HOME/.local/share/claude/versions/1.0" ]] || exit 1 +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "Claude Code old version · stopped (active version changed)" +} + +@test "Claude Desktop bundled cleanup rechecks activity after sizing" { + local isolated_home="$HOME/claude-size-race" + local claude_support="$isolated_home/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +pgrep() { + [[ -e "$HOME/claude-started" ]] && return 0 + return 1 +} +get_cleanup_path_size_kb() { + : > "$HOME/claude-started" + echo 1 +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +rm -f "$HOME/claude-started" +clean_claude_desktop_bundled_versions 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code/2.1.140" ]] || exit 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code-vm/2.1.140" ]] || exit 1 +printf 'DEFER:%s\n' "$(format_deferred_cleanup_families)" +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "DEFER:Claude Desktop" +} + +@test "Claude Desktop bundled cleanup rechecks active-version evidence after sizing" { + local isolated_home="$HOME/claude-active-evidence-race" + local claude_support="$isolated_home/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +pgrep() { return 1; } +get_cleanup_path_size_kb() { + local active="$HOME/Library/Application Support/Claude/claude-code/2.1.150" + if [[ -d "$active" ]]; then + mv "$active" "$HOME/relocated-active-version" + fi + echo 1 +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_claude_desktop_bundled_versions 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code/2.1.140" ]] || exit 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code-vm/2.1.140" ]] || exit 1 +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "stopped (active version changed)" +} + +@test "Claude Desktop bundled cleanup rechecks the SDK after retention planning" { + local isolated_home="$HOME/claude-sdk-plan-race" + local claude_support="$isolated_home/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +pgrep() { return 1; } +get_cleanup_path_size_kb() { + : > "$HOME/arm-sdk-plan-race" + echo 1 +} +_versioned_agent_entry_mtime() { + if [[ -e "$HOME/arm-sdk-plan-race" && ! -e "$HOME/flipped-sdk-plan-race" ]]; then + : > "$HOME/flipped-sdk-plan-race" + echo "2.1.140" > "$HOME/Library/Application Support/Claude/claude-code-vm/.sdk-version" + fi + command stat -f%m "$1" +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_claude_desktop_bundled_versions 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code/2.1.140" ]] || exit 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code-vm/2.1.140" ]] || exit 1 +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "stopped (active version changed)" +} + +@test "Claude Desktop bundled cleanup rechecks compiled-model policy after sizing" { + local isolated_home="$HOME/claude-policy-race" + local claude_support="$isolated_home/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +pgrep() { return 1; } +get_cleanup_path_size_kb() { + mkdir -p "$1/com.apple.e5rt.e5bundlecache" + echo 1 +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_claude_desktop_bundled_versions 1 +[[ -d "$HOME/Library/Application Support/Claude/claude-code/2.1.140" ]] || exit 1 +EOF + + assert_run_success + assert_output_not_contains "UNEXPECTED_DELETE" + assert_output_contains "stopped (retention changed)" +} + +@test "clean_dev_ai_agents skips Claude Desktop bundled versions when sdk version is path-like" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + echo "../2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "· skipped (active version unknown)" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop cleanup when active version is missing from one bundled root" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "· skipped (active version unknown)" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop cleanup when only one bundled root can identify active version" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "· skipped (active version unknown)" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop bundled versions while Claude Desktop is running" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { + [[ "$1" == "-x" && "$2" == "Claude" ]] +} +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_not_contains "Claude Desktop bundled Claude Code · skipped" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_codex_marketplace_staging removes only aged staging prefixes (#1389)" { + local tmp="$HOME/.codex/.tmp" + local bundled="$tmp/bundled-marketplaces" + local staging="$tmp/marketplaces/.staging" + mkdir -p "$bundled/openai-bundled" \ + "$bundled/openai-bundled.staging-old" \ + "$bundled/openai-bundled.staging-fresh" \ + "$tmp/marketplaces/my-marketplace" \ + "$staging/marketplace-upgrade-old" \ + "$staging/marketplace-add-old" \ + "$staging/marketplace-backup-old" + touch "$bundled/openai-bundled/KEEP" \ + "$bundled/openai-bundled.staging-old/GONE" \ + "$bundled/openai-bundled.staging-fresh/KEEP" \ + "$tmp/marketplaces/my-marketplace/KEEP" \ + "$staging/marketplace-upgrade-old/GONE" \ + "$staging/marketplace-add-old/GONE" \ + "$staging/marketplace-backup-old/KEEP" + touch -t 202001010000 \ + "$bundled/openai-bundled" \ + "$bundled/openai-bundled.staging-old" \ + "$tmp/marketplaces/my-marketplace" \ + "$staging/marketplace-upgrade-old" \ + "$staging/marketplace-add-old" \ + "$staging/marketplace-backup-old" + # Fresh staging must stay (age gate). + touch "$bundled/openai-bundled.staging-fresh" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +# The staging cleanup has no engine-absent fallback (see the audited-count +# gate in clean_core.bats), so a standalone case supplies the guard itself. +safe_clean_guarded() { + local guard="$1" + shift + "$guard" || return 75 + safe_clean "$@" +} +note_activity() { :; } +clean_codex_marketplace_staging +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Codex marketplace staging|$bundled/openai-bundled.staging-old"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:Codex marketplace staging|$staging/marketplace-upgrade-old"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:Codex marketplace staging|$staging/marketplace-add-old"* ]] || return 1 + [[ "$output" != *"openai-bundled|"* ]] || return 1 + [[ "$output" != *"openai-bundled.staging-fresh"* ]] || return 1 + [[ "$output" != *"my-marketplace"* ]] || return 1 + [[ "$output" != *"marketplace-backup-old"* ]] || return 1 +} + +@test "clean_codex_marketplace_staging defers while Codex runtime is active" { + local staging="$HOME/.codex/.tmp/marketplaces/.staging" + mkdir -p "$staging/marketplace-upgrade-old" + touch "$staging/marketplace-upgrade-old/payload" + touch -t 202001010000 "$staging/marketplace-upgrade-old" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { + if [[ "$*" == *codex* ]] || [[ "$*" == *Codex* ]] || [[ "$*" == *ChatGPT* ]]; then + return 0 + fi + return 1 +} +lsof() { return 1; } +safe_clean() { echo "UNEXPECTED:$1"; } +mole_defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } +clean_codex_marketplace_staging +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"DEFER:Codex"* ]] || return 1 + [[ "$output" != *"UNEXPECTED:"* ]] || return 1 + [[ -f "$staging/marketplace-upgrade-old/payload" ]] +} diff --git a/Resources/mole/tests/clean_app_caches.bats b/Resources/mole/tests/clean_app_caches.bats index 067664f..118632b 100644 --- a/Resources/mole/tests/clean_app_caches.bats +++ b/Resources/mole/tests/clean_app_caches.bats @@ -10,11 +10,17 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -25,19 +31,30 @@ teardown_file() { set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -pgrep() { return 0; } + pgrep() { [[ "$1" == "-x" && "$2" == "xcodebuild" ]]; } safe_clean() { echo "$2"; } clean_xcode_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Xcode is running"* ]] - [[ "$output" != *"derived data"* ]] - [[ "$output" != *"archives"* ]] + [[ "$output" != *"Xcode DerivedData · skipped"* ]] || return 1 + [[ "$output" != *"derived data"* ]] || return 1 [[ "$output" != *"documentation cache"* ]] } -@test "clean_xcode_tools cleans documentation caches when Xcode is not running" { +@test "clean_xcode_tools preserves device logs and user documentation stores" { + local ios_log="$HOME/Library/Developer/Xcode/iOS Device Logs/sentinel.log" + local watch_log="$HOME/Library/Developer/Xcode/watchOS Device Logs/sentinel.log" + local doc_cache="$HOME/Library/Developer/Xcode/DocumentationCache/sentinel.doc" + local doc_index="$HOME/Library/Developer/Xcode/DocumentationIndex/sentinel.index" + mkdir -p "$(dirname "$ios_log")" "$(dirname "$watch_log")" \ + "$(dirname "$doc_cache")" "$(dirname "$doc_index")" + touch "$ios_log" "$watch_log" "$doc_cache" "$doc_index" + mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode" + mkdir -p "$HOME/Library/Developer/Xcode/Products" + touch "$HOME/Library/Caches/com.apple.dt.Xcode/candidate" + touch "$HOME/Library/Developer/Xcode/Products/candidate" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" @@ -48,26 +65,307 @@ clean_xcode_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Xcode derived data"* ]] - [[ "$output" == *"Xcode archives"* ]] - [[ "$output" == *"Xcode documentation cache"* ]] - [[ "$output" == *"Xcode documentation index"* ]] + # Xcode cache and build products are positive controls proving the cleanup + # body ran; diagnostics and downloaded documentation must stay review-only. + [[ "$output" == *"Xcode cache"* ]] || return 1 + [[ "$output" == *"Xcode build products"* ]] || return 1 + [[ "$output" != *"iOS device logs"* ]] || return 1 + [[ "$output" != *"watchOS device logs"* ]] || return 1 + [[ "$output" != *"Xcode documentation cache"* ]] || return 1 + [[ "$output" != *"Xcode documentation index"* ]] || return 1 + [[ "$output" != *"Xcode archives"* ]] || return 1 + [[ -f "$ios_log" && -f "$watch_log" && -f "$doc_cache" && -f "$doc_index" ]] +} + +@test "clean_xcode_tools skips Xcode paths while xcodebuild is active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { [[ "$2" == "xcodebuild" ]]; } +safe_clean() { + case "${!#}" in + "Xcode cache" | "Xcode build products") echo "UNEXPECTED_XCODE_CLEAN:${!#}" ;; + esac +} +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Xcode cache/build products · skipped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_XCODE_CLEAN"* ]] +} + +@test "clean_xcode_tools fails closed when process state is unknown" { + mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode" + touch "$HOME/Library/Caches/com.apple.dt.Xcode/candidate" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { return 2; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"process state unknown"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_3d_tools defers Autodesk cache while Fusion helper is active (#1390)" { + mkdir -p "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { return 0; } +safe_clean() { + case "${!#}" in + "Autodesk cache") echo "UNEXPECTED_CLEAN:${!#}" ;; + esac +} +mole_defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } +clean_3d_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Autodesk"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_3d_tools cleans Autodesk cache when Fusion is not running (#1390)" { + mkdir -p "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { return 1; } +safe_clean() { echo "CLEAN:${!#}"; } +clean_3d_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"CLEAN:Autodesk cache"* ]] || return 1 +} + +@test "clean_xcode_tools does not defer empty Xcode and Simulator roots" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +rm -rf "$HOME/Library/Caches/com.apple.dt.Xcode" \ + "$HOME/Library/Developer/Xcode/Products" \ + "$HOME/Library/Developer/Xcode/DerivedData" \ + "$HOME/Library/Developer/CoreSimulator/Caches" \ + "$HOME/Library/Developer/CoreSimulator/Devices" \ + "$HOME/Library/Logs/CoreSimulator" +mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode" \ + "$HOME/Library/Developer/Xcode/Products" \ + "$HOME/Library/Developer/Xcode/DerivedData" \ + "$HOME/Library/Developer/CoreSimulator/Caches" \ + "$HOME/Library/Developer/CoreSimulator/Devices" \ + "$HOME/Library/Logs/CoreSimulator" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] || return 1 + [[ "$output" != *"process state unknown"* ]] +} + +@test "clean_xcode_tools does not defer broken-symlink-only Xcode roots" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +rm -rf "$HOME/Library/Caches/com.apple.dt.Xcode" \ + "$HOME/Library/Developer/Xcode/Products" \ + "$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode" +ln -s "$HOME/missing-xcode-cache" "$HOME/Library/Caches/com.apple.dt.Xcode/broken" +mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode/compiled/com.apple.e5rt.e5bundlecache" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_xcode_tools does not defer after a cache-only pass completes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +rm -rf "$HOME/Library/Caches/com.apple.dt.Xcode" \ + "$HOME/Library/Developer/Xcode/Products" \ + "$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode" +touch "$HOME/Library/Caches/com.apple.dt.Xcode/candidate" + +probe_round=0 +_xcode_cleanup_process_state() { + probe_round=$((probe_round + 1)) + [[ $probe_round -gt 2 ]] && return 0 + return 1 +} +_app_cache_safe_clean_guarded() { + local state=0 + _xcode_cleanup_process_state || state=$? + [[ $state -eq 1 ]] || return 75 + command rm -f "$HOME/Library/Caches/com.apple.dt.Xcode/candidate" + echo "CLEANED:Xcode cache" +} +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +clean_xcode_tools +[[ ! -e "$HOME/Library/Caches/com.apple.dt.Xcode/candidate" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"CLEANED:Xcode cache"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DEFER"* ]] +} + +@test "clean_xcode_tools ignores active whitelist-only candidates" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +rm -rf "$HOME/Library/Caches/com.apple.dt.Xcode" \ + "$HOME/Library/Developer/Xcode/Products" \ + "$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$HOME/Library/Caches/com.apple.dt.Xcode" +target="$HOME/Library/Caches/com.apple.dt.Xcode/whitelisted" +touch "$target" +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_xcode_tools does not duplicate unavailable simulator cleanup" { + run grep -n "simctl" "$PROJECT_ROOT/lib/clean/app_caches.sh" + + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "standalone guarded app-cache cleanup rechecks before falling back to safe_clean" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +unset -f safe_clean_guarded 2> /dev/null || true +deny_delete() { return 1; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } +note_activity() { :; } + +rc=0 +_app_cache_safe_clean_guarded deny_delete "Guarded cache" "$HOME/cache" "Guarded cache" || rc=$? +[[ $rc -ne 0 ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 +} + +@test "standalone simulator probe ignores idle launchd services" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { + [[ "$1" == "-x" && ("$2" == "CoreSimulatorService" || "$2" == "simdiskimaged") ]] +} + +probe_status=0 +_simulator_cleanup_process_state || probe_status=$? +[[ $probe_status -eq 1 ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } } -@test "clean_media_players protects spotify offline cache" { +@test "clean_media_players protects spotify offline cache when bnk has content" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" mkdir -p "$HOME/Library/Application Support/Spotify/PersistentCache/Storage" -touch "$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk" +dd if=/dev/zero of="$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk" bs=1024 count=2 2>/dev/null safe_clean() { echo "CLEAN:$2"; } clean_media_players EOF [ "$status" -eq 0 ] + [[ "$output" != *"CLEAN:Spotify cache"* ]] || return 1 [[ "$output" == *"Spotify cache protected"* ]] - [[ "$output" != *"CLEAN: Spotify cache"* ]] +} + +@test "clean_media_players cleans spotify cache when bnk is empty" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +mkdir -p "$HOME/Library/Application Support/Spotify/PersistentCache/Storage" +> "$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk" +safe_clean() { echo "CLEAN:$2"; } +clean_media_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Spotify cache protected"* ]] || return 1 + [[ "$output" == *"CLEAN:Spotify cache"* ]] } @test "clean_user_gui_applications calls all sections" { @@ -80,131 +378,1029 @@ safe_clean() { :; } clean_xcode_tools() { echo "xcode"; } clean_code_editors() { echo "editors"; } clean_communication_apps() { echo "comm"; } +clean_dingtalk() { echo "dingtalk"; } +clean_ai_apps() { echo "ai"; } clean_user_gui_applications EOF [ "$status" -eq 0 ] - [[ "$output" == *"xcode"* ]] - [[ "$output" == *"editors"* ]] - [[ "$output" == *"comm"* ]] + [[ "$output" != *"xcode"* ]] || return 1 + [[ "$output" != *"editors"* ]] || return 1 + [[ "$output" == *"comm"* ]] || return 1 + [[ "$output" == *"dingtalk"* ]] || return 1 + [[ "$output" == *"ai"* ]] } -@test "clean_ai_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_final_cut_pro_generated_caches targets only safe generated media in Movies libraries" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_ai_apps + +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/Proxy Media" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/High Quality Media" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Analysis Files/Stabilization" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Original Media/Render Files/High Quality Media" +mkdir -p "$HOME/Documents/Other.fcpbundle/Event/Render Files/High Quality Media" + +touch "$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media/render.mov" +touch "$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/Proxy Media/proxy.mov" + +pgrep() { return 1; } +safe_clean() { + local arg + for arg in "$@"; do + printf 'CLEAN:%s\n' "$arg" + done +} + +clean_final_cut_pro_generated_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"ChatGPT cache"* ]] - [[ "$output" == *"Claude desktop cache"* ]] + [[ "$output" == *"CLEAN:$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media"* ]] || return 1 + [[ "$output" == *"CLEAN:$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/Proxy Media"* ]] || return 1 + [[ "$output" == *"CLEAN:Final Cut Pro generated cache"* ]] || return 1 + [[ "$output" != *"Transcoded Media/High Quality Media"* ]] || return 1 + [[ "$output" != *"Analysis Files"* ]] || return 1 + [[ "$output" != *"Original Media"* ]] || return 1 + [[ "$output" != *"Documents/Other.fcpbundle"* ]] } -@test "clean_design_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_final_cut_pro_generated_caches skips while Final Cut Pro is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_design_tools + +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media" +pgrep() { return 0; } +safe_clean() { + echo "unexpected safe_clean" + return 1 +} + +clean_final_cut_pro_generated_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"Sketch cache"* ]] - [[ "$output" == *"Figma cache"* ]] + [[ "$output" != *"Final Cut Pro generated caches · skipped"* ]] || return 1 + [[ "$output" != *"unexpected safe_clean"* ]] } -@test "clean_dingtalk calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_final_cut_pro_generated_caches does not defer whitelist-only targets" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_dingtalk + +target="$HOME/Movies/Whitelisted.fcpbundle/Event/Render Files/High Quality Media" +mkdir -p "$target" +touch "$target/render.mov" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_final_cut_pro_generated_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_final_cut_pro_generated_caches rechecks activity after sizing" { + run env HOME="$HOME/fcp-size-race" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +target="$HOME/Movies/Race.fcpbundle/Event/Render Files/High Quality Media" +mkdir -p "$target" +touch "$target/render.mov" +pgrep() { + [[ -e "$HOME/fcp-started" ]] && return 0 + return 1 +} +get_cleanup_path_size_kb() { + : > "$HOME/fcp-started" + echo 1 +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +rm -f "$HOME/fcp-started" +clean_final_cut_pro_generated_caches +[[ -f "$target/render.mov" ]] || exit 1 +printf 'DEFER:%s\n' "$(format_deferred_cleanup_families)" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DELETE"* ]] || return 1 + [[ "$output" == *"DEFER:Final Cut Pro"* ]] +} + +@test "clean_final_cut_pro_generated_caches fails closed when its process probe errors" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +target="$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media" +mkdir -p "$target" +touch "$target/sentinel" +pgrep() { return 2; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:${!#}"; } +note_activity() { :; } + +clean_final_cut_pro_generated_caches +[[ -f "$target/sentinel" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Final Cut Pro generated caches · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "clean_jianying_pro_generated_caches targets only whitelisted regenerable subdirs" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +cache_root="$HOME/Movies/JianyingPro/User Data/Cache" +# Regenerable (should be cleaned) +mkdir -p "$cache_root/recognize" +mkdir -p "$cache_root/frameThumbnail" +mkdir -p "$cache_root/audioWave" +mkdir -p "$cache_root/AlgorithmCache" +# Draft-referenced / downloaded assets (must be preserved) +mkdir -p "$cache_root/effect" +mkdir -p "$cache_root/music" +mkdir -p "$cache_root/AigcMaterailCache" +mkdir -p "$cache_root/agencycache" +# Copies of user-imported material (must be preserved, see the exclusion note) +mkdir -p "$cache_root/image" +mkdir -p "$cache_root/importcache3" +# The user's editable drafts (must never be touched) +mkdir -p "$HOME/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/my-project" + +pgrep() { return 1; } +safe_clean() { + local arg + for arg in "$@"; do + printf 'CLEAN:%s\n' "$arg" + done +} + +clean_jianying_pro_generated_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"DingTalk iDingTalk cache"* ]] - [[ "$output" == *"DingTalk logs"* ]] + [[ "$output" == *"CLEAN:$HOME/Movies/JianyingPro/User Data/Cache/recognize"* ]] || return 1 + [[ "$output" == *"CLEAN:$HOME/Movies/JianyingPro/User Data/Cache/frameThumbnail"* ]] || return 1 + [[ "$output" == *"CLEAN:$HOME/Movies/JianyingPro/User Data/Cache/audioWave"* ]] || return 1 + [[ "$output" == *"CLEAN:$HOME/Movies/JianyingPro/User Data/Cache/AlgorithmCache"* ]] || return 1 + [[ "$output" == *"CLEAN:JianyingPro generated cache"* ]] || return 1 + [[ "$output" != *"Cache/effect"* ]] || return 1 + [[ "$output" != *"Cache/music"* ]] || return 1 + [[ "$output" != *"Cache/image"* ]] || return 1 + [[ "$output" != *"importcache3"* ]] || return 1 + [[ "$output" != *"AigcMaterailCache"* ]] || return 1 + [[ "$output" != *"agencycache"* ]] || return 1 + [[ "$output" != *"Projects"* ]] || return 1 } -@test "clean_download_managers calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "jianying_pro_is_running ignores the resident menu-bar tray helper" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_download_managers + +# Faithful pgrep mock for a process table that contains ONLY the always-on +# tray helper: -x compares the pattern against the process name exactly, +# and -f substring-matches the pattern against the command line, like real +# pgrep does. +helper_name="VideoFusion-macOSTrayHelper" +helper_cmdline="/Applications/VideoFusion-macOS.app/Contents/Frameworks/VideoFusion-macOSTrayHelper.app/Contents/MacOS/VideoFusion-macOSTrayHelper" +pgrep() { + local mode="$1" + local pattern="${!#}" + if [[ "$mode" == "-x" ]]; then + [[ "$helper_name" == "$pattern" ]] && return 0 + return 1 + fi + case "$helper_cmdline" in + *"$pattern"*) return 0 ;; + esac + return 1 +} + +# Mock fidelity check: the historical broad probe DOES match the helper's +# command line. Without this, a lazy mock would pass even if the production +# probe were widened back to "/VideoFusion-macOS.app/". +if pgrep -f "/VideoFusion-macOS.app/" > /dev/null 2>&1; then + echo "MOCK-FAITHFUL: broad pattern matches helper" +fi + +if jianying_pro_is_running; then + echo "WRONG: reported running" +else + echo "OK: not running" +fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"Aria2 cache"* ]] - [[ "$output" == *"qBittorrent cache"* ]] + [[ "$output" == *"MOCK-FAITHFUL: broad pattern matches helper"* ]] || return 1 + [[ "$output" == *"OK: not running"* ]] || return 1 + [[ "$output" != *"WRONG"* ]] || return 1 } -@test "clean_productivity_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_jianying_pro_generated_caches skips while JianyingPro is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_productivity_apps + +mkdir -p "$HOME/Movies/JianyingPro/User Data/Cache/recognize" +pgrep() { return 0; } +safe_clean() { + echo "unexpected safe_clean" + return 1 +} + +clean_jianying_pro_generated_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"MiaoYan cache"* ]] - [[ "$output" == *"Flomo cache"* ]] + [[ "$output" == *"JianyingPro generated caches · skipped (JianyingPro running)"* ]] || return 1 + [[ "$output" != *"unexpected safe_clean"* ]] || return 1 } -@test "clean_screenshot_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_jianying_pro_generated_caches fails closed when the process probe errors" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_screenshot_tools + +mkdir -p "$HOME/Movies/JianyingPro/User Data/Cache/recognize" +pgrep() { return 2; } +safe_clean() { + echo "unexpected safe_clean" + return 1 +} + +clean_jianying_pro_generated_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"CleanShot cache"* ]] - [[ "$output" == *"Xnip cache"* ]] + [[ "$output" == *"skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"unexpected safe_clean"* ]] || return 1 } -@test "clean_office_applications calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_jianying_pro_generated_caches is a no-op when cache root is absent" { + local empty_home + empty_home="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches.XXXXXX")" + run env HOME="$empty_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail -source "$PROJECT_ROOT/lib/clean/user.sh" -stop_section_spinner() { :; } -safe_clean() { echo "$2"; } -clean_office_applications +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +pgrep() { return 1; } +safe_clean() { + echo "unexpected safe_clean" + return 1 +} + +clean_jianying_pro_generated_caches EOF + rm -rf "$empty_home" [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Word cache"* ]] - [[ "$output" == *"Apple iWork cache"* ]] + [[ "$output" != *"unexpected safe_clean"* ]] || return 1 } -@test "clean_communication_apps includes Microsoft Teams legacy caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "is_final_cut_pro_generated_cache_target rejects protected sibling paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_communication_apps + +library="$HOME/Movies/Project.fcpbundle" +mkdir -p "$library/Event/Render Files/High Quality Media" +mkdir -p "$library/Event/Original Media/Render Files/High Quality Media" +mkdir -p "$library/Event/Transcoded Media/High Quality Media" + +is_final_cut_pro_generated_cache_target "$library" "$library/Event/Render Files/High Quality Media" +! is_final_cut_pro_generated_cache_target "$library" "$library/Event/Original Media/Render Files/High Quality Media" +! is_final_cut_pro_generated_cache_target "$library" "$library/Event/Transcoded Media/High Quality Media" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Teams legacy cache"* ]] - [[ "$output" == *"Microsoft Teams legacy logs"* ]] } -@test "clean_gaming_platforms includes steam and minecraft related caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_ai_apps calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } -clean_gaming_platforms +note_activity() { :; } +clean_ai_apps EOF [ "$status" -eq 0 ] - [[ "$output" == *"Steam app cache"* ]] - [[ "$output" == *"Steam shader cache"* ]] - [[ "$output" == *"Minecraft logs"* ]] - [[ "$output" == *"Lunar Client logs"* ]] + [[ "$output" == *"ChatGPT cache"* ]] || return 1 + [[ "$output" == *"Claude desktop cache"* ]] || return 1 + [[ "$output" == *"Google Clearcut logs"* ]] || return 1 + [[ "$output" == *"LM Studio cache"* ]] || return 1 + [[ "$output" != *"Codex"* ]] +} + +@test "clean_ai_apps targets app cache but never the legacy LM Studio home" { + mkdir -p "$HOME/Library/Caches/com.lmstudio.lmstudio" + echo "cache" > "$HOME/Library/Caches/com.lmstudio.lmstudio/cache.bin" + mkdir -p "$HOME/.cache/lm-studio/models" + echo "model" > "$HOME/.cache/lm-studio/models/keep.gguf" + mkdir -p "$HOME/.lmstudio/models" + echo "model" > "$HOME/.lmstudio/models/keep.gguf" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { printf 'CLEAN:%s\n' "${@:1:$#-1}"; } +note_activity() { :; } +clean_ai_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Caches/com.lmstudio.lmstudio/cache.bin"* ]] || return 1 + [[ "$output" != *"$HOME/.cache/lm-studio"* ]] || return 1 + [[ "$output" != *"$HOME/.lmstudio"* ]] || return 1 +} + +@test "clean_ai_apps skips Codex Desktop state by default" { + mkdir -p "$HOME/Library/Application Support/Codex/Cache" "$HOME/Library/Logs/com.openai.codex" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +note_activity() { echo "NOTE_ACTIVITY"; } +clean_ai_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Codex Desktop state"* ]] || return 1 + [[ "$output" != *"NOTE_ACTIVITY"* ]] || return 1 + [[ "$output" != *"Codex cache"* ]] || return 1 + [[ "$output" != *"Codex CLI logs"* ]] +} + +@test "clean_design_tools calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_design_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Sketch cache"* ]] || return 1 + [[ "$output" == *"Figma cache"* ]] +} + +@test "clean_dingtalk calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p ~/Library/Application\ Support/iDingTalk +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_dingtalk +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DingTalk iDingTalk cache"* ]] || return 1 + [[ "$output" == *"DingTalk logs"* ]] +} + +@test "clean_download_managers calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_download_managers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Aria2 cache"* ]] || return 1 + [[ "$output" == *"qBittorrent cache"* ]] +} + +@test "clean_productivity_apps calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_productivity_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"MiaoYan cache"* ]] || return 1 + [[ "$output" == *"Flomo cache"* ]] +} + +@test "clean_screenshot_tools calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_screenshot_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CleanShot cache"* ]] || return 1 + [[ "$output" == *"Xnip cache"* ]] +} + +@test "clean_office_applications calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +safe_clean() { echo "$2"; } +clean_office_applications +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Microsoft Word cache"* ]] || return 1 + [[ "$output" == *"Apple iWork cache"* ]] +} + +@test "clean_communication_apps includes Microsoft Teams legacy caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p ~/Library/Application\ Support/Microsoft/Teams +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_communication_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Microsoft Teams legacy cache"* ]] || return 1 + [[ "$output" == *"Microsoft Teams legacy logs"* ]] +} + +@test "clean_gaming_platforms includes steam and minecraft related caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p ~/Library/Application\ Support/Steam ~/Library/Application\ Support/Battle.net +mkdir -p ~/Library/Application\ Support/minecraft ~/.lunarclient +mkdir -p ~/Library/Application\ Support/PCSX2 ~/Library/Application\ Support/rpcs3 +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_gaming_platforms +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Steam app cache"* ]] || return 1 + [[ "$output" == *"Steam shader cache"* ]] || return 1 + [[ "$output" == *"Minecraft logs"* ]] || return 1 + [[ "$output" == *"Lunar Client logs"* ]] +} + +@test "clean_code_editors includes Zed caches" { + mkdir -p "$HOME/Library/Application Support/Zed/node/cache/_cacache" + mkdir -p "$HOME/Library/Application Support/Zed/node/node-v24.11.0-darwin-arm64/cache/_cacache" + mkdir -p "$HOME/Library/Application Support/Zed/node/node-v24.11.0-darwin-arm64/bin" + mkdir -p "$HOME/Library/Application Support/Zed/db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "CLEAN:$1|$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Zed cache"* ]] || return 1 + [[ "$output" == *"CLEAN:$HOME/Library/Application Support/Zed/node/cache/_cacache|Zed npm cache"* ]] || return 1 + [[ "$output" == *"CLEAN:$HOME/Library/Application Support/Zed/node/node-v24.11.0-darwin-arm64/cache/_cacache|Zed npm cache"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Application Support/Zed/db"* ]] || return 1 + [[ "$output" != *"node-v24.11.0-darwin-arm64/bin"* ]] || return 1 + [[ "$output" == *"Zed logs"* ]] || return 1 +} + +@test "clean_code_editors includes VS Code WebStorage CacheStorage only" { + mkdir -p "$HOME/Library/Application Support/Code/WebStorage/29/CacheStorage/uuid-1" + mkdir -p "$HOME/Library/Application Support/Code/WebStorage/29/Local Storage" + touch "$HOME/Library/Application Support/Code/WebStorage/29/QuotaManager" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "CLEAN:$1|$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Application Support/Code/WebStorage/29/CacheStorage/uuid-1|VS Code webview cache"* ]] || return 1 + [[ "$output" != *"Local Storage"* ]] || return 1 + [[ "$output" != *"QuotaManager"* ]] +} + +@test "clean_shell_utils includes Warp and Ghostty caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_shell_utils +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Warp cache"* ]] || return 1 + [[ "$output" == *"Warp log"* ]] || return 1 + [[ "$output" == *"Warp Sentry crash reports"* ]] || return 1 + [[ "$output" == *"Ghostty cache"* ]] +} + +@test "clean_video_players includes Stremio caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p ~/Library/Application\ Support/stremio +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_video_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Stremio cache"* ]] || return 1 + [[ "$output" == *"Stremio server cache"* ]] +} + +@test "clean_video_players cleans SenPlayer videoCache but not sibling data (#1070)" { + local sen="$HOME/Library/Containers/com.wuziqi.SenPlayer/Data" + mkdir -p "$sen/tmp/videoCache" "$sen/Documents" + touch "$sen/tmp/videoCache/segment.mp4" "$sen/Documents/saved.mp4" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { local arg; for arg in "$@"; do printf 'CLEAN:%s\n' "$arg"; done; } +clean_video_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Containers/com.wuziqi.SenPlayer/Data/tmp/videoCache/segment.mp4"* && + "$output" != *"SenPlayer/Data/Documents"* ]] +} + +@test "clean_productivity_apps cleans Folo Cache_Data but not sibling data (#1070)" { + local folo="$HOME/Library/Containers/is.follow/Data/Library/Application Support/Folo" + mkdir -p "$folo/Cache/Cache_Data" + touch "$folo/Cache/Cache_Data/blob" "$folo/Cache/other.bin" "$folo/db.sqlite" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { local arg; for arg in "$@"; do printf 'CLEAN:%s\n' "$arg"; done; } +clean_productivity_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Containers/is.follow/Data/Library/Application Support/Folo/Cache/Cache_Data/blob"* && + "$output" != *"Folo/Cache/other.bin"* && + "$output" != *"db.sqlite"* ]] +} + +@test "clean_editor_obsolete_extensions removes only dirs listed in .obsolete (#910)" { + local ext_root="$HOME/.vscode/extensions" + mkdir -p "$ext_root/pub.ext-old-1.0.0" "$ext_root/pub.ext-new-1.1.0" + cat > "$ext_root/.obsolete" << 'JSON' +{ + "pub.ext-old-1.0.0": true +} +JSON + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "CLEAN:$1"; } +clean_editor_obsolete_extensions +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/.vscode/extensions/pub.ext-old-1.0.0"* ]] || return 1 + [[ "$output" != *"pub.ext-new-1.1.0"* ]] +} + +@test "clean_editor_obsolete_extensions rejects path-traversal keys in .obsolete (#910)" { + rm -rf "$HOME/.vscode" "$HOME/.vscode-insiders" "$HOME/.cursor" + local ext_root="$HOME/.cursor/extensions" + mkdir -p "$ext_root" + mkdir -p "$HOME/obsolete-victim" + # A legitimate entry alongside the malicious ones. Without it the function has + # nothing to clean, output is empty, and "no CLEAN: line" cannot distinguish + # "traversal rejected" from "never ran". + mkdir -p "$ext_root/publisher.legit-1.0.0" + cat > "$ext_root/.obsolete" << 'JSON' +{ + "../../obsolete-victim": true, + "..": true, + "publisher.legit-1.0.0": true +} +JSON + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "CLEAN:$1"; } +clean_editor_obsolete_extensions +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$ext_root/publisher.legit-1.0.0"* ]] || return 1 + [[ "$output" != *"obsolete-victim"* ]] || return 1 + [[ "$output" != *"CLEAN:$HOME/.cursor\""* ]] || return 1 + [ -d "$HOME/obsolete-victim" ] +} + +@test "clean_code_editors includes CodeBuddy Extension caches when directory exists" { + mkdir -p "$HOME/Library/Application Support/CodeBuddyExtension" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CodeBuddy Extension cache"* ]] || return 1 + [[ "$output" == *"CodeBuddy Extension logs"* ]] +} + +@test "clean_code_editors includes CodeBuddy CN caches when directory exists" { + mkdir -p "$HOME/Library/Application Support/CodeBuddy CN" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CodeBuddy CN cache"* ]] || return 1 + [[ "$output" == *"CodeBuddy CN logs"* ]] || return 1 + [[ "$output" == *"CodeBuddy CN GPU cache"* ]] +} + +@test "clean_code_editors skips CodeBuddy when directories are absent" { + rm -rf "$HOME/Library/Application Support/CodeBuddyExtension" "$HOME/Library/Application Support/CodeBuddy CN" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"CodeBuddy"* ]] +} + +@test "clean_media_players includes QQ Music Mac container caches" { + mkdir -p "$HOME/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_media_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QQ Music Mac cache"* ]] || return 1 + [[ "$output" == *"QQ Music streaming cache"* ]] || return 1 + [[ "$output" == *"QQ Music logs"* ]] || return 1 + [[ "$output" == *"QQ Music container cache"* ]] +} + +@test "clean_media_players does not reference iDownloadProxy" { + mkdir -p "$HOME/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$1 $2"; } +clean_media_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"iDownloadProxy"* ]] +} + +@test "clean_video_players includes Tencent Video container caches" { + mkdir -p "$HOME/Library/Containers/com.tencent.tenvideo/Data/Library/Application Support" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_video_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Tencent Video old installer"* ]] || return 1 + [[ "$output" == *"Tencent Video native cache"* ]] || return 1 + [[ "$output" == *"Tencent Video document cache"* ]] +} + +@test "clean_productivity_apps includes Spacedrive thumbnail cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_productivity_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Spacedrive thumbnail cache"* ]] +} + +@test "clean_neatdm_stale_segments removes segments older than threshold" { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + rm -rf "$neatdm_dir" + mkdir -p "$neatdm_dir/12345" + touch "$neatdm_dir/12345/seg.x0" + # Set mtime to 31 days ago + touch -t "$(date -v-31d '+%Y%m%d%H%M.%S')" "$neatdm_dir/12345/seg.x0" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"NeatDM stale downloads"* ]] || return 1 + [[ "$output" == *"1 items"* ]] +} + +@test "clean_neatdm_stale_segments skips recent segments" { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + rm -rf "$neatdm_dir" + mkdir -p "$neatdm_dir/67890" + touch "$neatdm_dir/67890/seg.x0" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"NeatDM stale downloads"* ]] || return 1 + # The absence of a label is weak evidence on its own: this run prints nothing at + # all, so assert the survival the test is actually named for. + [ -f "$neatdm_dir/67890/seg.x0" ] + [ -d "$neatdm_dir/67890" ] +} + +@test "clean_neatdm_stale_segments skips non-numeric segment-like directories" { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + rm -rf "$neatdm_dir" + mkdir -p "$neatdm_dir/history-backup" + touch "$neatdm_dir/history-backup/seg.x0" + touch -t "$(date -v-31d '+%Y%m%d%H%M.%S')" "$neatdm_dir/history-backup/seg.x0" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"NeatDM stale downloads"* ]] || return 1 + # This path prints nothing, so the absence check alone cannot fail. Assert the + # survival the test is named for. + [ -f "$neatdm_dir/history-backup/seg.x0" ] + [ -d "$neatdm_dir/history-backup" ] +} + +@test "clean_neatdm_stale_segments skips when directory absent" { + rm -rf "$HOME/Library/Application Support/com.NeatDownloadManager" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ -z "$output" ]] +} + +@test "clean_launcher_apps does not touch Raycast cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +mkdir -p "$HOME/Library/Caches/com.raycast.macos/urlcache" +mkdir -p "$HOME/Library/Caches/com.raycast.macos/fsCachedData" +safe_clean() { echo "CLEAN:$2|$1"; } +clean_launcher_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Raycast"* ]] && [[ "$output" != *"raycast"* ]] +} + +@test "Xcode DerivedData cleanup propagates a size timeout before deletion" { + local isolated_home="$HOME/xcode-derived-timeout" + mkdir -p "$isolated_home/Library/Developer/Xcode/DerivedData/App-abc" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_CURRENT_COMMAND=clean /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +DRY_RUN=false +MOLE_CLEAN_CANCEL_STATUS=0 +_xcode_cleanup_process_state() { return 1; } +get_path_size_kb() { return 124; } +safe_remove() { echo "UNEXPECTED_DELETE:$1"; } +set +e +clean_xcode_derived_data +rc=$? +set -e +printf 'SIZE_RC:%s CANCEL:%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SIZE_RC:124 CANCEL:124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DELETE"* ]] +} + +@test "clean_3d_tools skips Autodesk cache while AcCoreConsole is running (#1390)" { + mkdir -p "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db-shm" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db-wal" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { + if [[ "$*" == *AcCoreConsole* ]] || [[ "$*" == *com.autodesk.* ]]; then + return 0 + fi + return 1 +} +safe_clean() { + local desc="${*: -1}" + case "$desc" in + "Autodesk cache") echo "UNEXPECTED_AUTODESK:$desc" ;; + esac +} +safe_clean_guarded() { echo "UNEXPECTED_GUARDED:$*"; } +mole_defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } +clean_3d_tools +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"DEFER:Autodesk"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_AUTODESK"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_GUARDED"* ]] || return 1 + [[ -f "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" ]] +} + +@test "clean_3d_tools removes Autodesk cache when no Autodesk process is running" { + rm -rf "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + mkdir -p "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +pgrep() { return 1; } +safe_clean() { + local desc="${*: -1}" + echo "SAFE_CLEAN:$desc" + local arg + for arg in "${@:1:$#-1}"; do + echo "PATH:$arg" + done +} +safe_clean_guarded() { shift; safe_clean "$@"; } +note_activity() { :; } +clean_3d_tools +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Autodesk cache"* ]] || return 1 + [[ "$output" == *"PATH:"*"com.autodesk.AcCoreConsole"* ]] || return 1 +} + +@test "safe_remove refuses a live reverse-DNS user cache (#1390)" { + mkdir -p "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + local db="$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + touch "$db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=0 /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +# The owner probe reads one `ps` snapshot rather than forking pgrep per +# candidate, so a live helper is simulated by the table it would appear in. +ps() { + cat <<'TABLE' + PID PPID COMM ARGS + 901 1 /Applications/Autodesk Fusion.app/Contents/MacOS/AcCoreConsole /Applications/Autodesk Fusion.app/Contents/MacOS/AcCoreConsole +TABLE +} +oplog_enabled() { return 1; } +log_operation() { :; } +debug_log() { :; } +db="$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" +set +e +safe_remove "$db" true +rc=$? +set -e +[[ $rc -ne 0 ]] +[[ -f "$db" ]] +INNER + + [ "$status" -eq 0 ] + [[ -f "$db" ]] +} + +@test "safe_remove deletes an idle reverse-DNS user cache" { + mkdir -p "$HOME/Library/Caches/com.example.idleapp" + local db="$HOME/Library/Caches/com.example.idleapp/Cache.db" + touch "$db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=0 /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +pgrep() { return 1; } +lsof() { return 1; } +oplog_enabled() { return 1; } +log_operation() { :; } +debug_log() { :; } +validate_path_for_deletion() { return 0; } +db="$HOME/Library/Caches/com.example.idleapp/Cache.db" +safe_remove "$db" true +[[ ! -e "$db" ]] +INNER + + [ "$status" -eq 0 ] + [[ ! -e "$db" ]] } diff --git a/Resources/mole/tests/clean_apps.bats b/Resources/mole/tests/clean_apps.bats index 0fafe10..d1b2e36 100644 --- a/Resources/mole/tests/clean_apps.bats +++ b/Resources/mole/tests/clean_apps.bats @@ -10,26 +10,55 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-apps-module.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } @test "clean_ds_store_tree reports dry-run summary" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true NO_COLOR= /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +start_inline_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +get_file_size() { echo $((2 * 1024 * 1024 * 1024)); } +bytes_to_human() { echo "2.15GB"; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +mkdir -p "$HOME/test_ds" +touch "$HOME/test_ds/.DS_Store" +clean_ds_store_tree "$HOME/test_ds" "DS test" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DS test"* ]] || return 1 + [[ "$output" == *$'\033[0;33m→\033[0m'* ]] +} + +@test "clean_ds_store_tree uses green for successful cleanups" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false NO_COLOR= /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" start_inline_spinner() { :; } stop_section_spinner() { :; } note_activity() { :; } -get_file_size() { echo 10; } -bytes_to_human() { echo "0B"; } +get_file_size() { echo 512; } +bytes_to_human() { echo "512B"; } files_cleaned=0 total_size_cleaned=0 total_items=0 @@ -39,335 +68,2220 @@ clean_ds_store_tree "$HOME/test_ds" "DS test" EOF [ "$status" -eq 0 ] - [[ "$output" == *"DS test"* ]] + [[ "$output" == *"DS test"* ]] || return 1 + [[ "$output" == *$'\033[0;32m✓\033[0m'* ]] } @test "scan_installed_apps uses cache when fresh" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" mkdir -p "$HOME/.cache/mole" -echo "com.example.App" > "$HOME/.cache/mole/installed_apps_cache" +printf '%s\n%s\n' "com.example.App" "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" > "$HOME/.cache/mole/installed_apps_cache" get_file_mtime() { date +%s; } debug_log() { :; } +create_temp_dir() { echo "UNEXPECTED_SCAN"; return 1; } scan_installed_apps "$HOME/installed.txt" cat "$HOME/installed.txt" EOF [ "$status" -eq 0 ] - [[ "$output" == *"com.example.App"* ]] + [[ "$output" == "com.example.App" ]] || return 1 + [[ "$output" != *"UNEXPECTED_SCAN"* ]] || return 1 + [[ "$output" != *"mole-installed-apps-cache"* ]] } -@test "is_bundle_orphaned returns true for old uninstalled bundle" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=30 bash --noprofile --norc <<'EOF' +@test "scan_installed_apps fails closed when a complete cache cannot reach scan output" { + run env HOME="$HOME/cache-output-failure" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -should_protect_data() { return 1; } -get_file_mtime() { echo 0; } -if is_bundle_orphaned "com.example.Old" "$HOME/old" "$HOME/installed.txt"; then - echo "orphan" -fi + +mkdir -p "$HOME/.cache/mole" "$HOME/installed-output" +printf '%s\n%s\n' "com.example.App" "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" > "$HOME/.cache/mole/installed_apps_cache" +get_file_mtime() { date +%s; } +debug_log() { :; } +scan_status=0 +scan_installed_apps "$HOME/installed-output" || scan_status=$? +[[ $scan_status -ne 0 ]] EOF - [ "$status" -eq 0 ] - [[ "$output" == *"orphan"* ]] + [ "$status" -eq 0 ] || { echo "$output"; return 1; } } -@test "clean_orphaned_app_data skips when no permission" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "scan_installed_apps rejects the previous complete-cache schema and finds the installed app" { + run env HOME="$HOME/unmarked-cache" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -rm -rf "$HOME/Library/Caches" -clean_orphaned_app_data + +mkdir -p "$HOME/.cache/mole" "$HOME/Applications/Present.app/Contents" +printf '%s\n%s\n' "com.example.Missing" "# mole-installed-apps-cache:v2:complete" > "$HOME/.cache/mole/installed_apps_cache" +cat > "$HOME/Applications/Present.app/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.example.Present + +PLIST +get_file_mtime() { date +%s; } +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" +grep -Fx "com.example.Present" "$HOME/installed.txt" +if grep -Fx "com.example.Missing" "$HOME/installed.txt"; then + exit 1 +fi +printf 'CACHE_SCHEMA_REBUILT:com.example.Present\n' EOF - [ "$status" -eq 0 ] - [[ "$output" == *"No permission"* ]] + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"com.example.Present"* ]] || return 1 + [[ "$output" != *"com.example.Missing"* ]] } -@test "clean_orphaned_app_data handles paths with spaces correctly" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "scan_installed_apps rejects a cache timestamp from the future" { + run env HOME="$HOME/future-cache" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -# Mock scan_installed_apps - return empty (no installed apps) -scan_installed_apps() { - : > "$1" -} +mkdir -p "$HOME/.cache/mole" "$HOME/Applications/FuturePresent.app/Contents" +printf '%s\n%s\n' "com.example.FutureStale" "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" > "$HOME/.cache/mole/installed_apps_cache" +cat > "$HOME/Applications/FuturePresent.app/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.example.FuturePresent + +PLIST +get_file_mtime() { echo $(( $(date +%s) + 60 )); } +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" +grep -Fx "com.example.FuturePresent" "$HOME/installed.txt" +if grep -Fx "com.example.FutureStale" "$HOME/installed.txt"; then + exit 1 +fi +printf 'FUTURE_CACHE_REBUILT:com.example.FuturePresent\n' +EOF -# Mock mdfind to return empty (no app found) -mdfind() { - return 0 + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"com.example.FuturePresent"* ]] || return 1 + [[ "$output" != *"com.example.FutureStale"* ]] } -# Ensure local function mock works even if timeout/gtimeout is installed -run_with_timeout() { shift; "$@"; } - -# Mock safe_clean (normally from bin/clean.sh) -safe_clean() { - rm -rf "$1" - return 0 -} +@test "scan_installed_apps ignores same-directory staging files" { + run env HOME="$HOME/staged-cache" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" -# Create required Library structure for permission check -mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/.cache/mole" "$HOME/Applications/StagePresent.app/Contents" +printf '%s\n%s\n' "com.example.PartialStage" "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" > "$HOME/.cache/mole/installed_apps_cache.tmp.interrupted" +cat > "$HOME/Applications/StagePresent.app/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.example.StagePresent + +PLIST +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" +grep -Fx "com.example.StagePresent" "$HOME/installed.txt" +if grep -Fx "com.example.PartialStage" "$HOME/installed.txt"; then + exit 1 +fi +printf 'STAGED_CACHE_IGNORED:com.example.StagePresent\n' +EOF -# Create test structure with spaces in path (old modification time: 31 days ago) -mkdir -p "$HOME/Library/Saved Application State/com.test.orphan.savedState" -# Create a file with some content so directory size > 0 -echo "test data" > "$HOME/Library/Saved Application State/com.test.orphan.savedState/data.plist" -# Set modification time to 31 days ago (older than 30-day threshold) -touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Saved Application State/com.test.orphan.savedState" 2>/dev/null || true + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"com.example.StagePresent"* ]] || return 1 + [[ "$output" != *"com.example.PartialStage"* ]] +} -# Disable spinner for test -start_section_spinner() { :; } -stop_section_spinner() { :; } +@test "scan_installed_apps keeps the previous complete cache when publish fails" { + run env HOME="$HOME/publish-failure" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" -# Run cleanup -clean_orphaned_app_data +cache_file="$HOME/.cache/mole/installed_apps_cache" +mkdir -p "$(dirname "$cache_file")" "$HOME/Applications/CurrentScan.app/Contents" +printf '%s\n%s\n' "com.example.Previous" "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" > "$cache_file" +cat > "$HOME/Applications/CurrentScan.app/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.example.CurrentScan + +PLIST +get_file_mtime() { echo 0; } +debug_log() { :; } +mv() { return 73; } -# Verify path with spaces was handled correctly (not split into multiple paths) -if [[ -d "$HOME/Library/Saved Application State/com.test.orphan.savedState" ]]; then - echo "ERROR: Orphaned savedState not deleted" +scan_installed_apps "$HOME/installed.txt" +grep -Fx "com.example.CurrentScan" "$HOME/installed.txt" +grep -Fx "com.example.Previous" "$cache_file" +[[ "$(tail -n 1 "$cache_file")" == "$INSTALLED_APPS_CACHE_COMPLETE_MARKER" ]] +if find "$(dirname "$cache_file")" -maxdepth 1 -name 'installed_apps_cache.tmp.*' -print -quit | grep -q .; then exit 1 -else - echo "SUCCESS: Orphaned savedState deleted correctly" fi +printf 'PUBLISH_FAILURE_CURRENT:com.example.CurrentScan\n' +printf 'PUBLISH_FAILURE_PREVIOUS:com.example.Previous\n' EOF - [ "$status" -eq 0 ] - [[ "$output" == *"SUCCESS"* ]] + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"com.example.CurrentScan"* ]] || return 1 + [[ "$output" == *"com.example.Previous"* ]] } -@test "clean_orphaned_app_data only counts successful deletions" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "scan_installed_apps fails closed when a discovered app has no readable bundle id" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -# Mock scan_installed_apps - return empty -scan_installed_apps() { - : > "$1" -} +# HOME is shared across tests in this file; drop any cache a prior test wrote +# so this one exercises a real scan rather than reading a stale cache. +rm -f "$HOME/.cache/mole/installed_apps_cache" + +# A plist that cannot be parsed at all. This is the case that has to fail +# closed: the app may well have a CFBundleIdentifier that simply could not be +# read, and leaving that id out of the installed list is what turns a live +# app's data into an apparent orphan. A plist that parses and merely lacks the +# key is a different thing and is covered by its own test, since a bundle with +# no id owns no bundle-id-named leftovers and cannot be mistaken for one. +mkdir -p "$HOME/Applications/FakeApp.app/Contents" +printf 'not a property list at all' > "$HOME/Applications/FakeApp.app/Contents/Info.plist" + +# Create a valid .app alongside it +mkdir -p "$HOME/Applications/GoodApp.app/Contents" +cat > "$HOME/Applications/GoodApp.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.GoodApp + + +PLIST -# Mock mdfind to return empty (no app found) -mdfind() { - return 0 +debug_log() { :; } +scan_status=0 +scan_installed_apps "$HOME/installed.txt" || scan_status=$? +[[ $scan_status -ne 0 ]] || exit 1 +[[ ! -e "$HOME/.cache/mole/installed_apps_cache" ]] || exit 1 +printf 'APP_METADATA_FAILURE_CLOSED\n' +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"APP_METADATA_FAILURE_CLOSED"* ]] || return 1 } -# Ensure local function mock works even if timeout/gtimeout is installed -run_with_timeout() { shift; "$@"; } +@test "scan_installed_apps fails closed when every running-app probe fails" { + local scan_home="$HOME/running-probe-failure" + rm -rf "$scan_home" + mkdir -p "$scan_home" -# Create required Library structure for permission check -mkdir -p "$HOME/Library/Caches" + run env HOME="$scan_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" -# Create test files (old modification time: 31 days ago) -mkdir -p "$HOME/Library/Caches/com.test.orphan1" -mkdir -p "$HOME/Library/Caches/com.test.orphan2" -# Create files with content so size > 0 -echo "data1" > "$HOME/Library/Caches/com.test.orphan1/data" -echo "data2" > "$HOME/Library/Caches/com.test.orphan2/data" -# Set modification time to 31 days ago -touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan1" 2>/dev/null || true -touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan2" 2>/dev/null || true +app_path="$HOME/Applications/ProbeApp.app" +mkdir -p "$app_path/Contents" "$HOME/stub-bin" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.example.ProbeApp + +PLIST -# Mock safe_clean to fail on first item, succeed on second -safe_clean() { - if [[ "$1" == *"orphan1"* ]]; then - return 1 # Fail - else - rm -rf "$1" - return 0 # Succeed - fi -} +cat > "$HOME/stub-bin/find" <<'SH' +#!/bin/sh +if [ "${1:-}" = "$HOME/Applications" ]; then + printf '%s\n' "$HOME/Applications/ProbeApp.app" +fi +exit 0 +SH +for command_name in osascript lsappinfo; do + cat > "$HOME/stub-bin/$command_name" <<'SH' +#!/bin/sh +exit 64 +SH +done +chmod +x "$HOME/stub-bin/find" "$HOME/stub-bin/osascript" "$HOME/stub-bin/lsappinfo" +export PATH="$HOME/stub-bin:/usr/bin:/bin" -# Disable spinner -start_section_spinner() { :; } -stop_section_spinner() { :; } +debug_log() { :; } +scan_status=0 +scan_installed_apps "$HOME/installed.txt" || scan_status=$? +[[ $scan_status -ne 0 ]] || exit 1 +[[ ! -e "$HOME/.cache/mole/installed_apps_cache" ]] || exit 1 +printf 'AUXILIARY_PROBE_FAILURE_CLOSED\n' +EOF -# Run cleanup -clean_orphaned_app_data + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"AUXILIARY_PROBE_FAILURE_CLOSED"* ]] || return 1 +} -# Verify first item still exists (safe_clean failed) -if [[ -d "$HOME/Library/Caches/com.test.orphan1" ]]; then - echo "PASS: Failed deletion preserved" +@test "scan_installed_apps keeps find traversal options before predicates" { + rm -f "$HOME/.cache/mole/installed_apps_cache" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +stub_dir="$HOME/stub-bin" +mkdir -p "$stub_dir" "$HOME/Applications/Ordered.app/Contents" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +root="$1" +shift +case "$root" in + "$HOME/Library/LaunchAgents" | "/Library/LaunchAgents") exit 0 ;; +esac +if [ "${1:-}" != "-maxdepth" ] || + [ "${2:-}" != "3" ] || + [ "${3:-}" != "-type" ] || + [ "${4:-}" != "d" ] || + [ "${5:-}" != "-name" ] || + [ "${6:-}" != "*.app" ]; then + exit 64 fi -# Verify second item deleted -if [[ ! -d "$HOME/Library/Caches/com.test.orphan2" ]]; then - echo "PASS: Successful deletion removed" +if [ "$root" = "$HOME/Applications" ]; then + printf '%s\n' "$HOME/Applications/Ordered.app" fi +SH +chmod +x "$stub_dir/find" -# Check that output shows correct count (only 1, not 2) +cat > "$HOME/Applications/Ordered.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Ordered + + +PLIST + +debug_log() { :; } +export PATH="$stub_dir:$PATH" +scan_installed_apps "$HOME/installed.txt" +cat "$HOME/installed.txt" EOF [ "$status" -eq 0 ] - [[ "$output" == *"PASS: Failed deletion preserved"* ]] - [[ "$output" == *"PASS: Successful deletion removed"* ]] + [[ "$output" == *"com.example.Ordered"* ]] } -@test "clean_orphaned_app_data removes orphaned Claude VM bundle" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "scan_installed_apps aggregates LaunchAgent bundle names without scratch paths" { + run env HOME="$HOME/agent-scan" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -scan_installed_apps() { - : > "$1" +mkdir -p "$HOME/Applications/AgentOwner.app/Contents" "$HOME/Library/LaunchAgents" +cat > "$HOME/Applications/AgentOwner.app/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.example.AgentOwner + +PLIST +touch "$HOME/Library/LaunchAgents/com.example.Agent.plist" +debug_log() { :; } + +scan_installed_apps "$HOME/installed.txt" +grep -qFx 'com.example.AgentOwner' "$HOME/installed.txt" +grep -qFx 'com.example.Agent' "$HOME/installed.txt" +if grep -qF '/Library/LaunchAgents/com.example.Agent.plist' "$HOME/installed.txt"; then + exit 1 +fi +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } } -mdfind() { - return 0 +@test "scan_installed_apps fails closed when scan result aggregation fails" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +rm -f "$HOME/.cache/mole/installed_apps_cache" +stub_dir="$HOME/stub-bin-aggregation" +mkdir -p "$stub_dir" "$HOME/Applications" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +exit 0 +SH +cat > "$stub_dir/lsappinfo" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$stub_dir/find" "$stub_dir/lsappinfo" +export PATH="$stub_dir:$PATH" + +aggregate_failure_seen="$HOME/aggregate-failure-seen" +cat() { + local input + for input in "$@"; do + case "$input" in + */apps_*.txt) + : > "$aggregate_failure_seen" + return 73 + ;; + esac + done + command cat "$@" } +debug_log() { :; } -pgrep() { - return 1 +scan_status=0 +scan_installed_apps "$HOME/installed.txt" || scan_status=$? +[[ -e "$aggregate_failure_seen" ]] || exit 1 +[[ $scan_status -ne 0 ]] || exit 1 +printf 'AGGREGATION_FAILED_CLOSED\n' +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"AGGREGATION_FAILED_CLOSED"* ]] || return 1 } -run_with_timeout() { shift; "$@"; } -get_file_mtime() { echo 0; } -get_path_size_kb() { echo 4; } +@test "scan_installed_apps leaves tracked scratch cleanup to the temp registry (#1313)" { + local scan_home="$HOME/registry-scan" + rm -rf "$scan_home" + mkdir -p "$scan_home" -safe_clean() { - echo "$2" - rm -rf "$1" + run env HOME="$scan_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +mkdir -p "$HOME/mole-tmp" +export TMPDIR="$HOME/mole-tmp" + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +rm -f "$HOME/.cache/mole/installed_apps_cache" + +stub_dir="$HOME/stub-bin-registry" +mkdir -p "$stub_dir" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +exit 0 +SH +cat > "$stub_dir/lsappinfo" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$stub_dir/find" "$stub_dir/lsappinfo" +export PATH="$stub_dir:$PATH" + +remove_calls="$HOME/safe-remove-calls" +: > "$remove_calls" +safe_remove() { + printf '%s\n' "$1" >> "$remove_calls" + return 1 +} +debug_log() { + printf 'DEBUG:%s\n' "$*" } -start_section_spinner() { :; } -stop_section_spinner() { :; } +scan_installed_apps "$HOME/installed.txt" -mkdir -p "$HOME/Library/Caches" -mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" -echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" +[[ -s "$MOLE_TEMP_REGISTRY_FILE" ]] || exit 1 +scan_tmp_dir=$(head -n 1 "$MOLE_TEMP_REGISTRY_FILE") +[[ -d "$scan_tmp_dir" ]] || exit 1 +[[ ! -s "$remove_calls" ]] || exit 1 -clean_orphaned_app_data +outside_file="$HOME/outside-temp-root" +touch "$outside_file" +cleanup_temp_files -if [[ ! -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then - echo "PASS: Claude VM removed" -fi +[[ ! -e "$scan_tmp_dir" ]] || exit 1 +[[ -e "$outside_file" ]] || exit 1 +printf 'REGISTRY_CLEANUP_OK\n' EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Orphaned Claude workspace VM"* ]] - [[ "$output" == *"PASS: Claude VM removed"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"DEBUG:Scanned 0 unique applications"* ]] || return 1 + [[ "$output" == *"REGISTRY_CLEANUP_OK"* ]] || return 1 } -@test "clean_orphaned_app_data keeps recent Claude VM bundle when Claude lookup misses" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_app_data fails closed when the installed app scan fails" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" +mkdir -p "$HOME/Library/Caches/com.example.LiveApp" +touch -t "$(date -v-31d +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.example.LiveApp" + scan_installed_apps() { : > "$1" -} - -mdfind() { - return 0 -} - -pgrep() { return 1 } - +mdfind() { return 0; } run_with_timeout() { shift; "$@"; } -get_file_mtime() { date +%s; } - +get_path_size_kb() { printf '1\n'; } safe_clean() { - echo "UNEXPECTED:$2" - return 1 + : > "$HOME/safe-clean-called" + return 0 } - start_section_spinner() { :; } stop_section_spinner() { :; } -mkdir -p "$HOME/Library/Caches" -mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" -echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" +set +e +clean_orphaned_app_data +rc=$? +set -e + +[[ $rc -eq 0 ]] || exit 1 +[[ ! -e "$HOME/safe-clean-called" ]] || exit 1 +printf 'SCAN_FAILURE_CLOSED\n' +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped: Unable to scan installed applications"* ]] || return 1 + [[ "$output" == *"SCAN_FAILURE_CLOSED"* ]] +} + +@test "clean_orphaned_app_data fails closed when an app directory find fails" { + local scan_home="$HOME/find-failure-scan" + rm -rf "$scan_home" + mkdir -p "$scan_home" + + run env HOME="$scan_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +mkdir -p "$HOME/Applications/LiveApp.app" \ + "$HOME/Applications/Partial.app/Contents" \ + "$HOME/Library/Caches/com.example.LiveApp" +touch -t "$(date -v-31d +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.example.LiveApp" +rm -f "$HOME/.cache/mole/installed_apps_cache" + +cat > "$HOME/Applications/Partial.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Partial + + +PLIST + +stub_dir="$HOME/stub-bin-find-failure" +mkdir -p "$stub_dir" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +if [ "${1:-}" = "$HOME/Applications" ]; then + exit 64 +fi +if [ "${1:-}" = "/Applications" ]; then + printf '%s\n' "$HOME/Applications/Partial.app" +fi +exit 0 +SH +cat > "$stub_dir/lsappinfo" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$stub_dir/find" "$stub_dir/lsappinfo" +export PATH="$stub_dir:$PATH" + +mdfind() { return 0; } +run_with_timeout() { shift; "$@"; } +get_path_size_kb() { printf '1\n'; } +safe_clean() { + : > "$HOME/safe-clean-called" + return 0 +} +start_section_spinner() { :; } +stop_section_spinner() { :; } + +set +e +clean_orphaned_app_data +rc=$? +set -e + +[[ $rc -eq 0 ]] || exit 1 +[[ ! -e "$HOME/safe-clean-called" ]] || exit 1 +[[ ! -e "$HOME/.cache/mole/installed_apps_cache" ]] || exit 1 +printf 'APP_DIRECTORY_SCAN_FAILURE_CLOSED\n' +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped: Unable to scan installed applications"* ]] || return 1 + [[ "$output" == *"APP_DIRECTORY_SCAN_FAILURE_CLOSED"* ]] +} + +@test "clean_orphaned_app_data skips gracefully under errexit and names the unreadable bundle" { + local scan_home="$HOME/errexit-scan" + rm -rf "$scan_home" + mkdir -p "$scan_home" + + run env HOME="$scan_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +mkdir -p "$HOME/Applications/Broken.app/Contents" \ + "$HOME/Library/Caches/com.example.LiveApp" +touch -t "$(date -v-31d +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.example.LiveApp" +rm -f "$HOME/.cache/mole/installed_apps_cache" + +stub_dir="$HOME/stub-bin-errexit-scan" +mkdir -p "$stub_dir" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +if [ "${1:-}" = "$HOME/Applications" ]; then + printf '%s\n' "$HOME/Applications/Broken.app" +fi +exit 0 +SH +cat > "$stub_dir/lsappinfo" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$stub_dir/find" "$stub_dir/lsappinfo" +export PATH="$stub_dir:$PATH" + +mdfind() { return 0; } +run_with_timeout() { shift; "$@"; } +get_path_size_kb() { printf '1\n'; } +safe_clean() { + : > "$HOME/safe-clean-called" + return 0 +} +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# No set +e wrapper: the production section window is the only reason a bare +# scan failure did not abort before this change, and a future caller outside +# that window must still reach the graceful skip. +clean_orphaned_app_data + +[[ ! -e "$HOME/safe-clean-called" ]] || exit 1 +printf 'ERREXIT_SCAN_FAILURE_CLOSED\n' +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped: Unable to scan installed applications (Broken.app)"* ]] || return 1 + [[ "$output" == *"ERREXIT_SCAN_FAILURE_CLOSED"* ]] +} + +@test "clean_orphaned_app_data renders hostile unreadable bundle names as inert text" { + local scan_home="$HOME/control-name-scan" + rm -rf "$scan_home" + mkdir -p "$scan_home" + + run env HOME="$scan_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 NO_COLOR=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +bad_name=$'Bad\\033[2J-\033[2J.app' +mkdir -p "$HOME/Applications/$bad_name/Contents" \ + "$HOME/Library/Caches/com.example.LiveApp" +touch -t "$(date -v-31d +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.example.LiveApp" + +stub_dir="$HOME/stub-bin-control-name" +mkdir -p "$stub_dir" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +if [ "${1:-}" = "$HOME/Applications" ]; then + printf '%s\n' "$HOME/Applications/$BAD_APP_NAME" +fi +exit 0 +SH +cat > "$stub_dir/lsappinfo" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$stub_dir/find" "$stub_dir/lsappinfo" +export BAD_APP_NAME="$bad_name" +export PATH="$stub_dir:$PATH" + +mdfind() { return 0; } +run_with_timeout() { shift; "$@"; } +get_path_size_kb() { printf '1\n'; } +safe_clean() { echo "UNEXPECTED_CLEAN"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +debug_log() { printf 'DEBUG:%s\n' "$*"; } + +clean_orphaned_app_data +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *'Bad\033[2J-'* ]] || return 1 + [[ "$output" != *$'\033[2J'* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "is_bundle_orphaned returns true for old uninstalled bundle" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=30 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +should_protect_data() { return 1; } +get_file_mtime() { echo 0; } +if is_bundle_orphaned "com.example.Old" "$HOME/old" "$HOME/installed.txt"; then + echo "orphan" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"orphan"* ]] +} + +@test "clean_orphaned_app_data skips when no permission" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +rm -rf "$HOME/Library/Caches" +clean_orphaned_app_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No permission"* ]] +} + +@test "clean_orphaned_app_data handles paths with spaces correctly" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Mock scan_installed_apps - return empty (no installed apps) +scan_installed_apps() { + : > "$1" +} + +# Mock mdfind to return empty (no app found) +mdfind() { + return 0 +} + +# Ensure local function mock works even if timeout/gtimeout is installed +run_with_timeout() { shift; "$@"; } + +# Mock safe_clean (normally from bin/clean.sh) +safe_clean() { + rm -rf "$1" + return 0 +} +bundle_has_installed_app() { return 1; } +safe_clean_guarded() { + local guard="$1" + shift + "$guard" "$1" || return $? + safe_clean "$@" +} + +# Create required Library structure for permission check +mkdir -p "$HOME/Library/Caches" + +# Create test structure with spaces in path (old modification time: 31 days ago) +mkdir -p "$HOME/Library/Saved Application State/com.test.orphan.savedState" +# Create a file with some content so directory size > 0 +echo "test data" > "$HOME/Library/Saved Application State/com.test.orphan.savedState/data.plist" +# Set modification time to 31 days ago (older than 30-day threshold) +touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Saved Application State/com.test.orphan.savedState" 2>/dev/null || true + +# Disable spinner for test +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# Run cleanup +clean_orphaned_app_data + +# Verify path with spaces was handled correctly (not split into multiple paths) +if [[ -d "$HOME/Library/Saved Application State/com.test.orphan.savedState" ]]; then + echo "ERROR: Orphaned savedState not deleted" + exit 1 +else + echo "SUCCESS: Orphaned savedState deleted correctly" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS"* ]] +} + +@test "clean_orphaned_app_data only counts successful deletions" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Mock scan_installed_apps - return empty +scan_installed_apps() { + : > "$1" +} + +# Mock mdfind to return empty (no app found) +mdfind() { + return 0 +} + +# Ensure local function mock works even if timeout/gtimeout is installed +run_with_timeout() { shift; "$@"; } + +# Create required Library structure for permission check +mkdir -p "$HOME/Library/Caches" + +# Create test files (old modification time: 31 days ago) +mkdir -p "$HOME/Library/Caches/com.test.orphan1" +mkdir -p "$HOME/Library/Caches/com.test.orphan2" +# Create files with content so size > 0 +echo "data1" > "$HOME/Library/Caches/com.test.orphan1/data" +echo "data2" > "$HOME/Library/Caches/com.test.orphan2/data" +# Set modification time to 31 days ago +touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan1" 2>/dev/null || true +touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan2" 2>/dev/null || true + +# Mock safe_clean to fail on first item, succeed on second +safe_clean() { + if [[ "$1" == *"orphan1"* ]]; then + return 1 # Fail + else + rm -rf "$1" + return 0 # Succeed + fi +} +bundle_has_installed_app() { return 1; } +safe_clean_guarded() { + local guard="$1" + shift + "$guard" "$1" || return $? + safe_clean "$@" +} + +# Disable spinner +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# Run cleanup +clean_orphaned_app_data + +# Verify first item still exists (safe_clean failed) +if [[ -d "$HOME/Library/Caches/com.test.orphan1" ]]; then + echo "PASS: Failed deletion preserved" +fi + +# Verify second item deleted +if [[ ! -d "$HOME/Library/Caches/com.test.orphan2" ]]; then + echo "PASS: Successful deletion removed" +fi + +# Check that output shows correct count (only 1, not 2) +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: Failed deletion preserved"* ]] || return 1 + [[ "$output" == *"PASS: Successful deletion removed"* ]] +} + +@test "clean_orphaned_app_data uses dry-run wording for orphaned summary (#1192)" { + local test_home="$HOME/dry-run-orphan-summary" + rm -rf "$test_home" + mkdir -p "$test_home" + + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { + : > "$1" +} + +is_bundle_orphaned() { + return 0 +} + +is_claude_vm_bundle_orphaned() { + return 1 +} + +safe_clean() { + if [[ "${DRY_RUN:-false}" == "true" ]]; then + return 0 + fi + rm -rf "$1" + return 0 +} +bundle_has_installed_app() { return 1; } +safe_clean_guarded() { + local guard="$1" + shift + "$guard" "$1" || return $? + safe_clean "$@" +} + +get_path_size_kb() { + echo 2048 +} + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } + +mkdir -p "$HOME/Library/Caches/com.test.orphan-dry-run" +echo "data" > "$HOME/Library/Caches/com.test.orphan-dry-run/data" clean_orphaned_app_data +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Would clean 1 items, about 2.0MB"* ]] || return 1 + [[ "$output" != *"Cleaned 1 items"* ]] || return 1 + [ -d "$test_home/Library/Caches/com.test.orphan-dry-run" ] || return 1 +} + +@test "clean_orphaned_app_data removes orphaned Claude VM bundle" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { + : > "$1" +} + +mdfind() { + return 0 +} + +pgrep() { + return 1 +} + +run_with_timeout() { shift; "$@"; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 4; } + +safe_clean() { + echo "$2" + rm -rf "$1" +} +bundle_has_installed_app() { return 1; } +safe_clean_guarded() { + local guard="$1" + shift + "$guard" "$1" || return $? + safe_clean "$@" +} + +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" +echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" + +clean_orphaned_app_data + +if [[ ! -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then + echo "PASS: Claude VM removed" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphaned Claude workspace VM"* ]] || return 1 + [[ "$output" == *"PASS: Claude VM removed"* ]] +} + +@test "orphan cleanup guard rejects replacement objects and newly installed apps" { + local candidate="$HOME/Library/Caches/com.test.raced-orphan" + mkdir -p "$candidate" + printf 'original\n' > "$candidate/data" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +candidate="$HOME/Library/Caches/com.test.raced-orphan" +orphan_cleanup_candidate_snapshot "$candidate" +_ORPHAN_CLEANUP_EXPECTED_IDENTITY="$_ORPHAN_CANDIDATE_IDENTITY" +_ORPHAN_CLEANUP_EXPECTED_PARENT="$_ORPHAN_CANDIDATE_PARENT" +_ORPHAN_CLEANUP_EXPECTED_PARENT_ID="$_ORPHAN_CANDIDATE_PARENT_ID" +_ORPHAN_CLEANUP_EXPECTED_TARGET_ID="$_ORPHAN_CANDIDATE_TARGET_ID" +_ORPHAN_CLEANUP_BUNDLE_ID="com.test.raced-orphan" +_ORPHAN_CLEANUP_KIND="bundle" + +mv "$candidate" "$candidate.original" +mkdir -p "$candidate" +printf 'replacement\n' > "$candidate/data" +bundle_has_installed_app() { return 1; } +rc=0 +orphan_cleanup_candidate_still_eligible "$candidate" || rc=$? +[[ $rc -eq 1 ]] || exit 1 +[[ -f "$candidate/data" && -f "$candidate.original/data" ]] || exit 1 + +orphan_cleanup_candidate_snapshot "$candidate" +_ORPHAN_CLEANUP_EXPECTED_IDENTITY="$_ORPHAN_CANDIDATE_IDENTITY" +_ORPHAN_CLEANUP_EXPECTED_PARENT="$_ORPHAN_CANDIDATE_PARENT" +_ORPHAN_CLEANUP_EXPECTED_PARENT_ID="$_ORPHAN_CANDIDATE_PARENT_ID" +_ORPHAN_CLEANUP_EXPECTED_TARGET_ID="$_ORPHAN_CANDIDATE_TARGET_ID" +bundle_has_installed_app() { return 0; } +rc=0 +orphan_cleanup_candidate_still_eligible "$candidate" || rc=$? +[[ $rc -eq 1 ]] || exit 1 +[[ -f "$candidate/data" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "orphan cleanup binds its approved object to the final safe_remove sink" { + local candidate="$HOME/Library/Caches/com.test.bound-orphan" + mkdir -p "$candidate" + printf 'cache\n' > "$candidate/data" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/bin/clean.sh" + +candidate="$HOME/Library/Caches/com.test.bound-orphan" +orphan_cleanup_candidate_snapshot "$candidate" +_ORPHAN_CLEANUP_EXPECTED_IDENTITY="$_ORPHAN_CANDIDATE_IDENTITY" +_ORPHAN_CLEANUP_EXPECTED_PARENT="$_ORPHAN_CANDIDATE_PARENT" +_ORPHAN_CLEANUP_EXPECTED_PARENT_ID="$_ORPHAN_CANDIDATE_PARENT_ID" +_ORPHAN_CLEANUP_EXPECTED_TARGET_ID="$_ORPHAN_CANDIDATE_TARGET_ID" +_ORPHAN_CLEANUP_BUNDLE_ID="com.test.bound-orphan" +_ORPHAN_CLEANUP_KIND="bundle" +DRY_RUN=false +MOLE_CURRENT_COMMAND=clean +MOLE_CLEAN_CANCEL_STATUS=0 +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +bundle_has_installed_app() { return 1; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +get_cleanup_path_size_kb() { printf '1\n'; } +safe_remove() { + [[ "$1" == "$candidate" ]] || exit 1 + [[ "$5" == "$_ORPHAN_CLEANUP_EXPECTED_PARENT" ]] || exit 1 + [[ "$6" == "$_ORPHAN_CLEANUP_EXPECTED_PARENT_ID" ]] || exit 1 + [[ "$7" == "$_ORPHAN_CLEANUP_EXPECTED_TARGET_ID" ]] || exit 1 + printf 'BOUND_SINK\n' + return 0 +} + +safe_clean_guarded orphan_cleanup_candidate_still_eligible \ + "$candidate" "Bound orphan" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"BOUND_SINK"* ]] +} + +@test "clean_orphaned_app_data keeps recent Claude VM bundle when Claude lookup misses" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { + : > "$1" +} + +mdfind() { + return 0 +} + +pgrep() { + return 1 +} + +run_with_timeout() { shift; "$@"; } +get_file_mtime() { date +%s; } + +safe_clean() { + echo "UNEXPECTED:$2" + return 1 +} + +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" +echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then + echo "PASS: Recent Claude VM kept" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] || return 1 + [[ "$output" == *"PASS: Recent Claude VM kept"* ]] +} + +@test "clean_orphaned_app_data keeps Claude VM bundle when Claude is installed" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { + echo "com.anthropic.claudefordesktop" > "$1" +} + +pgrep() { + return 1 +} + +safe_clean() { + echo "UNEXPECTED:$2" + return 1 +} + +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" +echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then + echo "PASS: Claude VM kept" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] || return 1 + [[ "$output" == *"PASS: Claude VM kept"* ]] +} + + +@test "clean_orphaned_app_data honors WHITELIST_PATTERNS for Claude VM bundle" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { : > "$1"; } +mdfind() { return 0; } +pgrep() { return 1; } +run_with_timeout() { shift; "$@"; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 4; } +safe_clean() { echo "UNEXPECTED_CLEAN:$2"; rm -rf "$1"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" +echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" + +WHITELIST_PATTERNS=("$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle") + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then + echo "PASS: Claude VM preserved by whitelist" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] || return 1 + [[ "$output" == *"PASS: Claude VM preserved by whitelist"* ]] +} + +@test "clean_orphaned_app_data honors WHITELIST_PATTERNS for orphaned caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { : > "$1"; } +is_bundle_orphaned() { return 0; } +is_claude_vm_bundle_orphaned() { return 1; } +mdfind() { return 0; } +pgrep() { return 1; } +run_with_timeout() { shift; "$@"; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 4; } +safe_clean() { echo "UNEXPECTED_CLEAN:$2"; rm -rf "$1"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches/com.devtool.localbuild" +echo "c" > "$HOME/Library/Caches/com.devtool.localbuild/data" + +WHITELIST_PATTERNS=("$HOME/Library/Caches/com.devtool.localbuild") + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Caches/com.devtool.localbuild" ]]; then + echo "PASS: whitelisted orphan cache preserved" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] || return 1 + [[ "$output" == *"PASS: whitelisted orphan cache preserved"* ]] +} + +@test "is_critical_system_component matches known system services" { + run /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/app_protection.sh" +is_critical_system_component "backgroundtaskmanagement" && echo "yes" +is_critical_system_component "SystemSettings" && echo "yes" +EOF + [ "$status" -eq 0 ] + [[ "${lines[0]}" == "yes" ]] || return 1 + [[ "${lines[1]}" == "yes" ]] +} + +@test "is_critical_system_component ignores non-system names" { + run /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/app_protection.sh" +if is_critical_system_component "myapp"; then + echo "bad" +else + echo "ok" +fi +EOF + [ "$status" -eq 0 ] + [[ "$output" == "ok" ]] +} + +@test "clean_orphaned_system_services respects dry-run" { + # Without MOLE_TEST_MODE=0 the sweep early-returns under setup_file's + # MOLE_TEST_MODE=1, leaving $output empty and both negative assertions true. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.sogou.test.plist" +# An empty file is never classified as an orphan, so the sweep found nothing and +# the dry-run branch under test never ran. +cat > "$tmp_plist" < + + + + Label + com.sogou.test + Program + $tmp_dir/missing-binary + + +PLIST + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + printf '%s\0' "$tmp_plist" + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "launchctl-called" + return 0 + fi + if [[ "$1" == "rm" ]]; then + echo "rm-called" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"rm-called"* ]] || return 1 + [[ "$output" != *"launchctl-called"* ]] || return 1 + # Positive control: every other assertion here is true on empty output, so + # without this the test cannot distinguish "dry-run behaved" from "nothing ran". + [[ "$output" == *"Orphaned services · "*" found dry"* ]] +} + +@test "clean_orphaned_system_services reports an authorization timeout" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +_mole_bounded_sudo() { return 124; } +note_activity() { printf 'ACTIVITY\n'; } +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"authorization check timed out, skipped cleanup"* ]] || return 1 + [[ "$output" == *"ACTIVITY"* ]] +} + +@test "clean_orphaned_system_services reports a budget exhausted by an empty inventory" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { printf 'ACTIVITY\n'; } +debug_log() { :; } +_mole_bounded_sudo() { return 0; } +_mole_materialize_bounded_sudo_find() { + : > "$1" + SECONDS=$((SECONDS + 61)) + return 0 +} +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"scan incomplete, skipped cleanup"* ]] || return 1 + [[ "$output" == *"ACTIVITY"* ]] +} + +@test "clean_orphaned_system_services reads unreadable plists through sudo PlistBuddy" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } +should_protect_path() { return 1; } + +tmp_dir="$(mktemp -d)" +tmp_binary="$tmp_dir/live-helper" +tmp_plist="$tmp_dir/com.example.live-helper.plist" +touch "$tmp_binary" +cat > "$tmp_plist" < + + + + Label + com.example.live-helper + Program + $tmp_binary + + +PLIST +chmod 000 "$tmp_plist" + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "/usr/libexec/PlistBuddy" ]]; then + case "$3" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") printf '%s\n' "$tmp_binary"; return 0 ;; + esac + return 1 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Found 1 orphaned"* ]] || return 1 + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 +} + +@test "clean_orphaned_system_services keeps earlier candidates when a later privileged inventory times out" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { printf 'ACTIVITY\n'; } +debug_log() { :; } + +tmp_dir=$(mktemp -d) +tmp_plist="$tmp_dir/com.example.partial.plist" +candidate_trace="$tmp_dir/candidate.trace" +cat > "$tmp_plist" < +Program$tmp_dir/missing +PLIST + +scan_calls=0 +_mole_materialize_bounded_sudo_find() { + scan_calls=$((scan_calls + 1)) + printf 'SCAN:%s\n' "$3" + if [[ "$3" == "/Library/LaunchDaemons" ]]; then + printf '%s\0' "$tmp_plist" > "$1" + return 0 + fi + if [[ "$3" == "/Library/LaunchAgents" ]]; then + printf '%s\0' "$tmp_dir/partial.plist" > "$1" + return 124 + fi + : > "$1" +} +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) return 1 ;; + /usr/libexec/PlistBuddy) + case "${3:-}" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") + printf 'CANDIDATE_PROBED\n' >> "$candidate_trace" + printf '%s\n' "$tmp_dir/missing" + ;; + esac + ;; + */stat) + printf 'IDENTITY_CAPTURED\n' >> "$candidate_trace" + "$@" + ;; + *) return 0 ;; + esac +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} + +clean_orphaned_system_services +printf 'SCAN_CALLS=%s\n' "$scan_calls" +cat "$candidate_trace" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"SCAN:/Library/LaunchDaemons"* ]] || return 1 + [[ "$output" == *"CANDIDATE_PROBED"* ]] || return 1 + [[ "$output" == *"IDENTITY_CAPTURED"* ]] || return 1 + [[ "$output" == *"SCAN:/Library/LaunchAgents"* ]] || return 1 + [[ "$output" == *"scan incomplete, skipped cleanup"* ]] || return 1 + [[ "$output" == *"ACTIVITY"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_orphaned_system_services propagates an interrupted plist probe before deletion" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir=$(mktemp -d) +tmp_plist="$tmp_dir/com.example.interrupt.plist" +trace="$tmp_dir/probe.trace" +touch "$tmp_plist" +_mole_materialize_bounded_sudo_find() { + if [[ "$3" == "/Library/LaunchDaemons" ]]; then + printf '%s\0' "$tmp_plist" > "$1" + else + : > "$1" + fi +} +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + /usr/libexec/PlistBuddy) + printf 'PLIST_PROBE\n' >> "$trace" + return 130 + ;; + *) return 0 ;; + esac +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} + +rc=0 +clean_orphaned_system_services || rc=$? +printf 'RC=%s\n' "$rc" +cat "$trace" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" == *"PLIST_PROBE"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_orphaned_system_services propagates an interrupted parent-app resolver" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +should_protect_path() { return 1; } +_privileged_helper_bundle_id_from_binary() { printf 'com.example.helper\n'; } +bundle_has_installed_app() { return 130; } + +tmp_dir=$(mktemp -d) +tmp_plist="$tmp_dir/com.example.helper.plist" +touch "$tmp_plist" +_mole_materialize_bounded_sudo_find() { + if [[ "$3" == "/Library/LaunchDaemons" ]]; then + printf '%s\0' "$tmp_plist" > "$1" + else + : > "$1" + fi +} +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) return 0 ;; + /usr/libexec/PlistBuddy) + case "${3:-}" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") printf '/Library/PrivilegedHelperTools/com.example.helper\n' ;; + esac + ;; + /usr/bin/stat) command "$@" ;; + *) return 0 ;; + esac +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} + +rc=0 +clean_orphaned_system_services || rc=$? +printf 'RC=%s\n' "$rc" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_orphaned_system_services propagates an interrupted protect-pattern mdfind" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +should_protect_path() { return 1; } +run_with_timeout() { shift; "$@"; } +mdfind() { + printf 'MDFIND_INTERRUPTED\n' >> "$trace" + return 130 +} + +tmp_dir=$(mktemp -d) +trace="$tmp_dir/mdfind.trace" +bundle_id="" +if [[ ! -e "/Library/Input Methods/SogouInput.app" ]]; then + bundle_id="com.sogou.test" +elif [[ ! -e "/Applications/ClashMac.app" ]]; then + bundle_id="com.clashmac.test" +elif [[ ! -e "/Applications/i4Tools.app" ]]; then + bundle_id="cn.i4tools.test" +elif [[ ! -e "/Applications/Wireshark.app" ]]; then + bundle_id="org.wireshark.ChmodBPF" +elif [[ ! -e "/Applications/zoom.us.app" ]]; then + bundle_id="us.zoom.test" +elif [[ ! -e "/Applications/Docker.app" ]]; then + bundle_id="com.docker.test" +else + printf 'No absent protected app fixture is available\n' >&2 + exit 99 +fi + +tmp_plist="$tmp_dir/$bundle_id.plist" +touch "$tmp_plist" +_mole_materialize_bounded_sudo_find() { + if [[ "$3" == "/Library/LaunchDaemons" ]]; then + printf '%s\0' "$tmp_plist" > "$1" + else + : > "$1" + fi +} +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) return 1 ;; + /usr/libexec/PlistBuddy) + case "${3:-}" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") printf '%s\n' "$tmp_dir/missing" ;; + esac + ;; + *) return 0 ;; + esac +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} + +rc=0 +clean_orphaned_system_services || rc=$? +printf 'RC=%s\n' "$rc" +cat "$trace" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" == *"MDFIND_INTERRUPTED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_orphaned_system_services stops before removal when launchctl unload times out" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { printf 'ACTIVITY\n'; } +debug_log() { :; } +should_protect_path() { return 1; } + +tmp_dir=$(mktemp -d) +tmp_plist="$tmp_dir/com.example.unload-timeout.plist" +touch "$tmp_plist" +_mole_materialize_bounded_sudo_find() { + if [[ "$3" == "/Library/LaunchDaemons" ]]; then + printf '%s\0' "$tmp_plist" > "$1" + else + : > "$1" + fi +} +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) return 1 ;; + /usr/libexec/PlistBuddy) + case "${3:-}" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") printf '%s\n' "$tmp_dir/missing" ;; + esac + ;; + /usr/bin/stat) command "$@" ;; + du) printf '4\n' ;; + launchctl) + printf 'LAUNCHCTL_TIMEOUT\n' + return 124 + ;; + *) return 0 ;; + esac +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"LAUNCHCTL_TIMEOUT"* ]] || return 1 + [[ "$output" == *"unload timed out, stopped cleanup"* ]] || return 1 + [[ "$output" == *"ACTIVITY"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_orphaned_system_services does not unload a plist replaced after classification" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +should_protect_path() { return 1; } +debug_log() { printf '%s\n' "$*"; } + +tmp_dir=$(mktemp -d) +tmp_plist="$tmp_dir/com.example.replaced.plist" +replacement="$tmp_dir/replacement.plist" +marker="$tmp_dir/identity-recorded" +touch "$tmp_plist" "$replacement" +_mole_materialize_bounded_sudo_find() { + if [[ "$3" == "/Library/LaunchDaemons" ]]; then + printf '%s\0' "$tmp_plist" > "$1" + else + : > "$1" + fi +} +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) return 1 ;; + /usr/libexec/PlistBuddy) + case "${3:-}" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") printf '%s\n' "$tmp_dir/missing" ;; + esac + ;; + /usr/bin/stat) command "$@" ;; + du) + if [[ ! -e "$marker" ]]; then + touch "$marker" + rm -f "$tmp_plist" + mv "$replacement" "$tmp_plist" + fi + printf '4\n' + ;; + launchctl) + printf 'UNEXPECTED_UNLOAD\n' + return 99 + ;; + *) return 0 ;; + esac +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Keeping changed or no-longer-orphaned service before unload"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_UNLOAD"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_orphaned_system_services does not count protected skips as cleaned" { + # setup_file exports MOLE_TEST_MODE=1, under which clean_orphaned_system_services + # returns immediately and leaves $output empty. Override it as the sibling cases do. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +should_protect_path() { return 0; } +safe_sudo_remove() { + echo "unexpected-remove" + return 0 +} + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.sogou.test.plist" +# _plist_is_orphaned needs a Program key pointing at a missing binary; an empty +# file is never classified as an orphan, so the sweep found nothing and this test +# produced no output at all. +cat > "$tmp_plist" < + + + + Label + com.sogou.test + Program + $tmp_dir/missing-binary + + +PLIST + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "unexpected-launchctl" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphaned services · skipped 1 protected"* ]] || return 1 + [[ "$output" != *"Orphaned services · cleaned"* ]] || return 1 + [[ "$output" != *"unexpected-remove"* ]] || return 1 + [[ "$output" != *"unexpected-launchctl"* ]] +} + +# 48ca1090 (#1082) made this sweep call should_protect_path under +# MOLE_UNINSTALL_MODE=1, which deliberately stops consulting DATA_PROTECTED_BUNDLES +# so orphaned vendor helpers can be reclaimed; only SYSTEM_CRITICAL_BUNDLES still +# block. AmneziaWG sits in the data-protected list, so an orphan whose parent app +# is gone is removed by design, exactly like the com.docker case asserted below. +# The older "must stay protected" expectation outlived that change only because +# the assertion sat mid-test and could not fail. +@test "clean_orphaned_system_services reclaims an AmneziaWG helper once its app is gone" { + # setup_file exports MOLE_TEST_MODE=1, under which clean_orphaned_system_services + # returns immediately and leaves $output empty. Override it as the sibling cases do. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +bundle_has_installed_app() { return 1; } +safe_sudo_remove() { + echo "removed:$1" + return 0 +} + +# Routed through /Library/LaunchDaemons, which exists on every macOS box. The +# PrivilegedHelperTools scan is guarded by [[ -d /Library/PrivilegedHelperTools ]] +# in lib/clean/apps.sh, and that directory is absent on GitHub runners, so a +# helper fixture makes this case find nothing and pass vacuously in CI. +tmp_dir="$(mktemp -d)" +tmp_helper="$tmp_dir/org.amnezia.awg.plist" +cat > "$tmp_helper" < + + + + Label + org.amnezia.awg + Program + $tmp_dir/missing-binary + + +PLIST + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_helper" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_helper" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "launchctl-unload:$*" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphaned services · cleaned 1"* ]] || return 1 + [[ "$output" == *"removed:"*"org.amnezia.awg.plist"* ]] || return 1 + # A LaunchDaemon orphan is unloaded before removal, so this call is the + # expected order rather than a stray one. + [[ "$output" == *"launchctl-unload:"*"org.amnezia.awg.plist"* ]] +} + +@test "_privileged_helper_bundle_id_from_binary prefers Info.plist bundle ID over directory and executable names" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +plutil() { + [[ "$*" == *"/Library/PrivilegedHelperTools/com.example.directory.bundle/Contents/Info.plist"* ]] || return 1 + printf '%s\n' "io.github.clash-verge-rev.clash-verge-rev.service" +} + +result=$(_privileged_helper_bundle_id_from_binary "/Library/PrivilegedHelperTools/com.example.directory.bundle/Contents/MacOS/clash-verge-service") +printf '%s\n' "$result" +EOF + + [ "$status" -eq 0 ] + [ "$output" = "io.github.clash-verge-rev.clash-verge-rev.service" ] +} + +@test "clean_orphaned_system_services removes orphaned helper despite data protection (#1082)" { + # The Docker leftover in #1082 survived because should_protect_data matches + # com.docker.* and blocked cleanup. com.getpostman.* hits the exact same + # should_protect_data branch; orphan cleanup must call should_protect_path in + # uninstall mode so a verified orphan is not blocked by data protection. + # Routed through /Library/LaunchDaemons (always present) rather than + # /Library/PrivilegedHelperTools (absent on CI runners). + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.getpostman.helper.plist" +# Program points at a missing binary, so the plist is a genuine orphan. +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" 2> /dev/null || true + +removed_marker="$tmp_dir/removed" +safe_sudo_remove() { + echo "removed:$1" + printf '%s\n' "$1" >> "$removed_marker" + return 0 +} + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphaned services · cleaned 1"* ]] || return 1 + [[ "$output" == *"removed:"* ]] || return 1 + [[ "$output" != *"skipped 1 protected"* ]] || return 1 +} + +@test "clean_orphaned_system_services keeps daemons whose binary is root-only readable (#1188)" { + # Intego-style self-protecting software (antivirus, endpoint agents) makes + # its install tree root-only readable, so the unprivileged -e probe misses + # the daemon binary and every one of its LaunchDaemons used to be flagged + # as an orphan and removed, breaking the product. The binary must be + # re-probed with sudo before being treated as missing. A genuinely missing + # binary must still be detected, which also proves the scan actually ran. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir="$(mktemp -d)" +protected_plist="$tmp_dir/com.example.selfprotect.daemon.plist" +orphan_plist="$tmp_dir/com.example.gone.daemon.plist" +root_only_binary="$tmp_dir/rootonly/selfprotectd" + +# PlistBuddy announces "File Doesn't Exist, Will Create" on stdout, which +# would land in $output and trip the negative plist-name assertions below. +/usr/libexec/PlistBuddy -c "Add :Program string $root_only_binary" "$protected_plist" > /dev/null 2>&1 || true +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$orphan_plist" > /dev/null 2>&1 || true + +safe_sudo_remove() { + echo "removed:$1" + return 0 +} + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "test" ]]; then + # Simulate the root-only readable install dir: the binary exists for + # root but the unprivileged [[ -e ]] probe cannot see it. + if [[ "${3:-}" == "$root_only_binary" ]]; then + return 0 + fi + return 1 + fi + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$protected_plist" "$orphan_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 ${3:-}" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphaned services · cleaned 1"* ]] || return 1 + [[ "$output" == *"removed:"* ]] || return 1 + [[ "$output" == *"com.example.gone.daemon.plist"* ]] || return 1 + [[ "$output" != *"com.example.selfprotect.daemon.plist"* ]] || return 1 +} + +@test "clean_orphaned_system_services counts safe_sudo protected skips as protected (#1141)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=false MOLE_DRY_RUN=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } +should_protect_path() { + if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then + return 1 + fi + return 0 +} + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.adobe.example.plist" +cat > "$tmp_plist" < + + + + Label + com.adobe.example + Program + $tmp_dir/missing-binary + + +PLIST + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "launchctl-called" + return 0 + fi + if [[ "$1" == "rm" ]]; then + echo "rm-called" + return 0 + fi + command "$@" +} -if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then - echo "PASS: Recent Claude VM kept" -fi +clean_orphaned_system_services EOF [ "$status" -eq 0 ] - [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] - [[ "$output" == *"PASS: Recent Claude VM kept"* ]] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"Orphaned services · skipped 1 protected"* ]] || return 1 + [[ "$output" != *"rm-called"* ]] || return 1 + [[ "$output" != *"Failed to remove orphaned service"* ]] || return 1 } -@test "clean_orphaned_app_data keeps Claude VM bundle when Claude is installed" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_system_services dry-run skips protected paths (#886)" { + # MOLE_TEST_NO_AUTH=0 overrides the CI default (=1) so the function actually + # runs past the auth-skip guard in apps.sh; the sudo() mock satisfies the + # `sudo -n true` probe. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -scan_installed_apps() { - echo "com.anthropic.claudefordesktop" > "$1" -} - -pgrep() { - return 1 -} - -safe_clean() { - echo "UNEXPECTED:$2" - return 1 -} - start_section_spinner() { :; } stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } -mkdir -p "$HOME/Library/Caches" -mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" -echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" +should_protect_path() { return 0; } -clean_orphaned_app_data +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.microsoft.office.licensingV2.helper.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-protected-helper" "$tmp_plist" 2>/dev/null || true -if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then - echo "PASS: Claude VM kept" -fi +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + command "$@" +} + +clean_orphaned_system_services EOF + # `|| return 1` after each assertion ensures bats fails as soon as one fails + # (bare `[[ ]]` in the middle of a test body gets swallowed by the next + # passing command; see #886 review notes). [ "$status" -eq 0 ] - [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] - [[ "$output" == *"PASS: Claude VM kept"* ]] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"skipped 1 protected"* ]] || return 1 + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 } - -@test "is_critical_system_component matches known system services" { - run bash --noprofile --norc <<'EOF' +@test "clean_orphaned_system_services dry-run reports unprotected orphans (#886)" { + # MOLE_TEST_NO_AUTH=0 overrides CI default so the function executes. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' set -euo pipefail -source "$PROJECT_ROOT/lib/core/app_protection.sh" -is_critical_system_component "backgroundtaskmanagement" && echo "yes" -is_critical_system_component "SystemSettings" && echo "yes" -EOF - [ "$status" -eq 0 ] - [[ "${lines[0]}" == "yes" ]] - [[ "${lines[1]}" == "yes" ]] +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } + +should_protect_path() { return 1; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.example.unprotected.orphan.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" 2>/dev/null || true + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + command "$@" } -@test "is_critical_system_component ignores non-system names" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/app_protection.sh" -if is_critical_system_component "myapp"; then - echo "bad" -else - echo "ok" -fi +clean_orphaned_system_services EOF + [ "$status" -eq 0 ] - [[ "$output" == "ok" ]] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"Would remove orphaned service"* ]] || return 1 + [[ "$output" != *"Skipping protected"* ]] || return 1 } -@test "clean_orphaned_system_services respects dry-run" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +@test "clean_orphaned_system_services dry-run writes orphan paths to the export list (#1210)" { + # MOLE_TEST_NO_AUTH=0 overrides CI default so the function executes. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" @@ -377,178 +2291,421 @@ stop_section_spinner() { :; } note_activity() { :; } debug_log() { :; } +should_protect_path() { return 1; } + tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.sogou.test.plist" -touch "$tmp_plist" +tmp_plist="$tmp_dir/com.example.exported.orphan.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" > /dev/null 2>&1 || true + +EXPORT_LIST_FILE="$tmp_dir/clean-list.txt" +touch "$EXPORT_LIST_FILE" sudo() { if [[ "$1" == "-n" && "$2" == "true" ]]; then return 0 fi + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "find" ]]; then - printf '%s\0' "$tmp_plist" - return 0 - fi - if [[ "$1" == "du" ]]; then - echo "4 $tmp_plist" - return 0 - fi - if [[ "$1" == "launchctl" ]]; then - echo "launchctl-called" - return 0 - fi - if [[ "$1" == "rm" ]]; then - echo "rm-called" + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac return 0 fi command "$@" } clean_orphaned_system_services +echo "--- export list ---" +cat "$EXPORT_LIST_FILE" EOF [ "$status" -eq 0 ] - [[ "$output" != *"rm-called"* ]] - [[ "$output" != *"launchctl-called"* ]] + [[ "$output" == *"found dry"* ]] || return 1 + [[ "$output" == *"com.example.exported.orphan.plist # "* ]] || return 1 } -@test "is_launch_item_orphaned detects orphan when program missing" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_container_stubs removes stub container when app is uninstalled" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.orphan.plist" +# Pin the app-existence probe: the hardcoded example bundle id may match an +# app that is actually installed on the developer's machine. +_container_stub_app_exists() { return 1; } -cat > "$tmp_plist" << 'PLIST' - - - - - Label - com.test.orphan - ProgramArguments - - /nonexistent/app/program - - - -PLIST +# Stub container: only the metadata plist, no Data/ subdir +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" +# Canonical app path does not exist (uninstalled) +# mdfind returns nothing (uninstalled) +mdfind() { echo ""; return 0; } run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -fi +files_cleaned=0 +total_items=0 +total_size_cleaned=0 -rm -rf "$tmp_dir" +clean_orphaned_container_stubs + +if [[ ! -d "$stub" ]]; then + echo "PASS: stub removed" +else + echo "FAIL: stub still exists" + exit 1 +fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"orphan"* ]] + [[ "$output" == *"PASS: stub removed"* ]] || return 1 + [[ "$output" == *"Orphaned app container stubs"* ]] } -@test "is_launch_item_orphaned protects when program exists" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_container_stubs preserves content that appears during removal" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.active.plist" -tmp_program="$tmp_dir/program" -touch "$tmp_program" +# Pin the app-existence probe: the hardcoded example bundle id may match an +# app that is actually installed on the developer's machine. +_container_stub_app_exists() { return 1; } -cat > "$tmp_plist" << PLIST - - - - - Label - com.test.active - ProgramArguments - - $tmp_program - - - -PLIST +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" +fake_bin="$(mktemp -d "$HOME/fake-bin.XXXXXX")" +cat > "$fake_bin/rm" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +target="" +for arg in "$@"; do + target="$arg" +done +if [[ -n "$target" ]]; then + if [[ -d "$target" ]]; then + touch "$target/raced-content" + else + parent=$(dirname "$target") + touch "$parent/raced-content" + fi +fi +exec /bin/rm "$@" +SH +chmod +x "$fake_bin/rm" +PATH="$fake_bin:$PATH" +export PATH +hash -r + +mdfind() { echo ""; return 0; } run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ -f "$stub/raced-content" ]]; then + echo "PASS: race content preserved" else - echo "not-orphan" + echo "FAIL: race content was deleted" + exit 1 fi +EOF -rm -rf "$tmp_dir" + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: race content preserved"* ]] || return 1 + [[ "$output" == *"could not be removed"* ]] +} + +@test "container stub removal must bypass safe_remove because Containers are protected" { + # Guard for the "tidy the outlier back into the house pattern" trap: routing + # _remove_verified_container_stub through safe_remove looks like a cleanup + # win, but should_protect_path blankets ~/Library/Containers, so the shared + # helper refuses the stub and the cleaner silently stops working. This test + # pins the REASON the carve-out exists, so the next refactor sees it fail. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +# common.sh turns errexit on; the probes below are EXPECTED to return 1. +set +e + +# Keep this policy probe independent of the checkout location. A detached +# worktree commonly lives below /private/var/folders, whose children are +# intentionally accepted as disposable temp data before app protection runs. +stub="/Users/mole-clean-apps-fixture-$$/Library/Containers/com.macpaw.CleanMyMac-mas" +plist="$stub/.com.apple.containermanagerd.metadata.plist" + +validate_path_for_deletion "$stub" > /dev/null 2>&1 +echo "validate_dir_rc=$?" +validate_path_for_deletion "$plist" > /dev/null 2>&1 +echo "validate_plist_rc=$?" EOF [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] + # Both must be REFUSED by the shared validator; that is exactly why the + # stub remover keeps its own narrow guards plus a raw rm/rmdir. + [[ "$output" == *"validate_dir_rc=1"* ]] || return 1 + [[ "$output" == *"validate_plist_rc=1"* ]] || return 1 } -@test "is_launch_item_orphaned protects when app support active" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_container_stubs preserves container when app is installed" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.appsupport.plist" - -mkdir -p "$HOME/Library/Application Support/TestApp" -touch "$HOME/Library/Application Support/TestApp/recent.txt" +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" -cat > "$tmp_plist" << 'PLIST' - - - - - Label - com.test.appsupport - ProgramArguments - - $HOME/Library/Application Support/TestApp/Current/app - - - -PLIST +# Simulate the app installed in a user-level Applications directory. +mkdir -p "$HOME/Applications/CleanMyMac X.app" +mdfind() { echo ""; return 0; } run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } +files_cleaned=0 +total_items=0 +total_size_cleaned=0 -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" +clean_orphaned_container_stubs + +if [[ -d "$stub" ]]; then + echo "PASS: stub preserved" else - echo "not-orphan" + echo "FAIL: stub was wrongly removed" + exit 1 fi -rm -rf "$tmp_dir" -rm -rf "$HOME/Library/Application Support/TestApp" EOF [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] + [[ "$output" == *"PASS: stub preserved"* ]] +} + +@test "clean_orphaned_container_stubs preserves container with Data subdirectory" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Container has a Data/ subtree: real sandbox data, must NOT be deleted +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub/Data/Library/Preferences" +touch "$stub/.com.apple.containermanagerd.metadata.plist" +touch "$stub/Data/Library/Preferences/settings.plist" + +mdfind() { echo ""; return 0; } +run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } + +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ -d "$stub/Data" ]]; then + echo "PASS: data container preserved" +else + echo "FAIL: data container was wrongly removed" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: data container preserved"* ]] } -@test "clean_orphaned_launch_agents skips when no orphans" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_container_stubs preserves non-metadata-only container" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -mkdir -p "$HOME/Library/LaunchAgents" +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" +touch "$stub/session.lock" + +mdfind() { echo ""; return 0; } +run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } + +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ -f "$stub/session.lock" ]]; then + echo "PASS: non-stub container preserved" +else + echo "FAIL: non-stub container was wrongly removed" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: non-stub container preserved"* ]] +} + +@test "clean_orphaned_system_services tolerates all-whitelisted orphans on /bin/bash 3.2 (#1127)" { + # macOS ships /bin/bash 3.2 (Apple does not upgrade past it, GPLv3) and + # lib/clean/apps.sh runs under `set -u`, where bash 3.2 treats "${empty[@]}" + # as an unbound variable rather than an empty expansion. When orphans are + # found but every one is whitelisted, kept_files ends up empty and the + # whitelist filter's `orphaned_files=("${kept_files[@]}")` aborted the whole + # clean run with "kept_files[@]: unbound variable". Force /bin/bash so the + # 3.2 expansion behaviour is exercised regardless of any newer bash on PATH. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" start_section_spinner() { :; } stop_section_spinner() { :; } note_activity() { :; } -get_path_size_kb() { echo "1"; } -run_with_timeout() { shift; "$@"; } +debug_log() { :; } + +should_protect_path() { return 1; } +# Every detected orphan is whitelisted, so kept_files stays empty. +is_path_whitelisted() { return 0; } +WHITELIST_PATTERNS=("com.example.*") + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.example.whitelisted.orphan.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" 2> /dev/null || true + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + command "$@" +} -clean_orphaned_launch_agents +clean_orphaned_system_services EOF [ "$status" -eq 0 ] + [[ "$output" != *"unbound variable"* ]] || return 1 + # Whitelisted orphan must be filtered out, so nothing is reported for removal. + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 +} + +@test "installed-app scan reads wrapped bundles and tolerates a missing bundle id" { + # Same two shapes that broke the uninstall scan: an iOS app on Apple + # Silicon keeps its plist under Wrapper/.app, and vendor launchers + # ship one with no CFBundleIdentifier. Both used to fail the scan closed, + # which skipped App leftovers entirely. A plist that will not parse still + # must fail closed, because there the id may exist and be unreadable. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +rm -f "$HOME/.cache/mole/installed_apps_cache" + +apps="$HOME/Applications" +rm -rf "$apps"; mkdir -p "$apps/Good.app/Contents" "$apps/Wrapped.app/Wrapper/Inner.app" "$apps/NoId.app/Contents" +plist() { + cat > "$1" < + +$2 +PLIST +} +plist "$apps/Good.app/Contents/Info.plist" 'CFBundleIdentifiercom.example.good' +plist "$apps/Wrapped.app/Wrapper/Inner.app/Info.plist" 'CFBundleIdentifiercom.example.wrapped' +plist "$apps/NoId.app/Contents/Info.plist" 'CFBundleExecutablerun.sh' + +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" || { echo "SCAN_FAILED"; exit 1; } +grep -Fxq "com.example.good" "$HOME/installed.txt" || { echo "MISSING_GOOD"; exit 1; } +grep -Fxq "com.example.wrapped" "$HOME/installed.txt" || { echo "MISSING_WRAPPED"; exit 1; } + +# A plist that cannot be parsed still fails the scan closed. +printf 'not a plist' > "$apps/NoId.app/Contents/Info.plist" +rm -f "$HOME/.cache/mole/installed_apps_cache" +if scan_installed_apps "$HOME/installed2.txt"; then + echo "CORRUPT_NOT_FAILED"; exit 1 +fi +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "installed-app scan skips an iOS app with a dangling WrappedBundle symlink" { + # AudioCopy.app has no Contents/, a WrappedBundle symlink into a Wrapper/ + # that does not exist, so no readable plist anywhere. It owns no + # bundle-id-named data, so skipping it invents no orphan; before this it + # failed the whole App-leftovers scan closed on that machine. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << 'EOF' +set -uo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +rm -f "$HOME/.cache/mole/installed_apps_cache" +apps="$HOME/Applications" +rm -rf "$apps" +mkdir -p "$apps/Good.app/Contents" "$apps/AudioCopy.app" +ln -s "Wrapper/AudioCopy.app" "$apps/AudioCopy.app/WrappedBundle" +printf '%s' 'CFBundleIdentifiercom.example.good' > "$apps/Good.app/Contents/Info.plist" +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" || { echo "SCAN_FAILED"; exit 1; } +grep -Fxq "com.example.good" "$HOME/installed.txt" || { echo "MISSING_GOOD"; exit 1; } +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "installed-app scan still fails closed on a plist-less app with no dangling wrapper" { + # The dangling-symlink skip is narrow: an app with no plist and no + # WrappedBundle symlink at all keeps failing the scan closed, since its + # identity is genuinely unknown rather than provably absent. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << 'EOF' +set -uo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" +rm -f "$HOME/.cache/mole/installed_apps_cache" +apps="$HOME/Applications" +rm -rf "$apps" +mkdir -p "$apps/Good.app/Contents" "$apps/Mystery.app/Contents" +printf '%s' 'CFBundleIdentifiercom.example.good' > "$apps/Good.app/Contents/Info.plist" +debug_log() { :; } +if scan_installed_apps "$HOME/installed.txt"; then + echo "SCAN_SUCCEEDED_UNEXPECTEDLY"; exit 1 +fi +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } } diff --git a/Resources/mole/tests/clean_browser_versions.bats b/Resources/mole/tests/clean_browser_versions.bats index b90350a..e83984c 100644 --- a/Resources/mole/tests/clean_browser_versions.bats +++ b/Resources/mole/tests/clean_browser_versions.bats @@ -10,36 +10,196 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } @test "clean_chrome_old_versions skips when Chrome is running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" +versions_dir="$HOME/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions" + mkdir -p "$versions_dir/128.0.0.0" "$versions_dir/129.0.0.0" + ln -s "129.0.0.0" "$versions_dir/Current" +touch "$versions_dir/128.0.0.0/sentinel" +# Adding the sentinel updates the old directory mtime. Pin both directories so +# a wall-clock second boundary cannot make the old version look like a newer +# staged update under parallel test load. +touch -t 202401010000 "$versions_dir/128.0.0.0" +touch -t 202402010000 "$versions_dir/129.0.0.0" +export MOLE_CHROME_APP_PATHS="$HOME/Applications/Google Chrome.app" + # Mock pgrep to simulate Chrome running pgrep() { return 0; } export -f pgrep +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +defer_cleanup_family() { echo "DEFER:$1"; } + +clean_chrome_old_versions +[[ -f "$versions_dir/128.0.0.0/sentinel" ]] || exit 1 +rm -rf "$HOME/Applications/Google Chrome.app" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DEFER:Chrome"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 + [[ "$output" != *"Chrome old versions · skipped"* ]] +} + +@test "clean_chrome_old_versions does not defer protected-only versions" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +versions_dir="$HOME/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions" +old="$versions_dir/128.0.0.0" +mkdir -p "$old" "$versions_dir/129.0.0.0" +ln -s "129.0.0.0" "$versions_dir/Current" +export MOLE_CHROME_APP_PATHS="$HOME/Applications/Google Chrome.app" +should_protect_path() { [[ "$1" == "$old" ]]; } +is_path_whitelisted() { return 1; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +clean_chrome_old_versions +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_chrome_old_versions skips when only Chrome helpers are running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +versions_dir="$HOME/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions" + mkdir -p "$versions_dir/128.0.0.0" "$versions_dir/129.0.0.0" + ln -s "129.0.0.0" "$versions_dir/Current" +touch "$versions_dir/128.0.0.0/sentinel" +touch -t 202401010000 "$versions_dir/128.0.0.0" +touch -t 202402010000 "$versions_dir/129.0.0.0" +export MOLE_CHROME_APP_PATHS="$HOME/Applications/Google Chrome.app" + +pgrep() { + case "$*" in + *"Google Chrome Helper"*) return 0 ;; + *) return 1 ;; + esac +} +export -f pgrep +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +defer_cleanup_family() { echo "DEFER:$1"; } clean_chrome_old_versions +[[ -f "$versions_dir/128.0.0.0/sentinel" ]] || exit 1 +rm -rf "$HOME/Applications/Google Chrome.app" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Google Chrome running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] + [[ "$output" == *"DEFER:Chrome"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 + [[ "$output" != *"Chrome old versions · skipped"* ]] +} + +@test "clean_chrome_old_versions fails closed when the process probe errors" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +versions_dir="$HOME/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions" +mkdir -p "$versions_dir/128.0.0.0" "$versions_dir/129.0.0.0" +ln -s "129.0.0.0" "$versions_dir/Current" +touch "$versions_dir/128.0.0.0/sentinel" +touch -t 202401010000 "$versions_dir/128.0.0.0" +touch -t 202402010000 "$versions_dir/129.0.0.0" +export MOLE_CHROME_APP_PATHS="$HOME/Applications/Google Chrome.app" +pgrep() { return 2; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } + +clean_chrome_old_versions +[[ -f "$versions_dir/128.0.0.0/sentinel" ]] || exit 1 +rm -rf "$HOME/Applications/Google Chrome.app" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Chrome old versions · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_chrome_old_versions counts only successful removals" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +versions_dir="$HOME/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions" +rm -rf "$HOME/Applications/Google Chrome.app" +mkdir -p "$versions_dir/127.0.0.0" "$versions_dir/128.0.0.0" "$versions_dir/130.0.0.0" +touch -t 202601010000 "$versions_dir/127.0.0.0" +touch -t 202602010000 "$versions_dir/128.0.0.0" +touch -t 202603010000 "$versions_dir/130.0.0.0" +ln -s "130.0.0.0" "$versions_dir/Current" +export MOLE_CHROME_APP_PATHS="$HOME/Applications/Google Chrome.app" + +pgrep() { return 1; } +has_sudo_session() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo 10; } +bytes_to_human() { echo "$1 bytes"; } +note_activity() { :; } +debug_log() { :; } + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +safe_remove() { return 1; } +clean_chrome_old_versions +echo "ALL_FAILED:$files_cleaned:$total_size_cleaned" + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +safe_remove() { [[ "$1" == *"127.0.0.0" ]]; } +clean_chrome_old_versions +echo "PARTIAL:$files_cleaned:$total_size_cleaned" +rm -rf "$HOME/Applications/Google Chrome.app" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"ALL_FAILED:0:0"* ]] || return 1 + [[ "$output" == *"PARTIAL:1:10"* ]] || return 1 + [[ "$output" == *"Chrome old versions"*"1 dirs"* ]] } @test "clean_chrome_old_versions removes old versions but keeps current" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -52,6 +212,7 @@ export -f pgrep CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" # Create Current symlink pointing to 130.0.0.0 ln -s "130.0.0.0" "$VERSIONS_DIR/Current" @@ -75,13 +236,13 @@ echo "Cleaned: $files_cleaned items" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Chrome old versions"* ]] - [[ "$output" == *"dry"* ]] + [[ "$output" == *"Chrome old versions"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 [[ "$output" == *"Cleaned: 2 items"* ]] } @test "clean_chrome_old_versions respects whitelist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -94,6 +255,7 @@ export -f pgrep CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" # Create Current symlink pointing to 130.0.0.0 ln -s "130.0.0.0" "$VERSIONS_DIR/Current" @@ -123,8 +285,10 @@ EOF [[ "$output" == *"Cleaned: 1 items"* ]] } -@test "clean_edge_updater_old_versions keeps latest version" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' +@test "clean_chrome_old_versions keeps newest version even when Current points older" { + rm -rf "$HOME/Applications/Google Chrome.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -132,6 +296,138 @@ source "$PROJECT_ROOT/lib/clean/user.sh" pgrep() { return 1; } export -f pgrep +CHROME_APP="$HOME/Applications/Google Chrome.app" +VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" +mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" +touch -t 202601010000 "$VERSIONS_DIR/128.0.0.0" +touch -t 202602010000 "$VERSIONS_DIR/129.0.0.0" +touch -t 202603010000 "$VERSIONS_DIR/130.0.0.0" +ln -s "129.0.0.0" "$VERSIONS_DIR/Current" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_chrome_old_versions +echo "Cleaned: $files_cleaned items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cleaned: 1 items"* ]] +} + +@test "clean_edge_old_versions keeps newest version even when Current points older" { + rm -rf "$HOME/Applications/Microsoft Edge.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +export -f pgrep + +EDGE_APP="$HOME/Applications/Microsoft Edge.app" +VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" +mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_EDGE_APP_PATHS="$EDGE_APP" +touch -t 202601010000 "$VERSIONS_DIR/128.0.0.0" +touch -t 202602010000 "$VERSIONS_DIR/129.0.0.0" +touch -t 202603010000 "$VERSIONS_DIR/130.0.0.0" +ln -s "129.0.0.0" "$VERSIONS_DIR/Current" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_edge_old_versions +echo "Cleaned: $files_cleaned items" +EOF + + # HOME is shared across tests in this file; leave a clean slate for the + # later "removes old versions" test that reuses this app path. + rm -rf "$HOME/Applications/Microsoft Edge.app" + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + # 130 is a freshly staged update newer than Current (129); only 128 goes. + [[ "$output" == *"Cleaned: 1 items"* ]] || { + echo "$output" + return 1 + } +} + +@test "clean_brave_old_versions keeps newest version even when Current points older" { + rm -rf "$HOME/Applications/Brave Browser.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +export -f pgrep + +BRAVE_APP="$HOME/Applications/Brave Browser.app" +VERSIONS_DIR="$BRAVE_APP/Contents/Frameworks/Brave Browser Framework.framework/Versions" +mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_BRAVE_APP_PATHS="$BRAVE_APP" +touch -t 202601010000 "$VERSIONS_DIR/128.0.0.0" +touch -t 202602010000 "$VERSIONS_DIR/129.0.0.0" +touch -t 202603010000 "$VERSIONS_DIR/130.0.0.0" +ln -s "129.0.0.0" "$VERSIONS_DIR/Current" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_brave_old_versions +echo "Cleaned: $files_cleaned items" +EOF + + rm -rf "$HOME/Applications/Brave Browser.app" + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Cleaned: 1 items"* ]] || { + echo "$output" + return 1 + } +} + +@test "clean_edge_updater_old_versions keeps latest version" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +# No readable installed-Edge version: pins the conservative keep-latest +# fallback even on machines where a real Edge is installed. +plutil() { return 1; } +export -f pgrep plutil + UPDATER_DIR="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" mkdir -p "$UPDATER_DIR"/{117.0.2045.60,118.0.2088.46,119.0.2108.9} @@ -151,16 +447,221 @@ echo "Cleaned: $files_cleaned items" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Edge updater old versions"* ]] - [[ "$output" == *"dry"* ]] + [[ "$output" == *"Edge updater old versions"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 [[ "$output" == *"Cleaned: 2 items"* ]] } +@test "clean_chrome_old_versions dry run rechecks the browser after sizing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +versions_dir="$HOME/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions" +mkdir -p "$versions_dir/128.0.0.0" "$versions_dir/129.0.0.0" +ln -s "129.0.0.0" "$versions_dir/Current" +export MOLE_CHROME_APP_PATHS="$HOME/Applications/Google Chrome.app" +pgrep() { [[ -e "$HOME/chrome-started" ]]; } +get_path_size_kb() { touch "$HOME/chrome-started"; echo 10; } +record_dry_run_cleanup_target() { echo "UNEXPECTED_RECORD:$1"; } +defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } +clean_chrome_old_versions +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Chrome"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_RECORD"* ]] +} + +@test "clean_edge_updater_old_versions does not defer protected-only versions" { + run env HOME="$HOME/edge-protected-only" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" +old="$updater_dir/117.0" +mkdir -p "$old" "$updater_dir/118.0" +plutil() { return 1; } +should_protect_path() { [[ "$1" == "$old" ]]; } +is_path_whitelisted() { return 1; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +clean_edge_updater_old_versions +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_edge_updater_old_versions counts only successful removals" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" +edge_app="$HOME/Applications/Microsoft Edge.app" +rm -rf "$updater_dir" "$edge_app" +mkdir -p "$updater_dir/117.0" "$updater_dir/118.0" "$edge_app/Contents" +touch "$edge_app/Contents/Info.plist" + +pgrep() { return 1; } +plutil() { echo "120.0"; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo 10; } +bytes_to_human() { echo "$1 bytes"; } +note_activity() { :; } +debug_log() { :; } + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +safe_remove() { return 1; } +clean_edge_updater_old_versions +echo "ALL_FAILED:$files_cleaned:$total_size_cleaned" + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +safe_remove() { [[ "$1" == *"117.0" ]]; } +clean_edge_updater_old_versions +echo "PARTIAL:$files_cleaned:$total_size_cleaned" +rm -rf "$updater_dir" "$edge_app" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"ALL_FAILED:0:0"* ]] || return 1 + [[ "$output" == *"PARTIAL:1:10"* ]] || return 1 + [[ "$output" == *"Edge updater old versions"*"1 dirs"* ]] +} + +# Issue #1216: after Edge updates itself, the updater staging dir can hold a +# single payload that is OLDER than the installed Edge. The keep-latest rule +# kept that stale copy forever because it was the only directory. +@test "clean_edge_updater_old_versions removes a lone payload older than installed Edge (#1216)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +plutil() { echo "150.0.4078.65"; } +export -f pgrep plutil +mkdir -p "$HOME/Applications/Microsoft Edge.app/Contents" +touch "$HOME/Applications/Microsoft Edge.app/Contents/Info.plist" + +UPDATER_DIR="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" +rm -rf "$UPDATER_DIR" +mkdir -p "$UPDATER_DIR/149.0.4022.52" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_edge_updater_old_versions +echo "Cleaned: $files_cleaned items" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Edge updater old versions"* ]] || return 1 + [[ "$output" == *"Cleaned: 1 items"* ]] || return 1 +} + +@test "clean_edge_updater_old_versions keeps payloads not older than installed Edge" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +plutil() { echo "150.0.4078.65"; } +export -f pgrep plutil +mkdir -p "$HOME/Applications/Microsoft Edge.app/Contents" +touch "$HOME/Applications/Microsoft Edge.app/Contents/Info.plist" + +UPDATER_DIR="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" +rm -rf "$UPDATER_DIR" +# One stale, one equal to installed, one staged-newer pending update. +mkdir -p "$UPDATER_DIR"/{149.0.4022.52,150.0.4078.65,151.0.5000.1} + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_edge_updater_old_versions +echo "Cleaned: $files_cleaned items" +[[ -d "$UPDATER_DIR/150.0.4078.65" ]] && echo "KEPT-EQUAL" +[[ -d "$UPDATER_DIR/151.0.5000.1" ]] && echo "KEPT-NEWER" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Cleaned: 1 items"* ]] || return 1 + [[ "$output" == *"KEPT-EQUAL"* ]] || return 1 + [[ "$output" == *"KEPT-NEWER"* ]] || return 1 +} + +@test "clean_edge_updater_old_versions keeps a lone payload when installed version is unknown" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +plutil() { return 1; } +export -f pgrep plutil + +UPDATER_DIR="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" +rm -rf "$UPDATER_DIR" +mkdir -p "$UPDATER_DIR/149.0.4022.52" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_edge_updater_old_versions +echo "Cleaned: $files_cleaned items" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Cleaned: 0 items"* ]] || return 1 +} + @test "clean_chrome_old_versions DRY_RUN mode does not delete files" { # Create test directory CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,130.0.0.0} + export MOLE_CHROME_APP_PATHS="$CHROME_APP" # Remove Current if it exists as a directory, then create symlink rm -rf "$VERSIONS_DIR/Current" @@ -169,7 +670,7 @@ EOF # Create a marker file in old version touch "$VERSIONS_DIR/128.0.0.0/marker.txt" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -189,7 +690,7 @@ clean_chrome_old_versions EOF [ "$status" -eq 0 ] - [[ "$output" == *"dry"* ]] + [[ "$output" == *"dry"* ]] || return 1 # Verify marker file still exists (not deleted in dry run) [ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ] } @@ -198,7 +699,7 @@ EOF # Use a fresh temp directory for this test TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test5.XXXXXX")" - run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -219,6 +720,7 @@ total_items=0 CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" # No Current symlink created clean_chrome_old_versions @@ -230,21 +732,34 @@ EOF } @test "clean_edge_old_versions skips when Edge is running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" +versions_dir="$HOME/Applications/Microsoft Edge.app/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$versions_dir/120.0.0.0" "$versions_dir/121.0.0.0" + ln -s "121.0.0.0" "$versions_dir/Current" +touch "$versions_dir/120.0.0.0/sentinel" +touch -t 202401010000 "$versions_dir/120.0.0.0" +touch -t 202402010000 "$versions_dir/121.0.0.0" +export MOLE_EDGE_APP_PATHS="$HOME/Applications/Microsoft Edge.app" + # Mock pgrep to simulate Edge running pgrep() { return 0; } export -f pgrep +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +defer_cleanup_family() { echo "DEFER:$1"; } clean_edge_old_versions +[[ -f "$versions_dir/120.0.0.0/sentinel" ]] || exit 1 +rm -rf "$HOME/Applications/Microsoft Edge.app" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Edge running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] + [[ "$output" == *"DEFER:Edge"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 + [[ "$output" != *"Edge old versions · skipped"* ]] } @test "clean_edge_old_versions removes old versions but keeps current" { @@ -255,7 +770,7 @@ EOF ln -s "122.0.0.0" "$VERSIONS_DIR/Current" run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true \ - MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' + MOLE_EDGE_APP_PATHS="$EDGE_APP" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -277,8 +792,8 @@ echo "Cleaned: $files_cleaned items" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Edge old versions"* ]] - [[ "$output" == *"dry"* ]] + [[ "$output" == *"Edge old versions"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 [[ "$output" == *"Cleaned: 2 items"* ]] } @@ -293,7 +808,7 @@ EOF ln -s "122.0.0.0" "$VERSIONS_DIR/Current" run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" \ - MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' + MOLE_EDGE_APP_PATHS="$EDGE_APP" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -317,3 +832,33 @@ EOF # Should exit gracefully with no cleanup output [[ "$output" != *"Edge old versions"* ]] } + +@test "browser cleanup stops after an old-version size timeout" { + local isolated_home="$HOME/browser-aggregate-timeout" + mkdir -p "$isolated_home" + + run env HOME="$isolated_home" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_CURRENT_COMMAND=clean /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { :; } +clean_service_worker_cache() { :; } +pgrep() { return 1; } +clean_chrome_old_versions() { + _mole_record_clean_cancellation 124 + return 124 +} +clean_edge_old_versions() { echo "UNEXPECTED_EDGE_CLEAN"; } +set +e +clean_browsers +rc=$? +set -e +printf 'BROWSER_RC:%s CANCEL:%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"BROWSER_RC:124 CANCEL:124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_EDGE_CLEAN"* ]] +} diff --git a/Resources/mole/tests/clean_cached_device_firmware.bats b/Resources/mole/tests/clean_cached_device_firmware.bats new file mode 100644 index 0000000..46ea895 --- /dev/null +++ b/Resources/mole/tests/clean_cached_device_firmware.bats @@ -0,0 +1,261 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-device-firmware.XXXXXX")" + export HOME + + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + + mkdir -p "$HOME" +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + rm -rf "$HOME/Library" +} + +@test "clean_cached_device_firmware is a no-op when no .ipsw files exist" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Items: $total_items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Items: 0"* ]] || return 1 + [[ "$output" != *"Cached device firmware"* ]] +} + +@test "clean_cached_device_firmware reports .ipsw files in dry-run from iTunes dirs" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +IPAD_DIR="$HOME/Library/iTunes/iPad Software Updates" +mkdir -p "$IPHONE_DIR" "$IPAD_DIR" +touch "$IPHONE_DIR/iPhone17,1_18.0_22A000_Restore.ipsw" +touch "$IPHONE_DIR/iPhone15,2_17.5_21F000_Restore.ipsw" +touch "$IPAD_DIR/iPad14,1_18.0_22A000_Restore.ipsw" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "5242880"; } # 5GB +bytes_to_human() { echo "5.0G"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Files: $files_cleaned Items: $total_items" + +# Verify files still exist (dry-run must not delete) +[[ -f "$IPHONE_DIR/iPhone17,1_18.0_22A000_Restore.ipsw" ]] || exit 11 +[[ -f "$IPAD_DIR/iPad14,1_18.0_22A000_Restore.ipsw" ]] || exit 12 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] || return 1 + [[ "$output" == *"3 files"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"Files: 3 Items: 1"* ]] +} + +@test "clean_cached_device_firmware finds .ipsw in Apple Configurator 2 nested cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +CONFIG_DIR="$HOME/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Firmware/iPhone" +mkdir -p "$CONFIG_DIR" +touch "$CONFIG_DIR/nested_firmware.ipsw" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "6291456"; } +bytes_to_human() { echo "6G"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Files: $files_cleaned" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] || return 1 + [[ "$output" == *"Files: 1"* ]] +} + +@test "clean_cached_device_firmware removes .ipsw files when not dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +IPSW="$IPHONE_DIR/test_firmware.ipsw" +touch "$IPSW" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +safe_remove() { rm -f "$1"; return 0; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity safe_remove + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware + +if [[ -f "$IPSW" ]]; then + echo "FAIL: ipsw still present" + exit 10 +fi +echo "DELETED" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] || return 1 + [[ "$output" == *"DELETED"* ]] +} + +@test "clean_cached_device_firmware dry-run leaves real filesystem untouched (no safe_remove mock)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +IPSW="$IPHONE_DIR/preserve.ipsw" +touch "$IPSW" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +# Do NOT mock safe_remove: real function must honor DRY_RUN +clean_cached_device_firmware + +if [[ ! -f "$IPSW" ]]; then + echo "FAIL: dry-run deleted the file" + exit 20 +fi +echo "PRESERVED" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"PRESERVED"* ]] +} + +@test "clean_cached_device_firmware respects whitelist" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +touch "$IPHONE_DIR/keep.ipsw" + +is_path_whitelisted() { return 0; } +get_path_size_kb() { echo "5242880"; } +bytes_to_human() { echo "5G"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Files: $files_cleaned" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Files: 0"* ]] || return 1 + [[ "$output" != *"Cached device firmware"* ]] +} + +@test "clean_cached_device_firmware does not report success when deletion fails" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +IPSW="$IPHONE_DIR/fail_firmware.ipsw" +touch "$IPSW" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +safe_remove() { return 1; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity safe_remove + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Files: $files_cleaned Items: $total_items Size: $total_size_cleaned" + +if [[ ! -f "$IPSW" ]]; then + echo "FAIL: file deleted" + exit 30 +fi +echo "PRESENT" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Files: 0 Items: 0 Size: 0"* ]] || return 1 + [[ "$output" == *"PRESENT"* ]] || return 1 + [[ "$output" != *"Cached device firmware"* ]] +} diff --git a/Resources/mole/tests/clean_core.bats b/Resources/mole/tests/clean_core.bats index 836c15e..750e986 100644 --- a/Resources/mole/tests/clean_core.bats +++ b/Resources/mole/tests/clean_core.bats @@ -10,22 +10,47 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-home.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + + # Two tests below run the real pipeline (MOLE_TEST_MODE=0), which otherwise + # scans the host: du -sk over every mounted CoreSimulator runtime volume + # plus a full lsregister -dump. That cost ~32s per test and scaled with + # whatever Xcode and LaunchServices happened to hold, which made this file + # the critical path of the whole CI suite. Neither scan feeds an assertion + # here, so point both at nothing. The paths stay absent: setup() wipes + # $HOME between tests. + MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$HOME/absent-sim-runtime-volumes" + MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$HOME/absent-sim-runtime-cryptex" + MOLE_LSREGISTER_PATH="" + export MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT + export MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT + export MOLE_LSREGISTER_PATH + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" rm -rf "${HOME:?}"/* rm -rf "$HOME/Library" "$HOME/.config" mkdir -p "$HOME/Library/Caches" "$HOME/.config/mole" - unset TEST_MOCK_BIN + unset TEST_MOCK_BIN MOCK_TOOLCHAIN_BIN } set_mock_sudo_cached() { @@ -43,7 +68,8 @@ MOCK } set_mock_sudo_uncached() { - TEST_MOCK_BIN="$HOME/bin" + local mock_home="${1:-$HOME}" + TEST_MOCK_BIN="$mock_home/bin" mkdir -p "$TEST_MOCK_BIN" cat > "$TEST_MOCK_BIN/sudo" << 'MOCK' #!/bin/bash @@ -63,41 +89,993 @@ run_clean_dry_run() { "$PROJECT_ROOT/mole" clean --dry-run } +# Stub the two host toolchains the real pipeline shells out to, so what these +# tests measure does not depend on the machine's Homebrew or Xcode. brew is +# required to be mocked by project policy: no verification run may reach a real +# package manager. xcrun follows for the same reason, and returning non-zero is +# the CLT-only shape clean already handles. Neither tool feeds an assertion. +# +# These stubs are correctness, not speed. They were first added expecting a cold +# runner's brew and CoreSimulator startup to be the bulk of the ~30s each of +# these tests costs on CI; a timed dry-run on a runner disproved that. The whole +# pipeline takes ~10s there with these seams applied, and the rest is contention +# from running the suite at more jobs than the runner has cores. +set_mock_host_toolchains() { + local mock_home="${1:-$HOME}" + MOCK_TOOLCHAIN_BIN="$mock_home/toolchain-bin" + mkdir -p "$MOCK_TOOLCHAIN_BIN" + + cat > "$MOCK_TOOLCHAIN_BIN/brew" << 'MOCK' +#!/bin/bash +# Shim: report an empty Homebrew so cleanup has nothing to preview or remove. +case "${1:-}" in + --cache) echo "$HOME/Library/Caches/Homebrew" ;; + --prefix) echo "$HOME/homebrew" ;; +esac +exit 0 +MOCK + + cat > "$MOCK_TOOLCHAIN_BIN/xcrun" << 'MOCK' +#!/bin/bash +# Shim: no simulator toolchain, which is the CLT-only shape clean handles. +exit 1 +MOCK + + chmod +x "$MOCK_TOOLCHAIN_BIN/brew" "$MOCK_TOOLCHAIN_BIN/xcrun" +} + +@test "safe_clean item count reflects cleaned items, not raw target count" { + local base="$HOME/safe_clean_count" + mkdir -p "$base" + printf 'xxxx' > "$base/a" + printf 'xxxx' > "$base/b" + printf 'xxxx' > "$base/keep" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +# One of the three targets is whitelisted, so only two are actually cleaned. +is_path_whitelisted() { [[ "\$1" == "$base/keep" ]]; } +safe_remove() { /bin/rm -rf "\$1"; return 0; } +safe_clean "$base/a" "$base/b" "$base/keep" "Test cache" +EOF + + [ "$status" -eq 0 ] || return 1 + # Two items were removed, so the detail column must say "2 items", not "3". + # Every assertion ends with || return 1: bare [[ ]] failures mid-test can be + # swallowed and let the test pass vacuously (same shape as #886). + [[ "$output" == *"2 items"* ]] || return 1 + [[ "$output" != *"3 items"* ]] || return 1 + [[ ! -e "$base/a" ]] || return 1 + [[ ! -e "$base/b" ]] || return 1 + [[ -e "$base/keep" ]] || return 1 + + rm -rf "$base" +} + +@test "safe_clean_guarded rechecks after parallel size probes before deletion" { + local base="$HOME/safe_clean_guarded" + mkdir -p "$base/a" "$base/b" "$base/c" "$base/d" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +get_cleanup_path_size_kb() { touch "$base/process-started"; echo 1; } +delete_guard() { [[ ! -e "$base/process-started" ]]; } +safe_remove() { echo "UNEXPECTED_REMOVE:\$1"; /bin/rm -rf "\$1"; } + +rc=0 +safe_clean_guarded delete_guard \ + "$base/a" "$base/b" "$base/c" "$base/d" \ + "Guarded cache" || rc=\$? +[[ \$rc -eq 75 ]] || { echo "WRONG_RC:\$rc"; exit 1; } +for path in "$base/a" "$base/b" "$base/c" "$base/d"; do + [[ -d "\$path" ]] || { echo "WRONG: removed \$path"; exit 1; } +done +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "safe_clean_guarded dry-run consults the guard before registering preview targets" { + local base="$HOME/safe_clean_guarded_dry" + mkdir -p "$base/a" "$base/b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=true +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +delete_guard() { return 1; } +register_dry_run_cleanup_target() { echo "UNEXPECTED_REGISTER:\$1"; } +safe_remove() { echo "UNEXPECTED_REMOVE:\$1"; } + +# A guard that refuses must stop the preview the same way it stops the real +# run, before any dry-run target is registered into the summary ledger. +rc=0 +safe_clean_guarded delete_guard \ + "$base/a" "$base/b" \ + "Guarded cache" || rc=\$? +[[ \$rc -eq 75 ]] || { echo "WRONG_RC:\$rc"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_REGISTER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 + [[ "$output" != *"would clean"* ]] +} + +@test "safe_clean_guarded dry-run stops at the first target-specific denial" { + local base="$HOME/safe_clean_guarded_dry_targets" + mkdir -p "$base/a" "$base/b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=true +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +get_cleanup_path_size_kb() { echo 1; } +delete_guard() { + echo "GUARD:\$1" + [[ "\$1" == "$base/a" ]] +} +register_dry_run_cleanup_target() { echo "REGISTER:\$1"; return 0; } + +rc=0 +safe_clean_guarded delete_guard \ + "$base/a" "$base/b" \ + "Target guard preview" || rc=\$? +printf 'RC=%s FILES=%s\n' "\$rc" "\$files_cleaned" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"GUARD:$base/a"* ]] || return 1 + [[ "$output" == *"GUARD:$base/b"* ]] || return 1 + [[ "$output" == *"REGISTER:$base/a"* ]] || return 1 + [[ "$output" != *"REGISTER:$base/b"* ]] || return 1 + [[ "$output" == *"RC=75 FILES=1"* ]] || return 1 + [[ "$output" != *"2 items"* ]] +} + +@test "safe_clean_guarded filters ineligible targets before the dry-run guard" { + local base="$HOME/safe_clean_guarded_filtered" + mkdir -p "$base/protected" "$base/whitelisted" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=true +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +should_protect_path() { [[ "\$1" == "$base/protected" ]]; } +is_path_whitelisted() { [[ "\$1" == "$base/whitelisted" ]]; } +holds_compiled_model_cache() { return 1; } +delete_guard() { echo "UNEXPECTED_GUARD:\$1"; return 1; } +register_dry_run_cleanup_target() { echo "UNEXPECTED_REGISTER:\$1"; } +safe_remove() { echo "UNEXPECTED_REMOVE:\$1"; } + +rc=0 +safe_clean_guarded delete_guard \ + "$base/missing" "$base/protected" "$base/whitelisted" \ + "Filtered guarded cache" || rc=\$? +[[ \$rc -eq 0 ]] || { echo "WRONG_RC:\$rc"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_GUARD"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REGISTER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "safe_clean propagates an interrupted parallel size worker before deletion" { + local base="$HOME/safe_clean_parallel_interrupt" + mkdir -p "$base/a" "$base/b" "$base/c" "$base/d" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +get_cleanup_path_size_kb() { + [[ "\$1" == "$base/b" ]] && return 130 + echo 1 +} +safe_remove() { echo "UNEXPECTED_REMOVE:\$1"; /bin/rm -rf "\$1"; } + +rc=0 +safe_clean "$base/a" "$base/b" "$base/c" "$base/d" \ + "Interrupted size batch" || rc=\$? +[[ \$rc -eq 130 ]] || { echo "WRONG_RC:\$rc"; exit 1; } +for path in "$base/a" "$base/b" "$base/c" "$base/d"; do + [[ -d "\$path" ]] || { echo "WRONG: removed \$path"; exit 1; } +done +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "safe_clean stops a multi-target batch when deletion is interrupted" { + local base="$HOME/safe_clean_delete_interrupt" + mkdir -p "$base/a" "$base/b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +get_cleanup_path_size_kb() { echo 1; } +safe_remove() { + echo "REMOVE:\$1" + [[ "\$1" == "$base/a" ]] && return 130 + /bin/rm -rf "\$1" +} + +rc=0 +safe_clean "$base/a" "$base/b" "Interrupted delete batch" || rc=\$? +[[ \$rc -eq 130 ]] || { echo "WRONG_RC:\$rc"; exit 1; } +[[ -d "$base/a" && -d "$base/b" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"REMOVE:$base/a"* ]] || return 1 + [[ "$output" != *"REMOVE:$base/b"* ]] +} + +@test "safe_clean keeps cancellation sticky across best-effort callers" { + local base="$HOME/safe_clean_sticky_interrupt" + mkdir -p "$base/a" "$base/b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc <=128)" { + local base="$HOME/safe_clean_removal_interrupt" + mkdir -p "$base/a" "$base/b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 \ + /bin/bash --noprofile --norc << EOF +set -euo pipefail +source "\$PROJECT_ROOT/lib/core/common.sh" +source "\$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=false +MOLE_CURRENT_COMMAND=clean +MOLE_CLEAN_CANCEL_STATUS=0 +export MO_NO_OPLOG=1 +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +unset MOLE_CLEAN_SIZING_TIMEOUTS MOLE_CLEAN_REMOVAL_TIMEOUTS +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +get_cleanup_path_size_kb() { echo 1; } +run_with_timeout() { + [[ "\${2:-}" == "rm" ]] && return 130 + "\$@" +} + +rc=0 +safe_clean "$base/a" "$base/b" "Interrupted removal batch" || rc=\$? +printf 'RC=%s CANCEL=%s REMOVAL=%s\n' "\$rc" "\${MOLE_CLEAN_CANCEL_STATUS:-0}" "\${MOLE_CLEAN_REMOVAL_TIMEOUTS:-0}" +[[ -d "$base/a" && -d "$base/b" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=130 CANCEL=130 REMOVAL=0"* ]] || return 1 +} + @test "mo clean --dry-run skips system cleanup in non-interactive mode" { set_mock_sudo_uncached run_clean_dry_run [ "$status" -eq 0 ] - [[ "$output" == *"Dry Run Mode"* ]] + [[ "$output" == *"Dry Run Mode"* ]] || return 1 [[ "$output" == *"sudo -v && mo clean --dry-run"* ]] [[ "$output" != *"system preview included"* ]] } -@test "mo clean --dry-run includes system preview when sudo is cached" { +@test "MOLE_DRY_RUN enables the complete clean preview without deleting Trash" { + mkdir -p "$HOME/.Trash" + printf 'keep\n' > "$HOME/.Trash/env-dry-run-sentinel" + + run env HOME="$HOME" MOLE_TEST_MODE=1 MOLE_DRY_RUN=1 \ + "$PROJECT_ROOT/mole" clean + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Dry Run Mode"* ]] || return 1 + [[ -f "$HOME/.Trash/env-dry-run-sentinel" ]] +} + +@test "mo clean --dry-run does not probe sudo in test mode" { set_mock_sudo_cached + cat > "$TEST_MOCK_BIN/sudo" << 'MOCK' +#!/bin/bash +echo "sudo should not be called" >&2 +exit 99 +MOCK + chmod +x "$TEST_MOCK_BIN/sudo" + run_clean_dry_run [ "$status" -eq 0 ] - [[ "$output" == *"system preview included"* ]] + [[ "$output" == *"sudo -v && mo clean --dry-run"* ]] + [[ "$output" != *"sudo should not be called"* ]] +} + +@test "mo clean rejects removed cleanup selection flags" { + local removed_flag + for removed_flag in "--select" "--categories" "--exclude"; do + run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean "$removed_flag" + [ "$status" -eq 1 ] + [[ "$output" == *"was removed in this release"* ]] || return 1 + [[ "$output" == *"mo clean --dry-run"* ]] || return 1 + done } @test "mo clean --dry-run shows hint when sudo is not cached" { set_mock_sudo_uncached run_clean_dry_run [ "$status" -eq 0 ] - [[ "$output" == *"sudo -v"* ]] + [[ "$output" == *"sudo -v"* ]] || return 1 [[ "$output" == *"full preview"* ]] } +@test "mo clean adopts cached sudo before system cleanup (#1084)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc << 'SCRIPT' +set -euo pipefail +TRACE="$HOME/sudo-adopt.log" +> "$TRACE" + +source "$PROJECT_ROOT/bin/clean.sh" + +DRY_RUN=false +EXTERNAL_VOLUME_TARGET="" + +sudo() { + printf 'sudo %s\n' "$*" >> "$TRACE" + [[ "${1:-}" == "-n" && "${2:-}" == "-v" ]] +} +_start_sudo_keepalive() { + printf 'keepalive\n' >> "$TRACE" + echo "keepalive-pid" +} +_stop_sudo_keepalive() { :; } + +start_cleanup +cat "$TRACE" +printf 'SYSTEM_CLEAN=%s\n' "$SYSTEM_CLEAN" +printf 'MOLE_SUDO_ESTABLISHED=%s\n' "$MOLE_SUDO_ESTABLISHED" +printf 'MOLE_SUDO_KEEPALIVE_PID=%s\n' "$MOLE_SUDO_KEEPALIVE_PID" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"sudo -n -v"* ]] || return 1 + [[ "$output" == *"keepalive"* ]] || return 1 + [[ "$output" == *"SYSTEM_CLEAN=true"* ]] || return 1 + [[ "$output" == *"MOLE_SUDO_ESTABLISHED=true"* ]] || return 1 + [[ "$output" == *"MOLE_SUDO_KEEPALIVE_PID=keepalive-pid"* ]] +} + +@test "clean main restores the terminal and exits with an interrupted cleanup status" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +start_cleanup() { :; } +hide_cursor() { printf 'HIDE\n'; } +perform_cleanup() { return 130; } +show_cursor() { printf 'SHOW\n'; } + +main +SCRIPT + + [ "$status" -eq 130 ] + [[ "$output" == *"HIDE"* ]] || return 1 + [[ "$output" == *"SHOW"* ]] +} + +@test "mo clean sudo prompt preserves a directly typed password (#1059)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +ensure_sudo_session() { + echo "ENSURE_PLAIN" + return 0 +} +ensure_sudo_session_with_password() { + echo "ENSURE_PASSWORD=$1" + [[ "$1" == "secret" ]] +} +drain_pending_input() { :; } +# A user who expects a password prompt may start typing immediately. The first +# printable key and the rest of the line must reach authentication together. +read_key() { + echo "CHAR:s" +} +read_clean_sudo_password_remainder() { + printf -v "$1" '%s' "ecret" +} + +prompt_for_system_clean +printf '\nSYSTEM_CLEAN=%s\n' "$SYSTEM_CLEAN" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"continue"* ]] || return 1 + [[ "$output" != *"Enter"*"password"* ]] || return 1 + [[ "$output" == *"ENSURE_PASSWORD=secret"* ]] || return 1 + [[ "$output" != *"ENSURE_PLAIN"* ]] || return 1 + [[ "$output" == *"SYSTEM_CLEAN=true"* ]] || return 1 + [[ "$output" != *"Skipped"* ]] +} + +@test "mo clean sudo prompt still skips on explicit Space (#1059)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +ensure_sudo_session() { + echo "ENSURE_SUDO" + return 0 +} +drain_pending_input() { :; } +read_key() { + echo "SPACE" +} + +prompt_for_system_clean +printf '\nSYSTEM_CLEAN=%s\n' "$SYSTEM_CLEAN" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped"* ]] || return 1 + [[ "$output" != *"ENSURE_SUDO"* ]] || return 1 + [[ "$output" == *"SYSTEM_CLEAN=false"* ]] +} + +@test "cloud and office timeout path uses helper function instead of bash -c" { + run /bin/bash -c "grep -Eq 'run_with_shell_timeout 300 run_cloud_and_office_cleanup' '$PROJECT_ROOT/bin/clean.sh'" + [ "$status" -eq 0 ] + + run /bin/bash -c "! grep -Eq 'run_with_timeout 300[[:space:]]+bash[[:space:]]+-c' '$PROJECT_ROOT/bin/clean.sh'" + [ "$status" -eq 0 ] +} + +@test "mo clean summary separates tracked cleanup from free space change" { + local mock_bin="$HOME/bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/df" << 'MOCK' +#!/bin/bash +count_file="${MOLE_DF_COUNT:?}" +count=0 +if [[ -f "$count_file" ]]; then + count=$(cat "$count_file") +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$count_file" + +available=73400320 +if [[ "$count" -ge 2 ]]; then + available=74400320 +fi + +printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' +printf '/dev/disk1 200000000 126599680 %s 64%% /\n' "$available" +MOCK + chmod +x "$mock_bin/df" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" MOLE_DF_COUNT="$HOME/df.count" MOLE_TEST_MODE=0 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +DRY_RUN=false +SYSTEM_CLEAN=false +EXTERNAL_VOLUME_TARGET="" +WHITELIST_PATTERNS=() +WHITELIST_WARNINGS=() + +check_tcc_permissions() { :; } +start_section() { :; } +end_section() { :; } +log_operation_session_end() { :; } +run_with_shell_timeout() { shift; "$@"; } + +clean_user_essentials() { + total_size_cleaned=$((total_size_cleaned + 1000000)) + files_cleaned=$((files_cleaned + 1)) + total_items=$((total_items + 1)) +} +clean_finder_metadata() { :; } +clean_app_caches() { :; } +clean_browsers() { :; } +run_cloud_and_office_cleanup() { :; } +clean_developer_tools() { :; } +clean_user_gui_applications() { :; } +clean_virtualization_tools() { :; } +clean_application_support_logs() { :; } +clean_orphaned_app_data() { :; } +clean_orphaned_system_services() { :; } +clean_orphaned_container_stubs() { :; } +show_user_launch_agent_hint_notice() { :; } +clean_apple_silicon_caches() { :; } +clean_cached_device_firmware() { :; } +clean_time_machine_failed_backups() { :; } +check_large_file_candidates() { :; } +show_project_artifact_hint_notice() { :; } + +perform_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Free space: 75.16GB"* ]] || return 1 + [[ "$output" == *"Tracked cleanup:"* ]] || return 1 + [[ "$output" == *"1.02GB"* ]] || return 1 + [[ "$output" == *"Free space: 76.19GB (+1.02GB)"* ]] || return 1 + [[ "$output" != *"Space freed:"* ]] || return 1 + [ "$(cat "$HOME/df.count")" = "2" ] +} + +@test "mo clean --dry-run survives an unwritable TMPDIR" { + local blocked_tmp="$HOME/blocked-tmp" + mkdir -p "$blocked_tmp" + chmod 500 "$blocked_tmp" + + set_mock_sudo_uncached + local test_path="$PATH" + if [[ -n "${TEST_MOCK_BIN:-}" ]]; then + test_path="$TEST_MOCK_BIN:$PATH" + fi + + run env HOME="$HOME" TMPDIR="$blocked_tmp" MOLE_TEST_MODE=1 PATH="$test_path" \ + "$PROJECT_ROOT/mole" clean --dry-run + + [ "$status" -eq 0 ] + [[ "$output" != *"mktemp:"* ]] || return 1 + [[ "$output" != *"Failed to create temporary file"* ]] || return 1 + [ -d "$HOME/.cache/mole/tmp" ] +} + @test "mo clean --dry-run reports user cache without deleting it" { mkdir -p "$HOME/Library/Caches/TestApp" echo "cache data" > "$HOME/Library/Caches/TestApp/cache.tmp" run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run [ "$status" -eq 0 ] - [[ "$output" == *"User app cache"* ]] - [[ "$output" == *"Potential space"* ]] + [[ "$output" == *"User app cache"* ]] || return 1 + [[ "$output" == *"Potential space"* ]] || return 1 [ -f "$HOME/Library/Caches/TestApp/cache.tmp" ] } +@test "mo clean --dry-run reports stale login item without deleting it" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.stale.plist" << 'PLIST' + + + + + Label + com.example.stale + ProgramArguments + + /Applications/Missing.app/Contents/MacOS/Missing + + + +PLIST + + # MOLE_TEST_MODE=1 short-circuits clean into a stub that never reaches + # the App leftovers section, so the report assertion needs the real + # sections to run. Dry-run keeps this side-effect free. + set_mock_host_toolchains + run env HOME="$HOME" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=1 \ + PATH="$MOCK_TOOLCHAIN_BIN:$PATH" "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Stale login item · ~/Library/LaunchAgents/com.example.stale.plist"* ]] || return 1 + [[ "$output" == *"review before removing"* ]] || return 1 + [ -f "$HOME/Library/LaunchAgents/com.example.stale.plist" ] +} + +@test "mo clean --dry-run does not export duplicate targets across sections" { + mkdir -p "$HOME/Library/Application Support/Code/CachedData" + echo "cache" > "$HOME/Library/Application Support/Code/CachedData/data.bin" + + set_mock_host_toolchains + run env HOME="$HOME" MOLE_TEST_MODE=0 \ + PATH="$MOCK_TOOLCHAIN_BIN:$PATH" "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + + run grep -c "Application Support/Code/CachedData" "$HOME/.config/mole/clean-list.txt" + [ "$status" -eq 0 ] + [ "$output" -eq 1 ] +} + +@test "mo clean --dry-run keeps container totals and preview paths consistent (#1282)" { + # This assertion depends on an exact total. Give it a private HOME so + # hidden directories left by earlier cases cannot add cleanup candidates. + local test_home + test_home="$(mktemp -d "${BATS_TEST_TMPDIR}/clean-1282-home.XXXXXX")" + mkdir -p "$test_home/.config/mole" + + local explicit_cache="$test_home/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches" + local generic_cache="$test_home/Library/Containers/com.example.generic/Data/Library/Caches" + local compiled_cache="$generic_cache/com.apple.e5rt.e5bundlecache" + local whitelisted_cache="$test_home/Library/Containers/com.example.whitelisted/Data/Library/Caches" + local protected_cache="$test_home/Library/Containers/com.apple.Safari/Data/Library/Caches" + mkdir -p "$explicit_cache" "$generic_cache" "$compiled_cache" "$whitelisted_cache" "$protected_cache" + dd if=/dev/zero of="$explicit_cache/explicit.bin" bs=1024 count=1024 2> /dev/null + dd if=/dev/zero of="$generic_cache/generic.bin" bs=1024 count=1024 2> /dev/null + dd if=/dev/zero of="$compiled_cache/model.bin" bs=1024 count=1024 2> /dev/null + dd if=/dev/zero of="$whitelisted_cache/keep.bin" bs=1024 count=1024 2> /dev/null + dd if=/dev/zero of="$protected_cache/protected.bin" bs=1024 count=1024 2> /dev/null + printf '%s\n' "$whitelisted_cache/keep.bin" > "$test_home/.config/mole/whitelist" + set_mock_sudo_uncached "$test_home" + set_mock_host_toolchains "$test_home" + run env HOME="$test_home" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=1 \ + PATH="$TEST_MOCK_BIN:$MOCK_TOOLCHAIN_BIN:$PATH" \ + "$PROJECT_ROOT/mole" clean --dry-run + + [ "$status" -eq 0 ] || return 1 + local preview="$test_home/.config/mole/clean-list.txt" + [[ -f "$preview" ]] || return 1 + [[ "$(grep -cF "$explicit_cache/explicit.bin" "$preview")" -eq 1 ]] || return 1 + [[ "$(grep -cF "$generic_cache/generic.bin" "$preview")" -eq 1 ]] || return 1 + [[ "$(grep -cF "$compiled_cache/model.bin" "$preview")" -eq 0 ]] || return 1 + [[ "$(grep -cF "$whitelisted_cache/keep.bin" "$preview")" -eq 0 ]] || return 1 + [[ "$(grep -cF "$protected_cache/protected.bin" "$preview")" -eq 0 ]] || return 1 + local preview_total preview_items preview_categories + preview_total=$(sed -n 's/^# Potential cleanup: //p' "$preview") + preview_items=$(sed -n 's/^# Items: //p' "$preview") + preview_categories=$(sed -n 's/^# Categories: //p' "$preview") + [[ -n "$preview_total" && "$preview_items" =~ ^[0-9]+$ && "$preview_categories" =~ ^[0-9]+$ ]] || return 1 + [[ "$output" != *"Category total"* ]] || return 1 + printf '%s\n' "$output" | grep -F "Potential space:" | + grep -F "Items: $preview_items" | + grep -F "Categories: $preview_categories" | + grep -qF "$preview_total" || return 1 +} + +@test "dry-run ledger keeps shell-timeout child candidates and unknown sizes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +DRY_RUN=true +CLEAN_PREVIEW_FINAL_FILE="$HOME/ledger-preview.txt" +prepare_clean_preview_file +CURRENT_SECTION="Cloud & Office" +candidate="$HOME/Library/Application Support/Cloud/cache.bin" +mkdir -p "$(dirname "$candidate")" +touch "$candidate" + +record_timeout_candidate() { + record_dry_run_cleanup_target "$candidate" 0 1 false +} +run_with_shell_timeout 5 record_timeout_candidate < /dev/null + +render_clean_preview_from_ledger +printf 'PARTIAL=%s\n' "$DRY_RUN_TOTAL_PARTIAL" +cat "$EXPORT_LIST_FILE" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"PARTIAL=true"* ]] || return 1 + [[ "$output" == *"Cloud & Office"* ]] || return 1 + [[ "$output" == *"cache.bin # size unknown"* ]] || return 1 +} + +@test "mo clean --dry-run never previews a live SQLite database family (#1390)" { + local test_home + test_home="$(mktemp -d "${BATS_TEST_TMPDIR}/clean-1390-home.XXXXXX")" + mkdir -p "$test_home/.config/mole" \ + "$test_home/Library/Caches/com.autodesk.AcCoreConsole" + + local db="$test_home/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + printf 'cache-db' > "$db" + printf 'wal' > "$db-wal" + printf 'shm' > "$db-shm" + + # Dry-run must not list the family even though the sweep reaches it: a + # live WAL-mode database stays put, and the preview must agree with the + # real run so the promised totals are the ones actually reclaimable. + # Real-run preservation is pinned at the deletion boundary by + # validate_path_for_deletion (tests/core_safe_functions.bats). + set_mock_host_toolchains + run env HOME="$test_home" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=1 \ + PATH="$MOCK_TOOLCHAIN_BIN:$PATH" "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] || return 1 + [[ "$(grep -cF "Cache.db" "$test_home/.config/mole/clean-list.txt")" -eq 0 ]] || return 1 + [[ -f "$db" && -f "$db-wal" && -f "$db-shm" ]] || return 1 +} + @test "mo clean honors whitelist entries" { mkdir -p "$HOME/Library/Caches/WhitelistedApp" echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp" @@ -108,7 +1086,7 @@ EOF run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run [ "$status" -eq 0 ] - [[ "$output" == *"Protected"* ]] + [[ "$output" == *"Protected"* ]] || return 1 [ -f "$HOME/Library/Caches/WhitelistedApp/data.tmp" ] } @@ -122,7 +1100,7 @@ EOF run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run [ "$status" -eq 0 ] - [[ "$output" == *"Protected"* ]] + [[ "$output" == *"Protected"* ]] || return 1 [ -f "$HOME/Library/Caches/WhitelistedApp/data.tmp" ] } @@ -131,32 +1109,96 @@ EOF echo "dependency" > "$HOME/.m2/repository/org/example/lib.jar" run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - [ -f "$HOME/.m2/repository/org/example/lib.jar" ] - [[ "$output" != *"Maven repository cache"* ]] + [ "$status" -eq 0 ] || return 1 + # The jar must survive, and the dry-run must not offer the Maven repo as a + # cleanup target. The label is "Maven local repository" (maven.sh); the old + # assertion checked a string that never appears, so it passed vacuously. + [ -f "$HOME/.m2/repository/org/example/lib.jar" ] || return 1 + [[ "$output" != *"Maven local repository"* ]] || return 1 } @test "FINDER_METADATA_SENTINEL in whitelist protects .DS_Store files" { mkdir -p "$HOME/Documents" touch "$HOME/Documents/.DS_Store" + # The sentinel's value is FINDER_METADATA; FINDER_METADATA_SENTINEL is the + # variable name and matches nothing in a whitelist file. cat > "$HOME/.config/mole/whitelist" << EOF -FINDER_METADATA_SENTINEL +FINDER_METADATA EOF - # Test whitelist logic directly instead of running full clean - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + # Two halves of the real mechanism: load_whitelist must surface the sentinel so + # bin/clean.sh's scan can see it, and clean_finder_metadata must bail once that + # scan has flipped the flag. The previous version called is_whitelisted, which + # answers "is this exact pattern already in the whitelist" for the management UI + # and never matches a file path, so it asserted nothing. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'SCRIPT' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/manage/whitelist.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" load_whitelist -if is_whitelisted "$HOME/Documents/.DS_Store"; then - echo "protected by whitelist" +sentinel_loaded=false +if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + for entry in "${WHITELIST_PATTERNS[@]}"; do + if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then + sentinel_loaded=true + break + fi + done fi -EOF +echo "sentinel_loaded=$sentinel_loaded" + +PROTECT_FINDER_METADATA=true +clean_ds_store_tree() { echo "CLEANED:$1"; } +clean_finder_metadata +echo "done" +SCRIPT [ "$status" -eq 0 ] - [[ "$output" == *"protected by whitelist"* ]] + [[ "$output" == *"sentinel_loaded=true"* ]] || return 1 + [[ "$output" != *"CLEANED:"* ]] || return 1 + [[ "$output" == *"done"* ]] || return 1 + [ -f "$HOME/Documents/.DS_Store" ] +} + +@test "custom whitelist without FINDER_METADATA still protects .DS_Store via safety merge (#1396)" { + mkdir -p "$HOME/Documents" "$HOME/.config/mole" + touch "$HOME/Documents/.DS_Store" + # Pre-FINDER_METADATA user file: custom path only, no sentinel. + printf '%s\n' "$HOME/.cache/custom-keep/*" > "$HOME/.config/mole/whitelist" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +# Mirror bin/clean.sh load + safety merge + protect flag. +declare -a WHITELIST_PATTERNS=() +while IFS= read -r line; do + [[ -z "$line" || "$line" =~ ^# ]] && continue + WHITELIST_PATTERNS+=("$line") +done < "$HOME/.config/mole/whitelist" +ensure_safety_whitelist_patterns + +PROTECT_FINDER_METADATA=false +for entry in "${WHITELIST_PATTERNS[@]}"; do + if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then + PROTECT_FINDER_METADATA=true + break + fi +done +echo "protect=$PROTECT_FINDER_METADATA" + +clean_ds_store_tree() { echo "CLEANED:$1"; } +clean_finder_metadata +echo "done" +SCRIPT + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"protect=true"* ]] || { echo "$output"; return 1; } + [[ "$output" != *"CLEANED:"* ]] || { echo "$output"; return 1; } + [[ "$output" == *"done"* ]] || return 1 [ -f "$HOME/Documents/.DS_Store" ] } @@ -166,7 +1208,7 @@ EOF touch "$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2" touch "$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl2" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -183,7 +1225,7 @@ EOF @test "_clean_recent_items handles missing shared directory" { rm -rf "$HOME/Library/Application Support/com.apple.sharedfilelist" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -200,7 +1242,7 @@ EOF mkdir -p "$HOME/Library/Mail Downloads" echo "test" > "$HOME/Library/Mail Downloads/small.txt" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -216,11 +1258,15 @@ EOF touch "$HOME/Library/Mail Downloads/old.pdf" touch -t 202301010000 "$HOME/Library/Mail Downloads/old.pdf" - dd if=/dev/zero of="$HOME/Library/Mail Downloads/dummy.dat" bs=1024 count=6000 2>/dev/null + if command -v mkfile > /dev/null 2>&1; then + mkfile -n 6000k "$HOME/Library/Mail Downloads/dummy.dat" + else + truncate -s 6000k "$HOME/Library/Mail Downloads/dummy.dat" + fi [ -f "$HOME/Library/Mail Downloads/old.pdf" ] - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -231,6 +1277,34 @@ EOF [ ! -f "$HOME/Library/Mail Downloads/old.pdf" ] } +@test "_clean_mail_downloads uses dry-run wording and keeps attachments" { + mkdir -p "$HOME/Library/Mail Downloads" + touch "$HOME/Library/Mail Downloads/old.pdf" + touch -t 202301010000 "$HOME/Library/Mail Downloads/old.pdf" + + # MOLE_MAIL_DOWNLOADS_MIN_KB is readonly in base.sh, so an env override is + # discarded and the sweep stays below threshold. Grow the directory instead, + # the same way the non-dry-run case above does. + if command -v mkfile > /dev/null 2>&1; then + mkfile -n 6000k "$HOME/Library/Mail Downloads/dummy.dat" + else + truncate -s 6000k "$HOME/Library/Mail Downloads/dummy.dat" + fi + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 1; } +_clean_mail_downloads +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Would clean 1 mail attachments"* ]] || return 1 + [[ "$output" != *"Cleaned 1 mail attachments"* ]] || return 1 + [ -f "$HOME/Library/Mail Downloads/old.pdf" ] +} + @test "clean_time_machine_failed_backups detects running backup correctly" { if ! command -v tmutil > /dev/null 2>&1; then skip "tmutil not available" @@ -262,7 +1336,7 @@ fi MOCK_TMUTIL chmod +x "$mock_bin/tmutil" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" @@ -274,7 +1348,7 @@ clean_time_machine_failed_backups EOF [ "$status" -eq 0 ] - [[ "$output" != *"Time Machine backup in progress, skipping cleanup"* ]] + [[ "$output" != *"Time Machine cleanup · skipped (backup in progress)"* ]] } @test "clean_time_machine_failed_backups skips when backup is actually running" { @@ -308,7 +1382,7 @@ fi MOCK_TMUTIL chmod +x "$mock_bin/tmutil" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" @@ -320,6 +1394,319 @@ clean_time_machine_failed_backups EOF [ "$status" -eq 0 ] - [[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]] + [[ "$output" == *"Time Machine cleanup · skipped (backup in progress)"* ]] +} + +@test "start_section recycles an idle section header in place on a TTY" { + if ! /usr/bin/script -q /dev/null /bin/true > /dev/null 2>&1; then + skip "script cannot allocate a TTY in this environment" + fi + + raw="$HOME/section-recycle.raw" + # shellcheck disable=SC2016 # inner bash expands these from its environment + env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 TERM=xterm-256color \ + /usr/bin/script -q "$raw" /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + start_section "Idle Alpha" + end_section + start_section "Active Beta" + note_activity + echo " row output" + end_section + ' > /dev/null 2>&1 + + raw_content="$(cat "$raw")" + # Idle header painted, then the next header overwrites its line in place. + [[ "$raw_content" == *"Idle Alpha"* ]] || return 1 + [[ "$raw_content" == *$'\033[1A\r\033[2K'*"Active Beta"* ]] || return 1 + # TTY path must not fall back to the piped-output placeholder row. + [[ "$raw_content" != *"Nothing to clean"* ]] || return 1 +} + +@test "log_success rows mark section activity so headers keep their blank separator" { + if ! /usr/bin/script -q /dev/null /bin/true > /dev/null 2>&1; then + skip "script cannot allocate a TTY in this environment" + fi + + raw="$HOME/section-log-activity.raw" + # shellcheck disable=SC2016 # inner bash expands these from its environment + env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 TERM=xterm-256color \ + /usr/bin/script -q "$raw" /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + start_section "System" + log_success "System crash reports" + end_section + start_section "User essentials" + note_activity + end_section + ' > /dev/null 2>&1 + + raw_content="$(cat "$raw")" + # The log_success row counts as activity: the section is not idle, so the + # next header must not recycle (and eat) the row line. + [[ "$raw_content" == *"System crash reports"* ]] || return 1 + [[ "$raw_content" != *$'\033[1A'* ]] || return 1 + [[ "$raw_content" != *"Nothing to clean"* ]] || return 1 +} + +@test "sections whose rows come only from log_success are not marked idle in pipes" { + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + start_section "System" + log_success "System crash reports" + end_section + ' + [ "$status" -eq 0 ] + [[ "$output" == *"System crash reports"* ]] || return 1 + [[ "$output" != *"Nothing to clean"* ]] || return 1 +} + +@test "safe_clean skips caches that hold a compiled model cache" { + export_file="$HOME/e5rt-list.txt" + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + DRY_RUN=true + # Set after sourcing: clean.sh assigns EXPORT_LIST_FILE at load time. + EXPORT_LIST_FILE="$HOME/e5rt-list.txt" + : > "$EXPORT_LIST_FILE" + e5rt_cache="$HOME/Library/Caches/com.example.ocr/com.apple.e5rt.e5bundlecache" + mkdir -p "$e5rt_cache" "$HOME/Library/Caches/com.example.plain" + # Both need real bytes: zero-sized entries never reach the export list. + dd if=/dev/zero of="$e5rt_cache/model.e5" bs=1024 count=200 2> /dev/null + dd if=/dev/zero of="$HOME/Library/Caches/com.example.plain/junk" bs=1024 count=300 2> /dev/null + safe_clean "$HOME"/Library/Caches/* "User app cache" + ' + [ "$status" -eq 0 ] || return 1 + list_content="$(cat "$export_file")" + [[ "$list_content" == *"com.example.plain"* ]] || return 1 + [[ "$list_content" != *"com.example.ocr"* ]] || return 1 } +@test "active clean sections rely on the final total" { + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + start_section "First" + total_size_cleaned=$((total_size_cleaned + 3000)) + note_activity + end_section + start_section "Second" + total_size_cleaned=$((total_size_cleaned + 2000)) + note_activity + end_section + ' + [[ "$status" -eq 0 ]] || return 1 + [[ "$output" == *"First"*"Second"* ]] || return 1 + [[ "$output" != *"Category total"* ]] || return 1 +} + +@test "active cleanup families are deduplicated for the final summary" { + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + defer_cleanup_family "Xcode" + defer_cleanup_family "Simulator" + defer_cleanup_family "Xcode" + defer_cleanup_family "Codex" + format_deferred_cleanup_families + ' + [[ "$status" -eq 0 ]] || return 1 + [[ "$output" == "Xcode, Simulator, Codex" ]] +} + +@test "timeout worker cleanup families reach the parent summary" { + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + DEFERRED_CLEANUP_FAMILIES=() + DEFERRED_CLEANUP_FAMILIES_FILE=$(create_temp_file) + run_with_shell_timeout 5 defer_cleanup_family "Dropbox" + sync_deferred_cleanup_families + format_deferred_cleanup_families + ' + [[ "$status" -eq 0 ]] || { + echo "$output" + return 1 + } + [[ "$output" == "Dropbox" ]] +} + +@test "report-only clean sections omit the category total" { + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + start_section "Large files" + log_success "iOS backups" + end_section + ' + [[ "$status" -eq 0 ]] || return 1 + [[ "$output" == *"iOS backups"* ]] || return 1 + # A hint row is activity but reclaims nothing: a "0B" footer under a row + # quoting a huge directory reads as a bug, so there must be no footer. + [[ "$output" != *"Category total"* ]] || return 1 +} + +@test "log rows do not trigger purge's export-only note_activity override" { + export_file="$HOME/purge-log-activity.txt" + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" EXPORT_LIST_FILE="$export_file" \ + MOLE_SKIP_MAIN=1 MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/purge.sh" + start_section "Project artifacts" + log_success "Project cache" + end_section + [[ ! -s "$EXPORT_LIST_FILE" ]] || return 1 + ' + [ "$status" -eq 0 ] + [[ "$output" == *"Project cache"* ]] || return 1 +} + +@test "root preview staging is published through the invoking-user boundary" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +calls="$HOME/preview-user-boundary.calls" +CLEAN_PREVIEW_STAGING_FILE="$HOME/root-owned-preview.stage" +CLEAN_PREVIEW_FINAL_FILE="$HOME/user-config/clean-list.txt" +EXPORT_LIST_FILE="$CLEAN_PREVIEW_STAGING_FILE" +SUDO_USER="preview-user" +printf 'preview content\n' > "$CLEAN_PREVIEW_STAGING_FILE" + +run_clean_preview_as_invoking_user() { + printf '%s\n' "$*" >> "$calls" + "$@" +} + +publish_clean_preview_file +[[ "$EXPORT_LIST_FILE" == "$CLEAN_PREVIEW_FINAL_FILE" ]] || exit 1 +[[ "$(cat "$CLEAN_PREVIEW_FINAL_FILE")" == "preview content" ]] || exit 1 +grep -q '^/bin/mkdir -p ' "$calls" +grep -q '^/usr/bin/tee ' "$calls" +EOF + + [ "$status" -eq 0 ] +} + +@test "end_section keeps the Nothing-to-clean fallback for piped output" { + # shellcheck disable=SC2016 # inner bash expands these from its environment + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/bin/clean.sh" + start_section "Idle Alpha" + end_section + ' + [ "$status" -eq 0 ] + [[ "$output" == *"Idle Alpha"* ]] || return 1 + [[ "$output" == *"Nothing to clean"* ]] || return 1 + [[ "$output" != *"Category total"* ]] || return 1 +} + +@test "cleanup libs share one engine-absent shim instead of forking their own" { + # bin/clean.sh owns the deferred-family ledger and is the only production + # entry point that sources lib/clean/*, so a cleanup lib reaches it through + # a `declare -f` probe. Three byte-identical copies of that probe grew in + # dev.sh, user.sh, and app_caches.sh before it was hoisted into + # mole_defer_cleanup_family. Pin the shape so a fourth cannot appear: a + # forked copy drifts silently, and this one sits on the path that decides + # whether a running app's cache is left alone. + local shim_definitions + shim_definitions=$(command grep -rn 'declare -f defer_cleanup_family' "$PROJECT_ROOT/lib" | wc -l | tr -d ' ') + [ "$shim_definitions" -eq 1 ] || { + echo "expected exactly one defer shim in lib/, found $shim_definitions:" + command grep -rn 'declare -f defer_cleanup_family' "$PROJECT_ROOT/lib" + return 1 + } + command grep -rn 'declare -f defer_cleanup_family' "$PROJECT_ROOT/lib" | command grep -q 'lib/core/base.sh' || { + echo "the defer shim moved out of lib/core/base.sh" + return 1 + } +} + +@test "engine-absent cleanup fallbacks stay at their audited count" { + # Each `declare -f safe_clean_guarded` branch is a second, degraded copy of + # the delete guard: production always has bin/clean.sh loaded and never runs + # them, while standalone Bats cases always do. That split is tolerated for + # the ten audited sites and must not grow, because every new one is another + # place the guarded and unguarded verdicts can disagree without a user ever + # exercising the branch that was reviewed. + # + # Adding cleanup code? Call safe_clean_guarded directly and let the test + # provide it, rather than hand-rolling an eleventh fallback. Lowering this + # baseline after removing one is expected; raising it needs a stated reason. + local fallbacks + fallbacks=$(command grep -rn 'declare -f safe_clean_guarded' "$PROJECT_ROOT/lib" | wc -l | tr -d ' ') + [ "$fallbacks" -eq 10 ] || { + echo "engine-absent fallback count is $fallbacks, audited baseline is 10:" + command grep -rn 'declare -f safe_clean_guarded' "$PROJECT_ROOT/lib" + return 1 + } +} + +@test "mole_clean_process_guard denies on an unknown process state" { + # Every cleanup delete guard now funnels its process question through this + # one translator, so its tri-state contract is the single place a slip + # would turn "Mole could not tell" into "safe to delete" across the whole + # clean command. Pin all three states, including that 2 denies. + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +running() { return 0; } +not_running() { return 1; } +unknown() { return 2; } + +_MOLE_CLEAN_GUARD_REASON="" +mole_clean_process_guard not_running "App started" || exit 1 +[[ -z "$_MOLE_CLEAN_GUARD_REASON" ]] || exit 1 + +rc=0 +mole_clean_process_guard running "App started" || rc=$? +[[ $rc -eq 1 ]] || exit 1 +[[ "$_MOLE_CLEAN_GUARD_REASON" == "App started" ]] || exit 1 + +rc=0 +mole_clean_process_guard unknown "App started" || rc=$? +[[ $rc -eq 1 ]] || exit 1 +[[ "$_MOLE_CLEAN_GUARD_REASON" == "process state unknown" ]] || exit 1 + +rc=0 +mole_clean_process_guard unknown "Updater started" "updater state unknown" || rc=$? +[[ $rc -eq 1 ]] || exit 1 +[[ "$_MOLE_CLEAN_GUARD_REASON" == "updater state unknown" ]] || exit 1 +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "cleanup delete guards do not re-implement the process-state translation" { + # Nine guards open-coded the same six lines. The failure mode is not + # duplication, it is that one transcription slip folds state 2 into "not + # running" and deletes a live app's files, while the other eight copies + # still read correctly in review. + local open_coded + open_coded=$( + command awk ' + /^[A-Za-z_][A-Za-z0-9_]*\(\)/ { fn = $0; sub(/\(\).*/, "", fn) } + fn ~ /_delete_guard_allows$/ && /\|\| process_state=\$\?/ { print FILENAME ":" FNR " " fn } + ' "$PROJECT_ROOT"/lib/clean/*.sh + ) + [ -z "$open_coded" ] || { + echo "these guards translate the process state themselves instead of calling mole_clean_process_guard:" + echo "$open_coded" + return 1 + } +} diff --git a/Resources/mole/tests/clean_corepack.bats b/Resources/mole/tests/clean_corepack.bats new file mode 100644 index 0000000..5df0908 --- /dev/null +++ b/Resources/mole/tests/clean_corepack.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-corepack.XXXXXX")" + export HOME + + mkdir -p "$HOME" +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +# Regression: clean_corepack_cache must suppress corepack's interactive +# download prompt. Without COREPACK_ENABLE_DOWNLOAD_PROMPT=0 the command can +# block on stdin while its prompt is hidden by the > /dev/null 2>&1 redirect, +# so the section looks frozen until the timeout fires. +@test "clean_corepack_cache suppresses the corepack download prompt" { + local log="$HOME/corepack-calls.log" + : > "$log" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" COREPACK_LOG="$log" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +# run_with_timeout normally execs the real binary, bypassing function mocks; +# override it so the corepack stub below is reachable and sees the env. +run_with_timeout() { shift; "$@"; } +clean_tool_cache() { shift 2; "$@"; } +safe_clean() { echo "safe_clean:$2"; } +# Log to a file rather than stdout: the --version detection call is wrapped in +# > /dev/null 2>&1, so stdout would hide it. +corepack() { + echo "corepack:$*:prompt=${COREPACK_ENABLE_DOWNLOAD_PROMPT:-UNSET}" >> "$COREPACK_LOG" +} +export -f corepack +clean_corepack_cache +EOF + + [ "$status" -eq 0 ] || return 1 + local calls + calls="$(cat "$log")" + [[ "$calls" == *"corepack:--version:prompt=0"* ]] || return 1 + [[ "$calls" == *"corepack:cache clean:prompt=0"* ]] || return 1 + [[ "$calls" != *"prompt=UNSET"* ]] || return 1 +} + +# When corepack is not installed the cleanup must fall through to the safe +# local-path delete and never invoke corepack. +@test "clean_corepack_cache falls back to safe_clean without corepack" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +clean_tool_cache() { echo "clean_tool_cache:$1"; } +safe_clean() { echo "safe_clean:$2"; } +command() { + if [[ "$1" == "-v" && "$2" == "corepack" ]]; then + return 1 + fi + builtin command "$@" +} +clean_corepack_cache +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"safe_clean:Corepack cache"* ]] || return 1 + [[ "$output" != *"clean_tool_cache:Corepack cache"* ]] || return 1 +} diff --git a/Resources/mole/tests/clean_dev_caches.bats b/Resources/mole/tests/clean_dev_caches.bats index 9a2137e..d60c167 100644 --- a/Resources/mole/tests/clean_dev_caches.bats +++ b/Resources/mole/tests/clean_dev_caches.bats @@ -14,44 +14,183 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } -@test "clean_dev_npm cleans orphaned pnpm store" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_dev_npm prunes pnpm store without deleting orphaned global store" { + # Real file on PATH so type -P prefers the stub over any host pnpm. + mkdir -p "$HOME/bin" + cat > "$HOME/bin/pnpm" <<'SCRIPT' +#!/bin/bash +case "${1:-}" in + --version) echo "11.0.0"; exit 0 ;; + store) + [[ "${2:-}" == "path" ]] && { echo "/tmp/pnpm-store"; exit 0; } + [[ "${2:-}" == "prune" ]] && exit 0 + ;; +esac +exit 2 +SCRIPT + chmod +x "$HOME/bin/pnpm" + + run env HOME="$HOME" PATH="$HOME/bin:/usr/bin:/bin" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" start_section_spinner() { :; } stop_section_spinner() { :; } -clean_tool_cache() { echo "$1"; } +clean_tool_cache() { echo "$1|$2"; } safe_clean() { echo "$2"; } note_activity() { :; } run_with_timeout() { shift; "$@"; } -pnpm() { - if [[ "$1" == "store" && "$2" == "prune" ]]; then - return 0 - fi - if [[ "$1" == "store" && "$2" == "path" ]]; then - echo "/tmp/pnpm-store" - return 0 - fi - return 0 -} +pgrep() { return 1; } npm() { return 0; } -export -f pnpm npm +export -f pgrep npm clean_dev_npm EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Orphaned pnpm store"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"pnpm cache|/tmp/pnpm-store"* ]] || return 1 + [[ "$output" != *"Orphaned pnpm store"* ]] || return 1 +} + +@test "clean_pnpm_stores prunes each distinct store from installed majors" { + # issue #1370: active PATH pnpm (v11) plus a mise-installed pnpm 10. + mkdir -p "$HOME/bin" "$HOME/.local/share/mise/installs/pnpm/10.34.5" + cat > "$HOME/bin/pnpm" <<'SCRIPT' +#!/bin/bash +case "${1:-}" in + --version) echo "11.17.0"; exit 0 ;; + store) + if [[ "${2:-}" == "path" ]]; then + echo "$HOME/Library/pnpm/store/v11" + exit 0 + fi + if [[ "${2:-}" == "prune" ]]; then + echo "PRUNE_V11" + exit 0 + fi + ;; +esac +exit 2 +SCRIPT + chmod +x "$HOME/bin/pnpm" + cat > "$HOME/.local/share/mise/installs/pnpm/10.34.5/pnpm" <<'SCRIPT' +#!/bin/bash +case "${1:-}" in + --version) echo "10.34.5"; exit 0 ;; + store) + if [[ "${2:-}" == "path" ]]; then + echo "$HOME/.local/share/pnpm/store/v10" + exit 0 + fi + if [[ "${2:-}" == "prune" ]]; then + echo "PRUNE_V10" + exit 0 + fi + ;; +esac +exit 2 +SCRIPT + chmod +x "$HOME/.local/share/mise/installs/pnpm/10.34.5/pnpm" + + run env HOME="$HOME" PATH="$HOME/bin:/usr/bin:/bin" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +export -f pgrep +clean_tool_cache() { + local description="$1" + local cache_path="$2" + shift 2 + echo "CACHE:$description|$cache_path" + "$@" +} +clean_pnpm_stores +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"CACHE:pnpm cache|$HOME/Library/pnpm/store/v11"* ]] || return 1 + [[ "$output" == *"CACHE:pnpm cache|$HOME/.local/share/pnpm/store/v10"* ]] || return 1 + [[ "$output" == *"PRUNE_V11"* ]] || return 1 + [[ "$output" == *"PRUNE_V10"* ]] || return 1 +} + +@test "clean_pnpm_stores skips when pnpm is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { printf 'DEBUG:%s\n' "$*"; } +pgrep() { return 0; } +pnpm() { echo "UNEXPECTED"; return 0; } +export -f pgrep pnpm +clean_pnpm_stores +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"skipping store prune"* ]] || return 1 + [[ "$output" != *"UNEXPECTED"* ]] || return 1 +} + +# Corepack and npm-installed pnpm run as `node .../pnpm.cjs`, so the busy +# guard has to match the invoked program, not the process name. `-x pnpm` +# saw only the standalone binary and let a prune race a live install. +@test "pnpm busy guard sees a corepack pnpm and ignores a lockfile mention" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +# Stand in for the real process table: pgrep -f matches its pattern against +# each full argv line. +PROCESS_TABLE="" +pgrep() { + [[ "$1" == "-f" ]] || return 1 + printf '%s\n' "$PROCESS_TABLE" | grep -qE "$2" +} + +PROCESS_TABLE="node /Users/x/.cache/node/corepack/v1/pnpm/9.1.0/bin/pnpm.cjs install" +printf 'COREPACK=%s\n' "$(pnpm_process_blocks_prune && echo block || echo allow)" +PROCESS_TABLE="vim /Users/x/project/pnpm-lock.yaml" +printf 'LOCKFILE=%s\n' "$(pnpm_process_blocks_prune && echo block || echo allow)" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"COREPACK=block"* ]] || return 1 + [[ "$output" == *"LOCKFILE=allow"* ]] } @test "clean_dev_npm cleans default npm residual directories" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -72,14 +211,217 @@ clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] - [[ "$output" == *"npm npx cache|$HOME/.npm/_npx/*"* ]] - [[ "$output" == *"npm logs|$HOME/.npm/_logs/*"* ]] + [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] || return 1 + [[ "$output" == *"npm npx cache|$HOME/.npm/_npx/*"* ]] || return 1 + [[ "$output" == *"npm logs|$HOME/.npm/_logs/*"* ]] || return 1 [[ "$output" == *"npm prebuilds|$HOME/.npm/_prebuilds/*"* ]] } +@test "clean_dev_jvm never enters daemon cleanup while Gradle is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +gradle_daemon_running() { return 0; } +safe_clean() { echo "SAFE_CLEAN:${!#}"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"SAFE_CLEAN:Gradle daemon"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Gradle workers"* ]] +} + +@test "clean_dev_jvm fails closed for every Gradle target when the process probe errors" { + rm -rf "$HOME/.gradle/caches" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon" "$HOME/.gradle/workers" + mkdir -p "$HOME/.gradle/caches/build-cache-1" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon/8.14" "$HOME/.gradle/workers/worker-1" + touch "$HOME/.gradle/caches/build-cache-1/entry" "$HOME/.gradle/notifications/entry" "$HOME/.gradle/daemon/8.14/entry" "$HOME/.gradle/workers/worker-1/entry" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 2; } +safe_clean() { echo "SAFE_CLEAN:${!#}"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Gradle targets · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Gradle"* ]] || return 1 +} + +@test "clean_dev_jvm defers every Gradle target while Gradle is running" { + rm -rf "$HOME/.gradle/caches" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon" "$HOME/.gradle/workers" + mkdir -p "$HOME/.gradle/caches/build-cache-1" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon/8.14" "$HOME/.gradle/workers/worker-1" + touch "$HOME/.gradle/caches/build-cache-1/entry" "$HOME/.gradle/notifications/entry" "$HOME/.gradle/daemon/8.14/entry" "$HOME/.gradle/workers/worker-1/entry" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +gradle_daemon_running() { return 0; } +defer_cleanup_family() { echo "DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Gradle"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Gradle"* ]] || return 1 +} + +@test "clean_dev_jvm cleans every Gradle target when idle" { + rm -rf "$HOME/.gradle/caches" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon" "$HOME/.gradle/workers" + mkdir -p "$HOME/.gradle/caches/build-cache-1" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon/8.14" "$HOME/.gradle/workers/worker-1" + touch "$HOME/.gradle/caches/build-cache-1/entry" "$HOME/.gradle/notifications/entry" "$HOME/.gradle/daemon/8.14/entry" "$HOME/.gradle/workers/worker-1/entry" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +gradle_daemon_running() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"SAFE_CLEAN:Gradle build cache|"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:Gradle notifications cache|"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:"*".gradle/daemon/8.14"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:"*".gradle/workers/worker-1"* ]] || return 1 + rm -rf "$HOME/.gradle" +} + +@test "clean_dev_jvm stops remaining Gradle cleanup when the delete guard refuses" { + rm -rf "$HOME/.gradle/caches" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon" "$HOME/.gradle/workers" + mkdir -p "$HOME/.gradle/caches/build-cache-1" "$HOME/.gradle/notifications" "$HOME/.gradle/daemon/8.14" "$HOME/.gradle/workers/worker-1" + touch "$HOME/.gradle/caches/build-cache-1/entry" "$HOME/.gradle/notifications/entry" "$HOME/.gradle/daemon/8.14/entry" "$HOME/.gradle/workers/worker-1/entry" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +gradle_daemon_running() { return 1; } +_dev_process_delete_guard_allows() { return 1; } +defer_cleanup_family() { echo "DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Gradle"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Gradle"* ]] || return 1 + rm -rf "$HOME/.gradle" +} + +@test "clean_dev_jvm ignores empty Gradle daemon roots while active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +rm -rf "$HOME/.gradle/daemon" "$HOME/.gradle/workers" +mkdir -p "$HOME/.gradle/daemon" "$HOME/.gradle/workers" +gradle_daemon_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Gradle daemon"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Gradle workers"* ]] || return 1 + [[ "$output" != *"process state unknown"* ]] +} + +@test "clean_dev_jvm ignores broken-symlink-only Gradle roots while active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +rm -rf "$HOME/.gradle/daemon" "$HOME/.gradle/workers" +mkdir -p "$HOME/.gradle/daemon" "$HOME/.gradle/workers" +ln -s "$HOME/missing-gradle-daemon" "$HOME/.gradle/daemon/broken" +ln -s "$HOME/missing-gradle-worker" "$HOME/.gradle/workers/broken" +mkdir -p "$HOME/.gradle/daemon/compiled/com.apple.e5rt.e5bundlecache" +gradle_daemon_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Gradle daemon"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Gradle workers"* ]] +} + +@test "clean_dev_jvm ignores active whitelist-only Gradle daemon entries" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +rm -rf "$HOME/.gradle/daemon" "$HOME/.gradle/workers" +mkdir -p "$HOME/.gradle/daemon" +target="$HOME/.gradle/daemon/whitelisted" +touch "$target" +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +gradle_daemon_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { :; } +clean_dev_jvm +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER:Gradle"* ]] +} + +@test "clean_conda_metadata_caches honors package cache whitelist before conda clean" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +WHITELIST_PATTERNS=("$HOME/anaconda3/pkgs") +conda() { echo "conda called"; return 0; } +export -f conda +clean_conda_metadata_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"conda index/tarball/log caches · skipped (whitelist)"* ]] || return 1 + [[ "$output" != *"conda called"* ]] +} + @test "clean_dev_npm cleans custom npm cache path when detected" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -100,15 +442,15 @@ clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] - [[ "$output" == *"npm cache directory (custom path)|/tmp/mole-custom-npm-cache/_cacache/*"* ]] - [[ "$output" == *"npm npx cache (custom path)|/tmp/mole-custom-npm-cache/_npx/*"* ]] - [[ "$output" == *"npm logs (custom path)|/tmp/mole-custom-npm-cache/_logs/*"* ]] + [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] || return 1 + [[ "$output" == *"npm cache directory (custom path)|/tmp/mole-custom-npm-cache/_cacache/*"* ]] || return 1 + [[ "$output" == *"npm npx cache (custom path)|/tmp/mole-custom-npm-cache/_npx/*"* ]] || return 1 + [[ "$output" == *"npm logs (custom path)|/tmp/mole-custom-npm-cache/_logs/*"* ]] || return 1 [[ "$output" == *"npm prebuilds (custom path)|/tmp/mole-custom-npm-cache/_prebuilds/*"* ]] } @test "clean_dev_npm falls back to default cache when npm path is invalid" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -129,12 +471,12 @@ clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] + [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] || return 1 [[ "$output" != *"(custom path)"* ]] } @test "clean_dev_npm treats default cache path with trailing slash as same path" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -155,94 +497,1554 @@ clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] + [[ "$output" == *"npm cache directory|$HOME/.npm/_cacache/*"* ]] || return 1 [[ "$output" != *"(custom path)"* ]] } -@test "clean_dev_docker skips when daemon not running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 DRY_RUN=false bash --noprofile --norc <<'EOF' +@test "clean_dev_npm cleans default bun cache when bun is unavailable" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" start_section_spinner() { :; } stop_section_spinner() { :; } -run_with_timeout() { return 1; } -clean_tool_cache() { echo "$1"; } -safe_clean() { echo "$2"; } -debug_log() { echo "$*"; } -docker() { return 1; } -export -f docker -clean_dev_docker +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { return 1; } +export -f npm bun +clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"Docker daemon not running"* ]] - [[ "$output" != *"Docker build cache"* ]] + [[ "$output" == *"Bun cache|$HOME/.bun/install/cache/*"* ]] || return 1 + [[ "$output" != *"bun cache|bun cache bun pm cache rm"* ]] || return 1 + [[ "$output" != *"Orphaned bun cache"* ]] } -@test "clean_developer_tools runs key stages" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_dev_npm uses bun cache command for default bun cache path" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } stop_section_spinner() { :; } -clean_sqlite_temp_files() { :; } -clean_dev_npm() { echo "npm"; } -clean_homebrew() { echo "brew"; } -clean_project_caches() { :; } -clean_dev_python() { :; } -clean_dev_go() { :; } -clean_dev_rust() { :; } -check_rust_toolchains() { :; } -check_android_ndk() { :; } -clean_dev_docker() { :; } -clean_dev_cloud() { :; } -clean_dev_nix() { :; } -clean_dev_shell() { :; } -clean_dev_frontend() { :; } -clean_xcode_documentation_cache() { :; } -clean_dev_mobile() { :; } -clean_dev_jvm() { :; } -clean_dev_other_langs() { :; } -clean_dev_cicd() { :; } -clean_dev_database() { :; } -clean_dev_api_tools() { :; } -clean_dev_network() { :; } -clean_dev_misc() { :; } -clean_dev_elixir() { :; } -clean_dev_haskell() { :; } -clean_dev_ocaml() { :; } -clean_dev_editors() { :; } -clean_code_editors() { :; } -clean_dev_jetbrains_toolbox() { :; } -clean_xcode_tools() { :; } -safe_clean() { :; } -debug_log() { :; } -clean_developer_tools +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "$HOME/.bun/install/cache" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"npm"* ]] - [[ "$output" == *"brew"* ]] + [[ "$output" == *"bun cache"* ]] || return 1 + [[ "$output" != *"Bun cache|$HOME/.bun/install/cache/*"* ]] || return 1 + [[ "$output" != *"Orphaned bun cache"* ]] } -@test "clean_project_caches cleans flutter .dart_tool and build directories" { - mkdir -p "$HOME/Code/flutter_app/.dart_tool" "$HOME/Code/flutter_app/build" - touch "$HOME/Code/flutter_app/.dart_tool/cache.bin" - touch "$HOME/Code/flutter_app/build/output.bin" +@test "clean_dev_npm cleans orphaned default bun cache when custom path is configured" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "/tmp/mole-bun-cache" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + [ "$status" -eq 0 ] + [[ "$output" == *"bun cache"* ]] || return 1 + [[ "$output" == *"Orphaned bun cache|$HOME/.bun/install/cache/*"* ]] +} + +@test "clean_dev_npm treats default bun cache path with trailing slash as same path" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/caches.sh" -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -create_temp_file() { mktemp; } +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } safe_clean() { echo "$2|$1"; } -DRY_RUN=false -clean_project_caches +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "$HOME/.bun/install/cache/" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"Flutter build cache (.dart_tool)"* ]] - [[ "$output" == *"Flutter build cache (build/)"* ]] + [[ "$output" == *"bun cache"* ]] || return 1 + [[ "$output" != *"Orphaned bun cache"* ]] +} + +@test "clean_dev_npm falls back to filesystem cleanup when bun cache command fails" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 1 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "/tmp/mole-bun-cache" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Bun cache|/tmp/mole-bun-cache/*"* ]] || return 1 + [[ "$output" == *"Orphaned bun cache|$HOME/.bun/install/cache/*"* ]] +} + +@test "clean_dev_docker skips daemon-managed cleanup by default" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { echo "$2"; } +note_activity() { :; } +debug_log() { :; } +docker() { echo "docker called"; return 0; } +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker unused data · review with docker system df"* ]] || return 1 + [[ "$output" == *"Docker BuildX cache"* ]] || return 1 + [[ "$output" != *"docker called"* ]] +} + +@test "clean_dev_docker keeps BuildX cache cleanup" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +debug_log() { :; } +docker() { return 0; } +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker BuildX cache|$HOME/.docker/buildx/cache/*"* ]] +} + +@test "clean_dev_docker reports OrbStack data without deleting disk images" { + local orb_data="$HOME/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data" + mkdir -p "$orb_data" + touch "$orb_data/data.img.raw" "$orb_data/swap.img" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { printf '%s|%s\n' "$2" "$1"; } +note_activity() { :; } +debug_log() { :; } +get_path_size_kb() { echo "4096"; } +bytes_to_human() { echo "4M"; } +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"OrbStack container data · 4M · review with docker system df"* ]] || return 1 + [[ "$output" == *"Docker BuildX cache|$HOME/.docker/buildx/cache/*"* ]] || return 1 + [[ "$output" != *"data.img.raw"* ]] || return 1 + [[ "$output" != *"swap.img"* ]] +} + +@test "clean_dev_docker stops before BuildX cleanup when OrbStack sizing times out" { + local orb_data="$HOME/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data" + mkdir -p "$orb_data" + touch "$orb_data/data.img.raw" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false \ + MOLE_CURRENT_COMMAND=clean MOLE_CLEAN_CANCEL_STATUS=0 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "UNEXPECTED_BUILDX:$2|$1"; } +get_path_size_kb() { return 124; } +note_activity() { :; } +debug_log() { :; } +set +e +clean_dev_docker +rc=$? +set -e +printf 'RC=%s CANCEL=%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=124 CANCEL=124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_BUILDX"* ]] +} + +@test "clean_dev_docker no longer depends on whitelist to avoid prune" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { + [[ "$1" == "$HOME/.docker" ]] && return 0 + return 1 +} +export -f is_path_whitelisted +docker() { echo "docker called"; return 0; } +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker unused data · review with docker system df"* ]] || return 1 + [[ "$output" != *"whitelisted"* ]] || return 1 + [[ "$output" != *"mo clean --whitelist"* ]] || return 1 + [[ "$output" != *"docker called"* ]] +} + +@test "codex_desktop_running recognizes current and legacy app aliases (#1305)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +matched_query="" +pgrep() { + [[ "$*" == "$matched_query" ]] +} + +for matched_query in "-x Codex" "-f /Codex.app/" "-x ChatGPT" "-f /ChatGPT.app/"; do + codex_desktop_running || exit 1 +done + +matched_query="-x unrelated" +if codex_desktop_running; then + exit 1 +fi +printf 'CODEX_DESKTOP_ALIASES_OK\n' +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"CODEX_DESKTOP_ALIASES_OK"* ]] || return 1 +} + +@test "standalone Xcode guarded cleanup rechecks before safe_clean fallback" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +unset -f safe_clean_guarded 2> /dev/null || true +deny_xcode_delete() { return 1; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } +note_activity() { :; } + +rc=0 +_xcode_safe_clean_guarded deny_xcode_delete "Xcode cache" "$HOME/cache" "Xcode cache" || rc=$? +[[ $rc -ne 0 ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 +} + +@test "ChatGPT running keeps Codex runtime and update staging cleanup dormant (#1305)" { + local case_home="$HOME/chatgpt-running-case" + local runtime_root="$case_home/.cache/codex-runtimes" + local staging_root="$case_home/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$case_home" + mkdir -p "$runtime_root/incomplete-install" "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { [[ "$1" == "-x" && "$2" == "ChatGPT" ]]; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } + +clean_codex_runtimes +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Codex runtimes · skipped"* ]] || return 1 + [[ "$output" != *"Codex Desktop update staging · skipped"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 +} + +@test "clean_codex_runtimes reports active runtime for manual review" { + mkdir -p "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/runtime.json" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · manual review (1M)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Codex CLI runtimes|$HOME/.cache/codex-runtimes/codex-primary-runtime"* ]] +} + +@test "clean_codex_runtimes cleans only stale incomplete runtime dirs" { + mkdir -p "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin" + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/runtime.json" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Codex CLI runtimes|$HOME/.cache/codex-runtimes/incomplete-old"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Codex CLI runtimes|$HOME/.cache/codex-runtimes/codex-primary-runtime"* ]] +} + +@test "clean_codex_runtimes skips all runtimes while Codex is running" { + mkdir -p "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin" + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/runtime.json" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 0; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Codex runtimes · skipped"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] +} + +@test "clean_codex_runtimes skips incomplete runtimes while lowercase Codex CLI is running" { + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { [[ "$*" == "-x codex" ]]; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:$2|$1"; } +defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Codex"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "clean_codex_runtimes does not defer compiled-model-only stale runtimes" { + local case_home="$HOME/codex-compiled-only" + local runtime_dir="$case_home/.cache/codex-runtimes/incomplete-old" + mkdir -p "$runtime_dir/com.apple.e5rt.e5bundlecache" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$1"; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_codex_runtimes respects whitelist" { + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { [[ "$1" == "$HOME/.cache/codex-runtimes"* || "$1" == "$HOME/.cache/codex-runtimes/incomplete-old" ]]; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · skipped (whitelist)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] +} + +@test "clean_codex_runtimes respects child runtime whitelist" { + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { [[ "$1" == "$HOME/.cache/codex-runtimes/incomplete-old" ]]; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · manual review"* ]] || return 1 + [[ "$output" == *"Codex runtimes · skipped (whitelist)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] +} + +@test "empty Codex cache leaves and fresh staging do not register active cleanup" { + local case_home="$HOME/codex-empty-active" + local cache_root="$case_home/Library/Caches/Codex/Default/Cache" + local staging_root="$case_home/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + mkdir -p "$cache_root" "$staging_root/fresh" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:$2|$1"; } +is_path_whitelisted() { return 1; } +note_activity() { :; } +clean_codex_desktop_caches +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "clean_codex_desktop_staging selects only stale first-level installation directories" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/stale/Codex.app" "$staging_root/fresh/Codex.app" + touch -t 202001010000 "$staging_root/stale" + # A newly staged app may preserve an old bundle timestamp. The fresh outer + # Sparkle directory, not its nested app, is the retention boundary. + touch -t 202001010000 "$staging_root/fresh/Codex.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Codex Desktop stale update staging|$staging_root/stale"* ]] || return 1 + [[ "$output" != *"$staging_root/fresh"* ]] || return 1 + [[ "$output" != *"$HOME/.codex"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Application Support/Codex"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Logs/com.openai.codex"* ]] || return 1 +} + +@test "clean_codex_desktop_staging rejects a symlinked staging ancestor" { + local case_home="$HOME/codex-staging-ancestor-link" + local sparkle_parent="$case_home/Library/Caches/com.openai.codex" + local outside="$case_home/Documents/StagingVictim" + local outside_entry="$outside/Installation/stale" + mkdir -p "$sparkle_parent" "$outside_entry" + touch "$outside_entry/OUTSIDE_SENTINEL" + touch -t 202001010000 "$outside_entry" + ln -s "$outside" "$sparkle_parent/org.sparkle-project.Sparkle" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +pgrep() { return 1; } +codex_sparkle_staging_has_open_files() { return 1; } +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_codex_desktop_staging +[[ -f "$HOME/Documents/StagingVictim/Installation/stale/OUTSIDE_SENTINEL" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DELETE"* ]] +} + +@test "clean_codex_desktop_staging rechecks physical containment after sizing" { + local case_home="$HOME/codex-staging-containment-race" + local sparkle_parent="$case_home/Library/Caches/com.openai.codex" + local sparkle_root="$sparkle_parent/org.sparkle-project.Sparkle" + local staging_entry="$sparkle_root/Installation/stale" + local outside="$case_home/Documents/StagingVictim" + mkdir -p "$staging_entry" "$outside/Installation/stale" + touch "$staging_entry/owned" "$outside/Installation/stale/OUTSIDE_SENTINEL" + touch -t 202001010000 "$staging_entry" "$outside/Installation/stale" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +pgrep() { return 1; } +codex_sparkle_staging_has_open_files() { return 1; } +get_cleanup_path_size_kb() { + if [[ ! -e "$HOME/switched-staging-root" ]]; then + : > "$HOME/switched-staging-root" + mv "$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle" "$HOME/original-sparkle" + ln -s "$HOME/Documents/StagingVictim" "$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle" + fi + echo 1 +} +safe_remove() { echo "UNEXPECTED_DELETE:$1"; return 0; } +clean_codex_desktop_staging +[[ -f "$HOME/Documents/StagingVictim/Installation/stale/OUTSIDE_SENTINEL" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DELETE"* ]] +} + +@test "clean_codex_desktop_staging does not defer compiled-model-only candidates" { + local case_home="$HOME/codex-staging-compiled-only" + local stale="$case_home/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation/stale" + mkdir -p "$stale/com.apple.e5rt.e5bundlecache" + touch -t 202001010000 "$stale" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +codex_desktop_process_state() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$1"; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "clean_codex_desktop_staging skips while Codex or Sparkle updater is running" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { [[ "$1" == "-x" && "$2" == "Codex" ]]; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"skipped (Codex running)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { [[ "$1" == "-f" && "$2" == *"sparkle-project"* ]]; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"skipped (updater running)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 +} + +@test "clean_codex_desktop_staging skips open files and honors whitelist" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { printf 'n%s\n' "$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation/stale/Codex.app"; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"skipped (files in use)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { return 124; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"skipped (open-file check unavailable)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +is_path_whitelisted() { [[ "$1" == "$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" ]]; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"would skip (whitelist)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 +} + +@test "clean_codex_desktop_staging fails closed when lsof is unavailable" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +probe_rc=0 +PATH=/nonexistent codex_sparkle_staging_has_open_files "$HOME/missing" || probe_rc=$? +[[ $probe_rc -eq 2 ]] || { echo "WRONG_LSOF_RC:$probe_rc"; exit 1; } + +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +codex_sparkle_staging_has_open_files() { return 2; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"open-file check unavailable"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "codex staging treats lsof exit one with stderr as unknown" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +lsof() { return 1; } +run_with_timeout() { + echo "lsof: cannot stat test path" >&2 + return 1 +} +probe_rc=0 +codex_sparkle_staging_has_open_files "$HOME/missing" || probe_rc=$? +[[ $probe_rc -eq 2 ]] || { echo "WRONG_LSOF_RC:$probe_rc"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "clean_codex_desktop_staging rechecks Codex at the deletion boundary" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" "$HOME/codex-staging-probes" + mkdir -p "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +codex_desktop_process_state() { + printf 'probe\n' >> "$HOME/codex-staging-probes" + [[ $(wc -l < "$HOME/codex-staging-probes" | tr -d ' ') -ge 2 ]] +} +codex_sparkle_updater_running() { return 1; } +codex_sparkle_staging_has_open_files() { return 1; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:$2|$1"; } +defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Codex"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 + [ -d "$staging_root/stale" ] +} + +@test "clean_codex_desktop_staging revalidates candidate age before deletion" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" "$HOME/codex-staging-age-probes" + mkdir -p "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +codex_desktop_process_state() { + if [[ ! -e "$HOME/codex-staging-age-probes" ]]; then + : > "$HOME/codex-staging-age-probes" + touch "$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation/stale" + fi + return 1 +} +codex_sparkle_updater_running() { return 1; } +codex_sparkle_staging_has_open_files() { return 1; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 + [ -d "$staging_root/stale" ] +} + +@test "clean_codex_desktop_staging routes dry-run candidates through safe_clean" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/stale" + touch -t 202001010000 "$staging_root/stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$DRY_RUN|$2|$1"; } +note_activity() { :; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:true|Codex Desktop stale update staging|$staging_root/stale"* ]] || return 1 +} + +@test "clean_dev_mise respects MISE_CACHE_DIR and only targets cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MISE_CACHE_DIR="/tmp/mise-cache" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_tool_cache() { :; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +clean_dev_mise +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"mise cache|/tmp/mise-cache/*"* ]] || return 1 + [[ "$output" != *".local/share/mise"* ]] +} + +@test "clean_dev_other_langs cleans configured composer cache paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" COMPOSER_HOME="$HOME/.config/composer-home" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_other_langs +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PHP Composer cache (legacy)|"* ]] || return 1 + [[ "$output" == *"PHP Composer cache|"* ]] +} + +@test "clean_dev_rust honors CARGO_HOME and RUSTUP_HOME when absolute" { + # mise and friends relocate cargo/rustup via env; hardcoded ~/.cargo misses + # the live cache (issue #1378). Scope stays regenerable leaves only. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + CARGO_HOME="$HOME/.local/share/mise/cargo" \ + RUSTUP_HOME="$HOME/.local/share/mise/rustup" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_rust +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Rust cargo cache|$HOME/.local/share/mise/cargo/registry/cache/*"* ]] || return 1 + [[ "$output" == *"Cargo git cache|$HOME/.local/share/mise/cargo/git/*"* ]] || return 1 + [[ "$output" == *"Rustup downloads cache|$HOME/.local/share/mise/rustup/downloads/*"* ]] || return 1 + [[ "$output" != *"/.cargo/"* ]] || return 1 + [[ "$output" != *"/.rustup/"* ]] || return 1 +} + +@test "clean_dev_rust falls back to default homes without env" { + run env -u CARGO_HOME -u RUSTUP_HOME \ + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +unset CARGO_HOME RUSTUP_HOME +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_rust +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Rust cargo cache|$HOME/.cargo/registry/cache/*"* ]] || return 1 + [[ "$output" == *"Cargo git cache|$HOME/.cargo/git/*"* ]] || return 1 + [[ "$output" == *"Rustup downloads cache|$HOME/.rustup/downloads/*"* ]] || return 1 +} + +@test "resolve_tool_home rejects relative and traversal env values" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +fail=0 +expect() { + local got + got=$(resolve_tool_home "$1" "$HOME/.cargo") + if [[ "$got" != "$2" ]]; then + printf 'UNEXPECTED: env=%q got=%q want=%q\n' "$1" "$got" "$2" + fail=1 + fi +} +expect "" "$HOME/.cargo" +expect "$HOME/.local/share/mise/cargo" "$HOME/.local/share/mise/cargo" +expect "relative/cargo" "$HOME/.cargo" +expect "$HOME/../evil" "$HOME/.cargo" +expect "/tmp/foo/../bar" "$HOME/.cargo" +exit $fail +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_developer_tools runs key stages" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/dev.sh" +stop_section_spinner() { :; } +clean_sqlite_temp_files() { :; } +clean_dev_npm() { echo "npm"; } +clean_homebrew() { echo "brew"; } +clean_project_caches() { :; } +clean_dev_python() { :; } +clean_dev_go() { :; } +clean_dev_mise() { echo "mise"; } +clean_dev_rust() { :; } +check_rust_toolchains() { :; } +clean_dev_ruby() { :; } +clean_dev_perl() { :; } +check_android_ndk() { :; } +clean_dev_docker() { :; } +clean_dev_cloud() { :; } +clean_dev_nix() { :; } +clean_dev_shell() { :; } +clean_dev_frontend() { :; } +clean_xcode_documentation_cache() { :; } +clean_dev_mobile() { :; } +clean_dev_jvm() { :; } +clean_dev_other_langs() { :; } +clean_dev_cicd() { :; } +clean_dev_database() { :; } +clean_dev_api_tools() { :; } +clean_dev_network() { :; } +clean_dev_misc() { :; } +clean_dev_elixir() { :; } +clean_dev_haskell() { :; } +clean_dev_ocaml() { :; } +clean_code_editors() { :; } +clean_dev_jetbrains_toolbox() { :; } +clean_xcode_tools() { :; } +safe_clean() { :; } +debug_log() { :; } +clean_developer_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"npm"* ]] || return 1 + [[ "$output" == *"mise"* ]] || return 1 + [[ "$output" == *"brew"* ]] +} + +@test "clean_dev_ruby cleans rbenv, gem, and bundler caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_ruby +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"rbenv download cache|"* ]] || return 1 + [[ "$output" == *"gem spec cache|"* ]] || return 1 + [[ "$output" == *"gem package cache|"* ]] || return 1 + [[ "$output" == *"Ruby Bundler cache|"* ]] +} + +@test "clean_dev_perl cleans CPAN build and source caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_perl +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CPAN build artifacts|"* ]] || return 1 + [[ "$output" == *"CPAN source cache|"* ]] +} + +@test "clean_dev_other_langs no longer includes Ruby Bundler cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_other_langs +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Ruby Bundler cache"* ]] +} + +@test "clean_project_caches cleans flutter .dart_tool and build directories" { + mkdir -p "$HOME/Code/flutter_app/.dart_tool" "$HOME/Code/flutter_app/build" + touch "$HOME/Code/flutter_app/.dart_tool/cache.bin" + touch "$HOME/Code/flutter_app/build/output.bin" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/caches.sh" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +create_temp_file() { mktemp; } +safe_clean() { echo "$2|$1"; } +DRY_RUN=false +clean_project_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Flutter build cache (.dart_tool)"* ]] || return 1 + [[ "$output" == *"Flutter build cache (build/)"* ]] +} + +@test "project cache processing stops after a Python size timeout" { + local python_root="$HOME/Code/A" + local next_root="$HOME/Code/B" + mkdir -p "$python_root/__pycache__" "$next_root/.next/cache" + touch "$python_root/__pycache__/module.pyc" "$next_root/.next/cache/output" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false \ + MOLE_CURRENT_COMMAND=clean MOLE_CLEAN_CANCEL_STATUS=0 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/caches.sh" +matches_file=$(mktemp) +printf '%s\t%s\n' "$HOME/Code/A" "$HOME/Code/A/__pycache__" > "$matches_file" +printf '%s\t%s\n' "$HOME/Code/B" "$HOME/Code/B/.next" >> "$matches_file" +get_path_size_kb() { return 124; } +safe_clean() { echo "UNEXPECTED_CONTINUATION:$2|$1"; } +safe_remove() { echo "UNEXPECTED_DELETE:$1"; } + +set +e +process_project_cache_matches "$matches_file" +rc=$? +set -e +rm -f "$matches_file" +printf 'RC=%s CANCEL=%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=124 CANCEL=124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CONTINUATION"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DELETE"* ]] +} + +@test "clean_dev_misc includes Chrome DevTools MCP cache when server not running" { + mkdir -p "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache" + touch "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache/data" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +pgrep() { return 1; } +safe_clean() { echo "$2"; } +safe_find_delete() { :; } +clean_service_worker_cache() { :; } +clean_dev_misc +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome DevTools MCP browser cache"* ]] || return 1 + [[ "$output" != *"Chrome DevTools MCP cache"* ]] +} + +@test "clean_dev_misc skips Chrome DevTools MCP cache when server is running" { + mkdir -p "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache" + touch "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache/data" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +pgrep() { return 0; } +safe_clean() { echo "$2"; } +safe_find_delete() { :; } +clean_service_worker_cache() { :; } +clean_dev_misc +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Chrome DevTools MCP caches · skipped"* ]] || return 1 + [[ "$output" != *"Chrome DevTools MCP browser cache"* ]] +} + +@test "clean_chrome_devtools_mcp_caches preserves profile state" { + profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + mkdir -p "$profile/Default/Cache" "$profile/Default/Code Cache" "$profile/Default/GPUCache" + mkdir -p "$profile/Default/Service Worker/CacheStorage" + mkdir -p "$profile/Default/Local Storage/leveldb" + touch "$profile/Default/Cache/data" "$profile/Default/Code Cache/data" "$profile/Default/GPUCache/data" + touch "$profile/Default/Service Worker/CacheStorage/data" + touch "$profile/Default/Cookies" "$profile/Default/Local Storage/leveldb/state" + touch "$profile/Local State" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +clean_service_worker_cache() { echo "SWC:$1|$2"; } +clean_chrome_devtools_mcp_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP browser cache|$profile/Default/Cache/"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP code cache|$profile/Default/Code Cache/"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP GPU cache|$profile/Default/GPUCache/"* ]] || return 1 + [[ "$output" == *"SWC:Chrome DevTools MCP|$profile/Default/Service Worker/CacheStorage"* ]] || return 1 + [[ "$output" != *"Cookies"* ]] || return 1 + [[ "$output" != *"Local Storage"* ]] || return 1 + [[ "$output" != *"Local State"* ]] +} + +@test "clean_chrome_devtools_mcp_caches ignores an empty active profile" { + profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + rm -rf "$profile" + mkdir -p "$profile/Default/Cache" "$profile/Default/Service Worker/CacheStorage" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$2"; } +clean_service_worker_cache() { echo "UNEXPECTED_SWC"; } +clean_chrome_devtools_mcp_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SWC"* ]] || return 1 + [[ "$output" != *"process state unknown"* ]] +} + +@test "clean_chrome_devtools_mcp_caches recognizes root-level cache candidates" { + profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + rm -rf "$profile" + mkdir -p "$profile/extensions_crx_cache" + touch "$profile/extensions_crx_cache/candidate" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +clean_service_worker_cache() { :; } +clean_chrome_devtools_mcp_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP extension cache|$profile/extensions_crx_cache/candidate"* ]] +} + +@test "report_agent_worktree_candidates reports large worktree containers as review only" { + mkdir -p "$HOME/code/proj/.claude/worktrees/wt-one" + echo "data" > "$HOME/code/proj/.claude/worktrees/wt-one/file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +get_path_size_kb() { echo "2097152"; } +report_agent_worktree_candidates +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"AI agent worktrees"* ]] || return 1 + [[ "$output" == *"GB"* ]] || return 1 + [[ "$output" == *".claude/worktrees"* ]] || return 1 + # Report only: the worktree must still exist afterwards. + [ -d "$HOME/code/proj/.claude/worktrees/wt-one" ] +} + +@test "report_agent_worktree_candidates stays silent below the 1GB bar" { + mkdir -p "$HOME/code/proj/.claude/worktrees/wt-one" + echo "data" > "$HOME/code/proj/.claude/worktrees/wt-one/file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +get_path_size_kb() { echo "512000"; } +report_agent_worktree_candidates +EOF + + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +_codex_version_plist() { + mkdir -p "$(dirname "$1")" + local bundle_id="${3:-com.openai.codex}" + cat > "$1" << PLIST + + +CFBundleIdentifier$bundle_idCFBundleVersion$2 +PLIST +} + +@test "codex staging removes a superseded staged build regardless of age (#1359)" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/superseded/Codex.app/Contents" "$staging_root/pending/Codex.app/Contents" + _codex_version_plist "$staging_root/superseded/Codex.app/Contents/Info.plist" "5628" + _codex_version_plist "$staging_root/pending/Codex.app/Contents/Info.plist" "5900" + # The pending entry is ancient; version must protect it anyway. + touch -t 202001010000 "$staging_root/pending" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +_codex_installed_build_version() { echo "5848"; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"SAFE_CLEAN:Codex Desktop stale update staging|"*"/superseded"* ]] || return 1 + [[ "$output" != *"/pending"* ]] || return 1 +} + +@test "codex staging removes an equal staged build and keeps invalid metadata on the age rule" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/equal/Codex.app/Contents" \ + "$staging_root/badmeta-old/Codex.app/Contents" \ + "$staging_root/badmeta-fresh/Codex.app/Contents" + _codex_version_plist "$staging_root/equal/Codex.app/Contents/Info.plist" "5848" + _codex_version_plist "$staging_root/badmeta-old/Codex.app/Contents/Info.plist" "not-a-number" + _codex_version_plist "$staging_root/badmeta-fresh/Codex.app/Contents/Info.plist" "also.bad" + touch -t 202001010000 "$staging_root/badmeta-old" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +_codex_installed_build_version() { echo "5848"; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"/equal"* ]] || return 1 + [[ "$output" == *"/badmeta-old"* ]] || return 1 + [[ "$output" != *"/badmeta-fresh"* ]] || return 1 +} + +@test "codex staging keeps the age rule when the installed build is unknown" { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/versioned-fresh/Codex.app/Contents" + _codex_version_plist "$staging_root/versioned-fresh/Codex.app/Contents/Info.plist" "1" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +_codex_installed_build_version() { return 1; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 +} + +@test "codex staging refuses version supersession for a foreign staged bundle id" { + # A lower version number on a DIFFERENT app proves nothing about + # Codex's staging; identity gates the comparison, so the entry falls + # back to the age rule and a fresh one stays. + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/foreign/Other.app/Contents" + _codex_version_plist "$staging_root/foreign/Other.app/Contents/Info.plist" "1" "com.example.other" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 1; } +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +is_path_whitelisted() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +note_activity() { :; } +_codex_installed_build_version() { echo "5848"; } +clean_codex_desktop_staging +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"SAFE_CLEAN:"* ]] || return 1 +} + +@test "codex installed-version resolution fails on two copies that disagree" { + # Both copies share the one staging cache, so a staged build may be the + # pending update for either. Disagreeing installed versions make + # ownership ambiguous and must resolve to the age rule, never to the + # first copy found. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +run_with_timeout() { shift; "$@"; } +mdfind() { return 1; } +_codex_app_build_version() { + case "$1" in + "/Applications/Codex.app") echo "5900" ;; + "$HOME/Applications/Codex.app") echo "5800" ;; + *) return 1 ;; + esac +} +mkdir -p "/tmp/nonexistent-guard" 2>/dev/null || true +if _codex_installed_build_version; then + echo "RESOLVED_DESPITE_CONFLICT" +else + echo "AMBIGUOUS_FALLS_BACK" +fi +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"AMBIGUOUS_FALLS_BACK"* ]] || return 1 + [[ "$output" != *"RESOLVED_DESPITE_CONFLICT"* ]] || return 1 +} + +@test "codex resolution treats a failed mdfind as unanswered, not as no-other-copies" { + # A timed-out or failed mdfind may be hiding an unindexed extra copy + # whose pending update is the staged build under judgment. Resolution + # must fail (age rule), even when a fixed-path copy reads cleanly; a + # clean rc 0 with no rows is the only valid "no other copies". + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +run_with_timeout() { shift; "$@"; } +mkdir -p "$HOME/Applications/Codex.app/Contents" +cat > "$HOME/Applications/Codex.app/Contents/Info.plist" << 'PLIST' + + +CFBundleIdentifiercom.openai.codexCFBundleVersion5800 +PLIST +mdfind() { return 2; } +if _codex_installed_build_version; then + echo "RESOLVED_DESPITE_MDFIND_FAILURE" +fi +mdfind() { return 0; } +resolved=$(_codex_installed_build_version) || { echo "CLEAN_EMPTY_FAILED"; exit 1; } +echo "CLEAN_EMPTY_RESOLVED=$resolved" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"RESOLVED_DESPITE_MDFIND_FAILURE"* ]] || return 1 + [[ "$output" == *"CLEAN_EMPTY_RESOLVED=5800"* ]] || return 1 +} + +@test "codex supersession boundary re-verifies the installed set before deleting" { + # The scan snapshot is not enough: a copy installed or swapped after + # the scan (an older one whose pending update is exactly this staged + # build) must void the supersession at the deletion boundary. + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + rm -rf "$staging_root" + mkdir -p "$staging_root/entry/Codex.app/Contents" + _codex_version_plist "$staging_root/entry/Codex.app/Contents/Info.plist" "5628" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +run_with_timeout() { shift; "$@"; } +staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" +_MOLE_CODEX_STAGING_ROOT="$staging_root" +_MOLE_CODEX_STAGING_ENTRY="$staging_root/entry" +_MOLE_CODEX_STAGING_MODE="superseded" +_MOLE_CODEX_INSTALLED_BUILD="5848" + +_codex_installed_build_version() { echo "5900"; } +if _codex_staging_entry_is_still_stale; then + echo "STALE_DESPITE_CHANGED_INSTALL" +fi +_codex_installed_build_version() { return 1; } +if _codex_staging_entry_is_still_stale; then + echo "STALE_DESPITE_AMBIGUOUS_INSTALL" +fi +_codex_installed_build_version() { echo "5848"; } +if _codex_staging_entry_is_still_stale; then + echo "STALE_WITH_STABLE_INSTALL" +fi +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"STALE_DESPITE_CHANGED_INSTALL"* ]] || return 1 + [[ "$output" != *"STALE_DESPITE_AMBIGUOUS_INSTALL"* ]] || return 1 + [[ "$output" == *"STALE_WITH_STABLE_INSTALL"* ]] || return 1 } diff --git a/Resources/mole/tests/clean_hints.bats b/Resources/mole/tests/clean_hints.bats index 04ab24f..dd04d52 100644 --- a/Resources/mole/tests/clean_hints.bats +++ b/Resources/mole/tests/clean_hints.bats @@ -12,24 +12,36 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "${HOME:?}"/* + rm -rf "${HOME:?}"/.[!.]* "${HOME:?}"/..?* 2> /dev/null || true mkdir -p "$HOME/.config/mole" } +teardown() { + rm -rf "$HOME/Library/LaunchAgents" +} + @test "probe_project_artifact_hints reuses purge targets and excludes noisy names" { local root="$HOME/hints-root" mkdir -p "$root/proj/node_modules" "$root/proj/vendor" "$root/proj/bin" touch "$root/proj/package.json" printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOT1' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT1' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/hints.sh" @@ -40,14 +52,14 @@ printf 'examples=%s\n' "${PROJECT_ARTIFACT_HINT_EXAMPLES[*]}" EOT1 [ "$status" -eq 0 ] - [[ "$output" == *"count=1"* ]] - [[ "$output" == *"node_modules"* ]] - [[ "$output" != *"vendor"* ]] + [[ "$output" == *"count=1"* ]] || return 1 + [[ "$output" == *"node_modules"* ]] || return 1 + [[ "$output" != *"vendor"* ]] || return 1 [[ "$output" != *"/bin"* ]] } @test "show_project_artifact_hint_notice renders sampled summary" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOT2' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT2' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/hints.sh" @@ -66,34 +78,324 @@ show_project_artifact_hint_notice EOT2 [ "$status" -eq 0 ] - [[ "$output" == *"5+"* ]] - [[ "$output" == *"at least 2.00MB sampled from 2 items"* ]] - [[ "$output" == *"Examples:"* ]] - [[ "$output" == *"Review: mo purge"* ]] + [[ "$output" == *"Build artifacts"* ]] || return 1 + [[ "$output" == *"5+ dirs, 2.00MB+"* ]] || return 1 + [[ "$output" == *"mo purge"* ]] || return 1 } -@test "show_system_data_hint_notice reports large clue paths" { - mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" +@test "show_project_artifact_hint_notice points zero-size samples to include-empty (#869)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT2B' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +probe_project_artifact_hints() { + PROJECT_ARTIFACT_HINT_DETECTED=true + PROJECT_ARTIFACT_HINT_COUNT=1 + PROJECT_ARTIFACT_HINT_TRUNCATED=false + PROJECT_ARTIFACT_HINT_EXAMPLES=("~/www/demo/node_modules") + PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 + PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=1 + PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false +} +bytes_to_human() { echo "0B"; } +note_activity() { :; } +show_project_artifact_hint_notice +EOT2B - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOT3' + [ "$status" -eq 0 ] + [[ "$output" == *", 0B"* ]] || return 1 + [[ "$output" == *"mo purge --include-empty"* ]] || return 1 +} + +@test "show_project_artifact_hint_notice reports skipped slow project artifact scans (#1053)" { + local root="$HOME/Library/CloudStorage" + mkdir -p "$root" + printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT2C' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/hints.sh" run_with_timeout() { shift - if [[ "${1:-}" == "du" ]]; then - printf '3145728 %s\n' "${4:-/tmp}" - return 0 + return 124 +} +note_activity() { :; } +show_project_artifact_hint_notice +EOT2C + + [ "$status" -eq 0 ] + [[ "$output" == *"Build artifacts · scan skipped"* ]] || return 1 + [[ "$output" == *"mo purge"* ]] || return 1 +} + +@test "probe_project_artifact_hints stops at the wall-clock budget (#1053)" { + local root="$HOME/hints-root" + mkdir -p "$root/proj/node_modules" + touch "$root/proj/package.json" + printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TIMEOUT_HINT_SCAN_SEC=0 \ + /bin/bash --noprofile --norc << 'EOT2D' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +run_with_timeout() { shift; "$@"; } +probe_project_artifact_hints +printf 'count=%s\n' "$PROJECT_ARTIFACT_HINT_COUNT" +printf 'skipped=%s\n' "$PROJECT_ARTIFACT_HINT_SCAN_SKIPPED" +EOT2D + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] || return 1 + [[ "$output" == *"skipped=true"* ]] +} + +@test "probe_project_artifact_hints respects budget inside nested-dir loop (#1053)" { + # Regression: old code had no deadline check inside the nested-dir while loop. + # When a single scan root is used the outer-root deadline guard never fires for + # the second time (the loop ends before the next iteration), so the nested loop + # could run unchecked after SECONDS crossed the deadline. + # + # Setup: one root with one project containing two nested sub-projects, each + # with a build/ artifact. hint_collect_child_dirs_with_timeout sleeps 2s on + # the nested call so SECONDS advances past the 1s budget before the nested-dir + # while loop starts. + # + # New code: deadline fires on the FIRST nested-dir iteration → count=0, skipped=true. + # Old code: nested loop runs without a deadline check → count=2, skipped=false. + local root="$HOME/hints-deadline-nested" + mkdir -p "$root/bigproject/sub1/build" + mkdir -p "$root/bigproject/sub2/build" + touch "$root/bigproject/package.json" + printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TIMEOUT_HINT_SCAN_SEC=1 \ + HINTS_ROOT="$root" \ + /bin/bash --noprofile --norc << 'EOT_NESTED' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +run_with_timeout() { shift; "$@"; } +hint_collect_child_dirs_with_timeout() { + local dir="$1" out="$2" + if [[ "$dir" == "$HINTS_ROOT" ]]; then + printf '%s\0' "$HINTS_ROOT/bigproject" >> "$out" + else + # Simulate a slow nested find that lets SECONDS cross the 1s budget. + sleep 2 + printf '%s\0' "$HINTS_ROOT/bigproject/sub1" "$HINTS_ROOT/bigproject/sub2" >> "$out" fi - "$@" } -bytes_to_human() { echo "3.00GB"; } +probe_project_artifact_hints +printf 'count=%s\n' "$PROJECT_ARTIFACT_HINT_COUNT" +printf 'skipped=%s\n' "$PROJECT_ARTIFACT_HINT_SCAN_SKIPPED" +EOT_NESTED + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] || return 1 + [[ "$output" == *"skipped=true"* ]] +} + +@test "show_user_launch_agent_hint_notice reports missing app-backed target" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.stale.plist" << 'PLIST' + + + + + Label + com.example.stale + ProgramArguments + + /Applications/Missing.app/Contents/MacOS/Missing + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT4' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +show_user_launch_agent_hint_notice +EOT4 + + [ "$status" -eq 0 ] + [[ "$output" == *"Stale login item · ~/Library/LaunchAgents/com.example.stale.plist"* ]] || return 1 + [[ "$output" == *"Missing app/helper target"* ]] || return 1 + [[ "$output" == *"review before removing"* ]] || return 1 +} + +@test "show_user_launch_agent_hint_notice trusts an existing executable Program target (#1262)" { + local updater="$HOME/Library/Application Support/Google/GoogleUpdater/GoogleUpdater.app/Contents/MacOS/GoogleUpdater" + mkdir -p "$HOME/Library/LaunchAgents" "$(dirname "$updater")" + touch "$updater" + chmod +x "$updater" + cat > "$HOME/Library/LaunchAgents/com.google.GoogleUpdater.wake.plist" << PLIST + + + + + Label + com.google.GoogleUpdater.wake + ProgramArguments + + $updater + --wake + + AssociatedBundleIdentifiers + + com.google.GoogleUpdater + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" UPDATER="$updater" /bin/bash --noprofile --norc << 'EOT4A' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +bundle_has_installed_app() { return 0; } note_activity() { :; } -show_system_data_hint_notice -EOT3 + +live_output=$(show_user_launch_agent_hint_notice) +[[ "$live_output" != *"Stale login item"* ]] + +chmod -x "$UPDATER" +show_user_launch_agent_hint_notice + +chmod +x "$UPDATER" +command rm -f -- "$UPDATER" +show_user_launch_agent_hint_notice +EOT4A + + [ "$status" -eq 0 ] + [[ "$output" == *"Stale login item · ~/Library/LaunchAgents/com.google.GoogleUpdater.wake.plist"* ]] || return 1 + [[ "$output" == *"Program target is not executable"* ]] || return 1 + [[ "$output" == *"Missing app/helper target"* ]] || return 1 +} + +@test "show_user_launch_agent_hint_notice gives Program precedence over ProgramArguments.0" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.program-precedence.plist" << 'PLIST' + + + + + Label + com.example.program-precedence + Program + /Applications/Missing.app/Contents/MacOS/Missing + ProgramArguments + + /bin/bash + --version + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT4B' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +show_user_launch_agent_hint_notice +EOT4B + + [ "$status" -eq 0 ] + [[ "$output" == *"Stale login item · ~/Library/LaunchAgents/com.example.program-precedence.plist"* ]] || return 1 + [[ "$output" == *"Missing app/helper target"* ]] || return 1 +} + +@test "show_user_launch_agent_hint_notice skips custom shell wrappers" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.custom.plist" << 'PLIST' + + + + + Label + com.example.custom + ProgramArguments + + /bin/bash + -c + $HOME/bin/custom-task + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT5' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +show_user_launch_agent_hint_notice +EOT5 + + [ "$status" -eq 0 ] + [[ "$output" != *"Stale login item"* ]] || return 1 +} + +@test "show_user_launch_agent_hint_notice skips MachServices-only plists" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.google.keystone.agent.plist" << 'PLIST' + + + + + Label + com.google.keystone.agent + MachServices + + com.google.Keystone.Agent + + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT6' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +show_user_launch_agent_hint_notice +EOT6 + + [ "$status" -eq 0 ] + [[ "$output" != *"Stale login item"* ]] || return 1 + [[ "$output" != *"Associated app not found"* ]] || return 1 +} + +@test "project artifact hint shows a loading state while probing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT7' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +start_section_spinner() { echo "SPIN:$1"; } +stop_section_spinner() { echo "SPIN-STOP"; } +# The probe itself can take up to its 15s budget; the section must show a +# loading state for that whole window, not pop the row out of silence. +probe_project_artifact_hints() { + echo "PROBE" + PROJECT_ARTIFACT_HINT_DETECTED=false + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=false +} +show_project_artifact_hint_notice +EOT7 [ "$status" -eq 0 ] - [[ "$output" == *"Xcode DerivedData: 3.00GB"* ]] - [[ "$output" == *"~/Library/Developer/Xcode/DerivedData"* ]] - [[ "$output" == *"Review: mo analyze, Device backups, docker system df"* ]] + [[ "$output" == *"SPIN:Scanning project artifacts..."* ]] || return 1 + # Spinner starts before the probe runs and stops after it. + [[ "$output" == *"SPIN:Scanning project artifacts... +PROBE +SPIN-STOP"* ]] } diff --git a/Resources/mole/tests/clean_mail_downloads.bats b/Resources/mole/tests/clean_mail_downloads.bats new file mode 100644 index 0000000..592ee5a --- /dev/null +++ b/Resources/mole/tests/clean_mail_downloads.bats @@ -0,0 +1,213 @@ +#!/usr/bin/env bats + +# Regression for #1344: a Mail Downloads directory that cannot be sized within +# the disk-verify timeout (exit 124) must be skipped as a single target, not +# end the whole `mo clean` run at User essentials. Signal-class statuses +# (>=128) keep their cancellation semantics. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-mail-downloads.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-mail-downloads."* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-mail-downloads."* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + rm -rf "$HOME/Library" +} + +@test "mail dir sizing timeout (124) skips the target and keeps the run going" { + mkdir -p "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 1; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +get_path_size_kb() { return 124; } +_clean_mail_downloads +echo "RC=$?" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Mail Downloads · skipped (sizing unavailable)"* ]] || return 1 + [[ "$output" == *"RC=0"* ]] || return 1 +} + +@test "mail dir sizing timeout (124) skips one dir and still cleans the other" { + mkdir -p "$HOME/Library/Mail Downloads" + echo x > "$HOME/Library/Mail Downloads/old.bin" + touch -t 202401010000 "$HOME/Library/Mail Downloads/old.bin" + mkdir -p "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 1; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +safe_remove() { echo "REMOVE:$1"; return 0; } +get_path_size_kb() { + if [[ "$1" == "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" ]]; then + return 124 + fi + if [[ "$1" == "$HOME/Library/Mail Downloads" ]]; then + echo "6000" + return 0 + fi + echo "6000" +} +_clean_mail_downloads +echo "RC=$?" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"REMOVE:$HOME/Library/Mail Downloads/old.bin"* ]] || return 1 + [[ "$output" == *"Mail Downloads · skipped (sizing unavailable)"* ]] || return 1 + [[ "$output" == *"RC=0"* ]] || return 1 +} + +@test "mail attachment sizing timeout (124) skips the file without aborting" { + mkdir -p "$HOME/Library/Mail Downloads" + echo x > "$HOME/Library/Mail Downloads/stuck.bin" + touch -t 202401010000 "$HOME/Library/Mail Downloads/stuck.bin" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 1; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "DEBUG:$*"; } +safe_remove() { echo "REMOVE:$1"; return 0; } +get_path_size_kb() { + if [[ "$1" == "$HOME/Library/Mail Downloads" ]]; then + echo "6000" + return 0 + fi + return 124 +} +_clean_mail_downloads +echo "RC=$?" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Mail attachment sizing failed"* ]] || return 1 + [[ "$output" != *"REMOVE:"* ]] || return 1 + [[ "$output" == *"RC=0"* ]] || return 1 +} + +@test "mail dir sizing signal (130) still cancels the run" { + mkdir -p "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 1; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +get_path_size_kb() { return 130; } +_clean_mail_downloads +echo "RC=$?" +EOF + + [ "$status" -eq 130 ] || return 1 + [[ "$output" != *"skipped (sizing unavailable)"* ]] || return 1 +} + +@test "mo clean completes when the Mail Downloads du stalls (#1344)" { + mkdir -p "$HOME/Library/Caches" + mkdir -p "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" + echo x > "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/old.docx" + + SHIM_DIR="$(mktemp -d "${BATS_TEST_TMPDIR:-/tmp}/mail-shim.XXXXXX")" + cat > "$SHIM_DIR/du" << 'SHIM' +#!/bin/bash +for a in "$@"; do + if [[ "$a" == *"Mail Downloads"* ]]; then + /bin/sleep 60 + exit 124 + fi +done +exec /usr/bin/du "$@" +SHIM + chmod +x "$SHIM_DIR/du" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + PATH="$SHIM_DIR:$PATH" MOLE_TIMEOUT_DISK_VERIFY_SEC=2 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +# Stub every other section so the run reaches the summary quickly; only +# clean_user_essentials (which contains _clean_mail_downloads) stays real. +for fn in clean_finder_metadata clean_app_caches clean_browsers \ + run_cloud_and_office_cleanup clean_developer_tools \ + clean_user_gui_applications clean_virtualization_tools \ + clean_application_support_logs clean_orphaned_app_data \ + clean_orphaned_system_services clean_orphaned_container_stubs \ + show_user_launch_agent_hint_notice \ + clean_apple_silicon_caches clean_cached_device_firmware \ + clean_time_machine_failed_backups check_large_file_candidates \ + show_project_artifact_hint_notice; do + eval "$fn() { return 0; }" +done +run_with_shell_timeout() { return 0; } +perform_cleanup +EOF + + rm -rf "$SHIM_DIR" + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Cleanup complete"* ]] || return 1 + [[ "$output" == *"Mail Downloads · skipped (sizing unavailable)"* ]] || return 1 +} + +@test "mail dir sizing hard failure (1) skips the target and keeps the run going (#1366)" { + # On Intel with macOS 26 the protected Mail container refuses du + # outright: rc=1 immediately, no timeout. That must skip exactly like + # the 124 case instead of cancelling the whole run. + mkdir -p "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 1; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +get_path_size_kb() { return 1; } +_clean_mail_downloads +echo "RC=$?" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Mail Downloads · skipped (sizing unavailable)"* ]] || return 1 + [[ "$output" == *"RC=0"* ]] || return 1 +} diff --git a/Resources/mole/tests/clean_misc.bats b/Resources/mole/tests/clean_misc.bats index 31282bb..114712a 100644 --- a/Resources/mole/tests/clean_misc.bats +++ b/Resources/mole/tests/clean_misc.bats @@ -10,18 +10,24 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-extras.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } @test "clean_cloud_storage calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -31,27 +37,52 @@ clean_cloud_storage EOF [ "$status" -eq 0 ] - [[ "$output" == *"Dropbox cache"* ]] + [[ "$output" == *"Dropbox cache"* ]] || return 1 [[ "$output" == *"Google Drive cache"* ]] } @test "clean_virtualization_tools hits cache paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" stop_section_spinner() { :; } +pgrep() { return 1; } +safe_clean() { echo "$2|$1"; } +clean_virtualization_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"VMware Fusion cache"* ]] || return 1 + [[ "$output" == *"Parallels cache"* ]] || return 1 + [[ "$output" == *"UTM app cache|$HOME/Library/Caches/com.utmapp.UTM/"* ]] || return 1 + [[ "$output" == *"UTM sandbox cache|$HOME/Library/Containers/com.utmapp.UTM/Data/Library/Caches/"* ]] || return 1 + [[ "$output" == *"UTM temporary files|$HOME/Library/Containers/com.utmapp.UTM/Data/tmp/"* ]] +} + +@test "clean_virtualization_tools skips UTM caches while UTM is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +debug_log() { :; } +pgrep() { + [[ "${1:-}" == "-x" && "${2:-}" == "UTM" ]] +} safe_clean() { echo "$2"; } clean_virtualization_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"VMware Fusion cache"* ]] - [[ "$output" == *"Parallels cache"* ]] + [[ "$output" == *"VMware Fusion cache"* ]] || return 1 + [[ "$output" == *"Parallels cache"* ]] || return 1 + [[ "$output" != *"UTM app cache"* ]] || return 1 + [[ "$output" != *"UTM sandbox cache"* ]] } @test "clean_email_clients calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -59,58 +90,239 @@ clean_email_clients EOF [ "$status" -eq 0 ] - [[ "$output" == *"Spark cache"* ]] + [[ "$output" == *"Spark cache"* ]] || return 1 [[ "$output" == *"Airmail cache"* ]] } -@test "clean_note_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_virtualization_tools includes Lima download cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_note_apps +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +safe_clean() { echo "$2|$1"; } +clean_virtualization_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Notion cache"* ]] - [[ "$output" == *"Obsidian cache"* ]] + [[ "$output" == *"Lima download cache|$HOME/Library/Caches/lima/download/by-url-sha256/"* ]] } -@test "clean_task_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_tart_caches runs only the native cache-only age prune" { + rm -rf "$HOME/.tart" "$HOME/tart-args" "$HOME/tart-payload" + mkdir -p "$HOME/.tart/cache/OCIs" + : > "$HOME/tart-payload" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_task_apps +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { [[ -e "$HOME/tart-payload" ]] && echo 4096 || echo 1024; } +bytes_to_human() { echo "$1 bytes"; } +run_with_timeout() { shift; "$@"; } +tart() { + printf '%s\n' "$*" > "$HOME/tart-args" + rm -f "$HOME/tart-payload" +} +clean_tart_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"Todoist cache"* ]] - [[ "$output" == *"Any.do cache"* ]] + [ "$(< "$HOME/tart-args")" = "prune --entries caches --older-than 30" ] || return 1 + [[ "$output" == *"Tart caches · pruned"* ]] || return 1 + [[ "$output" != *"--entries vms"* ]] || return 1 } -@test "scan_external_volumes skips when no volumes" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_tart_caches dry-run shows size, policy, and exact command without execution" { + rm -rf "$HOME/.tart" "$HOME/tart-called" + mkdir -p "$HOME/.tart/cache/IPSWs" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +note_activity() { :; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo 2048; } +bytes_to_human() { echo "2MB"; } +tart() { : > "$HOME/tart-called"; } +clean_tart_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"would prune items older than 30 days (2MB)"* ]] || return 1 + [[ "$output" == *"tart prune --entries caches --older-than 30"* ]] || return 1 + [ ! -e "$HOME/tart-called" ] || return 1 +} + +@test "clean_tart_caches skips active and whitelisted caches" { + rm -rf "$HOME/.tart" + mkdir -p "$HOME/.tart/cache/OCIs" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +note_activity() { :; } +pgrep() { [[ "$1" == "-x" && "$2" == "tart" ]]; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo 1024; } +bytes_to_human() { echo "1MB"; } +tart() { echo "TART_CALLED"; } +clean_tart_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"skipped (Tart running)"* ]] || return 1 + [[ "$output" != *"TART_CALLED"* ]] || return 1 + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +note_activity() { :; } +is_path_whitelisted() { [[ "$1" == "$HOME/.tart/cache" ]]; } +get_path_size_kb() { echo 1024; } +tart() { echo "TART_CALLED"; } +clean_tart_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"would skip (whitelist)"* ]] || return 1 + [[ "$output" != *"TART_CALLED"* ]] || return 1 +} + +@test "clean_tart_caches fails closed when its process probe errors" { + rm -rf "$HOME/.tart" "$HOME/tart-called" + mkdir -p "$HOME/.tart/cache/OCIs" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 2; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo 1024; } +note_activity() { :; } +tart() { : > "$HOME/tart-called"; } +clean_tart_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Tart caches · skipped (process state unknown)"* ]] || return 1 + [ ! -e "$HOME/tart-called" ] +} + +@test "clean_tart_caches rechecks activity before native prune" { + rm -rf "$HOME/.tart" "$HOME/tart-called" "$HOME/tart-probes" + mkdir -p "$HOME/.tart/cache/OCIs" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail -export DRY_RUN="false" source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" -run_with_timeout() { return 1; } -# Mock missing dependencies and UI to ensure test passes regardless of volumes -clean_ds_store_tree() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } +pgrep() { + printf 'probe\n' >> "$HOME/tart-probes" + [[ $(wc -l < "$HOME/tart-probes" | tr -d ' ') -ge 2 ]] +} is_path_whitelisted() { return 1; } -WHITELIST_PATTERNS=() -PROTECT_FINDER_METADATA="false" -scan_external_volumes +get_path_size_kb() { echo 1024; } +note_activity() { :; } +defer_cleanup_family() { echo "DEFER:$1"; } +tart() { : > "$HOME/tart-called"; } +clean_tart_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Tart"* ]] || return 1 + [ ! -e "$HOME/tart-called" ] +} + +@test "clean_tart_caches reports native prune failure without claiming success" { + rm -rf "$HOME/.tart" + mkdir -p "$HOME/.tart/cache/OCIs" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo 1024; } +bytes_to_human() { echo "1MB"; } +run_with_timeout() { shift; "$@"; } +tart() { return 7; } +clean_tart_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Tart caches · prune failed"* ]] || return 1 + [[ "$output" != *"Tart caches · pruned"* ]] || return 1 +} + +@test "clean_tart_caches is silent without Tart or a cache" { + rm -rf "$HOME/.tart" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false PATH="/usr/bin:/bin" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +clean_tart_caches +mkdir -p "$HOME/.tart/cache/OCIs" +clean_tart_caches EOF [ "$status" -eq 0 ] + [ -z "$output" ] || return 1 +} + +@test "clean_note_apps calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_note_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Notion cache"* ]] || return 1 + [[ "$output" == *"Obsidian cache"* ]] +} + +@test "clean_task_apps calls expected caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_task_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Todoist cache"* ]] || return 1 + [[ "$output" == *"Any.do cache"* ]] } @test "clean_video_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -118,12 +330,12 @@ clean_video_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"ScreenFlow cache"* ]] + [[ "$output" == *"ScreenFlow cache"* ]] || return 1 [[ "$output" == *"Final Cut Pro cache"* ]] } @test "clean_video_players calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -131,25 +343,46 @@ clean_video_players EOF [ "$status" -eq 0 ] - [[ "$output" == *"IINA cache"* ]] + [[ "$output" == *"IINA cache"* ]] || return 1 [[ "$output" == *"VLC cache"* ]] } @test "clean_3d_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + # Autodesk is only swept when a cache tree exists (#1390 made the row + # conditional on real targets), so the fixture has to provide one or the + # assertion below passes on an empty glob and proves nothing. + mkdir -p "$HOME/Library/Caches/com.autodesk.AcCoreConsole" + touch "$HOME/Library/Caches/com.autodesk.AcCoreConsole/Cache.db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" +# Autodesk moved behind the live-owner guard in #1390, so a case that only +# stubs safe_clean would silently stop seeing its row. Supply the guarded +# path too, and an empty process table so the verdict does not depend on +# whether Fusion happens to be running here. +pgrep() { return 1; } +ps() { printf ' PID PPID COMM ARGS\n'; } safe_clean() { echo "$2"; } +safe_clean_guarded() { + local guard="$1" + shift + "$guard" || return 75 + local count=$# + echo "${!count}" +} clean_3d_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Blender cache"* ]] + [[ "$output" == *"Blender cache"* ]] || return 1 [[ "$output" == *"Cinema 4D cache"* ]] + [[ "$output" == *"Autodesk cache"* ]] || return 1 } @test "clean_gaming_platforms calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -157,12 +390,12 @@ clean_gaming_platforms EOF [ "$status" -eq 0 ] - [[ "$output" == *"Steam cache"* ]] + [[ "$output" == *"Steam cache"* ]] || return 1 [[ "$output" == *"Epic Games cache"* ]] } @test "clean_translation_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -170,12 +403,12 @@ clean_translation_apps EOF [ "$status" -eq 0 ] - [[ "$output" == *"Youdao Dictionary cache"* ]] + [[ "$output" == *"Youdao Dictionary cache"* ]] || return 1 [[ "$output" == *"Eudict cache"* ]] } @test "clean_launcher_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -183,12 +416,12 @@ clean_launcher_apps EOF [ "$status" -eq 0 ] - [[ "$output" == *"Alfred cache"* ]] + [[ "$output" == *"Alfred cache"* ]] || return 1 [[ "$output" == *"The Unarchiver cache"* ]] } @test "clean_remote_desktop calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -196,12 +429,12 @@ clean_remote_desktop EOF [ "$status" -eq 0 ] - [[ "$output" == *"TeamViewer cache"* ]] + [[ "$output" == *"TeamViewer cache"* ]] || return 1 [[ "$output" == *"AnyDesk cache"* ]] } @test "clean_system_utils calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -209,12 +442,12 @@ clean_system_utils EOF [ "$status" -eq 0 ] - [[ "$output" == *"Input Source Pro cache"* ]] + [[ "$output" == *"Input Source Pro cache"* ]] || return 1 [[ "$output" == *"WakaTime cache"* ]] } @test "clean_shell_utils calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } @@ -222,6 +455,6 @@ clean_shell_utils EOF [ "$status" -eq 0 ] - [[ "$output" == *"Zsh completion cache"* ]] + [[ "$output" == *"Zsh completion cache"* ]] || return 1 [[ "$output" == *"wget HSTS cache"* ]] } diff --git a/Resources/mole/tests/clean_summary_cancel.bats b/Resources/mole/tests/clean_summary_cancel.bats new file mode 100644 index 0000000..877eac8 --- /dev/null +++ b/Resources/mole/tests/clean_summary_cancel.bats @@ -0,0 +1,159 @@ +#!/usr/bin/env bats + +# Regression for #1342: when a cleanup scan/size check hits its internal +# timeout (exit 124), `mo clean` must still print the final summary with an +# explicit reason instead of exiting silently mid-run. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-summary-cancel.XXXXXX")" + export HOME + + mkdir -p "$HOME/Library/Caches" + mkdir -p "$HOME/.config/mole" + for i in 1 2 3 4 5; do + mkdir -p "$HOME/Library/Caches/cachedir$i" + done +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-clean-summary-cancel."* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-clean-summary-cancel."* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi +} + +run_perform_cleanup_with() { + export SECTION_RC="$1" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +# Stub every section so perform_cleanup never scans the real machine. +# run_with_shell_timeout is stubbed too: its shell-fallback killer process +# would otherwise outlive the test when pgrep is unavailable. +for fn in clean_user_essentials clean_finder_metadata clean_app_caches \ + clean_browsers run_cloud_and_office_cleanup clean_developer_tools \ + clean_user_gui_applications clean_virtualization_tools \ + clean_application_support_logs clean_orphaned_app_data \ + clean_orphaned_system_services clean_orphaned_container_stubs \ + show_user_launch_agent_hint_notice \ + clean_apple_silicon_caches clean_cached_device_firmware \ + clean_time_machine_failed_backups check_large_file_candidates \ + show_project_artifact_hint_notice; do + eval "$fn() { return 0; }" +done +run_with_shell_timeout() { return 0; } +clean_user_essentials() { return "$SECTION_RC"; } +perform_cleanup +EOF +} + +@test "sizing timeout (124) still prints the summary (#1342)" { + run_perform_cleanup_with 124 + + [ "$status" -eq 124 ] + [[ "$output" == *"Cleanup cancelled"* ]] + [[ "$output" == *"timed out (exit 124)"* ]] + [[ "$output" == *"Remaining cleanup was skipped"* ]] +} + +@test "interrupted section (>=128) prints an interrupted summary" { + run_perform_cleanup_with 130 + + [ "$status" -eq 130 ] + [[ "$output" == *"Cleanup interrupted"* ]] + [[ "$output" == *"was interrupted (exit 130)"* ]] +} + +@test "successful run still prints a complete summary" { + run_perform_cleanup_with 0 + + [ "$status" -eq 0 ] + [[ "$output" == *"Cleanup complete"* ]] + [[ "$output" != *"Cleanup cancelled"* ]] +} + +@test "run with removal timeouts completes and reports them (#1384)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +# Stub every section so perform_cleanup never scans the real machine. +for fn in clean_user_essentials clean_finder_metadata clean_app_caches \ + clean_browsers run_cloud_and_office_cleanup clean_developer_tools \ + clean_user_gui_applications clean_virtualization_tools \ + clean_application_support_logs clean_orphaned_app_data \ + clean_orphaned_system_services clean_orphaned_container_stubs \ + show_user_launch_agent_hint_notice \ + clean_apple_silicon_caches clean_cached_device_firmware \ + clean_time_machine_failed_backups check_large_file_candidates \ + show_project_artifact_hint_notice; do + eval "$fn() { return 0; }" +done +run_with_shell_timeout() { return 0; } +clean_user_essentials() { MOLE_CLEAN_REMOVAL_TIMEOUTS=3; return 0; } +perform_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cleanup complete"* ]] + [[ "$output" != *"Cleanup cancelled"* ]] + [[ "$output" == *"3 item(s) exceeded the 30s removal budget"* ]] +} + +@test "sizing timeouts still clean and the summary reports the under-count (#1374)" { + mkdir -p "$HOME/Library/Caches/cache1374" + printf x > "$HOME/Library/Caches/cache1374/file.bin" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +# Stub every section so perform_cleanup never scans the real machine. +# run_with_shell_timeout is stubbed too: its shell-fallback killer process +# would otherwise outlive the test when pgrep is unavailable. +for fn in clean_finder_metadata clean_app_caches \ + clean_browsers run_cloud_and_office_cleanup clean_developer_tools \ + clean_user_gui_applications clean_virtualization_tools \ + clean_application_support_logs clean_orphaned_app_data \ + clean_orphaned_system_services clean_orphaned_container_stubs \ + show_user_launch_agent_hint_notice \ + clean_apple_silicon_caches clean_cached_device_firmware \ + clean_time_machine_failed_backups check_large_file_candidates \ + show_project_artifact_hint_notice; do + eval "$fn() { return 0; }" +done +run_with_shell_timeout() { return 0; } +# Force every size check to hit the sizing budget. +get_cleanup_path_size_kb() { return 124; } +clean_user_essentials() { + safe_clean "$HOME/Library/Caches/cache1374" "User app cache" +} +perform_cleanup +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Cleanup complete"* ]] || return 1 + [[ "$output" != *"Cleanup cancelled"* ]] || return 1 + [[ "$output" == *"size-check budget"* ]] || return 1 + [[ "$output" == *"under-reported"* ]] || return 1 + [[ ! -e "$HOME/Library/Caches/cache1374" ]] +} diff --git a/Resources/mole/tests/clean_system_caches.bats b/Resources/mole/tests/clean_system_caches.bats index 0275c70..a033aba 100644 --- a/Resources/mole/tests/clean_system_caches.bats +++ b/Resources/mole/tests/clean_system_caches.bats @@ -17,13 +17,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" @@ -39,7 +46,7 @@ setup() { } @test "check_tcc_permissions skips in non-interactive mode" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions" < /dev/null + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions" < /dev/null [ "$status" -eq 0 ] [[ ! -f "$HOME/.cache/mole/permissions_granted" ]] } @@ -48,22 +55,22 @@ setup() { mkdir -p "$HOME/.cache/mole" touch "$HOME/.cache/mole/permissions_granted" - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; [[ -t 1 ]] || true; check_tcc_permissions" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; [[ -t 1 ]] || true; check_tcc_permissions" [ "$status" -eq 0 ] } @test "check_tcc_permissions validates protected directories" { - [[ -d "$HOME/Library/Caches" ]] - [[ -d "$HOME/Library/Logs" ]] - [[ -d "$HOME/.cache/mole" ]] + [[ -d "$HOME/Library/Caches" ]] || return 1 + [[ -d "$HOME/Library/Logs" ]] || return 1 + [[ -d "$HOME/.cache/mole" ]] || return 1 - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions < /dev/null" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions < /dev/null" [ "$status" -eq 0 ] } @test "clean_service_worker_cache returns early when path doesn't exist" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; clean_service_worker_cache 'TestBrowser' '/nonexistent/path'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; clean_service_worker_cache 'TestBrowser' '/nonexistent/path'" [ "$status" -eq 0 ] } @@ -71,11 +78,11 @@ setup() { local test_cache="$HOME/test_sw_cache" mkdir -p "$test_cache" - run bash -c " - run_with_timeout() { shift; \"\$@\"; } - export -f run_with_timeout + run /bin/bash --noprofile --norc -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/caches.sh' + run_with_timeout() { shift; \"\$@\"; } + export -f run_with_timeout clean_service_worker_cache 'TestBrowser' '$test_cache' " [ "$status" -eq 0 ] @@ -88,7 +95,11 @@ setup() { mkdir -p "$test_cache/abc123_https_capcut.com_0" mkdir -p "$test_cache/def456_https_example.com_0" - run bash -c " + run /bin/bash -c " + export DRY_RUN=true + export PROTECTED_SW_DOMAINS=(capcut.com photopea.com) + source '$PROJECT_ROOT/lib/core/common.sh' + source '$PROJECT_ROOT/lib/clean/caches.sh' run_with_timeout() { local timeout=\"\$1\" shift @@ -105,19 +116,199 @@ setup() { \"\$@\" } export -f run_with_timeout - export DRY_RUN=true - export PROTECTED_SW_DOMAINS=(capcut.com photopea.com) + clean_service_worker_cache 'TestBrowser' '$test_cache' + " + [ "$status" -eq 0 ] + + [[ -d "$test_cache/abc123_https_capcut.com_0" ]] || return 1 + + rm -rf "$test_cache" +} + +# Regression for #724: MV3 extension SW caches are keyed by origin hash, +# so the PROTECTED_SW_DOMAINS domain-match never fires for them. The +# whitelist is the only escape hatch users have, respect it here. +@test "clean_service_worker_cache honors is_path_whitelisted (#724)" { + local test_cache="$HOME/test_sw_cache_wl" + mkdir -p "$test_cache/abc123hash_extension" + mkdir -p "$test_cache/def456hash_other" + + run /bin/bash -c " + export DRY_RUN=false + export PROTECTED_SW_DOMAINS=(nomatch.invalid) source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/caches.sh' + WHITELIST_PATTERNS=('$test_cache/abc123hash_extension') + safe_remove() { echo \"REMOVE:\$1\"; return 0; } + export -f safe_remove + note_activity() { :; } + export -f note_activity + run_with_timeout() { + local timeout=\"\$1\" + shift + if [[ \"\$1\" == \"sh\" ]]; then + printf '%s\n' '$test_cache/abc123hash_extension' '$test_cache/def456hash_other' + return 0 + fi + if [[ \"\$1\" == \"du\" ]]; then + printf '2048\t%s\n' \"\$3\" + return 0 + fi + \"\$@\" + } + export -f run_with_timeout clean_service_worker_cache 'TestBrowser' '$test_cache' " + [ "$status" -eq 0 ] + # Whitelisted dir must never be passed to safe_remove + [[ "$output" != *"REMOVE:$test_cache/abc123hash_extension"* ]] || return 1 + # Non-whitelisted dir must be removed + [[ "$output" == *"REMOVE:$test_cache/def456hash_other"* ]] || return 1 + # UI reports the protection count + [[ "$output" == *"1 protected"* ]] || return 1 - [[ -d "$test_cache/abc123_https_capcut.com_0" ]] + rm -rf "$test_cache" +} + +@test "clean_service_worker_cache colors cleaned size with success color" { + local test_cache="$HOME/test_sw_cache_colored" + mkdir -p "$test_cache/abc123_https_example.com_0" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc < "$HOME/.config/mole/purge_paths" source "$PROJECT_ROOT/lib/core/common.sh" @@ -175,7 +488,7 @@ safe_clean() { echo "$2|$1"; } clean_project_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"Next.js build cache"* ]] + [[ "$output" == *"Next.js build cache"* ]] || return 1 grep -q -- "-P $HOME/CustomProjects " "$find_log" run grep -q -- "-P $HOME " "$find_log" [ "$status" -eq 1 ] @@ -188,7 +501,7 @@ EOF touch "$HOME/go/src/demo/go.mod" touch "$HOME/go/src/demo/.next/cache/test.cache" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" @@ -196,7 +509,7 @@ safe_clean() { echo "$2|$1"; } clean_project_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"Next.js build cache|$HOME/go/src/demo/.next/cache/test.cache"* ]] + [[ "$output" == *"Next.js build cache|$HOME/go/src/demo/.next/cache/test.cache"* ]] || return 1 rm -rf "$HOME/go" } @@ -206,7 +519,7 @@ EOF touch "$HOME/go/src/github.com/example/demo/go.mod" touch "$HOME/go/src/github.com/example/demo/.next/cache/test.cache" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" @@ -214,11 +527,30 @@ safe_clean() { echo "$2|$1"; } clean_project_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"Next.js build cache|$HOME/go/src/github.com/example/demo/.next/cache/test.cache"* ]] + [[ "$output" == *"Next.js build cache|$HOME/go/src/github.com/example/demo/.next/cache/test.cache"* ]] || return 1 rm -rf "$HOME/go" } +@test "discover_project_cache_roots dedupes aliased roots by filesystem identity" { + mkdir -p "$HOME/code/demo/.dart_tool" + touch "$HOME/code/demo/pubspec.yaml" + mkdir -p "$HOME/.config/mole" + ln -s "$HOME/code" "$HOME/Code" + printf '%s\n' "$HOME/Code" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/caches.sh" +roots=$(discover_project_cache_roots) +printf '%s\n' "$roots" +printf 'COUNT=%s\n' "$(printf '%s\n' "$roots" | sed '/^$/d' | wc -l | tr -d ' ')" +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=1"* ]] +} + @test "clean_project_caches skips stalled root scans" { mkdir -p "$HOME/.config/mole" mkdir -p "$HOME/SlowProjects/app" @@ -240,14 +572,14 @@ for arg in "\$@"; do done if [[ "\$root" == "$HOME/SlowProjects" ]]; then trap "" TERM - sleep 30 + sleep 5 exit 0 fi exit 0 EOF chmod +x "$fake_bin/find" - run /usr/bin/perl -e 'alarm 8; exec @ARGV' env -i HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH:/usr/bin:/bin:/usr/sbin:/sbin" TERM="${TERM:-xterm-256color}" bash --noprofile --norc <<'EOF' + run /usr/bin/perl -e 'alarm 5; exec @ARGV' env -i HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH:/usr/bin:/bin:/usr/sbin:/sbin" TERM="${TERM:-xterm-256color}" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" @@ -260,21 +592,49 @@ clean_project_caches echo "ELAPSED=$SECONDS" EOF [ "$status" -eq 0 ] - [[ "$output" == *"ELAPSED="* ]] + [[ "$output" == *"ELAPSED="* ]] || return 1 elapsed=$(printf '%s\n' "$output" | awk -F= '/ELAPSED=/{print $2}' | tail -1) - [[ "$elapsed" =~ ^[0-9]+$ ]] + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 (( elapsed < 5 )) rm -rf "$HOME/.config/mole" "$HOME/SlowProjects" "$fake_bin" } +@test "scan_project_cache_root prunes conda and site-packages" { + mkdir -p "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg1/__pycache__" + mkdir -p "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg2/__pycache__" + mkdir -p "$HOME/Projects/app/__pycache__" + touch "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg1/__pycache__/mod.pyc" + touch "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg2/__pycache__/mod.pyc" + touch "$HOME/Projects/app/pyproject.toml" + touch "$HOME/Projects/app/__pycache__/mod.pyc" + + local output_file + output_file=$(mktemp) + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc < "$slow_scan" <<'SCRIPT' +#!/bin/bash +printf 'started\n' >> "$SYSTEM_SCAN_TRACE" +printf 'partial\0' +exec sleep 4 +SCRIPT + chmod +x "$slow_scan" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" SLOW_SCAN="$slow_scan" \ + SYSTEM_SCAN_TRACE="$trace" \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" +scan_file=$(create_temp_file) +rc=0 +materialize_completed_system_scan "$scan_file" 1 "$SLOW_SCAN" || rc=$? +printf 'RC=%s\n' "$rc" +printf 'BYTES=%s\n' "$(wc -c < "$scan_file" | tr -d ' ')" +rm -f -- "$scan_file" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$(< "$trace")" == "started" ]] || return 1 + [[ "$output" == *"BYTES=0"* ]] +} + +@test "materialize_completed_system_scan preserves NUL-delimited paths with newlines" { + local producer="$HOME/newline-system-scan.sh" + cat > "$producer" <<'SCRIPT' +#!/bin/bash +printf '/Volumes/Backup/line\nbreak.inProgress\0' +SCRIPT + chmod +x "$producer" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PRODUCER="$producer" \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" +scan_file=$(create_temp_file) +materialize_completed_system_scan "$scan_file" 1 "$PRODUCER" +record="" +IFS= read -r -d '' record < "$scan_file" || true +[[ "$record" == $'/Volumes/Backup/line\nbreak.inProgress' ]] +printf 'PRESERVED\n' +rm -f -- "$scan_file" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == "PRESERVED" ]] +} + +@test "clean_deep_system stops later scans after its overall budget" { + run /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" +calls=0 +safe_sudo_find_delete() { + calls=$((calls + 1)) + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + SECONDS=$((SECONDS + 121)) + return 0 +} +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_deep_system +printf 'CALLS=%s\n' "$calls" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"CALLS=1"* ]] || return 1 + [[ "$output" == *"time limit reached"* ]] +} + +@test "clean_deep_system propagates an interrupted privileged cleanup" { + run /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" +calls=0 +safe_sudo_find_delete() { + calls=$((calls + 1)) + return 130 +} +start_section_spinner() { :; } +stop_section_spinner() { :; } +rc=0 +clean_deep_system || rc=$? +printf 'RC=%s\nCALLS=%s\n' "$rc" "$calls" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" == *"CALLS=1"* ]] +} + +@test "clean_deep_system propagates an interrupted macOS version probe" { + run /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" +safe_sudo_find_delete() { + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + return 0 +} +safe_sudo_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 99 +} +show_large_active_powerlog_notice() { :; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +run_with_timeout() { + local _duration="$1" + shift + if [[ "${1:-}" == "sw_vers" ]]; then + printf 'SW_VERS_INTERRUPTED\n' + return 130 + fi + "$@" +} +rc=0 +clean_deep_system || rc=$? +printf 'RC=%s\n' "$rc" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + @test "clean_deep_system issues safe sudo deletions" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/system_calls.log" > "$CALL_LOG" @@ -29,6 +191,7 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "test" ]]; then return 0 fi @@ -46,9 +209,10 @@ sudo() { return 0 } safe_sudo_find_delete() { - echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" + echo "safe_sudo_find_delete:$1:$2:$3:$4:${5:-default}:${6:-none}:${7:-none}:${8:-none}" >> "$CALL_LOG" return 0 } +show_large_active_powerlog_notice() { echo "powerlog_notice" >> "$CALL_LOG"; } safe_sudo_remove() { echo "safe_sudo_remove:$1" >> "$CALL_LOG" return 0 @@ -56,24 +220,32 @@ safe_sudo_remove() { log_success() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } get_file_mtime() { echo 0; } get_path_size_kb() { echo 0; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"/Library/Caches"* ]] - [[ "$output" == *"/private/tmp"* ]] - [[ "$output" == *"/private/var/log"* ]] + [[ "$output" == *"/Library/Caches"* ]] || return 1 + [[ "$output" =~ safe_sudo_find_delete:/Library/Caches:\*\.cache:7:f:5:[0-9]+ ]] || return 1 + [[ "$output" =~ safe_sudo_find_delete:/Library/Caches:\*\.cache:7:f:5:[0-9]+:\*\.tmp:\*\.log ]] || return 1 + # Generic shared temp roots are not exact cleanup targets: age alone does + # not authorize deleting third-party runtime state from them. + [[ "$output" != *"safe_sudo_find_delete:/private/tmp:"* ]] || return 1 + [[ "$output" != *"safe_sudo_find_delete:/private/var/tmp:"* ]] || return 1 + [[ "$output" == *"/private/var/log"* ]] || return 1 + [[ "$output" =~ safe_sudo_find_delete:/private/var/log:\*\.log:7:f:3:[0-9]+ ]] || return 1 + [[ "$output" =~ safe_sudo_find_delete:/private/var/log:\*\.log:7:f:3:[0-9]+:\*\.gz:\*\.asl ]] || return 1 + [[ "$output" =~ safe_sudo_find_delete:/private/var/db/powerlog:\*:7:f:5:[0-9]+ ]] || return 1 + [[ "$output" == *"powerlog_notice"* ]] } @test "clean_deep_system does not touch /Library/Updates when directory absent" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/system_calls_skip.log" > "$CALL_LOG" @@ -90,7 +262,7 @@ log_success() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" @@ -101,7 +273,7 @@ EOF } @test "clean_deep_system cleans third-party adobe logs conservatively" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/system_calls_adobe.log" > "$CALL_LOG" @@ -109,6 +281,7 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "test" ]]; then return 0 fi @@ -117,6 +290,8 @@ sudo() { /Library/Caches) printf '%s\0' "/Library/Caches/test.log" ;; /private/var/log) printf '%s\0' "/private/var/log/system.log" ;; /Library/Logs) echo "/Library/Logs/adobegc.log" ;; + /Library/Logs/Adobe) printf '%s\0' "/Library/Logs/Adobe/old.log" ;; + /Library/Logs/CreativeCloud) printf '%s\0' "/Library/Logs/CreativeCloud/old.log" ;; esac return 0 fi @@ -137,24 +312,135 @@ safe_sudo_remove() { log_success() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } get_file_mtime() { echo 0; } get_path_size_kb() { echo 0; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/Adobe:*"* ]] || return 1 + [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/CreativeCloud:*"* ]] || return 1 + [[ "$output" == *"safe_sudo_find_delete:/Library/Logs:adobegc.log"* ]] +} + +@test "clean_deep_system removes stale idleassetsd aerial downloads scoped to the temp dir (#1253)" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/system_calls_idle.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +IDLE_DIR="/private/var/folders/zz/abcdef/T/com.apple.idleassetsd" +sudo() { + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "test" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + # Locator: enumerate idleassetsd temp dirs under the root-owned tree. + if [[ "$2" == "/private/var/folders" ]]; then + printf '%s\0' "$IDLE_DIR" + return 0 + fi + # Probe: report a stale aborted download inside that dir. + if [[ "$2" == "$IDLE_DIR" ]]; then + echo "$IDLE_DIR/CFNetworkDownload_abc.tmp" + return 0 + fi + return 0 + fi + if [[ "$1" == "stat" ]]; then + echo "0" + return 0 + fi + return 0 +} +safe_sudo_find_delete() { + echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" + if [[ "$1" == "$IDLE_DIR" ]]; then + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=1 + else + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + fi + return 0 +} +safe_sudo_remove() { return 0; } +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 0; } +find() { return 0; } +mock_run_with_timeout_skipping_var_folders + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + # Scoped to the idleassetsd temp dir and the aborted-download name only: + # never a bare CFNetworkDownload_*.tmp sweep across all of /private/var/folders. + [[ "$output" == *"safe_sudo_find_delete:/private/var/folders/zz/abcdef/T/com.apple.idleassetsd:CFNetworkDownload_*.tmp"* ]] || return 1 + [[ "$output" == *"SUCCESS:Stale wallpaper downloads"* ]] || return 1 +} + +@test "clean_deep_system does not report idleassetsd success when no stale download exists (#1253)" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/system_calls_idle_empty.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +IDLE_DIR="/private/var/folders/zz/abcdef/T/com.apple.idleassetsd" +sudo() { + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "test" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + # Locator returns the dir, but the probe finds nothing stale in it. + if [[ "$2" == "/private/var/folders" ]]; then + printf '%s\0' "$IDLE_DIR" + return 0 + fi + return 0 + fi + if [[ "$1" == "stat" ]]; then + echo "0" + return 0 + fi + return 0 +} +safe_sudo_find_delete() { + echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" + return 0 +} +safe_sudo_remove() { return 0; } +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 0; } +find() { return 0; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/Adobe:*"* ]] - [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/CreativeCloud:*"* ]] - [[ "$output" == *"safe_sudo_remove:/Library/Logs/adobegc.log"* ]] + [[ "$output" == *"safe_sudo_find_delete:/private/var/folders/zz/abcdef/T/com.apple.idleassetsd:CFNetworkDownload_*.tmp"* ]] || return 1 + [[ "$output" != *"SUCCESS:Stale wallpaper downloads"* ]] || return 1 } @test "clean_deep_system does not report third-party adobe log success when no old files exist" { - run bash --noprofile --norc << 'EOF2' + run /bin/bash --noprofile --norc << 'EOF2' set -euo pipefail CALL_LOG="$HOME/system_calls_adobe_empty.log" > "$CALL_LOG" @@ -162,6 +448,7 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "test" ]]; then return 0 fi @@ -185,14 +472,13 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } get_file_mtime() { echo 0; } get_path_size_kb() { echo 0; } find() { return 0; } run_with_timeout() { local _timeout="$1" shift - if [[ "${1:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/private/var/folders" ]]; then + if [[ "${1:-}" == "/usr/bin/find" && "${2:-}" == "/private/var/folders" ]]; then return 0 fi "$@" @@ -203,14 +489,14 @@ cat "$CALL_LOG" EOF2 [ "$status" -eq 0 ] - [[ "$output" != *"SUCCESS:Third-party system logs"* ]] - [[ "$output" != *"safe_sudo_find_delete:/Library/Logs/Adobe:*"* ]] - [[ "$output" != *"safe_sudo_find_delete:/Library/Logs/CreativeCloud:*"* ]] + [[ "$output" != *"SUCCESS:Third-party system logs"* ]] || return 1 + [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/Adobe:*"* ]] || return 1 + [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/CreativeCloud:*"* ]] || return 1 [[ "$output" != *"safe_sudo_remove:/Library/Logs/adobegc.log"* ]] } @test "clean_deep_system does not report third-party adobe log success when deletion fails" { - run bash --noprofile --norc << 'EOF3' + run /bin/bash --noprofile --norc << 'EOF3' set -euo pipefail CALL_LOG="$HOME/system_calls_adobe_fail.log" > "$CALL_LOG" @@ -218,6 +504,7 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "test" ]]; then return 0 fi @@ -246,14 +533,13 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } get_file_mtime() { echo 0; } get_path_size_kb() { echo 0; } find() { return 0; } run_with_timeout() { local _timeout="$1" shift - if [[ "${1:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/private/var/folders" ]]; then + if [[ "${1:-}" == "/usr/bin/find" && "${2:-}" == "/private/var/folders" ]]; then return 0 fi "$@" @@ -264,12 +550,12 @@ cat "$CALL_LOG" EOF3 [ "$status" -eq 0 ] - [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/Adobe:*"* ]] + [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/Adobe:*"* ]] || return 1 [[ "$output" != *"SUCCESS:Third-party system logs"* ]] } @test "clean_time_machine_failed_backups exits when tmutil has no destinations" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" @@ -291,11 +577,13 @@ clean_time_machine_failed_backups EOF [ "$status" -eq 0 ] - [[ "$output" == *"No incomplete backups found"* ]] + # The no-destinations path is silent now (debug-only); an idle Time + # Machine section collapses instead of printing a reassurance row. + [ -z "$output" ] } @test "clean_local_snapshots reports snapshot count" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" @@ -317,12 +605,12 @@ clean_local_snapshots EOF [ "$status" -eq 0 ] - [[ "$output" == *"Time Machine local snapshots:"* ]] + [[ "$output" == *"Time Machine local snapshots ·"* ]] || return 1 [[ "$output" == *"tmutil listlocalsnapshots /"* ]] } @test "clean_local_snapshots is quiet when no snapshots" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" @@ -344,7 +632,7 @@ EOF } @test "clean_homebrew skips when cleaned recently" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/brew.sh" @@ -358,11 +646,11 @@ clean_homebrew EOF [ "$status" -eq 0 ] - [[ "$output" == *"cleaned"* ]] + [[ -z "$output" ]] } @test "clean_homebrew runs cleanup with timeout stubs" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/brew.sh" @@ -406,297 +694,352 @@ EOF [[ "$output" == *"Homebrew cleanup"* ]] } -@test "check_appstore_updates is skipped for performance" { - run bash --noprofile --norc << 'EOF' +@test "clean_homebrew prevents cleanup from implicitly autoremoving formulae" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -check_appstore_updates -echo "COUNT=$APPSTORE_UPDATE_COUNT" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"COUNT=0"* ]] -} +source "$PROJECT_ROOT/lib/clean/brew.sh" -@test "check_homebrew_updates reports counts and exports update variables" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +mkdir -p "$HOME/.cache/mole" "$HOME/Library/Caches/Homebrew" +rm -f "$HOME/.cache/mole/brew_last_cleanup" +calls="$HOME/brew_calls.log" +: > "$calls" +start_inline_spinner(){ :; } +stop_inline_spinner(){ :; } +note_activity(){ :; } run_with_timeout() { - local timeout="${1:-}" + local duration="$1" shift + printf 'CALL:%s env_no_autoremove=%s\n' "$*" "${HOMEBREW_NO_AUTOREMOVE:-}" >> "$calls" + if [[ "$1" == "du" ]]; then + echo "51201 $3" + return 0 + fi "$@" } brew() { - if [[ "$1" == "outdated" && "$2" == "--formula" && "$3" == "--quiet" ]]; then - printf "wget\njq\n" - return 0 - fi - if [[ "$1" == "outdated" && "$2" == "--cask" && "$3" == "--quiet" ]]; then - printf "iterm2\n" - return 0 - fi - return 0 + case "$*" in + "cleanup --prune=30") + echo "Removing: package" + return 0 + ;; + "autoremove --dry-run") + echo "==> Would autoremove 1 unneeded formula:" + echo "python@3.14" + return 0 + ;; + "autoremove") + echo "REAL_AUTOREMOVE" + return 0 + ;; + *) + return 0 + ;; + esac } -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +clean_homebrew +cat "$calls" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"2 formula, 1 cask available"* ]] - [[ "$output" == *"COUNTS=3:2:1"* ]] + [[ "$output" == *"CALL:brew cleanup --prune=30 env_no_autoremove=1"* ]] || return 1 + [[ "$output" == *"Homebrew autoremove would remove"* ]] || return 1 + [[ "$output" == *"python@3.14"* ]] || return 1 + [[ "$output" == *"Homebrew autoremove · skipped"* ]] || return 1 + [[ "$output" == *"CALL:brew autoremove --dry-run"* ]] || return 1 + [[ "$output" != *"REAL_AUTOREMOVE"* ]] } -@test "check_homebrew_updates shows timeout warning when brew query times out" { - run bash --noprofile --norc << 'EOF' +@test "clean_homebrew restores an active Cellar link removed by cleanup (#1206)" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -run_with_timeout() { return 124; } -brew() { return 0; } -rm -f "$HOME/.cache/mole/brew_updates" +TEST_BREW_PREFIX="$HOME/homebrew" +TEST_BREW_CELLAR="$TEST_BREW_PREFIX/Cellar" +node_target="$TEST_BREW_CELLAR/node/26.4.0/bin/node" +npx_target="$TEST_BREW_CELLAR/node/26.4.0/bin/npx" +replacement_npx_target="$TEST_BREW_CELLAR/node/26.5.0/bin/npx" +mkdir -p "$TEST_BREW_PREFIX/bin" "$TEST_BREW_CELLAR/node/26.4.0/bin" "$TEST_BREW_CELLAR/node/26.5.0/bin" "$HOME/Library/Caches/Homebrew" +printf '#!/bin/sh\n' > "$node_target" +printf '#!/bin/sh\n' > "$npx_target" +printf '#!/bin/sh\n' > "$replacement_npx_target" +ln -s ../Cellar/node/26.4.0/bin/node "$TEST_BREW_PREFIX/bin/node" +ln -s ../Cellar/node/26.4.0/bin/npx "$TEST_BREW_PREFIX/bin/npx" +rm -f "$HOME/.cache/mole/brew_last_cleanup" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +ensure_user_file() { mkdir -p "$(dirname "$1")"; : > "$1"; } +run_with_timeout() { + shift + if [[ "$1" == "du" ]]; then + echo "51201 $3" + return 0 + fi + "$@" +} +brew() { + case "$*" in + --prefix) printf '%s\n' "$TEST_BREW_PREFIX" ;; + --cellar) printf '%s\n' "$TEST_BREW_CELLAR" ;; + "cleanup --prune=30") + rm -f "$TEST_BREW_PREFIX/bin/node" "$TEST_BREW_PREFIX/bin/npx" + ln -s ../Cellar/node/26.5.0/bin/npx "$TEST_BREW_PREFIX/bin/npx" + ;; + "autoremove --dry-run") : ;; + *) return 0 ;; + esac +} -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +clean_homebrew +[[ -L "$TEST_BREW_PREFIX/bin/node" ]] +[[ "$(readlink "$TEST_BREW_PREFIX/bin/node")" == "../Cellar/node/26.4.0/bin/node" ]] +[[ "$(readlink "$TEST_BREW_PREFIX/bin/npx")" == "../Cellar/node/26.5.0/bin/npx" ]] +[[ -x "$node_target" || -f "$node_target" ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"Check timed out"* ]] - [[ "$output" == *"COUNTS=0:0:0"* ]] + [[ "$output" == *"Homebrew links · restored 1 active executable(s)"* ]] || { + echo "$output" + return 1 + } } -@test "check_homebrew_updates shows failure warning when brew query fails" { - run bash --noprofile --norc << 'EOF' +@test "clean_homebrew does not restore a link after its Cellar target is removed (#1206)" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -run_with_timeout() { return 1; } -brew() { return 0; } -rm -f "$HOME/.cache/mole/brew_updates" +TEST_BREW_PREFIX="$HOME/homebrew-removed" +TEST_BREW_CELLAR="$TEST_BREW_PREFIX/Cellar" +node_target="$TEST_BREW_CELLAR/node/26.4.0/bin/node" +mkdir -p "$TEST_BREW_PREFIX/bin" "$TEST_BREW_CELLAR/node/26.4.0/bin" "$HOME/Library/Caches/Homebrew" +printf '#!/bin/sh\n' > "$node_target" +ln -s ../Cellar/node/26.4.0/bin/node "$TEST_BREW_PREFIX/bin/node" +rm -f "$HOME/.cache/mole/brew_last_cleanup" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +ensure_user_file() { mkdir -p "$(dirname "$1")"; : > "$1"; } +run_with_timeout() { + shift + if [[ "$1" == "du" ]]; then + echo "51201 $3" + return 0 + fi + "$@" +} +brew() { + case "$*" in + --prefix) printf '%s\n' "$TEST_BREW_PREFIX" ;; + --cellar) printf '%s\n' "$TEST_BREW_CELLAR" ;; + "cleanup --prune=30") + rm -f "$TEST_BREW_PREFIX/bin/node" "$node_target" + ;; + "autoremove --dry-run") : ;; + *) return 0 ;; + esac +} -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +clean_homebrew +[[ ! -e "$TEST_BREW_PREFIX/bin/node" && ! -L "$TEST_BREW_PREFIX/bin/node" ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"Check failed"* ]] - [[ "$output" == *"COUNTS=0:0:0"* ]] + [[ "$output" != *"Homebrew links · restored"* ]] } -@test "check_macos_update avoids slow softwareupdate scans" { - run bash --noprofile --norc << 'EOF' +@test "clean_homebrew does not restore executable links outside the Cellar (#1206)" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -defaults() { echo "1"; } +TEST_BREW_PREFIX="$HOME/homebrew-external" +TEST_BREW_CELLAR="$TEST_BREW_PREFIX/Cellar" +external_target="$HOME/custom-tools/node" +mkdir -p "$TEST_BREW_PREFIX/bin" "$TEST_BREW_CELLAR" "$(dirname "$external_target")" "$HOME/Library/Caches/Homebrew" +printf '#!/bin/sh\n' > "$external_target" +ln -s "$external_target" "$TEST_BREW_PREFIX/bin/node" +rm -f "$HOME/.cache/mole/brew_last_cleanup" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +ensure_user_file() { mkdir -p "$(dirname "$1")"; : > "$1"; } run_with_timeout() { - local timeout="${1:-}" shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - cat <<'OUT' -Software Update Tool - -Software Update found the following new or updated software: -* Label: macOS 99 -OUT + if [[ "$1" == "du" ]]; then + echo "51201 $3" return 0 fi - return 124 + "$@" +} +brew() { + case "$*" in + --prefix) printf '%s\n' "$TEST_BREW_PREFIX" ;; + --cellar) printf '%s\n' "$TEST_BREW_CELLAR" ;; + "cleanup --prune=30") rm -f "$TEST_BREW_PREFIX/bin/node" ;; + "autoremove --dry-run") : ;; + *) return 0 ;; + esac } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" +clean_homebrew +[[ ! -e "$TEST_BREW_PREFIX/bin/node" && ! -L "$TEST_BREW_PREFIX/bin/node" ]] +[[ -f "$external_target" ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + [[ "$output" != *"Homebrew links · restored"* ]] } -@test "check_macos_update clears update flag when softwareupdate reports no updates" { - run bash --noprofile --norc << 'EOF' +@test "restore_homebrew_active_links rejects paths outside Homebrew bin roots" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -defaults() { echo "1"; } +TEST_BREW_PREFIX="$HOME/homebrew-forged" +TEST_BREW_CELLAR="$TEST_BREW_PREFIX/Cellar" +target="$TEST_BREW_CELLAR/node/26.4.0/bin/node" +forged_link="$HOME/outside-homebrew/node" +mkdir -p "$TEST_BREW_PREFIX/bin" "$(dirname "$target")" "$(dirname "$forged_link")" +printf '#!/bin/sh\n' > "$target" run_with_timeout() { - local timeout="${1:-}" shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - cat <<'OUT' -Software Update Tool - -Finding available software -No new software available. -OUT - return 0 - fi - return 124 + "$@" +} +brew() { + case "$*" in + --prefix) printf '%s\n' "$TEST_BREW_PREFIX" ;; + --cellar) printf '%s\n' "$TEST_BREW_CELLAR" ;; + *) return 0 ;; + esac } +note_activity() { :; } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } +BREW_ACTIVE_PREFIX="$TEST_BREW_PREFIX" +BREW_ACTIVE_CELLAR="$TEST_BREW_CELLAR" +BREW_ACTIVE_LINK_PATHS=("$forged_link") +BREW_ACTIVE_LINK_TARGETS=("$target") +BREW_ACTIVE_RESOLVED_TARGETS=("$target") -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" +restore_homebrew_active_links +[[ ! -e "$forged_link" && ! -L "$forged_link" ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + [[ "$output" != *"Homebrew links · restored"* ]] } -@test "check_macos_update keeps update flag when softwareupdate times out" { - run bash --noprofile --norc << 'EOF' +@test "root Homebrew link restoration drops to the invoking user" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -defaults() { echo "1"; } +HOME=$(cd -P "$HOME" && pwd) +TEST_BREW_PREFIX="$HOME/homebrew-root-boundary" +TEST_BREW_CELLAR="$TEST_BREW_PREFIX/Cellar" +target="$TEST_BREW_CELLAR/node/26.4.0/bin/node" +link_path="$TEST_BREW_PREFIX/bin/node" +calls="$HOME/homebrew-root-boundary.calls" +mkdir -p "$TEST_BREW_PREFIX/bin" "$(dirname "$target")" +printf '#!/bin/sh\n' > "$target" run_with_timeout() { - local timeout="${1:-}" shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 124 - fi - return 124 + "$@" +} +brew() { + case "$*" in + --prefix) printf '%s\n' "$TEST_BREW_PREFIX" ;; + --cellar) printf '%s\n' "$TEST_BREW_CELLAR" ;; + *) return 0 ;; + esac +} +note_activity() { :; } +is_root_user() { return 0; } +run_homebrew_link_restore_as_invoking_user() { + printf '%s\n' "$*" >> "$calls" + "$@" } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } +SUDO_USER="brew-user" +BREW_ACTIVE_PREFIX="$TEST_BREW_PREFIX" +BREW_ACTIVE_CELLAR="$TEST_BREW_CELLAR" +BREW_ACTIVE_LINK_PATHS=("$link_path") +BREW_ACTIVE_LINK_TARGETS=("$target") +BREW_ACTIVE_RESOLVED_TARGETS=("$target") -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" +restore_homebrew_active_links +[[ -L "$link_path" ]] +[[ "$(readlink "$link_path")" == "$target" ]] +grep -Fq "/bin/ln -s $target $link_path" "$calls" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + [[ "$output" == *"Homebrew links · restored 1 active executable(s)"* ]] } -@test "check_macos_update keeps update flag when softwareupdate returns empty output" { - run bash --noprofile --norc << 'EOF' +@test "clean_homebrew dry-run shows brew autoremove preview without removing formulae" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -defaults() { echo "1"; } +calls="$HOME/brew_dry_run_calls.log" +: > "$calls" +DRY_RUN=true run_with_timeout() { - local timeout="${1:-}" + local duration="$1" shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 0 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update skips softwareupdate when defaults shows no updates" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "0"; } - -run_with_timeout() { - echo "SHOULD_NOT_CALL_SOFTWAREUPDATE" - return 0 -} - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]] + printf 'CALL:%s\n' "$*" >> "$calls" + "$@" } - -@test "check_macos_update outputs debug info when MO_DEBUG set" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -export MO_DEBUG=1 - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - echo "No new software available." - return 0 - fi - return 124 +brew() { + case "$*" in + "autoremove --dry-run") + echo "==> Would autoremove 1 unneeded formula:" + echo "python@3.14" + return 0 + ;; + "autoremove") + echo "REAL_AUTOREMOVE" + return 0 + ;; + *) + return 0 + ;; + esac } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update 2>&1 +clean_homebrew +cat "$calls" EOF [ "$status" -eq 0 ] - [[ "$output" == *"[DEBUG] softwareupdate exit status:"* ]] + [[ "$output" == *"Homebrew · would cleanup"* ]] || return 1 + [[ "$output" == *"Homebrew autoremove would remove"* ]] || return 1 + [[ "$output" == *"python@3.14"* ]] || return 1 + [[ "$output" == *"CALL:brew autoremove --dry-run"* ]] || return 1 + [[ "$output" != *"CALL:brew cleanup --prune=30"* ]] || return 1 + [[ "$output" != *"REAL_AUTOREMOVE"* ]] } @test "run_with_timeout succeeds without GNU timeout" { - run bash --noprofile --norc -c ' + run /bin/bash --noprofile --norc -c ' set -euo pipefail PATH="/usr/bin:/bin" unset MO_TIMEOUT_INITIALIZED MO_TIMEOUT_BIN @@ -707,12 +1050,12 @@ EOF } @test "run_with_timeout enforces timeout and returns 124" { - run bash --noprofile --norc -c ' + run /bin/bash --noprofile --norc -c ' set -euo pipefail PATH="/usr/bin:/bin" unset MO_TIMEOUT_INITIALIZED MO_TIMEOUT_BIN source "'"$PROJECT_ROOT"'/lib/core/common.sh" - run_with_timeout 1 sleep 5 + run_with_timeout 1 sleep 3 ' [ "$status" -eq 124 ] } @@ -724,11 +1067,11 @@ EOF touch -t 202301010000 "$state_dir/com.example.app.savedState/data.plist" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_saved_state_cleanup +execute_optimization saved_state_cleanup EOF [ "$status" -eq 0 ] @@ -737,57 +1080,58 @@ EOF @test "opt_saved_state_cleanup handles missing state directory" { rm -rf "$HOME/Library/Saved Application State" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_saved_state_cleanup +execute_optimization saved_state_cleanup EOF [ "$status" -eq 0 ] [[ "$output" == *"App saved states optimized"* ]] } -@test "opt_saved_state_cleanup continues on permission denied (silent exit)" { +@test "opt_saved_state_cleanup reports a removal failure" { local state_dir="$HOME/Library/Saved Application State" mkdir -p "$state_dir/com.example.old.savedState" touch -t 202301010000 "$state_dir/com.example.old.savedState" 2> /dev/null || true - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" safe_remove() { return 1; } -opt_saved_state_cleanup +execute_optimization saved_state_cleanup EOF [ "$status" -eq 0 ] - [[ "$output" == *"App saved states optimized"* ]] + [[ "$output" == *"Failed to remove 1 old saved state(s)"* ]] } -@test "opt_cache_refresh continues on permission denied (silent exit)" { +@test "opt_cache_refresh reports a removal failure" { local cache_dir="$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" mkdir -p "$cache_dir" touch "$cache_dir/test.db" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" qlmanage() { return 0; } +should_protect_path() { return 1; } safe_remove() { return 1; } -opt_cache_refresh +execute_optimization cache_refresh EOF [ "$status" -eq 0 ] - [[ "$output" == *"QuickLook thumbnails refreshed"* ]] + [[ "$output" == *"Failed to remove 1 Finder cache target(s)"* ]] } @test "opt_cache_refresh cleans Quick Look cache" { mkdir -p "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" touch "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache/test.db" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" @@ -798,7 +1142,7 @@ cleanup_path() { [[ -e "$path" ]] && rm -rf "$path" 2>/dev/null || true } export -f qlmanage cleanup_path -opt_cache_refresh +execute_optimization cache_refresh EOF [ "$status" -eq 0 ] @@ -806,7 +1150,7 @@ EOF } @test "get_path_size_kb returns zero for missing directory" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=0 bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=0 /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" size=$(get_path_size_kb "/nonexistent/path") @@ -821,7 +1165,7 @@ EOF mkdir -p "$HOME/test_size" dd if=/dev/zero of="$HOME/test_size/file.dat" bs=1024 count=10 2> /dev/null - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=0 bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=0 /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" size=$(get_path_size_kb "$HOME/test_size") @@ -833,7 +1177,7 @@ EOF } @test "opt_fix_broken_configs reports fixes" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/maintenance.sh" @@ -843,7 +1187,7 @@ fix_broken_preferences() { echo 2 } -opt_fix_broken_configs +execute_optimization fix_broken_configs EOF [ "$status" -eq 0 ] @@ -851,7 +1195,7 @@ EOF } @test "clean_deep_system cleans memory exception reports" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/memory_exception_calls.log" > "$CALL_LOG" @@ -859,6 +1203,9 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + # Production calls sudo -n, so without this the first argument is always "-n" + # and every branch below falls through to the bare return. + if [[ "${1:-}" == "-n" ]]; then shift; fi if [[ "$1" == "test" ]]; then return 0 fi @@ -877,26 +1224,30 @@ sudo() { } safe_sudo_find_delete() { echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" + if [[ "$1" == "/private/var/db/reportmemoryexception/MemoryLimitViolations" ]]; then + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=1 + else + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + fi return 0 } safe_sudo_remove() { return 0; } log_success() { :; } -is_sip_enabled() { return 1; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"reportmemoryexception/MemoryLimitViolations"* ]] - [[ "$output" == *"-mtime +30"* ]] # 30-day retention - [[ "$output" == *"safe_sudo_find_delete"* ]] + [[ "$output" == *"reportmemoryexception/MemoryLimitViolations"* ]] || return 1 + [[ "$output" == *"-mtime +30"* ]] || return 1 # 30-day retention + [[ "$output" == *"safe_sudo_find_delete:/private/var/db/reportmemoryexception/MemoryLimitViolations:*"* ]] } @test "clean_deep_system memory exception respects DRY_RUN flag" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/memory_exception_dryrun_calls.log" > "$CALL_LOG" @@ -904,8 +1255,10 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + # Production calls sudo -n; without stripping it every branch below is skipped. + if [[ "${1:-}" == "-n" ]]; then shift; fi if [[ "$1" == "test" ]]; then - [[ "$2" == "/private/var/db/reportmemoryexception/MemoryLimitViolations" ]] && return 0 + [[ "$*" == *"/private/var/db/reportmemoryexception/MemoryLimitViolations"* ]] && return 0 # call is `sudo -n test -d `, dir is $3 return 1 fi if [[ "$1" == "find" ]]; then @@ -922,26 +1275,30 @@ sudo() { } safe_sudo_find_delete() { echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" + if [[ "$1" == "/private/var/db/reportmemoryexception/MemoryLimitViolations" ]]; then + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=1 + else + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + fi return 0 } safe_sudo_remove() { return 0; } log_success() { :; } log_info() { echo "$*"; } -is_sip_enabled() { return 1; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"[DRY-RUN] Would remove"* ]] - [[ "$output" != *"safe_sudo_find_delete:/private/var/db/reportmemoryexception/MemoryLimitViolations"* ]] + [[ "$output" == *"[DRY-RUN] Would remove"* ]] || return 1 + [[ "$output" == *"1 old memory exception reports"* ]] } @test "clean_deep_system does not log memory exception success when nothing cleaned" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/memory_exception_success_calls.log" > "$CALL_LOG" @@ -950,7 +1307,7 @@ source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { if [[ "$1" == "test" ]]; then - [[ "$2" == "/private/var/db/reportmemoryexception/MemoryLimitViolations" ]] && return 0 + [[ "$*" == *"/private/var/db/reportmemoryexception/MemoryLimitViolations"* ]] && return 0 # call is `sudo -n test -d `, dir is $3 return 1 fi if [[ "$1" == "find" ]]; then @@ -968,9 +1325,8 @@ safe_sudo_find_delete() { } safe_sudo_remove() { return 0; } log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } -is_sip_enabled() { return 1; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" @@ -980,8 +1336,8 @@ EOF [[ "$output" != *"SUCCESS:Memory exception reports"* ]] } -@test "clean_deep_system cleans diagnostic trace logs" { - run bash --noprofile --norc << 'EOF' +@test "clean_deep_system uses one broad diagnostic log scan" { + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/diag_calls.log" > "$CALL_LOG" @@ -989,6 +1345,8 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + # Production calls sudo -n; without stripping it every branch below is skipped. + if [[ "${1:-}" == "-n" ]]; then shift; fi if [[ "$1" == "test" ]]; then return 0 fi @@ -1003,6 +1361,7 @@ sudo() { fi return 0 } + safe_sudo_find_delete() { echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" return 0 @@ -1014,22 +1373,87 @@ safe_sudo_remove() { log_success() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } find() { return 0; } -run_with_timeout() { shift; "$@"; } +mock_run_with_timeout_skipping_var_folders clean_deep_system cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"diagnostics/Persist"* ]] - [[ "$output" == *"diagnostics/Special"* ]] - [[ "$output" == *"tracev3"* ]] + [[ "$output" == *"safe_sudo_find_delete:/private/var/db/diagnostics:*"* ]] || return 1 + [[ "$output" == *"safe_sudo_find_delete:/private/var/db/DiagnosticPipeline:*"* ]] || return 1 + [[ "$output" != *"safe_sudo_find_delete:/private/var/db/diagnostics:*.tracev3"* ]] +} + +@test "show_large_active_powerlog_notice reports an abnormal database in real and dry-run modes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +sudo() { + [[ "${1:-}" == "-n" ]] && shift + if [[ "${1:-}" == "$STAT_BSD" && "${2:-}" == "-f%z" && "${3:-}" == "$MOLE_ACTIVE_POWERLOG_DB_PATH" ]]; then + echo $((10 * 1024 * 1024 * 1024)) + return 0 + fi + return 1 +} +bytes_to_human() { echo "10.00GB"; } +format_path_link() { printf '%s\n' "$1"; } + +MOLE_DRY_RUN=0 +live_output=$(show_large_active_powerlog_notice) +MOLE_DRY_RUN=1 +dry_output=$(show_large_active_powerlog_notice) + +[[ "$live_output" == "$dry_output" ]] || exit 1 +printf '%s\n' "$live_output" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Power telemetry database"* ]] || return 1 + [[ "$output" == *"10.00GB"* ]] || return 1 + [[ "$output" == *"CurrentBackgroundProcessingDB.BGSQL"* ]] || return 1 + [[ "$output" == *"active, kept"* ]] +} + +@test "show_large_active_powerlog_notice fails closed on small or invalid size probes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +mock_size="" +probe_succeeds=1 +sudo() { + [[ "${1:-}" == "-n" ]] && shift + if [[ "$probe_succeeds" == "1" && "${1:-}" == "$STAT_BSD" && "${2:-}" == "-f%z" && "${3:-}" == "$MOLE_ACTIVE_POWERLOG_DB_PATH" ]]; then + printf '%s\n' "$mock_size" + return 0 + fi + return 1 +} + +mock_size=$((10 * 1024 * 1024 * 1024 - 1)) +small_output=$(show_large_active_powerlog_notice) +mock_size="not-a-size" +invalid_output=$(show_large_active_powerlog_notice) +probe_succeeds=0 +failed_output=$(show_large_active_powerlog_notice) + +[[ -z "$small_output" ]] || exit 1 +[[ -z "$invalid_output" ]] || exit 1 +[[ -z "$failed_output" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [ -z "$output" ] } @test "clean_deep_system cleans code_sign_clone caches via safe_sudo_remove" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/code_sign_clone_calls.log" > "$CALL_LOG" @@ -1053,12 +1477,11 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { local _timeout="$1" shift - if [[ "${1:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/private/var/folders" ]]; then + if [[ "${1:-}" == "/usr/bin/find" && "${2:-}" == "/private/var/folders" ]]; then printf '%s\0' "/private/var/folders/test/a/X/demo.code_sign_clone" return 0 fi @@ -1070,12 +1493,12 @@ cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/X/demo.code_sign_clone"* ]] + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/X/demo.code_sign_clone"* ]] || return 1 [[ "$output" == *"SUCCESS:Browser code signature caches"* ]] } @test "clean_deep_system skips code_sign_clone success when removal fails" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/code_sign_clone_fail_calls.log" > "$CALL_LOG" @@ -1099,12 +1522,11 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { local _timeout="$1" shift - if [[ "${1:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/private/var/folders" ]]; then + if [[ "${1:-}" == "/usr/bin/find" && "${2:-}" == "/private/var/folders" ]]; then printf '%s\0' "/private/var/folders/test/a/X/demo.code_sign_clone" return 0 fi @@ -1116,64 +1538,297 @@ cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/X/demo.code_sign_clone"* ]] + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/X/demo.code_sign_clone"* ]] || return 1 [[ "$output" != *"SUCCESS:Browser code signature caches"* ]] } -@test "opt_memory_pressure_relief skips when pressure is normal" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_deep_system skips EDR code_sign clones (CrowdStrike Falcon tamper)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail +CALL_LOG="$HOME/edr_code_sign_calls.log" +> "$CALL_LOG" source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +sudo() { + if [[ "$1" == "test" ]]; then + return 1 + fi + if [[ "$1" == "find" ]]; then + return 0 + fi + return 0 +} +safe_sudo_find_delete() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +find() { return 0; } +run_with_timeout() { + local _timeout="$1" + shift + if [[ "${1:-}" == "/usr/bin/find" && "${2:-}" == "/private/var/folders" ]]; then + printf '%s\0' \ + "/private/var/folders/test/a/X/com.crowdstrike.falcon.App.code_sign_clone" \ + "/private/var/folders/test/a/X/demo.code_sign_clone" + return 0 + fi + "$@" +} + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + # A normal (browser-style) code-sign clone is still reclaimed. + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/X/demo.code_sign_clone"* ]] || return 1 + # The EDR agent's code-sign clone must never be deleted. + [[ "$output" != *"com.crowdstrike"* ]] || return 1 +} + +@test "clean_deep_system cleans CleanMyMac-observed rebuildable system caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/rebuildable_cache_calls.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" -memory_pressure() { - echo "System-wide memory free percentage: 50%" +sudo() { + if [[ "$1" == "test" ]]; then + case "$3" in + /Library/Caches/com.apple.iconservices.store) + return 0 + ;; + esac + return 1 + fi + if [[ "$1" == "find" ]]; then + return 0 + fi return 0 } -export -f memory_pressure +safe_sudo_find_delete() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +find() { return 0; } +mock_run_with_timeout_skipping_var_folders -opt_memory_pressure_relief +clean_deep_system +cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Memory pressure already optimal"* ]] + [[ "$output" == *"safe_sudo_remove:/Library/Caches/com.apple.iconservices.store"* ]] || return 1 + [[ "$output" == *"SUCCESS:Rebuildable system caches, 1 item"* ]] } -@test "opt_memory_pressure_relief executes purge when pressure is high" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "clean_deep_system does not report an absent rebuildable system cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail +CALL_LOG="$HOME/rebuildable_cache_absent_calls.log" +> "$CALL_LOG" source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" -memory_pressure() { - echo "System-wide memory free percentage: warning" +safe_sudo_find_delete() { + MOLE_SAFE_SUDO_FIND_DELETE_COUNT=0 + return 0 +} +_mole_bounded_sudo() { return 1; } +safe_sudo_remove() { + echo "UNEXPECTED_REMOVE:$1" >> "$CALL_LOG" return 0 } -export -f memory_pressure +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +mock_run_with_timeout_skipping_var_folders + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_REMOVE:/Library/Caches/com.apple.iconservices.store"* ]] || return 1 + [[ "$output" != *"SUCCESS:Rebuildable system caches"* ]] +} + +@test "is_rebuildable_gpu_cache_dir only allows C GPU cache shards" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/com.apple.metal" +is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/com.apple.metalfe" +is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/com.apple.gpuarchiver" +! is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/T/com.example.App/com.apple.metal" +! is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/not-a-gpu-cache" +! is_rebuildable_gpu_cache_dir "/Library/Extensions/com.example.driver/com.apple.metal" +EOF + + [ "$status" -eq 0 ] +} + +@test "gpu_cache_dir_is_stale uses contained file mtimes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +stale_dir="$HOME/gpu-stale" +active_dir="$HOME/gpu-active" +mkdir -p "$stale_dir" "$active_dir" +touch "$stale_dir/functions.data" "$active_dir/functions.data" +touch -t 202001010000 "$stale_dir/functions.data" + +gpu_cache_dir_is_stale "$stale_dir" 1 +! gpu_cache_dir_is_stale "$active_dir" 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_deep_system cleans only narrow private var GPU cache shards" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/gpu_cache_calls.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { - if [[ "$1" == "purge" ]]; then - echo "purge:executed" + if [[ "$1" == "test" ]]; then + return 1 + fi + if [[ "$1" == "find" ]]; then return 0 fi - return 1 + return 0 +} +safe_sudo_find_delete() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +find() { return 0; } +gpu_cache_dir_is_stale() { return 0; } +run_with_timeout() { + local _timeout="$1" + shift + # Answer only the GPU-cache scan. Matching on the bare "find /private/var/folders" + # prefix also swallowed the code_sign_clone sweep, which then received this GPU + # list and removed every entry in it, including the /T/ path this test asserts is + # never touched. + if [[ "${1:-}" == "/usr/bin/find" && "${2:-}" == "/private/var/folders" && "$*" == *"com.apple.metal"* ]]; then + printf 'find_args:%s\n' "$*" >> "$CALL_LOG" + printf '%s\0' \ + "/private/var/folders/test/a/C/com.example.App/com.apple.metal" \ + "/private/var/folders/test/a/C/com.example.App/com.apple.metalfe" \ + "/private/var/folders/test/a/C/com.example.App/com.apple.gpuarchiver" \ + "/private/var/folders/test/a/T/com.example.App/com.apple.metal" \ + "/private/var/folders/test/a/C/com.example.App/not-a-gpu-cache" + return 0 + fi + "$@" +} + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.metal"* ]] || return 1 + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.metalfe"* ]] || return 1 + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.gpuarchiver"* ]] || return 1 + [[ "$output" != *"/private/var/folders/test/a/T/com.example.App/com.apple.metal"* ]] || return 1 + [[ "$output" != *"not-a-gpu-cache"* ]] || return 1 + [[ "$output" != *"-mtime +1"* ]] || return 1 + [[ "$output" == *"SUCCESS:Accessible rebuildable GPU caches, 3 items"* ]] +} + +@test "clean_deep_system skips EDR/security-agent GPU caches (CrowdStrike Falcon tamper)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/gpu_cache_edr_calls.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +sudo() { + if [[ "$1" == "test" ]]; then + return 1 + fi + return 0 +} +safe_sudo_find_delete() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +find() { return 0; } +gpu_cache_dir_is_stale() { return 0; } +run_with_timeout() { + local _timeout="$1" + shift + # The GPU-cache sweep is the deep walk (maxdepth 8); feed candidates only to + # it and let every other find scan return nothing so this exercises just it. + if [[ "${1:-}" == "/usr/bin/find" && "${4:-}" == "8" ]]; then + printf '%s\0' \ + "/private/var/folders/test/a/C/com.crowdstrike.falcon.App/com.apple.metalfe" \ + "/private/var/folders/test/a/C/com.sentinelone.agent/com.apple.metal" \ + "/private/var/folders/test/a/C/com.example.App/com.apple.metalfe" + return 0 + fi + if [[ "${1:-}" == "/usr/bin/find" ]]; then + return 0 + fi + "$@" } -export -f sudo -opt_memory_pressure_relief +clean_deep_system +cat "$CALL_LOG" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Inactive memory released"* ]] - [[ "$output" == *"System responsiveness improved"* ]] + # The normal third-party GPU cache is still reclaimed. + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.metalfe"* ]] || return 1 + # EDR agent caches must never be touched (tamper alert -> corporate malware report). + [[ "$output" != *"com.crowdstrike"* ]] || return 1 + [[ "$output" != *"com.sentinelone"* ]] || return 1 + [[ "$output" == *"SUCCESS:Accessible rebuildable GPU caches, 1 item"* ]] || return 1 } @test "opt_network_stack_optimize skips when network is healthy" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mock_bin="$HOME/network-healthy-bin" +mkdir -p "$mock_bin" +printf '#!/bin/bash\nexit 0\n' > "$mock_bin/route" +printf '#!/bin/bash\necho "ip_address: 93.184.216.34"\n' > "$mock_bin/dscacheutil" +chmod +x "$mock_bin/route" "$mock_bin/dscacheutil" +PATH="$mock_bin:$PATH" + route() { return 0 } @@ -1185,19 +1840,53 @@ dscacheutil() { } export -f dscacheutil -opt_network_stack_optimize +execute_optimization network_stack_optimize EOF [ "$status" -eq 0 ] [[ "$output" == *"Network stack already optimal"* ]] } +@test "opt_network_stack_optimize skips when VPN is active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +route() { + echo "unexpected-route" + return 0 +} +export -f route + +sudo() { + echo "unexpected-sudo" + return 0 +} +export -f sudo + +execute_optimization network_stack_optimize +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Network stack refresh skipped, active VPN detected"* ]] || return 1 + [[ "$output" != *"unexpected-route"* ]] || return 1 + [[ "$output" != *"unexpected-sudo"* ]] +} + @test "opt_network_stack_optimize flushes when network has issues" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mock_bin="$HOME/network-failure-bin" +mkdir -p "$mock_bin" +printf '#!/bin/bash\nexit 1\n' > "$mock_bin/route" +printf '#!/bin/bash\nexit 1\n' > "$mock_bin/dscacheutil" +chmod +x "$mock_bin/route" "$mock_bin/dscacheutil" +PATH="$mock_bin:$PATH" + route() { if [[ "$2" == "get" ]]; then return 1 @@ -1231,29 +1920,23 @@ dscacheutil() { } export -f dscacheutil -opt_network_stack_optimize +# Sudo is mocked above; explicitly opt out of the test-mode short-circuit +# in optimize_sudo_available so this success-path test reaches the mock. +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH +execute_optimization network_stack_optimize EOF [ "$status" -eq 0 ] - [[ "$output" == *"Network routing table refreshed"* ]] + [[ "$output" == *"Network routing table refreshed"* ]] || return 1 [[ "$output" == *"ARP cache cleared"* ]] } @test "opt_disk_permissions_repair skips when permissions are fine" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -stat() { - if [[ "$2" == "%Su" ]]; then - echo "$USER" - return 0 - fi - command stat "$@" -} -export -f stat - test() { if [[ "$1" == "-e" || "$1" == "-w" ]]; then return 0 @@ -1262,7 +1945,7 @@ test() { } export -f test -opt_disk_permissions_repair +execute_optimization disk_permissions_repair EOF [ "$status" -eq 0 ] @@ -1270,19 +1953,13 @@ EOF } @test "opt_disk_permissions_repair calls diskutil when needed" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -stat() { - if [[ "$2" == "%Su" ]]; then - echo "root" - return 0 - fi - command stat "$@" -} -export -f stat +USER="not-the-home-owner" +export USER sudo() { if [[ "$1" == "diskutil" && "$2" == "resetUserPermissions" ]]; then @@ -1302,191 +1979,268 @@ start_inline_spinner() { :; } stop_inline_spinner() { :; } export -f start_inline_spinner stop_inline_spinner -opt_disk_permissions_repair +# Sudo is mocked above; explicitly opt out of the test-mode short-circuit +# in optimize_sudo_available so this success-path test reaches the mock. +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH +execute_optimization disk_permissions_repair EOF [ "$status" -eq 0 ] [[ "$output" == *"User directory permissions repaired"* ]] } -@test "opt_bluetooth_reset skips when HID device is connected" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "opt_spotlight_index_optimize skips when search is fast" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - Apple Magic Keyboard: - Connected: Yes - Type: Keyboard -PROFILER_OUT +mdutil() { + if [[ "$1" == "-s" ]]; then + echo "Indexing enabled." + return 0 + fi return 0 } -export -f system_profiler - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset skips when media apps are running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" +export -f mdutil -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods Pro: - Connected: Yes - Type: Headphones -PROFILER_OUT +mdfind() { return 0 } -export -f system_profiler +export -f mdfind -pgrep() { - if [[ "$2" == "Spotify" ]]; then - echo "12345" - return 0 - fi - return 1 +date() { + echo "1000" } -export -f pgrep +export -f date -opt_bluetooth_reset +execute_optimization spotlight_index_optimize EOF [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] + [[ "$output" == *"Spotlight index already optimal"* ]] } -@test "opt_bluetooth_reset skips when Bluetooth audio output is active" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "software_update_pending_or_unknown fails closed and trusts only an empty RecommendedUpdates array" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -system_profiler() { - if [[ "$1" == "SPAudioDataType" ]]; then - cat << 'AUDIO_OUT' -Audio: - Devices: - AirPods Pro: - Default Output Device: Yes - Manufacturer: Apple Inc. - Output Channels: 2 - Transport: Bluetooth - Output Source: AirPods Pro -AUDIO_OUT - return 0 - elif [[ "$1" == "SPBluetoothDataType" ]]; then - echo "Bluetooth:" - return 0 - fi - return 1 -} -export -f system_profiler - -awk() { - if [[ "${*}" == *"Default Output Device"* ]]; then - cat << 'AWK_OUT' - Default Output Device: Yes - Manufacturer: Apple Inc. - Output Channels: 2 - Transport: Bluetooth - Output Source: AirPods Pro -AWK_OUT - return 0 - fi - command awk "$@" -} -export -f awk +source "$PROJECT_ROOT/lib/clean/system.sh" -opt_bluetooth_reset +fixture_dir="$HOME/su_probe" +mkdir -p "$fixture_dir" + +cat > "$fixture_dir/pending.plist" << 'PLIST' + + + + + RecommendedUpdates + + + Display Name + macOS Update + + + + +PLIST + +cat > "$fixture_dir/empty.plist" << 'PLIST' + + + + + RecommendedUpdates + + + +PLIST + +cat > "$fixture_dir/nokey.plist" << 'PLIST' + + + + + LastSuccessfulDate + never + + +PLIST + +printf 'not a plist' > "$fixture_dir/corrupt.plist" + +# Queued updates block cleanup. +software_update_pending_or_unknown "$fixture_dir/pending.plist" || exit 1 +# Only a readable, explicitly empty array clears the gate. +if software_update_pending_or_unknown "$fixture_dir/empty.plist"; then exit 1; fi +# Missing key, unreadable state, and a missing file all fail closed. +software_update_pending_or_unknown "$fixture_dir/nokey.plist" || exit 1 +software_update_pending_or_unknown "$fixture_dir/corrupt.plist" || exit 1 +software_update_pending_or_unknown "$fixture_dir/does-not-exist.plist" || exit 1 +echo "GATES_OK" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] + [[ "$output" == *"GATES_OK"* ]] } -@test "opt_bluetooth_reset restarts when safe" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "software_update_pending_or_unknown propagates an interrupted plist probe" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods: - Connected: Yes - Type: Audio -PROFILER_OUT - return 0 -} -export -f system_profiler - -pgrep() { - if [[ "$2" == "bluetoothd" ]]; then - return 1 # bluetoothd not running after TERM - fi - return 1 -} -export -f pgrep +source "$PROJECT_ROOT/lib/clean/system.sh" +fixture="$HOME/software-update.plist" +touch "$fixture" +run_with_timeout() { return 130; } +rc=0 +software_update_pending_or_unknown "$fixture" || rc=$? +printf 'RC=%s\n' "$rc" +EOF -sudo() { - if [[ "$1" == "pkill" ]]; then - echo "pkill:bluetoothd:$2" - return 0 - fi - return 1 + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] } -export -f sudo -sleep() { :; } -export -f sleep +@test "time_machine_candidate_still_eligible rejects replacement symlink and active backup races" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" -opt_bluetooth_reset +candidate="$HOME/old.inProgress" +mkdir -p "$candidate" +deadline=$((SECONDS + 10)) +expected=$(time_machine_candidate_identity "$candidate" "$deadline") +expected_mtime="${expected##*:}" +get_epoch_seconds() { echo "$((expected_mtime + 49 * 3600))"; } +tm_is_running() { return 1; } + +time_machine_candidate_still_eligible "$candidate" "$expected" 48 "$deadline" + +replacement="$HOME/replacement.inProgress" +mkdir -p "$replacement" +rm -rf "$candidate" +mv "$replacement" "$candidate" +if time_machine_candidate_still_eligible "$candidate" "$expected" 48 "$deadline"; then + echo "REPLACEMENT_ACCEPTED" + exit 1 +fi + +rm -rf "$candidate" +mkdir -p "$HOME/real-backup" +ln -s "$HOME/real-backup" "$candidate" +if time_machine_candidate_still_eligible "$candidate" "$expected" 48 "$deadline"; then + echo "SYMLINK_ACCEPTED" + exit 1 +fi + +rm -f "$candidate" +mkdir -p "$candidate" +expected=$(time_machine_candidate_identity "$candidate" "$deadline") +expected_mtime="${expected##*:}" +tm_is_running() { return 0; } +if time_machine_candidate_still_eligible "$candidate" "$expected" 48 "$deadline"; then + echo "ACTIVE_ACCEPTED" + exit 1 +fi +echo "RACES_REJECTED" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth module restarted"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RACES_REJECTED"* ]] || return 1 + [[ "$output" != *"ACCEPTED"* ]] } -@test "opt_spotlight_index_optimize skips when search is fast" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +@test "macos_installer_candidate_still_eligible rejects invalid age replacement active and pending races" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" -mdutil() { - if [[ "$1" == "-s" ]]; then - echo "Indexing enabled." +installer="$HOME/Install macOS Test.app" +mkdir -p "$installer/Contents" +touch "$installer/Contents/Info.plist" +deadline=$((SECONDS + 10)) +expected=$(macos_installer_candidate_identity "$installer" "$deadline") +expected_mtime="${expected##*:}" +get_epoch_seconds() { echo "$((expected_mtime + 15 * 86400))"; } +software_update_pending_or_unknown() { return 1; } +macos_installer_process_is_idle() { return 0; } +run_with_timeout() { + local _duration="$1" + shift + if [[ "${1:-}" == "/usr/libexec/PlistBuddy" ]]; then + printf '15.0\n' return 0 fi - return 0 + "$@" } -export -f mdutil -mdfind() { - return 0 +macos_installer_candidate_still_eligible "$installer" "$expected" 14 "$deadline" + +macos_installer_candidate_identity() { printf '1:2:0\n'; } +if macos_installer_candidate_still_eligible "$installer" '1:2:0' 14 "$deadline"; then + echo 'INVALID_MTIME_ACCEPTED' + exit 1 +fi +unset -f macos_installer_candidate_identity +source "$PROJECT_ROOT/lib/clean/system.sh" +software_update_pending_or_unknown() { return 1; } + +replacement="$HOME/installer-replacement" +mkdir -p "$replacement/Contents" +touch "$replacement/Contents/Info.plist" +rm -rf "$installer" +mv "$replacement" "$installer" +if macos_installer_candidate_still_eligible "$installer" "$expected" 14 "$deadline"; then + echo 'REPLACEMENT_ACCEPTED' + exit 1 +fi + +expected=$(macos_installer_candidate_identity "$installer" "$deadline") +expected_mtime="${expected##*:}" +macos_installer_process_is_idle() { return 1; } +if macos_installer_candidate_still_eligible "$installer" "$expected" 14 "$deadline"; then + echo 'ACTIVE_ACCEPTED' + exit 1 +fi +macos_installer_process_is_idle() { return 0; } +software_update_pending_or_unknown() { return 0; } +if macos_installer_candidate_still_eligible "$installer" "$expected" 14 "$deadline"; then + echo 'PENDING_ACCEPTED' + exit 1 +fi +echo 'INSTALLER_RACES_REJECTED' +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"INSTALLER_RACES_REJECTED"* ]] || return 1 + [[ "$output" != *"ACCEPTED"* ]] } -export -f mdfind -date() { - echo "1000" +@test "clean never deletes Software Update-owned staging trees" { + run grep -nE \ + 'find /Library/Updates|safe_sudo_remove "/macOS Install Data"|install_data_newest_mtime|macos_installer_process_running' \ + "$PROJECT_ROOT/lib/clean/system.sh" + + [ "$status" -eq 1 ] || { + echo "$output" >&2 + return 1 + } } -export -f date -opt_spotlight_index_optimize -EOF +@test "var/folders scans prune non-target containers at depth 3" { + # find's -path is a test, not a prune: without a container-level prune the + # GPU scan walks the entire T/ temp tree to depth 8 (measured 217k dirs / + # 19s on a dev machine, against an 8s budget) even though only C/ can + # match, so the step times out on every run. Same shape for the X/-only + # code-sign scan. Pin both prunes. + run grep -cF -- '\( -depth 3 ! -name C \) -prune' "$PROJECT_ROOT/lib/clean/system.sh" + [ "$status" -eq 0 ] || return 1 + [ "$output" -ge 1 ] || return 1 - [ "$status" -eq 0 ] - [[ "$output" == *"Spotlight index already optimal"* ]] + run grep -cF -- '\( -depth 3 ! -name X \) -prune' "$PROJECT_ROOT/lib/clean/system.sh" + [ "$status" -eq 0 ] || return 1 + [ "$output" -ge 1 ] } diff --git a/Resources/mole/tests/clean_user_core.bats b/Resources/mole/tests/clean_user_core.bats index acf3b54..80f3847 100644 --- a/Resources/mole/tests/clean_user_core.bats +++ b/Resources/mole/tests/clean_user_core.bats @@ -10,18 +10,24 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-user-core.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } @test "clean_user_essentials respects Trash whitelist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -38,29 +44,72 @@ EOF [[ "$output" != *"Trash"* ]] } -@test "clean_user_essentials falls back when Finder trash operations time out" { +@test "clean_user_essentials avoids Darwin runtime probes and live-log truncation" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +safe_clean() { echo "SAFE:$2"; } +clean_trash() { echo "TRASH"; } +_clean_recent_items() { :; } +_clean_mail_downloads() { :; } +getconf() { echo "WRONG:getconf"; return 99; } +lsof() { echo "WRONG:lsof"; return 99; } +mole_truncate_log_file() { echo "WRONG:truncate"; return 99; } +clean_user_essentials +EOF + + [ "$status" -eq 0 ] || return 1 + [ "$output" = $'SAFE:User app cache\nSAFE:User app logs\nTRASH' ] +} + +@test "clean_trash dry run stays silent for compiled-model-only items" { + mkdir -p "$HOME/.Trash/model/com.apple.e5rt.e5bundlecache" + touch "$HOME/.Trash/model/com.apple.e5rt.e5bundlecache/weights.bin" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +note_activity() { :; } +record_dry_run_cleanup_target() { echo "UNEXPECTED_RECORD:$1"; } +get_path_size_kb() { echo "UNEXPECTED_SIZE"; return 1; } +clean_trash +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == "" ]] || return 1 + rm -rf "$HOME/.Trash/model" +} + +@test "clean_user_essentials empties trash directly without Finder prompt" { mkdir -p "$HOME/.Trash" touch "$HOME/.Trash/one.tmp" "$HOME/.Trash/two.tmp" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" DRY_RUN=false start_section_spinner() { :; } stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } safe_clean() { :; } note_activity() { :; } is_path_whitelisted() { return 1; } debug_log() { :; } -run_with_timeout() { - local _duration="$1" - shift - if [[ "$1" == "osascript" ]]; then - return 124 - fi - "$@" +osascript() { + echo "FAIL: osascript called, should be direct delete" >&2 + return 1 } +export -f osascript safe_remove() { local target="$1" /bin/rm -rf "$target" @@ -73,11 +122,47 @@ clean_user_essentials EOF [ "$status" -eq 0 ] - [[ "$output" == *"Trash · emptied, 2 items"* ]] + [[ "$output" == *"Trash · emptied, 2 items"* ]] || return 1 + [[ "$output" != *"osascript called"* ]] +} + +@test "clean_user_essentials keeps Mole runtime logs while cleaning other user logs" { + mkdir -p "$HOME/Library/Logs/mole" + mkdir -p "$HOME/Library/Logs/OtherApp" + touch "$HOME/Library/Logs/mole/operations.log" + touch "$HOME/Library/Logs/OtherApp/old.log" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +DRY_RUN=false +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +safe_clean() { + local path="" + for path in "${@:1:$#-1}"; do + if should_protect_path "$path"; then + continue + fi + /bin/rm -rf "$path" + done +} + +clean_user_essentials + +[[ -d "$HOME/Library/Logs/mole" ]] +[[ -f "$HOME/Library/Logs/mole/operations.log" ]] +[[ ! -e "$HOME/Library/Logs/OtherApp/old.log" ]] +EOF + + [ "$status" -eq 0 ] } @test "clean_app_caches includes macOS system caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -96,8 +181,56 @@ EOF [[ "$output" == *"Saved application states"* ]] || [[ "$output" == *"App caches"* ]] } +@test "clean_app_caches does not clean Autosave Information" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +start_section_spinner() { :; } +safe_clean() { echo "$2|$1"; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Autosave information"* ]] || return 1 + [[ "$output" != *"Library/Autosave Information"* ]] +} + +@test "clean_app_caches includes additional Apple cache families" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +start_section_spinner() { :; } +safe_clean() { echo "$2"; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_app_caches +EOF + + [ "$status" -eq 0 ] || return 1 + # The E5RT bundle cache is deliberately no longer a cleanup target: see + # holds_compiled_model_cache(). Assert it first so the check cannot pass + # vacuously on empty output. + [[ "$output" != *"Apple Intelligence runtime cache"* ]] || return 1 + [[ "$output" == *"Apple Media Services cache"* ]] || return 1 + [[ "$output" == *"Duet Expert cache"* ]] || return 1 + [[ "$output" == *"Parsecd cache"* ]] || return 1 + [[ "$output" == *"Apple Python cache"* ]] || return 1 +} + @test "clean_app_caches shows spinner during initial app cache scan" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -114,9 +247,9 @@ EOF [[ "$output" == *"SPIN_START:Scanning app caches..."* ]] } -@test "clean_support_app_data targets crash, wallpaper, and messages preview caches only" { +@test "clean_support_app_data targets crash reports and messages preview caches only" { local support_home="$HOME/support-cache-home-1" - run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail mkdir -p "$HOME" source "$PROJECT_ROOT/lib/core/common.sh" @@ -135,17 +268,18 @@ rm -rf "$HOME/Library/Application Support/com.apple.idleassetsd" EOF [ "$status" -eq 0 ] - [[ "$output" == *"FIND:$support_home/Library/Application Support/CrashReporter:30:f"* ]] - [[ "$output" == *"FIND:$support_home/Library/Application Support/com.apple.idleassetsd:30:f"* ]] - [[ "$output" == *"Messages sticker cache"* ]] - [[ "$output" == *"Messages preview attachment cache"* ]] - [[ "$output" == *"Messages preview sticker cache"* ]] + [[ "$output" == *"FIND:$support_home/Library/Application Support/CrashReporter:30:f"* ]] || return 1 + [[ "$output" != *"com.apple.idleassetsd"* ]] || return 1 + [[ "$output" != *"Aerial wallpaper videos"* ]] || return 1 + [[ "$output" == *"Messages sticker cache"* ]] || return 1 + [[ "$output" == *"Messages preview attachment cache"* ]] || return 1 + [[ "$output" == *"Messages preview sticker cache"* ]] || return 1 [[ "$output" != *"Messages attachments"* ]] } -@test "clean_support_app_data skips messages preview caches while Messages is running" { +@test "clean_support_app_data always cleans messages preview caches" { local support_home="$HOME/support-cache-home-2" - run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail mkdir -p "$HOME" source "$PROJECT_ROOT/lib/core/common.sh" @@ -158,14 +292,18 @@ clean_support_app_data EOF [ "$status" -eq 0 ] - [[ "$output" == *"Messages is running"* ]] - [[ "$output" != *"Messages sticker cache"* ]] - [[ "$output" != *"Messages preview attachment cache"* ]] - [[ "$output" != *"Messages preview sticker cache"* ]] + [[ "$output" == *"Messages sticker cache"* ]] || return 1 + [[ "$output" == *"Messages preview attachment cache"* ]] || return 1 + [[ "$output" == *"Messages preview sticker cache"* ]] } -@test "clean_app_caches skips protected containers" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +@test "clean_app_caches never hands a third-party container to safe_clean" { + # The previous version mocked safe_clean to a no-op and asserted only that + # "App caches" was absent from empty output, so it could not fail. It also + # could not test what its name claimed: clean_app_caches walks a fixed list of + # Apple container paths and never enumerates arbitrary bundle ids, so the + # com.example.app fixture was never in scope either way. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -173,9 +311,7 @@ start_section_spinner() { :; } stop_section_spinner() { :; } bytes_to_human() { echo "0B"; } note_activity() { :; } -safe_clean() { :; } -should_protect_data() { return 0; } -is_critical_system_component() { return 0; } +safe_clean() { echo "CLEAN:$1"; } files_cleaned=0 total_size_cleaned=0 total_items=0 @@ -185,11 +321,87 @@ clean_app_caches EOF [ "$status" -eq 0 ] - [[ "$output" != *"App caches"* ]] || [[ "$output" == *"already clean"* ]] + # Positive control: without it every assertion below is true on empty output. + [[ "$output" == *"CLEAN:"*"Containers/com.apple."* ]] || return 1 + [[ "$output" != *"com.example.app"* ]] || return 1 + [[ "$output" != *"Containers/com.example"* ]] +} + +@test "clean_app_caches preserves nested E5RT caches in sandboxed apps" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +DRY_RUN=false +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +should_protect_path() { return 1; } +is_path_whitelisted() { return 1; } +safe_remove() { + /bin/rm -rf "$1" +} + +container="$HOME/Library/Containers/com.example.ocr" +cache_dir="$container/Data/Library/Caches" +e5rt_parent="$cache_dir/com.example.ocr" +mkdir -p "$e5rt_parent/com.apple.e5rt.e5bundlecache" "$cache_dir/disposable" +touch "$e5rt_parent/com.apple.e5rt.e5bundlecache/model.e5" "$cache_dir/disposable/data.tmp" + +total_size=0 +total_size_partial=false +cleaned_count=0 +found_any=false +precise_size_limit=64 +precise_size_used=0 +process_container_cache "$container" + +# Report state instead of asserting here: this script is fed to bash on stdin, +# and a child that drains the heredoc truncates whatever follows, so trailing +# in-script assertions can silently never run. Assert on $output below. +printf 'E5RT_KEPT=%s\n' "$([[ -f "$e5rt_parent/com.apple.e5rt.e5bundlecache/model.e5" ]] && echo yes || echo no)" +printf 'SIBLING_REMOVED=%s\n' "$([[ -e "$cache_dir/disposable" ]] && echo no || echo yes)" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"E5RT_KEPT=yes"* ]] || return 1 + [[ "$output" == *"SIBLING_REMOVED=yes"* ]] || return 1 +} + +@test "clean_app_caches skips expensive size scans for large sandboxed caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +safe_clean() { :; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +get_path_size_kb() { + echo "SHOULD_NOT_SIZE_SCAN" + return 0 +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Containers/com.example.large/Data/Library/Caches" +for i in $(seq 1 101); do + touch "$HOME/Library/Containers/com.example.large/Data/Library/Caches/file-$i.tmp" +done + +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Sandboxed app caches"* ]] || return 1 + [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]] } @test "clean_application_support_logs counts nested directory contents in dry-run size summary" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -204,8 +416,8 @@ files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Application Support/TestApp/logs/nested" -dd if=/dev/zero of="$HOME/Library/Application Support/TestApp/logs/nested/data.bin" bs=1024 count=2 2> /dev/null +mkdir -p "$HOME/Library/Application Support/TestApp/Code Cache/nested" +dd if=/dev/zero of="$HOME/Library/Application Support/TestApp/Code Cache/nested/data.bin" bs=1024 count=2 2> /dev/null clean_application_support_logs echo "TOTAL_KB=$total_size_cleaned" @@ -213,16 +425,16 @@ rm -rf "$HOME/Library/Application Support" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Application Support logs/caches"* ]] + [[ "$output" == *"Application Support logs/caches"* ]] || return 1 local total_kb total_kb=$(printf '%s\n' "$output" | sed -n 's/.*TOTAL_KB=\([0-9][0-9]*\).*/\1/p' | tail -1) - [[ -n "$total_kb" ]] + [[ -n "$total_kb" ]] || return 1 [[ "$total_kb" -ge 2 ]] } @test "clean_application_support_logs uses bulk clean for large Application Support directories" { local support_home="$HOME/support-appsupport-bulk" - run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail mkdir -p "$HOME" source "$PROJECT_ROOT/lib/core/common.sh" @@ -239,9 +451,9 @@ files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Application Support/adspower_global/logs" +mkdir -p "$HOME/Library/Application Support/adspower_global/Crashpad/completed" for i in $(seq 1 101); do - touch "$HOME/Library/Application Support/adspower_global/logs/file-$i.log" + touch "$HOME/Library/Application Support/adspower_global/Crashpad/completed/file-$i.dmp" done clean_application_support_logs @@ -249,15 +461,116 @@ rm -rf "$HOME/Library/Application Support" EOF [ "$status" -eq 0 ] - [[ "$output" == *"SPIN:Scanning Application Support... 1/1 [adspower_global, bulk clean]"* ]] - [[ "$output" == *"Application Support logs/caches"* ]] - [[ "$output" != *"151250 items"* ]] + [[ "$output" == *"SPIN:Scanning Application Support... 1/1 [adspower_global, bulk clean]"* ]] || return 1 + [[ "$output" == *"Application Support logs/caches"* ]] || return 1 + [[ "$output" != *"151250 items"* ]] || return 1 + [[ "$output" != *"REMOVE:"* ]] +} + +@test "clean_application_support_logs does not clean generic Application Support logs" { + local support_home="$HOME/support-appsupport-generic-logs" + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +safe_remove() { echo "REMOVE:$1"; } +update_progress_if_needed() { return 1; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/TestApp/logs" +touch "$HOME/Library/Application Support/TestApp/logs/runtime.log" + +clean_application_support_logs +test -f "$HOME/Library/Application Support/TestApp/logs/runtime.log" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"REMOVE:"* ]] +} + +@test "clean_application_support_logs cleans Electron-style Cache only when cache markers exist" { + local support_home="$HOME/support-appsupport-electron-cache" + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +update_progress_if_needed() { return 1; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +WHITELIST_PATTERNS=() +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/ElectronLike/Code Cache" +mkdir -p "$HOME/Library/Application Support/ElectronLike/Cache" +mkdir -p "$HOME/Library/Application Support/ElectronLike/CachedData" +touch "$HOME/Library/Application Support/ElectronLike/Code Cache/runtime.bin" +touch "$HOME/Library/Application Support/ElectronLike/Cache/http-cache" +touch "$HOME/Library/Application Support/ElectronLike/CachedData/v8-data" + +mkdir -p "$HOME/Library/Application Support/PlainApp/Cache" +touch "$HOME/Library/Application Support/PlainApp/Cache/keep.db" + +clean_application_support_logs + +test ! -e "$HOME/Library/Application Support/ElectronLike/Code Cache/runtime.bin" +test ! -e "$HOME/Library/Application Support/ElectronLike/Cache/http-cache" +test ! -e "$HOME/Library/Application Support/ElectronLike/CachedData/v8-data" +test -e "$HOME/Library/Application Support/PlainApp/Cache/keep.db" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_application_support_logs skips whitelisted application support directories" { + local support_home="$HOME/support-appsupport-whitelist" + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +safe_remove() { echo "REMOVE:$1"; } +update_progress_if_needed() { return 1; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +WHITELIST_PATTERNS=("$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev") +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/Code Cache" +touch "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/Code Cache/runtime.bin" + +clean_application_support_logs +test -f "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/Code Cache/runtime.bin" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] [[ "$output" != *"REMOVE:"* ]] } @test "app_support_entry_count_capped stops at cap without failing under pipefail" { local support_home="$HOME/support-appsupport-cap" - run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail mkdir -p "$HOME" source "$PROJECT_ROOT/lib/core/common.sh" @@ -278,7 +591,7 @@ EOF } @test "clean_group_container_caches keeps protected caches and cleans non-protected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -310,35 +623,66 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"Group Containers logs/caches"* ]] + [[ "$output" == *"Group Containers logs/caches"* ]] || return 1 [[ "$output" == *"PASS"* ]] } -@test "clean_group_container_caches respects whitelist entries" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' +@test "clean_handoff_pasteboard_cache removes stale items and keeps fresh ones (#1178)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" start_section_spinner() { :; } stop_section_spinner() { :; } -bytes_to_human() { echo "0B"; } note_activity() { :; } files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches" -echo "protected" > "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/keep.db" -echo "remove" > "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/drop.db" +pb="$HOME/Library/Group Containers/group.com.apple.coreservices.useractivityd/shared-pasteboard" +mkdir -p "$pb/stale-item" "$pb/fresh-item" +echo "payload" > "$pb/stale-item/data" +echo "payload" > "$pb/fresh-item/data" +touch -t 202001010000 "$pb/stale-item" -is_path_whitelisted() { - [[ "$1" == *"/group.com.example.tool/Library/Caches/keep.db" ]] +clean_handoff_pasteboard_cache + +# Stale item is removed, the in-flight (fresh) item and the container root +# survive. If path protection ever tightens over group.com.apple.* the stale +# item would survive too and this test must fail loudly. +if [[ ! -e "$pb/stale-item" ]] && [[ -e "$pb/fresh-item" ]] && [[ -d "$pb" ]]; then + echo "PASS" +else + echo "FAIL" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Handoff clipboard cache"* ]] || return 1 + [[ "$output" == *"PASS"* ]] || return 1 } -clean_group_container_caches +@test "clean_handoff_pasteboard_cache dry run reports without deleting" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 -if [[ -e "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/keep.db" ]] \ - && [[ ! -e "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/drop.db" ]]; then +pb="$HOME/Library/Group Containers/group.com.apple.coreservices.useractivityd/shared-pasteboard" +mkdir -p "$pb/stale-item" +echo "payload" > "$pb/stale-item/data" +touch -t 202001010000 "$pb/stale-item" + +clean_handoff_pasteboard_cache + +if [[ -e "$pb/stale-item/data" ]]; then echo "PASS" else echo "FAIL" @@ -347,11 +691,38 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"PASS"* ]] + [[ "$output" == *"Handoff clipboard cache"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"PASS"* ]] || return 1 } -@test "clean_group_container_caches skips systemgroup apple containers" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' +@test "jetbrains_stale_version_dirs reports only superseded IDE version dirs (#1179)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +jb="$HOME/Library/Application Support/JetBrains" +mkdir -p "$jb/GoLand2024.3" "$jb/GoLand2025.1" "$jb/GoLand2025.2" \ + "$jb/PyCharm2025.1" "$jb/IntelliJIdea2024.2" "$jb/IntelliJIdea2024.10" \ + "$jb/Toolbox" + +jetbrains_stale_version_dirs "$jb" | sort +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"GoLand2024.3"* ]] || return 1 + [[ "$output" == *"GoLand2025.1"* ]] || return 1 + # Minor version 10 outranks 2: 2024.2 is stale, 2024.10 is the newest. + [[ "$output" == *"IntelliJIdea2024.2"* ]] || return 1 + [[ "$output" != *"GoLand2025.2"* ]] || return 1 + [[ "$output" != *"PyCharm"* ]] || return 1 + [[ "$output" != *"IntelliJIdea2024.10"* ]] || return 1 + [[ "$output" != *"Toolbox"* ]] || return 1 +} + +@test "clean_group_container_caches skips Apple Notes group container" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -363,12 +734,13 @@ files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Group Containers/systemgroup.com.apple.example/Library/Caches" -echo "system-data" > "$HOME/Library/Group Containers/systemgroup.com.apple.example/Library/Caches/cache.db" +notes_cache="$HOME/Library/Group Containers/group.com.apple.notes/Library/Caches" +mkdir -p "$notes_cache" +echo "notes" > "$notes_cache/NoteStore.sqlite" clean_group_container_caches -if [[ -e "$HOME/Library/Group Containers/systemgroup.com.apple.example/Library/Caches/cache.db" ]]; then +if [[ -e "$notes_cache/NoteStore.sqlite" ]]; then echo "PASS" else echo "FAIL" @@ -380,8 +752,8 @@ EOF [[ "$output" == *"PASS"* ]] } -@test "clean_group_container_caches does not report when only whitelisted items exist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' +@test "clean_group_container_caches respects whitelist entries" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -393,16 +765,18 @@ files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Group Containers/group.com.example.onlywhite/Library/Caches" -echo "whitelisted" > "$HOME/Library/Group Containers/group.com.example.onlywhite/Library/Caches/keep.db" +mkdir -p "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches" +echo "protected" > "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/keep.db" +echo "remove" > "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/drop.db" is_path_whitelisted() { - [[ "$1" == *"/group.com.example.onlywhite/Library/Caches/keep.db" ]] + [[ "$1" == *"/group.com.example.tool/Library/Caches/keep.db" ]] } clean_group_container_caches -if [[ -e "$HOME/Library/Group Containers/group.com.example.onlywhite/Library/Caches/keep.db" ]]; then +if [[ -e "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/keep.db" ]] \ + && [[ ! -e "$HOME/Library/Group Containers/group.com.example.tool/Library/Caches/drop.db" ]]; then echo "PASS" else echo "FAIL" @@ -412,41 +786,125 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"PASS"* ]] - [[ "$output" != *"Group Containers logs/caches"* ]] } -@test "clean_finder_metadata respects protection flag" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PROTECT_FINDER_METADATA=true /bin/bash --noprofile --norc <<'EOF' +@test "clean_group_container_caches skips systemgroup apple containers" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } note_activity() { :; } -clean_finder_metadata -EOF +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Group Containers/systemgroup.com.apple.example/Library/Caches" +echo "system-data" > "$HOME/Library/Group Containers/systemgroup.com.apple.example/Library/Caches/cache.db" + +clean_group_container_caches + +if [[ -e "$HOME/Library/Group Containers/systemgroup.com.apple.example/Library/Caches/cache.db" ]]; then + echo "PASS" +else + echo "FAIL" + exit 1 +fi +EOF [ "$status" -eq 0 ] - # Whitelist-protected items no longer show output (UX improvement in V1.22.0) - [[ "$output" == "" ]] + [[ "$output" == *"PASS"* ]] } -@test "check_ios_device_backups returns when no backup dir" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +@test "clean_group_container_caches does not report when only whitelisted items exist" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" -check_ios_device_backups +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Group Containers/group.com.example.onlywhite/Library/Caches" +echo "whitelisted" > "$HOME/Library/Group Containers/group.com.example.onlywhite/Library/Caches/keep.db" + +is_path_whitelisted() { + [[ "$1" == *"/group.com.example.onlywhite/Library/Caches/keep.db" ]] +} + +clean_group_container_caches + +if [[ -e "$HOME/Library/Group Containers/group.com.example.onlywhite/Library/Caches/keep.db" ]]; then + echo "PASS" +else + echo "FAIL" + exit 1 +fi EOF [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] || return 1 + [[ "$output" != *"Group Containers logs/caches"* ]] +} + +@test "clean_group_container_caches skips per-item size scans for large candidates" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +get_path_size_kb() { + echo "SHOULD_NOT_SIZE_SCAN" + return 0 +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Group Containers/group.com.example.large/Library/Caches" +for i in $(seq 1 101); do + touch "$HOME/Library/Group Containers/group.com.example.large/Library/Caches/file-$i.tmp" +done + +clean_group_container_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Group Containers logs/caches"* ]] || return 1 + [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]] +} + +@test "clean_finder_metadata respects protection flag" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PROTECT_FINDER_METADATA=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +note_activity() { :; } +clean_finder_metadata +EOF + + [ "$status" -eq 0 ] + # Whitelist-protected items no longer show output (UX improvement in V1.22.0) + [[ "$output" == "" ]] } @test "clean_browsers calls expected cache paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" safe_clean() { echo "$2"; } +clean_service_worker_cache() { :; } note_activity() { :; } files_cleaned=0 total_size_cleaned=0 @@ -455,13 +913,418 @@ clean_browsers EOF [ "$status" -eq 0 ] - [[ "$output" == *"Safari cache"* ]] - [[ "$output" == *"Firefox cache"* ]] + [[ "$output" == *"Safari cache"* ]] || return 1 + [[ "$output" == *"Firefox cache"* ]] || return 1 [[ "$output" == *"Puppeteer browser cache"* ]] } +@test "clean_browsers never enters Firefox cleanup while Firefox is running" { + mkdir -p "$HOME/Library/Caches/Firefox" \ + "$HOME/Library/Application Support/Firefox/Profiles/default/cache2" + touch "$HOME/Library/Caches/Firefox/candidate" \ + "$HOME/Library/Application Support/Firefox/Profiles/default/cache2/candidate" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { [[ "$*" == "-x Firefox" ]]; } +safe_clean() { echo "SAFE_CLEAN:${!#}"; } +clean_service_worker_cache() { :; } +note_activity() { :; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"SAFE_CLEAN:Firefox cache"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Firefox profile cache"* ]] +} + +@test "clean_browsers fails closed when the Chrome process probe errors" { + local chrome_support="$HOME/Library/Application Support/Google/Chrome" + rm -rf "$chrome_support" + mkdir -p "$chrome_support/Default/Code Cache" + touch "$chrome_support/Default/Code Cache/candidate" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 2; } +safe_clean() { echo "SAFE_CLEAN:${!#}"; } +clean_service_worker_cache() { :; } +note_activity() { :; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Chrome profile caches · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Chrome code cache"* ]] +} + +@test "clean_browsers does not defer empty Chrome and Firefox roots" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +rm -rf "$HOME/Library/Application Support/Google/Chrome" \ + "$HOME/Library/Caches/Firefox" \ + "$HOME/Library/Application Support/Firefox/Profiles" +mkdir -p "$HOME/Library/Application Support/Google/Chrome/Default/Code Cache" \ + "$HOME/Library/Caches/Firefox" \ + "$HOME/Library/Application Support/Firefox/Profiles/default/cache2" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { :; } +clean_service_worker_cache() { :; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER:Chrome"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DEFER:Firefox"* ]] || return 1 + [[ "$output" != *"process state unknown"* ]] +} + +@test "clean_browsers does not defer broken-symlink-only Chrome and Firefox roots" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +rm -rf "$HOME/Library/Application Support/Google/Chrome" \ + "$HOME/Library/Caches/Firefox" \ + "$HOME/Library/Application Support/Firefox/Profiles" +mkdir -p "$HOME/Library/Application Support/Google/Chrome/Default/Code Cache" \ + "$HOME/Library/Caches/Firefox" \ + "$HOME/Library/Application Support/Firefox/Profiles/default/cache2" +ln -s "$HOME/missing-chrome-cache" \ + "$HOME/Library/Application Support/Google/Chrome/Default/Code Cache/broken" +ln -s "$HOME/missing-firefox-cache" "$HOME/Library/Caches/Firefox/broken" +ln -s "$HOME/missing-firefox-profile-cache" \ + "$HOME/Library/Application Support/Firefox/Profiles/default/cache2/broken" +mkdir -p "$HOME/Library/Application Support/Google/Chrome/Default/Code Cache/compiled/com.apple.e5rt.e5bundlecache" +mkdir -p "$HOME/Library/Caches/Firefox/compiled/com.apple.e5rt.e5bundlecache" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:${!#}"; } +clean_service_worker_cache() { :; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER:Chrome"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DEFER:Firefox"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Chrome code cache"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Firefox cache"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN:Firefox profile cache"* ]] +} + +@test "clean_browsers ignores active whitelist-only Chrome profile caches" { + local chrome_support="$HOME/Library/Application Support/Google/Chrome" + rm -rf "$chrome_support" + mkdir -p "$chrome_support/Default/Code Cache" + touch "$chrome_support/Default/Code Cache/whitelisted" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +target="$HOME/Library/Application Support/Google/Chrome/Default/Code Cache/whitelisted" +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { :; } +clean_service_worker_cache() { :; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER:Chrome"* ]] +} + +@test "clean_cloud_storage never enters active provider cleanup when caches are absent" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { + case "$*" in + "-x Dropbox" | "-x Google Drive" | "-x OneDrive") return 0 ;; + *) return 1 ;; + esac +} +safe_clean() { echo "SAFE_CLEAN:${!#}"; } +clean_cloud_storage +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"SAFE_CLEAN:Dropbox cache"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Google Drive cache"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:OneDrive cache"* ]] +} + +@test "clean_cloud_storage fails closed when provider probes error" { + local cache_root="$HOME/Library/Caches" + mkdir -p "$cache_root/com.getdropbox.dropbox" \ + "$cache_root/com.google.GoogleDrive" \ + "$cache_root/com.microsoft.OneDrive" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +pgrep() { return 2; } +should_protect_path() { return 1; } +safe_clean() { echo "SAFE_CLEAN:${!#}"; } +clean_cloud_storage +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Dropbox cache · skipped (process state unknown)"* ]] || return 1 + [[ "$output" == *"Google Drive cache · skipped (process state unknown)"* ]] || return 1 + [[ "$output" == *"OneDrive cache · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Dropbox cache"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:Google Drive cache"* ]] || return 1 + [[ "$output" != *"SAFE_CLEAN:OneDrive cache"* ]] +} + +@test "clean_browsers keeps all Chrome AI model stores when whitelisted" { + local chrome_support="$HOME/Library/Application Support/Google/Chrome" + mkdir -p "$chrome_support/OptGuideOnDeviceModel/2026" + mkdir -p "$chrome_support/OptGuideOnDeviceClassifierModel/2026" + mkdir -p "$chrome_support/optimization_guide_model_store/2026" + mkdir -p "$chrome_support/Default/Code Cache/js" + touch "$chrome_support/OptGuideOnDeviceModel/2026/model.bin" + touch "$chrome_support/OptGuideOnDeviceClassifierModel/2026/classifier.bin" + touch "$chrome_support/optimization_guide_model_store/2026/model.bin" + touch "$chrome_support/Default/Code Cache/js/cache.bin" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +WHITELIST_PATTERNS=( + "$HOME/Library/Application Support/Google/Chrome/OptGuideOnDevice*/*" + "$HOME/Library/Application Support/Google/Chrome/optimization_guide_model_store/*" +) +pgrep() { return 1; } +# The fixture HOME carries Library/Caches/com.apple.Safari from a sibling +# test, and validate_path_for_deletion refuses a cache whose owner is live. +# Pin an empty process table so this whitelist test does not depend on +# whether Safari happens to be running on the machine (#1390). +ps() { printf ' PID PPID COMM ARGS\n'; } +clean_service_worker_cache() { :; } +note_activity() { :; } +safe_clean() { + local count=$# + local label="${!count}" + local index item + for ((index = 1; index < count; index++)); do + item="${!index}" + if is_path_whitelisted "$item"; then + printf 'KEEP:%s:%s\n' "$label" "$item" + else + safe_remove "$item" true + fi + done +} +clean_browsers +EOF + + [ "$status" -eq 0 ] || return 1 + [[ -f "$chrome_support/OptGuideOnDeviceModel/2026/model.bin" ]] || return 1 + [[ -f "$chrome_support/OptGuideOnDeviceClassifierModel/2026/classifier.bin" ]] || return 1 + [[ -f "$chrome_support/optimization_guide_model_store/2026/model.bin" ]] || return 1 + [[ ! -e "$chrome_support/Default/Code Cache/js/cache.bin" ]] || return 1 + [[ "$output" == *"KEEP:Chrome on-device model cache"* ]] || return 1 + [[ "$output" == *"KEEP:Chrome on-device classifier cache"* ]] || return 1 + [[ "$output" == *"KEEP:Chrome optimization guide models"* ]] || return 1 +} + +@test "clean_browsers preserves Brave Service Worker ScriptCache" { + mkdir -p "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Service Worker/ScriptCache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2"; } +clean_service_worker_cache() { echo "Brave SW $1"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Brave SW Brave"* ]] || return 1 + [[ "$output" != *"Brave Service Worker ScriptCache"* ]] || return 1 + + rm -rf "$HOME/Library" +} + +@test "clean_browsers covers Arc User Data layout" { + mkdir -p "$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2|$1"; } +clean_service_worker_cache() { echo "Arc SW $2"; } +note_activity() { :; } +pgrep() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Arc code cache|$HOME/Library/Application Support/Arc/User Data/"* ]] || return 1 + [[ "$output" == *"Arc component CRX cache|$HOME/Library/Application Support/Arc/User Data/component_crx_cache/"* ]] || return 1 + [[ "$output" == *"Arc extensions CRX cache|$HOME/Library/Application Support/Arc/User Data/extensions_crx_cache/"* ]] || return 1 + [[ "$output" == *"Arc SW $HOME/Library/Application Support/Arc/User Data/Default/Service Worker/CacheStorage"* ]] || return 1 + [[ "$output" != *"Arc Service Worker ScriptCache|$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache/"* ]] || return 1 + + rm -rf "$HOME/Library" +} + +@test "clean_browsers always preserves Chromium Service Worker ScriptCache (#785 #964 #968)" { + mkdir -p "$HOME/Library/Application Support/Google/Chrome/Default/Service Worker/ScriptCache" + mkdir -p "$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache" + mkdir -p "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Service Worker/ScriptCache" + mkdir -p "$HOME/Library/Application Support/Vivaldi/Default/Service Worker/ScriptCache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2"; } +clean_service_worker_cache() { echo "SW-CALL $1"; } +note_activity() { :; } +pgrep() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + # CacheStorage cleanup still runs (it has its own protection logic). + [[ "$output" == *"SW-CALL Chrome"* ]] || return 1 + # ScriptCache cleanup must NOT run at all: wiping V8 bytecode can break + # Chromium MV3 extension service workers even after the browser exits. + [[ "$output" != *"Chrome Service Worker ScriptCache"* ]] || return 1 + [[ "$output" != *"Arc Service Worker ScriptCache"* ]] || return 1 + [[ "$output" != *"Brave Service Worker ScriptCache"* ]] || return 1 + [[ "$output" != *"Vivaldi Service Worker ScriptCache"* ]] || return 1 + + rm -rf "$HOME/Library" +} + +@test "clean_browsers preserves Arc User Data ScriptCache regardless of running state" { + mkdir -p "$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2|$1"; } +clean_service_worker_cache() { echo "Arc SW $2"; } +note_activity() { :; } +pgrep() { + [[ "${2:-}" == "Arc" ]] +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Arc SW $HOME/Library/Application Support/Arc/User Data/Default/Service Worker/CacheStorage"* ]] || return 1 + [[ "$output" != *"Arc Service Worker ScriptCache|$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache/"* ]] || return 1 + + rm -rf "$HOME/Library" +} + +@test "clean_browsers covers QQ Browser 3 caches when not running" { + mkdir -p "$HOME/Library/Application Support/QQBrowser3/Default/Code Cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2|$1"; } +clean_service_worker_cache() { :; } +note_activity() { :; } +pgrep() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QQ Browser cache|$HOME/Library/Caches/com.tencent.QQBrowser3/"* ]] || return 1 + [[ "$output" == *"QQ Browser code cache|$HOME/Library/Application Support/QQBrowser3/"* ]] || return 1 + [[ "$output" == *"QQ Browser component cache|$HOME/Library/Application Support/QQBrowser3/component_crx_cache/"* ]] || return 1 + + rm -rf "$HOME/Library" +} + +@test "clean_browsers skips QQ Browser 3 profile caches while running" { + mkdir -p "$HOME/Library/Application Support/QQBrowser3/Default/Code Cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2|$1"; } +clean_service_worker_cache() { :; } +note_activity() { :; } +pgrep() { + [[ "${2:-}" == "QQBrowser3" ]] +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QQ Browser cache|$HOME/Library/Caches/com.tencent.QQBrowser3/"* ]] || return 1 + [[ "$output" != *"QQ Browser code cache"* ]] || return 1 + [[ "$output" != *"QQ Browser GPU cache"* ]] || return 1 + + rm -rf "$HOME/Library" +} + @test "clean_application_support_logs skips when no access" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -474,7 +1337,7 @@ EOF } @test "clean_apple_silicon_caches exits when not M-series" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" IS_M_SERIES=false bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" IS_M_SERIES=false /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -494,7 +1357,7 @@ EOF mkdir -p "$HOME/.Trash/.hidden_dir" mkdir -p "$HOME/.Trash/regular_dir" - run bash <<'EOF' + run /bin/bash << 'EOF' set -euo pipefail count=0 while IFS= read -r -d '' item; do @@ -505,9 +1368,308 @@ echo "COUNT: $count" EOF [ "$status" -eq 0 ] - [[ "$output" == *"COUNT: 5"* ]] - [[ "$output" == *"FOUND: .hidden_file"* ]] - [[ "$output" == *"FOUND: .DS_Store"* ]] - [[ "$output" == *"FOUND: .hidden_dir"* ]] + [[ "$output" == *"COUNT: 5"* ]] || return 1 + [[ "$output" == *"FOUND: .hidden_file"* ]] || return 1 + [[ "$output" == *"FOUND: .DS_Store"* ]] || return 1 + [[ "$output" == *"FOUND: .hidden_dir"* ]] || return 1 [[ "$output" == *"FOUND: regular_file.txt"* ]] } + +@test "validate_external_volume_target canonicalizes root before comparing target" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +mock_bin="$HOME/bin" +mkdir -p "$mock_bin" +cat > "$mock_bin/diskutil" <<'MOCK' +#!/bin/bash +exit 0 +MOCK +chmod +x "$mock_bin/diskutil" +export PATH="$mock_bin:$PATH" + +real_root="$(mktemp -d "$HOME/ext-real.XXXXXX")" +link_root="$HOME/ext-link" +ln -s "$real_root" "$link_root" +mkdir -p "$link_root/USB" +export MOLE_EXTERNAL_VOLUMES_ROOT="$link_root" + +resolved=$(validate_external_volume_target "$link_root/USB") +echo "RESOLVED=$resolved" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"RESOLVED="*"/USB"* ]] || return 1 + [[ "$output" != *"must be under"* ]] +} + +@test "clean_app_caches caps precise sandbox size scans when many containers exist" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_CONTAINER_CACHE_PRECISE_SIZE_LIMIT=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +safe_clean() { :; } +clean_support_app_data() { :; } +clean_group_container_caches() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +count_file="$HOME/size-count" +get_path_size_kb() { + local count + count=$(cat "$count_file" 2> /dev/null || echo "0") + count=$((count + 1)) + echo "$count" > "$count_file" + echo "1" +} + +for i in $(seq 1 5); do + mkdir -p "$HOME/Library/Containers/com.example.$i/Data/Library/Caches" + touch "$HOME/Library/Containers/com.example.$i/Data/Library/Caches/file-$i.tmp" +done + +clean_app_caches +echo "SIZE_CALLS=$(cat "$count_file")" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Sandboxed app caches"* ]] || return 1 + [[ "$output" == *"SIZE_CALLS=2"* ]] +} + +@test "clean_app_caches stops before deleting when a container size probe times out" { + local container="$HOME/Library/Containers/com.example.timeout/Data/Library/Caches" + mkdir -p "$container" + touch "$container/payload" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false \ + MOLE_CURRENT_COMMAND=clean MOLE_CLEAN_CANCEL_STATUS=0 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +safe_clean() { :; } +safe_remove() { echo "UNEXPECTED_DELETE:$1"; } +clean_support_app_data() { :; } +clean_group_container_caches() { echo "UNEXPECTED_CONTINUATION"; } +clean_handoff_pasteboard_cache() { echo "UNEXPECTED_CONTINUATION"; } +note_activity() { :; } +get_path_size_kb() { return 124; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +set +e +clean_app_caches +rc=$? +set -e +printf 'RC=%s CANCEL=%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=124 CANCEL=124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DELETE"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CONTINUATION"* ]] +} + +# Regression for discussion #583: the only Dia row used to be +# ~/Library/Caches/company.thebrowser.dia, which on a real install holds nothing +# but Sentry crash state. The actual Chromium caches live under +# ~/Library/Caches/Dia/User Data and ~/Library/Application Support/Dia/User Data, +# so `mo clean` reclaimed 0 bytes from Dia. Paths below were measured on Dia +# 1.41.1 (bundle company.thebrowser.dia), not inferred from Chromium convention. +@test "clean_browsers covers the real Dia cache locations" { + mkdir -p "$HOME/Library/Caches/company.thebrowser.dia/io.sentry" + mkdir -p "$HOME/Library/Caches/Dia/User Data/Default/Cache/Cache_Data" + mkdir -p "$HOME/Library/Caches/Dia/User Data/Default/Code Cache/js" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/GraphiteDawnCache" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/GPUPersistentCache" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/component_crx_cache" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/extensions_crx_cache" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/Default/DawnGraphiteCache" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/Default/DawnWebGPUCache" + mkdir -p "$HOME/Library/Application Support/Dia/User Data/Default/GPUCache" + touch "$HOME/Library/Caches/Dia/User Data/Default/Cache/Cache_Data/entry" + touch "$HOME/Library/Application Support/Dia/User Data/component_crx_cache/blob" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +clean_service_worker_cache() { :; } +# Must be mocked: an unmocked pgrep sees the maintainer's real Dia process and +# silently flips this test to the skip branch. +pgrep() { return 1; } +safe_clean() { local n=$#; echo "CLEAN:${!n}"; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"CLEAN:Dia HTTP cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia code cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia component CRX cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia extensions CRX cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia Graphite Dawn cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia GPU cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia Dawn Graphite cache"* ]] || return 1 + [[ "$output" == *"CLEAN:Dia Dawn WebGPU cache"* ]] || return 1 +} + +@test "clean_browsers skips Dia Application Support caches while Dia runs" { + mkdir -p "$HOME/Library/Application Support/Dia/User Data/component_crx_cache" + mkdir -p "$HOME/Library/Caches/Dia/User Data/Default/Cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +clean_service_worker_cache() { :; } +pgrep() { [[ "${2:-}" == "Dia" ]] && return 0; return 1; } +safe_clean() { local n=$#; echo "CLEAN:${!n}"; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Dia Application Support cache"* ]] || return 1 + [[ "$output" == *"skipped (Dia running)"* ]] || return 1 + [[ "$output" != *"CLEAN:Dia component CRX cache"* ]] || return 1 + [[ "$output" != *"CLEAN:Dia HTTP cache"* ]] || return 1 +} + +@test "clean_browsers fails closed when the Dia process probe errors" { + mkdir -p "$HOME/Library/Application Support/Dia/User Data/component_crx_cache" + mkdir -p "$HOME/Library/Caches/Dia/User Data/Default/Cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +note_activity() { :; } +clean_service_worker_cache() { :; } +pgrep() { return 2; } +safe_clean() { local n=$#; echo "CLEAN:${!n}"; } +clean_browsers +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"CLEAN:Dia component CRX cache"* ]] || return 1 + [[ "$output" != *"CLEAN:Dia HTTP cache"* ]] || return 1 +} + +@test "large files includes the unique System Data review targets" { + local review_home="$HOME/large-review-targets" + mkdir -p \ + "$review_home/Library/Developer/Xcode/DerivedData" \ + "$review_home/Library/Developer/CoreSimulator/Devices" \ + "$review_home/Library/Containers/com.docker.docker/Data" + + run env HOME="$review_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +docker() { return 1; } +defaults() { return 1; } +du() { printf '2097152 %s\n' "${2:-/tmp}"; } +run_with_timeout() { + shift + "$@" +} +check_large_file_candidates +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"⊙"* ]] && + [[ "$output" == *"Xcode DerivedData"* ]] && + [[ "$output" == *"Simulator data"* ]] && + [[ "$output" == *"Docker Desktop data"* ]] || { + echo "$output" + return 1 + } +} + +@test "large files dates the irreplaceable rows and leaves caches undated" { + local review_home="$HOME/large-review-dates" + mkdir -p \ + "$review_home/Library/Application Support/MobileSync/Backup/00008150-DEVICE" \ + "$review_home/Library/Developer/Xcode/Archives/2026-03-04" \ + "$review_home/Library/Developer/Xcode/DerivedData/Some-project" + touch -t 202601021200 "$review_home/Library/Application Support/MobileSync/Backup/00008150-DEVICE" + touch -t 202603041200 "$review_home/Library/Developer/Xcode/Archives/2026-03-04" + + run env HOME="$review_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +docker() { return 1; } +defaults() { return 1; } +du() { printf '2097152 %s\n' "${2:-/tmp}"; } +run_with_timeout() { + shift + "$@" +} +check_large_file_candidates +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + # Size alone cannot decide these two: the date separates a live phone + # backup from a dead one, and a shipped archive from a stray export. + [[ "$output" == *"iOS backups"*"2026-01-02"* ]] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode archives"*"2026-03-04"* ]] || { + echo "$output" + return 1 + } + # Rebuildable caches stay undated on purpose; their age never changes the + # answer, and a date on every row would bury the two that matter. + local derived_row + derived_row=$(printf '%s\n' "$output" | grep 'Xcode DerivedData' || true) + [[ -n "$derived_row" ]] || { + echo "$output" + return 1 + } + [[ "$derived_row" != *[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]* ]] || { + echo "$derived_row" + return 1 + } +} diff --git a/Resources/mole/tests/clean_xcode_derived_data.bats b/Resources/mole/tests/clean_xcode_derived_data.bats new file mode 100644 index 0000000..d1ac077 --- /dev/null +++ b/Resources/mole/tests/clean_xcode_derived_data.bats @@ -0,0 +1,476 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-xcode-dd.XXXXXX")" + export HOME + + mkdir -p "$HOME" +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +@test "clean_xcode_derived_data reports project count and size" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +cleanup_result_color_kb() { echo "\033[0;32m"; } +bytes_to_human() { echo "36 KB"; } +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +pgrep() { return 1; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/ProjectAlpha-abcdef123" +mkdir -p "$dd_dir/ProjectBeta-ghijkl456" +mkdir -p "$dd_dir/ProjectGamma-mnopqr789" +echo "build output" > "$dd_dir/ProjectAlpha-abcdef123/build.o" +echo "build output" > "$dd_dir/ProjectBeta-ghijkl456/build.o" +echo "build output" > "$dd_dir/ProjectGamma-mnopqr789/build.o" + +clean_xcode_derived_data +printf 'COUNTERS:%s:%s:%s\n' "$files_cleaned" "$total_size_cleaned" "$total_items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"3 projects"* ]] || return 1 + [[ "$output" == *"Xcode DerivedData"* ]] || return 1 + [[ "$output" == *"COUNTERS:3:"*":1"* ]] +} + +@test "clean_xcode_derived_data honors a real DerivedData whitelist entry (#710)" { + # Uses the real is_path_whitelisted (not a stub) with an actual whitelist + # pattern. clean_xcode_derived_data deletes via safe_remove directly, so + # the protection has to live in safe_remove; before that fix this test + # deletes the build dirs and fails. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +cleanup_result_color_kb() { echo "\033[0;32m"; } +bytes_to_human() { echo "36 KB"; } +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +pgrep() { return 1; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/ProjectAlpha-abcdef123" +echo "build output" > "$dd_dir/ProjectAlpha-abcdef123/build.o" + +# The shipped whitelist preset for DerivedData. +WHITELIST_PATTERNS=("$dd_dir/*") + +clean_xcode_derived_data + +[[ -f "$dd_dir/ProjectAlpha-abcdef123/build.o" ]] || { echo "WRONG: whitelisted DerivedData was deleted"; exit 1; } + +# HOME is shared across tests in this file (setup_file, no per-test reset). +# The whitelisted dir survives by design, so remove it here or it leaks into +# the "empty DerivedData" test. +rm -rf "$dd_dir" +EOF + + [ "$status" -eq 0 ] || return 1 +} + +@test "clean_xcode_derived_data skips when xcodebuild is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +defer_cleanup_family() { echo "DEFER:$1"; } +DRY_RUN=false + +pgrep() { [[ "$1" == "-x" && "$2" == "xcodebuild" ]]; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/SomeProject-abc123" +echo "data" > "$dd_dir/SomeProject-abc123/build.o" + +clean_xcode_derived_data +[[ -f "$dd_dir/SomeProject-abc123/build.o" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DEFER:Xcode"* ]] || return 1 + [[ "$output" != *"Xcode DerivedData · skipped"* ]] +} + +@test "clean_xcode_derived_data keeps build output when pgrep fails" { + run env HOME="$HOME/probe-error" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } +pgrep() { return 2; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; return 0; } + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/OwnedProject" +touch "$dd_dir/OwnedProject/build.o" +clean_xcode_derived_data +[[ -f "$dd_dir/OwnedProject/build.o" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_derived_data keeps build output when pgrep is unavailable" { + run env HOME="$HOME/probe-missing" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/OwnedProject" +touch "$dd_dir/OwnedProject/build.o" +PATH=/nonexistent +clean_xcode_derived_data +[[ -f "$dd_dir/OwnedProject/build.o" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"skipped (process state unknown)"* ]] +} + +@test "clean_xcode_derived_data reports completed removals before a tooling race stops it" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +cleanup_result_color_kb() { echo ""; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } +safe_remove() { command rm -rf "$1"; } +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +probe_round=0 +_xcode_cleanup_process_state() { + probe_round=$((probe_round + 1)) + if [[ $probe_round -le 3 ]]; then + return 1 + fi + return 0 +} + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +rm -rf "$dd_dir" +mkdir -p "$dd_dir/One" "$dd_dir/Two" "$dd_dir/Three" + +clean_xcode_derived_data + +remaining=$(command find "$dd_dir" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') +[[ "$remaining" -eq 2 ]] || { echo "WRONG_REMAINING:$remaining"; exit 1; } +[[ "$files_cleaned" -eq 1 && "$total_items" -eq 1 && "$total_size_cleaned" -eq 1 ]] || { + echo "WRONG_COUNTERS:$files_cleaned:$total_items:$total_size_cleaned" + exit 1 +} +rm -rf "$dd_dir" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode DerivedData · 1 project"* ]] || return 1 + [[ "$output" != *"Xcode DerivedData · stopped"* ]] +} + +@test "clean_xcode_derived_data rechecks tooling after the size probe" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { touch "$HOME/xcode-started"; echo 1; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +_xcode_cleanup_process_state() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +rm -rf "$dd_dir" "$HOME/xcode-started" +mkdir -p "$dd_dir/One" +clean_xcode_derived_data +[[ -d "$dd_dir/One" ]] || { echo "WRONG: project removed"; exit 1; } +rm -rf "$dd_dir" "$HOME/xcode-started" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Xcode DerivedData · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_derived_data passes its measured size to the real deletion sink" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +note_activity() { :; } +is_path_whitelisted() { return 1; } +cleanup_result_color_kb() { echo ""; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { + printf 'size\n' >> "$HOME/derived-size-probes" + local round + round=$(wc -l < "$HOME/derived-size-probes" | tr -d ' ') + [[ $round -ge 2 ]] && touch "$HOME/xcode-started" + echo 1 +} +_xcode_cleanup_process_state() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +rm -rf "$dd_dir" "$HOME/xcode-started" "$HOME/derived-size-probes" +mkdir -p "$dd_dir/One" +clean_xcode_derived_data +[[ ! -e "$dd_dir/One" ]] || { echo "WRONG: project remains"; exit 1; } +[[ ! -e "$HOME/xcode-started" ]] || { echo "WRONG: deletion sink repeated size probe"; exit 1; } +[[ "$(wc -l < "$HOME/derived-size-probes" | tr -d ' ')" -eq 1 ]] || exit 1 +rm -rf "$dd_dir" "$HOME/derived-size-probes" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "clean_xcode_derived_data handles empty DerivedData" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +DRY_RUN=false +pgrep() { return 0; } +export -f pgrep + +mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"projects"* ]] + [[ "$output" != *"UNEXPECTED_DEFER"* ]] +} + +@test "clean_xcode_derived_data handles missing DerivedData dir" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +DRY_RUN=false +pgrep() { return 1; } +export -f pgrep + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_xcode_derived_data dry run shows would-clean message" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +DRY_RUN=true +pgrep() { return 1; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/MyApp-abc123" +echo "data" > "$dd_dir/MyApp-abc123/build.o" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"1 project"* ]] +} + +@test "clean_xcode_derived_data dry run sizes only eligible projects" { + run env HOME="$HOME/dry-run-filtered" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +note_activity() { :; } +DRY_RUN=true +pgrep() { return 1; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { + [[ "$1" == *"/Eligible" ]] && echo 7 || echo 900 +} + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +eligible="$dd_dir/Eligible" +excluded="$dd_dir/Excluded" +mkdir -p "$eligible" "$excluded" +touch "$eligible/build.o" "$excluded/private.o" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$excluded" ]]; } + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode DerivedData · 1 project, 7168 bytes"* ]] || return 1 + [[ "$output" != *"928768 bytes"* ]] +} + +@test "clean_xcode_derived_data dry run hides projects rejected after sizing" { + run env HOME="$HOME/dry-run-policy-race" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +DRY_RUN=true +pgrep() { return 1; } +get_path_size_kb() { + mkdir -p "$1/com.apple.e5rt.e5bundlecache" + echo 7 +} +record_dry_run_cleanup_target() { + holds_compiled_model_cache "$1" && return 1 + echo "UNEXPECTED_REGISTER:$1" + return 0 +} + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/EligibleBeforeSizing" +touch "$dd_dir/EligibleBeforeSizing/build.o" +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Xcode DerivedData"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REGISTER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_ACTIVITY"* ]] +} + +@test "clean_xcode_derived_data dry run rechecks Xcode after sizing" { + run env HOME="$HOME/dry-run-process-race" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" + +DRY_RUN=true +get_path_size_kb() { + : > "$HOME/xcode-started" + echo 7 +} +_xcode_cleanup_process_state() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} +record_dry_run_cleanup_target() { echo "UNEXPECTED_REGISTER:$1"; return 0; } +defer_cleanup_family() { echo "DEFER:$1"; } +note_activity() { :; } + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/EligibleBeforeSizing" +touch "$dd_dir/EligibleBeforeSizing/build.o" +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"DEFER:Xcode"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REGISTER"* ]] || return 1 + [[ "$output" != *"Xcode DerivedData · 1 project"* ]] +} diff --git a/Resources/mole/tests/cli.bats b/Resources/mole/tests/cli.bats index 44882fd..b2b90cc 100644 --- a/Resources/mole/tests/cli.bats +++ b/Resources/mole/tests/cli.bats @@ -7,18 +7,52 @@ setup_file() { ORIGINAL_HOME="${HOME:-}" export ORIGINAL_HOME + # Capture real GOCACHE before HOME is replaced with a temp dir. + # Without this, go build would use $HOME/Library/Caches/go-build inside the + # temp dir (empty), causing a full cold rebuild on every test run (~6s). + ORIGINAL_GOCACHE="$(go env GOCACHE 2>/dev/null || true)" + export ORIGINAL_GOCACHE + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" export HOME mkdir -p "$HOME" + + CLI_OWNS_GO_HELPERS=0 + export CLI_OWNS_GO_HELPERS + + if [[ -x "${MOLE_TEST_ANALYZE_BIN:-}" && -x "${MOLE_TEST_STATUS_BIN:-}" ]]; then + ANALYZE_BIN="$MOLE_TEST_ANALYZE_BIN" + STATUS_BIN="$MOLE_TEST_STATUS_BIN" + export ANALYZE_BIN STATUS_BIN + elif command -v go > /dev/null 2>&1; then + # Build Go binaries from current source for JSON tests. + # Point GOPATH/GOMODCACHE/GOCACHE at the real home so local focused runs + # can reuse caches when the full runner did not prebuild helpers. + ANALYZE_BIN="$(mktemp "${TMPDIR:-/tmp}/analyze-go.XXXXXX")" + STATUS_BIN="$(mktemp "${TMPDIR:-/tmp}/status-go.XXXXXX")" + GOPATH="${ORIGINAL_HOME}/go" GOMODCACHE="${ORIGINAL_HOME}/go/pkg/mod" \ + GOCACHE="${ORIGINAL_GOCACHE}" \ + go build -o "$ANALYZE_BIN" "$PROJECT_ROOT/cmd/analyze" 2>/dev/null + GOPATH="${ORIGINAL_HOME}/go" GOMODCACHE="${ORIGINAL_HOME}/go/pkg/mod" \ + GOCACHE="${ORIGINAL_GOCACHE}" \ + go build -o "$STATUS_BIN" "$PROJECT_ROOT/cmd/status" 2>/dev/null + CLI_OWNS_GO_HELPERS=1 + export ANALYZE_BIN STATUS_BIN + fi } teardown_file() { - rm -rf "$HOME/.config/mole" - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME/.config/mole" + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi + if [[ "${CLI_OWNS_GO_HELPERS:-0}" == "1" ]]; then + rm -f "${ANALYZE_BIN:-}" "${STATUS_BIN:-}" + fi } create_fake_utils() { @@ -43,9 +77,32 @@ fi exit 0 SCRIPT chmod +x "$dir/bioutil" + + cat >"$dir/chown" <<'SCRIPT' +#!/usr/bin/env bash +exit 0 +SCRIPT + chmod +x "$dir/chown" + + cat >"$dir/install" <<'SCRIPT' +#!/usr/bin/env bash +args=() +skip_next="" +for arg in "$@"; do + if [[ -n "$skip_next" ]]; then skip_next=""; continue; fi + case "$arg" in -o|-g) skip_next=1 ;; *) args+=("$arg") ;; esac +done +exec /usr/bin/install "${args[@]}" +SCRIPT + chmod +x "$dir/install" } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config/mole" mkdir -p "$HOME/.config/mole" } @@ -53,8 +110,10 @@ setup() { @test "mole --help prints command overview" { run env HOME="$HOME" "$PROJECT_ROOT/mole" --help [ "$status" -eq 0 ] - [[ "$output" == *"mo clean"* ]] - [[ "$output" == *"mo analyze"* ]] + [[ "$output" == *"mo clean"* ]] || return 1 + [[ "$output" == *"mo optimize"* ]] || return 1 + [[ "$output" == *"mo analyze"* ]] || return 1 + [[ "$output" != *"mo optimise"* ]] } @test "mole --version reports script version" { @@ -64,6 +123,22 @@ setup() { [[ "$output" == *"$expected_version"* ]] } +@test "mole --version does not hang on slow Homebrew detection" { + local fake_bin + fake_bin="$(mktemp -d "${BATS_TEST_TMPDIR}/fake-bin.XXXXXX")" + ln -s "$PROJECT_ROOT/mole" "$fake_bin/mole" + cat > "$fake_bin/brew" <<'SCRIPT' +#!/usr/bin/env bash +sleep 3 +exit 1 +SCRIPT + chmod +x "$fake_bin/brew" + + run env HOME="$HOME" PATH="$fake_bin:$PATH" MOLE_HOMEBREW_DETECT_TIMEOUT=1 "$PROJECT_ROOT/mole" --version + [ "$status" -eq 0 ] + [[ "$output" == *"Install: Manual"* ]] +} + @test "mole --version shows nightly channel metadata" { expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" mkdir -p "$HOME/.config/mole" @@ -73,7 +148,7 @@ EOF run env HOME="$HOME" "$PROJECT_ROOT/mole" --version [ "$status" -eq 0 ] - [[ "$output" == *"Mole version $expected_version"* ]] + [[ "$output" == *"Mole version $expected_version"* ]] || return 1 [[ "$output" == *"Channel: Nightly"* ]] } @@ -83,20 +158,69 @@ EOF [[ "$output" == *"Unknown command: unknown-command"* ]] } +@test "mole --help does not list check command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" != *"mo check"* ]] +} + +@test "mole --help documents history command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" == *"mo history"* ]] +} + +@test "mole check is not a public command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" check --help + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown command: check"* ]] +} + +@test "mole doctor is not a public command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" doctor --help + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown command: doctor"* ]] +} + +@test "mole optimize --check is not a public option" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" optimize --check + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown optimize option: --check"* ]] +} + @test "mole uninstall --whitelist returns unsupported option error" { run env HOME="$HOME" "$PROJECT_ROOT/mole" uninstall --whitelist [ "$status" -ne 0 ] [[ "$output" == *"Unknown uninstall option: --whitelist"* ]] } -@test "show_main_menu hides update shortcut when no update notice is available" { - run bash --noprofile --norc <<'EOF' +@test "main menu controls line shows the update shortcut only when an update is available" { + # The controls line is rendered only under a tty, so test the pure builder + # directly. Both the negative and positive cases run so the assertion + # cannot pass vacuously. + run /bin/bash --noprofile --norc -c "MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 HOME=\"\$(mktemp -d)\" source '$PROJECT_ROOT/mole'; _main_menu_controls_line true false" + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"U Update"* ]] || return 1 + + run /bin/bash --noprofile --norc -c "MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 HOME=\"\$(mktemp -d)\" source '$PROJECT_ROOT/mole'; _main_menu_controls_line true true" + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"U Update"* ]] || return 1 + + # TouchID setup takes precedence: no update shortcut even if one is ready. + run /bin/bash --noprofile --norc -c "MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 HOME=\"\$(mktemp -d)\" source '$PROJECT_ROOT/mole'; _main_menu_controls_line false true" + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"T TouchID"* ]] || return 1 + [[ "$output" != *"U Update"* ]] || return 1 +} + +@test "show_main_menu keeps history out of the primary menu" { + run /bin/bash --noprofile --norc <<'EOF' set -euo pipefail HOME="$(mktemp -d)" export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" show_brand_banner() { printf 'banner\n'; } -show_menu_option() { printf '%s' "$2"; } +show_menu_option() { printf '%s\n' "$2"; } MAIN_MENU_BANNER="" MAIN_MENU_UPDATE_MESSAGE="" MAIN_MENU_SHOW_UPDATE=false @@ -104,11 +228,13 @@ show_main_menu 1 true EOF [ "$status" -eq 0 ] - [[ "$output" != *"U Update"* ]] + [[ "$output" == *"Clean Free up disk space"* ]] || return 1 + [[ "$output" != *"History"* ]] || return 1 + [[ "$output" != *"history"* ]] } @test "interactive_main_menu ignores U shortcut when update notice is hidden" { - run bash --noprofile --norc <<'EOF' + run /bin/bash --noprofile --norc <<'EOF' set -euo pipefail HOME="$(mktemp -d)" export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 @@ -135,8 +261,26 @@ EOF [[ "$output" != *"UPDATE_CALLED"* ]] } +@test "read_update_message_cache ignores notices older than current script" { + run /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +HOME="$(mktemp -d)" +export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 +mkdir -p "$HOME/.cache/mole" +msg_cache="$HOME/.cache/mole/update_message" +printf 'Update 1.43.0 available, run mo update\n' > "$msg_cache" +touch -t 200001010000 "$msg_cache" +source "$PROJECT_ROOT/mole" +message="$(read_update_message_cache "$msg_cache")" +[[ -z "$message" ]] || exit 1 +[[ ! -s "$msg_cache" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + @test "interactive_main_menu accepts U shortcut when update notice is visible" { - run bash --noprofile --norc <<'EOF' + run /bin/bash --noprofile --norc <<'EOF' set -euo pipefail HOME="$(mktemp -d)" export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 @@ -157,6 +301,43 @@ EOF [[ "$output" == *"UPDATE_CALLED"* ]] } +@test "interactive_main_menu drains numeric shortcut Enter before launching uninstall" { + run /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +HOME="$(mktemp -d)" +export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 +source "$PROJECT_ROOT/mole" + +fake_root="$HOME/fake-mole" +mkdir -p "$fake_root/bin" +cat > "$fake_root/bin/uninstall.sh" <<'SCRIPT' +#!/usr/bin/env bash +if IFS= read -r -s -n1 -t 0.1 key; then + if [[ -z "$key" ]]; then + echo "LEAK:ENTER" + else + printf 'LEAK:%s\n' "$key" + fi +else + echo "NO_LEAK" +fi +SCRIPT +chmod +x "$fake_root/bin/uninstall.sh" + +SCRIPT_DIR="$fake_root" +show_brand_banner() { :; } +show_main_menu() { :; } +hide_cursor() { :; } +show_cursor() { :; } + +interactive_main_menu < <(printf '2\n') +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"NO_LEAK"* ]] || return 1 + [[ "$output" != *"LEAK:"* ]] +} + @test "touchid status reports current configuration" { run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status [ "$status" -eq 0 ] @@ -164,7 +345,7 @@ EOF } @test "mo optimize command is recognized" { - run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" + run /bin/bash -c "grep -Eq '\"optimi[sz]e\"[[:space:]]*\\|[[:space:]]*\"optimi[sz]e\"' '$PROJECT_ROOT/mole'" [ "$status" -eq 0 ] } @@ -184,7 +365,7 @@ EOF [ "$status" -eq 0 ] MOLE_OUTPUT="$output" - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + DEBUG_LOG="$HOME/Library/Logs/mole/mole_debug_session.log" [ -f "$DEBUG_LOG" ] run grep "Mole Debug Session" "$DEBUG_LOG" @@ -206,7 +387,7 @@ EOF run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run [ "$status" -eq 0 ] - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + DEBUG_LOG="$HOME/Library/Logs/mole/mole_debug_session.log" run grep "User:" "$DEBUG_LOG" [ "$status" -eq 0 ] @@ -215,6 +396,41 @@ EOF [ "$status" -eq 0 ] } +@test "mo clean --help includes external volume option" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --help + [ "$status" -eq 0 ] + [[ "$output" == *"--external PATH"* ]] || return 1 + [[ "$output" == *"already-uninstalled apps"* ]] +} + +@test "mo uninstall --help directs leftover-only cleanup to clean" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" uninstall --help + [ "$status" -eq 0 ] + [[ "$output" == *"already gone, use mo clean"* ]] +} + +@test "mo clean --external accepts canonicalized custom root" { + real_root="$(mktemp -d "$HOME/ext-real.XXXXXX")" + link_root="$HOME/ext-link" + ln -s "$real_root" "$link_root" + mkdir -p "$link_root/USB/.Trashes" + touch "$link_root/USB/.Trashes/cache.tmp" + + mock_bin="$HOME/mock-bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/diskutil" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$mock_bin/diskutil" + + run env HOME="$HOME" PATH="$mock_bin:$PATH" MOLE_EXTERNAL_VOLUMES_ROOT="$link_root" \ + MOLE_TEST_NO_AUTH=1 "$PROJECT_ROOT/mole" clean --external "$link_root/USB" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Clean External Volume"* ]] || return 1 + [[ "$output" == *"External volume cleanup"* ]] +} + @test "touchid status reflects pam file contents" { pam_file="$HOME/pam_test" cat >"$pam_file" <<'EOF' @@ -223,7 +439,7 @@ EOF run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status [ "$status" -eq 0 ] - [[ "$output" == *"not configured"* ]] + [[ "$output" == *"not configured"* ]] || return 1 cat >"$pam_file" <<'EOF' auth sufficient pam_tid.so @@ -273,8 +489,241 @@ EOF run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable --dry-run [ "$status" -eq 0 ] - [[ "$output" == *"DRY RUN MODE"* ]] + [[ "$output" == *"DRY RUN MODE"* ]] || return 1 run grep "pam_tid.so" "$pam_file" [ "$status" -ne 0 ] } + +@test "enable_touchid sets correct file permissions on pam file" { + pam_file="$HOME/pam_perms_enable" + cat >"$pam_file" <<'EOF' +auth sufficient pam_opendirectory.so +EOF + + fake_bin="$HOME/fake-bin-perms-enable" + create_fake_utils "$fake_bin" + + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable + [ "$status" -eq 0 ] + grep -q "pam_tid.so" "$pam_file" + + local perms + perms=$(stat -f "%Lp" "$pam_file" 2>/dev/null || stat -c "%a" "$pam_file" 2>/dev/null) + [ "$perms" = "444" ] +} + +@test "disable_touchid sets correct file permissions on pam file" { + pam_file="$HOME/pam_perms_disable" + cat >"$pam_file" <<'EOF' +auth sufficient pam_tid.so +auth sufficient pam_opendirectory.so +EOF + + fake_bin="$HOME/fake-bin-perms-disable" + create_fake_utils "$fake_bin" + + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable + [ "$status" -eq 0 ] + + local perms + perms=$(stat -f "%Lp" "$pam_file" 2>/dev/null || stat -c "%a" "$pam_file" 2>/dev/null) + [ "$perms" = "444" ] +} + +@test "enable_touchid sets correct permissions on sudo_local file" { + pam_file="$HOME/pam_perms_sudolocal" + pam_local="$(dirname "$pam_file")/sudo_local_perms" + cat >"$pam_file" <<'EOF' +# sudo: auth account password session +auth include sudo_local +auth sufficient pam_opendirectory.so +EOF + + fake_bin="$HOME/fake-bin-perms-sudolocal" + create_fake_utils "$fake_bin" + + run env PATH="$fake_bin:$PATH" \ + MOLE_PAM_SUDO_FILE="$pam_file" \ + MOLE_PAM_SUDO_LOCAL_FILE="$pam_local" \ + "$PROJECT_ROOT/bin/touchid.sh" enable + [ "$status" -eq 0 ] + grep -q "pam_tid.so" "$pam_local" + + local perms + perms=$(stat -f "%Lp" "$pam_local" 2>/dev/null || stat -c "%a" "$pam_local" 2>/dev/null) + [ "$perms" = "444" ] +} + +# --- JSON output mode tests --- + +@test "mo analyze --json outputs valid JSON with expected fields" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json /tmp + [ "$status" -eq 0 ] + + # Validate it is parseable JSON + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" + + # Check required top-level keys + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert 'path' in data, 'missing path' +assert 'overview' in data, 'missing overview' +assert 'entries' in data, 'missing entries' +assert 'total_size' in data, 'missing total_size' +assert 'total_files' in data, 'missing total_files' +assert isinstance(data['entries'], list), 'entries is not a list' +" +} + +@test "mo analyze --json entries contain required fields" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json /tmp + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert data['overview'] is False, 'explicit path should not be overview mode' +for entry in data['entries']: + assert 'name' in entry, 'entry missing name' + assert 'path' in entry, 'entry missing path' + assert 'size' in entry, 'entry missing size' + assert 'is_dir' in entry, 'entry missing is_dir' +" +} + +@test "mo analyze --json path reflects target directory" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json /tmp + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert data['path'] == '/tmp' or data['path'] == '/private/tmp', \ + f\"unexpected path: {data['path']}\" +" +} + +@test "mo status --json outputs valid JSON with expected fields" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run "$STATUS_BIN" --json + [ "$status" -eq 0 ] + + # Validate it is parseable JSON + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" + + # Check required top-level keys + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for key in ['cpu', 'memory', 'disks', 'health_score', 'host', 'uptime']: + assert key in data, f'missing key: {key}' +" +} + +@test "mo status --json cpu section has expected structure" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run "$STATUS_BIN" --json + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +cpu = data['cpu'] +assert 'usage' in cpu, 'cpu missing usage' +assert 'logical_cpu' in cpu, 'cpu missing logical_cpu' +assert isinstance(cpu['usage'], (int, float)), 'cpu usage is not a number' +" +} + +@test "mo status --json memory section has expected structure" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run "$STATUS_BIN" --json + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +mem = data['memory'] +assert 'total' in mem, 'memory missing total' +assert 'used' in mem, 'memory missing used' +assert 'used_percent' in mem, 'memory missing used_percent' +assert mem['total'] > 0, 'memory total should be positive' +" +} + +@test "mo status --json piped to stdout auto-detects JSON mode" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + # When piped (not a tty), status should auto-detect and output JSON + output=$("$STATUS_BIN" 2>/dev/null) + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" +} + +@test "mo status --watch streams newline-delimited JSON" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run python3 - "$STATUS_BIN" <<'PY' +import json +import subprocess +import sys + +status_bin = sys.argv[1] +proc = subprocess.Popen( + [status_bin, "--watch", "--interval", "200ms"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, +) +lines = [] +try: + for _ in range(3): + line = proc.stdout.readline() + if not line: + raise RuntimeError("missing watch output") + snapshot = json.loads(line) + for key in ("collected_at", "cpu", "memory", "disk_io", "network", "health_score"): + if key not in snapshot: + raise RuntimeError(f"missing key: {key}") + lines.append(snapshot) +finally: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=3) + +if proc.stderr.read(): + raise RuntimeError("watch wrote to stderr") +print(f"watch_lines={len(lines)}") +PY + [ "$status" -eq 0 ] + [[ "$output" == *"watch_lines=3"* ]] +} diff --git a/Resources/mole/tests/completion.bats b/Resources/mole/tests/completion.bats index 562a731..d51660c 100755 --- a/Resources/mole/tests/completion.bats +++ b/Resources/mole/tests/completion.bats @@ -20,7 +20,9 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -30,6 +32,11 @@ teardown_file() { } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" mkdir -p "$HOME" @@ -48,27 +55,28 @@ setup() { @test "completion --help shows usage" { run "$PROJECT_ROOT/bin/completion.sh" --help [ "$status" -ne 0 ] - [[ "$output" == *"Usage: mole completion"* ]] + [[ "$output" == *"Usage: mole completion"* ]] || return 1 [[ "$output" == *"Auto-install"* ]] } @test "completion bash generates valid bash script" { run "$PROJECT_ROOT/bin/completion.sh" bash [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] + [[ "$output" == *"_mole_completions"* ]] || return 1 [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } @test "completion bash script includes all commands" { run "$PROJECT_ROOT/bin/completion.sh" bash [ "$status" -eq 0 ] - [[ "$output" == *"optimize"* ]] - [[ "$output" == *"clean"* ]] - [[ "$output" == *"uninstall"* ]] - [[ "$output" == *"analyze"* ]] - [[ "$output" == *"status"* ]] - [[ "$output" == *"purge"* ]] - [[ "$output" == *"touchid"* ]] + [[ "$output" == *"optimize"* ]] || return 1 + [[ "$output" == *"clean"* ]] || return 1 + [[ "$output" == *"uninstall"* ]] || return 1 + [[ "$output" == *"analyze"* ]] || return 1 + [[ "$output" == *"status"* ]] || return 1 + [[ "$output" == *"history"* ]] || return 1 + [[ "$output" == *"purge"* ]] || return 1 + [[ "$output" == *"touchid"* ]] || return 1 [[ "$output" == *"completion"* ]] } @@ -78,8 +86,20 @@ setup() { [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } +@test "completion bash includes current clean, analyze, history, and purge options only" { + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"--dry-run -n --external --whitelist --debug --help -h"* ]] || return 1 + [[ "$output" == *"--json --help -h"* ]] || return 1 + [[ "$output" == *"--json --limit --help -h"* ]] || return 1 + [[ "$output" == *"--paths --dry-run -n --include-empty --debug --help -h"* ]] || return 1 + [[ "$output" != *"--select"* ]] || return 1 + [[ "$output" != *"--categories"* ]] || return 1 + [[ "$output" != *"--exclude-paths"* ]] +} + @test "completion bash can be loaded in bash" { - run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" + run /bin/bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" [ "$status" -eq 0 ] [[ "$output" == *"_mole_completions"* ]] } @@ -87,39 +107,68 @@ setup() { @test "completion zsh generates valid zsh script" { run "$PROJECT_ROOT/bin/completion.sh" zsh [ "$status" -eq 0 ] - [[ "$output" == *"#compdef mole mo"* ]] + [[ "$output" == *"#compdef mole mo"* ]] || return 1 [[ "$output" == *"_mole()"* ]] } @test "completion zsh includes command descriptions" { run "$PROJECT_ROOT/bin/completion.sh" zsh [ "$status" -eq 0 ] - [[ "$output" == *"optimize:Check and maintain system"* ]] - [[ "$output" == *"clean:Free up disk space"* ]] + [[ "$output" == *"optimize:Refresh caches and services"* ]] || return 1 + [[ "$output" == *"clean:Free up disk space"* ]] || return 1 + [[ "$output" == *"history:Review cleanup activity"* ]] +} + +@test "completion zsh includes current clean, analyze, history, and purge options only" { + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] + [[ "$output" == *"--dry-run"* ]] || return 1 + [[ "$output" == *"--external"* ]] || return 1 + [[ "$output" == *"--whitelist"* ]] || return 1 + [[ "$output" == *"--json"* ]] || return 1 + [[ "$output" == *"--limit"* ]] || return 1 + [[ "$output" == *"--include-empty"* ]] || return 1 + [[ "$output" != *"--select"* ]] || return 1 + [[ "$output" != *"--categories"* ]] || return 1 + [[ "$output" != *"--exclude-paths"* ]] } @test "completion fish generates valid fish script" { run "$PROJECT_ROOT/bin/completion.sh" fish [ "$status" -eq 0 ] - [[ "$output" == *"complete -c mole"* ]] - [[ "$output" == *"complete -c mo"* ]] + [[ "$output" == *"complete -f -c mole"* ]] || return 1 + [[ "$output" == *"complete -f -c mo"* ]] } @test "completion fish includes both mole and mo commands" { output="$("$PROJECT_ROOT/bin/completion.sh" fish)" - mole_count=$(echo "$output" | grep -c "complete -c mole") - mo_count=$(echo "$output" | grep -c "complete -c mo") + mole_count=$(echo "$output" | grep -c "complete -f -c mole") + mo_count=$(echo "$output" | grep -c "complete -f -c mo") [ "$mole_count" -gt 0 ] [ "$mo_count" -gt 0 ] } +@test "completion fish includes current clean, analyze, history, and purge options only" { + run "$PROJECT_ROOT/bin/completion.sh" fish + [ "$status" -eq 0 ] + [[ "$output" == *"-l dry-run"* ]] || return 1 + [[ "$output" == *"-l external"* ]] || return 1 + [[ "$output" == *"-l whitelist"* ]] || return 1 + [[ "$output" == *"-l json"* ]] || return 1 + [[ "$output" == *"-l limit"* ]] || return 1 + [[ "$output" == *"-l include-empty"* ]] || return 1 + [[ "$output" != *"-l select"* ]] || return 1 + [[ "$output" != *"-l categories"* ]] || return 1 + [[ "$output" != *"-l exclude-paths"* ]] +} + @test "completion auto-install detects zsh" { # shellcheck disable=SC2030,SC2031 export SHELL=/bin/zsh # Simulate auto-install (no interaction) - run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" + run /bin/bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" if [[ "$output" == *"Already configured"* ]]; then skip "Already configured from previous test" @@ -144,7 +193,7 @@ setup() { @test "completion --dry-run previews changes without writing config" { run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh" --dry-run [ "$status" -eq 0 ] - [[ "$output" == *"DRY RUN MODE"* ]] + [[ "$output" == *"DRY RUN MODE"* ]] || return 1 [ ! -f "$HOME/.zshrc" ] } diff --git a/Resources/mole/tests/core_common.bats b/Resources/mole/tests/core_common.bats index 69d0a6f..4c3f087 100644 --- a/Resources/mole/tests/core_common.bats +++ b/Resources/mole/tests/core_common.bats @@ -14,19 +14,26 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" mkdir -p "$HOME" } @test "mo_spinner_chars returns default sequence" { - result="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; mo_spinner_chars")" + result="$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; mo_spinner_chars")" [ "$result" = "|/-\\" ] } @@ -35,23 +42,154 @@ setup() { if [[ "$(uname -m)" == "arm64" ]]; then expected="Apple Silicon" fi - result="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; detect_architecture")" + result="$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; detect_architecture")" [ "$result" = "$expected" ] } @test "get_free_space returns a non-empty value" { - result="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; get_free_space")" + result="$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; get_free_space")" [[ -n "$result" ]] } +@test "get_free_space uses decimal formatting from df kilobytes" { + local mock_bin="$HOME/bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/df" <<'MOCK' +#!/bin/bash +printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' +printf '/dev/disk1 200000000 126599680 73400320 64%% /\n' +MOCK + chmod +x "$mock_bin/df" + + output="$( + HOME="$HOME" PATH="$mock_bin:$PATH" /bin/bash --noprofile --norc <<'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +get_free_space_kb +get_free_space +format_free_space_kb 73400320 +format_free_space_kb invalid +format_free_space_delta_kb 1024 +format_free_space_delta_kb -1024 +EOF + )" + + lines=() + while IFS= read -r line; do + lines+=("$line") + done <<< "$output" + + [ "${lines[0]}" = "73400320" ] + [ "${lines[1]}" = "75.16GB" ] + [ "${lines[2]}" = "75.16GB" ] + [ "${lines[3]}" = "Unknown" ] + [ "${lines[4]}" = "+1.0MB" ] + [ "${lines[5]}" = "-1.0MB" ] +} + +@test "cleanup_result_color_kb always returns green" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +small_kb=1 +large_kb=$(((MOLE_ONE_GB_BYTES * 2) / 1024)) + +if [[ "$(cleanup_result_color_kb "$small_kb")" == "$GREEN" ]] && + [[ "$(cleanup_result_color_kb "$large_kb")" == "$GREEN" ]]; then + echo "ok" +fi +EOF + + [ "$status" -eq 0 ] + [ "$output" = "ok" ] +} + +@test "mole_is_reverse_dns_bundle_id rejects defaults domains and glob-like ids" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +for valid in "com.example.App" "andriiliakh.Artpaper" "dev.zed.Zed-Nightly" "org.keepassxc.KeePassXC"; do + mole_is_reverse_dns_bundle_id "$valid" || { + echo "valid rejected: $valid" + exit 1 + } +done + +for invalid in "-g" "NSGlobalDomain" "com-example" "com.foo.*" "com.foo.[abc]" "unknown" ""; do + if mole_is_reverse_dns_bundle_id "$invalid"; then + echo "invalid accepted: $invalid" + exit 1 + fi +done +EOF + + [ "$status" -eq 0 ] +} + +@test "mole_name_has_bundle_id_boundary rejects sibling bundle prefixes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +bundle_id="com.example.TestApp" + +for valid in \ + "com.example.TestApp.plist" \ + "com.example.TestApp.helper.plist" \ + "/tmp/com.example.TestApp.pkg.bom"; do + mole_name_starts_with_bundle_id_boundary "$valid" "$bundle_id" || { + echo "valid start boundary rejected: $valid" + exit 1 + } +done + +for invalid in \ + "group.com.example.TestApp" \ + "TEAM.com.example.TestApp.FileProvider" \ + "com.example.TestApplication.plist"; do + if mole_name_starts_with_bundle_id_boundary "$invalid" "$bundle_id"; then + echo "sibling start boundary accepted: $invalid" + exit 1 + fi +done + +for valid in \ + "com.example.TestApp.plist" \ + "com.example.TestApp.helper.plist" \ + "group.com.example.TestApp" \ + "TEAM.com.example.TestApp.FileProvider" \ + "/tmp/com.example.TestApp.pkg.bom"; do + mole_name_has_bundle_id_boundary "$valid" "$bundle_id" || { + echo "valid boundary rejected: $valid" + exit 1 + } +done + +for invalid in \ + "com.example.TestApplication.plist" \ + "group.com.example.TestApplication" \ + "xcom.example.TestApp" \ + "com.example.TestAppHelper.plist" \ + "com-example-TestApp.plist"; do + if mole_name_has_bundle_id_boundary "$invalid" "$bundle_id"; then + echo "sibling boundary accepted: $invalid" + exit 1 + fi +done +EOF + + [ "$status" -eq 0 ] +} + @test "log_info prints message and appends to log file" { local message="Informational message from test" local stdout_output - stdout_output="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_info '$message'")" - [[ "$stdout_output" == *"$message"* ]] + stdout_output="$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_info '$message'")" + [[ "$stdout_output" == *"$message"* ]] || return 1 - local log_file="$HOME/.config/mole/mole.log" - [[ -f "$log_file" ]] + local log_file="$HOME/Library/Logs/mole/mole.log" + [[ -f "$log_file" ]] || return 1 grep -q "INFO: $message" "$log_file" } @@ -59,31 +197,121 @@ setup() { local message="Something went wrong" local stderr_file="$HOME/log_error_stderr.txt" - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_error '$message' 1>/dev/null 2>'$stderr_file'" + HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_error '$message' 1>/dev/null 2>'$stderr_file'" - [[ -s "$stderr_file" ]] + [[ -s "$stderr_file" ]] || return 1 grep -q "$message" "$stderr_file" - local log_file="$HOME/.config/mole/mole.log" - [[ -f "$log_file" ]] + local log_file="$HOME/Library/Logs/mole/mole.log" + [[ -f "$log_file" ]] || return 1 grep -q "ERROR: $message" "$log_file" } +@test "log_operation recreates operations log if the log directory disappears mid-session" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +rm -rf "$HOME/Library/Logs/mole" +log_operation "clean" "REMOVED" "/tmp/example" "1KB" +EOF + [ "$status" -eq 0 ] + + local oplog="$HOME/Library/Logs/mole/operations.log" + [[ -f "$oplog" ]] || return 1 + grep -Fq "[clean] REMOVED /tmp/example (1KB)" "$oplog" +} + +@test "should_protect_path protects Mole runtime logs" { + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc -c \ + 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$HOME/Library/Logs/mole/operations.log" && echo protected || echo not-protected' + )" + [ "$result" = "protected" ] +} + +@test "should_protect_path protects wallpaper and aerial assets" { + local path result + for path in \ + "$HOME/Library/Application Support/com.apple.idleassetsd" \ + "$HOME/Library/Application Support/com.apple.idleassetsd/Customer/video.mov" \ + "/Library/Application Support/com.apple.idleassetsd/Customer/video.mov" \ + "$HOME/Library/Application Support/com.apple.wallpaper/aerials/video.mov" \ + "$HOME/Library/Application Support/com.apple.wallpaper/aerials/thumbnails/video.png"; do + result=$(HOME="$HOME" TARGET_PATH="$path" /bin/bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo protected || echo unprotected') + [ "$result" = "protected" ] || return 1 + done + + path="$HOME/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/rebuildable.bin" + result=$(HOME="$HOME" TARGET_PATH="$path" /bin/bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo protected || echo unprotected') + [ "$result" = "unprotected" ] +} + +@test "xcode_build_tooling_process_state recognizes command-line build owners" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +pgrep() { + [[ "$1" == "-x" && "$2" == "xcodebuild" ]] +} +xcode_build_tooling_process_state +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } +} + +@test "xcode_build_tooling_process_state reports unknown on probe errors and missing pgrep" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +pgrep() { return 2; } +process_state=0 +xcode_build_tooling_process_state || process_state=$? +[[ "$process_state" -eq 2 ]] || exit 2 +unset -f pgrep +PATH=/nonexistent +process_state=0 +xcode_build_tooling_process_state || process_state=$? +[[ "$process_state" -eq 2 ]] || exit 3 +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } +} + +@test "xcode_build_tooling_process_state reports reliable no-match" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +pgrep() { return 1; } +process_state=0 +xcode_build_tooling_process_state || process_state=$? +[[ "$process_state" -eq 1 ]] +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } +} + @test "rotate_log_once only checks log size once per session" { - local log_file="$HOME/.config/mole/mole.log" + local log_file="$HOME/Library/Logs/mole/mole.log" mkdir -p "$(dirname "$log_file")" - dd if=/dev/zero of="$log_file" bs=1024 count=1100 2> /dev/null + if command -v mkfile > /dev/null 2>&1; then + mkfile -n 1100k "$log_file" + else + truncate -s 1100k "$log_file" + fi - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'" - [[ -f "${log_file}.old" ]] + # log.sh calls rotate_log_once at source time and exports MOLE_LOG_ROTATED. + # scripts/test.sh sources file_ops.sh, so the whole bats run already carries the + # marker and a child would skip rotation. Clear it to get a fresh session. + env -u MOLE_LOG_ROTATED HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'" + [[ -f "${log_file}.old" ]] || return 1 - result=$(HOME="$HOME" MOLE_LOG_ROTATED=1 bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; echo \$MOLE_LOG_ROTATED") + result=$(HOME="$HOME" MOLE_LOG_ROTATED=1 /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; echo \$MOLE_LOG_ROTATED") [[ "$result" == "1" ]] } @test "drain_pending_input clears stdin buffer" { result=$( - (echo -e "test\ninput" | HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; drain_pending_input; echo done") & + (echo -e "test\ninput" | HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; drain_pending_input; echo done") & pid=$! sleep 2 if kill -0 "$pid" 2> /dev/null; then @@ -99,7 +327,7 @@ setup() { @test "bytes_to_human converts byte counts into readable units" { output="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' source "$PROJECT_ROOT/lib/core/common.sh" bytes_to_human 512 bytes_to_human 2000 @@ -119,8 +347,107 @@ EOF [ "${bytes_lines[3]}" = "3.00GB" ] } +@test "percent_encode_path encodes spaces and multibyte characters per byte" { + output="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +percent_encode_path "/Users/x/Library/Application Support/中文 dir" +printf '\n' +percent_encode_path "/plain/path-1.2_3~ok" +printf '\n' +EOF + )" + + encode_lines=() + while IFS= read -r line; do + encode_lines+=("$line") + done <<< "$output" + + [ "${encode_lines[0]}" = "/Users/x/Library/Application%20Support/%E4%B8%AD%E6%96%87%20dir" ] + [ "${encode_lines[1]}" = "/plain/path-1.2_3~ok" ] +} + +@test "format_path_link falls back to plain tilde path without a TTY" { + output="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +format_path_link "$HOME/Library/Application Support/MobileSync/Backup" +printf '\n' +EOF + )" + + # Captured output is not a TTY, so no OSC 8 escapes may leak into pipes. + # shellcheck disable=SC2088 # literal tilde is the expected display form + [ "$output" = "~"'/Library/Application Support/MobileSync/Backup' ] +} + +@test "colorize_human_size colors dry-run size units by suffix" { + output="$( + env -u NO_COLOR HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +colorize_human_size "1.00GB" +printf '\n' +colorize_human_size "5.0MB" +printf '\n' +colorize_human_size "180KB" +printf '\n' +colorize_human_size "0B" +printf '\n' +EOF + )" + + color_lines=() + while IFS= read -r line; do + color_lines+=("$line") + done <<< "$output" + + [ "${color_lines[0]}" = $'\033[0;31m1.00GB\033[0m' ] + [ "${color_lines[1]}" = $'\033[0;33m5.0MB\033[0m' ] + [ "${color_lines[2]}" = $'\033[0;32m180KB\033[0m' ] + [ "${color_lines[3]}" = $'\033[0;38;5;244m0B\033[0m' ] +} + +@test "muted text avoids theme-defined ANSI bright black" { + run grep -R -I -nF '0;90m' \ + "$PROJECT_ROOT/lib" \ + "$PROJECT_ROOT/bin" \ + "$PROJECT_ROOT/mole" \ + "$PROJECT_ROOT/cmd/analyze" + + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "mole_pgrep_any distinguishes active, inactive, and unknown probes" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +pgrep() { + case "$2" in + active) return 0 ;; + missing) return 1 ;; + broken) return 2 ;; + esac + return 1 +} + +mole_pgrep_any -x missing -f active +set +e +mole_pgrep_any -x missing -f absent +inactive_rc=$? +mole_pgrep_any -x missing -f broken +unknown_rc=$? +set -e +printf 'inactive=%s unknown=%s\n' "$inactive_rc" "$unknown_rc" +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"inactive=1 unknown=2"* ]] +} + @test "create_temp_file and create_temp_dir are tracked and cleaned" { - HOME="$HOME" bash --noprofile --norc << 'EOF' + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' source "$PROJECT_ROOT/lib/core/common.sh" create_temp_file > "$HOME/temp_file_path.txt" create_temp_dir > "$HOME/temp_dir_path.txt" @@ -136,98 +463,404 @@ EOF @test "should_protect_data protects system and critical apps" { - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.apple.Safari' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.apple.Safari' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.clash.app' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'io.github.clash-verge-rev.clash-verge-rev' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.amnezia.awg' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.wireguard.macos' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.example.RegularApp' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] +} + +# Regression: CUPS prefs have a bundle-ID-style name but no parent .app, +# so the orphan sweep deleted them and users lost their default printer +# and recent-printer list. See #731. +@test "should_protect_data protects CUPS printing prefs (#731)" { + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.cups.PrintingPrefs' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.cups.printers' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] +} + +@test "should_protect_data protects Codex runtime identifiers" { + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'Codex' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.openai.codex' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'codex-runtimes' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.clash.app' && echo 'protected' || echo 'not-protected'") + local codex_runtimes_path="$HOME/.cache/codex-runtimes" + result=$(HOME="$HOME" TARGET_PATH="$codex_runtimes_path" /bin/bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo "protected" || echo "not-protected"') [ "$result" = "protected" ] - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.example.RegularApp' && echo 'protected' || echo 'not-protected'") + for codex_state_path in \ + "$HOME/Library/Application Support/Codex/Cache/index" \ + "$HOME/Library/Logs/com.openai.codex/codex.log" \ + "$HOME/.codex/sessions/2026/06/session.jsonl" \ + "$HOME/.codex/cache/session_index.jsonl" \ + "$HOME/.codex/cache/codex_app_directory/index.json" \ + "$HOME/.codex/state_5.sqlite" \ + "$HOME/.codex/logs_2.sqlite"; do + result=$(HOME="$HOME" TARGET_PATH="$codex_state_path" /bin/bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo "protected" || echo "not-protected"') + [ "$result" = "protected" ] + done +} + +@test "should_protect_data covers Raycast wildcard variants" { + for id in com.raycast.macos com.raycast.shared com.raycast.macos.BrowserExtension com.raycast-x.macos; do + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data '$id' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + done + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.raycastfoo.bar' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] } +@test "should_protect_path protects NetworkExtension VPN preferences" { + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_path '/Volumes/Data/Library/Preferences/com.apple.networkextension.plist' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + local user_network_ext_pref="$HOME/Library/Preferences/com.apple.networkextension.necp.plist" + result=$(HOME="$HOME" TARGET_PATH="$user_network_ext_pref" /bin/bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo "protected" || echo "not-protected"') + [ "$result" = "protected" ] +} + @test "input methods are protected during cleanup but allowed for uninstall" { - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.sogou.inputmethod.pinyin' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.sogou.inputmethod.pinyin' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] + + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.inputmethod.SCIM' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] +} + +@test "Karabiner-Elements is protected during cleanup but allowed for uninstall" { + # Keyboard config and preferences stay protected during clean + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.pqrs.Karabiner-Elements.Settings' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + # But the app itself is a third-party app, not a system component, so it can be uninstalled + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'org.pqrs.Karabiner-Elements.Settings' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] + + # The main app bundle id (the actual `mo uninstall --list` key) behaves the same: + # removable from uninstall, still data-protected during clean. + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'org.pqrs.Karabiner-Elements' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.inputmethod.SCIM' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.pqrs.Karabiner-Elements' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] } @test "Apple apps from App Store can be uninstalled (Issue #386)" { # Xcode should NOT be protected from uninstall - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.dt.Xcode' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.dt.Xcode' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] # Final Cut Pro should NOT be protected from uninstall - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.FinalCutPro' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.FinalCutPro' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] # GarageBand should NOT be protected from uninstall - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.GarageBand' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.GarageBand' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] # iWork apps should NOT be protected from uninstall - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.iWork.Pages' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.iWork.Pages' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] # But Safari (system app) should still be protected - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.Safari' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.Safari' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] # And Finder should still be protected - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.finder' && echo 'protected' || echo 'not-protected'") + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.finder' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] } @test "print_summary_block formats output correctly" { - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; print_summary_block 'success' 'Test Summary' 'Detail 1' 'Detail 2'") - [[ "$result" == *"Test Summary"* ]] - [[ "$result" == *"Detail 1"* ]] + result=$(HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; print_summary_block 'success' 'Test Summary' 'Detail 1' 'Detail 2'") + [[ "$result" == *"Test Summary"* ]] || return 1 + [[ "$result" == *"Detail 1"* ]] || return 1 [[ "$result" == *"Detail 2"* ]] } @test "start_inline_spinner and stop_inline_spinner work in non-TTY" { - result=$(HOME="$HOME" bash --noprofile --norc << 'EOF' + result=$(HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' source "$PROJECT_ROOT/lib/core/common.sh" MOLE_SPINNER_PREFIX=" " start_inline_spinner "Testing..." sleep 0.1 stop_inline_spinner echo "done" EOF -) + ) [[ "$result" == *"done"* ]] } +@test "start_inline_spinner ignores PATH-provided sleep in TTY mode" { + if ! /usr/bin/script -q /dev/null /bin/true > /dev/null 2>&1; then + skip "script cannot allocate a TTY in this environment" + fi + + local fake_bin="$HOME/fake-bin" + local marker="$HOME/fake-sleep.marker" + + mkdir -p "$fake_bin" + cat > "$fake_bin/sleep" <> "$marker" +exec /bin/sleep "\$@" +EOF + chmod +x "$fake_bin/sleep" + + PATH="$fake_bin:$PATH" PROJECT_ROOT="$PROJECT_ROOT" HOME="$HOME" \ + /usr/bin/script -q /dev/null /bin/bash --noprofile --norc -c \ + "source \"\$PROJECT_ROOT/lib/core/common.sh\"; start_inline_spinner \"Testing...\"; /bin/sleep 0.15; stop_inline_spinner" \ + > /dev/null 2>&1 + + [ ! -f "$marker" ] +} + +@test "update_inline_spinner_message returns 1 without an active spinner" { + run /bin/bash --noprofile --norc -c \ + "source '$PROJECT_ROOT/lib/core/common.sh'; update_inline_spinner_message 'New text'" + [ "$status" -eq 1 ] +} + +@test "update_inline_spinner_message swaps a live TTY spinner's text in place" { + if ! /usr/bin/script -q /dev/null /bin/true > /dev/null 2>&1; then + skip "script cannot allocate a TTY in this environment" + fi + + local raw="$HOME/spinner-update.raw" + # shellcheck disable=SC2016 # inner bash expands these from its environment + PROJECT_ROOT="$PROJECT_ROOT" HOME="$HOME" TERM=xterm-256color \ + /usr/bin/script -q "$raw" /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/lib/core/common.sh" + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Phase one..." + pid_before="$INLINE_SPINNER_PID" + control_dir="$INLINE_SPINNER_CONTROL_DIR" + control_mode=$(stat -f%Lp "$control_dir") + [[ "$INLINE_SPINNER_MSG_FILE" == "$control_dir/message" && "$control_mode" == "700" ]] && echo "CONTROL_PRIVATE" + /bin/sleep 0.2 + update_inline_spinner_message "Phase two..." || echo "UPDATE_FAILED" + /bin/sleep 0.2 + pid_after="$INLINE_SPINNER_PID" + stop_inline_spinner + [[ "$pid_before" == "$pid_after" && -n "$pid_before" ]] && echo "PID_STABLE" + ' > /dev/null 2>&1 + + raw_content="$(cat "$raw")" + [[ "$raw_content" == *"Phase one..."* ]] || return 1 + [[ "$raw_content" == *"Phase two..."* ]] || return 1 + [[ "$raw_content" != *"UPDATE_FAILED"* ]] || return 1 + [[ "$raw_content" == *"PID_STABLE"* ]] || return 1 + [[ "$raw_content" == *"CONTROL_PRIVATE"* ]] || return 1 +} + +@test "update_progress_if_needed updates spinner text without restarting it" { + if ! /usr/bin/script -q /dev/null /bin/true > /dev/null 2>&1; then + skip "script cannot allocate a TTY in this environment" + fi + + local raw="$HOME/spinner-progress.raw" + # shellcheck disable=SC2016 # inner bash expands these from its environment + PROJECT_ROOT="$PROJECT_ROOT" HOME="$HOME" TERM=xterm-256color \ + /usr/bin/script -q "$raw" /bin/bash --noprofile --norc -c ' + source "$PROJECT_ROOT/lib/core/common.sh" + start_section_spinner "Scanning items... 0/10" + pid_before="$INLINE_SPINNER_PID" + last_tick=0 + update_progress_if_needed 5 10 last_tick 1 + pid_after="$INLINE_SPINNER_PID" + /bin/sleep 0.2 + stop_inline_spinner + [[ "$pid_before" == "$pid_after" && -n "$pid_before" ]] && echo "PID_STABLE" + ' > /dev/null 2>&1 + + raw_content="$(cat "$raw")" + [[ "$raw_content" == *"Scanning items... 5/10"* ]] || return 1 + [[ "$raw_content" == *"PID_STABLE"* ]] || return 1 +} + +@test "safe_clear_lines emits the same erase sequence per line to the target device" { + local out="$HOME/clear-lines.out" + run /bin/bash --noprofile --norc -c \ + "export MOLE_ANSI_SUPPORTED_CACHE=0; source '$PROJECT_ROOT/lib/core/common.sh'; safe_clear_lines 2 '$out'" + [ "$status" -eq 0 ] + + expected="$(printf '\033[1A\r\033[2K\033[1A\r\033[2K')" + [ "$(cat "$out")" = "$expected" ] +} + @test "read_key maps j/k/h/l to navigation" { - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" [ "$output" = "DOWN" ] - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'k' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'k' | read_key" [ "$output" = "UP" ] - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'h' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'h' | read_key" [ "$output" = "LEFT" ] - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'l' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'l' | read_key" [ "$output" = "RIGHT" ] } @test "read_key maps uppercase J/K/H/L to navigation" { - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'J' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'J' | read_key" [ "$output" = "DOWN" ] - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'K' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'K' | read_key" [ "$output" = "UP" ] } +@test "read_key maps gg to TOP and a lone g to OTHER" { + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; printf 'gg' | read_key" + [ "$output" = "TOP" ] + + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; printf 'g' | read_key" + [ "$output" = "OTHER" ] +} + +@test "read_key gg works on macOS default Bash 3.2 (fractional read -t rejected)" { + # macOS ships /bin/bash 3.2.57, which rejects fractional `read -t` timeouts. + # Exercise the shortcut under that exact interpreter when present so the + # portability regression is caught on macOS even if bats runs under bash 4+. + [[ -x /bin/bash ]] || skip "/bin/bash not available" + local major + major=$(/bin/bash -c 'echo "${BASH_VERSINFO[0]}"' 2> /dev/null || echo 99) + [[ "$major" -lt 4 ]] || skip "/bin/bash is not a 3.x build" + + run /bin/bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; printf 'gg' | read_key" + [ "$output" = "TOP" ] +} + @test "read_key respects MOLE_READ_KEY_FORCE_CHAR" { - run bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" + run /bin/bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" [ "$output" = "CHAR:j" ] } + +@test "read_key keeps Ctrl-C as quit when forcing printable characters" { + run /bin/bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; printf '\\003' | read_key" + [ "$output" = "QUIT" ] +} + +@test "ensure_sudo_session returns 1 and sets MOLE_SUDO_ESTABLISHED=false in test mode" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc <<'SCRIPT' +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" +MOLE_SUDO_ESTABLISHED="" +ensure_sudo_session "Test prompt" && rc=0 || rc=$? +echo "EXIT=$rc" +echo "FLAG=$MOLE_SUDO_ESTABLISHED" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"EXIT=1"* ]] || return 1 + [[ "$output" == *"FLAG=false"* ]] +} + +@test "sudo helpers do not invoke sudo in no-auth test mode" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc <<'SCRIPT' +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" +sudo() { + echo "SUDO_CALLED:$*" >&2 + exit 99 +} +export -f sudo + +has_sudo_session && has_rc=0 || has_rc=$? +request_sudo_access "Test prompt" && request_rc=0 || request_rc=$? +ensure_sudo_session "Test prompt" && ensure_rc=0 || ensure_rc=$? + +echo "HAS=$has_rc" +echo "REQUEST=$request_rc" +echo "ENSURE=$ensure_rc" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"HAS=1"* ]] || return 1 + [[ "$output" == *"REQUEST=1"* ]] || return 1 + [[ "$output" == *"ENSURE=1"* ]] || return 1 + [[ "$output" != *"SUDO_CALLED"* ]] +} + +@test "ensure_sudo_session short-circuits to 0 when session already established" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" +has_sudo_session() { return 0; } +export -f has_sudo_session +MOLE_SUDO_ESTABLISHED="true" +ensure_sudo_session "Test prompt" +echo "EXIT=$?" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"EXIT=0"* ]] +} + +@test "adopt_sudo_session starts keepalive for cached sudo" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" + +sudo() { + printf 'SUDO:%s\n' "$*" + [[ "${1:-}" == "-n" && "${2:-}" == "-v" ]] +} +_start_sudo_keepalive() { + echo "keepalive-pid" +} +_stop_sudo_keepalive() { :; } + +adopt_sudo_session +echo "EXIT=$?" +echo "FLAG=$MOLE_SUDO_ESTABLISHED" +echo "PID=$MOLE_SUDO_KEEPALIVE_PID" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"SUDO:-n -v"* ]] || return 1 + [[ "$output" == *"EXIT=0"* ]] || return 1 + [[ "$output" == *"FLAG=true"* ]] || return 1 + [[ "$output" == *"PID=keepalive-pid"* ]] +} + +# A cleanup lib sourced without an entry point must not abort on an unset +# DRY_RUN: the read sites are unguarded by convention, so the default lives in +# base.sh. Reproduces the "DRY_RUN: unbound variable" abort from lib/clean/caches.sh. +@test "cleanup libs run under set -u without an entry point assigning DRY_RUN" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +[[ "${DRY_RUN:-unset}" != "unset" ]] || { echo "DRY_RUN still unset after sourcing common.sh"; exit 1; } +echo "DRY_RUN=$DRY_RUN" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"DRY_RUN=false"* ]] +} diff --git a/Resources/mole/tests/core_performance.bats b/Resources/mole/tests/core_performance.bats index 4bf4034..665b6d2 100644 --- a/Resources/mole/tests/core_performance.bats +++ b/Resources/mole/tests/core_performance.bats @@ -106,32 +106,9 @@ setup() { [ "$result" = "$current_user" ] } -@test "get_invoking_user executes quickly" { - local start end elapsed - local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-500}" - - start=$(date +%s%N) - for i in {1..100}; do - get_invoking_user > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt "$limit_ms" ] -} - -@test "get_darwin_major caches correctly" { - local first second - first=$(get_darwin_major) - second=$(get_darwin_major) - - [ "$first" = "$second" ] - [[ "$first" =~ ^[0-9]+$ ]] -} - @test "create_temp_file and cleanup_temp_files work efficiently" { local start end elapsed + local limit_ms="${MOLE_PERF_CREATE_TEMP_FILE_LIMIT_MS:-3000}" declare -a MOLE_TEMP_DIRS=() @@ -143,7 +120,7 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 1000 ] + [ "$elapsed" -lt "$limit_ms" ] [ "${#MOLE_TEMP_FILES[@]}" -eq 50 ] @@ -152,7 +129,7 @@ setup() { end=$(date +%s%N) elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 2000 ] + [ "$elapsed" -lt "$limit_ms" ] [ "${#MOLE_TEMP_FILES[@]}" -eq 0 ] } @@ -161,43 +138,18 @@ setup() { local temp_file temp_file=$(mktemp_file "test_prefix") - [[ "$temp_file" =~ test_prefix ]] + [[ "$temp_file" =~ test_prefix ]] || return 1 [ -f "$temp_file" ] rm -f "$temp_file" } -@test "get_brand_name handles common apps efficiently" { - local start end elapsed - - get_brand_name "wechat" > /dev/null - - start=$(date +%s%N) - for i in {1..50}; do - get_brand_name "wechat" > /dev/null - get_brand_name "QQ" > /dev/null - get_brand_name "dingtalk" > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt 5000 ] -} - -@test "get_brand_name returns correct localized names" { - local result - result=$(get_brand_name "wechat") - - [[ "$result" == "WeChat" || "$result" == "微信" ]] -} - @test "get_optimal_parallel_jobs returns sensible values" { local result result=$(get_optimal_parallel_jobs) - [[ "$result" =~ ^[0-9]+$ ]] + [[ "$result" =~ ^[0-9]+$ ]] || return 1 [ "$result" -gt 0 ] [ "$result" -le 128 ] @@ -233,5 +185,6 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 2000 ] + local limit_ms="${MOLE_PERF_SECTION_LIMIT_MS:-2000}" + [ "$elapsed" -lt "$limit_ms" ] } diff --git a/Resources/mole/tests/core_safe_functions.bats b/Resources/mole/tests/core_safe_functions.bats index 5805f04..aaf5020 100644 --- a/Resources/mole/tests/core_safe_functions.bats +++ b/Resources/mole/tests/core_safe_functions.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi source "$PROJECT_ROOT/lib/core/common.sh" TEST_DIR="$HOME/test_safe_functions" mkdir -p "$TEST_DIR" @@ -31,66 +38,352 @@ teardown() { } @test "validate_path_for_deletion rejects empty path" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion ''" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion ''" [ "$status" -eq 1 ] } @test "validate_path_for_deletion rejects relative path" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion 'relative/path'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion 'relative/path'" [ "$status" -eq 1 ] } @test "validate_path_for_deletion rejects path traversal" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'" [ "$status" -eq 1 ] # Test other path traversal patterns - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/var/log/../../etc'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/var/log/../../etc'" [ "$status" -eq 1 ] - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/..'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/..'" [ "$status" -eq 1 ] } @test "validate_path_for_deletion accepts Firefox-style ..files directories" { # Firefox uses ..files suffix in IndexedDB directory names - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/2753419432nreetyfallipx..files'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/2753419432nreetyfallipx..files'" [ "$status" -eq 0 ] - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/storage/default/https+++www.netflix.com/idb/name..files/data'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/storage/default/https+++www.netflix.com/idb/name..files/data'" [ "$status" -eq 0 ] # Directories with .. in the middle of names should be allowed - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/test..backup/file.txt'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/test..backup/file.txt'" [ "$status" -eq 0 ] } @test "validate_path_for_deletion rejects system directories" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/System'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/System'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/usr/bin'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/etc'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Library/Apple'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Applications/Finder.app'" [ "$status" -eq 1 ] - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/usr/bin'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Users'" [ "$status" -eq 1 ] +} + +@test "validate_path_for_deletion rejects aliased critical paths" { + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '//etc/passwd'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '///System'" + [ "$status" -eq 1 ] +} + +@test "validate_path_for_deletion rejects a target whose ancestor symlink redirects into a critical path" { + # The deny list and the -L check both look at the literal string / leaf, so + # a symlinked ANCESTOR used to slip through: the policy path looked like an + # ordinary cache dir while rm followed the link into the real tree. + local fake_caches="$TEST_DIR/redirected-Caches" + ln -s /System "$fake_caches" + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$fake_caches/Library/Caches/victim'" + [ "$status" -eq 1 ] + [[ "$output" == *"resolves into a critical system path"* ]] || return 1 +} + +@test "validate_path_for_deletion rejects a target whose ancestor symlink redirects into protected user data" { + local protected_home="$TEST_DIR/home" + mkdir -p "$protected_home/Library/Keychains" + local fake_cache_root="$TEST_DIR/cache-root" + ln -s "$protected_home/Library" "$fake_cache_root" - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/etc'" + # should_protect_path is home-relative, so drive it against a fake HOME. + run /bin/bash -c "export HOME='$protected_home'; source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$fake_cache_root/Keychains/login.keychain-db'" [ "$status" -eq 1 ] } +@test "validate_path_for_deletion still accepts an ordinary path under a real directory" { + # The ancestor guard is deny-only: it must not reject legitimate targets + # whose ancestors merely resolve (e.g. /tmp -> /private/tmp on macOS). + mkdir -p "$TEST_DIR/real/Caches" + : > "$TEST_DIR/real/Caches/cache.db" + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/real/Caches/cache.db'" + [ "$status" -eq 0 ] +} + @test "validate_path_for_deletion accepts valid path" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/valid'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/valid'" + [ "$status" -eq 0 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$HOME/Library/Caches/com.example.app/cache.db'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion rejects temp roots while allowing their children" { + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/tmp'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/tmp'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/folders'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/dev'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/tmp/mole-old-artifact'" + [ "$status" -eq 0 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/tmp/mole-old-artifact'" + [ "$status" -eq 0 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/folders/test/a/C/com.example.App/cache'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion rejects case aliases of critical roots" { + run /bin/bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + checked=0 + for pair in \ + '/SYSTEM|/System' \ + '/DEV|/dev' \ + '/PRIVATE/TMP|/private/tmp' \ + '/PRIVATE/VAR/FOLDERS|/private/var/folders' \ + '/USERS|/Users'; do + alias_path=\${pair%%|*} + canonical_path=\${pair#*|} + if [[ -e \"\$alias_path\" && \"\$alias_path\" -ef \"\$canonical_path\" ]]; then + checked=\$((checked + 1)) + validate_path_for_deletion \"\$alias_path\" && exit 90 + fi + done + [[ \$checked -gt 0 ]] || return 1 + " + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion accepts CoreSimulator system cache children" { + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Library/Developer/CoreSimulator/Caches/dyld'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion allows Darwin C cache shards but rejects protected extension paths" { + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/folders/test/a/C/com.example.App/com.apple.metal'" + [ "$status" -eq 0 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Library/Extensions/com.example.driver/com.apple.metal' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"critical system path"* ]] +} + +@test "validate_path_for_deletion rejects endpoint-security agent var/folders caches" { + # Central chokepoint: every safe_remove / safe_sudo_remove caller is covered, + # not only the cleanup sweeps that pre-check the predicate. + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/folders/9d/abc/C/com.crowdstrike.falcon.App/com.apple.metalfe'" + [ "$status" -eq 1 ] + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/PRIVATE/VAR/FOLDERS/9D/ABC/C/COM.CROWDSTRIKE.FALCON.APP/com.apple.metalfe'" + [ "$status" -eq 1 ] + + # A normal app's Darwin cache shard stays deletable. + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/folders/9d/abc/C/com.example.App/com.apple.metalfe'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion rejects the active powerlog database family" { + local db="/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/CurrentBackgroundProcessingDB.BGSQL" + local path="" + + for path in "$db" "$db-wal" "$db-shm" "${db%/*}/./${db##*/}" "${db%/*}/currentbackgroundprocessingdb.bgsql"; do + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$path'" + [ "$status" -eq 1 ] || return 1 + done + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/ArchivedBackgroundProcessingDB.BGSQL'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion refuses a live SQLite database family (#1390)" { + local db="$TEST_DIR/live-sqlite/Cache.db" + mkdir -p "$(dirname "$db")" + printf 'db' > "$db" + printf 'wal' > "$db-wal" + printf 'shm' > "$db-shm" + + for path in "$db" "$db-wal" "$db-shm"; do + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$path'" + [ "$status" -eq 1 ] || return 1 + done +} + +@test "validate_path_for_deletion refuses an SQLite database held open by a process (#1390)" { + local db="$TEST_DIR/open-sqlite/Cache.db" + mkdir -p "$(dirname "$db")" + printf 'db' > "$db" + + run env PROJECT_ROOT="$PROJECT_ROOT" db="$db" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +lsof() { return 0; } +run_with_timeout() { shift; "$@"; } +validate_path_for_deletion "$db" +EOF + + [ "$status" -eq 1 ] +} + +@test "validate_path_for_deletion allows an idle SQLite cache database (#1390)" { + local db="$TEST_DIR/idle-sqlite/Cache.db" + mkdir -p "$(dirname "$db")" + printf 'db' > "$db" + + run env PROJECT_ROOT="$PROJECT_ROOT" db="$db" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +lsof() { return 1; } +run_with_timeout() { shift; "$@"; } +validate_path_for_deletion "$db" +EOF + + [ "$status" -eq 0 ] +} + +@test "should_protect_path applies high-risk cleanup denylist" { + run /bin/bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + should_protect_path '$HOME/Library/Caches/ms-playwright/chromium-123' + should_protect_path '$HOME/Library/Caches/com.apple.homed/state' + should_protect_path '$HOME/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite' + should_protect_path '$HOME/Library/Preferences/com.paceap.eden.iLokLicenseManager.plist' + should_protect_path '/private/var/folders/aa/bb/C/com.native-instruments.NativeAccess/license' + should_protect_path '/Library/Audio/Plug-Ins/VST3/Example.vst3' + should_protect_data 'com.native-instruments.NativeAccess' + ! should_protect_path '$HOME/Library/Application Support/Example/Cache/item' + " + [ "$status" -eq 0 ] +} + +@test "is_endpoint_security_cache_path matches only EDR agent var/folders caches" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +# Deleting anything an EDR agent owns under the per-user Darwin folder trips +# sensor tamper detection (CrowdStrike MacFalconSensorTamper, MITRE T1562.001). +# The matcher covers the vendor bundle id anywhere under var/folders: the C/ +# shader cache that triggered the real corporate alert, the X/ code-signature +# clone, and T/ temp. Protection-only, so a wide match within var/folders is +# intentional. +is_endpoint_security_cache_path "/private/var/folders/9d/abc123/C/com.crowdstrike.falcon.App/com.apple.metalfe" +is_endpoint_security_cache_path "/private/var/folders/9d/abc123/X/com.crowdstrike.falcon.App.code_sign_clone" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/T/com.crowdstrike.falcon.App/scratch" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.sentinelone.agent/com.apple.metal" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.jamf.management/com.apple.gpuarchiver" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.paloaltonetworks.GlobalProtect/com.apple.metalfe" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.eset.endpoint/com.apple.metal" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.sentinel-labs.agent/com.apple.metalfe" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.jamfsoftware.selfservice/com.apple.gpuarchiver" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/X/com.cisco.anyconnect.gui.code_sign_clone" +is_endpoint_security_cache_path "/private/var/folders/aa/bb/X/com.cisco.secureclient.gui.code_sign_clone" +# A normal third-party app's cache is not an EDR cache. +! is_endpoint_security_cache_path "/private/var/folders/aa/bb/C/com.example.App/com.apple.metalfe" +# Non-security Cisco products (e.g. Webex) are not matched; only the secure-access clients are. +! is_endpoint_security_cache_path "/private/var/folders/aa/bb/X/com.cisco.webex.code_sign_clone" +# Paths outside var/folders are out of scope for this predicate. +! is_endpoint_security_cache_path "/Applications/Falcon.app" +# A non-Darwin path that merely contains "var/folders" must NOT match (anchored). +! is_endpoint_security_cache_path "/Users/me/project/var/folders/com.crowdstrike.fixture/cache" +EOF + + [ "$status" -eq 0 ] +} + +@test "should_protect_path protects endpoint-security / EDR agent caches (CrowdStrike Falcon tamper)" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +# Matched by the dedicated EDR predicate before any bundle/filename fallback, +# so the result is deterministic regardless of nounset/source order. +should_protect_path "/private/var/folders/9d/abc123/C/com.crowdstrike.falcon.App/com.apple.metalfe" +should_protect_path "/private/var/folders/aa/bb/C/com.sentinelone.agent/com.apple.metal" +EOF + + [ "$status" -eq 0 ] +} + +@test "should_protect_path protects OrbStack live container data" { + local orb_group_data="$HOME/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data/data.img.raw" + local orb_state="$HOME/.orbstack/state.db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORB_GROUP_DATA="$orb_group_data" ORB_STATE="$orb_state" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +should_protect_data "dev.orbstack.OrbStack" +should_protect_data "dev.kdrag0n.MacVirt" +should_protect_path "$ORB_GROUP_DATA" +should_protect_path "$ORB_STATE" +EOF + [ "$status" -eq 0 ] } @test "safe_remove validates path before deletion" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' 2>&1" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' 2>&1" [ "$status" -eq 1 ] } +@test "validate_path_for_deletion rejects symlink to protected system path" { + local link_path="$TEST_DIR/system-link" + ln -s "/System" "$link_path" + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$link_path' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"protected system path"* ]] +} + +@test "safe_remove silent mode hides protected symlink validation warning" { + local link_path="$TEST_DIR/silent-system-link" + ln -s "/System" "$link_path" + + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$link_path' true 2>&1" + [ "$status" -eq 1 ] + [[ -L "$link_path" ]] || return 1 + [[ "$output" != *"Symlink points to protected system path"* ]] +} + @test "safe_remove successfully removes file" { local test_file="$TEST_DIR/test_file.txt" echo "test" > "$test_file" - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$test_file' true" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$test_file' true" [ "$status" -eq 0 ] [ ! -f "$test_file" ] } @@ -100,13 +393,13 @@ teardown() { mkdir -p "$test_subdir" touch "$test_subdir/file.txt" - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$test_subdir' true" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$test_subdir' true" [ "$status" -eq 0 ] [ ! -d "$test_subdir" ] } @test "safe_remove handles non-existent path gracefully" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$TEST_DIR/nonexistent' true" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$TEST_DIR/nonexistent' true" [ "$status" -eq 0 ] } @@ -114,7 +407,7 @@ teardown() { local test_file="$TEST_DIR/interrupt_file" echo "test" > "$test_file" - run bash -c " + run /bin/bash -c " source '$PROJECT_ROOT/lib/core/common.sh' rm() { return 130; } safe_remove '$test_file' true @@ -123,32 +416,1506 @@ teardown() { [ -f "$test_file" ] } +@test "safe_remove bounds a stalled external rm" { + local target_file="$TEST_DIR/stalled-rm-file" + local mock_bin="$TEST_DIR/stalled-rm-bin" + local trace="$TEST_DIR/stalled-rm.trace" + mkdir -p "$mock_bin" + touch "$target_file" + + cat > "$mock_bin/rm" <<'MOCK' +#!/bin/bash +if [[ "$*" == *"$TARGET_FILE"* ]]; then + printf 'rm %s\n' "$*" >> "$MOLE_RM_TRACE" + exec sleep 4 +fi +exec /bin/rm "$@" +MOCK + chmod +x "$mock_bin/rm" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_FILE="$target_file" \ + PATH="$mock_bin:$PATH" MOLE_RM_TRACE="$trace" MOLE_TIMEOUT_DISK_VERIFY_SEC=1 \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +get_path_size_kb() { echo 1; } +started=$(date +%s) +rc=0 +safe_remove "$TARGET_FILE" true || rc=$? +elapsed=$(( $(date +%s) - started )) +printf 'RC=%s\nELAPSED=%s\n' "$rc" "$elapsed" +SCRIPT + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$(< "$trace")" == *"$target_file"* ]] || return 1 + local elapsed="${output##*ELAPSED=}" + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 + [ "$elapsed" -lt 3 ] + [ -e "$target_file" ] +} + +@test "safe_remove refuses a compiled model cache created during size probing" { + local target_dir="$TEST_DIR/compiled-model-race" + mkdir -p "$target_dir" + touch "$target_dir/data" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +oplog_enabled() { return 0; } +get_path_size_kb() { + mkdir -p "$1/com.apple.e5rt.e5bundlecache" + echo 1 +} +rm() { echo "UNEXPECTED_REMOVE:$*"; return 99; } +safe_remove "$TARGET_DIR" true && rc=0 || rc=$? +printf 'RC=%s\n' "$rc" +[[ -d "$TARGET_DIR/com.apple.e5rt.e5bundlecache" ]] +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "safe_remove dry-run refuses an existing compiled model cache" { + local target_dir="$TEST_DIR/compiled-model-dry-run" + mkdir -p "$target_dir/com.apple.e5rt.e5bundlecache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +MOLE_DRY_RUN=1 +record_dry_run_cleanup_target() { echo "UNEXPECTED_REGISTER:$1"; } +safe_remove "$TARGET_DIR" true && rc=0 || rc=$? +printf 'RC=%s\n' "$rc" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REGISTER"* ]] +} + @test "safe_remove in silent mode suppresses error output" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' true 2>&1" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' true 2>&1" [ "$status" -eq 1 ] } @test "safe_find_delete validates base directory" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '/nonexistent' '*.tmp' 7 'f' 2>&1" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '/nonexistent' '*.tmp' 7 'f' 2>&1" [ "$status" -eq 1 ] } +@test "safe_sudo_remove refuses symlink paths" { + local target_dir="$TEST_DIR/real" + local link_dir="$TEST_DIR/link" + mkdir -p "$target_dir" + ln -s "$target_dir" "$link_dir" + + run /bin/bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + sudo() { return 0; } + export -f sudo + safe_sudo_remove '$link_dir' 2>&1 + " + [ "$status" -eq 1 ] + [[ "$output" == *"Refusing to sudo remove symlink"* ]] +} + +@test "safe_sudo_remove never opens an interactive sudo prompt" { + local target_dir="$TEST_DIR/sudo-target" + mkdir -p "$target_dir" + touch "$target_dir/file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +# This test models a root-owned, immutable system path so it reaches the +# noninteractive sudo authentication branch. +_mole_privileged_path_has_mutable_ancestor() { return 1; } + +sudo() { + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + du) + shift + command du "$@" + ;; + rm) + shift + command rm "$@" + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +safe_sudo_remove "$TARGET_DIR" +[[ ! -e "$TARGET_DIR" ]] || exit 1 +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" != *"INTERACTIVE_SUDO"* ]] +} + +@test "safe_sudo_remove bounds a stalled external sudo rm" { + local target_dir="$TEST_DIR/stalled-sudo-rm-target" + local mock_bin="$TEST_DIR/stalled-sudo-rm-bin" + local trace="$TEST_DIR/stalled-sudo-rm.trace" + mkdir -p "$target_dir" "$mock_bin" + touch "$target_dir/data" + + cat > "$mock_bin/sudo" <<'MOCK' +#!/bin/bash +set -u +[[ "${1:-}" == "-n" ]] && shift +case "${1:-}" in + rm) + printf 'sudo-rm %s\n' "$*" >> "$MOLE_SUDO_RM_TRACE" + exec sleep 4 + ;; + *) + exit 99 + ;; +esac +MOCK + chmod +x "$mock_bin/sudo" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + PATH="$mock_bin:$PATH" MOLE_SUDO_RM_TRACE="$trace" \ + MOLE_TIMEOUT_DISK_VERIFY_SEC=1 MO_NO_OPLOG=1 \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +started=$(date +%s) +rc=0 +safe_sudo_remove "$TARGET_DIR" || rc=$? +elapsed=$(( $(date +%s) - started )) +printf 'RC=%s\nELAPSED=%s\n' "$rc" "$elapsed" +SCRIPT + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$(< "$trace")" == *"sudo-rm rm -rf $target_dir"* ]] || return 1 + local elapsed="${output##*ELAPSED=}" + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 + [ "$elapsed" -lt 3 ] + [ -e "$target_dir/data" ] +} + +@test "safe_sudo_remove refuses a compiled model cache created during size probing" { + local target_dir="$TEST_DIR/compiled-model-sudo-race" + mkdir -p "$target_dir" + touch "$target_dir/data" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +oplog_enabled() { return 0; } +sudo() { + if [[ "${1:-}" == "-n" && "${2:-}" == "test" ]]; then + shift 2 + command test "$@" + return $? + fi + if [[ "${1:-}" == "-n" && "${2:-}" == "du" ]]; then + mkdir -p "$TARGET_DIR/com.apple.e5rt.e5bundlecache" + printf '1 %s\n' "$TARGET_DIR" + return 0 + fi + echo "UNEXPECTED_SUDO:$*" + return 99 +} +safe_sudo_remove "$TARGET_DIR" && rc=0 || rc=$? +printf 'RC=%s\n' "$rc" +[[ -d "$TARGET_DIR/com.apple.e5rt.e5bundlecache" ]] +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=13"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUDO"* ]] +} + +@test "safe_sudo_remove dry-run refuses an existing compiled model cache" { + local target_dir="$TEST_DIR/compiled-model-sudo-dry-run" + mkdir -p "$target_dir/com.apple.e5rt.e5bundlecache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MOLE_TEST_MODE=1 MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +MOLE_DRY_RUN=1 +_mole_privileged_path_has_mutable_ancestor() { return 1; } +record_dry_run_cleanup_target() { echo "UNEXPECTED_REGISTER:$1"; } +safe_sudo_remove "$TARGET_DIR" && rc=0 || rc=$? +printf 'RC=%s\n' "$rc" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=13"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REGISTER"* ]] +} + +@test "safe_sudo_remove returns auth failure when noninteractive sudo expires" { + local target_dir="$TEST_DIR/sudo-expired" + mkdir -p "$target_dir" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +# This test models a root-owned, immutable system path so it reaches the +# noninteractive sudo authentication branch. +_mole_privileged_path_has_mutable_ancestor() { return 1; } + +sudo() { + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + echo "sudo: a password is required" >&2 + return 1 +} +export -f sudo + +safe_sudo_remove "$TARGET_DIR" && rc=0 || rc=$? +echo "RC=$rc" +[[ -e "$TARGET_DIR" ]] || exit 1 +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=11"* ]] || return 1 + [[ "$output" != *"INTERACTIVE_SUDO"* ]] +} + +@test "safe_sudo_remove returns protected-path code for safety skips" { + local target_dir="/private/var/folders/9d/abc/C/com.crowdstrike.falcon.App/com.apple.metalfe" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +safe_sudo_remove "$TARGET_DIR" && rc=0 || rc=$? +echo "RC=$rc" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=13"* ]] +} + +@test "safe_sudo_find_delete never opens an interactive sudo prompt" { + local target_dir="$TEST_DIR/sudo-find-target" + local script="$TEST_DIR/sudo-find-delete-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/old.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +TRACE="${TARGET_DIR}.sudo.trace" +> "$TRACE" + +sudo() { + printf 'SUDO:%s\n' "$*" >> "$TRACE" + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/old.log" + ;; + du) + shift + command du "$@" + ;; + rm) + return 0 + ;; + *) + "$@" + ;; + esac +} + +export -f sudo + +set +e +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +rc=$? +set -e +printf 'RC=%s\n' "$rc" +cat "$TRACE" || true +exit 0 +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=0"* ]] || return 1 + [[ "$output" == *"SUDO:-n test -d "* ]] || return 1 + [[ "$output" == *"SUDO:-n test -L "* ]] || return 1 + [[ "$output" == *"SUDO:-n find "* ]] || return 1 + [[ "$output" != *"INTERACTIVE_SUDO"* ]] +} + +@test "safe_sudo_find_delete bounds a stalled sudo find" { + local target_dir="$TEST_DIR/sudo-find-timeout-target" + local mock_bin="$TEST_DIR/sudo-find-timeout-bin" + local trace="$TEST_DIR/sudo-find-timeout.trace" + mkdir -p "$target_dir" "$mock_bin" + + cat > "$mock_bin/sudo" <<'MOCK' +#!/bin/bash +set -u +[[ "${1:-}" == "-n" ]] && shift +case "${1:-}" in + true) + exit 0 + ;; + test) + shift + /bin/test "$@" + ;; + find) + shift + printf 'find %s\n' "$*" >> "$MOLE_SUDO_FIND_TRACE" + exec sleep 4 + ;; + *) + exit 99 + ;; +esac +MOCK + chmod +x "$mock_bin/sudo" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + PATH="$mock_bin:$PATH" MOLE_SUDO_FIND_TRACE="$trace" \ + MOLE_TIMEOUT_DISK_VERIFY_SEC=1 MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +started=$(date +%s) +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +elapsed=$(( $(date +%s) - started )) +printf 'RC=%s\n' "$rc" +printf 'ELAPSED=%s\n' "$elapsed" +SCRIPT + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [ -f "$trace" ] || return 1 + local trace_content + trace_content=$(< "$trace") + [[ "$trace_content" == "find $target_dir -maxdepth 1 -name *.log -type f -print0" ]] || { + echo "TRACE=$trace_content" + return 1 + } + [[ "$output" == *"ELAPSED="* ]] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + local elapsed="${output##*ELAPSED=}" + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 + [ "$elapsed" -lt 3 ] +} + +@test "safe_sudo_find_delete discards partial output when sudo find times out" { + local target_dir="$TEST_DIR/sudo-find-partial-timeout-target" + local mock_bin="$TEST_DIR/sudo-find-partial-timeout-bin" + local trace="$TEST_DIR/sudo-find-partial-timeout.trace" + mkdir -p "$target_dir" "$mock_bin" + touch "$target_dir/old.log" + + cat > "$mock_bin/sudo" <<'MOCK' +#!/bin/bash +set -u +printf '%s\n' "$*" >> "$MOLE_SUDO_FIND_TRACE" +[[ "${1:-}" == "-n" ]] && shift +case "${1:-}" in + test) + shift + /bin/test "$@" + ;; + true) + exit 0 + ;; + find) + printf '%s\0' "$TARGET_DIR/old.log" + exec sleep 4 + ;; + xargs | rm) + printf 'UNEXPECTED_DELETE:%s\n' "$*" >> "$MOLE_SUDO_FIND_TRACE" + exit 99 + ;; + *) + exit 99 + ;; +esac +MOCK + chmod +x "$mock_bin/sudo" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + PATH="$mock_bin:$PATH" MOLE_SUDO_FIND_TRACE="$trace" \ + MOLE_TIMEOUT_DISK_VERIFY_SEC=1 MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +printf 'RC=%s\n' "$rc" +SCRIPT + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=124"* ]] || return 1 + local trace_content + trace_content=$(< "$trace") + [[ "$trace_content" == *"-n find $target_dir -maxdepth 1 -name *.log -type f -print0"* ]] || return 1 + [[ "$trace_content" != *"UNEXPECTED_DELETE"* ]] || return 1 + [[ -e "$target_dir/old.log" ]] +} + +@test "safe_sudo_find_delete bounds a stalled sudo batch removal" { + local target_dir="$TEST_DIR/sudo-batch-timeout-target" + local mock_bin="$TEST_DIR/sudo-batch-timeout-bin" + local trace="$TEST_DIR/sudo-batch-timeout.trace" + mkdir -p "$target_dir" "$mock_bin" + touch "$target_dir/old.log" + + cat > "$mock_bin/sudo" <<'MOCK' +#!/bin/bash +set -u +[[ "${1:-}" == "-n" ]] && shift +case "${1:-}" in + test) + shift + /bin/test "$@" + ;; + true) + exit 0 + ;; + find) + printf '%s\0' "$TARGET_DIR/old.log" + ;; + */stat) + exec "$@" + ;; + xargs) + printf 'xargs %s\n' "$*" >> "$MOLE_SUDO_BATCH_TRACE" + exec sleep 30 + ;; + *) + exit 99 + ;; +esac +MOCK + chmod +x "$mock_bin/sudo" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + PATH="$mock_bin:$PATH" MOLE_SUDO_BATCH_TRACE="$trace" MOLE_TIMEOUT_DISK_VERIFY_SEC=1 \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +started=$(date +%s) +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +elapsed=$(( $(date +%s) - started )) +printf 'RC=%s\nELAPSED=%s\n' "$rc" "$elapsed" +SCRIPT + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$(< "$trace")" == *"xargs xargs -0 /bin/sh -c"* ]] || return 1 + local elapsed="${output##*ELAPSED=}" + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 + [ "$elapsed" -lt 10 ] +} + +@test "safe_sudo_find_delete stops before scanning when its deadline is exhausted" { + local target_dir="$TEST_DIR/sudo-deadline-target" + mkdir -p "$target_dir" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +sudo() { + printf 'SUDO:%s\n' "$*" + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) + shift + command test "$@" + ;; + find | xargs) + printf 'UNEXPECTED_WORK:%s\n' "$*" + return 99 + ;; + *) "$@" ;; + esac +} +deadline=$SECONDS +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" "$deadline" || rc=$? +printf 'RC=%s\nCOUNT=%s\n' "$rc" "${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-unset}" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$output" == *"COUNT=0"* ]] || return 1 + [[ "$output" != *"SUDO:"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_WORK"* ]] +} + +@test "_mole_timeout_with_deadline clamps fractional timeouts to remaining whole seconds" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +# `remaining` is `deadline - SECONDS`, and SECONDS keeps ticking in real +# time, so one shared `SECONDS=100` before four forks means the last call +# sees a smaller window than the first. On a slow runner the window reached +# zero, the helper returned 124 with no output, and the case failed for +# reasons that had nothing to do with clamping. Re-pin the clock per call. +SECONDS=100 +printf 'CLAMPED=%s\n' "$(_mole_timeout_with_deadline 30.5 101)" +SECONDS=100 +printf 'SHORT=%s\n' "$(_mole_timeout_with_deadline 0.5 101)" +SECONDS=100 +printf 'ZERO=%s\n' "$(_mole_timeout_with_deadline 0 101)" +SECONDS=100 +printf 'LEADING=%s\n' "$(_mole_timeout_with_deadline 08.5 101)" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"CLAMPED=1"* ]] || return 1 + [[ "$output" == *"SHORT=0.5"* ]] || return 1 + [[ "$output" == *"ZERO=1"* ]] || return 1 + [[ "$output" == *"LEADING=1"* ]] +} + +@test "get_path_size_kb bounds the app metadata fast path" { + local app_dir="$TEST_DIR/Stalled.app" + local mock_bin="$TEST_DIR/stalled-mdls-bin" + local trace="$TEST_DIR/stalled-mdls.trace" + mkdir -p "$app_dir" "$mock_bin" + + cat > "$mock_bin/mdls" <<'MOCK' +#!/bin/bash +printf 'mdls %s\n' "$*" >> "$MOLE_MDLS_TRACE" +exec sleep 4 +MOCK + chmod +x "$mock_bin/mdls" + cat > "$mock_bin/du" <<'MOCK' +#!/bin/bash +printf 'UNEXPECTED_DU %s\n' "$*" >> "$MOLE_MDLS_TRACE" +exec sleep 4 +MOCK + chmod +x "$mock_bin/du" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP_DIR="$app_dir" \ + PATH="$mock_bin:$PATH" MOLE_MDLS_TRACE="$trace" \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +started=$(date +%s) +set +e +# Budget must be >= 2s. SECONDS has whole-second granularity, so a 1s budget +# is really "until the next second boundary" and can collapse to nearly zero, +# returning 124 before mdls is ever spawned. Two seconds always leaves at +# least a full second for the probe, which is what this case asserts on. +size=$(get_path_size_kb "$APP_DIR" 2) +size_rc=$? +set -e +elapsed=$(( $(date +%s) - started )) +printf 'SIZE=%s\nRC=%s\nELAPSED=%s\n' "$size" "$size_rc" "$elapsed" +[[ $size_rc -eq 124 ]] +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"SIZE="* ]] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$(< "$trace")" == *"$app_dir"* ]] || return 1 + [[ "$(< "$trace")" != *"UNEXPECTED_DU"* ]] || return 1 + local elapsed="${output##*ELAPSED=}" + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 + [ "$elapsed" -lt 4 ] +} + +@test "safe_remove stops when a size probe is interrupted" { + local app_dir="$TEST_DIR/Interrupted.app" + mkdir -p "$app_dir" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP_DIR="$app_dir" \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +run_with_timeout() { + local _duration="$1" + shift + if [[ "${1:-}" == "mdls" ]]; then + return 130 + fi + "$@" +} +rm() { + printf 'UNEXPECTED_REMOVE:%s\n' "$*" + return 99 +} +rc=0 +safe_remove "$APP_DIR" true || rc=$? +printf 'RC=%s\n' "$rc" +[[ -d "$APP_DIR" ]] +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "safe_sudo_find_delete propagates interrupts from every sudo preflight" { + local target_dir="$TEST_DIR/sudo-preflight-interrupt-target" + mkdir -p "$target_dir" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +for fail_stage in initial_auth base base_auth link link_auth; do + true_calls=0 + sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) + true_calls=$((true_calls + 1)) + if [[ "$fail_stage" == "initial_auth" && $true_calls -eq 1 ]] || \ + [[ "$fail_stage" == "base_auth" && $true_calls -eq 2 ]] || \ + [[ "$fail_stage" == "link_auth" && $true_calls -eq 2 ]]; then + return 130 + fi + return 0 + ;; + test) + shift + if [[ "${1:-}" == "-d" ]]; then + [[ "$fail_stage" == "base" ]] && return 130 + [[ "$fail_stage" == "base_auth" ]] && return 1 + elif [[ "${1:-}" == "-L" ]]; then + [[ "$fail_stage" == "link" ]] && return 130 + [[ "$fail_stage" == "link_auth" ]] && return 1 + fi + command test "$@" + ;; + find | xargs | rm) + printf 'UNEXPECTED_PRIVILEGED_ACTION:%s\n' "$*" + return 99 + ;; + *) return 0 ;; + esac + } + + rc=0 + safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" || rc=$? + printf '%s:%s\n' "$fail_stage" "$rc" +done +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"initial_auth:130"* ]] || return 1 + [[ "$output" == *"base:130"* ]] || return 1 + [[ "$output" == *"base_auth:130"* ]] || return 1 + [[ "$output" == *"link:130"* ]] || return 1 + [[ "$output" == *"link_auth:130"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_PRIVILEGED_ACTION"* ]] +} + +@test "safe_sudo_find_delete keeps a file refreshed after the initial age scan" { + local target_dir="$TEST_DIR/sudo-age-refresh-target" + local target_file="$target_dir/old.log" + mkdir -p "$target_dir" + touch "$target_file" + touch -t "$(date -v-8d '+%Y%m%d%H%M.%S')" "$target_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" TARGET_FILE="$target_file" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_FILE" + touch "$TARGET_FILE" + ;; + */stat) "$@" ;; + xargs) + shift + command xargs "$@" + ;; + *) "$@" ;; + esac +} +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "1" "f" "1" || rc=$? +printf 'RC=%s\nCOUNT=%s\n' "$rc" "${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-unset}" +[[ -e "$TARGET_FILE" ]] && printf 'SURVIVED\n' +cat "$HOME/Library/Logs/mole/operations.log" 2> /dev/null || true +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"COUNT=0"* ]] || return 1 + [[ "$output" == *"SURVIVED"* ]] || return 1 + [[ "$output" != *"REMOVED $target_file"* ]] +} + +@test "safe_sudo_find_delete keeps a path replaced after identity capture" { + local target_dir="$TEST_DIR/sudo-identity-replace-target" + local target_file="$target_dir/old.log" + mkdir -p "$target_dir" + printf 'original\n' > "$target_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" TARGET_FILE="$target_file" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) + shift + command test "$@" + ;; + find) printf '%s\0' "$TARGET_FILE" ;; + */stat) + identity=$("$@") + if [[ ! -e "$TARGET_DIR/replaced.marker" ]]; then + : > "$TARGET_DIR/replaced.marker" + /bin/rm -f "$TARGET_FILE" + printf 'replacement\n' > "$TARGET_FILE" + fi + printf '%s\n' "$identity" + ;; + xargs) + shift + command xargs "$@" + ;; + *) "$@" ;; + esac +} +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +printf 'RC=%s\nCOUNT=%s\nCONTENT=%s\n' "$rc" \ + "${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-unset}" "$(< "$TARGET_FILE")" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"COUNT=0"* ]] || return 1 + [[ "$output" == *"CONTENT=replacement"* ]] +} + +@test "safe_sudo_find_delete does not run an accumulated batch after a probe timeout" { + local target_dir="$TEST_DIR/sudo-probe-timeout-target" + local first_file="$target_dir/first.log" + local second_file="$target_dir/second.log" + local marker="$target_dir/first-stat-complete" + mkdir -p "$target_dir" + touch "$first_file" "$second_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + FIRST_FILE="$first_file" SECOND_FILE="$second_file" MARKER="$marker" \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +MOLE_TEST_MODE=0 +MOLE_TEST_NO_AUTH=0 +_mole_privileged_path_has_mutable_ancestor() { return 1; } +should_protect_path() { return 1; } +is_path_whitelisted() { return 1; } +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) command "$@" ;; + find) printf '%s\0%s\0' "$FIRST_FILE" "$SECOND_FILE" ;; + /usr/bin/stat) + if [[ ! -e "$MARKER" ]]; then + touch "$MARKER" + command "$@" + else + printf 'STAT_TIMEOUT\n' + return 124 + fi + ;; + xargs) + printf 'UNEXPECTED_BATCH\n' + return 99 + ;; + *) command "$@" ;; + esac +} + +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +printf 'RC=%s\n' "$rc" +[[ -e "$FIRST_FILE" && -e "$SECOND_FILE" ]] +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [ -e "$marker" ] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_BATCH"* ]] +} + +@test "safe_sudo_find_delete logs only acknowledged removals from a timed-out batch" { + local target_dir="$TEST_DIR/sudo-partial-ack-target" + local mock_bin="$TEST_DIR/sudo-partial-ack-bin" + local trace="$TEST_DIR/sudo-partial-ack.trace" + mkdir -p "$target_dir" "$mock_bin" + touch "$target_dir/a.log" "$target_dir/b.log" + + cat > "$mock_bin/sudo" <<'MOCK' +#!/bin/bash +set -u +[[ "${1:-}" == "-n" ]] && shift +case "${1:-}" in + true) exit 0 ;; + test) + shift + /bin/test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/a.log" "$TARGET_DIR/b.log" + ;; + */stat) exec "$@" ;; + xargs) + printf 'xargs-started\n' >> "$MOLE_PARTIAL_ACK_TRACE" + IFS= read -r -d '' record || exit 1 + rest=${record#*:} + rest=${rest#*:} + path=${rest#*:} + /bin/rm -f -- "$path" || exit 1 + printf '%s\0' "$path" + exec sleep 4 + ;; + *) exit 99 ;; +esac +MOCK + chmod +x "$mock_bin/sudo" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + PATH="$mock_bin:$PATH" MOLE_PARTIAL_ACK_TRACE="$trace" \ + MOLE_TIMEOUT_DISK_VERIFY_SEC=1 MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +printf 'RC=%s\nCOUNT=%s\n' "$rc" "${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-unset}" +cat "$HOME/Library/Logs/mole/operations.log" 2> /dev/null || true +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$(< "$trace")" == "xargs-started" ]] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$output" == *"COUNT=1"* ]] || return 1 + [ ! -e "$target_dir/a.log" ] || return 1 + [ -e "$target_dir/b.log" ] || return 1 + [[ "$output" == *"REMOVED $target_dir/a.log (batch)"* ]] || return 1 + [[ "$output" != *"REMOVED $target_dir/b.log (batch)"* ]] +} + +@test "safe_sudo_find_delete rejects an oversized privileged batch before deletion" { + local target_dir="$TEST_DIR/sudo-batch-limit-target" + mkdir -p "$target_dir" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MO_NO_OPLOG=1 MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +_mole_privileged_path_has_mutable_ancestor() { return 1; } +_mole_privileged_batch_max_items() { printf '4\n'; } +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + true) return 0 ;; + test) + shift + command test "$@" + ;; + find) + i=0 + while [[ $i -lt 5 ]]; do + printf '%s\0' "$TARGET_DIR/item-$i.log" + i=$((i + 1)) + done + ;; + */stat) printf '1:2:3\n' ;; + xargs) + printf 'UNEXPECTED_BATCH:%s\n' "$*" + return 99 + ;; + *) "$@" ;; + esac +} +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" "1" || rc=$? +printf 'RC=%s\nCOUNT=%s\n' "$rc" "${MOLE_SAFE_SUDO_FIND_DELETE_COUNT:-unset}" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"COUNT=0"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_BATCH"* ]] +} + +@test "safe_sudo_find_delete never previews or removes active powerlog database aliases" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +active_db="/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/CurrentBackgroundProcessingDB.BGSQL" +active_dot_alias="${active_db%/*}/./${active_db##*/}" +active_case_alias="${active_db%/*}/currentbackgroundprocessingdb.bgsql" +archived_db="/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/ArchivedBackgroundProcessingDB.BGSQL" + +should_protect_path() { return 1; } +is_path_whitelisted() { return 1; } +_mole_privileged_path_has_mutable_ancestor() { return 1; } +record_dry_run_cleanup_target() { printf 'PREVIEW:%s\n' "$1"; } +safe_sudo_remove() { printf 'REMOVE:%s\n' "$1"; } +append_log_lines() { + shift + printf '%s\n' "$@" +} + +sudo() { + [[ "${1:-}" == "-n" ]] && shift + case "${1:-}" in + test) + [[ "${2:-}" == "-d" ]] + ;; + find) + printf '%s\0' "$active_db" "$active_db-wal" "$active_db-shm" "$active_dot_alias" "$active_case_alias" "$archived_db" + ;; + */stat) + printf '1:2:3\n' + ;; + xargs) + while IFS= read -r -d '' record; do + rest=${record#*:} + rest=${rest#*:} + rest=${rest#*:} + printf '%s\0' "$rest" + done + ;; + *) + return 0 + ;; + esac +} + +MOLE_DRY_RUN=1 +safe_sudo_find_delete "/private/var/db/powerlog" "*" "7" "f" +MOLE_DRY_RUN=0 +safe_sudo_find_delete "/private/var/db/powerlog/." "*" "7" "f" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"PREVIEW:/private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/ArchivedBackgroundProcessingDB.BGSQL"* ]] || return 1 + [[ "$output" == *"REMOVED /private/var/db/powerlog/Library/PerfPowerTelemetry/BackgroundProcessing/ArchivedBackgroundProcessingDB.BGSQL (batch)"* ]] || return 1 + [[ "$output" != *"CurrentBackgroundProcessingDB.BGSQL"* ]] || return 1 + [[ "$output" != *"currentbackgroundprocessingdb.bgsql"* ]] || return 1 + [[ "$output" != *"/./CurrentBackgroundProcessingDB.BGSQL"* ]] +} + +@test "safe_sudo_find_delete batches file removals into one xargs rm" { + local target_dir="$TEST_DIR/sudo-batch-target" + local script="$TEST_DIR/sudo-batch-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/a.log" "$target_dir/b.log" "$target_dir/keep.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +TRACE="$TARGET_DIR/sudo.trace" +> "$TRACE" + +# This test models a root-owned, non-user-writable log tree. +_mole_privileged_path_has_mutable_ancestor() { return 1; } + +WHITELIST_PATTERNS=("$TARGET_DIR/keep.log") + +sudo() { + printf 'SUDO:%s\n' "$*" >> "$TRACE" + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/a.log" "$TARGET_DIR/b.log" "$TARGET_DIR/keep.log" + ;; + xargs) + shift + command xargs "$@" + ;; + rm) + echo "SINGLE_FILE_RM:$*" + return 0 + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +set +e +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +rc=$? +set -e +printf 'RC=%s\n' "$rc" +[[ -e "$TARGET_DIR/a.log" ]] && echo "A_SURVIVED" || echo "A_REMOVED" +[[ -e "$TARGET_DIR/b.log" ]] && echo "B_SURVIVED" || echo "B_REMOVED" +[[ -e "$TARGET_DIR/keep.log" ]] && echo "KEEP_SURVIVED" || echo "KEEP_REMOVED" +printf 'XARGS_CALLS=%s\n' "$(grep -c 'SUDO:-n xargs' "$TRACE" || true)" +cat "$TRACE" +echo "--OPLOG--" +cat "$HOME/Library/Logs/mole/operations.log" 2> /dev/null || true +exit 0 +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=0"* ]] || return 1 + [[ "$output" == *"A_REMOVED"* ]] || return 1 + [[ "$output" == *"B_REMOVED"* ]] || return 1 + [[ "$output" == *"KEEP_SURVIVED"* ]] || return 1 + [[ "$output" == *"XARGS_CALLS=1"* ]] || return 1 + [[ "$output" != *"SINGLE_FILE_RM"* ]] || return 1 + [[ "$output" == *"REMOVED $target_dir/a.log (batch)"* ]] || return 1 + [[ "$output" == *"REMOVED $target_dir/b.log (batch)"* ]] || return 1 + [[ "$output" != *"INTERACTIVE_SUDO"* ]] || return 1 +} + +@test "safe_sudo_find_delete reports a failed batch without logging REMOVED" { + local target_dir="$TEST_DIR/sudo-batch-lapsed" + local script="$TEST_DIR/sudo-batch-lapsed-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/a.log" "$target_dir/b.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +# This test models a root-owned, non-user-writable log tree. +_mole_privileged_path_has_mutable_ancestor() { return 1; } + +# Simulate a credential that dies at the batch xargs rm. Nothing was deleted, +# so the helper must return the failure and no REMOVED lines may be logged. +sudo() { + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + true) + return 0 + ;; + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/a.log" "$TARGET_DIR/b.log" + ;; + xargs) + return 1 + ;; + *) + return 1 + ;; + esac +} +export -f sudo + +set +e +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +rc=$? +set -e +printf 'RC=%s\n' "$rc" +[[ -e "$TARGET_DIR/a.log" ]] && echo "A_SURVIVED" || echo "A_REMOVED" +echo "--OPLOG--" +cat "$HOME/Library/Logs/mole/operations.log" 2> /dev/null || true +exit 0 +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"A_SURVIVED"* ]] || return 1 + # Scope to this test's paths: the oplog HOME is shared across tests and + # earlier batch tests legitimately log their own "(batch)" lines. + [[ "$output" != *"REMOVED $target_dir/a.log (batch)"* ]] || return 1 + [[ "$output" != *"REMOVED $target_dir/b.log (batch)"* ]] || return 1 + [[ "$output" != *"INTERACTIVE_SUDO"* ]] || return 1 +} + +@test "safe_sudo_find_delete batch path survives set -e with oplog disabled" { + local target_dir="$TEST_DIR/sudo-batch-nooplog" + local script="$TEST_DIR/sudo-batch-nooplog-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/a.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +# Diagnostic breadcrumbs: this test fails only on some CI images, so record +# which bash runs the script and every mock invocation on a side channel. +printf 'DIAG_BASH:%s (%s)\n' "$BASH_VERSION" "$(command -v bash || true)" +source "$PROJECT_ROOT/lib/core/common.sh" +echo "DIAG_SOURCED" + +# This test models a root-owned, non-user-writable log tree. +_mole_privileged_path_has_mutable_ancestor() { return 1; } + +sudo() { + printf 'MOCK_CALL:%s\n' "$*" >> "$TARGET_DIR/mock.trace" || true + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/a.log" + ;; + xargs) + shift + command xargs "$@" + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +echo "DIAG_MOCK_READY" +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +# Reaching this line proves the disabled-oplog branch did not trip set -e. +echo "SURVIVED_SET_E" +[[ -e "$TARGET_DIR/a.log" ]] && echo "A_SURVIVED" || echo "A_REMOVED" +exit 0 +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MO_NO_OPLOG=1 MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + + # This test is environment-sensitive (errexit active during the call); on + # failure surface the exit status, captured output, and an xtrace replay + # so CI logs show where the inner script died instead of a bare rc check. + if [ "$status" -ne 0 ]; then + echo "inner script exit status: $status" + echo "--- captured output ---" + echo "$output" + echo "--- mock call trace ---" + cat "$target_dir/mock.trace" 2> /dev/null || echo "(no mock trace)" + echo "--- xtrace replay (tail) ---" + env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MO_NO_OPLOG=1 MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc -x "$script" 2>&1 | tail -60 || true + return 1 + fi + [[ "$output" == *"SURVIVED_SET_E"* ]] || return 1 + [[ "$output" == *"A_REMOVED"* ]] || return 1 +} + +@test "safe_sudo_find_delete reports batch failure without a second removal pass" { + local target_dir="$TEST_DIR/sudo-batch-fallback" + local script="$TEST_DIR/sudo-batch-fallback-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/stuck.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +TRACE="$TARGET_DIR/sudo.trace" +> "$TRACE" + +# This test models a root-owned, non-user-writable log tree. +_mole_privileged_path_has_mutable_ancestor() { return 1; } + +sudo() { + printf 'SUDO:%s\n' "$*" >> "$TRACE" + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/stuck.log" + ;; + xargs) + # Simulate a batch failure without deleting anything. + return 1 + ;; + du) + shift + command du "$@" + ;; + rm) + return 0 + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +set +e +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +rc=$? +set -e +printf 'RC=%s\n' "$rc" +cat "$TRACE" +exit 0 +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"SUDO:-n xargs -0 /bin/sh -c"* ]] || return 1 + [[ "$output" != *"SUDO:-n rm -rf $target_dir/stuck.log"* ]] || return 1 + [[ "$output" != *"INTERACTIVE_SUDO"* ]] || return 1 +} + +@test "safe_sudo_find_delete never elevates deletion below a user-writable parent" { + local target_dir="$TEST_DIR/sudo-mutable-parent" + local script="$TEST_DIR/sudo-mutable-parent-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/old.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +TRACE="$TARGET_DIR/sudo.trace" +> "$TRACE" + +sudo() { + printf 'SUDO:%s\n' "$*" >> "$TRACE" + [[ "${1:-}" == "-n" ]] || return 99 + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/old.log" + ;; + xargs | rm) + echo "PRIVILEGED_DELETE:$*" + return 0 + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" || rc=$? +printf 'RC=%s\n' "$rc" +[[ -e "$TARGET_DIR/old.log" ]] && echo "TARGET_SURVIVED" || echo "TARGET_REMOVED" +cat "$TRACE" +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=0"* ]] || return 1 + [[ "$output" == *"TARGET_REMOVED"* ]] || return 1 + [[ "$output" != *"PRIVILEGED_DELETE"* ]] || return 1 + [[ "$output" != *"SUDO:-n xargs"* ]] || return 1 + [[ "$output" != *"SUDO:-n rm"* ]] || return 1 +} + +@test "safe_sudo_find_delete treats a user-owned 0555 parent as mutable" { + local target_dir="$TEST_DIR/sudo-owner-mutable-parent" + local script="$TEST_DIR/sudo-owner-mutable-parent-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/old.log" + chmod 0555 "$target_dir" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +TRACE="${TARGET_DIR}.sudo.trace" +> "$TRACE" + +sudo() { + printf 'SUDO:%s\n' "$*" >> "$TRACE" + [[ "${1:-}" == "-n" ]] || return 99 + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/old.log" + ;; + xargs | rm) + echo "PRIVILEGED_DELETE:$*" + return 0 + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +rc=0 +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" || rc=$? +printf 'RC=%s\n' "$rc" +[[ -e "$TARGET_DIR/old.log" ]] && echo "TARGET_SURVIVED" || echo "TARGET_REMOVED" +cat "$TRACE" +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc "$script" + chmod 0755 "$target_dir" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"TARGET_SURVIVED"* ]] || return 1 + [[ "$output" != *"PRIVILEGED_DELETE"* ]] || return 1 + [[ "$output" != *"SUDO:-n xargs"* ]] || return 1 + [[ "$output" != *"SUDO:-n rm"* ]] || return 1 +} + +@test "safe_sudo_remove honours the cleanup whitelist before sudo" { + local target="$TEST_DIR/whitelisted-sudo-target" + mkdir -p "$target" + touch "$target/data" + + # shellcheck disable=SC2016 # The inner bash expands TARGET and PROJECT_ROOT. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET="$target" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc -c ' + set -euo pipefail + source "$PROJECT_ROOT/lib/core/common.sh" + is_path_whitelisted() { [[ "$1" == "$TARGET" ]]; } + sudo() { + echo "UNEXPECTED_SUDO:$*" + return 99 + } + set +e + safe_sudo_remove "$TARGET" + rc=$? + set -e + printf "RC=%s\n" "$rc" + [[ -e "$TARGET/data" ]] && echo "TARGET_SURVIVED" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"TARGET_SURVIVED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUDO"* ]] +} + @test "safe_find_delete rejects symlinked directory" { local real_dir="$TEST_DIR/real" local link_dir="$TEST_DIR/link" mkdir -p "$real_dir" ln -s "$real_dir" "$link_dir" - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$link_dir' '*.tmp' 7 'f' 2>&1" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$link_dir' '*.tmp' 7 'f' 2>&1" [ "$status" -eq 1 ] - [[ "$output" == *"symlink"* ]] + [[ "$output" == *"symlink"* ]] || return 1 rm -rf "$link_dir" "$real_dir" } @test "safe_find_delete validates type filter" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$TEST_DIR' '*.tmp' 7 'x' 2>&1" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$TEST_DIR' '*.tmp' 7 'x' 2>&1" [ "$status" -eq 1 ] [[ "$output" == *"Invalid type filter"* ]] } @@ -162,20 +1929,183 @@ teardown() { touch -t "$(date -v-8d '+%Y%m%d%H%M.%S' 2>/dev/null || date -d '8 days ago' '+%Y%m%d%H%M.%S')" "$old_file" 2>/dev/null || true - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$TEST_DIR' '*.tmp' 7 'f'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$TEST_DIR' '*.tmp' 7 'f'" [ "$status" -eq 0 ] + [ ! -e "$old_file" ] || return 1 + [ -e "$new_file" ] +} + +@test "safe_find_delete discards a timed-out partial scan" { + local target_dir="$TEST_DIR/find-partial-target" + local target_file="$target_dir/old.tmp" + local mock_bin="$TEST_DIR/find-partial-bin" + local trace="$TEST_DIR/find-partial.trace" + mkdir -p "$target_dir" "$mock_bin" + touch "$target_file" + + cat > "$mock_bin/find" <<'MOCK' +#!/bin/bash +printf 'find %s\n' "$*" >> "$MOLE_FIND_TRACE" +printf '%s\0' "$TARGET_FILE" +exec sleep 4 +MOCK + chmod +x "$mock_bin/find" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" \ + TARGET_FILE="$target_file" MOLE_FIND_TRACE="$trace" PATH="$mock_bin:$PATH" \ + MOLE_TIMEOUT_DISK_VERIFY_SEC=1 /bin/bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +safe_remove() { + printf 'UNEXPECTED_DELETE:%s\n' "$1" + return 99 +} +rc=0 +safe_find_delete "$TARGET_DIR" "*.tmp" "0" "f" || rc=$? +printf 'RC=%s\n' "$rc" +SCRIPT + + [ "$status" -eq 0 ] || return 1 + [[ "$(< "$trace")" == *"$target_dir -maxdepth 5 -name *.tmp -type f -print0"* ]] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DELETE"* ]] || return 1 + [ -e "$target_file" ] +} + +@test "safe_find_delete works when app protection is not loaded" { + local old_file="$TEST_DIR/file-ops-only.tmp" + touch "$old_file" + touch -t "$(date -v-8d '+%Y%m%d%H%M.%S' 2>/dev/null || date -d '8 days ago' '+%Y%m%d%H%M.%S')" "$old_file" 2>/dev/null || true + + run /bin/bash --noprofile --norc </dev/null 2>&1 set -e end_time=$(date +%s) @@ -99,7 +99,7 @@ setup() { @test "run_with_timeout: handles fast-completing commands" { start_time=$(date +%s) - bash -c " + /bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' run_with_timeout 10 echo 'fast' @@ -111,7 +111,7 @@ setup() { } @test "run_with_timeout: works in pipefail mode" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' run_with_timeout 5 echo 'pipefail_test' @@ -120,7 +120,7 @@ setup() { } @test "run_with_timeout: doesn't cause unintended exits" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' run_with_timeout 5 true || true @@ -130,7 +130,7 @@ setup() { } @test "run_with_timeout: handles commands with arguments" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' run_with_timeout 5 echo 'arg1' 'arg2' 'arg3' @@ -139,7 +139,7 @@ setup() { } @test "run_with_timeout: handles commands with spaces in arguments" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' run_with_timeout 5 echo 'hello world' @@ -148,7 +148,7 @@ setup() { } @test "run_with_timeout: debug logging when MO_DEBUG=1" { - output=$(bash -c " + output=$(/bin/bash -c " set -euo pipefail export MO_DEBUG=1 source '$PROJECT_ROOT/lib/core/timeout.sh' @@ -158,7 +158,7 @@ setup() { } @test "run_with_timeout: no debug logging when MO_DEBUG=0" { - output=$(bash -c " + output=$(/bin/bash -c " set -euo pipefail export MO_DEBUG=0 unset MO_TIMEOUT_INITIALIZED @@ -169,7 +169,7 @@ setup() { } @test "timeout.sh: prevents multiple sourcing" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' source '$PROJECT_ROOT/lib/core/timeout.sh' @@ -179,10 +179,254 @@ setup() { } @test "timeout.sh: sets MOLE_TIMEOUT_LOADED flag" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/timeout.sh' echo \"\$MOLE_TIMEOUT_LOADED\" ") [[ "$result" == "1" ]] } + +@test "run_with_timeout: perl fallback preserves command exit code (#1003)" { + if ! command -v perl > /dev/null 2>&1; then + skip "perl not available" + fi + set +e + /bin/bash -c " + set +e + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN=\"\$(command -v perl)\" + run_with_timeout 5 sh -c 'exit 7' + exit \$? + " + exit_code=$? + set -e + [[ $exit_code -eq 7 ]] +} + +@test "run_with_timeout: perl fallback kills long-running command (#1003)" { + if ! command -v perl > /dev/null 2>&1; then + skip "perl not available" + fi + start_time=$(date +%s) + set +e + /bin/bash -c " + set +e + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN=\"\$(command -v perl)\" + run_with_timeout 2 sleep 8 + " > /dev/null 2>&1 + set -e + end_time=$(date +%s) + duration=$((end_time - start_time)) + [[ $duration -lt 7 ]] +} + +@test "run_with_timeout: perl fallback cleans child when its caller dies" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/perl ]]; then + skip "macOS perl required" + fi + + local child_pid_file="$BATS_TEST_TMPDIR/timeout-child.pid" + local caller_pid="" + local perl_pid="" + local child_pid="" + + /bin/bash "$PROJECT_ROOT/tests/timeout_parent_death_fixture.sh" \ + "$PROJECT_ROOT" "$child_pid_file" > /dev/null 2>&1 & + caller_pid=$! + + for _ in {1..50}; do + [[ -s "$child_pid_file" ]] && break + /bin/sleep 0.1 + done + [[ -s "$child_pid_file" ]] || { + kill -KILL "$caller_pid" 2> /dev/null || true + return 1 + } + + child_pid=$(cat "$child_pid_file") + perl_pid=$(pgrep -P "$caller_pid" -x perl 2> /dev/null | head -1 || true) + [[ "$child_pid" =~ ^[0-9]+$ && "$perl_pid" =~ ^[0-9]+$ ]] || { + kill -KILL "$caller_pid" "$child_pid" 2> /dev/null || true + return 1 + } + + kill -TERM "$caller_pid" + for _ in {1..50}; do + if ! kill -0 "$perl_pid" 2> /dev/null && ! kill -0 "$child_pid" 2> /dev/null; then + break + fi + /bin/sleep 0.1 + done + + local leaked=0 + if kill -0 "$perl_pid" 2> /dev/null || kill -0 "$child_pid" 2> /dev/null; then + leaked=1 + kill -KILL "$perl_pid" "$child_pid" 2> /dev/null || true + fi + kill -KILL "$caller_pid" 2> /dev/null || true + wait "$caller_pid" 2> /dev/null || true + + [ "$leaked" -eq 0 ] +} + +# setsid() in the perl fallback strips the controlling terminal, which breaks +# nested sudo inside brew cask uninstall scripts (issue #1003). The fallback must +# use setpgid to keep the tty while still enabling process-group kill. This guards +# against a regression that is otherwise only observable on a real terminal. +@test "timeout.sh: perl fallback must not detach the controlling tty (#1003)" { + # Match call statements at line start, not the comments that explain why + # setsid is avoided (those mention "setsid()" mid-line and would false-positive). + run grep -nE '^[[:space:]]*setsid[[:space:]]*\(' "$PROJECT_ROOT/lib/core/timeout.sh" + [ "$status" -ne 0 ] + run grep -nE '^[[:space:]]*setpgid[[:space:]]*\(' "$PROJECT_ROOT/lib/core/timeout.sh" + [ "$status" -eq 0 ] +} + +@test "run_with_timeout: perl fallback keeps tty reads in foreground (#1201)" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/expect || ! -x /usr/bin/perl ]]; then + skip "macOS expect/perl required" + fi + + run /usr/bin/expect "$PROJECT_ROOT/tests/timeout_tty_read.exp" "$PROJECT_ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"READ:typed-value"* ]] +} + +@test "run_with_timeout: perl fallback restores tty after timeout (#1201)" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/expect || ! -x /usr/bin/perl ]]; then + skip "macOS expect/perl required" + fi + + run /usr/bin/expect "$PROJECT_ROOT/tests/timeout_tty_restore.exp" "$PROJECT_ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"TIMEOUT:124"* ]] || return 1 + [[ "$output" == *"READ-AFTER:typed-after"* ]] +} + +# Issue #1222: the perl fallback hands the controlling terminal to its timed +# child whenever stdin is a tty (the #1201 behaviour). When it is invoked from a +# background metadata/scan worker in bin/uninstall.sh that still has the tty on +# stdin, that handoff steals the foreground process group from the foreground +# script, which then stops with SIGTTIN at the confirmation prompt. Redirecting +# the worker's stdin from /dev/null makes -t STDIN false and skips the handoff. +# These two tests pin both halves of the contract: the handoff still happens for +# interactive (tty) callers, and never happens once stdin is /dev/null. +_tty_bg_field() { + # Extract NAME= from the fixture output (single line each). + printf '%s\n' "$2" | sed -n "s/.*${1}=\\([0-9][0-9]*\\).*/\\1/p" | head -1 +} + +@test "run_with_timeout: perl fallback hands tty to child when stdin is a tty (#1201/#1222)" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/expect || ! -x /usr/bin/perl ]]; then + skip "macOS expect/perl required" + fi + + run /usr/bin/expect "$PROJECT_ROOT/tests/timeout_tty_background.exp" "$PROJECT_ROOT" tty + + [ "$status" -eq 0 ] || return 1 + local child fg caller + child=$(_tty_bg_field CHILD_PGRP "$output") + fg=$(_tty_bg_field FG "$output") + caller=$(_tty_bg_field CALLER_PGRP "$output") + [[ -n "$child" && -n "$fg" && -n "$caller" ]] || return 1 + # The timed child captured the terminal's foreground process group. + [ "$fg" = "$child" ] || return 1 + [ "$fg" != "$caller" ] || return 1 +} + +@test "run_with_timeout: perl fallback keeps tty with caller when stdin is /dev/null (#1222)" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/expect || ! -x /usr/bin/perl ]]; then + skip "macOS expect/perl required" + fi + + run /usr/bin/expect "$PROJECT_ROOT/tests/timeout_tty_background.exp" "$PROJECT_ROOT" devnull + + [ "$status" -eq 0 ] || return 1 + local child fg caller + child=$(_tty_bg_field CHILD_PGRP "$output") + fg=$(_tty_bg_field FG "$output") + caller=$(_tty_bg_field CALLER_PGRP "$output") + [[ -n "$child" && -n "$fg" && -n "$caller" ]] || return 1 + # No handoff: the terminal's foreground group stayed with the caller. + [ "$fg" = "$caller" ] || return 1 + [ "$fg" != "$child" ] || return 1 +} + +# Guard the actual call sites: background uninstall workers must redirect stdin +# from /dev/null. Without it the Perl timeout fallback can steal the terminal +# from a background worker and suspend the foreground prompt with SIGTTIN. +@test "uninstall: background timeout workers redirect stdin (#1222)" { + # Disowned metadata-refresh subshell close. + run grep -nE '^[[:space:]]*\)[[:space:]]*>[[:space:]]*/dev/null[[:space:]]+2>&1[[:space:]]+<[[:space:]]*/dev/null[[:space:]]*&[[:space:]]*$' "$PROJECT_ROOT/bin/uninstall.sh" + [ "$status" -eq 0 ] || return 1 + # Parallel scan workers. + run grep -nE 'process_app_metadata[[:space:]].*<[[:space:]]*/dev/null[[:space:]]*&[[:space:]]*$' "$PROJECT_ROOT/bin/uninstall.sh" + [ "$status" -eq 0 ] || return 1 + # Post-uninstall work: Homebrew autoremove and LaunchServices/Dock refresh. + run grep -cE '^[[:space:]]*\)[[:space:]]*>[[:space:]]*/dev/null[[:space:]]+2>&1[[:space:]]+<[[:space:]]*/dev/null[[:space:]]*&[[:space:]]*$' "$PROJECT_ROOT/lib/uninstall/batch.sh" + [ "$status" -eq 0 ] || return 1 + [ "$output" -ge 2 ] || return 1 +} + +@test "run_with_timeout: shell fallback preserves caller INT trap" { + result=$(/bin/bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN='' + trap 'echo caller-trap' INT + run_with_timeout 2 true + trap -p INT + ") + [[ "$result" == *"caller-trap"* ]] +} + +@test "run_with_timeout: shell fallback cleans up watchdog sleep" { + /bin/bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN='' + run_with_timeout 287 true + sleep 0.1 + leaked='' + for pid in \$(pgrep -x sleep 2>/dev/null || true); do + command_line=\$(ps -p \"\$pid\" -o command= 2>/dev/null || true) + if [[ \"\$command_line\" == 'sleep 287' ]]; then + leaked=\"\$pid\" + kill \"\$pid\" 2>/dev/null || true + fi + done + [[ -z \"\$leaked\" ]] || return 1 + " +} + +# A directory-sizing `du` on a stalled network mount or a huge tree wedges the +# whole scan: it has no internal bound and the caller usually pipes it into a +# command substitution that just waits. Every `du -s*` in lib/ and bin/ must +# therefore run under run_with_timeout or the shared bounded sudo helper. This +# test pins that so a new sizing site cannot be added unbounded. +@test "every du sizing call in lib/ and bin/ runs under run_with_timeout" { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + unbounded="" + while IFS=: read -r file line text; do + [[ "$text" =~ ^[[:space:]]*# ]] && continue + local first_line=$((line > 2 ? line - 2 : 1)) + local context="" + context=$(sed -n "${first_line},${line}p" "$file") + if ! grep -Eq 'run_with_timeout|_mole_bounded_sudo' <<< "$context"; then + unbounded+="${file}:${line}:${text}"$'\n' + fi + done < <(grep -rn -- 'du -s' "$PROJECT_ROOT/lib" "$PROJECT_ROOT/bin" || true) + if [[ -n "$unbounded" ]]; then + echo "Unbounded du call sites:" >&2 + echo "$unbounded" >&2 + return 1 + fi +} diff --git a/Resources/mole/tests/dev_extended.bats b/Resources/mole/tests/dev_extended.bats index ec7515e..63a9095 100644 --- a/Resources/mole/tests/dev_extended.bats +++ b/Resources/mole/tests/dev_extended.bats @@ -14,7 +14,9 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -22,7 +24,7 @@ teardown_file() { @test "clean_dev_elixir cleans hex cache" { mkdir -p "$HOME/.mix" "$HOME/.hex" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -51,7 +53,7 @@ EOF @test "clean_dev_haskell cleans cabal install cache" { mkdir -p "$HOME/.cabal" "$HOME/.stack" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -80,7 +82,7 @@ EOF @test "clean_dev_ocaml cleans opam cache" { mkdir -p "$HOME/.opam" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -92,45 +94,15 @@ EOF [[ "$output" == *"Opam cache"* ]] } -@test "clean_dev_editors cleans VS Code and Zed caches" { - mkdir -p "$HOME/Library/Caches/com.microsoft.VSCode" "$HOME/Library/Application Support/Code" "$HOME/Library/Caches/Zed" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/dev.sh" -safe_clean() { echo "$2"; } -clean_dev_editors -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"VS Code cached data"* ]] - [[ "$output" == *"Zed cache"* ]] -} - -@test "clean_dev_editors does not clean VS Code workspace storage" { - mkdir -p "$HOME/Library/Application Support/Code/User/workspaceStorage/abc123" - touch "$HOME/Library/Application Support/Code/User/workspaceStorage/abc123/workspace.json" - - # Source and run the function - source "$PROJECT_ROOT/lib/core/common.sh" - source "$PROJECT_ROOT/lib/clean/dev.sh" - # shellcheck disable=SC2329 - safe_clean() { :; } - clean_dev_editors > /dev/null 2>&1 || true - - # Verify the file still exists - [ -f "$HOME/Library/Application Support/Code/User/workspaceStorage/abc123/workspace.json" ] -} - @test "check_android_ndk reports multiple NDK versions" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run /bin/bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_REVIEW="⊙" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" == *"Android NDK versions: 3 found"* ]] + [[ "$output" == *"Android NDK versions · 3 found"* ]] } @test "check_android_ndk silent when only one NDK" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run /bin/bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_REVIEW="⊙" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] [[ "$output" != *"NDK versions"* ]] @@ -140,7 +112,7 @@ EOF local ds_dir="$HOME/EmptyDeviceSupport" mkdir -p "$ds_dir" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -154,190 +126,2640 @@ EOF [[ "$output" == *"survived"* ]] } -@test "clean_xcode_documentation_cache keeps newest DeveloperDocumentation index" { - local doc_root="$HOME/DocumentationCache" - mkdir -p "$doc_root" +@test "empty Xcode roots do not register active cleanup families" { + local doc_root="$HOME/DocumentationCacheSingle" + local simulator_root="$HOME/SystemCoreSimulatorCachesEmpty" + mkdir -p "$doc_root" "$simulator_root" touch "$doc_root/DeveloperDocumentation.index" - touch "$doc_root/DeveloperDocumentation-16.0.index" - touch -t 202402010000 "$doc_root/DeveloperDocumentation.index" - touch -t 202401010000 "$doc_root/DeveloperDocumentation-16.0.index" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" \ + MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$simulator_root" \ + /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" +pgrep() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } note_activity() { :; } -has_sudo_session() { return 0; } +clean_xcode_documentation_cache +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] +} + +@test "clean_xcode_device_support skips while Xcode tooling is active" { + local ds_dir="$HOME/ActiveDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { [[ "$*" == *"xcodebuild"* ]]; } +safe_clean() { echo "UNEXPECTED_CLEAN:$*"; } +clean_xcode_device_support "$HOME/ActiveDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"iOS DeviceSupport · skipped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] || return 1 +} + +@test "clean_xcode_device_support preserves every version when metadata is unreadable" { + local ds_dir="$HOME/UnreadableDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +stat() { return 9; } +safe_clean() { echo "UNEXPECTED_CLEAN:$*"; } +clean_xcode_device_support "$HOME/UnreadableDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"iOS DeviceSupport · skipped (metadata unavailable)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] || return 1 +} + +@test "clean_xcode_device_support keeps two newest versions and removes only older versions" { + local ds_dir="$HOME/RetainedDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +safe_remove() { command rm -rf "$1"; } +safe_clean() { :; } +clean_xcode_device_support "$HOME/RetainedDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ ! -e "$ds_dir/17.0" ]] || return 1 + [[ -d "$ds_dir/17.1" && -d "$ds_dir/17.2" ]] +} + +@test "clean_xcode_device_support keeps newline-containing paths byte-exact" { + local ds_dir="$HOME/NewlineDeviceSupport" + local newline_a="$ds_dir/Current"$'\n'"A" + local newline_b="$ds_dir/Current"$'\n'"B" + mkdir -p "$ds_dir/Current" "$newline_a" "$newline_b" "$ds_dir/Old" + touch -t 202601010000 "$ds_dir/Old" + touch -t 202602010000 "$newline_b" + touch -t 202603010000 "$newline_a" + touch -t 202604010000 "$ds_dir/Current" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +safe_remove() { command rm -rf "$1"; } +safe_clean() { :; } +clean_xcode_device_support "$HOME/NewlineDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ -d "$ds_dir/Current" && -d "$newline_a" ]] || return 1 + [[ ! -e "$newline_b" && ! -e "$ds_dir/Old" ]] +} + +@test "clean_xcode_device_support fails closed when process state is unknown" { + local ds_dir="$HOME/UnknownProcessDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 2; } +safe_remove() { echo "UNEXPECTED_REMOVE:$*"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$*"; } +clean_xcode_device_support "$HOME/UnknownProcessDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"iOS DeviceSupport · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_"* ]] || return 1 + [[ -d "$ds_dir/17.0" && -d "$ds_dir/17.1" && -d "$ds_dir/17.2" ]] +} + +@test "clean_xcode_device_support rechecks tooling before destructive work" { + local ds_dir="$HOME/RacingDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +probe_round=0 +_xcode_xctest_devices_process_running() { + probe_round=$((probe_round + 1)) + [[ $probe_round -ge 2 ]] +} +safe_remove() { echo "UNEXPECTED_REMOVE:$*"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$*"; } +clean_xcode_device_support "$HOME/RacingDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"iOS DeviceSupport · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_"* ]] || return 1 + [[ -d "$ds_dir/17.0" && -d "$ds_dir/17.1" && -d "$ds_dir/17.2" ]] +} + +@test "clean_xcode_device_support reports completed removals before a tooling race stops it" { + local ds_dir="$HOME/PartialDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" "$ds_dir/17.3" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" + touch -t 202604010000 "$ds_dir/17.3" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +cleanup_result_color_kb() { echo ""; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } +should_protect_path() { return 1; } is_path_whitelisted() { return 1; } +safe_remove() { command rm -rf "$1"; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } + +probe_round=0 +_xcode_xctest_devices_process_running() { + probe_round=$((probe_round + 1)) + if [[ $probe_round -le 3 ]]; then + return 1 + fi + return 0 +} + +clean_xcode_device_support "$HOME/PartialDeviceSupport" "iOS DeviceSupport" +remaining=$(command find "$HOME/PartialDeviceSupport" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') +[[ "$remaining" -eq 3 ]] || { echo "WRONG_REMAINING:$remaining"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"iOS DeviceSupport · removed 1 old versions"* ]] || return 1 + [[ "$output" != *"iOS DeviceSupport · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "clean_xcode_device_support does not defer after the only stale version is removed" { + local ds_dir="$HOME/CompletedDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +cleanup_result_color_kb() { echo ""; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } should_protect_path() { return 1; } -safe_sudo_remove() { - local target="$1" - echo "CLEAN:$target:Xcode documentation cache (old indexes)" +is_path_whitelisted() { return 1; } +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/device-support-removal-complete" ]] && return 0 + return 1 } -clean_xcode_documentation_cache +safe_remove() { + command rm -rf "$1" + : > "$HOME/device-support-removal-complete" +} +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } + +clean_xcode_device_support "$HOME/CompletedDeviceSupport" "iOS DeviceSupport" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"CLEAN:$doc_root/DeveloperDocumentation-16.0.index:Xcode documentation cache (old indexes)"* ]] - [[ "$output" != *"CLEAN:$doc_root/DeveloperDocumentation.index:Xcode documentation cache (old indexes)"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"iOS DeviceSupport · removed 1 old versions"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 + [[ ! -e "$ds_dir/17.0" && -d "$ds_dir/17.1" && -d "$ds_dir/17.2" ]] } -@test "clean_xcode_documentation_cache skips when Xcode is running" { - local doc_root="$HOME/DocumentationCache" - mkdir -p "$doc_root" - touch "$doc_root/DeveloperDocumentation.index" - touch "$doc_root/DeveloperDocumentation-16.0.index" +@test "clean_xcode_device_support does not defer when only whitelisted inner caches remain" { + local ds_dir="$HOME/CompletedWhitelistedDeviceSupport" + local kept_cache="$ds_dir/17.2/Symbols/System/Library/Caches/whitelisted" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$(dirname "$kept_cache")" + touch "$kept_cache" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" +target="$HOME/CompletedWhitelistedDeviceSupport/17.2/Symbols/System/Library/Caches/whitelisted" note_activity() { :; } -pgrep() { return 0; } -safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } -clean_xcode_documentation_cache +cleanup_result_color_kb() { echo ""; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/device-support-whitelist-removal-complete" ]] && return 0 + return 1 +} +safe_remove() { + command rm -rf "$1" + : > "$HOME/device-support-whitelist-removal-complete" +} +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } + +clean_xcode_device_support "$HOME/CompletedWhitelistedDeviceSupport" "iOS DeviceSupport" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"skipping documentation cache cleanup"* ]] - [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"iOS DeviceSupport · removed 1 old versions"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 + [[ ! -e "$ds_dir/17.0" && -f "$kept_cache" ]] } -@test "check_rust_toolchains reports multiple toolchains" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" +@test "clean_xcode_device_support ignores active whitelist-only stale versions" { + local ds_dir="$HOME/WhitelistedDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" - [ "$status" -eq 0 ] - [[ "$output" == *"Rust toolchains: 3 found"* ]] + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +target="$HOME/WhitelistedDeviceSupport/17.0" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +_xcode_xctest_devices_process_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$2"; } + +clean_xcode_device_support "$HOME/WhitelistedDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] } -@test "check_rust_toolchains silent when only one toolchain" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" +@test "clean_xcode_device_support ignores active compiled-model-only stale versions" { + local ds_dir="$HOME/CompiledDeviceSupport" + mkdir -p "$ds_dir/17.0/com.apple.e5rt.e5bundlecache" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" - [ "$status" -eq 0 ] - [[ "$output" != *"Rust toolchains"* ]] + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +_xcode_xctest_devices_process_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$1"; } +clean_xcode_device_support "$HOME/CompiledDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] } -@test "clean_dev_jetbrains_toolbox cleans old versions and bypasses toolbox whitelist" { - local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" - mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/241.3" - ln -s "241.3" "$toolbox_channel/current" - touch -t 202401010000 "$toolbox_channel/241.1" - touch -t 202402010000 "$toolbox_channel/241.2" - touch -t 202403010000 "$toolbox_channel/241.3" +@test "clean_xcode_device_support dry run does not double-count stale inner caches" { + local ds_dir="$HOME/PreviewDeviceSupport" + mkdir -p \ + "$ds_dir/17.0/Symbols/System/Library/Caches/stale-cache" \ + "$ds_dir/17.1/Symbols/System/Library/Caches/kept-cache" \ + "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" -toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" -WHITELIST_PATTERNS=("$toolbox_root"* "$HOME/Library/Application Support/JetBrains*") note_activity() { :; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } +_xcode_xctest_devices_process_running() { return 1; } +record_dry_run_cleanup_target() { printf 'RECORD:%s\n' "$1"; } safe_clean() { - local target="$1" - for pattern in "${WHITELIST_PATTERNS[@]+${WHITELIST_PATTERNS[@]}}"; do - if [[ "$pattern" == "$toolbox_root"* ]]; then - echo "WHITELIST_NOT_REMOVED" - exit 1 - fi + local count=$# index=1 arg + for arg in "$@"; do + [[ $index -lt $count ]] && printf 'SAFE:%s\n' "$arg" + index=$((index + 1)) done - echo "$target" } -MOLE_JETBRAINS_TOOLBOX_KEEP=1 -clean_dev_jetbrains_toolbox +clean_xcode_device_support "$HOME/PreviewDeviceSupport" "iOS DeviceSupport" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"/241.1"* ]] - [[ "$output" != *"/241.2"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RECORD:$ds_dir/17.0"* ]] || return 1 + [[ "$output" == *"SAFE:$ds_dir/17.1/Symbols/System/Library/Caches/kept-cache"* ]] || return 1 + [[ "$output" != *"SAFE:$ds_dir/17.0/Symbols/System/Library/Caches/stale-cache"* ]] } -@test "clean_dev_jetbrains_toolbox keeps current directory and removes older versions" { - local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" - mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/current" - touch -t 202401010000 "$toolbox_channel/241.1" - touch -t 202402010000 "$toolbox_channel/241.2" +@test "clean_xcode_device_support previews eligible inner cache under an excluded stale root" { + local ds_dir="$HOME/PreviewExcludedDeviceSupport" + mkdir -p \ + "$ds_dir/17.0/com.apple.e5rt.e5bundlecache" \ + "$ds_dir/17.0/Symbols/System/Library/Caches/eligible" \ + "$ds_dir/17.1" \ + "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 DRY_RUN=true /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" note_activity() { :; } -safe_clean() { echo "$1"; } -MOLE_JETBRAINS_TOOLBOX_KEEP=1 -clean_dev_jetbrains_toolbox +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } +_xcode_xctest_devices_process_running() { return 1; } +record_dry_run_cleanup_target() { printf 'RECORD:%s\n' "$1"; } +safe_clean() { + local count=$# index=1 arg + for arg in "$@"; do + [[ $index -lt $count ]] && printf 'SAFE:%s\n' "$arg" + index=$((index + 1)) + done +} +clean_xcode_device_support "$HOME/PreviewExcludedDeviceSupport" "iOS DeviceSupport" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"/241.1"* ]] - [[ "$output" != *"/241.2"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"RECORD:$ds_dir/17.0"* ]] || return 1 + [[ "$output" == *"SAFE:$ds_dir/17.0/Symbols/System/Library/Caches/eligible"* ]] } -@test "clean_xcode_simulator_runtime_volumes shows scan progress and skips sizing in-use volumes" { - local volumes_root="$HOME/sim-volumes" - local cryptex_root="$HOME/sim-cryptex" - mkdir -p "$volumes_root/in-use-runtime" "$volumes_root/unused-runtime" - mkdir -p "$cryptex_root" +@test "clean_xcode_device_support rechecks tooling after the destructive size probe" { + local ds_dir="$HOME/PostSizeDeviceSupport" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$cryptex_root" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +should_protect_path() { return 1; } +is_path_whitelisted() { return 1; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } -size_log="$HOME/size-calls.log" -: > "$size_log" -DRY_RUN=false +get_path_size_kb() { + printf 'size\n' >> "$HOME/device-size-probes" + size_round=$(wc -l < "$HOME/device-size-probes" | tr -d ' ') + [[ $size_round -ge 1 ]] && touch "$HOME/xcode-started" + echo 1 +} +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} + +rm -f "$HOME/xcode-started" "$HOME/device-size-probes" +clean_xcode_device_support "$HOME/PostSizeDeviceSupport" "iOS DeviceSupport" +[[ -d "$HOME/PostSizeDeviceSupport/17.0" ]] || { echo "WRONG: old version removed"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"iOS DeviceSupport · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_"* ]] +} + +@test "clean_xcode_device_support passes its measured size to the real deletion sink" { + local ds_dir="$HOME/DeviceSupportRealSink" + mkdir -p "$ds_dir/17.0" "$ds_dir/17.1" "$ds_dir/17.2" + touch -t 202601010000 "$ds_dir/17.0" + touch -t 202602010000 "$ds_dir/17.1" + touch -t 202603010000 "$ds_dir/17.2" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DEVICE_SUPPORT_KEEP=2 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" note_activity() { :; } -has_sudo_session() { return 0; } -is_path_whitelisted() { return 1; } should_protect_path() { return 1; } -_sim_runtime_mount_points() { - printf '%s\n' "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/in-use-runtime" +is_path_whitelisted() { return 1; } +safe_clean() { :; } +get_path_size_kb() { + printf 'size\n' >> "$HOME/device-real-size-probes" + local round + round=$(wc -l < "$HOME/device-real-size-probes" | tr -d ' ') + [[ $round -ge 2 ]] && touch "$HOME/xcode-started" + echo 1 } -_sim_runtime_size_kb() { - local target_path="$1" - echo "$target_path" >> "$size_log" - echo "1" +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 } -safe_sudo_remove() { - local target_path="$1" - echo "REMOVE:$target_path" - return 0 + +rm -f "$HOME/xcode-started" "$HOME/device-real-size-probes" +clean_xcode_device_support "$HOME/DeviceSupportRealSink" "iOS DeviceSupport" +[[ ! -e "$HOME/DeviceSupportRealSink/17.0" ]] || { echo "WRONG: old version remains"; exit 1; } +[[ ! -e "$HOME/xcode-started" ]] || { echo "WRONG: deletion sink repeated size probe"; exit 1; } +[[ "$(wc -l < "$HOME/device-real-size-probes" | tr -d ' ')" -eq 1 ]] || exit 1 +printf 'COUNTERS:%s:%s:%s\n' "$files_cleaned" "$total_size_cleaned" "$total_items" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"COUNTERS:1:1:1"* ]] } -clean_xcode_simulator_runtime_volumes -echo "SIZE_LOG_START" -cat "$size_log" +@test "clean_xcode_documentation_cache keeps newest DeveloperDocumentation index" { + local doc_root="$HOME/DocumentationCache" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" + touch "$doc_root/DeveloperDocumentation-16.0.index" + touch -t 202402010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202401010000 "$doc_root/DeveloperDocumentation-16.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +# Without this the real pgrep runs against the host, so the result depends on +# whether the developer happens to have Xcode open. The sibling case mocks the +# running side; this one has to mock the not-running side. +pgrep() { return 1; } +_coresimulator_booted_device_state() { return 1; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +_sim_runtime_size_kb() { echo 2; } +safe_sudo_remove() { + local target="$1" + echo "CLEAN:$target:Xcode documentation cache (old indexes)" +} +clean_xcode_documentation_cache +printf 'COUNTERS:%s:%s:%s\n' "$files_cleaned" "$total_size_cleaned" "$total_items" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Xcode runtime volumes · scanning 2 entries"* ]] - [[ "$output" == *"Xcode runtime volumes · cleaning 1 unused"* ]] - [[ "$output" == *"REMOVE:$volumes_root/unused-runtime"* ]] - [[ "$output" == *"$volumes_root/unused-runtime"* ]] - [[ "$output" != *"$volumes_root/in-use-runtime"* ]] + [[ "$output" == *"CLEAN:$doc_root/DeveloperDocumentation-16.0.index:Xcode documentation cache (old indexes)"* ]] || return 1 + [[ "$output" != *"CLEAN:$doc_root/DeveloperDocumentation.index:Xcode documentation cache (old indexes)"* ]] || return 1 + [[ "$output" == *"COUNTERS:1:2:1"* ]] } -@test "clean_dev_mobile continues cleanup when simctl is unavailable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_xcode_documentation_cache skips when Xcode is running" { + local doc_root="$HOME/DocumentationCache" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" + touch "$doc_root/DeveloperDocumentation-16.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 0; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } +clean_xcode_documentation_cache +EOF + [ "$status" -eq 0 ] + [[ "$output" != *"Xcode documentation cache · skipped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@test "clean_xcode_documentation_cache does not defer whitelist-only stale indexes" { + local doc_root="$HOME/DocumentationCacheWhitelistOnly" + local stale="$doc_root/DeveloperDocumentation-16.0.index" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" "$stale" + touch -t 202602010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202601010000 "$stale" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +target="$MOLE_XCODE_DOCUMENTATION_CACHE_DIR/DeveloperDocumentation-16.0.index" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +_xcode_xctest_devices_process_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; } +clean_xcode_documentation_cache +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_documentation_cache ignores symlink and compiled-model-only stale indexes" { + local doc_root="$HOME/DocumentationCachePolicyOnly" + local outside="$HOME/DocumentationCachePolicyOutside" + mkdir -p "$doc_root/DeveloperDocumentation-15.0.index/com.apple.e5rt.e5bundlecache" + touch "$doc_root/DeveloperDocumentation.index" "$outside" + ln -s "$outside" "$doc_root/DeveloperDocumentation-16.0.index" + touch -t 202603010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202602010000 "$outside" + touch -t 202601010000 "$doc_root/DeveloperDocumentation-15.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +_xcode_xctest_devices_process_running() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_DRY_CLEAN:$1"; } +safe_sudo_remove() { echo "UNEXPECTED_REAL_CLEAN:$1"; } +for DRY_RUN in false true; do + clean_xcode_documentation_cache +done +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DRY_CLEAN"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REAL_CLEAN"* ]] +} + +@test "clean_xcode_documentation_cache preserves newline-containing index paths" { + local doc_root="$HOME/DocumentationCacheNewline" + local newline_index=$'DeveloperDocumentation-16.0\njunk.index' + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" \ + "$doc_root/DeveloperDocumentation-15.0.index" \ + "$doc_root/$newline_index" + touch -t 202603010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202601010000 "$doc_root/DeveloperDocumentation-15.0.index" + touch -t 202602010000 "$doc_root/$newline_index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +newline_index=$'DeveloperDocumentation-16.0\njunk.index' +_xcode_xctest_devices_process_running() { return 1; } +has_sudo_session() { return 0; } +_sim_runtime_size_kb() { echo 1; } +safe_sudo_remove() { command rm -rf "$1"; } +clean_xcode_documentation_cache +[[ -e "$MOLE_XCODE_DOCUMENTATION_CACHE_DIR/DeveloperDocumentation.index" ]] || exit 1 +[[ ! -e "$MOLE_XCODE_DOCUMENTATION_CACHE_DIR/DeveloperDocumentation-15.0.index" ]] || exit 1 +[[ ! -e "$MOLE_XCODE_DOCUMENTATION_CACHE_DIR/$newline_index" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "clean_xcode_documentation_cache skips when the process probe fails" { + local doc_root="$HOME/DocumentationCacheProbeError" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" "$doc_root/DeveloperDocumentation-16.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 2; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } +clean_xcode_documentation_cache +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode documentation cache · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@test "clean_xcode_documentation_cache rechecks tooling after sudo authorization" { + local doc_root="$HOME/DocumentationCacheSudoRace" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" "$doc_root/DeveloperDocumentation-16.0.index" + touch -t 202602010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202601010000 "$doc_root/DeveloperDocumentation-16.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +has_sudo_session() { return 1; } +ensure_sudo_session() { touch "$HOME/xcode-started"; return 0; } +_sim_runtime_size_kb() { echo 1; } +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 0; } + +rm -f "$HOME/xcode-started" +clean_xcode_documentation_cache +[[ -e "$MOLE_XCODE_DOCUMENTATION_CACHE_DIR/DeveloperDocumentation-16.0.index" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Xcode documentation cache · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_documentation_cache reports a stop after partial removal" { + local doc_root="$HOME/DocumentationCachePartialRace" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" "$doc_root/DeveloperDocumentation-16.0.index" "$doc_root/DeveloperDocumentation-15.0.index" + touch -t 202603010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202602010000 "$doc_root/DeveloperDocumentation-16.0.index" + touch -t 202601010000 "$doc_root/DeveloperDocumentation-15.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +has_sudo_session() { return 0; } +_sim_runtime_size_kb() { echo 1; } +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} +safe_sudo_remove() { + command rm -rf "$1" + touch "$HOME/xcode-started" +} + +rm -f "$HOME/xcode-started" +clean_xcode_documentation_cache +remaining=$(command find "$MOLE_XCODE_DOCUMENTATION_CACHE_DIR" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ') +[[ "$remaining" -eq 2 ]] || { echo "WRONG_REMAINING:$remaining"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode documentation cache · removed 1 old indexes"* ]] || return 1 + [[ "$output" != *"Xcode documentation cache · stopped"* ]] +} + +@test "clean_xcode_documentation_cache does not report clean after a protected item and stop" { + local doc_root="$HOME/DocumentationCacheProtectedRace" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" "$doc_root/DeveloperDocumentation-16.0.index" "$doc_root/DeveloperDocumentation-15.0.index" + touch -t 202603010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202602010000 "$doc_root/DeveloperDocumentation-16.0.index" + touch -t 202601010000 "$doc_root/DeveloperDocumentation-15.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +is_path_whitelisted() { return 1; } +should_protect_path() { + if [[ "$1" == *"DeveloperDocumentation-16.0.index" ]]; then + touch "$HOME/xcode-started" + return 0 + fi + return 1 +} +has_sudo_session() { return 0; } +_sim_runtime_size_kb() { echo 1; } +_xcode_xctest_devices_process_running() { + [[ -e "$HOME/xcode-started" ]] && return 0 + return 1 +} +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 0; } + +rm -f "$HOME/xcode-started" +clean_xcode_documentation_cache +remaining=$(command find "$MOLE_XCODE_DOCUMENTATION_CACHE_DIR" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ') +[[ "$remaining" -eq 3 ]] || { echo "WRONG_REMAINING:$remaining"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode documentation cache · skipped 1 protected items"* ]] || return 1 + [[ "$output" != *"Xcode documentation cache · stopped"* ]] || return 1 + [[ "$output" != *"already clean"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_device_support skips all deletion paths while build tooling owns it" { + local ds_dir="$HOME/OwnedDeviceSupport" + mkdir -p "$ds_dir/17.0/Symbols/System/Library/Caches" "$ds_dir/17.1" "$ds_dir/17.2" + touch "$ds_dir/17.0/Symbols/System/Library/Caches/cache" "$ds_dir/device.log" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { [[ "$1" == "-x" && "$2" == "swift-frontend" ]]; } +safe_remove() { echo "UNEXPECTED_SAFE_REMOVE:$1"; return 0; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN:$1"; return 0; } +clean_xcode_device_support "$HOME/OwnedDeviceSupport" "iOS DeviceSupport" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"iOS DeviceSupport · skipped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_"* ]] +} + +@test "clean_xcode_system_coresimulator_caches removes only direct cache children" { + local cache_root="$HOME/SystemCoreSimulatorCaches" + mkdir -p "$cache_root/dyld/runtime" "$cache_root/metadata" + touch "$cache_root/dyld/runtime/cache" "$cache_root/metadata/index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +_coresimulator_booted_device_state() { return 1; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +get_path_size_kb() { echo 3; } +safe_sudo_remove() { echo "REMOVE:$1"; } +clean_xcode_system_coresimulator_caches +printf 'COUNTERS:%s:%s:%s\n' "$files_cleaned" "$total_size_cleaned" "$total_items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"REMOVE:$cache_root/dyld"* ]] || return 1 + [[ "$output" == *"REMOVE:$cache_root/metadata"* ]] || return 1 + [[ "$output"$'\n' != *"REMOVE:$cache_root"$'\n'* ]] || return 1 + [[ "$output" == *"COUNTERS:2:6:1"* ]] +} + +@test "clean_xcode_system_coresimulator_caches skips while CoreSimulator is active" { + local cache_root="$HOME/SystemCoreSimulatorCaches" + mkdir -p "$cache_root/dyld" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { + [[ "$1" == "-x" && "$2" == "Simulator" ]] +} +debug_log() { echo "DEBUG:$*"; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Xcode Simulator system cache · skipped"* ]] || return 1 + [[ "$output" == *"DEBUG:CoreSimulator process detected: Simulator"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@test "clean_xcode_system_coresimulator_caches does not defer whitelist-only entries" { + local cache_root="$HOME/SystemCoreSimulatorWhitelistOnly" + local target="$cache_root/dyld" + mkdir -p "$target" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +target="$MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR/dyld" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$target" ]]; } +_coresimulator_activity_state() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_system_coresimulator_caches ignores symlink and compiled-model-only entries" { + local cache_root="$HOME/SystemCoreSimulatorPolicyOnly" + local outside="$HOME/SystemCoreSimulatorPolicyOutside" + mkdir -p "$cache_root/compiled/com.apple.e5rt.e5bundlecache" "$outside" + ln -s "$outside" "$cache_root/symlink" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +_coresimulator_activity_state() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_system_coresimulator_caches fails closed when pgrep fails (#1304)" { + local cache_root + cache_root="$HOME/SystemCoreSimulatorCaches" + mkdir -p "$cache_root/dyld" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 2; } +debug_log() { echo "DEBUG:$*"; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Xcode Simulator system cache · skipped (process state unknown)"* ]] || return 1 + [[ "$output" == *"DEBUG:CoreSimulator process check failed: Xcode (exit=2)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] || return 1 +} + +@test "CoreSimulator process guard reports the exact matched probe (#1304)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +debug_log() { :; } +pgrep() { + [[ "$1" == "$EXPECTED_MODE" && "$2" == "$EXPECTED_PATTERN" ]] +} + +while IFS='|' read -r EXPECTED_MODE EXPECTED_PATTERN expected_label; do + [[ -n "$EXPECTED_PATTERN" ]] || continue + _coresimulator_cache_process_running || exit 1 + [[ "$_MOLE_XCODE_PROCESS_MATCH" == "$expected_label" ]] || exit 2 + printf 'matched:%s\n' "$_MOLE_XCODE_PROCESS_MATCH" +done <<'CASES' +-x|Xcode|Xcode +-x|Simulator|Simulator +-x|xcodebuild|xcodebuild +-x|xctest|xctest +-x|XCTRunner|XCTRunner +-x|simctl|simctl +CASES +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"matched:Xcode"* ]] || return 1 + [[ "$output" == *"matched:Simulator"* ]] || return 1 + [[ "$output" == *"matched:xcodebuild"* ]] || return 1 + [[ "$output" == *"matched:simctl"* ]] || return 1 +} + +@test "persistent services and generic compilers do not block cleanup without a booted device (#1319)" { + local cache_root="$HOME/SystemCoreSimulatorCachesPersistentServices" + mkdir -p "$cache_root/dyld" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { + case "$2" in + CoreSimulatorService | simdiskimaged | com.apple.CoreSimulator | XCBBuildService | swift-frontend) return 0 ;; + *) return 1 ;; + esac +} +_coresimulator_booted_device_state() { return 1; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +get_path_size_kb() { echo 1; } +safe_sudo_remove() { echo "REMOVE:$1"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"REMOVE:$cache_root/dyld"* ]] || return 1 + [[ "$output" != *"skipped (CoreSimulator running)"* ]] || return 1 +} + +@test "foreground simulator tooling blocks cleanup before a device is booted (#1319)" { + local cache_root="$HOME/SystemCoreSimulatorCachesForegroundTooling" + mkdir -p "$cache_root/dyld" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +_coresimulator_booted_device_state() { return 1; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } + +for ACTIVE_PROCESS in simctl xcodebuild; do + pgrep() { [[ "$1" == "-x" && "$2" == "$ACTIVE_PROCESS" ]]; } + clean_xcode_system_coresimulator_caches + printf 'BLOCKED:%s\n' "$ACTIVE_PROCESS" +done +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"BLOCKED:simctl"* ]] || return 1 + [[ "$output" == *"BLOCKED:xcodebuild"* ]] || return 1 + [[ "$output" != *"skipped (CoreSimulator running)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] || return 1 +} + +@test "booted or unknown simulator state blocks system cache cleanup (#1319)" { + local cache_root="$HOME/SystemCoreSimulatorCachesBootedState" + mkdir -p "$cache_root/dyld" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } + +for SIMULATOR_STATE in 0 2; do + _coresimulator_booted_device_state() { return "$SIMULATOR_STATE"; } + clean_xcode_system_coresimulator_caches +done +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"skipped (CoreSimulator running)"* ]] || return 1 + [[ "$output" == *"skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] || return 1 +} + +@test "booted simulator probe distinguishes active empty and unknown states (#1319)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +debug_log() { :; } +_MOLE_SIMCTL_RESOLUTION_STATUS="ready" +_run_simctl() { + case "$PROBE_CASE" in + active) printf '%s\n' '{"devices":{"runtime":[{"udid":"ABC"}]}}' ;; + empty) printf '%s\n' '{"devices":{}}' ;; + malformed) printf '%s\n' 'not-json' ;; + failed) return 124 ;; + esac +} + +set +e +PROBE_CASE=active; _coresimulator_booted_device_state; active_rc=$? +PROBE_CASE=empty; _coresimulator_booted_device_state; empty_rc=$? +PROBE_CASE=malformed; _coresimulator_booted_device_state; malformed_rc=$? +PROBE_CASE=failed; _coresimulator_booted_device_state; failed_rc=$? +set -e +printf 'active=%s empty=%s malformed=%s failed=%s\n' "$active_rc" "$empty_rc" "$malformed_rc" "$failed_rc" +[[ "$active_rc" -eq 0 && "$empty_rc" -eq 1 && "$malformed_rc" -eq 2 && "$failed_rc" -eq 2 ]] +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"active=0 empty=1 malformed=2 failed=2"* ]] || return 1 +} + +@test "simulator activity checks recheck foreground owners after the booted probe (#1319)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +debug_log() { :; } +pgrep() { + [[ -e "$HOME/foreground-started" && "$1" == "-x" && "$2" == "xcodebuild" ]] +} +_coresimulator_booted_device_state() { + touch "$HOME/foreground-started" + return 1 +} + +rm -f "$HOME/foreground-started" +set +e +_coresimulator_activity_state +coresimulator_rc=$? +rm -f "$HOME/foreground-started" +_xctest_devices_activity_state +xctest_rc=$? +set -e +printf 'coresimulator=%s xctest=%s\n' "$coresimulator_rc" "$xctest_rc" +[[ "$coresimulator_rc" -eq 0 && "$xctest_rc" -eq 0 ]] +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"coresimulator=0 xctest=0"* ]] || return 1 +} + +@test "clean_xcode_system_coresimulator_caches reports completed removals before a process race stops it" { + local cache_root="$HOME/PartialSystemCoreSimulatorCaches" + mkdir -p "$cache_root/first" "$cache_root/second" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +cleanup_result_color_kb() { echo ""; } +bytes_to_human() { echo "$1 bytes"; } +get_path_size_kb() { echo 1; } +simulator_started=false +safe_sudo_remove() { + command rm -rf "$1" + simulator_started=true +} +_coresimulator_cache_process_running() { + [[ "$simulator_started" == "true" ]] +} +_coresimulator_booted_device_state() { return 1; } + +clean_xcode_system_coresimulator_caches +remaining=$(command find "$MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ') +[[ "$remaining" -eq 1 ]] || { echo "WRONG_REMAINING:$remaining"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode Simulator system cache · removed 1"* ]] || return 1 + [[ "$output" != *"Xcode Simulator system cache · stopped"* ]] +} + +@test "clean_xcode_system_coresimulator_caches rechecks after the size probe" { + local cache_root="$HOME/PostSizeSystemCoreSimulatorCaches" + mkdir -p "$cache_root/entry" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +get_path_size_kb() { touch "$HOME/simulator-started"; echo 1; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; } +_coresimulator_cache_process_running() { + [[ -e "$HOME/simulator-started" ]] && return 0 + return 1 +} +_coresimulator_booted_device_state() { return 1; } + +rm -f "$HOME/simulator-started" +clean_xcode_system_coresimulator_caches +[[ -d "$MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR/entry" ]] || { echo "WRONG: cache removed"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Xcode Simulator system cache · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_system_coresimulator_caches reports deletion failures" { + local cache_root="$HOME/FailedSystemCoreSimulatorCaches" + mkdir -p "$cache_root/entry" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +get_path_size_kb() { echo 1; } +_coresimulator_cache_process_running() { return 1; } +_coresimulator_booted_device_state() { return 1; } +safe_sudo_remove() { return 1; } +clean_xcode_system_coresimulator_caches +[[ -d "$MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR/entry" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode Simulator system cache · could not remove 1 entries"* ]] || return 1 + [[ "$output" != *"already clean"* ]] +} + +@test "clean_xcode_xctest_devices targets only exact XCTestDevices directory" { + local developer_root="$HOME/Library/Developer" + mkdir -p "$developer_root/XCTestDevices" "$developer_root/XCTestDevices-old" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +_coresimulator_booted_device_state() { return 1; } +safe_clean() { printf 'SAFE:%s|%s\n' "$1" "$2"; } +clean_xcode_xctest_devices +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE:$developer_root/XCTestDevices|Xcode XCTestDevices test data"* ]] || return 1 + [[ "$output" != *"XCTestDevices-old"* ]] +} + +@test "clean_xcode_xctest_devices skips while XCTest process is active" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { + [[ "$*" == *"xcodebuild"* ]] +} +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } +clean_xcode_xctest_devices +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Xcode or XCTest running"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "clean_xcode_xctest_devices fails closed for booted or unknown simulator state (#1319)" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } + +for SIMULATOR_STATE in 0 2; do + _coresimulator_booted_device_state() { return "$SIMULATOR_STATE"; } + clean_xcode_xctest_devices +done +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Xcode XCTestDevices · skipped (Xcode or XCTest running)"* ]] || return 1 + [[ "$output" == *"Xcode XCTestDevices · skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] || return 1 +} + +@test "clean_xcode_xctest_devices rechecks after safe_clean sizing" { + local xctest_root="$HOME/PostSizeXCTestDevices" + mkdir -p "$xctest_root/device" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_XCODE_XCTEST_DEVICES_DIR="$xctest_root" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=false +note_activity() { :; } +get_cleanup_path_size_kb() { touch "$HOME/xctest-started"; echo 1; } +pgrep() { [[ -e "$HOME/xctest-started" ]]; } +_coresimulator_booted_device_state() { return 1; } +safe_remove() { echo "UNEXPECTED_REMOVE:$1"; command rm -rf "$1"; } + +rm -f "$HOME/xctest-started" +clean_xcode_xctest_devices +[[ -d "$MOLE_XCODE_XCTEST_DEVICES_DIR/device" ]] || { echo "WRONG: XCTestDevices removed"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Xcode XCTestDevices · stopped"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_xctest_devices dry-run keeps XCTestDevices directory" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + touch "$xctest_root/test-device" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=true +MOLE_DRY_RUN=1 +pgrep() { return 1; } +_coresimulator_booted_device_state() { return 1; } +clean_xcode_xctest_devices +[[ -d "$HOME/Library/Developer/XCTestDevices" ]] && echo "STILL_EXISTS" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode XCTestDevices test data"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"STILL_EXISTS"* ]] +} + +@test "clean_xcode_xctest_devices respects whitelist" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + touch "$xctest_root/test-device" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +WHITELIST_PATTERNS=("$HOME/Library/Developer/XCTestDevices") +pgrep() { return 1; } +_coresimulator_booted_device_state() { return 1; } +clean_xcode_xctest_devices +[[ -d "$HOME/Library/Developer/XCTestDevices" ]] && echo "STILL_EXISTS" +printf 'WHITELIST_SKIPPED:%s\n' "$whitelist_skipped_count" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"STILL_EXISTS"* ]] || return 1 + [[ "$output" == *"WHITELIST_SKIPPED:1"* ]] +} + +@test "clean_xcode_xctest_devices does not defer a whitelisted root" { + local xctest_root="$HOME/WhitelistedXCTestDevices" + mkdir -p "$xctest_root" + touch "$xctest_root/test-device" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_XCTEST_DEVICES_DIR="$xctest_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +should_protect_path() { return 1; } +is_path_whitelisted() { [[ "$1" == "$MOLE_XCODE_XCTEST_DEVICES_DIR" ]]; } +_xctest_devices_activity_state() { return 0; } +defer_cleanup_family() { echo "UNEXPECTED_DEFER:$1"; } +safe_clean() { echo "UNEXPECTED_CLEAN:$1"; } +clean_xcode_xctest_devices +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_DEFER"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] +} + +@test "check_rust_toolchains reports multiple toolchains" { + run /bin/bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_REVIEW="⊙" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + + [ "$status" -eq 0 ] + [[ "$output" == *"Rust toolchains · 3 found"* ]] +} + +@test "check_rust_toolchains silent when only one toolchain" { + run /bin/bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_REVIEW="⊙" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + + [ "$status" -eq 0 ] + [[ "$output" != *"Rust toolchains"* ]] +} + +@test "clean_dev_jetbrains_toolbox cleans old versions and bypasses toolbox whitelist" { + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/241.3" + ln -s "241.3" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + touch -t 202403010000 "$toolbox_channel/241.3" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" +WHITELIST_PATTERNS=("$toolbox_root"* "$HOME/Library/Application Support/JetBrains*") +note_activity() { :; } +safe_clean() { + local target="$1" + for pattern in "${WHITELIST_PATTERNS[@]+${WHITELIST_PATTERNS[@]}}"; do + if [[ "$pattern" == "$toolbox_root"* ]]; then + echo "WHITELIST_NOT_REMOVED" + exit 1 + fi + done + echo "$target" +} +MOLE_JETBRAINS_TOOLBOX_KEEP=1 +clean_dev_jetbrains_toolbox +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] || return 1 + [[ "$output" != *"/241.2"* ]] +} + +@test "clean_dev_jetbrains_toolbox keeps current directory and removes older versions" { + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1"; } +MOLE_JETBRAINS_TOOLBOX_KEEP=1 +clean_dev_jetbrains_toolbox +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] || return 1 + [[ "$output" != *"/241.2"* ]] +} + +@test "clean_dev_ai_agents keeps newest version and removes older ones by mtime" { + local claude_root="$HOME/.local/share/claude/versions" + local cursor_root="$HOME/.local/share/cursor-agent/versions" + local copilot_root="$HOME/.copilot/pkg/universal" + mkdir -p "$claude_root" "$cursor_root" "$copilot_root" + touch -t 202604170829 "$claude_root/2.1.112" + touch -t 202604180902 "$claude_root/2.1.113" + touch -t 202604181002 "$claude_root/2.1.114" + mkdir -p "$cursor_root/2026.04.08-old" "$cursor_root/2026.04.15-new" + touch -t 202604080000 "$cursor_root/2026.04.08-old" + touch -t 202604150000 "$cursor_root/2026.04.15-new" + mkdir -p "$copilot_root/1.0.5" "$copilot_root/1.0.32" "$copilot_root/1.0.34" + touch -t 202604010000 "$copilot_root/1.0.5" + touch -t 202604200000 "$copilot_root/1.0.32" + touch -t 202604250000 "$copilot_root/1.0.34" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1|$2"; } +clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/2.1.112|Claude Code old version"* ]] || return 1 + [[ "$output" == *"/2.1.113|Claude Code old version"* ]] || return 1 + [[ "$output" != *"/2.1.114|"* ]] || return 1 + [[ "$output" == *"/2026.04.08-old|Cursor Agent old version"* ]] || return 1 + [[ "$output" != *"/2026.04.15-new|"* ]] || return 1 + [[ "$output" == *"/1.0.5|GitHub Copilot CLI old version"* ]] || return 1 + [[ "$output" == *"/1.0.32|GitHub Copilot CLI old version"* ]] || return 1 + [[ "$output" != *"/1.0.34|"* ]] +} + +@test "clean_dev_ai_agents protects the active version pointed at by ~/.local/bin/" { + local claude_root="$HOME/.local/share/claude/versions" + local cursor_root="$HOME/.local/share/cursor-agent/versions" + local bin_dir="$HOME/.local/bin" + rm -rf "$claude_root" "$cursor_root" "$bin_dir" + mkdir -p "$claude_root" "$cursor_root" "$bin_dir" + + mkdir -p "$claude_root/2.1.112" "$claude_root/2.1.113" "$claude_root/2.1.114" + touch -t 202604170000 "$claude_root/2.1.112" + touch -t 202604180000 "$claude_root/2.1.113" + touch -t 202604200000 "$claude_root/2.1.114" + ln -s "$claude_root/2.1.113" "$bin_dir/claude" + + mkdir -p "$cursor_root/2026.04.01-old" "$cursor_root/2026.04.10-active" "$cursor_root/2026.04.20-newest" + touch -t 202604010000 "$cursor_root/2026.04.01-old" + touch -t 202604100000 "$cursor_root/2026.04.10-active" + touch -t 202604200000 "$cursor_root/2026.04.20-newest" + : > "$cursor_root/2026.04.10-active/cursor-agent" + ln -s "$cursor_root/2026.04.10-active/cursor-agent" "$bin_dir/cursor-agent" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1|$2"; } +clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/2.1.112|Claude Code old version"* ]] || return 1 + [[ "$output" != *"/2.1.113|"* ]] || return 1 + [[ "$output" != *"/2.1.114|"* ]] || return 1 + [[ "$output" == *"/2026.04.01-old|Cursor Agent old version"* ]] || return 1 + [[ "$output" != *"/2026.04.10-active|"* ]] || return 1 + [[ "$output" != *"/2026.04.20-newest|"* ]] +} + +@test "clean_dev_ai_agents skips cleanup entirely when the active symlink is broken" { + local claude_root="$HOME/.local/share/claude/versions" + local bin_dir="$HOME/.local/bin" + rm -rf "$claude_root" "$bin_dir" + mkdir -p "$claude_root" "$bin_dir" + + mkdir -p "$claude_root/2.1.112" "$claude_root/2.1.113" "$claude_root/2.1.114" + touch -t 202604170000 "$claude_root/2.1.112" + touch -t 202604180000 "$claude_root/2.1.113" + touch -t 202604200000 "$claude_root/2.1.114" + ln -s "$claude_root/2.1.999-missing" "$bin_dir/claude" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1|$2"; } +clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"|Claude Code old version"* ]] || return 1 + [[ "$output" == *"Claude Code old version · skipped (active symlink broken)"* ]] || return 1 + + rm -f "$bin_dir/claude" +} + +@test "clean_dev_ai_agents respects MOLE_AI_AGENTS_KEEP and skips missing roots" { + local claude_root="$HOME/.local/share/claude/versions" + # Earlier cases in this file seed versions under the shared HOME; without a + # reset this sees five versions instead of three and KEEP=2 sweeps 2.1.101 too. + rm -rf "$claude_root" + mkdir -p "$claude_root" + touch -t 202604170000 "$claude_root/2.1.100" + touch -t 202604180000 "$claude_root/2.1.101" + touch -t 202604190000 "$claude_root/2.1.102" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1"; } +MOLE_AI_AGENTS_KEEP=2 clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/2.1.100"* ]] || return 1 + [[ "$output" != *"/2.1.101"* ]] || return 1 + [[ "$output" != *"/2.1.102"* ]] +} + +@test "clean_dev_jetbrains_logs only targets JetBrains logs" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { printf '%s|%s\n' "$1" "$2"; } +clean_dev_jetbrains_logs +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/Library/Logs/JetBrains/*|JetBrains IDE logs"* ]] || return 1 + [[ "$output" != *"Library/Caches/JetBrains"* ]] +} + +@test "clean_developer_tools includes JetBrains logs but not JetBrains cache sweep" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +stop_section_spinner() { :; } +note_activity() { :; } +safe_clean() { printf '%s|%s\n' "$1" "$2"; } +clean_tool_cache() { :; } +check_rust_toolchains() { :; } +clean_dev_npm() { :; } +clean_dev_python() { :; } +clean_dev_go() { :; } +clean_dev_mise() { :; } +clean_dev_rust() { :; } +clean_dev_docker() { :; } +clean_dev_cloud() { :; } +clean_dev_nix() { :; } +clean_dev_shell() { :; } +clean_dev_frontend() { :; } +clean_project_caches() { :; } +clean_dev_mobile() { :; } +clean_dev_jvm() { :; } +clean_dev_jetbrains_toolbox() { :; } +clean_dev_ai_agents() { :; } +clean_dev_other_langs() { :; } +clean_dev_cicd() { :; } +clean_dev_database() { :; } +clean_dev_api_tools() { :; } +clean_dev_network() { :; } +clean_dev_misc() { :; } +clean_dev_elixir() { :; } +clean_dev_haskell() { :; } +clean_dev_ocaml() { :; } +clean_xcode_tools() { :; } +clean_code_editors() { :; } +clean_homebrew() { :; } +clean_developer_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/Library/Logs/JetBrains/*|JetBrains IDE logs"* ]] || return 1 + [[ "$output" != *"Library/Caches/JetBrains"* ]] || return 1 + [[ "$output" == *"$HOME/Library/Caches/Homebrew/downloads/*|Homebrew cache"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Caches/Homebrew/*|Homebrew cache"* ]] || return 1 + [[ "$output" != *"Library/Caches/Homebrew/api"* ]] || return 1 + [[ "$output" != *"Library/Caches/Homebrew/bootsnap"* ]] +} + +@test "clean_dev_misc protects Claude Code and OpenCode recovery state" { + mkdir -p "$HOME/.claude/projects/project-a/memory" + mkdir -p "$HOME/.claude/plugins/cache/plugin-a" + mkdir -p "$HOME/.claude/plugins/marketplaces" + mkdir -p "$HOME/.claude/paste-cache" + mkdir -p "$HOME/.claude/tmp" + mkdir -p "$HOME/.claude/session-env" + mkdir -p "$HOME/.claude/shell-snapshots" + mkdir -p "$HOME/.local/share/opencode/snapshot/project" + mkdir -p "$HOME/.local/share/opencode/log" + mkdir -p "$HOME/.cache/opencode" + mkdir -p "$HOME/Library/Application Support/Claude/pending-uploads" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { printf 'SAFE:%s|%s\n' "$1" "$2"; } +safe_find_delete() { printf 'FIND:%s|%s|%s|%s\n' "$1" "$2" "$3" "$4"; } +clean_service_worker_cache() { :; } +clean_dev_misc +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"$HOME/.claude/projects"* ]] || return 1 + [[ "$output" != *"$HOME/.claude/plugins/cache"* ]] || return 1 + [[ "$output" != *"$HOME/.claude/plugins/marketplaces"* ]] || return 1 + [[ "$output" != *"$HOME/.claude/paste-cache"* ]] || return 1 + [[ "$output" != *"$HOME/.claude/tmp"* ]] || return 1 + [[ "$output" != *"$HOME/.claude/session-env"* ]] || return 1 + [[ "$output" != *"$HOME/.local/share/opencode/snapshot"* ]] || return 1 + [[ "$output" != *"$HOME/.local/share/opencode/log"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Application Support/Claude/pending-uploads"* ]] || return 1 + [[ "$output" == *"$HOME/.cache/opencode"* ]] || return 1 + [[ "$output" != *"$HOME/.claude/shell-snapshots"* ]] +} + +@test "clean_xcode_simulator_runtime_volumes shows scan progress and skips sizing in-use volumes" { + local volumes_root="$HOME/sim-volumes" + local cryptex_root="$HOME/sim-cryptex" + mkdir -p "$volumes_root/in-use-runtime" "$volumes_root/unused-runtime" + mkdir -p "$cryptex_root" + + # The "scanning N entries" line is deliberately gated behind MO_DEBUG (the + # spinner carries the feedback otherwise), so this case has to ask for it. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$cryptex_root" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +size_log="$HOME/size-calls.log" +: > "$size_log" +DRY_RUN=false + +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +_sim_runtime_mount_points() { + printf '%s\n' "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/in-use-runtime" +} +_sim_runtime_size_kb() { + local target_path="$1" + echo "$target_path" >> "$size_log" + echo "1" +} +safe_sudo_remove() { + local target_path="$1" + echo "REMOVE:$target_path" + return 0 +} + +clean_xcode_simulator_runtime_volumes +echo "SIZE_LOG_START" +cat "$size_log" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode runtime volumes · scanning 2 entries"* ]] || return 1 + # 16a8bcaf consolidated the per-stage "cleaning N unused" line into one final + # result message; assert the line that survived. + [[ "$output" == *"Xcode runtime volumes · removed 1 ("* ]] || return 1 + [[ "$output" == *"REMOVE:$volumes_root/unused-runtime"* ]] || return 1 + [[ "$output" == *"$volumes_root/unused-runtime"* ]] || return 1 + [[ "$output" != *"$volumes_root/in-use-runtime"* ]] +} + +@test "clean_xcode_simulator_runtime_volumes deletes nothing when mount enumeration fails" { + local volumes_root="$HOME/sim-volumes" + mkdir -p "$volumes_root/runtime-a" "$volumes_root/runtime-b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$HOME/none" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +DRY_RUN=false +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +# mount failed: no lines. Without the guard every runtime is UNUSED and deleted. +_sim_runtime_mount_points() { printf ''; } +_sim_runtime_size_kb() { echo "1"; } +safe_sudo_remove() { echo "REMOVE:$1"; return 0; } + +clean_xcode_simulator_runtime_volumes + +# Positive control. The guard makes this path print nothing at all, so "no +# REMOVE line" alone cannot tell a working guard from a run that never reached +# the deletion branch. Same fixture, this time with mounts enumerable. +echo "CONTROL" +_sim_runtime_mount_points() { printf '%s\n' "/"; } +clean_xcode_simulator_runtime_volumes +EOF + + [ "$status" -eq 0 ] || return 1 + guarded="${output%%CONTROL*}" + control="${output#*CONTROL}" + [[ "$guarded" != *"REMOVE:"* ]] || { + echo "deleted a volume despite unknown mount state" + return 1 + } + [[ "$control" == *"REMOVE:"* ]] || { + echo "control run removed nothing, so the guarded run proves nothing" + return 1 + } +} + +@test "clean_xcode_simulator_runtime_volumes rechecks mounts after sizing" { + local volumes_root="$HOME/sim-volumes-race" + mkdir -p "$volumes_root/runtime-a" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$HOME/none" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +DRY_RUN=false +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +_sim_runtime_mount_points() { + printf 'probe\n' >> "$HOME/mount-probes" + local round + round=$(wc -l < "$HOME/mount-probes" | tr -d ' ') + if [[ $round -eq 1 ]]; then + printf '%s\n' "/" + else + printf '%s\n' "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/runtime-a" + fi +} +_sim_runtime_size_kb() { echo 1; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 0; } + +rm -f "$HOME/mount-probes" +clean_xcode_simulator_runtime_volumes +[[ -d "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/runtime-a" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode runtime volumes · stopped (runtime became mounted)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "clean_xcode_simulator_runtime_volumes reports deletion failures" { + local volumes_root="$HOME/sim-volumes-failed" + mkdir -p "$volumes_root/runtime-a" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$HOME/none" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +DRY_RUN=false +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +_sim_runtime_mount_points() { printf '%s\n' "/"; } +_sim_runtime_size_kb() { echo 1; } +safe_sudo_remove() { return 1; } +clean_xcode_simulator_runtime_volumes +[[ -d "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/runtime-a" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode runtime volumes · could not remove 1 entries"* ]] || return 1 + [[ "$output" != *"already clean"* ]] +} + +@test "clean_xcode_simulator_runtime_volumes reports a mount stop after an earlier failure" { + local volumes_root="$HOME/sim-volumes-failure-stop" + mkdir -p "$volumes_root/runtime-a" "$volumes_root/runtime-b" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$HOME/none" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +DRY_RUN=false +note_activity() { :; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +_sim_runtime_mount_points() { + printf 'probe\n' >> "$HOME/mount-failure-stop-probes" + local round + round=$(wc -l < "$HOME/mount-failure-stop-probes" | tr -d ' ') + if [[ $round -le 2 ]]; then + printf '%s\n' "/" + else + printf '%s\n' "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/runtime-b" + fi +} +_sim_runtime_size_kb() { echo 1; } +safe_sudo_remove() { return 1; } + +rm -f "$HOME/mount-failure-stop-probes" +clean_xcode_simulator_runtime_volumes +[[ -d "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/runtime-a" ]] || exit 1 +[[ -d "$MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT/runtime-b" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Xcode runtime volumes · could not remove 1 entries"* ]] || return 1 + [[ "$output" == *"Xcode runtime volumes · stopped (runtime became mounted)"* ]] +} + +@test "clean_dev_mobile leaves an idle section when no unavailable simulator exists" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 DRY_RUN=true \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +_xcode_safe_clean_guarded() { :; } +xcrun() { :; } +_resolve_simctl_developer_dir() { + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" + _MOLE_SIMCTL_DEVELOPER_DIR="$HOME/Xcode.app/Contents/Developer" +} +_run_simctl() { return 0; } +debug_log() { :; } +DRY_RUN=true +start_section "Developer tools" +clean_dev_mobile +end_section +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"Developer tools"*"Nothing to clean"* ]] +} + +@test "clean_dev_mobile continues cleanup when simctl is unavailable" { + local tmp_bin + tmp_bin="$HOME/simctl-unavailable-bin" + mkdir -p "$tmp_bin" + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +exit 1 +XEOF + cat > "$tmp_bin/xcode-select" << 'XEOF' +#!/bin/bash +printf '/Library/Developer/CommandLineTools\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +_MOLE_SIMCTL_XCODE_APP_ROOTS=("$HOME/EmptyApplications") +mkdir -p "${_MOLE_SIMCTL_XCODE_APP_ROOTS[0]}" check_android_ndk() { :; } clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } clean_xcode_device_support() { echo "DEVICE_SUPPORT:$2"; } safe_clean() { echo "SAFE_CLEAN:$2"; } +safe_clean_guarded() { echo "SAFE_CLEAN_GUARDED:$1:${*: -1}"; } +note_activity() { :; } +debug_log() { :; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"simctl could not be resolved"* ]] || return 1 + [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN_GUARDED:_coresimulator_delete_guard_allows:Simulator runtime cache"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN_GUARDED:_xcode_delete_guard_allows:Xcode Interface Builder cache"* ]] || return 1 + [[ "$output" == *"SAFE_CLEAN:Android SDK cache"* ]] || return 1 +} + +@test "clean_dev_mobile retries simctl probe on cold-boot timeout (#890)" { + # Exercises the timeout-retry branch (the only path the #890 fix touches). + # Strategy: + # - put a real `xcrun` shim on PATH so `command -v xcrun` succeeds AND + # `declare -F xcrun` returns false → function falls into the else branch. + # - stub `run_with_timeout` so the first probe returns 124 (timeout) and + # the second returns 0, mirroring a cold-boot CoreSimulatorService + # warmup. + # - the shim itself returns empty for the post-probe + # `xcrun simctl list devices unavailable` call so we take the + # "already clean" branch and don't try to delete anything. + local tmp_bin + tmp_bin="$HOME/simctl-retry-bin" + mkdir -p "$tmp_bin" "$HOME/Xcode.app/Contents/Developer" + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +exit 0 +XEOF + cat > "$tmp_bin/xcode-select" << XEOF +#!/bin/bash +printf '$HOME/Xcode.app/Contents/Developer\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } +sleep() { echo "UNEXPECTED_SLEEP:$*"; return 99; } + +# First call (5s timeout) simulates cold-boot warmup → return 124. +# Second call (8s timeout) succeeds. +__rwt_count=0 +run_with_timeout() { + shift + case " $* " in + *" xcrun simctl list devices ") + __rwt_count=$((__rwt_count + 1)) + if [[ $__rwt_count -eq 1 ]]; then + return 124 + fi + ;; + esac + "$@" +} + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"simctl probe succeeded on retry"* ]] || return 1 + [[ "$output" != *"simctl not available"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SLEEP"* ]] || return 1 +} + +@test "clean_dev_mobile classifies simctl probe failures and sanitizes debug output (#1304)" { + local tmp_bin + tmp_bin="$HOME/simctl-classification-bin" + mkdir -p "$tmp_bin" "$HOME/Xcode.app/Contents/Developer" + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +exit 0 +XEOF + chmod +x "$tmp_bin/xcrun" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { echo "DEBUG:$*"; } +_resolve_simctl_developer_dir() { + _MOLE_SIMCTL_DEVELOPER_DIR="$HOME/Xcode.app/Contents/Developer" + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" +} + +_run_simctl() { + local timeout_seconds="$1" + shift + if [[ "$*" == "list devices" ]]; then + __probe_call=$((__probe_call + 1)) + printf '%b' "$PROBE_STDERR" >&2 + if [[ $__probe_call -eq 1 ]]; then + return "$PROBE_FIRST_STATUS" + fi + return "$PROBE_RETRY_STATUS" + fi + if [[ "$*" == "list devices unavailable" ]]; then + return 0 + fi + return 1 +} + +run_probe_case() { + local label="$1" + PROBE_FIRST_STATUS="$2" + PROBE_RETRY_STATUS="$3" + __probe_call=0 + PROBE_STDERR="$HOME/Library/Developer/private"$'\n\033[31mprobe failed\033[0m\n' + printf 'CASE:%s\n' "$label" + clean_dev_mobile +} + +run_probe_case retry-success 124 0 +run_probe_case timeout 124 124 +run_probe_case failure 7 7 +run_probe_case failure-then-timeout 7 124 +run_probe_case timeout-then-failure 124 7 +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"DEBUG:simctl probe statuses: first=124 retry=0"* ]] || return 1 + [[ "$output" == *"Xcode unavailable simulators · simctl probe timed out"* ]] || return 1 + [[ "$output" == *"Xcode unavailable simulators · simctl probe failed (exit=7)"* ]] || return 1 + [[ "$output" == *"DEBUG:simctl probe statuses: first=7 retry=124"* ]] || return 1 + [[ "$output" == *"DEBUG:simctl probe first stderr: ~/Library/Developer/private [31mprobe failed[0m"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Developer/private"* ]] || return 1 +} + +@test "clean_dev_mobile uses the sole Xcode Beta candidate when CLT is selected (#1261)" { + local tmp_bin candidate developer_dir + tmp_bin="$HOME/simctl-single-bin" + candidate="$HOME/Applications/Xcode-Beta.app" + developer_dir="$candidate/Contents/Developer" + mkdir -p "$tmp_bin" "$developer_dir" + + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +if [[ "$*" == "--find simctl" ]]; then + [[ "${DEVELOPER_DIR:-}" == "$EXPECTED_DEVELOPER_DIR" ]] || exit 1 + exit +fi +printf '%s|%s\n' "${DEVELOPER_DIR:-}" "$*" >> "$SIMCTL_CALL_LOG" +case "$*" in + "simctl list devices") + exit 0 + ;; + "simctl list devices unavailable") + printf ' iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)\n' + exit 0 + ;; +esac +exit 1 +XEOF + cat > "$tmp_bin/xcode-select" << 'XEOF' +#!/bin/bash +printf '/Library/Developer/CommandLineTools\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" \ + DRY_RUN=true EXPECTED_DEVELOPER_DIR="$developer_dir" \ + SIMCTL_CALL_LOG="$HOME/simctl-single.log" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +_MOLE_SIMCTL_XCODE_APP_ROOTS=("$HOME/Applications") +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { :; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Xcode unavailable simulators · would clean 1"* ]] || return 1 + [[ -s "$HOME/simctl-single.log" ]] || return 1 + while IFS= read -r call; do + [[ "$call" == "$developer_dir|"* ]] || return 1 + done < "$HOME/simctl-single.log" +} + +@test "clean_dev_mobile skips ambiguous Xcode candidates without choosing one (#1261)" { + local tmp_bin + tmp_bin="$HOME/simctl-ambiguous-bin" + mkdir -p "$tmp_bin" + for app in Xcode.app Xcode-Beta.app; do + mkdir -p "$HOME/Applications/$app/Contents/Developer" + done + + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +if [[ "$*" == "--find simctl" ]]; then + [[ "${DEVELOPER_DIR:-}" == "$HOME/Applications/Xcode.app/Contents/Developer" || + "${DEVELOPER_DIR:-}" == "$HOME/Applications/Xcode-Beta.app/Contents/Developer" ]] + exit +fi +printf '%s\n' "$*" >> "$SIMCTL_CALL_LOG" +exit 0 +XEOF + cat > "$tmp_bin/xcode-select" << 'XEOF' +#!/bin/bash +printf '/Library/Developer/CommandLineTools\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" \ + SIMCTL_CALL_LOG="$HOME/simctl-ambiguous.log" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +_MOLE_SIMCTL_XCODE_APP_ROOTS=("$HOME/Applications") +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { echo "DEBUG:$*"; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"multiple Xcode apps found; set DEVELOPER_DIR"* ]] || return 1 + [[ "$output" == *"DEBUG:simctl Xcode candidate: $HOME/Applications/Xcode.app"* ]] || return 1 + [[ "$output" == *"DEBUG:simctl Xcode candidate: $HOME/Applications/Xcode-Beta.app"* ]] || return 1 + [[ ! -e "$HOME/simctl-ambiguous.log" ]] || return 1 +} + +@test "clean_dev_mobile does not replace a selected full Xcode when simctl is unavailable" { + local tmp_bin selected candidate + tmp_bin="$HOME/simctl-selected-invalid-bin" + selected="$HOME/Applications/Xcode-Selected.app/Contents/Developer" + candidate="$HOME/Applications/Xcode-Beta.app/Contents/Developer" + mkdir -p "$tmp_bin" "$selected" "$candidate" + + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +printf '%s|%s\n' "${DEVELOPER_DIR:-}" "$*" >> "$SIMCTL_CALL_LOG" +if [[ "${DEVELOPER_DIR:-}" == "$CANDIDATE_DEVELOPER_DIR" ]]; then + exit 0 +fi +exit 1 +XEOF + cat > "$tmp_bin/xcode-select" << XEOF +#!/bin/bash +printf '$selected\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + # GitHub's macOS image exports DEVELOPER_DIR. This case exercises the + # xcode-select branch, so inherited toolchain state must not change it. + run env -u DEVELOPER_DIR HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" \ + CANDIDATE_DEVELOPER_DIR="$candidate" \ + SIMCTL_CALL_LOG="$HOME/simctl-selected-invalid.log" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +_MOLE_SIMCTL_XCODE_APP_ROOTS=("$HOME/Applications") +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { :; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"simctl could not be resolved"* ]] || return 1 + local actual_calls + actual_calls=$(cat "$HOME/simctl-selected-invalid.log") + [[ -n "$actual_calls" ]] || return 1 + local actual_call + while IFS= read -r actual_call; do + if [[ "$actual_call" != "$selected|--find simctl" ]]; then + printf 'unexpected simctl resolution call: %q\n' "$actual_call" >&2 + return 1 + fi + done <<< "$actual_calls" + [[ "$actual_calls" != *"$candidate|"* ]] || return 1 +} + +@test "clean_dev_mobile does not override an invalid explicit DEVELOPER_DIR (#1261)" { + local tmp_bin candidate + tmp_bin="$HOME/simctl-explicit-invalid-bin" + candidate="$HOME/Applications/Xcode-Beta.app/Contents/Developer" + mkdir -p "$tmp_bin" "$candidate" + + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +printf '%s\n' "$*" >> "$SIMCTL_CALL_LOG" +exit 0 +XEOF + cat > "$tmp_bin/xcode-select" << 'XEOF' +#!/bin/bash +printf '%s\n' "$*" >> "$XCODE_SELECT_CALL_LOG" +printf '/Library/Developer/CommandLineTools\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" \ + DEVELOPER_DIR="$HOME/MissingXcode.app/Contents/Developer" \ + SIMCTL_CALL_LOG="$HOME/simctl-explicit-invalid.log" \ + XCODE_SELECT_CALL_LOG="$HOME/xcode-select-explicit-invalid.log" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +_MOLE_SIMCTL_XCODE_APP_ROOTS=("$HOME/Applications") +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { :; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"DEVELOPER_DIR has no simctl"* ]] || return 1 + [[ ! -e "$HOME/simctl-explicit-invalid.log" ]] || return 1 + [[ ! -e "$HOME/xcode-select-explicit-invalid.log" ]] || return 1 +} + +@test "clean_dev_mobile does not race a timed-out simctl delete with manual removal" { + local tmp_bin developer_dir + tmp_bin="$HOME/simctl-delete-failure-bin" + developer_dir="$HOME/Xcode-delete-failure.app/Contents/Developer" + mkdir -p "$tmp_bin" "$developer_dir" \ + "$HOME/Library/Developer/CoreSimulator/Devices/ABCDEF01-2345-6789-ABCD-EF0123456789" + + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +case "$*" in + "--find simctl" | "simctl list devices") + exit 0 + ;; + "simctl list devices unavailable") + printf ' iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)\n' + exit 0 + ;; + "simctl delete unavailable") + exit 124 + ;; +esac +exit 1 +XEOF + cat > "$tmp_bin/xcode-select" << XEOF +#!/bin/bash +printf '$developer_dir\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" \ + DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +safe_remove() { echo "UNEXPECTED_FALLBACK:$1"; return 1; } +note_activity() { :; } +debug_log() { :; } +start_section_spinner() { :; } +stop_section_spinner() { :; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Xcode unavailable simulators · cleanup timed out"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_FALLBACK"* ]] || return 1 + [[ "$output" != *"Xcode unavailable simulators · removed"* ]] || return 1 +} + +@test "clean_dev_mobile does not bypass simctl when a device becomes busy" { + local tmp_bin developer_dir + tmp_bin="$HOME/simctl-busy-bin" + developer_dir="$HOME/Xcode-busy.app/Contents/Developer" + mkdir -p "$tmp_bin" "$developer_dir" \ + "$HOME/Library/Developer/CoreSimulator/Devices/ABCDEF01-2345-6789-ABCD-EF0123456789" + + cat > "$tmp_bin/xcrun" << 'XEOF' +#!/bin/bash +case "$*" in + "--find simctl" | "simctl list devices") + exit 0 + ;; + "simctl list devices unavailable") + printf ' iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)\n' + exit 0 + ;; + "simctl delete unavailable") + printf 'device is busy\n' >&2 + exit 1 + ;; +esac +exit 1 +XEOF + cat > "$tmp_bin/xcode-select" << XEOF +#!/bin/bash +printf '$developer_dir\n' +XEOF + chmod +x "$tmp_bin/xcrun" "$tmp_bin/xcode-select" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$tmp_bin:$PATH" \ + DRY_RUN=false /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +safe_remove() { echo "UNEXPECTED_FALLBACK:$1"; return 1; } +note_activity() { :; } +debug_log() { :; } +start_section_spinner() { :; } +stop_section_spinner() { :; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"cleanup failed (device in use)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_FALLBACK"* ]] || return 1 + [[ "$output" != *"Xcode unavailable simulators · removed"* ]] || return 1 +} + +@test "clean_dev_mobile never deletes from a timed-out list or reports a timed-out recount as success" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false \ + SIMCTL_SAFETY_LOG="$HOME/simctl-safety.log" \ + SIMCTL_RECOUNT_STATE="$HOME/simctl-recount.state" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +get_path_size_kb() { echo "1"; } +note_activity() { :; } +debug_log() { :; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +cleanup_result_color_kb() { printf '%s' "$GREEN"; } +xcrun() { return 0; } +_resolve_simctl_developer_dir() { + _MOLE_SIMCTL_DEVELOPER_DIR="$HOME/Xcode.app/Contents/Developer" + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" +} + +scenario="list-timeout" +_run_simctl() { + shift + case "$*" in + "list devices") + return 0 + ;; + "list devices unavailable") + if [[ "$scenario" == "list-timeout" ]]; then + echo " iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)" + return 124 + fi + if [[ ! -e "$SIMCTL_RECOUNT_STATE" ]]; then + touch "$SIMCTL_RECOUNT_STATE" + echo " iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)" + return 0 + fi + return 124 + ;; + "delete unavailable") + printf 'DELETE\n' >> "$SIMCTL_SAFETY_LOG" + return 0 + ;; + esac + return 1 +} + +clean_dev_mobile +if [[ -e "$SIMCTL_SAFETY_LOG" ]]; then + echo "UNEXPECTED_DELETE_AFTER_LIST_TIMEOUT" +fi + +scenario="recount-timeout" +clean_dev_mobile +printf 'DELETE_COUNT=%s\n' "$(wc -l < "$SIMCTL_SAFETY_LOG" | tr -d ' ')" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"simctl list failed (exit=124)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DELETE_AFTER_LIST_TIMEOUT"* ]] || return 1 + [[ "$output" == *"cleanup completed, unable to verify remaining devices"* ]] || return 1 + [[ "$output" == *"DELETE_COUNT=1"* ]] || return 1 + [[ "$output" != *"removed 1"* ]] || return 1 +} + +@test "clean_dev_mobile stops before simctl delete when unavailable-device sizing times out" { + local case_home="$HOME/simctl-size-timeout" + local udid="ABCDEF01-2345-6789-ABCD-EF0123456789" + mkdir -p "$case_home/Library/Developer/CoreSimulator/Devices/$udid" + + run env HOME="$case_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false \ + MOLE_CURRENT_COMMAND=clean MOLE_CLEAN_CANCEL_STATUS=0 \ + SIMCTL_CALL_LOG="$case_home/simctl-calls.log" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_system_coresimulator_caches() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_xctest_devices() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } note_activity() { :; } debug_log() { :; } -xcrun() { return 1; } +xcrun() { :; } +_resolve_simctl_developer_dir() { + _MOLE_SIMCTL_DEVELOPER_DIR="$HOME/Xcode.app/Contents/Developer" + _MOLE_SIMCTL_RESOLUTION_STATUS="ready" +} +_run_simctl() { + shift + printf '%s\n' "$*" >> "$SIMCTL_CALL_LOG" + case "$*" in + "list devices") return 0 ;; + "list devices unavailable") + printf ' iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)\n' + return 0 + ;; + "delete unavailable") return 0 ;; + esac + return 1 +} +get_path_size_kb() { return 124; } +set +e clean_dev_mobile +rc=$? +set -e +printf 'RC=%s CANCEL=%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=124 CANCEL=124"* ]] || return 1 + [[ -f "$case_home/simctl-calls.log" ]] || return 1 + if grep -q '^delete unavailable$' "$case_home/simctl-calls.log"; then + return 1 + fi +} + +@test "clean_dev_ai_agents protects the copilot version pointed at by ~/.local/bin/copilot" { + local copilot_root="$HOME/.copilot/pkg/universal" + local bin_dir="$HOME/.local/bin" + rm -rf "$HOME/.copilot" "$HOME/.local/share/claude" "$HOME/.local/share/cursor-agent" "$bin_dir" + mkdir -p "$copilot_root" "$bin_dir" + + mkdir -p "$copilot_root/1.0.5" "$copilot_root/1.0.32" "$copilot_root/1.0.34" + : > "$copilot_root/1.0.32/copilot" + ln -s "../../.copilot/pkg/universal/1.0.32/copilot" "$bin_dir/copilot" + + # Keep the active version older than a pre-downloaded update. The launcher, + # not mtime order, must decide which version remains pinned. + touch -t 202604010000 "$copilot_root/1.0.5" + touch -t 202604200000 "$copilot_root/1.0.32" + touch -t 202604250000 "$copilot_root/1.0.34" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1|$2"; } +clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/1.0.5|GitHub Copilot CLI old version"* ]] || return 1 + [[ "$output" != *"/1.0.32|"* ]] || return 1 + [[ "$output" != *"/1.0.34|"* ]] +} + +@test "developer cleanup stops before later tools after agent inventory cancellation" { + run env HOME="$HOME/developer-aggregate-timeout" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_CURRENT_COMMAND=clean /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +stop_section_spinner() { :; } +for fn in \ + clean_dev_npm clean_dev_python clean_dev_go clean_dev_mise clean_dev_rust \ + check_rust_toolchains clean_dev_ruby clean_dev_perl clean_dev_docker \ + clean_dev_cloud clean_dev_nix clean_dev_shell clean_dev_frontend \ + clean_project_caches clean_dev_mobile clean_dev_jvm \ + clean_dev_jetbrains_toolbox clean_dev_jetbrains_logs; do + eval "$fn() { :; }" +done +clean_dev_ai_agents() { + _mole_record_clean_cancellation 124 + return 124 +} +clean_dev_other_langs() { echo "UNEXPECTED_LATER_DELETE"; } +set +e +clean_developer_tools +rc=$? +set -e +printf 'DEVELOPER_RC:%s CANCEL:%s\n' "$rc" "$MOLE_CLEAN_CANCEL_STATUS" +[[ $rc -eq 124 && $MOLE_CLEAN_CANCEL_STATUS -eq 124 ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"simctl not available"* ]] - [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] - [[ "$output" == *"SAFE_CLEAN:Android SDK cache"* ]] + [[ "$output" == *"DEVELOPER_RC:124 CANCEL:124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_LATER_DELETE"* ]] } diff --git a/Resources/mole/tests/file_ops_mole_delete.bats b/Resources/mole/tests/file_ops_mole_delete.bats new file mode 100644 index 0000000..63a3989 --- /dev/null +++ b/Resources/mole/tests/file_ops_mole_delete.bats @@ -0,0 +1,1404 @@ +#!/usr/bin/env bats + +# Tests for mole_delete in lib/core/file_ops.sh. +# Exercises permanent mode (default), trash mode (via MOLE_TEST_TRASH_DIR +# so Finder is never invoked), dry-run, and the deletions log. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-mole-delete.XXXXXX")" + export SANDBOX + export MOLE_DELETE_LOG="$SANDBOX/deletions.log" + export MOLE_TEST_TRASH_DIR="$SANDBOX/Trash" + export MOLE_TEST_NO_AUTH=1 + unset MOLE_DELETE_MODE + unset MOLE_DRY_RUN +} + +teardown() { + rm -rf "$SANDBOX" +} + +prelude() { + cat < "$victim/keep.txt" + + run /bin/bash --noprofile --norc < /dev/null || true)" ]] +} + +@test "mole_delete trash mode moves the target instead of rm -rf" { + local victim="$SANDBOX/victim_trash" + mkdir -p "$victim" + printf 'payload' > "$victim/data.txt" + + run /bin/bash --noprofile --norc < /dev/null || true)" ]] +} + +@test "mole_delete moves sudo-required paths to invoking user Trash" { + local victim="$SANDBOX/victim_sudo_trash" + local fake_bin="$SANDBOX/bin" + local fake_home="$SANDBOX/home" + local trace="$SANDBOX/trace.log" + + mkdir -p "$fake_bin" "$fake_home" + mkdir -p "$victim" + printf 'payload' > "$victim/data.txt" + + cat > "$fake_bin/trash" <<'SH' +#!/bin/bash +printf 'trash %s\n' "$*" >> "$MOLE_TEST_TRACE" +exit 99 +SH + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + cat > "$fake_bin/osascript" <<'SH' +#!/bin/bash +printf 'osascript %s\n' "$*" >> "$MOLE_TEST_TRACE" +exit 98 +SH + chmod +x "$fake_bin/trash" "$fake_bin/sudo" "$fake_bin/osascript" + + run /bin/bash --noprofile --norc < /dev/null || true)" -eq 1 ]] || return 1 + [[ "$(grep -c '^sudo -n trash ' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ "$(grep -c '^trash ' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ "$(grep -c '^osascript ' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + # The per-item stage lives inside a root-owned 0711 parent, so an + # unprivileged rmdir cannot unlink it and would leak one directory per move. + [[ "$(grep -c "^sudo -n /bin/rmdir $SANDBOX/stage-sudo-trash\$" "$trace" 2> /dev/null || true)" -eq 1 ]] || return 1 + # The shared staging root stays: removing it raced with concurrent runs. + [[ "$(grep -c 'rmdir /Library/MoleTrashStaging' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ ! -d "$SANDBOX/stage-sudo-trash" ]] || return 1 + # -x stops the recursive chown at a mount point nested inside the payload; + # the same-device gate only covers the payload root. + [[ "$(grep -c '^sudo -n /usr/sbin/chown -Rhx ' "$trace" 2> /dev/null || true)" -eq 1 ]] || return 1 + + local status_col + status_col=$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG") + [ "$status_col" = "ok" ] +} + +@test "mole_delete refuses symlinked invoking user Trash for sudo-required paths" { + local victim="$SANDBOX/victim_sudo_symlink_trash" + local fake_bin="$SANDBOX/bin" + local fake_home="$SANDBOX/home" + local redirected="$SANDBOX/redirected" + local trace="$SANDBOX/trace.log" + + mkdir -p "$fake_bin" "$fake_home" "$redirected" "$victim" + printf 'payload' > "$victim/data.txt" + ln -s "$redirected" "$fake_home/.Trash" + + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + chmod +x "$fake_bin/sudo" + + run /bin/bash --noprofile --norc < /dev/null || true)" ]] || return 1 + [[ "$(grep -c '^sudo -n /bin/mv ' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + + local status_col + status_col=$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG") + [ "$status_col" = "trash-failed" ] +} + +@test "mole_delete uses unique Trash name for sudo-required path conflicts" { + local victim="$SANDBOX/conflict_app" + local fake_bin="$SANDBOX/bin" + local fake_home="$SANDBOX/home" + local trace="$SANDBOX/trace.log" + + mkdir -p "$fake_bin" "$fake_home/.Trash" "$victim" + printf 'payload' > "$victim/data.txt" + mkdir -p "$fake_home/.Trash/$(basename "$victim")" + + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + chmod +x "$fake_bin/sudo" + + run /bin/bash --noprofile --norc < /dev/null || true)" -eq 1 ]] || return 1 + + local status_col + status_col=$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG") + [ "$status_col" = "ok" ] +} + +@test "sudo Trash preserves staged payload without privileged rollback after handoff" { + local victim="$SANDBOX/recovery_app" + local fake_home="$SANDBOX/home" + local stage="$SANDBOX/recovery-stage" + local trace="$SANDBOX/recovery-sudo.log" + + mkdir -p "$fake_home" "$victim" + printf 'payload' > "$victim/data.txt" + + run /bin/bash --noprofile --norc <> "$trace" + "\$@" +} +set +e +mole_delete "$victim" true +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=1"* ]] || return 1 + [[ "$output" == *"item preserved for recovery"* ]] || return 1 + [[ ! -e "$victim" ]] || return 1 + [[ -f "$stage/item/data.txt" ]] || return 1 + [[ "$(grep -c "/bin/rm -rf $stage" "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ "$(grep -c "/bin/mv $stage/item $victim" "$trace" 2> /dev/null || true)" -eq 0 ]] +} + +@test "privileged Trash staging never uses world-writable Library Caches" { + run grep -nF '/Library/Caches/.mole-trash' "$PROJECT_ROOT/lib/core/file_ops.sh" + [ "$status" -ne 0 ] + + run grep -nF 'stage_root="/Library/MoleTrashStaging"' "$PROJECT_ROOT/lib/core/file_ops.sh" + [ "$status" -eq 0 ] +} + +@test "Microsoft Word app path uses the direct Trash mover without trash or Finder" { + local fake_home="$SANDBOX/home" + local trace="$SANDBOX/direct-route.log" + mkdir -p "$fake_home" + + run /bin/bash --noprofile --norc <> "$trace" + return 0 +} +trash() { + printf 'trash:%s\n' "\$*" >> "$trace" + return 99 +} +osascript() { + printf 'osascript:%s\n' "\$*" >> "$trace" + return 98 +} +_mole_path_requires_direct_trash "/Applications/Microsoft Word.app" +! _mole_path_requires_direct_trash "/Applications/Utilities/Microsoft Word.app" +! _mole_path_requires_direct_trash "/Applications/Microsoft Word.app/Contents" +_mole_move_to_trash "/Applications/Microsoft Word.app" false +EOF + + [ "$status" -eq 0 ] + grep -qF "direct:/Applications/Microsoft Word.app:false" "$trace" + [[ "$(grep -c '^trash:' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ "$(grep -c '^osascript:' "$trace" 2> /dev/null || true)" -eq 0 ]] +} + +@test "application Trash falls back to Finder after a direct TCC denial" { + local trace="$SANDBOX/app-finder-fallback.log" + + run /bin/bash --noprofile --norc <> "$trace" + return "\$MOLE_ERR_PRIVACY_DENIED" +} +_mole_move_app_to_trash_via_finder() { + printf 'finder:%s\n' "\$1" >> "$trace" + return 0 +} +_mole_move_to_trash "/Applications/Developer.app" false +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$(grep -c '^direct:/Applications/Developer.app:false$' "$trace" 2> /dev/null || true)" -eq 1 ]] || return 1 + [[ "$(grep -c '^finder:/Applications/Developer.app$' "$trace" 2> /dev/null || true)" -eq 1 ]] +} + +@test "Trash mode refuses sudo-required app below mutable Applications" { + local victim="$SANDBOX/RootOwned.app" + local trace="$SANDBOX/finder-app.log" + mkdir -p "$victim" + printf 'payload' > "$victim/data.txt" + + run /bin/bash --noprofile --norc <> "$trace" +} +osascript() { + printf 'FINDER:%s\n' "\$*" >> "$trace" +} +set +e +mole_delete "$victim" true +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +[[ \$rc -eq \$MOLE_ERR_MUTABLE_PARENT ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ -f "$victim/data.txt" ]] || return 1 + [[ "$output" == *"RC=15"* ]] || return 1 + [[ "$(grep -c '^FINDER:' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ "$(grep -c '^DIRECT:' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [ "$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG")" = "mutable-parent" ] +} + +@test "Trash mode preserves mutable-parent classification when the second path probe catches a race" { + local victim="$SANDBOX/RacedRootOwned.app" + local fake_home="$SANDBOX/race-home" + mkdir -p "$victim" "$fake_home" + printf 'payload' > "$victim/data.txt" + + run /bin/bash --noprofile --norc <> "$trace"; return 0; } +safe_sudo_remove() { printf 'SAFE_SUDO_REMOVE\n' >> "$trace"; return 0; } +set +e +mole_delete "$victim" true +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +[[ \$rc -eq \$MOLE_ERR_MUTABLE_PARENT ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ -d "$victim" ]] || return 1 + [[ "$output" == *"RC=15"* ]] || return 1 + [[ ! -s "$trace" ]] || return 1 + [ "$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG")" = "mutable-parent" ] +} + +@test "permanent delete preserves mutable-parent classification when the sink recheck catches a race" { + local victim="$SANDBOX/RacedPermanent.app" + local trace="$SANDBOX/permanent-race.log" + mkdir -p "$victim" + printf 'payload' > "$victim/data.txt" + + run /bin/bash --noprofile --norc <> "$trace"; return 0; } +safe_sudo_remove() { printf 'SAFE_SUDO_REMOVE\n' >> "$trace"; return 0; } +set +e +mole_delete "$victim" true +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +[[ \$rc -eq \$MOLE_ERR_MUTABLE_PARENT ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ -f "$victim/data.txt" ]] || return 1 + [[ "$output" == *"RC=15"* ]] || return 1 + [[ ! -s "$trace" ]] || return 1 + [ "$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG")" = "mutable-parent" ] +} + +@test "permanent symlink delete preserves mutable-parent classification at the sink" { + local target="$SANDBOX/target.app" + local victim="$SANDBOX/RacedPermanentLink.app" + local trace="$SANDBOX/permanent-link-race.log" + mkdir -p "$target" + ln -s "$target" "$victim" + + run /bin/bash --noprofile --norc <> "$trace"; return 0; } +set +e +mole_delete "$victim" true +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +[[ \$rc -eq \$MOLE_ERR_MUTABLE_PARENT ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ -L "$victim" ]] || return 1 + [[ "$output" == *"RC=15"* ]] || return 1 + [[ ! -s "$trace" ]] || return 1 + [ "$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG")" = "mutable-parent" ] +} + +@test "normal app data uses direct Trash with a unique name and mode 0700" { + local fake_home="$SANDBOX/home" + local victim="$fake_home/Library/Containers/com.microsoft.Word" + local existing="$fake_home/.Trash/com.microsoft.Word" + local trace="$SANDBOX/direct-user.log" + mkdir -p "$victim" "$existing" + printf 'document state' > "$victim/state.db" + + run /bin/bash --noprofile --norc <> "$trace" + return 99 +} +osascript() { + printf 'osascript:%s\n' "\$*" >> "$trace" + return 98 +} +mole_delete "$victim" false +EOF + + [ "$status" -eq 0 ] + [[ ! -e "$victim" ]] || return 1 + [[ -d "$existing" ]] || return 1 + [[ -n "$(find "$fake_home/.Trash" -mindepth 1 -maxdepth 1 -name 'com.microsoft.Word.*' -print -quit)" ]] || return 1 + [ "$(stat -f '%Lp' "$fake_home/.Trash")" = "700" ] + [[ "$(grep -c '^trash:' "$trace" 2> /dev/null || true)" -eq 0 ]] || return 1 + [[ "$(grep -c '^osascript:' "$trace" 2> /dev/null || true)" -eq 0 ]] +} + +@test "normal direct Trash refuses a symlinked invoking user Trash" { + local fake_home="$SANDBOX/home" + local victim="$fake_home/Library/Application Scripts/com.microsoft.Word" + local redirected="$SANDBOX/redirected" + mkdir -p "$victim" "$redirected" + ln -s "$redirected" "$fake_home/.Trash" + + run /bin/bash --noprofile --norc < /dev/null || true)" ]] +} + +@test "direct Trash reports TCC denial and never falls back to permanent delete" { + local fake_home="$SANDBOX/home" + local victim="$fake_home/Library/Containers/com.microsoft.Word" + local trace="$SANDBOX/privacy-denied.log" + mkdir -p "$victim" + printf 'document state' > "$victim/state.db" + + run /bin/bash --noprofile --norc <&2 + return 1 +} +trash() { + printf 'trash\n' >> "$trace" + return 99 +} +osascript() { + printf 'osascript\n' >> "$trace" + return 98 +} +safe_remove() { + printf 'safe_remove\n' >> "$trace" + return 97 +} +set +e +mole_delete "$victim" false +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +[[ \$rc -eq \$MOLE_ERR_PRIVACY_DENIED ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ -d "$victim" ]] || return 1 + [[ "$output" == *"App Management, App Data, or Full Disk Access"* ]] || return 1 + [[ "$output" != *"Touch ID"* ]] || return 1 + [[ "$output" == *"RC=14"* ]] || return 1 + [[ ! -s "$trace" ]] || return 1 + [ "$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG")" = "privacy-denied" ] +} + +@test "direct Trash recognizes lowercase permission denied" { + local fake_home="$SANDBOX/home" + local victim="$fake_home/Library/Group Containers/UBF8T346G9.Office" + mkdir -p "$victim" + + run /bin/bash --noprofile --norc <&2 + return 1 +} +set +e +_mole_move_path_to_user_trash "$victim" false +rc=\$? +set -e +printf 'RC=%s\n' "\$rc" +[[ \$rc -eq \$MOLE_ERR_PRIVACY_DENIED ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ -d "$victim" ]] || return 1 + [[ "$output" == *"RC=14"* ]] +} + +@test "direct Trash generic failure stays closed" { + local fake_home="$SANDBOX/home" + local victim="$fake_home/Library/Application Scripts/com.microsoft.Word" + local trace="$SANDBOX/generic-failure.log" + mkdir -p "$victim" + + run /bin/bash --noprofile --norc <&2 + return 1 +} +trash() { + printf 'trash\n' >> "$trace" + return 99 +} +osascript() { + printf 'osascript\n' >> "$trace" + return 98 +} +safe_remove() { + printf 'safe_remove\n' >> "$trace" + return 97 +} +set +e +mole_delete "$victim" false +rc=\$? +set -e +[[ \$rc -eq 1 ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ -d "$victim" ]] || return 1 + [[ ! -s "$trace" ]] || return 1 + [[ "$output" == *"refusing permanent delete"* ]] +} + +@test "privacy denial diagnosis recommends terminal privacy access, not Touch ID" { + run /bin/bash --noprofile --norc < "$fake_bin/check_touchid_support" <<'SH' +#!/bin/bash +printf 'called\n' >> "$MOLE_TEST_TRACE" +exit 0 +SH + chmod +x "$fake_bin/check_touchid_support" + + run /bin/bash --noprofile --norc < "$victim" + + run /bin/bash --noprofile --norc < "$victim" + + run /bin/bash --noprofile --norc < "$victim" + + run /bin/bash --noprofile --norc < "$first" + : > "$second" + + run /bin/bash --noprofile --norc < "$victim" + + # Pointing MOLE_TEST_TRASH_DIR at a non-writable parent forces the stub + # trash move to fail, exercising the fallback path. + local blocked="$SANDBOX/blocked/Trash" + mkdir -p "$(dirname "$blocked")" + chmod 0555 "$(dirname "$blocked")" + + run /bin/bash --noprofile --norc < "$first" + : > "$second" + + local blocked="$SANDBOX/blocked/Trash" + mkdir -p "$(dirname "$blocked")" + chmod 0555 "$(dirname "$blocked")" + + run /bin/bash --noprofile --norc < "$victim" + + run /bin/bash --noprofile --norc < "$victim/data" + + run /bin/bash --noprofile --norc < "$victim/data" + +rc=0 +safe_remove "$victim" true 1 "" \ + "\$expected_parent" "\$expected_parent_id" "\$expected_target_id" || rc=\$? +[[ \$rc -ne 0 ]] || exit 1 +[[ -f "$victim/data" && -f "$parent.original/victim/data" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "safe_remove records a clean interruption and blocks later deletion sinks" { + local first="$SANDBOX/interrupted-clean-delete" + local second="$SANDBOX/later-clean-delete" + mkdir -p "$first" "$second" + + run /bin/bash --noprofile --norc < "$victim/data" + + run /bin/bash --noprofile --norc < "\$1/data" +} +safe_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "\$1" + return 0 +} + +rc=0 +mole_delete "$victim" false "\$expected_identity" || rc=\$? +[[ \$rc -ne 0 ]] || exit 1 +[[ -f "$victim/data" && -f "$victim.original/data" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "Finder fallback refuses an app replaced after direct Trash denial" { + local victim="$SANDBOX/Raced.app" + local trace="$SANDBOX/raced-finder.log" + mkdir -p "$victim" + printf 'original\n' > "$victim/data" + + run /bin/bash --noprofile --norc < "\$1/data" + return "\$MOLE_ERR_PRIVACY_DENIED" +} +osascript() { + printf 'UNEXPECTED_FINDER:%s\n' "\$*" >> "$trace" + return 0 +} + +rc=0 +mole_delete "$victim" false "\$expected_identity" || rc=\$? +[[ \$rc -ne 0 ]] || exit 1 +[[ -f "$victim/data" && -f "$victim.original/data" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ ! -e "$trace" ]] +} + +@test "mole_delete dry-run propagates an interrupted preview registration" { + local victim="$SANDBOX/interrupted-preview" + mkdir -p "$victim" + + run /bin/bash --noprofile --norc < "$victim" + local broken_log_dir="$SANDBOX/no_write/logs" + mkdir -p "$(dirname "$broken_log_dir")" + chmod 0555 "$(dirname "$broken_log_dir")" + + run /bin/bash --noprofile --norc < "$SANDBOX/second_victim" +mole_delete "$SANDBOX/second_victim" +EOF + + chmod 0755 "$(dirname "$broken_log_dir")" + + [ "$status" -eq 0 ] + # Warning visible exactly once. + local warn_count + warn_count=$(printf '%s\n' "$output" | grep -c "deletions audit log unavailable" || true) + [ "$warn_count" = "1" ] +} + +@test "get_path_size_kb bounds a hung du instead of wedging the sizing worker" { + # Regression: the primary sizing du (file_ops.sh get_path_size_kb) ran + # WITHOUT run_with_timeout while every sibling call site was bounded, so + # one stalled SMB/FUSE mount wedged a parallel scan worker forever. The + # stub du sleeps far past the 1s override; a bounded helper returns the + # timeout status quickly, while an unbounded one trips the bats timeout. + local stub_dir="$SANDBOX/stub-bin" + mkdir -p "$stub_dir" + cat > "$stub_dir/du" <<'STUB' +#!/bin/bash +sleep 30 +echo "999999 /" +STUB + chmod +x "$stub_dir/du" + + local victim_dir="$SANDBOX/big-dir" + mkdir -p "$victim_dir" + + local started elapsed + started=$SECONDS + run /bin/bash --noprofile --norc < "$victim/data.txt" + + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + chmod +x "$fake_bin/sudo" + + run /bin/bash --noprofile --norc < /dev/null || true)" -eq 1 ]] || return 1 + [[ "$(grep -c -- 'du -skP' "$trace" 2> /dev/null || true)" -eq 0 ]] +} + +# The stage-root preparation is the part with a concurrency contract, and the +# Trash tests above mock the whole stage creator, so it needs its own coverage: +# two Mole processes can both observe a missing root, and a plain mkdir would +# make the loser abort a Trash move that was perfectly safe. +@test "privileged Trash stage root tolerates a concurrent creator" { + local stage_root="$SANDBOX/stage-root" + local trace="$SANDBOX/root-prep.log" + local fake_bin="$SANDBOX/bin" + + mkdir -p "$fake_bin" + # `mkdir` without -p loses the race: another process created the root between + # this process's check and its own mkdir, which is the EEXIST the fix + # absorbs. chown/chmod cannot really run unprivileged, so they report + # success; reaching them at all is the evidence that mkdir did not abort. + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +case "$1" in + */mkdir) + shift + if [[ "${1:-}" == "-p" ]]; then + shift + mkdir -p "$@" + exit $? + fi + mkdir "$@" 2> /dev/null + exit $? + ;; + */chown | */chmod) exit 0 ;; +esac +"$@" +SH + chmod +x "$fake_bin/sudo" + # The concurrent winner already created it. + mkdir -p "$stage_root" + : > "$trace" + + run /bin/bash --noprofile --norc < /dev/null || true)" -eq 1 ]] || return 1 + [[ "$(grep -c '^sudo -n /usr/sbin/chown 0:0 ' "$trace" 2> /dev/null || true)" -eq 1 ]] || return 1 + [[ "$(grep -c '^sudo -n /bin/chmod 711 ' "$trace" 2> /dev/null || true)" -eq 1 ]] || return 1 + # The sandbox root is owned by the test user, so the verification gate must + # still refuse it: tolerating EEXIST must not weaken the ownership check. + [ "$status" -ne 0 ] || return 1 + [[ -d "$stage_root" ]] +} + +@test "privileged Trash stage root refuses a symlinked root" { + local real_dir="$SANDBOX/elsewhere" + local stage_root="$SANDBOX/stage-root-link" + local trace="$SANDBOX/root-link.log" + + mkdir -p "$real_dir" + ln -s "$real_dir" "$stage_root" + : > "$trace" + + run /bin/bash --noprofile --norc <> "$trace"; return 0; } +_mole_prepare_privileged_trash_stage_root "$stage_root" +EOF + + [ "$status" -ne 0 ] || return 1 + # Nothing privileged may run against a symlinked root. + [[ ! -s "$trace" ]] +} diff --git a/Resources/mole/tests/file_ops_safe_remove_symlink.bats b/Resources/mole/tests/file_ops_safe_remove_symlink.bats new file mode 100644 index 0000000..1357f4b --- /dev/null +++ b/Resources/mole/tests/file_ops_safe_remove_symlink.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats + +# Tests for safe_remove_symlink in lib/core/file_ops.sh. +# The helper removes a symlink itself (never its target), refuses anything +# that is not a symlink, runs the deletion validator, and honours dry-run. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-symlink.XXXXXX")" + export SANDBOX + export MOLE_DELETE_LOG="$SANDBOX/deletions.log" + export MOLE_TEST_NO_AUTH=1 + unset MOLE_DRY_RUN +} + +teardown() { + rm -rf "$SANDBOX" +} + +prelude() { + cat < "$target/data.txt" + ln -s "$target" "$link" + + run /bin/bash --noprofile --norc < "$victim" + + run /bin/bash --noprofile --norc < "$SANDBOX/empty" + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/empty" +EOF + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "get_path_size_kb rounds up sub-KB files to 1 KB" { + # 500 bytes is < 1 KB; ceiling rounding should report 1. + dd if=/dev/zero of="$SANDBOX/small" bs=500 count=1 2> /dev/null + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/small" +EOF + [ "$status" -eq 0 ] + [ "$output" = "1" ] +} + +@test "get_path_size_kb reports exact 1 KB for 1024-byte file" { + dd if=/dev/zero of="$SANDBOX/onek" bs=1024 count=1 2> /dev/null + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/onek" +EOF + [ "$status" -eq 0 ] + [ "$output" = "1" ] +} + +@test "get_path_size_kb rounds up odd byte counts" { + # 50000 bytes / 1024 = 48.83..., ceiling is 49. + dd if=/dev/zero of="$SANDBOX/odd" bs=50000 count=1 2> /dev/null + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/odd" +EOF + [ "$status" -eq 0 ] + [ "$output" = "49" ] +} + +@test "get_path_size_kb does not follow symlinks" { + # 100 KB target, symlink should report its own (tiny) size, not 100 KB. + dd if=/dev/zero of="$SANDBOX/target" bs=1024 count=100 2> /dev/null + ln -s "$SANDBOX/target" "$SANDBOX/link" + + target_kb=$(/bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/target" +EOF +) + link_kb=$(/bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/link" +EOF +) + + [ "$target_kb" = "100" ] + # Symlink path strings are short, so link size rounds to 1 KB or 0. + # Either is acceptable; what must NOT happen is the link reporting the + # 100 KB target size. + [ "$link_kb" -lt 10 ] +} + +@test "get_path_size_kb still returns 0 for broken symlinks" { + ln -s "$SANDBOX/missing" "$SANDBOX/broken" + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/broken" +EOF + [ "$status" -eq 0 ] + # -e on a broken symlink returns false, so the early return triggers. + [ "$output" = "0" ] +} + +@test "get_path_size_kb sums directory contents recursively" { + mkdir -p "$SANDBOX/dir/sub" + dd if=/dev/zero of="$SANDBOX/dir/a" bs=1024 count=10 2> /dev/null + dd if=/dev/zero of="$SANDBOX/dir/sub/b" bs=1024 count=20 2> /dev/null + + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/dir" +EOF + [ "$status" -eq 0 ] + # Should be at least the sum of the two files (30 KB). Filesystem + # overhead may push it slightly higher, so use >= rather than ==. + [ "$output" -ge 30 ] +} + +@test "get_path_size_kb handles whitespace in paths" { + local quirky="$SANDBOX/dir with spaces" + mkdir -p "$quirky" + dd if=/dev/zero of="$quirky/payload" bs=1024 count=5 2> /dev/null + + run /bin/bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$quirky/payload" +EOF + [ "$status" -eq 0 ] + [ "$output" = "5" ] +} + +@test "get_path_size_kb propagates metadata stat and du timeouts" { + mkdir -p "$SANDBOX/Stalled.app" "$SANDBOX/stalled-dir" + printf 'x\n' > "$SANDBOX/stalled-file" + + run env PROJECT_ROOT="$PROJECT_ROOT" SANDBOX="$SANDBOX" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +run_with_timeout() { return 124; } + +for target in "$SANDBOX/Stalled.app" "$SANDBOX/stalled-file" "$SANDBOX/stalled-dir"; do + size="" + rc=0 + size=$(get_path_size_kb "$target") || rc=$? + printf 'RC=%s SIZE=%s TARGET=%s\n' "$rc" "$size" "${target##*/}" + [[ $rc -eq 124 && -z "$size" ]] || exit 1 +done +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=124 SIZE= TARGET=Stalled.app"* ]] || return 1 + [[ "$output" == *"RC=124 SIZE= TARGET=stalled-file"* ]] || return 1 + [[ "$output" == *"RC=124 SIZE= TARGET=stalled-dir"* ]] +} diff --git a/Resources/mole/tests/format_on_edit_hook.bats b/Resources/mole/tests/format_on_edit_hook.bats new file mode 100644 index 0000000..d5ad881 --- /dev/null +++ b/Resources/mole/tests/format_on_edit_hook.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats + +setup() { + PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + HOOK="$PROJECT_ROOT/.claude/hooks/format-on-edit.sh" + TEST_REPO="$(cd -P "$BATS_TEST_TMPDIR" && pwd)/repo" + STUB_BIN="$BATS_TEST_TMPDIR/bin" + FORMAT_LOG="$BATS_TEST_TMPDIR/format.log" + + mkdir -p "$TEST_REPO/cmd/demo" "$TEST_REPO/scripts" "$STUB_BIN" + git init -q "$TEST_REPO" + printf 'package demo\n' > "$TEST_REPO/cmd/demo/main.go" + printf '#!/bin/bash\n' > "$TEST_REPO/scripts/demo.sh" + + cat > "$STUB_BIN/goimports" <<'SH' +#!/bin/bash +printf 'goimports:%s\n' "$*" >> "$FORMAT_LOG" +SH + cat > "$STUB_BIN/shfmt" <<'SH' +#!/bin/bash +printf 'shfmt:%s\n' "$*" >> "$FORMAT_LOG" +SH + chmod +x "$STUB_BIN/goimports" "$STUB_BIN/shfmt" +} + +run_hook() { + local payload="$1" + # shellcheck disable=SC2016 # the inner bash receives payload and hook as argv + run env PATH="$STUB_BIN:$PATH" FORMAT_LOG="$FORMAT_LOG" \ + /bin/bash -c 'printf "%s\n" "$1" | /bin/bash "$2"' _ "$payload" "$HOOK" +} + +@test "Claude file_path payload formats one repository file" { + payload=$(jq -nc \ + --arg cwd "$TEST_REPO" \ + --arg file "$TEST_REPO/cmd/demo/main.go" \ + '{cwd: $cwd, tool_input: {file_path: $file}}') + + run_hook "$payload" + + [ "$status" -eq 0 ] + [ "$(cat "$FORMAT_LOG")" = "goimports:-w -local github.com/tw93/mole $TEST_REPO/cmd/demo/main.go" ] +} + +@test "hook refuses files and symlink targets outside the repository" { + outside="$BATS_TEST_TMPDIR/outside.go" + printf 'package outside\n' > "$outside" + ln -s "$outside" "$TEST_REPO/cmd/demo/link.go" + + payload=$(jq -nc \ + --arg cwd "$TEST_REPO" \ + --arg file "$outside" \ + '{cwd: $cwd, tool_input: {file_path: $file}}') + run_hook "$payload" + [ "$status" -eq 0 ] + + payload=$(jq -nc \ + --arg cwd "$TEST_REPO" \ + --arg file "$TEST_REPO/cmd/demo/link.go" \ + '{cwd: $cwd, tool_input: {file_path: $file}}') + run_hook "$payload" + [ "$status" -eq 0 ] + [ ! -e "$FORMAT_LOG" ] +} + +@test "Codex project skills are symlinks to the canonical Claude skills" { + local skill + local skill_link + + for skill in mole release-flow release-notes; do + skill_link="$PROJECT_ROOT/.agents/skills/$skill" + [ -L "$skill_link" ] + [ "$(readlink "$skill_link")" = "../../.claude/skills/$skill" ] + [ -f "$skill_link/SKILL.md" ] + done + + skill_link="$PROJECT_ROOT/.agents/skills/release-notes" + [ -x "$skill_link/scripts/post-reactions.sh" ] + grep -q '^policy:$' "$skill_link/agents/openai.yaml" + grep -q '^ allow_implicit_invocation: false$' "$skill_link/agents/openai.yaml" +} diff --git a/Resources/mole/tests/fuzz_corpus/dangerous_paths.txt b/Resources/mole/tests/fuzz_corpus/dangerous_paths.txt new file mode 100644 index 0000000..c8f70db --- /dev/null +++ b/Resources/mole/tests/fuzz_corpus/dangerous_paths.txt @@ -0,0 +1,129 @@ +# Adversarial path corpus for validate_path_for_deletion. +# Every path in this file MUST be rejected (validate returns non-zero). +# Lines starting with # or empty are ignored. No quoting. +# When adding entries: prefer real-world attacks over synthetic ones. +# +# Categories (kept grouped for review, not parsed): + +# ---- empty / not absolute ---- +relative/path +./foo +foo/bar +~/Documents +just-a-name + +# ---- root and top-level system dirs ---- +/ +/bin +/dev +/dev/null +/sbin +/usr +/usr/bin +/usr/sbin +/usr/lib +/System +/Library/Extensions +/private +/etc +/private/etc +/var +/var/db +/private/var +/private/var/db +/SYSTEM +/DEV +/PRIVATE/TMP +/PRIVATE/VAR/FOLDERS +/USERS + +# ---- shallow system files ---- +/bin/bash +/bin/sh +/sbin/init +/usr/bin/ls +/usr/lib/dyld +/System/Library/CoreServices/Finder.app +/etc/passwd +/etc/sudoers +/private/etc/passwd +/var/db/dslocal +/private/var/db/dslocal/nodes/Default/users/root.plist + +# ---- path traversal variants ---- +/../etc/passwd +/Users/../etc/passwd +/Users/me/../../etc +/private/var/db/../../../etc/passwd +/var/../etc/passwd +/Users/me/Documents/../../../etc +/../../../../../etc + +# ---- control character injection ---- +/Users/me/foo bar + +# ---- protected system caches ---- +/System/Library/Caches +/System/Library/Caches/com.apple.kext.caches +/Library/Apple +/Library/Apple/System +/System/Library +/System/Volumes/Data/System +/System/Library/PrivateFrameworks + +# ---- protected app/data namespaces ---- +/Applications/Safari.app +/Applications/Finder.app +/System/Applications/Mail.app +/System/Applications/Settings.app +/System/Applications/Utilities/Keychain Access.app +/Library/Keychains +/Library/Keychains/login.keychain-db + +# ---- /var/db sensitive subtrees ---- +/var/db/sudo +/var/db/SystemPolicy +/var/db/locationd +/var/db/uuidtext +/private/var/db/sudo +/private/var/db/SystemPolicy + +# ---- attempts to escape with weird separators ---- +//etc/passwd +///System +/etc/./passwd +/etc/. +/etc/.. +/System/. + +# ---- attempts to look like /private aliases ---- +/private/etc/sudoers +/private/etc/master.passwd +/private/var/audit +/private/var/folders/.. + +# ---- realistic user-facing oopses ---- +/Users +/Users/Shared +/Users/Guest + +# ---- top-level writable roots (empty-variable collapse: "$dir/$name" with +# ---- an empty $name lands on the bare root). Children stay deletable. ---- +/Applications +/Library +/Library/Application Support +/Volumes +/opt +/opt/homebrew +/usr/local +/usr/share +/usr/libexec +/var/root +/private/var/root +/Users/me +/Users/tw93 + +# ---- realistic mistakes that should still fail (no absolute prefix) ---- +Library/Caches/com.apple.spotlight +.Trash +.cache/foo diff --git a/Resources/mole/tests/health_json.bats b/Resources/mole/tests/health_json.bats new file mode 100644 index 0000000..e3be535 --- /dev/null +++ b/Resources/mole/tests/health_json.bats @@ -0,0 +1,41 @@ +#!/usr/bin/env bats +# get_memory_info must scale vm_stat page counts by vm_stat's own page size +# (16384 on Apple Silicon), not a hardcoded 4096, or memory_used_gb in the +# status JSON reads 4x low on Apple Silicon. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "get_memory_info uses vm_stat's declared page size" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" 2> /dev/null || true +source "$PROJECT_ROOT/lib/check/health_json.sh" + +# 1,000,000 pages active+wired+compressed at 16384 bytes each = ~15.26 GiB. +sysctl() { + case "$*" in + *hw.memsize*) echo $((64 * 1024 * 1024 * 1024)) ;; + *hw.pagesize*) echo 16384 ;; + *) command sysctl "$@" ;; + esac +} +vm_stat() { + cat <<'VMSTAT' +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages active: 500000. +Pages wired down: 300000. +Pages occupied by compressor: 200000. +VMSTAT +} + +get_memory_info +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + # used = (500000+300000+200000)*16384 bytes = 15.26 GiB, not 3.8 (4096). + used="${output%% *}" + awk -v u="$used" 'BEGIN { exit !(u > 14 && u < 17) }' || { echo "used_gb=$used (expected ~15.3, 4096 would give ~3.8)"; return 1; } +} diff --git a/Resources/mole/tests/history.bats b/Resources/mole/tests/history.bats new file mode 100644 index 0000000..55ac992 --- /dev/null +++ b/Resources/mole/tests/history.bats @@ -0,0 +1,239 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-history-home.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + rm -rf "$HOME/Library" + mkdir -p "$HOME/Library/Logs/mole" +} + +write_history_logs() { + cat > "$HOME/Library/Logs/mole/operations.log" <<'EOF' +# ========== clean session started at 2026-05-24 10:00:00 ========== +[2026-05-24 10:00:01] [clean] REMOVED /tmp/cache one (2KB) +[2026-05-24 10:00:02] [clean] TRASHED /tmp/Old App.app (4KB) +[2026-05-24 10:00:03] [clean] SKIPPED /tmp/protected (whitelist) +[2026-05-24 10:00:04] [clean] FAILED /tmp/fail (permission denied) +# ========== clean session ended at 2026-05-24 10:00:05, 2 items, 6KB ========== +# ========== purge session started at 2026-05-24 11:00:00 ========== +[2026-05-24 11:00:01] [purge] REMOVED /tmp/build (10KB) +# ========== purge session ended at 2026-05-24 11:00:02, 1 items, 10KB ========== +EOF + + printf '2026-05-24T10:00:02+0000\ttrash\t4\tok\t/tmp/Old App.app\n' > "$HOME/Library/Logs/mole/deletions.log" + printf '2026-05-24T11:00:01+0000\tpermanent\t10\tdry-run\t/tmp/build\n' >> "$HOME/Library/Logs/mole/deletions.log" +} + +@test "mo history summarizes operation sessions and deletion audit" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"Mole History"* ]] || return 1 + [[ "$output" == *"purge"* ]] || return 1 + [[ "$output" == *"1 items, 10KB"* ]] || return 1 + [[ "$output" == *"clean"* ]] || return 1 + [[ "$output" == *"removed 1, trashed 1, skipped 1, failed 1"* ]] || return 1 + [[ "$output" == *"/tmp/Old App.app"* ]] +} + +@test "mo history --json returns stable parseable fields" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --json + [ "$status" -eq 0 ] + + printf '%s\n' "$output" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +assert data["limit"] == 20 +assert data["sessions"][0]["command"] == "purge" +assert data["sessions"][1]["command"] == "clean" +assert data["sessions"][1]["actions"]["trashed"] == 1 +assert data["sessions"][1]["actions"]["failed"] == 1 +assert data["deletions"][0]["mode"] == "permanent" +assert data["deletions"][0]["size_kb"] == 10 +assert data["deletions"][1]["path"] == "/tmp/Old App.app" +' +} + +@test "mo history preserves failed optimize task counts" { + cat > "$HOME/Library/Logs/mole/operations.log" <<'EOF' +# ========== optimize session started at 2026-05-24 12:00:00 ========== +[2026-05-24 12:00:01] [optimize] TASK_FAILED disk_verify (task outcome) +[2026-05-24 12:00:02] [optimize] TASK_FAILED periodic_maintenance (task outcome) +# ========== optimize session ended at 2026-05-24 12:00:05, 3 items, 0B ========== +EOF + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"2 optimize tasks failed"* ]] || return 1 + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --json + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + printf '%s\n' "$output" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +assert data["sessions"][0]["command"] == "optimize" +assert data["sessions"][0]["items"] == 3 +assert data["sessions"][0]["failed_tasks"] == 2 +' +} + +@test "operation logging writes the canonical failed task action" { + local log_file="$HOME/Library/Logs/mole/task-outcome.log" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" OPERATIONS_LOG_FILE="$log_file" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +log_operation optimize TASK_FAILED disk_verify "task outcome" +cat "$OPERATIONS_LOG_FILE" +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"[optimize] TASK_FAILED disk_verify (task outcome)"* ]] || return 1 +} + +@test "mo history --json escapes unusual path characters" { + : > "$HOME/Library/Logs/mole/operations.log" + weird_path=$'/tmp/unicode-\xe9\x9b\xaa-quote"slash\\tab\tbackspace\bformfeed\fend' + printf '2026-05-24T10:00:02+0000\ttrash\t4\tok\t%s\n' "$weird_path" > "$HOME/Library/Logs/mole/deletions.log" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --json + [ "$status" -eq 0 ] + + printf '%s\n' "$output" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +assert data["deletions"][0]["path"] == "/tmp/unicode-\u96ea-quote\"slash\\tab\tbackspace\bformfeed\fend" +' +} + +@test "mo history --limit caps sessions and deletion entries" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 1 + [ "$status" -eq 0 ] + [[ "$output" == *"purge"* ]] || return 1 + [[ "$output" != *"clean 2026-05-24 10:00:00"* ]] || return 1 + [[ "$output" == *"/tmp/build"* ]] || return 1 + [[ "$output" != *"/tmp/Old App.app"* ]] +} + +@test "mo history --limit accepts decimal values with leading zeros" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 0001 + [ "$status" -eq 0 ] + [[ "$output" == *"purge"* ]] || return 1 + [[ "$output" != *"clean 2026-05-24 10:00:00"* ]] || return 1 + [[ "$output" != *"value too great for base"* ]] +} + +@test "mo history handles empty logs" { + : > "$HOME/Library/Logs/mole/operations.log" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"No operation history yet"* ]] || return 1 + [[ "$output" == *"No deletion audit entries yet"* ]] +} + +@test "mo history tolerates malformed session summaries" { + cat > "$HOME/Library/Logs/mole/operations.log" <<'EOF' +# ========== clean session started at 2026-05-24 10:00:00 ========== +[2026-05-24 10:00:01] [clean] REMOVED /tmp/cache (2KB) +# ========== clean session ended at malformed summary ========== +EOF + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"clean 2026-05-24 10:00:00, 0 items, 0B"* ]] || return 1 + [[ "$output" == *"removed 1, ended malformed summary"* ]] || return 1 + [[ "$output" != *"malformed summary items"* ]] +} + +@test "mo history does not create logs when none exist" { + rm -rf "$HOME/Library" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"No operation history yet"* ]] || return 1 + [ ! -e "$HOME/Library/Logs/mole/operations.log" ] + [ ! -e "$HOME/Library/Logs/mole/mole.log" ] +} + +@test "mo history early dispatch respects source guard" { + # shellcheck disable=SC2016 + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc -c ' +set -euo pipefail +set -- history +MOLE_TEST_MODE=1 +MOLE_SKIP_MAIN=1 +source "$PROJECT_ROOT/mole" +echo sourced +' + [ "$status" -eq 0 ] + [[ "$output" == *"sourced"* ]] || return 1 + [[ "$output" != *"Mole History"* ]] +} + +@test "mo history early dispatch keeps global debug flag behavior" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" --debug history --limit 0001 + [ "$status" -eq 0 ] + [[ "$output" == *"Mole History"* ]] || return 1 + [[ "$output" != *"Unknown option"* ]] || return 1 + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --debug --limit 0001 + [ "$status" -eq 0 ] + [[ "$output" == *"Mole History"* ]] || return 1 + [[ "$output" != *"Unknown option"* ]] +} + +@test "mo history rejects unknown options" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --bad-option + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown option for mo history"* ]] +} + +@test "mo history rejects invalid limit values" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit nope + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid value for --limit"* ]] || return 1 + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 500 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid value for --limit"* ]] || return 1 + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 999999999999999999999999 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid value for --limit"* ]] || return 1 + [[ "$output" != *"value too great for base"* ]] +} diff --git a/Resources/mole/tests/install_checksum.bats b/Resources/mole/tests/install_checksum.bats new file mode 100644 index 0000000..216402a --- /dev/null +++ b/Resources/mole/tests/install_checksum.bats @@ -0,0 +1,1268 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-install-checksum-home.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + rm -rf "${HOME:?}"/* + mkdir -p "$HOME/source" "$HOME/config/bin" "$HOME/install" + cat > "$HOME/source/mole" << 'MOLE' +VERSION="1.2.3" +MOLE +} + +load_installer_binary_helpers() { + eval "$(sed -n '/^curl_download_with_retry()/,/^}/p' "$PROJECT_ROOT/install.sh")" + eval "$(sed -n '/^get_source_version()/,/^install_files()/p' "$PROJECT_ROOT/install.sh" | sed '$d')" +} +export -f load_installer_binary_helpers + +@test "download_binary installs release asset only after checksum verification" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +# Exercise the checksum-only path deterministically: a real authenticated gh on +# the host would otherwise run `attestation verify` against the fake fixture and +# fail. Attestation policy itself is covered by its own test below. +verify_release_attestation() { return 2; } + +content="verified-binary" +asset="analyze-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf '%s' "$content" | shasum -a 256 | awk '{print $1}') + +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf '%s' "$content" > "$out" ;; + *"SHA256SUMS") printf '%s %s\n' "$hash" "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +download_binary "analyze" +grep -q "verified-binary" "$CONFIG_DIR/bin/analyze-go" +test -x "$CONFIG_DIR/bin/analyze-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS:Installed analyze"* ]] +} + +@test "download_binary retries transient asset and checksum failures" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +verify_release_attestation() { return 2; } +sleep() { :; } + +content="retried-binary" +asset="analyze-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf '%s' "$content" | shasum -a 256 | awk '{print $1}') +asset_attempts="$HOME/asset.attempts" +checksum_attempts="$HOME/checksum.attempts" + +curl() { + local out="" url="" counter="" attempt=0 + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + + case "$url" in + *"SHA256SUMS") counter="$checksum_attempts" ;; + *"${asset}") counter="$asset_attempts" ;; + *) return 22 ;; + esac + [[ -f "$counter" ]] && attempt=$(cat "$counter") + attempt=$((attempt + 1)) + printf '%s\n' "$attempt" > "$counter" + if [[ "$attempt" -lt 3 ]]; then + return 35 + fi + + case "$url" in + *"SHA256SUMS") printf '%s %s\n' "$hash" "$asset" > "$out" ;; + *"${asset}") printf '%s' "$content" > "$out" ;; + esac +} + +download_binary "analyze" +[ "$(cat "$asset_attempts")" -eq 3 ] || exit 1 +[ "$(cat "$checksum_attempts")" -eq 3 ] || exit 1 +grep -qx "$content" "$CONFIG_DIR/bin/analyze-go" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"SUCCESS:Installed analyze"* ]] +} + +@test "download_binary aborts on checksum mismatch without downgrading to a source build" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +# Keep the checksum path deterministic and offline: an authenticated gh on the +# host would run `attestation verify` against the fake fixture over the network. +# Attestation policy has its own test below. +verify_release_attestation() { return 2; } +# A tampered asset must NEVER reroute onto an unverified source build. +build_binary_from_source() { + echo "SOURCE_BUILD_INVOKED" + printf 'built-from-source' > "$2" + chmod +x "$2" + return 0 +} +get_latest_release_tag() { echo "V1.2.3"; } + +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf 'tampered-binary' > "$out" ;; + *"SHA256SUMS") printf '%064d %s\n' 0 "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +if download_binary "status"; then + echo "UNEXPECTED_SUCCESS" + exit 1 +fi +# No unverified artifact left behind under the installed name. +if [[ -e "$CONFIG_DIR/bin/status-go" ]]; then + grep -q "tampered-binary" "$CONFIG_DIR/bin/status-go" && echo "TAMPERED_INSTALLED" + grep -q "built-from-source" "$CONFIG_DIR/bin/status-go" && echo "SOURCE_INSTALLED" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"SOURCE_BUILD_INVOKED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUCCESS"* ]] || return 1 + [[ "$output" != *"TAMPERED_INSTALLED"* ]] || return 1 + [[ "$output" != *"SOURCE_INSTALLED"* ]] || return 1 + [[ "$output" == *"aborting instead of falling back"* ]] +} + +@test "download_binary preserves the installed helper when verification and rebuild fail (#1193)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +verify_release_asset_checksum() { return 1; } +get_latest_release_tag() { echo "V1.2.3"; } +build_binary_from_source() { return 1; } +curl() { + local out="" + while [[ $# -gt 0 ]]; do + if [[ "$1" == "-o" ]]; then + out="$2" + shift 2 + else + shift + fi + done + printf 'unverified-new-binary' > "$out" +} + +printf 'known-good-old-binary' > "$CONFIG_DIR/bin/analyze-go" +chmod +x "$CONFIG_DIR/bin/analyze-go" + +if download_binary "analyze"; then + echo "UNEXPECTED_SUCCESS" + exit 1 +fi + +grep -qx 'known-good-old-binary' "$CONFIG_DIR/bin/analyze-go" +if find "$CONFIG_DIR/bin" -maxdepth 1 -name '.analyze-go.*' -print -quit | grep -q .; then + echo "STAGING_FILE_LEAKED" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_SUCCESS"* ]] || return 1 + [[ "$output" != *"STAGING_FILE_LEAKED"* ]] +} + +@test "download_binary aborts when SHA256SUMS has no matching asset entry" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +# Keep the checksum path deterministic and offline: an authenticated gh on the +# host would run `attestation verify` against the fake fixture over the network. +# Attestation policy has its own test below. +verify_release_attestation() { return 2; } +build_binary_from_source() { + echo "SOURCE_BUILD_INVOKED" + printf 'rebuilt-after-missing-checksum' > "$2" + chmod +x "$2" + return 0 +} +get_latest_release_tag() { echo "V1.2.3"; } + +asset="analyze-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf 'release-binary' | shasum -a 256 | awk '{print $1}') +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf 'release-binary' > "$out" ;; + *"SHA256SUMS") printf '%s other-asset\n' "$hash" > "$out" ;; + *) return 1 ;; + esac +} + +if download_binary "analyze"; then + echo "UNEXPECTED_SUCCESS" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"SOURCE_BUILD_INVOKED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUCCESS"* ]] || return 1 + [[ "$output" == *"aborting instead of falling back"* ]] +} + +@test "download_binary aborts when SHA256SUMS cannot be downloaded" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +build_binary_from_source() { + echo "SOURCE_BUILD_INVOKED" + printf 'rebuilt-after-checksum-404' > "$2" + chmod +x "$2" + return 0 +} +get_latest_release_tag() { echo "V1.2.3"; } + +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf 'release-binary' > "$out" ;; + *"SHA256SUMS") return 22 ;; + *) return 1 ;; + esac +} + +# An unreachable/blocked SHA256SUMS is indistinguishable from a suppressed +# one, so it must fail closed too, not silently build from unverified source. +if download_binary "status"; then + echo "UNEXPECTED_SUCCESS" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"SOURCE_BUILD_INVOKED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUCCESS"* ]] || return 1 + [[ "$output" == *"aborting instead of falling back"* ]] +} + +@test "download_binary verifies fallback release asset against fallback checksums" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +get_latest_release_tag() { echo "V1.2.2"; } +# See note above: keep the fallback-checksum path independent of host gh state. +verify_release_attestation() { return 2; } + +content="fallback-binary" +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf '%s' "$content" | shasum -a 256 | awk '{print $1}') +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"V1.2.3/${asset}") return 22 ;; + *"V1.2.2/${asset}") printf '%s' "$content" > "$out" ;; + *"V1.2.2/SHA256SUMS") printf '%s %s\n' "$hash" "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +download_binary "status" +grep -q "fallback-binary" "$CONFIG_DIR/bin/status-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS:Installed status from V1.2.2"* ]] +} + +@test "download_binary aborts on fallback-tag checksum mismatch without a source build" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +get_latest_release_tag() { echo "V1.2.2"; } +verify_release_attestation() { return 2; } +# The fallback tag is the last verification gate before the source-build +# branch; a mismatch there is tampering evidence and must abort too. +build_binary_from_source() { + echo "SOURCE_BUILD_INVOKED" + printf 'built-from-source' > "$2" + chmod +x "$2" + return 0 +} + +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +good_hash=$(printf 'expected-binary' | shasum -a 256 | awk '{print $1}') +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"V1.2.3/${asset}") return 22 ;; + *"V1.2.2/${asset}") printf 'tampered-binary' > "$out" ;; + *"V1.2.2/SHA256SUMS") printf '%s %s\n' "$good_hash" "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +if download_binary "status"; then + echo "UNEXPECTED_SUCCESS" + exit 1 +fi +if [[ -e "$CONFIG_DIR/bin/status-go" ]]; then + echo "BINARY_INSTALLED_ANYWAY" +fi +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"SOURCE_BUILD_INVOKED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUCCESS"* ]] || return 1 + [[ "$output" != *"BINARY_INSTALLED_ANYWAY"* ]] || return 1 + [[ "$output" == *"aborting instead of falling back"* ]] || return 1 +} + +@test "install_files fails closed when sudo is unavailable, even under || caller (#update-incident)" { + # Old moles invoke `install_files || {...}`, which disables errexit inside + # the function. Uncached `sudo -n` then failed on every copy while the + # install still reported success with the OLD entry script in place + # ("Updated to latest version, 1.45.0" while fetching V1.47.0). + # MOLE_TEST_NO_AUTH must not leak in: it would take the blocked-in-test-mode + # branch instead of the real ensure_sudo_ready gate. sudo is a function mock. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=0 MOLE_TEST_MODE=0 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +eval "$(sed -n '/^needs_sudo() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^ensure_sudo_ready() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^maybe_sudo() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^install_files() {/,/^}/p' "$PROJECT_ROOT/install.sh")" + +INSTALL_DIR="$HOME/rooty-bin" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' ICON_ERROR='err' ICON_ADMIN='adm' +MOLE_ASSUME_SUDO_AUTH=1 + +mkdir -p "$CONFIG_DIR" "$SOURCE_DIR" +printf '#!/bin/bash\nVERSION="9.9.9"\n' > "$SOURCE_DIR/mole" +printf '#!/bin/bash\n' > "$SOURCE_DIR/mo" +# Non-writable install dir: needs_sudo must answer true for a plain user. +mkdir -m 555 "$INSTALL_DIR" + +log_error() { echo "ERROR:$*"; } +log_success() { echo "SUCCESS:$*"; } +log_admin() { echo "ADMIN:$*"; } +download_binary() { echo "DOWNLOAD_CALLED:$1"; return 0; } +sudo() { + echo "sudo: a password is required" >&2 + return 1 +} + +# Reproduce the exact caller shape from the update flow. +install_files || echo "HANDLED_FAILURE" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"HANDLED_FAILURE"* ]] || return 1 + [[ "$output" == *"sudo -v && mo update"* ]] || return 1 + [[ "$output" != *"SUCCESS:Installed mole"* ]] || return 1 + [[ "$output" != *"DOWNLOAD_CALLED"* ]] || return 1 +} + +@test "verify_installation rejects a stale entry script after an update (#update-incident)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -uo pipefail + +eval "$(sed -n '/^get_source_version() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^get_installed_version() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^verify_installation() {/,/^}/p' "$PROJECT_ROOT/install.sh")" + +INSTALL_DIR="$HOME/bin" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +GREEN='' RED='' YELLOW='' NC='' +ICON_ERROR='err' + +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR/lib/core" "$SOURCE_DIR" +touch "$CONFIG_DIR/lib/core/common.sh" +# The old entry script survived a failed copy: runnable, wrong version. +printf '#!/bin/bash\nVERSION="1.45.0"\nexit 0\n' > "$INSTALL_DIR/mole" +chmod +x "$INSTALL_DIR/mole" +printf '#!/bin/bash\nVERSION="1.47.0"\n' > "$SOURCE_DIR/mole" + +log_error() { echo "ERROR:$*"; } +log_warning() { echo "WARNING:$*"; } + +verify_installation +echo "UNEXPECTED_PASS" +EOF + + # verify_installation exits 1 on the mixed-version state. + [ "$status" -eq 1 ] || return 1 + [[ "$output" != *"UNEXPECTED_PASS"* ]] || return 1 + [[ "$output" == *"was not replaced"* ]] || return 1 + [[ "$output" == *"1.45.0"* && "$output" == *"1.47.0"* ]] || return 1 +} + +@test "installer bounds installed binary version and help probes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +INSTALL_DIR="$HOME/install/bin" +CONFIG_DIR="$HOME/install/config" +fake_bin="$HOME/fake-bin" +trace="$HOME/probe.trace" +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR/lib/core" "$fake_bin" +: > "$CONFIG_DIR/lib/core/common.sh" + +cat > "$INSTALL_DIR/mole" <<'MOLE' +#!/bin/bash +VERSION="9.9.9" +sleep 3 +MOLE +chmod +x "$INSTALL_DIR/mole" + +cat > "$fake_bin/gtimeout" <<'TIMEOUT' +#!/bin/bash +printf '%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" >> "$PROBE_TRACE" +exit 124 +TIMEOUT +chmod +x "$fake_bin/gtimeout" + +export PATH="$fake_bin:/usr/bin:/bin" +export PROBE_TRACE="$trace" +eval "$(sed -n '/^run_install_probe_with_timeout() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^get_installed_version() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^verify_installation() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +get_source_version() { printf '9.9.9\n'; } +log_error() { printf 'ERROR:%s\n' "$*"; } +log_warning() { printf 'WARNING:%s\n' "$*"; } + +[[ "$(get_installed_version)" == "9.9.9" ]] || exit 1 +if verify_installation; then + echo "UNEXPECTED_HELP_PROBE_SUCCESS" + exit 1 +fi +grep -qF -- "-k|1|5|$INSTALL_DIR/mole|--version" "$trace" +grep -qF -- "-k|1|5|$INSTALL_DIR/mole|--help" "$trace" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_HELP_PROBE_SUCCESS"* ]] +} + +@test "installer shell fallback stops TERM-ignoring verification probes" { + local timeout_cmd="timeout" + command -v timeout > /dev/null 2>&1 || timeout_cmd="gtimeout" + command -v "$timeout_cmd" > /dev/null 2>&1 || skip "timeout command unavailable" + + run "$timeout_cmd" 3 env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +eval "$(sed -n '/^run_install_probe_with_timeout() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +run_install_probe_with_timeout 1 /bin/bash -c 'exit 0' || { + echo "UNEXPECTED_FAST_PROBE_FAILURE" + exit 1 +} +if run_install_probe_with_timeout 0.1 /bin/bash -c 'trap "" TERM; sleep 5 & wait'; then + echo "UNEXPECTED_PROBE_SUCCESS" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_FAST_PROBE_FAILURE"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_PROBE_SUCCESS"* ]] +} + +@test "standalone installer cleans source temp under trailing-slash TMPDIR" { + local tmp_root="$HOME/installer-tmp" + mkdir -p "$tmp_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TMPDIR="$tmp_root/" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +log_error() { printf 'ERROR:%s\n' "$*"; } +stop_line_spinner() { :; } +release_install_lock() { :; } + +eval "$(sed -n '/^safe_rm() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^cleanup_installer() {/,/^}/p' "$PROJECT_ROOT/install.sh")" + +INSTALL_SOURCE_TMP=$(mktemp -d "${TMPDIR}mole-source.XXXXXX") +source_tmp="$INSTALL_SOURCE_TMP" +printf 'downloaded source\n' > "$source_tmp/payload" +cleanup_installer + +[[ -z "$INSTALL_SOURCE_TMP" ]] || exit 1 +[[ ! -e "$source_tmp" ]] || exit 1 +if safe_rm "${TMPDIR%/}"; then + echo "UNEXPECTED_TEMP_ROOT_REMOVAL" + exit 1 +fi +[[ -d "${TMPDIR%/}" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_TEMP_ROOT_REMOVAL"* ]] || return 1 + [[ "$output" != *"safe_rm: refusing to remove non-temp path"* ]] || return 1 +} + +@test "installer source temp stays removable by safe_rm when TMPDIR is unset" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +unset TMPDIR +log_error() { printf 'ERROR:%s\n' "$*"; } +stop_line_spinner() { :; } +release_install_lock() { :; } + +eval "$(sed -n '/^safe_rm() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^cleanup_installer() {/,/^}/p' "$PROJECT_ROOT/install.sh")" + +# Evaluate install.sh's own source-download mktemp lines with TMPDIR unset, +# then run the same EXIT-trap cleanup path against the created directory. +# Anchor on the assignment rather than the phrase: a comment above it that +# mentions `mktemp -d` would otherwise be scraped and eval'd to nothing, +# leaving INSTALL_SOURCE_TMP unset and the failure looking like a real one. +tmp_lines="$(sed -n '/^[[:space:]]*tmp="\$(mktemp -d/{p;n;p;q;}' "$PROJECT_ROOT/install.sh" | sed 's/^[[:space:]]*//')" +[[ -n "$tmp_lines" ]] || { echo "NO_MKTEMP_LINES"; exit 1; } +eval "$tmp_lines" +source_tmp="$INSTALL_SOURCE_TMP" +printf 'downloaded source\n' > "$source_tmp/payload" +cleanup_installer + +[[ -z "$INSTALL_SOURCE_TMP" ]] || exit 1 +[[ ! -e "$source_tmp" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"safe_rm: refusing to remove non-temp path"* ]] || return 1 +} + +@test "source download mktemp template derives from TMPDIR" { + run grep -qE 'mktemp -d "\$\{TMPDIR:-/tmp\}/mole\.XXXXXX"' "$PROJECT_ROOT/install.sh" + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "standalone installer serializes writers with the stable install lock" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +INSTALL_DIR="$HOME/install/bin" +INSTALL_LOCK_PATH="" +INSTALL_LOCK_CONTROL="" +INSTALL_LOCK_HOLDER_PID="" +mkdir -p "$INSTALL_DIR" +/bin/chmod 0775 "$INSTALL_DIR" + +eval "$(sed -n '/^safe_rm() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^needs_sudo() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^ensure_sudo_ready() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^maybe_sudo() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^install_lock_has_unsafe_ancestor() {/,/^get_remote_main_commit_hash() {/p' "$PROJECT_ROOT/install.sh" | sed '$d')" +log_error() { printf 'ERROR:%s\n' "$*"; } + +[[ "$(/usr/bin/stat -f%Lp "$INSTALL_DIR")" == "775" ]] || exit 1 +if ! install_lock_has_unsafe_ancestor true; then + echo "UNEXPECTED_PRIVILEGED_GROUP_WRITABLE_PREFIX_ACCEPTED" + exit 1 +fi + +acl_rule="everyone allow list,add_file,search,add_subdirectory,delete_child,file_inherit,directory_inherit" +/bin/chmod +a "$acl_rule" "$INSTALL_DIR" +/bin/mkdir -m 0700 "$INSTALL_DIR/acl-probe" +/bin/ls -lde "$INSTALL_DIR/acl-probe" | /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:' +/bin/rmdir "$INSTALL_DIR/acl-probe" +if acquire_install_lock; then + echo "UNEXPECTED_WRITABLE_PARENT_ACL_ACCEPTED" + exit 1 +fi +# A refusal must name its own cause. Reporting an ancestor rejection as a busy +# lock is what sent #1335 reverse-engineering the check by hand. +[[ "$INSTALL_LOCK_FAILURE" == "unsafe_ancestor" ]] || exit 1 +[[ "$INSTALL_LOCK_UNSAFE_ANCESTOR" == "$INSTALL_DIR" ]] || exit 1 +/bin/chmod -N "$INSTALL_DIR" +acl_rule="everyone deny writeattr,file_inherit,directory_inherit" +/bin/chmod +a "$acl_rule" "$INSTALL_DIR" +acquire_install_lock +lock_path="$INSTALL_DIR/.mole-update.lock/kernel.lock" +[[ "$INSTALL_LOCK_PATH" == "$lock_path" ]] || exit 1 +if /bin/ls -lde "$INSTALL_DIR/.mole-update.lock" | /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:'; then + echo "UNEXPECTED_INHERITED_INSTALL_LOCK_ACL" + exit 1 +fi +if acquire_install_lock; then + echo "UNEXPECTED_CONCURRENT_INSTALL_LOCK" + exit 1 +fi +[[ "$INSTALL_LOCK_FAILURE" == "busy" ]] || exit 1 +release_install_lock +[[ -f "$lock_path" ]] || exit 1 + +holder_ready="$HOME/install-lock-holder.ready" +holder_ready_tmp="$holder_ready.tmp" +external_holder="" +holder_child_pid="" +cleanup_external_holder() { + local child_pid + if [[ "$holder_child_pid" =~ ^[0-9]+$ ]]; then + kill "$holder_child_pid" 2> /dev/null || true + fi + if [[ "$external_holder" =~ ^[0-9]+$ ]]; then + for child_pid in $(/usr/bin/pgrep -P "$external_holder" 2> /dev/null || true); do + kill "$child_pid" 2> /dev/null || true + done + kill "$external_holder" 2> /dev/null || true + wait "$external_holder" 2> /dev/null || true + fi + unlink "$holder_ready" 2> /dev/null || true + unlink "$holder_ready_tmp" 2> /dev/null || true +} +trap cleanup_external_holder EXIT +/usr/bin/lockf -k -s -t 0 -w "$lock_path" /bin/sh -c ' + printf "%s\n" "$$" > "$1" || exit 1 + mv "$1" "$2" || exit 1 + exec /bin/sleep 30 +' sh "$holder_ready_tmp" "$holder_ready" & +external_holder=$! +for _ in {1..200}; do + if [[ -s "$holder_ready" ]]; then + holder_child_pid=$(cat "$holder_ready") + if [[ "$holder_child_pid" =~ ^[0-9]+$ ]] && + kill -0 "$external_holder" 2> /dev/null && + kill -0 "$holder_child_pid" 2> /dev/null; then + break + fi + holder_child_pid="" + fi + kill -0 "$external_holder" 2> /dev/null || break + /bin/sleep 0.01 +done +if [[ ! "$holder_child_pid" =~ ^[0-9]+$ ]]; then + exit 1 +fi +external_lock_bypassed=false +if acquire_install_lock; then + external_lock_bypassed=true + release_install_lock +fi +cleanup_external_holder +trap - EXIT +if [[ "$external_lock_bypassed" == "true" ]]; then + echo "UNEXPECTED_EXTERNAL_LOCK_BYPASS" + exit 1 +fi +acquire_install_lock +release_install_lock +[[ -f "$lock_path" ]] || exit 1 + +victim="$HOME/lock-symlink-victim" +printf 'DO-NOT-TOUCH\n' > "$victim" +/bin/rm -f "$lock_path" +ln -s "$victim" "$lock_path" +if acquire_install_lock; then + echo "UNEXPECTED_LOCK_SYMLINK_FOLLOW" + exit 1 +fi +# A planted lock path is not contention, and must not be reported as such. +[[ "$INSTALL_LOCK_FAILURE" == "lock_path" ]] || exit 1 +[[ "$(cat "$victim")" == "DO-NOT-TOUCH" ]] || exit 1 +unlink "$lock_path" +mkfifo "$lock_path" +if acquire_install_lock; then + echo "UNEXPECTED_LOCK_FIFO_OPEN" + exit 1 +fi +[[ "$INSTALL_LOCK_FAILURE" == "lock_path" ]] || exit 1 +unlink "$lock_path" +acquire_install_lock +release_install_lock +! compgen -G "$INSTALL_DIR/.mole-update.lock/control.*" > /dev/null + +declare -f acquire_install_lock | grep -q '/usr/bin/lockf' +! grep -q 'trap cleanup_tmp EXIT' "$PROJECT_ROOT/install.sh" +grep -q "trap 'cleanup_installer' EXIT" "$PROJECT_ROOT/install.sh" +! grep -qF 'Another Mole installation or update is already writing' "$PROJECT_ROOT/install.sh" +# Both call sites route through the reporter, and each cause keeps its own +# remedy. A single catch-all lock message is the regression being pinned. +! grep -qF 'Could not acquire the Mole installation lock for' "$PROJECT_ROOT/install.sh" +[[ "$(grep -c 'report_install_lock_failure$' "$PROJECT_ROOT/install.sh")" -eq 2 ]] || exit 1 +# Pin the reason codes, not the wording. Pinning a sentence is what let the +# first fix swap one vague message for another and lock it in as a +# requirement, so assert instead that every cause the code can raise reaches a +# branch of its own, and that each branch says what happened and what to run. +for lock_reason in $(grep -oE 'INSTALL_LOCK_FAILURE="[a-z_]+"' "$PROJECT_ROOT/install.sh" | + sed 's/.*="//;s/"//' | sort -u); do + [[ "$lock_reason" == "busy" ]] && continue + grep -qE "^[[:space:]]+${lock_reason}\)\$" "$PROJECT_ROOT/install.sh" || { + echo "MISSING_LOCK_BRANCH:$lock_reason" + exit 1 + } +done +# The ancestor check refuses for five independent reasons and each needs a +# different command: chown does not clear an ACL, chmod does not undo a +# symlink. One shared sentence sends the user to run something inert. +for ancestor_reason in $(grep -oE 'INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="[a-z_]+"' "$PROJECT_ROOT/install.sh" | + sed 's/.*="//;s/"//' | sort -u); do + grep -qE "^[[:space:]]+${ancestor_reason}\)\$" "$PROJECT_ROOT/install.sh" || { + echo "MISSING_ANCESTOR_BRANCH:$ancestor_reason" + exit 1 + } +done +# Every branch that speaks to the user tells them what to run next. Checked on +# leaf branches only: `unsafe_ancestor)` just opens a nested case and its +# children carry the messages, so a branch with no log_error of its own is a +# delegator, not a silent refusal. +awk '/^report_install_lock_failure\(\)/{inside=1; next} + inside && /^\}$/{inside=0} + inside && /^[[:space:]]+[a-z_*]+\)$/{ + if (name != "" && errors > 0 && !next_step) {print "NO_NEXT_STEP:" name; bad=1} + name=$1; errors=0; next_step=0; next + } + inside && /log_error/{errors++; if ($0 ~ /retry/) next_step=1} + END{ + if (name != "" && errors > 0 && !next_step) {print "NO_NEXT_STEP:" name; bad=1} + exit bad + }' "$PROJECT_ROOT/install.sh" || exit 1 +# A lapsed session gets one terminal-bound retry, and never a silent prompt on +# captured stdio or a hang where there is no terminal to ask on. +grep -qF 'sudo -v < /dev/tty > /dev/tty 2> /dev/tty' "$PROJECT_ROOT/install.sh" +grep -qF '[[ -r /dev/tty && -w /dev/tty ]] || return 1' "$PROJECT_ROOT/install.sh" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_CONCURRENT_INSTALL_LOCK"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_EXTERNAL_LOCK_BYPASS"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_LOCK_SYMLINK_FOLLOW"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_LOCK_FIFO_OPEN"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_INHERITED_INSTALL_LOCK_ACL"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_WRITABLE_PARENT_ACL_ACCEPTED"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_PRIVILEGED_GROUP_WRITABLE_PREFIX_ACCEPTED"* ]] || return 1 +} + +@test "standalone installer normalizes a relative prefix before lock validation" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +cd "$HOME" +INSTALL_DIR="relative/bin" +mkdir -p "$INSTALL_DIR" + +eval "$(sed -n '/^safe_rm() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^install_lock_has_unsafe_ancestor() {/,/^install_lock_process_start() {/p' "$PROJECT_ROOT/install.sh" | sed '$d')" +eval "$(sed -n '/^normalize_install_dir() {/,/^}/p' "$PROJECT_ROOT/install.sh")" + +normalize_install_dir +[[ "$INSTALL_DIR" == "$(pwd -P)/relative/bin" ]] || exit 1 +if install_lock_has_unsafe_ancestor false; then + echo "UNEXPECTED_RELATIVE_PREFIX_REJECTED_AFTER_NORMALIZATION" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_RELATIVE_PREFIX_REJECTED_AFTER_NORMALIZATION"* ]] +} + +@test "write_install_channel_metadata succeeds for stable channel with empty commit hash" { + # Regression: the previous `[[ -n "$h" ]] && printf` form returned 1 + # whenever the commit hash was empty (always the case on stable), making + # the block redirect look like an I/O failure and tripping the warning. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +CONFIG_DIR="$HOME/config" +mkdir -p "$CONFIG_DIR" + +eval "$(sed -n '/^write_install_channel_metadata()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +if ! write_install_channel_metadata "stable" ""; then + echo "WRONG: stable write reported failure"; exit 1 +fi +[[ -f "$CONFIG_DIR/install_channel" ]] || { echo "WRONG: file not created"; exit 1; } +grep -q '^CHANNEL=stable$' "$CONFIG_DIR/install_channel" || { echo "WRONG: channel value missing"; cat "$CONFIG_DIR/install_channel"; exit 1; } +grep -q '^COMMIT_HASH=' "$CONFIG_DIR/install_channel" && { echo "WRONG: commit hash leaked"; exit 1; } + +# Nightly path with a commit hash should still work. +if ! write_install_channel_metadata "nightly" "deadbeef" "heal-123-456-789"; then + echo "WRONG: nightly write failed"; exit 1 +fi +grep -q '^CHANNEL=nightly$' "$CONFIG_DIR/install_channel" || { echo "WRONG: nightly channel"; exit 1; } +grep -q '^COMMIT_HASH=deadbeef$' "$CONFIG_DIR/install_channel" || { echo "WRONG: nightly commit"; exit 1; } +grep -q '^INSTALL_RECEIPT=heal-123-456-789$' "$CONFIG_DIR/install_channel" || { echo "WRONG: install receipt missing"; exit 1; } + +if ! write_install_channel_metadata "stable" "" "update-123-456-789"; then + echo "WRONG: update receipt rejected"; exit 1 +fi +grep -q '^INSTALL_RECEIPT=update-123-456-789$' "$CONFIG_DIR/install_channel" || { echo "WRONG: update receipt missing"; exit 1; } + +if write_install_channel_metadata "nightly" "badcafe" $'heal-valid\nCOMMIT_HASH=forged'; then + echo "WRONG: malformed receipt accepted"; exit 1 +fi +grep -q '^CHANNEL=stable$' "$CONFIG_DIR/install_channel" || { echo "WRONG: rejected receipt changed metadata"; exit 1; } + +# No leftover temp files. +if ls "$CONFIG_DIR"/install_channel.?????? 2>/dev/null | grep -q .; then + echo "WRONG: tmp file leaked"; ls "$CONFIG_DIR"; exit 1 +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "main source archives are pinned when a commit is known" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +eval "$(sed -n '/^source_archive_url()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +commit="0123456789abcdef0123456789abcdef01234567" +[[ "$(source_archive_url main "$commit")" == "https://github.com/tw93/mole/archive/$commit.tar.gz" ]] || exit 1 +[[ "$(source_archive_url main "")" == "https://github.com/tw93/mole/archive/refs/heads/main.tar.gz" ]] || exit 1 +[[ "$(source_archive_url dev "")" == "https://github.com/tw93/mole/archive/refs/heads/dev.tar.gz" ]] || exit 1 +[[ "$(source_archive_url V1.2.3 "")" == "https://github.com/tw93/mole/archive/refs/tags/V1.2.3.tar.gz" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "verify_release_attestation maps gh availability and result to 2/0/1" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +eval "$(sed -n '/^verify_release_attestation()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +stubdir="$(mktemp -d "${TMPDIR:-/tmp}/mole-gh-stub.XXXXXX")" +cat > "$stubdir/gh" <<'STUB' +#!/bin/bash +case "$1 $2" in + "auth status") exit "${STUB_AUTH_RC:-0}" ;; + "attestation verify") exit "${STUB_VERIFY_RC:-0}" ;; +esac +exit 0 +STUB +chmod +x "$stubdir/gh" +target="$(mktemp "${TMPDIR:-/tmp}/mole-att-file.XXXXXX")" + +# gh missing -> cannot verify (2) +( PATH="/var/empty"; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 2 ] || { echo "WRONG: gh-missing rc=$rc want 2"; exit 1; } + +# gh present but unauthenticated -> cannot verify (2) +( PATH="$stubdir:$PATH"; export STUB_AUTH_RC=1; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 2 ] || { echo "WRONG: unauth rc=$rc want 2"; exit 1; } + +# gh authenticated + attestation verifies -> 0 +( PATH="$stubdir:$PATH"; export STUB_AUTH_RC=0 STUB_VERIFY_RC=0; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 0 ] || { echo "WRONG: verify-ok rc=$rc want 0"; exit 1; } + +# gh authenticated + attestation fails -> 1 +( PATH="$stubdir:$PATH"; export STUB_AUTH_RC=0 STUB_VERIFY_RC=1; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 1 ] || { echo "WRONG: verify-fail rc=$rc want 1"; exit 1; } + +rm -rf "$stubdir" "$target" +EOF + + [ "$status" -eq 0 ] +} + +@test "verify_release_asset_checksum enforces attestation policy gate" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +eval "$(sed -n '/^extract_release_checksum()/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^calculate_file_sha256()/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^verify_release_asset_checksum()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +log_success() { echo "SUCCESS:$*"; } +log_error() { echo "ERROR:$*"; } + +asset="status-darwin-amd64" +file="$(mktemp "${TMPDIR:-/tmp}/mole-asset.XXXXXX")" +printf 'release-binary' > "$file" +hash="$(printf 'release-binary' | shasum -a 256 | awk '{print $1}')" +download_release_checksums() { printf '%s %s\n' "$hash" "$asset" > "$2"; return 0; } + +# attestation verification failed (status 1) -> fatal, never installs +verify_release_attestation() { return 1; } +out="$(verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 1 ] || { echo "WRONG: status1 rc=$rc want 1"; exit 1; } +[[ "$out" == *"ERROR:Release attestation verification failed"* ]] || { echo "WRONG: status1 error missing: $out"; exit 1; } + +# cannot verify (status 2) + MOLE_REQUIRE_ATTESTATION=1 -> fatal +verify_release_attestation() { return 2; } +out="$(MOLE_REQUIRE_ATTESTATION=1 verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 1 ] || { echo "WRONG: require-gate rc=$rc want 1"; exit 1; } +[[ "$out" == *"ERROR:MOLE_REQUIRE_ATTESTATION=1 set but gh"* ]] || { echo "WRONG: require-gate error missing: $out"; exit 1; } + +# cannot verify (status 2) without the gate -> falls back to checksum-only +verify_release_attestation() { return 2; } +out="$(verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 0 ] || { echo "WRONG: checksum-only rc=$rc want 0"; exit 1; } + +# attestation verified (status 0) + checksum match -> success with combined label +verify_release_attestation() { return 0; } +out="$(verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 0 ] || { echo "WRONG: verified rc=$rc want 0"; exit 1; } +[[ "$out" == *"SUCCESS:Verified ${asset} · sha256 + attestation"* ]] || { echo "WRONG: verified success missing: $out"; exit 1; } + +rm -f "$file" +EOF + + [ "$status" -eq 0 ] +} + +@test "teardown never turns a finished install into a failure" { + # cleanup_installer runs from the EXIT trap under `set -e`, so a refusal + # inside it used to become the script's exit status and report a verified + # install as failed (#1343). The refusal still prints; it just no longer + # decides the verdict. + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +log_error() { printf 'ERROR:%s\n' "$*"; } +stop_line_spinner() { :; } +release_install_lock() { :; } +eval "$(sed -n '/^cleanup_installer() {/,/^}/p' "$PROJECT_ROOT/install.sh")" + +# A path safe_rm must refuse, standing in for any future refusal. +safe_rm() { log_error "safe_rm: refusing to remove non-temp path: $1"; return 1; } +INSTALL_SOURCE_TMP="/not/a/temp/path" +trap 'cleanup_installer' EXIT +exit 0 +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"refusing to remove"* ]] || return 1 +} + +@test "the source temp dir and safe_rm agree on the temp root" { + # A bare `mktemp -d` ignores TMPDIR on macOS, so the creator and the + # remover disagreed whenever TMPDIR was unset or pointed elsewhere. + # Assert the behaviour, not the template: create the dir the way the + # installer does, then hand it to the real safe_rm. + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +log_error() { printf 'ERROR:%s\n' "$*"; } +eval "$(sed -n '/^safe_rm() {/,/^}/p' "$PROJECT_ROOT/install.sh")" +# Anchor on the assignment, not the phrase: a comment that merely mentions +# `mktemp -d` would otherwise be picked up and eval'd to nothing. +mktemp_line=$(grep -m1 -E '^[[:space:]]*tmp="\$\(mktemp -d' "$PROJECT_ROOT/install.sh" | sed 's/^[[:space:]]*//') +[[ -n "$mktemp_line" ]] || { echo "NO_MKTEMP_LINE"; exit 1; } + +for scenario in unset darwin slash; do + case "$scenario" in + unset) unset TMPDIR ;; + darwin) TMPDIR="$(getconf DARWIN_USER_TEMP_DIR)"; export TMPDIR ;; + slash) TMPDIR="$(getconf DARWIN_USER_TEMP_DIR)"; TMPDIR="${TMPDIR%/}/"; export TMPDIR ;; + esac + eval "$mktemp_line" + [[ -d "$tmp" ]] || { echo "NO_DIR:$scenario"; exit 1; } + safe_rm "$tmp" || { echo "REFUSED:$scenario"; exit 1; } + [[ ! -e "$tmp" ]] || { echo "LEFT_BEHIND:$scenario"; exit 1; } +done +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"REFUSED"* ]] || return 1 +} + +@test "the install lock still works where /usr/bin/lockf was never shipped" { + # lockf only ships with newer macOS. Requiring it made both install and + # update exit before writing a file on every older release (#1348), so the + # absent case falls back to an atomic mkdir. Simulate absence by pointing + # the check at a path that cannot exist. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +patched="$HOME/install-nolockf.sh" +sed 's#/usr/bin/lockf#/usr/bin/lockf_absent_for_test#g' "$PROJECT_ROOT/install.sh" > "$patched" + +INSTALL_DIR="$HOME/install" +INSTALL_LOCK_FAILURE="" +INSTALL_LOCK_UNSAFE_ANCESTOR="" +INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="" +INSTALL_LOCK_PATH=""; INSTALL_LOCK_CONTROL=""; INSTALL_LOCK_HOLDER_PID=""; INSTALL_LOCK_USE_SUDO=false +log_error() { printf 'ERROR:%s\n' "$*"; } +# awk, not sed: a BSD sed address built from a function name trips over the +# parentheses for some of these, and the failure is a silent missing function. +for fn in install_lock_command install_lock_has_unsafe_ancestor install_lock_prepare_dir \ + install_lock_read_owner install_lock_remove_control install_lock_process_start \ + install_lock_current_shell_pid install_lock_reauthenticate acquire_install_lock \ + release_install_lock; do + body="$(awk -v f="$fn" 'index($0, f "()")==1{p=1} p{print} p&&/^}$/{exit}' "$patched")" + [[ -n "$body" ]] || { echo "NO_BODY:$fn"; exit 1; } + eval "$body" +done + +acquire_install_lock || { echo "ACQUIRE_FAILED:$INSTALL_LOCK_FAILURE"; exit 1; } +mutex="$INSTALL_DIR/.mole-update.lock/holder" +[[ -d "$mutex" ]] || { echo "NO_MUTEX_HELD"; exit 1; } + +# A second acquire must be refused while this one holds the mutex. +( acquire_install_lock ) && { echo "DOUBLE_ACQUIRE"; exit 1; } + +release_install_lock +for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ -d "$mutex" ]] || break + /bin/sleep 0.1 +done +[[ ! -d "$mutex" ]] || { echo "MUTEX_LEAKED"; exit 1; } +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"DOUBLE_ACQUIRE"* ]] || return 1 + [[ "$output" != *"MUTEX_LEAKED"* ]] || return 1 +} + +@test "the update path never runs brew inside the pre-authed window" { + # brew's entry point resets the sudo timestamp as a security measure; + # running it after pre-auth killed the handed-over ticket within five + # seconds (field ticket watchdog) and forced a second password prompt + # on every update. Homebrew ownership is decided from the Cellar on + # disk instead, so brew itself never runs in that window. + # Comment lines are stripped first: the invariant is that install.sh does + # not RUN this, and a comment explaining why it was dropped is not a call. + # A bare grep flagged exactly that comment and read as a real regression. + if command grep -vE '^[[:space:]]*#' "$PROJECT_ROOT/install.sh" | + command grep -q 'brew list mole'; then + echo "brew list mole is back in install.sh" + return 1 + fi + command grep -q 'homebrew_owns_mole()' "$PROJECT_ROOT/install.sh" || { + echo "cellar-based ownership check missing" + return 1 + } + + # The helper itself: a Cellar dir under HOMEBREW_PREFIX means owned, + # no Cellar anywhere means not owned, and brew is never executed. + eval "$(sed -n '/^homebrew_owns_mole()/,/^}/p' "$PROJECT_ROOT/install.sh")" + local fake_prefix="$BATS_TEST_TMPDIR/fakebrew" + mkdir -p "$fake_prefix/Cellar/mole" + HOMEBREW_PREFIX="$fake_prefix" homebrew_owns_mole || { + echo "cellar dir not detected" + return 1 + } + if HOMEBREW_PREFIX="$BATS_TEST_TMPDIR/empty" homebrew_owns_mole 2> /dev/null && + [[ ! -d /opt/homebrew/Cellar/mole && ! -d /usr/local/Cellar/mole ]]; then + echo "claimed ownership with no cellar anywhere" + return 1 + fi +} diff --git a/Resources/mole/tests/installer.bats b/Resources/mole/tests/installer.bats index 1e26595..f3c5046 100644 --- a/Resources/mole/tests/installer.bats +++ b/Resources/mole/tests/installer.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" export MO_DEBUG=0 @@ -62,7 +69,7 @@ setup() { @test "scan_installers_in_path (fallback find): finds .dmg files" { touch "$HOME/Downloads/Chrome.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" @@ -78,16 +85,16 @@ setup() { touch "$HOME/Downloads/App3.iso" touch "$HOME/Downloads/App.mpkg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" == *"App1.dmg"* ]] - [[ "$output" == *"App2.pkg"* ]] - [[ "$output" == *"App3.iso"* ]] + [[ "$output" == *"App1.dmg"* ]] || return 1 + [[ "$output" == *"App2.pkg"* ]] || return 1 + [[ "$output" == *"App3.iso"* ]] || return 1 [[ "$output" == *"App.mpkg"* ]] } @@ -98,18 +105,20 @@ setup() { touch "$HOME/Downloads/level1/level2/deep.dmg" touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - # Default max depth is 2 - [[ "$output" == *"shallow.dmg"* ]] - [[ "$output" == *"mid.dmg"* ]] - [[ "$output" == *"deep.dmg"* ]] - [[ "$output" != *"too-deep.dmg"* ]] + # Default max depth is 2 (INSTALLER_SCAN_MAX_DEPTH_DEFAULT), so level2 and below + # must be excluded. Assert on the containing path, not the bare filename: + # *"deep.dmg"* also matches too-deep.dmg, which hid the boundary entirely. + [[ "$output" == *"/shallow.dmg"* ]] || return 1 + [[ "$output" == *"/level1/mid.dmg"* ]] || return 1 + [[ "$output" != *"/level1/level2/deep.dmg"* ]] || return 1 + [[ "$output" != *"/level1/level2/level3/too-deep.dmg"* ]] } @test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { @@ -117,19 +126,19 @@ setup() { touch "$HOME/Downloads/top.dmg" touch "$HOME/Downloads/level1/nested.dmg" - run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" == *"top.dmg"* ]] + [[ "$output" == *"top.dmg"* ]] || return 1 [[ "$output" != *"nested.dmg"* ]] } @test "scan_installers_in_path (fallback find): handles non-existent directory" { - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" @@ -145,16 +154,16 @@ setup() { touch "$HOME/Downloads/archive.tar.gz" touch "$HOME/Downloads/Installer.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" != *"document.pdf"* ]] - [[ "$output" != *"image.jpg"* ]] - [[ "$output" != *"archive.tar.gz"* ]] + [[ "$output" != *"document.pdf"* ]] || return 1 + [[ "$output" != *"image.jpg"* ]] || return 1 + [[ "$output" != *"archive.tar.gz"* ]] || return 1 [[ "$output" == *"Installer.dmg"* ]] } @@ -171,7 +180,7 @@ setup() { # Add an installer to the one directory that exists touch "$HOME/Downloads/test.dmg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_all_installers @@ -188,7 +197,7 @@ setup() { @test "scan_installers_in_path (fallback find): handles filenames with spaces" { touch "$HOME/Downloads/My App Installer.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" @@ -201,7 +210,7 @@ setup() { @test "scan_installers_in_path (fallback find): handles filenames with special characters" { touch "$HOME/Downloads/App-v1.2.3_beta.pkg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" @@ -216,7 +225,7 @@ setup() { touch "$HOME/Downloads/document.pdf" touch "$HOME/Downloads/image.png" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" @@ -233,14 +242,237 @@ setup() { ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" ln -s /nonexistent "$HOME/Downloads/dangling.lnk" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" == *"real.dmg"* ]] - [[ "$output" != *"symlink.dmg"* ]] + [[ "$output" == *"real.dmg"* ]] || return 1 + [[ "$output" != *"symlink.dmg"* ]] || return 1 [[ "$output" != *"dangling.lnk"* ]] } + +@test "delete_selected_installers removes selected files and records successes" { + local first="$HOME/Downloads/First.dmg" + local second="$HOME/Downloads/Second.pkg" + printf 'one' > "$first" + printf 'two' > "$second" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + + INSTALLER_PATHS=("$2" "$3") + INSTALLER_SIZES=(3 3) + MOLE_SELECTION_RESULT="0,1" + + delete_selected_installers < <(printf "\n") + printf "deleted=%s failed=%s freed=%s\n" "$total_deleted" "${total_delete_failed:-0}" "$total_size_freed_kb" + [[ ! -e "$2" ]] || return 1 + [[ ! -e "$3" ]] || return 1 + grep -F "[installer] REMOVED $2" "$HOME/Library/Logs/mole/operations.log" > /dev/null + ' bash "$PROJECT_ROOT/bin/installer.sh" "$first" "$second" + + [ "$status" -eq 0 ] + [[ "$output" == *"deleted=2 failed=0"* ]] +} + +@test "delete_selected_installers records protected-path failures" { + local removable="$HOME/Downloads/Good.dmg" + printf 'good' > "$removable" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + + system_size=$(get_file_size "/System") + INSTALLER_PATHS=("$2" "/System") + INSTALLER_SIZES=(4 "$system_size") + MOLE_SELECTION_RESULT="0,1" + + set +e + delete_selected_installers < <(printf "\n") + rc=$? + set -e + printf "rc=%s deleted=%s failed=%s\n" "$rc" "$total_deleted" "${total_delete_failed:-0}" + if [[ ${total_delete_failed:-0} -gt 0 ]]; then + printf "failure=%s\n" "${INSTALLER_DELETE_FAILURES[0]}" + fi + [[ ! -e "$2" ]] || return 1 + ' bash "$PROJECT_ROOT/bin/installer.sh" "$removable" + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=3 deleted=1 failed=1"* ]] || return 1 + [[ "$output" == *"failure=/System (delete failed)"* ]] +} + +@test "execute_installer_delete_plan refuses replaced files" { + local target="$HOME/Downloads/Replaced.dmg" + local replacement="$HOME/Downloads/Replacement.dmg" + printf 'one' > "$target" + printf 'one' > "$replacement" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + source "$1" + + INSTALLER_PATHS=("$2") + INSTALLER_SIZES=("$(get_file_size "$2")") + build_installer_delete_plan 0 + mv "$2" "$2.old" + mv "$3" "$2" + + set +e + execute_installer_delete_plan + rc=$? + set -e + + printf "rc=%s deleted=%s failed=%s failure=%s\n" "$rc" "$total_deleted" "$total_delete_failed" "${INSTALLER_DELETE_FAILURES[0]}" + [[ -e "$2" ]] || return 1 + [[ -e "$2.old" ]] || return 1 + ' bash "$PROJECT_ROOT/bin/installer.sh" "$target" "$replacement" + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=3 deleted=0 failed=1"* ]] || return 1 + [[ "$output" == *"Replaced.dmg (changed since scan)"* ]] +} + +@test "execute_installer_delete_plan refuses size drift" { + local target="$HOME/Downloads/Grew.dmg" + printf 'one' > "$target" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + source "$1" + + INSTALLER_PATHS=("$2") + INSTALLER_SIZES=("$(get_file_size "$2")") + build_installer_delete_plan 0 + printf "two" >> "$2" + + set +e + execute_installer_delete_plan + rc=$? + set -e + + printf "rc=%s deleted=%s failed=%s failure=%s\n" "$rc" "$total_deleted" "$total_delete_failed" "${INSTALLER_DELETE_FAILURES[0]}" + [[ -e "$2" ]] || return 1 + ' bash "$PROJECT_ROOT/bin/installer.sh" "$target" + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=3 deleted=0 failed=1"* ]] || return 1 + [[ "$output" == *"Grew.dmg (changed since scan)"* ]] +} + +@test "show_summary reports installer delete failures" { + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + source "$1" + + total_deleted=1 + total_size_freed_kb=1 + total_delete_failed=2 + INSTALLER_DELETE_FAILURES=("$HOME/Downloads/Blocked.dmg (protected path)" "$HOME/Downloads/Stale.pkg (still exists)") + + show_summary + ' bash "$PROJECT_ROOT/bin/installer.sh" + + [ "$status" -eq 0 ] + [[ "$output" == *"Installer cleanup incomplete"* ]] || return 1 + [[ "$output" == *"Failed to remove"* ]] || return 1 + [[ "$output" == *"Blocked.dmg"* ]] || return 1 + [[ "$output" == *"protected path"* ]] || return 1 + [[ "$output" == *"Stale.pkg"* ]] || return 1 + [[ "$output" == *"still exists"* ]] || return 1 + [[ "$output" != *"Your Mac is cleaner now!"* ]] +} + +@test "main exits nonzero after real incomplete installer cleanup" { + local removable="$HOME/Downloads/MainGood.dmg" + printf 'good' > "$removable" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + test_removable="$2" + + collect_installers() { + local system_size + system_size=$(get_file_size "/System") + INSTALLER_PATHS=("$test_removable" "/System") + INSTALLER_SIZES=(4 "$system_size") + DISPLAY_NAMES=("MainGood.dmg" "System") + return 0 + } + + show_installer_menu() { + MOLE_SELECTION_RESULT="0,1" + return 0 + } + + set +e + main < <(printf "\n") + rc=$? + set -e + printf "rc=%s removed=%s\n" "$rc" "$([[ ! -e "$test_removable" ]] && echo yes || echo no)" + ' bash "$PROJECT_ROOT/bin/installer.sh" "$removable" + + [ "$status" -eq 0 ] + [[ "$output" == *"Installer cleanup incomplete"* ]] || return 1 + [[ "$output" == *"rc=1"* ]] || return 1 + [[ "$output" == *"removed=yes"* ]] +} + +@test "main reports incomplete cleanup under errexit" { + local removable="$HOME/Downloads/ErrexitGood.dmg" + printf 'good' > "$removable" + + # Production runs installer.sh with set -euo pipefail active, so main must + # not let a nonzero perform_installers status trip errexit before the + # incomplete-cleanup summary is printed. + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" /bin/bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + test_removable="$2" + + collect_installers() { + local system_size + system_size=$(get_file_size "/System") + INSTALLER_PATHS=("$test_removable" "/System") + INSTALLER_SIZES=(4 "$system_size") + DISPLAY_NAMES=("ErrexitGood.dmg" "System") + return 0 + } + + show_installer_menu() { + MOLE_SELECTION_RESULT="0,1" + return 0 + } + + main < <(printf "\n") + ' bash "$PROJECT_ROOT/bin/installer.sh" "$removable" + + [ "$status" -eq 1 ] + [[ "$output" == *"Installer cleanup incomplete"* ]] || return 1 + [[ "$output" == *"Failed to remove"* ]] || return 1 + [[ ! -e "$removable" ]] +} diff --git a/Resources/mole/tests/installer_fd.bats b/Resources/mole/tests/installer_fd.bats index 10d98ff..11db4e2 100644 --- a/Resources/mole/tests/installer_fd.bats +++ b/Resources/mole/tests/installer_fd.bats @@ -20,13 +20,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" export MO_DEBUG=0 @@ -54,7 +61,7 @@ require_fd() { touch "$HOME/Downloads/Chrome.dmg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -74,16 +81,16 @@ require_fd() { touch "$HOME/Downloads/App3.iso" touch "$HOME/Downloads/App.mpkg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" == *"App1.dmg"* ]] - [[ "$output" == *"App2.pkg"* ]] - [[ "$output" == *"App3.iso"* ]] + [[ "$output" == *"App1.dmg"* ]] || return 1 + [[ "$output" == *"App2.pkg"* ]] || return 1 + [[ "$output" == *"App3.iso"* ]] || return 1 [[ "$output" == *"App.mpkg"* ]] } @@ -98,7 +105,7 @@ require_fd() { touch "$HOME/Downloads/level1/level2/deep.dmg" touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -106,9 +113,9 @@ require_fd() { [ "$status" -eq 0 ] # Default max depth is 2 - [[ "$output" == *"shallow.dmg"* ]] - [[ "$output" == *"mid.dmg"* ]] - [[ "$output" == *"deep.dmg"* ]] + [[ "$output" == *"shallow.dmg"* ]] || return 1 + [[ "$output" == *"mid.dmg"* ]] || return 1 + [[ "$output" == *"deep.dmg"* ]] || return 1 [[ "$output" != *"too-deep.dmg"* ]] } @@ -121,14 +128,14 @@ require_fd() { touch "$HOME/Downloads/top.dmg" touch "$HOME/Downloads/level1/nested.dmg" - run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " + run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 /bin/bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" == *"top.dmg"* ]] + [[ "$output" == *"top.dmg"* ]] || return 1 [[ "$output" != *"nested.dmg"* ]] } @@ -137,7 +144,7 @@ require_fd() { return 0 fi - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -157,16 +164,16 @@ require_fd() { touch "$HOME/Downloads/archive.tar.gz" touch "$HOME/Downloads/Installer.dmg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" != *"document.pdf"* ]] - [[ "$output" != *"image.jpg"* ]] - [[ "$output" != *"archive.tar.gz"* ]] + [[ "$output" != *"document.pdf"* ]] || return 1 + [[ "$output" != *"image.jpg"* ]] || return 1 + [[ "$output" != *"archive.tar.gz"* ]] || return 1 [[ "$output" == *"Installer.dmg"* ]] } @@ -177,7 +184,7 @@ require_fd() { touch "$HOME/Downloads/My App Installer.dmg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -194,7 +201,7 @@ require_fd() { touch "$HOME/Downloads/App-v1.2.3_beta.pkg" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -213,7 +220,7 @@ require_fd() { touch "$HOME/Downloads/document.pdf" touch "$HOME/Downloads/image.png" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -232,14 +239,14 @@ require_fd() { ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" ln -s /nonexistent "$HOME/Downloads/dangling.lnk" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" [ "$status" -eq 0 ] - [[ "$output" == *"real.dmg"* ]] - [[ "$output" != *"symlink.dmg"* ]] + [[ "$output" == *"real.dmg"* ]] || return 1 + [[ "$output" != *"symlink.dmg"* ]] || return 1 [[ "$output" != *"dangling.lnk"* ]] } diff --git a/Resources/mole/tests/installer_zip.bats b/Resources/mole/tests/installer_zip.bats index 743df15..59a7423 100644 --- a/Resources/mole/tests/installer_zip.bats +++ b/Resources/mole/tests/installer_zip.bats @@ -30,13 +30,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" export MO_DEBUG=0 @@ -85,7 +92,7 @@ require_unzip_support() { done (cd "$HOME/Downloads" && zip -q -r large-installer.zip large-app) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" if is_installer_zip "'"$HOME/Downloads/large-installer.zip"'"; then @@ -108,7 +115,7 @@ require_unzip_support() { touch "$HOME/Downloads/app-content/MyApp.app" (cd "$HOME/Downloads" && zip -q -r app.zip app-content) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" if is_installer_zip "'"$HOME/Downloads/app.zip"'"; then @@ -137,7 +144,7 @@ require_unzip_support() { touch "$HOME/Downloads/deep-content/MyApp.app" (cd "$HOME/Downloads" && zip -q -r deep.zip deep-content) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" if is_installer_zip "'"$HOME/Downloads/deep.zip"'"; then @@ -173,7 +180,7 @@ require_unzip_support() { EOF (cd "$HOME/Downloads" && zip -q -r realapp.zip RealApp.app) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" if is_installer_zip "'"$HOME/Downloads/realapp.zip"'"; then @@ -197,7 +204,7 @@ EOF touch "$HOME/Downloads/data/file2.pdf" (cd "$HOME/Downloads" && zip -q -r data.zip data) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" if is_installer_zip "'"$HOME/Downloads/data.zip"'"; then @@ -214,7 +221,7 @@ EOF @test "is_installer_zip: returns NOT_INSTALLER when ZIP list command is unavailable" { touch "$HOME/Downloads/empty.zip" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" ZIP_LIST_CMD=() @@ -238,7 +245,7 @@ EOF touch "$HOME/Downloads/app-content/MyApp.app" (cd "$HOME/Downloads" && zip -q -r app.zip app-content) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" ZIP_LIST_CMD=(unzip -Z -1) @@ -265,7 +272,7 @@ EOF touch "$HOME/Downloads/app-content/MyApp.app" (cd "$HOME/Downloads" && zip -q -r installer.zip app-content) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_all_installers @@ -286,7 +293,7 @@ EOF touch "$HOME/Downloads/data/file2.pdf" (cd "$HOME/Downloads" && zip -q -r data.zip data) - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_all_installers @@ -306,7 +313,7 @@ EOF # Create a corrupt ZIP file by just writing garbage data echo "This is not a valid ZIP file" > "$HOME/Downloads/corrupt.zip" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -336,7 +343,7 @@ EOF # Remove read permissions from restricted.zip chmod 000 "$HOME/Downloads/restricted.zip" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -344,8 +351,8 @@ EOF # Should succeed and find the readable.zip but skip restricted.zip [ "$status" -eq 0 ] - [[ "$output" == *"readable.zip"* ]] - [[ "$output" != *"restricted.zip"* ]] + [[ "$output" == *"readable.zip"* ]] || return 1 + [[ "$output" != *"restricted.zip"* ]] || return 1 # Cleanup: restore permissions for teardown chmod 644 "$HOME/Downloads/restricted.zip" @@ -364,7 +371,7 @@ EOF # Create a corrupt ZIP echo "garbage data" > "$HOME/Downloads/corrupt.zip" - run bash -euo pipefail -c ' + run /bin/bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_installers_in_path "$2" @@ -372,6 +379,6 @@ EOF # Should find the valid ZIP and silently skip the corrupt one [ "$status" -eq 0 ] - [[ "$output" == *"valid-installer.zip"* ]] + [[ "$output" == *"valid-installer.zip"* ]] || return 1 [[ "$output" != *"corrupt.zip"* ]] } diff --git a/Resources/mole/tests/manage_autofix.bats b/Resources/mole/tests/manage_autofix.bats deleted file mode 100644 index 219d6d2..0000000 --- a/Resources/mole/tests/manage_autofix.bats +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT -} - -@test "show_suggestions lists auto and manual items and exports flag" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" - -export FIREWALL_DISABLED=true -export FILEVAULT_DISABLED=true -export TOUCHID_NOT_CONFIGURED=true -export CACHE_SIZE_GB=9 -export BREW_HAS_WARNINGS=true -export DISK_FREE_GB=25 - -show_suggestions -echo "AUTO_FLAG=${HAS_AUTO_FIX_SUGGESTIONS}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Enable Firewall for better security"* ]] - [[ "$output" == *"Enable FileVault"* ]] - [[ "$output" == *"Enable Touch ID for sudo"* ]] - [[ "$output" == *"Low disk space (25GB free)"* ]] - [[ "$output" == *"AUTO_FLAG=true"* ]] -} - -@test "ask_for_auto_fix accepts Enter" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" -HAS_AUTO_FIX_SUGGESTIONS=true -read_key() { echo "ENTER"; return 0; } -ask_for_auto_fix -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"yes"* ]] -} - -@test "ask_for_auto_fix rejects other keys" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" -HAS_AUTO_FIX_SUGGESTIONS=true -read_key() { echo "ESC"; return 0; } -ask_for_auto_fix -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"no"* ]] -} - -@test "perform_auto_fix applies available actions and records summary" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" - -has_sudo_session() { return 0; } -ensure_sudo_session() { return 0; } -sudo() { - case "$1" in - defaults) return 0 ;; - bash) return 0 ;; - /usr/libexec/ApplicationFirewall/socketfilterfw) return 0 ;; - *) return 0 ;; - esac -} - -export FIREWALL_DISABLED=true -export TOUCHID_NOT_CONFIGURED=true - -perform_auto_fix -echo "SUMMARY=${AUTO_FIX_SUMMARY}" -echo "DETAILS=${AUTO_FIX_DETAILS}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Firewall enabled"* ]] - [[ "$output" == *"Touch ID configured"* ]] - [[ "$output" == *"SUMMARY=Auto fixes applied: 2 issues"* ]] - [[ "$output" == *"DETAILS"* ]] -} diff --git a/Resources/mole/tests/manage_sudo.bats b/Resources/mole/tests/manage_sudo.bats index 32c77bb..eb93e21 100644 --- a/Resources/mole/tests/manage_sudo.bats +++ b/Resources/mole/tests/manage_sudo.bats @@ -10,6 +10,24 @@ setup() { source "$PROJECT_ROOT/lib/core/sudo.sh" } +@test "check_touchid_support falls back to legacy sudo when sudo_local is present" { + local pam_sudo="$BATS_TEST_TMPDIR/sudo" + local pam_sudo_local="$BATS_TEST_TMPDIR/sudo_local" + + printf 'auth sufficient pam_tid.so\n' > "$pam_sudo" + printf 'auth include pam_opendirectory.so\n' > "$pam_sudo_local" + export MOLE_PAM_SUDO_FILE="$pam_sudo" + export MOLE_PAM_SUDO_LOCAL_FILE="$pam_sudo_local" + + run check_touchid_support + [ "$status" -eq 0 ] + + printf 'auth include pam_opendirectory.so\n' > "$pam_sudo" + + run check_touchid_support + [ "$status" -eq 1 ] +} + @test "has_sudo_session returns 1 when no sudo session" { # shellcheck disable=SC2329 sudo() { return 1; } @@ -26,7 +44,7 @@ setup() { } export -f sudo - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; has_sudo_session" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; has_sudo_session" [ "$status" -eq 1 ] # Expected: no sudo session } @@ -41,19 +59,19 @@ setup() { export -f sudo local pid - pid=$(bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _start_sudo_keepalive") + pid=$(/bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _start_sudo_keepalive") - [[ "$pid" =~ ^[0-9]+$ ]] + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true } @test "_stop_sudo_keepalive handles invalid PID gracefully" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _stop_sudo_keepalive ''" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _stop_sudo_keepalive ''" [ "$status" -eq 0 ] - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _stop_sudo_keepalive '99999'" + run /bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _stop_sudo_keepalive '99999'" [ "$status" -eq 0 ] } @@ -62,11 +80,69 @@ setup() { @test "stop_sudo_session cleans up keepalive process" { export MOLE_SUDO_KEEPALIVE_PID="99999" - run bash -c "export MOLE_SUDO_KEEPALIVE_PID=99999; source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; stop_sudo_session" + run /bin/bash -c "export MOLE_SUDO_KEEPALIVE_PID=99999; source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; stop_sudo_session" [ "$status" -eq 0 ] } @test "sudo manager initializes global state correctly" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; echo \$MOLE_SUDO_ESTABLISHED") + result=$(/bin/bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; echo \$MOLE_SUDO_ESTABLISHED") [[ "$result" == "false" ]] || [[ -z "$result" ]] } + +@test "request_sudo_access clears four lines in clamshell mode when Touch ID hint is shown" { + run /bin/bash -c ' + unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + source "'"$PROJECT_ROOT"'/lib/core/common.sh" + source "'"$PROJECT_ROOT"'/lib/core/sudo.sh" + + tty_file="$(mktemp)" + chmod 600 "$tty_file" + + sudo() { + case "$1" in + -n) return 1 ;; + -k) return 0 ;; + *) return 1 ;; + esac + } + tty() { printf "%s\n" "$tty_file"; } + is_clamshell_mode() { return 0; } + check_touchid_support() { return 0; } + _request_password() { return 0; } + safe_clear_lines() { printf "CLEAR:%s\n" "$1"; } + + request_sudo_access "Admin access required" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAR:4"* ]] +} + +@test "request_sudo_access keeps three-line cleanup in clamshell mode without Touch ID" { + run /bin/bash -c ' + unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + source "'"$PROJECT_ROOT"'/lib/core/common.sh" + source "'"$PROJECT_ROOT"'/lib/core/sudo.sh" + + tty_file="$(mktemp)" + chmod 600 "$tty_file" + + sudo() { + case "$1" in + -n) return 1 ;; + -k) return 0 ;; + *) return 1 ;; + esac + } + tty() { printf "%s\n" "$tty_file"; } + is_clamshell_mode() { return 0; } + check_touchid_support() { return 1; } + _request_password() { return 0; } + safe_clear_lines() { printf "CLEAR:%s\n" "$1"; } + + request_sudo_access "Admin access required" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAR:3"* ]] +} diff --git a/Resources/mole/tests/manage_whitelist.bats b/Resources/mole/tests/manage_whitelist.bats index fdaa659..c2bc072 100644 --- a/Resources/mole/tests/manage_whitelist.bats +++ b/Resources/mole/tests/manage_whitelist.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" mkdir -p "$HOME" WHITELIST_PATH="$HOME/.config/mole/whitelist" @@ -28,7 +35,7 @@ setup() { @test "patterns_equivalent treats paths with tilde expansion as equal" { local status - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/test\""; then + if HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/test\""; then status=0 else status=$? @@ -38,7 +45,7 @@ setup() { @test "patterns_equivalent distinguishes different paths" { local status - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/other\""; then + if HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/other\""; then status=0 else status=$? @@ -47,9 +54,9 @@ setup() { } @test "save_whitelist_patterns keeps unique entries and preserves header" { - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/foo\" \"\$HOME/.cache/foo\" \"\$HOME/.cache/bar\"" + HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/foo\" \"\$HOME/.cache/foo\" \"\$HOME/.cache/bar\"" - [[ -f "$WHITELIST_PATH" ]] + [[ -f "$WHITELIST_PATH" ]] || return 1 lines=() while IFS= read -r line; do @@ -62,8 +69,8 @@ setup() { @test "load_whitelist falls back to defaults when config missing" { rm -f "$WHITELIST_PATH" - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; rm -f \"\$HOME/.config/mole/whitelist\"; load_whitelist; printf '%s\n' \"\${CURRENT_WHITELIST_PATTERNS[@]}\"" > "$HOME/current_whitelist.txt" - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; printf '%s\n' \"\${DEFAULT_WHITELIST_PATTERNS[@]}\"" > "$HOME/default_whitelist.txt" + HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; rm -f \"\$HOME/.config/mole/whitelist\"; load_whitelist; printf '%s\n' \"\${CURRENT_WHITELIST_PATTERNS[@]}\"" > "$HOME/current_whitelist.txt" + HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; printf '%s\n' \"\${DEFAULT_WHITELIST_PATTERNS[@]}\"" > "$HOME/default_whitelist.txt" current=() while IFS= read -r line; do @@ -81,14 +88,14 @@ setup() { @test "is_whitelisted matches saved patterns exactly" { local status - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/unique-pattern\""; then + if HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/unique-pattern\""; then status=0 else status=$? fi [ "$status" -eq 0 ] - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/other-pattern\""; then + if HOME="$HOME" /bin/bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/other-pattern\""; then status=0 else status=$? @@ -96,29 +103,191 @@ setup() { [ "$status" -ne 0 ] } +@test "optimize whitelist ignores and does not resave removed task ids" { + local optimize_path="$HOME/.config/mole/whitelist_optimize" + mkdir -p "$(dirname "$optimize_path")" + printf 'dock_refresh\nmemory_pressure_relief\ncache_refresh\n' > "$optimize_path" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +load_whitelist optimize +printf 'loaded:%s\n' "${CURRENT_WHITELIST_PATTERNS[@]}" +save_whitelist_patterns optimize dock_refresh memory_pressure_relief cache_refresh +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"loaded:cache_refresh"* ]] || return 1 + [[ "$output" != *"loaded:dock_refresh"* ]] || return 1 + [[ "$output" != *"loaded:memory_pressure_relief"* ]] || return 1 + grep -qFx 'cache_refresh' "$optimize_path" + run grep -qFx 'dock_refresh' "$optimize_path" + [ "$status" -eq 1 ] + run grep -qFx 'memory_pressure_relief' "$optimize_path" + [ "$status" -eq 1 ] +} + +@test "load_whitelist merges FINDER_METADATA into an existing custom file (#1396)" { + mkdir -p "$(dirname "$WHITELIST_PATH")" + printf '%s\n' "$HOME/.cache/custom-keep/*" > "$WHITELIST_PATH" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +load_whitelist +has_sentinel=false +has_custom=false +for p in "${CURRENT_WHITELIST_PATTERNS[@]}"; do + [[ "$p" == "$FINDER_METADATA_SENTINEL" ]] && has_sentinel=true + [[ "$p" == "$HOME/.cache/custom-keep/*" ]] && has_custom=true +done +printf 'sentinel=%s custom=%s count=%s\n' "$has_sentinel" "$has_custom" "${#CURRENT_WHITELIST_PATTERNS[@]}" +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"sentinel=true"* ]] || { echo "$output"; return 1; } + [[ "$output" == *"custom=true"* ]] || { echo "$output"; return 1; } +} + +@test "ensure_safety_whitelist_patterns is idempotent and preserves custom entries" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +declare -a WHITELIST_PATTERNS=("$HOME/.cache/custom-keep/*" "$FINDER_METADATA_SENTINEL") +declare -a CURRENT_WHITELIST_PATTERNS=("${WHITELIST_PATTERNS[@]}") +ensure_safety_whitelist_patterns +ensure_safety_whitelist_patterns +sentinel_count=0 +custom_count=0 +for p in "${WHITELIST_PATTERNS[@]}"; do + [[ "$p" == "$FINDER_METADATA_SENTINEL" ]] && sentinel_count=$((sentinel_count + 1)) + [[ "$p" == "$HOME/.cache/custom-keep/*" ]] && custom_count=$((custom_count + 1)) +done +printf 'sentinel=%s custom=%s total=%s\n' "$sentinel_count" "$custom_count" "${#WHITELIST_PATTERNS[@]}" +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"sentinel=1"* ]] || { echo "$output"; return 1; } + [[ "$output" == *"custom=1"* ]] || { echo "$output"; return 1; } + [[ "$output" == *"total=2"* ]] || { echo "$output"; return 1; } +} + +@test "legacy optimize whitelist with only removed task ids migrates safely on Bash 3.2" { + local legacy_path="$HOME/.config/mole/whitelist_checks" + local optimize_path="$HOME/.config/mole/whitelist_optimize" + mkdir -p "$(dirname "$legacy_path")" + printf 'dock_refresh\nmemory_pressure_relief\n' > "$legacy_path" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +load_whitelist optimize +[[ ${#CURRENT_WHITELIST_PATTERNS[@]} -eq 0 ]] +printf 'survived\n' +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"survived"* ]] || return 1 + [[ -f "$optimize_path" ]] || return 1 + run grep -qFx 'dock_refresh' "$optimize_path" + [ "$status" -eq 1 ] +} + +@test "whitelist inventory exposes LM Studio app cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +get_all_cache_items +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"LM Studio app cache|\$HOME/Library/Caches/com.lmstudio.lmstudio/*|ai_ml_cache"* ]] || return 1 + [[ "$output" != *".cache/lm-studio"* ]] +} + +@test "whitelist inventory exposes Codex staging and Tart caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +get_all_cache_items +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex Desktop update staging|\$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation|ai_ml_cache"* ]] || return 1 + [[ "$output" == *"Tart OCI/IPSW cache|\$HOME/.tart/cache|container_cache"* ]] || return 1 +} + +@test "whitelist inventory exposes Chrome AI model stores" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +get_all_cache_items +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Chrome on-device AI models|\$HOME/Library/Application Support/Google/Chrome/OptGuideOnDevice*/*|ai_ml_cache"* ]] || return 1 + [[ "$output" == *"Chrome optimization guide models|\$HOME/Library/Application Support/Google/Chrome/optimization_guide_model_store/*|ai_ml_cache"* ]] || return 1 + [[ "$output" == *"Chrome browser cache|\$HOME/Library/Caches/Google/Chrome/*|browser_cache"* ]] || return 1 +} + @test "mo clean --whitelist persists selections" { whitelist_file="$HOME/.config/mole/whitelist" mkdir -p "$(dirname "$whitelist_file")" - run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" + run /bin/bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" [ "$status" -eq 0 ] first_pattern=$(grep -v '^[[:space:]]*#' "$whitelist_file" | grep -v '^[[:space:]]*$' | head -n 1) [ -n "$first_pattern" ] - run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$' \\n' | HOME='$HOME' ./mo clean --whitelist" + run /bin/bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$' \\n' | HOME='$HOME' ./mo clean --whitelist" [ "$status" -eq 0 ] run grep -Fxq "$first_pattern" "$whitelist_file" [ "$status" -eq 1 ] - run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" + run /bin/bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" [ "$status" -eq 0 ] run grep -Fxq "$first_pattern" "$whitelist_file" [ "$status" -eq 1 ] } +@test "mo clean --whitelist cancel preserves existing file (#807)" { + whitelist_file="$HOME/.config/mole/whitelist" + mkdir -p "$(dirname "$whitelist_file")" + + run /bin/bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" + [ "$status" -eq 0 ] + [[ -f "$whitelist_file" ]] || return 1 + before_hash=$(shasum "$whitelist_file" | awk '{print $1}') + + run /bin/bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf 'q' | HOME='$HOME' ./mo clean --whitelist" + [ "$status" -eq 0 ] + [[ "$output" == *"Cancelled"* ]] || return 1 + after_hash=$(shasum "$whitelist_file" | awk '{print $1}') + [ "$before_hash" = "$after_hash" ] +} + +@test "whitelist validation accepts special and non-ASCII characters (#749)" { + # Verify the [[:cntrl:]] guard accepts valid macOS path chars and rejects control chars. + run /bin/bash --noprofile --norc -c " + accept() { [[ ! \"\$1\" =~ [[:cntrl:]] ]] && echo ACCEPT || echo REJECT; } + accept '/Users/me/Library/Application Support/Foo & Bar' + accept '/Users/me/Library/Caches/com.example+beta' + accept '/Users/me/Library/Caches/com.example(Preview)' + accept '/Users/me/Library/Caches/บริษัท' + accept '/Users/me/Library/Caches/app,[test]' + [[ \$'line\nbreak' =~ [[:cntrl:]] ]] && echo REJECT_NEWLINE || echo FAIL + [[ \$'tab\there' =~ [[:cntrl:]] ]] && echo REJECT_TAB || echo FAIL + " + [ "$status" -eq 0 ] + [[ "$output" == *"ACCEPT"* ]] || return 1 + [[ "$output" != *"REJECT /Users"* ]] || return 1 + [[ "$output" == *"REJECT_NEWLINE"* ]] || return 1 + [[ "$output" == *"REJECT_TAB"* ]] +} + @test "is_path_whitelisted protects parent directories of whitelisted nested paths" { local status - if HOME="$HOME" bash --noprofile --norc -c " + if HOME="$HOME" /bin/bash --noprofile --norc -c " source '$PROJECT_ROOT/lib/core/base.sh' source '$PROJECT_ROOT/lib/core/app_protection.sh' WHITELIST_PATTERNS=(\"\$HOME/Library/Caches/org.R-project.R/R/renv\") @@ -130,3 +299,112 @@ setup() { fi [ "$status" -eq 0 ] } + +@test "default whitelist protects tealdeer cache parent for tldr pages" { + local status + if HOME="$HOME" /bin/bash --noprofile --norc -c " + source '$PROJECT_ROOT/lib/manage/whitelist.sh' + rm -f \"\$HOME/.config/mole/whitelist\" + load_whitelist + is_path_whitelisted \"\$HOME/Library/Caches/tealdeer\" + "; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} + +# Regression for #724: when a caller concats a glob expansion that ends +# in `/` with a sub-path that starts with `/`, the result contains `//`. +# Without slash collapsing, the comparison with a single-slash whitelist +# entry always fails and Chrome MV3 service workers get wiped. +@test "is_path_whitelisted matches entries against paths containing double slashes (#724)" { + local status + if HOME="$HOME" /bin/bash --noprofile --norc -c " + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + WHITELIST_PATTERNS=(\"\$HOME/Library/Application Support/Google/Chrome/Default/Service Worker/CacheStorage\") + is_path_whitelisted \"\$HOME/Library/Application Support/Google/Chrome/Default//Service Worker/CacheStorage\" + "; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} + +# safe_find_delete must consult the user whitelist on every match. Per-caller +# gates were missed in past releases (#710, #724, #738, #744); enforcing it +# inside the iterator makes whitelist protection structural rather than +# case-by-case. Regression for #757. +@test "safe_find_delete respects user whitelist for matched paths (#757)" { + local target_dir="$HOME/safe_find_delete_target" + local protected_file="$target_dir/protected.mat" + local removable_file="$target_dir/removable.mat" + mkdir -p "$target_dir" + : > "$protected_file" + : > "$removable_file" + touch -t 202001010000 "$protected_file" "$removable_file" + + HOME="$HOME" /bin/bash --noprofile --norc -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + source '$PROJECT_ROOT/lib/core/file_ops.sh' + WHITELIST_PATTERNS=(\"$target_dir/protected.mat\") + safe_find_delete \"$target_dir\" '*' 1 f + " > /dev/null + + [[ -f "$protected_file" ]] || { + printf 'protected file was unexpectedly removed\n' >&2 + return 1 + } + [[ ! -f "$removable_file" ]] || { + printf 'removable file was unexpectedly kept\n' >&2 + return 1 + } +} + +@test "safe_find_delete respects user whitelist glob patterns (#757)" { + local target_dir="$HOME/idleassetsd_target" + local protected_file="$target_dir/Customer/cbbim-w-prod.mat" + local removable_file="$target_dir/other/extra.dat" + mkdir -p "$target_dir/Customer" "$target_dir/other" + : > "$protected_file" + : > "$removable_file" + touch -t 202001010000 "$protected_file" "$removable_file" + + HOME="$HOME" /bin/bash --noprofile --norc -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + source '$PROJECT_ROOT/lib/core/file_ops.sh' + WHITELIST_PATTERNS=(\"$target_dir/Customer/*\") + safe_find_delete \"$target_dir\" '*' 1 f + " > /dev/null + + [[ -f "$protected_file" ]] || { + printf 'glob-whitelisted file was unexpectedly removed\n' >&2 + return 1 + } + [[ ! -f "$removable_file" ]] || { + printf 'non-whitelisted file was unexpectedly kept\n' >&2 + return 1 + } +} + +@test "is_path_whitelisted collapses slashes in whitelist entries too (#724)" { + local status + if HOME="$HOME" /bin/bash --noprofile --norc -c " + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + WHITELIST_PATTERNS=(\"\$HOME//Library//Caches//chrome-sw\") + is_path_whitelisted \"\$HOME/Library/Caches/chrome-sw\" + "; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} diff --git a/Resources/mole/tests/menu_trap_restore.bats b/Resources/mole/tests/menu_trap_restore.bats new file mode 100644 index 0000000..470a575 --- /dev/null +++ b/Resources/mole/tests/menu_trap_restore.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats +# The paginated/simple selectors override EXIT/INT/TERM while they run. +# They must restore the caller's traps on exit, or an outer handler (e.g. +# bin/uninstall.sh's session-end operation-log writer) is silently dropped. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "paginated_multi_select preserves the caller's EXIT trap" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +# Neutralize terminal control so the menu runs headless. +enter_alt_screen() { :; } +leave_alt_screen() { :; } +stty() { :; } +tput() { :; } +clear() { :; } +printf_at() { :; } +export MOLE_MANAGED_ALT_SCREEN=1 +export MOLE_READ_KEY_FORCE_CHAR=1 + +# Arm an outer EXIT trap exactly like bin/uninstall.sh does. +trap 'echo OUTER_EXIT_MARKER' EXIT + +# A single ENTER confirms the current (empty) selection. +# Feed input via redirection, NOT a pipe: a pipe would run the menu in a +# subshell and hide its trap manipulation from this shell. +paginated_multi_select "Pick" "alpha" "beta" < <(printf '\n') > /dev/null 2>&1 || true + +# The caller's EXIT trap must still be armed after the menu returns. +current_exit_trap=$(trap -p EXIT) +[[ "$current_exit_trap" == *OUTER_EXIT_MARKER* ]] || { echo "OUTER EXIT TRAP LOST: $current_exit_trap"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + # The outer trap should also actually fire when the shell exits. + [[ "$output" == *OUTER_EXIT_MARKER* ]] || return 1 +} + +@test "paginated_multi_select restores a caller that had no EXIT trap" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +enter_alt_screen() { :; } +leave_alt_screen() { :; } +stty() { :; } +tput() { :; } +export MOLE_MANAGED_ALT_SCREEN=1 +export MOLE_READ_KEY_FORCE_CHAR=1 + +# Feed input via redirection, NOT a pipe: a pipe would run the menu in a +# subshell and hide its trap manipulation from this shell. +paginated_multi_select "Pick" "alpha" "beta" < <(printf '\n') > /dev/null 2>&1 || true + +# No caller EXIT trap existed; the menu's own cleanup trap must be gone, +# not left dangling. +current_exit_trap=$(trap -p EXIT) +[[ -z "$current_exit_trap" ]] || { echo "STRAY EXIT TRAP: $current_exit_trap"; exit 1; } +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } +} + +@test "paginated_multi_select does not replace the caller cleanup function" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +enter_alt_screen() { :; } +leave_alt_screen() { :; } +stty() { :; } +tput() { :; } +export MOLE_MANAGED_ALT_SCREEN=1 + +cleanup() { echo OUTER_CLEANUP_MARKER; } +trap cleanup EXIT +original_cleanup=$(declare -f cleanup) + +paginated_multi_select "Pick" "alpha" "beta" < <(printf 'q') > /dev/null 2>&1 || true + +current_cleanup=$(declare -f cleanup) +[[ "$current_cleanup" == "$original_cleanup" ]] || { + printf 'caller cleanup function was replaced:\n%s\n' "$current_cleanup" >&2 + exit 1 +} +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *OUTER_CLEANUP_MARKER* ]] || return 1 +} diff --git a/Resources/mole/tests/no_color.bats b/Resources/mole/tests/no_color.bats new file mode 100644 index 0000000..af40cfa --- /dev/null +++ b/Resources/mole/tests/no_color.bats @@ -0,0 +1,37 @@ +#!/usr/bin/env bats +# Verify NO_COLOR support per https://no-color.org. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "NO_COLOR strips ANSI escapes from base color vars" { + run env NO_COLOR=1 PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +printf '%s' "<${GREEN}><${RED}><${YELLOW}><${BLUE}><${CYAN}><${PURPLE}><${PURPLE_BOLD}><${GRAY}><${NC}>" +EOF + [ "$status" -eq 0 ] + [ "$output" = "<><><><><><><><><>" ] +} + +@test "default keeps ANSI escapes in base color vars" { + run env -u NO_COLOR PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +printf '%s' "${GREEN}x${NC}" +EOF + [ "$status" -eq 0 ] + [ "$output" = $'\033[0;32mx\033[0m' ] +} + +@test "empty NO_COLOR keeps ANSI escapes per spec" { + run env NO_COLOR="" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +printf '%s' "${RED}y${NC}" +EOF + [ "$status" -eq 0 ] + [ "$output" = $'\033[0;31my\033[0m' ] +} diff --git a/Resources/mole/tests/optimize.bats b/Resources/mole/tests/optimize.bats index 00a7b11..435dd84 100644 --- a/Resources/mole/tests/optimize.bats +++ b/Resources/mole/tests/optimize.bats @@ -1,64 +1,67 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } -@test "needs_permissions_repair returns true when home not writable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" USER="tester" bash --noprofile --norc <<'EOF' +@test "needs_permissions_repair returns true when home owner differs" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" USER="tester" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -stat() { echo "root"; } -export -f stat if needs_permissions_repair; then echo "needs" fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"needs"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"needs"* ]] } -@test "has_bluetooth_hid_connected detects HID" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "needs_permissions_repair ignores PATH-provided GNU stat (#1196)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" USER="$USER" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -system_profiler() { - cat << 'OUT' -Bluetooth: - Apple Magic Mouse: - Connected: Yes - Type: Mouse -OUT + +stat() { + printf ' File: "%s"\n ID: 10000110000001a Namelen: ? Type: apfs\n' "$HOME" + return 1 } -export -f system_profiler -if has_bluetooth_hid_connected; then - echo "hid" +export -f stat + +if needs_permissions_repair; then + echo "needs" +else + echo "optimal" fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"hid"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"optimal"* ]] || return 1 + [[ "$output" != *"needs"* ]] } @test "is_ac_power detects AC power" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/optimize/tasks.sh" pmset() { echo "AC Power"; } @@ -68,134 +71,1942 @@ if is_ac_power; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"ac"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"ac"* ]] } -@test "is_memory_pressure_high detects warning" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "dry-run keeps healthy conditional system tasks unchanged" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 MOLE_ASSUME_VPN_ACTIVE=0 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -memory_pressure() { echo "warning"; } -export -f memory_pressure -if is_memory_pressure_high; then - echo "high" -fi + +mkdir -p "$HOME/bin" +printf '#!/bin/bash\nexit 0\n' > "$HOME/bin/route" +printf '#!/bin/bash\nexit 0\n' > "$HOME/bin/dscacheutil" +chmod +x "$HOME/bin/route" "$HOME/bin/dscacheutil" +PATH="$HOME/bin:$PATH" +needs_permissions_repair() { return 1; } + +execute_optimization network_stack_optimize +execute_optimization disk_permissions_repair +[[ "$(optimize_outcome_count unchanged)" == "2" ]] || exit 1 EOF - [ "$status" -eq 0 ] - [[ "$output" == *"high"* ]] + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Network stack already optimal"* ]] || return 1 + [[ "$output" == *"User directory permissions already optimal"* ]] || return 1 } @test "opt_system_maintenance reports DNS and Spotlight" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" flush_dns_cache() { return 0; } -mdutil() { echo "Indexing enabled."; } -opt_system_maintenance +mkdir -p "$HOME/bin" +printf '#!/bin/bash\necho "Indexing enabled."\n' > "$HOME/bin/mdutil" +chmod +x "$HOME/bin/mdutil" +PATH="$HOME/bin:$PATH" +execute_optimization system_maintenance EOF - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache flushed"* ]] - [[ "$output" == *"Spotlight index verified"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"DNS cache flushed"* ]] || return 1 + [[ "$output" == *"Spotlight index verified"* ]] } @test "opt_network_optimization refreshes DNS" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" flush_dns_cache() { return 0; } -opt_network_optimization +execute_optimization network_optimization +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DNS cache refreshed"* ]] || return 1 + [[ "$output" == *"mDNSResponder restarted"* ]] +} + +@test "fix_broken_preferences repairs only non-Apple preference plists" { + local test_home="$HOME/fixprefs-basic" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +CALL_LOG="$HOME/fix-broken-preferences.log" +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs/ByHost" +touch \ + "$prefs/com.example.broken.plist" \ + "$prefs/com.apple.broken.plist" \ + "$prefs/loginwindow.plist" \ + "$prefs/ByHost/com.example.byhost.plist" \ + "$prefs/ByHost/loginwindow.plist" + +plutil() { + echo "lint:$2" >> "$CALL_LOG" + return 1 +} +safe_remove() { + echo "remove:$1" >> "$CALL_LOG" +} + +count=$(fix_broken_preferences) +echo "count=$count" +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=3"* ]] || return 1 + [[ "$output" == *"remove:$test_home/Library/Preferences/com.example.broken.plist"* ]] || return 1 + [[ "$output" == *"remove:$test_home/Library/Preferences/ByHost/com.example.byhost.plist"* ]] || return 1 + [[ "$output" == *"remove:$test_home/Library/Preferences/ByHost/loginwindow.plist"* ]] || return 1 + [[ "$output" != *"lint:$test_home/Library/Preferences/com.apple.broken.plist"* ]] || return 1 + [[ "$output" != *"lint:$test_home/Library/Preferences/loginwindow.plist"* ]] +} + +@test "fix_broken_preferences does not count safe_remove failures" { + local test_home="$HOME/fixprefs-remove-failure" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs" +touch "$prefs/com.example.broken.plist" + +plutil() { return 1; } +safe_remove() { return 1; } + +count=$(fix_broken_preferences) +echo "count=$count" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] +} + +@test "opt_fix_broken_configs debug lists only successfully repaired paths" { + local test_home="$HOME/fixprefs-debug-paths" + local repaired="$test_home/Library/Preferences/com.example.repaired.plist" + local failed="$test_home/Library/Preferences/com.example.failed.plist" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs" +touch \ + "$prefs/com.example.repaired.plist" \ + "$prefs/com.example.failed.plist" + +plutil() { return 1; } +safe_remove() { + [[ "$1" != *"failed.plist" ]] +} + +execute_optimization fix_broken_configs +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"Repaired 1 corrupted preference files"* ]] || return 1 + [[ "$output" == *"Removed corrupted preference:"* ]] || return 1 + [[ "$output" == *"$repaired"* ]] || return 1 + [[ "$output" != *"$failed"* ]] || return 1 +} + +@test "fix_broken_preferences does not count protected Adobe plists" { + local test_home="$HOME/fixprefs-protected" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +prefs="$HOME/Library/Preferences" +plist="$prefs/com.adobe.Photoshop.uxp_com.adobe.ccx.start.plist" +mkdir -p "$prefs" +touch "$plist" + +plutil() { return 1; } + +count=$(fix_broken_preferences) +echo "count=$count" +[[ -f "$plist" ]] && echo "still-present" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] || return 1 + [[ "$output" == *"still-present"* ]] +} + +@test "fix_broken_preferences lints plists in one batch instead of per file" { + local test_home="$HOME/fixprefs-batch" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +CALL_LOG="$HOME/plutil-calls.log" +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs" +touch \ + "$prefs/com.example.one.plist" \ + "$prefs/com.example.two.plist" \ + "$prefs/com.example.three.plist" + +plutil() { + echo "call" >> "$CALL_LOG" + return 0 +} + +count=$(fix_broken_preferences) +echo "count=$count" +echo "calls=$(wc -l < "$CALL_LOG" | tr -d ' ')" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] || return 1 + [[ "$output" == *"calls=1"* ]] || return 1 +} + +@test "opt_fix_broken_configs reports partial results when scan hits its time budget" { + local test_home="$HOME/fixprefs-budget" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TIMEOUT_HINT_SCAN_SEC=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs" +touch "$prefs/com.example.slow.plist" + +plutil() { return 0; } + +execute_optimization fix_broken_configs +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Preference scan hit its time budget"* ]] || return 1 + [[ "$output" != *"All preference files valid"* ]] || return 1 +} + +@test "opt_cache_refresh reuses measured cache sizes for deletion" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +CALL_LOG="$HOME/cache-refresh.log" +cache_dir="$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" +mkdir -p "$cache_dir" +touch "$cache_dir/test.db" + +get_path_size_kb() { + echo "size:$1" >> "$CALL_LOG" + echo "42" +} +should_protect_path() { + return 1 +} +safe_remove() { + echo "remove:$1:${3:-missing}" >> "$CALL_LOG" +} + +execute_optimization cache_refresh +echo "cleaned=${OPTIMIZE_CACHE_CLEANED_KB:-missing}" +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QuickLook thumbnails refreshed"* ]] || return 1 + [[ "$output" == *"cleaned=42"* ]] || return 1 + [[ "$output" == *"remove:$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache:42"* ]] || return 1 + [ "$(grep -c "size:$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" <<< "$output")" -eq 1 ] +} + +@test "optimize scans never delete candidates from partial find output" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +saved="$HOME/Library/Saved Application State/Partial.savedState" +shared="$HOME/Library/Application Support/com.apple.sharedfilelist/Partial.sfl3" +mkdir -p "$saved" "${shared%/*}" +touch "$shared" +safe_remove() { + printf 'UNEXPECTED_REMOVE:%s\n' "$1" + return 0 +} +run_with_timeout() { + shift + case "$*" in + *"Saved Application State"*) printf '%s\0' "$saved" ;; + *) printf '%s\0' "$shared" ;; + esac + return 73 +} + +optimize_task_start +opt_saved_state_cleanup +optimize_task_finish saved_state_cleanup +optimize_task_start +opt_shared_file_list_repair +optimize_task_finish shared_file_list_repair +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "optimize saved-state cleanup propagates deletion interruption" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +saved="$HOME/Library/Saved Application State/Interrupted.savedState" +mkdir -p "$saved" +run_with_timeout() { + shift + printf '%s\0' "$saved" +} +should_protect_path() { return 1; } +safe_remove() { return 130; } +optimize_task_start +rc=0 +opt_saved_state_cleanup || rc=$? +printf 'RC=%s\n' "$rc" +[[ $rc -eq 130 ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=130"* ]] +} + +@test "opt_quarantine_cleanup reports clean when no database" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +execute_optimization quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] +} + +@test "opt_quarantine_cleanup reports entries in dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Stub whitelist check to always allow. +should_protect_path() { return 1; } +# Create a mock quarantine database with entries. +mkdir -p "$HOME/Library/Preferences" +local_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2" +sqlite3 "$local_db" "CREATE TABLE IF NOT EXISTS LSQuarantineEvent (id TEXT);" +sqlite3 "$local_db" "INSERT INTO LSQuarantineEvent VALUES ('test1');" +sqlite3 "$local_db" "INSERT INTO LSQuarantineEvent VALUES ('test2');" +execute_optimization quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Quarantine history cleared"* ]] || return 1 + [[ "$output" == *"2 entries"* ]] +} + +@test "opt_quarantine_cleanup skips when sqlite3 unavailable" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +export PATH="/nonexistent" +execute_optimization quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"sqlite3 unavailable"* ]] +} + +@test "execute_optimization dispatches quarantine_cleanup" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +opt_quarantine_cleanup() { echo "quarantine"; optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; } +optimize_outcomes_reset +execute_optimization quarantine_cleanup +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 EOF - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache refreshed"* ]] - [[ "$output" == *"mDNSResponder restarted"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"quarantine"* ]] } @test "opt_sqlite_vacuum reports sqlite3 unavailable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" +pgrep() { return 1; } export PATH="/nonexistent" -opt_sqlite_vacuum +execute_optimization sqlite_vacuum EOF - [ "$status" -eq 0 ] - [[ "$output" == *"sqlite3 unavailable"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"sqlite3 unavailable"* ]] } -@test "opt_font_cache_rebuild succeeds in dry-run" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +@test "opt_sqlite_vacuum reports failed when only some databases optimize" { + run env HOME="$HOME/sqlite-partial" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_font_cache_rebuild + +mkdir -p "$HOME/Library/Messages" "$HOME/Library/Safari" +touch "$HOME/Library/Messages/chat.db" "$HOME/Library/Safari/History.db" +pgrep() { return 1; } +file() { echo "SQLite 3.x database"; } +get_file_size() { echo 1; } +run_with_timeout() { + shift + "$@" +} +sqlite3() { + case "$2" in + "PRAGMA page_count; PRAGMA freelist_count;") printf '100\n10\n' ;; + "PRAGMA integrity_check;") echo "ok" ;; + "VACUUM;") [[ "$1" == *"chat.db" ]] ;; + esac +} +export -f pgrep file get_file_size run_with_timeout sqlite3 + +execute_optimization sqlite_vacuum +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Font cache cleared"* ]] + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Optimized 1 databases"* ]] || return 1 + [[ "$output" == *"Failed on 1 databases"* ]] || return 1 } -@test "opt_dock_refresh clears cache files" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +@test "opt_sqlite_vacuum reports a failed integrity probe" { + run env HOME="$HOME/sqlite-integrity" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -mkdir -p "$HOME/Library/Application Support/Dock" -touch "$HOME/Library/Application Support/Dock/test.db" -safe_remove() { return 0; } -opt_dock_refresh +db="$HOME/Library/Messages/chat.db" +mkdir -p "$(dirname "$db")" +touch "$db" +pgrep() { return 1; } +file() { echo "SQLite 3.x database"; } +get_file_size() { echo 1; } +run_with_timeout() { + shift + if [[ "$3" == "PRAGMA page_count; PRAGMA freelist_count;" ]]; then + printf '100\n10\n' + return 0 + fi + return 7 +} +sqlite3() { return 0; } +export -f pgrep file get_file_size run_with_timeout sqlite3 + +execute_optimization sqlite_vacuum +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed on 1 databases"* ]] || return 1 +} + +@test "opt_sqlite_vacuum reports oversized databases as skipped" { + run env HOME="$HOME/sqlite-oversized" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +db="$HOME/Library/Messages/chat.db" +mkdir -p "$(dirname "$db")" +touch "$db" +pgrep() { return 1; } +file() { echo "SQLite 3.x database"; } +get_file_size() { echo $((MOLE_SQLITE_MAX_SIZE + 1)); } +export -f pgrep file get_file_size + +execute_optimization sqlite_vacuum +[[ "$(optimize_outcome_count skipped)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Skipped 1 databases over the 100 MB safety limit"* ]] || return 1 +} + +@test "optimize does not auto-fix Gatekeeper anymore" { + run grep -n "spctl --master-enable\\|SECURITY_FIXES+=([\"']gatekeeper|" "$PROJECT_ROOT/bin/optimize.sh" + + [ "$status" -eq 1 ] +} + +@test "opt_prevent_network_dsstore dry-run reports enabled" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + case "$1" in + read) return 1 ;; + write) return 0 ;; + esac +} +execute_optimization prevent_network_dsstore +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *".DS_Store prevention enabled"* ]] +} + +@test "opt_prevent_network_dsstore idempotent when already set" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + echo "1" + return 0 + fi + return 0 +} +execute_optimization prevent_network_dsstore +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already enabled"* ]] +} + +@test "opt_prevent_network_dsstore reports a partial write failure" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + return 1 + fi + [[ "$3" == "DSDontWriteNetworkStores" ]] +} +export -f defaults + +execute_optimization prevent_network_dsstore +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *".DS_Store prevention enabled"* ]] || return 1 + [[ "$output" == *"Failed to enable .DS_Store prevention for 1 volume type(s)"* ]] || return 1 +} + +@test "opt_legacy_overrides_audit stays silent-positive when defaults are in effect" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then return 1; fi + echo "DELETE_CALLED:$*" + return 0 +} +execute_optimization legacy_overrides_audit +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"No legacy App Nap or disk-image overrides found"* ]] || return 1 + [[ "$output" != *"DELETE_CALLED"* ]] || return 1 +} + +@test "opt_legacy_overrides_audit removes App Nap and skip-verify overrides (#1242 #1243)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + # -g NSAppSleepDisabled and diskimages skip-verify are overridden; + # the other skip-verify variants stay at the OS default. + if [[ "$2" == "-g" && "$3" == "NSAppSleepDisabled" ]]; then echo "1"; return 0; fi + if [[ "$2" == "com.apple.frameworks.diskimages" && "$3" == "skip-verify" ]]; then echo "1"; return 0; fi + return 1 + fi + echo "DELETE_CALLED:$2 $3" + return 0 +} +execute_optimization legacy_overrides_audit +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"DELETE_CALLED:-g NSAppSleepDisabled"* ]] || return 1 + [[ "$output" == *"DELETE_CALLED:com.apple.frameworks.diskimages skip-verify"* ]] || return 1 + [[ "$output" != *"skip-verify-locked"* ]] || return 1 + [[ "$output" == *"Removed override: App Nap disabled globally"* ]] || return 1 + [[ "$output" == *"Removed override: Disk-image verification skipped (skip-verify)"* ]] || return 1 +} + +@test "opt_legacy_overrides_audit dry-run previews without deleting" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + if [[ "$2" == "-g" && "$3" == "NSAppSleepDisabled" ]]; then echo "1"; return 0; fi + return 1 + fi + echo "DELETE_CALLED:$*" + return 0 +} +execute_optimization legacy_overrides_audit +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Would remove override: App Nap disabled globally"* ]] || return 1 + [[ "$output" != *"DELETE_CALLED"* ]] || return 1 +} + +@test "opt_legacy_overrides_audit honors plist whitelist before repair" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + if [[ "$2" == "-g" && "$3" == "NSAppSleepDisabled" ]]; then echo "1"; return 0; fi + return 1 + fi + echo "DELETE_CALLED:$*" + return 0 +} +is_path_whitelisted() { [[ "$1" == *".GlobalPreferences.plist" ]]; } +execute_optimization legacy_overrides_audit EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Dock cache cleared"* ]] - [[ "$output" == *"Dock refreshed"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Skipped (whitelisted): App Nap disabled globally"* ]] || return 1 + [[ "$output" != *"DELETE_CALLED"* ]] || return 1 +} + +@test "opt_legacy_overrides_audit reports failed after a partial repair" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + echo "1" + return 0 + fi + [[ "$3" == "NSAppSleepDisabled" ]] +} +export -f defaults + +execute_optimization legacy_overrides_audit +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Removed override: App Nap disabled globally"* ]] || return 1 + [[ "$output" == *"Could not remove override"* ]] || return 1 +} + +# cc31ee3a ("Remove optimize confirmation prompt, run all tasks automatically") +# flipped every health item to safe=true before V1.34.0. The old "optional" +# expectation outlived that decision only because the assertion sat mid-test and +# could not fail. +@test "prevent_network_dsstore is auto-run and described in optimize health json" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/check/health_json.sh" +json="$(generate_health_json | tr '\n' ' ')" + +if printf '%s\n' "$json" | grep -q '"action": "prevent_network_dsstore".*"safe": true'; then + echo "auto-run" +fi +if printf '%s\n' "$json" | grep -q 'persistent Finder preference'; then + echo "described" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"auto-run"* ]] || return 1 + [[ "$output" == *"described"* ]] } @test "execute_optimization dispatches actions" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_dock_refresh() { echo "dock"; } -execute_optimization dock_refresh +opt_cache_refresh() { echo "cache"; optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; } +optimize_outcomes_reset +execute_optimization cache_refresh +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 EOF - [ "$status" -eq 0 ] - [[ "$output" == *"dock"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"cache"* ]] } @test "execute_optimization rejects unknown action" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" execute_optimization unknown_action EOF - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown action"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"Unknown action"* ]] || return 1 } -@test "opt_launch_services_rebuild handles missing lsregister without exiting" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "execute_optimization rejects unknown action before whitelist policy" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" -get_lsregister_path() { - echo "" - return 0 +is_whitelisted() { return 0; } +execute_optimization unknown_action +EOF + + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown action"* ]] } -opt_launch_services_rebuild -echo "survived" + +@test "opt_prune_spotlight_orphan_rules removes orphan but keeps system, apple and installed rules" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string System.iphoneApps" \ + -c "Add :EnabledPreferenceRules:1 string com.apple.Safari" \ + -c "Add :EnabledPreferenceRules:2 string com.installed.App" \ + -c "Add :EnabledPreferenceRules:3 string com.lm.william.TwinklingCard" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "DEFAULTS: $*" ;; + esac +} +bundle_has_installed_app() { [[ "$1" == "com.installed.App" ]]; } +execute_optimization spotlight_orphan_rules_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Removed 1 orphan"* ]] || return 1 + [[ "$output" == *"DEFAULTS: write"* ]] || return 1 + [[ "$output" == *"System.iphoneApps"* ]] || return 1 + [[ "$output" == *"com.apple.Safari"* ]] || return 1 + [[ "$output" == *"com.installed.App"* ]] || return 1 + [[ "$output" != *"com.lm.william.TwinklingCard"* ]] +} + +@test "opt_prune_spotlight_orphan_rules dry-run reports but does not write" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string System.iphoneApps" \ + -c "Add :EnabledPreferenceRules:1 string com.lm.william.TwinklingCard" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "DEFAULTS: $*" ;; + esac +} +bundle_has_installed_app() { return 1; } +execute_optimization spotlight_orphan_rules_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Would remove 1 orphan"* ]] || return 1 + [[ "$output" != *"DEFAULTS: write"* ]] || return 1 + [[ "$output" != *"DEFAULTS: delete"* ]] +} + +@test "opt_prune_spotlight_orphan_rules reports clean when every rule still has its app" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string System.iphoneApps" \ + -c "Add :EnabledPreferenceRules:1 string com.apple.Safari" \ + -c "Add :EnabledPreferenceRules:2 string com.installed.App" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "DEFAULTS: $*" ;; + esac +} +bundle_has_installed_app() { return 0; } +execute_optimization spotlight_orphan_rules_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] || return 1 + [[ "$output" != *"DEFAULTS: write"* ]] +} + +@test "opt_prune_spotlight_orphan_rules propagates an interrupted app resolver" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string com.example.Interrupted" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "UNEXPECTED_WRITE: $*" ;; + esac +} +bundle_has_installed_app() { return 130; } +rc=0 +opt_prune_spotlight_orphan_rules || rc=$? +printf 'RC=%s\n' "$rc" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_WRITE"* ]] +} + +@test "opt_spotlight_index_optimize reports optimal when probes are fast" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +STUB="$HOME/spotlight-stubs" +mkdir -p "$STUB" +printf '#!/bin/bash\necho "/: Indexing enabled."\n' > "$STUB/mdutil" +printf '#!/bin/bash\necho "mdfind:$*" >> "$HOME/mdfind-calls.log"\nexit 0\n' > "$STUB/mdfind" +chmod +x "$STUB/mdutil" "$STUB/mdfind" +PATH="$STUB:$PATH" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +is_ac_power() { return 0; } +execute_optimization spotlight_index_optimize +echo "probes=$(wc -l < "$HOME/mdfind-calls.log" | tr -d ' ')" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Spotlight index already optimal"* ]] || return 1 + [[ "$output" == *"probes=2"* ]] || return 1 +} + +@test "opt_spotlight_index_optimize skips the speed probe on battery" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +STUB="$HOME/spotlight-stubs-battery" +mkdir -p "$STUB" +printf '#!/bin/bash\necho "/: Indexing enabled."\n' > "$STUB/mdutil" +printf '#!/bin/bash\necho "mdfind:$*" >> "$HOME/mdfind-battery.log"\nexit 0\n' > "$STUB/mdfind" +chmod +x "$STUB/mdutil" "$STUB/mdfind" +PATH="$STUB:$PATH" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +is_ac_power() { return 1; } +execute_optimization spotlight_index_optimize +[[ -f "$HOME/mdfind-battery.log" ]] && echo "probed" || echo "no-probe" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Spotlight index already optimal"* ]] || return 1 + [[ "$output" == *"no-probe"* ]] || return 1 +} + +@test "opt_spotlight_index_optimize dry-run reports rebuild when probes are slow" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 MOLE_OPTIMIZE_SPOTLIGHT_SLOW_SEC=-1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +STUB="$HOME/spotlight-stubs-slow" +mkdir -p "$STUB" +printf '#!/bin/bash\necho "/: Indexing enabled."\n' > "$STUB/mdutil" +printf '#!/bin/bash\nexit 0\n' > "$STUB/mdfind" +chmod +x "$STUB/mdutil" "$STUB/mdfind" +PATH="$STUB:$PATH" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +is_ac_power() { return 0; } +execute_optimization spotlight_index_optimize +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Spotlight index rebuild started"* ]] || return 1 +} + +@test "opt_prune_spotlight_orphan_rules reports clean when rules key is absent" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { return 1; } +execute_optimization spotlight_orphan_rules_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] +} + +@test "execute_optimization dispatches spotlight_orphan_rules_cleanup" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +opt_prune_spotlight_orphan_rules() { echo "pruned"; optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; } +optimize_outcomes_reset +execute_optimization spotlight_orphan_rules_cleanup +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"pruned"* ]] +} + +@test "opt_launch_services_rebuild handles missing lsregister without exiting" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +get_lsregister_path() { + echo "" + return 0 +} +execute_optimization launch_services_rebuild +echo "survived" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"lsregister not found"* ]] || return 1 + [[ "$output" == *"survived"* ]] +} + +@test "opt_launch_agents_cleanup reports healthy when no directory" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +execute_optimization launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Launch Agents all healthy"* ]] +} + +@test "opt_launch_agents_cleanup detects broken agents" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Create mock LaunchAgents with a broken binary reference. +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.broken.plist" <<'PLIST' + + + + + Label + com.test.broken + ProgramArguments + + /nonexistent/binary + + + +PLIST +safe_remove() { return 0; } +execute_optimization launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cleaned 1 broken Launch Agent"* ]] +} + +@test "opt_launch_agents_cleanup skips healthy agents" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Clean up any leftover plists from previous tests. +rm -f "$HOME/Library/LaunchAgents"/*.plist 2>/dev/null || true +# Create mock LaunchAgent pointing to an existing binary. +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.healthy.plist" < + + + + Label + com.test.healthy + ProgramArguments + + /bin/bash + + + +PLIST +execute_optimization launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Launch Agents all healthy"* ]] +} + +@test "opt_launch_agents_cleanup spares agents on unmounted volumes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Clean up any leftover plists from previous tests. +rm -f "$HOME/Library/LaunchAgents"/*.plist 2>/dev/null || true +# A program on an unplugged /Volumes/ is missing but not broken; +# the volume is simply unmounted, so the agent must be left alone. +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.external.plist" <<'PLIST' + + + + + Label + com.test.external + ProgramArguments + + /Volumes/MoleNonexistentDisk/tool + + + +PLIST +execute_optimization launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Launch Agents all healthy"* ]] +} + +@test "execute_optimization dispatches launch_agents_cleanup" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +opt_launch_agents_cleanup() { echo "launch_agents"; optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; } +optimize_outcomes_reset +execute_optimization launch_agents_cleanup +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"launch_agents"* ]] +} + +@test "opt_periodic_maintenance reports current when log is fresh" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +periodic() { true; } +export -f periodic +tmplog="$(mktemp /tmp/mole-test-daily.XXXXXX)" +touch "$tmplog" +MOLE_PERIODIC_LOG="$tmplog" execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count unchanged)" == "1" ]] || exit 1 +rm -f "$tmplog" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already current"* ]] +} + +@test "opt_periodic_maintenance ignores non-BSD stat earlier in PATH" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +periodic() { true; } +export -f periodic +tmpdir="$(mktemp -d /tmp/mole-test-stat-path.XXXXXX)" +mkdir -p "$tmpdir/bin" +cat > "$tmpdir/bin/stat" <<'STAT' +#!/usr/bin/env bash +echo " File: /var/log/daily.out" +STAT +chmod +x "$tmpdir/bin/stat" +tmplog="$tmpdir/daily.out" +touch "$tmplog" +PATH="$tmpdir/bin:$PATH" MOLE_PERIODIC_LOG="$tmplog" execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count unchanged)" == "1" ]] || exit 1 +rm -rf "$tmpdir" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already current"* ]] || return 1 + [[ "$output" != *"unbound variable"* ]] +} + +@test "opt_periodic_maintenance triggers in dry-run when log is stale" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +periodic() { true; } +export -f periodic +tmplog="$(mktemp /tmp/mole-test-daily.XXXXXX)" +touch -t "$(date -v-10d +%Y%m%d%H%M.%S)" "$tmplog" +MOLE_PERIODIC_LOG="$tmplog" execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 +rm -f "$tmplog" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance triggered"* ]] +} + +@test "opt_periodic_maintenance triggers in dry-run when log is missing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +periodic() { true; } +export -f periodic +MOLE_PERIODIC_LOG="/tmp/mole-test-nonexistent-daily.out" execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance triggered"* ]] +} + +@test "opt_periodic_maintenance reports skipped without admin access" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +periodic() { true; } +export -f periodic +MOLE_PERIODIC_LOG="$HOME/missing-daily.out" execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count skipped)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Periodic maintenance skipped (requires sudo)"* ]] || return 1 +} + +@test "opt_periodic_maintenance reports command failure" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=0 MOLE_TEST_MODE=0 MOLE_OPTIMIZE_SUDO_AVAILABLE=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +periodic() { true; } +sudo() { return 7; } +export -f periodic sudo +MOLE_PERIODIC_LOG="$HOME/missing-daily.out" execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to run periodic maintenance (exit=7)"* ]] || return 1 +} + +@test "opt_disk_verify reports a timed out probe as failed" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ENABLE_DISK_VERIFY=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +run_with_timeout() { return 124; } + +execute_optimization disk_verify +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count unchanged)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Disk verification timed out"* ]] || return 1 +} + +@test "opt_network_stack_optimize reports a partial flush failure" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mkdir -p "$HOME/bin" +printf '#!/bin/bash\nexit 1\n' > "$HOME/bin/route" +printf '#!/bin/bash\nexit 1\n' > "$HOME/bin/dscacheutil" +chmod +x "$HOME/bin/route" "$HOME/bin/dscacheutil" +PATH="$HOME/bin:$PATH" +optimize_sudo_available() { return 0; } +sudo() { + if [[ "$1" == "route" ]]; then + return 0 + fi + return 7 +} +export -f optimize_sudo_available sudo + +execute_optimization network_stack_optimize +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Network routing table refreshed"* ]] || return 1 + [[ "$output" == *"Network stack refresh incomplete (1 operation(s) failed)"* ]] || return 1 +} + +@test "run_optimize_diagnostics flags sustained CloudShell as primary bottleneck" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'120 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-capture\n35 /usr/libexec/syspolicyd\n20 /System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'140 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-processor\n30 /usr/libexec/syspolicyd\n18 /System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: CloudShell / AliEntSafe"* ]] || return 1 + [[ "$output" == *"Mole will not terminate enterprise security processes"* ]] +} + +@test "run_optimize_diagnostics treats CoreSimulator images as informational for syspolicyd" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'55 /usr/libexec/syspolicyd\n12 /usr/libexec/diskimagesiod' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'60 /usr/libexec/syspolicyd\n10 /Library/Developer/PrivateFrameworks/CoreSimulator.framework/Resources/bin/simdiskimaged' \ + MOLE_OPTIMIZE_SPCTL_STATUS="assessments enabled" \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime/example.asset/AssetData/Restore/000.dmg\n/dev/disk8s1\t/Library/Developer/CoreSimulator/Volumes/iOS_23E244\n' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: syspolicyd"* ]] || return 1 + [[ "$output" == *"Gatekeeper status: assessments enabled"* ]] || return 1 + [[ "$output" == *"Only system-managed CoreSimulator images are mounted"* ]] || return 1 + [[ "$output" != *"assessment overhead:"* ]] +} + +@test "run_optimize_diagnostics suppresses one-off CPU spikes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'180 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-capture' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'5 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-capture' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No sustained high-CPU bottleneck detected"* ]] +} + +@test "run_optimize_diagnostics offers user-mounted images under syspolicyd pressure in dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'55 /usr/libexec/syspolicyd' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'60 /usr/libexec/syspolicyd' \ + MOLE_OPTIMIZE_SPCTL_STATUS="assessments enabled" \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Users/test/Downloads/TestInstaller.dmg\n/dev/disk14s1\t/Volumes/Test Installer\n' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: syspolicyd"* ]] || return 1 + [[ "$output" == *"Mounted image adds assessment overhead:"* ]] || return 1 + [[ "$output" == *"TestInstaller.dmg"* ]] || return 1 + [[ "$output" == *"/Volumes/Test Installer"* ]] || return 1 + [[ "$output" == *"Would offer detach for 1 mounted image"* ]] +} + +@test "run_optimize_diagnostics keeps healthy runs quiet even with user-mounted images" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'1 /usr/sbin/distnoted' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'1 /usr/sbin/distnoted' \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Users/test/Downloads/TestInstaller.dmg\n/dev/disk14s1\t/Volumes/Test Installer\n' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No sustained high-CPU bottleneck detected"* ]] || return 1 + [[ "$output" != *"assessment overhead:"* ]] || return 1 + [[ "$output" != *"Would offer detach"* ]] || return 1 + [[ "$output" != *"/Volumes/Test Installer"* ]] +} + +@test "run_optimize_diagnostics skips protected mounted images" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'55 /usr/libexec/syspolicyd' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'60 /usr/libexec/syspolicyd' \ + MOLE_OPTIMIZE_SPCTL_STATUS="assessments enabled" \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Users/test/Downloads/KeepMe.dmg\n/dev/disk15s1\t/Volumes/KeepMe\n' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { + [[ "$1" == "/Volumes/KeepMe" ]] +} +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: syspolicyd"* ]] || return 1 + [[ "$output" != *"assessment overhead:"* ]] || return 1 + [[ "$output" != *"Would offer detach"* ]] +} + +@test "run_optimize_diagnostics honors optimize whitelist paths for mounted images (#977)" { + mkdir -p "$HOME/.config/mole" + cat > "$HOME/.config/mole/whitelist_optimize" <<'EOF' +system_maintenance +/Volumes/EXT3/Mail/TB.dmg +/Volumes/mail +EOF + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'55 /usr/libexec/syspolicyd' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'60 /usr/libexec/syspolicyd' \ + MOLE_OPTIMIZE_SPCTL_STATUS="assessments enabled" \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Volumes/EXT3/Mail/TB.dmg\n/dev/disk6s2 Apple_HFS /Volumes/mail\n' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +load_whitelist optimize +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: syspolicyd"* ]] || return 1 + [[ "$output" != *"assessment overhead:"* ]] || return 1 + [[ "$output" != *"Would offer detach"* ]] +} + +@test "run_optimize_diagnostics stays quiet when nothing matches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'4 /usr/sbin/distnoted\n3 /usr/libexec/coreaudiod' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'5 /usr/sbin/distnoted\n2 /usr/libexec/coreaudiod' \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No sustained high-CPU bottleneck detected"* ]] +} + +@test "opt_diag_detach_candidates prints summary line only for multiple images" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +run_with_timeout() { return 0; } +echo "--- single ---" +opt_diag_detach_candidates $'/Users/test/A.dmg\t/Volumes/A' +echo "--- double ---" +opt_diag_detach_candidates $'/Users/test/A.dmg\t/Volumes/A\n/Users/test/B.dmg\t/Volumes/B' +EOF + + [ "$status" -eq 0 ] + single="${output#*--- single ---}" + single="${single%%--- double ---*}" + double="${output#*--- double ---}" + [[ "$single" == *"Detached /Volumes/A"* ]] || return 1 + [[ "$single" != *"mounted images"* ]] || return 1 + [[ "$double" == *"Detached 2 mounted images"* ]] || return 1 +} + +@test "opt_diag_offer_detach_candidates renders image paths without terminal escapes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" NO_COLOR=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +image_path=$'/Users/test/Bad\\033[2J-\033[2J.dmg' +mount_path=$'/Volumes/Bad\\033[H-\033[H' +MOLE_DRY_RUN=1 opt_diag_offer_detach_candidates "${image_path}"$'\t'"${mount_path}" +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *'Bad\033[2J-'* ]] || return 1 + [[ "$output" == *'/Volumes/Bad\033[H-'* ]] || return 1 + [[ "$output" != *$'\033[2J'* ]] || return 1 + [[ "$output" != *$'\033[H'* ]] +} + +@test "opt_diag_detach_candidates renders result paths without terminal escapes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" NO_COLOR=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +run_with_timeout() { + shift + shift + shift + [[ "$1" == *Success* ]] +} +success_mount=$'/Volumes/Success\\033[2J-\033[2J' +failed_mount=$'/Volumes/Failed\\033[H-\033[H' +candidates="/tmp/one.dmg"$'\t'"$success_mount"$'\n'"/tmp/two.dmg"$'\t'"$failed_mount" +opt_diag_detach_candidates "$candidates" +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *'Detached /Volumes/Success\033[2J-'* ]] || return 1 + [[ "$output" == *'Failed to detach /Volumes/Failed\033[H-'* ]] || return 1 + [[ "$output" != *$'\033[2J'* ]] || return 1 + [[ "$output" != *$'\033[H'* ]] +} + +@test "opt_periodic_maintenance skips when periodic command missing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +command() { + if [[ "$1" == "-v" && "$2" == "periodic" ]]; then + return 1 + fi + builtin command "$@" +} +export -f command +execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count unavailable)" == "1" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance skipped (not available on this macOS version)"* ]] +} + +@test "execute_optimization dispatches periodic_maintenance" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +opt_periodic_maintenance() { echo "periodic"; optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; } +optimize_outcomes_reset +execute_optimization periodic_maintenance +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"periodic"* ]] +} + +@test "execute_optimization skips whitelisted task ids" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +is_whitelisted() { [[ "$1" == "cache_refresh" ]]; } +opt_cache_refresh() { echo "UNEXPECTED_CACHE"; } +optimize_outcomes_reset +execute_optimization cache_refresh +[[ "$(optimize_outcome_count skipped)" == "1" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped (whitelisted): Finder Cache Refresh"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_CACHE"* ]] +} + +@test "optimize whitelist is loaded before system health checks" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +load_line=$(awk '/load_whitelist "optimize"/ { print NR; exit }' "$PROJECT_ROOT/bin/optimize.sh") +health_line=$(awk '/^[[:space:]]*show_system_health / { print NR; exit }' "$PROJECT_ROOT/bin/optimize.sh") +if [[ "$load_line" -lt "$health_line" ]]; then + echo "ordered" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ordered"* ]] +} + +@test "optimize interrupt cleanup disables the EXIT trap before cleanup" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +body=$(sed -n '/^handle_interrupt() {/,/^}/p' "$PROJECT_ROOT/bin/optimize.sh") +trap_line=$(printf '%s\n' "$body" | awk '/trap - EXIT/ { print NR; exit }') +cleanup_line=$(printf '%s\n' "$body" | awk '/^[[:space:]]*cleanup_all 130$/ { print NR; exit }') +[[ -n "$trap_line" && -n "$cleanup_line" && "$trap_line" -lt "$cleanup_line" ]] +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "show_system_health formats floats under comma-decimal locales (#1220)" { + # Find an installed locale whose decimal separator is a comma. + local comma_locale="" candidate + for candidate in fr_FR.UTF-8 de_DE.UTF-8 pt_BR.UTF-8 es_ES.UTF-8 it_IT.UTF-8 nl_NL.UTF-8; do + if [[ "$(LC_ALL="$candidate" /bin/bash -c 'printf "%.1f" 1' 2> /dev/null)" == "1,0" ]]; then + comma_locale="$candidate" + break + fi + done + [[ -n "$comma_locale" ]] || skip "no comma-decimal locale installed" + + run env LC_ALL="$comma_locale" LANG="$comma_locale" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +eval "$(sed -n '/^json_get_value()/,/^}$/p' "$PROJECT_ROOT/bin/optimize.sh")" +eval "$(sed -n '/^show_system_health()/,/^}$/p' "$PROJECT_ROOT/bin/optimize.sh")" +ICON_ADMIN="*" +health_json='{"memory_used_gb": 5.70, "memory_total_gb": 8.00, "disk_used_gb": 287.86, "disk_total_gb": 351.19, "uptime_days": 6.1}' +show_system_health "$health_json" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"6/8 GB RAM"* ]] || return 1 + [[ "$output" == *"288/351 GB Disk"* ]] || return 1 + [[ "$output" == *"Uptime 6d"* ]] +} + +@test "optimize whitelist items include task ids" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +get_optimize_whitelist_items +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Permission Repair|disk_permissions_repair|optimize_task"* ]] || return 1 + [[ "$output" == *"Login Items Audit|login_items_audit|optimize_task"* ]] || return 1 + [[ "$output" == *"Legacy Overrides|legacy_overrides_audit|optimize_task"* ]] || return 1 +} + +@test "_login_item_app_exists finds nested helper app bundles" { + local helper="$HOME/Applications/Roon.app/Contents/RoonServer.app" + mkdir -p "$helper" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mdfind() { return 1; } +sfltool() { return 1; } +export -f mdfind sfltool +if _login_item_app_exists "RoonServer"; then + echo "found" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"found"* ]] +} + +@test "_login_item_app_exists finds nested helper apps by bundle display name" { + local helper="$HOME/Applications/Adobe Acrobat DC.app/Contents/Helpers/AdobeResourceSynchronizer.app" + mkdir -p "$helper/Contents" + cat > "$helper/Contents/Info.plist" <<'PLIST' + + + + + CFBundleDisplayName + Acrobat Collaboration Synchronizer + CFBundleName + AdobeResourceSynchronizer + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mdfind() { return 1; } +sfltool() { return 1; } +export -f mdfind sfltool +if _login_item_app_exists "Acrobat Collaboration Synchronizer"; then + echo "found" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"found"* ]] +} + +@test "_login_item_app_exists trusts an existing System Events login item path" { + local helper="$HOME/Applications/Adobe Acrobat DC.app/Contents/Helpers/AdobeResourceSynchronizer.app" + mkdir -p "$helper" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 HELPER_PATH="$helper" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mdfind() { return 1; } +sfltool() { return 1; } +export -f mdfind sfltool +if _login_item_app_exists "Acrobat Collaboration Synchronizer" "$HELPER_PATH" 2>&1; then + echo "found" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"found"* ]] || return 1 + [[ "$output" == *"resolved by login item path"* ]] +} + +@test "optimize_sudo_available returns false when sudo session was denied" { + run env PROJECT_ROOT="$PROJECT_ROOT" MOLE_OPTIMIZE_SUDO_AVAILABLE="false" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +if optimize_sudo_available; then + echo "WRONG: returned true under denied sudo" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "optimize_sudo_available returns false in test mode regardless of optimize entrypoint" { + # Ad-hoc task invocation under MOLE_TEST_NO_AUTH must hard-deny sudo + # even when MOLE_OPTIMIZE_SUDO_AVAILABLE was never set by bin/optimize.sh. + run env PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +unset MOLE_OPTIMIZE_SUDO_AVAILABLE +if optimize_sudo_available; then + echo "WRONG: leaked sudo to test-mode caller" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "flush_dns_cache does not invoke sudo under MOLE_TEST_NO_AUTH" { + # Reproduces the reported regression: ad-hoc flush_dns_cache under test + # mode used to fall through optimize_sudo_available and reach `sudo dscacheutil`. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +unset MOLE_OPTIMIZE_SUDO_AVAILABLE +trace="$HOME/sudo_calls.log" +: > "$trace" +sudo() { + printf 'SUDO_CALLED:%s\n' "$*" >> "$trace" + return 0 +} +export -f sudo + +flush_dns_cache 2>&1 || true + +if [[ -s "$trace" ]]; then + echo "WRONG: sudo invoked under test mode:" + cat "$trace" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "sudo-required optimize tasks short-circuit without invoking sudo when access denied" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_SUDO_AVAILABLE="false" \ + MOLE_DRY_RUN="0" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +trace="$HOME/sudo_calls.log" +: > "$trace" +sudo() { + printf 'sudo %s\n' "$*" >> "$trace" + return 0 +} +export -f sudo + +# Force the "needs work" branch so each task reaches its sudo block. +needs_permissions_repair() { return 0; } +has_active_vpn_interface() { return 1; } +route() { return 1; } +dscacheutil() { return 1; } +mdutil() { echo "Indexing enabled."; } +mdfind() { sleep 4; } +get_epoch_seconds() { date +%s; } +is_ac_power() { return 0; } +pgrep() { return 1; } +system_profiler() { return 1; } +plutil() { return 1; } +defaults() { return 1; } +get_path_size_kb() { echo "0"; } +debug_log() { :; } +opt_msg() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } + +execute_optimization network_stack_optimize 2>&1 || true +execute_optimization disk_permissions_repair 2>&1 || true +execute_optimization periodic_maintenance 2>&1 || true +flush_dns_cache 2>&1 || true + +if [[ -s "$trace" ]]; then + echo "WRONG: sudo invoked while denied:" + cat "$trace" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "opt_diag_parse_image_mount_pairs ignores image-alias/icon-path lines (#960)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" + +# Sample hdiutil info block reproducing the issue from #960. The image-alias +# line carries an absolute path identical to image-path, which the previous +# extract_mount regex incorrectly accepted as a mount point. Only the +# /dev/disk* line is a real mount. +sample=$(cat <<'HDIUTIL' +================================================ +image-path : /Volumes/EXT3/Mail/TB.dmg +image-alias : /Volumes/EXT3/Mail/TB.dmg +shadow-path : +icon-path : /System/Library/PrivateFrameworks/DiskImages.framework/Resources/CDiskImage.icns +image-type : read-only +/dev/disk6 Apple_partition_scheme +/dev/disk6s1 Apple_partition_map +/dev/disk6s2 Apple_HFS /Volumes/mail +HDIUTIL +) + +opt_diag_parse_image_mount_pairs "$sample" +EOF + + [ "$status" -eq 0 ] + # Expect exactly one pair: image=/Volumes/EXT3/Mail/TB.dmg mount=/Volumes/mail + line_count=$(printf '%s\n' "$output" | awk 'NF' | wc -l | tr -d ' ') + [ "$line_count" = "1" ] + [[ "$output" == *"/Volumes/EXT3/Mail/TB.dmg"$'\t'"/Volumes/mail"* ]] || return 1 + # Critical regression guard: image-alias line must not surface as a mount. + [[ "$output" != *"/Volumes/EXT3/Mail/TB.dmg"$'\t'"/Volumes/EXT3/Mail/TB.dmg"* ]] +} + +@test "has_active_vpn_interface respects MOLE_ASSUME_VPN_ACTIVE override" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Force scutil/route to fail loudly so the env override is the only path. +scutil() { echo "should not be called" >&2; return 1; } +route() { echo "should not be called" >&2; return 1; } +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"vpn"* ]] || return 1 + [[ "$output" != *"no_vpn"* ]] || return 1 + [[ "$output" != *"should not be called"* ]] +} + +@test "has_active_vpn_interface returns false when MOLE_ASSUME_VPN_ACTIVE=0" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# scutil/route should not run when env says no. +scutil() { echo "should not be called" >&2; return 1; } +route() { echo "should not be called" >&2; return 1; } +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] || return 1 + [[ "$output" != *"should not be called"* ]] +} + +@test "has_active_vpn_interface detects scutil Connected entry" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mock_bin="$HOME/vpn-connected-bin" +mkdir -p "$mock_bin" +cat > "$mock_bin/scutil" <<'MOCK' +#!/bin/bash +cat <<'OUTPUT' +* (Disconnected) AA1B2C3D-1111-2222-3333-444455556666 PPP (L2TP) "Office VPN" [L2TP] +* (Connected) 87654321-aaaa-bbbb-cccc-dddddddddddd IPSec (IKEv2) "Remote Office"[IKEv2] +OUTPUT +MOCK +# Default route should NOT be consulted once scutil already proved a VPN active. +printf '#!/bin/bash\ntouch "$HOME/route-called"\nexit 1\n' > "$mock_bin/route" +chmod +x "$mock_bin/scutil" "$mock_bin/route" +PATH="$mock_bin:$PATH" +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +[[ ! -e "$HOME/route-called" ]] || echo "should not be called" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"vpn"* ]] || return 1 + [[ "$output" != *"should not be called"* ]] +} + +@test "has_active_vpn_interface ignores scutil entries that are all Disconnected" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mock_bin="$HOME/vpn-disconnected-bin" +mkdir -p "$mock_bin" +cat > "$mock_bin/scutil" <<'MOCK' +#!/bin/bash +cat <<'OUTPUT' +* (Disconnected) AA1B2C3D-1111-2222-3333-444455556666 PPP (L2TP) "Office VPN" [L2TP] +* (Disconnected) 87654321-aaaa-bbbb-cccc-dddddddddddd IPSec (IKEv2) "Remote Office"[IKEv2] +OUTPUT +MOCK +# Default route via en0 (no VPN). This is the user's case in #959. +printf '%s\n' '#!/bin/bash' 'echo " interface: en0"' > "$mock_bin/route" +chmod +x "$mock_bin/scutil" "$mock_bin/route" +PATH="$mock_bin:$PATH" +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] +} + +@test "has_active_vpn_interface detects full-tunnel via utun default route" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# No system-managed VPN configured in scutil. +mock_bin="$HOME/vpn-full-tunnel-bin" +mkdir -p "$mock_bin" +printf '%s\n' '#!/bin/bash' 'exit 0' > "$mock_bin/scutil" +# Default route owned by utun3 -> full-tunnel VPN (WireGuard / OpenVPN style). +printf '%s\n' '#!/bin/bash' 'echo " interface: utun3"' > "$mock_bin/route" +chmod +x "$mock_bin/scutil" "$mock_bin/route" +PATH="$mock_bin:$PATH" +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"vpn"* ]] +} + +@test "has_active_vpn_interface returns false for iCloud Private Relay style utun (#959)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Private Relay / Continuity create utun* but the default route stays on en0. +# The old netstat/ifconfig probe would have false-positived this; the new +# probe must not. +mock_bin="$HOME/vpn-private-relay-bin" +mkdir -p "$mock_bin" +printf '%s\n' '#!/bin/bash' 'exit 0' > "$mock_bin/scutil" +printf '%s\n' '#!/bin/bash' 'echo " interface: en0"' > "$mock_bin/route" +chmod +x "$mock_bin/scutil" "$mock_bin/route" +PATH="$mock_bin:$PATH" +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] +} + +@test "opt_diag_parse_image_mount_pairs handles multiple blocks" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" + +sample=$(cat <<'HDIUTIL' +================================================ +image-path : /Users/test/Sample.dmg +image-alias : /Users/test/Sample.dmg +/dev/disk5s2 Apple_HFS /Volumes/Sample +================================================ +image-path : /Library/Developer/CoreSimulator/Volumes/iOS_17.dmg +image-alias : /Library/Developer/CoreSimulator/Volumes/iOS_17.dmg +/dev/disk7s1 Apple_APFS /Library/Developer/CoreSimulator/Volumes/iOS_17.0 +HDIUTIL +) + +opt_diag_parse_image_mount_pairs "$sample" | awk 'NF' | sort EOF - [ "$status" -eq 0 ] - [[ "$output" == *"lsregister not found"* ]] - [[ "$output" == *"survived"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"/Users/test/Sample.dmg"$'\t'"/Volumes/Sample"* ]] || return 1 + [[ "$output" == *"/Library/Developer/CoreSimulator/Volumes/iOS_17.dmg"$'\t'"/Library/Developer/CoreSimulator/Volumes/iOS_17.0"* ]] || return 1 + line_count=$(printf '%s\n' "$output" | awk 'NF' | wc -l | tr -d ' ') + [ "$line_count" = "2" ] } diff --git a/Resources/mole/tests/optimize_catalog.bats b/Resources/mole/tests/optimize_catalog.bats new file mode 100644 index 0000000..6138a56 --- /dev/null +++ b/Resources/mole/tests/optimize_catalog.bats @@ -0,0 +1,229 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "optimize exposes no manual memory purge task (#1309)" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/catalog.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +for action in "${MOLE_OPTIMIZE_ACTIONS[@]}"; do + [[ "$action" != "memory_pressure_relief" ]] || exit 1 +done +if declare -F is_memory_pressure_high > /dev/null 2>&1; then + exit 2 +fi +if declare -F opt_memory_pressure_relief > /dev/null 2>&1; then + exit 3 +fi +if command grep -nE '(^|[^[:alnum:]_])(/usr/sbin/)?purge([[:space:]]|$)' "$PROJECT_ROOT/lib/optimize/tasks.sh"; then + exit 4 +fi +EOF + + [ "$status" -eq 0 ] || return 1 +} + +@test "default optimize catalog never restarts Dock (#1300)" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/catalog.sh" + +optimize_catalog_handler_for system_maintenance >/dev/null +if optimize_catalog_handler_for dock_refresh >/dev/null 2>&1; then + echo "Dock refresh is still registered" + exit 1 +fi +if grep -nE 'killall[[:space:]]+Dock' "$PROJECT_ROOT/lib/optimize/tasks.sh"; then + echo "Optimize still terminates Dock" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize catalog preserves the complete public task contract" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/catalog.sh" + +expected=$(cat <<'CONTRACT' +system_maintenance|opt_system_maintenance|DNS & Spotlight Check|DNS & Spotlight Check|Refresh DNS cache & verify Spotlight status|true +cache_refresh|opt_cache_refresh|Finder Cache Refresh|Finder Cache Refresh|Refresh QuickLook thumbnails & icon services cache|true +saved_state_cleanup|opt_saved_state_cleanup|App State Cleanup|App State Cleanup|Remove old saved application states (30+ days)|true +fix_broken_configs|opt_fix_broken_configs|Broken Config Repair|Broken Config Repair|Fix corrupted preferences files|true +network_optimization|opt_network_optimization|Network Cache Refresh|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true +sqlite_vacuum|opt_sqlite_vacuum|Database Optimization|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|true +launch_services_rebuild|opt_launch_services_rebuild|LaunchServices Repair|LaunchServices Repair|Repair "Open with" menu & file associations|true +prevent_network_dsstore|opt_prevent_network_dsstore|Prevent Finder .DS_Store|Prevent Finder .DS_Store|Set a persistent Finder preference to stop writing .DS_Store on SMB/AFP/NFS and USB volumes|true +legacy_overrides_audit|opt_legacy_overrides_audit|Legacy Overrides|Legacy Overrides|Remove hidden App Nap and disk-image verification overrides left by old tweak tools|true +network_stack_optimize|opt_network_stack_optimize|Network Stack Refresh|Network Stack Refresh|Flush routing table and ARP cache to resolve network issues|true +disk_permissions_repair|opt_disk_permissions_repair|Permission Repair|Permission Repair|Fix user directory permission issues|true +spotlight_index_optimize|opt_spotlight_index_optimize|Spotlight Optimization|Spotlight Optimization|Rebuild index if search is slow (smart detection)|true +spotlight_orphan_rules_cleanup|opt_prune_spotlight_orphan_rules|Spotlight Orphan Rules|Spotlight Orphan Rules|Remove Spotlight search-rule entries for apps that are no longer installed|true +periodic_maintenance|opt_periodic_maintenance|Periodic Maintenance|Periodic Maintenance|Run macOS daily/weekly/monthly maintenance scripts if stale|true +shared_file_list_repair|opt_shared_file_list_repair|Shared File Lists|Shared File Lists|Repair corrupted Finder favorites and recent documents|true +disk_verify|opt_disk_verify|Disk Health|Disk Health|Verify filesystem integrity|true +login_items_audit|opt_login_items_audit|Login Items|Login Items Audit|Audit login items for broken entries|true +quarantine_cleanup|opt_quarantine_cleanup|Quarantine Database Cleanup|Quarantine Database Cleanup|Clear Gatekeeper download tracking history|true +launch_agents_cleanup|opt_launch_agents_cleanup|Launch Agents Cleanup|Launch Agents Cleanup|Remove broken LaunchAgents whose binaries no longer exist|true +notification_cleanup|opt_notification_cleanup|Notifications|Notifications|Clean old delivered notifications to reduce database bloat|true +coreduet_cleanup|opt_coreduet_cleanup|Usage Data|Usage Data|Clean old usage tracking data|true +CONTRACT +) + +actual="" +for ((index = 0; index < ${#MOLE_OPTIMIZE_ACTIONS[@]}; index++)); do + printf -v row '%s|%s|%s|%s|%s|%s' \ + "${MOLE_OPTIMIZE_ACTIONS[$index]}" \ + "${MOLE_OPTIMIZE_HANDLERS[$index]}" \ + "${MOLE_OPTIMIZE_HEALTH_NAMES[$index]}" \ + "${MOLE_OPTIMIZE_WHITELIST_NAMES[$index]}" \ + "${MOLE_OPTIMIZE_DESCRIPTIONS[$index]}" \ + "${MOLE_OPTIMIZE_SAFE_VALUES[$index]}" + actual+="${actual:+$'\n'}$row" +done + +[[ "$actual" == "$expected" ]] || { diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$actual"); exit 1; } +optimize_catalog_validate || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize catalog resolves handler and display ownership together" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/catalog.sh" + +[[ "$(optimize_catalog_handler_for cache_refresh)" == "opt_cache_refresh" ]] || exit 1 +[[ "$(optimize_catalog_health_name_for cache_refresh)" == "Finder Cache Refresh" ]] || exit 1 +if optimize_catalog_health_name_for unknown_action; then + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "health JSON preserves the exact optimization contract" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/check/health_json.sh" + +contract_hash=$( + generate_health_json | + sed -n '/ "optimizations": \[/,$p' | + shasum -a 256 | + awk '{print $1}' +) +expected_hash="8896e6dedcab9ab76accb1ea7502c59b711da912473923b089451222ddc61c2c" +if [[ "$contract_hash" != "$expected_hash" ]]; then + echo "health optimization contract hash: expected $expected_hash, got $contract_hash" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize whitelist preserves every public task label and action" { + run env HOME="$BATS_TEST_TMPDIR/home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" + +contract_hash=$(get_optimize_whitelist_items | shasum -a 256 | awk '{print $1}') +expected_hash="04376c036db32e504cac07b054532446465c2fd83a19c0e05a7710fa87f92078" +if [[ "$contract_hash" != "$expected_hash" ]]; then + echo "optimize whitelist contract hash: expected $expected_hash, got $contract_hash" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize catalog rejects duplicate identities and unsafe tasks" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +if /bin/bash --noprofile --norc < <( + awk '!changed && /opt_cache_refresh/ {sub(/opt_cache_refresh/, "opt_system_maintenance"); changed=1} {print}' \ + "$PROJECT_ROOT/lib/optimize/catalog.sh" +); then + echo "duplicate handler passed validation" + exit 1 +fi + +if /bin/bash --noprofile --norc < <( + awk '!changed && / true$/ {sub(/ true$/, " false"); changed=1} {print}' \ + "$PROJECT_ROOT/lib/optimize/catalog.sh" +); then + echo "unsafe task passed validation" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Duplicate optimize task handler: opt_system_maintenance"* ]] || return 1 + [[ "$output" == *"Optimize task is not safe for automatic execution: system_maintenance"* ]] || return 1 +} + +@test "optimize catalog resolves handlers by exact action id" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/catalog.sh" + +if ! handler=$(optimize_catalog_handler_for spotlight_orphan_rules_cleanup); then + echo "known action did not resolve" + exit 1 +fi +[[ "$handler" == "opt_prune_spotlight_orphan_rules" ]] || exit 1 +if optimize_catalog_handler_for unknown_action; then + echo "unknown action resolved" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimization task module implements every catalog handler" { + run env HOME="$BATS_TEST_TMPDIR/home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +[[ ${#MOLE_OPTIMIZE_ACTIONS[@]} -eq 21 ]] || exit 1 +for handler in "${MOLE_OPTIMIZE_HANDLERS[@]}"; do + if ! declare -F "$handler" >/dev/null; then + echo "missing handler: $handler" + exit 1 + fi +done +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize catalog consumers can be sourced repeatedly" { + run env HOME="$BATS_TEST_TMPDIR/home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +source "$PROJECT_ROOT/lib/check/health_json.sh" +source "$PROJECT_ROOT/lib/check/health_json.sh" + +declare -F execute_optimization >/dev/null || exit 1 +declare -F generate_health_json >/dev/null || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} diff --git a/Resources/mole/tests/optimize_db.bats b/Resources/mole/tests/optimize_db.bats new file mode 100644 index 0000000..bec986f --- /dev/null +++ b/Resources/mole/tests/optimize_db.bats @@ -0,0 +1,284 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize-db.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +create_logical_file() { + local path="$1" + local size="$2" + + if command -v mkfile > /dev/null 2>&1; then + mkfile -n "$size" "$path" + else + truncate -s "$size" "$path" + fi +} + +@test "opt_notification_cleanup reports healthy when db is small" { + local tmp_dir nc_db_dir + tmp_dir=$(mktemp -d) + # Legacy Darwin-user-dir layout (pre-Sequoia fallback). + nc_db_dir="$tmp_dir/com.apple.notificationcenter/db2" + mkdir -p "$nc_db_dir" + create_logical_file "$nc_db_dir/db" 1k + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc < "$fake_bin/du" <<'EOF' +#!/bin/bash +echo "112640 total" +EOF + chmod +x "$fake_bin/du" + + run env HOME="$tmp_dir" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH" /bin/bash --noprofile --norc < MOLE_SQLITE_MAX_SIZE (100 MiB). +get_file_size() { echo 209715200; } +should_protect_path() { return 1; } +bytes_to_human() { echo "200.0MB"; } +run_with_timeout() { echo "UNEXPECTED_SQLITE"; return 0; } + +execute_optimization sqlite_vacuum +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"No databases compacted"* ]] || return 1 + [[ "$output" == *"100 MB safety limit"* ]] || return 1 + [[ "$output" == *"Messages/chat.db"* ]] || return 1 + [[ "$output" != *"All databases already optimized"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SQLITE"* ]] || return 1 +} diff --git a/Resources/mole/tests/optimize_outcomes.bats b/Resources/mole/tests/optimize_outcomes.bats new file mode 100644 index 0000000..b25c071 --- /dev/null +++ b/Resources/mole/tests/optimize_outcomes.bats @@ -0,0 +1,167 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "optimize outcomes record one result per task" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +optimize_outcomes_reset +optimize_task_start +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED" +optimize_task_finish system_maintenance + +[[ "$(optimize_outcome_count applied)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count unchanged)" == "0" ]] || exit 1 +[[ "$(optimize_outcome_total)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize outcomes distinguish unresolved attention from failure" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +optimize_outcomes_reset +optimize_task_start +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_ATTENTION" +optimize_task_finish login_items_audit + +[[ "$(optimize_outcome_count attention)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count failed)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize outcome counts give failures precedence over partial changes" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +optimize_outcomes_reset +optimize_task_start +optimize_task_result_from_counts 2 1 0 +optimize_task_finish sqlite_vacuum + +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize run success rejects failed tasks but allows attention" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +optimize_task_start +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_ATTENTION" +optimize_task_finish login_items_audit +optimize_outcomes_succeeded || exit 1 + +optimize_task_start +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_FAILED" +optimize_task_finish periodic_maintenance +if optimize_outcomes_succeeded; then + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "optimize outcomes reject invalid and duplicate task results" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +if optimize_task_result invented; then + echo "invalid outcome accepted" + exit 1 +fi + +optimize_task_start +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" +if optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; then + echo "second task outcome accepted" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Invalid optimize task outcome: invented"* ]] || return 1 + [[ "$output" == *"Optimize task outcome is already set: unchanged"* ]] || return 1 +} + +@test "optimize outcomes reject results outside an active task" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +if optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_APPLIED"; then + echo "inactive task outcome accepted" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Optimize task was not started"* ]] || return 1 +} + +@test "optimize outcomes reject missing and duplicate task records" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +optimize_task_start +if optimize_task_finish periodic_maintenance; then + echo "missing task outcome accepted" + exit 1 +fi + +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNAVAILABLE" +optimize_task_finish periodic_maintenance +optimize_task_start +optimize_task_result "$MOLE_OPTIMIZE_OUTCOME_UNCHANGED" +if optimize_task_finish periodic_maintenance; then + echo "duplicate task record accepted" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Optimize task did not report an outcome: periodic_maintenance"* ]] || return 1 + [[ "$output" == *"Optimize task outcome is already recorded: periodic_maintenance"* ]] || return 1 +} + +@test "optimize outcomes expose failed actions without leaking ledger storage" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" + +for record in "cache_refresh:applied" "disk_verify:failed" "login_items_audit:attention" "periodic_maintenance:failed"; do + action=${record%%:*} + outcome=${record#*:} + optimize_task_start + optimize_task_result "$outcome" + optimize_task_finish "$action" +done + +expected=$(printf 'disk_verify\nperiodic_maintenance\n') +[[ "$(optimize_failed_actions)" == "$expected" ]] || exit 1 +if grep -q 'MOLE_OPTIMIZE_RESULT_' "$PROJECT_ROOT/bin/optimize.sh"; then + echo "optimize command reads private outcome storage" + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} diff --git a/Resources/mole/tests/optimize_probe_outcomes.bats b/Resources/mole/tests/optimize_probe_outcomes.bats new file mode 100644 index 0000000..0a930da --- /dev/null +++ b/Resources/mole/tests/optimize_probe_outcomes.bats @@ -0,0 +1,292 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize-probes.XXXXXX")" + export TEST_HOME +} + +teardown_file() { + if [[ "$TEST_HOME" == "${BATS_TEST_DIRNAME}/tmp-optimize-probes."* ]]; then + rm -rf "$TEST_HOME" + fi +} + +@test "system maintenance reports a failed Spotlight probe" { + run env HOME="$TEST_HOME/system" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +unset MOLE_TEST_NO_AUTH MOLE_TEST_MODE +flush_dns_cache() { return 0; } +mkdir -p "$HOME/bin" +printf '#!/bin/bash\nexit 7\n' > "$HOME/bin/mdutil" +chmod +x "$HOME/bin/mdutil" +PATH="$HOME/bin:$PATH" + +execute_optimization system_maintenance +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to verify Spotlight index"* ]] || return 1 +} + +@test "Spotlight optimization reports a failed status probe" { + run env HOME="$TEST_HOME/spotlight" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +run_with_timeout() { return 124; } + +execute_optimization spotlight_index_optimize +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to inspect Spotlight index (exit=124)"* ]] || return 1 +} + +@test "quarantine cleanup reports a failed row-count probe" { + run env HOME="$TEST_HOME/quarantine" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2" +mkdir -p "$(dirname "$db")" +touch "$db" +sqlite3() { return 0; } +should_protect_path() { return 1; } +run_with_timeout() { return 7; } + +execute_optimization quarantine_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to inspect quarantine database"* ]] || return 1 +} + +@test "login item audit reports a failed snapshot" { + run env HOME="$TEST_HOME/login" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +unset MOLE_TEST_NO_AUTH MOLE_TEST_MODE +_login_items_snapshot() { return 7; } + +execute_optimization login_items_audit +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to inspect login items"* ]] || return 1 +} + +@test "notification cleanup reports a failed size probe" { + run env HOME="$TEST_HOME/notification" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +getconf() { echo "$HOME/runtime"; } +db="$HOME/runtime/com.apple.notificationcenter/db2/db" +mkdir -p "$(dirname "$db")" +touch "$db" +opt_existing_file_size_kb_strict() { return 124; } + +execute_optimization notification_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to inspect Notification Center database size"* ]] || return 1 +} + +@test "CoreDuet cleanup reports a failed size probe" { + run env HOME="$TEST_HOME/coreduet-size" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +db="$HOME/Library/Application Support/Knowledge/knowledgeC.db" +mkdir -p "$(dirname "$db")" +touch "$db" +run_with_timeout() { return 124; } + +execute_optimization coreduet_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to inspect Knowledge database size"* ]] || return 1 +} + +@test "sudo-dependent maintenance is skipped when admin access is denied" { + run env HOME="$TEST_HOME/admin" PROJECT_ROOT="$PROJECT_ROOT" MOLE_OPTIMIZE_SUDO_AVAILABLE=false /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mdutil() { echo "UNEXPECTED_MDUTIL"; return 0; } + +execute_optimization system_maintenance +execute_optimization network_optimization +[[ "$(optimize_outcome_count skipped)" == "2" ]] || exit 1 +[[ "$(optimize_outcome_count failed)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"admin access required"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_MDUTIL"* ]] || return 1 +} + +@test "Spotlight optimization reports failed speed probes" { + run env HOME="$TEST_HOME/spotlight-speed" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +run_with_timeout() { + if [[ "$2" == "mdutil" ]]; then + echo "Indexing enabled." + return 0 + fi + return 7 +} +is_ac_power() { return 0; } +time_file="$HOME/probe-time" +echo 0 > "$time_file" +get_epoch_seconds() { + local call + call=$(cat "$time_file") + call=$((call + 1)) + echo "$call" > "$time_file" + if ((call % 2 == 1)); then + echo 100 + else + echo 110 + fi +} +sleep() { return 0; } + +execute_optimization spotlight_index_optimize +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Spotlight speed check failed (2 probe(s))"* ]] || return 1 +} + +@test "saved state cleanup reports a failed discovery scan" { + run env HOME="$TEST_HOME/saved-scan" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mkdir -p "$HOME/Library/Saved Application State" "$HOME/bin" +printf '#!/bin/bash\nexit 7\n' > "$HOME/bin/find" +chmod +x "$HOME/bin/find" +PATH="$HOME/bin:$PATH" + +execute_optimization saved_state_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to scan old saved states"* ]] || return 1 + [[ "$output" != *"App saved states optimized"* ]] || return 1 + [[ "$output" != *"Failed to remove"* ]] || return 1 +} + +@test "shared file list repair reports a failed discovery scan" { + run env HOME="$TEST_HOME/shared-scan" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mkdir -p "$HOME/Library/Application Support/com.apple.sharedfilelist" "$HOME/bin" +printf '#!/bin/bash\nexit 7\n' > "$HOME/bin/find" +chmod +x "$HOME/bin/find" +PATH="$HOME/bin:$PATH" + +execute_optimization shared_file_list_repair +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to scan shared file lists"* ]] || return 1 + [[ "$output" != *"Failed to repair"* ]] || return 1 +} + +@test "optimize external probes use bounded execution" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +tasks_file="$PROJECT_ROOT/lib/optimize/tasks.sh" + +system_body=$(sed -n '/^opt_system_maintenance() {/,/^}/p' "$tasks_file") +saved_body=$(sed -n '/^opt_saved_state_cleanup() {/,/^}/p' "$tasks_file") +network_body=$(sed -n '/^opt_network_stack_optimize() {/,/^}/p' "$tasks_file") +vpn_body=$(sed -n '/^has_active_vpn_interface() {/,/^}/p' "$tasks_file") +shared_body=$(sed -n '/^opt_shared_file_list_repair() {/,/^}/p' "$tasks_file") + +[[ "$system_body" == *'run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" mdutil -s /'* ]] || exit 1 +[[ "$saved_body" == *'run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find'* ]] || exit 1 +[[ "$network_body" == *'run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" route -n get default'* ]] || exit 1 +[[ "$network_body" == *'run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" dscacheutil -q host'* ]] || exit 1 +[[ "$vpn_body" == *'run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" scutil --nc list'* ]] || exit 1 +[[ "$vpn_body" == *'run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" route -n get default'* ]] || exit 1 +[[ "$shared_body" == *'run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find'* ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} + +@test "network probe timeout never authorizes maintenance" { + run env HOME="$TEST_HOME/network-timeout" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 MOLE_DRY_RUN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +run_with_timeout() { return 124; } +sudo() { echo "UNEXPECTED_SUDO"; return 0; } + +execute_optimization network_stack_optimize +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Network health check timed out"* ]] || return 1 + [[ "$output" != *"Network routing table refreshed"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUDO"* ]] || return 1 +} + +@test "network probes preserve the caller errexit mode" { + run env HOME="$TEST_HOME/vpn-errexit" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +run_with_timeout() { return 124; } +sudo() { echo "UNEXPECTED_SUDO"; return 0; } +set +e +execute_optimization network_stack_optimize +false +echo "survived:$(optimize_outcome_count failed)" +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to inspect active VPN state"* ]] || return 1 + [[ "$output" == *"survived:1"* ]] || return 1 + [[ "$output" != *"Network routing table refreshed"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUDO"* ]] || return 1 +} + +@test "optimize tasks never toggle the caller errexit option" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +tasks_file="$PROJECT_ROOT/lib/optimize/tasks.sh" +if grep -nE '^[[:space:]]*set [+-]e([[:space:]]|$)' "$tasks_file"; then + exit 1 +fi +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} diff --git a/Resources/mole/tests/optimize_removal_outcomes.bats b/Resources/mole/tests/optimize_removal_outcomes.bats new file mode 100644 index 0000000..c894ae4 --- /dev/null +++ b/Resources/mole/tests/optimize_removal_outcomes.bats @@ -0,0 +1,163 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize-removals.XXXXXX")" + export TEST_HOME +} + +teardown_file() { + if [[ "$TEST_HOME" == "${BATS_TEST_DIRNAME}/tmp-optimize-removals."* ]]; then + rm -rf "$TEST_HOME" + fi +} + +@test "cache refresh reports a failed cache removal" { + run env HOME="$TEST_HOME/cache" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +mkdir -p "$HOME/Library/Caches/com.apple.iconservices" +should_protect_path() { return 1; } +safe_remove() { return 1; } +qlmanage() { return 0; } + +execute_optimization cache_refresh +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "${OPTIMIZE_CACHE_CLEANED_KB:-missing}" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to remove 1 Finder cache target(s)"* ]] || return 1 +} + +@test "cache refresh reports failed rebuild commands" { + run env HOME="$TEST_HOME/cache-command" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +qlmanage() { return 9; } + +execute_optimization cache_refresh +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +[[ "$(optimize_outcome_count applied)" == "0" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to rebuild 2 Finder cache service(s)"* ]] || return 1 + [[ "$output" != *"QuickLook thumbnails refreshed"* ]] || return 1 +} + +@test "saved state cleanup reports a failed removal" { + run env HOME="$TEST_HOME/saved-state" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +state_path="$HOME/Library/Saved Application State/Test.savedState" +mkdir -p "$state_path" +touch -t 202001010000 "$state_path" +should_protect_path() { return 1; } +safe_remove() { return 1; } + +execute_optimization saved_state_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to remove 1 old saved state(s)"* ]] || return 1 + [[ "$output" != *"App saved states optimized"* ]] || return 1 +} + +@test "launch agent cleanup reports a failed removal" { + run env HOME="$TEST_HOME/launch-agent" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +plist="$HOME/Library/LaunchAgents/com.test.broken.plist" +mkdir -p "$(dirname "$plist")" +/usr/libexec/PlistBuddy -c "Add :Program string /missing/test-agent" "$plist" > /dev/null 2>&1 +safe_remove() { return 1; } +launchctl() { return 0; } + +execute_optimization launch_agents_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to remove 1 broken Launch Agent(s)"* ]] || return 1 +} + +@test "shared file list repair reports a failed removal" { + run env HOME="$TEST_HOME/shared-list" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +sfl_dir="$HOME/Library/Application Support/com.apple.sharedfilelist" +mkdir -p "$sfl_dir" +touch "$sfl_dir/com.test.invalid.sfl3" +plutil() { return 1; } +safe_remove() { return 1; } + +execute_optimization shared_file_list_repair +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to repair 1 corrupted shared file list(s)"* ]] || return 1 +} + +@test "CoreDuet cleanup reports a failed sidecar removal" { + run env HOME="$TEST_HOME/coreduet" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +knowledge_dir="$HOME/Library/Application Support/Knowledge" +mkdir -p "$knowledge_dir" +touch "$knowledge_dir/knowledgeC.db" "$knowledge_dir/knowledgeC.db-wal" +run_with_timeout() { echo "112640 total"; } +safe_remove() { return 1; } +sqlite3() { return 0; } + +execute_optimization coreduet_cleanup +[[ "$(optimize_outcome_count failed)" == "1" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"Knowledge database cleanup incomplete"* ]] || return 1 +} + +@test "CoreDuet cleanup preserves sidecars when sqlite3 is unavailable" { + run env HOME="$TEST_HOME/coreduet-unavailable" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +knowledge_dir="$HOME/Library/Application Support/Knowledge" +wal_file="$knowledge_dir/knowledgeC.db-wal" +mkdir -p "$knowledge_dir" +touch "$knowledge_dir/knowledgeC.db" "$wal_file" +run_with_timeout() { echo "112640 total"; } +awk() { echo "112640"; } +safe_remove() { + echo "UNEXPECTED_REMOVE:$1" + return 0 +} +PATH="/nonexistent" + +execute_optimization coreduet_cleanup +[[ "$(optimize_outcome_count unavailable)" == "1" ]] || exit 1 +[[ -f "$wal_file" ]] || exit 1 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"sqlite3 not available"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] || return 1 +} diff --git a/Resources/mole/tests/optimize_summary.bats b/Resources/mole/tests/optimize_summary.bats new file mode 100644 index 0000000..a52cf88 --- /dev/null +++ b/Resources/mole/tests/optimize_summary.bats @@ -0,0 +1,105 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize-summary.XXXXXX")" + export TEST_HOME +} + +teardown_file() { + if [[ "$TEST_HOME" == "${BATS_TEST_DIRNAME}/tmp-optimize-summary."* ]]; then + rm -rf "$TEST_HOME" + fi +} + +@test "optimize dry-run summary reports outcomes instead of catalog size" { + run env HOME="$TEST_HOME" MOLE_TEST_NO_AUTH=1 MOLE_ASSUME_VPN_ACTIVE=0 NO_COLOR=1 "$PROJECT_ROOT/mole" optimize --dry-run + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" =~ Would\ apply\ [0-9]+\ optimizations ]] || { echo "$output"; return 1; } + local applied_count="${BASH_REMATCH[0]#Would apply }" + applied_count="${applied_count% optimizations}" + [[ "$output" != *"Would apply 23 optimizations"* ]] || return 1 + [[ "$output" =~ [0-9]+\ unchanged ]] || return 1 + [[ "$output" =~ [0-9]+\ skipped ]] || return 1 + [[ "$output" != *"System fully optimized"* ]] || return 1 + + run env HOME="$TEST_HOME" "$PROJECT_ROOT/mole" history --json + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"\"items\": $applied_count"* ]] || return 1 + [[ "$output" == *"\"failed_tasks\": 0"* ]] || return 1 +} + +@test "optimize failure reaches terminal exit and history contracts" { + local config_dir="$TEST_HOME/.config/mole" + local stub_dir="$TEST_HOME/bin" + mkdir -p "$config_dir" "$stub_dir" + + run env PROJECT_ROOT="$PROJECT_ROOT" CONFIG_FILE="$config_dir/whitelist_optimize" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/catalog.sh" +for action in "${MOLE_OPTIMIZE_ACTIONS[@]}"; do + [[ "$action" == "cache_refresh" ]] || printf '%s\n' "$action" +done > "$CONFIG_FILE" +EOF + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + + cat > "$stub_dir/qlmanage" <<'EOF' +#!/bin/bash +exit 9 +EOF + chmod +x "$stub_dir/qlmanage" + + run env HOME="$TEST_HOME" MOLE_TEST_NO_AUTH=1 NO_COLOR=1 PATH="$stub_dir:$PATH" \ + "$PROJECT_ROOT/mole" optimize + [[ "$status" -eq 1 ]] || { echo "$output"; return 1; } + [[ "$output" == *"1 failed"* ]] || { echo "$output"; return 1; } + [[ "$output" == *"Failed to rebuild 2 Finder cache service(s)"* ]] || return 1 + + run env HOME="$TEST_HOME" "$PROJECT_ROOT/mole" history --json + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + printf '%s\n' "$output" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +session = data["sessions"][0] +assert session["command"] == "optimize" +assert session["items"] == 0 +assert session["failed_tasks"] == 1 +' +} + +@test "optimize cleanup records startup and interrupt failures in history" { + run env HOME="$TEST_HOME/cleanup-history" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/outcomes.sh" +eval "$(sed -n '/^cleanup_all() {/,/^}/p' "$PROJECT_ROOT/bin/optimize.sh")" + +stop_inline_spinner() { :; } +stop_sudo_session() { :; } +cleanup_temp_files() { :; } +log_operation_session_end() { :; } +log_operation() { printf '%s|%s|%s|%s\n' "$1" "$2" "$3" "$4"; } + +optimize_outcomes_reset +cleanup_all 1 +cleanup_all 130 +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } + [[ "$output" == *"optimize|TASK_FAILED|session|exit status 1"* ]] || return 1 + [[ "$output" == *"optimize|TASK_FAILED|interrupted|exit status 130"* ]] +} + +@test "optimize EXIT trap forwards the terminal status to cleanup" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +grep -qF "trap 'cleanup_all \"\$?\"' EXIT" "$PROJECT_ROOT/bin/optimize.sh" +EOF + + [[ "$status" -eq 0 ]] || { echo "$output"; return 1; } +} diff --git a/Resources/mole/tests/path_validation_fuzz.bats b/Resources/mole/tests/path_validation_fuzz.bats new file mode 100644 index 0000000..191909c --- /dev/null +++ b/Resources/mole/tests/path_validation_fuzz.bats @@ -0,0 +1,133 @@ +#!/usr/bin/env bats +# Property-based test: every path in tests/fuzz_corpus/dangerous_paths.txt +# MUST be rejected by validate_path_for_deletion. If even one passes, +# the corpus has caught a real safety regression - investigate, do not +# weaken the corpus. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-path-fuzz.XXXXXX")" + export HOME + mkdir -p "$HOME" + + CORPUS="$BATS_TEST_DIRNAME/fuzz_corpus/dangerous_paths.txt" + export CORPUS +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + # shellcheck source=lib/core/common.sh + source "$PROJECT_ROOT/lib/core/common.sh" +} + +@test "corpus file exists and is non-empty" { + [ -f "$CORPUS" ] + [ -s "$CORPUS" ] +} + +@test "every dangerous path is rejected by validate_path_for_deletion" { + [ -f "$CORPUS" ] + + local rejected=0 + local accepted=0 + local -a accepted_paths=() + local line + + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and blank lines + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "$line" ]] && continue + + run /bin/bash --noprofile --norc -s -- "$line" <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +validate_path_for_deletion "$1" +EOF + if [[ "$status" -eq 0 ]]; then + accepted=$((accepted + 1)) + accepted_paths+=("$line") + else + rejected=$((rejected + 1)) + fi + done < "$CORPUS" + + if [[ $accepted -gt 0 ]]; then + printf 'FAIL: %d dangerous paths were accepted:\n' "$accepted" >&2 + printf ' %s\n' "${accepted_paths[@]}" >&2 + fi + [ "$accepted" -eq 0 ] + [ "$rejected" -ge 50 ] +} + +@test "generated control-character paths are rejected" { + run /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +validate_path_for_deletion $'/Users/me/with\nnewline' +EOF + [ "$status" -eq 1 ] + + run /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +validate_path_for_deletion $'/Users/me/with\tab' +EOF + [ "$status" -eq 1 ] +} + +@test "property: an ancestor symlink into any critical root is always rejected" { + # The string corpus cannot express this class: the path text is innocuous + # and only the filesystem state makes it dangerous. Generate one case per + # critical root instead, so a future refactor of the ancestor guard cannot + # silently narrow it to a single hardcoded root. + local sandbox + sandbox="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-ancestor.XXXXXX")" + + # Only roots whose DESCENDANTS the policy denies. /var is deliberately not + # here: /var/folders and friends are cleanable temp trees, so /var/x/y is + # accepted literally too, and the symlinked form must stay consistent with + # that (the guard is deny-only, it never invents a stricter policy). + local root + for root in /System /usr /bin /etc; do + local link="$sandbox/link-${root//\//_}" + ln -s "$root" "$link" + # Victim sits directly under the redirected dir, so its parent (the + # symlink) really resolves: this is the shape a hijacked cache root + # takes, and the shape the guard must catch. + run /bin/bash --noprofile --norc <&2 + return 1 + fi mkdir -p "$HOME/www" mkdir -p "$HOME/dev" mkdir -p "$HOME/.cache/mole" rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* + rm -rf "${HOME:?}/Library/CloudStorage" "${HOME:?}/Library/Mobile Documents" +} + +@test "mole_purge_is_cloud_synced_path matches only exact cloud roots and descendants" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +physical_home="$HOME/cloud-home-physical" +logical_home="$HOME/cloud-home-link" +mkdir -p "$physical_home" +ln -s "$physical_home" "$logical_home" +HOME="$logical_home" +source "$PROJECT_ROOT/lib/clean/purge_shared.sh" + +mole_purge_is_cloud_synced_path "$HOME/Library/CloudStorage" +mole_purge_is_cloud_synced_path "$HOME/Library/CloudStorage/Provider/project/target" +mole_purge_is_cloud_synced_path "$HOME/Library/Mobile Documents" +mole_purge_is_cloud_synced_path "$HOME/Library/Mobile Documents/com~apple~CloudDocs/project/node_modules" +mole_purge_is_cloud_synced_path "$physical_home/Library/CloudStorage/Provider/project/target" +mole_purge_is_cloud_synced_path "$physical_home/Library/Mobile Documents/com~apple~CloudDocs/project/node_modules" + +if mole_purge_is_cloud_synced_path "$HOME/Library/CloudStorageBackup/project/target"; then + exit 1 +fi +if mole_purge_is_cloud_synced_path "$HOME/Library/Mobile Documents-old/project/target"; then + exit 1 +fi +if mole_purge_is_cloud_synced_path "$physical_home/Library/CloudStorageBackup/project/target"; then + exit 1 +fi +if mole_purge_is_cloud_synced_path "$physical_home/Library/Mobile Documents-old/project/target"; then + exit 1 +fi +EOF + + [ "$status" -eq 0 ] || return 1 } @test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" { - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then echo 'UNSAFE' @@ -41,7 +83,7 @@ setup() { } @test "is_safe_project_artifact: allows proper project artifacts" { - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/myproject/node_modules' '$HOME/www'; then echo 'ALLOWED' @@ -53,7 +95,7 @@ setup() { } @test "is_safe_project_artifact: rejects non-absolute paths" { - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then echo 'UNSAFE' @@ -65,7 +107,7 @@ setup() { } @test "is_safe_project_artifact: validates depth calculation" { - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/project/subdir/node_modules' '$HOME/www'; then echo 'ALLOWED' @@ -80,7 +122,7 @@ setup() { mkdir -p "$HOME/single-project/node_modules" touch "$HOME/single-project/package.json" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/single-project/node_modules' '$HOME/single-project'; then echo 'ALLOWED' @@ -97,7 +139,7 @@ setup() { touch "$HOME/www/real/proj/package.json" ln -s "$HOME/www/real" "$HOME/www/link" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/real/proj/node_modules' '$HOME/www/link/proj'; then echo 'ALLOWED' @@ -109,10 +151,59 @@ setup() { [[ "$result" == "ALLOWED" ]] } +@test "is_safe_configured_purge_artifact rejects paths outside configured roots" { + mkdir -p "$HOME/www/project/node_modules" "$HOME/dev/other/node_modules" + + result=$(/bin/bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + PURGE_SEARCH_PATHS=('$HOME/www') + if is_safe_configured_purge_artifact '$HOME/dev/other/node_modules'; then + echo 'UNSAFE' + else + echo 'BLOCKED' + fi + ") + + [[ "$result" == "BLOCKED" ]] +} + +@test "compact_purge_scan_path keeps the tail of long purge paths visible" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_SKIP_MAIN=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/purge.sh" +compact_purge_scan_path "$HOME/projects/team/service/very/deep/component/node_modules" 32 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == ".../deep/component/node_modules" ]] +} + +@test "compact_purge_menu_path keeps the project tail visible" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +compact_purge_menu_path "$HOME/projects/team/service/very/deep/component/node_modules" 32 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == ".../deep/component/node_modules" ]] +} + +@test "format_purge_target_path rewrites home with tilde" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +format_purge_target_path "$HOME/www/app/node_modules" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == \~/www/app/node_modules ]] +} + @test "filter_nested_artifacts: removes nested node_modules" { mkdir -p "$HOME/www/project/node_modules/package/node_modules" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' '$HOME/www/project/node_modules' '$HOME/www/project/node_modules/package/node_modules' | \ filter_nested_artifacts | wc -l | tr -d ' ' @@ -125,7 +216,7 @@ setup() { mkdir -p "$HOME/www/project1/node_modules" mkdir -p "$HOME/www/project2/target" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' '$HOME/www/project1/node_modules' '$HOME/www/project2/target' | \ filter_nested_artifacts | wc -l | tr -d ' ' @@ -142,7 +233,7 @@ setup() { mkdir -p "$HOME/www/testapp/build/Framework.build" mkdir -p "$HOME/www/testapp/build/Package.build" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' \ '$HOME/www/testapp/build' \ @@ -163,7 +254,7 @@ setup() { touch "$HOME/www/test-rails/Gemfile" touch "$HOME/www/test-rails/bin/rails" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_rails_project_root '$HOME/www/test-rails'; then echo 'YES' @@ -179,7 +270,7 @@ setup() { mkdir -p "$HOME/www/not-rails" touch "$HOME/www/not-rails/package.json" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_rails_project_root '$HOME/www/not-rails'; then echo 'YES' @@ -195,7 +286,7 @@ setup() { mkdir -p "$HOME/www/test-go" touch "$HOME/www/test-go/go.mod" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_go_project_root '$HOME/www/test-go'; then echo 'YES' @@ -211,7 +302,7 @@ setup() { mkdir -p "$HOME/www/test-php" touch "$HOME/www/test-php/composer.json" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_php_project_root '$HOME/www/test-php'; then echo 'YES' @@ -230,7 +321,7 @@ setup() { touch "$HOME/www/rails-app/Gemfile" touch "$HOME/www/rails-app/config/environment.rb" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/rails-app/vendor'; then echo 'PROTECTED' @@ -246,7 +337,7 @@ setup() { mkdir -p "$HOME/www/php-app/vendor" touch "$HOME/www/php-app/composer.json" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/php-app/vendor'; then echo 'PROTECTED' @@ -259,7 +350,7 @@ setup() { } @test "is_project_container detects project indicators" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" mkdir -p "$HOME/Workspace2/project" @@ -274,7 +365,7 @@ EOF } @test "discover_project_dirs includes detected containers" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" mkdir -p "$HOME/CustomProjects/app" @@ -285,8 +376,53 @@ EOF [ "$status" -eq 0 ] } +@test "discover_project_dirs includes agent worktree containers" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.codex/worktrees/checkout/node_modules" +discover_project_dirs | grep -q "^$HOME/.codex/worktrees$" +EOF + + [ "$status" -eq 0 ] +} + +@test "discover_project_dirs still ignores unlisted dot directories" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.local/share/app" +touch "$HOME/.local/share/app/package.json" +if discover_project_dirs | grep -q "$HOME/.local"; then + echo "leaked" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"leaked"* ]] || return 1 +} + +@test "agent worktree container does not allow direct-child artifact removal" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.codex/worktrees/node_modules" +mkdir -p "$HOME/.codex/worktrees/checkout/node_modules" +if is_safe_project_artifact "$HOME/.codex/worktrees/node_modules" "$HOME/.codex/worktrees"; then + echo "direct-child-allowed" +fi +if is_safe_project_artifact "$HOME/.codex/worktrees/checkout/node_modules" "$HOME/.codex/worktrees"; then + echo "nested-allowed" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"nested-allowed"* ]] || return 1 + [[ "$output" != *"direct-child-allowed"* ]] || return 1 +} + @test "save_discovered_paths writes config with tilde" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" save_discovered_paths "$HOME/Projects" @@ -297,7 +433,7 @@ EOF } @test "select_purge_categories returns failure on empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" if select_purge_categories; then @@ -309,7 +445,7 @@ EOF } @test "select_purge_categories restores caller EXIT/INT/TERM traps" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" trap 'echo parent-exit' EXIT @@ -347,22 +483,56 @@ EOF } @test "confirm_purge_cleanup accepts Enter" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" drain_pending_input() { :; } -confirm_purge_cleanup 2 1024 0 <<< '' +confirm_purge_cleanup 2 1024 0 0 <<< '' EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] +} + +@test "confirm_purge_cleanup shows selected paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +drain_pending_input() { :; } +confirm_purge_cleanup 2 1024 0 0 "~/www/app/node_modules" "~/www/app/dist" <<< '' +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Selected paths:"* ]] || return 1 + [[ "$output" == *"~/www/app/node_modules"* ]] || return 1 + [[ "$output" == *"~/www/app/dist"* ]] +} + +@test "confirm_purge_cleanup warns once before confirming cloud-synced artifacts" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +drain_pending_input() { :; } +confirm_purge_cleanup 2 1024 0 1 "[cloud] ~/Library/CloudStorage/Provider/app/target" "~/www/app/dist" <<< '' +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Cloud-synced artifacts may also be removed from other devices."* ]] || return 1 + [[ "$output" == *"mo purge --paths"* ]] || return 1 + local warning_count + warning_count=$(printf '%s\n' "$output" | grep -cF "Cloud-synced artifacts may also be removed from other devices.") + [ "$warning_count" -eq 1 ] || return 1 + local warning_line prompt_line + warning_line=$(printf '%s\n' "$output" | grep -nF "Cloud-synced artifacts may also be removed from other devices." | cut -d: -f1) + prompt_line=$(printf '%s\n' "$output" | grep -nF "Remove 2 artifacts" | cut -d: -f1) + [ "$warning_line" -lt "$prompt_line" ] || return 1 } @test "confirm_purge_cleanup cancels on ESC" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" drain_pending_input() { :; } -confirm_purge_cleanup 2 1024 0 <<< $'\033' +confirm_purge_cleanup 2 1024 0 0 <<< $'\033' EOF [ "$status" -eq 1 ] @@ -372,7 +542,7 @@ EOF mkdir -p "$HOME/www/go-app/vendor" touch "$HOME/www/go-app/go.mod" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/go-app/vendor'; then echo 'PROTECTED' @@ -387,7 +557,7 @@ EOF @test "is_protected_vendor_dir: protects unknown vendor (conservative)" { mkdir -p "$HOME/www/unknown-app/vendor" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/unknown-app/vendor'; then echo 'PROTECTED' @@ -403,7 +573,7 @@ EOF mkdir -p "$HOME/www/php-app/vendor" touch "$HOME/www/php-app/composer.json" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_purge_artifact '$HOME/www/php-app/vendor'; then echo 'PROTECTED' @@ -419,7 +589,7 @@ EOF @test "is_protected_purge_artifact: returns false for non-vendor artifacts" { mkdir -p "$HOME/www/app/node_modules" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_purge_artifact '$HOME/www/app/node_modules'; then echo 'PROTECTED' @@ -444,7 +614,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/rails-app/vendor' '$scan_output'; then @@ -466,7 +636,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/php-app/vendor' '$scan_output'; then @@ -489,7 +659,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/go-app/vendor' '$scan_output'; then @@ -511,7 +681,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/unknown-app/vendor' '$scan_output'; then @@ -534,7 +704,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' MO_USE_FIND=1 scan_purge_targets '$HOME/single-project' '$scan_output' if grep -q '$HOME/single-project/node_modules' '$scan_output'; then @@ -549,6 +719,28 @@ EOF [[ "$result" == "FOUND" ]] } +@test "scan_purge_targets: includes Terragrunt cache in project root with find mode" { + mkdir -p "$HOME/terragrunt-project/.terragrunt-cache" + touch "$HOME/terragrunt-project/terragrunt.hcl" + + local scan_output + scan_output="$(mktemp)" + + result=$(/bin/bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/terragrunt-project' '$scan_output' + if grep -q '$HOME/terragrunt-project/.terragrunt-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'MISSING' + fi + ") + + rm -f "$scan_output" + + [[ "$result" == "FOUND" ]] +} + @test "scan_purge_targets: supports trailing slash search path in find mode" { mkdir -p "$HOME/single-project/node_modules" touch "$HOME/single-project/package.json" @@ -556,7 +748,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' MO_USE_FIND=1 scan_purge_targets '$HOME/single-project/' '$scan_output' if grep -q '$HOME/single-project/node_modules' '$scan_output'; then @@ -571,11 +763,105 @@ EOF [[ "$result" == "FOUND" ]] } +@test "scan_purge_targets: includes valid CACHEDIR.TAG directories in find mode" { + mkdir -p "$HOME/www/python-app/.custom-cache" + touch "$HOME/www/python-app/pyproject.toml" + printf 'Signature: 8a477f597d28d172789f06886806bc55\n' > "$HOME/www/python-app/.custom-cache/CACHEDIR.TAG" + + scan_output=$(mktemp) + result=$(/bin/bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/www' '$scan_output' + if grep -q '$HOME/www/python-app/.custom-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'NOT_FOUND' + fi + ") + rm -f "$scan_output" + + [[ "$result" == "FOUND" ]] +} + +@test "scan_purge_targets: ignores invalid CACHEDIR.TAG signatures" { + mkdir -p "$HOME/www/python-app/.custom-cache" + touch "$HOME/www/python-app/pyproject.toml" + printf 'Signature: invalid\n' > "$HOME/www/python-app/.custom-cache/CACHEDIR.TAG" + + scan_output=$(mktemp) + result=$(/bin/bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/www' '$scan_output' + if grep -q '$HOME/www/python-app/.custom-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'NOT_FOUND' + fi + ") + rm -f "$scan_output" + + [[ "$result" == "NOT_FOUND" ]] +} + +@test "scan_purge_targets: keeps CACHEDIR.TAG under Library out of purge scans" { + mkdir -p "$HOME/www/python-app/Library/fontconfig-cache" + touch "$HOME/www/python-app/pyproject.toml" + printf 'Signature: 8a477f597d28d172789f06886806bc55\n' > "$HOME/www/python-app/Library/fontconfig-cache/CACHEDIR.TAG" + + scan_output=$(mktemp) + result=$(/bin/bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/www' '$scan_output' + if grep -q '$HOME/www/python-app/Library/fontconfig-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'NOT_FOUND' + fi + ") + rm -f "$scan_output" + + [[ "$result" == "NOT_FOUND" ]] +} + +@test "scan_purge_targets: trusts empty fd result without falling back to find" { + mkdir -p "$HOME/.config/mole" "$HOME/www/empty-project" + printf '%s\n' "$HOME/www" > "$HOME/.config/mole/purge_paths" + + local mock_bin="$HOME/mock-bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/fd" <<'EOF' +#!/bin/bash +exit 0 +EOF + chmod +x "$mock_bin/fd" + cat > "$mock_bin/find" <<'EOF' +#!/bin/bash +echo find-called >> "$HOME/find-called" +exit 0 +EOF + chmod +x "$mock_bin/find" + + local scan_output + scan_output="$(mktemp)" + + run env HOME="$HOME" PATH="$mock_bin:$PATH" /bin/bash --noprofile --norc </dev/null - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' get_dir_size_kb '$HOME/www/test-project/node_modules' ") @@ -622,7 +1005,7 @@ EOF } @test "get_dir_size_kb: handles non-existent paths gracefully" { - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' get_dir_size_kb '$HOME/www/non-existent' ") @@ -632,7 +1015,7 @@ EOF @test "get_dir_size_kb: returns TIMEOUT when size calculation hangs" { mkdir -p "$HOME/www/stuck-project/node_modules" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' run_with_timeout() { return 124; } @@ -642,8 +1025,21 @@ EOF [[ "$result" == "TIMEOUT" ]] } +@test "get_dir_size_kb: returns ERROR when du fails without timing out" { + mkdir -p "$HOME/www/error-project/node_modules" + + result=$(/bin/bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + source '$PROJECT_ROOT/lib/clean/project.sh' + run_with_timeout() { return 2; } + get_dir_size_kb '$HOME/www/error-project/node_modules' + ") + + [[ "$result" == "ERROR" ]] +} + @test "clean_project_artifacts: restores caller INT/TERM traps" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail export HOME='$HOME' source '$PROJECT_ROOT/lib/core/common.sh' @@ -673,7 +1069,7 @@ EOF } @test "clean_project_artifacts: handles empty directory gracefully" { - run bash -c " + run /bin/bash -c " export HOME='$HOME' source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' @@ -684,7 +1080,7 @@ EOF } @test "clean_project_artifacts: handles empty menu options under set -u" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/project.sh" @@ -702,19 +1098,79 @@ EOF [[ "$output" == *"No artifacts found to purge"* ]] } +@test "clean_project_artifacts: include-empty exposes zero-size artifacts (#869)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/project.sh" + +mkdir -p "$HOME/.cache/mole" +echo "0" > "$HOME/.cache/mole/purge_stats" + +mkdir -p "$HOME/www/test-project/node_modules" +touch "$HOME/www/test-project/package.json" +touch -t 202001010101 "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" + +PURGE_SEARCH_PATHS=("$HOME/www") +get_dir_size_kb() { echo 0; } + +export MOLE_PURGE_INCLUDE_EMPTY=1 +export MOLE_DRY_RUN=1 +clean_project_artifacts /dev/null || echo missing)" +echo "SIZE=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo missing)" +[[ -d "$HOME/www/test-project/node_modules" ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=1"* ]] || return 1 + [[ "$output" == *"SIZE=0"* ]] +} + +@test "clean_project_artifacts: skips size calculation errors instead of showing 0B (#869)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/project.sh" + +mkdir -p "$HOME/www/test-project/node_modules" +touch "$HOME/www/test-project/package.json" +touch -t 202001010101 "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" + +PURGE_SEARCH_PATHS=("$HOME/www") +get_dir_size_kb() { echo ERROR; } + +clean_project_artifacts "$HOME/.cache/mole/purge_stats" +# purge_count must be seeded too: without it this reads a previous test's file +# through the shared HOME, or reports "missing" when run alone. +echo "0" > "$HOME/.cache/mole/purge_count" mkdir -p "$HOME/www/test-project/node_modules" echo "test data" > "$HOME/www/test-project/node_modules/file.js" touch "$HOME/www/test-project/package.json" -touch -t 202001010101 "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" +# The contained file has to be aged as well. Recency is judged from the newest +# entry inside the artifact, so a fresh file.js marks the whole node_modules as +# recent, the non-interactive branch skips it, and the failed-removal path this +# test exists for is never reached. +touch -t 202001010101 "$HOME/www/test-project/node_modules/file.js" \ + "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" PURGE_SEARCH_PATHS=("$HOME/www") safe_remove() { return 1; } @@ -729,10 +1185,121 @@ echo "SIZE=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo missing)" EOF [ "$status" -eq 0 ] - [[ "$output" == *"COUNT=0"* ]] + [[ "$output" == *"COUNT=0"* ]] || return 1 [[ "$output" == *"SIZE=0"* ]] } +@test "clean_project_artifacts accepts configured artifacts outside HOME (#1205)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" BATS_TEST_TMPDIR="$BATS_TEST_TMPDIR" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/project.sh" + +external_root="$BATS_TEST_TMPDIR/var-www" +artifact="$external_root/site/node_modules" +mkdir -p "$artifact" "$HOME/.cache/mole" +touch "$external_root/site/package.json" + +PURGE_SEARCH_PATHS=("$external_root") +scan_purge_targets() { printf '%s\n' "$artifact" > "$2"; } +get_dir_size_kb() { echo 1; } +is_recently_modified() { return 1; } +safe_remove() { + printf 'REMOVE:%s\n' "$1" + return 0 +} + +export MOLE_DRY_RUN=1 +clean_project_artifacts "$2"; } +get_dir_size_kb() { echo 4; } +is_recently_modified() { + _PURGE_ACTIVITY_STATE=old + return 1 +} +purge_target_activity_still_safe() { return 0; } +safe_remove() { + printf 'REMOVE:%s\n' "$1" + return 0 +} + +export MOLE_DRY_RUN=1 +clean_project_artifacts "$2" ;; + "$local_root") printf '%s\n' "$local_artifact" > "$2" ;; + esac +} +get_dir_size_kb() { + case "$1" in + "$cloud_artifact") echo 4 ;; + "$local_artifact") echo 8 ;; + esac +} +is_recently_modified() { + _PURGE_ACTIVITY_STATE=old + return 1 +} +purge_target_activity_still_safe() { return 0; } +safe_remove() { + printf 'REMOVE:%s\n' "$1" + return 0 +} + +unset MOLE_DRY_RUN +clean_project_artifacts /dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then skip "gtimeout/timeout not available" @@ -746,7 +1313,7 @@ EOF timeout_cmd="timeout" command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run /bin/bash -c " export HOME='$HOME' $timeout_cmd 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true " @@ -768,6 +1335,13 @@ EOF [[ "$output" == *"mo purge"* ]] } +@test "mo purge --help includes include-empty option" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" purge --help + [ "$status" -eq 0 ] + [[ "$output" == *"--include-empty"* ]] || return 1 + [[ "$output" == *"Show zero-size project artifact directories"* ]] +} + @test "mo purge: accepts --debug flag" { if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then skip "gtimeout/timeout not available" @@ -776,9 +1350,9 @@ EOF timeout_cmd="timeout" command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run /bin/bash -c " export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true " true } @@ -791,14 +1365,31 @@ EOF timeout_cmd="timeout" command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run /bin/bash -c " export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true " [[ "$output" == *"DRY RUN MODE"* ]] || [[ "$output" == *"Dry run complete"* ]] } +@test "mo purge: accepts --include-empty flag" { + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi + + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + + run /bin/bash -c " + export HOME='$HOME' + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge --include-empty --dry-run < /dev/null 2>&1 + " + + [ "$status" -eq 0 ] || [ "$status" -eq 2 ] + [[ "$output" != *"Unknown option"* ]] +} + @test "mo purge: creates cache directory for stats" { if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then skip "gtimeout/timeout not available" @@ -807,9 +1398,9 @@ EOF timeout_cmd="timeout" command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - bash -c " + /bin/bash -c " export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true " [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] @@ -820,7 +1411,7 @@ EOF mkdir -p "$HOME/www/dotnet-app/bin/Debug" touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then echo 'FOUND' @@ -836,7 +1427,7 @@ EOF mkdir -p "$HOME/www/dotnet-app/bin" touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then echo 'FOUND' @@ -853,7 +1444,7 @@ EOF mkdir -p "$HOME/www/dotnet-app/obj" touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/obj'; then echo 'FOUND' @@ -872,7 +1463,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/dotnet-app/bin' '$scan_output'; then @@ -894,7 +1485,7 @@ EOF local scan_output scan_output="$(mktemp)" - result=$(bash -c " + result=$(/bin/bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/ruby-app/bin' '$scan_output'; then @@ -907,3 +1498,207 @@ EOF rm -f "$scan_output" [[ "$result" == "SKIPPED" ]] } + +# --------------------------------------------------------------------------- +# Regression tests: sort-order consistency in clean_project_artifacts +# +# Bug: after sorting artifacts by size (descending), item_display_paths was +# not included in the reorder, so PURGE_CATEGORY_FULL_PATHS_ARRAY ended up +# in the original discovery order (alphabetical) while every other parallel +# array (menu_options, item_paths, item_sizes, …) was in size order. +# Effect: the "Full path" footer showed the wrong project for the highlighted +# item, and the confirmation dialog listed paths that did not match the +# selection. See https://github.com/tw93/Mole/issues/647 +# +# These tests run clean_project_artifacts under a pseudo-terminal (so the +# interactive code path is taken and select_purge_categories is called). +# The function is overridden to capture PURGE_CATEGORY_FULL_PATHS_ARRAY and +# PURGE_CATEGORY_SIZES without performing any actual deletion. +# --------------------------------------------------------------------------- + +# Run a bash script file under a pseudo-terminal so that [[ -t 0 ]] is true +# inside the script. Required to exercise the interactive branch of +# clean_project_artifacts, which only calls select_purge_categories when +# stdin is a tty. +_run_in_pty() { + local script_file="$1" + # A socket-backed runner stdin makes macOS script(1) fail before the child starts. + script -q /dev/null /bin/bash --noprofile --norc "$script_file" < /dev/null 2>/dev/null +} + +@test "sort: PURGE_CATEGORY_FULL_PATHS_ARRAY[0] is the largest artifact after size-descending sort" { + # alpha = small (~5 KB), beta = large (~200 KB). + # Alphabetical discovery order puts alpha first; size order puts beta first. + # After the sort, PURGE_CATEGORY_FULL_PATHS_ARRAY[0] must be beta's path. + mkdir -p "$HOME/www/alpha/node_modules" + mkdir -p "$HOME/www/beta/node_modules" + echo '{}' > "$HOME/www/alpha/package.json" + echo '{}' > "$HOME/www/beta/package.json" + dd if=/dev/zero of="$HOME/www/alpha/node_modules/data" bs=1024 count=5 2>/dev/null + dd if=/dev/zero of="$HOME/www/beta/node_modules/data" bs=1024 count=200 2>/dev/null + + local capture_file script_file + capture_file=$(mktemp "$HOME/sort_capture.XXXXXX") + script_file=$(mktemp "$HOME/sort_script.XXXXXX.sh") + + cat > "$script_file" << SCRIPT +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.cache/mole" +export XDG_CACHE_HOME="$HOME/.cache" +export TERM="dumb" +PURGE_SEARCH_PATHS=("$HOME/www") + +# Override the interactive selector: dump the full-path array to the capture +# file then cancel (return 1) so nothing is deleted. +select_purge_categories() { + printf '%s\n' "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}" > "$capture_file" + PURGE_SELECTION_RESULT="" + return 1 +} + +clean_project_artifacts 2>/dev/null || true +SCRIPT + + _run_in_pty "$script_file" + rm -f "$script_file" + + if [[ ! -s "$capture_file" ]]; then + rm -f "$capture_file" + fail "capture file is empty – select_purge_categories was never called (stdin was not a tty?)" + fi + + local first_path + first_path=$(head -1 "$capture_file") + rm -f "$capture_file" + + # With the bug item_display_paths is not sorted, so alpha (alphabetically + # first) appears at index 0 → [[ ... == *beta* ]] fails. + # After the fix beta (largest) is at index 0 → test passes. + [[ "$first_path" == *"beta"* ]] +} + +@test "sort: PURGE_CATEGORY_FULL_PATHS_ARRAY and PURGE_CATEGORY_SIZES indices are consistent" { + mkdir -p "$HOME/www/alpha/node_modules" + mkdir -p "$HOME/www/beta/node_modules" + echo '{}' > "$HOME/www/alpha/package.json" + echo '{}' > "$HOME/www/beta/package.json" + dd if=/dev/zero of="$HOME/www/alpha/node_modules/data" bs=1024 count=5 2>/dev/null + dd if=/dev/zero of="$HOME/www/beta/node_modules/data" bs=1024 count=200 2>/dev/null + + local capture_file script_file + capture_file=$(mktemp "$HOME/sort_capture.XXXXXX") + script_file=$(mktemp "$HOME/sort_script.XXXXXX.sh") + + cat > "$script_file" << SCRIPT +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.cache/mole" +export XDG_CACHE_HOME="$HOME/.cache" +export TERM="dumb" +PURGE_SEARCH_PATHS=("$HOME/www") + +select_purge_categories() { + echo "SIZES=\${PURGE_CATEGORY_SIZES:-}" > "$capture_file" + local i=0 + for p in "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}"; do + echo "PATH[\$i]=\$p" >> "$capture_file" + i=\$((i + 1)) + done + PURGE_SELECTION_RESULT="" + return 1 +} + +clean_project_artifacts 2>/dev/null || true +SCRIPT + + _run_in_pty "$script_file" + rm -f "$script_file" + + if [[ ! -s "$capture_file" ]]; then + rm -f "$capture_file" + fail "capture file is empty – select_purge_categories was never called (stdin was not a tty?)" + fi + + local sizes_csv + sizes_csv=$(grep '^SIZES=' "$capture_file" | cut -d= -f2-) + IFS=',' read -r -a sizes <<< "$sizes_csv" + + local path0 path1 + path0=$(grep '^PATH\[0\]=' "$capture_file" | head -1 | cut -d= -f2-) + path1=$(grep '^PATH\[1\]=' "$capture_file" | head -1 | cut -d= -f2-) + rm -f "$capture_file" + + # PURGE_CATEGORY_SIZES must be sorted descending (largest first). + [ "${sizes[0]}" -gt "${sizes[1]}" ] + + # Index 0 → largest artifact → beta's path. + # With the bug path0 = alpha (discovery order) → [[ ... == *beta* ]] fails. + [[ "$path0" == *"beta"* ]] || return 1 + + # Index 1 → smaller artifact → alpha's path. + [[ "$path1" == *"alpha"* ]] +} + +@test "sort: cloud marker stays aligned across menu and full-path arrays" { + mkdir -p "$HOME/www/local-project/node_modules" + mkdir -p "$HOME/Library/CloudStorage/TestProvider/cloud-project/node_modules" + echo '{}' > "$HOME/www/local-project/package.json" + echo '{}' > "$HOME/Library/CloudStorage/TestProvider/cloud-project/package.json" + dd if=/dev/zero of="$HOME/www/local-project/node_modules/data" bs=1024 count=200 2>/dev/null + dd if=/dev/zero of="$HOME/Library/CloudStorage/TestProvider/cloud-project/node_modules/data" bs=1024 count=5 2>/dev/null + + local capture_file script_file + capture_file=$(mktemp "$HOME/sort_cloud_capture.XXXXXX") + script_file=$(mktemp "$HOME/sort_cloud_script.XXXXXX.sh") + + cat > "$script_file" << SCRIPT +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.cache/mole" +export XDG_CACHE_HOME="$HOME/.cache" +export TERM="dumb" +PURGE_SEARCH_PATHS=("$HOME/www" "$HOME/Library/CloudStorage") + +select_purge_categories() { + local i=0 + for option in "\$@"; do + echo "MENU[\$i]=\$option" >> "$capture_file" + i=\$((i + 1)) + done + i=0 + for path in "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}"; do + echo "PATH[\$i]=\$path" >> "$capture_file" + i=\$((i + 1)) + done + PURGE_SELECTION_RESULT="" + return 1 +} + +clean_project_artifacts 2>/dev/null || true +SCRIPT + + _run_in_pty "$script_file" + rm -f "$script_file" + + if [[ ! -s "$capture_file" ]]; then + rm -f "$capture_file" + fail "capture file is empty; select_purge_categories was never called" + fi + + local menu0 menu1 path0 path1 + menu0=$(grep '^MENU\[0\]=' "$capture_file" | cut -d= -f2-) + menu1=$(grep '^MENU\[1\]=' "$capture_file" | cut -d= -f2-) + path0=$(grep '^PATH\[0\]=' "$capture_file" | cut -d= -f2-) + path1=$(grep '^PATH\[1\]=' "$capture_file" | cut -d= -f2-) + rm -f "$capture_file" + + [[ "$menu0" != *"[cloud]"* ]] || return 1 + [[ "$path0" != *"[cloud]"* ]] || return 1 + [[ "$menu0" == *"local-project"* ]] || return 1 + [[ "$path0" == *"local-project"* ]] || return 1 + [[ "$menu1" == *"[cloud]"* ]] || return 1 + [[ "$path1" == *"[cloud]"* ]] || return 1 + [[ "$menu1" == *"cloud-project"* ]] || return 1 + [[ "$path1" == *"cloud-project"* ]] || return 1 +} diff --git a/Resources/mole/tests/purge_config_paths.bats b/Resources/mole/tests/purge_config_paths.bats index 9fe106b..5315193 100644 --- a/Resources/mole/tests/purge_config_paths.bats +++ b/Resources/mole/tests/purge_config_paths.bats @@ -14,24 +14,31 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" mkdir -p "$HOME/.config/mole" } @test "load_purge_config loads default paths when config file is missing" { - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" [ "$status" -eq 0 ] - [[ "$output" == *"$HOME/Projects"* ]] - [[ "$output" == *"$HOME/GitHub"* ]] + [[ "$output" == *"$HOME/Projects"* ]] || return 1 + [[ "$output" == *"$HOME/GitHub"* ]] || return 1 [[ "$output" == *"$HOME/dev"* ]] } @@ -43,15 +50,30 @@ $HOME/custom/projects $HOME/work EOF - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" [ "$status" -eq 0 ] - [[ "$output" == *"$HOME/custom/projects"* ]] - [[ "$output" == *"$HOME/work"* ]] + [[ "$output" == *"$HOME/custom/projects"* ]] || return 1 + [[ "$output" == *"$HOME/work"* ]] || return 1 [[ "$output" != *"$HOME/GitHub"* ]] } +@test "load_purge_config can exclude default cloud storage roots" { + local config_file="$HOME/.config/mole/purge_paths" + + cat > "$config_file" << EOF +$HOME/custom/projects +EOF + + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; printf '%s\n' \"\${PURGE_SEARCH_PATHS[@]}\"" + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == "$HOME/custom/projects" ]] || return 1 + [[ "$output" != *"$HOME/Library/CloudStorage"* ]] || return 1 + [[ "$output" != *"$HOME/Library/Mobile Documents"* ]] || return 1 +} + @test "load_purge_config expands tilde in paths" { local config_file="$HOME/.config/mole/purge_paths" @@ -60,12 +82,12 @@ EOF ~/another/one EOF - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" [ "$status" -eq 0 ] - [[ "$output" == *"$HOME/tilde/expanded"* ]] - [[ "$output" == *"$HOME/another/one"* ]] + [[ "$output" == *"$HOME/tilde/expanded"* ]] || return 1 + [[ "$output" == *"$HOME/another/one"* ]] || return 1 [[ "$output" != *"~"* ]] } @@ -79,7 +101,7 @@ $HOME/valid/path $HOME/another/path EOF - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${#PURGE_SEARCH_PATHS[@]}\"; echo \"\${PURGE_SEARCH_PATHS[*]}\"" + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${#PURGE_SEARCH_PATHS[@]}\"; echo \"\${PURGE_SEARCH_PATHS[*]}\"" [ "$status" -eq 0 ] @@ -88,7 +110,7 @@ EOF local count="${lines[0]}" [ "$count" -eq 2 ] - [[ "$output" == *"$HOME/valid/path"* ]] + [[ "$output" == *"$HOME/valid/path"* ]] || return 1 [[ "$output" == *"$HOME/another/path"* ]] } @@ -96,7 +118,7 @@ EOF local config_file="$HOME/.config/mole/purge_paths" touch "$config_file" - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" [ "$status" -eq 0 ] @@ -107,9 +129,53 @@ EOF local config_file="$HOME/.config/mole/purge_paths" echo "# Just a comment" > "$config_file" - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" + [ "$status" -eq 0 ] - + [[ "$output" == *"$HOME/Projects"* ]] } + +@test "load_purge_config deduplicates case variants on case-insensitive FS" { + # Create a real directory so resolve_path_case can cd into it + mkdir -p "$HOME/code" + + local config_file="$HOME/.config/mole/purge_paths" + cat > "$config_file" << EOF +$HOME/code +$HOME/Code +EOF + + run env HOME="$HOME" /bin/bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${#PURGE_SEARCH_PATHS[@]}\"" + + [ "$status" -eq 0 ] + + # On case-insensitive FS (macOS default) both resolve to the same path, + # so count should be 1. On case-sensitive FS, Code doesn't exist, so + # resolve_path_case returns it unchanged, count may be 2 which is correct + # since they really are different directories. + if [[ -d "$HOME/Code" && "$(cd "$HOME/Code" && pwd -P)" == "$(cd "$HOME/code" && pwd -P)" ]]; then + [ "$output" = "1" ] + fi +} + +@test "discover_project_dirs deduplicates default Code vs actual code" { + # Simulate: $HOME/code exists (actual dir), $HOME/Code is in defaults + mkdir -p "$HOME/code/myproject" + touch "$HOME/code/myproject/package.json" + + # No config file, triggers discovery + run env HOME="$HOME" /bin/bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + discover_project_dirs + " + + [ "$status" -eq 0 ] + + # On case-insensitive FS, $HOME/code should appear only once + if [[ -d "$HOME/Code" && "$(cd "$HOME/Code" && pwd -P)" == "$(cd "$HOME/code" && pwd -P)" ]]; then + local count + count=$(echo "$output" | grep -c "$HOME/code" || true) + [ "$count" -le 1 ] + fi +} diff --git a/Resources/mole/tests/regression.bats b/Resources/mole/tests/regression.bats index 3e10baa..11b12de 100644 --- a/Resources/mole/tests/regression.bats +++ b/Resources/mole/tests/regression.bats @@ -9,7 +9,7 @@ setup() { @test "find with non-existent directory doesn't cause script exit (pipefail bug)" { - result=$(bash -c ' + result=$(/bin/bash -c ' set -euo pipefail find /non/existent/dir -name "*.cache" 2>/dev/null || true echo "survived" @@ -18,7 +18,7 @@ setup() { } @test "browser directory check pattern is safe when directories don't exist" { - result=$(bash -c ' + result=$(/bin/bash -c ' set -euo pipefail search_dirs=() [[ -d "/non/existent/chrome" ]] && search_dirs+=("/non/existent/chrome") @@ -33,7 +33,7 @@ setup() { } @test "empty array doesn't cause unbound variable error" { - result=$(bash -c ' + result=$(/bin/bash -c ' set -euo pipefail search_dirs=() @@ -47,7 +47,7 @@ setup() { @test "version comparison works correctly" { - result=$(bash -c ' + result=$(/bin/bash -c ' v1="1.11.8" v2="1.11.9" if [[ "$(printf "%s\n" "$v1" "$v2" | sort -V | head -1)" == "$v1" && "$v1" != "$v2" ]]; then @@ -58,7 +58,7 @@ setup() { } @test "version comparison with same versions" { - result=$(bash -c ' + result=$(/bin/bash -c ' v1="1.11.8" v2="1.11.8" if [[ "$(printf "%s\n" "$v1" "$v2" | sort -V | head -1)" == "$v1" && "$v1" != "$v2" ]]; then @@ -71,7 +71,7 @@ setup() { } @test "version prefix v/V is stripped correctly" { - result=$(bash -c ' + result=$(/bin/bash -c ' version="v1.11.9" clean=${version#v} clean=${clean#V} @@ -89,7 +89,7 @@ setup() { command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" # shellcheck disable=SC2016 - result=$($timeout_cmd 5 bash -c ' + result=$($timeout_cmd 5 /bin/bash -c ' result=$(curl -fsSL --connect-timeout 1 --max-time 2 "http://192.0.2.1:12345/test" 2>/dev/null || echo "failed") if [[ "$result" == "failed" ]]; then echo "timeout_works" @@ -106,11 +106,11 @@ setup() { cat > "$fake_cmd" <<'EOF' #!/bin/bash trap "" TERM -sleep 30 +sleep 5 EOF chmod +x "$fake_cmd" - run /usr/bin/perl -e 'alarm 8; exec @ARGV' env FAKE_CMD="$fake_cmd" bash --noprofile --norc <<'EOF' + run /usr/bin/perl -e 'alarm 5; exec @ARGV' env FAKE_CMD="$fake_cmd" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/timeout.sh" MO_TIMEOUT_BIN="" @@ -123,14 +123,14 @@ set -e echo "STATUS=$status ELAPSED=$SECONDS" EOF [ "$status" -eq 0 ] - [[ "$output" == *"STATUS=124"* ]] + [[ "$output" == *"STATUS=124"* ]] || return 1 elapsed=$(printf '%s\n' "$output" | awk '{for (i = 1; i <= NF; i++) if ($i ~ /^ELAPSED=/) {split($i, kv, "="); print kv[2]}}' | tail -1) - [[ "$elapsed" =~ ^[0-9]+$ ]] + [[ "$elapsed" =~ ^[0-9]+$ ]] || return 1 (( elapsed < 6 )) } @test "empty version string is handled gracefully" { - result=$(bash -c ' + result=$(/bin/bash -c ' latest="" if [[ -z "$latest" ]]; then echo "handled" @@ -141,7 +141,7 @@ EOF @test "grep with no match doesn't cause exit in pipefail mode" { - result=$(bash -c ' + result=$(/bin/bash -c ' set -euo pipefail echo "test" | grep "nonexistent" || true echo "survived" @@ -150,7 +150,7 @@ EOF } @test "command substitution failure is handled with || true" { - result=$(bash -c ' + result=$(/bin/bash -c ' set -euo pipefail output=$(false) || true echo "survived" @@ -159,7 +159,7 @@ EOF } @test "arithmetic on zero doesn't cause exit" { - result=$(bash -c ' + result=$(/bin/bash -c ' set -euo pipefail count=0 ((count++)) || true @@ -170,7 +170,7 @@ EOF @test "safe_remove pattern doesn't fail on non-existent path" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/common.sh' safe_remove '$HOME/non/existent/path' true > /dev/null 2>&1 || true @@ -180,10 +180,140 @@ EOF } @test "module loading doesn't fail" { - result=$(bash -c " + result=$(/bin/bash -c " set -euo pipefail source '$PROJECT_ROOT/lib/core/common.sh' echo 'loaded' ") [[ "$result" == "loaded" ]] } + +@test "normalize_paths_for_cleanup handles large nested batches without hanging" { + local limit_ms="${MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS:-10000}" + + run env PROJECT_ROOT="$PROJECT_ROOT" LIMIT_MS="$limit_ms" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +PYTHON_BIN="${PYTHON_BIN:-}" +if [[ -z "$PYTHON_BIN" ]]; then + PYTHON_BIN=$(command -v python3 || command -v python || true) +fi +[[ -n "$PYTHON_BIN" ]] || { echo "python unavailable"; exit 127; } + +"$PYTHON_BIN" - <<'PY' +from pathlib import Path +import os +project_root = Path(os.environ["PROJECT_ROOT"]) +text = (project_root / "bin/clean.sh").read_text() +start = text.index("normalize_paths_for_cleanup() {") +depth = 0 +end = None +for i in range(start, len(text)): + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break +Path("/tmp/normalize_paths_for_cleanup.sh").write_text(text[start:end] + "\n") +PY + +source /tmp/normalize_paths_for_cleanup.sh + +paths=( + "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" + "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches/" +) +for i in $(seq 1 6000); do + paths+=("$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches/item-$i") + paths+=("$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches/item-$i") +done + +start_ns=$("$PYTHON_BIN" - <<'PY' +import time +print(time.time_ns()) +PY +) +normalized=() +while IFS= read -r -d '' line; do + normalized+=("$line") +done < <(normalize_paths_for_cleanup "${paths[@]}") +end_ns=$("$PYTHON_BIN" - <<'PY' +import time +print(time.time_ns()) +PY +) +elapsed_ms=$(( (end_ns - start_ns) / 1000000 )) + +printf 'COUNT=%s ELAPSED_MS=%s\n' "${#normalized[@]}" "$elapsed_ms" +printf '%s\n' "${normalized[@]}" + +[[ ${#normalized[@]} -eq 2 ]] || exit 1 +[[ "${normalized[0]}" == "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches" || "${normalized[1]}" == "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches" ]] +[[ "${normalized[0]}" == "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" || "${normalized[1]}" == "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" ]] +(( elapsed_ms < LIMIT_MS )) +EOF + + if [ "$status" -ne 0 ]; then + printf 'normalize_paths_for_cleanup status=%s\n' "$status" >&3 + printf '%s\n' "$output" >&3 + fi + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=2"* ]] +} + +@test "normalize_paths_for_cleanup removes whole Gradle DSL hash dirs" { + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +PYTHON_BIN="${PYTHON_BIN:-}" +if [[ -z "$PYTHON_BIN" ]]; then + PYTHON_BIN=$(command -v python3 || command -v python || true) +fi +[[ -n "$PYTHON_BIN" ]] || { echo "python unavailable"; exit 127; } + +"$PYTHON_BIN" - <<'PY' +from pathlib import Path +import os +project_root = Path(os.environ["PROJECT_ROOT"]) +text = (project_root / "bin/clean.sh").read_text() +start = text.index("normalize_paths_for_cleanup() {") +depth = 0 +end = None +for i in range(start, len(text)): + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break +Path("/tmp/normalize_paths_for_cleanup_gradle.sh").write_text(text[start:end] + "\n") +PY + +source /tmp/normalize_paths_for_cleanup_gradle.sh + +hash_dir="$HOME/.gradle/caches/8.13/groovy-dsl/abc123" +paths=( + "$hash_dir/metadata.bin" + "$hash_dir/classes/cp.bin" + "$HOME/.gradle/caches/8.13/kotlin-dsl/def456/metadata.bin" +) + +normalized=() +while IFS= read -r -d '' line; do + normalized+=("$line") +done < <(normalize_paths_for_cleanup "${paths[@]}") + +printf '%s\n' "${normalized[@]}" + +[[ ${#normalized[@]} -eq 2 ]] || exit 1 +[[ "${normalized[0]}" == "$HOME/.gradle/caches/8.13/groovy-dsl/abc123" || "${normalized[1]}" == "$HOME/.gradle/caches/8.13/groovy-dsl/abc123" ]] +[[ "${normalized[0]}" == "$HOME/.gradle/caches/8.13/kotlin-dsl/def456" || "${normalized[1]}" == "$HOME/.gradle/caches/8.13/kotlin-dsl/def456" ]] +EOF + + [ "$status" -eq 0 ] +} diff --git a/Resources/mole/tests/scripts.bats b/Resources/mole/tests/scripts.bats index 8dc0edc..b2bd462 100644 --- a/Resources/mole/tests/scripts.bats +++ b/Resources/mole/tests/scripts.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="dumb" rm -rf "${HOME:?}"/* mkdir -p "$HOME" @@ -29,8 +36,8 @@ setup() { @test "check.sh --help shows usage information" { run "$PROJECT_ROOT/scripts/check.sh" --help [ "$status" -eq 0 ] - [[ "$output" == *"Usage"* ]] - [[ "$output" == *"--format"* ]] + [[ "$output" == *"Usage"* ]] || return 1 + [[ "$output" == *"--format"* ]] || return 1 [[ "$output" == *"--no-format"* ]] } @@ -38,37 +45,105 @@ setup() { [ -f "$PROJECT_ROOT/scripts/check.sh" ] [ -x "$PROJECT_ROOT/scripts/check.sh" ] - run bash -c "grep -q 'Mole Check' '$PROJECT_ROOT/scripts/check.sh'" - [ "$status" -eq 0 ] + run /bin/bash -c "grep -q 'Mole Check' '$PROJECT_ROOT/scripts/check.sh'" + [ "$status" -eq 0 ] +} + +@test "diagnostic guidance check rejects equivalent pipe-to-shell spellings across lines" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +eval "$(sed -n '/^check_diagnostic_guidance()/,/^}/p' "$PROJECT_ROOT/scripts/check.sh")" + +safe="$HOME/safe-guidance.md" +cat > "$safe" <<'SAFE' +Download `Mole-Diagnose.command` with `curl -o`, inspect it, then open it manually. +SAFE +check_diagnostic_guidance "$safe" + +assert_unsafe() { + local name="$1" + local guidance="$2" + local unsafe="$HOME/unsafe-${name}.md" + printf '%s\n' "$guidance" > "$unsafe" + if check_diagnostic_guidance "$unsafe"; then + echo "UNEXPECTED_UNSAFE_PASS:$name" + exit 1 + fi +} + +assert_unsafe path '`curl https://example.test/Mole-Diagnose.command | /bin/bash`' +assert_unsafe command '`curl https://example.test/Mole-Diagnose.command | command bash`' +assert_unsafe sudo '`curl https://example.test/Mole-Diagnose.command | sudo -u root bash`' +assert_unsafe env $'`curl https://example.test/Mole-Diagnose.command \\\n | env MODE=1 zsh`' +assert_unsafe tee $'`curl https://example.test/Mole-Diagnose.command |\n tee /tmp/diagnose | dash`' +assert_unsafe quoted "\`curl https://example.test/Mole-Diagnose.command | 'bash'\`" +assert_unsafe ansi_c "\`curl https://example.test/Mole-Diagnose.command | \$'bash'\`" +assert_unsafe ksh '`curl https://example.test/Mole-Diagnose.command | ksh`' +assert_unsafe escaped '`curl https://example.test/Mole-Diagnose.command | ba\sh`' +EOF + + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" != *"UNEXPECTED_UNSAFE_PASS:"* ]] } @test "test.sh script exists and is valid" { [ -f "$PROJECT_ROOT/scripts/test.sh" ] [ -x "$PROJECT_ROOT/scripts/test.sh" ] - run bash -c "grep -q 'Mole Test Runner' '$PROJECT_ROOT/scripts/test.sh'" + run /bin/bash -c "grep -q 'Mole Test Runner' '$PROJECT_ROOT/scripts/test.sh'" [ "$status" -eq 0 ] } @test "test.sh includes test lint step" { - run bash -c "grep -q 'Test script lint' '$PROJECT_ROOT/scripts/test.sh'" + run /bin/bash -c "grep -q 'Test script lint' '$PROJECT_ROOT/scripts/test.sh'" [ "$status" -eq 0 ] } @test "Makefile has build target for Go binaries" { - run bash -c "grep -Eq '(^|[[:space:]])(go|\\$\\(GO\\))[[:space:]]+build' '$PROJECT_ROOT/Makefile'" + run /bin/bash -c "grep -Eq '(^|[[:space:]])(go|\\$\\(GO\\))[[:space:]]+build' '$PROJECT_ROOT/Makefile'" + [ "$status" -eq 0 ] +} + +@test "release builds disable cgo and check minimum macOS version" { + run /bin/bash -c "grep -q '^RELEASE_GO_ENV := CGO_ENABLED=0$' '$PROJECT_ROOT/Makefile'" + [ "$status" -eq 0 ] + run /bin/bash -c "grep -q 'scripts/check_release_minos.sh' '$PROJECT_ROOT/.github/workflows/release.yml'" + [ "$status" -eq 0 ] + [ -x "$PROJECT_ROOT/scripts/check_release_minos.sh" ] +} + +@test "release workflow keeps the Homebrew Core PR open (#1209)" { + local workflow="$PROJECT_ROOT/.github/workflows/release.yml" + + run grep -F "Have you followed the [guidelines for contributing]" "$workflow" + [ "$status" -eq 0 ] + run grep -F "pulls?state=all&head=tw93:" "$workflow" + [ "$status" -eq 0 ] + run grep -F 'PR_STATE" != "open"' "$workflow" + [ "$status" -eq 0 ] + run grep -F 'core_status=published' "$workflow" + [ "$status" -eq 0 ] + run grep -F 'core_status=pr-open' "$workflow" + [ "$status" -eq 0 ] + + run awk ' + /name: Update Homebrew formula \(Official Core\)/ { in_step = 1 } + in_step && /continue-on-error:/ { found = 1 } + in_step && /name: Verify formula updates/ { exit found ? 1 : 0 } + END { if (!in_step) exit 1 } + ' "$workflow" [ "$status" -eq 0 ] } @test "setup-quick-launchers.sh has detect_mo function" { - run bash -c "grep -q 'detect_mo()' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" + run /bin/bash -c "grep -q 'detect_mo()' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" [ "$status" -eq 0 ] } @test "setup-quick-launchers.sh has Raycast script generation" { - run bash -c "grep -q 'create_raycast_commands' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" + run /bin/bash -c "grep -q 'create_raycast_commands' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" [ "$status" -eq 0 ] - run bash -c "grep -q 'write_raycast_script' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" + run /bin/bash -c "grep -q 'write_raycast_script' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" [ "$status" -eq 0 ] } @@ -84,8 +159,8 @@ EOF run env HOME="$HOME" TERM="dumb" PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ "$PROJECT_ROOT/scripts/setup-quick-launchers.sh" [ "$status" -eq 0 ] - [[ "$output" == *"Raycast: Mole Clean | Alfred keyword: clean"* ]] - [[ "$output" == *"Raycast: Mole Status | Alfred keyword: status"* ]] + [[ "$output" == *"Raycast: Mole Clean | Alfred keyword: clean"* ]] || return 1 + [[ "$output" == *"Raycast: Mole Status | Alfred keyword: status"* ]] || return 1 local raycast_dir="$HOME/Library/Application Support/Raycast/script-commands" [ -d "$raycast_dir" ] @@ -126,8 +201,40 @@ EOF } @test "install.sh supports dev branch installs" { - run bash -c "grep -q 'refs/heads/dev.tar.gz' '$PROJECT_ROOT/install.sh'" + run env PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +eval "$(sed -n '/^source_archive_url()/,/^}/p' "$PROJECT_ROOT/install.sh")" +[[ "$(source_archive_url dev "")" == "https://github.com/tw93/mole/archive/refs/heads/dev.tar.gz" ]] +EOF + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + run /bin/bash -c "grep -q 'MOLE_VERSION=\"dev\"' '$PROJECT_ROOT/install.sh'" [ "$status" -eq 0 ] - run bash -c "grep -q 'MOLE_VERSION=\"dev\"' '$PROJECT_ROOT/install.sh'" +} + +@test "release workflow keeps Homebrew distribution on official core only" { + run grep -q 'update-homebrew-core:' "$PROJECT_ROOT/.github/workflows/release.yml" + [ "$status" -eq 0 ] + + run grep -Eq 'update-personal-tap:|tw93/homebrew-tap|PAT_TOKEN' "$PROJECT_ROOT/.github/workflows/release.yml" + [ "$status" -ne 0 ] + + [ ! -e "$PROJECT_ROOT/scripts/update_homebrew_tap_formula.sh" ] + + run grep -Eq 'Homebrew tap|personal tap' "$PROJECT_ROOT/.claude/skills/release-notes/SKILL.md" + [ "$status" -ne 0 ] + run grep -q 'Homebrew Core PR is workflow-driven' "$PROJECT_ROOT/.claude/skills/release-notes/SKILL.md" [ "$status" -eq 0 ] } + +@test "no shell function shares another's body under a different name" { + # This gate also lives in check.sh, but CI runs scripts/test.sh and never + # check.sh, so without this case it could not block a pull request. The + # class it catches is invisible to grep: the copies that matter have + # already had their variables renamed, which is why review reads them as + # separate helpers. Run the script with --list to inspect every group. + run python3 "$PROJECT_ROOT/scripts/audit_function_duplication.py" + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} diff --git a/Resources/mole/tests/test_diagnostic_reports_standalone.sh b/Resources/mole/tests/test_diagnostic_reports_standalone.sh deleted file mode 100644 index 8137a30..0000000 --- a/Resources/mole/tests/test_diagnostic_reports_standalone.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash -# Standalone test for get_diagnostic_report_paths_for_app (Issue #441). Run: bash tests/test_diagnostic_reports_standalone.sh - -set +e -set +u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -if [[ ! -f "$PROJECT_ROOT/lib/core/app_protection.sh" ]]; then - PROJECT_ROOT="$(pwd)" - SCRIPT_DIR="$PROJECT_ROOT/tests" -fi -cd "$PROJECT_ROOT" || exit 1 - -source_crlf_safe() { - local f="$1" - if [[ -f "$f" ]]; then - # shellcheck source=/dev/null - source /dev/stdin <<< "$(sed 's/\r$//' < "$f")" - fi -} - -source_crlf_safe "$PROJECT_ROOT/lib/core/base.sh" -source_crlf_safe "$PROJECT_ROOT/lib/core/app_protection.sh" -set +e -set +u - -FAILED=0 -PASSED=0 - -assert_contains() { - local haystack="$1" - local needle="$2" - local name="${3:-assert}" - if [[ "$haystack" == *"$needle"* ]]; then - echo " OK $name" - ((PASSED++)) - return 0 - fi - echo " FAIL $name (expected to find: $needle)" - ((FAILED++)) - return 1 -} - -assert_empty() { - local val="$1" - local name="${2:-assert}" - if [[ -z "$val" ]]; then - echo " OK $name (empty as expected)" - ((PASSED++)) - return 0 - fi - echo " FAIL $name (expected empty, got: $val)" - ((FAILED++)) - return 1 -} - -echo "Testing get_diagnostic_report_paths_for_app (DiagnosticReports uninstall)" -echo "" - -out=$(get_diagnostic_report_paths_for_app "/Applications/Foo.app" "Foo" "/nonexistent/dir" 2> /dev/null || true) -assert_empty "$out" "missing directory returns empty" - -TMP_EMPTY=$(mktemp -d 2> /dev/null || mktemp -d -t mole-test 2> /dev/null || echo "") -[[ -z "$TMP_EMPTY" ]] && TMP_EMPTY="/tmp/mole-test-$$" && mkdir -p "$TMP_EMPTY" -out=$(get_diagnostic_report_paths_for_app "" "Ab" "$TMP_EMPTY" 2> /dev/null || true) -assert_empty "$out" "empty app_path returns empty" -rm -rf "$TMP_EMPTY" 2> /dev/null || true - -TMP_DIAG=$(mktemp -d 2> /dev/null || mktemp -d -t mole-diag 2> /dev/null || echo "/tmp/mole-diag-$$") -TMP_APP=$(mktemp -d 2> /dev/null || mktemp -d -t mole-app 2> /dev/null || echo "/tmp/mole-app-$$") -mkdir -p "$TMP_DIAG" "$TMP_APP" -mkdir -p "$TMP_APP/Contents" -printf '%s' 'CFBundleExecutableMyApp' > "$TMP_APP/Contents/Info.plist" - -touch "$TMP_DIAG/MyApp_2025-02-10-120000_host.ips" -touch "$TMP_DIAG/MyApp.crash" -touch "$TMP_DIAG/MyApp_2025-02-10-120001_host.spin" -touch "$TMP_DIAG/OtherApp_2025-02-10.ips" -touch "$TMP_DIAG/MyAppPro_2025-02-10-120002_host.ips" -touch "$TMP_DIAG/MyAppPro.crash" -touch "$TMP_DIAG/MyApp_log.txt" - -out=$(get_diagnostic_report_paths_for_app "$TMP_APP" "My App" "$TMP_DIAG" 2> /dev/null || true) - -assert_contains "$out" "MyApp_2025-02-10-120000" "returns .ips file" -assert_contains "$out" "MyApp.crash" "returns .crash file" -assert_contains "$out" "MyApp_2025-02-10-120001" "returns .spin file" -assert_contains "$out" ".ips" "output contains .ips path" -if [[ "$out" == *"OtherApp"* ]]; then - echo " FAIL should not return OtherApp" - ((FAILED++)) -else - echo " OK does not return OtherApp" - ((PASSED++)) -fi -if [[ "$out" == *"MyAppPro"* ]]; then - echo " FAIL should not return MyAppPro (prefix collision)" - ((FAILED++)) -else - echo " OK does not return MyAppPro" - ((PASSED++)) -fi -if [[ "$out" == *"MyApp_log.txt"* ]]; then - echo " FAIL should not return non-diagnostic extension" - ((FAILED++)) -else - echo " OK does not return .txt file" - ((PASSED++)) -fi - -rm -rf "$TMP_DIAG" "$TMP_APP" 2> /dev/null || true - -TMP_DIAG2=$(mktemp -d 2> /dev/null || mktemp -d -t mole-diag2 2> /dev/null || echo "/tmp/mole-diag2-$$") -TMP_APP2=$(mktemp -d 2> /dev/null || mktemp -d -t mole-app2 2> /dev/null || echo "/tmp/mole-app2-$$") -mkdir -p "$TMP_DIAG2" "$TMP_APP2" -mkdir -p "$TMP_APP2/Contents" -touch "$TMP_DIAG2/TestApp_2025-02-10.ips" - -out=$(get_diagnostic_report_paths_for_app "$TMP_APP2" "Test App" "$TMP_DIAG2" 2> /dev/null || true) -assert_contains "$out" "TestApp_" "fallback to nospace app name matches file" - -rm -rf "$TMP_DIAG2" "$TMP_APP2" 2> /dev/null || true - -echo "" -echo "Result: $PASSED passed, $FAILED failed" -if [[ $FAILED -gt 0 ]]; then - exit 1 -fi -echo "All DiagnosticReports tests passed." -exit 0 diff --git a/Resources/mole/tests/test_match_apps_helper.sh b/Resources/mole/tests/test_match_apps_helper.sh new file mode 100644 index 0000000..85cc7e0 --- /dev/null +++ b/Resources/mole/tests/test_match_apps_helper.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Test helper: load match_apps_by_name directly from bin/uninstall.sh for unit testing. +# Requires apps_data and selected_apps arrays to be defined before sourcing. + +# Declared by caller before sourcing this file +: "${apps_data?apps_data array must be set before sourcing this file}" + +_test_helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_repo_root="$(cd "${_test_helper_dir}/.." && pwd)" +_uninstall_script="${_repo_root}/bin/uninstall.sh" + +if [[ ! -f "${_uninstall_script}" ]]; then + echo "Error: unable to find ${_uninstall_script}" >&2 + return 1 +fi + +# Suppress color codes in test output +YELLOW="" +NC="" + +eval "$( + sed -n '/^match_apps_by_name()[[:space:]]*{/,/^}$/p' "${_uninstall_script}" +)" + +unset _test_helper_dir +unset _repo_root +unset _uninstall_script diff --git a/Resources/mole/tests/timeout_parent_death_fixture.sh b/Resources/mole/tests/timeout_parent_death_fixture.sh new file mode 100755 index 0000000..1808350 --- /dev/null +++ b/Resources/mole/tests/timeout_parent_death_fixture.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -uo pipefail + +PROJECT_ROOT="$1" +CHILD_PID_FILE="$2" + +# shellcheck source=lib/core/timeout.sh +source "$PROJECT_ROOT/lib/core/timeout.sh" + +MO_TIMEOUT_BIN="" +MO_TIMEOUT_PERL_BIN="/usr/bin/perl" + +# shellcheck disable=SC2016 # The inner bash expands $$ and $1. +run_with_timeout 30 /bin/bash --noprofile --norc -c ' + printf "%s\n" "$$" > "$1" + while :; do + /bin/sleep 1 + done +' timeout-child "$CHILD_PID_FILE" diff --git a/Resources/mole/tests/timeout_tty_background.exp b/Resources/mole/tests/timeout_tty_background.exp new file mode 100755 index 0000000..ee48c2b --- /dev/null +++ b/Resources/mole/tests/timeout_tty_background.exp @@ -0,0 +1,22 @@ +#!/usr/bin/expect -f +# Runs tests/timeout_tty_background_fixture.sh under a real pty (issue #1222) so +# the perl timeout fallback has a controlling terminal to hand off. Prints the +# fixture output for the bats caller to assert on. +# +# Args: PROJECT_ROOT MODE (MODE = tty | devnull) + +set timeout 8 +set project_root [lindex $argv 0] +set mode [lindex $argv 1] + +spawn /bin/bash "$project_root/tests/timeout_tty_background_fixture.sh" "$project_root" "$mode" + +expect { + -re {CALLER_PGRP=[0-9]+\r?\n} {} + timeout { exit 124 } + eof {} +} + +expect eof +set result [wait] +exit [lindex $result 3] diff --git a/Resources/mole/tests/timeout_tty_background_fixture.sh b/Resources/mole/tests/timeout_tty_background_fixture.sh new file mode 100755 index 0000000..9ab42d9 --- /dev/null +++ b/Resources/mole/tests/timeout_tty_background_fixture.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Regression fixture for issue #1222. +# +# The perl timeout fallback hands the controlling terminal to its timed child +# whenever stdin is a tty, so nested sudo inside the child can prompt (#1201). +# bin/uninstall.sh, however, calls run_with_timeout from background metadata and +# scan workers that have no use for the terminal. When such a worker inherits +# the tty on stdin, this handoff steals the terminal's foreground process group +# from the foreground script, which then stops with SIGTTIN at its confirmation +# prompt before anything is uninstalled. +# +# This fixture reports whether the timed child captured the controlling +# terminal's foreground process group: +# MODE=tty - stdin is the tty; the handoff must happen (#1201 preserved) +# MODE=devnull - stdin is /dev/null; the handoff must be skipped (#1222 fix) +# +# The child reads the foreground pgrp via /dev/tty (the controlling terminal), +# so the probe works even when its own stdin is /dev/null. + +set -uo pipefail + +PROJECT_ROOT="$1" +MODE="${2:-devnull}" + +# shellcheck source=lib/core/timeout.sh +source "$PROJECT_ROOT/lib/core/timeout.sh" + +# Force the perl fallback: this is the path that performs the tty handoff. +MO_TIMEOUT_BIN="" +MO_TIMEOUT_PERL_BIN="/usr/bin/perl" + +caller_pgrp=$(ps -o pgid= -p $$ | tr -d ' ') + +export MOLE_TTY_PROBE_MODE="$MODE" + +# The handoff is not synchronous with this child starting: run_with_timeout's +# perl parent calls tcsetpgrp *after* forking, so sampling the foreground group +# the instant the child runs can legitimately still show the caller's group. +# That raced on CI while passing 30 consecutive local runs. Wait for the handoff +# in tty mode instead of racing it, and in devnull mode let the terminal settle +# first, so a delayed handoff would still be caught rather than sampled past. +# shellcheck disable=SC2016 # Perl source; $pgrp/$tty/$fg are Perl variables. +child_probe=' + use POSIX qw(tcgetpgrp); + my $pgrp = getpgrp(); + open(my $tty, "<", "/dev/tty") or exit 3; + my $fg = tcgetpgrp(fileno($tty)); + if (($ENV{MOLE_TTY_PROBE_MODE} || "") eq "tty") { + for (1 .. 200) { + last if $fg == $pgrp; + select(undef, undef, undef, 0.01); + $fg = tcgetpgrp(fileno($tty)); + } + } else { + select(undef, undef, undef, 0.2); + $fg = tcgetpgrp(fileno($tty)); + } + print "CHILD_PGRP=$pgrp FG=$fg\n"; +' + +if [[ "$MODE" == "tty" ]]; then + run_with_timeout 3 /usr/bin/perl -e "$child_probe" +else + run_with_timeout 3 /usr/bin/perl -e "$child_probe" < /dev/null +fi + +echo "CALLER_PGRP=$caller_pgrp" diff --git a/Resources/mole/tests/timeout_tty_concurrent.exp b/Resources/mole/tests/timeout_tty_concurrent.exp new file mode 100644 index 0000000..ad452c7 --- /dev/null +++ b/Resources/mole/tests/timeout_tty_concurrent.exp @@ -0,0 +1,31 @@ +#!/usr/bin/expect -f + +set timeout 10 +set project_root [lindex $argv 0] + +spawn /bin/bash "$project_root/tests/timeout_tty_concurrent_fixture.sh" "$project_root" + +expect { + -re {CHILD:NOT-OWNS\r?\n} {} + -re {CHILD:OWNS\r?\n} { puts "\nHELPER TOOK A TERMINAL IT DID NOT OWN"; exit 3 } + timeout { exit 124 } + eof { exit 1 } +} + +expect { + -re {READY\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +send -- "typed-value\r" + +expect { + -re {READ:typed-value\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +expect eof +set result [wait] +exit [lindex $result 3] diff --git a/Resources/mole/tests/timeout_tty_concurrent_fixture.sh b/Resources/mole/tests/timeout_tty_concurrent_fixture.sh new file mode 100644 index 0000000..c8aec69 --- /dev/null +++ b/Resources/mole/tests/timeout_tty_concurrent_fixture.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -uo pipefail + +PROJECT_ROOT="$1" + +# shellcheck source=lib/core/timeout.sh +source "$PROJECT_ROOT/lib/core/timeout.sh" + +MO_TIMEOUT_BIN="" +MO_TIMEOUT_PERL_BIN="/usr/bin/perl" + +# Mole runs timeout helpers concurrently inside a single process group (the +# uninstall scan workers). Only the helper whose process group actually owns +# the terminal may hand it to its child: a helper that hands off while a +# sibling's child owns the terminal also restores the terminal to that +# sibling's child, which is a dead or reaped process group by then, and the +# script is left without the terminal. The next prompt read then stops on +# SIGTTIN and the uninstall hangs before removing anything (#1222, #1218). +# +# Here the background worker takes the terminal first and keeps it for 2s. The +# foreground helper below starts inside that window, so its child must report +# that it does NOT own the terminal. +(run_with_timeout 5 /bin/sleep 2) < /dev/tty & +worker=$! + +/bin/sleep 0.3 + +# shellcheck disable=SC2016 # Embedded Perl uses Perl variables inside single quotes. +run_with_timeout 3 /usr/bin/perl -e ' + use POSIX qw(tcgetpgrp); + my $owner = -t STDIN ? tcgetpgrp(0) : -1; + printf "CHILD:%s\n", ($owner == getpgrp() ? "OWNS" : "NOT-OWNS"); +' + +wait "$worker" 2> /dev/null || true + +printf 'READY\n' +read -r value +printf 'READ:%s\n' "$value" diff --git a/Resources/mole/tests/timeout_tty_foreground.bats b/Resources/mole/tests/timeout_tty_foreground.bats new file mode 100644 index 0000000..6d45793 --- /dev/null +++ b/Resources/mole/tests/timeout_tty_foreground.bats @@ -0,0 +1,97 @@ +#!/usr/bin/env bats + +setup() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + export MO_DEBUG=0 +} + +# Concurrent timeout helpers share one process group, so a helper that starts +# while a sibling holds the terminal must not treat the sibling's child as the +# terminal's original owner: restoring the terminal to that dead process group +# suspended the next prompt read with SIGTTIN (issues #1222, #1218). +@test "run_with_timeout: concurrent perl helpers keep the terminal with the script (#1222)" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/expect || ! -x /usr/bin/perl ]]; then + skip "macOS expect/perl required" + fi + + run /usr/bin/expect "$PROJECT_ROOT/tests/timeout_tty_concurrent.exp" "$PROJECT_ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"READ:typed-value"* ]] +} + +# Background scan workers never read the terminal. Leaving the tty on their +# stdin let their timeout helpers take the terminal away from the foreground +# prompt, which is what suspended `mo uninstall ` before it removed +# anything (issue #1222). +@test "uninstall: background metadata workers detach stdin from the terminal (#1222)" { + run grep -nE '^[[:space:]]*\) < /dev/null &' "$PROJECT_ROOT/bin/uninstall.sh" + [ "$status" -eq 0 ] + + run grep -nE 'process_app_metadata .* < /dev/null &' "$PROJECT_ROOT/bin/uninstall.sh" + [ "$status" -eq 0 ] + + run grep -nE '^[[:space:]]*\) > /dev/null 2>&1 < /dev/null &' "$PROJECT_ROOT/bin/uninstall.sh" + [ "$status" -eq 0 ] +} + +# Purge scans and size calculations run behind a tty-writing spinner. They must +# not let run_with_timeout's Perl fallback hand the controlling terminal to a +# background child, which suspends the foreground command with SIGTTOU (#1205). +@test "purge: background timeout workers detach stdin from the terminal (#1205)" { + run grep -nF "scan_purge_targets \"\$path\" \"\$scan_output\" < /dev/null &" "$PROJECT_ROOT/lib/clean/project.sh" + [ "$status" -eq 0 ] || return 1 + + run grep -nF "(get_dir_size_kb \"\$_sz_item\" > \"\$_stmp\" 2> /dev/null) < /dev/null &" "$PROJECT_ROOT/lib/clean/project.sh" + [ "$status" -eq 0 ] || return 1 +} + +@test "clean: indirect background timeout workers detach stdin from the terminal" { + run awk ' + /get_cleanup_path_size_kb "\$path"/ { in_worker = 1; remaining = 24 } + in_worker && /\)[[:space:]]*< \/dev\/null &/ { found = 1; exit } + in_worker && --remaining <= 0 { exit } + END { exit(found ? 0 : 1) } + ' "$PROJECT_ROOT/bin/clean.sh" + [ "$status" -eq 0 ] || return 1 + + # shellcheck disable=SC2016 # Match literal variables in the source line. + run grep -nF 'project_cache_has_indicators "$dir" 5 && echo "$dir" >> "$_indicator_tmp") < /dev/null &' \ + "$PROJECT_ROOT/lib/clean/caches.sh" + [ "$status" -eq 0 ] || return 1 +} + +# Source-level guard for direct calls and nearby subshell bodies. Shell has no +# static call graph, so indirect workers are pinned explicitly above and in the +# other call-site tests in this file. +@test "direct background timeout calls and nearby subshells detach stdin" { + local offenders="" + local file line text + while IFS= read -r hit; do + file="${hit%%:*}" + line="${hit#*:}" + line="${line%%:*}" + text="${hit#*:*:}" + # Background jobs already redirecting stdin are fine. + case "$text" in + *"< /dev/null"*) continue ;; + esac + # The job either calls run_with_timeout directly, or is a subshell whose + # body does. Look back a few lines for the subshell case. + if printf '%s' "$text" | grep -q 'run_with_timeout'; then + offenders="$offenders$file:$line"$'\n' + continue + fi + if [[ "$text" == *")"* ]] && + sed -n "$((line > 12 ? line - 12 : 1)),${line}p" "$file" | grep -q 'run_with_timeout'; then + offenders="$offenders$file:$line"$'\n' + fi + done < <(grep -rnE '[^&|]& *$' "$PROJECT_ROOT/lib" "$PROJECT_ROOT/bin" --include='*.sh' | grep -v 'disown') + + if [[ -n "$offenders" ]]; then + echo "Background jobs reaching run_with_timeout without '< /dev/null':" >&2 + printf '%s' "$offenders" >&2 + return 1 + fi +} diff --git a/Resources/mole/tests/timeout_tty_read.exp b/Resources/mole/tests/timeout_tty_read.exp new file mode 100644 index 0000000..73e99f7 --- /dev/null +++ b/Resources/mole/tests/timeout_tty_read.exp @@ -0,0 +1,24 @@ +#!/usr/bin/expect -f + +set timeout 4 +set project_root [lindex $argv 0] + +spawn /bin/bash "$project_root/tests/timeout_tty_read_fixture.sh" "$project_root" + +expect { + -re {READY\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +send -- "typed-value\r" + +expect { + -re {READ:typed-value\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +expect eof +set result [wait] +exit [lindex $result 3] diff --git a/Resources/mole/tests/timeout_tty_read_fixture.sh b/Resources/mole/tests/timeout_tty_read_fixture.sh new file mode 100644 index 0000000..7e73c66 --- /dev/null +++ b/Resources/mole/tests/timeout_tty_read_fixture.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -euo pipefail + +PROJECT_ROOT="$1" +MODE="${2:-read}" + +# shellcheck source=lib/core/timeout.sh +source "$PROJECT_ROOT/lib/core/timeout.sh" + +MO_TIMEOUT_BIN="" +MO_TIMEOUT_PERL_BIN="/usr/bin/perl" + +if [[ "$MODE" == "timeout" ]]; then + set +e + run_with_timeout 1 /bin/sleep 8 + rc=$? + set -e + + printf 'TIMEOUT:%s\n' "$rc" + printf 'READY-AFTER\n' + read -r value + printf 'READ-AFTER:%s\n' "$value" + exit 0 +fi + +# shellcheck disable=SC2016 # The inner bash expands value after reading the tty. +run_with_timeout 2 /bin/bash --noprofile --norc -c ' + printf "READY\n" + read -r value + printf "READ:%s\n" "$value" +' diff --git a/Resources/mole/tests/timeout_tty_restore.exp b/Resources/mole/tests/timeout_tty_restore.exp new file mode 100644 index 0000000..ce6133b --- /dev/null +++ b/Resources/mole/tests/timeout_tty_restore.exp @@ -0,0 +1,30 @@ +#!/usr/bin/expect -f + +set timeout 5 +set project_root [lindex $argv 0] + +spawn /bin/bash "$project_root/tests/timeout_tty_read_fixture.sh" "$project_root" timeout + +expect { + -re {TIMEOUT:124\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +expect { + -re {READY-AFTER\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +send -- "typed-after\r" + +expect { + -re {READ-AFTER:typed-after\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +expect eof +set result [wait] +exit [lindex $result 3] diff --git a/Resources/mole/tests/uninstall.bats b/Resources/mole/tests/uninstall.bats index e6bfe27..00cf6e3 100644 --- a/Resources/mole/tests/uninstall.bats +++ b/Resources/mole/tests/uninstall.bats @@ -1,67 +1,238 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats - if [[ -z "$ORIGINAL_HOME" ]]; then - ORIGINAL_HOME="${HOME:-}" - fi - export ORIGINAL_HOME + ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats + if [[ -z "$ORIGINAL_HOME" ]]; then + ORIGINAL_HOME="${HOME:-}" + fi + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" + export HOME } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - export TERM="dumb" - rm -rf "${HOME:?}"/* - mkdir -p "$HOME" + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" } create_app_artifacts() { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Containers/com.example.TestApp" - mkdir -p "$HOME/Library/Preferences" - touch "$HOME/Library/Preferences/com.example.TestApp.plist" - mkdir -p "$HOME/Library/Preferences/ByHost" - touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" - mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" - mkdir -p "$HOME/Library/LaunchAgents" - touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Containers/com.example.TestApp" + mkdir -p "$HOME/Library/Preferences" + touch "$HOME/Library/Preferences/com.example.TestApp.plist" + touch "$HOME/Library/Preferences/TestApp.plist" + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" + mkdir -p "$HOME/Library/Saved Application State/TestApp.savedState" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + mkdir -p "$HOME/.cache/testapp" } @test "find_app_files discovers user-level leftovers" { - create_app_artifacts + create_app_artifacts - result="$( - HOME="$HOME" bash --noprofile --norc <<'EOF' + result="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" find_app_files "com.example.TestApp" "TestApp" EOF - )" + )" + + [[ "$result" == *"Application Support/TestApp"* ]] || return 1 + [[ "$result" == *"Caches/TestApp"* ]] || return 1 + [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] || return 1 + [[ "$result" == *"Preferences/TestApp.plist"* ]] || return 1 + [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] || return 1 + [[ "$result" == *"Saved Application State/TestApp.savedState"* ]] || return 1 + [[ "$result" == *"Containers/com.example.TestApp"* ]] || return 1 + [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] || return 1 + [[ "$result" == *".cache/testapp"* ]] +} + +@test "find_app_files discovers recent-document shared file lists by bundle id" { + mkdir -p "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments" + touch "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/com.rogueamoeba.soundsource.sfl2" + touch "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/com.rogueamoeba.soundsource.sfl3" + touch "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/com.rogueamoeba.soundsource.sfl4" + touch "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/com.apple.systemsettings.sfl3" + + result="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.rogueamoeba.soundsource" "SoundSource" +EOF + )" + + [[ "$result" == *"com.rogueamoeba.soundsource.sfl2"* ]] || return 1 + [[ "$result" == *"com.rogueamoeba.soundsource.sfl3"* ]] || return 1 + [[ "$result" == *"com.rogueamoeba.soundsource.sfl4"* ]] || return 1 + [[ "$result" != *"com.apple.systemsettings.sfl3"* ]] +} + +@test "find_app_files discovers nested XPC helper preferences from selected app" { + app="$HOME/Applications/SoundSource.app" + mkdir -p "$app/Contents/Frameworks/RemoteAU.framework/Versions/A/XPCServices/RemoteAUHost.xpc/Contents" + mkdir -p "$app/Contents/Frameworks/Sparkle.framework/Versions/A/XPCServices/DownloaderService.xpc/Contents" + mkdir -p "$app/Contents/Frameworks/Sparkle.framework/Versions/A/Resources/Autoupdate.app/Contents" + mkdir -p "$HOME/Library/Caches/com.rogueamoeba.RemoteAUHost" + mkdir -p "$HOME/Library/Caches/com.rogueamoeba.RemoteAUHost.shared" + mkdir -p "$HOME/Library/HTTPStorages/org.sparkle-project.DownloaderService" + mkdir -p "$HOME/Library/Preferences" + cat > "$app/Contents/Frameworks/RemoteAU.framework/Versions/A/XPCServices/RemoteAUHost.xpc/Contents/Info.plist" << 'PLIST' + + + + CFBundleIdentifiercom.rogueamoeba.RemoteAUHost + +PLIST + cat > "$app/Contents/Frameworks/Sparkle.framework/Versions/A/XPCServices/DownloaderService.xpc/Contents/Info.plist" << 'PLIST' + + + + CFBundleIdentifierorg.sparkle-project.DownloaderService + +PLIST + cat > "$app/Contents/Frameworks/Sparkle.framework/Versions/A/Resources/Autoupdate.app/Contents/Info.plist" << 'PLIST' + + + + CFBundleIdentifierorg.sparkle-project.Sparkle.Autoupdate + +PLIST + touch "$HOME/Library/Preferences/com.rogueamoeba.RemoteAUHost.plist" + touch "$HOME/Library/Preferences/org.sparkle-project.Sparkle.Autoupdate.plist" + + result="$( + HOME="$HOME" APP="$app" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.rogueamoeba.soundsource" "SoundSource" "$APP" +EOF + )" + + [[ "$result" == *"Library/Preferences/com.rogueamoeba.RemoteAUHost.plist"* ]] || return 1 + [[ "$result" == *"Library/Caches/com.rogueamoeba.RemoteAUHost"* ]] || return 1 + [[ "$result" != *"Library/Caches/com.rogueamoeba.RemoteAUHost.shared"* ]] || return 1 + [[ "$result" != *"org.sparkle-project.Sparkle.Autoupdate.plist"* ]] || return 1 + [[ "$result" != *"org.sparkle-project.DownloaderService"* ]] +} + +@test "find_app_files discards an incomplete root but propagates cancellation" { + local nested="$HOME/Library/Caches/examplevendor/ExampleProduct" + mkdir -p "$nested" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" NESTED="$nested" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +run_with_timeout() { + local _duration="$1" + shift + if [[ "${1:-}" == "find" ]]; then + printf '%s\0' "$NESTED" + return "${SCAN_RC:?}" + fi + "$@" +} + +SCAN_RC=1 +result=$(find_app_files "com.examplevendor.ExampleProduct" "ExampleProduct") +[[ "$result" != *"$NESTED"* ]] || exit 1 + +SCAN_RC=130 +rc=0 +result=$(find_app_files "com.examplevendor.ExampleProduct" "ExampleProduct") || rc=$? +[[ $rc -eq 130 ]] || exit 1 +[[ "$result" != *"$NESTED"* ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "find_app_system_files discovers bundle-id-prefixed LaunchDaemons" { + fakebin="$HOME/fakebin" + mkdir -p "$fakebin" + + # The new dot-anchored alternation invokes find with two -name patterns: + # "${bundle_id}.plist" and "${bundle_id}.*.plist". Match on either form. + cat > "$fakebin/find" << 'SCRIPT' +#!/bin/sh +args="$*" + +case "$args" in + *"/Library/LaunchDaemons"*'-name com.west2online.ClashXPro.*.plist'*) + printf '%s\0' "/Library/LaunchDaemons/com.west2online.ClashXPro.ProxyConfigHelper.plist" + ;; +esac +SCRIPT + chmod +x "$fakebin/find" + + run env HOME="$HOME" PATH="$fakebin:$PATH" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +result=$(find_app_system_files "com.west2online.ClashXPro" "ClashX Pro") +[[ "$result" == *"/Library/LaunchDaemons/com.west2online.ClashXPro.ProxyConfigHelper.plist"* ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} - [[ "$result" == *"Application Support/TestApp"* ]] - [[ "$result" == *"Caches/TestApp"* ]] - [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] - [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] - [[ "$result" == *"Containers/com.example.TestApp"* ]] - [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] +# The previous "${bundle_id}*.plist" glob over-matched: bundle "com.foo" +# would harvest "com.foobar.plist" and "com.foobaz.plist" from unrelated +# vendors. The dot-anchored alternation only matches at the dot boundary. +@test "find_app_system_files does not over-match sibling-vendor LaunchDaemons" { + # Use a real /Library/LaunchDaemons-like fixture by isolating PATH so the + # function falls back to the system find binary, then assert only the + # expected files are surfaced. + fakebase="$HOME/fakebase" + mkdir -p "$fakebase/Library/LaunchAgents" "$fakebase/Library/LaunchDaemons" + : > "$fakebase/Library/LaunchDaemons/com.foo.plist" # exact match - keep + : > "$fakebase/Library/LaunchDaemons/com.foo.helper.plist" # dotted - keep + : > "$fakebase/Library/LaunchDaemons/com.foobar.plist" # sibling - reject + : > "$fakebase/Library/LaunchDaemons/com.foobaz.helper.plist" # sibling - reject + + # Verify the find pattern itself, since the production find is hard-coded + # to /Library/* paths. This mirrors what app_protection.sh emits. + run /bin/bash --noprofile --norc -c " + cd '$fakebase/Library/LaunchDaemons' + find . -maxdepth 1 \( -name 'com.foo.plist' -o -name 'com.foo.*.plist' \) | sort + " + [ "$status" -eq 0 ] + [[ "$output" == *"com.foo.plist"* ]] || return 1 + [[ "$output" == *"com.foo.helper.plist"* ]] || return 1 + [[ "$output" != *"com.foobar.plist"* ]] || return 1 + [[ "$output" != *"com.foobaz.helper.plist"* ]] } @test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" @@ -81,27 +252,35 @@ cat > "$app_dir/Contents/Info.plist" << 'PLIST' PLIST touch "$diag_dir/Foo.crash" +touch "$diag_dir/Foo.diag" +touch "$diag_dir/Foo Helper.diag" touch "$diag_dir/Foo_2026-01-01-120000_host.ips" touch "$diag_dir/Foobar.crash" +touch "$diag_dir/Foobar.diag" +touch "$diag_dir/Foobar Helper.diag" touch "$diag_dir/Foobar_2026-01-01-120001_host.ips" result=$(get_diagnostic_report_paths_for_app "$app_dir" "Foo" "$diag_dir") [[ "$result" == *"Foo.crash"* ]] || exit 1 +[[ "$result" == *"Foo.diag"* ]] || exit 1 +[[ "$result" == *"Foo Helper.diag"* ]] || exit 1 [[ "$result" == *"Foo_2026-01-01-120000_host.ips"* ]] || exit 1 [[ "$result" != *"Foobar.crash"* ]] || exit 1 +[[ "$result" != *"Foobar.diag"* ]] || exit 1 +[[ "$result" != *"Foobar Helper.diag"* ]] || exit 1 [[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "calculate_total_size returns aggregate kilobytes" { - mkdir -p "$HOME/sized" - dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 >/dev/null 2>&1 - dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 >/dev/null 2>&1 + mkdir -p "$HOME/sized" + dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 > /dev/null 2>&1 + dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 > /dev/null 2>&1 - result="$( - HOME="$HOME" bash --noprofile --norc <<'EOF' + result="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" files="$(printf '%s @@ -109,15 +288,71 @@ files="$(printf '%s ' "$HOME/sized/file1" "$HOME/sized/file2")" calculate_total_size "$files" EOF - )" + )" + + [ "$result" -ge 3 ] +} + +@test "calculate_total_size does not double-count nested paths" { + mkdir -p "$HOME/sized-parent/child" + dd if=/dev/zero of="$HOME/sized-parent/child/payload" bs=1024 count=2 > /dev/null 2>&1 + + result="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +parent="$HOME/sized-parent" +child="$HOME/sized-parent/child" +parent_only=$(calculate_total_size "$parent") +with_child=$(calculate_total_size "$(printf '%s\n%s\n' "$parent" "$child")") +printf '%s|%s\n' "$parent_only" "$with_child" +EOF + )" + + parent_only="${result%%|*}" + with_child="${result##*|}" + [ "$parent_only" -gt 0 ] + [ "$with_child" -eq "$parent_only" ] +} + +@test "format_uninstall_preview_path includes per-path size" { + dd if=/dev/zero of="$HOME/preview-size-file" bs=1024 count=1 > /dev/null 2>&1 + + result="$( + HOME="$HOME" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +format_uninstall_preview_path "$HOME/preview-size-file" +EOF + )" + + [[ "$result" == *"~/preview-size-file"* ]] || return 1 + [[ "$result" == *"1KB"* ]] +} + +@test "format_uninstall_preview_path propagates timed out and interrupted size probes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +get_path_size_kb() { return "$SIZE_RC"; } +for SIZE_RC in 124 130; do + rc=0 + format_uninstall_preview_path "$HOME/interrupted-preview" || rc=$? + printf 'SIZE_RC=%s RC=%s\n' "$SIZE_RC" "$rc" +done +EOF - [ "$result" -ge 3 ] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"SIZE_RC=124 RC=124"* ]] || return 1 + [[ "$output" == *"SIZE_RC=130 RC=130"* ]] } @test "batch_uninstall_applications removes selected app data" { - create_app_artifacts + create_app_artifacts - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -146,7 +381,7 @@ files_cleaned=0 total_items=0 total_size_cleaned=0 -batch_uninstall_applications +printf '\n' | batch_uninstall_applications [[ ! -d "$app_bundle" ]] || exit 1 [[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1 @@ -155,273 +390,3528 @@ batch_uninstall_applications [[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } -@test "batch_uninstall_applications preview shows full related file list" { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Logs/TestApp" - touch "$HOME/Library/Logs/TestApp/log1.log" - touch "$HOME/Library/Logs/TestApp/log2.log" - touch "$HOME/Library/Logs/TestApp/log3.log" - touch "$HOME/Library/Logs/TestApp/log4.log" - touch "$HOME/Library/Logs/TestApp/log5.log" - touch "$HOME/Library/Logs/TestApp/log6.log" +@test "batch uninstall routes a root-owned app through unprivileged Trash when its parent is writable (#1331)" { + mkdir -p "$HOME/Applications/RootOwned.app" + local trace="$HOME/root-owned-trash.log" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" +export MOLE_DELETE_MODE=trash -request_sudo_access() { return 0; } start_inline_spinner() { :; } stop_inline_spinner() { :; } -enter_alt_screen() { :; } -leave_alt_screen() { :; } -hide_cursor() { :; } -show_cursor() { :; } -remove_apps_from_dock() { :; } +get_file_owner() { echo root; } pgrep() { return 1; } -pkill() { return 0; } -sudo() { return 0; } -has_sensitive_data() { return 1; } +find_app_files() { return 0; } find_app_system_files() { return 0; } -find_app_files() { - cat << LIST -$HOME/Library/Application Support/TestApp -$HOME/Library/Caches/TestApp -$HOME/Library/Logs/TestApp/log1.log -$HOME/Library/Logs/TestApp/log2.log -$HOME/Library/Logs/TestApp/log3.log -$HOME/Library/Logs/TestApp/log4.log -$HOME/Library/Logs/TestApp/log5.log -$HOME/Library/Logs/TestApp/log6.log -LIST +ensure_sudo_session() { echo "UNEXPECTED_SUDO"; return 1; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +force_kill_app() { return 0; } +mole_delete() { + printf 'DELETE:%s:%s\n' "$1" "${2:-false}" >> "$HOME/root-owned-trash.log" + return 0 } -selected_apps=() -selected_apps+=("0|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|0|Never") +selected_apps=("0|$HOME/Applications/RootOwned.app|RootOwned|com.example.RootOwned|0|Never") files_cleaned=0 total_items=0 total_size_cleaned=0 -printf 'q' | batch_uninstall_applications +printf '\n' | batch_uninstall_applications EOF - [ "$status" -eq 0 ] - [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] - [[ "$output" != *"more files"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$(grep -c "^DELETE:$HOME/Applications/RootOwned.app:false$" "$trace" 2> /dev/null || true)" -eq 1 ]] || return 1 + [[ "$output" != *"UNEXPECTED_SUDO"* ]] || return 1 + [[ "$output" != *"cannot be removed safely by Mole"* ]] } -@test "safe_remove can remove a simple directory" { - mkdir -p "$HOME/test_dir" - touch "$HOME/test_dir/file.txt" +@test "batch uninstall rejects privileged permanent removal below a mutable parent before side effects (#1299)" { + mkdir -p "$HOME/Applications/RootOwned.app" + mkdir -p "$HOME/Library/Application Support/RootOwned" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +export MOLE_DELETE_MODE=permanent -safe_remove "$HOME/test_dir" -[[ ! -d "$HOME/test_dir" ]] || exit 1 +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { echo root; } +_mole_privileged_path_has_mutable_ancestor() { return 0; } +pgrep() { return 1; } +find_app_files() { printf 'DISCOVERY\n' >> "$HOME/mutable-parent-side-effects.log"; return 1; } +ensure_sudo_session() { echo "UNEXPECTED_SUDO"; return 1; } +stop_launch_services() { echo "UNEXPECTED_LAUNCH_TEARDOWN"; return 1; } +unregister_app_bundle() { echo "UNEXPECTED_UNREGISTER"; return 1; } +remove_login_item() { echo "UNEXPECTED_LOGIN_ITEM"; return 1; } +force_kill_app() { echo "UNEXPECTED_KILL"; return 1; } +mole_delete() { echo "UNEXPECTED_DELETE"; return 1; } + +selected_apps=("0|$HOME/Applications/RootOwned.app|RootOwned|com.example.RootOwned|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +rc=0 +batch_uninstall_applications || rc=$? +[[ $rc -eq 1 ]] || { echo "WRONG_RC:$rc"; exit 1; } +[[ -d "$HOME/Applications/RootOwned.app" ]] || { echo "WRONG: bundle removed"; exit 1; } +[[ -d "$HOME/Library/Application Support/RootOwned" ]] || { echo "WRONG: app data removed"; exit 1; } +[[ ! -e "$HOME/mutable-parent-side-effects.log" ]] || { echo "WRONG: discovery ran"; exit 1; } EOF - [ "$status" -eq 0 ] + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"cannot be removed safely by Mole from this location"* ]] || return 1 + [[ "$output" == *"Move it to Trash in Finder"* ]] || return 1 + [[ "$output" == *"protected containers and app data untouched"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_"* ]] } -@test "decode_file_list validates base64 encoding" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "a foreign Caskroom-like symlink never selects a Homebrew cask (#1299)" { + local fake_target="$HOME/foreign/Caskroom/real-cask/1.0/Fake.app" + mkdir -p "$HOME/Applications" "$fake_target" + ln -s "$fake_target" "$HOME/Applications/Fake.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" -valid_data=$(printf '/path/one -/path/two' | base64) -result=$(decode_file_list "$valid_data" "TestApp") -[[ -n "$result" ]] || exit 1 +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_brew_cask_name() { return 1; } +get_file_owner() { echo root; } +_mole_privileged_path_has_mutable_ancestor() { return 0; } +pgrep() { return 1; } +find_app_files() { printf 'DISCOVERY\n' >> "$HOME/foreign-cask-side-effects.log"; return 1; } +brew_uninstall_cask() { echo "UNEXPECTED_BREW:$*"; return 0; } +mole_delete() { echo "UNEXPECTED_DELETE:$*"; return 0; } + +selected_apps=("0|$HOME/Applications/Fake.app|Fake|com.example.Fake|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +rc=0 +batch_uninstall_applications || rc=$? +[[ $rc -eq 1 ]] || { echo "WRONG_RC:$rc"; exit 1; } +[[ -L "$HOME/Applications/Fake.app" ]] || { echo "WRONG: symlink removed"; exit 1; } +[[ ! -e "$HOME/foreign-cask-side-effects.log" ]] || { echo "WRONG: discovery ran"; exit 1; } EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"cannot be removed safely by Mole from this location"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_"* ]] } -@test "decode_file_list rejects invalid base64" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "uninstall_bundle_id_has_surviving_sibling detects unselected same-bundle install" { + mkdir -p "$HOME/Applications/Shared.app" "$HOME/Applications/Shared-beta.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" -if result=$(decode_file_list "not-valid-base64!!!" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true +apps_data=( + "0|$HOME/Applications/Shared.app|Shared|com.example.Shared|0|Never|0" + "0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.Shared|0|Never|0" +) + +# Only the beta variant is selected; the stable install survives. +selected_apps=("0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.Shared|0|Never") +uninstall_bundle_id_has_surviving_sibling "com.example.Shared" "$HOME/Applications/Shared-beta.app" || { + echo "WRONG: surviving sibling not detected" + exit 1 +} + +# Both variants selected: no survivor, bundle-id cleanup is safe. +selected_apps=( + "0|$HOME/Applications/Shared.app|Shared|com.example.Shared|0|Never" + "0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.Shared|0|Never" +) +if uninstall_bundle_id_has_surviving_sibling "com.example.Shared" "$HOME/Applications/Shared-beta.app"; then + echo "WRONG: sibling reported although both installs are selected" + exit 1 +fi + +# Unknown bundle id never reports a sibling. +if uninstall_bundle_id_has_surviving_sibling "unknown" "$HOME/Applications/Shared-beta.app"; then + echo "WRONG: unknown bundle id reported a sibling" + exit 1 fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } -@test "decode_file_list handles empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "uninstall_bundle_id_has_surviving_sibling compares bundle ids case-insensitively" { + mkdir -p "$HOME/Applications/Shared.app" "$HOME/Applications/Shared-beta.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" -empty_data=$(printf '' | base64) -result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true -[[ -z "$result" ]] +# The survivor's id differs from the selected app's only in case. On a default +# APFS volume both apps read and write the SAME ~/Library/Preferences plist, so +# a literal comparison here would let the zap wipe the survivor's settings. +apps_data=( + "0|$HOME/Applications/Shared.app|Shared|com.Example.Shared|0|Never|0" + "0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.shared|0|Never|0" +) +selected_apps=("0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.shared|0|Never") + +uninstall_bundle_id_has_surviving_sibling "com.example.shared" "$HOME/Applications/Shared-beta.app" || { + echo "WRONG: case-differing survivor was not detected" + exit 1 +} + +names=$(uninstall_surviving_sibling_names "com.example.shared" "$HOME/Applications/Shared-beta.app") +case "$names" in +*shared*) ;; +*) + echo "WRONG: case-differing survivor contributed no protected names" + exit 1 + ;; +esac + +# A genuinely different id must still not register as a sibling. +if uninstall_bundle_id_has_surviving_sibling "com.example.other" "$HOME/Applications/Shared-beta.app"; then + echo "WRONG: unrelated bundle id reported a sibling" + exit 1 +fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } -@test "decode_file_list rejects non-absolute paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "live same-bundle scan finds a sibling that appeared after preview" { + local app_root="$HOME/live-apps" + mkdir -p "$app_root/Selected.app/Contents" \ + "$app_root/Setapp/NewSibling.app/Contents" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP_ROOT="$app_root" \ + /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -bad_data=$(printf 'relative/path' | base64) -if result=$(decode_file_list "$bad_data" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true -fi +pkg_receipt_nonstandard_app_paths() { :; } + +printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.live-shared' \ + '' \ + > "$APP_ROOT/Selected.app/Contents/Info.plist" +cp "$APP_ROOT/Selected.app/Contents/Info.plist" \ + "$APP_ROOT/Setapp/NewSibling.app/Contents/Info.plist" +selected_apps=("0|$APP_ROOT/Selected.app|Selected|com.example.live-shared|0|Never") +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$APP_ROOT") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +uninstall_live_bundle_has_other_install \ + "com.example.live-shared" "$APP_ROOT/Selected.app" +first_fingerprint="$_MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT" +[[ -n "$first_fingerprint" && ${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]} -eq 1 ]] + +mkdir -p "$APP_ROOT/Utilities/AnotherSibling.app/Contents" +cp "$APP_ROOT/Selected.app/Contents/Info.plist" \ + "$APP_ROOT/Utilities/AnotherSibling.app/Contents/Info.plist" +uninstall_live_bundle_has_other_install \ + "com.example.live-shared" "$APP_ROOT/Selected.app" +[[ ${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]} -eq 2 ]] +[[ "$first_fingerprint" != "$_MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT" ]] + +mkdir -p "$HOME/external/LinkedSibling.app/Contents" +cp "$APP_ROOT/Selected.app/Contents/Info.plist" \ + "$HOME/external/LinkedSibling.app/Contents/Info.plist" +ln -s "$HOME/external/LinkedSibling.app" "$APP_ROOT/LinkedSibling.app" +uninstall_live_bundle_has_other_install \ + "com.example.live-shared" "$APP_ROOT/Selected.app" +[[ ${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]} -eq 3 ]] EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } -@test "decode_file_list handles both BSD and GNU base64 formats" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "live same-bundle scan accepts dot-app text in a volume ancestor" { + local app_root="$HOME/Backup.app-data/Applications" + mkdir -p "$app_root/Survivor.app/Contents" "$HOME/Selected.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP_ROOT="$app_root" \ + /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.volume-shared' \ + '' \ + > "$APP_ROOT/Survivor.app/Contents/Info.plist" +selected_apps=("0|$HOME/Selected.app|Selected|com.example.volume-shared|0|Never") +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$APP_ROOT") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +uninstall_live_bundle_has_other_install \ + "com.example.volume-shared" "$HOME/Selected.app" +[[ ${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]} -eq 1 ]] +EOF -test_paths="/path/to/file1 -/path/to/file2" - -encoded_data=$(printf '%s' "$test_paths" | base64 | tr -d '\n') - -result=$(decode_file_list "$encoded_data" "TestApp") + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} -[[ "$result" == *"/path/to/file1"* ]] || exit 1 -[[ "$result" == *"/path/to/file2"* ]] || exit 1 +@test "live same-bundle scan finds an app at a mounted volume root" { + local volumes_root="$HOME/Volumes" + local survivor="$volumes_root/Example/Survivor.app" + mkdir -p "$survivor/Contents" "$HOME/Selected.app" -[[ -n "$result" ]] || exit 1 + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + VOLUMES_ROOT="$volumes_root" SURVIVOR="$survivor" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.volume-root' \ + '' \ + > "$SURVIVOR/Contents/Info.plist" +selected_apps=("0|$HOME/Selected.app|Selected|com.example.volume-root|0|Never") +_MOLE_UNINSTALL_LIVE_APP_ROOTS=() +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$VOLUMES_ROOT" +live_rc=0 +uninstall_live_bundle_has_other_install \ + "com.example.volume-root" "$HOME/Selected.app" || live_rc=$? +printf 'LIVE_RC=%s PATHS=%s\n' "$live_rc" "${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]}" +[[ $live_rc -eq 0 ]] +[[ ${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]} -eq 1 ]] +[[ "${_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[0]}" == "$SURVIVOR" ]] EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } } -@test "refresh_launch_services_after_uninstall falls back after timeout" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "live same-bundle scan covers exact package receipt apps" { + local app_root="$HOME/pkg-root" + mkdir -p "$app_root/one/two/three/four/Deep.app/Contents" "$HOME/Selected.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP_ROOT="$app_root" \ + /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" -log_file="$HOME/lsregister-timeout.log" -: > "$log_file" -call_index=0 +printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.pkg-shared' \ + '' \ + > "$APP_ROOT/one/two/three/four/Deep.app/Contents/Info.plist" +selected_apps=("0|$HOME/Selected.app|Selected|com.example.pkg-shared|0|Never") +pkg_receipt_nonstandard_app_paths() { + printf '%s\n' "$APP_ROOT/one/two/three/four/Deep.app" +} +_MOLE_UNINSTALL_LIVE_APP_ROOTS=() +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +uninstall_live_bundle_has_other_install \ + "com.example.pkg-shared" "$HOME/Selected.app" +[[ ${#_MOLE_UNINSTALL_LIVE_SIBLING_PATHS[@]} -eq 1 ]] +EOF -get_lsregister_path() { echo "/bin/echo"; } -debug_log() { echo "DEBUG:$*" >> "$log_file"; } + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "strict package receipt discovery rejects partial output" { + run env HOME="$HOME/pkg-partial" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +pkgutil() { + case "$1" in + --pkgs) printf 'com.example.one\ncom.example.two\n' ;; + --files) + if [[ "$2" == "com.example.one" ]]; then + printf 'opt/example/One.app/Contents/Info.plist\n' + else + return 124 + fi + ;; + esac +} run_with_timeout() { - local duration="$1" shift - call_index=$((call_index + 1)) - echo "CALL${call_index}:$duration:$*" >> "$log_file" + "$@" +} - if [[ "$call_index" -eq 2 ]]; then - return 124 - fi - if [[ "$call_index" -eq 3 ]]; then - return 124 - fi - return 0 +rc=0 +output=$(MOLE_PKG_RECEIPT_CACHE_DISABLE=1 \ + pkg_receipt_nonstandard_app_paths --require-complete) || rc=$? +printf 'RC=%s OUTPUT=%s\n' "$rc" "$output" +[[ $rc -eq 124 && -z "$output" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=124 OUTPUT="* ]] } -if refresh_launch_services_after_uninstall; then - echo "RESULT:ok" -else - echo "RESULT:fail" -fi +@test "non-strict receipt discovery bounds each pkgutil file listing" { + local mock_bin="$HOME/mock-pkgutil-bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/pkgutil" <<'MOCK' +#!/bin/bash +case "$1" in + --pkgs) printf 'com.example.big\ncom.example.after\n' ;; + --files) exec sleep 30 ;; +esac +MOCK + chmod +x "$mock_bin/pkgutil" + + run env HOME="$HOME/pkg-bound" PROJECT_ROOT="$PROJECT_ROOT" \ + PATH="$mock_bin:/usr/bin:/bin" \ + MOLE_PKG_RECEIPT_CACHE_DISABLE=1 \ + MOLE_PKG_RECEIPT_SCAN_TIMEOUT=1 \ + MOLE_PKG_RECEIPT_LIST_TIMEOUT=1 /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" -cat "$log_file" +started=$(date +%s) +rc=0 +output=$(pkg_receipt_nonstandard_app_paths) || rc=$? +elapsed=$(( $(date +%s) - started )) +printf 'RC=%s ELAPSED=%s OUTPUT=%s\n' "$rc" "$elapsed" "$output" +[[ $elapsed -lt 8 ]] EOF - [ "$status" -eq 0 ] - [[ "$output" == *"RESULT:ok"* ]] - [[ "$output" == *"CALL2:15:/bin/echo -r -f -domain local -domain user -domain system"* ]] - [[ "$output" == *"CALL3:10:/bin/echo -r -f -domain local -domain user"* ]] - [[ "$output" == *"DEBUG:LaunchServices rebuild timed out, trying lighter version"* ]] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=0 "* ]] || return 1 + [[ "$output" == *" OUTPUT=" ]] } -@test "remove_mole deletes manual binaries and caches" { - mkdir -p "$HOME/.local/bin" - touch "$HOME/.local/bin/mole" - touch "$HOME/.local/bin/mo" - mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" +@test "live same-bundle scan discards partial find output" { + local app_root="$HOME/partial-live-apps" + mkdir -p "$app_root/Selected.app" "$app_root/Partial.app/Contents" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP_ROOT="$app_root" \ + /bin/bash --noprofile --norc <<'EOF' set -euo pipefail -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -rm() { - local -a flags=() - local -a paths=() - local arg - for arg in "$@"; do - if [[ "$arg" == -* ]]; then - flags+=("$arg") - else - paths+=("$arg") - fi - done - local path - for path in "${paths[@]}"; do - if [[ "$path" == "$HOME" || "$path" == "$HOME/"* ]]; then - /bin/rm "${flags[@]}" "$path" - fi - done - return 0 -} -sudo() { - if [[ "$1" == "rm" ]]; then - shift - rm "$@" - return 0 +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +selected_apps=("0|$APP_ROOT/Selected.app|Selected|com.example.partial|0|Never") +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$APP_ROOT") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +run_with_timeout() { + shift + if [[ "${1:-}" == "find" ]]; then + printf '%s\0' "$APP_ROOT/Partial.app" + return 73 fi + "$@" +} +rc=0 +uninstall_live_bundle_has_other_install \ + "com.example.partial" "$APP_ROOT/Selected.app" || rc=$? +printf 'RC=%s\n' "$rc" +[[ $rc -eq 2 ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=2"* ]] +} + +@test "batch execution rejects a changed same-bundle app set before teardown" { + run env HOME="$HOME/live-set-race" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +app_path="$HOME/Applications/Race.app" +mkdir -p "$app_path" +expected_identity=$(_batch_selected_app_identity "$app_path") +preview_fingerprint=$(printf '%s' 'old-sibling-set' | base64 | tr -d '\n') +fields=( + "Race" "$app_path" "unknown" "0" "" "" "false" "false" "false" + "" "" "" "" "guard" "$expected_identity" "com.example.race" + "$preview_fingerprint" "missing" +) +old_ifs="$IFS" +IFS='|' +detail="${fields[*]}" +IFS="$old_ifs" + +uninstall_live_bundle_has_other_install() { + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="new-sibling-set" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=("$HOME/Applications/New.app") return 0 } -export -f start_inline_spinner stop_inline_spinner rm sudo -printf '\n' | "$PROJECT_ROOT/mole" remove +stop_launch_services() { : > "$HOME/teardown-ran"; } + +app_details=("$detail") +success_count=0 +failed_count=0 +brew_apps_removed=0 +failed_items=() +success_items=() +success_dock_targets=() +system_extension_warning_apps=() +review_only_system_leftovers=() +review_only_system_leftover_keys=() +running_at_uninstall_apps=() +total_size_freed=0 +files_cleaned=0 +total_items=0 + +_batch_execute_removals +[[ $success_count -eq 0 && $failed_count -eq 1 ]] +[[ "${failed_items[0]}" == *"app installation set changed after preview"* ]] +[[ ! -e "$HOME/teardown-ran" ]] EOF - [ "$status" -eq 0 ] - [ ! -f "$HOME/.local/bin/mole" ] - [ ! -f "$HOME/.local/bin/mo" ] - [ ! -d "$HOME/.config/mole" ] - [ ! -d "$HOME/.cache/mole" ] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } } -@test "remove_mole dry-run keeps manual binaries and caches" { - mkdir -p "$HOME/.local/bin" - touch "$HOME/.local/bin/mole" - touch "$HOME/.local/bin/mo" - mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" +@test "batch execution rejects a selected Info.plist changed after preview" { + run env HOME="$HOME/selected-info-race" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +app_path="$HOME/Applications/Race.app" +mkdir -p "$app_path/Contents" +printf 'old bundle metadata\n' > "$app_path/Contents/Info.plist" +touch -t 202001010000 "$app_path/Contents/Info.plist" +expected_identity=$(_batch_selected_app_identity "$app_path") +expected_info_identity=$(_batch_selected_app_info_identity "$app_path") + +fields=( + "Race" "$app_path" "com.example.race" "0" "" "" "false" "false" "false" + "" "" "" "" "none" "$expected_identity" "com.example.race" "" + "$expected_info_identity" +) +old_ifs="$IFS" +IFS='|' +detail="${fields[*]}" +IFS="$old_ifs" + +printf 'new bundle metadata\n' > "$app_path/Contents/Info.plist" +touch -t 202101010000 "$app_path/Contents/Info.plist" +[[ "$(_batch_selected_app_identity "$app_path")" == "$expected_identity" ]] +[[ "$(_batch_selected_app_info_identity "$app_path")" != "$expected_info_identity" ]] + +uninstall_live_bundle_has_other_install() { + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + return 1 +} +stop_launch_services() { : > "$HOME/teardown-ran"; } + +app_details=("$detail") +success_count=0 +failed_count=0 +brew_apps_removed=0 +failed_items=() +success_items=() +success_dock_targets=() +system_extension_warning_apps=() +review_only_system_leftovers=() +review_only_system_leftover_keys=() +running_at_uninstall_apps=() +total_size_freed=0 +files_cleaned=0 +total_items=0 + +_batch_execute_removals +[[ $success_count -eq 0 && $failed_count -eq 1 ]] +[[ "${failed_items[0]}" == *"selected app changed after preview"* ]] +[[ ! -e "$HOME/teardown-ran" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' +@test "batch scan narrows a live same-bundle plan to the selected app bundle" { + run env HOME="$HOME/live-bundle-only" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +selected="$HOME/Applications/Selected.app" +survivor="$HOME/Applications/Survivor.app" +mkdir -p "$selected/Contents" "$survivor/Contents" +for app in "$selected" "$survivor"; do + printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.shared' \ + '' > "$app/Contents/Info.plist" +done + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf 'com.example.shared\n'; } +official_uninstaller_vendor() { return 1; } +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { printf '1\n'; } +find_app_files() { : > "$HOME/unexpected-discovery"; return 99; } +calculate_total_size() { printf '0\n'; } +has_sensitive_data() { return 1; } +discover_login_item_helper_bundle_ids() { return 0; } + +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$HOME/Applications") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +apps_data=( + "0|$selected|Selected|com.example.shared|0|Never|0" + "0|$survivor|Changed Current Name|com.example.shared|0|Never|0" +) +selected_apps=("0|$selected|Selected|com.example.shared|0|Never") +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 + +_batch_scan_app_details +IFS='|' read -r _ _ stored_bundle _ _ _ _ _ _ _ _ _ _ stored_guard _ \ + stored_original stored_fingerprint _ <<< "${app_details[0]}" +[[ "$stored_bundle" == "unknown" ]] +[[ "$stored_guard" == "guard_login" ]] +[[ "$stored_original" == "com.example.shared" ]] +[[ -n "$stored_fingerprint" ]] +[[ ! -e "$HOME/unexpected-discovery" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "batch execution protects an earlier app when a later same-bundle selection changes" { + run env HOME="$HOME/multi-selected-race" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +first="$HOME/Applications/First.app" +second="$HOME/Applications/Second.app" +mkdir -p "$first/Contents" "$second/Contents" +for app in "$first" "$second"; do + printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.shared' \ + '' > "$app/Contents/Info.plist" + touch -t 202001010000 "$app/Contents/Info.plist" +done + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf 'com.example.shared\n'; } +official_uninstaller_vendor() { return 1; } +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { printf '1\n'; } +find_app_files() { : > "$HOME/unexpected-discovery"; return 99; } +calculate_total_size() { printf '0\n'; } +has_sensitive_data() { return 1; } +discover_login_item_helper_bundle_ids() { return 0; } + +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$HOME/Applications") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +apps_data=( + "0|$first|First|com.example.shared|0|Never|0" + "0|$second|Second|com.example.shared|0|Never|0" +) +selected_apps=( + "0|$first|First|com.example.shared|0|Never" + "0|$second|Second|com.example.shared|0|Never" +) +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 + +_batch_scan_app_details +[[ ${#app_details[@]} -eq 2 ]] +[[ ! -e "$HOME/unexpected-discovery" ]] + +printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.replaced' \ + '' > "$second/Contents/Info.plist" +touch -t 202101010000 "$second/Contents/Info.plist" +stop_launch_services() { : > "$HOME/teardown-ran"; } + +success_count=0 +failed_count=0 +brew_apps_removed=0 +failed_items=() +success_items=() +success_dock_targets=() +system_extension_warning_apps=() +review_only_system_leftovers=() +review_only_system_leftover_keys=() +running_at_uninstall_apps=() +total_size_freed=0 +files_cleaned=0 +total_items=0 + +_batch_execute_removals +[[ $success_count -eq 0 && $failed_count -eq 2 ]] +[[ "${failed_items[0]}" == *"app installation set changed after preview"* ]] +[[ "${failed_items[1]}" == *"selected app changed after preview"* ]] +[[ ! -e "$HOME/teardown-ran" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "batch execution removes stable same-bundle multi-selections" { + run env HOME="$HOME/multi-selected-stable" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +pkg_receipt_nonstandard_app_paths() { :; } + +first="$HOME/Applications/First.app" +second="$HOME/Applications/Second.app" +mkdir -p "$first/Contents" "$second/Contents" +for app in "$first" "$second"; do + printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.shared' \ + '' > "$app/Contents/Info.plist" +done + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf '%s\n' "$2"; } +official_uninstaller_vendor() { return 1; } +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { printf '1\n'; } +find_app_files() { : > "$HOME/unexpected-discovery"; return 99; } +calculate_total_size() { printf '0\n'; } +has_sensitive_data() { return 1; } +discover_login_item_helper_bundle_ids() { return 0; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } + +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$HOME/Applications") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-volumes" +apps_data=( + "0|$first|First|com.example.shared|0|Never|0" + "0|$second|Second|com.example.shared|0|Never|0" +) +selected_apps=( + "0|$first|First|com.example.shared|0|Never" + "0|$second|Second|com.example.shared|0|Never" +) +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 + +_batch_scan_app_details +[[ ${#app_details[@]} -eq 2 ]] +[[ ! -e "$HOME/unexpected-discovery" ]] + +success_count=0 +failed_count=0 +brew_apps_removed=0 +failed_items=() +success_items=() +success_dock_targets=() +system_extension_warning_apps=() +review_only_system_leftovers=() +review_only_system_leftover_keys=() +running_at_uninstall_apps=() +total_size_freed=0 +files_cleaned=0 +total_items=0 + +_batch_execute_removals +[[ $success_count -eq 2 && $failed_count -eq 0 ]] +[[ ! -e "$first" && ! -e "$second" ]] + +# Dry-run records simulated success but leaves both paths in place. Those +# still-live paths must remain in the expected fingerprint for the second app. +export MOLE_DRY_RUN=1 +first="$HOME/Applications/DryFirst.app" +second="$HOME/Applications/DrySecond.app" +mkdir -p "$first/Contents" "$second/Contents" +for app in "$first" "$second"; do + printf '%s\n' \ + '' \ + '' \ + 'CFBundleIdentifiercom.example.dryshared' \ + '' > "$app/Contents/Info.plist" +done +apps_data=( + "0|$first|DryFirst|com.example.dryshared|0|Never|0" + "0|$second|DrySecond|com.example.dryshared|0|Never|0" +) +selected_apps=( + "0|$first|DryFirst|com.example.dryshared|0|Never" + "0|$second|DrySecond|com.example.dryshared|0|Never" +) +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 +dry_scan_rc=0 +_batch_scan_app_details || dry_scan_rc=$? +[[ $dry_scan_rc -eq 0 ]] +[[ ${#app_details[@]} -eq 2 ]] + +success_count=0 +failed_count=0 +brew_apps_removed=0 +failed_items=() +success_items=() +success_dock_targets=() +system_extension_warning_apps=() +review_only_system_leftovers=() +review_only_system_leftover_keys=() +running_at_uninstall_apps=() +total_size_freed=0 +files_cleaned=0 +total_items=0 +dry_execute_rc=0 +_batch_execute_removals || dry_execute_rc=$? +[[ $dry_execute_rc -eq 0 ]] +[[ $success_count -eq 2 && $failed_count -eq 0 ]] +[[ -e "$first" && -e "$second" ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "batch_uninstall_applications keeps shared bundle-id leftovers when a sibling install survives" { + # Xcode.app and Xcode-beta.app both use com.apple.dt.Xcode. Uninstalling + # only the beta must not delete bundle-id-keyed files still owned by the + # surviving stable install. + mkdir -p "$HOME/Applications/Shared.app" "$HOME/Applications/Shared-beta.app" + mkdir -p "$HOME/Library/Caches/com.example.Shared" + mkdir -p "$HOME/Library/Preferences" + touch "$HOME/Library/Preferences/com.example.Shared.plist" + mkdir -p "$HOME/Library/Caches/Shared-beta" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } + +apps_data=( + "0|$HOME/Applications/Shared.app|Shared|com.example.Shared|0|Never|0" + "0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.Shared|0|Never|0" +) +selected_apps=("0|$HOME/Applications/Shared-beta.app|Shared-beta|com.example.Shared|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications + +# The selected bundle and its name-keyed leftovers are gone. +[[ ! -d "$HOME/Applications/Shared-beta.app" ]] || { echo "WRONG: beta bundle preserved"; exit 1; } +[[ ! -d "$HOME/Library/Caches/Shared-beta" ]] || { echo "WRONG: beta name cache preserved"; exit 1; } + +# The surviving install and every bundle-id-keyed path are untouched. +[[ -d "$HOME/Applications/Shared.app" ]] || { echo "WRONG: surviving install removed"; exit 1; } +[[ -d "$HOME/Library/Caches/com.example.Shared" ]] || { echo "WRONG: shared bundle-id cache removed"; exit 1; } +[[ -f "$HOME/Library/Preferences/com.example.Shared.plist" ]] || { echo "WRONG: shared bundle-id prefs removed"; exit 1; } +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications keeps name-keyed leftovers when sibling installs share a display name" { + # On unindexed volumes mdls returns (null) and CFBundleName collapses both + # installs to one display name ("Xcode" for Xcode-beta.app). Discovery must + # fall back to the .app basename; when even that collides with the + # survivor, name cleanup and login-item removal must be suppressed. + mkdir -p "$HOME/Applications/SharedName-beta.app" "$HOME/Applications/SharedName.app" + mkdir -p "$HOME/OtherApps/SharedName.app" + mkdir -p "$HOME/Library/Application Support/SharedName" + mkdir -p "$HOME/Library/Caches/SharedName" + mkdir -p "$HOME/Library/Preferences" + touch "$HOME/Library/Preferences/SharedName.plist" + mkdir -p "$HOME/Library/Caches/SharedName-beta" + # Same-bundle siblings ship the same CFBundleExecutable (Xcode-beta.app + # ships "Xcode"); diagnostic-report discovery keys on it, so the beta's + # Info.plist points at the shared executable name. + mkdir -p "$HOME/Applications/SharedName-beta.app/Contents" + printf '%s' 'CFBundleIdentifiercom.example.sharednameCFBundleExecutableSharedName' > "$HOME/Applications/SharedName-beta.app/Contents/Info.plist" + mkdir -p "$HOME/Library/Logs/DiagnosticReports" + touch "$HOME/Library/Logs/DiagnosticReports/SharedName-2026-07-03-101010.ips" + # LaunchAgents referencing an install by exact path: the one pointing at + # the selected beta must still be unloaded under the guard (the bundle id + # is demoted to "unknown", but the path scan is exact evidence), while the + # one pointing at the survivor must stay loaded. + mkdir -p "$HOME/Library/LaunchAgents" + printf '%s' "$HOME/Applications/SharedName-beta.app/Contents/MacOS/SharedName" > "$HOME/Library/LaunchAgents/com.thirdparty.betahelper.plist" + printf '%s' "$HOME/Applications/SharedName.app/Contents/MacOS/SharedName" > "$HOME/Library/LaunchAgents/com.thirdparty.stablehelper.plist" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } +remove_login_item() { printf 'LOGIN_ITEM:%s\n' "$1" >> "$HOME/login.log"; } +force_kill_app() { printf 'KILL:%s\n' "$1" >> "$HOME/kill.log"; return 0; } +unload_launch_plist() { printf 'UNLOAD:%s\n' "$1" >> "$HOME/unload.log"; } + +# Case 1: display names collide ("SharedName" for both) but basenames differ. +# Discovery must use the basename (SharedName-beta) so the survivor's +# name-keyed dirs stay, and login-item removal must be skipped. +apps_data=( + "0|$HOME/Applications/SharedName.app|SharedName|com.example.sharedname|0|Never|0" + "0|$HOME/Applications/SharedName-beta.app|SharedName|com.example.sharedname|0|Never|0" +) +selected_apps=("0|$HOME/Applications/SharedName-beta.app|SharedName|com.example.sharedname|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/SharedName-beta.app" ]] || { echo "WRONG: beta bundle preserved"; exit 1; } +[[ ! -d "$HOME/Library/Caches/SharedName-beta" ]] || { echo "WRONG: beta's own cache preserved"; exit 1; } +[[ -d "$HOME/Applications/SharedName.app" ]] || { echo "WRONG: survivor removed"; exit 1; } +[[ -d "$HOME/Library/Application Support/SharedName" ]] || { echo "WRONG: survivor app support removed"; exit 1; } +[[ -d "$HOME/Library/Caches/SharedName" ]] || { echo "WRONG: survivor cache removed"; exit 1; } +[[ -f "$HOME/Library/Preferences/SharedName.plist" ]] || { echo "WRONG: survivor prefs removed"; exit 1; } +[[ ! -f "$HOME/login.log" ]] || { echo "WRONG: login item removed on colliding name"; cat "$HOME/login.log"; exit 1; } +[[ -f "$HOME/Library/Logs/DiagnosticReports/SharedName-2026-07-03-101010.ips" ]] || { echo "WRONG: survivor crash reports deleted under sibling guard"; exit 1; } +grep -q "UNLOAD:.*com.thirdparty.betahelper.plist" "$HOME/unload.log" 2> /dev/null || { echo "WRONG: path-referenced agent of the selected app not unloaded under guard"; exit 1; } +! grep -q "com.thirdparty.stablehelper.plist" "$HOME/unload.log" 2> /dev/null || { echo "WRONG: survivor's agent unloaded"; cat "$HOME/unload.log"; exit 1; } + +# Case 2: basenames collide too (same SharedName.app in two folders). +# Name discovery must be suppressed entirely: only the bundle goes. +apps_data=( + "0|$HOME/OtherApps/SharedName.app|SharedName|com.example.sharedname|0|Never|0" + "0|$HOME/Applications/SharedName.app|SharedName|com.example.sharedname|0|Never|0" +) +selected_apps=("0|$HOME/Applications/SharedName.app|SharedName|com.example.sharedname|0|Never") + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/SharedName.app" ]] || { echo "WRONG: selected bundle preserved"; exit 1; } +[[ -d "$HOME/OtherApps/SharedName.app" ]] || { echo "WRONG: survivor removed (case 2)"; exit 1; } +[[ -d "$HOME/Library/Application Support/SharedName" ]] || { echo "WRONG: shared-name app support removed (case 2)"; exit 1; } +[[ -d "$HOME/Library/Caches/SharedName" ]] || { echo "WRONG: shared-name cache removed (case 2)"; exit 1; } +[[ -f "$HOME/Library/Preferences/SharedName.plist" ]] || { echo "WRONG: shared-name prefs removed (case 2)"; exit 1; } +[[ ! -f "$HOME/login.log" ]] || { echo "WRONG: login item removed on colliding name (case 2)"; exit 1; } + +# Case 3: the Xcode toolchain heuristic matches by regex substring, so even +# a non-colliding basename ("XcodeClone-beta") would sweep DerivedData that +# the surviving install still uses. The sibling guard must disable it. +mkdir -p "$HOME/Applications/XcodeClone.app" "$HOME/Applications/XcodeClone-beta.app" +mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" + +apps_data=( + "0|$HOME/Applications/XcodeClone.app|XcodeClone|com.example.xcodeclone|0|Never|0" + "0|$HOME/Applications/XcodeClone-beta.app|XcodeClone|com.example.xcodeclone|0|Never|0" +) +selected_apps=("0|$HOME/Applications/XcodeClone-beta.app|XcodeClone|com.example.xcodeclone|0|Never") + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/XcodeClone-beta.app" ]] || { echo "WRONG: beta bundle preserved (case 3)"; exit 1; } +[[ -d "$HOME/Applications/XcodeClone.app" ]] || { echo "WRONG: survivor removed (case 3)"; exit 1; } +[[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]] || { echo "WRONG: DerivedData swept despite surviving sibling (case 3)"; exit 1; } + +# Case 4: inverse direction: uninstalling the base-named install while the +# hyphen-suffixed sibling survives. The discovery name ("RevBase") is +# contained in the survivor's identifiers ("RevBase-beta"), and downstream +# matchers are substring-based (the LaunchAgents scan globs "**.plist"), +# so name discovery must be suppressed entirely. +mkdir -p "$HOME/Applications/RevBase.app" "$HOME/Applications/RevBase-beta.app" +mkdir -p "$HOME/Library/Application Support/RevBase" +mkdir -p "$HOME/Library/LaunchAgents" +touch "$HOME/Library/LaunchAgents/com.example.RevBase-beta.agent.plist" + +apps_data=( + "0|$HOME/Applications/RevBase.app|RevBase|com.example.revbase|0|Never|0" + "0|$HOME/Applications/RevBase-beta.app|RevBase-beta|com.example.revbase|0|Never|0" +) +selected_apps=("0|$HOME/Applications/RevBase.app|RevBase|com.example.revbase|0|Never") + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/RevBase.app" ]] || { echo "WRONG: selected base bundle preserved (case 4)"; exit 1; } +[[ -d "$HOME/Applications/RevBase-beta.app" ]] || { echo "WRONG: suffixed survivor removed (case 4)"; exit 1; } +[[ -f "$HOME/Library/LaunchAgents/com.example.RevBase-beta.agent.plist" ]] || { echo "WRONG: survivor launch agent removed (case 4)"; exit 1; } +[[ -d "$HOME/Library/Application Support/RevBase" ]] || { echo "WRONG: shared app support removed (case 4)"; exit 1; } +[[ ! -f "$HOME/login.log" ]] || { echo "WRONG: login item removed (case 4)"; exit 1; } + +# Across all four guard cases process termination must never run: +# force_kill_app quits by bundle id and matches by CFBundleExecutable, and +# both can belong to the surviving install. +[[ ! -f "$HOME/kill.log" ]] || { echo "WRONG: process termination attempted under sibling guard"; cat "$HOME/kill.log"; exit 1; } + +# Case 5 (control): without a surviving sibling the termination and +# diagnostic-report paths must still run, proving the negative assertions +# above are not vacuous. +mkdir -p "$HOME/Applications/SoloApp.app" +touch "$HOME/Library/Logs/DiagnosticReports/SoloApp-2026-07-03-101010.ips" +apps_data=("0|$HOME/Applications/SoloApp.app|SoloApp|com.example.soloapp|0|Never|0") +selected_apps=("0|$HOME/Applications/SoloApp.app|SoloApp|com.example.soloapp|0|Never") + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/SoloApp.app" ]] || { echo "WRONG: solo bundle preserved (case 5)"; exit 1; } +grep -q "KILL:SoloApp" "$HOME/kill.log" 2> /dev/null || { echo "WRONG: termination skipped without sibling guard (case 5)"; exit 1; } +[[ ! -f "$HOME/Library/Logs/DiagnosticReports/SoloApp-2026-07-03-101010.ips" ]] || { echo "WRONG: diagnostic reports not collected without sibling guard (case 5)"; exit 1; } +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications blocks official-uninstaller apps" { + mkdir -p "$HOME/Applications/Falcon.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +mole_delete() { echo "MOLE_DELETE:$1"; return 0; } + +selected_apps=("0|$HOME/Applications/Falcon.app|Falcon|com.crowdstrike.falcon.UserAgent|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +if batch_uninstall_applications; then + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"requires the official CrowdStrike uninstaller"* ]] || return 1 + [[ "$output" != *"MOLE_DELETE"* ]] +} + +@test "batch_uninstall_applications keeps system remnants review-only" { + mkdir -p "$HOME/Applications/ReviewOnly.app" "$HOME/system" + touch "$HOME/system/com.example.review.helper" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "1"; } +calculate_total_size() { echo "1"; } +find_app_files() { :; } +find_app_system_files() { printf '%s\n' "$HOME/system/com.example.review.helper"; } +get_diagnostic_report_paths_for_app() { :; } +remove_file_list() { + printf 'REMOVE_LIST:%s:%s\n' "${2:-false}" "$1" >> "$HOME/remove.log" + return 0 +} +mole_delete() { + printf 'MOLE_DELETE:%s:%s\n' "$2" "$1" >> "$HOME/remove.log" + rm -rf "$1" + return 0 +} + +selected_apps=("0|$HOME/Applications/ReviewOnly.app|ReviewOnly|com.example.review|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > "$HOME/output.log" 2>&1 + +grep -q "Review only: ~/system/com.example.review.helper" "$HOME/output.log" +# The summary states the count, not the paths: they were already listed above +# the confirmation prompt, so the path must appear exactly once in the run. +[[ "$(grep -cF "~/system/com.example.review.helper" "$HOME/output.log")" -eq 1 ]] +grep -q "Kept 1 system-level path, which Mole never removes" "$HOME/output.log" +# Keeping system paths is the designed outcome, so the run is not "incomplete". +! grep -q "Uninstall incomplete" "$HOME/output.log" +grep -q "Uninstall complete" "$HOME/output.log" +# The point of the whole case: the file is reported, never deleted. +! grep -q "$HOME/system/com.example.review.helper" "$HOME/remove.log" +[[ -e "$HOME/system/com.example.review.helper" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications dry-run does not report expected leftovers as failures" { + create_app_artifacts + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } +find_app_system_files() { + mkdir -p "$HOME/system" + touch "$HOME/system/com.example.TestApp.helper" + printf '%s\n' "$HOME/system/com.example.TestApp.helper" +} + +export MOLE_DRY_RUN=1 +export MOLE_DELETE_MODE=trash + +app_bundle="$HOME/Applications/TestApp.app" +mkdir -p "$app_bundle" + +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +output_file="$HOME/dry_run_uninstall.log" +printf '\n' | batch_uninstall_applications > "$output_file" 2>&1 +output=$(cat "$output_file") + +[[ -d "$app_bundle" ]] || { echo "WRONG: dry-run removed app bundle"; cat "$output_file"; exit 1; } +[[ -d "$HOME/Library/Application Support/TestApp" ]] || { echo "WRONG: dry-run removed app support"; cat "$output_file"; exit 1; } +[[ -d "$HOME/Library/Caches/TestApp" ]] || { echo "WRONG: dry-run removed cache"; cat "$output_file"; exit 1; } +[[ -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || { echo "WRONG: dry-run removed prefs"; cat "$output_file"; exit 1; } + +[[ "$output" == *"Uninstall dry run complete"* ]] || { echo "WRONG: missing dry-run summary"; cat "$output_file"; exit 1; } +[[ "$output" == *"Would remove 1 app"* ]] || { echo "WRONG: missing would-remove summary"; cat "$output_file"; exit 1; } +[[ "$output" != *"Could not remove"* ]] || { echo "WRONG: dry-run reported expected leftovers"; cat "$output_file"; exit 1; } +[[ "$output" != *"system-level path"* ]] || { echo "WRONG: dry-run reported post-removal system leftovers"; cat "$output_file"; exit 1; } +[[ "$output" != *"Uninstall incomplete"* ]] || { echo "WRONG: dry-run marked incomplete"; cat "$output_file"; exit 1; } +EOF + + [ "$status" -eq 0 ] +} + +@test "force_kill_app skips the kill ladder when Quit succeeds" { + # run_with_timeout invokes its argv via gtimeout/timeout, which exec the + # real binary and bypass bash functions, so we shadow osascript via a + # real script on PATH and read the trace it writes. + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/kill_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" << STUB +#!/bin/bash +printf 'osascript %s\n' "\$*" >> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +# This unit test covers force_kill_app's ladder, not the timeout backend. +# Keep the PATH osascript stub deterministic under the parallel full suite. +run_with_timeout() { + shift + "$@" +} + +# Bundle with a known id so the Quit step uses the precise `id "..."` form +# rather than the by-name fallback. +app_path="$HOME/Applications/TestApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableTestApp + CFBundleIdentifiercom.example.TestApp + +PLIST + +# First pgrep finds the process (so we enter the kill flow); subsequent +# pgrep calls find nothing (so the function returns 0 once Quit "lands"). +pgrep_count=0 +pgrep() { + pgrep_count=$((pgrep_count + 1)) + if [[ $pgrep_count -eq 1 ]]; then + echo 12345 + return 0 + fi + return 1 +} +export -f pgrep + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pkill + +sleep() { :; } +export -f sleep + +# Allow the osascript branch to run (the upfront guard skips it under test mode). +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "TestApp" "$app_path" +EOF + + [ "$status" -eq 0 ] + grep -q 'osascript .*tell application id .*com\.example\.TestApp.* to quit' "$trace" || + { + echo "WRONG: missing AppleScript Quit" + cat "$trace" + return 1 + } + if grep -q '^pkill ' "$trace"; then + echo "WRONG: pkill ran even though Quit succeeded" + cat "$trace" + return 1 + fi +} + +@test "force_kill_app escalates to pkill when Quit does not land" { + # Process keeps showing up in pgrep until pkill -9 fires, exercising the + # SIGTERM and SIGKILL rungs of the escalation ladder. + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/kill_escalate_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" << STUB +#!/bin/bash +printf 'osascript %s\n' "\$*" >> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +app_path="$HOME/Applications/StubbornApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableStubbornApp + CFBundleIdentifiercom.example.StubbornApp + +PLIST + +# Stays alive until SIGKILL lands, then disappears. +sigkill_seen=0 +pgrep() { + if [[ $sigkill_seen -eq 1 ]]; then + return 1 + fi + echo 12345 + return 0 +} +export -f pgrep + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + for arg in "$@"; do + if [[ "$arg" == "-9" ]]; then + sigkill_seen=1 + fi + done + return 0 +} +export -f pkill +export sigkill_seen + +sudo() { return 1; } +export -f sudo + +sleep() { :; } +export -f sleep + +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "StubbornApp" "$app_path" +EOF + + [ "$status" -eq 0 ] + grep -q '^pkill -x StubbornApp' "$trace" || + { + echo "WRONG: SIGTERM rung did not fire" + cat "$trace" + return 1 + } + grep -q '^pkill -9 -x StubbornApp' "$trace" || + { + echo "WRONG: SIGKILL rung did not fire" + cat "$trace" + return 1 + } +} + +@test "force_kill_app rejects unsafe bundle id in AppleScript Quit target" { + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/unsafe_kill_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" << STUB +#!/bin/bash +printf 'osascript %s\n' "\$*" >> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +app_path="$HOME/Applications/TestApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableTestApp + CFBundleIdentifiercom.example.TestApp" to display dialog "mole + +PLIST + +pgrep_count=0 +pgrep() { + pgrep_count=$((pgrep_count + 1)) + if [[ $pgrep_count -eq 1 ]]; then + echo 12345 + return 0 + fi + return 1 +} +export -f pgrep + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pkill + +sleep() { :; } +export -f sleep + +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "TestApp" "$app_path" +EOF + + [ "$status" -eq 0 ] + if grep -q 'display dialog' "$trace"; then + echo "WRONG: unsafe bundle id reached AppleScript" + cat "$trace" + return 1 + fi + grep -q 'osascript .*tell application "TestApp" to quit' "$trace" || + { + echo "WRONG: unsafe id did not fall back to app name" + cat "$trace" + return 1 + } +} + +@test "force_kill_app refuses to operate on system process names" { + # Defensive guard: a third-party .app could set CFBundleExecutable to a + # system process name (Finder, Dock, loginwindow, etc.). Even though the + # uninstall selection layer filters out protected bundle IDs, force_kill_app + # is a public function and must hold its own boundary. Verify it returns 1 + # without invoking pkill or osascript for these names. + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/system_proc_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" << STUB +#!/bin/bash +printf 'osascript %s\n' "\$*" >> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + for spoofed in Finder Dock loginwindow WindowServer SystemUIServer; do + : > "$trace" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" SPOOFED="$spoofed" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +app_path="$HOME/Applications/Evil-$SPOOFED.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << PLIST + + + + CFBundleExecutable$SPOOFED + CFBundleIdentifiercom.example.evil + +PLIST + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pkill + +# pgrep must NOT be called - the guard runs before any process probing. +pgrep() { + printf 'pgrep %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pgrep + +sleep() { :; } +export -f sleep + +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "Evil-$SPOOFED" "$app_path" +EOF + + [ "$status" -eq 1 ] || + { + echo "WRONG: spoofed $spoofed did not return 1 (got $status)" + cat "$trace" + return 1 + } + if [[ -s "$trace" ]]; then + echo "WRONG: spoofed $spoofed reached pkill/pgrep/osascript" + cat "$trace" + return 1 + fi + done +} + +@test "batch_uninstall_applications proceeds with deletion when force_kill_app fails" { + # Reproduces the issue where uninstalling a still-running app (e.g. Mole.app + # with a watchdog or XPC helper that ignores SIGKILL) used to abort with + # "still running" and leave the bundle on disk. macOS allows deleting a + # running app's bundle; we should warn the user but proceed. + create_app_artifacts + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +# Pretend the kill ladder exhausted itself: process is still there. +force_kill_app() { return 1; } +sudo() { return 0; } + +app_bundle="$HOME/Applications/TestApp.app" +mkdir -p "$app_bundle" + +related="$(find_app_files "com.example.TestApp" "TestApp")" +encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') + +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +# Send batch_uninstall_applications its own /dev/null stdin so the inline +# `read -r -s -n1 key` does not steal a byte from the heredoc script source +# (which would silently corrupt the next bash command into 127). +output_file="$HOME/batch_output.log" +printf '\n' | batch_uninstall_applications > "$output_file" 2>&1 +output=$(cat "$output_file") + +# Bundle and leftovers must be gone even though kill failed. +[[ ! -d "$app_bundle" ]] || { echo "WRONG: bundle preserved despite running flag"; cat "$output_file"; exit 1; } +[[ ! -d "$HOME/Library/Caches/TestApp" ]] || { echo "WRONG: cache preserved"; exit 1; } +[[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || { echo "WRONG: prefs preserved"; exit 1; } + +# The legacy "still running" failure summary must NOT fire. +[[ "$output" != *"is still running"* ]] || { echo "WRONG: legacy still-running failure surfaced"; exit 1; } +[[ "$output" != *Failed:*TestApp* ]] || { echo "WRONG: app counted as failed"; exit 1; } + +# A friendlier warning should appear so the user knows to quit the lingering process. +[[ "$output" == *"Still running during uninstall"* ]] || { echo "WRONG: missing running-process warning"; cat "$output_file"; exit 1; } +[[ "$output" == *TestApp* ]] || { echo "WRONG: warning omits app name"; exit 1; } +EOF + + [ "$status" -eq 0 ] +} + +@test "stop_launch_services unloads launch agents without deleting plists" { + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.helper.plist" + touch "$HOME/Library/LaunchAgents/com.example.TestApplication.plist" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +trace="$HOME/trace.log" +launchctl() { + printf 'launchctl %s\n' "$*" >> "$trace" +} +run_with_timeout() { + shift + "$@" +} +safe_remove() { + printf 'safe_remove %s\n' "$*" >> "$trace" + return 0 +} +safe_sudo_remove() { + printf 'safe_sudo_remove %s\n' "$*" >> "$trace" + return 0 +} + +stop_launch_services "com.example.TestApp" "false" "" + + grep -Fq "launchctl unload $HOME/Library/LaunchAgents/com.example.TestApp.plist" "$trace" + grep -Fq "launchctl unload $HOME/Library/LaunchAgents/com.example.TestApp.helper.plist" "$trace" + ! grep -Fq "com.example.TestApplication.plist" "$trace" + ! grep -q "safe_remove" "$trace" + [[ -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1 + [[ -f "$HOME/Library/LaunchAgents/com.example.TestApp.helper.plist" ]] || exit 1 + [[ -f "$HOME/Library/LaunchAgents/com.example.TestApplication.plist" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications preview shows full related file list" { + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Logs/TestApp" + touch "$HOME/Library/Logs/TestApp/log1.log" + touch "$HOME/Library/Logs/TestApp/log2.log" + touch "$HOME/Library/Logs/TestApp/log3.log" + touch "$HOME/Library/Logs/TestApp/log4.log" + touch "$HOME/Library/Logs/TestApp/log5.log" + touch "$HOME/Library/Logs/TestApp/log6.log" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } +has_sensitive_data() { return 1; } +find_app_system_files() { return 0; } +find_app_files() { + cat << LIST +$HOME/Library/Application Support/TestApp +$HOME/Library/Caches/TestApp +$HOME/Library/Logs/TestApp/log1.log +$HOME/Library/Logs/TestApp/log2.log +$HOME/Library/Logs/TestApp/log3.log +$HOME/Library/Logs/TestApp/log4.log +$HOME/Library/Logs/TestApp/log5.log +$HOME/Library/Logs/TestApp/log6.log +LIST +} + +selected_apps=() +selected_apps+=("0|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\nq' | batch_uninstall_applications +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] || return 1 + [[ "$output" != *"more files"* ]] +} + +@test "uninstall_persist_cache_file heals non-writable destination" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail + +# Source only the helper by evaluating its function definition. +eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +src="$HOME/cache.src" +dst="$HOME/cache.dst" +printf 'fresh-data\n' > "$src" +printf 'stale-data\n' > "$dst" +chmod 0444 "$dst" +[[ ! -w "$dst" ]] || { echo "precondition: dst should be read-only" >&2; exit 1; } + +uninstall_persist_cache_file "$src" "$dst" + +[[ ! -e "$src" ]] || { echo "src should be gone" >&2; exit 1; } +[[ -f "$dst" ]] || { echo "dst missing" >&2; exit 1; } +grep -q 'fresh-data' "$dst" || { echo "dst not updated"; exit 1; } +EOF + + [ "$status" -eq 0 ] +} + +@test "uninstall_persist_cache_file does not hang when mv would prompt (stdin closed)" { + # Regression for #722: BSD mv without -f prompts on non-writable dst and + # blocks reading stdin. The helper must close stdin and use -f. + # + # The hang detector uses a marker file rather than a PID-based watchdog: + # PIDs get recycled quickly on CI and a stale `kill -9 $pid` can succeed + # against an unrelated process, producing a false HANG. The marker + # approach only cares about whether the helper itself completed. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +src="$HOME/snap.src" +dst="$HOME/snap.dst" +done_marker="$HOME/snap.done" +printf 'x\n' > "$src" +printf 'y\n' > "$dst" +chmod 0444 "$dst" + +( + printf 'n\nn\nn\n' | uninstall_persist_cache_file "$src" "$dst" + : > "$done_marker" +) & +bgpid=$! + +# Poll for completion marker for up to ~5s. +for _ in $(seq 1 50); do + [[ -e "$done_marker" ]] && break + sleep 0.1 +done + +if [[ ! -e "$done_marker" ]]; then + kill -9 "$bgpid" 2>/dev/null || true + echo HANG +fi +wait "$bgpid" 2>/dev/null || true +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"HANG"* ]] +} + +@test "uninstall_persist_cache_file is a no-op when source is empty" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +src="$HOME/empty.src" +dst="$HOME/keep.dst" +: > "$src" +printf 'untouched\n' > "$dst" + +uninstall_persist_cache_file "$src" "$dst" + +[[ ! -e "$src" ]] || exit 1 +grep -q 'untouched' "$dst" || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "cached uninstall metadata is rejected when the current bundle is protected" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +app_path="$HOME/Applications/Safari.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.apple.Safari + + +PLIST + +if uninstall_resolve_eligible_bundle_id "$app_path" "com.example.cached" > /dev/null; then + echo "protected app should not be eligible" >&2 + exit 1 +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "cached uninstall metadata is rejected when the app is background-only" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +uninstall_print_app_search_dirs() { printf '%s\n' "$HOME/Applications"; } + +app_path="$HOME/Applications/Vendor/Helper.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Helper + LSBackgroundOnly + + + +PLIST + +if uninstall_resolve_eligible_bundle_id "$app_path" "com.example.Helper" > /dev/null; then + echo "background-only app should not be eligible" >&2 + exit 1 +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "OneDrive Mac App Store bundle is eligible even when marked background-only" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +uninstall_print_app_search_dirs() { printf '%s\n' "$HOME/Applications"; } + +app_path="$HOME/Applications/OneDrive.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.microsoft.OneDrive-mac + LSBackgroundOnly + + + +PLIST + +result=$(uninstall_resolve_eligible_bundle_id "$app_path" "") +[[ "$result" == "com.microsoft.OneDrive-mac" ]] || { + echo "unexpected bundle id: $result" >&2 + exit 1 +} +EOF + + [ "$status" -eq 0 ] +} + +@test "eligible uninstall metadata uses the current bundle id over stale cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +app_path="$HOME/Applications/Plain.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Plain + + +PLIST + +result=$(uninstall_resolve_eligible_bundle_id "$app_path" "com.example.Stale") +[[ "$result" == "com.example.Plain" ]] || { + echo "unexpected bundle id: $result" >&2 + exit 1 +} +EOF + + [ "$status" -eq 0 ] +} + +@test "safe_remove can remove a simple directory" { + mkdir -p "$HOME/test_dir" + touch "$HOME/test_dir/file.txt" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +safe_remove "$HOME/test_dir" +[[ ! -d "$HOME/test_dir" ]] || exit 1 +EOF + [ "$status" -eq 0 ] +} + +@test "decode_file_list validates base64 encoding" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +valid_data=$(printf '/path/one +/path/two' | base64) +result=$(decode_file_list "$valid_data" "TestApp") +[[ -n "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list rejects invalid base64" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +if result=$(decode_file_list "not-valid-base64!!!" "TestApp" 2>/dev/null); then + [[ -z "$result" ]] || exit 1 +else + true +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "login item helper discovery discards partial results and preserves cancellation" { + local app="$HOME/Applications/PartialHelpers.app" + local helper="$app/Contents/Library/LoginItems/Partial.app" + mkdir -p "$helper/Contents" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP="$app" HELPER="$helper" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +run_with_timeout() { + shift + if [[ "${1:-}" == "find" ]]; then + printf '%s\0' "$HELPER" + return 73 + fi + "$@" +} +result=$(discover_login_item_helper_bundle_ids "$APP") +[[ -z "$result" ]] || exit 1 + +run_with_timeout() { return 130; } +rc=0 +discover_login_item_helper_bundle_ids "$APP" > /dev/null || rc=$? +printf 'RC=%s\n' "$rc" +[[ $rc -eq 130 ]] +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"RC=130"* ]] +} + +@test "bootout_login_item_helpers never touches the com.apple namespace" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +is_uninstall_dry_run() { return 1; } +run_with_timeout() { shift; "$@"; } +# The call site redirects launchctl to /dev/null, so trace to a file. +TRACE="$HOME/bootout.trace" +> "$TRACE" +launchctl() { echo "BOOTOUT:$2" >> "$TRACE"; } +export -f launchctl + +# A third-party helper whose Info.plist claims an Apple label must be +# skipped; only the vendor helper may be booted out. +bootout_login_item_helpers "com.apple.Safari.helper +com.vendor.App-Helper" +cat "$TRACE" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"BOOTOUT:gui/$(id -u)/com.vendor.App-Helper"* ]] || return 1 + [[ "$output" != *"com.apple.Safari.helper"* ]] || return 1 +} + +@test "decode_bundle_id_list preserves login item helper ids" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +# Regression for App Cleaner 9 (#helper bootout): bundle ids are not paths, +# so routing them through decode_file_list blanked the list and skipped the +# launchctl bootout of the app's login item helpers. +helper_ids=$(printf 'com.nektony.App-Cleaner-SIIICn-UIHelper +com.nektony.App-Cleaner-SIIICn-Monitor' | base64) +result=$(decode_bundle_id_list "$helper_ids" "App Cleaner 9" 2>&1) +[[ "$result" == *"com.nektony.App-Cleaner-SIIICn-UIHelper"* ]] || exit 1 +[[ "$result" == *"com.nektony.App-Cleaner-SIIICn-Monitor"* ]] || exit 1 +[[ "$result" != *"Invalid path"* ]] || exit 1 + +# The execute path must decode helper ids with the id decoder, not the +# path decoder that rejects them. +grep -q 'decode_bundle_id_list "$encoded_login_item_helpers"' "$PROJECT_ROOT/lib/uninstall/batch.sh" || exit 1 +if grep -q 'decode_file_list "$encoded_login_item_helpers"' "$PROJECT_ROOT/lib/uninstall/batch.sh"; then + exit 1 +fi +exit 0 +EOF + + [ "$status" -eq 0 ] +} + +@test "uninstall_resolve_display_name keeps versioned app names when metadata is generic" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +function run_with_timeout() { + shift + "$@" +} + +function mdls() { + echo "Xcode" +} + +function plutil() { + if [[ "$3" == *"Info.plist" ]]; then + echo "Xcode" + return 0 + fi + return 1 +} + +MOLE_UNINSTALL_USER_LC_ALL="" +MOLE_UNINSTALL_USER_LANG="" + +eval "$(sed -n '/^uninstall_resolve_display_name()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +app_path="$HOME/Applications/Xcode 16.4.app" +mkdir -p "$app_path/Contents" +touch "$app_path/Contents/Info.plist" + +result=$(uninstall_resolve_display_name "$app_path" "Xcode 16.4.app") +[[ "$result" == "Xcode 16.4" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list handles empty input" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +empty_data=$(printf '' | base64) +result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true +[[ -z "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list rejects non-absolute paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +bad_data=$(printf 'relative/path' | base64) +if result=$(decode_file_list "$bad_data" "TestApp" 2>/dev/null); then + [[ -z "$result" ]] || exit 1 +else + true +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list handles both BSD and GNU base64 formats" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +test_paths="/path/to/file1 +/path/to/file2" + +encoded_data=$(printf '%s' "$test_paths" | base64 | tr -d '\n') + +result=$(decode_file_list "$encoded_data" "TestApp") + +[[ "$result" == *"/path/to/file1"* ]] || exit 1 +[[ "$result" == *"/path/to/file2"* ]] || exit 1 + +[[ -n "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "refresh_launch_services_after_uninstall falls back after timeout" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +log_file="$HOME/lsregister-timeout.log" +: > "$log_file" +call_index=0 + +get_lsregister_path() { echo "/bin/echo"; } +debug_log() { echo "DEBUG:$*" >> "$log_file"; } +run_with_timeout() { + local duration="$1" + shift + call_index=$((call_index + 1)) + echo "CALL${call_index}:$duration:$*" >> "$log_file" + + if [[ "$call_index" -eq 2 ]]; then + return 124 + fi + if [[ "$call_index" -eq 3 ]]; then + return 124 + fi + return 0 +} + +if refresh_launch_services_after_uninstall; then + echo "RESULT:ok" +else + echo "RESULT:fail" +fi + +cat "$log_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT:ok"* ]] || return 1 + [[ "$output" == *"CALL2:15:/bin/echo -r -f -domain local -domain user -domain system"* ]] || return 1 + [[ "$output" == *"CALL3:10:/bin/echo -r -f -domain local -domain user"* ]] || return 1 + [[ "$output" == *"DEBUG:LaunchServices rebuild timed out, trying lighter version"* ]] +} + +@test "remove_mole deletes manual binaries and caches" { + mkdir -p "$HOME/.local/bin" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole" + echo "protected-entry" > "$HOME/.config/mole/whitelist" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +rm() { + local -a flags=() + local -a paths=() + local arg + for arg in "$@"; do + if [[ "$arg" == -* ]]; then + flags+=("$arg") + else + paths+=("$arg") + fi + done + local path + for path in "${paths[@]}"; do + if [[ "$path" == "$HOME" || "$path" == "$HOME/"* ]]; then + /bin/rm "${flags[@]}" "$path" + fi + done + return 0 +} +sudo() { + if [[ "$1" == "rm" ]]; then + shift + rm "$@" + return 0 + fi + return 0 +} +export -f start_inline_spinner stop_inline_spinner rm sudo +printf '\n' | "$PROJECT_ROOT/mole" remove +EOF + + [ "$status" -eq 0 ] + [ ! -f "$HOME/.local/bin/mole" ] || return 1 + [ ! -f "$HOME/.local/bin/mo" ] || return 1 + [ ! -d "$HOME/.config/mole" ] || return 1 + [ ! -d "$HOME/.cache/mole" ] || return 1 + [ ! -d "$HOME/Library/Logs/mole" ] || return 1 + # Config is user-authored state and must survive in the Trash (#1346). + [ -f "$HOME/.Trash/mole-config/whitelist" ] || return 1 +} + +@test "remove_mole dry-run keeps manual binaries and caches" { + mkdir -p "$HOME/.local/bin" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +export -f start_inline_spinner stop_inline_spinner +printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN MODE"* ]] || return 1 + [ -f "$HOME/.local/bin/mole" ] + [ -f "$HOME/.local/bin/mo" ] + [ -d "$HOME/.config/mole" ] + [ -d "$HOME/.cache/mole" ] + [ -d "$HOME/Library/Logs/mole" ] +} + +@test "remove_mole test mode ignores PATH installs outside test HOME" { + mkdir -p "$HOME/.local/bin" "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + + fake_global_bin="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-remove-path.XXXXXX")" + touch "$fake_global_bin/mole" + touch "$fake_global_bin/mo" + cat > "$fake_global_bin/brew" << 'EOF' +#!/bin/bash +exit 0 +EOF + chmod +x "$fake_global_bin/brew" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_global_bin:/usr/bin:/bin" MOLE_TEST_MODE=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +export -f start_inline_spinner stop_inline_spinner +printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +EOF + + rm -rf "$fake_global_bin" + + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.local/bin/mole"* ]] || return 1 + [[ "$output" == *"$HOME/.local/bin/mo"* ]] || return 1 + [[ "$output" != *"$fake_global_bin/mole"* ]] || return 1 + [[ "$output" != *"$fake_global_bin/mo"* ]] || return 1 + [[ "$output" != *"brew uninstall --force mole"* ]] +} +@test "match_apps_by_name finds exact match case-insensitively" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" + "1001|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000" + "1002|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "testapp" +echo "count=${#selected_apps[@]}" +echo "match=${selected_apps[0]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] || return 1 + [[ "$output" == *"TestApp"* ]] +} + +@test "match_apps_by_name finds by directory name" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1002|$HOME/Applications/TestApp.app|Test Application|com.example.TestApp|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "TestApp" +echo "count=${#selected_apps[@]}" +echo "match=${selected_apps[0]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] || return 1 + [[ "$output" == *"Test Application"* ]] +} + +@test "match_apps_by_name warns on no match" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "nonexistent" +echo "count=${#selected_apps[@]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Warning: No application found matching 'nonexistent'"* ]] || return 1 + [[ "$output" == *"count=0"* ]] +} + +@test "match_apps_by_name handles multiple app names" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" + "1001|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000" + "1002|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "testapp2" "testapp3" +echo "count=${#selected_apps[@]}" +for app in "${selected_apps[@]}"; do + IFS='|' read -r _ _ name _ _ _ _ <<< "$app" + echo "matched=$name" +done +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=2"* ]] || return 1 + [[ "$output" == *"matched=TestApp2"* ]] || return 1 + [[ "$output" == *"matched=TestApp3"* ]] +} + +@test "match_apps_by_name falls back to substring match" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" + "1001|$HOME/Applications/SlackDesktop.app|Slack|com.tinyspeck.slackmacgap|200 MB|1000001|204800" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "test" +echo "count=${#selected_apps[@]}" +for app in "${selected_apps[@]}"; do + IFS='|' read -r _ _ name _ _ _ _ <<< "$app" + echo "matched=$name" +done +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] || return 1 + [[ "$output" == *"matched=TestApp"* ]] +} + +@test "match_apps_by_name does not duplicate when same name given twice" { + run /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "testapp" "testapp" +echo "count=${#selected_apps[@]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + +@test "main clears pending input before app selection after scan (#726)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +trace_file="$HOME/uninstall-trace.log" +app_cache_file="$HOME/apps-cache.txt" +touch "$app_cache_file" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +scan_applications() { printf '%s\n' "$app_cache_file"; } +load_applications() { + printf 'load\n' >> "$trace_file" + return 0 +} +drain_pending_input() { + printf 'drain\n' >> "$trace_file" +} +select_apps_for_uninstall() { + printf 'select\n' >> "$trace_file" + _MOLE_MENU_USER_QUIT=1 + return 1 +} + +eval "$(sed -n '/^main()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +main + +expected=$(printf 'load\ndrain\nselect\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + [ "$status" -eq 0 ] +} + +@test "main keeps scan and selector on one alternate screen until cancel (#1194)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +trace_file="$HOME/uninstall-screen-trace.log" +app_cache_file="$HOME/apps-cache.txt" +touch "$app_cache_file" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { printf 'clear\n' >> "$trace_file"; } +start_uninstall_interactive_screen() { + export MOLE_ALT_SCREEN_ACTIVE=1 + export MOLE_MANAGED_ALT_SCREEN=1 + printf 'start\n' >> "$trace_file" +} +stop_uninstall_interactive_screen() { + printf 'stop\n' >> "$trace_file" + unset MOLE_ALT_SCREEN_ACTIVE MOLE_MANAGED_ALT_SCREEN +} +scan_applications() { + printf 'scan\n' >> "$trace_file" + printf '%s\n' "$app_cache_file" +} +uninstall_app_inventory_fingerprint() { + printf 'fingerprint\n' >> "$trace_file" + printf 'inventory\n' +} +load_applications() { printf 'load\n' >> "$trace_file"; } +drain_pending_input() { printf 'drain\n' >> "$trace_file"; } +select_apps_for_uninstall() { + [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]] + [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" ]] + printf 'select\n' >> "$trace_file" + _MOLE_MENU_USER_QUIT=1 + return 1 +} + +eval "$(sed -n '/^main()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +main + +expected=$(printf 'start\nscan\nfingerprint\nload\ndrain\nselect\nstop\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + [ "$status" -eq 0 ] +} + +@test "scan_applications starts feedback before discovery and cleans no-app state" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_FORCE_SCAN_SPINNER=1 /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +trace_file="$HOME/scan-feedback-trace.log" +scan_temp="$HOME/scan-feedback-temp" + +MOLE_UNINSTALL_META_CACHE_DIR="$HOME/.cache/mole" +MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstall_app_metadata_v1" +MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" + +create_temp_file() { printf '%s\n' "$scan_temp"; } +ensure_user_dir() { mkdir -p "$1"; } +ensure_user_file() { + mkdir -p "$(dirname "$1")" + : > "$1" +} + +_scan_discover_apps() { + if [[ -n "${spinner_pid:-}" ]]; then + printf 'spinner-before-discover\n' >> "$trace_file" + else + printf 'missing-spinner\n' >> "$trace_file" + fi + : > "$discovered_file" +} +_scan_partition_cache() { printf 'partition\n' >> "$trace_file"; } +_scan_resolve_uncached() { printf 'resolve\n' >> "$trace_file"; } +_scan_dedupe_bundle_ids() { printf 'dedupe\n' >> "$trace_file"; } +_scan_finalize_index() { printf 'finalize\n' >> "$trace_file"; } + +eval "$(sed -n '/^scan_applications()/,/^load_applications()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +set +e +scan_applications > "$HOME/scan-feedback.out" 2> "$HOME/scan-feedback.err" +rc=$? +set -e + +[[ $rc -eq 1 ]] || exit 1 + +expected=$(printf 'spinner-before-discover\npartition\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} + +[[ ! -e "${scan_temp}.spinner_shown" ]] +[[ ! -e "${scan_temp}.scan_status" ]] +INNER + + [ "$status" -eq 0 ] +} + +@test "select_apps_for_uninstall drains pending input before opening paginated menu" { + mkdir -p "$HOME/Applications/TraceApp.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +trace_file="$HOME/selector-drain-trace.log" + +source "$PROJECT_ROOT/lib/ui/app_selector.sh" + +apps_data=("1700000000|$HOME/Applications/TraceApp.app|TraceApp|com.example.TraceApp|1MB|Today|1024") +selected_apps=() + +get_display_width() { printf '%s\n' "${#1}"; } +format_app_display() { + printf 'format\n' >> "$trace_file" + printf '%s' "$1" +} +drain_pending_input() { printf 'drain\n' >> "$trace_file"; } +paginated_multi_select() { + printf 'guard:%s\n' "${MOLE_MENU_IGNORE_INITIAL_ENTER:-unset}" >> "$trace_file" + printf 'paginated\n' >> "$trace_file" + MOLE_SELECTION_RESULT="0" + return 0 +} + +select_apps_for_uninstall +[[ ${#selected_apps[@]} -eq 1 ]] +[[ -z "${MOLE_MENU_IGNORE_INITIAL_ENTER:-}" ]] + +expected=$(printf 'format\ndrain\nguard:1\npaginated\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + [ "$status" -eq 0 ] +} + +@test "paginated menu can ignore one initial Enter for uninstall launch guard" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +key_state="$HOME/menu-initial-enter-state" +read_key() { + if [[ ! -f "$key_state" ]]; then + : > "$key_state" + echo "ENTER" + else + echo "QUIT" + fi +} + +MOLE_SELECTION_RESULT="" +set +e +MOLE_MENU_IGNORE_INITIAL_ENTER=1 paginated_multi_select "Test Menu" "First App" > "$HOME/menu.out" 2> "$HOME/menu.err" +rc=$? +set -e + +echo "rc=$rc" +echo "result=${MOLE_SELECTION_RESULT:-}" +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=1"* ]] || return 1 + [[ "$output" == *"result="* ]] || return 1 + [[ "$output" != *"result=0"* ]] +} + +@test "paginated menu skips Size sort when size metadata is unavailable (#1126)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +key_state="$HOME/menu-no-size-state" +read_key() { + local n + n=$(cat "$key_state" 2> /dev/null || echo 0) + n=$((n + 1)) + printf '%s\n' "$n" > "$key_state" + case "$n" in + 1 | 2) echo "CHAR:S" ;; + *) echo "ENTER" ;; + esac +} + +MOLE_SELECTION_RESULT="" +unset MOLE_MENU_SORT_MODE MOLE_MENU_SORT_REVERSE MOLE_MENU_META_SIZEKB +set +e +MOLE_MENU_META_EPOCHS="100,200" paginated_multi_select "Test Menu" "Alpha" "Beta" > "$HOME/menu.out" 2> "$HOME/menu.err" < /dev/null +rc=$? +set -e +echo "rc=$rc" +echo "mode=${MOLE_MENU_SORT_MODE:-}" +echo "result=${MOLE_SELECTION_RESULT:-}" +[[ $rc -eq 0 ]] || exit 1 +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=0"* ]] || return 1 + [[ "$output" == *"mode=date"* ]] || return 1 + [[ "$output" == *"result=0"* ]] +} + +@test "paginated menu reverses Size order when size metadata is available (#1126)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +key_state="$HOME/menu-size-state" +read_key() { + local n + n=$(cat "$key_state" 2> /dev/null || echo 0) + n=$((n + 1)) + printf '%s\n' "$n" > "$key_state" + case "$n" in + 1) echo "${NEXT_KEY:-ENTER}" ;; + *) echo "ENTER" ;; + esac +} + +MOLE_SELECTION_RESULT="" +set +e +MOLE_MENU_META_SIZEKB="1,100" MOLE_MENU_SORT_MODE=size MOLE_MENU_SORT_REVERSE=false paginated_multi_select "Test Menu" "Small" "Large" > "$HOME/menu-default.out" 2> "$HOME/menu-default.err" < /dev/null +default_rc=$? +set -e +echo "default=${MOLE_SELECTION_RESULT:-}" + +: > "$key_state" +MOLE_SELECTION_RESULT="" +set +e +NEXT_KEY="CHAR:O" MOLE_MENU_META_SIZEKB="1,100" MOLE_MENU_SORT_MODE=size MOLE_MENU_SORT_REVERSE=false paginated_multi_select "Test Menu" "Small" "Large" > "$HOME/menu-reverse.out" 2> "$HOME/menu-reverse.err" < /dev/null +reverse_rc=$? +set -e +echo "default_rc=$default_rc" +echo "reverse_rc=$reverse_rc" +echo "reverse=${MOLE_SELECTION_RESULT:-}" +[[ $default_rc -eq 0 ]] || exit 1 +[[ $reverse_rc -eq 0 ]] || exit 1 +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"default_rc=0"* ]] || return 1 + [[ "$output" == *"reverse_rc=0"* ]] || return 1 + [[ "$output" == *"default=1"* ]] || return 1 + [[ "$output" == *"reverse=0"* ]] +} + +@test "main reuses the app list after a removal-only uninstall (#866, #1315)" { + local first_cache + first_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-866-first.XXXXXX")" + + mkdir -p "$HOME/Applications/FirstApp.app" "$HOME/Applications/SecondApp.app" + cat > "$first_cache" << CACHE +1700000000|$HOME/Applications/FirstApp.app|FirstApp|com.example.FirstApp|10MB|Today|10240 +1700000001|$HOME/Applications/SecondApp.app|SecondApp|com.example.SecondApp|11MB|Today|11264 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" FIRST_CACHE="$first_cache" \ + /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +trace_file="$HOME/uninstall-866-trace.log" +scan_state_file="$HOME/uninstall-866-scan-count" +printf '0\n' > "$scan_state_file" +select_count=0 +fingerprint_state="before" +selected_apps=() + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +drain_pending_input() { :; } +uninstall_app_inventory_fingerprint() { + if [[ "$fingerprint_state" == "before" ]]; then + printf '%s|1\n%s|1\n' "$HOME/Applications/FirstApp.app" "$HOME/Applications/SecondApp.app" + else + printf '%s|1\n' "$HOME/Applications/SecondApp.app" + fi +} +batch_uninstall_applications() { + printf 'batch\n' >> "$trace_file" + rmdir "$HOME/Applications/FirstApp.app" + fingerprint_state="after" +} +uninstall_normalize_size_display() { printf '%s\n' "$1"; } +uninstall_normalize_last_used_display() { printf '%s\n' "$1"; } +scan_applications() { + local scan_count + scan_count=$(cat "$scan_state_file") + scan_count=$((scan_count + 1)) + printf '%s\n' "$scan_count" > "$scan_state_file" + printf 'scan:%s\n' "$scan_count" >> "$trace_file" + printf '%s\n' "$FIRST_CACHE" +} +load_applications() { + local apps_file="$1" + apps_data=() + selection_state=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + [[ -e "$app_path" ]] || continue + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + selection_state+=(false) + done < "$apps_file" + printf 'load:%s\n' "${apps_data[0]#*|}" >> "$trace_file" +} +select_apps_for_uninstall() { + select_count=$((select_count + 1)) + printf 'select:%s\n' "$select_count" >> "$trace_file" + if [[ $select_count -eq 1 ]]; then + selected_apps=("${apps_data[0]}") + return 0 + fi + _MOLE_MENU_USER_QUIT=1 + return 1 +} + +eval "$(sed -n '/^uninstall_inventory_can_reuse_cached_apps()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")" +eval "$(sed -n '/^main()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +printf '\n' | main + +expected=$(printf 'scan:1\nload:%s/Applications/FirstApp.app|FirstApp|com.example.FirstApp|10MB|Today|10240\nselect:1\nbatch\nload:%s/Applications/SecondApp.app|SecondApp|com.example.SecondApp|11MB|Today|11264\nselect:2\n' "$HOME" "$HOME") +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + rm -f "$first_cache" + [ "$status" -eq 0 ] +} + +@test "inventory cache reuse accepts removals only and rejects stale changes (#1315)" { + run env HOME="$HOME/inventory-reuse" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +eval "$(sed -n '/^uninstall_inventory_can_reuse_cached_apps()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +mkdir -p "$HOME/Applications/First.app" "$HOME/Applications/Second.app" +mkdir -p "$HOME/Applications/With|Pipe.app" +old=$(printf '%s|1|1\n%s|1|1\n' "$HOME/Applications/First.app" "$HOME/Applications/Second.app") +removed=$(printf '%s|1|1\n' "$HOME/Applications/Second.app") +changed=$(printf '%s|2|1\n' "$HOME/Applications/Second.app") +added=$(printf '%s|1|1\n%s|1|1\n' "$HOME/Applications/Second.app" "$HOME/Applications/Third.app") +pipe_old=$(printf '%s|1|1\n%s|1|1\n' "$HOME/Applications/Second.app" "$HOME/Applications/With|Pipe.app") + +if uninstall_inventory_can_reuse_cached_apps "$old" "$removed"; then + exit 1 +fi +if uninstall_inventory_can_reuse_cached_apps "$pipe_old" "$removed"; then + exit 2 +fi +rmdir "$HOME/Applications/With|Pipe.app" +uninstall_inventory_can_reuse_cached_apps "$pipe_old" "$removed" || exit 3 +rmdir "$HOME/Applications/First.app" +uninstall_inventory_can_reuse_cached_apps "$old" "$removed" || exit 4 +if uninstall_inventory_can_reuse_cached_apps "$old" "$changed"; then + exit 5 +fi +if uninstall_inventory_can_reuse_cached_apps "$old" "$added"; then + exit 6 +fi +if uninstall_inventory_can_reuse_cached_apps "$old" ""; then + exit 7 +fi +INNER + + [ "$status" -eq 0 ] || return 1 +} + +@test "inventory fingerprint changes when only Info.plist changes" { + run env HOME="$HOME/inventory-plist-mtime" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_print_app_paths_with_mtime()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")" +eval "$(sed -n '/^uninstall_app_inventory_fingerprint()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +app_path="$HOME/Applications/Mutable.app" +mkdir -p "$app_path/Contents" +touch -t 202001010000 "$app_path/Contents/Info.plist" +uninstall_print_app_search_dirs() { printf '%s\n' "$HOME/Applications"; } +pkg_receipt_nonstandard_app_paths() { :; } +uninstall_should_skip_app_path() { return 1; } + +before=$(uninstall_app_inventory_fingerprint) +touch -t 202101010000 "$app_path/Contents/Info.plist" +after=$(uninstall_app_inventory_fingerprint) +[[ "$before" != "$after" ]] || exit 1 +INNER + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "batch scan refreshes selected app identity before leftover discovery" { + run env HOME="$HOME/batch-refresh-identity" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +app_path="$HOME/Applications/Current.app" +mkdir -p "$app_path" start_inline_spinner() { :; } stop_inline_spinner() { :; } -export -f start_inline_spinner stop_inline_spinner -printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +uninstall_resolve_eligible_bundle_id() { printf 'com.example.Current\n'; } +official_uninstaller_vendor() { return 1; } +uninstall_bundle_id_has_surviving_sibling() { return 1; } +uninstall_live_bundle_has_other_install() { + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + return 1 +} +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { printf '1\n'; } +find_app_files() { printf '%s|%s\n' "$1" "$2" > "$HOME/discovery-identity"; } +get_diagnostic_report_paths_for_app() { return 0; } +find_app_system_files() { return 0; } +calculate_total_size() { printf '0\n'; } +has_sensitive_data() { return 1; } +discover_login_item_helper_bundle_ids() { return 0; } + +selected_apps=("0|$app_path|Stale Display|com.example.Stale|0|Never") +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 +_batch_scan_app_details + +[[ "$(cat "$HOME/discovery-identity")" == "com.example.Current|Current" ]] || exit 1 +[[ "${app_details[0]}" == "Stale Display|$app_path|com.example.Current|"* ]] || exit 1 +INNER + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "batch scan stops before discovery when app sizing is interrupted" { + run env HOME="$HOME/batch-size-interrupt" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +app_path="$HOME/Applications/Interrupted.app" +mkdir -p "$app_path" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf 'com.example.Interrupted\n'; } +official_uninstaller_vendor() { return 1; } +uninstall_bundle_id_has_surviving_sibling() { return 1; } +uninstall_live_bundle_has_other_install() { + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + return 1 +} +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { return 130; } +find_app_files() { printf 'UNEXPECTED_DISCOVERY\n'; return 99; } + +selected_apps=("0|$app_path|Interrupted|com.example.Interrupted|0|Never") +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 +rc=0 +_batch_scan_app_details || rc=$? +printf 'RC=%s DETAILS=%s\n' "$rc" "${#app_details[@]}" +INNER + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=130 DETAILS=0"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DISCOVERY"* ]] +} + +@test "batch uninstall stops before discovery and teardown when app sizing times out" { + run env HOME="$HOME/batch-size-timeout" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +app_path="$HOME/Applications/TimedOut.app" +mkdir -p "$app_path" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf 'com.example.TimedOut\n'; } +official_uninstaller_vendor() { return 1; } +uninstall_bundle_id_has_surviving_sibling() { return 1; } +uninstall_live_bundle_has_other_install() { + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + return 1 +} +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { return 124; } +find_app_files() { echo "UNEXPECTED_DISCOVERY"; return 99; } +stop_launch_services() { echo "UNEXPECTED_TEARDOWN"; } +unregister_app_bundle() { echo "UNEXPECTED_TEARDOWN"; } +remove_login_item() { echo "UNEXPECTED_TEARDOWN"; } +force_kill_app() { echo "UNEXPECTED_TEARDOWN"; } +mole_delete() { echo "UNEXPECTED_DELETE"; } + +selected_apps=("0|$app_path|TimedOut|com.example.TimedOut|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 +rc=0 +batch_uninstall_applications || rc=$? +printf 'RC=%s\n' "$rc" +[[ $rc -eq 124 ]] +INNER + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=124"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DISCOVERY"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_TEARDOWN"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DELETE"* ]] +} + +@test "batch scan keeps the app plan when related-file sizing times out" { + # Related size is display-only (#1383). A stalled du on leftovers must not + # abort the batch; the leftover paths stay in the plan with size 0. + run env HOME="$HOME/batch-related-size-timeout" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +app_path="$HOME/Applications/TimedOut.app" +related_path="$HOME/Library/Caches/com.example.TimedOut" +mkdir -p "$app_path" "$related_path" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf 'com.example.TimedOut\n'; } +official_uninstaller_vendor() { return 1; } +uninstall_bundle_id_has_surviving_sibling() { return 1; } +uninstall_live_bundle_has_other_install() { + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + return 1 +} +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { + if [[ "$1" == "$app_path" ]]; then + printf '1\n' + return 0 + fi + return 124 +} +find_app_files() { printf '%s\n' "$related_path"; } +get_diagnostic_report_paths_for_app() { return 0; } +find_app_system_files() { return 0; } +discover_login_item_helper_bundle_ids() { return 0; } +has_sensitive_data() { return 1; } + +selected_apps=("0|$app_path|TimedOut|com.example.TimedOut|0|Never") +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 +rc=0 +_batch_scan_app_details || rc=$? +printf 'RC=%s DETAILS=%s\n' "$rc" "${#app_details[@]}" +[[ $rc -eq 0 && ${#app_details[@]} -eq 1 ]] +INNER + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=0 DETAILS=1"* ]] +} + +@test "batch scan keeps the app when leftover discovery times out after receipt work (#1383)" { + # Machine-wide receipt walks can exhaust the shared deadline; leftover + # discovery then returns 124. That must narrow to the selected app, not + # abort with "nothing was removed". + run env HOME="$HOME/batch-leftover-timeout" PROJECT_ROOT="$PROJECT_ROOT" \ + /bin/bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +app_path="$HOME/Applications/UniFi-Discover.app" +mkdir -p "$app_path" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +_batch_refresh_selected_app_bundle_id() { printf 'com.example.unifi\n'; } +official_uninstaller_vendor() { return 1; } +uninstall_bundle_id_has_surviving_sibling() { return 1; } +uninstall_live_bundle_has_other_install() { + # Sibling scan finished with a complete "no sibling" proof, but burned + # the shared wall clock so leftover discovery has no budget left. + _MOLE_UNINSTALL_DISCOVERY_DEADLINE=$SECONDS + _MOLE_UNINSTALL_LIVE_SIBLING_FINGERPRINT="" + _MOLE_UNINSTALL_LIVE_SIBLING_PATHS=() + return 1 +} +pgrep() { return 1; } +get_brew_cask_name() { return 1; } +get_file_owner() { whoami; } +get_path_size_kb() { printf '4\n'; } +find_app_files() { return 124; } +get_diagnostic_report_paths_for_app() { echo "UNEXPECTED_DIAG"; return 99; } +find_app_system_files() { echo "UNEXPECTED_SYSTEM"; return 99; } +discover_login_item_helper_bundle_ids() { return 0; } +has_sensitive_data() { return 1; } + +selected_apps=("0|$app_path|UniFi Discover|com.example.unifi|0|Never") +running_apps=() +sudo_apps=() +brew_cask_apps=() +blocked_apps=() +manual_removal_apps=() +app_details=() +total_estimated_size=0 +rc=0 +_batch_scan_app_details || rc=$? +printf 'RC=%s DETAILS=%s\n' "$rc" "${#app_details[@]}" +# Plan must exist: one detail row, app-only (no leftover encoding of UNEXPECTED_*) +[[ $rc -eq 0 && ${#app_details[@]} -eq 1 ]] +printf 'DETAIL=%s\n' "${app_details[0]}" +INNER + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"RC=0 DETAILS=1"* ]] || return 1 + [[ "$output" == *"leftover scan timed out; only the app bundle will be removed"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DIAG"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SYSTEM"* ]] || return 1 +} + +# --------------------------------------------------------------------------- +# #723: Trash routing default and --permanent flag +# --------------------------------------------------------------------------- + +@test "uninstall main sets MOLE_DELETE_MODE=trash by default" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-723-trash.XXXXXX")" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { return 0; } +drain_pending_input() { :; } +select_apps_for_uninstall() { + printf 'delete_mode=%s\n' "${MOLE_DELETE_MODE:-unset}" + _MOLE_MENU_USER_QUIT=1 + return 1 +} + +eval "$(sed -n '/^main()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == *"delete_mode=trash"* ]] +} + +@test "uninstall main sets MOLE_DELETE_MODE=permanent with --permanent flag" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-723-perm.XXXXXX")" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { return 0; } +drain_pending_input() { :; } +select_apps_for_uninstall() { + printf 'delete_mode=%s\n' "${MOLE_DELETE_MODE:-unset}" + _MOLE_MENU_USER_QUIT=1 + return 1 +} + +eval "$(sed -n '/^main()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --permanent +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == *"delete_mode=permanent"* ]] +} + +# --------------------------------------------------------------------------- +# --list: read-only inventory of installable app names (PR #755 scope) +# --------------------------------------------------------------------------- + +@test "uninstall --list prints table with NAME, BUNDLE ID, UNINSTALL NAME, SIZE" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-text.XXXXXX")" + # Format matches load_applications: epoch|app_path|app_name|bundle_id|size|last_used|size_kb + cat > "$apps_cache" << 'CACHE' +1700000000|/Applications/Slack.app|Slack|com.tinyspeck.slackmacgap|180MB|Today|184320 +1700000000|/Applications/Zoom.app|Zoom|us.zoom.xos|140MB|Yesterday|143360 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + done < "$1" +} +# Stub Homebrew so test stays hermetic and brew detection never fires. +is_homebrew_available() { return 1; } +get_brew_cask_name() { return 1; } +# Stubbed because the production helper lives earlier in bin/uninstall.sh +# and our sed slice only pulls list-related helpers + main(). +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +# Force text mode by simulating a TTY for stdout via /dev/tty redirect not +# available in bats; instead pipe through a wrapper that fakes -t 1. Simplest: +# call the function directly so [[ -t 1 ]] uses bash's stdout (the bats pipe). +# We accept the function emits JSON when piped; assert against JSON shape too. +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + # Bats pipes stdout, so output is JSON. Assert both apps and uninstall_name. + [[ "$output" == *'"name": "Slack"'* ]] || return 1 + [[ "$output" == *'"name": "Zoom"'* ]] || return 1 + [[ "$output" == *'"uninstall_name": "Slack"'* ]] || return 1 + [[ "$output" == *'"bundle_id": "com.tinyspeck.slackmacgap"'* ]] || return 1 + [[ "$output" == *'"source": "App"'* ]] +} + +@test "uninstall --list emits JSON array when stdout is piped" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-json.XXXXXX")" + cat > "$apps_cache" << 'CACHE' +1700000000|/Applications/Slack.app|Slack|com.tinyspeck.slackmacgap|180MB|Today|184320 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + done < "$1" +} +is_homebrew_available() { return 1; } +get_brew_cask_name() { return 1; } +# Stubbed because the production helper lives earlier in bin/uninstall.sh +# and our sed slice only pulls list-related helpers + main(). +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + # Output should start with '[' and end with ']' to be a valid JSON array. + [[ "${output:0:1}" == "[" ]] || return 1 + [[ "${output: -1}" == "]" ]] || return 1 + # Round-trip via python to confirm it parses as JSON. + if command -v python3 > /dev/null; then + printf '%s\n' "$output" | python3 -c 'import sys, json; d=json.load(sys.stdin); assert isinstance(d, list) and len(d)==1 and d[0]["name"]=="Slack"' + fi +} + +@test "uninstall --list with empty scan returns empty JSON array" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-empty.XXXXXX")" + # Non-empty file so load_applications doesn't bail early on size check. + echo "" > "$apps_cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + return 0 +} +is_homebrew_available() { return 1; } +get_brew_cask_name() { return 1; } +# Stubbed because the production helper lives earlier in bin/uninstall.sh +# and our sed slice only pulls list-related helpers + main(). +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == "[]" ]] +} + +@test "uninstall --list flags brew-managed apps with cask uninstall_name" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-brew.XXXXXX")" + cat > "$apps_cache" << 'CACHE' +1700000000|/Applications/Visual Studio Code.app|Visual Studio Code|com.microsoft.VSCode|420MB|Today|430080 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" /bin/bash --noprofile --norc << 'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + done < "$1" +} +# Force brew-managed result. +is_homebrew_available() { return 0; } +get_brew_cask_name() { printf '%s' "visual-studio-code"; return 0; } +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == *'"uninstall_name": "visual-studio-code"'* ]] || return 1 + [[ "$output" == *'"source": "Homebrew"'* ]] +} + +# Regression tests for #940: warn about background jobs that survive uninstall. +# Detection is launchctl-only. sfltool dumpbtm is deliberately not used: +# unprivileged dumpbtm pops the macOS "sfltool wants to make changes" +# admin-password dialog on every uninstall batch. +_bg_items_runner() { + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + DETAIL="$1" SUCCESS_PATH="$2" LAUNCHCTL_RC="${3:-113}" \ + MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +launchctl() { return "${LAUNCHCTL_RC}"; } +_uninstall_match_loaded_background_items "$DETAIL" -- "$SUCCESS_PATH" +EOF +} + +@test "_uninstall_match_loaded_background_items reports app whose job is still loaded" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + + result="$(_bg_items_runner "$detail" "/Applications/Paste.app" 0)" + + [ "$result" = "Paste" ] +} + +@test "_uninstall_match_loaded_background_items stays silent when no job is loaded" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + + result="$(_bg_items_runner "$detail" "/Applications/Paste.app" 113)" + + [ -z "$result" ] +} + +@test "_uninstall_match_loaded_background_items checks helper ids under the sibling guard" { + # Sibling guard demotes bundle_id to "unknown" while helper ids stay + # valid; a loaded helper job must still be reported. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +helpers=$(printf 'com.wiheads.paste.helper' | base64) +detail="Paste|/Applications/Paste.app|unknown|0|||false|false|false||||$helpers|guard" + +launchctl() { [[ "$2" == *"com.wiheads.paste.helper"* ]] && return 0 || return 113; } +result=$(_uninstall_match_loaded_background_items "$detail" -- "/Applications/Paste.app") +[[ "$result" == "Paste" ]] || exit 1 + +launchctl() { return 113; } +result=$(_uninstall_match_loaded_background_items "$detail" -- "/Applications/Paste.app") +[[ -z "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "_uninstall_match_loaded_background_items stays quiet in test mode" { + # Test mode must not probe launchctl at all; summaries stay silent so + # end-to-end uninstall tests never see a background-item warning. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +launchctl() { return 0; } +detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" +result=$(_uninstall_match_loaded_background_items "$detail" -- "/Applications/Paste.app") +[[ -z "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "_uninstall_match_loaded_background_items skips apps that were not successfully removed" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + + result="$(_bg_items_runner "$detail" "/Applications/OtherApp.app" 0)" + + [ -z "$result" ] +} + +@test "_uninstall_match_loaded_background_items ignores unknown bundle id without helpers" { + local detail="Paste|/Applications/Paste.app|unknown|0|||false|false|false||||" + + result="$(_bg_items_runner "$detail" "/Applications/Paste.app" 0)" + + [ -z "$result" ] +} + +@test "execution spinner starts before the same-bundle re-scan (#1340 family)" { + # The pre-teardown re-scan can burn tens of seconds on a large receipt + # set. When the spinner started after it, the Enter confirm was followed + # by dead silence and users read the prompt as hung. Pin the order + # inside _batch_execute_removals. + local body spin_line scan_line + body=$(awk '/^_batch_execute_removals\(\)/{f=1} f{n++; print n": "$0} f && /^\}/{exit}' \ + "$PROJECT_ROOT/lib/uninstall/batch.sh") + spin_line=$(printf '%s\n' "$body" | command grep -m1 'start_inline_spinner' | cut -d: -f1) + scan_line=$(printf '%s\n' "$body" | command grep -m1 'uninstall_live_bundle_has_other_install' | cut -d: -f1) + [[ -n "$spin_line" && -n "$scan_line" ]] || { + echo "expected both calls inside _batch_execute_removals" + return 1 + } + [[ "$spin_line" -lt "$scan_line" ]] || { + echo "spinner starts at line $spin_line, after the re-scan at $scan_line" + return 1 + } +} + +@test "match_apps_by_name joins multi-word args into one exact app name (#1365)" { + # `mo uninstall Tor Browser` arrives as two words; "Tor" alone + # substring-matched WebSTORm. The joined words exactly name an + # installed app, so that must be the single match. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/WebStorm.app|WebStorm|com.jetbrains.WebStorm|3.06 GB|1000000|3208960" + "1001|$HOME/Applications/Tor Browser.app|Tor Browser|org.torproject.torbrowser|501.8 MB|1000001|513843" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "Tor" "Browser" +echo "count=${#selected_apps[@]}" +echo "match=${selected_apps[0]}" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"count=1"* ]] || return 1 + [[ "$output" == *"Tor Browser"* ]] || return 1 + [[ "$output" != *"WebStorm"* ]] || return 1 +} + +@test "match_apps_by_name keeps per-word matching when the joined form names nothing" { + # Two genuinely separate app queries must keep working after the + # joined-form check: "TestApp2 TestApp3" names no single app. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000" + "1001|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "TestApp2" "TestApp3" +echo "count=${#selected_apps[@]}" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"count=2"* ]] || return 1 +} + +@test "match_apps_by_name keeps two-app meaning when every word exactly names its own app" { + # With Foo.app, Bar.app, and "Foo Bar.app" all installed, the joined + # interpretation must not silently swallow the original two-app query. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/Foo.app|Foo|com.example.foo|100 MB|1000000|102400" + "1001|$HOME/Applications/Bar.app|Bar|com.example.bar|100 MB|1000001|102400" + "1002|$HOME/Applications/Foo Bar.app|Foo Bar|com.example.foobar|100 MB|1000002|102400" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "Foo" "Bar" +echo "count=${#selected_apps[@]}" +printf 'sel=%s\n' "${selected_apps[@]}" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"DRY RUN MODE"* ]] - [ -f "$HOME/.local/bin/mole" ] - [ -f "$HOME/.local/bin/mo" ] - [ -d "$HOME/.config/mole" ] - [ -d "$HOME/.cache/mole" ] + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"count=2"* ]] || return 1 + [[ "$output" == *"|Foo|"* ]] || return 1 + [[ "$output" == *"|Bar|"* ]] || return 1 + [[ "$output" != *"Foo Bar"* ]] || return 1 } diff --git a/Resources/mole/tests/uninstall_naming_variants.bats b/Resources/mole/tests/uninstall_naming_variants.bats index eee48f9..eb26583 100644 --- a/Resources/mole/tests/uninstall_naming_variants.bats +++ b/Resources/mole/tests/uninstall_naming_variants.bats @@ -17,14 +17,19 @@ setup_file() { } teardown_file() { - if [[ -d "$HOME" && "$HOME" =~ tmp-naming ]]; then + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then rm -rf "$HOME" fi export HOME="$ORIGINAL_HOME" } setup() { - find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2> /dev/null || true source "$PROJECT_ROOT/lib/core/base.sh" source "$PROJECT_ROOT/lib/core/log.sh" source "$PROJECT_ROOT/lib/core/app_protection.sh" @@ -65,7 +70,7 @@ setup() { result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") - [[ "$result" =~ .config/zed ]] + [[ "$result" =~ .config/zed ]] || return 1 [[ "$result" =~ "Library/Application Support/Zed" ]] } @@ -77,26 +82,34 @@ setup() { result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") - [[ "$result" =~ Library/HTTPStorages/dev\.zed\.Zed-Preview ]] + [[ "$result" =~ Library/HTTPStorages/dev\.zed\.Zed-Preview ]] || return 1 [[ ! "$result" =~ storage/default/https\+\+\+zed\.dev ]] } @test "find_app_files detects multiple naming variants simultaneously" { mkdir -p "$HOME/.config/maestro-studio" + mkdir -p "$HOME/.cache/maestro-studio" mkdir -p "$HOME/Library/Application Support/MaestroStudio" mkdir -p "$HOME/Library/Application Support/Maestro-Studio" + mkdir -p "$HOME/Library/Preferences" + mkdir -p "$HOME/Library/Saved Application State/MaestroStudio.savedState" mkdir -p "$HOME/.local/share/maestrostudio" echo "test" > "$HOME/.config/maestro-studio/config.json" + echo "test" > "$HOME/.cache/maestro-studio/cache.db" echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db" echo "test" > "$HOME/Library/Application Support/Maestro-Studio/prefs.json" + echo "test" > "$HOME/Library/Preferences/Maestro-Studio.plist" echo "test" > "$HOME/.local/share/maestrostudio/cache.db" result=$(find_app_files "com.maestro.studio" "Maestro Studio") - [[ "$result" =~ .config/maestro-studio ]] - [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] - [[ "$result" =~ "Library/Application Support/Maestro-Studio" ]] + [[ "$result" =~ .config/maestro-studio ]] || return 1 + [[ "$result" =~ .cache/maestro-studio ]] || return 1 + [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] || return 1 + [[ "$result" =~ "Library/Application Support/Maestro-Studio" ]] || return 1 + [[ "$result" =~ Library/Preferences/Maestro-Studio\.plist ]] || return 1 + [[ "$result" =~ Library/Saved\ Application\ State/MaestroStudio\.savedState ]] || return 1 [[ "$result" =~ .local/share/maestrostudio ]] } @@ -109,10 +122,291 @@ setup() { [[ "$result" =~ .local/share/firefox ]] } +@test "find_app_files detects bundle-id-derived extension leftovers" { + mkdir -p "$HOME/Library/Application Support/FileProvider/com.tencent.xinWeChat.WeChatFileProviderExtension" + mkdir -p "$HOME/Library/Application Scripts/com.tencent.xinWeChat.WeChatMacShare" + mkdir -p "$HOME/Library/Application Scripts/5A4RE8SF68.com.tencent.xinWeChat" + mkdir -p "$HOME/Library/Containers/com.tencent.xinWeChat.WeChatFileProviderExtension" + mkdir -p "$HOME/Library/Group Containers/5A4RE8SF68.com.tencent.xinWeChat" + mkdir -p "$HOME/Library/Containers/com.tencent.otherapp.Helper" + + result=$(find_app_files "com.tencent.xinWeChat" "WeChat") + + [[ "$result" =~ Library/Application\ Support/FileProvider/com.tencent.xinWeChat.WeChatFileProviderExtension ]] || return 1 + [[ "$result" =~ Library/Application\ Scripts/com.tencent.xinWeChat.WeChatMacShare ]] || return 1 + [[ "$result" =~ Library/Application\ Scripts/5A4RE8SF68.com.tencent.xinWeChat ]] || return 1 + [[ "$result" =~ Library/Containers/com.tencent.xinWeChat.WeChatFileProviderExtension ]] || return 1 + [[ "$result" =~ Library/Group\ Containers/5A4RE8SF68.com.tencent.xinWeChat ]] || return 1 + [[ ! "$result" =~ Library/Containers/com.tencent.otherapp.Helper ]] +} + +@test "find_app_files handles the first derived bundle match under bash 3.2 set -u" { + mkdir -p "$HOME/Library/Containers/com.example.Widget.Helper" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/log.sh" +source "$PROJECT_ROOT/lib/core/app_protection.sh" +find_app_files "com.example.Widget" "Widget" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Library/Containers/com.example.Widget.Helper"* ]] || return 1 + [[ "$output" != *"unbound variable"* ]] +} + +@test "find_app_files emits embedded extension leftovers once" { + local app="$HOME/Applications/Developer.app" + local widget="$app/Contents/PlugIns/Developer Widget.appex/Contents" + local app_scripts="$HOME/Library/Application Scripts/developer.apple.wwdc-Release.Developer-Widget" + local container="$HOME/Library/Containers/developer.apple.wwdc-Release.Developer-Widget" + mkdir -p "$widget" "$app_scripts" "$container" + cat > "$widget/Info.plist" <<'PLIST' + + + + CFBundleIdentifierdeveloper.apple.wwdc-Release.Developer-Widget + +PLIST + + result=$( + HOME="$HOME" APP="$app" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "developer.apple.wwdc-Release" "Developer" "$APP" +EOF + ) + + [ "$(printf '%s\n' "$result" | awk -v path="$app_scripts" '$0 == path { count++ } END { print count + 0 }')" -eq 1 ] || return 1 + [ "$(printf '%s\n' "$result" | awk -v path="$container" '$0 == path { count++ } END { print count + 0 }')" -eq 1 ] +} + +@test "find_app_files detects vendor-nested Application Support directories" { + mkdir -p "$HOME/Library/Application Support/Avid/Sibelius" + mkdir -p "$HOME/Library/Application Support/OtherVendor/Sibelius" + echo "test" > "$HOME/Library/Application Support/Avid/Sibelius/settings.db" + echo "test" > "$HOME/Library/Application Support/OtherVendor/Sibelius/settings.db" + + result=$(find_app_files "com.avid.sibelius" "Sibelius") + + [[ "$result" =~ Library/Application\ Support/Avid/Sibelius ]] || return 1 + [[ ! "$result" =~ Library/Application\ Support/OtherVendor/Sibelius ]] +} + @test "find_app_files does not match empty app name" { mkdir -p "$HOME/Library/Application Support/test" + mkdir -p "$HOME/Library/Preferences" + mkdir -p "$HOME/.config" "$HOME/.cache" "$HOME/.local/share" + + result=$(find_app_files "com.test" "" 2> /dev/null || true) + + [[ ! "$result" =~ "Library/Application Support"$ ]] || return 1 + [[ ! "$result" =~ "Library/Preferences"$ ]] || return 1 + [[ ! "$result" =~ "$HOME/."$ ]] || return 1 + [[ ! "$result" =~ ".config"$ ]] || return 1 + [[ ! "$result" =~ ".cache"$ ]] || return 1 + [[ ! "$result" =~ ".local/share"$ ]] +} + +# Regression: with an invalid bundle id AND an empty app name, no pattern +# block fires, leaving user_patterns empty. macOS /bin/bash 3.2 under set -u +# treats expanding an empty array as an unbound variable, so the scan must +# use the +-guard idiom instead of crashing. +@test "find_app_files survives empty pattern list under bash 3.2 set -u" { + run /bin/bash -c "set -u +source '$PROJECT_ROOT/lib/core/base.sh' +source '$PROJECT_ROOT/lib/core/log.sh' +source '$PROJECT_ROOT/lib/core/app_protection.sh' +find_app_files 'invalid_bundle' ''" + + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"unbound variable"* ]] || return 1 +} + +@test "find_app_files detects VS Code stable Application Support folder (#850)" { + mkdir -p "$HOME/Library/Application Support/Code" + mkdir -p "$HOME/Library/Application Support/Code - Insiders" + mkdir -p "$HOME/.vscode" + + result=$(find_app_files "com.microsoft.VSCode" "Visual Studio Code") + + [[ "$result" =~ Library/Application\ Support/Code$'\n' ]] || [[ "$result" == *"Library/Application Support/Code"* ]] + [[ "$result" == *"/.vscode"* ]] || return 1 + [[ "$result" != *"Code - Insiders"* ]] +} + +@test "find_app_files detects VS Code Insiders Application Support folder (#850)" { + mkdir -p "$HOME/Library/Application Support/Code" + mkdir -p "$HOME/Library/Application Support/Code - Insiders" + mkdir -p "$HOME/.vscode-insiders" + + result=$(find_app_files "com.microsoft.VSCodeInsiders" "Visual Studio Code - Insiders") + + [[ "$result" == *"Library/Application Support/Code - Insiders"* ]] || return 1 + [[ "$result" == *"/.vscode-insiders"* ]] || return 1 + [[ ! "$result" =~ Library/Application\ Support/Code$'\n' ]] +} + +@test "find_app_files detects Anki support files but preserves user profile data (#1145)" { + mkdir -p "$HOME/Library/Application Support/Anki2" + mkdir -p "$HOME/Library/Application Support/AnkiProgramFiles" + + result=$(find_app_files "net.ankiweb.anki" "Anki") + + [[ "$result" != *"Library/Application Support/Anki2"* ]] || return 1 + [[ "$result" == *"Library/Application Support/AnkiProgramFiles"* ]] +} + +# Independent CLI dotdir protection, issue #993. +# Uninstalling a GUI app named "Claude" / "OpenCode" / etc. must not delete +# the same-named standalone CLI tool's state directory. + +@test "find_app_files preserves ~/.claude when uninstalling Claude.app (#993)" { + mkdir -p "$HOME/.claude/projects" + mkdir -p "$HOME/Library/Application Support/Claude" + echo "memory" > "$HOME/.claude/projects/sample" + + result=$(find_app_files "com.anthropic.claudefordesktop" "Claude") + + [[ "$result" == *"Library/Application Support/Claude"* ]] || return 1 + [[ "$result" != *"$HOME/.claude"* ]] || return 1 + [[ "$result" != *"$HOME/.Claude"* ]] +} + +@test "find_app_files preserves ~/.local/share/opencode when uninstalling OpenCode.app (#993)" { + mkdir -p "$HOME/.local/share/opencode/snapshot" + mkdir -p "$HOME/.config/opencode" + mkdir -p "$HOME/.opencode" + mkdir -p "$HOME/Library/Application Support/opencode" + + result=$(find_app_files "ai.opencode.desktop" "opencode") + + [[ "$result" == *"Library/Application Support/opencode"* ]] || return 1 + [[ "$result" != *".local/share/opencode"* ]] || return 1 + [[ "$result" != *".config/opencode"* ]] || return 1 + [[ "$result" != *"$HOME/.opencode"* ]] +} + +@test "find_app_files preserves ~/.codex when uninstalling Codex.app (#993)" { + mkdir -p "$HOME/.codex" + mkdir -p "$HOME/.config/codex" + mkdir -p "$HOME/Library/Application Support/Codex" + + result=$(find_app_files "com.openai.codex" "Codex") + + [[ "$result" == *"Library/Application Support/Codex"* ]] || return 1 + [[ "$result" != *"$HOME/.codex"* ]] || return 1 + [[ "$result" != *".config/codex"* ]] +} + +@test "find_app_files still removes Zed XDG state (independent-CLI list must not over-protect)" { + # Sanity check that the deny-list does not break legitimate GUI-app XDG + # cleanup added for #377. Zed is a GUI app that owns ~/.config/zed and + # ~/.local/share/zed and must still be picked up on uninstall. + mkdir -p "$HOME/.config/zed" + mkdir -p "$HOME/.local/share/zed" + + result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") + + [[ "$result" == *".config/zed"* ]] || [[ "$result" == *".local/share/zed"* ]] +} + +@test "find_app_files keeps Raycast v2 data when uninstalling Raycast v1 (#1202)" { + # Raycast v2 is a separate app (com.raycast-x.macos); the v1 "*raycast*" + # sweeps must not collect any of its directories. + mkdir -p "$HOME/Library/Application Support/com.raycast.macos" + mkdir -p "$HOME/Library/Application Support/com.raycast-x.macos" + mkdir -p "$HOME/Library/Containers/com.raycast.macos" + mkdir -p "$HOME/Library/Containers/com.raycast-x.macos" + mkdir -p "$HOME/Library/Caches/com.raycast.macos" + mkdir -p "$HOME/Library/Caches/Raycast-X" + mkdir -p "$HOME/Library/Application Support/Code/User/globalStorage/raycast-x.raycast" + + result=$(find_app_files "com.raycast.macos" "Raycast") + + [[ "$result" == *"Application Support/com.raycast.macos"* ]] || return 1 + [[ "$result" == *"Containers/com.raycast.macos"* ]] || return 1 + [[ "$result" == *"Caches/com.raycast.macos"* ]] || return 1 + [[ "$result" != *"com.raycast-x.macos"* ]] || return 1 + [[ "$result" != *"Caches/Raycast-X"* ]] || return 1 + [[ "$result" != *"raycast-x.raycast"* ]] || return 1 +} + +@test "find_app_files derives a camel-split data dir from the bundle leaf (AyuGram Desktop)" { + # tdesktop forks: display name "AyuGram", bundle one.ayugram.AyuGramDesktop, + # data at "Application Support/AyuGram Desktop". No display-name variant + # reaches it; the bundle leaf does. + mkdir -p "$HOME/Library/Application Support/AyuGram Desktop" + echo "tdata" > "$HOME/Library/Application Support/AyuGram Desktop/settings" + + result=$(find_app_files "one.ayugram.AyuGramDesktop" "AyuGram") + + [[ "$result" =~ "Library/Application Support/AyuGram Desktop" ]] || return 1 +} + +@test "find_app_files also takes the raw bundle leaf as an exact dir name" { + mkdir -p "$HOME/Library/Application Support/AyuGramDesktop" + echo "tdata" > "$HOME/Library/Application Support/AyuGramDesktop/settings" + + result=$(find_app_files "one.ayugram.AyuGramDesktop" "AyuGram") + + [[ "$result" =~ "Library/Application Support/AyuGramDesktop" ]] || return 1 +} + +@test "bundle-leaf variants need eight characters and a camel transition" { + # A short or single-word leaf ("app", "desktop", "helper") must derive + # nothing: exact-path or not, those names collide with unrelated dirs. + mkdir -p "$HOME/Library/Application Support/app" + mkdir -p "$HOME/Library/Application Support/desktop" + mkdir -p "$HOME/Library/Application Support/Whatsapp" + + result=$(find_app_files "com.example.app" "Example") + [[ "$result" != *"Application Support/app"* ]] || return 1 + + result=$(find_app_files "com.example.desktop" "Example") + [[ "$result" != *"Application Support/desktop"* ]] || return 1 + + # 8+ chars but no lower-to-upper transition: no derivation either. + result=$(find_app_files "com.example.Whatsapp" "Example") + [[ "$result" != *"Application Support/Whatsapp"* ]] || return 1 +} + +@test "bundle-leaf variants stay quiet when the leaf equals the display name" { + # When leaf and display name agree, the ordinary app-name patterns + # already cover the dir; the derivation must not add anything, and an + # invalid bundle id must never reach the derivation at all. + mkdir -p "$HOME/Library/Application Support/CamelCaseApp" + + result=$(find_app_files "com.example.CamelCaseApp" "CamelCaseApp") + [[ "$result" =~ "Application Support/CamelCaseApp" ]] || return 1 + + result=$(find_app_files "unknown" "Other") + [[ "$result" != *"CamelCaseApp"* ]] || return 1 +} + +@test "bundle-leaf variants refuse a leaf that does not extend the display name" { + # Safety review collision classes: a wrapper or fork whose bundle leaf + # names ANOTHER product must derive nothing, even though the leaf clears + # every size floor. The dirs exist here, so a miss is a real exclusion. + mkdir -p "$HOME/Library/Application Support/Google Chrome" + mkdir -p "$HOME/Library/Application Support/GoogleChrome" + mkdir -p "$HOME/Library/Application Support/Telegram Desktop" + mkdir -p "$HOME/Library/Application Support/AddressBook" + mkdir -p "$HOME/Library/Application Support/AyuGram Desktop" + + result=$(find_app_files "com.wrapper.GoogleChrome" "My Chrome SSB") + [[ "$result" != *"Google Chrome"* ]] || return 1 + [[ "$result" != *"GoogleChrome"* ]] || return 1 + + result=$(find_app_files "org.acmefork.TelegramDesktop" "64Gram") + [[ "$result" != *"Telegram Desktop"* ]] || return 1 - result=$(find_app_files "com.test" "" 2>/dev/null || true) + result=$(find_app_files "com.acme.AddressBook" "Acme Contacts Sync") + [[ "$result" != *"Application Support/AddressBook"* ]] || return 1 - [[ ! "$result" =~ "Library/Application Support"$ ]] + # Positive control in the same world: the leaf that extends its own + # display name still derives, so the negatives above are not vacuous. + result=$(find_app_files "one.ayugram.AyuGramDesktop" "AyuGram") + [[ "$result" =~ "Application Support/AyuGram Desktop" ]] || return 1 } diff --git a/Resources/mole/tests/uninstall_remove_file_list.bats b/Resources/mole/tests/uninstall_remove_file_list.bats new file mode 100644 index 0000000..691ce83 --- /dev/null +++ b/Resources/mole/tests/uninstall_remove_file_list.bats @@ -0,0 +1,297 @@ +#!/usr/bin/env bats + +# Tests for remove_file_list batching in lib/uninstall/batch.sh. +# Exercises the batched Trash path (single _mole_move_to_trash_batch call for +# eligible files) and the fallback when the batch helper fails. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-batch.XXXXXX")" + export SANDBOX + export MOLE_DELETE_LOG="$SANDBOX/deletions.log" + export MOLE_TEST_TRASH_DIR="$SANDBOX/Trash" + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_MODE=trash + unset MOLE_DRY_RUN + HOME="$SANDBOX/home" + mkdir -p "$HOME" + export HOME +} + +teardown() { + rm -rf "$SANDBOX" +} + +prelude() { + cat < "$f1" + : > "$f2" + : > "$f3" + : > "$f4" + : > "$f5" + local list + printf -v list '%s\n%s\n%s\n%s\n%s' "$f1" "$f2" "$f3" "$f4" "$f5" + + local count_file="$SANDBOX/batch_calls" + : > "$count_file" + + # Stub the batch helper to (1) record how many times it was called and + # how many paths each call covered, (2) emulate the real test-harness + # behavior by mv'ing each path into MOLE_TEST_TRASH_DIR. This lets the + # test assert both "called once" and "every file landed in trash". + run /bin/bash --noprofile --norc <> "$count_file" + local p dest + for p in "\$@"; do + dest="\$MOLE_TEST_TRASH_DIR/\$(basename "\$p").stub.\$RANDOM" + mv "\$p" "\$dest" 2>/dev/null || return 1 + done + return 0 +} +remove_file_list "$list" "false" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"5"* ]] # remove_file_list echoes count + + # All five files moved to the stub trash dir. + local in_trash + in_trash=$(find "$MOLE_TEST_TRASH_DIR" -type f | wc -l | tr -d ' ') + [ "$in_trash" -eq 5 ] + for f in "$f1" "$f2" "$f3" "$f4" "$f5"; do + [[ ! -e "$f" ]] || return 1 + done + + # Single batch invocation, with all five paths. + local call_count + call_count=$(wc -l < "$count_file" | tr -d ' ') + [ "$call_count" -eq 1 ] + grep -q '^call 5$' "$count_file" + + # Audit log records one ok line per moved path. + local ok_lines + ok_lines=$(awk -F'\t' '$4 == "ok" && $2 == "trash"' "$MOLE_DELETE_LOG" | wc -l | tr -d ' ') + [ "$ok_lines" -eq 5 ] +} + +@test "remove_file_list preserves unmoved paths when the guarded batch helper fails" { + local f1="$SANDBOX/x.plist" + local f2="$SANDBOX/y.plist" + : > "$f1" + : > "$f2" + local list + printf -v list '%s\n%s' "$f1" "$f2" + + local trace="$SANDBOX/trace" + : > "$trace" + + # A failed identity-bound batch must not hand the same stale lexical paths + # to a second sink. The files stay in place for manual review. + run /bin/bash --noprofile --norc <> "$trace" + return 99 +} +remove_file_list "$list" "false" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"0"* ]] || return 1 + [[ -e "$f1" && -e "$f2" ]] || return 1 + [[ ! -s "$trace" ]] +} + +@test "guarded Trash batch rejects an ancestor swapped after collection" { + local base="$SANDBOX/swap-parent" + local original_parent="$SANDBOX/original-parent" + local outside_parent="$HOME/Documents/OutsideParent" + local target="$base/cache" + mkdir -p "$target" "$outside_parent/cache" + touch "$target/OWNED_SENTINEL" "$outside_parent/cache/OUTSIDE_SENTINEL" + local list="$target" + + run /bin/bash --noprofile --norc < "$f1" + + # Drop MOLE_TEST_TRASH_DIR so we exercise the real helper path; the + # MOLE_TEST_NO_AUTH guard must fail closed before any AppleScript runs. + run /bin/bash --noprofile --norc < "$f1" + : > "$f2" + local list + printf -v list '%s\n%s' "$f1" "$f2" + + local batch_count="$SANDBOX/batch_count" + local fallback_count="$SANDBOX/fallback_count" + : > "$batch_count" + : > "$fallback_count" + + run /bin/bash --noprofile --norc <> "$batch_count" + return 0 +} +mole_delete() { + printf '%s\n' "\$1" >> "$fallback_count" + rm -f "\$1" + return 0 +} +remove_file_list "$list" "true" +EOF + + [ "$status" -eq 0 ] + + # Sudo path must avoid the batch helper entirely. + [[ ! -s "$batch_count" ]] || return 1 + + local n + n=$(wc -l < "$fallback_count" | tr -d ' ') + [ "$n" -eq 2 ] +} + +@test "remove_file_list stops after an interrupted per-file delete" { + local first="$SANDBOX/interrupt-first.plist" + local second="$SANDBOX/interrupt-second.plist" + : > "$first" + : > "$second" + local list + printf -v list '%s\n%s' "$first" "$second" + + run /bin/bash --noprofile --norc < "$ordinary" + local list + printf -v list '%s\n%s\n%s\n%s' "$container" "$group_container" "$app_scripts" "$ordinary" + + local direct_trace="$SANDBOX/direct.log" + local batch_trace="$SANDBOX/batch.log" + : > "$direct_trace" + : > "$batch_trace" + + run /bin/bash --noprofile --norc <> "$batch_trace" + return 0 +} +mole_delete() { + printf '%s|%s\n' "\$1" "\${2:-false}" >> "$direct_trace" + return 0 +} +trash() { + echo "trash CLI must not be called" >&2 + return 99 +} +osascript() { + echo "Finder must not be called" >&2 + return 98 +} +remove_file_list "$list" "false" +EOF + + [ "$status" -eq 0 ] + [ "$(wc -l < "$direct_trace" | tr -d ' ')" -eq 3 ] + grep -qF "$container|false" "$direct_trace" + grep -qF "$group_container|false" "$direct_trace" + grep -qF "$app_scripts|false" "$direct_trace" + [ "$(wc -l < "$batch_trace" | tr -d ' ')" -eq 1 ] + grep -qxF "$ordinary" "$batch_trace" + [[ "$output" != *"trash CLI must not be called"* ]] || return 1 + [[ "$output" != *"Finder must not be called"* ]] +} diff --git a/Resources/mole/tests/uninstall_safety.bats b/Resources/mole/tests/uninstall_safety.bats new file mode 100644 index 0000000..1013bac --- /dev/null +++ b/Resources/mole/tests/uninstall_safety.bats @@ -0,0 +1,742 @@ +#!/usr/bin/env bats + +# Safety boundary tests for find_app_files() and ByHost cleanup. +# These guard against regressions where uninstalling a developer toolchain +# would silently delete user project source, signing keys, OAuth tokens, +# or other manually-curated data. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${BATS_TMPDIR:-}" + if [[ -z "$ORIGINAL_HOME" ]]; then + ORIGINAL_HOME="${HOME:-}" + fi + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-safety-home.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" +} + +@test "find_app_files preserves Android Studio project source and credentials" { + mkdir -p "$HOME/AndroidStudioProjects/my-app" + mkdir -p "$HOME/.android/avd/Pixel_5.avd" + mkdir -p "$HOME/.android/cache" + touch "$HOME/.android/debug.keystore" + touch "$HOME/.android/adbkey" + mkdir -p "$HOME/Library/Android/sdk/platform-tools" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.google.android.studio" "Android Studio" +EOF + )" + + [[ "$result" != *"AndroidStudioProjects"* ]] || { echo "leaked project source"; exit 1; } + [[ "$result" != *"/.android/avd"* ]] || { echo "leaked AVD images"; exit 1; } + [[ "$result" != *"/.android/debug.keystore"* ]] || { echo "leaked signing key"; exit 1; } + [[ "$result" != *"/.android/adbkey"* ]] || { echo "leaked adb key"; exit 1; } + [[ "$result" != *"Library/Android"* ]] || { echo "leaked SDK tree"; exit 1; } + [[ "$result" == *"/.android/cache"* ]] || { echo "missed safe cache subdir"; exit 1; } +} + +@test "find_app_files preserves Docker auth tokens and config" { + mkdir -p "$HOME/.docker" + touch "$HOME/.docker/config.json" + mkdir -p "$HOME/.docker/contexts/meta" + mkdir -p "$HOME/.docker/buildx" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.docker.docker" "Docker" +EOF + )" + + [[ "$result" != *"/.docker/config.json"* ]] || { echo "leaked Docker auth tokens"; exit 1; } + [[ "$result" != *"/.docker/contexts"* ]] || { echo "leaked Docker contexts"; exit 1; } + # An exact-match line for $HOME/.docker would route the entire tree (auth + # tokens, contexts, plugins) to deletion. Walk every line so the assertion + # cannot be silently satisfied. + while IFS= read -r line; do + [[ "$line" == "$HOME/.docker" ]] && { echo "leaked entire ~/.docker tree"; exit 1; } + done <<< "$result" + # Buildx cache is regenerable, safe to clean. + [[ "$result" == *"/.docker/buildx"* ]] || { echo "missed safe buildx cache"; exit 1; } +} + +@test "official uninstaller vendor blocks managed security apps" { + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +official_uninstaller_vendor "com.crowdstrike.falcon.UserAgent" "Falcon" "/Applications/Falcon.app" +official_uninstaller_vendor "com.jamf.management.Jamf" "Jamf Connect" "/Applications/Jamf Connect.app" +EOF + )" + + [[ "$result" == *"CrowdStrike"* ]] || { echo "missed CrowdStrike"; exit 1; } + [[ "$result" == *"Jamf"* ]] || { echo "missed Jamf"; exit 1; } +} + +@test "receipt payload allowlist rejects broad system roots" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +receipt_payload_path_is_allowlisted "/Library/LaunchAgents/com.example.foo.helper.plist" "com.example.foo" +receipt_payload_path_is_allowlisted "/Library/PrivilegedHelperTools/com.example.foo.helper" "com.example.foo" +! receipt_payload_path_is_allowlisted "/Library/Application Support/Foo" "com.example.foo" +! receipt_payload_path_is_allowlisted "/Applications/Foo.app" "com.example.foo" +! receipt_payload_path_is_allowlisted "/usr/local/bin/foo" "com.example.foo" +EOF + + [ "$status" -eq 0 ] +} + +@test "launch plist unload validates path and uses timeout" { + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.foo.plist" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +run_with_timeout() { + printf '%s\n' "$*" > "$HOME/launchctl-call.log" + return 0 +} + +unload_launch_plist "$HOME/Library/LaunchAgents/com.example.foo.plist" "false" +grep -q "5 launchctl unload $HOME/Library/LaunchAgents/com.example.foo.plist" "$HOME/launchctl-call.log" +EOF + + [ "$status" -eq 0 ] +} + +@test "login item helper discovery reads embedded helper bundle ids" { + app="$HOME/Applications/Carrier.app" + helper="$app/Contents/Library/LoginItems/Carrier Helper.app/Contents" + mkdir -p "$helper" + cat > "$helper/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.carrier.helper + + +PLIST + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +discover_login_item_helper_bundle_ids "$HOME/Applications/Carrier.app" +EOF + )" + + [[ "$result" == "com.example.carrier.helper" ]] +} + +@test "login item helper discovery discards partial results and propagates cancellation" { + app="$HOME/Applications/RacedCarrier.app" + helper="$app/Contents/Library/LoginItems/Raced Helper.app/Contents" + mkdir -p "$helper" + printf '\n' > "$helper/Info.plist" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" APP="$app" \ + HELPER_APP="${helper%/Contents}" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +run_with_timeout() { + local _duration="$1" + shift + if [[ "${1:-}" == "find" ]]; then + printf '%s\0' "$HELPER_APP" + return "${SCAN_RC:?}" + fi + "$@" +} + +SCAN_RC=1 +result=$(discover_login_item_helper_bundle_ids "$APP") +[[ -z "$result" ]] || exit 1 + +SCAN_RC=130 +rc=0 +result=$(discover_login_item_helper_bundle_ids "$APP") || rc=$? +[[ $rc -eq 130 ]] || exit 1 +[[ -z "$result" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "find_app_files preserves Xcode user data and only collects regenerable caches" { + mkdir -p "$HOME/Library/Developer/Xcode/DerivedData/MyApp-abc/Build" + mkdir -p "$HOME/Library/Developer/Xcode/iOS DeviceSupport/17.0" + mkdir -p "$HOME/Library/Developer/Xcode/Archives/2026/03/MyApp.xcarchive" + mkdir -p "$HOME/Library/Developer/Xcode/UserData" + mkdir -p "$HOME/Library/Developer/Toolchains/swift-6.0.xctoolchain" + mkdir -p "$HOME/Library/Developer/CoreSimulator/Devices/abc" + mkdir -p "$HOME/Library/Developer/CoreSimulator/Caches/dyld" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.apple.dt.Xcode" "Xcode" +EOF + )" + + # Bare ~/Library/Developer must never appear, otherwise the whole tree + # (Archives, UserData, Toolchains, Devices) gets routed to deletion. + while IFS= read -r line; do + [[ "$line" == "$HOME/Library/Developer" ]] && { echo "leaked entire Library/Developer"; exit 1; } + done <<< "$result" + + [[ "$result" != *"/Library/Developer/Xcode/Archives"* ]] || { echo "leaked Xcode archives"; exit 1; } + [[ "$result" != *"/Library/Developer/Xcode/UserData"* ]] || { echo "leaked Xcode user data"; exit 1; } + [[ "$result" != *"/Library/Developer/Toolchains"* ]] || { echo "leaked toolchains"; exit 1; } + [[ "$result" != *"/Library/Developer/CoreSimulator/Devices"* ]] || { echo "leaked simulator devices"; exit 1; } + + [[ "$result" == *"/Library/Developer/Xcode/DerivedData"* ]] || { echo "missed DerivedData cache"; exit 1; } + [[ "$result" == *"/Library/Developer/Xcode/iOS DeviceSupport"* ]] || { echo "missed iOS DeviceSupport"; exit 1; } + [[ "$result" == *"/Library/Developer/CoreSimulator/Caches"* ]] || { echo "missed simulator caches"; exit 1; } +} + +@test "find_app_files preserves DevEco project source and Huawei account state" { + mkdir -p "$HOME/DevEcoStudioProjects/my-harmonyos-app" + mkdir -p "$HOME/HarmonyOS/projects" + mkdir -p "$HOME/DevEco-Studio/config" + mkdir -p "$HOME/Library/Application Support/Huawei/IdeaIC/options" + mkdir -p "$HOME/Library/Huawei/SDK" + mkdir -p "$HOME/.huawei/AppGallery" + mkdir -p "$HOME/.ohos/sdk" + mkdir -p "$HOME/Library/Caches/Huawei" + mkdir -p "$HOME/Library/Logs/Huawei" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.huawei.deveco" "DevEco-Studio" +EOF + )" + + [[ "$result" != *"DevEcoStudioProjects"* ]] || { echo "leaked DevEco project source"; exit 1; } + [[ "$result" != *"$HOME/HarmonyOS"* ]] || { echo "leaked HarmonyOS project root"; exit 1; } + [[ "$result" != *"$HOME/DevEco-Studio"* ]] || { echo "leaked DevEco IDE config + license state"; exit 1; } + [[ "$result" != *"Application Support/Huawei"* ]] || { echo "leaked Huawei IDE settings"; exit 1; } + [[ "$result" != *"$HOME/Library/Huawei"* ]] || { echo "leaked Huawei SDK tree"; exit 1; } + [[ "$result" != *"$HOME/.huawei"* ]] || { echo "leaked Huawei account state"; exit 1; } + [[ "$result" != *"$HOME/.ohos"* ]] || { echo "leaked OHOS SDK config"; exit 1; } + [[ "$result" == *"Caches/Huawei"* ]] || { echo "missed Huawei cache"; exit 1; } + [[ "$result" == *"Logs/Huawei"* ]] || { echo "missed Huawei logs"; exit 1; } +} + +@test "find_app_files rejects bundle ids with glob metacharacters" { + # Pre-stage Group Containers and ByHost entries that an over-broad + # wildcard could accidentally pick up. A malformed bundle id like + # "com.foo.*" must not expand into matches against unrelated containers. + mkdir -p "$HOME/Library/Group Containers/group.com.example.real" + mkdir -p "$HOME/Library/Group Containers/group.com.victim.unrelated" + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.real.ABC.plist" + touch "$HOME/Library/Preferences/ByHost/com.victim.unrelated.ABC.plist" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.real.plist" + touch "$HOME/Library/LaunchAgents/com.victim.unrelated.plist" + mkdir -p "$HOME/.ssh" + touch "$HOME/.ssh/id_rsa" + + for bad_id in "com.foo.*" "com.foo.?" "com.foo.[abc]" "../../.ssh/id_rsa" "../etc/passwd" "*"; do + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" BAD_ID="$bad_id" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "$BAD_ID" "FakeApp" +EOF + )" + + [[ "$result" != *"Group Containers/group.com.victim.unrelated"* ]] \ + || { echo "bundle id '$bad_id' over-matched Group Containers"; exit 1; } + [[ "$result" != *"ByHost/com.victim.unrelated"* ]] \ + || { echo "bundle id '$bad_id' over-matched ByHost"; exit 1; } + [[ "$result" != *"LaunchAgents/com.victim.unrelated"* ]] \ + || { echo "bundle id '$bad_id' over-matched LaunchAgents"; exit 1; } + [[ "$result" != *"/.ssh/id_rsa"* ]] \ + || { echo "bundle id '$bad_id' traversed into .ssh"; exit 1; } + done +} + +@test "find_app_files still resolves wildcards for legitimate reverse-DNS bundle ids" { + # Sanity check: the new validation must not regress the common case. + mkdir -p "$HOME/Library/Group Containers/group.com.example.real" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.real.plist" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.example.real" "RealApp" +EOF + )" + + [[ "$result" == *"Group Containers/group.com.example.real"* ]] \ + || { echo "missed legitimate Group Container match"; exit 1; } + [[ "$result" == *"LaunchAgents/com.example.real.plist"* ]] \ + || { echo "missed legitimate LaunchAgent match"; exit 1; } +} + +@test "find_app_files keeps bundle-id-derived paths on dot boundaries" { + mkdir -p "$HOME/Library/Preferences/ByHost" + mkdir -p "$HOME/Library/Group Containers/group.com.example.TestApp" + mkdir -p "$HOME/Library/Group Containers/group.com.example.TestApplication" + mkdir -p "$HOME/Library/Containers/com.example.TestApp.helper" + mkdir -p "$HOME/Library/Containers/com.example.TestApplication" + mkdir -p "$HOME/Library/Application Scripts/TEAM.com.example.TestApp.Extension" + mkdir -p "$HOME/Library/Application Scripts/TEAM.com.example.TestApplication.Extension" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApplication.ABC123.plist" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.example.TestApp" "TestApp" +EOF + )" + + [[ "$result" == *"ByHost/com.example.TestApp.ABC123.plist"* ]] || { echo "missed ByHost plist"; exit 1; } + [[ "$result" == *"Group Containers/group.com.example.TestApp"* ]] || { echo "missed group container"; exit 1; } + [[ "$result" == *"Containers/com.example.TestApp.helper"* ]] || { echo "missed helper container"; exit 1; } + [[ "$result" == *"Application Scripts/TEAM.com.example.TestApp.Extension"* ]] || { echo "missed prefixed app script"; exit 1; } + [[ "$result" != *"TestApplication"* ]] || { echo "matched sibling bundle prefix"; printf '%s\n' "$result"; exit 1; } +} + +@test "ByHost cleanup routes through user-mode mole_delete (no sudo prompt)" { + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Applications/TestApp.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +trace="$HOME/mole_delete.log" +mole_delete() { + printf '%s|%s\n' "$1" "${2:-false}" >> "$trace" + return 0 +} +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } + +app_bundle="$HOME/Applications/TestApp.app" + +related="$(find_app_files "com.example.TestApp" "TestApp")" +encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') + +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications + +if grep -q "ByHost.*com.example.TestApp.*plist|true" "$trace"; then + echo "ByHost plist routed through sudo mole_delete" + cat "$trace" >&2 + exit 1 +fi + +grep -q "ByHost.*com.example.TestApp.*plist|false" "$trace" +EOF + + [ "$status" -eq 0 ] +} + +@test "malformed bundle ids do not trigger defaults or ByHost side effects" { + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Applications/TestApp.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +trace="$HOME/side_effects.log" + +defaults() { + printf 'defaults:%s\n' "$*" >> "$trace" + return 0 +} +mole_delete() { + printf 'mole_delete:%s|%s\n' "$1" "${2:-false}" >> "$trace" + return 0 +} +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +remove_login_item() { :; } +unregister_app_bundle() { :; } +force_kill_app() { return 0; } +request_sudo_access() { return 0; } +ensure_sudo_session() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } + +for bad_id in "-g" "NSGlobalDomain" "com-example"; do + : > "$trace" + selected_apps=() + selected_apps+=("0|$HOME/Applications/TestApp.app|TestApp|$bad_id|0|Never") + files_cleaned=0 + total_items=0 + total_size_cleaned=0 + + batch_uninstall_applications /dev/null || true' EXIT + +out="$(create_temp_file)" +rc=0 +_uninstall_materialize_complete_find0 "$out" "$((SECONDS + 30))" \ + "$root" -maxdepth 3 \( -type d -o -type l \) -name '*.app' || rc=$? + +[[ "$rc" -eq "$MOLE_UNINSTALL_SCAN_PARTIAL" ]] || { echo "RC:$rc want $MOLE_UNINSTALL_SCAN_PARTIAL"; exit 1; } +# The listing it did produce must survive: discarding it is what turned a +# readable-but-incomplete scan into a total failure. +grep -qa "Some.app" "$out" || { echo "RESULTS_DISCARDED"; exit 1; } +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"RESULTS_DISCARDED"* ]] || return 1 +} + +@test "wrapped iOS bundles and id-less bundles do not make the sibling scan unknown (#1339)" { + # Two bundle shapes that are ordinary installs, not mysteries: an iOS app + # on Apple Silicon keeps its plist under Wrapper/.app, and vendor + # uninstallers ship a plist with no CFBundleIdentifier at all. Both read as + # "unknown" before, and one of either anywhere on the machine aborted the + # uninstall of every other app. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +apps="$HOME/sibling-shapes" +mkdir -p "$apps/Wrapped.app/Wrapper/Inner.app" "$apps/NoId.app/Contents" "$apps/Broken.app/Contents" +cat > "$apps/Wrapped.app/Wrapper/Inner.app/Info.plist" <<'PLIST' + + +CFBundleIdentifiercom.example.wrapped +PLIST +cat > "$apps/NoId.app/Contents/Info.plist" <<'PLIST' + + +CFBundleExecutablerun.sh +PLIST +printf 'not a plist' > "$apps/Broken.app/Contents/Info.plist" + +live_paths=(); live_records=() +deadline=$((SECONDS + 30)) + +# The wrapped bundle's real id must be found, so it matches when it should. +rc=0 +_uninstall_collect_live_sibling_candidate "$apps/Wrapped.app" "/nowhere.app" \ + "com.example.wrapped" "$deadline" true || rc=$? +[[ "$rc" -eq 0 ]] || { echo "WRAPPED_RC:$rc want 0"; exit 1; } + +# No CFBundleIdentifier is an answer: it cannot be a sibling. +rc=0 +_uninstall_collect_live_sibling_candidate "$apps/NoId.app" "/nowhere.app" \ + "com.example.wrapped" "$deadline" true || rc=$? +[[ "$rc" -eq 1 ]] || { echo "NOID_RC:$rc want 1"; exit 1; } + +# A plist that will not parse is still unknown, and must stay that way. +rc=0 +_uninstall_collect_live_sibling_candidate "$apps/Broken.app" "/nowhere.app" \ + "com.example.wrapped" "$deadline" true || rc=$? +[[ "$rc" -eq 2 ]] || { echo "BROKEN_RC:$rc want 2"; exit 1; } +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "interactive scan failure is a visible abort, not a silent success (#1339)" { + # The interactive loop used to return to the prompt with nothing on screen + # when the scan could not complete; the session then read as a successful + # run with zero operations. The abort must be printed after the alternate + # screen is restored, and the command must fail. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/uninstall.sh" + +# No real machine scanning or terminal in this test. +scan_applications() { return 1; } +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } + +main +EOF + + [ "$status" -eq 1 ] + [[ "$output" == *"Uninstall aborted: could not complete the application scan"* ]] +} + +@test "failed app selection aborts visibly instead of returning success (#1339)" { + # EOF or a broken selector used to exit 0 with nothing printed, so the + # session read as successful with zero operations. A selector that did + # not complete for any reason other than a deliberate quit must say so + # and fail. The deliberate-quit case is pinned separately below. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/uninstall.sh" + +fake_apps_list="$HOME/fake-apps" +printf '0|/tmp/Fake.app|Fake|com.example.fake|1KB|Today|1\n' > "$fake_apps_list" +scan_applications() { printf '%s\n' "$fake_apps_list"; } +load_applications() { + apps_data=("0|/tmp/Fake.app|Fake|com.example.fake|1KB|Today|1") + selection_state=(false) + return 0 +} +select_apps_for_uninstall() { return 1; } +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } + +main +EOF + + [ "$status" -eq 1 ] + [[ "$output" == *"Uninstall aborted: application selection did not complete"* ]] +} + +@test "a deliberate quit in the selector stays a quiet cancel, not an abort" { + # Pressing q is the documented way to leave the selector, matching + # mole's other cancel flows (mo remove ESC exits 0 silently). Only a + # selector that broke may print the abort and fail; the menu marks the + # difference through _MOLE_MENU_USER_QUIT. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/uninstall.sh" + +fake_apps_list="$HOME/fake-apps" +printf '0|/tmp/Fake.app|Fake|com.example.fake|1KB|Today|1\n' > "$fake_apps_list" +scan_applications() { printf '%s\n' "$fake_apps_list"; } +load_applications() { + apps_data=("0|/tmp/Fake.app|Fake|com.example.fake|1KB|Today|1") + selection_state=(false) + return 0 +} +select_apps_for_uninstall() { + _MOLE_MENU_USER_QUIT=1 + return 1 +} +start_uninstall_interactive_screen() { :; } +stop_uninstall_interactive_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } + +main +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"Uninstall aborted"* ]] || return 1 + [ "$status" -eq 0 ] +} + +@test "uninstall --list surfaces a failed scan instead of a bare exit (#1339)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/uninstall.sh" + +scan_applications() { return 1; } + +uninstall_list_apps +EOF + + [ "$status" -eq 1 ] + [[ "$output" == *"Uninstall aborted: could not complete the application scan"* ]] +} + +@test "a receipt scan that outlives its budget degrades to indeterminate, not a dead run" { + # Receipt enumeration is machine-wide: 274 receipts with one holding + # 22k paths blew the shared deadline and the resulting 124 ended the + # whole uninstall with nothing on screen (#1340). Out of budget is an + # incomplete scan, so it must land on the same partial verdict an + # unreadable path produces, never abort the run. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +root="$HOME/live-roots" +mkdir -p "$root" +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$root") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-such-volumes" +pkg_receipt_nonstandard_app_paths() { return 124; } + +rc=0 +uninstall_live_bundle_has_other_install \ + "com.example.selected" "$root/Selected.app" || rc=$? +[[ "$rc" -eq "$MOLE_UNINSTALL_SCAN_PARTIAL" ]] || { echo "RC:$rc want $MOLE_UNINSTALL_SCAN_PARTIAL"; exit 1; } +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "a signal during the receipt scan still cancels the uninstall" { + # Only deadline timeouts degrade to the partial verdict. A signal is + # the user cancelling, and must keep propagating unchanged. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +root="$HOME/live-roots" +mkdir -p "$root" +_MOLE_UNINSTALL_LIVE_APP_ROOTS=("$root") +_MOLE_UNINSTALL_LIVE_VOLUMES_ROOT="$HOME/no-such-volumes" +pkg_receipt_nonstandard_app_paths() { return 130; } + +rc=0 +uninstall_live_bundle_has_other_install \ + "com.example.selected" "$root/Selected.app" || rc=$? +[[ "$rc" -eq 130 ]] || { echo "RC:$rc want 130"; exit 1; } +EOF + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } +} + +@test "execution-time partial acceptance is gated on an empty deletion plan" { + # guard_login alone does not prove a bundle-only plan: the + # surviving-sibling name-collision path sets it while keeping + # name-keyed leftovers in encoded_files. If the partial re-check + # acceptance ever drops the empty-deletion-list gate, a sibling + # hidden behind the unreadable part of a partial re-scan could lose + # name-keyed data without the fingerprint defense. + local window + # shellcheck disable=SC2016 # the \$ patterns are literal source text + window=$(command grep -A2 'live_sibling_rc -eq \$MOLE_UNINSTALL_SCAN_PARTIAL &&' \ + "$PROJECT_ROOT/lib/uninstall/batch.sh") + # Positive control: the acceptance branch must exist at all. + printf '%s\n' "$window" | command grep -q 'guard_login' || { + echo "acceptance branch not found" + return 1 + } + # shellcheck disable=SC2016 # the \$ pattern is literal source text + printf '%s\n' "$window" | command grep -q -- '-z "\$encoded_files"' || { + echo "gate missing the empty-plan check" + return 1 + } +} diff --git a/Resources/mole/tests/uninstall_scan_bash32.bats b/Resources/mole/tests/uninstall_scan_bash32.bats new file mode 100644 index 0000000..948f23d --- /dev/null +++ b/Resources/mole/tests/uninstall_scan_bash32.bats @@ -0,0 +1,597 @@ +#!/usr/bin/env bats + +# Regression for #863: "Can't Open App List, Scanning forever." +# +# macOS ships /bin/bash 3.2 (Apple does not upgrade past it, GPLv3). The +# bin/uninstall.sh shebang is `#!/bin/bash`, so the installed script runs +# under 3.2 regardless of any Homebrew bash also on the system. Under +# `set -u`, bash 3.2 treats `"${empty_array[@]}"` as an unbound expansion +# rather than expanding to zero elements. +# +# scan_applications declares `local -a app_data_tuples=()` and only appends +# rows for apps that miss the warm metadata cache (uncached_rows_file). When +# every discovered app is satisfied by the cache, app_data_tuples stays +# empty while scan_raw_file is non-empty (use_cached_scan_metadata already +# wrote rows to it). The early-return at the `[[ ... && ! -s ... ]]` guard +# therefore does not fire, and the subsequent `for ... in +# "${app_data_tuples[@]}"` iteration aborts with +# "app_data_tuples[@]: unbound variable". + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-scan-bash32.XXXXXX")" + export HOME + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + export TERM="dumb" +} + +teardown() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi +} + +# Build a sourceable copy of bin/uninstall.sh: rewrites SCRIPT_DIR so library +# sources resolve, and strips the `main "$@"` invocation so we can drive +# scan_applications directly. +sourceable_uninstall_sh() { + local out="$1" + awk -v script_dir="$PROJECT_ROOT/bin" ' + /^SCRIPT_DIR=/ { print "SCRIPT_DIR=\"" script_dir "\""; next } + /main "\$@"/ { print "# main skipped by test"; next } + { print } + ' "$PROJECT_ROOT/bin/uninstall.sh" > "$out" +} + +create_test_app_bundle() { + local app_path="$1" + local bundle_id="$2" + local display_name="$3" + local background_only="${4:-false}" + + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" < + + + + CFBundleIdentifier + $bundle_id + CFBundleName + $display_name + + +PLIST + + if [[ "$background_only" == "true" ]]; then + /usr/libexec/PlistBuddy -c "Add :LSBackgroundOnly bool true" \ + "$app_path/Contents/Info.plist" > /dev/null 2>&1 + fi +} + +@test "scan_applications: Pass 2 tolerates empty app_data_tuples on /bin/bash 3.2 (#863)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + mkdir -p "$apps_root/TestApp.app/Contents" + : > "$apps_root/TestApp.app/Contents/Info.plist" + + # Seed the warm metadata cache so that the one discovered app + # (TestApp.app) is a cache hit: matching mtime, non-empty bundle id + # and display name are the conditions the awk classifier and + # use_cached_scan_metadata require for the cached branch to "stick". + app_mtime="$(stat -f %m "$apps_root/TestApp.app")" + cache_dir="$HOME/.cache/mole" + mkdir -p "$cache_dir" + printf '%s|%s|4|0|0|com.test.TestApp|TestApp\n' \ + "$apps_root/TestApp.app" "$app_mtime" \ + > "$cache_dir/uninstall_app_metadata_v1" + + done_marker="$HOME/scan.done" + + # The bug not only emits "unbound variable"; the spinner subshell can + # keep running after the parent script errors out. The user-visible + # symptom is exactly "scanning forever". Mirror the marker-file watchdog + # from the #722 hang test (uninstall.bats: "uninstall_persist_cache_file + # does not hang...") so a regression surfaces as HANG rather than blocking + # the whole bats run. + ( + env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 \ + APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' > "$HOME/scan.out" 2> "$HOME/scan.err" +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +# Skip the real pkgutil receipt scan: it walks every package on the host and +# can take longer than this test's watchdog on machines with large receipt +# databases. The regression under test is the empty app_data_tuples guard. +pkg_receipt_nonstandard_app_paths() { return 0; } + +# Restrict the discovered search dirs to our sandboxed Applications folder +# so scan_applications does not pick up real /Applications and dilute the +# all-cached condition we are exercising. +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +# Bundle-id resolution would otherwise call /usr/bin/mdls and reject our +# placeholder Info.plist. The cached branch only needs an echo-through here. +uninstall_resolve_eligible_bundle_id() { printf '%s\n' "${2:-${1##*/}}"; } + +scan_applications > /dev/null +EOF + : > "$done_marker" + ) & + bgpid=$! + + # Poll for completion marker for up to ~5s. + for _ in $(seq 1 50); do + [[ -e "$done_marker" ]] && break + sleep 0.1 + done + + status_msg="" + if [[ ! -e "$done_marker" ]]; then + kill -TERM "$bgpid" 2> /dev/null || true + # Reap the orphaned spinner subshell so it does not leak into the + # next test or the rest of the run. + pkill -P "$bgpid" 2> /dev/null || true + status_msg="HANG" + fi + wait "$bgpid" 2> /dev/null || true + + [[ -z "$status_msg" ]] || { + echo "scan_applications hung, Pass 2 guard regressed" >&2 + echo "stderr captured:" >&2 + cat "$HOME/scan.err" >&2 2> /dev/null || true + false + } + # Use `run` + status check rather than bare `! grep`: bats SC2314 rejects + # a trailing `!` because earlier bats versions ignored it. `run` records + # the inverted status explicitly so the assertion is portable. + run grep -q 'unbound variable' "$HOME/scan.err" + [ "$status" -ne 0 ] +} + +@test "scan_applications surfaces inline app size before deferred refresh (#1126)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/SizedApp.app" + create_test_app_bundle "$app_path" "com.example.SizedApp" "SizedApp" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC=0 \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } +mdls() { + if [[ "${2:-}" == "kMDItemLogicalSize" ]]; then + printf '4096\n' + return 0 + fi + printf '(null)\n' +} + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|SizedApp|com.example.SizedApp|4KB|"* ]] || return 1 + [[ "$output" == *"|4" ]] +} + +@test "scan_applications falls back to bounded du when the quick mdls size probe misses" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/DuApp.app" + create_test_app_bundle "$app_path" "com.example.DuApp" "DuApp" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC=0 \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } +# Spotlight has not indexed the freshly installed app yet. +mdls() { printf '(null)\n'; } +du() { printf '2048\t/mocked\n'; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|DuApp|com.example.DuApp|2.1MB|"* ]] || return 1 + [[ "$output" == *"|2048" ]] +} + +@test "scan_applications keeps the fast path when cold rows exceed the du fallback cap" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/CapApp.app" + create_test_app_bundle "$app_path" "com.example.CapApp" "CapApp" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS=0 \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } +mdls() { printf '(null)\n'; } +du() { printf '2048\t/mocked\n'; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|CapApp|com.example.CapApp|--|"* ]] || return 1 + [[ "$output" == *"|0" ]] +} + +@test "scan_applications includes Artpaper's two-segment bundle id (#861)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/Artpaper.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + andriiliakh.Artpaper + CFBundleName + Artpaper + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|Artpaper|andriiliakh.Artpaper|"* ]] +} + +@test "scan_applications includes top-level background apps but excludes nested helpers (#970/#1265)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + onedrive_app="$apps_root/OneDrive.app" + betterdisplay_app="$apps_root/BetterDisplay.app" + nested_helper="$apps_root/Vendor/Helper.app" + create_test_app_bundle "$onedrive_app" "com.microsoft.OneDrive-mac" "OneDrive" true + create_test_app_bundle "$betterdisplay_app" "pro.betterdisplay.BetterDisplay" "BetterDisplay" true + create_test_app_bundle "$nested_helper" "com.example.Helper" "Helper" true + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$onedrive_app|OneDrive|com.microsoft.OneDrive-mac|"* ]] || return 1 + [[ "$output" == *"|$betterdisplay_app|BetterDisplay|pro.betterdisplay.BetterDisplay|"* ]] || return 1 + [[ "$output" != *"|$nested_helper|Helper|com.example.Helper|"* ]] || return 1 +} + +@test "scan_applications dedupes backup Applications clones by bundle id (#975)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + backup_root="$HOME/BackupClone/Applications" + local_app="$apps_root/Dupe.app" + backup_app="$backup_root/Dupe.app" + create_test_app_bundle "$local_app" "com.example.Dupe" "Dupe" + create_test_app_bundle "$backup_app" "com.example.Dupe" "Dupe" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" BACKUP_ROOT="$backup_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT" "$BACKUP_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$local_app|Dupe|com.example.Dupe|"* ]] || return 1 + [[ "$output" != *"|$backup_app|Dupe|com.example.Dupe|"* ]] +} + +@test "scan_applications keeps distinct installs sharing a bundle id (Xcode vs Xcode-beta)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + stable_app="$apps_root/Xcode.app" + beta_app="$apps_root/Xcode-beta.app" + create_test_app_bundle "$stable_app" "com.apple.dt.Xcode" "Xcode" + create_test_app_bundle "$beta_app" "com.apple.dt.Xcode" "Xcode" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$stable_app|"* ]] || return 1 + [[ "$output" == *"|$beta_app|"* ]] +} + +@test "scan_applications keeps unique apps from backup Applications roots (#975)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + backup_root="$HOME/BackupClone/Applications" + backup_app="$backup_root/OnlyThere.app" + create_test_app_bundle "$backup_app" "com.example.OnlyThere" "OnlyThere" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 BACKUP_ROOT="$backup_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$BACKUP_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$backup_app|OnlyThere|com.example.OnlyThere|"* ]] +} + +@test "scan_applications keeps original rows when dedupe pass fails (#975)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +scan_raw_file="$HOME/scan.raw" +printf '%s\n' "$HOME/Applications/Keep.app|Keep|com.example.Keep|1" > "$scan_raw_file" + +awk() { return 2; } + +_scan_dedupe_bundle_ids +cat "$scan_raw_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == "$HOME/Applications/Keep.app|Keep|com.example.Keep|1" ]] +} + +@test "scan_applications ignores PATH stat shims (#865)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/Plain.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Plain + CFBundleName + Plain + + +PLIST + + stub_dir="$HOME/stub-bin" + mkdir -p "$stub_dir" + cat > "$stub_dir/stat" <<'SH' +#!/bin/sh +exit 64 +SH + chmod +x "$stub_dir/stat" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + PATH="$stub_dir:$PATH" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|Plain|com.example.Plain|"* ]] +} + +@test "receipt discovery survives its first candidate on /bin/bash 3.2 (#1354)" { + # The seen_apps dedup loop ran over "${seen_apps[@]}" while the array + # was still empty for the first candidate; bash 3.2 under set -u + # aborts that expansion, the scan subshell died, and the uninstall + # spinner span forever. The candidate prefixes are fixed system paths, + # so the harness rewrites them into the test HOME (same pattern as + # sourceable_uninstall_sh) and leaves the loop under test untouched. + local mock_bin="$HOME/mock-pkgutil" + mkdir -p "$mock_bin" "$HOME/usr-local/Example.app/Contents" + cat > "$mock_bin/pkgutil" << MOCK +#!/bin/bash +case "\$1" in + --pkgs) printf 'com.example.tool\n' ;; + --files) printf '${HOME#/}/usr-local/Example.app/Contents/Info.plist\n' ;; +esac +MOCK + chmod +x "$mock_bin/pkgutil" + + # The copy must rename the load guard too: common.sh already sourced the + # real file, and the readonly guard would silently keep the original + # function, turning this test into a no-op against the wrong code. + sed -e "s|/usr/local/\*.app|$HOME/usr-local/*.app|g" \ + -e 's|MOLE_PKG_RECEIPTS_LOADED|MOLE_PKG_RECEIPTS_TEST_LOADED|g' \ + "$PROJECT_ROOT/lib/core/pkg_receipts.sh" > "$HOME/pkg_receipts_test.sh" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + PATH="$mock_bin:/usr/bin:/bin" \ + MOLE_PKG_RECEIPT_CACHE_DISABLE=1 /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$HOME/pkg_receipts_test.sh" + +rc=0 +out=$(pkg_receipt_nonstandard_app_paths) || rc=$? +printf 'RC=%s OUT=%s\n' "$rc" "$out" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" != *"unbound variable"* ]] || return 1 + [[ "$output" == *"RC=0 OUT=$HOME/usr-local/Example.app"* ]] || return 1 +} + +@test "a newly installed receipt invalidates the cached complete answer" { + # uninstall reads a complete receipt answer as proof that no other install + # owns an app's leftovers. A TTL alone cannot carry that proof: a sibling + # packaged after the cache was written stays invisible for up to an hour, + # and the shared-bundle-id guard then clears leftovers the survivor needs. + # The cache is keyed by the receipt list, so installing anything busts it. + local mock_bin="$HOME/mock-pkgutil-cache" + local pkgs_file="$HOME/receipt-pkgs.txt" + mkdir -p "$mock_bin" \ + "$HOME/usr-local/First.app/Contents" \ + "$HOME/usr-local/Second.app/Contents" + cat > "$mock_bin/pkgutil" << MOCK +#!/bin/bash +case "\$1" in + --pkgs) cat "$pkgs_file" ;; + --files) + case "\$2" in + com.example.first) printf '${HOME#/}/usr-local/First.app/Contents/Info.plist\n' ;; + com.example.second) printf '${HOME#/}/usr-local/Second.app/Contents/Info.plist\n' ;; + esac + ;; +esac +MOCK + chmod +x "$mock_bin/pkgutil" + printf 'com.example.first\n' > "$pkgs_file" + + sed -e "s|/usr/local/\*.app|$HOME/usr-local/*.app|g" \ + -e 's|MOLE_PKG_RECEIPTS_LOADED|MOLE_PKG_RECEIPTS_TEST_LOADED|g' \ + "$PROJECT_ROOT/lib/core/pkg_receipts.sh" > "$HOME/pkg_receipts_cache_test.sh" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PKGS_FILE="$pkgs_file" \ + PATH="$mock_bin:/usr/bin:/bin" \ + MOLE_PKG_RECEIPT_CACHE_FILE="$HOME/receipt-cache" \ + /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$HOME/pkg_receipts_cache_test.sh" + +first=$(pkg_receipt_nonstandard_app_paths --require-complete) +# Same receipts: the cache may answer, and must still answer correctly. +warm=$(pkg_receipt_nonstandard_app_paths --require-complete) +# A second package lands. The cached answer is now incomplete. +printf 'com.example.first\ncom.example.second\n' > "$PKGS_FILE" +after=$(pkg_receipt_nonstandard_app_paths --require-complete) +printf 'FIRST=[%s]\nWARM=[%s]\nAFTER=[%s]\n' \ + "$(printf '%s' "$first" | tr '\n' ' ')" \ + "$(printf '%s' "$warm" | tr '\n' ' ')" \ + "$(printf '%s' "$after" | tr '\n' ' ')" +EOF + + [ "$status" -eq 0 ] || { + echo "$output" + return 1 + } + [[ "$output" == *"FIRST=[$HOME/usr-local/First.app]"* ]] || return 1 + [[ "$output" == *"WARM=[$HOME/usr-local/First.app]"* ]] || return 1 + # The whole point: the newly packaged sibling must appear immediately. + [[ "$output" == *"AFTER=[$HOME/usr-local/First.app $HOME/usr-local/Second.app]"* ]] || return 1 +} diff --git a/Resources/mole/tests/uninstall_tty_foreground.bats b/Resources/mole/tests/uninstall_tty_foreground.bats new file mode 100644 index 0000000..a6fbc5b --- /dev/null +++ b/Resources/mole/tests/uninstall_tty_foreground.bats @@ -0,0 +1,34 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "tty ownership helper distinguishes foreground and background processes" { + if [[ "$(uname -s)" != "Darwin" || ! -x /usr/bin/expect || ! -x /usr/bin/perl ]]; then + skip "macOS expect/perl required" + fi + + run /usr/bin/expect "$PROJECT_ROOT/tests/uninstall_tty_foreground.exp" "$PROJECT_ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"TTY-STATE:foreground"* ]] || return 1 + [[ "$output" == *"TTY-STATE:background"* ]] +} + +@test "completed uninstall checks tty ownership before countdown input" { + run grep -n 'if ! mole_tty_is_foreground; then' "$PROJECT_ROOT/bin/uninstall.sh" + + [ "$status" -eq 0 ] +} + +@test "tty ownership helper permits non-terminal input" { + run /bin/bash -c ' + set -euo pipefail + source "$1/lib/core/timeout.sh" + mole_tty_is_foreground + ' _ "$PROJECT_ROOT" + + [ "$status" -eq 0 ] +} diff --git a/Resources/mole/tests/uninstall_tty_foreground.exp b/Resources/mole/tests/uninstall_tty_foreground.exp new file mode 100755 index 0000000..caac3b6 --- /dev/null +++ b/Resources/mole/tests/uninstall_tty_foreground.exp @@ -0,0 +1,40 @@ +#!/usr/bin/expect -f + +set timeout 4 +set project_root [lindex $argv 0] + +spawn env "PS1=PROMPT> " /bin/zsh -df +expect { + -re {PROMPT> } {} + timeout { exit 124 } + eof { exit 1 } +} + +send -- "/bin/bash '$project_root/tests/uninstall_tty_foreground_fixture.sh' '$project_root'\r" +expect { + -re {TTY-STATE:foreground\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} +expect { + -re {PROMPT> } {} + timeout { exit 124 } + eof { exit 1 } +} + +send -- "/bin/bash '$project_root/tests/uninstall_tty_foreground_fixture.sh' '$project_root' &\r" + +expect { + -re {TTY-STATE:background\r?\n} {} + timeout { exit 124 } + eof { exit 1 } +} + +expect { + -re {PROMPT> } {} + timeout { exit 124 } + eof { exit 1 } +} + +send -- "exit\r" +expect eof diff --git a/Resources/mole/tests/uninstall_tty_foreground_fixture.sh b/Resources/mole/tests/uninstall_tty_foreground_fixture.sh new file mode 100755 index 0000000..80d5b7e --- /dev/null +++ b/Resources/mole/tests/uninstall_tty_foreground_fixture.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +PROJECT_ROOT="$1" + +# shellcheck source=lib/core/timeout.sh +source "$PROJECT_ROOT/lib/core/timeout.sh" + +if mole_tty_is_foreground; then + printf 'TTY-STATE:foreground\n' +else + printf 'TTY-STATE:background\n' +fi diff --git a/Resources/mole/tests/update.bats b/Resources/mole/tests/update.bats index 19954eb..9b25c4a 100644 --- a/Resources/mole/tests/update.bats +++ b/Resources/mole/tests/update.bats @@ -1,647 +1,1777 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} - CURRENT_VERSION="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\\(.*\\)\"/\\1/')" - export CURRENT_VERSION +setup() { + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-home.XXXXXX")" + TEST_ROOT="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-case.XXXXXX")" + export HOME TEST_ROOT +} - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME +teardown() { + case "${HOME:-}" in + "${BATS_TEST_DIRNAME}/tmp-update-home."*) rm -rf "$HOME" ;; + esac + case "${TEST_ROOT:-}" in + "${BATS_TEST_DIRNAME}/tmp-update-case."*) rm -rf "$TEST_ROOT" ;; + esac +} - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-manager.XXXXXX")" - export HOME +make_manual_mole_install() { + local install_dir="$1" + local config_dir="$2" + local version="$3" + mkdir -p "$install_dir" "$config_dir/bin" + sed \ + -e "s|^SCRIPT_DIR=.*|SCRIPT_DIR=\"$config_dir\"|" \ + -e "s/^VERSION=\".*\"$/VERSION=\"$version\"/" \ + "$PROJECT_ROOT/mole" > "$install_dir/mole" + cp "$PROJECT_ROOT/mo" "$install_dir/mo" + cp -R "$PROJECT_ROOT/lib" "$config_dir/lib" + printf '#!/bin/bash\nexit 0\n' > "$config_dir/bin/analyze-go" + printf '#!/bin/bash\nexit 0\n' > "$config_dir/bin/status-go" + chmod +x "$install_dir/mole" "$install_dir/mo" "$config_dir/bin/analyze-go" "$config_dir/bin/status-go" +} - mkdir -p "${HOME}/.cache/mole" +make_homebrew_shadow() { + local bin_dir="$1" + local cellar_mole="$2" + mkdir -p "$bin_dir" "$(dirname "$cellar_mole")" + cp "$PROJECT_ROOT/mole" "$cellar_mole" + cp -R "$PROJECT_ROOT/lib" "$bin_dir/lib" + chmod +x "$cellar_mole" + ln -sf "$cellar_mole" "$bin_dir/mole" + ln -sf "$cellar_mole" "$bin_dir/mo" + + cat > "$bin_dir/brew" << 'SCRIPT' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$BREW_LOG" +case "${1:-}" in + list) + if [[ "${2:-}" == "--versions" ]]; then + printf 'mole 9.9.9\n' + fi + exit 0 + ;; + update) + exit 0 + ;; + upgrade) + if [[ -n "${BREW_UPGRADE_OUTPUT:-}" ]]; then + printf '%s\n' "$BREW_UPGRADE_OUTPUT" + fi + exit "${BREW_UPGRADE_STATUS:-0}" + ;; +esac +exit 0 +SCRIPT + chmod +x "$bin_dir/brew" } -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi +make_update_curl_stub() { + local bin_dir="$1" + local latest_version="$2" + cat > "$bin_dir/curl" << SCRIPT +#!/usr/bin/env bash +out="" +url="" +while [[ \$# -gt 0 ]]; do + case "\$1" in + -o) + out="\$2" + shift 2 + ;; + http*://*) + url="\$1" + shift + ;; + *) + shift + ;; + esac +done +[[ -n "\$url" ]] && printf '%s\n' "\$url" >> "\$CURL_URL_LOG" + +if [[ -n "\$out" ]]; then + if [[ -n "\${CURL_TRANSIENT_FAILURES:-}" && -n "\${CURL_ATTEMPT_LOG:-}" ]]; then + attempt=0 + [[ -f "\$CURL_ATTEMPT_LOG" ]] && attempt=\$(cat "\$CURL_ATTEMPT_LOG") + attempt=\$((attempt + 1)) + printf '%s\n' "\$attempt" > "\$CURL_ATTEMPT_LOG" + if [[ "\$attempt" -le "\$CURL_TRANSIENT_FAILURES" ]]; then + exit "\${CURL_TRANSIENT_STATUS:-35}" + fi + fi + cat > "\$out" <<'INSTALLER' +#!/usr/bin/env bash +printf '%s\n' "\$*" > "\$INSTALLER_ARGS_LOG" +printf '%s\n' "\${MOLE_VERSION:-}" > "\$INSTALLER_VERSION_LOG" +if [[ -n "\${INSTALLER_SUDO_AUTH_LOG:-}" ]]; then + printf '%s\n' "\${MOLE_ASSUME_SUDO_AUTH:-}" > "\$INSTALLER_SUDO_AUTH_LOG" +fi +prefix="" +config="" +while [[ \$# -gt 0 ]]; do + case "\$1" in + --prefix) prefix="\$2"; shift 2 ;; + --config) config="\$2"; shift 2 ;; + *) shift ;; + esac +done +# A handed-over sudo session means root would write regardless of the +# directory's mode bits; emulate that by unlocking the prefix the test +# made read-only to trigger the sudo path in the first place. +if [[ "\${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then + chmod u+w "\$prefix" 2>/dev/null || true +fi +mkdir -p "\$prefix" "\$config/bin" +printf '#!/bin/bash\necho "Mole version %s"\n' "\${MOLE_VERSION#V}" > "\$prefix/mole.next" +printf '#!/bin/bash\nexit 0\n' > "\$config/bin/analyze-go" +cp "\$config/bin/analyze-go" "\$config/bin/status-go" +chmod +x "\$prefix/mole.next" "\$config/bin/analyze-go" "\$config/bin/status-go" +mv "\$prefix/mole.next" "\$prefix/mole" +printf 'CHANNEL=stable\nINSTALL_RECEIPT=%s\n' "\${MOLE_INSTALL_RECEIPT:-}" > "\$config/install_channel" +rm -f "\$config/.helper_install_incomplete" +echo "Updated to latest version, \${MOLE_VERSION#V}" +INSTALLER + exit 0 +fi + +if [[ "\$url" == *"api.github.com"* ]]; then + printf '{"tag_name":"%s"}\n' "$latest_version" + exit 0 +fi + +printf 'VERSION="%s"\n' "$latest_version" +SCRIPT + chmod +x "$bin_dir/curl" } -setup() { - BREW_OUTDATED_COUNT=0 - BREW_FORMULA_OUTDATED_COUNT=0 - BREW_CASK_OUTDATED_COUNT=0 - APPSTORE_UPDATE_COUNT=0 - MACOS_UPDATE_AVAILABLE=false - MOLE_UPDATE_AVAILABLE=false +make_nightly_update_curl_stub() { + local bin_dir="$1" + local latest_commit="$2" + cat > "$bin_dir/curl" << SCRIPT +#!/usr/bin/env bash +out="" +url="" +while [[ \$# -gt 0 ]]; do + case "\$1" in + -o) + out="\$2" + shift 2 + ;; + http*://*) + url="\$1" + shift + ;; + *) + shift + ;; + esac +done +[[ -n "\$url" ]] && printf '%s\n' "\$url" >> "\$CURL_URL_LOG" + +if [[ -n "\$out" ]]; then + cat > "\$out" <<'INSTALLER' +#!/usr/bin/env bash +printf '%s\n' "\$*" > "\$INSTALLER_ARGS_LOG" +printf '%s\n' "\${MOLE_VERSION:-}" > "\$INSTALLER_VERSION_LOG" +if [[ -n "\${INSTALLER_COMMIT_LOG:-}" ]]; then + printf '%s\n' "\${MOLE_INSTALL_COMMIT:-}" > "\$INSTALLER_COMMIT_LOG" +fi +if [[ -n "\${INSTALLER_SUDO_AUTH_LOG:-}" ]]; then + printf '%s\n' "\${MOLE_ASSUME_SUDO_AUTH:-}" > "\$INSTALLER_SUDO_AUTH_LOG" +fi +prefix="" +config="" +while [[ \$# -gt 0 ]]; do + case "\$1" in + --prefix) prefix="\$2"; shift 2 ;; + --config) config="\$2"; shift 2 ;; + *) shift ;; + esac +done +mkdir -p "\$prefix" "\$config/bin" +printf '#!/bin/bash\necho "Mole version nightly"\n' > "\$prefix/mole.next" +printf '#!/bin/bash\nexit 0\n' > "\$config/bin/analyze-go" +cp "\$config/bin/analyze-go" "\$config/bin/status-go" +chmod +x "\$prefix/mole.next" "\$config/bin/analyze-go" "\$config/bin/status-go" +mv "\$prefix/mole.next" "\$prefix/mole" +printf 'CHANNEL=nightly\nCOMMIT_HASH=%s\nINSTALL_RECEIPT=%s\n' \ + "\${MOLE_INSTALL_COMMIT:0:7}" "\${MOLE_INSTALL_RECEIPT:-}" > "\$config/install_channel" +rm -f "\$config/.helper_install_incomplete" +echo "Updated to latest version, \${MOLE_VERSION#V}" +INSTALLER + exit 0 +fi - export MOCK_BIN_DIR="$BATS_TMPDIR/mole-mocks-$$" - mkdir -p "$MOCK_BIN_DIR" - export PATH="$MOCK_BIN_DIR:$PATH" +if [[ "\$url" == *"api.github.com/repos/tw93/mole/commits/main"* ]]; then + printf '{"sha":"%s"}\n' "$latest_commit" + exit 0 +fi + +exit 1 +SCRIPT + chmod +x "$bin_dir/curl" } -teardown() { - rm -rf "$MOCK_BIN_DIR" +make_nightly_api_failure_stubs() { + local bin_dir="$1" + local latest_commit="${2:-}" + cat > "$bin_dir/curl" <<'SCRIPT' +#!/usr/bin/env bash +out="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + http*://*) + url="$1" + shift + ;; + *) shift ;; + esac +done +[[ -n "$url" ]] && printf '%s\n' "$url" >> "$CURL_URL_LOG" + +if [[ -n "$out" ]]; then + cat > "$out" <<'INSTALLER' +#!/usr/bin/env bash +printf '%s\n' "$*" > "$INSTALLER_ARGS_LOG" +printf '%s\n' "${MOLE_INSTALL_COMMIT:-}" > "$INSTALLER_COMMIT_LOG" +prefix="" +config="" +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) prefix="$2"; shift 2 ;; + --config) config="$2"; shift 2 ;; + *) shift ;; + esac +done +mkdir -p "$prefix" "$config/bin" +printf '#!/bin/bash\necho "Mole version nightly"\n' > "$prefix/mole.next" +printf '#!/bin/bash\nexit 0\n' > "$config/bin/analyze-go" +cp "$config/bin/analyze-go" "$config/bin/status-go" +chmod +x "$prefix/mole.next" "$config/bin/analyze-go" "$config/bin/status-go" +mv "$prefix/mole.next" "$prefix/mole" +# Nightly success is bound to a per-attempt receipt plus the resolved commit. +# A stub that skips them is rejected by _update_verify_installed_generation. +printf 'CHANNEL=nightly\nCOMMIT_HASH=%s\nINSTALL_RECEIPT=%s\n' \ + "${MOLE_INSTALL_COMMIT:0:7}" "${MOLE_INSTALL_RECEIPT:-}" > "$config/install_channel" +rm -f "$config/.helper_install_incomplete" +echo "Updated to latest version, ${MOLE_VERSION#V}" +INSTALLER + exit 0 +fi + +exit 22 +SCRIPT + cat > "$bin_dir/git" <