From b9702975952d6688c1140698599e282090e88fc5 Mon Sep 17 00:00:00 2001 From: Rick Getz Date: Mon, 18 May 2026 11:04:18 -0400 Subject: [PATCH] feat(clean): add dirs_cleaner cleanup command --- README.md | 2 + SECURITY_AUDIT.md | 2 + bin/clean.sh | 44 ++ lib/clean/system.sh | 480 +++++++++++++++++ lib/core/file_ops.sh | 30 +- lib/core/help.sh | 1 + tests/clean_system_maintenance.bats | 807 ++++++++++++++++++++++++++++ tests/cli.bats | 1 + tests/core_safe_functions.bats | 48 ++ 9 files changed, 1412 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f60c5528..3c27cd79 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ Space freed: 95.5GB | Free space now: 223.5GB Note: In `mo clean` -> Developer tools, Mole removes unused CoreSimulator `Volumes/Cryptex` entries and skips `IN_USE` items. +During system cleanup, Mole also audits `/private/var/dirs_cleaner` for unusually large or stale macOS cleanup staging entries. The default check is report-only: it shows size, age, ownership metadata, and review commands. To explicitly clean stale top-level or shallow staging entries, preview with `mo clean --dirs-cleaner --dry-run`, then run `mo clean --dirs-cleaner`. + ### Smart App Uninstaller ```bash diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 4daeacc5..e4e8d736 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -79,6 +79,7 @@ Some subpaths under otherwise protected roots are explicitly allowlisted for bou - `/private/var/db/DiagnosticPipeline` - `/private/var/db/powerlog` - `/private/var/db/reportmemoryexception` +- `/private/var/dirs_cleaner/` (only via explicit stale staging cleanup; never the parent) This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns. @@ -110,6 +111,7 @@ Some subpaths under protected roots are explicitly allowlisted: - `/private/var/db/DiagnosticPipeline` - `/private/var/db/powerlog` - `/private/var/db/reportmemoryexception` +- `/private/var/dirs_cleaner/` (explicit cleanup only) ### Protected Categories diff --git a/bin/clean.sh b/bin/clean.sh index 74daa248..3274e487 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -25,6 +25,7 @@ SYSTEM_CLEAN=false DRY_RUN=false PROTECT_FINDER_METADATA=false EXTERNAL_VOLUME_TARGET="" +DIRS_CLEANER_CLEAN=false IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false") EXPORT_LIST_FILE="$HOME/.config/mole/clean-list.txt" @@ -1323,6 +1324,36 @@ run_cloud_and_office_cleanup() { clean_office_applications } +run_dirs_cleaner_cleanup_command() { + export MOLE_CURRENT_COMMAND="clean" + log_operation_session_start "clean" + + printf '\n' + echo -e "${PURPLE_BOLD}Clean macOS Cleanup Staging${NC}" + echo -e "${GRAY}/private/var/dirs_cleaner${NC}" + echo "" + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" + echo "" + fi + + if ! ensure_sudo_session "macOS cleanup staging requires admin access"; then + echo -e "${YELLOW}Authentication failed${NC}, cleanup staging skipped" + log_operation_session_end "clean" 0 0 + return 1 + fi + + hide_cursor + local rc=0 + clean_dirs_cleaner_staging || rc=$? + show_cursor + + log_operation_session_end "clean" 0 0 + printf '\n' + return "$rc" +} + main() { while [[ $# -gt 0 ]]; do case "$1" in @@ -1345,6 +1376,9 @@ main() { fi EXTERNAL_VOLUME_TARGET=$(validate_external_volume_target "$1") || exit 1 ;; + "--dirs-cleaner") + DIRS_CLEANER_CLEAN=true + ;; "--whitelist") source "$SCRIPT_DIR/../lib/manage/whitelist.sh" manage_whitelist "clean" @@ -1369,6 +1403,16 @@ main() { shift done + if [[ "$DIRS_CLEANER_CLEAN" == "true" ]]; then + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + echo "mo clean --dirs-cleaner cannot be combined with --external" >&2 + exit 1 + fi + + run_dirs_cleaner_cleanup_command + exit $? + fi + start_cleanup hide_cursor perform_cleanup diff --git a/lib/clean/system.sh b/lib/clean/system.sh index d936e69b..9dfce276 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -40,6 +40,482 @@ gpu_cache_dir_is_stale() { [[ -z "$recent_file" ]] } +dirs_cleaner_warn_threshold_kb() { + local warn_mb="${MOLE_DIRS_CLEANER_WARN_MB:-1024}" + [[ "$warn_mb" =~ ^[0-9]+$ ]] || warn_mb=1024 + echo $((warn_mb * 1024)) +} + +dirs_cleaner_age_threshold_days() { + local age_days="${MOLE_DIRS_CLEANER_AGE_DAYS:-3}" + [[ "$age_days" =~ ^[0-9]+$ ]] || age_days=3 + echo "$age_days" +} + +sudo_path_size_kb_xdev() { + local path="$1" + local size_kb="" + + size_kb=$(run_with_timeout 5 sudo du -skxP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) + if [[ "$size_kb" =~ ^[0-9]+$ ]]; then + echo "$size_kb" + else + echo "0" + fi +} + +sudo_path_metadata() { + local path="$1" + local stat_out="" + local owner="" + local mode="" + local flags="" + local mtime="" + + stat_out=$(sudo stat -f '%Su|%Sp|%Sf|%m' "$path" 2> /dev/null || true) + IFS='|' read -r owner mode flags mtime <<< "$stat_out" + + [[ -n "$owner" ]] || owner="unknown" + [[ -n "$mode" ]] || mode="unknown" + [[ -n "$flags" ]] || flags="unknown" + [[ "$mtime" =~ ^[0-9]+$ ]] || mtime="0" + + printf '%s|%s|%s|%s\n' "$owner" "$mode" "$flags" "$mtime" +} + +sudo_path_device_id() { + local path="$1" + local device_id="" + + device_id=$(sudo stat -f '%d' "$path" 2> /dev/null || true) + if [[ "$device_id" =~ ^[0-9]+$ ]]; then + echo "$device_id" + else + echo "" + fi +} + +sudo_shallow_oldest_mtime() { + local path="$1" + local fallback_mtime="${2:-0}" + local oldest_mtime="" + + oldest_mtime=$(run_with_timeout 5 sudo find "$path" -xdev -mindepth 0 -maxdepth 2 ! -type l -exec stat -f '%m' {} + 2> /dev/null | + awk 'BEGIN {min=""} /^[0-9]+$/ {if (min == "" || $1 < min) min = $1} END {print min}' || true) + + if [[ "$oldest_mtime" =~ ^[0-9]+$ ]]; then + echo "$oldest_mtime" + elif [[ "$fallback_mtime" =~ ^[0-9]+$ ]]; then + echo "$fallback_mtime" + else + echo "0" + fi +} + +sudo_shallow_newest_mtime() { + local path="$1" + local fallback_mtime="${2:-0}" + local newest_mtime="" + + newest_mtime=$(run_with_timeout 5 sudo find "$path" -xdev -mindepth 0 -maxdepth 2 ! -type l -exec stat -f '%m' {} + 2> /dev/null | + awk 'BEGIN {max=""} /^[0-9]+$/ {if (max == "" || $1 > max) max = $1} END {print max}' || true) + + if [[ "$newest_mtime" =~ ^[0-9]+$ ]]; then + echo "$newest_mtime" + elif [[ "$fallback_mtime" =~ ^[0-9]+$ ]]; then + echo "$fallback_mtime" + else + echo "0" + fi +} + +sudo_path_newest_mtime_xdev() { + local path="$1" + local fallback_mtime="${2:-0}" + local newest_mtime="" + + newest_mtime=$(run_with_timeout 20 sudo find "$path" -xdev ! -type l -exec stat -f '%m' {} + 2> /dev/null | + awk 'BEGIN {max=""} /^[0-9]+$/ {if (max == "" || $1 > max) max = $1} END {print max}') || return 1 + + if [[ "$newest_mtime" =~ ^[0-9]+$ ]]; then + echo "$newest_mtime" + elif [[ "$fallback_mtime" =~ ^[0-9]+$ ]]; then + echo "$fallback_mtime" + else + return 1 + fi +} + +format_dirs_cleaner_age() { + local mtime="${1:-0}" + local now + + [[ "$mtime" =~ ^[0-9]+$ ]] || mtime=0 + if [[ "$mtime" -le 0 ]]; then + echo "unknown" + return 0 + fi + + now=$(get_epoch_seconds) + if [[ "$now" -le "$mtime" ]]; then + echo "today" + return 0 + fi + + format_duration_human "$((now - mtime))" +} + +dirs_cleaner_child_has_nested_mount() { + local child="${1%/}" + local line + local mount_point + + while IFS= read -r line; do + mount_point="${line#* on }" + mount_point="${mount_point% (*}" + case "$mount_point" in + "$child"/*) + return 0 + ;; + esac + done < <(mount 2> /dev/null || true) + + return 1 +} + +dirs_cleaner_candidate_depth() { + local root="$1" + local path="$2" + local rel="${path#"$root"/}" + + if [[ -z "$rel" || "$rel" == "$path" ]]; then + echo "0" + elif [[ "$rel" == */*/* ]]; then + echo "3" + elif [[ "$rel" == */* ]]; then + echo "2" + else + echo "1" + fi +} + +dirs_cleaner_has_immediate_children() { + local path="$1" + local first_child="" + + first_child=$(run_with_timeout 5 sudo find "$path" -xdev -mindepth 1 -maxdepth 1 ! -type l -print -quit 2> /dev/null) || return 2 + [[ -n "$first_child" ]] +} + +dirs_cleaner_child_is_safe() { + local root="$1" + local root_device="$2" + local child="$3" + local context="${4:-dirs_cleaner}" + local max_depth="${5:-1}" + + case "$child" in + "$root"/*) ;; + *) + debug_log "Skipping dirs_cleaner child outside root: $child" + return 1 + ;; + esac + + local child_depth + child_depth=$(dirs_cleaner_candidate_depth "$root" "$child") + if [[ "$child_depth" -eq 0 || "$child_depth" -gt "$max_depth" ]]; then + debug_log "Skipping non-top-level dirs_cleaner target: $child" + return 1 + fi + + if [[ "$child" =~ [[:cntrl:]] ]]; then + debug_log "Skipping dirs_cleaner child with control characters" + return 1 + fi + + if sudo test -L "$child" 2> /dev/null; then + debug_log "Skipping symlinked dirs_cleaner child: $child" + return 1 + fi + + local child_device + child_device=$(sudo_path_device_id "$child") + if [[ -n "$root_device" && -n "$child_device" && "$child_device" != "$root_device" ]]; then + debug_log "Skipping dirs_cleaner child on different filesystem: $child" + log_operation "clean" "SKIPPED" "$child" "$context different filesystem" + return 1 + fi + + if dirs_cleaner_child_has_nested_mount "$child"; then + debug_log "Skipping dirs_cleaner child with nested mountpoint: $child" + log_operation "clean" "SKIPPED" "$child" "$context nested mountpoint" + return 1 + fi + + if should_protect_path "$child"; then + debug_log "Skipping protected dirs_cleaner child: $child" + return 1 + fi + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$child"; then + debug_log "Skipping whitelisted dirs_cleaner child: $child" + return 1 + fi + + return 0 +} + +report_stuck_dirs_cleaner_staging() { + local root="/private/var/dirs_cleaner" + local warn_kb + local age_threshold_days + local now + local entries_file + local errors_file + local root_device + local reported=0 + + if ! sudo test -d "$root" 2> /dev/null; then + return 0 + fi + + if sudo test -L "$root" 2> /dev/null; then + stop_section_spinner + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS cleanup staging audit skipped: symlinked path" + echo -e " ${GRAY}${root}${NC}" + note_activity + log_operation "clean" "SKIPPED" "$root" "dirs_cleaner audit symlinked root" + return 0 + fi + + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$root"; then + debug_log "Skipping dirs_cleaner audit: whitelisted root" + return 0 + fi + + warn_kb=$(dirs_cleaner_warn_threshold_kb) + age_threshold_days=$(dirs_cleaner_age_threshold_days) + now=$(get_epoch_seconds) + root_device=$(sudo_path_device_id "$root") + entries_file=$(mktemp_file "dirs_cleaner_entries") + errors_file=$(mktemp_file "dirs_cleaner_errors") + + if ! run_with_timeout 8 sudo find "$root" -xdev -mindepth 1 -maxdepth 1 ! -type l -print0 > "$entries_file" 2> "$errors_file"; then + stop_section_spinner + echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not fully inspect macOS cleanup staging" + echo -e " ${GRAY}${root}${NC}" + echo -e " ${GRAY}Review: sudo du -xhd 2 ${root}${NC}" + note_activity + log_operation "clean" "FAILED" "$root" "dirs_cleaner audit traversal failed" + return 0 + fi + + if [[ ! -s "$entries_file" ]]; then + return 0 + fi + + while IFS= read -r -d '' child; do + [[ -n "$child" ]] || continue + if ! dirs_cleaner_child_is_safe "$root" "$root_device" "$child" "dirs_cleaner audit"; then + continue + fi + + local size_kb + local metadata + local owner + local mode + local flags + local mtime + local oldest_mtime + local age_days=0 + local age_human + local size_human + local metadata_suffix="" + + size_kb=$(sudo_path_size_kb_xdev "$child") + metadata=$(sudo_path_metadata "$child") + IFS='|' read -r owner mode flags mtime <<< "$metadata" + oldest_mtime=$(sudo_shallow_oldest_mtime "$child" "$mtime") + if [[ "$oldest_mtime" =~ ^[0-9]+$ && "$oldest_mtime" -gt 0 && "$now" -gt "$oldest_mtime" ]]; then + age_days=$(((now - oldest_mtime) / 86400)) + fi + + if [[ "$size_kb" -lt "$warn_kb" && "$age_days" -lt "$age_threshold_days" ]]; then + continue + fi + + if [[ $reported -eq 0 ]]; then + stop_section_spinner + echo -e " ${YELLOW}${ICON_WARNING}${NC} Stuck macOS cleanup staging detected" + echo -e " ${GRAY}Review: sudo du -xhd 2 ${root}${NC}" + echo -e " ${GRAY}Review open handles: sudo lsof +D ${root}${NC}" + echo -e " ${GRAY}Clean stale staging: mo clean --dirs-cleaner --dry-run${NC}" + reported=1 + note_activity + fi + + size_human=$(bytes_to_human "$((size_kb * 1024))") + age_human=$(format_dirs_cleaner_age "$oldest_mtime") + if [[ -n "$flags" && "$flags" != "none" && "$flags" != "-" && "$flags" != "unknown" ]]; then + metadata_suffix=", flags $flags" + fi + + echo -e " ${GRAY}${child}${NC} ยท $(colorize_human_size "$size_human"), oldest ${age_human}, owner ${owner}, ${mode}${metadata_suffix}" + log_operation "clean" "WARNING" "$child" "dirs_cleaner staging ${size_human}, oldest ${age_human}, owner ${owner}" + done < "$entries_file" + + return 0 +} + +clean_dirs_cleaner_staging() { + local root="/private/var/dirs_cleaner" + local age_threshold_days + local now + local entries_file + local errors_file + local root_device + local cleaned_count=0 + local eligible_count=0 + local failed_count=0 + local total_size_kb=0 + + if ! sudo test -d "$root" 2> /dev/null; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No macOS cleanup staging found" + return 0 + fi + + if sudo test -L "$root" 2> /dev/null; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS cleanup staging cleanup skipped: symlinked path" + echo -e " ${GRAY}${root}${NC}" + log_operation "clean" "SKIPPED" "$root" "dirs_cleaner cleanup symlinked root" + return 0 + fi + + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$root"; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} macOS cleanup staging is whitelisted" + debug_log "Skipping dirs_cleaner cleanup: whitelisted root" + return 0 + fi + + age_threshold_days=$(dirs_cleaner_age_threshold_days) + now=$(get_epoch_seconds) + root_device=$(sudo_path_device_id "$root") + entries_file=$(mktemp_file "dirs_cleaner_cleanup_entries") + errors_file=$(mktemp_file "dirs_cleaner_cleanup_errors") + + if ! run_with_timeout 8 sudo find "$root" -xdev -mindepth 1 -maxdepth 2 ! -type l -print0 > "$entries_file" 2> "$errors_file"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not fully inspect macOS cleanup staging" + echo -e " ${GRAY}${root}${NC}" + echo -e " ${GRAY}Review: sudo du -xhd 2 ${root}${NC}" + log_operation "clean" "FAILED" "$root" "dirs_cleaner cleanup traversal failed" + return 1 + fi + + if [[ ! -s "$entries_file" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No macOS cleanup staging entries found" + return 0 + fi + + while IFS= read -r -d '' child; do + [[ -n "$child" ]] || continue + if ! dirs_cleaner_child_is_safe "$root" "$root_device" "$child" "dirs_cleaner cleanup" 2; then + continue + fi + + local child_depth + child_depth=$(dirs_cleaner_candidate_depth "$root" "$child") + if [[ "$child_depth" -eq 1 ]] && sudo test -d "$child" 2> /dev/null; then + dirs_cleaner_has_immediate_children "$child" + local has_children_rc=$? + if [[ $has_children_rc -eq 0 ]]; then + debug_log "Skipping non-empty top-level dirs_cleaner bucket; checking shallow children: $child" + continue + elif [[ $has_children_rc -ne 1 ]]; then + debug_log "Skipping dirs_cleaner bucket after child scan failure: $child" + log_operation "clean" "SKIPPED" "$child" "dirs_cleaner cleanup child scan failed" + continue + fi + fi + + local size_kb + local metadata + local owner + local mode + local flags + local mtime + local newest_full_mtime + local age_days=0 + local age_human + local size_human + local metadata_suffix="" + + size_kb=$(sudo_path_size_kb_xdev "$child") + metadata=$(sudo_path_metadata "$child") + IFS='|' read -r owner mode flags mtime <<< "$metadata" + newest_full_mtime=$(sudo_path_newest_mtime_xdev "$child" "$mtime") || { + debug_log "Skipping dirs_cleaner candidate after full staleness scan failure: $child" + log_operation "clean" "SKIPPED" "$child" "dirs_cleaner cleanup staleness scan failed" + continue + } + if [[ "$newest_full_mtime" =~ ^[0-9]+$ && "$newest_full_mtime" -gt 0 && "$now" -gt "$newest_full_mtime" ]]; then + age_days=$(((now - newest_full_mtime) / 86400)) + fi + + if [[ "$age_days" -lt "$age_threshold_days" ]]; then + continue + fi + + eligible_count=$((eligible_count + 1)) + total_size_kb=$((total_size_kb + size_kb)) + size_human=$(bytes_to_human "$((size_kb * 1024))") + age_human=$(format_dirs_cleaner_age "$newest_full_mtime") + if [[ -n "$flags" && "$flags" != "none" && "$flags" != "-" && "$flags" != "unknown" ]]; then + metadata_suffix=", flags $flags" + fi + + if [[ "${DRY_RUN:-false}" == "true" || "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${child}${NC}, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + echo -e " ${GRAY}newest ${age_human}, owner ${owner}, ${mode}${metadata_suffix}${NC}" + else + echo -e " ${GRAY}${ICON_LIST}${NC} Removing stale cleanup staging: ${child}${NC}" + echo -e " ${GRAY}${size_human}, newest ${age_human}, owner ${owner}, ${mode}${metadata_suffix}${NC}" + fi + + if safe_sudo_remove "$child" "dirs_cleaner"; then + cleaned_count=$((cleaned_count + 1)) + note_activity + if [[ "${DRY_RUN:-false}" == "true" || "${MOLE_DRY_RUN:-0}" == "1" ]]; then + log_operation "clean" "DRY_RUN" "$child" "dirs_cleaner staging ${size_human}, newest ${age_human}, owner ${owner}" + else + log_operation "clean" "REMOVED" "$child" "dirs_cleaner staging ${size_human}, newest ${age_human}, owner ${owner}" + fi + else + failed_count=$((failed_count + 1)) + log_operation "clean" "FAILED" "$child" "dirs_cleaner cleanup remove failed" + fi + done < "$entries_file" + + if [[ $eligible_count -eq 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No stale macOS cleanup staging found" + echo -e " ${GRAY}Threshold: ${age_threshold_days}d; adjust with MOLE_DIRS_CLEANER_AGE_DAYS${NC}" + return 0 + fi + + local total_size_human + total_size_human=$(bytes_to_human "$((total_size_kb * 1024))") + if [[ "${DRY_RUN:-false}" == "true" || "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Stale macOS cleanup staging, ${eligible_count} items, $(colorize_human_size "$total_size_human") ${YELLOW}dry${NC}" + elif [[ $failed_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS cleanup staging partially cleaned: ${cleaned_count}/${eligible_count} items" + echo -e " ${GRAY}Review: sudo du -xhd 2 ${root}${NC}" + return 1 + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} macOS cleanup staging, ${cleaned_count} items, $(colorize_human_size "$total_size_human")" + fi + + return 0 +} + # System caches, logs, and temp files. clean_deep_system() { stop_section_spinner @@ -255,6 +731,10 @@ clean_deep_system() { log_success "Accessible rebuildable GPU caches, $gpu_cache_cleaned $gpu_cache_label" fi + start_section_spinner "Auditing macOS cleanup staging..." + report_stuck_dirs_cleaner_staging + stop_section_spinner + 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 diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 513ed378..bba8e9fc 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -66,6 +66,7 @@ format_duration_human() { # Validate path for deletion (absolute, no traversal, not system dir) validate_path_for_deletion() { local path="$1" + local deletion_context="${2:-}" # Check path is not empty if [[ -z "$path" ]]; then @@ -145,6 +146,23 @@ validate_path_for_deletion() { ;; esac + case "$path" in + /private/var/dirs_cleaner | /private/var/dirs_cleaner/*) + if [[ "$deletion_context" == "dirs_cleaner" ]]; then + local dirs_cleaner_child="${path#/private/var/dirs_cleaner/}" + case "$dirs_cleaner_child" in + "" | */*/*) + ;; + *) + return 0 + ;; + esac + fi + log_error "Path validation failed: dirs_cleaner cleanup only allows top-level or shallow staging children: $path" + return 1 + ;; + 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 | /Library/Extensions/*) @@ -318,8 +336,9 @@ safe_remove_symlink() { # Safe sudo removal with symlink protection safe_sudo_remove() { local path="$1" + local deletion_context="${2:-}" - if ! validate_path_for_deletion "$path"; then + if ! validate_path_for_deletion "$path" "$deletion_context"; then if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$path"; then debug_log "Skipped sudo remove for protected path: $path" else @@ -329,10 +348,15 @@ safe_sudo_remove() { fi if [[ ! -e "$path" ]]; then - return 0 + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 0 + fi + if ! sudo test -e "$path" 2> /dev/null; then + return 0 + fi fi - if [[ -L "$path" ]]; then + if [[ -L "$path" ]] || { [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]] && sudo test -L "$path" 2> /dev/null; }; then log_error "Refusing to sudo remove symlink: $path" return 1 fi diff --git a/lib/core/help.sh b/lib/core/help.sh index 0c045d57..9e7b74de 100644 --- a/lib/core/help.sh +++ b/lib/core/help.sh @@ -8,6 +8,7 @@ show_clean_help() { echo "Options:" echo " --dry-run, -n Preview cleanup without making changes" echo " --external PATH Clean OS metadata from a mounted external volume" + echo " --dirs-cleaner Clean stale macOS cleanup staging" echo " --whitelist Manage protected paths" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index 879c5462..b69fce5a 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -974,6 +974,813 @@ EOF [[ "$output" == *"SUCCESS:Accessible rebuildable GPU caches, 3 items"* ]] } +@test "dirs_cleaner audit reports large staging entries without deletion" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DIRS_CLEANER_WARN_MB=1 MOLE_DIRS_CLEANER_AGE_DAYS=30 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 400000; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "NOTE_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 1; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/stuck" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/stuck" ]]; then + if [[ "$*" == *"-print -quit"* ]]; then + return 0 + fi + echo "1000" + return 0 + fi + return 0 + ;; + du) + echo "2048 $3" + return 0 + ;; + stat) + if [[ "$3" == "%d" ]]; then + echo "100" + return 0 + fi + echo "root|drwxr-xr-x|none|1000" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +report_stuck_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Stuck macOS cleanup staging detected"* ]] + [[ "$output" == *"/private/var/dirs_cleaner/stuck"* ]] + [[ "$output" == *"2.1MB"* ]] + [[ "$output" == *"owner root, drwxr-xr-x"* ]] + [[ "$output" == *"Review: sudo du -xhd 2 /private/var/dirs_cleaner"* ]] + [[ "$output" == *"Review open handles: sudo lsof +D /private/var/dirs_cleaner"* ]] + [[ "$output" == *"NOTE_ACTIVITY"* ]] + [[ "$output" == *"OP:WARNING:/private/var/dirs_cleaner/stuck"* ]] + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "dirs_cleaner audit skips fresh small entries" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DIRS_CLEANER_WARN_MB=1 MOLE_DIRS_CLEANER_AGE_DAYS=30 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 200000; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "UNEXPECTED_OP"; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/fresh" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/fresh" ]]; then + if [[ "$*" == *"-print -quit"* ]]; then + return 0 + fi + echo "200000" + return 0 + fi + return 0 + ;; + du) + echo "512 $3" + return 0 + ;; + stat) + if [[ "$3" == "%d" ]]; then + echo "100" + return 0 + fi + echo "root|drwxr-xr-x|none|200000" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +report_stuck_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Stuck macOS cleanup staging detected"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + +@test "dirs_cleaner audit refuses symlinked children" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DIRS_CLEANER_WARN_MB=0 MOLE_DIRS_CLEANER_AGE_DAYS=0 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" && "$3" == "/private/var/dirs_cleaner/link" ]]; then + return 0 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/link" + return 0 + fi + return 0 + ;; + du) + echo "UNEXPECTED_DU" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +report_stuck_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Stuck macOS cleanup staging detected"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + +@test "dirs_cleaner audit refuses symlinked root" { + 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/system.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "NOTE_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + return 1 + ;; + find) + echo "UNEXPECTED_FIND" + return 0 + ;; + esac + return 0 +} + +report_stuck_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"macOS cleanup staging audit skipped: symlinked path"* ]] + [[ "$output" == *"NOTE_ACTIVITY"* ]] + [[ "$output" == *"OP:SKIPPED:/private/var/dirs_cleaner"* ]] + [[ "$output" != *"UNEXPECTED_FIND"* ]] +} + +@test "dirs_cleaner audit skips top-level mountpoint children" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DIRS_CLEANER_WARN_MB=0 MOLE_DIRS_CLEANER_AGE_DAYS=0 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/mounted" + return 0 + fi + echo "UNEXPECTED_CHILD_FIND" + return 0 + ;; + stat) + if [[ "$4" == "/private/var/dirs_cleaner" ]]; then + echo "100" + return 0 + fi + if [[ "$4" == "/private/var/dirs_cleaner/mounted" ]]; then + echo "200" + return 0 + fi + return 1 + ;; + du) + echo "UNEXPECTED_DU" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +report_stuck_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Stuck macOS cleanup staging detected"* ]] + [[ "$output" == *"OP:SKIPPED:/private/var/dirs_cleaner/mounted"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + +@test "dirs_cleaner audit reports traversal failures with manual review command" { + 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/system.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "NOTE_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + echo "permission denied" >&2 + return 2 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +report_stuck_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Could not fully inspect macOS cleanup staging"* ]] + [[ "$output" == *"Review: sudo du -xhd 2 /private/var/dirs_cleaner"* ]] + [[ "$output" == *"NOTE_ACTIVITY"* ]] + [[ "$output" == *"OP:FAILED:/private/var/dirs_cleaner"* ]] +} + +@test "dirs_cleaner cleanup dry-run removes only eligible stale top-level entries through safe_sudo_remove" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_DRY_RUN=1 MOLE_DIRS_CLEANER_AGE_DAYS=3 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 400000; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { echo "NOTE_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { + if [[ "$1" == "/private/var/dirs_cleaner/J1" ]]; then + echo "UNEXPECTED_PARENT_REMOVE:$1" + return 1 + fi + echo "safe_sudo_remove:$1" + return 0 +} + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/stuck" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/stuck" ]]; then + echo "1000" + return 0 + fi + return 0 + ;; + du) + echo "2048 $3" + return 0 + ;; + stat) + if [[ "$3" == "%d" ]]; then + echo "100" + return 0 + fi + echo "root|drwxr-xr-x|none|1000" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"safe_sudo_remove:/private/var/dirs_cleaner/stuck"* ]] + [[ "$output" == *"Stale macOS cleanup staging, 1 items"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"OP:DRY_RUN:/private/var/dirs_cleaner/stuck"* ]] + [[ "$output" != *"OP:REMOVED"* ]] +} + +@test "dirs_cleaner cleanup removes stale shallow children instead of non-empty top-level buckets" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_DRY_RUN=1 MOLE_DIRS_CLEANER_AGE_DAYS=3 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 400000; } +note_activity() { echo "NOTE_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { echo "safe_sudo_remove:$1"; return 0; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner/J1" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0%s\0' "/private/var/dirs_cleaner/J1" "/private/var/dirs_cleaner/J1/stuck" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/J1" ]]; then + if [[ "$*" == *"-print -quit"* ]]; then + echo "/private/var/dirs_cleaner/J1/stuck" + return 0 + fi + echo "300000" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/J1/stuck" ]]; then + if [[ "$*" == *"-print -quit"* ]]; then + return 0 + fi + echo "1000" + return 0 + fi + return 0 + ;; + du) + echo "2048 $3" + return 0 + ;; + stat) + if [[ "$3" == "%d" ]]; then + echo "100" + return 0 + fi + echo "root|drwxr-xr-x|none|1000" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_PARENT_REMOVE"* ]] + [[ "$output" == *"safe_sudo_remove:/private/var/dirs_cleaner/J1/stuck"* ]] + [[ "$output" == *"OP:DRY_RUN:/private/var/dirs_cleaner/J1/stuck"* ]] + [[ "$output" == *"Stale macOS cleanup staging, 1 items"* ]] +} + +@test "dirs_cleaner cleanup skips stale shallow child when deeper descendant is fresh" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_DRY_RUN=1 MOLE_DIRS_CLEANER_AGE_DAYS=3 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 400000; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 1; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/J1/stuck" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/J1/stuck" ]]; then + echo "1000" + echo "399999" + return 0 + fi + return 0 + ;; + du) + echo "2048 $3" + return 0 + ;; + stat) + if [[ "$3" == "%d" ]]; then + echo "100" + return 0 + fi + echo "root|drwxr-xr-x|none|1000" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No stale macOS cleanup staging found"* ]] + [[ "$output" != *"UNEXPECTED_REMOVE"* ]] +} + +@test "dirs_cleaner cleanup skips top-level bucket when child scan fails" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_DRY_RUN=1 MOLE_DIRS_CLEANER_AGE_DAYS=0 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 400000; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 1; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner/J1" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/J1" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/J1" ]]; then + return 124 + fi + return 0 + ;; + stat) + echo "100" + return 0 + ;; + du) + echo "UNEXPECTED_DU" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No stale macOS cleanup staging found"* ]] + [[ "$output" == *"OP:SKIPPED:/private/var/dirs_cleaner/J1"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + +@test "dirs_cleaner cleanup skips fresh entries" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false MOLE_DRY_RUN=0 MOLE_DIRS_CLEANER_AGE_DAYS=3 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 200000; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "UNEXPECTED_OP"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 1; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/fresh" + return 0 + fi + if [[ "$2" == "/private/var/dirs_cleaner/fresh" ]]; then + echo "200000" + return 0 + fi + return 0 + ;; + du) + echo "2048 $3" + return 0 + ;; + stat) + if [[ "$3" == "%d" ]]; then + echo "100" + return 0 + fi + echo "root|drwxr-xr-x|none|200000" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No stale macOS cleanup staging found"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + +@test "dirs_cleaner cleanup skips top-level mountpoint children" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false MOLE_DRY_RUN=0 MOLE_DIRS_CLEANER_AGE_DAYS=0 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 200000; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 1; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/mounted" + return 0 + fi + echo "UNEXPECTED_CHILD_FIND" + return 0 + ;; + stat) + if [[ "$4" == "/private/var/dirs_cleaner" ]]; then + echo "100" + return 0 + fi + if [[ "$4" == "/private/var/dirs_cleaner/mounted" ]]; then + echo "200" + return 0 + fi + return 1 + ;; + du) + echo "UNEXPECTED_DU" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No stale macOS cleanup staging found"* ]] + [[ "$output" == *"OP:SKIPPED:/private/var/dirs_cleaner/mounted"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + +@test "dirs_cleaner cleanup skips children with nested mountpoints" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false MOLE_DRY_RUN=0 MOLE_DIRS_CLEANER_AGE_DAYS=0 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +get_epoch_seconds() { echo 400000; } +note_activity() { echo "UNEXPECTED_ACTIVITY"; } +log_operation() { echo "OP:$2:$3:$4"; } +safe_sudo_remove() { echo "UNEXPECTED_REMOVE:$1"; return 1; } +mount() { echo "dev on /private/var/dirs_cleaner/stuck/nested (apfs, local)"; } + +sudo() { + case "$1" in + test) + if [[ "$2" == "-d" && "$3" == "/private/var/dirs_cleaner" ]]; then + return 0 + fi + if [[ "$2" == "-L" ]]; then + return 1 + fi + return 1 + ;; + find) + if [[ "$2" == "/private/var/dirs_cleaner" ]]; then + printf '%s\0' "/private/var/dirs_cleaner/stuck" + return 0 + fi + echo "UNEXPECTED_CHILD_FIND" + return 0 + ;; + stat) + echo "100" + return 0 + ;; + du) + echo "UNEXPECTED_DU" + return 0 + ;; + esac + return 0 +} + +run_with_timeout() { + local _timeout="$1" + shift + "$@" +} + +clean_dirs_cleaner_staging +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No stale macOS cleanup staging found"* ]] + [[ "$output" == *"OP:SKIPPED:/private/var/dirs_cleaner/stuck"* ]] + [[ "$output" != *"UNEXPECTED"* ]] +} + @test "opt_memory_pressure_relief skips when pressure is normal" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail diff --git a/tests/cli.bats b/tests/cli.bats index 16da435c..65409b17 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -318,6 +318,7 @@ EOF run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --help [ "$status" -eq 0 ] [[ "$output" == *"--external PATH"* ]] + [[ "$output" == *"--dirs-cleaner"* ]] [[ "$output" == *"already-uninstalled apps"* ]] } diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats index af9ce9bf..97d66df2 100644 --- a/tests/core_safe_functions.bats +++ b/tests/core_safe_functions.bats @@ -100,6 +100,26 @@ teardown() { [[ "$output" == *"critical system directory"* ]] } +@test "validate_path_for_deletion gates dirs_cleaner children to cleanup context" { + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/dirs_cleaner/stuck' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"top-level or shallow staging children"* ]] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/dirs_cleaner/stuck' 'dirs_cleaner'" + [ "$status" -eq 0 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/dirs_cleaner/bucket/stuck' 'dirs_cleaner'" + [ "$status" -eq 0 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/dirs_cleaner' 'dirs_cleaner' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"top-level or shallow staging children"* ]] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/dirs_cleaner/bucket/stuck/deeper' 'dirs_cleaner' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"top-level or shallow staging children"* ]] +} + @test "should_protect_path applies high-risk cleanup denylist" { run bash -c " source '$PROJECT_ROOT/lib/core/common.sh' @@ -193,6 +213,34 @@ teardown() { [[ "$output" == *"Refusing to sudo remove symlink"* ]] } +@test "safe_sudo_remove honors sudo-visible dirs_cleaner child in dry-run" { + run bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + sudo() { + if [[ \"\$1\" == 'test' && \"\$2\" == '-e' && \"\$3\" == '/private/var/dirs_cleaner/stuck' ]]; then + return 0 + fi + if [[ \"\$1\" == 'test' && \"\$2\" == '-L' ]]; then + return 1 + fi + if [[ \"\$1\" == 'du' ]]; then + echo '4 /private/var/dirs_cleaner/stuck' + return 0 + fi + if [[ \"\$1\" == 'stat' ]]; then + echo '1000' + return 0 + fi + return 0 + } + export -f sudo + export MOLE_DRY_RUN=1 + safe_sudo_remove '/private/var/dirs_cleaner/stuck' 'dirs_cleaner' 2>&1 + " + [ "$status" -eq 0 ] + [[ "$output" == *"Would sudo remove"* ]] +} + @test "safe_find_delete rejects symlinked directory" { local real_dir="$TEST_DIR/real" local link_dir="$TEST_DIR/link"