diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 4daeacc59..a60e28d9c 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -301,6 +301,8 @@ Key coverage areas include: - bundle ID boundary matching and malformed-ID rejection (`tests/uninstall_safety.bats`) - bash 3.2 empty-array nounset compatibility (`tests/uninstall_scan_bash32.bats`) +Spotlight user search caches are protected from default `mo clean` removal. The explicit `mo optimize --spotlight-deep-reset` path is separate from normal cleanup and optimization, requires typed confirmation, and is limited to current-user Spotlight/CoreSpotlight cache targets before restarting indexing for `/`. + ## Known Limitations and Future Work - Cleanup is destructive. Most cleanup flows do not provide undo. diff --git a/bin/completion.sh b/bin/completion.sh index a5f54d4b5..921895194 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -12,6 +12,7 @@ for entry in "${MOLE_COMMANDS[@]}"; do done command_words="${command_names[*]}" clean_option_words="--dry-run -n --external --whitelist --debug --help -h" +optimize_option_words="--dry-run --spotlight-deep-reset --whitelist --debug --help -h" analyze_option_words="--json --help -h" purge_option_words="--paths --dry-run -n --include-empty --debug --help -h" @@ -35,6 +36,11 @@ emit_fish_completions() { 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 optimize optimise" -l dry-run -d "Preview optimization without making changes"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from optimize optimise" -l spotlight-deep-reset -d "Rebuild Spotlight user search caches after confirmation"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from optimize optimise" -l whitelist -d "Manage protected items"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from optimize optimise" -l debug -d "Show detailed logs"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from optimize optimise" -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" @@ -338,6 +344,9 @@ _mole_completions() purge) COMPREPLY=( \$(compgen -W "$purge_option_words" -- "\$cur_word") ) ;; + optimize|optimise) + COMPREPLY=( \$(compgen -W "$optimize_option_words" -- "\$cur_word") ) + ;; completion) COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") ) ;; @@ -387,6 +396,14 @@ EOF printf " '--debug[Show detailed logs]' \\\\\n" printf " '(-h --help)'{-h,--help}'[Show help]'\n" printf ' ;;\n' + printf ' optimize|optimise)\n' + printf ' _arguments \\\n' + printf " '--dry-run[Preview optimization without making changes]' \\\\\n" + printf " '--spotlight-deep-reset[Rebuild Spotlight user search caches after confirmation]' \\\\\n" + printf " '--whitelist[Manage protected items]' \\\\\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' diff --git a/bin/optimize.sh b/bin/optimize.sh index c27adb271..e945f6bf6 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -191,6 +191,7 @@ main() { export MOLE_CURRENT_COMMAND="optimize" local health_json + local spotlight_deep_reset="false" for arg in "$@"; do case "$arg" in "--help" | "-h") @@ -203,6 +204,9 @@ main() { "--dry-run") export MOLE_DRY_RUN=1 ;; + "--spotlight-deep-reset") + spotlight_deep_reset="true" + ;; "--whitelist") manage_whitelist "optimize" exit 0 @@ -230,6 +234,19 @@ main() { echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n" fi + load_whitelist "optimize" + if [[ "$spotlight_deep_reset" == "true" ]]; then + if command -v is_whitelisted > /dev/null && is_whitelisted "spotlight_deep_reset"; then + opt_msg "Skipped (whitelisted): Spotlight Deep Reset" + printf '\n' + return 0 + fi + announce_action "Spotlight Deep Reset" "Disable indexing, clear user search caches, and rebuild Spotlight" "confirm" + opt_spotlight_deep_reset + printf '\n' + return 0 + 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}" @@ -263,7 +280,6 @@ main() { stop_inline_spinner fi - load_whitelist "optimize" if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then local count=${#CURRENT_WHITELIST_PATTERNS[@]} if [[ $count -le 3 ]]; then diff --git a/lib/core/help.sh b/lib/core/help.sh index 0c045d575..4f4551714 100644 --- a/lib/core/help.sh +++ b/lib/core/help.sh @@ -31,6 +31,8 @@ show_optimize_help() { echo "" echo "Options:" echo " --dry-run Preview optimization without making changes" + echo " --spotlight-deep-reset" + echo " Rebuild Spotlight by clearing user search caches after confirmation" echo " --whitelist Manage protected items" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index e438cb734..99e51f8d8 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -173,6 +173,7 @@ Memory Optimization|memory_pressure_relief|optimize_task Network Stack Refresh|network_stack_optimize|optimize_task Permission Repair|disk_permissions_repair|optimize_task Spotlight Optimization|spotlight_index_optimize|optimize_task +Spotlight Deep Reset|spotlight_deep_reset|optimize_task Periodic Maintenance|periodic_maintenance|optimize_task Shared File Lists|shared_file_list_repair|optimize_task Disk Health|disk_verify|optimize_task diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index ea1880b53..16e3ca947 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -840,6 +840,155 @@ opt_spotlight_index_optimize() { fi } +_opt_spotlight_deep_reset_collect_targets() { + local -a patterns=( + "$HOME/Library/Metadata/CoreSpotlight" + "$HOME/Library/Caches/com.apple.Spotlight"* + "$HOME/Library/Caches/com.apple.spotlight"* + ) + + local target + for target in "${patterns[@]}"; do + [[ -e "$target" || -L "$target" ]] || continue + printf '%s\n' "$target" + done +} + +_opt_spotlight_deep_reset_target_allowed() { + local target="$1" + [[ -n "$target" ]] || return 1 + [[ "$target" == "$HOME/"* ]] || return 1 + [[ ! -L "$target" ]] || return 1 + + case "$target" in + "$HOME/Library/Metadata/CoreSpotlight") return 0 ;; + "$HOME/Library/Caches/com.apple.Spotlight"*) return 0 ;; + "$HOME/Library/Caches/com.apple.spotlight"*) return 0 ;; + esac + + return 1 +} + +_opt_spotlight_deep_reset_remove_target() { + local target="$1" + + if ! _opt_spotlight_deep_reset_target_allowed "$target"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Refusing unexpected Spotlight target: $target" + return 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove $target" + log_operation "${MOLE_CURRENT_COMMAND:-optimize}" "DRY_RUN" "$target" "spotlight deep reset" + return 0 + fi + + if rm -rf "$target"; then + log_operation "${MOLE_CURRENT_COMMAND:-optimize}" "REMOVED" "$target" "spotlight deep reset" + return 0 + fi + + log_operation "${MOLE_CURRENT_COMMAND:-optimize}" "FAILED" "$target" "spotlight deep reset" + return 1 +} + +_opt_spotlight_deep_reset_confirmed() { + local expected="RESET SPOTLIGHT" + local typed="${MOLE_SPOTLIGHT_DEEP_RESET_CONFIRM:-}" + + if [[ -z "$typed" ]]; then + echo -ne " ${PURPLE}${ICON_ARROW}${NC} Type ${YELLOW}${expected}${NC} to continue: " + IFS= read -r typed || typed="" + fi + + [[ "$typed" == "$expected" ]] +} + +opt_spotlight_deep_reset() { + local -a targets=() + local target + while IFS= read -r target; do + [[ -n "$target" ]] || continue + targets+=("$target") + done < <(_opt_spotlight_deep_reset_collect_targets) + + echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotlight deep reset will rebuild user search caches" + echo -e " ${GRAY}${ICON_LIST}${NC} Search results may be incomplete while macOS rebuilds the index" + + if [[ ${#targets[@]} -eq 0 ]]; then + echo -e " ${GRAY}${ICON_EMPTY}${NC} No user Spotlight cache targets found" + else + echo -e " ${GRAY}${ICON_LIST}${NC} Targets:" + for target in "${targets[@]}"; do + echo -e " ${GRAY}-${NC} $target" + done + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} DRY RUN: would run mdutil off/on and rebuild /" + for target in "${targets[@]}"; do + _opt_spotlight_deep_reset_remove_target "$target" || true + done + opt_msg "Spotlight deep reset preview complete" + return 0 + fi + + if ! _opt_spotlight_deep_reset_confirmed; then + echo -e " ${GRAY}${ICON_EMPTY}${NC} Spotlight deep reset cancelled" + return 0 + fi + + if ! ensure_sudo_session "Spotlight deep reset requires admin access"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotlight deep reset skipped ยท admin access required" + return 0 + fi + + local indexing_disabled=false + if sudo mdutil -i off / > /dev/null 2>&1; then + indexing_disabled=true + opt_msg "Spotlight indexing paused" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to pause Spotlight indexing" + return 1 + fi + + if pgrep -x corespotlightd > /dev/null 2>&1; then + if killall corespotlightd > /dev/null 2>&1; then + opt_msg "CoreSpotlight service restarted" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to restart CoreSpotlight service" + fi + fi + + local failed=0 + for target in "${targets[@]}"; do + if _opt_spotlight_deep_reset_remove_target "$target"; then + opt_msg "Removed $target" + else + failed=$((failed + 1)) + fi + done + + if [[ "$indexing_disabled" == "true" ]]; then + if sudo mdutil -i on / > /dev/null 2>&1; then + opt_msg "Spotlight indexing resumed" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to resume Spotlight indexing" + return 1 + fi + fi + + if sudo mdutil -E / > /dev/null 2>&1; then + opt_msg "Spotlight deep rebuild started" + echo -e " ${GRAY}Indexing will continue in background${NC}" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to start Spotlight rebuild" + return 1 + fi + + [[ "$failed" -eq 0 ]] +} + # Dock cache refresh. opt_dock_refresh() { local dock_support="$HOME/Library/Application Support/Dock" @@ -1393,6 +1542,7 @@ execute_optimization() { network_stack_optimize) opt_network_stack_optimize ;; disk_permissions_repair) opt_disk_permissions_repair ;; spotlight_index_optimize) opt_spotlight_index_optimize ;; + spotlight_deep_reset) opt_spotlight_deep_reset ;; launch_agents_cleanup) opt_launch_agents_cleanup ;; periodic_maintenance) opt_periodic_maintenance ;; shared_file_list_repair) opt_shared_file_list_repair ;; diff --git a/tests/completion.bats b/tests/completion.bats index 3d188c9d7..a9d52e0a1 100755 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -85,10 +85,11 @@ setup() { [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } -@test "completion bash includes current clean, analyze, and purge options only" { +@test "completion bash includes current clean, optimize, analyze, and purge options only" { run "$PROJECT_ROOT/bin/completion.sh" bash [ "$status" -eq 0 ] [[ "$output" == *"--dry-run -n --external --whitelist --debug --help -h"* ]] + [[ "$output" == *"--dry-run --spotlight-deep-reset --whitelist --debug --help -h"* ]] [[ "$output" == *"--json --help -h"* ]] [[ "$output" == *"--paths --dry-run -n --include-empty --debug --help -h"* ]] [[ "$output" != *"--select"* ]] @@ -116,12 +117,13 @@ setup() { [[ "$output" == *"clean:Free up disk space"* ]] } -@test "completion zsh includes current clean, analyze, and purge options only" { +@test "completion zsh includes current clean, optimize, analyze, and purge options only" { run "$PROJECT_ROOT/bin/completion.sh" zsh [ "$status" -eq 0 ] [[ "$output" == *"--dry-run"* ]] [[ "$output" == *"--external"* ]] [[ "$output" == *"--whitelist"* ]] + [[ "$output" == *"--spotlight-deep-reset"* ]] [[ "$output" == *"--json"* ]] [[ "$output" == *"--include-empty"* ]] [[ "$output" != *"--select"* ]] @@ -145,12 +147,13 @@ setup() { [ "$mo_count" -gt 0 ] } -@test "completion fish includes current clean, analyze, and purge options only" { +@test "completion fish includes current clean, optimize, analyze, and purge options only" { run "$PROJECT_ROOT/bin/completion.sh" fish [ "$status" -eq 0 ] [[ "$output" == *"-l dry-run"* ]] [[ "$output" == *"-l external"* ]] [[ "$output" == *"-l whitelist"* ]] + [[ "$output" == *"-l spotlight-deep-reset"* ]] [[ "$output" == *"-l json"* ]] [[ "$output" == *"-l include-empty"* ]] [[ "$output" != *"-l select"* ]] diff --git a/tests/optimize.bats b/tests/optimize.bats index 0821a39aa..4b6f60290 100644 --- a/tests/optimize.bats +++ b/tests/optimize.bats @@ -761,6 +761,135 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Permission Repair|disk_permissions_repair|optimize_task"* ]] [[ "$output" == *"Login Items Audit|login_items_audit|optimize_task"* ]] + [[ "$output" == *"Spotlight Deep Reset|spotlight_deep_reset|optimize_task"* ]] +} + +@test "optimize help documents spotlight deep reset option" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" "$PROJECT_ROOT/mole" optimize --help + + [ "$status" -eq 0 ] + [[ "$output" == *"--spotlight-deep-reset"* ]] + [[ "$output" == *"Spotlight"* ]] +} + +@test "spotlight deep reset dry-run lists exact targets without sudo or deletion" { + mkdir -p "$HOME/Library/Metadata/CoreSpotlight" + mkdir -p "$HOME/Library/Caches/com.apple.Spotlight" + mkdir -p "$HOME/Library/Caches/com.apple.spotlightLower" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +sudo() { + echo "UNEXPECTED_SUDO:$*" + return 1 +} +export -f sudo + +opt_spotlight_deep_reset +[[ -d "$HOME/Library/Metadata/CoreSpotlight" ]] +[[ -d "$HOME/Library/Caches/com.apple.Spotlight" ]] +[[ -d "$HOME/Library/Caches/com.apple.spotlightLower" ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN"* ]] + [[ "$output" == *"$HOME/Library/Metadata/CoreSpotlight"* ]] + [[ "$output" == *"$HOME/Library/Caches/com.apple.Spotlight"* ]] + [[ "$output" == *"$HOME/Library/Caches/com.apple.spotlightLower"* ]] + [[ "$output" != *"UNEXPECTED_SUDO"* ]] +} + +@test "spotlight deep reset dry-run refuses matching symlinks without aborting preview" { + mkdir -p "$HOME/Library/Caches" + ln -s "$HOME" "$HOME/Library/Caches/com.apple.SpotlightLink" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +opt_spotlight_deep_reset +[[ -L "$HOME/Library/Caches/com.apple.SpotlightLink" ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Refusing unexpected Spotlight target"* ]] + [[ "$output" == *"Spotlight deep reset preview complete"* ]] +} + +@test "spotlight deep reset requires typed confirmation" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_SPOTLIGHT_DEEP_RESET_CONFIRM="wrong" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +ensure_sudo_session() { + echo "UNEXPECTED_SUDO_SESSION" + return 1 +} + +opt_spotlight_deep_reset +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"cancelled"* ]] + [[ "$output" != *"UNEXPECTED_SUDO_SESSION"* ]] +} + +@test "spotlight deep reset deletes only approved targets and rebuilds index" { + mkdir -p "$HOME/Library/Metadata/CoreSpotlight" + mkdir -p "$HOME/Library/Caches/com.apple.Spotlight" + mkdir -p "$HOME/Library/Caches/com.apple.spotlightLower" + mkdir -p "$HOME/Library/Caches/com.apple.SpotlightExtra" + mkdir -p "$HOME/Library/Caches/com.apple.Unrelated" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_SPOTLIGHT_DEEP_RESET_CONFIRM="RESET SPOTLIGHT" \ + MOLE_OPTIMIZE_SUDO_AVAILABLE="true" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +trace="$HOME/spotlight-reset.log" +: > "$trace" + +ensure_sudo_session() { return 0; } +sudo() { + printf 'sudo %s\n' "$*" >> "$trace" + case "$1 $2 $3" in + "mdutil -i off"|"mdutil -i on"|"mdutil -E /") return 0 ;; + esac + return 1 +} +killall() { + printf 'killall %s\n' "$*" >> "$trace" + return 0 +} +pgrep() { + [[ "$1" == "-x" && "$2" == "corespotlightd" ]] +} +_opt_spotlight_deep_reset_remove_target() { + printf 'delete %s\n' "$1" >> "$trace" + rm -rf "$1" +} + +opt_spotlight_deep_reset +cat "$trace" +[[ ! -e "$HOME/Library/Metadata/CoreSpotlight" ]] +[[ ! -e "$HOME/Library/Caches/com.apple.Spotlight" ]] +[[ ! -e "$HOME/Library/Caches/com.apple.spotlightLower" ]] +[[ ! -e "$HOME/Library/Caches/com.apple.SpotlightExtra" ]] +[[ -d "$HOME/Library/Caches/com.apple.Unrelated" ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"sudo mdutil -i off /"* ]] + [[ "$output" == *"killall corespotlightd"* ]] + [[ "$output" == *"delete $HOME/Library/Metadata/CoreSpotlight"* ]] + [[ "$output" == *"sudo mdutil -i on /"* ]] + [[ "$output" == *"sudo mdutil -E /"* ]] } @test "_login_item_app_exists finds nested helper app bundles" {