From 09d0de0c8e70fb0dcc507f30f544c28472079817 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 6 Mar 2026 19:42:15 +0800 Subject: [PATCH 01/26] perf(core): optimize base functions with caching and improve robustness - Add global caching for `detect_architecture`, `get_darwin_major`, `get_optimal_parallel_jobs`, and `is_ansi_supported` to reduce subshell overhead. - Improve robustness of `get_lsregister_path` by returning 1 on failure. - Enhance security of `get_user_home` by replacing `eval echo` with `id -P`. --- lib/core/base.sh | 58 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/lib/core/base.sh b/lib/core/base.sh index 14dd48d..52c9609 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -61,7 +61,7 @@ get_lsregister_path() { fi done echo "" - return 0 + return 1 } # ============================================================================ @@ -191,11 +191,17 @@ is_sip_enabled() { # Detect CPU architecture # Returns: "Apple Silicon" or "Intel" detect_architecture() { - if [[ "$(uname -m)" == "arm64" ]]; then - echo "Apple Silicon" - else - echo "Intel" + if [[ -n "${MOLE_ARCH_CACHE:-}" ]]; then + echo "$MOLE_ARCH_CACHE" + return 0 fi + + if [[ "$(uname -m)" == "arm64" ]]; then + export MOLE_ARCH_CACHE="Apple Silicon" + else + export MOLE_ARCH_CACHE="Intel" + fi + echo "$MOLE_ARCH_CACHE" } # Get free disk space on root volume @@ -212,6 +218,11 @@ get_free_space() { # 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" + return 0 + fi + local kernel kernel=$(uname -r 2> /dev/null || true) local major="${kernel%%.*}" @@ -219,6 +230,7 @@ get_darwin_major() { # Return high number to skip potentially dangerous operations on unknown systems major=999 fi + export MOLE_DARWIN_MAJOR_CACHE="$major" echo "$major" } @@ -233,8 +245,10 @@ is_darwin_ge() { # Get optimal parallel jobs for operation type (scan|io|compute|default) get_optimal_parallel_jobs() { local operation_type="${1:-default}" - local cpu_cores - cpu_cores=$(sysctl -n hw.ncpu 2> /dev/null || echo 4) + if [[ -z "${MOLE_CPU_CORES_CACHE:-}" ]]; then + export MOLE_CPU_CORES_CACHE=$(sysctl -n hw.ncpu 2> /dev/null || echo 4) + fi + local cpu_cores="$MOLE_CPU_CORES_CACHE" case "$operation_type" in scan | io) echo $((cpu_cores * 2)) @@ -318,7 +332,7 @@ get_user_home() { fi if [[ -z "$home" ]]; then - home=$(eval echo "~$user" 2> /dev/null || true) + home=$(id -P "$user" 2> /dev/null | cut -d: -f9 || true) fi if [[ "$home" == "~"* ]]; then @@ -586,7 +600,7 @@ mktemp_file() { # Cleanup all tracked temp files and directories cleanup_temp_files() { - stop_inline_spinner 2> /dev/null || true + stop_inline_spinner || true local file if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then for file in "${MOLE_TEMP_FILES[@]}"; do @@ -641,7 +655,7 @@ note_activity() { # Usage: start_section_spinner "message" start_section_spinner() { local message="${1:-Scanning...}" - stop_inline_spinner 2> /dev/null || true + stop_inline_spinner || true if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "$message" fi @@ -651,7 +665,7 @@ start_section_spinner() { # Usage: stop_section_spinner stop_section_spinner() { # Always try to stop spinner (function handles empty PID gracefully) - stop_inline_spinner 2> /dev/null || true + 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 @@ -732,18 +746,30 @@ update_progress_if_needed() { # Usage: is_ansi_supported # Returns: 0 if supported, 1 if not is_ansi_supported() { + if [[ -n "${MOLE_ANSI_SUPPORTED_CACHE:-}" ]]; then + return "$MOLE_ANSI_SUPPORTED_CACHE" + fi + # Check if running in interactive terminal - [[ -t 1 ]] || return 1 + if ! [[ -t 1 ]]; then + export MOLE_ANSI_SUPPORTED_CACHE=1 + return 1 + fi # Check TERM variable - [[ -n "${TERM:-}" ]] || return 1 + if [[ -z "${TERM:-}" ]]; then + export MOLE_ANSI_SUPPORTED_CACHE=1 + return 1 + fi # Check for known ANSI-compatible terminals case "$TERM" in xterm* | vt100 | vt220 | screen* | tmux* | ansi | linux | rxvt* | konsole*) + export MOLE_ANSI_SUPPORTED_CACHE=0 return 0 ;; dumb | unknown) + export MOLE_ANSI_SUPPORTED_CACHE=1 return 1 ;; *) @@ -751,8 +777,12 @@ is_ansi_supported() { if command -v tput > /dev/null 2>&1; then # Test if terminal supports colors (good proxy for ANSI support) local colors=$(tput colors 2> /dev/null || echo "0") - [[ "$colors" -ge 8 ]] && return 0 + if [[ "$colors" -ge 8 ]]; then + export MOLE_ANSI_SUPPORTED_CACHE=0 + return 0 + fi fi + export MOLE_ANSI_SUPPORTED_CACHE=1 return 1 ;; esac From 89a9ae0ce2534f605e6edd6fd9370b2dfa9af479 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Mar 2026 10:10:41 +0800 Subject: [PATCH 02/26] fix(analyze): count top-level files in json output --- cmd/analyze/analyze_test.go | 25 +++++++++++++++++++++++++ cmd/analyze/json.go | 4 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index b0e9007..7a2fecd 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -91,6 +91,31 @@ func TestScanPathConcurrentBasic(t *testing.T) { } } +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) + + if result.TotalFiles != 2 { + t.Fatalf("expected 2 files in JSON output, got %d", result.TotalFiles) + } +} + func TestDeletePathWithProgress(t *testing.T) { skipIfFinderUnavailable(t) diff --git a/cmd/analyze/json.go b/cmd/analyze/json.go index 056edff..1c2ab44 100644 --- a/cmd/analyze/json.go +++ b/cmd/analyze/json.go @@ -58,6 +58,8 @@ func performScanForJSON(path string) jsonOutput { info, err := item.Info() if err == nil { size = info.Size() + atomic.AddInt64(&filesScanned, 1) + atomic.AddInt64(&bytesScanned, size) } } @@ -74,6 +76,6 @@ func performScanForJSON(path string) jsonOutput { Path: path, Entries: entries, TotalSize: totalSize, - TotalFiles: filesScanned, + TotalFiles: atomic.LoadInt64(&filesScanned), } } From 300aded07b16068419cdab9eed14320f44ed7d01 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Mar 2026 18:35:19 +0800 Subject: [PATCH 03/26] fix(clean): avoid stalls in app support scan --- lib/clean/user.sh | 23 +++++++++++++-- tests/clean_user_core.bats | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index a5a1ecb..60e2af9 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -752,6 +752,23 @@ clean_virtualization_tools() { # Estimate item size for Application Support cleanup. # Files use stat; directories use du with timeout to avoid long blocking scans. +app_support_entry_count_capped() { + local dir="$1" + local maxdepth="${2:-1}" + local cap="${3:-101}" + local count=0 + + while IFS= read -r -d '' _entry; do + count=$((count + 1)) + if ((count >= cap)); then + break + fi + done < <(command find "$dir" -mindepth 1 -maxdepth "$maxdepth" -print0 2> /dev/null) + + [[ "$count" =~ ^[0-9]+$ ]] || count=0 + printf '%s\n' "$count" +} + app_support_item_size_bytes() { local item="$1" local timeout_seconds="${2:-0.4}" @@ -768,7 +785,7 @@ app_support_item_size_bytes() { # Fast path: if directory has too many items, skip detailed size calculation # to avoid hanging on deep directories (e.g., node_modules, .git) local item_count - item_count=$(command find "$item" -maxdepth 2 -print0 2> /dev/null | tr -d '\0' | wc -c) + item_count=$(app_support_entry_count_capped "$item" 2 10001) if [[ "$item_count" -gt 10000 ]]; then # Return 1 to signal "too many items, size unknown" return 1 @@ -859,7 +876,7 @@ clean_application_support_logs() { if [[ -d "$candidate" ]]; then # Quick count check - skip if too many items to avoid hanging local quick_count - quick_count=$(command find "$candidate" -mindepth 1 -maxdepth 1 -printf '1\n' 2> /dev/null | wc -l | tr -d ' ') + quick_count=$(app_support_entry_count_capped "$candidate" 1 101) if [[ "$quick_count" -gt 100 ]]; then # Too many items - use bulk removal instead of item-by-item local app_label="$app_name" @@ -935,7 +952,7 @@ clean_application_support_logs() { if [[ -d "$candidate" ]]; then # Quick count check - skip if too many items local quick_count - quick_count=$(command find "$candidate" -mindepth 1 -maxdepth 1 -printf '1\n' 2> /dev/null | wc -l | tr -d ' ') + quick_count=$(app_support_entry_count_capped "$candidate" 1 101) if [[ "$quick_count" -gt 100 ]]; then local container_label="$container" if [[ ${#container_label} -gt 24 ]]; then diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 29b5f45..acf3b54 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -220,6 +220,63 @@ EOF [[ "$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' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { echo "SPIN:$1"; } +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; } +bytes_to_human() { echo "0B"; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/adspower_global/logs" +for i in $(seq 1 101); do + touch "$HOME/Library/Application Support/adspower_global/logs/file-$i.log" +done + +clean_application_support_logs +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" != *"REMOVE:"* ]] +} + +@test "app_support_entry_count_capped stops at cap without failing under pipefail" { + local support_home="$HOME/support-appsupport-cap" + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +mkdir -p "$HOME/Library/Application Support/adspower_global/logs" +for i in $(seq 1 150); do + touch "$HOME/Library/Application Support/adspower_global/logs/file-$i.log" +done + +count=$(app_support_entry_count_capped "$HOME/Library/Application Support/adspower_global/logs" 1 101) +echo "COUNT=$count" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=101"* ]] +} + @test "clean_group_container_caches keeps protected caches and cleans non-protected caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail From d189e1b84f54b34a057a14dff83250dd775f0b1c Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Mar 2026 20:03:11 +0800 Subject: [PATCH 04/26] test: fix update and cache cleanup cases --- tests/clean_system_caches.bats | 3 ++- tests/update.bats | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/clean_system_caches.bats b/tests/clean_system_caches.bats index a034c96..9ab37fa 100644 --- a/tests/clean_system_caches.bats +++ b/tests/clean_system_caches.bats @@ -177,7 +177,8 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Next.js build cache"* ]] grep -q -- "-P $HOME/CustomProjects " "$find_log" - ! grep -q -- "-P $HOME " "$find_log" + run grep -q -- "-P $HOME " "$find_log" + [ "$status" -eq 1 ] rm -rf "$HOME/CustomProjects" "$HOME/.config/mole" "$fake_bin" "$find_log" } diff --git a/tests/update.bats b/tests/update.bats index cb5c4a3..19954eb 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -600,7 +600,7 @@ EOF @test "get_homebrew_latest_version prefers brew outdated verbose target version" { run bash --noprofile --norc <<'EOF' set -euo pipefail -MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" +MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" brew() { if [[ "${1:-}" == "outdated" ]]; then @@ -625,7 +625,7 @@ EOF @test "get_homebrew_latest_version parses brew info fallback with heading prefix" { run bash --noprofile --norc <<'EOF' set -euo pipefail -MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" +MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" brew() { if [[ "${1:-}" == "outdated" ]]; then From dfedc029d1e7e03fc942b32b79f24036e6a910e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20Ta=C5=9Fhan?= Date: Sat, 7 Mar 2026 15:33:47 +0300 Subject: [PATCH 05/26] fix: handle empty menu_options in mo purge to prevent unbound variable error (#547) When no artifacts are found during scanning, `menu_options` remains an empty array. With `set -euo pipefail` active, expanding `${menu_options[@]}` on an empty array causes a fatal "unbound variable" error (line 1325). Add an early-return guard after the spinner stops: if no items were found, print a friendly "No artifacts found to purge" message and exit cleanly. Fixes #546 --- lib/clean/project.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index d6158b4..c1a9ee7 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -1310,6 +1310,14 @@ clean_project_artifacts() { if [[ -t 1 ]]; then stop_inline_spinner fi + # Exit early if no artifacts were found to avoid unbound variable errors + # when expanding empty arrays with set -u active. + if [[ ${#menu_options[@]} -eq 0 ]]; then + echo "" + echo -e "${GRAY}No artifacts found to purge${NC}" + printf '\n' + return 0 + fi # Set global vars for selector export PURGE_CATEGORY_SIZES=$( IFS=, From faf29b05f163a2fd7aeb342a99fb27d57028ec85 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 7 Mar 2026 20:30:06 +0800 Subject: [PATCH 06/26] Fix perl timeout fallback selection --- lib/core/timeout.sh | 4 ++-- tests/clean_system_caches.bats | 1 + tests/regression.bats | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/core/timeout.sh b/lib/core/timeout.sh index bcb3c9d..edd7051 100644 --- a/lib/core/timeout.sh +++ b/lib/core/timeout.sh @@ -42,9 +42,9 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then fi done - if [[ -z "$MO_TIMEOUT_BIN" ]] && command -v perl > /dev/null 2>&1; then + if command -v perl > /dev/null 2>&1; then MO_TIMEOUT_PERL_BIN="$(command -v perl)" - if [[ "${MO_DEBUG:-0}" == "1" ]]; then + if [[ -z "$MO_TIMEOUT_BIN" ]] && [[ "${MO_DEBUG:-0}" == "1" ]]; then echo "[TIMEOUT] Using perl fallback: $MO_TIMEOUT_PERL_BIN" >&2 fi fi diff --git a/tests/clean_system_caches.bats b/tests/clean_system_caches.bats index 9ab37fa..0275c70 100644 --- a/tests/clean_system_caches.bats +++ b/tests/clean_system_caches.bats @@ -252,6 +252,7 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" MO_TIMEOUT_BIN="" +MO_TIMEOUT_PERL_BIN="${MO_TIMEOUT_PERL_BIN:-$(command -v perl)}" export MOLE_PROJECT_CACHE_DISCOVERY_TIMEOUT=0.5 export MOLE_PROJECT_CACHE_SCAN_TIMEOUT=0.5 SECONDS=0 diff --git a/tests/regression.bats b/tests/regression.bats index 202c0d3..3e10baa 100644 --- a/tests/regression.bats +++ b/tests/regression.bats @@ -114,6 +114,7 @@ EOF set -euo pipefail source "$PROJECT_ROOT/lib/core/timeout.sh" MO_TIMEOUT_BIN="" +MO_TIMEOUT_PERL_BIN="${MO_TIMEOUT_PERL_BIN:-$(command -v perl)}" SECONDS=0 set +e run_with_timeout 1 "$FAKE_CMD" From 42cc50d0fd3ac71663b89f89617f22e5bc44d370 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 7 Mar 2026 20:35:35 +0800 Subject: [PATCH 07/26] test(purge): cover empty menu options path --- tests/purge.bats | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/purge.bats b/tests/purge.bats index 0137f7a..9895373 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -683,6 +683,25 @@ EOF [[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]] } +@test "clean_project_artifacts: handles empty menu options under set -u" { + 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/project.sh" + +mkdir -p "$HOME/www/test-project/node_modules" +touch "$HOME/www/test-project/package.json" + +PURGE_SEARCH_PATHS=("$HOME/www") +get_dir_size_kb() { echo 0; } + +clean_project_artifacts Date: Sat, 7 Mar 2026 12:38:52 +0000 Subject: [PATCH 08/26] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 71 ++++++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 30c6348..5db214e 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -200,17 +200,6 @@ - - - - - - - - andmev - - - @@ -221,7 +210,7 @@ ndbroadbent - + @@ -232,6 +221,17 @@ MohammedTarigg + + + + + + + + + onurtashan + + @@ -398,6 +398,17 @@ + + + + + + + + andmev + + + @@ -408,7 +419,7 @@ uluumbch - + @@ -419,7 +430,7 @@ ClathW - + @@ -430,7 +441,7 @@ Copper-Eye - + @@ -441,7 +452,7 @@ DimitarNestorov - + @@ -452,7 +463,7 @@ gokulp01 - + @@ -463,7 +474,7 @@ Hensell - + @@ -474,7 +485,7 @@ jalen0x - + @@ -485,7 +496,7 @@ kowyo - + @@ -496,7 +507,7 @@ kwakubiney - + @@ -507,7 +518,7 @@ LmanTW - + @@ -518,7 +529,7 @@ injuxtice - + @@ -529,7 +540,7 @@ khipu-luke - + @@ -540,7 +551,7 @@ mariovtor - + @@ -551,7 +562,7 @@ anonymort - + @@ -562,7 +573,7 @@ Schlauer-Hax - + @@ -573,7 +584,7 @@ mickyyy68 - + @@ -584,7 +595,7 @@ EastSun5566 - + From 50efe515650a0202e4bcfed8a3c7f059fe290af6 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Mar 2026 23:07:38 +0800 Subject: [PATCH 09/26] fix(clean): guard empty Xcode DeviceSupport arrays --- lib/clean/dev.sh | 72 +++++++++++++++++++++-------------------- tests/dev_extended.bats | 18 +++++++++++ 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 342ea0f..fb8eefd 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -359,47 +359,49 @@ clean_xcode_device_support() { version_dirs+=("$entry") done < <(command find "$ds_dir" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) - # Sort by modification time (most recent first) - local -a sorted_dirs=() - while IFS= read -r line; do - sorted_dirs+=("${line#* }") - done < <( - for entry in "${version_dirs[@]}"; do - printf '%s %s\n' "$(stat -f%m "$entry" 2> /dev/null || echo 0)" "$entry" - done | sort -rn - ) + if [[ ${#version_dirs[@]} -gt 0 ]]; then + # Sort by modification time (most recent first) + local -a sorted_dirs=() + while IFS= read -r line; do + sorted_dirs+=("${line#* }") + done < <( + for entry in "${version_dirs[@]}"; do + printf '%s %s\n' "$(stat -f%m "$entry" 2> /dev/null || echo 0)" "$entry" + done | sort -rn + ) - # Get stale versions (everything after keep_count) - local -a stale_dirs=("${sorted_dirs[@]:$keep_count}") + # Get stale versions (everything after keep_count) + local -a stale_dirs=("${sorted_dirs[@]:$keep_count}") - if [[ ${#stale_dirs[@]} -gt 0 ]]; then - # Calculate total size of stale versions - local stale_size_kb=0 entry_size_kb - for stale_entry in "${stale_dirs[@]}"; do - entry_size_kb=$(get_path_size_kb "$stale_entry" 2> /dev/null || echo 0) - stale_size_kb=$((stale_size_kb + entry_size_kb)) - done - local stale_size_human - stale_size_human=$(bytes_to_human "$((stale_size_kb * 1024))") - - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${display_name} · would remove ${#stale_dirs[@]} old versions (${stale_size_human}), keeping ${keep_count} most recent" - note_activity - else - # Remove old versions - local removed_count=0 + if [[ ${#stale_dirs[@]} -gt 0 ]]; then + # Calculate total size of stale versions + local stale_size_kb=0 entry_size_kb for stale_entry in "${stale_dirs[@]}"; do - if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry"; then - continue - fi - if safe_remove "$stale_entry"; then - removed_count=$((removed_count + 1)) - fi + entry_size_kb=$(get_path_size_kb "$stale_entry" 2> /dev/null || echo 0) + stale_size_kb=$((stale_size_kb + entry_size_kb)) done + local stale_size_human + stale_size_human=$(bytes_to_human "$((stale_size_kb * 1024))") - if [[ $removed_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${stale_size_human}" + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${display_name} · would remove ${#stale_dirs[@]} old versions (${stale_size_human}), keeping ${keep_count} most recent" note_activity + else + # Remove old versions + local removed_count=0 + for stale_entry in "${stale_dirs[@]}"; do + if should_protect_path "$stale_entry" || is_path_whitelisted "$stale_entry"; then + continue + fi + if safe_remove "$stale_entry"; then + removed_count=$((removed_count + 1)) + fi + done + + if [[ $removed_count -gt 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${stale_size_human}" + note_activity + fi fi fi fi diff --git a/tests/dev_extended.bats b/tests/dev_extended.bats index f50133a..ec7515e 100644 --- a/tests/dev_extended.bats +++ b/tests/dev_extended.bats @@ -136,6 +136,24 @@ EOF [[ "$output" != *"NDK versions"* ]] } +@test "clean_xcode_device_support handles empty directories under nounset" { + local ds_dir="$HOME/EmptyDeviceSupport" + mkdir -p "$ds_dir" + + 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" +note_activity() { :; } +safe_clean() { :; } +clean_xcode_device_support "$HOME/EmptyDeviceSupport" "iOS DeviceSupport" +echo "survived" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"survived"* ]] +} + @test "clean_xcode_documentation_cache keeps newest DeveloperDocumentation index" { local doc_root="$HOME/DocumentationCache" mkdir -p "$doc_root" From 26b267c4a2e887ea79882a0ee584a7f9bb8f27a4 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 15:29:25 +0800 Subject: [PATCH 10/26] fix: harden orphan cleanup and lsregister fallback --- lib/clean/apps.sh | 11 ++++++++++ lib/core/base.sh | 2 +- tests/clean_apps.bats | 47 +++++++++++++++++++++++++++++++++++++++++++ tests/optimize.bats | 18 +++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index f1e454d..7fea0cd 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -261,6 +261,17 @@ is_claude_vm_bundle_orphaned() { return 1 fi + if [[ -e "$vm_bundle_path" ]]; then + local last_modified_epoch + last_modified_epoch=$(get_file_mtime "$vm_bundle_path") + local current_epoch + current_epoch=$(get_epoch_seconds) + local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) + if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then + return 1 + 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" diff --git a/lib/core/base.sh b/lib/core/base.sh index 52c9609..be34ca8 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -61,7 +61,7 @@ get_lsregister_path() { fi done echo "" - return 1 + return 0 } # ============================================================================ diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index a974716..07659b4 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -229,6 +229,8 @@ pgrep() { } run_with_timeout() { shift; "$@"; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 4; } safe_clean() { echo "$2" @@ -254,6 +256,51 @@ EOF [[ "$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' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +scan_installed_apps() { + : > "$1" +} + +mdfind() { + return 0 +} + +pgrep() { + return 1 +} + +run_with_timeout() { shift; "$@"; } +get_file_mtime() { date +%s; } + +safe_clean() { + echo "UNEXPECTED:$2" + return 1 +} + +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" +echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then + echo "PASS: Recent Claude VM kept" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED:Orphaned Claude workspace VM"* ]] + [[ "$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' set -euo pipefail diff --git a/tests/optimize.bats b/tests/optimize.bats index a4ef886..00a7b11 100644 --- a/tests/optimize.bats +++ b/tests/optimize.bats @@ -181,3 +181,21 @@ EOF [ "$status" -eq 1 ] [[ "$output" == *"Unknown action"* ]] } + +@test "opt_launch_services_rebuild handles missing lsregister without exiting" { + 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" +get_lsregister_path() { + echo "" + return 0 +} +opt_launch_services_rebuild +echo "survived" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"lsregister not found"* ]] + [[ "$output" == *"survived"* ]] +} From 2a36c662aa22eb2cdcf370bd016aa1c03d335e84 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 15:33:30 +0800 Subject: [PATCH 11/26] fix: tighten orphan cleanup retention windows --- lib/clean/apps.sh | 11 ++++++----- lib/core/base.sh | 2 +- tests/clean_apps.bats | 16 ++++++++-------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 7fea0cd..75402f3 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -2,7 +2,8 @@ # Application Data Cleanup Module set -euo pipefail -readonly ORPHAN_AGE_THRESHOLD=${ORPHAN_AGE_THRESHOLD:-${MOLE_ORPHAN_AGE_DAYS:-60}} +readonly ORPHAN_AGE_THRESHOLD=${ORPHAN_AGE_THRESHOLD:-${MOLE_ORPHAN_AGE_DAYS:-30}} +readonly CLAUDE_VM_ORPHAN_AGE_THRESHOLD=${MOLE_CLAUDE_VM_ORPHAN_AGE_DAYS:-7} # Args: $1=target_dir, $2=label clean_ds_store_tree() { local target="$1" @@ -59,7 +60,7 @@ clean_ds_store_tree() { note_activity fi } -# Orphaned app data (60+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN +# Orphaned app data (30+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN # Usage: scan_installed_apps "output_file" scan_installed_apps() { local installed_bundles="$1" @@ -201,13 +202,13 @@ is_bundle_orphaned() { ;; esac - # 5. Fast path: 60-day modification check (stat call, fast) + # 5. Fast path: 30-day modification check (stat call, fast) if [[ -e "$directory_path" ]]; then local last_modified_epoch=$(get_file_mtime "$directory_path") local current_epoch current_epoch=$(get_epoch_seconds) local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) - if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then + if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-30} ]]; then return 1 fi fi @@ -267,7 +268,7 @@ is_claude_vm_bundle_orphaned() { local current_epoch current_epoch=$(get_epoch_seconds) local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) - if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then + if [[ $days_since_modified -lt ${CLAUDE_VM_ORPHAN_AGE_THRESHOLD:-7} ]]; then return 1 fi fi diff --git a/lib/core/base.sh b/lib/core/base.sh index be34ca8..5479fa3 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -68,7 +68,7 @@ get_lsregister_path() { # Global Configuration Constants # ============================================================================ readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file retention (days) -readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data retention (days) +readonly MOLE_ORPHAN_AGE_DAYS=30 # Orphaned data retention (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) diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index 07659b4..0fafe10 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -60,7 +60,7 @@ EOF } @test "is_bundle_orphaned returns true for old uninstalled bundle" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=60 bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=30 bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" @@ -116,12 +116,12 @@ safe_clean() { # Create required Library structure for permission check mkdir -p "$HOME/Library/Caches" -# Create test structure with spaces in path (old modification time: 61 days ago) +# Create test structure with spaces in path (old modification time: 31 days ago) mkdir -p "$HOME/Library/Saved Application State/com.test.orphan.savedState" # Create a file with some content so directory size > 0 echo "test data" > "$HOME/Library/Saved Application State/com.test.orphan.savedState/data.plist" -# Set modification time to 61 days ago (older than 60-day threshold) -touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Saved Application State/com.test.orphan.savedState" 2>/dev/null || true +# Set modification time to 31 days ago (older than 30-day threshold) +touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Saved Application State/com.test.orphan.savedState" 2>/dev/null || true # Disable spinner for test start_section_spinner() { :; } @@ -165,15 +165,15 @@ run_with_timeout() { shift; "$@"; } # Create required Library structure for permission check mkdir -p "$HOME/Library/Caches" -# Create test files (old modification time: 61 days ago) +# Create test files (old modification time: 31 days ago) mkdir -p "$HOME/Library/Caches/com.test.orphan1" mkdir -p "$HOME/Library/Caches/com.test.orphan2" # Create files with content so size > 0 echo "data1" > "$HOME/Library/Caches/com.test.orphan1/data" echo "data2" > "$HOME/Library/Caches/com.test.orphan2/data" -# Set modification time to 61 days ago -touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan1" 2>/dev/null || true -touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan2" 2>/dev/null || true +# Set modification time to 31 days ago +touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan1" 2>/dev/null || true +touch -t "$(date -v-31d +%Y%m%d%H%M.%S 2>/dev/null || date -d '31 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan2" 2>/dev/null || true # Mock safe_clean to fail on first item, succeed on second safe_clean() { From 943e68bb1ce42492bcd06a1d87229b8fc370d79b Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 15:35:45 +0800 Subject: [PATCH 12/26] docs: refresh security audit reference --- SECURITY_AUDIT.md | 210 +++++++++++++++++++++++++++------------------- 1 file changed, 126 insertions(+), 84 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index fcf73d3..dc33e7b 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,158 +1,200 @@ # Mole Security Reference -Version 1.28.0 | 2026-02-27 +Version 1.29.0 | 2026-03-08 + +This document describes the security-relevant behavior of the current codebase on `main`. ## Path Validation -Every deletion goes through `lib/core/file_ops.sh`. The `validate_path_for_deletion()` function rejects empty paths, paths with `/../` in them, and anything containing control characters like newlines or null bytes. +All destructive file operations go through `lib/core/file_ops.sh`. -Direct `find ... -delete` is not used for security-sensitive cleanup paths. Deletions go through validated safe wrappers like `safe_sudo_find_delete()`, `safe_sudo_remove()`, and `safe_remove()`. +- `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()`. -**Blocked paths**, even with sudo: +Blocked paths remain protected even with sudo, including: ```text -/ # root -/System # macOS system -/bin, /sbin, /usr # binaries -/etc, /var # config -/Library/Extensions # kexts -/private # system private +/ +/System +/bin +/sbin +/usr +/etc +/var +/private +/Library/Extensions ``` -Some system caches are OK to delete: +Some subpaths under protected roots are explicitly allowlisted for bounded cache and log cleanup, for example: -- `/System/Library/Caches/com.apple.coresymbolicationd/data` -- `/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` +- `/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` -See `lib/core/file_ops.sh:60-78`. - -When running with sudo, `safe_sudo_recursive_delete()` also checks for symlinks. Refuses to follow symlinks pointing to system files. +When running with sudo, symlinked targets are validated before deletion and system-target symlinks are refused. ## Cleanup Rules -**Orphan detection** at `lib/clean/apps.sh:orphan_detection()`: +### Orphan Detection -App data is only considered orphaned if the app itself is gone from all three locations: `/Applications`, `~/Applications`, `/System/Applications`. On top of that, the data must be untouched for at least 60 days. Adobe, Microsoft, and Google stuff is whitelisted regardless. +Orphaned app data is handled in `lib/clean/apps.sh`. -**Uninstall matching** at `lib/clean/apps.sh:uninstall_app()`: +- 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. -App names need at least 3 characters. Otherwise "Go" would match "Google" and that's bad. Fuzzy matching is off. Receipt scans only look under `/Applications` and `/Library/Application Support`, not in shared places like `/Library/Frameworks`. +Installed-app detection is broader than a simple `/Applications` scan and includes: -**Dev tools:** +- `/Applications` +- `/System/Applications` +- `~/Applications` +- Homebrew Caskroom locations +- Setapp application paths -Cache dirs like `~/.cargo/registry/cache` or `~/.gradle/caches` get cleaned. But `~/.cargo/bin`, `~/.mix/archives`, `~/.rustup` toolchains, `~/.stack/programs` stay untouched. +Spotlight fallback checks are bounded with short timeouts to avoid hangs. -**Application Support and Caches:** +### Uninstall Matching -- Cache entries are evaluated and removed safely on an item-by-item basis using `safe_remove()` (e.g., `process_container_cache`, `clean_application_support_logs`). -- Group Containers strictly filter against whitelists before deletion. -- Targets safe, age-gated resources natively (e.g., CrashReporter > 30 days, cached Steam/Simulator/Adobe/Teams log rot). -- Explicitly protects high-risk locations: `/private/var/folders/*` sweeping, iOS Backups (`MobileSync`), browser history/cookies, and destructive container/image pruning. +App uninstall behavior is implemented in `lib/uninstall/batch.sh` and related helpers. -**LaunchAgent removal:** +- 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. -Only removed when uninstalling the app that owns them. All `com.apple.*` items are skipped. Services get stopped via `launchctl` first. Generic names like Music, Notes, Photos are excluded from the search. +### Developer and Project Cleanup -`stop_launch_services()` checks bundle_id is valid reverse-DNS before using it in find patterns, stopping glob injection. `find_app_files()` skips LaunchAgents named after common words like Music or Notes. +Project artifact cleanup in `lib/clean/project.sh` protects recently modified targets: -`unregister_app_bundle` explicitly drops uninstalled applications from LaunchServices via `lsregister -u`. `refresh_launch_services_after_uninstall` triggers asynchronous database compacting and rebuilds to ensure complete removal of stale app references without blocking workflows. +- 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 -See `lib/core/app_protection.sh:find_app_files()`. +Developer-cache cleanup preserves toolchains and other high-value state. Examples intentionally left alone include: + +- `~/.cargo/bin` +- `~/.rustup` +- `~/.mix/archives` +- `~/.stack/programs` ## Protected Categories -System stuff stays untouched: Control Center, System Settings, TCC, Spotlight, `/Library/Updates`. +Protected or conservatively handled categories include: -VPN and proxy tools are skipped: Shadowsocks, V2Ray, Tailscale, Clash. - -AI tools are protected: Cursor, Claude, ChatGPT, Ollama, LM Studio. - -`~/Library/Messages/Attachments` and `~/Library/Metadata/CoreSpotlight` are kept out of automatic cleanup to avoid user-data or indexing risk. - -Time Machine backups running? Won't clean. Status unclear? Also won't clean. - -`com.apple.*` LaunchAgents/Daemons are never touched. - -See `lib/core/app_protection.sh:is_critical_system_component()`. +- 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 +- AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama +- `~/Library/Messages/Attachments` +- browser history and cookies +- Time Machine data while backup state is active or ambiguous +- `com.apple.*` LaunchAgents and LaunchDaemons ## Analyzer -`mo analyze` runs differently: +`mo analyze` is intentionally lower-risk than cleanup flows: -- Standard user permissions, no sudo -- Respects SIP -- Two keys to delete: press ⌫ first, then Enter. Hard to delete by accident. -- Files go to Trash via Finder API, not rm +- 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 -Code at `cmd/analyze/*.go`. +Code lives under `cmd/analyze/*.go`. -## Timeouts +## Timeouts and Hang Resistance -Network volume checks timeout after 5s (NFS/SMB/AFP can hang forever). mdfind searches get 10s. SQLite vacuum gets 20s, skipped if Mail/Safari/Messages is open. dyld cache rebuild gets 180s, skipped if done in the last 24h. +`lib/core/timeout.sh` uses this fallback order: -`brew_uninstall_cask()` treats exit code 124 as timeout failure, returns immediately. +1. `gtimeout` / `timeout` +2. a Perl helper with process-group cleanup +3. a shell fallback -`app_support_item_size_bytes` calculation leverages direct `stat -f%z` checks and uses `du` only for directories, combined with strict timeout protections to avoid process hangs. +Current notable timeouts in security-relevant paths: -Font cache rebuilding (`opt_font_cache_rebuild`) safely aborts if explicit browser processes (Safari, Chrome, Firefox, Arc, etc.) are detected, preventing GPU cache corruption and rendering bugs. +- 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 -See `lib/core/timeout.sh:run_with_timeout()`. +Additional safety behavior: -## User Config +- `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 -Put paths in `~/.config/mole/whitelist`, one per line: +## User Configuration + +Protected paths can be added to `~/.config/mole/whitelist`, one path per line. + +Example: ```bash -# exact matches only /Users/me/important-cache ~/Library/Application Support/MyApp ``` -These paths are protected from all operations. +Exact path protection is preferred over pattern-style broad deletion rules. -Run `mo clean --dry-run` or `mo optimize --dry-run` to preview what would happen without actually doing it. +Use `--dry-run` before destructive operations when validating new cleanup behavior. ## Testing -Security-sensitive cleanup paths are covered by BATS regression tests, including: +There is no dedicated `tests/security.bats`. Security-relevant behavior is covered by targeted BATS suites, including: - `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` -**System Memory Reports** computation uses bulk `find -exec stat` to avoid bash loop child-process limits on corrupted systems. -`bin/clean.sh` dry-run export temp files rely on tracked temp lifecycle (`create_temp_file()` + trap cleanup) to avoid orphan temp artifacts. -Background spinner logic interacts directly with `/dev/tty` and guarantees robust termination signals handling via trap mechanisms. - -Latest local verification for this release branch: - -- `bats tests/clean_core.bats` passed (12/12) -- `bats tests/clean_user_core.bats` passed (13/13) -- `bats tests/clean_dev_caches.bats` passed (8/8) -- `bats tests/clean_system_maintenance.bats` passed (40/40) -- `bats tests/purge.bats tests/core_safe_functions.bats` passed (67/67) - -Run tests: +Local verification used for the current branch includes: ```bash -bats tests/ # all -bats tests/security.bats # security only +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 ``` -CI runs shellcheck and go vet on every push. +CI additionally runs shell and Go validation on push. ## Dependencies -System binaries we use are all SIP protected: `plutil` (plist validation), `tmutil` (Time Machine), `dscacheutil` (cache rebuild), `diskutil` (volume info). +Primary Go dependencies are pinned in `go.mod`, including: -Go deps: bubbletea v0.23+, lipgloss v0.6+, gopsutil v3.22+, xxhash v2.2+. All MIT/BSD licensed. Versions are pinned, no CVEs. Binaries built via GitHub Actions. +- `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` + +System tooling relies mainly on Apple-provided binaries and standard macOS utilities such as: + +- `tmutil` +- `diskutil` +- `plutil` +- `launchctl` +- `osascript` +- `find` +- `stat` + +Dependency vulnerability status should be checked separately from this document. ## Limitations -System cache cleanup needs sudo, first time you'll get a password prompt. Orphan files wait 60 days before cleanup, use `mo uninstall` to delete manually if you're in a hurry. No undo, gone is gone, use dry-run first. Only recognizes English names, localized app names might be missed, but falls back to bundle ID. - -Won't touch: documents, media files, password managers, keychains, configs under `/etc`, browser history/cookies, git repos. +- 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. +- 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. From 17751e29d9c46de9c5bc711eba596b9337688b74 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 16:26:33 +0800 Subject: [PATCH 13/26] ci: align release workflow with curated notes --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1ac6db..dc22b27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,8 +73,13 @@ jobs: uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 if: startsWith(github.ref, 'refs/tags/') with: + name: ${{ github.ref_name }} files: bin/* - generate_release_notes: true + 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 From 4df6c9c531040b89803c4276ef50dfe8bec753f4 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 16:43:59 +0800 Subject: [PATCH 14/26] chore: prepare release v1.30.0 --- SECURITY_AUDIT.md | 2 +- mole | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index dc33e7b..0a6e471 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,6 +1,6 @@ # Mole Security Reference -Version 1.29.0 | 2026-03-08 +Version 1.30.0 | 2026-03-08 This document describes the security-relevant behavior of the current codebase on `main`. diff --git a/mole b/mole index 13ace9c..6f99026 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.29.0" +VERSION="1.30.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From 24da1e2ac146c79256833d75f99fe7da033bf7df Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 19:45:53 +0800 Subject: [PATCH 15/26] fix(clean): speed up Python bytecode cache cleanup --- lib/clean/caches.sh | 4 +++- tests/clean_system_caches.bats | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index 72892ce..463996b 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -212,7 +212,9 @@ clean_project_caches() { [[ -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 + # Remove the cache directory itself so we avoid expanding every + # .pyc file into a separate safe_clean target. + [[ -d "$cache_dir" ]] && safe_clean "$cache_dir" "Python bytecode cache" || true ;; ".dart_tool") if [[ -d "$cache_dir" ]]; then diff --git a/tests/clean_system_caches.bats b/tests/clean_system_caches.bats index 0275c70..bfd2930 100644 --- a/tests/clean_system_caches.bats +++ b/tests/clean_system_caches.bats @@ -138,6 +138,25 @@ setup() { rm -rf "$HOME/Projects" } +@test "clean_project_caches removes pycache directories as single targets" { + mkdir -p "$HOME/Projects/python-app/__pycache__" + touch "$HOME/Projects/python-app/pyproject.toml" + touch "$HOME/Projects/python-app/__pycache__/module.pyc" + + 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/caches.sh" +safe_clean() { echo "$2|$1"; } +clean_project_caches +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"Python bytecode cache|$HOME/Projects/python-app/__pycache__"* ]] + [[ "$output" != *"module.pyc"* ]] + + rm -rf "$HOME/Projects" +} + @test "clean_project_caches scans configured roots instead of HOME" { mkdir -p "$HOME/.config/mole" mkdir -p "$HOME/CustomProjects/app/.next/cache" From 8c53923ce82852f2e8bbfeea000eac653995cb18 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 8 Mar 2026 23:46:46 +0800 Subject: [PATCH 16/26] fix(status): improve disk card display refs #551 --- cmd/status/metrics_disk.go | 57 +++++++++++++++---- cmd/status/metrics_disk_test.go | 60 ++++++++++++++++++++ cmd/status/view.go | 17 +++++- cmd/status/view_test.go | 97 ++++++++++++++++++++++++++++----- 4 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 cmd/status/metrics_disk_test.go diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index da14f4d..92625ec 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -22,6 +22,23 @@ 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, +} + func collectDisks() ([]DiskStatus, error) { partitions, err := disk.Partitions(false) if err != nil { @@ -34,17 +51,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) @@ -97,6 +104,34 @@ 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 +} + var ( // External disk cache. lastDiskCacheAt time.Time diff --git a/cmd/status/metrics_disk_test.go b/cmd/status/metrics_disk_test.go new file mode 100644 index 0000000..32ed721 --- /dev/null +++ b/cmd/status/metrics_disk_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "testing" + + "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) + } + }) + } +} diff --git a/cmd/status/view.go b/cmd/status/view.go index 217d53c..f41e5e6 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -365,6 +365,8 @@ 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])) } } readBar := ioBar(io.ReadRate) @@ -398,8 +400,19 @@ 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 ioBar(rate float64) string { diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index d49f72b..79f6796 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -749,29 +749,52 @@ func TestMiniBar(t *testing.T) { func TestFormatDiskLine(t *testing.T) { tests := []struct { - name string - label string - disk DiskStatus + name string + label string + disk DiskStatus + wantUsed string + wantFree string + wantNoSubstr string }{ { - name: "empty label defaults to DISK", - label: "", - disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + name: "empty label defaults to DISK", + label: "", + disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + wantUsed: "100G used", + wantFree: "100G free", + wantNoSubstr: "%", }, { - name: "internal disk", - label: "INTR", - disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + name: "internal disk", + label: "INTR", + disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + wantUsed: "336G used", + wantFree: "164G free", + wantNoSubstr: "%", }, { - name: "external disk", - label: "EXTR1", - disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + name: "external disk", + label: "EXTR1", + disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + wantUsed: "850G used", + wantFree: "150G free", + wantNoSubstr: "%", }, { - name: "low usage", - label: "INTR", - disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + name: "low usage", + label: "INTR", + disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + wantUsed: "15G used", + wantFree: "85G free", + wantNoSubstr: "%", + }, + { + name: "used exceeds total clamps free to zero", + label: "INTR", + disk: DiskStatus{UsedPercent: 110.0, Used: 110 << 30, Total: 100 << 30}, + wantUsed: "110G used", + wantFree: "0 free", + wantNoSubstr: "%", }, } @@ -789,10 +812,54 @@ func TestFormatDiskLine(t *testing.T) { if !contains(got, expectedLabel) { t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel) } + if !contains(got, tt.wantUsed) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain used value %q", tt.label, got, tt.wantUsed) + } + if !contains(got, tt.wantFree) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain free value %q", tt.label, got, tt.wantFree) + } + if tt.wantNoSubstr != "" && 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}) + + 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{}) + + 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 TestGetScoreStyle(t *testing.T) { tests := []struct { name string From a34cdee809ea2f8493d56d1d72e7852c568177f7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 9 Mar 2026 16:24:43 +0000 Subject: [PATCH 17/26] chore: auto format code --- cmd/status/metrics_disk.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index 92625ec..8c8202b 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -23,20 +23,20 @@ var skipDiskMounts = map[string]bool{ } 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, + "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, } func collectDisks() ([]DiskStatus, error) { From af84d6f4be23f4518d4be7e6d8b7d8155d7a1abd Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 10 Mar 2026 15:27:24 +0800 Subject: [PATCH 18/26] docs: strengthen public security signals --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 2 + .github/ISSUE_TEMPLATE/config.yml | 3 + .github/dependabot.yml | 10 + .github/pull_request_template.md | 18 ++ .github/workflows/codeql.yml | 52 +++++ .github/workflows/release.yml | 30 ++- .github/workflows/test.yml | 10 + README.md | 20 +- SECURITY.md | 76 +++++++ SECURITY_AUDIT.md | 302 +++++++++++++++------------ lib/core/file_ops.sh | 5 +- tests/core_safe_functions.bats | 28 +++ 13 files changed, 417 insertions(+), 140 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/codeql.yml create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..74d9b7c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @tw93 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6e0779c..ca2ca4d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.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/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8d9ce89..ad78d2f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ 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 about: Join our Telegram group for questions and discussions diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 603f653..5109cab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,8 +4,18 @@ updates: directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + reviewers: + - "tw93" + open-pull-requests-limit: 10 - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + reviewers: + - "tw93" + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b383243 --- /dev/null +++ b/.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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b05614e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,52 @@ +name: CodeQL + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: manual + - language: actions + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + + - name: Set up Go + if: matrix.language == 'go' + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + with: + go-version: "1.24.6" + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + 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@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc22b27..3ed8a38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - 'V*' permissions: - contents: write + contents: read jobs: build: @@ -58,6 +58,10 @@ jobs: name: Publish Release needs: build runs-on: ubuntu-latest + permissions: + contents: write + attestations: write + id-token: write steps: - name: Download all artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 @@ -69,16 +73,32 @@ 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@v3 + 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 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4151314..dbbbe44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,6 +52,9 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - name: Install tools + run: brew install bats-core + - name: Check for unsafe rm usage run: | echo "Checking for unsafe rm patterns..." @@ -86,3 +89,10 @@ jobs: exit 1 fi echo "✓ No secrets found" + + - name: Run high-risk path regression tests + env: + 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/README.md b/README.md index 2c7ad76..0530d61 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,28 @@ mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only ``` +## Security & Safety Design + +Mole is a local system maintenance tool. Commands such as `clean`, `uninstall`, `purge`, `installer`, `remove`, and parts of `optimize` can perform destructive local operations. + +Mole is designed with safety-first defaults for local system maintenance. + +- Destructive operations are guarded by path validation, protected directory rules, conservative cleanup boundaries, and explicit confirmation where appropriate. +- Mole prioritizes bounded cleanup over aggressive cleanup. +- High-risk paths, sensitive data categories, system locations, and sudo flows have explicit protection boundaries. +- When uncertainty exists, the tool should refuse, skip, or require stronger confirmation instead of widening deletion scope. +- `mo analyze` is intentionally safer than cleanup flows for ad hoc deletion because it moves files to Trash through Finder instead of directly deleting them. +- Release assets are published with SHA-256 checksums, curated safety notes, and GitHub artifact attestations. + +Review these documents before using high-risk commands: + +- [SECURITY.md](SECURITY.md) +- [SECURITY_AUDIT.md](SECURITY_AUDIT.md) + ## 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 `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md). - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. ## Features in Detail diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7a38830 --- /dev/null +++ b/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/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 0a6e471..8fed2c4 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,18 +1,59 @@ -# 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. 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()` + +Blocked paths remain protected even with sudo. Examples include: ```text / @@ -26,7 +67,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,23 +78,84 @@ 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 +## Protected Directories and Categories -### Orphan Detection +Mole has explicit protected-path and protected-category logic in addition to root-path blocking. -Orphaned app data is handled in `lib/clean/apps.sh`. +Protected or conservatively handled categories include: -- 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. +- 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, and Tailscale +- AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama +- `~/Library/Messages/Attachments` +- browser history and cookies +- Time Machine data while backup state is active or ambiguous +- `com.apple.*` LaunchAgents and LaunchDaemons +- iCloud-synced `Mobile Documents` data -Installed-app detection is broader than a simple `/Applications` scan and includes: +Project purge also uses conservative heuristics: + +- 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 + +Developer cleanup also preserves high-value state. Examples intentionally left alone include: + +- `~/.cargo/bin` +- `~/.rustup` +- `~/.mix/archives` +- `~/.stack/programs` + +## Symlink and Path Traversal Handling + +Symlink behavior is intentionally conservative. + +- path validation checks symlink targets before deletion +- symlinks pointing at protected system targets are rejected +- `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 + +Path traversal handling is also explicit: + +- 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 + +## Privilege Escalation and Sudo Boundaries + +Mole uses sudo for a subset of system-maintenance paths, but elevated behavior is still bounded by validation and protected-path rules. + +Key properties: + +- 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 +- authentication, SIP/MDM, and read-only filesystem failures are classified separately in file-operation results + +When sudo is denied or unavailable, Mole prefers skipping privileged cleanup to forcing execution through unsafe fallback behavior. + +## Sensitive Data Exclusions + +Mole is not intended to aggressively delete high-value user data. + +Examples of conservative handling include: + +- 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 + +Installed-app detection is broader than a single `/Applications` scan and includes: - `/Applications` - `/System/Applications` @@ -61,140 +163,74 @@ Installed-app detection is broader than a simple `/Applications` scan and includ - Homebrew Caskroom locations - Setapp application paths -Spotlight fallback checks are bounded with short timeouts to avoid hangs. +This reduces the risk of incorrectly classifying active software as orphaned data. -### Uninstall Matching +## Dry-Run, Confirmation, and Audit Logging -App uninstall behavior is implemented in `lib/uninstall/batch.sh` and related helpers. +Mole exposes multiple safety controls before and during destructive actions: -- 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. +- `--dry-run` previews are available for major destructive commands +- interactive high-risk flows require explicit confirmation before deletion +- purge marks recent projects conservatively and leaves them unselected by default +- analyzer delete uses Finder Trash rather than direct permanent removal +- operation logs are written to `~/.config/mole/operations.log` unless disabled with `MO_NO_OPLOG=1` +- timeouts bound external commands so stalled discovery or uninstall operations do not silently hang the entire flow -### Developer and Project Cleanup +Relevant timeout behavior includes: -Project artifact cleanup in `lib/clean/project.sh` protects recently modified targets: +- orphan and Spotlight checks: 2s +- LaunchServices rebuild during uninstall: bounded 10s and 15s steps +- Homebrew uninstall cask flow: 300s by default, extended for large apps when needed +- project scans and sizing operations: bounded to avoid whole-home stalls -- 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 +## Release Integrity and Continuous Security Signals -Developer-cache cleanup preserves toolchains and other high-value state. Examples intentionally left alone include: +Mole treats release trust as part of its security posture, not just a packaging detail. -- `~/.cargo/bin` -- `~/.rustup` -- `~/.mix/archives` -- `~/.stack/programs` +Repository-level signals include: -## Protected Categories +- weekly Dependabot updates for Go modules and GitHub Actions +- 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 +- CodeQL scanning for Go and GitHub Actions workflows +- curated changelog-driven release notes with a dedicated `Safety-related changes` section +- published SHA-256 checksums for release assets +- GitHub artifact attestations for release assets -Protected or conservatively handled categories include: +These controls do not eliminate all supply-chain risk, but they make release changes easier to review and verify. -- 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 -- AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama -- `~/Library/Messages/Attachments` -- browser history and cookies -- Time Machine data while backup state is active or ambiguous -- `com.apple.*` LaunchAgents and LaunchDaemons +## Testing Coverage -## Analyzer - -`mo analyze` is intentionally lower-risk than cleanup flows: - -- 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 - -Code lives under `cmd/analyze/*.go`. - -## Timeouts and Hang Resistance - -`lib/core/timeout.sh` uses this fallback order: - -1. `gtimeout` / `timeout` -2. a Perl helper with process-group cleanup -3. a shell fallback - -Current notable timeouts in security-relevant paths: - -- 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 - -Additional safety behavior: - -- `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 - -## User Configuration - -Protected paths can be added to `~/.config/mole/whitelist`, one path per line. - -Example: - -```bash -/Users/me/important-cache -~/Library/Application Support/MyApp -``` - -Exact path protection is preferred over pattern-style broad deletion rules. - -Use `--dry-run` before destructive operations when validating new cleanup behavior. - -## Testing - -There is no dedicated `tests/security.bats`. Security-relevant behavior is covered by targeted BATS suites, including: +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/purge.bats` -- `tests/core_safe_functions.bats` +- `tests/installer.bats` - `tests/optimize.bats` -Local verification used for the current branch includes: +Key coverage areas include: -```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 -``` +- 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 +- confirmation flows exist for high-risk interactive operations -CI additionally runs shell and Go validation on push. +## Known Limitations and Future Work -## Dependencies - -Primary Go dependencies are pinned in `go.mod`, including: - -- `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` - -System tooling relies mainly on Apple-provided binaries and standard macOS utilities such as: - -- `tmutil` -- `diskutil` -- `plutil` -- `launchctl` -- `osascript` -- `find` -- `stat` - -Dependency vulnerability status should be checked separately from this document. - -## Limitations - -- 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. +- Cleanup is destructive. Most cleanup and uninstall flows do not provide undo. +- `mo analyze` delete is safer because it uses Trash, but other cleanup flows are permanent once confirmed. +- 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 signing and provenance signals are improving, but downstream package-manager trust also depends on external distribution infrastructure. +- 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/lib/core/file_ops.sh b/lib/core/file_ops.sh index 5c41618..82d70c2 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -92,7 +92,10 @@ 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/*) + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | \ + /usr | /usr/bin | /usr/bin/* | /usr/lib | /usr/lib/* | \ + /etc | /etc/* | /private/etc | /private/etc/* | \ + /Library/Extensions | /Library/Extensions/*) log_error "Symlink points to protected system path: $path -> $resolved_target" return 1 ;; diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats index 5805f04..a2ea495 100644 --- a/tests/core_safe_functions.bats +++ b/tests/core_safe_functions.bats @@ -66,6 +66,9 @@ teardown() { } @test "validate_path_for_deletion rejects system directories" { + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/'" + [ "$status" -eq 1 ] + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/System'" [ "$status" -eq 1 ] @@ -86,6 +89,15 @@ teardown() { [ "$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 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 successfully removes file" { local test_file="$TEST_DIR/test_file.txt" echo "test" > "$test_file" @@ -134,6 +146,22 @@ teardown() { [ "$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 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_find_delete rejects symlinked directory" { local real_dir="$TEST_DIR/real" local link_dir="$TEST_DIR/link" From 0876e74e86f58798c70a5f54aa3973dc6b214129 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:32:22 +0800 Subject: [PATCH 19/26] chore(deps): bump actions/attest-build-provenance from 3 to 4 (#557) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3 to 4. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ed8a38..1c3528f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,7 @@ jobs: cat SHA256SUMS - name: Generate artifact attestation - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@v4 with: subject-path: | bin/analyze-darwin-* From be1c36c20eb388d2e9306e46362e46d43eeca0d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:32:37 +0800 Subject: [PATCH 20/26] chore(deps): bump golang.org/x/sync from 0.19.0 to 0.20.0 (#555) Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.19.0 to 0.20.0. - [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0) --- updated-dependencies: - dependency-name: golang.org/x/sync dependency-version: 0.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 ++---- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 153fbad..7720324 100644 --- a/go.mod +++ b/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 + golang.org/x/sync v0.20.0 ) require ( diff --git a/go.sum b/go.sum index 66dea7c..4b90763 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,8 @@ 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= From 2f627ac3df44666ead7ef7980870a77681fb65d2 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 10 Mar 2026 15:35:28 +0800 Subject: [PATCH 21/26] docs: refine safety design copy --- README.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0530d61..c257f74 100644 --- a/README.md +++ b/README.md @@ -76,21 +76,13 @@ mo analyze /Volumes # Analyze external drives only ## Security & Safety Design -Mole is a local system maintenance tool. Commands such as `clean`, `uninstall`, `purge`, `installer`, `remove`, and parts of `optimize` can perform destructive local operations. +Mole is a local system maintenance tool, and some commands can perform destructive local operations. -Mole is designed with safety-first defaults for local system maintenance. +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. -- Destructive operations are guarded by path validation, protected directory rules, conservative cleanup boundaries, and explicit confirmation where appropriate. -- Mole prioritizes bounded cleanup over aggressive cleanup. -- High-risk paths, sensitive data categories, system locations, and sudo flows have explicit protection boundaries. -- When uncertainty exists, the tool should refuse, skip, or require stronger confirmation instead of widening deletion scope. -- `mo analyze` is intentionally safer than cleanup flows for ad hoc deletion because it moves files to Trash through Finder instead of directly deleting them. -- Release assets are published with SHA-256 checksums, curated safety notes, and GitHub artifact attestations. +`mo analyze` is safer for ad hoc cleanup because it moves files to Trash through Finder instead of deleting them directly. -Review these documents before using high-risk commands: - -- [SECURITY.md](SECURITY.md) -- [SECURITY_AUDIT.md](SECURITY_AUDIT.md) +Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for reporting guidance, safety boundaries, and current limitations. ## Tips From 65b0db4e1c6d0a4d1e90f4a39ece076e92341baa Mon Sep 17 00:00:00 2001 From: Nour <104147021+MohammedTarigg@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:20:40 +0300 Subject: [PATCH 22/26] feat(clean): add opt-in Docker unused data pruning (#554) * feat(clean): add opt-in Docker unused data pruning * fix(clean): make docker prune default --------- Co-authored-by: Tw93 --- lib/clean/dev.sh | 9 +++++++-- tests/clean_dev_caches.bats | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index fb8eefd..97c0905 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -198,13 +198,18 @@ clean_dev_docker() { fi stop_section_spinner if [[ "$docker_running" == "true" ]]; then - clean_tool_cache "Docker build cache" docker builder prune -af + # Remove unused images, stopped containers, unused networks, and + # anonymous volumes in one pass. This maps better to the large + # reclaimable "docker system df" buckets users typically see. + clean_tool_cache "Docker unused data" docker system prune -af --volumes else + echo -e " ${GRAY}${ICON_WARNING}${NC} Docker unused data · skipped (daemon not running)" + note_activity 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" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker unused data · would clean" fi fi safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" diff --git a/tests/clean_dev_caches.bats b/tests/clean_dev_caches.bats index 9a2137e..dfa12b0 100644 --- a/tests/clean_dev_caches.bats +++ b/tests/clean_dev_caches.bats @@ -160,24 +160,50 @@ EOF } @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' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" start_section_spinner() { :; } stop_section_spinner() { :; } run_with_timeout() { return 1; } -clean_tool_cache() { echo "$1"; } safe_clean() { echo "$2"; } -debug_log() { echo "$*"; } +debug_log() { :; } docker() { return 1; } export -f docker clean_dev_docker EOF [ "$status" -eq 0 ] - [[ "$output" == *"Docker daemon not running"* ]] - [[ "$output" != *"Docker build cache"* ]] + [[ "$output" == *"Docker unused data · skipped (daemon not running)"* ]] + [[ "$output" == *"Docker BuildX cache"* ]] + [[ "$output" != *"Docker unused data|Docker unused data docker system prune -af --volumes"* ]] +} + +@test "clean_dev_docker prunes unused docker data when daemon is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false 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() { shift; "$@"; } +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { :; } +docker() { + if [[ "$1" == "info" ]]; then + return 0 + fi + return 0 +} +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker unused data|Docker unused data docker system prune -af --volumes"* ]] } @test "clean_developer_tools runs key stages" { From 5fd61860579a0e15cc1f879eb7d6289be589fa06 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 10 Mar 2026 16:22:50 +0800 Subject: [PATCH 23/26] ci: align workflow Go versions with go.mod --- .github/workflows/check.yml | 4 ++-- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6f7b0e0..34bcf1a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -38,7 +38,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: - go-version: '1.24.6' + go-version-file: go.mod - name: Install goimports run: go install golang.org/x/tools/cmd/goimports@latest @@ -91,7 +91,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: - go-version: '1.24.6' + go-version-file: go.mod - name: Run check script run: ./scripts/check.sh --no-format diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b05614e..88bb275 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,7 +33,7 @@ jobs: if: matrix.language == 'go' uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c3528f..16219bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Build Binaries run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dbbbe44..07eb9f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Run test script env: From f2525709d3d98c07634933785f521e35a301d4dc Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 10 Mar 2026 15:54:13 +0800 Subject: [PATCH 24/26] docs: tidy quick start formatting --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c257f74..09fd6c0 100644 --- a/README.md +++ b/README.md @@ -26,22 +26,22 @@ ## Quick Start -**Install via Homebrew:** +**Install via Homebrew** ```bash brew install mole ``` -**Or via script:** +**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 @@ -60,13 +60,16 @@ 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 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 @@ -160,7 +163,7 @@ Use `mo optimize --whitelist` to exclude specific optimizations. ### 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. ```bash $ mo analyze @@ -226,10 +229,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 From 20a396b33e8db6676e5f813022266e4a28f3dd76 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 11 Mar 2026 11:25:03 +0800 Subject: [PATCH 25/26] chore: add journal/ to gitignore, merge path docs into SECURITY_AUDIT --- .gitignore | 1 + SECURITY_AUDIT.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/.gitignore b/.gitignore index 451942f..b83a872 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ GEMINI.md ANTIGRAVITY.md WARP.md AGENTS.md +journal/ .cursorrules # Go build artifacts (development) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 8fed2c4..7606abc 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -80,6 +80,57 @@ Some subpaths under otherwise protected roots are explicitly allowlisted for bou This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns. +## Path Protection Reference + +### Protected Prefixes (Never Deleted) + +```text +/ +/System +/bin +/sbin +/usr +/etc +/var +/private +/Library/Extensions +``` + +### Whitelist Exceptions (Allowlisted for Cleanup) + +Some subpaths under protected roots are explicitly allowlisted: + +- `/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` + +### Protected Categories + +In addition to path blocking, these categories are protected: + +- Keychains, password managers, credentials +- VPN/proxy tools (Shadowsocks, V2Ray, Clash, Tailscale) +- AI tools (Cursor, Claude, ChatGPT, Ollama) +- Browser history and cookies +- Time Machine data (during active backup) +- `com.apple.*` LaunchAgents/LaunchDaemons +- iCloud-synced `Mobile Documents` + +## Implementation Details + +All deletion routes through `lib/core/file_ops.sh`: + +- `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 + +See [`journal/2026-03-11-safe-remove-design.md`](journal/2026-03-11-safe-remove-design.md) for design rationale. + ## Protected Directories and Categories Mole has explicit protected-path and protected-category logic in addition to root-path blocking. From e642817b1f658fadc28de04fc4870bae6aec58ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:25:50 +0000 Subject: [PATCH 26/26] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 326 +++++++++++++++++++++++------------------------ 1 file changed, 163 insertions(+), 163 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 5db214e..ab4378d 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -90,17 +90,6 @@ - - - - - - - - Angelk90 - - - @@ -111,84 +100,18 @@ Sizk + + + + + + + + + Angelk90 + + - - - - - - - - rubnogueira - - - - - - - - - - - biplavbarua - - - - - - - - - - - bsisduck - - - - - - - - - - - spider-yamet - - - - - - - - - - - jimmystridh - - - - - - - - - - - fte-jjmartres - - - - - - - - - - - Else00 - - - @@ -199,18 +122,51 @@ carolyn-sun - + - + - - - ndbroadbent + + + Else00 - + + + + + + + + + fte-jjmartres + + + + + + + + + + + jimmystridh + + + + + + + + + + + spider-yamet + + + @@ -221,73 +177,40 @@ MohammedTarigg - + - + - - - onurtashan + + + bsisduck - + - + - - - ppauel + + + biplavbarua - + - + - - - shakeelmohamed + + + rubnogueira - - - - - - - - - Harsh-Kapoorr - - - - - - - - - - - thijsvanhal - - - - - - - - - - - TomP0 - - - + @@ -298,7 +221,95 @@ yuzeguitarist + + + + + + + + + TomP0 + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Harsh-Kapoorr + + + + + + + + + + + shakeelmohamed + + + + + + + + + + + ppauel + + + + + + + + + + + onurtashan + + + + + + + + + + + ndbroadbent + + + + + + + + + + andmev + + + @@ -309,7 +320,7 @@ bikraj2 - + @@ -320,7 +331,7 @@ bunizao - + @@ -331,7 +342,7 @@ rans0 - + @@ -342,7 +353,7 @@ frozturk - + @@ -353,7 +364,7 @@ huyixi - + @@ -364,7 +375,7 @@ purofle - + @@ -375,7 +386,7 @@ yamamel - + @@ -386,7 +397,7 @@ NanmiCoder - + @@ -397,17 +408,6 @@ KoukeNeko - - - - - - - - - andmev - -