diff --git a/.mole-cli-version b/.mole-cli-version index 034552a..5525f03 100644 --- a/.mole-cli-version +++ b/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.48.1 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..5525f03 100644 --- a/MoleUI/.mole-cli-version +++ b/MoleUI/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.48.1 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..fe2a7f5 --- /dev/null +++ b/Resources/mole/.claude/agents/bash32-portability-reviewer.md @@ -0,0 +1,54 @@ +--- +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 `AGENTS.md` section "Shell and Test Pitfalls (cumulative)" +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 `AGENTS.md` into a check against the + touched code. The list below is a search aid, not a replacement for that + section: + - 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..683121c --- /dev/null +++ b/Resources/mole/.claude/skills/bugs/SKILL.md @@ -0,0 +1,205 @@ +--- +name: bugs +description: "Mole's own defect catalog, mined from 656 fix commits: the eleven bug shapes that actually recur in this repo, the grep probe that surfaces each, and the guard that keeps it from coming back. Read before reviewing a diff, auditing an area, debugging a report, or accepting a contributed PR in this repo." +--- + +# Mole bug patterns + +What 2689 commits of history say about where this codebase actually breaks. Use it as the catalog; the generic sweep workflow lives in the global `bugscan` skill, root-causing a live symptom lives in `hunt`. This file is the part that is specific to Mole. + +## What the history measures + +Run these to refresh the numbers before trusting them: + +```bash +git log --oneline | wc -l # 2689 commits +git log --pretty=format:%s --grep='^fix' -i | wc -l # 656 fix-flavored +git log --pretty=format:%s --grep='^fix' -i | grep -cE '#[0-9]{3,4}' # 159 cite an issue/PR +``` + +Three facts that should shape how you work here: + +- **Most fixes were not reported by a user.** Only about a quarter of fix commits cite an issue or PR. The rest came out of review passes, release audits, and sibling sweeps after another fix. Waiting for a report is not the operating mode. +- **A fix ships with a guard.** 215 of the 300 most recent fix commits touch `tests/` or a `_test.go` in the same commit. A patch with no test is off-pattern for this repo. +- **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 eleven 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` | +| 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` | +| 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` | + +### 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`). + +```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 `clean_user_caches` simply forgot it. 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`). +- A Raycast v2 exclusion existed in one place but not in the `find` predicates that actually ran (`452e194d`). + +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. + +All 19 real `du -s` sites are wrapped today, and `tests/core_timeout.bats` pins that with a source-invariant test so a new sizing site cannot regress it. Copy that shape for any new unbounded command. + +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. + +```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`. Sixteen fixes are pure shell semantics. The cumulative list lives in `AGENTS.md` under "Shell and Test Pitfalls"; read it rather than duplicating it here. 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`. + +```bash +command grep -rn '\$\{[a-z_]*\[@\]\}' lib/ bin/ | wc -l # 317 sites; spot-check new ones +``` + +### 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 + +Fifty-plus commits. 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. + +### 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 + +A hundred commits mention spinners, frozen terminals, or blank sections. The bug is almost never the work being slow; it is the work happening 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 +``` + +There are currently **551** dead non-final `[[ ]]` assertions across 1119 tests, and **473** tests whose only bare gate is the final `[[ ]]`. Count them per test block, not per line: a line-level grep counts the final assertions too and overstates the problem. + +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 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. + +## 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 `#[cfg(test)]`, a bats fixture, 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/mole/SKILL.md b/Resources/mole/.claude/skills/mole/SKILL.md new file mode 100644 index 0000000..9b3452b --- /dev/null +++ b/Resources/mole/.claude/skills/mole/SKILL.md @@ -0,0 +1,105 @@ +--- +name: mole +description: "Drive the Mole CLI (`mo`) safely from an agent: which command answers which question, the machine-readable surfaces (analyze --json, history --json, the dry-run path list), and the rules that keep an automated run from deleting something the user wanted. Read before running any `mo` command on a user's Mac." +--- + +# 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 (`target/`, `build/`, + `dist/`, `.next/`). It deliberately does not touch anything that needs a + network to restore (`node_modules/`, `Pods/`, `venv/`), so a purge is always + recoverable with a local rebuild. `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..e03aa98 --- /dev/null +++ b/Resources/mole/.claude/skills/release-flow/SKILL.md @@ -0,0 +1,60 @@ +--- +name: release-flow +description: "Mole CLI release runbook: distribution channels, pre-flight checklist, capital-V tag publish, curated notes handoff, and release-only pitfalls. Read before any release-flavored task in this repo." +--- + +# 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. + +Shell and bats pitfalls (bash 3.2 arrays, heredoc `read -n1`, mock bypasses, CI runner quirks) stay in `AGENTS.md` under "Shell and Test Pitfalls"; re-read that section when release work touches shell code or tests. 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..82dcea6 --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/SKILL.md @@ -0,0 +1,110 @@ +--- +name: release-notes +description: Publish curated release notes for a Mole `V` tag. Encodes the compact bilingual format, the gh release edit (not create) flow, reporter/contributor thanks, and the six-reaction set. User-only because publishing is a side effect that touches the public release page. +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**. AGENTS.md cites `mo check / mo doctor` as a case where a removed command nearly shipped as a "feature". +- **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..134d303 --- /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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + 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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + 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..8cfbddc --- /dev/null +++ b/Resources/mole/AGENTS.md @@ -0,0 +1,190 @@ +# 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; 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; nine call sites use it today for paths the function itself created. 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 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. +- 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. +- **AI-generated PRs touching destructive sinks need line-by-line review.** Any PR touching `find_app_files`, `mole_delete`, `remove_file_list`, Group Container / `~/Library/Containers` traversal, `TeamID.*.prefix*` style wildcards, or any `find` recursion that ends in deletion must be audited per branch (fallback branches often regress to broad globs even when the primary branch looks correct), per protected-path coverage (does `should_protect_path` already include the new entry point?), and per user-confirmation step (does the PR silently skip an existing prompt?). When the PR is plausibly AI-generated, raise the bar: ask the contributor to narrow matchers to the exact bundle ID or app path before merge; do not approve "this looks fine." PR #874 (Group Container + diagnostic discovery) and PR #875 (interactive file selector) were merged and then reverted (`6ea1987`, `b4e9205`) precisely because a TeamID-prefix wildcard in a fallback branch matched far more than intended. Same shape, same revert risk. + +## Working Rules + +- Check `should_protect_path()` before adding cleanup behavior. +- Check app protection helpers before adding app cache, uninstall, or leftover cleanup behavior. +- **A new cleanup target needs a measured number and a stated non-target list.** Adding a path to a clean function is a permanent-deletion decision, and four `fix(clean)` commits in the window after V1.47.1 were corrections to targets that had already merged. JianyingPro shipped with `image/` and `importcache3/` in its whitelist, which hold copies of user-imported material rather than derived scratch, and `draft_info.json` is encrypted so a plaintext reference audit could not prove they were unreferenced (#1281, narrowed in `21191021`). The E5RT guard was placed on the container path only and the same deletion stayed reachable through `clean_user_essentials` (`3fda0b94`, then `7a28f375` moved it onto every cleanup path). The Zed npm cache took two passes, bundled then versioned. The only Dia row pointed at `~/Library/Caches/company.thebrowser.dia`, which on a real install holds nothing but Sentry crash state, so the feature reclaimed zero bytes until someone measured it (`c86f5a7f`). Before merging a new target, state three things: what it actually reclaimed on a real install, with the number and the app version it was measured on; which sibling directories under the same root are deliberately not targeted, and why each is user data rather than derived scratch; and whether the guard sits on every path that can reach those files or only the one the change exercised. "It looks like a cache" is not evidence. When the app's index is encrypted or opaque, a reference audit cannot prove a directory is unreferenced, so it stays out of scope. +- 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. 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. Five consecutive fixes converged on this invariant in 2026-06/07; do not add a teardown branch that bypasses the guard. +- Preference repair and optimize cleanup must skip protected and whitelisted plists before attempting removal. +- **Git worktree staleness is not decidable; do not build a feature that claims it is.** Clean the rebuildable artifacts inside a worktree, never the worktree itself. Four measured reasons, each reproducible with the commands named here. (1) Git's own expiry covers only registrations whose directory is already gone: `git worktree prune --expire` ignores live worktrees, so it reclaims zero bytes. (2) The industry heuristic (branch merged, remote branch deleted) does not apply, because agent-created worktrees are detached HEAD; 12 of 14 were on a sampled dev machine. (3) `git status --porcelain` reports a worktree holding a gitignored `.env` as clean, so a "clean plus old" rule deletes the only copy of a secret; only `--ignored` reveals it, and nothing can separate a `.env` from a `node_modules` automatically except an explicit whitelist check against `MOLE_PURGE_TARGETS`. (4) `git log --branches --not --remotes` misses unpushed commits sitting on a detached HEAD; the per-worktree form is `git log HEAD --not --remotes`. Sampling a heavy agent user found 215.6G of worktrees of which 168.7G (78%) was rebuildable artifacts and only 46.9G was checkout content, so the artifact side carries the value and needs no staleness proof at all. If a worktree state column is ever added, report blockers only (dirty, unpushed, locked, ignored entries outside the purge whitelist) and never emit a "safe to delete" verdict: the negative is provable, the positive is not. +- Purge discovery cannot see dot-directory containers. `discover_project_dirs` globs `"$HOME"/*/`, which skips dot directories, and `is_project_container` returns 1 for any basename starting with a dot, so a container like `~/.codex/worktrees` must be listed explicitly in `MOLE_PURGE_DEFAULT_SEARCH_PATHS`. Add exact container paths only; widening discovery to dot directories in general would pull in `~/Library` and similar. The scan layer needs no change: fd runs with `--hidden` and the find branch does not prune dot directories, so `/.claude/worktrees` is already reachable once its parent container is discovered. Separately, `is_project_container "$dir" 2` probes only two levels, so a container whose project roots sit deeper (Conductor-style `~/conductor///`) is still missed by design. +- **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. +- **Test-orphan pattern: grep the whole repo including top-level entry scripts before declaring a function dead.** Mole has a recurring shape where a helper is defined in `lib/core/base.sh` (or similar core lib), has full bats coverage in `tests/`, and is referenced by zero production callers. Past instances, all since removed from the repo: `is_sip_enabled`, `is_darwin_ge`, `get_invoking_user`, `get_brand_name`, `get_mole_temp_root`, `scan_external_volumes`, `clean_dev_editors`, `perform_updates`, `format_brew_update_label`, `brew_has_outdated`. A "zero callers" verdict requires three checks: (1) grep across `lib`, `bin`, `cmd`, `scripts`, `tests`, AND the top-level entry (`mole` shim, install/uninstall scripts), not just core lib dirs; (2) check for string-built call sites (`eval`, `declare -f`, `compgen`); (3) re-grep after removal to confirm nothing was hand-wired. When deleting a write-only helper, also trace every variable it wrote and every config it read; the entire data path may be orphaned. Sub-agent "dead code" reports are starting points, not verdicts. + +## 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. +- `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/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. +- **Remote diagnostics for unreproducible reports**: when an issue needs runtime evidence from the reporter's machine, ask them to run `curl -fsSL 'https://mole.fit/downloads/Mole-Diagnose.command' | bash` and email the resulting `Mole-Diagnose-*.zip` from their Desktop to the support address. The script is read-only (process samples, recent logs, crash reports) and lives in the `mole-mac` repo at `site/downloads/Mole-Diagnose.command`; it targets the Mole Mac app, so for CLI-only issues prefer asking for the relevant `mo` command output or `mo status` JSON instead. The zip contains local paths and logs: have reporters email it, never attach it to a public issue. +- **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. + +## Shell and Test Pitfalls (cumulative) + +These are real bugs hit on this codebase. Each one cost time. Re-read before touching the same area. + +This list is the shell mechanics already hit. The defect *classes* they belong to, the grep probe that surfaces each, and the guard that pins each one live in `.claude/skills/bugs/SKILL.md` (`/bugs`), mined from this repo's fix history; read it before reviewing a diff, auditing an area, debugging a report, or accepting a contributed PR. + +- **`BASH_SOURCE` / `$0` change meaning when a function moves files**: they name the file the code *lives in*, so a pure copy-paste extraction is not behavior-preserving. `resolve_mole_source_path` read `${BASH_SOURCE[0]:-$0}` to find the invoked `mole`; once it moved into the sourced `lib/manage/update.sh`, it resolved to that lib file, so self-update would have reinstalled over the wrong target. Fix: `mole` captures `MOLE_ENTRY_SCRIPT="${BASH_SOURCE[0]}"` before sourcing anything, and the update flow reads that. Caught by `tests/update.bats` ("targets the invoked manual install"). Before extracting any function, grep it for `BASH_SOURCE`, `$0`, and `FUNCNAME`. +- **Every `du -s` must run under `run_with_timeout`**: `du` has no internal bound, and callers wrap it in a command substitution that simply waits, so one stalled SMB/FUSE mount or one enormous tree wedges the whole scan. Use `MOLE_TIMEOUT_DISK_VERIFY_SEC`. A source-invariant test in `tests/core_timeout.bats` greps `lib/` and `bin/` and fails on any unbounded `du -s`, so a new sizing call site cannot regress this silently. +- **bash 3.2 nounset on empty arrays**: macOS default bash raises "unbound variable" when expanding `"${arr[@]}"` on an empty array under `set -u`. Always guard with `[[ ${#arr[@]} -gt 0 ]]` before expansion. Hit for `DEFAULT_OPTIMIZE_WHITELIST_PATTERNS`, declared in `lib/core/base.sh` and consumed in `lib/manage/whitelist.sh`, which already guards it. +- **`fn || handler` disables errexit inside `fn` for its entire body**: same bash rule as if-guards, but on a whole function it silently converts every unchecked failure inside into a no-op. `install_files || {...}` in the update flow let eight consecutive uncached `sudo -n` copies fail while the installer still reported success, leaving `/usr/local/bin/mole` at 1.45.0 with a 1.47.0 payload ("Updated to latest version, 1.45.0"). Safety-critical steps inside any function that a caller might invoke with `||` or `if` must use explicit `if ! cmd; then return 1; fi` checks, never rely on `set -e`; and installers must verify the installed version matches the just-installed source before claiming success. Fixed in `install.sh` for V1.47.1; regression tests in `tests/install_checksum.bats` reproduce the exact `|| caller` shape. +- **`[[ -n "$var" ]] && cmd` returns 1 when var is empty**: under `set -e` (or any caller that reads the exit code), this short-circuit form propagates exit 1 from the test, even though the intent was "skip silently". If the surrounding compound command relies on exit 0 (for example a `{...} > file ||` redirect), the optional cmd silently breaks the success path. Use plain `if/fi` whenever the conditional sits inside an exit-code-sensitive block. Hit in `install.sh` `write_install_channel_metadata` (stable channel always tripped the warning). +- **bats heredoc steals bytes from `read -n1`**: when the inner script runs via `bash <<'EOF' ... EOF`, a `read -r -s -n1` in the function under test consumes the next byte from the heredoc source itself, corrupting the next command (e.g. `echo` becomes `cho`, exit 127). Fix is to redirect the function's stdin from `/dev/null` inside the test. +- **macOS `script(1)` rejects socket-backed stdin**: PTY test helpers must redirect the wrapper's stdin from `/dev/null`; otherwise `script` exits before starting the child with `tcgetattr/ioctl: Operation not supported on socket`. +- **`run_with_timeout` execs the binary, bypassing bash function mocks**: gtimeout/timeout exec the real PATH binary, so a shell-function override of (e.g.) `osascript` is invisible. Tests must use a PATH stub directory and prepend it to `PATH`, not function shadowing. +- **CI runners lack `/Library/PrivilegedHelperTools`**: `clean_orphaned_system_services` guards that scan with `[[ -d /Library/PrivilegedHelperTools ]]`, which is false on GitHub macOS runners, so a test that feeds an orphan helper through that path finds zero orphans in CI even though it passes locally (the dir exists on dev machines). Route orphan-service tests through `/Library/LaunchDaemons`, which always exists on macOS. Hit fixing #1082. +- **A test can pass vacuously when the function early-returns**: `clean_apps.bats` `setup_file` exports `MOLE_TEST_MODE=1`, and `clean_orphaned_system_services` returns immediately under that flag, leaving `$output` empty. A test whose *last* assertion is a `[[ "$output" != *"..."* ]]` (true on empty) then passes green while its real `==` assertion in the middle is silently swallowed (same shape as #886). Always end each assertion with `|| return 1`, and override `MOLE_TEST_MODE=0` (plus a `sudo -n true` mock) when the test needs the function body to actually run. The bracket form decides whether the failure is swallowed: a non-final `[[ ]]` never fails the test, a non-final `[ ]` does, which is why `[ "$status" -eq 0 ]` keeps gating while every `[[ "$output" == ... ]]` above the last one is dead weight. This holds one layer down too: macOS `/bin/bash` 3.2.57 does not abort on a failing `[[ ]]` even under `set -euo pipefail`, so assertions in a heredoc'd inner script need `|| exit 1` (not `|| return 1`, which would run outside a function). Confirm with a two-line repro before trusting either claim: `@test "a" { [[ 1 -eq 2 ]]; [[ 1 -eq 1 ]]; }` passes, the same test with `[ ]` fails. The guard cannot see the sibling defect, a fixture that never reaches the code under test: output stays empty and every "must not appear" check is trivially true. Detect that class by asserting `[[ -n "$output" ]]` in place of the final assertion, or with a `teardown` that records empty `$output` across the suite. +- **BSD grep has no GNU `-Z`/`--null` output mode**: on stock macOS `grep -Z` means `--decompress` (ugrep aliases treat it as fuzzy matching), so `grep -rlZ ... | while read -d ''` consumes nothing and the loop is silently dead. Enumerate with `find ... -print0` and probe each file with `grep -qF` instead. The path-referenced LaunchAgent unload in `stop_launch_services` shipped dead this way from #816 until 2026-07. +- **PlistBuddy announces file creation on stdout**: `/usr/libexec/PlistBuddy -c "Add ..."` against a missing file prints `File Doesn't Exist, Will Create: ` to stdout, which lands in bats `$output` and trips negative `[[ "$output" != *""* ]]` assertions. Redirect stdout too (`> /dev/null 2>&1`) when creating plist fixtures. +- **macOS 14's /bin/bash fires errexit through if-guarded mock functions**: the macos-14 runner's bash 3.2.57 build kills a `set -e` script when an exported shell-function `sudo` mock returns nonzero inside an `if fn; then` condition with a `2> /dev/null` redirect; the same bash version on macOS 15+ and dev machines does not, so the failure reproduces on no local machine. Symptom: a bats inner script exits 1 with empty output at the first failing mock probe. Fix pattern: disable errexit before the first sudo probe inside the helper (see `safe_sudo_find_delete`) and restore it before each validation-gate return. When a test fails only on one runner image, make the test print exit status, captured output, and a mock call trace on failure instead of a bare rc assertion; that evidence trail is how this one was pinned. Hit fixing test 31 of `core_safe_functions.bats`, 2026-07. 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..bb2b7ba 100644 --- a/Resources/mole/CONTRIBUTORS.svg +++ b/Resources/mole/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -8,602 +8,1295 @@ - + tw93 - + - - - JackPhallen + + + sebastianbreguel - + - - - bhadraagada + + + JackPhallen - + - - - iamxorum + + + bhadraagada - + - - - dwjoss + + + M-Hassan-Raza - + - - - alexandear + + + yuzeguitarist - + - - - imnotnoahhh + + + dwjoss - + - - - amanthanvi + + + iamxorum - + - - - Angelk90 + + + alexandear - + - - - Sizk + + + noah-qin - + - - - rubnogueira + + + xronocode - + - - - biplavbarua + + + LuisMIguelFurlanettoSousa - + - - - bsisduck + + + amanthanvi - + - - - spider-yamet + + + hhh2210 - + - - - jimmystridh + + + yhy0 - + - - - fte-jjmartres + + + PremPrakashCodes - + - - - Else00 + + + Angelk90 - + - - - carolyn-sun + + + OWConnoi - + - - - ndbroadbent + + + RajvardhanPatil07 - + - - - MohammedTarigg + + + guobosheng - + - - - onurtashan + + + Sizk - + - - - ppauel + + + yetval - + - - - shakeelmohamed + + + fabdelgado - + - - - Harsh-Kapoorr + + + biplavbarua - + - - - thijsvanhal + + + bevanjkay - + - - - TomP0 + + + rubnogueira - + - - - yuzeguitarist + + + italojs - + - - - bikraj2 + + + gloiiire - + - - - bunizao + + + bsisduck - + - - - rans0 + + + philippb - + - - - frozturk + + + MohammedTarigg - + - - - huyixi + + + spider-yamet - + - - - purofle + + + jimmystridh - + - - - yamamel + + + fte-jjmartres - + - - - NanmiCoder + + + Else00 - + - - - KoukeNeko + + + byronwang2005 - + - - - andmev + + + carolyn-sun - + - - - uluumbch + + + alxhill - + - - - ClathW + + + Puhavik - + - - - Copper-Eye + + + Carsonn-Liu - + - - - DimitarNestorov + + + zeldrisho - + - - - gokulp01 + + + IMZihad21 - + - - - Hensell + + + abcreativ - + - - - jalen0x + + + bikraj2 - + - - - kowyo + + + bunizao - + - - - kwakubiney + + + chaochaoweb3 - + - - - LmanTW + + + guangjun-super - + - - - injuxtice + + + KoukeNeko - + - - - khipu-luke + + + unnipv - + - - - mariovtor + + + tylergibbs1 - + - - - anonymort + + + TyceHerrman - + - - - Schlauer-Hax + + + TomP0 - + - - - mickyyy68 + + + thijsvanhal - + - - - EastSun5566 - + + + Harsh-Kapoorr + + + + + + + + + shakeelmohamed + + + + + + + + + + + Shahfarzane + + + + + + + + + + + imrajyavardhan12 + + + + + + + + + + + pranahonk + + + + + + + + + + + NanmiCoder + + + + + + + + + + + Parsifa1 + + + + + + + + + + + z0ffy + + + + + + + + + + + YeJe-cpu + + + + + + + + + + + yamamel + + + + + + + + + + + sownhere + + + + + + + + + + + singkia + + + + + + + + + + + sibisai + + + + + + + + + + + purofle + + + + + + + + + + + mehmetcansahin + + + + + + + + + + + iwen-conf + + + + + + + + + + + iuhoay + + + + + + + + + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + fishwww-ww + + + + + + + + + + + honghoker + + + + + + + + + + + rans0 + + + + + + + + + + + corevibe555 + + + + + + + + + + + connorscott1 + + + + + + + + + + + clancyclaw + + + + + + + + + + + ppauel + + + + + + + + + + + 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 + + + + + + + + + + + khipu-luke + + + + + + + + + + + injuxtice + + + + + + + + + + + LmanTW + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + khisby + + + + + + + + + + + Jiaweeee + + + + + + + + + + + jason-costello + + + + + + + + + + + jalen0x + + \ 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..9340ac7 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 @@ -122,6 +145,8 @@ Uninstalling: Photoshop 2024 - Logs, WebKit storage, Cookies - Extensions, Plugins, Launch daemons +Note: On macOS 15 and later, Local Network permission entries can outlive app removal. Mole warns when an uninstalled app declares Local Network usage, but it does not auto-reset `/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist` because that reset is global and requires Recovery mode. + ==================================================================== Space freed: 12.8GB ==================================================================== @@ -145,25 +170,28 @@ System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d System optimization completed ==================================================================== -Use `mo optimize --whitelist` to exclude specific optimizations. +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. ``` ### 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..58fd81e 100644 --- a/Resources/mole/SECURITY_AUDIT.md +++ b/Resources/mole/SECURITY_AUDIT.md @@ -1,18 +1,63 @@ -# 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.48.1 on 2026-07-26. 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 +- stale LaunchServices cleanup in `mo clean` (`lib/clean/launch_services.sh`) only unregisters records with `lsregister -u` and never deletes files; it acts on an entry only when the dump marks it `Bundle node not found on disk` and the referenced `.app` is confirmed absent (`[[ ! -e ]]`), rejects `/System`, `/Library/Apple`, `..` traversal, and newline/carriage-return paths, honors dry-run, is bounded by `MOLE_LAUNCH_SERVICES_STALE_LIMIT` (default 50), and never performs a global `lsregister -r -f` rebuild +- 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 +71,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 +82,272 @@ 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 +- LaunchServices stale registration cleanup in clean: dump bounded to 10s, each unregister bounded to 3s +- 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 +- Dock Refresh no longer deletes any `*.db` under `~/Library/Application Support/Dock`. The previous implementation wiped `desktoppicture.db` and reset the user's wallpaper (#995); refreshing the Dock now relies on `killall` plus touching the plist instead. +- 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/clean_launch_services.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`) +- stale LaunchServices unregister limited to missing apps, dry-run preview, fail-closed on dump failure, and a path-safety filter that rejects live, system, traversal, and injection paths (`tests/clean_launch_services.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`) + +## 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..53da307 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..5d2cc2d 100755 --- a/Resources/mole/bin/clean.sh +++ b/Resources/mole/bin/clean.sh @@ -18,25 +18,56 @@ source "$SCRIPT_DIR/../lib/clean/apps.sh" source "$SCRIPT_DIR/../lib/clean/dev.sh" source "$SCRIPT_DIR/../lib/clean/app_caches.sh" source "$SCRIPT_DIR/../lib/clean/hints.sh" +source "$SCRIPT_DIR/../lib/clean/launch_services.sh" source "$SCRIPT_DIR/../lib/clean/system.sh" source "$SCRIPT_DIR/../lib/clean/user.sh" SYSTEM_CLEAN=false DRY_RUN=false 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 +75,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 +118,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,7 +129,7 @@ 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[@]}") @@ -106,6 +137,49 @@ expand_whitelist_patterns() { } expand_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 if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then @@ -119,6 +193,7 @@ fi total_items=0 TRACK_SECTION=0 SECTION_ACTIVITY=0 +SECTION_START_SIZE_KB=0 files_cleaned=0 total_size_cleaned=0 whitelist_skipped_count=0 @@ -129,6 +204,9 @@ 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 +SECTION_START_UNKNOWN_COUNT=0 # shellcheck disable=SC2329 note_activity() { @@ -137,6 +215,338 @@ note_activity() { fi } +# 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 + + 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" +} + +# Prints: known_size_kb item_count category_count unknown_size_count. +dry_run_ledger_stats() { + local known_size_kb=0 + local item_count=0 + local category_count=0 + local unknown_size_count=0 + local identity size_kb count size_known section path + local -a seen_sections=() + + if [[ -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" || ! -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then + printf '0 0 0 0\n' + return 0 + fi + + 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 + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + [[ "$count" =~ ^[0-9]+$ ]] || count=1 + known_size_kb=$((known_size_kb + size_kb)) + item_count=$((item_count + count)) + [[ "$size_known" == "true" ]] || unknown_size_count=$((unknown_size_count + 1)) + + if [[ ${#seen_sections[@]} -eq 0 ]] || ! mole_identity_in_list "$section" "${seen_sections[@]}"; then + seen_sections+=("$section") + category_count=$((category_count + 1)) + fi + done < <(emit_deduplicated_dry_run_ledger) + + printf '%s %s %s %s\n' "$known_size_kb" "$item_count" "$category_count" "$unknown_size_count" +} + +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 +571,51 @@ 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 + if [[ "$DRY_RUN" == "true" && -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" ]]; then + local _start_items _start_categories + read -r SECTION_START_SIZE_KB _start_items _start_categories SECTION_START_UNKNOWN_COUNT \ + < <(dry_run_ledger_stats) + else + SECTION_START_SIZE_KB="${total_size_cleaned:-0}" + SECTION_START_UNKNOWN_COUNT=0 + fi 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 +626,44 @@ 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 + # Report-only sections (Large files, System Data clues, Project + # artifacts, hint-only App leftovers) mark activity without reclaiming + # anything, so a footer there would read "Category total · 0B" directly + # under a row quoting a 100GB directory. Only sections that actually + # moved the tracked total get one. + local section_size_kb=0 + local section_unknown_count=0 + if [[ "$DRY_RUN" == "true" && -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" ]]; then + local current_size_kb _current_items _current_categories current_unknown_count + read -r current_size_kb _current_items _current_categories current_unknown_count \ + < <(dry_run_ledger_stats) + section_size_kb=$((current_size_kb - SECTION_START_SIZE_KB)) + section_unknown_count=$((current_unknown_count - SECTION_START_UNKNOWN_COUNT)) + else + section_size_kb=$((total_size_cleaned - SECTION_START_SIZE_KB)) + fi + + if [[ "$section_unknown_count" -gt 0 ]]; then + if [[ "$section_size_kb" -gt 0 ]]; then + echo -e " ${GRAY}${ICON_SUBLIST} Category total${NC} · at least $(colorize_human_size "$(bytes_to_human_kb "$section_size_kb")")" + else + echo -e " ${GRAY}${ICON_SUBLIST} Category total${NC} · size unknown" + fi + elif [[ "$section_size_kb" -gt 0 ]]; then + echo -e " ${GRAY}${ICON_SUBLIST} Category total${NC} · $(colorize_human_size "$(bytes_to_human_kb "$section_size_kb")")" + fi fi TRACK_SECTION=0 } @@ -187,11 +671,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 +768,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 +802,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 +816,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 @@ -328,7 +901,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 @@ -358,10 +931,13 @@ safe_clean() { 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 +956,27 @@ 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 + if [[ "$DRY_RUN" == "true" ]]; then + register_dry_run_cleanup_target "$path" || continue + fi + existing_paths+=("$path") + fi done - if [[ "$show_scan_feedback" == "true" ]]; then + 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,12 +1007,17 @@ safe_clean() { fi if [[ ${#existing_paths[@]} -eq 0 ]]; then + # 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 return 0 fi if [[ ${#existing_paths[@]} -gt 1 ]]; then local -a normalized_paths=() - while IFS= read -r path; do + while IFS= read -r -d '' path; do [[ -n "$path" ]] && normalized_paths+=("$path") done < <(normalize_paths_for_cleanup "${existing_paths[@]}") @@ -434,11 +1032,14 @@ safe_clean() { 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 +1054,39 @@ 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 + 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 < <(stat -f%z "${existing_paths[@]}" 2> /dev/null) + 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 + _dsize=$(get_cleanup_path_size_kb "${existing_paths[$idx]}") + [[ "$_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 @@ -501,7 +1110,7 @@ safe_clean() { echo "0 0" > "$tmp_file" fi mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true - ) & + ) < /dev/null & pids+=($!) idx=$((idx + 1)) @@ -529,10 +1138,15 @@ safe_clean() { fi 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 @@ -543,7 +1157,7 @@ safe_clean() { read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if safe_remove "$path" true; then + if safe_remove "$path" true "$size"; then removed=1 fi else @@ -566,10 +1180,17 @@ 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 @@ -581,7 +1202,7 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if safe_remove "$path" true; then + if safe_remove "$path" true "$size_kb"; then removed=1 fi else @@ -602,9 +1223,11 @@ 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 @@ -624,16 +1247,20 @@ 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) + 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}" idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then @@ -646,51 +1273,15 @@ safe_clean() { 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" + [[ "$size" =~ ^[0-9]+$ ]] || size=0 + append_dry_run_cleanup_target "$path" "$size" 1 true 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 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)) @@ -705,11 +1296,26 @@ start_cleanup() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="clean" 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 +1327,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 +1347,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 +1370,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 +1408,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 +1434,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 @@ -914,102 +1497,131 @@ 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 + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + start_section "External volume" + clean_external_volume_target "$EXTERNAL_VOLUME_TARGET" end_section - fi + else + # ===== 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 + 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 - # ===== 2. User essentials ===== - start_section "User essentials" - clean_user_essentials - clean_finder_metadata - scan_external_volumes - end_section + # ===== 2. User essentials ===== + start_section "User essentials" + clean_user_essentials + clean_finder_metadata + end_section - # ===== 3. App caches (merged sandboxed and standard app caches) ===== - start_section "App caches" - clean_app_caches - end_section + # ===== 3. App caches (merged sandboxed and standard app caches) ===== + start_section "App caches" + clean_app_caches + end_section - # ===== 4. Browsers ===== - start_section "Browsers" - clean_browsers - end_section + # ===== 4. Browsers ===== + start_section "Browsers" + clean_browsers + end_section - # ===== 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 + # ===== 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 -eq 130 ]]; then + return 130 + 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" + 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. App leftovers ===== + start_section "App leftovers" + clean_orphaned_app_data + clean_orphaned_system_services + clean_orphaned_container_stubs + clean_stale_launch_services_registrations + show_user_launch_agent_hint_notice + show_orphan_dotdir_hint_notice + end_section + + # ===== 11. Apple Silicon ===== + clean_apple_silicon_caches + + # ===== 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" + clean_cached_device_firmware + 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 + fi # ===== Final summary ===== + flush_idle_section_slot echo "" + if [[ "$DRY_RUN" == "true" ]]; then + render_clean_preview_from_ledger + fi + local summary_heading="" local summary_status="success" if [[ "$DRY_RUN" == "true" ]]; then @@ -1020,12 +1632,40 @@ perform_cleanup() { local -a summary_details=() - 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 +1675,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 +1693,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,9 +1707,10 @@ 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" @@ -1080,7 +1719,22 @@ perform_cleanup() { 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 + + 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 [[ $had_errexit -eq 1 ]]; then @@ -1094,9 +1748,23 @@ perform_cleanup() { printf '\n' } +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,12 +1776,36 @@ 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 @@ -1123,4 +1815,6 @@ main() { exit 0 } -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..b741662 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' } @@ -708,6 +842,10 @@ main() { 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..3e42151 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,74 +15,52 @@ 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 + if ((safe_count == 0)); then return fi local summary_title local -a summary_details=() - local total_applied=$((safe_count + confirm_count)) + local total_applied=$safe_count 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+=("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=() @@ -120,16 +98,6 @@ show_optimization_summary() { summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, all services tuned") 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") - fi summary_details+=("System fully optimized") fi @@ -139,12 +107,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 +121,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,185 +144,6 @@ 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() { stop_inline_spinner 2> /dev/null || true stop_sudo_session @@ -388,6 +178,11 @@ main() { manage_whitelist "optimize" exit 0 ;; + *) + echo "Unknown optimize option: $arg" + echo "Use 'mo optimize --help' for supported options." + exit 1 + ;; esac done @@ -406,12 +201,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 +220,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 +234,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,59 +246,41 @@ 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') + show_system_health "$health_json" - local item="${name}|${desc}|${action}|${path}" - - 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 - - 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 - fi - - local safe_count=${#safe_items[@]} - local confirm_count=${#confirm_items[@]} - - run_system_checks + local safe_count=0 + 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]} + safe_count=$((safe_count + 1)) + if command -v is_whitelisted > /dev/null && is_whitelisted "$action"; then + opt_msg "Skipped (whitelisted): $health_name" + continue + fi + announce_action "$health_name" + execute_optimization "$action" + done export OPTIMIZE_SAFE_COUNT=$safe_count - export OPTIMIZE_CONFIRM_COUNT=$confirm_count + export OPTIMIZE_CONFIRM_COUNT=0 show_optimization_summary 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..baccbf8 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..abfb923 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,291 @@ 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 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") + printf '%s|%s\n' "$pkg_app_path" "${app_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 + printf '%s|%s\n' "$app_path" "${app_mtime:-0}" + done < <(uninstall_print_app_paths_with_mtime "$app_dir") + done < <(uninstall_print_app_search_dirs) + } | sort -u +} + +# 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 "$app_path") + local app_mtime + app_mtime=$(get_file_mtime "$pkg_app_path") - 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" + printf "%s|%s|%s\n" "$pkg_app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" + done < <(pkg_receipt_nonstandard_app_paths) - # 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) + for app_dir in "${app_dirs[@]}"; do + if [[ ! -d "$app_dir" ]]; then continue; fi + + 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}" + + uninstall_should_skip_app_path "$app_path" && continue + + 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 +686,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 +698,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 +845,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 +982,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 +995,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 +1005,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 +1182,28 @@ 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 +} + +# 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 +1217,226 @@ 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=() + + 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 + return 1 + fi + if [[ ! -f "$apps_file" ]]; then + return 1 + fi + if ! load_applications "$apps_file"; then + rm -f "$apps_file" + 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 +1449,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,31 +1467,118 @@ 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 + show_cursor + return 1 + fi + if [[ ! -f "$apps_file" ]]; then + show_cursor + return 1 + fi + if ! load_applications "$apps_file"; then + rm -f "$apps_file" + show_cursor + 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 [[ -n "$current_inventory_fingerprint" && "$current_inventory_fingerprint" == "$cached_inventory_fingerprint" ]]; then + apps_file="$cached_apps_file" + reused_app_cache=true + fi + fi + + 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 + + if ! apps_file=$(scan_applications); then + return 1 + fi + + cached_apps_file="$apps_file" + cached_inventory_fingerprint=$(uninstall_app_inventory_fingerprint 2> /dev/null || echo "") fi if [[ ! -f "$apps_file" ]]; then @@ -871,30 +1587,36 @@ main() { if ! load_applications "$apps_file"; then rm -f "$apps_file" + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" 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 + stop_uninstall_interactive_screen show_cursor - clear_screen - printf '\033[2J\033[H' >&2 rm -f "$apps_file" + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" return 0 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 +1684,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 +1704,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 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..1365a2a 100644 --- a/Resources/mole/cmd/analyze/analyze_test.go +++ b/Resources/mole/cmd/analyze/analyze_test.go @@ -1,220 +1,2359 @@ +//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 TestEnsureOverviewSnapshotCacheDropsExpiredEntries(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)}, + "/expired": {Size: 1 << 20, Updated: time.Now().Add(-overviewCacheTTL - time.Hour)}, + "/empty": {Size: 0, 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"] + _, hasFresh := overviewSnapshotCache["/fresh"] + overviewSnapshotMu.Unlock() + + if hasExpired || hasEmpty { + t.Fatalf("expected expired and empty 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), + } + } + 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 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 +2839,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..7f71e17 100644 --- a/Resources/mole/cmd/analyze/cache.go +++ b/Resources/mole/cmd/analyze/cache.go @@ -1,19 +1,32 @@ +//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`. +const cacheSchemaVersion = 2 + type overviewSizeSnapshot struct { Size int64 `json:"size"` Updated time.Time `json:"updated"` @@ -36,13 +49,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 +111,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.Size <= 0 || now.Sub(snapshot.Updated) >= overviewCacheTTL { + delete(snapshots, path) + } + } overviewSnapshotCache = snapshots overviewSnapshotLoaded = true return nil @@ -122,27 +166,83 @@ 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(), } + 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 +260,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 +318,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 +565,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 +587,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 +625,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 +641,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 +656,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 +744,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 +775,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..0016cdb 100644 --- a/Resources/mole/cmd/analyze/constants.go +++ b/Resources/mole/cmd/analyze/constants.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import "time" @@ -9,6 +11,7 @@ const ( 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 +22,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, 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..417a639 100644 --- a/Resources/mole/cmd/analyze/format.go +++ b/Resources/mole/cmd/analyze/format.go @@ -1,10 +1,15 @@ +//go:build darwin + package main import ( "fmt" "os" + "slices" "strings" "time" + + "github.com/tw93/mole/internal/units" ) func displayPath(path string) string { @@ -54,8 +59,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,25 +82,25 @@ 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) @@ -110,6 +115,9 @@ func coloredProgressBar(value, maxValue int64, percent float64) string { } else { barColor = colorGreen } + if filled == 0 { + return barColor + "▏" + strings.Repeat(" ", barWidth-1) + colorReset + } var bar strings.Builder bar.WriteString(barColor) @@ -128,7 +136,7 @@ func coloredProgressBar(value, maxValue int64, percent float64) string { } } } else { - bar.WriteString(colorGray + "░" + barColor) + bar.WriteString(" ") } } return bar.String() + colorReset diff --git a/Resources/mole/cmd/analyze/format_test.go b/Resources/mole/cmd/analyze/format_test.go index 65a8333..0927c67 100644 --- a/Resources/mole/cmd/analyze/format_test.go +++ b/Resources/mole/cmd/analyze/format_test.go @@ -1,9 +1,13 @@ +//go:build darwin + package main import ( "strings" "testing" "time" + + "github.com/charmbracelet/lipgloss" ) func TestRuneWidth(t *testing.T) { @@ -20,7 +24,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 +59,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 +93,64 @@ 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) + } + }) + } +} + +func TestColoredProgressBarKeepsFixedWidthWithoutGrayTrack(t *testing.T) { + tests := []struct { + name string + value int64 + maxValue int64 + percent float64 + wantSmall bool + }{ + {"empty", 0, 100, 0, false}, + {"tiny nonzero", 1, 1000, 0.01, true}, + {"partial", 25, 100, 25, false}, + {"full", 100, 100, 100, false}, + } + + 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, "░") { + t.Fatalf("progress bar should not render a gray track: %q", got) + } + if tt.wantSmall && !strings.Contains(got, "▏") { + t.Fatalf("tiny nonzero progress should render a thin marker: %q", got) + } + }) + } +} + 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..c7b4d71 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() } @@ -99,7 +104,7 @@ func (m model) View() string { colorGreen, humanizeBytes(bytesScanned), colorReset) if m.currentPath != nil { - currentPath := m.currentPath.Load().(string) + currentPath, _ := m.currentPath.Load().(string) if currentPath != "" { shortPath := displayPath(currentPath) shortPath = truncateMiddle(shortPath, 50) @@ -107,22 +112,37 @@ func (m model) View() string { } } - 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 +176,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 +212,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 +261,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 +274,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 +320,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 +343,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 +422,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..9a99cd6 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) + + var rendered []string + for i, c := range cards { + if i > 0 { + rendered = append(rendered, "") + } + rendered = append(rendered, renderCard(c, cardWidth, 0)) + } + cardContent = lipgloss.JoinVertical(lipgloss.Left, rendered...) + } else { + cardWidth := max(24, termWidth/2-4) + cards := buildCards(m.metrics, cardWidth, cpuCores) + 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..d4df479 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,7 @@ type DiskStatus struct { UsedPercent float64 `json:"used_percent"` Fstype string `json:"fstype"` External bool `json:"external"` + SmartStatus string `json:"smart_status"` } type NetworkStatus struct { @@ -176,6 +190,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 +222,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 +585,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..2880972 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,38 @@ 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 + } + if 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 +170,158 @@ 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 mirrors the algorithm used by the Mole Mac app +// (SystemMetricsCollector.batteryHealthPercent): NominalChargeCapacity is +// preferred over AppleRawMaxCapacity, the ratio is rounded half-away-from-zero, +// and the result is clamped to [0, 100]. +func batteryHealthPercent(design, nominal, rawMax int) int { + if design <= 0 { + return 0 + } + capacity := nominal + if capacity == 0 { + capacity = rawMax + } + 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 +364,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..4aa0c53 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_battery_test.go @@ -0,0 +1,195 @@ +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 TestMergeBatteryHealthDataPrefersAppleSmartBatteryCapacity(t *testing.T) { + health, cycles, capacity := mergeBatteryHealthData("Good", 12, 97, 13, 100) + + 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 100", capacity) + } +} + +func TestParseAppleSmartBatteryHealthPrefersNominalCharge(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 != 83 { + t.Fatalf("capacity = %d, want 83", 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..c33046d 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,46 @@ 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 + if useCorrections && runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" { + used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used) + } + 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, }) 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 +143,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 +206,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 +216,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 +245,215 @@ 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 !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 !externalFound { + return metadata, errors.New("diskutil info missing Internal field") + } + 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 and percent for an +// APFS volume, accounting 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 uint64) (used uint64, usedPercent float64) { + // 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 } - if strings.HasPrefix(trim, "Device Location:") { - found = true - external = strings.Contains(trim, "External") + } + + // 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 + } } } - if !found { - return false, errors.New("diskutil info missing Internal field") + + // Tier 3: fall back to raw gopsutil values. + return rawUsed, float64(rawUsed) / float64(total) * 100.0 +} + +// 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 external, nil + + 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 +479,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 +494,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..0884e25 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_disk_test.go @@ -0,0 +1,365 @@ +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) + } +} 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..c8bcf38 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 } diff --git a/Resources/mole/cmd/status/metrics_network_test.go b/Resources/mole/cmd/status/metrics_network_test.go index 51e47cc..550d1ec 100644 --- a/Resources/mole/cmd/status/metrics_network_test.go +++ b/Resources/mole/cmd/status/metrics_network_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" gopsutilnet "github.com/shirou/gopsutil/v4/net" ) @@ -71,7 +72,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 +94,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 +102,83 @@ 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]) + } +} 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..ea96e04 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,10 @@ const ( iconBattery = "◪" iconSensors = "◈" iconProcs = "❊" + + metricLabelWidth = 6 + processMemoryWidth = 7 + processWideMinWidth = 46 ) // Mole body frames (facing right). @@ -140,11 +150,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 +166,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 +213,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 +273,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 +339,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 +372,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 +397,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 +425,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 +452,12 @@ 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])) } + lines = append(lines, formatDiskSMARTLine(disks)) } - 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 +485,81 @@ 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)) + } + return fmt.Sprintf("Total %s", strings.Join(parts, " · ")) +} + +func formatDiskSMARTLine(disks []DiskStatus) string { + if len(disks) == 1 { + status, failing := formatSMARTStatus(disks[0].SmartStatus, false) + if failing { + status += " · " + dangerStyle.Render("Back up now") + } + return fmt.Sprintf("%-*s %s", metricLabelWidth, "SMART", status) + } + + internal, external := splitDisks(disks) + parts := make([]string, 0, len(disks)+1) + hasFailing := false + addGroup := func(prefix string, list []DiskStatus) { + for index, disk := range list { + status, failing := formatSMARTStatus(disk.SmartStatus, true) + parts = append(parts, diskLabel(prefix, index, len(list))+" "+status) + hasFailing = hasFailing || failing + } + } + addGroup("INTR", internal) + addGroup("EXTR", external) + if hasFailing { + parts = append([]string{dangerStyle.Render("Back up now")}, parts...) + } + return fmt.Sprintf("%-*s %s", metricLabelWidth, "SMART", strings.Join(parts, " · ")) +} + +func formatSMARTStatus(status string, compact bool) (string, bool) { + switch status { + case smartStatusVerified: + if compact { + return okStyle.Render("OK"), false + } + return okStyle.Render("Verified"), false + case smartStatusFailing: + if compact { + return dangerStyle.Render("FAIL"), true + } + return dangerStyle.Render("Failing"), true + case smartStatusUnsupported: + if compact { + return subtleStyle.Render("N/A"), false + } + return subtleStyle.Render("Unsupported"), false + default: + if compact { + return subtleStyle.Render("?"), false + } + return subtleStyle.Render("Unknown"), false + } +} + +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 +574,59 @@ 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 "" +} + +func buildCards(m MetricsSnapshot, width int, cpuCores int) []cardData { cards := []cardData{ - renderCPUCard(m.CPU, m.Thermal), + renderCPUCard(m.CPU, m.Thermal, cpuCores), renderMemoryCard(m.Memory, width), - renderDiskCard(m.Disks, m.DiskIO), + renderDiskCard(m.Disks, m.DiskIO, m.TrashSize, m.TrashApprox), renderBatteryCard(m.Batteries, m.Thermal), - renderProcessCard(m.TopProcesses), + renderProcessCard(m.TopProcesses, width), renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width), } // Sensors card disabled - redundant with CPU temp @@ -466,7 +655,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) @@ -565,44 +754,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,14 +809,39 @@ 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 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, height int) string { if width <= 0 { width = colWidth @@ -651,6 +875,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 +896,7 @@ func progressBar(percent float64) string { builder.WriteString("░") } } - return colorizePercent(percent, builder.String()) + return builder.String() } func batteryProgressBar(percent float64) string { @@ -716,9 +944,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 +966,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 +995,13 @@ 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) +} + func renderTwoColumns(cards []cardData, width int) string { if len(cards) == 0 { return "" diff --git a/Resources/mole/cmd/status/view_test.go b/Resources/mole/cmd/status/view_test.go index d49f72b..a678881 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, + }) + + 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}, + wantUsed: "336G used", + wantFree: "164G free", + wantNoSubstr: "%", }, { - name: "internal disk", - label: "INTR", - disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 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: "external disk", - label: "EXTR1", - disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 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: "low usage", - label: "INTR", - disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + 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,153 @@ 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) != 4 { + t.Fatalf("renderDiskCard() single disk expected 4 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 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) != 4 { + t.Fatalf("renderDiskCard() multiple disks expected 4 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) != 5 { + t.Fatalf("renderDiskCard() expected 5 lines without trash, got %d", len(card.lines)) + } + if got := stripANSI(card.lines[4]); got != "I/O ▯▯▯▯▯ R 0 · ▮▮▯▯▯ W 25 MB/s" { + t.Fatalf("I/O line = %q", got) + } +} + +func TestRenderDiskCardFormatsSingleAndMultipleSMARTSummaries(t *testing.T) { + single := renderDiskCard([]DiskStatus{{ + UsedPercent: 30, + Used: 30 << 30, + Total: 100 << 30, + SmartStatus: smartStatusVerified, + }}, DiskIOStatus{}, 0, false) + if got := stripANSI(single.lines[2]); got != "SMART Verified" { + t.Fatalf("single SMART line = %q", got) + } + + multiple := 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) + if got := stripANSI(multiple.lines[2]); got != "SMART INTR OK · EXTR N/A" { + t.Fatalf("multiple SMART line = %q", got) + } +} + +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, 0) + 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 +1039,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 +1256,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, @@ -1028,10 +1330,135 @@ 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 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 +1478,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 +1494,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 +1596,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..31b0bc9 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.6 + golang.org/x/sys v0.41.0 ) require ( @@ -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..6697a2e 100644 --- a/Resources/mole/go.sum +++ b/Resources/mole/go.sum @@ -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.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= 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,8 +67,6 @@ 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= diff --git a/Resources/mole/install.sh b/Resources/mole/install.sh index d5eeb1a..7658522 100755 --- a/Resources/mole/install.sh +++ b/Resources/mole/install.sh @@ -5,11 +5,20 @@ 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() { @@ -19,6 +28,7 @@ start_line_spinner() { return } local chars="|/-\\" + # shellcheck disable=SC1003 [[ -z "$chars" ]] && chars='|/-\\' local i=0 (while true; do @@ -52,6 +62,34 @@ 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 + + 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 +} + safe_rm() { local target="${1:-}" local tmp_root @@ -104,9 +142,31 @@ 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 @@ -152,6 +212,10 @@ 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 @@ -167,7 +231,7 @@ resolve_source_dir() { 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 @@ -264,6 +328,121 @@ 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 + + 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 + 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 + 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 + log_success "Verified ${asset_name} (sha256 + attestation)" + fi + fi + fi + + rm -f "$checksums_file" + return "$result" +} + get_installed_version() { local binary="$INSTALL_DIR/mole" if [[ -x "$binary" ]]; then @@ -302,11 +481,18 @@ write_install_channel_metadata() { local commit_hash="${2:-}" local metadata_file="$CONFIG_DIR/install_channel" + 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 } > "$tmp_file" || { rm -f "$tmp_file" 2> /dev/null || true return 1 @@ -508,9 +694,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,33 +716,46 @@ 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. @@ -555,20 +765,65 @@ download_binary() { echo "Downloading ${binary_name}..." fi - if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; 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 curl_download_with_retry "$url" "$staged_path"; then 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 "Downloaded ${binary_name} binary" 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}" + if [[ -t 1 ]]; then + start_line_spinner "Retrying ${binary_name} from ${fallback_tag}..." + else + echo "Retrying ${binary_name} from ${fallback_tag}..." + fi + if curl_download_with_retry "$fallback_url" "$staged_path"; 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 "Downloaded ${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,12 +842,31 @@ 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 @@ -605,9 +879,12 @@ install_files() { 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" + 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 log_success "Installed mo alias" fi fi @@ -656,21 +933,42 @@ 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 + 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 return 0 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..707340a 100644 --- a/Resources/mole/lib/clean/app_caches.sh +++ b/Resources/mole/lib/clean/app_caches.sh @@ -1,6 +1,62 @@ #!/bin/bash # User GUI Applications Cleanup Module (desktop apps, media, utilities). set -euo pipefail +# 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 + + # Skip while Xcode is running to avoid build failures. + if pgrep -x "Xcode" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode DerivedData · skipped (Xcode running)" + note_activity + return 0 + fi + + # Count projects (each subdirectory is a project build). + local -a projects=() + while IFS= read -r -d '' dir; do + projects+=("$dir") + done < <(command find "$dd_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null || true) + + local project_count=${#projects[@]} + [[ $project_count -eq 0 ]] && return 0 + + # Calculate total size. + local size_kb=0 + size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skP "$dd_dir" 2> /dev/null | awk '{print $1}') || size_kb=0 + local size_human + size_human=$(bytes_to_human "$((size_kb * 1024))") + + local project_label="projects" + [[ $project_count -eq 1 ]] && project_label="project" + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode DerivedData · ${project_count} ${project_label}, ${size_human}" + note_activity + return 0 + fi + + # Remove all project build dirs using safe_remove. + local removed=0 + for dir in "${projects[@]}"; do + if safe_remove "$dir" "true"; then + removed=$((removed + 1)) + fi + done + + if [[ $removed -gt 0 ]]; then + local line_color + line_color=$(cleanup_result_color_kb "$size_kb" 2> /dev/null || echo "$GREEN") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode DerivedData · ${project_count} ${project_label}, ${line_color}${size_human}${NC}" + files_cleaned=$((${files_cleaned:-0} + removed)) + total_size_cleaned=$((${total_size_cleaned:-0} + size_kb)) + total_items=$((${total_items:-0} + removed)) + note_activity + fi +} # Xcode and iOS tooling. clean_xcode_tools() { # Skip DerivedData/Archives while Xcode is running. @@ -8,29 +64,92 @@ clean_xcode_tools() { 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" + # Skip Simulator caches/temp files while Simulator is running to avoid crashes. + local simulator_running=false + if pgrep -x "Simulator" > /dev/null 2>&1; then + simulator_running=true + fi + if [[ "$simulator_running" == "false" ]]; then + safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache" + safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files" + safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Simulator caches · skipped (Simulator running)" + note_activity + fi 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" + clean_xcode_derived_data 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" + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode DerivedData/Documentation · skipped (Xcode running)" + note_activity 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() { safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs" 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 +159,48 @@ 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 + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop state · preserved (sessions, credentials)" + note_activity + fi } # Design and creative tools. clean_design_tools() { @@ -74,14 +210,211 @@ 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() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Final Cut Pro" > /dev/null 2>&1 && return 0 + pgrep -f "/Final Cut Pro.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +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() { + if final_cut_pro_is_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Final Cut Pro generated caches · skipped (Final Cut Pro running)" + note_activity + return 0 + fi + + local -a fcp_cache_targets=() + local target + while IFS= read -r -d '' target; do + fcp_cache_targets+=("$target") + done < <(find_final_cut_pro_generated_cache_targets) + + [[ ${#fcp_cache_targets[@]} -gt 0 ]] || return 0 + + # 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. + safe_clean "${fcp_cache_targets[@]}" "Final Cut Pro generated cache" +} + +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 } # 3D and CAD tools. clean_3d_tools() { @@ -99,22 +432,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 +469,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 +489,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 +513,113 @@ 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 +} +# 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 + size_kb=$(get_path_size_kb "$seg_dir") + [[ "$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 +651,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 +699,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 diff --git a/Resources/mole/lib/clean/apps.sh b/Resources/mole/lib/clean/apps.sh index 75402f3..073de86 100644 --- a/Resources/mole/lib/clean/apps.sh +++ b/Resources/mole/lib/clean/apps.sh @@ -33,10 +33,14 @@ clean_ds_store_tree() { while IFS= read -r -d '' ds_file; do local size size=$(get_file_size "$ds_file") + if [[ "$DRY_RUN" == "true" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + local preview_size_kb=$(((size + 1023) / 1024)) + record_dry_run_cleanup_target "$ds_file" "$preview_size_kb" 1 true || continue + fi total_bytes=$((total_bytes + size)) file_count=$((file_count + 1)) if [[ "$DRY_RUN" != "true" ]]; then - rm -f "$ds_file" 2> /dev/null || true + safe_remove "$ds_file" true 2> /dev/null || true fi if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then break @@ -48,12 +52,14 @@ clean_ds_store_tree() { 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)) @@ -106,13 +112,13 @@ scan_installed_apps() { local -a app_paths=() 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) + done < <(command find "$app_dir" -maxdepth 3 -type d -name '*.app' 2> /dev/null) local count=0 for app_path in "${app_paths[@]:-}"; do 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 + if [[ -n "$bundle_id" && "$bundle_id" != "missing value" ]]; then echo "$bundle_id" count=$((count + 1)) fi @@ -123,19 +129,22 @@ scan_installed_apps() { 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" + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + local 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 || echo "") + echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' -e '/^missing value$/d' > "$scan_tmp_dir/running.txt" + 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 + run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true fi - ) & + ) < /dev/null & pids+=($!) ( - run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \ + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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 - ) & + ) < /dev/null & pids+=($!) debug_log "Waiting for ${#pids[@]} background processes: ${pids[*]}" if [[ ${#pids[@]} -gt 0 ]]; then @@ -166,8 +175,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 +248,28 @@ 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 -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,22 +305,21 @@ 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 -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 @@ -299,6 +330,7 @@ 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..." @@ -306,31 +338,40 @@ clean_orphaned_app_data() { scan_installed_apps "$installed_bundles" 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 + while IFS= read -r -d '' claude_vm_bundle; do + if is_claude_vm_bundle_orphaned "$claude_vm_bundle" "$installed_bundles"; then + if is_path_whitelisted "$claude_vm_bundle"; then + debug_log "Skipping whitelisted orphan: $claude_vm_bundle" + continue + fi + 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)) + fi + fi fi - fi + done < <(find "$claude_support_dir" -maxdepth 3 -name "*.bundle" -type d -print0 2> /dev/null || true) 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,7 +410,12 @@ clean_orphaned_app_data() { local bundle_id=$(basename "$match") bundle_id="${bundle_id%.savedState}" bundle_id="${bundle_id%.binarycookies}" + bundle_id="${bundle_id%.plist}" if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then + if is_path_whitelisted "$match"; then + debug_log "Skipping whitelisted orphan: $match" + continue + fi local size_kb size_kb=$(get_path_size_kb "$match") if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then @@ -382,35 +428,62 @@ clean_orphaned_app_data() { 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 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" + helper_bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist" 2> /dev/null || true) + [[ -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" ]] || ! sudo -n true 2> /dev/null; then return 0 fi 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=( + # 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 +494,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 - [[ -n "$app_path" && -d "$app_path" ]] && 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" + + 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,31 +548,137 @@ clean_orphaned_system_services() { [[ -d "$HOME/Library/Input Methods/$app_name" ]] && return 0 ;; esac - 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" - fi + done - if grep -Fxq "FOUND:$bundle_id" "$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 0 - 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" + 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 -ne 0 ]]; then + return 0 + elif [[ -n "$app_found" ]]; then + _mdfind_cache_store "$bundle_id" "true" return 0 fi - echo "NOTFOUND:$bundle_id" >> "$mdfind_cache_file" + _mdfind_cache_store "$bundle_id" "false" fi fi 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="" + value=$(sudo -n /usr/libexec/PlistBuddy -c "Print :$key" "$plist" 2> /dev/null || true) + + [[ -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="" + binary=$(_plist_program_value "$plist" "ProgramArguments:0" || true) + [[ -z "$binary" ]] && binary=$(_plist_program_value "$plist" "Program" || true) + [[ -z "$binary" ]] && return 1 + printf '%s\n' "$binary" + } + + # 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 + binary=$(_plist_binary_path "$plist") || 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 + elif sudo -n test -e "$binary" 2> /dev/null; then + binary_exists=true + fi + + # 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 + helper_bundle_id=$(_privileged_helper_bundle_id_from_binary "$binary") + if bundle_has_installed_app "$helper_bundle_id"; then + return 1 # Parent app still installed, plist is healthy + 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 + _system_service_app_exists "$bundle_id" "$app_path" && return 1 + # 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 @@ -476,28 +688,14 @@ clean_orphaned_system_services() { # Skip Apple system files [[ "$filename" == com.apple.* ]] && continue - # Extract bundle ID from filename (remove .plist extension) local bundle_id="${filename%.plist}" - # Check against known orphan patterns - for pattern_entry in "${known_orphan_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 - fi - done - done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + # Generic detection: binary-existence check. + if _plist_is_orphaned "$plist" "$bundle_id"; then + orphaned_files+=("$plist") + orphaned_count=$((orphaned_count + 1)) + fi + done < <(sudo -n find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) fi # Scan system LaunchAgents @@ -511,24 +709,12 @@ clean_orphaned_system_services() { local bundle_id="${filename%.plist}" - for pattern_entry in "${known_orphan_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 - fi - done - done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + # Generic detection: binary-existence check. + if _plist_is_orphaned "$plist" "$bundle_id"; then + orphaned_files+=("$plist") + orphaned_count=$((orphaned_count + 1)) + fi + done < <(sudo -n find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) fi # Scan PrivilegedHelperTools @@ -536,256 +722,331 @@ clean_orphaned_system_services() { while IFS= read -r -d '' helper; do local filename filename=$(basename "$helper") - local bundle_id="$filename" + + # 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 - [[ "$filename" == com.apple.* ]] && continue + [[ "$bundle_id" == com.apple.* ]] && continue - for pattern_entry in "${known_orphan_patterns[@]}"; do + # 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 pattern_entry + for pattern_entry in "${known_protect_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 - continue - 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)) + [[ "$filename" == $file_pattern || "$bundle_id" == $file_pattern ]] || continue + if _system_service_app_exists "$bundle_id" "$app_path"; then + is_protected=true break fi + # Pattern matched but app is absent → not protected; stop searching. + break 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 + if ! bundle_has_installed_app "$bundle_id"; then + orphaned_files+=("$helper") + orphaned_count=$((orphaned_count + 1)) + fi + fi + done < <(sudo -n find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null) fi stop_section_spinner + # Drop whitelisted entries before reporting/cleaning. + if [[ $orphaned_count -gt 0 && ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + local -a kept_files=() + for orphan_file in "${orphaned_files[@]}"; do + if is_path_whitelisted "$orphan_file"; then + debug_log "Skipping whitelisted orphan service: $orphan_file" + continue + fi + kept_files+=("$orphan_file") + 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[@]}") + else + orphaned_files=() + fi + fi + # Report and clean if [[ $orphaned_count -gt 0 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" + debug_log "Found $orphaned_count orphaned system services" - for orphan_file in "${orphaned_files[@]}"; do - local filename - filename=$(basename "$orphan_file") + local removed_count=0 + local skipped_protected_count=0 + local failed_count=0 + local removed_kb=0 - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + for orphan_file in "${orphaned_files[@]}"; do + # 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 + orphan_size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" sudo -n du -skP "$orphan_file" 2> /dev/null | awk '{print $1}' || echo "0") + [[ -n "$orphan_size_kb" ]] || 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 + file_size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" sudo -n du -skP "$orphan_file" 2> /dev/null | awk '{print $1}' || echo "0") + # Unload if it's a LaunchDaemon/LaunchAgent if [[ "$orphan_file" == *.plist ]]; then - sudo launchctl unload "$orphan_file" 2> /dev/null || true + sudo -n launchctl unload "$orphan_file" 2> /dev/null || true fi - if safe_sudo_remove "$orphan_file"; then + local remove_rc=0 + safe_sudo_remove "$orphan_file" || 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)) + 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 +# 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" - echo "$program" -} + [[ -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 -# Extract associated bundle identifier from plist -_extract_associated_bundle() { - local plist="$1" - local associated="" - - # 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="" - fi + 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 - echo "$associated" + # 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" } -# 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" - - # Layer 1: Check if program path exists - local program=$(_extract_program_path "$plist") - - # No program path - skip (not a standard launch item) - [[ -z "$program" ]] && return 1 - - # Program exists -> not orphaned - [[ -e "$program" ]] && return 1 +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" + ) - # 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 + local removed_count=0 + local failed_count=0 + local _ng_state + _ng_state=$(shopt -p nullglob || true) + shopt -s nullglob - # 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 - fi - fi - fi + _container_stub_app_exists() { + local bundle_id="$1" + local app_path="$2" - # Layer 3: Check Application Support directory activity - if [[ "$program" =~ /Library/Application\ Support/([^/]+)/ ]]; then - local app_support_name="${BASH_REMATCH[1]}" - - # 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 + [[ -d "$app_path" || -e "$app_path" ]] && return 0 + + 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 + + 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 -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 - 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 - fi - fi - fi - - # All checks failed -> likely orphaned - return 0 -} + return 1 + } -# Clean orphaned user-level LaunchAgents -# Only processes ~/Library/LaunchAgents (safer than system-level) -clean_orphaned_launch_agents() { - local launch_agents_dir="$HOME/Library/LaunchAgents" + local pattern_entry + for pattern_entry in "${stub_patterns[@]}"; do + local bundle_glob="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" - [[ ! -d "$launch_agents_dir" ]] && return 0 + local container_dir + for container_dir in "$containers_dir"/$bundle_glob; do + [[ -d "$container_dir" ]] || continue + [[ -L "$container_dir" ]] && continue - start_section_spinner "Scanning orphaned launch agents..." + local metadata_plist="$container_dir/.com.apple.containermanagerd.metadata.plist" + [[ -f "$metadata_plist" ]] || continue + if find "$container_dir" -mindepth 1 -maxdepth 1 ! -name ".com.apple.containermanagerd.metadata.plist" -print -quit 2> /dev/null | grep -q .; then + continue + fi - local -a orphaned_items=() - local total_orphaned_kb=0 + local bundle_id="${container_dir##*/}" - # Scan user LaunchAgents - while IFS= read -r -d '' plist; do - local filename=$(basename "$plist") + _container_stub_app_exists "$bundle_id" "$app_path" && continue - # Skip Apple's LaunchAgents - [[ "$filename" == com.apple.* ]] && continue + if is_path_whitelisted "$container_dir" 2> /dev/null; then + debug_log "Skipping whitelisted stub container: $container_dir" + continue + fi - 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. + if _remove_verified_container_stub "$container_dir" "$metadata_plist" > /dev/null 2>&1; 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 + stub_size_kb=$(get_path_size_kb "$container_dir" 2> /dev/null || echo "0") + [[ "$stub_size_kb" =~ ^[0-9]+$ ]] || stub_size_kb=0 + record_dry_run_cleanup_target "$container_dir" "$stub_size_kb" 1 true || continue + 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..c251294 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,47 @@ 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" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew · skipped (${cleaned_when})" + note_activity 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" + note_activity elif [[ "$brew_success" == "true" && -f "$brew_tmp_file" ]]; then local brew_output brew_output=$(cat "$brew_tmp_file" 2> /dev/null || echo "") @@ -96,31 +290,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..e21a38d 100644 --- a/Resources/mole/lib/clean/caches.sh +++ b/Resources/mole/lib/clean/caches.sh @@ -49,11 +49,17 @@ clean_service_worker_cache() { [[ ! -d "$cache_path" ]] && return 0 local cleaned_size=0 local protected_count=0 + # 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,28 +68,45 @@ 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" ]] && declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$cache_dir" "$size" 1 true || continue + fi if [[ "$DRY_RUN" != "true" ]]; then safe_remove "$cache_dir" true || true 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 @@ -117,6 +140,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 +152,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 +176,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 +242,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 +250,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 +278,207 @@ 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 + safe_clean "${target_paths[@]}" "$description" || true + 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 + safe_remove "$target_path" true || true + 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 + current_python_root="" + current_python_dirs=() + [[ -d "$cache_dir/cache" ]] && clean_project_cache_target "$cache_dir/cache"/* "Next.js build cache" || true + ;; + "__pycache__") + if [[ "$record_root" != "$current_python_root" && ${#current_python_dirs[@]} -gt 0 ]]; then + flush_python_group_if_needed "$current_python_root" current_python_dirs + 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 + current_python_root="" + current_python_dirs=() + if [[ -d "$cache_dir" ]]; then + clean_project_cache_target "$cache_dir" "Flutter build cache (.dart_tool)" || true + local build_dir="$(dirname "$cache_dir")/build" + if [[ -d "$build_dir" ]]; then + clean_project_cache_target "$build_dir" "Flutter build cache (build/)" || true + 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 + size_kb=$(get_path_size_kb "$cache_dir") + [[ "$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 +493,24 @@ 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 + + process_project_cache_matches "$root_matches_file" + rm -f "$root_matches_file" + + 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..b37465a 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,116 @@ 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() { + pgrep -f "org.gradle.launcher.daemon" > /dev/null 2>&1 && return 0 + pgrep -f "GradleDaemon" > /dev/null 2>&1 && return 0 + return 1 +} + # 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 @@ -75,34 +180,106 @@ clean_dev_npm() { 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="" + pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" 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" + + local pnpm_cache_check="$pnpm_default_store" + if [[ -n "$pnpm_store_path" && "$pnpm_store_path" == /* ]]; then + pnpm_cache_check="$pnpm_store_path" + fi + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" "$pnpm_cache_check" run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" pnpm store prune + else + debug_log "pnpm is unavailable, leaving global pnpm store for manual review: $pnpm_default_store" + fi + 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 +287,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,27 +307,79 @@ 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 } + +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" +} # Rust/cargo caches. 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" } +# 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. # Args: $1=directory, $2=tool_name, $3=list_command, $4=remove_command @@ -172,9 +400,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 " ${GREEN}${ICON_SUCCESS}${NC} ${tool_name} · ${count} found${hint}" fi } @@ -188,24 +416,34 @@ check_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_WARNING}${NC} Docker unused data · skipped (review: 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 + orb_size=$(get_path_size_kb "$orb_data" 2> /dev/null || echo 0) + [[ "$orb_size" =~ ^[0-9]+$ ]] || orb_size=0 fi + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} OrbStack container data · skipped ($(bytes_to_human $((orb_size * 1024))), review: 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 +451,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 @@ -255,7 +496,7 @@ clean_xcode_documentation_cache() { [[ -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" + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped (Xcode running)" note_activity return 0 fi @@ -304,7 +545,7 @@ clean_xcode_documentation_cache() { 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 @@ -330,7 +571,7 @@ clean_xcode_documentation_cache() { fi note_activity elif [[ $skipped_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode documentation cache · nothing to clean" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode documentation cache · already clean" echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode documentation cache · skipped ${skipped_count} protected items" note_activity else @@ -339,6 +580,124 @@ clean_xcode_documentation_cache() { fi } +_coresimulator_cache_process_running() { + pgrep -x "Xcode" > /dev/null 2>&1 || + pgrep -x "Simulator" > /dev/null 2>&1 || + pgrep -x "CoreSimulatorService" > /dev/null 2>&1 || + pgrep -x "simdiskimaged" > /dev/null 2>&1 || + pgrep -f "com.apple.CoreSimulator" > /dev/null 2>&1 +} + +_xcode_xctest_devices_process_running() { + _coresimulator_cache_process_running || + pgrep -x "xcodebuild" > /dev/null 2>&1 || + pgrep -x "xctest" > /dev/null 2>&1 || + pgrep -x "XCTRunner" > /dev/null 2>&1 || + pgrep -f "com.apple.dt.XCTest" > /dev/null 2>&1 || + pgrep -f "XCTest" > /dev/null 2>&1 +} + +clean_xcode_xctest_devices() { + local xctest_devices_dir="${MOLE_XCODE_XCTEST_DEVICES_DIR:-$HOME/Library/Developer/XCTestDevices}" + [[ -d "$xctest_devices_dir" ]] || return 0 + + if _xcode_xctest_devices_process_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode XCTestDevices · skipped (Xcode or XCTest running)" + note_activity + return 0 + fi + + safe_clean "$xctest_devices_dir" "Xcode XCTestDevices test data" +} + +clean_xcode_system_coresimulator_caches() { + local cache_root="${MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR:-/Library/Developer/CoreSimulator/Caches}" + [[ -d "$cache_root" ]] || return 0 + + if _coresimulator_cache_process_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped (CoreSimulator running)" + note_activity + return 0 + fi + + local -a cache_entries=() + 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 + + local entry + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + local total_size_kb=0 + local cleanable_count=0 + for entry in "${cache_entries[@]}"; do + if should_protect_path "$entry" || is_path_whitelisted "$entry"; then + continue + fi + local entry_size_kb + entry_size_kb=$(get_path_size_kb "$entry" 2> /dev/null || echo 0) + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + 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 + [[ "$cleanable_count" -gt 0 ]] || return 0 + 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 + return 0 + fi + + if ! has_sudo_session; then + 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 + + local removed_count=0 + local removed_size_kb=0 + local skipped_count=0 + for entry in "${cache_entries[@]}"; do + if should_protect_path "$entry" || is_path_whitelisted "$entry"; then + skipped_count=$((skipped_count + 1)) + continue + fi + local entry_size_kb + entry_size_kb=$(get_path_size_kb "$entry" 2> /dev/null || echo 0) + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + if safe_sudo_remove "$entry"; then + removed_count=$((removed_count + 1)) + removed_size_kb=$((removed_size_kb + entry_size_kb)) + 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 + 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 + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode Simulator system cache · already clean" + note_activity + 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. @@ -384,8 +743,25 @@ clean_xcode_device_support() { stale_size_human=$(bytes_to_human "$((stale_size_kb * 1024))") 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 + local dry_run_count=0 + stale_size_kb=0 + for stale_entry in "${stale_dirs[@]}"; do + if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry"; then + continue + fi + entry_size_kb=$(get_path_size_kb "$stale_entry" 2> /dev/null || echo 0) + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + 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 + 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 else # Remove old versions local removed_count=0 @@ -399,7 +775,9 @@ clean_xcode_device_support() { done if [[ $removed_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${stale_size_human}" + local line_color + line_color=$(cleanup_result_color_kb "$stale_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${line_color}${stale_size_human}${NC}" note_activity fi fi @@ -436,9 +814,9 @@ _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") + 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=$(du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") + 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 @@ -467,6 +845,14 @@ clean_xcode_simulator_runtime_volumes() { [[ -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 @@ -485,14 +871,11 @@ clean_xcode_simulator_runtime_volumes() { fi 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 @@ -501,6 +884,7 @@ clean_xcode_simulator_runtime_volumes() { local -a size_values=() local in_use_kb=0 local unused_kb=0 + local cleanable_unused_count=0 local i=0 for candidate in "${sorted_candidates[@]}"; do local size_kb @@ -510,7 +894,16 @@ clean_xcode_simulator_runtime_volumes() { if [[ "$status" == "IN_USE" ]]; then in_use_kb=$((in_use_kb + size_kb)) else - unused_kb=$((unused_kb + size_kb)) + 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 || { + i=$((i + 1)) + continue + } + fi + unused_kb=$((unused_kb + size_kb)) + cleanable_unused_count=$((cleanable_unused_count + 1)) + fi fi i=$((i + 1)) done @@ -519,7 +912,7 @@ clean_xcode_simulator_runtime_volumes() { runtime_scan_spinner=false fi - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode runtime volumes · ${unused_count} unused, ${in_use_count} in use" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode runtime volumes · ${cleanable_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))") @@ -615,10 +1008,12 @@ clean_xcode_simulator_runtime_volumes() { 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 " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human}), skipped ${skipped_protected} protected" + 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 " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human})" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC})" fi note_activity else @@ -631,145 +1026,273 @@ clean_xcode_simulator_runtime_volumes() { 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 "$@" +} + clean_dev_mobile() { check_android_ndk clean_xcode_documentation_cache + clean_xcode_system_coresimulator_caches clean_xcode_simulator_runtime_volumes + clean_xcode_xctest_devices 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" + _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 - simctl_available=false - fi + 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" + 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 + if _run_simctl "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" list devices > /dev/null 2>&1; then + simctl_probe_ok=true + else + if _run_simctl 8 list devices > /dev/null 2>&1; then # 8s: simctl retry after warmup, see lib/core/timeouts.sh + simctl_probe_ok=true + debug_log "simctl probe succeeded on retry (CoreSimulatorService warmup)" + else + debug_log "simctl probe failed after retry (5s + 8s timeouts)" + fi + fi + if [[ "$simctl_probe_ok" != "true" ]]; 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 + 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 + 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 - unavailable_size_human=$(bytes_to_human "$((unavailable_size_kb * 1024))") - 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" + 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 + unavailable_size_kb=$((unavailable_size_kb + $(get_path_size_kb "$simulator_device_path"))) + fi + done fi - else - # Skip if no unavailable simulators - if ((unavailable_before == 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + 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 + unavailable_path_size_kb=$(get_path_size_kb "$unavailable_path" 2> /dev/null || echo "0") + [[ "$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}" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + fi note_activity else - start_section_spinner "Checking unavailable simulators..." - - # Capture error output for diagnostics - local delete_output - local delete_exit_code=0 - delete_output=$(xcrun simctl delete unavailable 2>&1) || delete_exit_code=$? - - 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 - - removed_unavailable=$((unavailable_before - unavailable_after)) - if ((removed_unavailable < 0)); then - removed_unavailable=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 + # Skip if no unavailable simulators + if ((unavailable_before == 0)); then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + note_activity 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 - - # 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 + 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 - 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 + removed_unavailable=$((unavailable_before - unavailable_after)) + if ((removed_unavailable < 0)); then + removed_unavailable=0 fi - done - 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}" + 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 " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · partially cleaned ${manually_removed}/${#unavailable_udids[@]}, ${unavailable_size_human}" + 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 - 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)) + fi # Close if ((unavailable_before == 0)) + note_activity + fi # End of simctl_available check + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl not available" note_activity - fi # End of simctl_available check + 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 @@ -786,6 +1309,7 @@ clean_dev_mobile() { 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" + 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" @@ -804,10 +1328,18 @@ clean_dev_jvm() { if declare -f clean_maven_repository > /dev/null 2>&1; then clean_maven_repository fi - safe_clean ~/.sbt/* "SBT cache" + safe_clean ~/.sbt/boot/* "SBT boot cache" + safe_clean ~/.sbt/launchers/* "SBT launcher cache" safe_clean ~/.ivy2/cache/* "Ivy cache" - safe_clean ~/.gradle/caches/* "Gradle cache" - safe_clean ~/.gradle/daemon/* "Gradle daemon" + safe_clean ~/.gradle/caches/build-cache-*/* "Gradle build cache" + safe_clean ~/.gradle/notifications/* "Gradle notifications cache" + if gradle_daemon_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Gradle daemon/workers · skipped (Gradle daemon running)" + note_activity + else + safe_clean ~/.gradle/daemon/* "Gradle daemon" + safe_clean ~/.gradle/workers/* "Gradle workers" + fi } # JetBrains Toolbox old IDE versions (keep current + recent backup). clean_dev_jetbrains_toolbox() { @@ -914,10 +1446,273 @@ clean_dev_jetbrains_toolbox() { _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). +clean_versioned_agent_root() { + local versions_root="$1" + local label="$2" + local keep_previous="$3" + local active_path="${4:-}" + + [[ -d "$versions_root" ]] || return 0 + + local -a entries=() + local entry + while IFS= read -r -d '' entry; do + local name + name=$(basename "$entry") + [[ "$name" == .* ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + entries+=("$entry") + done < <(command find "$versions_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print0 2> /dev/null) + + [[ ${#entries[@]} -le "$keep_previous" ]] && return 0 + + local -a sorted=() + local line + while IFS= read -r line; do + sorted+=("${line#* }") + done < <( + local version_entry + for version_entry in "${entries[@]}"; do + local mtime + mtime=$(stat -f%m "$version_entry" 2> /dev/null || echo "0") + printf '%s %s\n' "$mtime" "$version_entry" + done | sort -rn + ) + + local idx=0 + local target + for target in "${sorted[@]}"; do + if [[ -n "$active_path" && "$target" == "$active_path" ]]; then + continue + fi + if [[ $idx -lt $keep_previous ]]; then + idx=$((idx + 1)) + continue + fi + safe_clean "$target" "$label" + note_activity + idx=$((idx + 1)) + done +} + +count_versioned_agent_entries() { + local versions_root="$1" + local count=0 + local entry + + [[ -d "$versions_root" ]] || { + echo 0 + return 0 + } + + while IFS= read -r -d '' entry; do + local name + name=$(basename "$entry") + [[ "$name" == .* ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + count=$((count + 1)) + done < <(command find "$versions_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print0 2> /dev/null) + + 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() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Claude" > /dev/null 2>&1 && return 0 + pgrep -f "/Claude.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +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" +} + +clean_claude_desktop_bundled_versions() { + local keep_previous="$1" + local claude_support="$HOME/Library/Application Support/Claude" + [[ -d "$claude_support" ]] || return 0 + + 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 + version_count=$(count_versioned_agent_entries "$versions_root") + [[ "$version_count" =~ ^[0-9]+$ ]] || version_count=0 + if [[ "$version_count" -gt 1 ]]; then + has_multiple_versions=true + break + fi + done + + [[ "$has_multiple_versions" == "true" ]] || return 0 + + if claude_desktop_running; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} Claude Desktop bundled Claude Code · skipped (Claude Desktop running)" + return 0 + fi + + 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 + + if [[ ! -e "$versions_root/$sdk_version" ]]; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active version unknown)" + return 0 + fi + done + + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + + local version_count + version_count=$(count_versioned_agent_entries "$versions_root") + [[ "$version_count" =~ ^[0-9]+$ ]] || version_count=0 + [[ "$version_count" -le 1 ]] && continue + + clean_versioned_agent_root "$versions_root" "$label" "$keep_previous" "$versions_root/$sdk_version" + 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 + if [[ ! -e "$active_symlink" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active symlink broken)" + note_activity + continue + fi + local target + target=$(readlink "$active_symlink" 2> /dev/null || true) + if [[ -z "$target" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active version unknown)" + note_activity + continue + fi + 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") + if ! target_parent=$(cd "$target_parent" 2> /dev/null && pwd -P); then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active version unknown)" + note_activity + continue + fi + target="$target_parent/$target_name" + + local entry entry_resolved entry_parent entry_name + for entry in "$versions_root"/*; do + [[ -e "$entry" ]] || continue + 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"/*) + active_path="$entry" + break + ;; + esac + done + if [[ -z "$active_path" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label · skipped (active version unknown)" + note_activity + continue + fi + fi + + clean_versioned_agent_root "$versions_root" "$label" "$keep_previous" "$active_path" + done + + clean_claude_desktop_bundled_versions "$keep_previous" +} + # Other language tool caches. clean_dev_other_langs() { - safe_clean ~/.bundle/cache/* "Ruby Bundler cache" - safe_clean ~/.composer/cache/* "PHP Composer cache" + 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" @@ -953,6 +1748,289 @@ clean_dev_api_tools() { safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" } + +codex_desktop_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Codex" > /dev/null 2>&1 && return 0 + pgrep -f "/Codex.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +codex_sparkle_updater_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -f "org[.]sparkle-project[.]Sparkle" > /dev/null 2>&1 && return 0 + pgrep -f "Sparkle[.]framework/.*/(Autoupdate|Installer|Downloader|Updater)" > /dev/null 2>&1 && return 0 + return 1 +} + +codex_sparkle_staging_has_open_files() { + local staging_root="$1" + command -v lsof > /dev/null 2>&1 || return 1 + + local lsof_output="" + local lsof_rc=0 + if lsof_output=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" lsof -Fn +D "$staging_root" 2> /dev/null); then + [[ -n "$lsof_output" ]] + return + else + lsof_rc=$? + fi + + # `lsof +D` returns 1 when no open files match. Timeouts or other failures + # are different: the caller must conservatively skip cleanup. + [[ "$lsof_rc" -eq 1 ]] && return 1 + return 2 +} + +clean_codex_desktop_staging() { + local staging_root="$HOME/Library/Caches/com.openai.codex/org.sparkle-project.Sparkle/Installation" + [[ -d "$staging_root" ]] || return 0 + + local entry_count=0 + entry_count=$(command find -P "$staging_root" -mindepth 1 -maxdepth 1 -type d -print 2> /dev/null | wc -l | tr -d ' ') + [[ "$entry_count" =~ ^[0-9]+$ ]] || entry_count=0 + [[ "$entry_count" -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 + + if codex_desktop_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · skipped (Codex running)" + note_activity + return 0 + fi + + if codex_sparkle_updater_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · skipped (updater running)" + note_activity + return 0 + fi + + local open_file_state=0 + if codex_sparkle_staging_has_open_files "$staging_root"; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop update staging · skipped (files in use)" + note_activity + 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 + while IFS= read -r -d '' stale_entry; do + safe_clean "$stale_entry" "Codex Desktop stale update staging" + done < <(command find -P "$staging_root" -mindepth 1 -maxdepth 1 -type d -mtime +"$MOLE_ORPHAN_AGE_DAYS" -print0 2> /dev/null) +} + +# True when the Codex CLI or the Codex Desktop app is running. +codex_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + pgrep -x "codex" > /dev/null 2>&1 && return 0 + codex_desktop_running +} + +antigravity_or_gemini_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Antigravity" > /dev/null 2>&1 && return 0 + pgrep -f "/Antigravity.app/" > /dev/null 2>&1 && return 0 + pgrep -x "gemini" > /dev/null 2>&1 && return 0 + pgrep -f "antigravity-browser-profile" > /dev/null 2>&1 && return 0 + return 1 +} + +chrome_devtools_mcp_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -f "chrome-devtools-mcp" > /dev/null 2>&1 && return 0 + return 1 +} + +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 size_kb=0 + + if declare -f get_path_size_kb > /dev/null 2>&1; then + size_kb=$(get_path_size_kb "$target" 2> /dev/null || echo 0) + fi + + if declare -f bytes_to_human > /dev/null 2>&1; then + bytes_to_human "$((size_kb * 1024))" + else + printf '%s KB' "$size_kb" + fi +} + +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 + + if codex_desktop_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex runtimes · skipped (Codex running)" + note_activity + return 0 + fi + + local size_human + size_human=$(_codex_runtime_size_human "$runtime_root") + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex runtimes · manual review (${size_human})" + note_activity + + local runtime_dir + 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 + safe_clean "$runtime_dir" "Codex CLI runtimes" + 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 + + if codex_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex CLI state · skipped (Codex running)" + note_activity + return 0 + fi + + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex CLI state · preserved (sessions, credentials)" + note_activity + debug_log "Codex CLI state left intact by default: $codex_root" +} + +# Shared Chromium Default profile caches that are safe to regenerate. +clean_chromium_default_caches() { + local profile_root="$1" + local label="$2" + + [[ -d "$profile_root" ]] || return 0 + + 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" +} + +# 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" + + if antigravity_or_gemini_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Antigravity/Gemini caches · skipped (Antigravity or Gemini running)" + note_activity + return 0 + fi + + if [[ -d "$ag_profile" ]]; then + clean_chromium_default_caches "$ag_profile" "Antigravity" + safe_clean "$ag_profile/GraphiteDawnCache"/* "Antigravity Graphite cache" + safe_clean "$ag_profile/component_crx_cache"/* "Antigravity component cache" + safe_clean "$ag_profile/extensions_crx_cache"/* "Antigravity extension cache" + clean_service_worker_cache "Antigravity" "$ag_profile/Default/Service Worker/CacheStorage" + fi + # Never clean ~/.gemini/tmp: despite the name it stores gemini-cli + # conversation checkpoints and prompt history (AI chat state, not temp). +} + +clean_chrome_devtools_mcp_caches() { + local mcp_profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + + if chrome_devtools_mcp_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome DevTools MCP caches · skipped (server running)" + note_activity + return 0 + fi + + [[ -d "$mcp_profile" ]] || return 0 + + clean_chromium_default_caches "$mcp_profile" "Chrome DevTools MCP" + safe_clean "$mcp_profile/Default/DawnCache"/* "Chrome DevTools MCP Dawn cache" + safe_clean "$mcp_profile/Default/GrShaderCache"/* "Chrome DevTools MCP shader cache" + safe_clean "$mcp_profile/Default/GraphiteDawnCache"/* "Chrome DevTools MCP Graphite cache" + safe_clean "$mcp_profile/GraphiteDawnCache"/* "Chrome DevTools MCP Graphite cache" + safe_clean "$mcp_profile/component_crx_cache"/* "Chrome DevTools MCP component cache" + safe_clean "$mcp_profile/extensions_crx_cache"/* "Chrome DevTools MCP extension cache" + + if declare -f clean_service_worker_cache > /dev/null 2>&1; then + clean_service_worker_cache "Chrome DevTools MCP" "$mcp_profile/Default/Service Worker/CacheStorage" + fi +} + # Misc dev tool caches. clean_dev_misc() { safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache" @@ -962,23 +2040,70 @@ 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" + safe_clean ~/Library/Application\ Support/Claude/pending-uploads/* "Claude pending uploads" + 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 CLI session state (~/.cache side above covers Electron cache) + if [[ -d ~/.local/share/opencode ]]; then + safe_clean ~/.local/share/opencode/snapshot/* "OpenCode snapshots" + safe_clean ~/.local/share/opencode/log/* "OpenCode logs" + fi + # 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 + # 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 cached browser binaries + safe_clean ~/Library/Caches/ms-playwright/* "Playwright browsers" + # 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 +2122,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,28 +2136,19 @@ 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" -} # 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_mise clean_dev_rust check_rust_toolchains + clean_dev_ruby + clean_dev_perl clean_dev_docker clean_dev_cloud clean_dev_nix @@ -1046,6 +2158,8 @@ clean_developer_tools() { clean_dev_mobile clean_dev_jvm clean_dev_jetbrains_toolbox + clean_dev_jetbrains_logs + clean_dev_ai_agents clean_dev_other_langs clean_dev_cicd clean_dev_database @@ -1060,8 +2174,10 @@ clean_developer_tools() { clean_xcode_tools clean_code_editors - # 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. + safe_clean ~/Library/Caches/Homebrew/downloads/* "Homebrew cache" local brew_lock_dirs=( "/opt/homebrew/var/homebrew/locks" "/usr/local/var/homebrew/locks" diff --git a/Resources/mole/lib/clean/hints.sh b/Resources/mole/lib/clean/hints.sh index f6538bf..3857435 100644 --- a/Resources/mole/lib/clean/hints.sh +++ b/Resources/mole/lib/clean/hints.sh @@ -54,6 +54,193 @@ 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" +} + +# Lowercase and strip to alnum, one line in, one line out. Under LC_ALL=C +# `[:alnum:]` is ASCII, so dropping everything outside a-z0-9 after the +# lowercase pass is the same transform applied line by line. +hint_normalize_app_match_lines() { + LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C tr -cd 'a-z0-9\n' +} + +# shellcheck disable=SC2329 +hint_collect_installed_gui_app_match_texts() { + local -a app_roots=( + "/Applications" + "/Applications/Setapp" + "/Applications/Utilities" + "$HOME/Applications" + "/Library/Input Methods" + "$HOME/Library/Input Methods" + "$HOME/Library/Application Support/Setapp/Applications" + ) + + local app_root app_path app_name info value + for app_root in "${app_roots[@]}"; do + [[ -d "$app_root" ]] || continue + while IFS= read -r -d '' app_path; do + [[ -n "$app_path" ]] || continue + + app_name="${app_path##*/}" + app_name="${app_name%.app}" + printf '%s\n' "$app_name" + + info="$app_path/Contents/Info.plist" + [[ -f "$info" ]] || continue + for value in \ + "$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || echo "")" \ + "$(plutil -extract CFBundleName raw "$info" 2> /dev/null || echo "")" \ + "$(plutil -extract CFBundleDisplayName raw "$info" 2> /dev/null || echo "")"; do + [[ -n "$value" && "$value" != "(null)" ]] && printf '%s\n' "$value" + done + done < <(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" find "$app_root" -maxdepth 2 -name "*.app" -print0 2> /dev/null || true) + done + + local -a cask_roots=( + "/opt/homebrew/Caskroom" + "/usr/local/Caskroom" + ) + + local cask_root cask_dir cask_name + for cask_root in "${cask_roots[@]}"; do + [[ -d "$cask_root" ]] || continue + while IFS= read -r -d '' cask_dir; do + [[ -n "$cask_dir" ]] || continue + cask_name="${cask_dir##*/}" + printf '%s\n' "$cask_name" + done < <(run_with_timeout 1 find "$cask_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null || true) # 1s: shallow brew cask dir list, see lib/core/timeouts.sh + done +} + +# shellcheck disable=SC2329 +hint_dotdir_owned_by_installed_gui_app() { + local installed_app_texts="$1" + shift || true + [[ -n "$installed_app_texts" && $# -gt 0 ]] || return 1 + + # Normalize each side once. This used to normalize inside the comparison + # loop, two tr processes per (app text, candidate) pair, so a machine with + # ~30 apps spent several hundred processes on a single orphan dotdir. + # Candidates shorter than 4 chars are dropped, as before, to keep matches + # like `.ai-old` against `AI.app` from firing (#872). + local -a needles=() + local candidate + while IFS= read -r candidate; do + [[ ${#candidate} -ge 4 ]] && needles+=("$candidate") + done < <(printf '%s\n' "$@" | hint_normalize_app_match_lines) + [[ ${#needles[@]} -gt 0 ]] || return 1 + + local value + while IFS= read -r value; do + [[ -n "$value" ]] || continue + for candidate in "${needles[@]}"; do + if [[ "$value" == *"$candidate"* ]]; then + return 0 + fi + done + done < <(printf '%s\n' "$installed_app_texts" | hint_normalize_app_match_lines) + + return 1 +} + # shellcheck disable=SC2329 record_project_artifact_hint() { local path="$1" @@ -100,11 +287,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,17 +322,17 @@ 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 local root_projects_scanned=0 @@ -162,9 +359,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 +405,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 +455,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 @@ -272,6 +506,17 @@ show_system_data_hint_notice() { "$HOME/Library/Mail" ) + local orbstack_data + for orbstack_data in "$HOME"/Library/Group\ Containers/*dev.orbstack/data; do + [[ -d "$orbstack_data" ]] || continue + labels+=("OrbStack data") + paths+=("$orbstack_data") + break + done + + # Several du probes with 0.8s budgets each still add up to seconds. + start_section_spinner "Checking System Data..." + local i for i in "${!paths[@]}"; do local path="${paths[$i]}" @@ -282,7 +527,7 @@ show_system_data_hint_notice() { if [[ "$size_kb" -ge "$threshold_kb" ]]; then clue_labels+=("${labels[$i]}") clue_sizes+=("$size_kb") - clue_paths+=("${path/#$HOME/~}") + clue_paths+=("$path") if [[ ${#clue_labels[@]} -ge $max_hits ]]; then break fi @@ -290,9 +535,11 @@ show_system_data_hint_notice() { fi done + stop_section_spinner + if [[ ${#clue_labels[@]} -eq 0 ]]; then - note_activity - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No common System Data clues detected" + # Stay silent so an idle System Data clues section collapses. + debug_log "No common System Data clues detected" return 0 fi @@ -301,10 +548,8 @@ show_system_data_hint_notice() { 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}" + echo -e " ${YELLOW}${ICON_WARNING}${NC} ${clue_labels[$i]} · ${GREEN}${human_size}${NC} · ${GRAY}$(format_path_link "${clue_paths[$i]}")${NC}" done - echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo analyze, Device backups, docker system df" } # shellcheck disable=SC2329 @@ -312,6 +557,10 @@ show_project_artifact_hint_notice() { probe_project_artifact_hints 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 +569,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 +586,382 @@ 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 + fi + + local partial_note="" + if [[ "${PROJECT_ARTIFACT_HINT_SCAN_SKIPPED:-false}" == "true" ]]; then + partial_note=" ${GRAY}(partial scan)${NC}" + fi + + echo -e " ${YELLOW}${ICON_WARNING}${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 labels=() + 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 + labels+=("$filename") + reasons+=("$reason") + targets+=("$target") + if [[ ${#labels[@]} -ge $max_hits ]]; then + break + fi + fi + done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + + stop_section_spinner + [[ ${#labels[@]} -eq 0 ]] && return 0 + + note_activity + + local i + for i in "${!labels[@]}"; do + echo -e " ${YELLOW}${ICON_WARNING}${NC} Stale login item · ${labels[$i]} · ${GRAY}${reasons[$i]}: ${targets[$i]}${NC}" + done +} + +readonly ORPHAN_DOTDIR_KNOWN_SAFE=( + # Shell + ".bash_history" ".bash_profile" ".bash_sessions" ".bashrc" + ".zshrc" ".zsh_history" ".zsh_sessions" ".zprofile" ".zshenv" ".zlogout" ".zcompdump" + ".profile" ".inputrc" ".hushlogin" + ".oh-my-zsh" ".zinit" ".zplug" ".antigen" ".p10k.zsh" + ".config" ".local" ".cache" + # Security + ".ssh" ".gnupg" ".gpg" ".pass""word-store" + # Git + ".gitconfig" ".gitignore_global" ".git-credentials" ".gitattributes_global" + # Language tools (Mole handles their caches separately) + ".pyenv" ".rbenv" ".nvm" ".nodenv" ".goenv" ".jenv" + ".rustup" ".cargo" ".ghcup" ".stack" ".cabal" + ".sdkman" ".jabba" ".asdf" ".mise" ".rtx" ".volta" ".fnm" + ".deno" ".bun" + # Package managers + ".npm" ".yarn" ".pnpm" ".bundle" ".gem" + ".composer" ".nuget" ".pub-cache" + ".m2" ".gradle" ".sbt" ".ivy2" ".lein" + ".hex" ".mix" ".opam" ".cpan" ".cpanm" + ".conda" ".virtualenvs" ".pipx" + # Cloud / devops + ".docker" ".kube" ".minikube" ".helm" + ".aws" ".azure" ".terraform" ".vagrant" + # Editors / IDEs + ".vim" ".vimrc" ".viminfo" ".emacs" ".emacs.d" ".doom.d" ".nano" ".nanorc" + ".vscode" ".cursor" ".atom" + # AI tools + ".claude" ".copilot" ".ollama" + # macOS system + ".Trash" ".Trashes" ".CFUserTextEncoding" ".DS_Store" ".cups" ".dropbox" + # Mobile / native dev + ".android" ".cocoapods" ".fastlane" ".expo" ".react-native" ".swiftpm" + # Terminal / misc + ".tmux" ".screen" ".wget-hsts" ".curlrc" ".netrc" ".wgetrc" + ".putty" + ".lesshst" ".python_history" ".node_repl_history" + ".irb_history" ".pry_history" + ".jupyter" ".ipython" ".matplotlib" ".keras" ".torch" + ".psql_history" ".mysql_history" ".sqlite_history" ".rediscli_history" ".mongo" ".dbshell" + # Homebrew / VCS + ".homebrew" ".hg" ".hgrc" ".svn" ".bazaar" + # Fly.io / Gemini (Tang uses these) + ".fly" ".gemini" +) + +# Standard locations for installed GUI apps. Overridable from tests. +_MOLE_DOTDIR_OWNER_APP_ROOTS=( + "/Applications" + "/Applications/Setapp" + "$HOME/Applications" +) + +# Emit every alnum token found in installed .app filenames + brew casks, +# lowercased, one per line. Caller filters/dedups. +# shellcheck disable=SC2329 +_dotdir_owner_collect_tokens() { + # Collect every name first, then tokenize in one pass. Doing it per name cost + # a basename plus two tr processes each, so a machine with 30 apps spawned + # ~90 processes on a path that runs during every clean. + local root entry name + local -a names=() + for root in "${_MOLE_DOTDIR_OWNER_APP_ROOTS[@]}"; do + [[ -d "$root" ]] || continue + for entry in "$root"/*.app; do + [[ -e "$entry" ]] || continue + name="${entry##*/}" + names+=("${name%.app}") + done + done + + if command -v brew > /dev/null 2>&1; then + local cask_list="" + cask_list=$(HOMEBREW_NO_ENV_HINTS=1 run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" brew list --cask 2> /dev/null) || true + if [[ -n "$cask_list" ]]; then + local cask + while IFS= read -r cask; do + [[ -n "$cask" ]] && names+=("$cask") + done <<< "$cask_list" 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}" + # bash 3.2 under nounset rejects "${names[@]}" when the array is empty. + [[ ${#names[@]} -gt 0 ]] || return 0 + + printf '%s\n' "${names[@]}" | + LC_ALL=C tr '[:upper:]' '[:lower:]' | + LC_ALL=C tr -cs 'a-z0-9' '\n' +} + +# Return 0 if any ≥4-char token from `name` matches a token harvested from +# installed `.app` bundles or Homebrew casks. Cached for 5 minutes. Short +# tokens (<4 chars) on either side are ignored to avoid false matches like +# `.ai-old` vs `AI.app`. See issue #872. +# shellcheck disable=SC2329 +dotdir_has_owning_gui_app() { + local name="$1" + [[ -z "$name" ]] && return 1 + [[ ${#name} -lt 4 ]] && return 1 + + local cache_dir="$HOME/.cache/mole" + local cache_file="$cache_dir/installed_app_tokens_cache" + local cache_ttl=300 + local now + now=$(date +%s) + + local rebuild=1 + if [[ -f "$cache_file" ]]; then + local mtime + mtime=$(get_file_mtime "$cache_file" 2> /dev/null || echo 0) + if [[ -n "$mtime" ]] && [[ $((now - mtime)) -lt $cache_ttl ]]; then + rebuild=0 + fi + fi + if [[ $rebuild -eq 1 ]]; then + ensure_user_dir "$cache_dir" 2> /dev/null || true + _dotdir_owner_collect_tokens 2> /dev/null | + LC_ALL=C awk 'length($0) >= 4' | + LC_ALL=C sort -u > "$cache_file" 2> /dev/null || return 1 fi - echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge" + [[ -s "$cache_file" ]] || return 1 + + local name_lower + name_lower=$(printf '%s' "$name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + local tok + while IFS= read -r tok; do + [[ -z "$tok" ]] && continue + [[ ${#tok} -ge 4 ]] || continue + if LC_ALL=C grep -Fxq "$tok" "$cache_file" 2> /dev/null; then + return 0 + fi + done < <(printf '%s\n' "$name_lower" | LC_ALL=C tr -cs 'a-z0-9' '\n') + + return 1 +} + +# Collect Claude Code plugin name tokens (the segment before '@' in a +# "plugin@marketplace" identifier) from the user's plugin config. Plugins own +# state directories such as ~/.cc-safety-net without installing a matching PATH +# binary or GUI app, so their dotdirs must not be flagged as orphans. +hint_collect_claude_plugin_tokens() { + local settings="$HOME/.claude/settings.json" + local installed="$HOME/.claude/plugins/installed_plugins.json" + # `|| true` keeps a no-match grep (the common case: Claude Code installed + # but no plugins) from aborting under `set -euo pipefail`. + { + if [[ -f "$settings" ]]; then + plutil -extract enabledPlugins json -o - "$settings" 2> /dev/null | + LC_ALL=C grep -oE '"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+"' || true + fi + if [[ -f "$installed" ]]; then + LC_ALL=C grep -oE '"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+"' "$installed" 2> /dev/null || true + fi + } | sed -E 's/^"//; s/@.*$//' | LC_ALL=C sort -u +} + +# Return 0 when a dotdir name embeds an enabled Claude Code plugin token. +# e.g. dotdir "cc-safety-net" embeds plugin token "safety-net". +hint_dotdir_owned_by_claude_plugin() { + local dotdir_name="$1" + local tokens="$2" + [[ -n "$tokens" ]] || return 1 + local token + while IFS= read -r token; do + [[ ${#token} -ge 4 ]] || continue + if [[ "$dotdir_name" == *"$token"* ]]; then + return 0 + fi + done <<< "$tokens" + return 1 +} + +# Detect ~/. directories that may belong to uninstalled CLI tools. +# shellcheck disable=SC2329 +show_orphan_dotdir_hint_notice() { + local max_hits=5 + local age_days="${MOLE_DOTDIR_ORPHAN_AGE_DAYS:-60}" + local now + now=$(date +%s) + + local -a labels=() + local installed_gui_app_texts="" + local installed_gui_app_texts_loaded=false + local claude_plugin_tokens="" + local claude_plugin_tokens_loaded=false + + # Per-dotdir du probes take seconds in total; without a spinner the + # section looks hung. + start_section_spinner "Checking orphan dotfiles..." + + while IFS= read -r dotdir; do + [[ -d "$dotdir" ]] || continue + local basename + basename=$(basename "$dotdir") + + local is_safe=false + local safe_name + for safe_name in "${ORPHAN_DOTDIR_KNOWN_SAFE[@]}"; do + if [[ "$basename" == "$safe_name" ]]; then + is_safe=true + break + fi + done + [[ "$is_safe" == "true" ]] && continue + + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$dotdir"; then + continue + fi + + local mtime + mtime=$(get_file_mtime "$dotdir" 2> /dev/null) || continue + local age_d=$(((now - mtime) / 86400)) + [[ $age_d -lt $age_days ]] && continue + + local name="${basename#.}" + local -a candidates=("$name") + local dehyphen="${name//-/_}" + [[ "$dehyphen" != "$name" ]] && candidates+=("$dehyphen") + local stripped="${name//-/}" + [[ "$stripped" != "$name" && "$stripped" != "$dehyphen" ]] && candidates+=("$stripped") + local no_suffix="${name%-cli}" + [[ "$no_suffix" != "$name" ]] && candidates+=("$no_suffix") + no_suffix="${name%-temp}" + [[ "$no_suffix" != "$name" ]] && candidates+=("$no_suffix") + no_suffix="${name%-data}" + [[ "$no_suffix" != "$name" ]] && candidates+=("$no_suffix") + + local has_binary=false + local c + for c in "${candidates[@]}"; do + if command -v "$c" > /dev/null 2>&1; then + has_binary=true + break + fi + done + [[ "$has_binary" == "true" ]] && continue + + if [[ "$claude_plugin_tokens_loaded" != "true" ]]; then + claude_plugin_tokens=$(hint_collect_claude_plugin_tokens) + claude_plugin_tokens_loaded=true + fi + if hint_dotdir_owned_by_claude_plugin "$name" "$claude_plugin_tokens"; then + continue + fi + + if [[ "$installed_gui_app_texts_loaded" != "true" ]]; then + installed_gui_app_texts=$(hint_collect_installed_gui_app_match_texts) + installed_gui_app_texts_loaded=true + fi + if hint_dotdir_owned_by_installed_gui_app "$installed_gui_app_texts" "${candidates[@]}"; then + continue + fi + + if [[ -d "$HOME/Library/LaunchAgents" ]]; then + if run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" grep -rlq "$basename" "$HOME/Library/LaunchAgents/" 2> /dev/null; then + continue + fi + fi + + if dotdir_has_owning_gui_app "$name"; then + continue + fi + + local size_note="" + local size_kb + if size_kb=$(hint_get_path_size_kb_with_timeout "$dotdir" 0.8); then + # Empty dotdirs reclaim nothing, so reviewing them is not worth a + # row. A failed probe (timeout) means the dir is big: keep it. + if [[ "$size_kb" -eq 0 ]]; then + continue + fi + size_note=" $(bytes_to_human $((size_kb * 1024)))" + fi + + # shellcheck disable=SC2088 + labels+=("~/${basename}${size_note}") + + if [[ ${#labels[@]} -ge $max_hits ]]; then + break + fi + done < <(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" find "$HOME" -maxdepth 1 -mindepth 1 -type d -name '.*' 2> /dev/null | LC_ALL=C sort) + + stop_section_spinner + [[ ${#labels[@]} -eq 0 ]] && return 0 + + note_activity + + # One compact row for the whole finding: label first, entries as detail. + local joined="" entry + for entry in "${labels[@]}"; do + joined+="${joined:+ · }${entry}" + done + echo -e " ${YELLOW}${ICON_WARNING}${NC} Orphan dotfiles · ${GRAY}${joined}${NC}" } diff --git a/Resources/mole/lib/clean/launch_services.sh b/Resources/mole/lib/clean/launch_services.sh new file mode 100644 index 0000000..3d977ad --- /dev/null +++ b/Resources/mole/lib/clean/launch_services.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# LaunchServices cleanup helpers for `mo clean`. + +set -euo pipefail + +# shellcheck disable=SC2329 +launch_services_extract_app_path_from_line() { + local line="$1" + [[ "$line" == */*".app"* ]] || return 1 + + local path="/${line#*/}" + path="${path%%.app*}.app" + path="${path%/}" + + [[ "$path" == /* && "$path" == *.app ]] || return 1 + printf '%s\n' "$path" +} + +# shellcheck disable=SC2329 +launch_services_stale_app_path_is_safe() { + local path="$1" + + [[ -n "$path" ]] || return 1 + [[ "$path" == /* ]] || return 1 + [[ "$path" == *.app ]] || return 1 + [[ "$path" != *$'\n'* && "$path" != *$'\r'* ]] || return 1 + + case "$path" in + *"/../"* | *"/.." | "../"* | "/System/"* | "/Library/Apple/"*) + return 1 + ;; + esac + + [[ ! -e "$path" ]] +} + +# shellcheck disable=SC2329 +launch_services_emit_missing_record_paths() { + local -a record_paths=() + local missing_record=false + local line app_path + + _flush_launch_services_record() { + if [[ "$missing_record" == "true" && ${#record_paths[@]} -gt 0 ]]; then + local record_path + for record_path in "${record_paths[@]}"; do + if launch_services_stale_app_path_is_safe "$record_path"; then + printf '%s\n' "$record_path" + fi + done + fi + record_paths=() + missing_record=false + } + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -z "$line" ]]; then + _flush_launch_services_record + continue + fi + if [[ "$line" =~ ^[[:space:]]*bundle[[:space:]] ]]; then + _flush_launch_services_record + fi + + if [[ "$line" == *"Bundle node not found on disk"* ]]; then + missing_record=true + fi + + if app_path=$(launch_services_extract_app_path_from_line "$line"); then + record_paths+=("$app_path") + fi + done + + _flush_launch_services_record +} + +# shellcheck disable=SC2329 +collect_stale_launch_services_app_paths() { + local lsregister="$1" + + [[ -x "$lsregister" ]] || return 0 + + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -dump 2> /dev/null | + launch_services_emit_missing_record_paths | + LC_ALL=C sort -u +} + +# shellcheck disable=SC2329 +clean_stale_launch_services_registrations() { + local lsregister + lsregister=$(get_lsregister_path) + [[ -x "$lsregister" ]] || return 0 + + local candidates_file + candidates_file=$(mktemp_file "launch_services_stale_apps") || return 0 + + # The lsregister dump below takes seconds on a large LaunchServices + # database; without a spinner the section looks hung (per-step loading + # feedback). + start_section_spinner "Scanning launch services..." + if ! collect_stale_launch_services_app_paths "$lsregister" > "$candidates_file"; then + stop_section_spinner + debug_log "LaunchServices stale app scan failed" + return 0 + fi + + local max_items="${MOLE_LAUNCH_SERVICES_STALE_LIMIT:-50}" + [[ "$max_items" =~ ^[0-9]+$ ]] || max_items=50 + [[ "$max_items" -gt 0 ]] || max_items=50 + + local -a stale_apps=() + local app_path + while IFS= read -r app_path; do + [[ -n "$app_path" ]] || continue + if launch_services_stale_app_path_is_safe "$app_path"; then + stale_apps+=("$app_path") + if [[ ${#stale_apps[@]} -ge "$max_items" ]]; then + break + fi + fi + done < "$candidates_file" + + stop_section_spinner + [[ ${#stale_apps[@]} -gt 0 ]] || return 0 + + note_activity + + local count="${#stale_apps[@]}" + local count_label="$count" + local total_candidates + total_candidates=$(wc -l < "$candidates_file" | tr -d '[:space:]') + if [[ "$total_candidates" =~ ^[0-9]+$ && "$total_candidates" -gt "$count" ]]; then + count_label="${count}+" + fi + + if [[ "${DRY_RUN:-false}" == "true" || "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} LaunchServices stale app registrations · would unregister ${count_label}" + echo -e " ${GRAY}${ICON_SUBLIST}${NC} Example: ${GRAY}${stale_apps[0]/#$HOME/~}${NC}" + return 0 + fi + + local success_count=0 + local failed_count=0 + start_section_spinner "Cleaning stale launch services..." + for app_path in "${stale_apps[@]}"; do + debug_log "Unregistering stale LaunchServices app: $app_path" + if run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" "$lsregister" -u "$app_path" > /dev/null 2>&1; then + success_count=$((success_count + 1)) + else + failed_count=$((failed_count + 1)) + debug_log "Failed to unregister stale LaunchServices app: $app_path" + fi + done + stop_section_spinner + + if [[ $success_count -gt 0 ]]; then + log_success "LaunchServices stale app registrations, $success_count removed" + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} LaunchServices stale app registrations, ${failed_count} failed" + fi +} 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..8db5c1c 100644 --- a/Resources/mole/lib/clean/system.sh +++ b/Resources/mole/lib/clean/system.sh @@ -1,13 +1,73 @@ #!/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="" + recent_file=$(command find "$cache_dir" -type f -mtime "-$age_days" -print -quit 2> /dev/null) || 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}" + [[ -f "$plist" ]] || return 0 + local recommended="" + if ! recommended=$(plutil -extract RecommendedUpdates json -o - "$plist" 2> /dev/null); 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 +} + # System caches, logs, and temp files. clean_deep_system() { stop_section_spinner local cache_cleaned=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 + if sudo -n test -d "/Library/Caches" 2> /dev/null; then while IFS= read -r -d '' file; do if should_protect_path "$file"; then continue @@ -15,7 +75,7 @@ clean_deep_system() { if safe_sudo_remove "$file"; then cache_cleaned=1 fi - done < <(sudo find "/Library/Caches" -maxdepth 5 -type f \( \ + done < <(sudo -n 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" \) \ @@ -27,7 +87,7 @@ clean_deep_system() { 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 sudo -n 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 @@ -36,13 +96,13 @@ clean_deep_system() { stop_section_spinner [[ $tmp_cleaned -eq 1 ]] && log_success "System temp files" 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 + if sudo -n 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 fi stop_section_spinner log_success "System crash reports" 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 + if sudo -n 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 @@ -57,69 +117,42 @@ clean_deep_system() { local third_party_logs_cleaned=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 sudo -n test -d "$third_party_log_dir" 2> /dev/null; then + if sudo -n 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 fi 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 sudo -n 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 third_party_logs_cleaned=1 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 - 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. + [[ -d "/Library/Updates" ]] && debug_log "Keeping /Library/Updates: managed by Software Update" + [[ -d "/macOS Install Data" ]] && debug_log "Keeping macOS Install Data: managed by Software Update" + 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 - fi # 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 current_macos_version="" + current_macos_version=$(sw_vers -productVersion 2> /dev/null | cut -d. -f1 || true) for installer_app in /Applications/Install\ macOS*.app; do [[ -d "$installer_app" ]] || continue + # Same fail-closed gate as /macOS Install Data: a queued Software + # Update may be about to use this installer. + if software_update_pending_or_unknown; then + debug_log "Skipping macOS installer app cleanup: Software Update pending or state unknown" + break + fi local app_name app_name=$(basename "$installer_app") # Skip if installer is currently running @@ -127,6 +160,19 @@ clean_deep_system() { debug_log "Skipping $app_name: currently running" continue fi + # Skip if this installer matches the current macOS major version. + # Users may need it for recovery or reinstallation. + if [[ -n "$current_macos_version" ]]; then + local installer_plist="$installer_app/Contents/Info.plist" + if [[ -f "$installer_plist" ]]; then + local installer_version="" + installer_version=$(/usr/libexec/PlistBuddy -c "Print :DTPlatformVersion" "$installer_plist" 2> /dev/null | cut -d. -f1 || true) + if [[ -n "$installer_version" && "$installer_version" == *"$current_macos_version"* ]]; then + debug_log "Keeping $app_name: matches current macOS version ($current_macos_version)" + continue + fi + fi + fi # Check age (same 14-day threshold as /macOS Install Data) local mtime mtime=$(get_file_mtime "$installer_app") @@ -152,13 +198,96 @@ clean_deep_system() { start_section_spinner "Scanning browser code signature caches..." local code_sign_cleaned=0 while IFS= read -r -d '' cache_dir; do + # Never delete an EDR agent's code-signature clone -- same sensor-tamper + # risk as its caches below. Browsers are the intended target here. + if is_endpoint_security_cache_path "$cache_dir"; then + continue + fi if safe_sudo_remove "$cache_dir"; then code_sign_cleaned=$((code_sign_cleaned + 1)) fi - done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) + done < <(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" command find /private/var/folders -maxdepth 5 -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) stop_section_spinner [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" + start_section_spinner "Cleaning rebuildable system service caches..." + local rebuildable_cache_cleaned=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 sudo -n test -e "$rebuildable_cache_dir" 2> /dev/null; then + if safe_sudo_remove "$rebuildable_cache_dir"; then + rebuildable_cache_cleaned=$((rebuildable_cache_cleaned + 1)) + fi + fi + done + stop_section_spinner + if [[ $rebuildable_cache_cleaned -gt 0 ]]; then + local rebuildable_cache_label="items" + [[ $rebuildable_cache_cleaned -eq 1 ]] && rebuildable_cache_label="item" + log_success "Rebuildable system caches, $rebuildable_cache_cleaned $rebuildable_cache_label" + fi + + start_section_spinner "Scanning accessible rebuildable GPU caches..." + local gpu_cache_cleaned=0 + local gpu_cache_dir="" + while IFS= read -r -d '' gpu_cache_dir; do + is_rebuildable_gpu_cache_dir "$gpu_cache_dir" || continue + # Endpoint-security/EDR agents (CrowdStrike Falcon, SentinelOne, ...) + # tamper-protect their cache container, so deleting even a rebuildable + # Metal shader cache inside it raises a sensor-tamper alert reported as + # malware. Skip only those; every other app's GPU cache stays cleanable. + # Use the dedicated EDR predicate here, not should_protect_path(): this + # loop targets com.apple.metal* dirs, and should_protect_path() can treat + # those rebuildable cache names as protected (via should_protect_data), + # which would suppress the whole sweep. + if is_endpoint_security_cache_path "$gpu_cache_dir"; then + continue + fi + gpu_cache_dir_is_stale "$gpu_cache_dir" "$MOLE_GPU_CACHE_AGE_DAYS" || continue + if safe_sudo_remove "$gpu_cache_dir"; then + gpu_cache_cleaned=$((gpu_cache_cleaned + 1)) + fi + done < <(run_with_timeout 8 command find /private/var/folders -maxdepth 8 -type d \( \ + -name "com.apple.gpuarchiver" -o \ + -name "com.apple.metal" -o \ + -name "com.apple.metalfe" \ + \) -path "*/C/*" -print0 2> /dev/null || true) # 8s: deep /private/var/folders walk, see lib/core/timeouts.sh + stop_section_spinner + if [[ $gpu_cache_cleaned -gt 0 ]]; then + local gpu_cache_label="items" + [[ $gpu_cache_cleaned -eq 1 ]] && gpu_cache_label="item" + log_success "Accessible rebuildable GPU caches, $gpu_cache_cleaned $gpu_cache_label" + 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_dir="" + while IFS= read -r -d '' idle_tmp_dir; do + # Only act when a stale aborted download actually exists, so the summary + # line stays truthful and the delete is skipped otherwise. + if sudo -n find "$idle_tmp_dir" -maxdepth 5 -type f -name "CFNetworkDownload_*.tmp" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then + if safe_sudo_find_delete "$idle_tmp_dir" "CFNetworkDownload_*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f"; then + idle_tmp_cleaned=$((idle_tmp_cleaned + 1)) + fi + fi + done < <(run_with_timeout 8 sudo -n find /private/var/folders -maxdepth 5 -type d -name "com.apple.idleassetsd" -path "*/T/*" -print0 2> /dev/null || true) # 8s: deep root-owned /private/var/folders walk + stop_section_spinner + [[ $idle_tmp_cleaned -gt 0 ]] && log_success "Stale wallpaper downloads" + 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 @@ -174,13 +303,13 @@ clean_deep_system() { 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 + if sudo -n 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) + stats_out=$(sudo -n 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)) @@ -198,6 +327,7 @@ clean_deep_system() { log_operation "clean" "REMOVED" "$mem_reports_dir" "$file_count files, $size_human" fi else + safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f" || true log_info "[DRY-RUN] Would remove $file_count old memory exception reports ($total_size_kb KB)" fi fi @@ -212,37 +342,50 @@ clean_deep_system() { 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") + tm_info=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" tmutil destinationinfo 2>&1 || echo "failed") if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; 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 -eq 0 || $rc_tm_running -eq 2 ]]; 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 ]]; 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,7 +405,7 @@ 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 @@ -270,7 +413,7 @@ clean_time_machine_failed_backups() { fi 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") + fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") # 1s: volume FS-type probe, see lib/core/timeouts.sh case "$fs_type" in nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; esac @@ -281,6 +424,10 @@ clean_time_machine_failed_backups() { # Only delete old incomplete backups (safety window). local file_mtime file_mtime=$(get_file_mtime "$inprogress_file") + # 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)) @@ -299,17 +446,23 @@ 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 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,8 +470,11 @@ 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 fi - done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) # 15s: Time Machine backupdb find, see lib/core/timeouts.sh fi # APFS bundles. for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do @@ -333,6 +489,8 @@ clean_time_machine_failed_backups() { [[ -d "$inprogress_file" ]] || continue local file_mtime file_mtime=$(get_file_mtime "$inprogress_file") + # 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)) @@ -351,7 +509,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 @@ -360,7 +521,9 @@ clean_time_machine_failed_backups() { 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 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,8 +531,10 @@ 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 fi - done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) # 15s: TM sparsebundle inner find, see lib/core/timeouts.sh fi done done @@ -377,7 +542,7 @@ clean_time_machine_failed_backups() { 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 } # Returns 0 if a backup is actively running. @@ -412,27 +577,28 @@ clean_local_snapshots() { 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) + snapshot_list=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" tmutil listlocalsnapshots / 2> /dev/null || true) stop_section_spinner [[ -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_WARNING}${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..81155a8 100644 --- a/Resources/mole/lib/clean/user.sh +++ b/Resources/mole/lib/clean/user.sh @@ -1,47 +1,84 @@ #!/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" - - 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") - fi - [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" +clean_trash() { + if is_path_whitelisted "$HOME/.Trash"; then + return 0 + fi + stop_section_spinner - 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)) + # 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 + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + 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; then + continue fi + local trash_item_kb + trash_item_kb=$(get_path_size_kb "$trash_item" 2> /dev/null || echo "0") + [[ "$trash_item_kb" =~ ^[0-9]+$ ]] || trash_item_kb=0 + record_dry_run_cleanup_target "$trash_item" "$trash_item_kb" 1 true || true 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 - fi fi + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" fi + note_activity + return 0 + fi + + if [[ $trash_count -eq 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" + return 0 + fi + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Emptying trash, ${trash_count} items..." + 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" + + start_section_spinner "Cleaning runtime files..." + _clean_darwin_user_runtime_dirs + + if [[ "${MOLE_SKIP_TRASH_CLEANUP:-0}" != "1" ]]; then + clean_trash + fi + stop_section_spinner # Recent items _clean_recent_items @@ -71,12 +108,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 +151,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 @@ -106,7 +175,16 @@ _clean_mail_downloads() { 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 + 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,135 +198,276 @@ _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" - ) +_darwin_user_runtime_realpath() { + local runtime_dir="$1" + [[ -n "$runtime_dir" && -d "$runtime_dir" && ! -L "$runtime_dir" ]] || return 1 + (cd "$runtime_dir" 2> /dev/null && pwd -P) +} - # 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 +_darwin_user_runtime_dir_is_safe() { + local runtime_dir="$1" + local kind="$2" + local resolved="" + resolved=$(_darwin_user_runtime_realpath "$runtime_dir") || return 1 - local cleaned_count=0 - local total_size=0 - local cleaned_any=false + case "$kind:$resolved" in + temp:/private/var/folders/*/*/T | cache:/private/var/folders/*/*/C) ;; + *) + debug_log "Skipping unexpected Darwin user runtime dir: $runtime_dir -> $resolved" + return 1 + ;; + esac - for app_path in "${app_paths[@]}"; do - [[ -d "$app_path" ]] || continue + local owner_uid current_uid + owner_uid=$(stat -f%u "$resolved" 2> /dev/null || echo "") + current_uid=$(id -u 2> /dev/null || echo "") + [[ -n "$owner_uid" && "$owner_uid" == "$current_uid" ]] +} - local versions_dir="$app_path/Contents/Frameworks/Google Chrome Framework.framework/Versions" - [[ -d "$versions_dir" ]] || continue +_clean_darwin_user_runtime_dir() { + local runtime_dir="$1" + local kind="$2" + local label="$3" + local age_days="${MOLE_DARWIN_USER_RUNTIME_AGE_DAYS:-7}" + local max_items="${MOLE_DARWIN_USER_RUNTIME_MAX_ITEMS:-1500}" + local scan_timeout="${MOLE_DARWIN_USER_RUNTIME_SCAN_TIMEOUT:-8}" - local current_link="$versions_dir/Current" - [[ -L "$current_link" ]] || continue + [[ "$age_days" =~ ^[0-9]+$ ]] || age_days=7 + [[ "$max_items" =~ ^[0-9]+$ ]] || max_items=1500 + [[ "$scan_timeout" =~ ^[0-9]+$ ]] || scan_timeout=8 + [[ -d "$runtime_dir" ]] || return 0 + _darwin_user_runtime_dir_is_safe "$runtime_dir" "$kind" || return 0 - local current_version - current_version=$(readlink "$current_link" 2> /dev/null || true) - current_version="${current_version##*/}" - [[ -n "$current_version" ]] || continue + local current_uid + current_uid=$(id -u 2> /dev/null || echo "") + [[ -n "$current_uid" ]] || return 0 - 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 + local count=0 + local total_size_kb=0 + local hit_cap=false + local found_any=false + local item + + # Per-item should_protect_path / is_path_whitelisted are intentionally + # skipped here. _darwin_user_runtime_dir_is_safe has already vetted the + # parent (must be DARWIN_USER_TEMP_DIR or DARWIN_USER_CACHE_DIR, owned by + # the current UID), find narrows to -user "$current_uid" -mtime +N and + # excludes state files (sqlite/db/plist), and safe_remove still routes + # through validate_path_for_deletion. For 1500 capped items that drops + # ~3000 per-item subshells; on a 20k-item TMPDIR this is the difference + # between a 30s stall and an under-3s pass. + while IFS= read -r -d '' item; do + [[ -e "$item" && ! -L "$item" ]] || continue + case "$item" in + *.sqlite | *.sqlite-shm | *.sqlite-wal | *.db | *.plist) continue - fi - old_versions+=("$dir") - done + ;; + esac - if [[ ${#old_versions[@]} -eq 0 ]]; then + # Never touch endpoint-security/EDR agent caches (tamper detection), + # even when the file is user-owned and old enough to qualify here. + if is_endpoint_security_cache_path "$item"; 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 + local item_size_kb=0 + item_size_kb=$(get_path_size_kb "$item" 2> /dev/null || echo "0") + [[ "$item_size_kb" =~ ^[0-9]+$ ]] || item_size_kb=0 + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" "$item_size_kb" 1 true || continue + fi + found_any=true + count=$((count + 1)) + total_size_kb=$((total_size_kb + item_size_kb)) + elif safe_remove "$item" true "$item_size_kb" > /dev/null 2>&1; then + found_any=true + count=$((count + 1)) + total_size_kb=$((total_size_kb + item_size_kb)) + fi + + if [[ "$count" -ge "$max_items" ]]; then + hit_cap=true + break + fi + done < <( + run_with_timeout "$scan_timeout" \ + find -P "$runtime_dir" -xdev -mindepth 1 -user "$current_uid" -type f -mtime +"$age_days" \ + ! -name "*.sqlite" ! -name "*.sqlite-shm" ! -name "*.sqlite-wal" ! -name "*.db" ! -name "*.plist" \ + -print0 2> /dev/null || true + ) + + if [[ "$count" -lt "$max_items" ]]; then + # Same safety contract as the file loop above: parent vetted, + # find narrowed to current UID + age + -type d -empty, and safe_remove + # still validates. Do not re-add per-item should_protect_path here. + while IFS= read -r -d '' item; do + [[ -d "$item" && ! -L "$item" ]] || continue + if is_endpoint_security_cache_path "$item"; then + continue + fi + if [[ "${DRY_RUN:-false}" == "true" ]]; then + if declare -f record_dry_run_cleanup_target > /dev/null 2>&1; then + record_dry_run_cleanup_target "$item" 0 1 true || continue fi + found_any=true + count=$((count + 1)) + elif safe_remove "$item" true "0" > /dev/null 2>&1; then + found_any=true + count=$((count + 1)) fi - done - done + if [[ "$count" -ge "$max_items" ]]; then + hit_cap=true + break + fi + done < <( + run_with_timeout "$scan_timeout" \ + find -P "$runtime_dir" -xdev -mindepth 1 -user "$current_uid" -type d -empty -mtime +"$age_days" -print0 2> /dev/null || true + ) + fi - if [[ "$cleaned_any" == "true" ]]; then + if [[ "$found_any" == "true" ]]; then + stop_section_spinner 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}" + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + local cap_note="" + [[ "$hit_cap" == "true" ]] && cap_note=", capped" + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${label}${NC} · ${YELLOW}${count} old items, $(colorize_human_size "$size_human") ${YELLOW}dry${cap_note}${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_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} ${label}${NC} · ${line_color}${count} old items, ${size_human}${cap_note}${NC}" fi - files_cleaned=$((files_cleaned + cleaned_count)) - total_size_cleaned=$((total_size_cleaned + total_size)) + files_cleaned=$((files_cleaned + count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) total_items=$((total_items + 1)) note_activity fi } -# 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" - else - app_paths=( - "/Applications/Microsoft Edge.app" - "$HOME/Applications/Microsoft Edge.app" - ) +_clean_darwin_user_runtime_dirs() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + [[ "${MOLE_ENABLE_DARWIN_RUNTIME_CLEANUP_IN_TESTS:-0}" == "1" ]] || return 0 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" + local temp_dir="" + local cache_dir="" + temp_dir=$(getconf DARWIN_USER_TEMP_DIR 2> /dev/null || true) + cache_dir=$(getconf DARWIN_USER_CACHE_DIR 2> /dev/null || true) + + _clean_darwin_user_runtime_dir "$temp_dir" "temp" "Darwin user temp files" + # _clean_darwin_user_runtime_dir stops the section spinner before printing + # its result line; restart it so the user does not see a silent gap while + # the cache scan and subsequent trash empty are running. + start_section_spinner "Cleaning runtime files..." + _clean_darwin_user_runtime_dir "$cache_dir" "cache" "Darwin user cache files" + start_section_spinner "Cleaning runtime files..." +} + +# 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=("$@") + + if "$running_probe"; then + echo -e " ${GRAY}${ICON_WARNING}${NC} ${label} old versions · skipped (${label} running)" + note_activity return 0 fi local cleaned_count=0 local total_size=0 local cleaned_any=false + local app_path 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 + # Every silent skip below logs its reason: "old versions not removed" + # reports are undiagnosable without knowing which gate bailed (#1216). + local 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 + 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" ]] || continue name=$(basename "$dir") [[ "$name" == "Current" ]] && continue [[ "$name" == "$current_version" ]] && continue + [[ -n "$newest_version" && "$name" == "$newest_version" ]] && continue if is_path_whitelisted "$dir"; then continue fi @@ -256,6 +475,7 @@ clean_edge_old_versions() { done if [[ ${#old_versions[@]} -eq 0 ]]; then + debug_log "${label} old versions: nothing to remove in $versions_dir (Current=$current_version)" continue fi @@ -263,6 +483,9 @@ clean_edge_old_versions() { local size_kb size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb="${size_kb:-0}" + if [[ "$DRY_RUN" == "true" ]] && 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 @@ -280,9 +503,11 @@ clean_edge_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 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} Edge 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)) @@ -291,13 +516,63 @@ clean_edge_old_versions() { fi } +# Chrome also runs under a helper process name, so the probe is wider than pgrep -x. +is_google_chrome_running() { + pgrep -x "Google Chrome" > /dev/null 2>&1 && return 0 + pgrep -x "Google Chrome Helper" > /dev/null 2>&1 && return 0 + pgrep -f "/Google Chrome.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +# Exact process names only: "Microsoft Edge" must not match Microsoft Teams. +is_microsoft_edge_running() { + pgrep -x "Microsoft Edge" > /dev/null 2>&1 +} + +is_brave_browser_running() { + pgrep -x "Brave Browser" > /dev/null 2>&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() { + local -a app_paths + if [[ -n "${MOLE_EDGE_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_EDGE_APP_PATHS" + else + app_paths=( + "/Applications/Microsoft Edge.app" + "$HOME/Applications/Microsoft Edge.app" + ) + fi + + _clean_chromium_old_versions "Edge" "Microsoft Edge Framework.framework" \ + is_microsoft_edge_running "${app_paths[@]}" +} + # Remove old Microsoft EdgeUpdater versions while keeping latest. 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" + echo -e " ${GRAY}${ICON_WARNING}${NC} Edge updater old versions · skipped (Edge running)" + note_activity return 0 fi @@ -308,13 +583,32 @@ clean_edge_updater_old_versions() { 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 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 cleaned_count=0 local total_size=0 @@ -323,13 +617,25 @@ clean_edge_updater_old_versions() { for dir in "${version_dirs[@]}"; do local name name=$(basename "$dir") - [[ "$name" == "$latest_version" ]] && continue + 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 is_path_whitelisted "$dir"; then continue fi local size_kb size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb="${size_kb:-0}" + if [[ "$DRY_RUN" == "true" ]] && 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 @@ -342,9 +648,11 @@ 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)) @@ -353,54 +661,20 @@ clean_edge_updater_old_versions() { 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" - note_activity - fi - return 0 +# 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 - 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 + + _clean_chromium_old_versions "Brave" "Brave Browser Framework.framework" \ + is_brave_browser_running "${app_paths[@]}" } # Finder metadata (.DS_Store). @@ -427,20 +701,75 @@ clean_support_app_data() { 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" + # Clean system-level idle/aerial screensaver videos (macOS re-downloads as needed). + local sys_idle_assets_dir="/Library/Application Support/com.apple.idleassetsd/Customer" + # Skip sudo operations during tests to avoid password prompts + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + if sudo -n test -d "$sys_idle_assets_dir" 2> /dev/null; then + safe_sudo_find_delete "$sys_idle_assets_dir" "*" "$support_age_days" "f" || true + fi + fi # 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 +782,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 +797,72 @@ 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 for container_dir in "$containers_dir"/*; do + [[ -d "$container_dir/Data/Library/Caches" ]] || continue process_container_cache "$container_dir" done + # eval: restore shopt state captured by $(shopt -p) eval "$_ng_state" stop_section_spinner 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)) @@ -502,6 +871,63 @@ clean_app_caches() { fi clean_group_container_caches + clean_handoff_pasteboard_cache +} + +# 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 + item_kb=$(get_path_size_kb "$item" 2> /dev/null || echo 0) + [[ "$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 +935,129 @@ 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 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 + if declare -f register_dry_run_cleanup_target > /dev/null 2>&1; then + register_dry_run_cleanup_target "$item" || continue + fi + + local item_size_kb=0 + local size_known=false + if [[ "$precise_size_used" -lt "$precise_size_limit" ]]; then + item_size_kb=$(get_path_size_kb "$item" 2> /dev/null || echo "0") + [[ "$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 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 - size=$(get_path_size_kb "$cache_dir") + size=$(get_path_size_kb "$cache_dir" 2> /dev/null || echo "0") + [[ "$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 +1065,303 @@ 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 + item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 + [[ "$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 - # 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.}" + 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##*/}" - 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 + start_section_spinner "Scanning external volume..." - 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" - ) + 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 candidate - for candidate in "${candidates[@]}"; do - [[ -d "$candidate" ]] || continue - [[ -L "$candidate" ]] && continue - if is_path_whitelisted "$candidate" 2> /dev/null; then - continue + local size_kb + size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") + [[ "$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 "$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 + size_kb=$(get_path_size_kb "$metadata_file" 2> /dev/null || echo "0") + [[ "$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 +1369,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 +1389,266 @@ 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_running=false + pgrep -x "Google Chrome" > /dev/null 2>&1 && _chrome_running=true + if [[ "$_chrome_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/Code\ Cache/* "Chrome code cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/DawnCache/* "Chrome Dawn cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/GrShaderCache/* "Chrome GR shader cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/GraphiteDawnCache/* "Chrome Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/component_crx_cache/* "Chrome component CRX cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/ShaderCache/* "Chrome shader cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/GrShaderCache/* "Chrome GR shader cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/GraphiteDawnCache/* "Chrome Dawn cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/Crashpad/completed/* "Chrome crash reports" + # On-device AI model stores managed by Chrome's component updater; + # re-downloaded on demand and often multiple GB (#1179). + safe_clean ~/Library/Application\ Support/Google/Chrome/OptGuideOnDeviceModel/* "Chrome on-device model cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/OptGuideOnDeviceClassifierModel/* "Chrome on-device classifier cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/optimization_guide_model_store/* "Chrome optimization guide models" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome Application Support cache · skipped (Chrome running)" + 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" + 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_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" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox cache · skipped (Firefox running)" + note_activity else safe_clean ~/Library/Caches/Firefox/* "Firefox cache" 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" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox profile cache · skipped (Firefox running)" + note_activity else safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" fi clean_chrome_old_versions clean_edge_old_versions clean_edge_updater_old_versions + clean_brave_old_versions + # 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 } # 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 + if pgrep -x "Dropbox" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Dropbox cache · skipped (Dropbox running)" + note_activity + else + safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" + safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" + fi + if pgrep -x "Google Drive" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Google Drive cache · skipped (Google Drive running)" + note_activity + else + safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" + 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" + if pgrep -x "OneDrive" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} OneDrive cache · skipped (OneDrive running)" + note_activity + else + safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" + 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 +1658,89 @@ 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 + cache_size_kb=$(get_path_size_kb "$cache_root" 2> /dev/null || echo 0) + [[ "$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 + + if pgrep -x "tart" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Tart caches · skipped (Tart running)" + note_activity + 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 + if 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 + remaining_kb=$(get_path_size_kb "$cache_root" 2> /dev/null || echo 0) + [[ "$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 +1784,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 +1799,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 +1854,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 +1877,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 +1913,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 +1933,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 +1951,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 +2004,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 +2023,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 +2038,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 +2076,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 +2085,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 +2104,141 @@ 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 + size_kb=$(get_path_size_kb "$ipsw" || echo 0) + 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" + 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" + 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 + for root in "${roots[@]}"; do + [[ -d "$root" ]] || continue + while IFS= read -r -d '' container; do + size_kb=$(get_path_size_kb "$container" 2> /dev/null || echo 0) + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + [[ "$size_kb" -ge "$threshold_kb" ]] || continue + echo -e " ${YELLOW}${ICON_WARNING}${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 } @@ -1068,6 +2247,47 @@ check_large_file_candidates() { local threshold_kb=$((1024 * 1024)) # 1GB local found_any=false + _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" + } + + # One row per large item: "label · size · path". The ◎ 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" + stop_section_spinner + echo -e " ${YELLOW}${ICON_WARNING}${NC} ${label} · ${GREEN}${size_human}${NC} · ${GRAY}$(format_path_link "$path")${NC}" + found_any=true + start_section_spinner "Scanning large files..." + } + + _report_large_review_dir() { + local label="$1" + local path="$2" + local probe_timeout="${3:-}" + [[ -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))") + _report_large_review_row "$label" "$size_human" "$path" + } + + # 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 @@ -1075,8 +2295,7 @@ check_large_file_candidates() { 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 @@ -1087,8 +2306,7 @@ check_large_file_candidates() { 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 @@ -1100,8 +2318,7 @@ check_large_file_candidates() { 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 @@ -1113,50 +2330,92 @@ check_large_file_candidates() { 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_WARNING}${NC} Time Machine local snapshots · ${GREEN}${snapshot_count}${NC}" found_any=true + start_section_spinner "Scanning large files..." fi fi fi 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_WARNING}${NC} Docker storage · ${GRAY}${docker_detail}${NC}" + found_any=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_WARNING}${NC} Docker storage · ${GRAY}docker system df${NC}" found_any=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" - fi + _report_large_review_dir "Xcode archives" "$HOME/Library/Developer/Xcode/Archives" + # 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" + _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 _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..d053d51 --- a/Resources/mole/lib/core/app_protection.sh +++ b/Resources/mole/lib/core/app_protection.sh @@ -17,545 +17,10 @@ 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" # Centralized check for critical system components (case-insensitive) is_critical_system_component() { @@ -575,10 +40,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 +76,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 +91,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 +108,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 +152,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 +179,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 +189,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 +216,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 +310,24 @@ 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 - # 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 + if is_orbstack_runtime_path "$path"; then return 0 fi - # Additional check for com.apple.Settings (macOS Sequoia System Settings) - if [[ "$path_lower" =~ com\.apple\.settings ]]; 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 +347,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 +362,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 +379,133 @@ 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 + ;; + # 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" ]]; 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 +519,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 +540,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 +573,222 @@ 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 +} + +find_vendor_nested_app_paths() { + local bundle_id="$1" + local app_name="$2" + shift 2 + + [[ -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 root candidate parent_dir parent_base parent_lower child_base child_lower + for root in "$@"; do + [[ -d "$root" ]] || continue + 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 + printf '%s\n' "$candidate" + fi + done < <(command find "$root" -mindepth 2 -maxdepth 2 -type d -print0 2> /dev/null) + done | sort -u +} + +find_shared_app_paths() { + local bundle_id="$1" + local app_name="$2" + shift 2 + + [[ -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 root candidate base lower_base + for root in "$@"; do + [[ -d "$root" ]] || continue + 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 + printf '%s\n' "$candidate" + fi + done < <(command find "$root" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) + done | sort -u +} + +# 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 + + [[ -n "$app_path" && -d "$app_path/Contents" ]] || return 0 + [[ "$app_path" == /* && "$app_path" != *$'\n'* && "$app_path" != *"/.."* ]] || return 0 + + 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 + printf '%s\n' "$embedded_id" + done < <(command find "$app_path/Contents" -maxdepth 12 -type f -path "*/Contents/Info.plist" -print0 2> /dev/null || true) | sort -u +} + # Locate files associated with an application find_app_files() { local bundle_id="$1" local app_name="$2" + local app_path="${3:-}" # Early validation: require at least one valid identifier # Skip scanning if both bundle_id and app_name are invalid @@ -929,45 +820,72 @@ 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 # Add all naming variants to cover inconsistent app directory naming # Issue #377: Apps create directories with various naming conventions @@ -977,12 +895,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 +923,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 +935,16 @@ 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 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) 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 +955,144 @@ 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 + while IFS= read -r vendor_nested_path; do + [[ -n "$vendor_nested_path" && -e "$vendor_nested_path" ]] && files_to_clean+=("$vendor_nested_path") + done < <( + find_vendor_nested_app_paths "$bundle_id" "$app_name" \ + "$HOME/Library/Application Support" \ + "$HOME/Library/Caches" \ + "$HOME/Library/Logs" + ) + 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/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id") [[ -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) + if mole_name_starts_with_bundle_id_boundary "$pref" "$bundle_id"; then + files_to_clean+=("$pref") + fi + done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 -type f -name "*.plist" -print0 2> /dev/null) + + # User LaunchAgents: wildcard scan for helper plists (e.g., com.example.app.helper.plist) + [[ -d ~/Library/LaunchAgents ]] && while IFS= read -r -d '' plist; do + files_to_clean+=("$plist") + done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) + + # 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 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 < <(command find ~/Library/Group\ Containers -maxdepth 1 -type d -print0 2> /dev/null) 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 + while IFS= read -r -d '' derived_path; do + mole_name_has_bundle_id_boundary "$derived_path" "$bundle_id" || continue + already_added=false + for existing_path in "${files_to_clean[@]}"; do + if [[ "$existing_path" == "$derived_path" ]]; then + already_added=true + break + fi + done + [[ "$already_added" == "true" ]] || files_to_clean+=("$derived_path") + done < <(command find "$derived_root" -maxdepth 1 -type d -print0 2> /dev/null) + 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 + while IFS= read -r -d '' sfl_file; do + files_to_clean+=("$sfl_file") + done < <(command find "$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 2> /dev/null) + 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 + 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 + [[ -d "$HOME/Library/Preferences/ByHost" ]] && while IFS= read -r -d '' embedded_candidate; do + files_to_clean+=("$embedded_candidate") + done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${embedded_id}.*.plist" -print0 2> /dev/null) + done < <(_mole_uninstall_embedded_bundle_ids "$app_path" "$bundle_id") fi # Launch Agents by name (special handling) @@ -1055,10 +1101,10 @@ 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 while IFS= read -r -d '' plist; do @@ -1072,53 +1118,123 @@ find_app_files() { 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) 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) 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 +1243,12 @@ 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 files_to_clean+=("$p") - done < <(command find "$dir" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + done < <(command find "$dir" -maxdepth 1 -type d -iname "*raycast*" ! -iname "*raycast-x*" -print0 2> /dev/null) done # Explicit Raycast container directories (hardcoded leftovers) @@ -1140,13 +1258,23 @@ find_app_files() { # 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) + done < <(command find "$HOME/Library/Caches" -maxdepth 2 -type d -iname "*raycast*" ! -iname "*raycast-x*" -print0 2> /dev/null) # 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) + done < <(command find "$vscode_global" -maxdepth 1 -type d -iname "*raycast*" ! -iname "*raycast-x*" -print0 2> /dev/null) + 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 + while IFS= read -r -d '' cr; do + files_to_clean+=("$cr") + done < <(command find "$crash_reporter_dir" -maxdepth 1 -type f \ + \( -name "${app_name}_*.plist" -o -name "${nospace_name}_*.plist" \) \ + -print0 2> /dev/null) fi # Output results @@ -1168,13 +1296,20 @@ 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 @@ -1182,19 +1317,24 @@ get_diagnostic_report_paths_for_app() { [[ -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 + find "$dir_abs" -maxdepth 1 -type f -print0 2> /dev/null || true ) return 0 } @@ -1209,7 +1349,10 @@ find_app_system_files() { 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 +1361,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 +1378,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 +1392,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 +1411,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,10 +1420,49 @@ 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 + 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 < <( + find_vendor_nested_app_paths "$bundle_id" "$app_name" \ + "/Library/Application Support" \ + "/Library/Caches" \ + "/Library/Logs" + ) + + # 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 + while IFS= read -r shared_app_path; do + [[ -n "$shared_app_path" && -e "$shared_app_path" ]] && system_files+=("$shared_app_path") + done < <(find_shared_app_paths "$bundle_id" "$app_name" "/Users/Shared") + + # 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 + system_files+=("$plist") + done < <(command find "$base" -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) + 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" ]] && 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 @@ -1275,21 +1470,52 @@ find_app_system_files() { # 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 + if mole_is_reverse_dns_bundle_id "$bundle_id"; 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_name_starts_with_bundle_id_boundary "$helper" "$bundle_id"; then + system_files+=("$helper") + fi + done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 -print0 2> /dev/null) [[ -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 mole_name_starts_with_bundle_id_boundary "$receipt" "$bundle_id"; then + system_files+=("$receipt") + fi + done < <(command find /private/var/db/receipts -maxdepth 1 -print0 2> /dev/null) + fi + + # 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 + 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 < <(command find /Library/PrivilegedHelperTools -maxdepth 1 -print0 2> /dev/null) fi - # Raycast system-level files + # 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) + done < <(command find "/Library/Application Support" -maxdepth 1 -type d -iname "*raycast*" ! -iname "*raycast-x*" -print0 2> /dev/null) fi local receipt_files="" @@ -1319,9 +1545,8 @@ find_app_receipt_files() { # 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 @@ -1333,8 +1558,10 @@ find_app_receipt_files() { # Usually in /var/db/receipts/ if [[ -d /private/var/db/receipts ]]; then 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 < <(find /private/var/db/receipts -maxdepth 1 -name "*.bom" -print0 2> /dev/null) fi # Process bom files if any found @@ -1366,31 +1593,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 @@ -1413,9 +1616,41 @@ find_app_receipt_files() { fi } -# 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,42 +1659,98 @@ 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 return 0 fi - # Try graceful termination first + # 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 ]] && pgrep -x "$match_pattern" > /dev/null 2>&1; do + sleep 0.1 + ((quit_wait--)) + done + wait "$quit_pid" 2> /dev/null || true + fi + + # Graceful Quit landed: skip the kill ladder entirely. + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + return 0 + fi + + # 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. pkill -x "$match_pattern" 2> /dev/null || true sleep 2 - - # Check again after graceful kill if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then return 0 fi - # Force kill if still running pkill -9 -x "$match_pattern" 2> /dev/null || true sleep 2 + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; 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 + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]] && + sudo -n true 2> /dev/null; then + sudo pkill -9 -x "$match_pattern" 2> /dev/null || true + sleep 2 fi - # Final check with longer timeout for stubborn processes + # 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 @@ -1469,7 +1760,6 @@ force_kill_app() { ((retries--)) done - # Still running after all attempts pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 } 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..be2f227 --- /dev/null +++ b/Resources/mole/lib/core/app_protection_data.sh @@ -0,0 +1,591 @@ +#!/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 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..3d26bac 100644 --- a/Resources/mole/lib/core/base.sh +++ b/Resources/mole/lib/core/base.sh @@ -10,19 +10,40 @@ 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;90m" + readonly NC="${ESC}[0m" +fi # ============================================================================ # Icon Definitions @@ -48,7 +69,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 +99,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 +130,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,9 +144,6 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( ) declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=( - "check_brew_health" - "check_touchid" - "check_git_config" ) # ============================================================================ @@ -171,23 +203,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 +219,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 +293,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 +465,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 +497,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 +610,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 +800,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 +809,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 +828,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 +840,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 +856,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 +887,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 +932,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 +970,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 +1013,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 diff --git a/Resources/mole/lib/core/bundle_resolver.sh b/Resources/mole/lib/core/bundle_resolver.sh new file mode 100644 index 0000000..31d5557 --- /dev/null +++ b/Resources/mole/lib/core/bundle_resolver.sh @@ -0,0 +1,111 @@ +#!/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 + +# Standard locations for installed apps on macOS. Overridable from tests. +_MOLE_BUNDLE_RESOLVER_APP_ROOTS=( + "/Applications" + "/Applications/Setapp" + "/Applications/Utilities" + "$HOME/Applications" +) + +# 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" + [[ -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 + # `|| true` guards against two failure modes under `set -e` + `pipefail`: + # run_with_timeout returning 124 on timeout, and mdfind itself exiting + # non-zero. Both must fall through to the filesystem scan below. + local hit="" + if declare -f run_with_timeout > /dev/null 2>&1; then + hit=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1) || true + else + hit=$(mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1) || true + 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 + while IFS= read -r -d '' app; do + if [[ -e "$app/Contents/Library/LaunchServices/$bundle_id" ]]; then + return 0 + fi + info="$app/Contents/Info.plist" + [[ -f "$info" ]] || continue + app_bundle=$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || echo "") + [[ -n "$app_bundle" ]] || continue + local app_bundle_lower + app_bundle_lower=$(printf '%s' "$app_bundle" | tr '[:upper:]' '[:lower:]') + [[ "$app_bundle_lower" == "$bundle_id_lower" ]] && return 0 + [[ -n "$parent_id_lower" && "$app_bundle_lower" == "$parent_id_lower" ]] && return 0 + if ((${#mapped_app_bundles[@]} > 0)); then + local mapped_bundle + for mapped_bundle in "${mapped_app_bundles[@]}"; do + [[ "$app_bundle" == "$mapped_bundle" ]] && return 0 + done + fi + done < <(find "$app_root" -maxdepth 1 -name "*.app" -print0 2> /dev/null) + 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..6600227 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,21 +121,27 @@ 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}') + 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=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') 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}') + 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=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-$current_version}" @@ -128,8 +178,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 +208,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..9fdf790 100644 --- a/Resources/mole/lib/core/file_ops.sh +++ b/Resources/mole/lib/core/file_ops.sh @@ -14,6 +14,8 @@ 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 # Ensure dependencies are loaded _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -63,6 +65,130 @@ 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 + + local trimmed="${path%/}" + [[ -n "$trimmed" ]] && printf '%s\n' "$trimmed" || printf '%s\n' "$path" +} + +_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 +199,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 +240,87 @@ 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 + # 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 +331,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 +353,50 @@ 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="" + if measured_size=$(get_path_size_kb "$path" 2> /dev/null) && [[ "$measured_size" =~ ^[0-9]+$ ]]; then + size_kb="$measured_size" + else + size_known=false + fi + fi + + record_dry_run_cleanup_target "$path" "$size_kb" 1 "$size_known" || true +} + # Safe wrapper around rm -rf with validation safe_remove() { local path="$1" local silent="${2:-false}" + local precomputed_size_kb="${3:-}" - # 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 @@ -196,6 +407,7 @@ safe_remove() { # Dry-run mode: log but don't delete if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + _record_file_ops_dry_run_target "$path" "$precomputed_size_kb" if [[ "${MO_DEBUG:-}" == "1" ]]; then local file_type="file" [[ -d "$path" ]] && file_type="directory" @@ -231,15 +443,18 @@ 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 + if [[ -n "$precomputed_size_kb" && "$precomputed_size_kb" =~ ^[0-9]+$ ]]; then + size_kb="$precomputed_size_kb" + elif [[ -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") - 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 @@ -283,14 +498,38 @@ safe_remove_symlink() { 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 + _record_file_ops_dry_run_target "$path" debug_log "[DRY RUN] Would remove symlink: $path" return 0 fi local rm_exit=0 if [[ "$use_sudo" == "true" ]]; then - sudo rm "$path" 2> /dev/null || rm_exit=$? + 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 @@ -304,12 +543,65 @@ safe_remove_symlink() { fi } -# Safe sudo removal with symlink protection +# 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. + sudo -n -u "#$invoking_uid" /usr/bin/test -w "$probe" 2> /dev/null && return 0 + 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" if ! validate_path_for_deletion "$path"; then - log_error "Path validation failed for sudo remove: $path" + 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 @@ -322,6 +614,35 @@ safe_sudo_remove() { 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 + + 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 + return $? + fi + debug_log "Refusing sudo remove below mutable parent: $path" + return 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + _record_file_ops_dry_run_target "$path" + 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" @@ -330,16 +651,16 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2> /dev/null; then + if sudo -n test -e "$path" 2> /dev/null; then local size_kb - size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" sudo -n 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))") fi - if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then + if sudo -n test -f "$path" 2> /dev/null || sudo -n test -d "$path" 2> /dev/null; then local mod_time - mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") + mod_time=$(sudo -n 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 @@ -361,8 +682,8 @@ safe_sudo_remove() { 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 sudo -n test -e "$path" 2> /dev/null; then + size_kb=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" sudo -n 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 @@ -371,7 +692,7 @@ safe_sudo_remove() { local output local ret=0 - output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove + output=$(sudo -n rm -rf "$path" 2>&1) || ret=$? # safe_remove if [[ $ret -eq 0 ]]; then log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" @@ -379,6 +700,10 @@ safe_sudo_remove() { 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" @@ -399,6 +724,573 @@ safe_sudo_remove() { 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] +# +# 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, 1 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 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 + if [[ ${EUID:-0} -ne 0 ]]; then + debug_log "Downgrading privileged delete below mutable parent: $path" + needs_sudo=false + else + _mole_delete_log "$mode" "unknown" "mutable-parent" "$path" + debug_log "Refusing privileged delete below mutable parent: $path" + return 1 + fi + 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=${PIPESTATUS[0]} + 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 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + if [[ "$size_kb" =~ ^[0-9]+$ ]]; then + _record_file_ops_dry_run_target "$path" "$size_kb" + else + _record_file_ops_dry_run_target "$path" + 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" || 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 denied Trash access. Grant App Data or Full Disk Access to 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 + _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 [[ -L "$path" ]]; then + safe_remove_symlink "$path" "$needs_sudo" || rc=$? + elif [[ "$needs_sudo" == "true" ]]; then + safe_sudo_remove "$path" || rc=$? + else + safe_remove "$path" "true" || rc=$? + fi + + local status_label="ok" + [[ $rc -ne 0 ]] && status_label="error" + _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" != */* ]] +} + +# 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_immediate_child_of "$path" "/Applications" && + [[ "${path##*/}" == *.app ]]; 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 +} + +# 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}" + + 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)" + 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" ]] || _mole_path_requires_direct_trash "$path"; then + _mole_move_path_to_user_trash "$path" "$needs_sudo" + return $? + fi + + # Prefer the `trash` CLI (Homebrew formula) for normal user-owned paths. + if command -v trash > /dev/null 2>&1; then + trash "$path" > /dev/null 2>&1 && return 0 + fi + + # AppleScript fallback. Pass the path via argv so special chars (quotes, + # backslashes) cannot break out of the quoted string. + 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}" + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + 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 + + if [[ "$needs_sudo" == "true" ]] && _mole_privileged_path_has_mutable_ancestor "$path"; then + if [[ ${EUID:-0} -ne 0 ]]; then + debug_log "Downgrading Trash move below mutable parent: $path" + needs_sudo=false + else + debug_log "Refusing privileged Trash move below mutable parent: $path" + return 1 + fi + fi + + 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 + + # 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 [[ ${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 + + 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 + + # 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 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="" + source_device=$($STAT_BSD -f%d "$path" 2> /dev/null || true) + stage_device=$($STAT_BSD -f%d "$stage_dir" 2> /dev/null || true) + 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 + + if ! sudo -n /bin/mv "$path" "$stage_path" 2> /dev/null; then + 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. + if ! sudo -n /bin/chmod 700 "$stage_dir" 2> /dev/null || + ! sudo -n /usr/sbin/chown -Rhx "$owner_uid:$owner_gid" "$stage_path" 2> /dev/null || + ! sudo -n /usr/sbin/chown "$owner_uid:$owner_gid" "$stage_dir" 2> /dev/null; then + 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 [[ ${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 + 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 + move_output=$(mv -n "$path" "$dest" 2>&1) || move_rc=$? + fi + if [[ $move_rc -ne 0 ]]; then + 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 + + 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 +# subprocess fan-out that made AppleScript-fallback uninstalls feel frozen +# (100 files * ~1s each). Returns 0 only when the entire batch landed in the +# Trash; callers must fall back to the per-file path on non-zero so nothing +# is silently skipped. +_mole_move_to_trash_batch() { + local -a paths=("$@") + [[ ${#paths[@]} -eq 0 ]] && return 0 + + 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 p dest + for p in "${paths[@]}"; do + dest="$MOLE_TEST_TRASH_DIR/$(basename "$p").$$.${ts}.$RANDOM" + mv "$p" "$dest" 2> /dev/null || return 1 + done + return 0 + fi + + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + if command -v trash > /dev/null 2>&1; then + trash "${paths[@]}" > /dev/null 2>&1 && return 0 + fi + + # AppleScript fallback: build one POSIX-file list and tell Finder once. + osascript - "${paths[@]}" > /dev/null 2>&1 << 'APPLESCRIPT' +on run argv + set posixList to {} + repeat with a in argv + set end of posixList to POSIX file (a as text) + end repeat + tell application "Finder" to delete posixList +end run +APPLESCRIPT +} + +_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 + + 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 +} + # ============================================================================ # Safe Find and Delete Operations # ============================================================================ @@ -434,13 +1326,25 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path + # Iterate results to respect both system protection and user whitelist. + # 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. 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 + 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 + match_size_kb=$(get_path_size_kb "$match" 2> /dev/null || echo "0") + [[ "$match_size_kb" =~ ^[0-9]+$ ]] || match_size_kb=0 + record_dry_run_cleanup_target "$match" "$match_size_kb" 1 true || continue + fi safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null < /dev/null || true) return 0 } @@ -452,20 +1356,50 @@ safe_sudo_find_delete() { local age_days="${3:-7}" local type_filter="${4:-f}" + 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 + + # 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 + if ! sudo -n test -d "$base_dir" 2> /dev/null; then debug_log "Directory does not exist, skipping: $base_dir" + if [[ $restore_errexit -eq 1 ]]; then + set -e + fi return 0 fi - if sudo test -L "$base_dir" 2> /dev/null; then + if sudo -n test -L "$base_dir" 2> /dev/null; then log_error "Refusing to search symlinked directory: $base_dir" + if [[ $restore_errexit -eq 1 ]]; then + set -e + fi 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'" + if [[ $restore_errexit -eq 1 ]]; then + set -e + fi return 1 fi @@ -481,13 +1415,99 @@ safe_sudo_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path + # 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=() while IFS= read -r -d '' match; do if should_protect_path "$match"; then continue fi + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$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 + safe_remove "$match" true || true + else + debug_log "Skipping sudo delete below mutable parent: $match" + 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="" + if raw_match_size=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" sudo -n du -skP "$match" 2> /dev/null | awk '{print $1; exit}'); 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 + if validate_path_for_deletion "$match"; then + batch_files+=("$match") + fi + continue + fi safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(sudo -n find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + + if [[ ${#batch_files[@]} -gt 0 ]]; then + local batch_rc=0 + printf '%s\0' "${batch_files[@]}" | sudo -n xargs -0 rm -f -- 2> /dev/null || batch_rc=$? + + # Plain if, not `oplog_enabled && ...`: the short-circuit form returns + # 1 when the oplog is disabled and would trip set -e in callers. + local batch_ts="" + if oplog_enabled; then + batch_ts=$(get_timestamp) + fi + # When the batch failed, confirm sudo credentials are still live + # before trusting the per-file probe below: with a lapsed credential + # `sudo -n test -e` fails for every file, which is indistinguishable + # from "file gone" and would log REMOVED entries for files still on + # disk. With dead credentials, retry everything through the + # single-file path so each failure is classified and logged. + local batch_sudo_alive=1 + if [[ $batch_rc -ne 0 ]] && ! sudo -n true 2> /dev/null; then + batch_sudo_alive=0 + fi + local -a removed_lines=() + local batch_file + for batch_file in "${batch_files[@]}"; do + if [[ $batch_rc -ne 0 ]] && { [[ $batch_sudo_alive -eq 0 ]] || sudo -n test -e "$batch_file" 2> /dev/null; }; then + # Survived the batch (SIP, immutable flag, permissions): + # retry through the single-file path so the failure is + # classified and logged exactly as before. + safe_sudo_remove "$batch_file" || true + elif [[ -n "$batch_ts" ]]; then + removed_lines+=("[$batch_ts] [${MOLE_CURRENT_COMMAND:-clean}] REMOVED $batch_file (batch)") + fi + done + if [[ ${#removed_lines[@]} -gt 0 ]]; then + append_log_lines "$OPERATIONS_LOG_FILE" "${removed_lines[@]}" + fi + fi + + if [[ $restore_errexit -eq 1 ]]; then + set -e + fi return 0 } @@ -497,6 +1517,16 @@ 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" [[ -z "$path" || ! -e "$path" ]] && { @@ -516,8 +1546,23 @@ get_path_size_kb() { fi fi + # Fast path for regular files and symlinks: avoid forking 'du'. + if [[ -f "$path" || -L "$path" ]]; then + local bytes + bytes=$(stat -f%z "$path" 2> /dev/null || echo "") + 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. On timeout the size reads 0, which only + # affects display/accounting, never deletion decisions. local size - size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) + size=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) if [[ "$size" =~ ^[0-9]+$ ]]; then echo "$size" @@ -531,15 +1576,40 @@ 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 + size_kb=$(get_path_size_kb "$file") + total_kb=$((total_kb + size_kb)) + done + echo "$total_kb" } @@ -567,6 +1637,13 @@ 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 privacy permission denied" + suggestion="Grant App Data or Full Disk Access to your terminal in System Settings" + ;; *) reason="permission denied" if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then 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..0a99d7d --- /dev/null +++ b/Resources/mole/lib/core/history.sh @@ -0,0 +1,553 @@ +#!/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_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_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_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_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)) ;; + 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_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 count_text + count_text=$(history_join_counts "$removed" "$trashed" "$skipped" "$failed" "$rebuilt" "$other") + [[ -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]}" + 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..fbf8a38 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,47 +92,63 @@ 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 } @@ -126,10 +158,36 @@ debug_log() { echo -e "${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 } +# 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 +221,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 +233,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 +256,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 +272,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 +297,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 +317,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 +368,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 +383,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..292690e --- /dev/null +++ b/Resources/mole/lib/core/pkg_receipts.sh @@ -0,0 +1,125 @@ +#!/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 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 + + if [[ "${MOLE_PKG_RECEIPT_CACHE_DISABLE:-0}" != "1" && -r "$cache_file" ]]; 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 + while IFS= read -r cached_app_path; do + [[ -n "$cached_app_path" && -d "$cached_app_path" ]] && printf '%s\n' "$cached_app_path" + done < "$cache_file" + return 0 + fi + fi + + local pkgs_output + 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 || true) + else + pkgs_output=$(pkgutil --pkgs 2> /dev/null || true) + fi + [[ -n "$pkgs_output" ]] || return 0 + + local -a seen_apps=() + local scan_start=$SECONDS + local scan_timeout="${MOLE_PKG_RECEIPT_SCAN_TIMEOUT:-8}" + 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 + break + fi + + [[ -n "$pkg_id" ]] || continue + [[ "$pkg_id" =~ ^com\.apple\. ]] && continue + + local pkg_files + pkg_files=$(pkgutil --files "$pkg_id" 2> /dev/null | command grep -E '^(/usr/local/|/opt/).*\.app(/|$)' || true) + [[ -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 + 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 + for seen in "${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 + printf '%s\n' "${seen_apps[@]}" | sort -u + fi + + if [[ "${MOLE_PKG_RECEIPT_CACHE_DISABLE:-0}" != "1" && -n "$cache_file" ]]; 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 + if [[ ${#seen_apps[@]} -gt 0 ]]; then + printf '%s\n' "${seen_apps[@]}" | sort -u > "$cache_tmp" + else + : > "$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..982f57f 100644 --- a/Resources/mole/lib/core/sudo.sh +++ b/Resources/mole/lib/core/sudo.sh @@ -44,58 +44,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 [[ -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 + if check_touchid_support; then + echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" + fi - # 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 + echo -e "${PURPLE}${ICON_ARROW}${NC} Enter your credentials:" > "$tty_path" - 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 +69,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 +95,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 +127,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 +205,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 # ============================================================================ @@ -271,9 +278,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 +340,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 +364,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..359dd74 --- /dev/null +++ b/Resources/mole/lib/core/timeouts.sh @@ -0,0 +1,67 @@ +#!/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}" 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..3c51de2 --- /dev/null +++ b/Resources/mole/lib/manage/remove.sh @@ -0,0 +1,246 @@ +#!/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 remove: $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" + 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 + rm -rf "$HOME/.config/mole" 2> /dev/null || true # SAFE: hardcoded Mole-owned dir, -d guarded + 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..8ef3401 --- a/Resources/mole/lib/manage/update.sh +++ b/Resources/mole/lib/manage/update.sh @@ -1,169 +1,726 @@ #!/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 - fi +# 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 +} + +# 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}" "$@" +} - local -a details=() - local formulas="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local casks="${BREW_CASK_OUTDATED_COUNT:-0}" +run_brew_query() { + run_brew_command "${MOLE_HOMEBREW_QUERY_TIMEOUT:-5}" "$@" +} - ((formulas > 0)) && details+=("${formulas} formula") - ((casks > 0)) && details+=("${casks} cask") +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 detail_str="${total} updates" - if ((${#details[@]} > 0)); then - detail_str="$( - IFS=', ' - printf '%s' "${details[*]}" - )" + 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 + + # 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 + + [[ -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 - printf "%s" "$detail_str" + [[ -n "$mole_path" ]] && printf '%s\n' "$mole_path" } -# 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" +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 + + 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" } -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 +is_homebrew_mole_path() { + local mole_path="$1" + local has_brew="$2" + local link_target="" + [[ -n "$mole_path" ]] || return 1 - if [[ "$need_probe" == "false" ]]; then - return 0 + 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 - local formula_count="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local cask_count="${BREW_CASK_OUTDATED_COUNT:-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 - local formula_outdated="" - local cask_outdated="" + has_brew=true + 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) + local mole_path + mole_path=$(resolve_mole_source_path || true) + is_homebrew_mole_path "$mole_path" "$has_brew" +} - 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}') +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 +} - BREW_FORMULA_OUTDATED_COUNT="$formula_count" - BREW_CASK_OUTDATED_COUNT="$cask_count" - BREW_OUTDATED_COUNT="$((formula_count + cask_count))" +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 } -brew_has_outdated() { - local kind="${1:-formula}" - command -v brew > /dev/null 2>&1 || return 1 +get_latest_commit_from_github() { + local sha + sha=$(curl -fsSL --connect-timeout 2 --max-time 3 \ + "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null | + grep '"sha"[[:space:]]*:[[:space:]]*"[0-9a-f]\{40\}"' | head -1 | sed -E 's/.*"sha"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') || sha="" + echo "$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 +} - if [[ "$kind" == "cask" ]]; then - brew outdated --cask --quiet 2> /dev/null | grep -q . +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 - brew outdated --quiet 2> /dev/null | grep -q . + : > "$msg_cache" 2> /dev/null || true + echo "" fi } -# Ask user if they want to update -# Returns: 0 if yes, 1 if no -ask_for_updates() { - populate_brew_update_counts_if_unset +# 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) - local has_updates=false - if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then - has_updates=true + 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) + + 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 - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - has_updates=true + 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 - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true + local disk_free + disk_free=$(get_free_space) + + local install_method="Manual" + if is_homebrew_install; then + install_method="Homebrew" fi - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true + 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}" +} - if [[ "$has_updates" == "false" ]]; then - 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 +} + +# 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 + 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 - if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then - echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " + # 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 key - if ! key=$(read_key); then - echo "skip" - return 1 + local latest="" + 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 (main)" + + if [[ "$force_update" != "true" ]]; then + local installed_commit latest_commit + installed_commit=$(get_install_commit) + latest_commit=$(get_latest_commit_from_github) + + 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 [[ "$key" == "ENTER" ]]; then - echo "yes" - return 0 + 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 + + local install_channel + install_channel=$(get_install_channel) + if [[ "$install_channel" == "nightly" || "$install_channel" == "dev" ]]; then + switch_to_stable_channel=true + fi + + 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 "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${VERSION}" + echo "" + exit 0 + fi fi fi - 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" + 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 [[ -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}" + + if [[ -t 1 ]]; then + start_inline_spinner "$download_label" + else + echo "${download_label%...}" 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 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 + } - return 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" -# 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 + 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 [[ -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 "") + if [[ -t 1 ]]; then stop_inline_spinner; fi + chmod +x "$tmp_installer" - 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)) - else - echo -e "${RED}✗${NC} Mole update failed" + 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 + + 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 + + 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 - 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 + if [[ "$nightly_update" == "true" ]]; then + if install_output=$(MOLE_ASSUME_SUDO_AUTH="$installer_assume_sudo_auth" 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" || "$switch_to_stable_channel" == "true" || "$repair_install" == "true" ]]; then + if install_output=$(MOLE_ASSUME_SUDO_AUTH="$installer_assume_sudo_auth" 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 - echo -e "${RED}Update failed, ${updated_count}/${total_count}${NC}" - return 1 + if install_output=$(MOLE_ASSUME_SUDO_AUTH="$installer_assume_sudo_auth" 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_ASSUME_SUDO_AUTH="$installer_assume_sudo_auth" 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 } diff --git a/Resources/mole/lib/manage/whitelist.sh b/Resources/mole/lib/manage/whitelist.sh index 41259ac..eb04c1c 100755 --- a/Resources/mole/lib/manage/whitelist.sh +++ b/Resources/mole/lib/manage/whitelist.sh @@ -8,6 +8,7 @@ set -euo pipefail _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" @@ -78,10 +79,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 +94,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 +125,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 +147,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 +164,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,10 +210,18 @@ 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 @@ -230,6 +241,7 @@ load_whitelist() { unique_patterns+=("$pattern") done CURRENT_WHITELIST_PATTERNS=("${unique_patterns[@]}") + WHITELIST_PATTERNS=("${unique_patterns[@]}") # Migrate legacy optimize config to the new path automatically if [[ "$mode" == "optimize" && "$using_legacy" == "true" && "$config_file" != "$WHITELIST_CONFIG_OPTIMIZE" ]]; then @@ -237,6 +249,7 @@ load_whitelist() { fi else CURRENT_WHITELIST_PATTERNS=() + WHITELIST_PATTERNS=() fi } @@ -284,7 +297,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 +381,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..2cae1b3 --- /dev/null +++ b/Resources/mole/lib/optimize/catalog.sh @@ -0,0 +1,168 @@ +#!/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 dock_refresh opt_dock_refresh \ + "Dock Refresh" "Dock Refresh" \ + "Fix broken icons and visual glitches in the Dock" 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 memory_pressure_relief opt_memory_pressure_relief \ + "Memory Optimization" "Memory Optimization" \ + "Release inactive memory to improve system responsiveness" 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_handler_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' "${MOLE_OPTIMIZE_HANDLERS[$index]}" + return 0 + fi + done + return 1 +} + +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..0872c5a --- /dev/null +++ b/Resources/mole/lib/optimize/diagnostics.sh @@ -0,0 +1,429 @@ +#!/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 + 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)) + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Detached ${mount_path}" + else + failed=$((failed + 1)) + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to detach ${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 + echo -e " ${image_path##*/} ${GRAY}→${NC} ${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..0d0bdcd 100644 --- a/Resources/mole/lib/optimize/maintenance.sh +++ b/Resources/mole/lib/optimize/maintenance.sh @@ -4,50 +4,109 @@ 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 + 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/tasks.sh b/Resources/mole/lib/optimize/tasks.sh index 0f69863..54fea3d 100644 --- a/Resources/mole/lib/optimize/tasks.sh +++ b/Resources/mole/lib/optimize/tasks.sh @@ -3,6 +3,15 @@ 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" + # Config constants (override via env). readonly MOLE_TM_THIN_TIMEOUT=180 readonly MOLE_TM_THIN_VALUE=9999999999 @@ -18,6 +27,33 @@ 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 + } + + opt_numeric_kb "$(get_path_size_kb "$path" 2> /dev/null || echo "0")" +} + run_launchctl_unload() { local plist_file="$1" local need_sudo="${2:-false}" @@ -27,6 +63,12 @@ run_launchctl_unload() { fi if [[ "$need_sudo" == "true" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 0 + fi + if ! optimize_sudo_available; then + return 0 + fi sudo launchctl unload "$plist_file" 2> /dev/null || true else launchctl unload "$plist_file" 2> /dev/null || true @@ -35,7 +77,7 @@ run_launchctl_unload() { 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,20 +97,6 @@ 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" } @@ -87,12 +115,57 @@ is_memory_pressure_high() { return 1 } +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 + if scutil --nc list 2> /dev/null | grep -Eq '^\* \(Connected\)'; then + return 0 + fi + fi + + if command -v route > /dev/null 2>&1; then + local default_iface + default_iface=$(route -n get default 2> /dev/null | + awk -F': ' '$1 ~ /^[[:space:]]*interface$/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}') + if [[ "$default_iface" =~ ^utun[0-9]+$ ]]; then + return 0 + fi + fi + + return 1 +} + flush_dns_cache() { if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then MOLE_DNS_FLUSHED=1 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 @@ -119,30 +192,16 @@ opt_system_maintenance() { opt_cache_refresh() { local total_cache_size=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 @@ -150,23 +209,40 @@ opt_cache_refresh() { qlmanage -r > /dev/null 2>&1 || true 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 + size_kb=$(opt_existing_path_size_kb "$target_path") + removable_targets+=("$target_path") + removable_sizes+=("$size_kb") + total_cache_size=$((total_cache_size + 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 + safe_remove "${removable_targets[$index]}" true "${removable_sizes[$index]}" > /dev/null 2>&1 || true done export OPTIMIZE_CACHE_CLEANED_KB="${total_cache_size}" @@ -209,20 +285,35 @@ 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 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 + if [[ $prefs_partial -ne 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Preference scan hit its time budget, repaired ${broken_prefs:-0} so far" + elif [[ $broken_prefs -gt 0 ]]; then opt_msg "Repaired $broken_prefs corrupted preference files" else opt_msg "All preference files valid" @@ -252,6 +343,59 @@ opt_network_optimization() { 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" + return 0 + fi + + local quarantine_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2" + + if [[ ! -f "$quarantine_db" ]]; then + opt_msg "Quarantine database already clean" + return 0 + fi + + if should_protect_path "$quarantine_db"; then + opt_msg "Quarantine database already clean" + return 0 + fi + + # Check if database has any entries worth cleaning. + local row_count + row_count=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sqlite3 "$quarantine_db" "SELECT COUNT(*) FROM LSQuarantineEvent;" 2> /dev/null || echo "0") + + if [[ ! "$row_count" =~ ^[0-9]+$ ]] || [[ "$row_count" -eq 0 ]]; then + opt_msg "Quarantine database already clean" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + local exit_code=0 + set +e + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sqlite3 "$quarantine_db" "DELETE FROM LSQuarantineEvent; VACUUM;" 2> /dev/null + exit_code=$? + set -e + + if [[ $exit_code -eq 0 ]]; then + opt_msg "Quarantine history cleared ($row_count entries)" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clean quarantine database" + fi + else + opt_msg "Quarantine history cleared ($row_count entries)" + fi +} + # SQLite vacuum for Mail/Messages/Safari (safety checks applied). opt_sqlite_vacuum() { if [[ "${MO_DEBUG:-}" == "1" ]]; then @@ -306,9 +450,10 @@ 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 @@ -320,11 +465,14 @@ opt_sqlite_vacuum() { # 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 "") + page_info=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null || echo "") 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)) @@ -336,11 +484,11 @@ opt_sqlite_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) + integrity_check=$(run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sqlite3 "$db_file" "PRAGMA integrity_check;" 2> /dev/null) local integrity_status=$? set -e - if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then + if [[ $integrity_status -ne 0 || "$integrity_check" != "ok" ]]; then skipped=$((skipped + 1)) continue fi @@ -349,7 +497,7 @@ opt_sqlite_vacuum() { local exit_code=0 if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then set +e - run_with_timeout 20 sqlite3 "$db_file" "VACUUM;" 2> /dev/null + run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" sqlite3 "$db_file" "VACUUM;" 2> /dev/null exit_code=$? set -e @@ -444,69 +592,6 @@ opt_launch_services_rebuild() { 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 - - 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 - - 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}" - return 0 - fi - - if sudo atsutil databases -remove > /dev/null 2>&1; then - success=true - fi - else - success=true - fi - - if [[ "$success" == "true" ]]; then - opt_msg "Font cache cleared" - opt_msg "System will rebuild font database automatically" - else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clear font cache" - 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 @@ -528,7 +613,27 @@ opt_memory_pressure_relief() { return 0 fi + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Memory pressure relief · skipped (admin access required)" + return 0 + fi + + local spinner_started="false" + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Releasing inactive memory..." + spinner_started="true" + fi + + local purge_ok="false" if sudo purge > /dev/null 2>&1; then + purge_ok="true" + fi + + if [[ "$spinner_started" == "true" ]]; then + stop_inline_spinner + fi + + if [[ "$purge_ok" == "true" ]]; then opt_msg "Inactive memory released" opt_msg "System responsiveness improved" else @@ -545,6 +650,11 @@ opt_network_stack_optimize() { local route_flushed="false" local arp_flushed="false" + if has_active_vpn_interface; then + opt_msg "Network stack refresh skipped, active VPN detected" + return 0 + fi + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then local route_ok=true local dns_ok=true @@ -561,6 +671,11 @@ opt_network_stack_optimize() { return 0 fi + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Network stack refresh · skipped (admin access required)" + return 0 + fi + if sudo route -n flush > /dev/null 2>&1; then route_flushed="true" fi @@ -605,6 +720,11 @@ opt_disk_permissions_repair() { return 0 fi + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk permissions repair · skipped (admin access required)" + return 0 + fi + if [[ -t 1 ]]; then start_inline_spinner "Repairing disk permissions..." fi @@ -630,92 +750,10 @@ opt_disk_permissions_repair() { 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" - fi - - local spinner_started="false" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Bluetooth..." - spinner_started="true" - 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" - 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 - fi - - if [[ "$bt_audio_active" == "true" ]]; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - return 0 - 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 - fi - opt_msg "Bluetooth module restarted" - opt_msg "Connectivity issues resolved" - else - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - fi - else - 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 "") + spotlight_status=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" 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" @@ -723,26 +761,51 @@ opt_spotlight_index_optimize() { fi 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" + return 0 + fi + + local slow_threshold="${MOLE_OPTIMIZE_SPOTLIGHT_SLOW_SEC:-3}" + if [[ ! "$slow_threshold" =~ ^-?[0-9]+$ ]]; then + slow_threshold=3 + fi + + local spinner_started="false" + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Spotlight speed..." + spinner_started="true" + fi + local slow_count=0 - local test_start test_end test_duration - for _ in 1 2; do + local test_start test_end test_duration probe + for probe in 1 2; do test_start=$(get_epoch_seconds) - mdfind "kMDItemFSName == 'Applications'" > /dev/null 2>&1 || true + # A timeout counts as slow: an mdfind that cannot answer within + # the probe ceiling is exactly the sluggishness being measured. + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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 + if [[ $test_duration -gt $slow_threshold ]]; then slow_count=$((slow_count + 1)) fi - sleep 1 + if [[ "$probe" == "1" ]]; then + sleep 1 + fi done - if [[ $slow_count -ge 2 ]]; then - if ! is_ac_power; then - opt_msg "Spotlight index already optimal" - return 0 - fi + if [[ "$spinner_started" == "true" ]]; then + stop_inline_spinner + fi + if [[ $slow_count -ge 2 ]]; then 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)" + 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" @@ -761,19 +824,72 @@ opt_spotlight_index_optimize() { 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" + 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 - fi - done < <(command find "$dock_support" -name "*.db" -type f 2> /dev/null || 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" && ! bundle_has_installed_app "$entry"; then + removed+=("$entry") + else + keep+=("$entry") + fi + ;; + esac + i=$((i + 1)) + done + + if [[ ${#removed[@]} -eq 0 ]]; then + opt_msg "Spotlight search rules already clean" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + opt_msg "Would remove ${#removed[@]} orphan Spotlight rule(s)" + 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. + if [[ ${#keep[@]} -gt 0 ]]; then + defaults write "$domain" EnabledPreferenceRules -array "${keep[@]}" 2> /dev/null || true + else + defaults delete "$domain" EnabledPreferenceRules 2> /dev/null || true fi + opt_msg "Removed ${#removed[@]} orphan Spotlight rule(s)" +} + +# Dock refresh (restart Dock so plist edits take effect). +# The previous implementation also wiped every "*.db" under +# ~/Library/Application Support/Dock, which deleted macOS's +# desktoppicture.db and reset the user's wallpaper (#995). No .db under +# that directory needs to be cleared for Dock to refresh, killall plus +# touching the plist is sufficient. +opt_dock_refresh() { local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist" if [[ -f "$dock_plist" ]]; then touch "$dock_plist" 2> /dev/null || true @@ -783,35 +899,598 @@ opt_dock_refresh() { killall Dock 2> /dev/null || true fi - if [[ "$refreshed" == "true" ]]; then - opt_msg "Dock cache cleared" - fi opt_msg "Dock refreshed" } +# 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 + + 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)) + fi + done + + if [[ $changed -eq 0 && $already -gt 0 ]]; then + opt_msg ".DS_Store prevention already enabled on network & USB volumes" + return 0 + fi + + if [[ $changed -gt 0 ]]; then + opt_msg ".DS_Store prevention enabled on network & USB volumes" + fi +} + +# 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" + return 0 + fi + + local 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]}" + continue + fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove override: ${found_labels[$idx]}" + continue + fi + if defaults delete "${found_domains[$idx]}" "${found_keys[$idx]}" 2> /dev/null; then + opt_msg "Removed override: ${found_labels[$idx]}" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not remove override: ${found_labels[$idx]}" + fi + done +} + +# 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" + return 0 + fi + + local broken_count=0 + local -a broken_plists=() + + for plist in "$agents_dir"/*.plist; do + [[ -f "$plist" ]] || continue + + local binary="" + binary=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$plist" 2> /dev/null || true) + if [[ -z "$binary" ]]; then + binary=$(/usr/libexec/PlistBuddy -c "Print :Program" "$plist" 2> /dev/null || true) + 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" + return 0 + fi + + for plist in "${broken_plists[@]}"; do + run_launchctl_unload "$plist" + safe_remove "$plist" true > /dev/null 2>&1 || true + done + + opt_msg "Cleaned $broken_count broken Launch Agent(s)" +} + +# 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)" + 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)" + 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)" + 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" + 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 + fi + else + opt_msg "Periodic maintenance triggered" + 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" + return 0 + fi + + local repaired=0 + 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 + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + safe_remove "$sfl_file" true > /dev/null 2>&1 || true + fi + repaired=$((repaired + 1)) + fi + done < <(command find "$sfl_dir" \( -name "*.sfl2" -o -name "*.sfl3" \) -type f ! -path "*ApplicationRecentDocuments*" -print0 2> /dev/null || true) + + if [[ $repaired -gt 0 ]]; then + opt_msg "Repaired $repaired corrupted shared file list(s)" + else + opt_msg "Shared file lists all healthy" + fi +} + +# Clean old delivered notifications from NotificationCenter database. +opt_notification_cleanup() { + local nc_db_dir + nc_db_dir="$(getconf DARWIN_USER_DIR 2> /dev/null || true)/com.apple.notificationcenter/db2" + local nc_db="$nc_db_dir/db" + + if [[ ! -f "$nc_db" ]]; then + opt_msg "Notification Center database not found" + return 0 + fi + + local db_size + db_size=$(opt_existing_path_size_kb "$nc_db") + + # 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))))" + 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))))" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Notification Center cleanup skipped (database busy or locked)" + fi + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available" + fi + else + opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))" + fi +} + +# 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)" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + opt_msg "Disk verify · skipped in dry-run" + return 0 + fi + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Verifying disk filesystem..." + fi + local output + output=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" diskutil verifyVolume / 2>&1 || true) + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + if echo "$output" | grep -qi "appears to be OK\|volume appears to be ok"; then + opt_msg "Disk filesystem verified OK" + elif echo "$output" | grep -qi "error\|corrupt\|invalid"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk issues detected · run: sudo diskutil repairVolume /" + else + opt_msg "Disk verify complete" + 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" + 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 + total_size=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skcP "${knowledge_files[@]}" 2> /dev/null | awk 'END {print $1 + 0}' || echo "0") + total_size=$(opt_numeric_kb "$total_size") + 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))))" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + # Remove WAL and SHM files safely (auto-regenerated by SQLite) + for f in "$wal_file" "$shm_file"; do + [[ -f "$f" ]] && safe_remove "$f" true > /dev/null 2>&1 || true + done + # Remove ZOBJECT entries older than 90 days (CoreTime is Mac epoch: seconds since 2001-01-01) + if command -v sqlite3 > /dev/null 2>&1; then + local sql_ok=0 + sqlite3 "$knowledge_db" \ + "DELETE FROM ZOBJECT WHERE ZCREATIONDATE < (strftime('%s','now','-90 days') - strftime('%s','2001-01-01')); VACUUM;" \ + 2> /dev/null || sql_ok=$? + if [[ $sql_ok -eq 0 ]]; then + opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Knowledge database cleanup skipped (database busy or locked)" + fi + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available" + fi + else + opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))" + 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 [[ -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" + return 0 + fi + + local items_output + items_output=$(_login_items_snapshot 2> /dev/null || true) + + if [[ -z "$items_output" ]]; then + opt_msg "No login items found" + 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)" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} $broken broken login item(s) · remove via System Settings > General > Login Items" + fi +} + # 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 + + if command -v is_whitelisted > /dev/null && is_whitelisted "$action"; then + opt_msg "Skipped (whitelisted): $action" + return 0 + fi + + local handler + if ! handler=$(optimize_catalog_handler_for "$action"); then + echo -e "${YELLOW}${ICON_ERROR}${NC} Unknown action: $action" + return 1 + fi + if ! declare -F "$handler" > /dev/null; then + echo -e "${YELLOW}${ICON_ERROR}${NC} Missing optimization handler: $handler" + return 1 + fi + + "$handler" } 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..6b12548 100755 --- a/Resources/mole/lib/ui/menu_paginated.sh +++ b/Resources/mole/lib/ui/menu_paginated.sh @@ -91,6 +91,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 +101,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 +124,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 +229,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 +259,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 +328,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 +469,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 +513,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 +571,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 +593,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 +604,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 +613,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 +645,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 +663,7 @@ paginated_multi_select() { top_index=0 need_full_redraw=true else - cleanup + _pm_cleanup return 1 fi ;; @@ -707,6 +783,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 +859,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 +978,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..43d410f 100755 --- a/Resources/mole/lib/uninstall/batch.sh +++ b/Resources/mole/lib/uninstall/batch.sh @@ -11,14 +11,27 @@ 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" ]] } +app_declares_local_network_usage() { + local app_path="$1" + local info_plist="$app_path/Contents/Info.plist" + + [[ -f "$info_plist" ]] || return 1 + + if plutil -extract NSLocalNetworkUsageDescription raw "$info_plist" > /dev/null 2>&1; then + return 0 + fi + + if plutil -extract NSBonjourServices xml1 -o - "$info_plist" > /dev/null 2>&1; then + return 0 + fi + + return 1 +} + # High-performance sensitive data detection (pure Bash, no subprocess) # Faster than grep for batch operations, especially when processing many apps has_sensitive_data() { @@ -77,44 +90,278 @@ 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 + for detail in "${details[@]}"; do + IFS='|' read -r app_name app_path bundle_id _ _ _ _ _ _ _ _ _ _ enc_helpers _ <<< "$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 + size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "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" + [[ -d "$login_items_root" ]] || return 0 + + local helper info bundle_id + while IFS= read -r -d '' helper; do + info="$helper/Contents/Info.plist" + [[ -f "$info" ]] || continue + bundle_id=$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || true) + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + printf '%s\n' "$bundle_id" + fi + done < <(find "$login_items_root" -maxdepth 1 -name "*.app" -print0 2> /dev/null || true) +} + +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 + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" launchctl bootout "gui/$uid/$helper_id" > /dev/null 2>&1 || true + done <<< "$helper_ids" +} + +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}" + can_unload_launch_plist "$plist" || return 0 + if [[ "$needs_sudo" == "true" ]]; then + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sudo launchctl unload "$plist" > /dev/null 2>&1 || true + else + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" launchctl unload "$plist" > /dev/null 2>&1 || true + fi +} + +# 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 + if [[ "$bundle_id_usable" == "true" ]] && [[ -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) + unload_launch_plist "$plist" "false" + done < <(find ~/Library/LaunchAgents -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) 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) + unload_launch_plist "$plist" "true" + done < <(find /Library/LaunchAgents -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) 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) + unload_launch_plist "$plist" "true" + done < <(find /Library/LaunchDaemons -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) + 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 + while IFS= read -r -d '' plist; do + grep -qF -- "$app_path" "$plist" 2> /dev/null || continue + unload_launch_plist "$plist" "false" + done < <(find ~/Library/LaunchAgents -maxdepth 1 -name '*.plist' -print0 2> /dev/null) + fi + if [[ "$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 + grep -qF -- "$app_path" "$plist" 2> /dev/null || continue + unload_launch_plist "$plist" "true" + done < <(find /Library/LaunchAgents -maxdepth 1 -name '*.plist' -print0 2> /dev/null) + fi + if [[ -d /Library/LaunchDaemons ]]; then + while IFS= read -r -d '' plist; do + grep -qF -- "$app_path" "$plist" 2> /dev/null || continue + unload_launch_plist "$plist" "true" + done < <(find /Library/LaunchDaemons -maxdepth 1 -name '*.plist' -print0 2> /dev/null) + fi fi fi } @@ -150,17 +397,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 +433,47 @@ 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//\"/\\\"}" + + 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 + 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, the eligible paths are sent to Trash in a single subprocess +# (one `trash` exec or one Finder AppleScript round-trip). This collapses the +# previous N-subprocess fan-out that caused the post-confirmation "frozen +# terminal" reported during `mo uninstall` on apps with many leftovers. 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=() while IFS= read -r file; do [[ -n "$file" && -e "$file" ]] || continue @@ -223,81 +482,250 @@ remove_file_list() { continue fi - if [[ -L "$file" ]]; then - safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true + 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 + trash_batch+=("$file") 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 - else - safe_remove "$file" true && ((++count)) || true - fi + fallback_paths+=("$file") fi done <<< "$file_list" + if [[ ${#trash_batch[@]} -gt 0 ]]; then + if _mole_move_to_trash_batch "${trash_batch[@]}"; then + local _bp _bsize + for _bp in "${trash_batch[@]}"; do + _bsize="unknown" + _mole_delete_log "trash" "$_bsize" "ok" "$_bp" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "TRASHED" "$_bp" "batch" + done + count=$((count + ${#trash_batch[@]})) + else + # Batch failed wholesale: route each path through mole_delete so + # per-file Trash handling fails closed and forensic logging stays + # intact. + fallback_paths+=("${trash_batch[@]}") + fi + 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. + mole_delete "$fb" "$use_sudo" && ((++count)) || true + 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. +uninstall_bundle_id_has_surviving_sibling() { + local bundle_id="$1" + local app_path="$2" + + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 1 + + local row other_path other_bundle + # 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" == "$bundle_id" ]] || 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 - # shellcheck disable=SC2154 - if [[ ${#selected_apps[@]} -eq 0 ]]; then - log_warning "No applications selected for uninstallation" return 0 - fi + done - local old_trap_int old_trap_term - old_trap_int=$(trap -p INT) - old_trap_term=$(trap -p TERM) + return 1 +} - _cleanup_sudo_keepalive() { - if command -v stop_sudo_session > /dev/null 2>&1; then - stop_sudo_session - fi - } +# 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" - _restore_uninstall_traps() { - _cleanup_sudo_keepalive - if [[ -n "$old_trap_int" ]]; then - eval "$old_trap_int" - else - trap - INT - fi - if [[ -n "$old_trap_term" ]]; then - eval "$old_trap_term" - else - trap - TERM - fi - } + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 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; return 130' INT TERM + local row other_path other_name other_bundle + for row in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ other_path other_name other_bundle _ _ _ <<< "$row" + [[ "$other_bundle" == "$bundle_id" ]] || 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 - # Pre-scan: running apps, sudo needs, size. - local -a running_apps=() - local -a sudo_apps=() - local total_estimated_size=0 - local -a app_details=() + 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. +# Reads: selected_apps +# Writes: running_apps, sudo_apps, brew_cask_apps, blocked_apps, app_details, +# total_estimated_size +_batch_scan_app_details() { # 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 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 + + # A surviving install sharing this bundle id (e.g. Xcode.app when + # uninstalling Xcode-beta.app) still owns every bundle-id-keyed path. + # Demote the bundle id to "unknown" so leftover discovery and the + # bundle-id-keyed removal steps all fall back to name/path matching. + # Name matching gets the same treatment: discovery keys on the .app + # basename (unique even when both installs resolve to one display + # name, which happens when mdls has no index and CFBundleName says + # "Xcode" for the beta), and if even that collides with the survivor, + # or its version-suffix-stripped base does, name discovery is dropped + # entirely so the fallback can never be broader than the primary path. + local sibling_guard="none" + local discovery_app_name="$app_name" + if uninstall_bundle_id_has_surviving_sibling "$bundle_id" "$app_path"; then + sibling_guard="guard" + discovery_app_name="${app_path##*/}" + discovery_app_name="${discovery_app_name%.app}" + + 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") @@ -319,6 +747,10 @@ batch_uninstall_applications() { fi fi + if [[ "$is_brew_cask" == "true" ]]; then + brew_cask_apps+=("$app_name") + fi + # Check if sudo is needed local needs_sudo=false local app_owner=$(get_file_owner "$app_path") @@ -329,29 +761,52 @@ batch_uninstall_applications() { 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") + 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 + related_files=$(MOLE_UNINSTALL_SIBLING_SURVIVES="$sibling_survives" find_app_files "$bundle_id" "$discovery_app_name" "$app_path" || true) + # 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. + if [[ "$sibling_guard" == "none" ]]; then + diag_user=$(get_diagnostic_report_paths_for_app "$app_path" "$discovery_app_name" "$HOME/Library/Logs/DiagnosticReports" || true) + [[ -n "$diag_user" ]] && related_files=$( + [[ -n "$related_files" ]] && echo "$related_files" + echo "$diag_user" + ) + diag_system=$(get_diagnostic_report_paths_for_app "$app_path" "$discovery_app_name" "/Library/Logs/DiagnosticReports" || true) + fi + system_files=$(find_app_system_files "$bundle_id" "$discovery_app_name" || true) fi + local related_size_kb=$(calculate_total_size "$related_files" || echo "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") @@ -363,6 +818,11 @@ batch_uninstall_applications() { has_sensitive_data="true" fi + local has_local_network_usage="false" + if app_declares_local_network_usage "$app_path"; then + has_local_network_usage="true" + fi + # Store details for later use (base64 keeps lists on one line). local encoded_files encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n' || echo "") @@ -370,29 +830,59 @@ 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 + login_item_helpers=$(discover_login_item_helper_bundle_ids "$app_path" || true) + local encoded_login_item_helpers + encoded_login_item_helpers=$(printf '%s' "$login_item_helpers" | 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|$has_local_network_usage|$encoded_review_system|$encoded_login_item_helpers|$sibling_guard") 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 +} + +# 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 has_local_network_usage encoded_review_system encoded_login_item_helpers sibling_guard <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -404,30 +894,38 @@ 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/~}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(format_uninstall_preview_path "$app_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/~}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(format_uninstall_preview_path "$file")" 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" + echo -e " ${BLUE}${ICON_WARNING}${NC} System: $(format_uninstall_preview_path "$file")" fi done <<< "$system_files" + + while IFS= read -r file; do + if [[ -n "$file" && -e "$file" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Review only: $(format_uninstall_preview_path "$file")" + 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 +944,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 +952,7 @@ batch_uninstall_applications() { *) echo "" echo "" - _restore_uninstall_traps - return 0 + return 2 ;; esac @@ -464,30 +960,51 @@ 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 (local network, 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, local_network_warning_apps, +# 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 has_local_network_usage encoded_review_system encoded_login_item_helpers sibling_guard <<< "$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="" @@ -506,36 +1023,90 @@ batch_uninstall_applications() { local has_system_files="false" [[ -n "$system_files" ]] && has_system_files="true" - stop_launch_services "$bundle_id" "$has_system_files" + stop_launch_services "$bundle_id" "$has_system_files" "$app_path" unregister_app_bundle "$app_path" - # Remove from Login Items - remove_login_item "$app_name" "$bundle_id" + # 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 [[ "${sibling_guard:-none}" != "guard_login" ]]; then + remove_login_item "$app_name" "$bundle_id" + else + debug_log "Skipping login item removal for $app_name: name is shared with a surviving install" + fi - if ! force_kill_app "$app_name" "$app_path"; then - reason="still running" + # 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 [[ "${sibling_guard:-none}" == "none" ]]; then + if ! force_kill_app "$app_name" "$app_path"; then + running_at_uninstall_apps+=("$app_name") + fi + else + debug_log "Skipping process termination for $app_name: identifiers are 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 + # 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 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 + if brew_uninstall_cask "$cask_name" "$app_path" "$cask_zap_mode"; then used_brew_successfully=true 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 + + if [[ $cask_state -eq 1 ]]; then + if ! mole_delete "$app_path" "$needs_sudo"; then + reason="brew cleanup incomplete, manual removal failed" + 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 +1125,24 @@ batch_uninstall_applications() { reason="protected system symlink, cannot remove" ;; *) - if ! safe_remove_symlink "$app_path" "true"; then + if ! mole_delete "$app_path" "true"; then reason="failed to remove symlink" fi ;; esac else - if ! safe_remove_symlink "$app_path" "true"; then + if ! mole_delete "$app_path" "true"; then reason="failed to remove symlink" fi fi else if is_uninstall_dry_run; then - if ! safe_remove "$app_path" true; then + if ! mole_delete "$app_path" "false"; then reason="dry-run path validation failed" fi else local ret=0 - safe_sudo_remove "$app_path" || ret=$? + mole_delete "$app_path" "true" || ret=$? if [[ $ret -ne 0 ]]; then local diagnosis diagnosis=$(diagnose_removal_failure "$ret" "$app_name") @@ -580,7 +1151,7 @@ batch_uninstall_applications() { fi fi else - if ! safe_remove "$app_path" true; then + if ! mole_delete "$app_path" "false"; then if [[ ! -w "$(dirname "$app_path")" ]]; then reason="parent directory not writable" else @@ -592,8 +1163,46 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then + 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 remove_file_list "$related_files" "false" > /dev/null + # 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 + _du_total=$(run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skcP "${leftover_paths[@]}" 2> /dev/null | awk 'END {print $1}') + 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 else @@ -608,7 +1217,7 @@ batch_uninstall_applications() { 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 +1227,60 @@ 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" - fi + while IFS= read -r -d '' plist_file; do + mole_delete "$plist_file" "false" || true + done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true) 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 + bootout_login_item_helpers "$login_item_helpers" + 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 +1289,29 @@ batch_uninstall_applications() { files_cleaned=$((files_cleaned + 1)) total_items=$((total_items + 1)) success_items+=("$app_path") + success_dock_targets+=("$app_path|$bundle_id") + if [[ "$has_local_network_usage" == "true" ]]; then + local_network_warning_apps+=("$app_name") + fi + + # 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 +1327,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, Local Network / 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, local_network_warning_apps, +# 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 +1437,67 @@ batch_uninstall_applications() { summary_details+=("No applications were uninstalled.") fi + if [[ ${#review_only_system_leftovers[@]} -gt 0 ]]; then + summary_status="warn" + summary_details+=("${ICON_REVIEW} System files left untouched after removal:") + + local system_leftover + local tilde_display='~' + for system_leftover in "${review_only_system_leftovers[@]}"; do + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} ${system_leftover/#$HOME/$tilde_display}") + done + + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Review these paths before removing them manually; prefer the app's official uninstaller when available") + fi + + if [[ ${#local_network_warning_apps[@]} -gt 0 ]]; then + local local_network_list="" + local idx + for ((idx = 0; idx < ${#local_network_warning_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && local_network_list+=", " + local_network_list+="${local_network_warning_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} Local Network permissions on macOS 15+ can outlive app removal: ${YELLOW}${local_network_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Mole does not reset ${GRAY}/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} If stale or duplicate entries remain, clear them manually in Recovery mode because the reset is global${NC}") + 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 +1506,158 @@ 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 + } + + # 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 total_estimated_size=0 + local -a app_details=() + + _batch_scan_app_details + if [[ $_batch_interrupted -eq 1 ]]; then + _restore_uninstall_traps + return 130 + fi + + if [[ ${#app_details[@]} -eq 0 ]]; then + _restore_uninstall_traps + return 1 + fi + + local _confirm_rc=0 + _batch_preview_and_confirm || _confirm_rc=$? + if [[ $_batch_interrupted -eq 1 ]]; then + _restore_uninstall_traps + return 130 + fi + case $_confirm_rc in + 0) ;; + 2) + _restore_uninstall_traps + return 0 + ;; + *) + _restore_uninstall_traps + 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 local_network_warning_apps=() + 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=() + + _batch_execute_removals + if [[ $_batch_interrupted -eq 1 ]]; then + _restore_uninstall_traps + return 130 + 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 diff --git a/Resources/mole/lib/uninstall/brew.sh b/Resources/mole/lib/uninstall/brew.sh index 012ca53..37b0fbe 100644 --- a/Resources/mole/lib/uninstall/brew.sh +++ b/Resources/mole/lib/uninstall/brew.sh @@ -35,6 +35,21 @@ is_homebrew_available() { command -v brew > /dev/null 2>&1 } +# 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 + cask_list=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null) || 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 +57,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 @@ -78,6 +94,7 @@ _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=() @@ -102,6 +119,14 @@ _detect_cask_via_caskroom_search() { # 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 info_output + info_output=$(HOMEBREW_NO_ENV_HINTS=1 brew info --cask "${uniq[0]}" 2> /dev/null) || return 1 + if [[ -n "$app_path" ]] && + ! grep -qF "$app_path" <<< "$info_output" && + ! grep -qF "/Applications/$app_bundle_name" <<< "$info_output" && + ! grep -qF "$app_bundle_name" <<< "$info_output"; then + return 1 + fi echo "${uniq[0]}" return 0 fi @@ -129,8 +154,13 @@ _detect_cask_via_brew_list() { 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 + # Verify this cask actually owns this app path or app bundle. + local info_output + info_output=$(HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null) || return 1 + grep -qF "$app_path" <<< "$info_output" || + grep -qF "/Applications/$app_bundle_name" <<< "$info_output" || + grep -qF "$app_bundle_name" <<< "$info_output" || + return 1 echo "$cask_name" } @@ -154,7 +184,7 @@ get_brew_cask_name() { # 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_caskroom_search "$app_bundle_name" "$app_path" && return 0 _detect_cask_via_symlink_check "$app_path" && return 0 _detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0 @@ -162,21 +192,31 @@ get_brew_cask_name() { } # Uninstall a Homebrew cask and verify removal -# Args: $1 - cask_name, $2 - app_path (optional, for verification) +# 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 failure 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 @@ -193,13 +233,23 @@ 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 @@ -211,7 +261,12 @@ brew_uninstall_cask() { # 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 + if is_brew_cask_installed "$cask_name"; then + cask_gone=false + else + local 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..7c58ae0 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.48.1" +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/check.sh b/Resources/mole/scripts/check.sh index 20e3dff..d6d3d44 100755 --- a/Resources/mole/scripts/check.sh +++ b/Resources/mole/scripts/check.sh @@ -44,11 +44,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 +85,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 +110,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 +125,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 +147,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 +162,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 +186,4 @@ 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}" -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}" -fi - -((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}" -fi - -echo -e "${BLUE} Optimization score: $OPTIMIZATION_SCORE/$TOTAL_CHECKS${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..3091df9 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,42 @@ 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)" - if [[ -z "$install_test_home" ]]; then - install_test_home="/tmp/mole-test-home" - mkdir -p "$install_test_home" + # 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" + else + install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)" + if [[ -z "$install_test_home" ]]; then + install_test_home="/tmp/mole-test-home" + mkdir -p "$install_test_home" + fi 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 /tmp/mole-test > /dev/null 2>&1; then + if [[ -f "/tmp/mole-test/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 + 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 + 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..d716311 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 +165,87 @@ 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 > /dev/null 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 +[[ -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 +263,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 +283,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 +295,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 +311,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 +344,196 @@ 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; } -sudo() { - echo "UNEXPECTED_SUDO_CALL:$*" - return 1 +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 "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..3b39759 --- /dev/null +++ b/Resources/mole/tests/bundle_resolver.bats @@ -0,0 +1,247 @@ +#!/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_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_contains "Claude Desktop bundled Claude Code · skipped (Claude Desktop running)" + assert_output_not_contains "SAFE_CLEAN:" +} diff --git a/Resources/mole/tests/clean_app_caches.bats b/Resources/mole/tests/clean_app_caches.bats index 067664f..59cf365 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 @@ -31,13 +37,12 @@ clean_xcode_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Xcode is running"* ]] - [[ "$output" != *"derived data"* ]] - [[ "$output" != *"archives"* ]] + [[ "$output" == *"Xcode DerivedData/Documentation · skipped (Xcode running)"* ]] || 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 cleans documentation caches but not archives when Xcode is not running" { 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 +53,51 @@ clean_xcode_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Xcode derived data"* ]] - [[ "$output" == *"Xcode archives"* ]] - [[ "$output" == *"Xcode documentation cache"* ]] + # clean_xcode_tools does not touch DerivedData (that is clean_xcode_derived_data), + # so assert the documentation cache this test is actually named for. + [[ "$output" == *"Xcode documentation cache"* ]] || return 1 + [[ "$output" != *"Xcode archives"* ]] || return 1 + [[ "$output" == *"Xcode documentation cache"* ]] || return 1 [[ "$output" == *"Xcode documentation index"* ]] } -@test "clean_media_players protects spotify offline cache" { +@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 "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,30 +110,316 @@ 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_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" + +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" == *"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_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" + +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" == *"Final Cut Pro generated caches · skipped (Final Cut Pro running)"* ]] || 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" == *"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 "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" + +# 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" == *"MOCK-FAITHFUL: broad pattern matches helper"* ]] || return 1 + [[ "$output" == *"OK: not running"* ]] || return 1 + [[ "$output" != *"WRONG"* ]] || return 1 +} + +@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" + +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" == *"JianyingPro generated caches · skipped (JianyingPro running)"* ]] || return 1 + [[ "$output" != *"unexpected safe_clean"* ]] || return 1 +} + +@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" + +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" == *"skipped (process state unknown)"* ]] || return 1 + [[ "$output" != *"unexpected safe_clean"* ]] || return 1 +} + +@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/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" != *"unexpected safe_clean"* ]] || return 1 +} + +@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" + +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 ] } @test "clean_ai_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/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +note_activity() { :; } +clean_ai_apps +EOF + + [ "$status" -eq 0 ] + [[ "$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" == *"ChatGPT cache"* ]] - [[ "$output" == *"Claude desktop cache"* ]] + [[ "$output" == *"Codex Desktop state · preserved (sessions, credentials)"* ]] || 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" 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"; } @@ -111,25 +427,26 @@ clean_design_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Sketch cache"* ]] + [[ "$output" == *"Sketch cache"* ]] || return 1 [[ "$output" == *"Figma cache"* ]] } @test "clean_dingtalk 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 +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"* ]] + [[ "$output" == *"DingTalk iDingTalk cache"* ]] || return 1 [[ "$output" == *"DingTalk logs"* ]] } @test "clean_download_managers 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"; } @@ -137,12 +454,12 @@ clean_download_managers EOF [ "$status" -eq 0 ] - [[ "$output" == *"Aria2 cache"* ]] + [[ "$output" == *"Aria2 cache"* ]] || return 1 [[ "$output" == *"qBittorrent cache"* ]] } @test "clean_productivity_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"; } @@ -150,12 +467,12 @@ clean_productivity_apps EOF [ "$status" -eq 0 ] - [[ "$output" == *"MiaoYan cache"* ]] + [[ "$output" == *"MiaoYan cache"* ]] || return 1 [[ "$output" == *"Flomo cache"* ]] } @test "clean_screenshot_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"; } @@ -163,12 +480,12 @@ clean_screenshot_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"CleanShot cache"* ]] + [[ "$output" == *"CleanShot cache"* ]] || return 1 [[ "$output" == *"Xnip cache"* ]] } @test "clean_office_applications 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/user.sh" stop_section_spinner() { :; } @@ -177,34 +494,420 @@ clean_office_applications EOF [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Word cache"* ]] + [[ "$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" bash --noprofile --norc << 'EOF' + 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"* ]] + [[ "$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" bash --noprofile --norc << 'EOF' + 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"* ]] - [[ "$output" == *"Steam shader cache"* ]] - [[ "$output" == *"Minecraft logs"* ]] + [[ "$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"* ]] +} diff --git a/Resources/mole/tests/clean_apps.bats b/Resources/mole/tests/clean_apps.bats index 0fafe10..6f14118 100644 --- a/Resources/mole/tests/clean_apps.bats +++ b/Resources/mole/tests/clean_apps.bats @@ -10,11 +10,17 @@ 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 @@ -28,8 +34,31 @@ 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 $((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 /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 512; } +bytes_to_human() { echo "512B"; } files_cleaned=0 total_size_cleaned=0 total_items=0 @@ -39,11 +68,12 @@ 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" @@ -59,8 +89,103 @@ EOF [[ "$output" == *"com.example.App"* ]] } +@test "scan_installed_apps filters missing value from osascript output" { + 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" + +# 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" + +# Create a fake .app with a plist that has no CFBundleIdentifier +mkdir -p "$HOME/Applications/FakeApp.app/Contents" +cat > "$HOME/Applications/FakeApp.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleName + FakeApp + + +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 + +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" +cat "$HOME/installed.txt" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"com.example.GoodApp"* ]] || return 1 + [[ "$output" != *"missing value"* ]] || return 1 +} + +@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 +if [ "${1:-}" != "-maxdepth" ] || + [ "${2:-}" != "3" ] || + [ "${3:-}" != "-type" ] || + [ "${4:-}" != "d" ] || + [ "${5:-}" != "-name" ] || + [ "${6:-}" != "*.app" ]; then + exit 64 +fi + +if [ "$root" = "$HOME/Applications" ]; then + printf '%s\n' "$HOME/Applications/Ordered.app" +fi +SH +chmod +x "$stub_dir/find" + +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" == *"com.example.Ordered"* ]] +} + @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' + 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" @@ -76,7 +201,7 @@ EOF } @test "clean_orphaned_app_data skips when no permission" { - 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" @@ -89,7 +214,7 @@ EOF } @test "clean_orphaned_app_data handles paths with spaces correctly" { - 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" @@ -144,7 +269,7 @@ EOF } @test "clean_orphaned_app_data only counts successful deletions" { - 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" @@ -206,12 +331,62 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"PASS: Failed deletion preserved"* ]] + [[ "$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 +} + +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" 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" @@ -252,12 +427,12 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"Orphaned Claude workspace VM"* ]] + [[ "$output" == *"Orphaned Claude workspace VM"* ]] || return 1 [[ "$output" == *"PASS: Claude VM removed"* ]] } @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' + 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" @@ -297,12 +472,12 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] + [[ "$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" 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" @@ -335,25 +510,94 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] + [[ "$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 bash --noprofile --norc <<'EOF' + 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" ]] + [[ "${lines[0]}" == "yes" ]] || return 1 [[ "${lines[1]}" == "yes" ]] } @test "is_critical_system_component ignores non-system names" { - run bash --noprofile --norc <<'EOF' + run /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/app_protection.sh" if is_critical_system_component "myapp"; then @@ -367,7 +611,9 @@ EOF } @test "clean_orphaned_system_services respects dry-run" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' + # 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" @@ -379,12 +625,26 @@ debug_log() { :; } tmp_dir="$(mktemp -d)" tmp_plist="$tmp_dir/com.sogou.test.plist" -touch "$tmp_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 @@ -408,147 +668,856 @@ clean_orphaned_system_services EOF [ "$status" -eq 0 ] - [[ "$output" != *"rm-called"* ]] - [[ "$output" != *"launchctl-called"* ]] + [[ "$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 "is_launch_item_orphaned detects orphan when program missing" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.orphan.plist" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } +should_protect_path() { return 1; } -cat > "$tmp_plist" << 'PLIST' +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.test.orphan - ProgramArguments - - /nonexistent/app/program - + com.example.live-helper + Program + $tmp_binary PLIST +chmod 000 "$tmp_plist" -run_with_timeout() { shift; "$@"; } - -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -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 + 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 "$@" +} -rm -rf "$tmp_dir" +clean_orphaned_system_services EOF [ "$status" -eq 0 ] - [[ "$output" == *"orphan"* ]] + [[ "$output" != *"Found 1 orphaned"* ]] || return 1 + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 } -@test "is_launch_item_orphaned protects when program exists" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.active.plist" -tmp_program="$tmp_dir/program" -touch "$tmp_program" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +should_protect_path() { return 0; } +safe_sudo_remove() { + echo "unexpected-remove" + return 0 +} -cat > "$tmp_plist" << PLIST +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.test.active - ProgramArguments - - $tmp_program - + com.sogou.test + Program + $tmp_dir/missing-binary PLIST -run_with_timeout() { shift; "$@"; } - -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -else - echo "not-orphan" -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 + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "unexpected-launchctl" + return 0 + fi + command "$@" +} -rm -rf "$tmp_dir" +clean_orphaned_system_services EOF [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] + [[ "$output" == *"Orphaned services · skipped 1 protected"* ]] || return 1 + [[ "$output" != *"Orphaned services · cleaned"* ]] || return 1 + [[ "$output" != *"unexpected-remove"* ]] || return 1 + [[ "$output" != *"unexpected-launchctl"* ]] } -@test "is_launch_item_orphaned protects when app support active" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +# 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" -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" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +bundle_has_installed_app() { return 1; } +safe_sudo_remove() { + echo "removed:$1" + return 0 +} -cat > "$tmp_plist" << 'PLIST' +# 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 - com.test.appsupport - ProgramArguments - - $HOME/Library/Application Support/TestApp/Current/app - + org.amnezia.awg + Program + $tmp_dir/missing-binary PLIST -run_with_timeout() { shift; "$@"; } - -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -else - echo "not-orphan" -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_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 "$@" +} -rm -rf "$tmp_dir" -rm -rf "$HOME/Library/Application Support/TestApp" +clean_orphaned_system_services EOF [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] + [[ "$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 "clean_orphaned_launch_agents skips when no orphans" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" -mkdir -p "$HOME/Library/LaunchAgents" +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() { :; } -get_path_size_kb() { echo "1"; } -run_with_timeout() { shift; "$@"; } +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 +} -clean_orphaned_launch_agents +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 "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$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_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" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } + +should_protect_path() { return 0; } + +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 + +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" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"skipped 1 protected"* ]] || return 1 + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 +} + +@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/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 "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"Would remove orphaned service"* ]] || return 1 + [[ "$output" != *"Skipping protected"* ]] || return 1 +} + +@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" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +should_protect_path() { return 1; } + +tmp_dir="$(mktemp -d)" +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 + 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" == *"found dry"* ]] || return 1 + [[ "$output" == *"com.example.exported.orphan.plist # "* ]] || return 1 +} + +@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" + +# 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; } + +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +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" == *"PASS: stub removed"* ]] || return 1 + [[ "$output" == *"Orphaned app container stubs"* ]] +} + +@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" + +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; } + +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 "FAIL: race content was deleted" + exit 1 +fi +EOF + + [ "$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 ] + # 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 "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" + +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.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 + +clean_orphaned_container_stubs + +if [[ -d "$stub" ]]; then + echo "PASS: stub preserved" +else + echo "FAIL: stub was wrongly removed" + exit 1 +fi + +EOF + + [ "$status" -eq 0 ] + [[ "$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_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" + +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() { :; } +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_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 } diff --git a/Resources/mole/tests/clean_browser_versions.bats b/Resources/mole/tests/clean_browser_versions.bats index b90350a..91cce4b 100644 --- a/Resources/mole/tests/clean_browser_versions.bats +++ b/Resources/mole/tests/clean_browser_versions.bats @@ -1,27 +1,33 @@ #!/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-browser-cleanup.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")" + export HOME - mkdir -p "$HOME" + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + + 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 "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" @@ -33,13 +39,33 @@ export -f pgrep clean_chrome_old_versions EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Google Chrome running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome old versions · skipped (Chrome running)"* ]] +} + +@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" + +pgrep() { + case "$*" in + *"Google Chrome Helper"*) return 0 ;; + *) return 1 ;; + esac +} +export -f pgrep + +clean_chrome_old_versions +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome old versions · skipped (Chrome running)"* ]] } @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 +78,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" @@ -74,14 +101,14 @@ clean_chrome_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] + [ "$status" -eq 0 ] + [[ "$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 +121,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" @@ -119,12 +147,14 @@ clean_chrome_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Cleaned: 1 items"* ]] + [ "$status" -eq 0 ] + [[ "$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 +162,126 @@ 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} @@ -150,26 +300,136 @@ clean_edge_updater_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Edge updater old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Edge updater old versions"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"Cleaned: 2 items"* ]] +} + +# 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} + # 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" - ln -s "130.0.0.0" "$VERSIONS_DIR/Current" + # Remove Current if it exists as a directory, then create symlink + rm -rf "$VERSIONS_DIR/Current" + ln -s "130.0.0.0" "$VERSIONS_DIR/Current" - # Create a marker file in old version - touch "$VERSIONS_DIR/128.0.0.0/marker.txt" + # 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" @@ -188,17 +448,17 @@ total_items=0 clean_chrome_old_versions EOF - [ "$status" -eq 0 ] - [[ "$output" == *"dry"* ]] - # Verify marker file still exists (not deleted in dry run) - [ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ] + [ "$status" -eq 0 ] + [[ "$output" == *"dry"* ]] || return 1 + # Verify marker file still exists (not deleted in dry run) + [ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ] } @test "clean_chrome_old_versions handles missing Current symlink gracefully" { - # Use a fresh temp directory for this test - TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test5.XXXXXX")" + # 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,18 +479,19 @@ 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 EOF - rm -rf "$TEST_HOME" - [ "$status" -eq 0 ] - # Should exit gracefully with no output + rm -rf "$TEST_HOME" + [ "$status" -eq 0 ] + # Should exit gracefully with no output } @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" @@ -242,20 +503,19 @@ export -f pgrep clean_edge_old_versions EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Edge running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Edge old versions · skipped (Edge running)"* ]] } @test "clean_edge_old_versions removes old versions but keeps current" { - # Create mock Edge directory structure - local EDGE_APP="$HOME/Applications/Microsoft Edge.app" - local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" - mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} - 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' + # Create mock Edge directory structure + local EDGE_APP="$HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} + 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" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -276,24 +536,24 @@ clean_edge_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Edge old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Edge old versions"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"Cleaned: 2 items"* ]] } @test "clean_edge_old_versions handles no old versions gracefully" { - # Use a fresh temp directory for this test - TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")" + # Use a fresh temp directory for this test + TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")" - # Create Edge with only current version - local EDGE_APP="$TEST_HOME/Applications/Microsoft Edge.app" - local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" - mkdir -p "$VERSIONS_DIR/122.0.0.0" - ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + # Create Edge with only current version + local EDGE_APP="$TEST_HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR/122.0.0.0" + 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' + run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + 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" @@ -312,8 +572,8 @@ total_items=0 clean_edge_old_versions EOF - rm -rf "$TEST_HOME" - [ "$status" -eq 0 ] - # Should exit gracefully with no cleanup output - [[ "$output" != *"Edge old versions"* ]] + rm -rf "$TEST_HOME" + [ "$status" -eq 0 ] + # Should exit gracefully with no cleanup output + [[ "$output" != *"Edge old versions"* ]] } 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..7d9e390 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,464 @@ 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 < "$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 "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() { :; } +clean_stale_launch_services_registrations() { :; } +show_user_launch_agent_hint_notice() { :; } +show_orphan_dotdir_hint_notice() { :; } +clean_apple_silicon_caches() { :; } +clean_cached_device_firmware() { :; } +clean_time_machine_failed_backups() { :; } +check_large_file_candidates() { :; } +show_system_data_hint_notice() { :; } +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 · com.example.stale.plist"* ]] || 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" + local explicit_bytes generic_bytes explicit_kb generic_kb expected_human + explicit_bytes=$(stat -f%z "$explicit_cache/explicit.bin") + generic_bytes=$(stat -f%z "$generic_cache/generic.bin") + explicit_kb=$(((explicit_bytes + 1023) / 1024)) + generic_kb=$(((generic_bytes + 1023) / 1024)) + # shellcheck disable=SC2016 + expected_human=$(env PROJECT_ROOT="$PROJECT_ROOT" EXPECTED_KB="$((explicit_kb + generic_kb))" \ + bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; bytes_to_human_kb "$EXPECTED_KB"') + + 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 + printf '%s\n' "$output" | grep -F "Category total" | grep -qF "$expected_human" || 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 +dry_run_ledger_stats +printf 'PARTIAL=%s\n' "$DRY_RUN_TOTAL_PARTIAL" +cat "$EXPORT_LIST_FILE" +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"0 1 1 1"* ]] || return 1 + [[ "$output" == *"PARTIAL=true"* ]] || return 1 + [[ "$output" == *"Cloud & Office"* ]] || return 1 + [[ "$output" == *"cache.bin # size unknown"* ]] || 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 +557,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 +571,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 +580,56 @@ 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" ] } @@ -166,7 +639,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 +656,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 +673,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 +689,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 +708,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 +767,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 +779,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 +813,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 +825,188 @@ 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 report isolated category totals" { + # 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"*"Category total"*"3.1MB"*"Second"*"Category total"*"2.0MB"* ]] || return 1 + [[ "$(printf '%s\n' "$output" | grep -c "Category total")" -eq 2 ]] || return 1 +} + +@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 +} 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..15da19c 100644 --- a/Resources/mole/tests/clean_dev_caches.bats +++ b/Resources/mole/tests/clean_dev_caches.bats @@ -14,14 +14,16 @@ 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" { + 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" @@ -47,11 +49,13 @@ clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"Orphaned pnpm store"* ]] + [[ "$output" == *"pnpm cache"* ]] || return 1 + [[ "$output" != *"Orphaned pnpm store"* ]] || return 1 + [[ "$output" != *"pnpm store"* ]] } @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 +76,33 @@ 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_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 +123,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 +152,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,33 +178,554 @@ 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"; } +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" == *"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_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_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" == *"bun cache"* ]] || return 1 + [[ "$output" != *"Bun cache|$HOME/.bun/install/cache/*"* ]] || return 1 + [[ "$output" != *"Orphaned bun cache"* ]] +} + +@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 + + [ "$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/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 "$HOME/.bun/install/cache/" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$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"; } -debug_log() { echo "$*"; } -docker() { return 1; } +note_activity() { :; } +debug_log() { :; } +docker() { echo "docker called"; return 0; } +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker unused data · skipped (review: 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 · skipped (4M, review: 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 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 daemon not running"* ]] - [[ "$output" != *"Docker build cache"* ]] + [[ "$output" == *"Docker unused data · skipped (review: docker system df)"* ]] || return 1 + [[ "$output" != *"whitelisted"* ]] || return 1 + [[ "$output" != *"mo clean --whitelist"* ]] || return 1 + [[ "$output" != *"docker called"* ]] +} + +@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"* ]] || 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 (Codex running)"* ]] || return 1 + [[ "$output" != *"SAFE_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 "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 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 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_developer_tools runs key stages" { - 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/dev.sh" stop_section_spinner() { :; } @@ -191,8 +735,11 @@ 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() { :; } @@ -211,7 +758,6 @@ 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() { :; } @@ -221,16 +767,60 @@ clean_developer_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"npm"* ]] + [[ "$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" 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" @@ -243,6 +833,123 @@ clean_project_caches EOF [ "$status" -eq 0 ] - [[ "$output" == *"Flutter build cache (.dart_tool)"* ]] + [[ "$output" == *"Flutter build cache (.dart_tool)"* ]] || return 1 [[ "$output" == *"Flutter build cache (build/)"* ]] } + +@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 "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" ] +} diff --git a/Resources/mole/tests/clean_hints.bats b/Resources/mole/tests/clean_hints.bats index 04ab24f..31f15a2 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,16 +78,130 @@ 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_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 + + [ "$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 + 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 +} +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_system_data_hint_notice reports large clue paths" { mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOT3' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOT3' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/hints.sh" @@ -93,7 +219,556 @@ show_system_data_hint_notice EOT3 [ "$status" -eq 0 ] - [[ "$output" == *"Xcode DerivedData: 3.00GB"* ]] - [[ "$output" == *"~/Library/Developer/Xcode/DerivedData"* ]] - [[ "$output" == *"Review: mo analyze, Device backups, docker system df"* ]] + [[ "$output" == *"Xcode DerivedData"* ]] || return 1 + [[ "$output" == *"3.00GB"* ]] || return 1 + [[ "$output" == *"~/Library/Developer/Xcode/DerivedData"* ]] || return 1 +} + +@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 · com.example.stale.plist"* ]] || return 1 + [[ "$output" == *"Missing app/helper target"* ]] || 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" < + + + + 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() { :; } + +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 · 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 · 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 +} + +# ---- Orphan dotfile hint tests ---- + +@test "show_orphan_dotdir_hint_notice skips known-safe directories" { + mkdir -p "$HOME/.ssh" "$HOME/.config" "$HOME/.npm" "$HOME/.cargo" "$HOME/.putty" + touch -t 202401010000 "$HOME/.ssh" "$HOME/.config" "$HOME/.npm" "$HOME/.cargo" "$HOME/.putty" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *"Orphan dotfiles"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice skips whitelisted directories" { + mkdir -p "$HOME/.custom-orphan-keep" + touch -t 202401010000 "$HOME/.custom-orphan-keep" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +WHITELIST_PATTERNS=("$HOME/.custom-orphan-keep") +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".custom-orphan-keep"* ]] || return 1 + [[ "$output" != *"Orphan dotfiles"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice reports dir with no matching binary" { + mkdir -p "$HOME/.fakecli-test-orphan" + touch -t 202401010000 "$HOME/.fakecli-test-orphan" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphan dotfiles"* ]] || return 1 + [[ "$output" == *".fakecli-test-orphan"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice skips empty dotdirs (nothing to reclaim)" { + mkdir -p "$HOME/.fakecli-empty-orphan" + touch -t 202401010000 "$HOME/.fakecli-empty-orphan" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "0"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *"Orphan dotfiles"* ]] || return 1 + [[ "$output" != *".fakecli-empty-orphan"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice keeps dotdir when size probe times out" { + mkdir -p "$HOME/.fakecli-slow-orphan" + touch -t 202401010000 "$HOME/.fakecli-slow-orphan" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { return 1; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *".fakecli-slow-orphan"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice skips dotdir owned by installed GUI app (#872)" { + mkdir -p "$HOME/.bridge" + touch -t 202401010000 "$HOME/.bridge" + + local app_path="$HOME/Applications/Proton Mail Bridge.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + ch.protonmail.bridge + CFBundleName + Proton Mail Bridge + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bridge"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips state dir owned by an enabled Claude Code plugin (#889)" { + mkdir -p "$HOME/.cc-safety-net" + touch -t 202401010000 "$HOME/.cc-safety-net" + + mkdir -p "$HOME/.claude" + cat > "$HOME/.claude/settings.json" <<'JSON' +{ + "enabledPlugins": { + "safety-net@cc-marketplace": true + } +} +JSON + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".cc-safety-net"* ]] +} + +@test "show_orphan_dotdir_hint_notice still flags a plugin-shaped dir with no enabled plugin (#889)" { + mkdir -p "$HOME/.cc-safety-net" + touch -t 202401010000 "$HOME/.cc-safety-net" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *".cc-safety-net"* ]] +} + +@test "show_orphan_dotdir_hint_notice survives Claude config that has no plugins (#889)" { + mkdir -p "$HOME/.fakecli-test-orphan" + touch -t 202401010000 "$HOME/.fakecli-test-orphan" + + # Claude Code installed but no plugins: settings.json without + # enabledPlugins and an installed_plugins.json with no plugin tokens. + # The token-collection greps match nothing here, which must not abort + # the hint under `set -euo pipefail`. + mkdir -p "$HOME/.claude/plugins" + echo '{"theme":"dark"}' > "$HOME/.claude/settings.json" + echo '{"marketplaces":{}}' > "$HOME/.claude/plugins/installed_plugins.json" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *".fakecli-test-orphan"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dir with existing binary" { + mkdir -p "$HOME/.bash" + touch -t 202401010000 "$HOME/.bash" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bash"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dirs younger than threshold" { + mkdir -p "$HOME/.youngcli-test" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".youngcli-test"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dotdir whose name matches an installed .app token (#872)" { + mkdir -p "$HOME/.bridge" + touch -t 202401010000 "$HOME/.bridge" + + local fake_apps_root="$HOME/fake-Applications" + mkdir -p "$fake_apps_root/Proton Mail Bridge.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" FAKE_APPS_ROOT="$fake_apps_root" \ + /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { return 0; } +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$FAKE_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bridge"* ]] || return 1 + [[ "$output" != *"Orphan dotfiles"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice skips dotdir whose name matches a brew cask token (#872)" { + mkdir -p "$HOME/.bridge" + touch -t 202401010000 "$HOME/.bridge" + + local empty_apps_root="$HOME/empty-Applications" + mkdir -p "$empty_apps_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" EMPTY_APPS_ROOT="$empty_apps_root" \ + /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { + if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then + printf '%s\n' "proton-mail-bridge" "1password" + return 0 + fi + return 0 +} +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$EMPTY_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bridge"* ]] || return 1 + [[ "$output" != *"Orphan dotfiles"* ]] || return 1 +} + +@test "show_orphan_dotdir_hint_notice still flags dotdir whose name has no matching app or cask (#872)" { + mkdir -p "$HOME/.fakeorphan42xyz" + touch -t 202401010000 "$HOME/.fakeorphan42xyz" + + local empty_apps_root="$HOME/empty-Applications2" + mkdir -p "$empty_apps_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" EMPTY_APPS_ROOT="$empty_apps_root" \ + /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { + if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then + printf '%s\n' "1password" "rectangle" + return 0 + fi + return 0 +} +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$EMPTY_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphan dotfiles"* ]] || return 1 + [[ "$output" == *".fakeorphan42xyz"* ]] +} + +@test "show_orphan_dotdir_hint_notice ignores short app-name tokens (<4 chars) to avoid false matches (#872)" { + # `.ai-old`: token `ai` is 2 chars; an `AI.app` should NOT exempt it. + mkdir -p "$HOME/.ai-old" + touch -t 202401010000 "$HOME/.ai-old" + + local fake_apps_root="$HOME/fake-Applications-short" + mkdir -p "$fake_apps_root/AI.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" FAKE_APPS_ROOT="$fake_apps_root" \ + /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { return 0; } +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$FAKE_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *"Orphan dotfiles"* ]] || return 1 + [[ "$output" == *".ai-old"* ]] +} + +@test "show_orphan_dotdir_hint_notice limits output to max 5 candidates" { + for i in $(seq 1 8); do + mkdir -p "$HOME/.orphantest${i}" + touch -t 202401010000 "$HOME/.orphantest${i}" + done + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + local count + count=$(echo "$output" | grep -o "orphantest" | wc -l | tr -d ' ') + [ "$count" -le 5 ] } diff --git a/Resources/mole/tests/clean_launch_services.bats b/Resources/mole/tests/clean_launch_services.bats new file mode 100644 index 0000000..30855c4 --- /dev/null +++ b/Resources/mole/tests/clean_launch_services.bats @@ -0,0 +1,217 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-launch-services-home.XXXXXX")" + TEST_ROOT="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-launch-services-case.XXXXXX")" + export HOME TEST_ROOT +} + +teardown() { + case "${HOME:-}" in + "${BATS_TEST_DIRNAME}/tmp-launch-services-home."*) rm -rf "$HOME" ;; + esac + case "${TEST_ROOT:-}" in + "${BATS_TEST_DIRNAME}/tmp-launch-services-case."*) rm -rf "$TEST_ROOT" ;; + esac +} + +write_lsregister_stub() { + local bin_path="$1" + mkdir -p "$(dirname "$bin_path")" + cat > "$bin_path" <<'SCRIPT' +#!/usr/bin/env bash +{ + printf 'argc=%s\n' "$#" + for arg in "$@"; do + printf 'arg=%s\n' "$arg" + done +} >> "$LSREGISTER_LOG" + +case "${1:-}" in + -dump) + cat "$LSREGISTER_DUMP" + exit 0 + ;; + -u) + exit 0 + ;; +esac +exit 2 +SCRIPT + chmod +x "$bin_path" +} + +@test "clean_stale_launch_services_registrations dry-run reports missing apps without unregistering" { + local lsregister="$TEST_ROOT/bin/lsregister" + local dump_file="$TEST_ROOT/lsregister.dump" + local log_file="$TEST_ROOT/lsregister.log" + local missing_app="$TEST_ROOT/Missing App.app" + local unrelated_missing_app="$TEST_ROOT/Unrelated Missing.app" + local existing_app="$TEST_ROOT/Existing.app" + mkdir -p "$existing_app" + write_lsregister_stub "$lsregister" + + cat > "$dump_file" < "$log_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" LSREGISTER_BIN="$lsregister" LSREGISTER_DUMP="$dump_file" LSREGISTER_LOG="$log_file" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" +get_lsregister_path() { printf '%s\n' "$LSREGISTER_BIN"; } +run_with_timeout() { shift; "$@"; } +note_activity() { printf 'activity\n'; } +DRY_RUN=true +clean_stale_launch_services_registrations +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"LaunchServices stale app registrations"* ]] || return 1 + [[ "$output" == *"would unregister 1"* ]] || return 1 + [[ "$output" == *"Missing App.app"* ]] || return 1 + grep -q 'arg=-dump' "$log_file" + if grep -q 'arg=-u' "$log_file"; then + return 1 + fi +} + +@test "clean_stale_launch_services_registrations unregisters only targeted missing app records" { + local lsregister="$TEST_ROOT/bin/lsregister" + local dump_file="$TEST_ROOT/lsregister.dump" + local log_file="$TEST_ROOT/lsregister.log" + local missing_app="$TEST_ROOT/Missing App.app" + local unrelated_missing_app="$TEST_ROOT/Unrelated Missing.app" + local existing_app="$TEST_ROOT/Existing.app" + mkdir -p "$existing_app" + write_lsregister_stub "$lsregister" + + cat > "$dump_file" < "$log_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" LSREGISTER_BIN="$lsregister" LSREGISTER_DUMP="$dump_file" LSREGISTER_LOG="$log_file" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" +get_lsregister_path() { printf '%s\n' "$LSREGISTER_BIN"; } +run_with_timeout() { shift; "$@"; } +note_activity() { printf 'activity\n'; } +DRY_RUN=false +MOLE_DRY_RUN=0 +clean_stale_launch_services_registrations +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"LaunchServices stale app registrations, 1 removed"* ]] || return 1 + grep -q 'arg=-dump' "$log_file" + grep -q 'argc=2' "$log_file" + grep -q 'arg=-u' "$log_file" + grep -q "arg=$missing_app" "$log_file" + if grep -q "arg=$existing_app" "$log_file"; then + return 1 + fi + if grep -q "arg=$unrelated_missing_app" "$log_file"; then + return 1 + fi + if grep -q 'arg=-r' "$log_file" || grep -q 'arg=-f' "$log_file"; then + return 1 + fi +} + +@test "launch_services_stale_app_path_is_safe rejects unsafe, live, and malformed paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TEST_ROOT="$TEST_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" + +fail=0 +expect_reject() { + if launch_services_stale_app_path_is_safe "$1"; then + printf 'UNEXPECTED_ACCEPT: %q\n' "$1" + fail=1 + fi +} +expect_accept() { + if ! launch_services_stale_app_path_is_safe "$1"; then + printf 'UNEXPECTED_REJECT: %q\n' "$1" + fail=1 + fi +} + +# Genuinely missing, absolute, .app bundle is the only case that may unregister. +expect_accept "$TEST_ROOT/Gone.app" + +# A live bundle on disk must never be unregistered. +mkdir -p "$TEST_ROOT/Live.app" +expect_reject "$TEST_ROOT/Live.app" + +# Format, protected-root, traversal, and injection rejections. +expect_reject "" +expect_reject "relative/Path.app" +expect_reject "$TEST_ROOT/NotAnApp" +expect_reject "/System/Applications/Gone.app" +expect_reject "/Library/Apple/Gone.app" +expect_reject "$TEST_ROOT/../Gone.app" +expect_reject "$(printf '/tmp/Bad\nName.app')" +expect_reject "$(printf '/tmp/Bad\rName.app')" + +exit $fail +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_stale_launch_services_registrations ignores dump failures" { + local lsregister="$TEST_ROOT/bin/lsregister" + local dump_file="$TEST_ROOT/missing.dump" + local log_file="$TEST_ROOT/lsregister.log" + write_lsregister_stub "$lsregister" + : > "$log_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" LSREGISTER_BIN="$lsregister" LSREGISTER_DUMP="$dump_file" LSREGISTER_LOG="$log_file" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" +get_lsregister_path() { printf '%s\n' "$LSREGISTER_BIN"; } +run_with_timeout() { shift; "$@"; } +note_activity() { printf 'activity\n'; } +DRY_RUN=false +clean_stale_launch_services_registrations +EOF + + [ "$status" -eq 0 ] + [[ "$output" == "" ]] || return 1 + grep -q 'arg=-dump' "$log_file" +} diff --git a/Resources/mole/tests/clean_misc.bats b/Resources/mole/tests/clean_misc.bats index 31282bb..42fb744 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,185 @@ 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 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 -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() { :; } +note_activity() { :; } +debug_log() { :; } +pgrep() { return 1; } is_path_whitelisted() { return 1; } -WHITELIST_PATTERNS=() -PROTECT_FINDER_METADATA="false" -scan_external_volumes +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 +276,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,12 +289,12 @@ 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' + 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"; } @@ -144,12 +302,12 @@ clean_3d_tools EOF [ "$status" -eq 0 ] - [[ "$output" == *"Blender cache"* ]] + [[ "$output" == *"Blender cache"* ]] || return 1 [[ "$output" == *"Cinema 4D cache"* ]] } @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 +315,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 +328,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 +341,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 +354,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 +367,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 +380,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_system_caches.bats b/Resources/mole/tests/clean_system_caches.bats index 0275c70..d240834 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,15 +116,131 @@ 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 +424,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 +437,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 +445,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 +455,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 +463,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 +508,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 +528,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 < "$CALL_LOG" @@ -29,6 +54,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 @@ -36,6 +62,11 @@ sudo() { case "$2" in /Library/Caches) printf '%s\0' "/Library/Caches/test.log" ;; /private/var/log) printf '%s\0' "/private/var/log/system.log" ;; + # Each sweep is gated on this probe finding at least one aged file, so a + # directory with no case here is silently never cleaned and the matching + # assertion below can never hold. + /private/tmp) printf '%s\0' "/private/tmp/stale.tmp" ;; + /private/var/tmp) printf '%s\0' "/private/var/tmp/stale.tmp" ;; esac return 0 fi @@ -56,24 +87,23 @@ 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" == *"/Library/Caches"* ]] || return 1 + [[ "$output" == *"/private/tmp"* ]] || return 1 [[ "$output" == *"/private/var/log"* ]] } @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 +120,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 +131,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 +139,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 +148,10 @@ 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" ;; + # The third-party sweep probes each vendor dir by exact path, so a + # bare /Library/Logs case never gates it on. + /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 +172,130 @@ 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:*"* ]] - [[ "$output" == *"safe_sudo_find_delete:/Library/Logs/CreativeCloud:*"* ]] + [[ "$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 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" + 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 skips idleassetsd sweep 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:$IDLE_DIR:CFNetworkDownload"* ]] || 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 +303,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,7 +327,6 @@ 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; } @@ -203,14 +344,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 +359,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,7 +388,6 @@ 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; } @@ -264,12 +405,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 +432,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 +460,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 +487,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" @@ -362,7 +505,7 @@ EOF } @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 +549,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 - "$@" -} - -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" + printf 'CALL:%s env_no_autoremove=%s\n' "$*" "${HOMEBREW_NO_AUTOREMOVE:-}" >> "$calls" + if [[ "$1" == "du" ]]; then + echo "51201 $3" return 0 fi - return 0 -} - -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"2 formula, 1 cask available"* ]] - [[ "$output" == *"COUNTS=3:2:1"* ]] + "$@" } -@test "check_homebrew_updates shows timeout warning when brew query times out" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -run_with_timeout() { return 124; } -brew() { return 0; } -rm -f "$HOME/.cache/mole/brew_updates" - -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"Check timed out"* ]] - [[ "$output" == *"COUNTS=0:0:0"* ]] +brew() { + 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 } -@test "check_homebrew_updates shows failure warning when brew query fails" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -run_with_timeout() { return 1; } -brew() { return 0; } -rm -f "$HOME/.cache/mole/brew_updates" - -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" == *"Check failed"* ]] - [[ "$output" == *"COUNTS=0:0:0"* ]] + [[ "$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_macos_update avoids slow softwareupdate scans" { - 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" -defaults() { echo "1"; } +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() { - 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" "$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 } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" +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" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + [[ "$output" == *"Homebrew links · restored 1 active executable(s)"* ]] || { + echo "$output" + return 1 + } } -@test "check_macos_update clears update flag when softwareupdate reports no updates" { - 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" -defaults() { echo "1"; } +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() { - 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 + 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" "$node_target" + ;; + "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" ]] 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 "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 - return 124 + 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 keeps update flag when softwareupdate returns empty output" { - 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 - 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" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + [[ "$output" != *"Homebrew links · restored"* ]] } -@test "check_macos_update skips softwareupdate when defaults shows no updates" { - 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 "0"; } +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() { - echo "SHOULD_NOT_CALL_SOFTWAREUPDATE" - return 0 + shift + "$@" +} +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" + "$@" } -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" +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") + +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" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]] + [[ "$output" == *"Homebrew links · restored 1 active executable(s)"* ]] } -@test "check_macos_update outputs debug info when MO_DEBUG set" { - 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" - -defaults() { echo "1"; } +source "$PROJECT_ROOT/lib/clean/brew.sh" -export MO_DEBUG=1 +calls="$HOME/brew_dry_run_calls.log" +: > "$calls" +DRY_RUN=true run_with_timeout() { - local timeout="${1:-}" + local duration="$1" shift - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - echo "No new software available." - return 0 - fi - return 124 + printf 'CALL:%s\n' "$*" >> "$calls" + "$@" +} +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 +905,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,7 +922,7 @@ 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" @@ -737,7 +935,7 @@ 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" @@ -753,7 +951,7 @@ EOF 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" @@ -770,7 +968,7 @@ EOF 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" @@ -787,7 +985,7 @@ EOF 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" @@ -806,7 +1004,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 +1019,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 +1031,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" @@ -851,7 +1049,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 +1057,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 @@ -881,22 +1082,21 @@ safe_sudo_find_delete() { } 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" == *"reportmemoryexception/MemoryLimitViolations"* ]] || return 1 [[ "$output" == *"-mtime +30"* ]] # 30-day retention [[ "$output" == *"safe_sudo_find_delete"* ]] } @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 +1104,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 @@ -927,21 +1129,20 @@ safe_sudo_find_delete() { 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 +1151,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 +1169,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" @@ -981,7 +1181,7 @@ EOF } @test "clean_deep_system cleans diagnostic trace logs" { - run bash --noprofile --norc << 'EOF' + run /bin/bash --noprofile --norc << 'EOF' set -euo pipefail CALL_LOG="$HOME/diag_calls.log" > "$CALL_LOG" @@ -989,6 +1189,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 @@ -1014,22 +1216,21 @@ 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" == *"safe_sudo_find_delete:/private/var/db/diagnostics:*.tracev3"* ]] || return 1 + [[ "$output" == *"safe_sudo_find_delete:/private/var/db/DiagnosticPipeline:*"* ]] || return 1 [[ "$output" == *"tracev3"* ]] } @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,7 +1254,6 @@ 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" @@ -1070,12 +1270,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,7 +1299,6 @@ 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" @@ -1116,12 +1315,255 @@ 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 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/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:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/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" + +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 +} +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 + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"safe_sudo_remove:/Library/Caches/com.apple.iconservices.store"* ]] || return 1 + [[ "$output" == *"SUCCESS:Rebuildable system caches, 1 item"* ]] +} + +@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" == "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; } +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:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/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:-}" == "command" && "${2:-}" == "find" && "${5:-}" == "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:-}" == "command" && "${2:-}" == "find" ]]; then + return 0 + fi + "$@" +} + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + # 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_memory_pressure_relief skips when pressure is normal" { - 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" @@ -1140,7 +1582,7 @@ EOF } @test "opt_memory_pressure_relief executes purge when pressure is high" { - 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" @@ -1160,16 +1602,19 @@ sudo() { } export -f sudo +# 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 opt_memory_pressure_relief EOF [ "$status" -eq 0 ] - [[ "$output" == *"Inactive memory released"* ]] + [[ "$output" == *"Inactive memory released"* ]] || return 1 [[ "$output" == *"System responsiveness improved"* ]] } @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" @@ -1192,8 +1637,35 @@ EOF [[ "$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 + +opt_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" @@ -1231,29 +1703,23 @@ dscacheutil() { } export -f dscacheutil +# 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 opt_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 @@ -1270,19 +1736,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,6 +1762,9 @@ start_inline_spinner() { :; } stop_inline_spinner() { :; } export -f start_inline_spinner stop_inline_spinner +# 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 opt_disk_permissions_repair EOF @@ -1309,158 +1772,8 @@ EOF [[ "$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' -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 - 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" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods Pro: - Connected: Yes - Type: Headphones -PROFILER_OUT - return 0 -} -export -f system_profiler - -pgrep() { - if [[ "$2" == "Spotify" ]]; then - echo "12345" - return 0 - fi - return 1 -} -export -f pgrep - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth 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' -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 - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset restarts when safe" { - 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" - -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 - -sudo() { - if [[ "$1" == "pkill" ]]; then - echo "pkill:bluetoothd:$2" - return 0 - fi - return 1 -} -export -f sudo - -sleep() { :; } -export -f sleep - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth module restarted"* ]] -} - @test "opt_spotlight_index_optimize skips when search is fast" { - 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" @@ -1490,3 +1803,78 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Spotlight index already optimal"* ]] } + +@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/clean/system.sh" + +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" == *"GATES_OK"* ]] +} + +@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 + } +} diff --git a/Resources/mole/tests/clean_user_core.bats b/Resources/mole/tests/clean_user_core.bats index acf3b54..536bca4 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,28 @@ EOF [[ "$output" != *"Trash"* ]] } -@test "clean_user_essentials falls back when Finder trash operations time out" { +@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 +78,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 +137,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 +203,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, idle assets, 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 +224,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" == *"FIND:$support_home/Library/Application Support/com.apple.idleassetsd:30:f"* ]] || 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,13 +248,17 @@ 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" { +@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" @@ -173,9 +267,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 +277,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 +372,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 +381,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 +407,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 +417,180 @@ 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 "_clean_darwin_user_runtime_dir removes only old non-state files" { + local runtime_home="$HOME/darwin-runtime" + run env HOME="$runtime_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +mkdir -p "$HOME/runtime/T" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +_darwin_user_runtime_dir_is_safe() { return 0; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +echo "old" > "$HOME/runtime/T/old.tmp" +echo "new" > "$HOME/runtime/T/new.tmp" +echo "state" > "$HOME/runtime/T/state.sqlite" +touch -t 202301010000 "$HOME/runtime/T/old.tmp" "$HOME/runtime/T/state.sqlite" + +_clean_darwin_user_runtime_dir "$HOME/runtime/T" "temp" "Darwin user temp files" + +[[ ! -e "$HOME/runtime/T/old.tmp" ]] +[[ -e "$HOME/runtime/T/new.tmp" ]] +[[ -e "$HOME/runtime/T/state.sqlite" ]] +echo "PASS" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] +} + +@test "_clean_darwin_user_runtime_dir skips endpoint-security (EDR) agent caches" { + local runtime_home="$HOME/darwin-runtime-edr" + run env HOME="$runtime_home" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +mkdir -p "$HOME/runtime/C/com.crowdstrike.falcon.App" "$HOME/runtime/C/com.example.App" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +_darwin_user_runtime_dir_is_safe() { return 0; } +# Isolate the loop's use of the guard from the predicate's real var/folders +# anchoring (which is covered in core_safe_functions.bats): treat the Falcon +# fixture as an EDR path regardless of the test sandbox location. +is_endpoint_security_cache_path() { case "$1" in *com.crowdstrike.*) return 0 ;; *) return 1 ;; esac; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +echo edr > "$HOME/runtime/C/com.crowdstrike.falcon.App/cache.bin" +echo norm > "$HOME/runtime/C/com.example.App/cache.bin" +touch -t 202301010000 "$HOME/runtime/C/com.crowdstrike.falcon.App/cache.bin" "$HOME/runtime/C/com.example.App/cache.bin" + +_clean_darwin_user_runtime_dir "$HOME/runtime/C" "cache" "Darwin user cache files" + +# The EDR agent's user-owned cache is never deleted (tamper protection)... +[[ -e "$HOME/runtime/C/com.crowdstrike.falcon.App/cache.bin" ]] +# ...while a normal app's old cache file still gets reclaimed. +[[ ! -e "$HOME/runtime/C/com.example.App/cache.bin" ]] +echo "PASS" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] +} + @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" @@ -310,7 +643,132 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"Group Containers logs/caches"* ]] + [[ "$output" == *"Group Containers logs/caches"* ]] || return 1 + [[ "$output" == *"PASS"* ]] +} + +@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() { :; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +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" + +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 +} + +@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 + +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" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Handoff clipboard cache"* ]] || return 1 + [[ "$output" == *"dry"* ]] || return 1 + [[ "$output" == *"PASS"* ]] || return 1 +} + +@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" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +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 "$notes_cache/NoteStore.sqlite" ]]; then + echo "PASS" +else + echo "FAIL" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] [[ "$output" == *"PASS"* ]] } @@ -411,10 +869,40 @@ fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"PASS"* ]] + [[ "$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 @@ -430,24 +918,138 @@ EOF [[ "$output" == "" ]] } -@test "check_ios_device_backups returns when no backup dir" { +@test "clean_browsers calls expected cache paths" { + 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 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Safari cache"* ]] || return 1 + [[ "$output" == *"Firefox cache"* ]] || return 1 + [[ "$output" == *"Puppeteer browser 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" -check_ios_device_backups +WHITELIST_PATTERNS=( + "$HOME/Library/Application Support/Google/Chrome/OptGuideOnDevice*/*" + "$HOME/Library/Application Support/Google/Chrome/optimization_guide_model_store/*" +) +pgrep() { return 1; } +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 calls expected cache paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' +@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 @@ -455,13 +1057,98 @@ clean_browsers EOF [ "$status" -eq 0 ] - [[ "$output" == *"Safari cache"* ]] - [[ "$output" == *"Firefox cache"* ]] - [[ "$output" == *"Puppeteer browser cache"* ]] + # 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 +1161,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 +1181,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 +1192,179 @@ 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"* ]] +} + +# 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 +} 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..db7a353 --- /dev/null +++ b/Resources/mole/tests/clean_xcode_derived_data.bats @@ -0,0 +1,196 @@ +#!/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 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"3 projects"* ]] || return 1 + [[ "$output" == *"Xcode DerivedData"* ]] || return 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 Xcode 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; } +DRY_RUN=false + +pgrep() { return 0; } +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 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode DerivedData · skipped (Xcode running)"* ]] +} + +@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; } +DRY_RUN=false +pgrep() { return 1; } +export -f pgrep + +mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"projects"* ]] +} + +@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"* ]] +} 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..705ed01 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,60 @@ 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 "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 +266,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 +286,68 @@ 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;90m0B\033[0m' ] +} + @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 +363,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" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.clash.app' && echo 'protected' || echo 'not-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" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.example.RegularApp' && echo 'protected' || echo 'not-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" ] + + 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" ] + + 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" 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_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" /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..21101ee 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,294 @@ 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 ] +} - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/etc'" +@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" + + # 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 "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 +335,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 +349,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 @@ -124,14 +359,593 @@ teardown() { } @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 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 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 does not log REMOVED when sudo lapses mid-batch" { + 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 right after the find: the batch xargs rm +# fails, and every later sudo probe (true / test / rm) fails for the same +# auth reason. Nothing was deleted, so no REMOVED lines may be logged. +sudo() { + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + find) + printf '%s\0' "$TARGET_DIR/a.log" "$TARGET_DIR/b.log" + ;; + *) + 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=0"* ]] || 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 retries batch survivors through safe_sudo_remove" { + 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=0"* ]] || return 1 + [[ "$output" == *"SUDO:-n xargs -0 rm -f --"* ]] || 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 + +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +[[ -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" == *"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 + +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +[[ -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" == *"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" { @@ -140,15 +954,15 @@ teardown() { 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 +976,35 @@ 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 ] +} + +@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,248 @@ 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. 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=$(grep -rn -- 'du -s' "$PROJECT_ROOT/lib" "$PROJECT_ROOT/bin" \ + | grep -v 'run_with_timeout' \ + | grep -v '^\s*#' \ + | grep -v ':[0-9]*:\s*#' || 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..0892391 100644 --- a/Resources/mole/tests/dev_extended.bats +++ b/Resources/mole/tests/dev_extended.bats @@ -1,28 +1,30 @@ #!/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-dev-extended.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-dev-extended.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 "clean_dev_elixir cleans hex cache" { - mkdir -p "$HOME/.mix" "$HOME/.hex" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + mkdir -p "$HOME/.mix" "$HOME/.hex" + 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" @@ -30,28 +32,28 @@ safe_clean() { echo "$2"; } clean_dev_elixir EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Hex cache"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Hex cache"* ]] } @test "clean_dev_elixir does not clean mix archives" { - mkdir -p "$HOME/.mix/archives" - touch "$HOME/.mix/archives/test_tool.ez" + mkdir -p "$HOME/.mix/archives" + touch "$HOME/.mix/archives/test_tool.ez" - # 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_elixir > /dev/null 2>&1 || true + # 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_elixir >/dev/null 2>&1 || true - # Verify the file still exists - [ -f "$HOME/.mix/archives/test_tool.ez" ] + # Verify the file still exists + [ -f "$HOME/.mix/archives/test_tool.ez" ] } @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' + mkdir -p "$HOME/.cabal" "$HOME/.stack" + 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" @@ -59,28 +61,28 @@ safe_clean() { echo "$2"; } clean_dev_haskell EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Cabal install cache"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Cabal install cache"* ]] } @test "clean_dev_haskell does not clean stack programs" { - mkdir -p "$HOME/.stack/programs/x86_64-osx" - touch "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" + mkdir -p "$HOME/.stack/programs/x86_64-osx" + touch "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" - # 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_haskell > /dev/null 2>&1 || true + # 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_haskell >/dev/null 2>&1 || true - # Verify the file still exists - [ -f "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" ] + # Verify the file still exists + [ -f "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" ] } @test "clean_dev_ocaml cleans opam cache" { - mkdir -p "$HOME/.opam" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + mkdir -p "$HOME/.opam" + 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" @@ -88,59 +90,29 @@ safe_clean() { echo "$2"; } clean_dev_ocaml EOF - [ "$status" -eq 0 ] - [[ "$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" ] + [ "$status" -eq 0 ] + [[ "$output" == *"Opam cache"* ]] } @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_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"Android NDK versions: 3 found"* ]] + [ "$status" -eq 0 ] + [[ "$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_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" != *"NDK versions"* ]] + [ "$status" -eq 0 ] + [[ "$output" != *"NDK versions"* ]] } @test "clean_xcode_device_support handles empty directories under nounset" { - local ds_dir="$HOME/EmptyDeviceSupport" - mkdir -p "$ds_dir" + 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" @@ -150,23 +122,27 @@ clean_xcode_device_support "$HOME/EmptyDeviceSupport" "iOS DeviceSupport" echo "survived" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"survived"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"survived"* ]] } @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" + 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" bash --noprofile --norc <<'EOF' + 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; } has_sudo_session() { return 0; } is_path_whitelisted() { return 1; } should_protect_path() { return 1; } @@ -177,18 +153,18 @@ safe_sudo_remove() { clean_xcode_documentation_cache 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 ] + [[ "$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)"* ]] } @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" + 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" bash --noprofile --norc <<'EOF' + 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" @@ -198,34 +174,158 @@ safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } clean_xcode_documentation_cache EOF - [ "$status" -eq 0 ] - [[ "$output" == *"skipping documentation cache cleanup"* ]] - [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode documentation cache · skipped (Xcode running)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@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; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +safe_sudo_remove() { echo "REMOVE:$1"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"REMOVE:$cache_root/dyld"* ]] || return 1 + [[ "$output" == *"REMOVE:$cache_root/metadata"* ]] || return 1 + [[ "$output"$'\n' != *"REMOVE:$cache_root"$'\n'* ]] +} + +@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() { return 0; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode Simulator system cache · skipped (CoreSimulator running)"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@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; } +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 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; } +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; } +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 "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" + 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_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"Rust toolchains: 3 found"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Rust toolchains · 3 found"* ]] } @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" + 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_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" != *"Rust toolchains"* ]] + [ "$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" + 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" 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" @@ -246,18 +346,18 @@ MOLE_JETBRAINS_TOOLBOX_KEEP=1 clean_dev_jetbrains_toolbox EOF - [ "$status" -eq 0 ] - [[ "$output" == *"/241.1"* ]] - [[ "$output" != *"/241.2"* ]] + [ "$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" + 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" 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" @@ -267,18 +367,239 @@ MOLE_JETBRAINS_TOOLBOX_KEEP=1 clean_dev_jetbrains_toolbox EOF - [ "$status" -eq 0 ] - [[ "$output" == *"/241.1"* ]] - [[ "$output" != *"/241.2"* ]] + [ "$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 does not touch Claude Code 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" + + 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/.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" + 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" - 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' + # 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" @@ -310,34 +631,581 @@ echo "SIZE_LOG_START" cat "$size_log" 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"* ]] + [ "$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_dev_mobile continues cleanup when simctl is unavailable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + 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"; } note_activity() { :; } debug_log() { :; } -xcrun() { return 1; } clean_dev_mobile EOF - [ "$status" -eq 0 ] - [[ "$output" == *"simctl not available"* ]] - [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] - [[ "$output" == *"SAFE_CLEAN:Android SDK cache"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"simctl not available"* ]] || return 1 + [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] || 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" < "$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" < "$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" < "$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" <> "$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_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|"* ]] } 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..57a4860 --- /dev/null +++ b/Resources/mole/tests/file_ops_mole_delete.bats @@ -0,0 +1,850 @@ +#!/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 "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 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 < "$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" + 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 "0" + # quickly, 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 < "$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" ] +} 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..2cef7fd --- /dev/null +++ b/Resources/mole/tests/history.bats @@ -0,0 +1,201 @@ +#!/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 --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..cc31656 --- /dev/null +++ b/Resources/mole/tests/install_checksum.bats @@ -0,0 +1,722 @@ +#!/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:Downloaded analyze binary"* ]] +} + +@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:Downloaded analyze binary"* ]] +} + +@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:Downloaded 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 "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"; 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; } + +# 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 "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 ] +} diff --git a/Resources/mole/tests/installer.bats b/Resources/mole/tests/installer.bats index 1e26595..937378c 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,199 @@ 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"* ]] +} 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..4dc4f8c 100644 --- a/Resources/mole/tests/manage_sudo.bats +++ b/Resources/mole/tests/manage_sudo.bats @@ -26,7 +26,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 +41,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 +62,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..912bc75 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,101 @@ setup() { [ "$status" -ne 0 ] } +@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 +209,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..132c82d 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,12 +71,12 @@ 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' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/optimize/tasks.sh" memory_pressure() { echo "warning"; } @@ -83,12 +86,12 @@ if is_memory_pressure_high; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"high"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"high"* ]] } @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" @@ -97,13 +100,13 @@ mdutil() { echo "Indexing enabled."; } opt_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" @@ -111,13 +114,243 @@ flush_dns_cache() { return 0; } opt_network_optimization EOF - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache refreshed"* ]] - [[ "$output" == *"mDNSResponder restarted"* ]] + [ "$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 "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; } + +opt_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" +} + +opt_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 "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" +opt_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');" +opt_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" +opt_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"; } +execute_optimization quarantine_cleanup +EOF + + [ "$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" @@ -125,40 +358,178 @@ export PATH="/nonexistent" opt_sqlite_vacuum EOF - [ "$status" -eq 0 ] - [[ "$output" == *"sqlite3 unavailable"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"sqlite3 unavailable"* ]] +} + +@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_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_dock_refresh reports refresh" { + 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" -opt_font_cache_rebuild +opt_dock_refresh EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Font cache cleared"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Dock refreshed"* ]] } -@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_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" -mkdir -p "$HOME/Library/Application Support/Dock" -touch "$HOME/Library/Application Support/Dock/test.db" -safe_remove() { return 0; } -opt_dock_refresh +defaults() { + case "$1" in + read) return 1 ;; + write) return 0 ;; + esac +} +opt_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 +} +opt_prevent_network_dsstore +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already enabled"* ]] +} + +@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 +} +opt_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 +} +opt_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 +} +opt_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" ]]; } +opt_legacy_overrides_audit +EOF + + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Skipped (whitelisted): App Nap disabled globally"* ]] || return 1 + [[ "$output" != *"DELETE_CALLED"* ]] || 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" == *"Dock cache cleared"* ]] - [[ "$output" == *"Dock refreshed"* ]] + [ "$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" @@ -166,24 +537,203 @@ opt_dock_refresh() { echo "dock"; } execute_optimization dock_refresh EOF - [ "$status" -eq 0 ] - [[ "$output" == *"dock"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"dock"* ]] } @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 ] + [[ "$output" == *"Unknown action"* ]] +} + +@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" ]]; } +opt_prune_spotlight_orphan_rules +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; } +opt_prune_spotlight_orphan_rules +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; } +opt_prune_spotlight_orphan_rules +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] || return 1 + [[ "$output" != *"DEFAULTS: 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; } +opt_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; } +opt_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; } +opt_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; } +opt_prune_spotlight_orphan_rules +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"; } +execute_optimization spotlight_orphan_rules_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"pruned"* ]] } @test "opt_launch_services_rebuild handles missing lsregister without exiting" { - 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" @@ -195,7 +745,883 @@ opt_launch_services_rebuild echo "survived" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"lsregister not found"* ]] - [[ "$output" == *"survived"* ]] + [ "$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" +opt_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; } +opt_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 +opt_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 +opt_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"; } +execute_optimization launch_agents_cleanup +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" opt_periodic_maintenance +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" opt_periodic_maintenance +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" opt_periodic_maintenance +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" opt_periodic_maintenance +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance triggered"* ]] +} + +@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_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 +opt_periodic_maintenance +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"; } +execute_optimization periodic_maintenance +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" == "dock_refresh" ]]; } +opt_dock_refresh() { echo "UNEXPECTED_DOCK"; } +execute_optimization dock_refresh +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped (whitelisted): dock_refresh"* ]] || return 1 + [[ "$output" != *"UNEXPECTED_DOCK"* ]] +} + +@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 "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. +is_memory_pressure_high() { return 0; } +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() { :; } + +opt_memory_pressure_relief 2>&1 || true +opt_network_stack_optimize 2>&1 || true +opt_disk_permissions_repair 2>&1 || true +opt_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" +scutil() { + cat <<'NC' +* (Disconnected) AA1B2C3D-1111-2222-3333-444455556666 PPP (L2TP) "Office VPN" [L2TP] +* (Connected) 87654321-aaaa-bbbb-cccc-dddddddddddd IPSec (IKEv2) "Remote Office"[IKEv2] +NC +} +export -f scutil +# Default route should NOT be consulted once scutil already proved a VPN active. +route() { echo "should not be called" >&2; return 1; } +export -f route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +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" +scutil() { + cat <<'NC' +* (Disconnected) AA1B2C3D-1111-2222-3333-444455556666 PPP (L2TP) "Office VPN" [L2TP] +* (Disconnected) 87654321-aaaa-bbbb-cccc-dddddddddddd IPSec (IKEv2) "Remote Office"[IKEv2] +NC +} +# Default route via en0 (no VPN). This is the user's case in #959. +route() { + cat <<'ROUTE' + route to: default +destination: default + mask: default + gateway: 192.168.1.1 + interface: en0 +ROUTE +} +export -f scutil route +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. +scutil() { echo ""; } +# Default route owned by utun3 -> full-tunnel VPN (WireGuard / OpenVPN style). +route() { + cat <<'ROUTE' + route to: default +destination: default + mask: default + gateway: 10.8.0.1 + interface: utun3 +ROUTE +} +export -f scutil route +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. +scutil() { echo ""; } +route() { + cat <<'ROUTE' + route to: default +destination: default + mask: default + gateway: 192.168.1.1 + interface: en0 +ROUTE +} +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] +} + +@test "opt_dock_refresh preserves desktoppicture.db and other db files (#995)" { + local dock_support="$HOME/Library/Application Support/Dock" + mkdir -p "$dock_support" + : > "$dock_support/desktoppicture.db" + : > "$dock_support/another.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/optimize/tasks.sh" +killall() { return 0; } +export -f killall +opt_dock_refresh +EOF + + [ "$status" -eq 0 ] + [ -f "$HOME/Library/Application Support/Dock/desktoppicture.db" ] + [ -f "$HOME/Library/Application Support/Dock/another.db" ] +} + +@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" == *"/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..578aca6 --- /dev/null +++ b/Resources/mole/tests/optimize_catalog.bats @@ -0,0 +1,173 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@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 +dock_refresh|opt_dock_refresh|Dock Refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|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 +memory_pressure_relief|opt_memory_pressure_relief|Memory Optimization|Memory Optimization|Release inactive memory to improve system responsiveness|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 "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="9c00db8177c600e35ba56df69c3c3dc078ffefee57d982a96b71b3174cb340ac" +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="89f0731c4074f1eaeabb4a2c7ab65e14392f28f31ebbc4abefc9f6919406f65a" +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 23 ]] || 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..51b47fe --- /dev/null +++ b/Resources/mole/tests/optimize_db.bats @@ -0,0 +1,119 @@ +#!/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) + 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 <&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..191363f 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,7 +45,7 @@ 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'" + run /bin/bash -c "grep -q 'Mole Check' '$PROJECT_ROOT/scripts/check.sh'" [ "$status" -eq 0 ] } @@ -46,29 +53,60 @@ setup() { [ -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 +122,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 +164,23 @@ EOF } @test "install.sh supports dev branch installs" { - run bash -c "grep -q 'refs/heads/dev.tar.gz' '$PROJECT_ROOT/install.sh'" + run /bin/bash -c "grep -q 'refs/heads/dev.tar.gz' '$PROJECT_ROOT/install.sh'" + [ "$status" -eq 0 ] + 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 ] } 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..274707f --- /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 = 12 } + 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..3deec7c 100644 --- a/Resources/mole/tests/uninstall.bats +++ b/Resources/mole/tests/uninstall.bats @@ -15,13 +15,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" @@ -34,34 +41,165 @@ create_app_artifacts() { 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 result="$( - HOME="$HOME" bash --noprofile --norc <<'EOF' + 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"* ]] - [[ "$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"* ]] + [[ "$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_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 ] +} + +# 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,14 +219,22 @@ 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 @@ -101,7 +247,7 @@ EOF dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 >/dev/null 2>&1 result="$( - HOME="$HOME" bash --noprofile --norc <<'EOF' + HOME="$HOME" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" files="$(printf '%s @@ -114,10 +260,48 @@ 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 "batch_uninstall_applications removes selected app data" { 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 +330,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 @@ -158,19 +342,57 @@ 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" +@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" + +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 - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +# 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 ] +} + +@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" @@ -186,242 +408,2236 @@ 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") +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 'q' | batch_uninstall_applications +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 ] - [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] - [[ "$output" != *"more files"* ]] } -@test "safe_remove can remove a simple directory" { - mkdir -p "$HOME/test_dir" - touch "$HOME/test_dir/file.txt" +@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' 'CFBundleExecutableSharedName' > "$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" 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" -safe_remove "$HOME/test_dir" -[[ ! -d "$HOME/test_dir" ]] || exit 1 +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 "decode_file_list validates base64 encoding" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" -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() { :; } +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 "decode_file_list rejects invalid base64" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" -if result=$(decode_file_list "not-valid-base64!!!" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true -fi +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" +grep -q "System files left untouched after removal:" "$HOME/output.log" +[[ "$(grep -cF "~/system/com.example.review.helper" "$HOME/output.log")" -eq 2 ]] +grep -q "Review these paths before removing them manually; prefer the app's official uninstaller when available" "$HOME/output.log" +! grep -q "$HOME/system/com.example.review.helper" "$HOME/remove.log" +[[ -e "$HOME/system/com.example.review.helper" ]] EOF [ "$status" -eq 0 ] } -@test "decode_file_list handles empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" -empty_data=$(printf '' | base64) -result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true -[[ -z "$result" ]] +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 files left untouched after removal"* ]] || { 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 "decode_file_list rejects non-absolute paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" <> "$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" -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 +# 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 "decode_file_list handles both BSD and GNU base64 formats" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" <> "$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" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" -test_paths="/path/to/file1 -/path/to/file2" +app_path="$HOME/Applications/StubbornApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableStubbornApp + CFBundleIdentifiercom.example.StubbornApp + +PLIST -encoded_data=$(printf '%s' "$test_paths" | base64 | tr -d '\n') +# 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 -result=$(decode_file_list "$encoded_data" "TestApp") +sudo() { return 1; } +export -f sudo -[[ "$result" == *"/path/to/file1"* ]] || exit 1 -[[ "$result" == *"/path/to/file2"* ]] || exit 1 +sleep() { :; } +export -f sleep -[[ -n "$result" ]] || exit 1 +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 "refresh_launch_services_after_uninstall falls back after timeout" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@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" <> "$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" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" -log_file="$HOME/lsregister-timeout.log" -: > "$log_file" -call_index=0 +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 -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" +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 - if [[ "$call_index" -eq 2 ]]; then - return 124 - fi - if [[ "$call_index" -eq 3 ]]; then - return 124 - fi - return 0 +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 } +export -f pkill -if refresh_launch_services_after_uninstall; then - echo "RESULT:ok" -else - echo "RESULT:fail" -fi +sleep() { :; } +export -f sleep -cat "$log_file" +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "TestApp" "$app_path" 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"* ]] + 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 "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 "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" <> "$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" PATH="/usr/bin:/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/uninstall/batch.sh" + +request_sudo_access() { return 0; } 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 +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 ] } -sudo() { - if [[ "$1" == "rm" ]]; then - shift - rm "$@" - return 0 - fi - return 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" } -export -f start_inline_spinner stop_inline_spinner rm sudo -printf '\n' | "$PROJECT_ROOT/mole" remove +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 ] - [ ! -f "$HOME/.local/bin/mole" ] - [ ! -f "$HOME/.local/bin/mo" ] - [ ! -d "$HOME/.config/mole" ] - [ ! -d "$HOME/.cache/mole" ] } -@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_uninstall_applications warns when removed app declares Local Network usage" { + 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; } + +app_bundle="$HOME/Applications/NetworkApp.app" +mkdir -p "$app_bundle/Contents" +cat > "$app_bundle/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.NetworkApp + NSLocalNetworkUsageDescription + Discover devices on the local network + + +PLIST + +selected_apps=() +selected_apps+=("0|$app_bundle|NetworkApp|com.example.NetworkApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Local Network permissions"* ]] || return 1 + [[ "$output" == *"NetworkApp"* ]] || return 1 + [[ "$output" == *"Recovery mode"* ]] +} - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' +@test "batch_uninstall_applications skips Local Network warning for regular 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/uninstall/batch.sh" + +request_sudo_access() { return 0; } start_inline_spinner() { :; } stop_inline_spinner() { :; } -export -f start_inline_spinner stop_inline_spinner -printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +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/PlainApp.app" +mkdir -p "$app_bundle/Contents" +cat > "$app_bundle/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.PlainApp + + +PLIST + +selected_apps=() +selected_apps+=("0|$app_bundle|PlainApp|com.example.PlainApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications 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" ] + [[ "$output" != *"Local Network permissions"* ]] +} + +@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 "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" + + 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" ] + [ ! -f "$HOME/.local/bin/mo" ] + [ ! -d "$HOME/.config/mole" ] + [ ! -d "$HOME/.cache/mole" ] + [ ! -d "$HOME/Library/Logs/mole" ] +} + +@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" + 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" + 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 rescans cleanly after returning from a completed uninstall (#866)" { + local first_cache second_cache + first_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-866-first.XXXXXX")" + second_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-866-second.XXXXXX")" + + mkdir -p "$HOME/Applications/FirstApp.app" "$HOME/Applications/SecondApp.app" + cat > "$first_cache" < "$second_cache" < "$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() { printf '%s\n' "$fingerprint_state"; } +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" + if [[ $scan_count -eq 1 ]]; then + printf '%s\n' "$FIRST_CACHE" + else + printf '%s\n' "$SECOND_CACHE" + fi +} +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 + return 1 +} + +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\nscan:2\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" "$second_cache" + [ "$status" -eq 0 ] +} + + +# --------------------------------------------------------------------------- +# #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}" + 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}" + 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||||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" ] } diff --git a/Resources/mole/tests/uninstall_naming_variants.bats b/Resources/mole/tests/uninstall_naming_variants.bats index eee48f9..ce51a5f 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,171 @@ 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 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.test" "" 2>/dev/null || true) + result=$(find_app_files "com.raycast.macos" "Raycast") - [[ ! "$result" =~ "Library/Application Support"$ ]] + [[ "$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 } 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..89f546c --- /dev/null +++ b/Resources/mole/tests/uninstall_remove_file_list.bats @@ -0,0 +1,240 @@ +#!/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 falls through to per-file path when 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" + + # Stub the batch helper to fail, and stub mole_delete to record per-file + # invocations and act on the file. This proves the fallback path runs once + # per file rather than silently dropping the batch. + run /bin/bash --noprofile --norc <> "$trace" + rm -f "\$1" + return 0 +} +remove_file_list "$list" "false" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"2"* ]] || return 1 + + [[ ! -e "$f1" ]] || return 1 + [[ ! -e "$f2" ]] || return 1 + + local fallback_calls + fallback_calls=$(wc -l < "$trace" | tr -d ' ') + [ "$fallback_calls" -eq 2 ] + grep -qF "mole_delete $f1" "$trace" + grep -qF "mole_delete $f2" "$trace" +} + +@test "_mole_move_to_trash_batch returns 1 when trash CLI is missing under MOLE_TEST_NO_AUTH" { + local f1="$SANDBOX/p.plist" + : > "$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 routes Microsoft Word app data per-file and batches ordinary leftovers" { + local container="$HOME/Library/Containers/com.microsoft.Word" + local group_container="$HOME/Library/Group Containers/UBF8T346G9.Office" + local app_scripts="$HOME/Library/Application Scripts/com.microsoft.Word" + local ordinary="$HOME/Library/Preferences/com.microsoft.Word.plist" + mkdir -p "$container" "$group_container" "$app_scripts" "$(dirname "$ordinary")" + : > "$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..aefe5f4 --- /dev/null +++ b/Resources/mole/tests/uninstall_safety.bats @@ -0,0 +1,441 @@ +#!/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 "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 &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" + +# 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|"* ]] +} 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..7ee83d3 100644 --- a/Resources/mole/tests/update.bats +++ b/Resources/mole/tests/update.bats @@ -1,647 +1,602 @@ #!/usr/bin/env bats setup_file() { - 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 - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-manager.XXXXXX")" - export HOME - - mkdir -p "${HOME}/.cache/mole" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT } 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 - - export MOCK_BIN_DIR="$BATS_TMPDIR/mole-mocks-$$" - mkdir -p "$MOCK_BIN_DIR" - export PATH="$MOCK_BIN_DIR:$PATH" + 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 } teardown() { - rm -rf "$MOCK_BIN_DIR" -} - -read_key() { - echo "ESC" - return 0 -} - -@test "ask_for_updates returns 1 when no updates available" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=0 -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=false -MOLE_UPDATE_AVAILABLE=false -ask_for_updates -EOF - - [ "$status" -eq 1 ] -} - -@test "ask_for_updates shows updates and waits for input" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=5 -BREW_FORMULA_OUTDATED_COUNT=3 -BREW_CASK_OUTDATED_COUNT=2 -APPSTORE_UPDATE_COUNT=1 -MACOS_UPDATE_AVAILABLE=true -MOLE_UPDATE_AVAILABLE=true - -read_key() { echo "ESC"; return 0; } - -ask_for_updates -EOF - - [ "$status" -eq 1 ] # ESC cancels - [[ "$output" == *"Update Mole now?"* ]] - [[ "$output" == *"Run "* ]] - [[ "$output" == *"brew upgrade"* ]] - [[ "$output" == *"Software Update"* ]] - [[ "$output" == *"App Store"* ]] - [[ "$output" != *"AVAILABLE UPDATES"* ]] -} - -@test "ask_for_updates with only macOS update shows settings hint without brew hint" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=0 -BREW_FORMULA_OUTDATED_COUNT=0 -BREW_CASK_OUTDATED_COUNT=0 -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=true -MOLE_UPDATE_AVAILABLE=false -ask_for_updates -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"Software Update"* ]] - [[ "$output" != *"brew upgrade"* ]] - [[ "$output" != *"AVAILABLE UPDATES"* ]] -} - -@test "ask_for_updates accepts Enter when updates exist" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=2 -BREW_FORMULA_OUTDATED_COUNT=2 -MOLE_UPDATE_AVAILABLE=true -read_key() { echo "ENTER"; return 0; } -ask_for_updates -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update Mole now?"* ]] - [[ "$output" == *"yes"* ]] -} - -@test "ask_for_updates auto-detects brew updates when counts are unset" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail - -cat > "$MOCK_BIN_DIR/brew" <<'SCRIPT' + 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 +} + +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" +} + +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 -if [[ "$1" == "outdated" && "$2" == "--formula" && "$3" == "--quiet" ]]; then - printf "wget\njq\n" - exit 0 -fi -if [[ "$1" == "outdated" && "$2" == "--cask" && "$3" == "--quiet" ]]; then - printf "iterm2\n" - exit 0 -fi +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 "$MOCK_BIN_DIR/brew" - -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -unset BREW_OUTDATED_COUNT BREW_FORMULA_OUTDATED_COUNT BREW_CASK_OUTDATED_COUNT -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=false -MOLE_UPDATE_AVAILABLE=false - -set +e -ask_for_updates -ask_status=$? -set -e - -echo "COUNTS:${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -exit "$ask_status" -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"brew upgrade"* ]] - [[ "$output" == *"COUNTS:3:2:1"* ]] - [[ "$output" != *"AVAILABLE UPDATES"* ]] + chmod +x "$bin_dir/brew" } -@test "format_brew_update_label lists formula and cask counts" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=5 -BREW_FORMULA_OUTDATED_COUNT=3 -BREW_CASK_OUTDATED_COUNT=2 -format_brew_update_label -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"3 formula"* ]] - [[ "$output" == *"2 cask"* ]] -} - -@test "perform_updates handles Homebrew success and Mole update" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" - -BREW_FORMULA_OUTDATED_COUNT=1 -BREW_CASK_OUTDATED_COUNT=0 -MOLE_UPDATE_AVAILABLE=true - -FAKE_DIR="$HOME/fake-script-dir" -mkdir -p "$FAKE_DIR/lib/manage" -cat > "$FAKE_DIR/mole" <<'SCRIPT' +make_update_curl_stub() { + local bin_dir="$1" + local latest_version="$2" + cat > "$bin_dir/curl" <