diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index da1854d..aa38925 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,20 +27,25 @@ jobs: ~/Library/Caches/Homebrew /usr/local/Cellar/shfmt /usr/local/Cellar/shellcheck - key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }} + /usr/local/Cellar/golangci-lint + key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }} restore-keys: | - ${{ runner.os }}-brew-quality- + ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck + run: brew install shfmt shellcheck golangci-lint - name: Set up Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 with: go-version: '1.24.6' + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports@latest + - name: Format all code run: | + export PATH=$(go env GOPATH)/bin:$PATH ./scripts/check.sh --format - name: Commit formatting changes @@ -75,12 +80,18 @@ jobs: ~/Library/Caches/Homebrew /usr/local/Cellar/shfmt /usr/local/Cellar/shellcheck - key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }} + /usr/local/Cellar/golangci-lint + key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }} restore-keys: | - ${{ runner.os }}-brew-quality- + ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck + run: brew install shfmt shellcheck golangci-lint + + - name: Set up Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + with: + go-version: '1.24.6' - name: Run check script run: ./scripts/check.sh --no-format diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml new file mode 100644 index 0000000..7934087 --- /dev/null +++ b/.github/workflows/update-contributors.yml @@ -0,0 +1,62 @@ +name: Update Contributors + +on: + push: + branches: [main, dev] + workflow_dispatch: + schedule: + - cron: "0 0 * * 0" # Every Sunday at midnight UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-contributors: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Generate contributors SVG + uses: tw93/contributors-list@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + svgPath: CONTRIBUTORS.svg + svgWidth: 1000 + avatarSize: 72 + avatarMargin: 45 + userNameHeight: 20 + noFetch: false + noCommit: true + truncate: 0 + includeBots: false + excludeUsers: "github-actions web-flow dependabot claude" + itemTemplate: | + + + + + + + + + {{{ name }}} + + + + - name: Commit & Push + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "chore: update contributors [skip ci]" + file_pattern: CONTRIBUTORS.svg + commit_user_name: github-actions[bot] + commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com + commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + push_options: '--force' diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..299e526 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,43 @@ +# golangci-lint configuration for Mole +# https://golangci-lint.run/usage/configuration/ + +version: "2" + +run: + timeout: 5m + # Only lint Go code in cmd directory + modules-download-mode: readonly + +linters: + enable: + # Default linters + - govet + - staticcheck + - errcheck + - ineffassign + - unused + + settings: + govet: + enable-all: true + disable: + - shadow + - fieldalignment # struct field alignment optimization is noisy + errcheck: + exclude-functions: + - (io.Closer).Close + - (*os/exec.Cmd).Run + - (*os/exec.Cmd).Start + staticcheck: + checks: ["all", "-QF1003", "-SA9003"] + + exclusions: + rules: + # Ignore certain patterns in test files + - path: _test\.go + linters: + - errcheck + # Ignore errors from os.Remove in cleanup code + - text: "os.Remove" + linters: + - errcheck diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c2654a..007ecd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,10 @@ ```bash # Install development tools -brew install shfmt shellcheck bats-core +brew install shfmt shellcheck bats-core golangci-lint + +# Install goimports for better Go formatting +go install golang.org/x/tools/cmd/goimports@latest ``` ## Development diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg new file mode 100644 index 0000000..3ab37f2 --- /dev/null +++ b/CONTRIBUTORS.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + tw93 + + + + + + + + + + + JackPhallen + + + + + + + + + + + amanthanvi + + + + + + + + + + + alexandear + + + + + + + + + + + rubnogueira + + + + + + + + + + + bsisduck + + + + + + + + + + + jimmystridh + + + + + + + + + + + fte-jjmartres + + + + + + + + + + + Else00 + + + + + + + + + + + carolyn-sun + + + + + + + + + + + purofle + + + + + + + + + + + huyixi + + + + + + + + + + + bunizao + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + ndbroadbent + + + + + + + + + + + MohammedEsafi + + + + + + + + + + + Schlauer-Hax + + + + + + + + + + + anonymort + + + + + + + + + + + khipu-luke + + + + + + + + + + + LmanTW + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + jalen0x + + + + + + + + + + + Hensell + + + + + + + + + + + ClathW + + + + + + + + + + + andmev + + + \ No newline at end of file diff --git a/README.md b/README.md index 682166f..02fd52c 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@

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

## Features -- **Unified toolkit**: Consolidated features of CleanMyMac, AppCleaner, DaisyDisk, and iStat into a **single binary** +- **All-in-one toolkit**: CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus combined into a **single binary** - **Deep cleaning**: Scans and removes caches, logs, and browser leftovers to **reclaim gigabytes of space** - **Smart uninstaller**: Thoroughly removes apps along with launch agents, preferences, and **hidden remnants** - **Disk insights**: Visualizes usage, manages large files, **rebuilds caches**, and refreshes system services @@ -44,16 +44,16 @@ Built with PowerShell and Go for native Windows performance. Run `mole` after in ## Quick Start -**Install by Brew, recommended:** +**Install via Homebrew — recommended:** ```bash brew install mole ``` -**or by Script, for older macOS or latest code:** +**Or via script:** ```bash -# Add '-s latest' for newest, '-s dev' for development, or '-s 1.17.0' for a version. +# 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 ``` @@ -70,7 +70,7 @@ mo purge # Clean project build artifacts mo installer # Find and remove installer files mo touchid # Configure Touch ID for sudo -mo completion # Setup shell tab completion +mo completion # Set up shell tab completion mo update # Update Mole mo remove # Remove Mole from system mo --help # Show help @@ -92,6 +92,7 @@ mo purge --paths # Configure project scan directories - **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. - **Debug Mode**: Use `--debug` for detailed logs (e.g., `mo clean --debug`). Combine with `--dry-run` for comprehensive preview including risk levels and file details. - **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`). +- **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit. - **Configuration**: Run `mo touchid` for Touch ID sudo, `mo completion` for shell tab completion, `mo clean --whitelist` to manage protected paths. ## Features in Detail @@ -157,7 +158,7 @@ System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d System optimization completed ==================================================================== -Use `mo optimize --whitelist` to protect specific optimization items from being run. +Use `mo optimize --whitelist` to exclude specific optimizations. ``` ### Disk Space Analyzer @@ -203,7 +204,7 @@ Up ▮▯▯▯▯ 0.8 MB/s Chrome ▮▮▮▯▯ 2 Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5% ``` -Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range. Press `k` to hide/show cat, `q` to quit. +Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range. ### Project Artifact Purge @@ -224,7 +225,7 @@ Select Categories to Clean - 18.5GB (8 selected) ● backend-service 2.5GB | node_modules ``` -> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects (< 7 days) are marked and unselected by default. +> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects — less than 7 days old — are marked and unselected by default.
Custom Scan Paths @@ -237,7 +238,7 @@ Run `mo purge --paths` to configure which directories to scan, or edit `~/.confi ~/Work/ClientB ``` -When custom paths are configured, only those directories are scanned. Otherwise, defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc. +When custom paths are configured, only those directories are scanned. Otherwise, it defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc.
@@ -266,24 +267,34 @@ Launch Mole commands instantly from Raycast or Alfred: curl -fsSL https://raw.githubusercontent.com/tw93/Mole/main/scripts/setup-quick-launchers.sh | bash ``` -Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. Mole automatically detects your terminal, or you can set `MO_LAUNCHER_APP=` to override. For Raycast, if this is your first script directory, add it in Raycast Extensions (Add Script Directory) and then run "Reload Script Directories" to load the new commands. +Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. + +Mole automatically detects your terminal, or set `MO_LAUNCHER_APP=` to override. For Raycast users: if this is your first script directory, add it via Raycast Extensions → Add Script Directory, then run "Reload Script Directories". ## Community Love -

- Community feedback on Mole -

+Mole wouldn't be possible without these amazing contributors. They've built countless features that make Mole what it is today. Go follow them! ❤️ -Users from around the world are loving Mole! Join the community and share your experience. + + + + +Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized. + +Community feedback on Mole ## Support - - -- If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac. +- If Mole saved you disk space, consider starring the repo or [sharing it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. - Have ideas or fixes? Check our [Contributing Guide](CONTRIBUTING.md), then open an issue or PR to help shape Mole's future. -- Love cats? Treat Tangyuan and Cola to canned food via this link to keep our mascots purring. +- Love Mole? Buy Tw93 an ice-cold Coke to keep the project alive and kicking! 🥤 + +
+Friends who bought me Coke +
+ +
## License -MIT License - feel free to enjoy and participate in open source. +MIT License — feel free to enjoy and participate in open source. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 30bbc60..2fc4a61 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -4,7 +4,7 @@ **Security Audit & Compliance Report** -Version 1.17.0 | December 31, 2025 +Version 1.19.0 | January 5, 2026 --- @@ -33,7 +33,7 @@ Version 1.17.0 | December 31, 2025 |-----------|---------| | Audit Date | December 31, 2025 | | Audit Conclusion | **PASSED** | -| Mole Version | V1.17.0 | +| Mole Version | V1.19.0 | | Audited Branch | `main` (HEAD) | | Scope | Shell scripts, Go binaries, Configuration | | Methodology | Static analysis, Threat modeling, Code review | @@ -47,6 +47,7 @@ Version 1.17.0 | December 31, 2025 - Comprehensive protection for VPN, AI tools, and system components - Atomic operations with crash recovery mechanisms - Full user control with dry-run and whitelist capabilities +- Installer cleanup safely scans common locations with user confirmation --- @@ -290,7 +291,7 @@ bats tests/security.bats # Run specific suite | Standard | Implementation | |----------|----------------| | OWASP Secure Coding | Input validation, least privilege, defense-in-depth | -| CWE-22 (Path Traversal) | Absolute path enforcement, `../` rejection | +| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components while allowing `..` in directory names (Firefox compatibility) | | CWE-78 (Command Injection) | Control character filtering | | CWE-59 (Link Following) | Symlink detection before privileged operations | | Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC | diff --git a/bin/clean.sh b/bin/clean.sh index 1586f4d..7beac0d 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -308,9 +308,8 @@ safe_clean() { return 0 fi - if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then - stop_section_spinner - fi + # Always stop spinner before outputting results + stop_section_spinner local description local -a targets diff --git a/bin/installer.sh b/bin/installer.sh index 73a8bd8..8eed412 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -205,12 +205,16 @@ collect_installers() { start_inline_spinner "Scanning for installers..." fi + # Start debug session + debug_operation_start "Collect Installers" "Scanning for redundant installer files" + # Scan all paths, deduplicate, and sort results local -a all_files=() while IFS= read -r file; do [[ -z "$file" ]] && continue all_files+=("$file") + debug_file_action "Found installer" "$file" done < <(scan_all_installers | sort -u) if [[ -t 1 ]]; then @@ -245,9 +249,20 @@ collect_installers() { local size_human size_human=$(bytes_to_human "$file_size") + # Get display filename - strip Homebrew hash prefix if present + local display_name + display_name=$(basename "$file") + if [[ "$source" == "Homebrew" ]]; then + # Homebrew names often look like: sha256--name--version + # Strip the leading hash if it matches [0-9a-f]{64}-- + if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then + display_name="${BASH_REMATCH[1]}" + fi + fi + # Format display with alignment local display - display=$(format_installer_display "$(basename "$file")" "$size_human" "$source") + display=$(format_installer_display "$display_name" "$size_human" "$source") # Store installer data in parallel arrays INSTALLER_PATHS+=("$file") diff --git a/bin/touchid.sh b/bin/touchid.sh index c1a626a..1f45914 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -14,10 +14,17 @@ LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)" source "$LIB_DIR/core/common.sh" readonly PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}" +readonly PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-/etc/pam.d/sudo_local}" readonly PAM_TID_LINE="auth sufficient pam_tid.so" # Check if Touch ID is already configured is_touchid_configured() { + # Check sudo_local first + if [[ -f "$PAM_SUDO_LOCAL_FILE" ]]; then + grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null && return 0 + fi + + # Fallback to standard sudo file if [[ ! -f "$PAM_SUDO_FILE" ]]; then return 1 fi @@ -74,7 +81,74 @@ enable_touchid() { echo "" fi - # Check if already configured + # Check if we should use sudo_local (Sonoma+) + if grep -q "sudo_local" "$PAM_SUDO_FILE"; then + # Check if already correctly configured in sudo_local + if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then + # It is in sudo_local, but let's check if it's ALSO in sudo (incomplete migration) + if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then + # Clean up legacy config + temp_file=$(mktemp) + grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" + if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}" + fi + fi + echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}" + return 0 + fi + + # Not configured in sudo_local yet. + # Check if configured in sudo (Legacy) + local is_legacy_configured=false + if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then + is_legacy_configured=true + fi + + # Function to write to sudo_local + local write_success=false + if [[ ! -f "$PAM_SUDO_LOCAL_FILE" ]]; then + # Create the file + echo "# sudo_local: local customizations for sudo" | sudo tee "$PAM_SUDO_LOCAL_FILE" > /dev/null + echo "$PAM_TID_LINE" | sudo tee -a "$PAM_SUDO_LOCAL_FILE" > /dev/null + sudo chmod 444 "$PAM_SUDO_LOCAL_FILE" + sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE" + write_success=true + else + # Append if not present + if ! grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then + temp_file=$(mktemp) + cp "$PAM_SUDO_LOCAL_FILE" "$temp_file" + echo "$PAM_TID_LINE" >> "$temp_file" + sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" + sudo chmod 444 "$PAM_SUDO_LOCAL_FILE" + sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE" + write_success=true + else + write_success=true # Already there (should be caught by first check, but safe fallback) + fi + fi + + if $write_success; then + # If we migrated from legacy, clean it up now + if $is_legacy_configured; then + temp_file=$(mktemp) + grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" + sudo mv "$temp_file" "$PAM_SUDO_FILE" + log_success "Touch ID migrated to sudo_local" + else + log_success "Touch ID enabled (via sudo_local) - try: sudo ls" + fi + return 0 + else + log_error "Failed to write to sudo_local" + return 1 + fi + fi + + # Legacy method: Modify sudo file directly + + # Check if already configured (Legacy) if is_touchid_configured; then echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}" return 0 @@ -129,26 +203,55 @@ disable_touchid() { return 0 fi - # Create backup only if it doesn't exist - if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then - if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then - log_error "Failed to create backup" + # Check sudo_local first + if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then + # Remove from sudo_local + temp_file=$(mktemp) + grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file" + + if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then + # Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup) + if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then + temp_file=$(mktemp) + grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" + sudo mv "$temp_file" "$PAM_SUDO_FILE" + fi + echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled (removed from sudo_local)${NC}" + echo "" + return 0 + else + log_error "Failed to disable Touch ID from sudo_local" return 1 fi fi - # Remove pam_tid.so line - temp_file=$(mktemp) - grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" + # Fallback to sudo file (legacy) + if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then + # Create backup only if it doesn't exist + if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then + if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then + log_error "Failed to create backup" + return 1 + fi + fi - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then - echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}" - echo "" - return 0 - else - log_error "Failed to disable Touch ID" - return 1 + # Remove pam_tid.so line + temp_file=$(mktemp) + grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" + + if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}" + echo "" + return 0 + else + log_error "Failed to disable Touch ID" + return 1 + fi fi + + # Should not reach here if is_touchid_configured was true + log_error "Could not find Touch ID configuration to disable" + return 1 } # Interactive menu diff --git a/bin/uninstall.sh b/bin/uninstall.sh index f16e017..389e222 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -59,6 +59,24 @@ scan_applications() { local temp_file temp_file=$(create_temp_file) + # Local spinner_pid for cleanup + local spinner_pid="" + + # Trap to handle Ctrl+C during scan + local scan_interrupted=false + # shellcheck disable=SC2329 # Function invoked indirectly via trap + trap_scan_cleanup() { + scan_interrupted=true + if [[ -n "$spinner_pid" ]]; then + kill -TERM "$spinner_pid" 2> /dev/null || true + wait "$spinner_pid" 2> /dev/null || true + fi + printf "\r\033[K" >&2 + rm -f "$temp_file" "${temp_file}.sorted" "${temp_file}.progress" 2> /dev/null || true + exit 130 + } + trap trap_scan_cleanup INT + local current_epoch current_epoch=$(get_epoch_seconds) @@ -228,7 +246,6 @@ scan_applications() { local progress_file="${temp_file}.progress" echo "0" > "$progress_file" - local spinner_pid="" ( # shellcheck disable=SC2329 # Function invoked indirectly via trap cleanup_spinner() { exit 0; } diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go index 8b70bf2..93fb391 100644 --- a/cmd/analyze/cache.go +++ b/cmd/analyze/cache.go @@ -30,6 +30,7 @@ func snapshotFromModel(m model) historyEntry { Entries: cloneDirEntries(m.entries), LargeFiles: cloneFileEntries(m.largeFiles), TotalSize: m.totalSize, + TotalFiles: m.totalFiles, Selected: m.selected, EntryOffset: m.offset, LargeSelected: m.largeSelected, @@ -49,7 +50,7 @@ func cloneDirEntries(entries []dirEntry) []dirEntry { return nil } copied := make([]dirEntry, len(entries)) - copy(copied, entries) + copy(copied, entries) //nolint:all return copied } @@ -58,7 +59,7 @@ func cloneFileEntries(files []fileEntry) []fileEntry { return nil } copied := make([]fileEntry, len(files)) - copy(copied, files) + copy(copied, files) //nolint:all return copied } @@ -208,7 +209,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) { if err != nil { return nil, err } - defer file.Close() + defer file.Close() //nolint:errcheck var entry cacheEntry decoder := gob.NewDecoder(file) @@ -250,6 +251,7 @@ func saveCacheToDisk(path string, result scanResult) error { Entries: result.Entries, LargeFiles: result.LargeFiles, TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, ModTime: info.ModTime(), ScanTime: time.Now(), } @@ -258,12 +260,35 @@ func saveCacheToDisk(path string, result scanResult) error { if err != nil { return err } - defer file.Close() + defer file.Close() //nolint:errcheck encoder := gob.NewEncoder(file) return encoder.Encode(entry) } +// peekCacheTotalFiles attempts to read the total file count from cache, +// ignoring expiration. Used for initial scan progress estimates. +func peekCacheTotalFiles(path string) (int64, error) { + cachePath, err := getCachePath(path) + if err != nil { + return 0, err + } + + file, err := os.Open(cachePath) + if err != nil { + return 0, err + } + defer file.Close() //nolint:errcheck + + var entry cacheEntry + decoder := gob.NewDecoder(file) + if err := decoder.Decode(&entry); err != nil { + return 0, err + } + + return entry.TotalFiles, nil +} + func invalidateCache(path string) { cachePath, err := getCachePath(path) if err == nil { diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index e97500e..bba4647 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -35,12 +35,14 @@ type scanResult struct { Entries []dirEntry LargeFiles []fileEntry TotalSize int64 + TotalFiles int64 } type cacheEntry struct { Entries []dirEntry LargeFiles []fileEntry TotalSize int64 + TotalFiles int64 ModTime time.Time ScanTime time.Time } @@ -50,6 +52,7 @@ type historyEntry struct { Entries []dirEntry LargeFiles []fileEntry TotalSize int64 + TotalFiles int64 Selected int EntryOffset int LargeSelected int @@ -114,6 +117,8 @@ type model struct { height int // Terminal height multiSelected map[string]bool // Track multi-selected items by path (safer than index) largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) + totalFiles int64 // Total files found in current/last scan + lastTotalFiles int64 // Total files from previous scan (for progress bar) } func (m model) inOverviewMode() bool { @@ -195,6 +200,13 @@ func newModel(path string, isOverview bool) model { } } + // Try to peek last total files for progress bar, even if cache is stale + if !isOverview { + if total, err := peekCacheTotalFiles(path); err == nil && total > 0 { + m.lastTotalFiles = total + } + } + return m } @@ -355,6 +367,7 @@ func (m model) scanCmd(path string) tea.Cmd { Entries: cached.Entries, LargeFiles: cached.LargeFiles, TotalSize: cached.TotalSize, + TotalFiles: 0, // Cache doesn't store file count currently, minor UI limitation } return scanResultMsg{result: result, err: nil} } @@ -441,6 +454,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.entries = filteredEntries m.largeFiles = msg.result.LargeFiles m.totalSize = msg.result.TotalSize + m.totalFiles = msg.result.TotalFiles m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) m.clampEntrySelection() m.clampLargeSelection() @@ -685,6 +699,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { invalidateCache(m.path) m.status = "Refreshing..." m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } atomic.StoreInt64(m.filesScanned, 0) atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.bytesScanned, 0) @@ -968,6 +985,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { m.entries = cloneDirEntries(cached.Entries) m.largeFiles = cloneFileEntries(cached.LargeFiles) m.totalSize = cached.TotalSize + m.totalFiles = cached.TotalFiles m.selected = cached.Selected m.offset = cached.EntryOffset m.largeSelected = cached.LargeSelected @@ -978,6 +996,10 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { m.scanning = false return m, nil } + m.lastTotalFiles = 0 + if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 { + m.lastTotalFiles = total + } return m, tea.Batch(m.scanCmd(m.path), tickCmd()) } m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size)) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index b6ab09b..0d7ddca 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -251,6 +251,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in Entries: entries, LargeFiles: largeFiles, TotalSize: total, + TotalFiles: atomic.LoadInt64(filesScanned), }, nil } diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index 6cdf400..67ae52b 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -75,10 +75,25 @@ func (m model) View() string { if m.scanning { filesScanned, dirsScanned, bytesScanned := m.getScanProgress() - fmt.Fprintf(&b, "%s%s%s%s Scanning: %s%s files%s, %s%s dirs%s, %s%s%s\n", + progressPrefix := "" + if m.lastTotalFiles > 0 { + percent := float64(filesScanned) / float64(m.lastTotalFiles) * 100 + // Cap at 100% generally + if percent > 100 { + percent = 100 + } + // While strictly scanning, cap at 99% to avoid "100% but still working" confusion + if m.scanning && percent >= 100 { + percent = 99 + } + progressPrefix = fmt.Sprintf(" %s(%.0f%%)%s", colorCyan, percent, colorReset) + } + + fmt.Fprintf(&b, "%s%s%s%s Scanning%s: %s%s files%s, %s%s dirs%s, %s%s%s\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, + progressPrefix, colorYellow, formatNumber(filesScanned), colorReset, colorYellow, formatNumber(dirsScanned), colorReset, colorGreen, humanizeBytes(bytesScanned), colorReset) diff --git a/cmd/status/main.go b/cmd/status/main.go index cc5ba0e..b8f7aeb 100644 --- a/cmd/status/main.go +++ b/cmd/status/main.go @@ -1,8 +1,11 @@ +// Package main provides the mo status command for real-time system monitoring. package main import ( "fmt" "os" + "path/filepath" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -37,10 +40,50 @@ type model struct { catHidden bool // true = hidden, false = visible } +// getConfigPath returns the path to the status preferences file. +func getConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "mole", "status_prefs") +} + +// loadCatHidden loads the cat hidden preference from config file. +func loadCatHidden() bool { + path := getConfigPath() + if path == "" { + return false + } + data, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "cat_hidden=true" +} + +// saveCatHidden saves the cat hidden preference to config file. +func saveCatHidden(hidden bool) { + path := getConfigPath() + if path == "" { + return + } + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + value := "cat_hidden=false" + if hidden { + value = "cat_hidden=true" + } + _ = os.WriteFile(path, []byte(value+"\n"), 0644) +} + func newModel() model { return model{ collector: NewCollector(), - catHidden: false, + catHidden: loadCatHidden(), } } @@ -55,8 +98,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q", "esc", "ctrl+c": return m, tea.Quit case "k": - // Toggle cat visibility + // Toggle cat visibility and persist preference m.catHidden = !m.catHidden + saveCatHidden(m.catHidden) return m, nil } case tea.WindowSizeMsg: diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index bb5df62..2b24bba 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -286,9 +286,8 @@ func commandExists(name string) bool { return false } defer func() { - if r := recover(); r != nil { - // Treat LookPath panics as "missing". - } + // Treat LookPath panics as "missing". + _ = recover() }() _, err := exec.LookPath(name) return err == nil diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go index f892bef..29c7690 100644 --- a/cmd/status/metrics_cpu.go +++ b/cmd/status/metrics_cpu.go @@ -32,7 +32,7 @@ func collectCPU() (CPUStatus, error) { } // Two-call pattern for more reliable CPU usage. - cpu.Percent(0, true) + warmUpCPU() time.Sleep(cpuSampleInterval) percents, err := cpu.Percent(0, true) var totalPercent float64 @@ -255,3 +255,7 @@ func fallbackCPUUtilization(logical int) (float64, []float64, error) { } return avg, perCore, nil } + +func warmUpCPU() { + cpu.Percent(0, true) //nolint:errcheck +} diff --git a/cmd/status/metrics_hardware.go b/cmd/status/metrics_hardware.go index 8117e57..54ff7ba 100644 --- a/cmd/status/metrics_hardware.go +++ b/cmd/status/metrics_hardware.go @@ -130,6 +130,8 @@ func parseInt(s string) int { return 0 } var num int - fmt.Sscanf(cleaned, "%d", &num) + if _, err := fmt.Sscanf(cleaned, "%d", &num); err != nil { + return 0 + } return num } diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go index 3a556f2..0e34828 100644 --- a/cmd/status/metrics_health.go +++ b/cmd/status/metrics_health.go @@ -70,10 +70,12 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d } // Memory pressure penalty. - if mem.Pressure == "warn" { + // Memory pressure penalty. + switch mem.Pressure { + case "warn": score -= memPressureWarnPenalty issues = append(issues, "Memory Pressure") - } else if mem.Pressure == "critical" { + case "critical": score -= memPressureCritPenalty issues = append(issues, "Critical Memory") } @@ -131,16 +133,17 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d } // Build message. - msg := "Excellent" - if score >= 90 { + var msg string + switch { + case score >= 90: msg = "Excellent" - } else if score >= 75 { + case score >= 75: msg = "Good" - } else if score >= 60 { + case score >= 60: msg = "Fair" - } else if score >= 40 { + case score >= 40: msg = "Poor" - } else { + default: msg = "Critical" } diff --git a/cmd/status/view.go b/cmd/status/view.go index 2691452..7ef56cd 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -187,15 +187,16 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int } func getScoreStyle(score int) lipgloss.Style { - if score >= 90 { + switch { + case score >= 90: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true) - } else if score >= 75 { + case score >= 75: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) - } else if score >= 60 { + case score >= 60: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) - } else if score >= 40 { + case score >= 40: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) - } else { + default: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) } } @@ -308,9 +309,10 @@ func renderMemoryCard(mem MemoryStatus) cardData { if mem.Pressure != "" { pressureStyle := okStyle pressureText := "Status " + mem.Pressure - if mem.Pressure == "warn" { + switch mem.Pressure { + case "warn": pressureStyle = warnStyle - } else if mem.Pressure == "critical" { + case "critical": pressureStyle = dangerStyle } lines = append(lines, pressureStyle.Render(pressureText)) @@ -706,11 +708,11 @@ func humanBytesCompact(v uint64) string { } } -func shorten(s string, max int) string { - if len(s) <= max { +func shorten(s string, maxLen int) string { + if len(s) <= maxLen { return s } - return s[:max-1] + "…" + return s[:maxLen-1] + "…" } func renderTwoColumns(cards []cardData, width int) string { diff --git a/install.sh b/install.sh index b2408f3..13cbf8f 100755 --- a/install.sh +++ b/install.sh @@ -100,7 +100,7 @@ resolve_source_dir() { local tmp tmp="$(mktemp -d)" - trap "stop_line_spinner 2>/dev/null; rm -rf '$tmp'" EXIT + trap 'stop_line_spinner 2>/dev/null; rm -rf "$tmp"' EXIT local branch="${MOLE_VERSION:-}" if [[ -z "$branch" ]]; then diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh index 3418aa3..c4b62c6 100644 --- a/lib/clean/app_caches.sh +++ b/lib/clean/app_caches.sh @@ -88,6 +88,7 @@ clean_productivity_apps() { safe_clean ~/Library/Caches/com.orabrowser.app/* "Ora browser cache" safe_clean ~/Library/Caches/com.filo.client/* "Filo cache" safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache" + safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache" } # Music/media players (protect Spotify offline music). clean_media_players() { diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 2c617a4..5822c6a 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -85,8 +85,9 @@ readonly DATA_PROTECTED_BUNDLES=( "com.lastpass.*" # LastPass "com.dashlane.*" # Dashlane "com.bitwarden.*" # Bitwarden - "com.keepassx.*" # KeePassXC + "com.keepassx.*" # KeePassXC (Legacy) "org.keepassx.*" # KeePassX + "org.keepassxc.*" # KeePassXC "com.authy.*" # Authy "com.yubico.*" # YubiKey Manager diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index c7b5bf3..4fb03a7 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -46,7 +46,9 @@ validate_path_for_deletion() { fi # Check for path traversal attempts - if [[ "$path" =~ \.\. ]]; then + # Only reject .. when it appears as a complete path component (/../ or /.. or ../) + # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") + if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then log_error "Path validation failed: path traversal not allowed: $path" return 1 fi @@ -254,12 +256,10 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path when available + # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do - if command -v should_protect_path > /dev/null 2>&1; then - if should_protect_path "$match"; then - continue - fi + if should_protect_path "$match"; then + continue fi safe_remove "$match" true || true done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) @@ -298,12 +298,10 @@ safe_sudo_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path when available + # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do - if command -v should_protect_path > /dev/null 2>&1; then - if should_protect_path "$match"; then - continue - fi + if should_protect_path "$match"; then + continue fi safe_sudo_remove "$match" || true done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index 9527d14..57b80b4 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -9,6 +9,13 @@ set -euo pipefail # ============================================================================ check_touchid_support() { + # Check sudo_local first (Sonoma+) + if [[ -f /etc/pam.d/sudo_local ]]; then + grep -q "pam_tid.so" /etc/pam.d/sudo_local 2> /dev/null + return $? + fi + + # Fallback to checking sudo directly if [[ -f /etc/pam.d/sudo ]]; then grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null return $? diff --git a/mole b/mole index a40c37c..398bf72 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.18.0" +VERSION="1.20.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -345,7 +345,7 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$("$mole_path" --version 2> /dev/null | awk 'NF {print $NF}' || echo "") + new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" else printf '\n' diff --git a/scripts/check.sh b/scripts/check.sh index d4fbc50..4249a82 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -74,8 +74,12 @@ if [[ "$MODE" == "format" ]]; then exit 1 fi - if command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}Formatting Go code...${NC}" + if command -v goimports > /dev/null 2>&1; then + echo -e "${YELLOW}Formatting Go code (goimports)...${NC}" + goimports -w -local github.com/tw93/Mole ./cmd + echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" + elif command -v go > /dev/null 2>&1; then + echo -e "${YELLOW}Formatting Go code (gofmt)...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" else @@ -95,14 +99,42 @@ if [[ "$MODE" != "check" ]]; then echo -e "${YELLOW}${ICON_WARNING} shfmt not installed, skipping${NC}\n" fi - if command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}2. Formatting Go code...${NC}" + if command -v goimports > /dev/null 2>&1; then + echo -e "${YELLOW}2. Formatting Go code (goimports)...${NC}" + goimports -w -local github.com/tw93/Mole ./cmd + echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" + elif command -v go > /dev/null 2>&1; then + echo -e "${YELLOW}2. Formatting Go code (gofmt)...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" fi fi -echo -e "${YELLOW}3. Running ShellCheck...${NC}" +echo -e "${YELLOW}3. Running Go linters...${NC}" +if command -v golangci-lint > /dev/null 2>&1; then + if ! golangci-lint config verify; then + echo -e "${RED}${ICON_ERROR} golangci-lint config invalid${NC}\n" + exit 1 + fi + if golangci-lint run ./cmd/...; then + echo -e "${GREEN}${ICON_SUCCESS} golangci-lint passed${NC}\n" + else + echo -e "${RED}${ICON_ERROR} golangci-lint failed${NC}\n" + exit 1 + fi +elif command -v go > /dev/null 2>&1; then + echo -e "${YELLOW}${ICON_WARNING} golangci-lint not installed, falling back to go vet${NC}" + if go vet ./cmd/...; then + echo -e "${GREEN}${ICON_SUCCESS} go vet passed${NC}\n" + else + echo -e "${RED}${ICON_ERROR} go vet failed${NC}\n" + exit 1 + fi +else + echo -e "${YELLOW}${ICON_WARNING} Go not installed, skipping Go checks${NC}\n" +fi + +echo -e "${YELLOW}4. Running ShellCheck...${NC}" if command -v shellcheck > /dev/null 2>&1; then if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n" @@ -114,7 +146,7 @@ else echo -e "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n" fi -echo -e "${YELLOW}4. Running syntax check...${NC}" +echo -e "${YELLOW}5. Running syntax check...${NC}" if ! bash -n mole; then echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n" exit 1 @@ -133,7 +165,7 @@ find lib -name "*.sh" | while read -r script; do done echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n" -echo -e "${YELLOW}5. Checking optimizations...${NC}" +echo -e "${YELLOW}6. Checking optimizations...${NC}" OPTIMIZATION_SCORE=0 TOTAL_CHECKS=0 diff --git a/tests/clean_misc.bats b/tests/clean_misc.bats index 1e7054e..c5e0d3c 100644 --- a/tests/clean_misc.bats +++ b/tests/clean_misc.bats @@ -92,9 +92,14 @@ EOF @test "scan_external_volumes skips when no volumes" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail +export DRY_RUN="false" source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" run_with_timeout() { return 1; } +# Mock missing dependencies and UI to ensure test passes regardless of volumes +clean_ds_store_tree() { :; } +start_section_spinner() { :; } +stop_section_spinner() { :; } scan_external_volumes EOF diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats index c6bcba6..a720787 100644 --- a/tests/core_safe_functions.bats +++ b/tests/core_safe_functions.bats @@ -43,6 +43,26 @@ teardown() { @test "validate_path_for_deletion rejects path traversal" { run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'" [ "$status" -eq 1 ] + + # Test other path traversal patterns + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/var/log/../../etc'" + [ "$status" -eq 1 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/..'" + [ "$status" -eq 1 ] +} + +@test "validate_path_for_deletion accepts Firefox-style ..files directories" { + # Firefox uses ..files suffix in IndexedDB directory names + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/2753419432nreetyfallipx..files'" + [ "$status" -eq 0 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/storage/default/https+++www.netflix.com/idb/name..files/data'" + [ "$status" -eq 0 ] + + # Directories with .. in the middle of names should be allowed + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/test..backup/file.txt'" + [ "$status" -eq 0 ] } @test "validate_path_for_deletion rejects system directories" {