mirror of
https://github.com/tw93/Mole.git
synced 2026-02-16 13:31:12 +00:00
Merge branch 'main' into dev
This commit is contained in:
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -79,7 +79,7 @@ jobs:
|
|||||||
echo "Checking for hardcoded secrets..."
|
echo "Checking for hardcoded secrets..."
|
||||||
matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \
|
matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \
|
||||||
| grep -v "# \|test" \
|
| grep -v "# \|test" \
|
||||||
| grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh" || true)
|
| grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh" || true)
|
||||||
if [[ -n "$matches" ]]; then
|
if [[ -n "$matches" ]]; then
|
||||||
echo "$matches"
|
echo "$matches"
|
||||||
echo "✗ Potential secrets found"
|
echo "✗ Potential secrets found"
|
||||||
|
|||||||
185
CONTRIBUTORS.svg
185
CONTRIBUTORS.svg
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 290 KiB |
@@ -90,6 +90,7 @@ mo purge --paths # Configure project scan directories
|
|||||||
|
|
||||||
- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp.
|
- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp.
|
||||||
- **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
|
- **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
|
||||||
|
- **Be Careful**: Although safe by design, file deletion is permanent. Please review operations carefully.
|
||||||
- **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.
|
- **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`).
|
- **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.
|
- **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit.
|
||||||
|
|||||||
@@ -2,36 +2,17 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Security Audit & Compliance Report**
|
**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.19.0 (2026-01-09)
|
||||||
|
|
||||||
Version 1.19.0 | January 5, 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Audit Status:** PASSED | **Risk Level:** LOW
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Audit Overview](#audit-overview)
|
|
||||||
2. [Security Philosophy](#security-philosophy)
|
|
||||||
3. [Threat Model](#threat-model)
|
|
||||||
4. [Defense Architecture](#defense-architecture)
|
|
||||||
5. [Safety Mechanisms](#safety-mechanisms)
|
|
||||||
6. [User Controls](#user-controls)
|
|
||||||
7. [Testing & Compliance](#testing--compliance)
|
|
||||||
8. [Dependencies](#dependencies)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Audit Overview
|
## Audit Overview
|
||||||
|
|
||||||
| Attribute | Details |
|
| Attribute | Details |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| Audit Date | December 31, 2025 |
|
| Audit Date | January 9, 2026 |
|
||||||
| Audit Conclusion | **PASSED** |
|
| Audit Conclusion | **PASSED** |
|
||||||
| Mole Version | V1.19.0 |
|
| Mole Version | V1.19.0 |
|
||||||
| Audited Branch | `main` (HEAD) |
|
| Audited Branch | `main` (HEAD) |
|
||||||
@@ -42,12 +23,12 @@ Version 1.19.0 | January 5, 2026
|
|||||||
|
|
||||||
**Key Findings:**
|
**Key Findings:**
|
||||||
|
|
||||||
- Multi-layered validation prevents critical system modifications
|
- Multi-layer validation effectively blocks risky system modifications.
|
||||||
- Conservative cleaning logic with 60-day dormancy rules
|
- Conservative cleaning logic ensures safety (e.g., 60-day dormancy rule).
|
||||||
- Comprehensive protection for VPN, AI tools, and system components
|
- Comprehensive protection for VPNs, AI tools, and core system components.
|
||||||
- Atomic operations with crash recovery mechanisms
|
- Atomic operations prevent state corruption during crashes.
|
||||||
- Full user control with dry-run and whitelist capabilities
|
- Dry-run and whitelist features give users full control.
|
||||||
- Installer cleanup safely scans common locations with user confirmation
|
- Installer cleanup scans safely and requires user confirmation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,14 +36,14 @@ Version 1.19.0 | January 5, 2026
|
|||||||
|
|
||||||
**Core Principle: "Do No Harm"**
|
**Core Principle: "Do No Harm"**
|
||||||
|
|
||||||
Mole operates under a **Zero Trust** architecture for all filesystem operations. Every modification request is treated as potentially dangerous until passing strict validation.
|
We built Mole on a **Zero Trust** architecture for filesystem operations. Every modification request is treated as dangerous until it passes strict validation.
|
||||||
|
|
||||||
**Guiding Priorities:**
|
**Guiding Priorities:**
|
||||||
|
|
||||||
1. **System Stability First** - Prefer leaving 1GB of junk over deleting 1KB of critical data
|
1. **System Stability First** - We'd rather leave 1GB of junk than delete 1KB of your data.
|
||||||
2. **Conservative by Default** - Require explicit user confirmation for high-risk operations
|
2. **Conservative by Default** - High-risk operations always require explicit confirmation.
|
||||||
3. **Fail Safe** - When in doubt, abort rather than proceed
|
3. **Fail Safe** - When in doubt, we abort immediately.
|
||||||
4. **Transparency** - All operations are logged and can be previewed via dry-run mode
|
4. **Transparency** - Every operation is logged and allows a preview via dry-run mode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,7 +70,7 @@ Mole operates under a **Zero Trust** architecture for all filesystem operations.
|
|||||||
|
|
||||||
### Multi-Layered Validation System
|
### Multi-Layered Validation System
|
||||||
|
|
||||||
All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 validation layers:
|
All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 layers of validation:
|
||||||
|
|
||||||
#### Layer 1: Input Sanitization
|
#### Layer 1: Input Sanitization
|
||||||
|
|
||||||
@@ -114,7 +95,7 @@ Even with `sudo`, these paths are **unconditionally blocked**:
|
|||||||
/Library/Extensions # Kernel extensions
|
/Library/Extensions # Kernel extensions
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache)
|
**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache).
|
||||||
|
|
||||||
**Code:** `lib/core/file_ops.sh:60-78`
|
**Code:** `lib/core/file_ops.sh:60-78`
|
||||||
|
|
||||||
@@ -122,9 +103,9 @@ Even with `sudo`, these paths are **unconditionally blocked**:
|
|||||||
|
|
||||||
For privileged operations, pre-flight checks prevent symlink-based attacks:
|
For privileged operations, pre-flight checks prevent symlink-based attacks:
|
||||||
|
|
||||||
- Detects symlinks pointing from cache folders to system files
|
- Detects symlinks from cache folders pointing to system files.
|
||||||
- Refuses recursive deletion of symbolic links in sudo mode
|
- Refuses recursive deletion of symbolic links in sudo mode.
|
||||||
- Validates real path vs symlink target
|
- Validates real path vs. symlink target.
|
||||||
|
|
||||||
**Code:** `lib/core/file_ops.sh:safe_sudo_recursive_delete()`
|
**Code:** `lib/core/file_ops.sh:safe_sudo_recursive_delete()`
|
||||||
|
|
||||||
@@ -132,18 +113,19 @@ For privileged operations, pre-flight checks prevent symlink-based attacks:
|
|||||||
|
|
||||||
When running with `sudo`:
|
When running with `sudo`:
|
||||||
|
|
||||||
- Auto-corrects ownership back to user (`chown -R`)
|
- Auto-corrects ownership back to user (`chown -R`).
|
||||||
- Operations restricted to user's home directory
|
- Restricts operations to the user's home directory.
|
||||||
- Multiple validation checkpoints
|
- Enforces multiple validation checkpoints.
|
||||||
|
|
||||||
### Interactive Analyzer (Go)
|
### Interactive Analyzer (Go)
|
||||||
|
|
||||||
The analyzer (`mo analyze`) uses a different security model:
|
The analyzer (`mo analyze`) uses a distinct security model:
|
||||||
|
|
||||||
- Runs with standard user permissions only
|
- Runs with standard user permissions only.
|
||||||
- Respects macOS System Integrity Protection (SIP)
|
- Respects macOS System Integrity Protection (SIP).
|
||||||
- All deletions require explicit user confirmation
|
- **Two-Key Confirmation:** Deletion requires ⌫ (Delete) to enter confirmation mode, then Enter to confirm. Prevents accidental double-press of the same key.
|
||||||
- OS-level enforcement (cannot delete `/System` due to Read-Only Volume)
|
- **Trash Instead of Delete:** Files are moved to macOS Trash using Finder's native API, allowing easy recovery if needed.
|
||||||
|
- OS-level enforcement (cannot delete `/System` due to Read-Only Volume).
|
||||||
|
|
||||||
**Code:** `cmd/analyze/*.go`
|
**Code:** `cmd/analyze/*.go`
|
||||||
|
|
||||||
@@ -159,7 +141,7 @@ The analyzer (`mo analyze`) uses a different security model:
|
|||||||
|------|--------------|-----------|
|
|------|--------------|-----------|
|
||||||
| 1. App Check | All installation locations | Must be missing from `/Applications`, `~/Applications`, `/System/Applications` |
|
| 1. App Check | All installation locations | Must be missing from `/Applications`, `~/Applications`, `/System/Applications` |
|
||||||
| 2. Dormancy | Modification timestamps | Untouched for ≥60 days |
|
| 2. Dormancy | Modification timestamps | Untouched for ≥60 days |
|
||||||
| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, Google resources protected |
|
| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, and Google resources are protected |
|
||||||
|
|
||||||
**Code:** `lib/clean/apps.sh:orphan_detection()`
|
**Code:** `lib/clean/apps.sh:orphan_detection()`
|
||||||
|
|
||||||
@@ -169,8 +151,8 @@ For user-selected app removal:
|
|||||||
|
|
||||||
- **Sanitized Name Matching:** "Visual Studio Code" → `VisualStudioCode`, `.vscode`
|
- **Sanitized Name Matching:** "Visual Studio Code" → `VisualStudioCode`, `.vscode`
|
||||||
- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google")
|
- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google")
|
||||||
- **Disabled:** Fuzzy matching, wildcard expansion for short names
|
- **Disabled:** Fuzzy matching and wildcard expansion for short names.
|
||||||
- **User Confirmation:** Required before deletion
|
- **User Confirmation:** Required before deletion.
|
||||||
|
|
||||||
**Code:** `lib/clean/apps.sh:uninstall_app()`
|
**Code:** `lib/clean/apps.sh:uninstall_app()`
|
||||||
|
|
||||||
@@ -183,19 +165,19 @@ For user-selected app removal:
|
|||||||
| System Components | Control Center, System Settings, TCC | Centralized detection via `is_critical_system_component()` |
|
| System Components | Control Center, System Settings, TCC | Centralized detection via `is_critical_system_component()` |
|
||||||
| Time Machine | Local snapshots, backups | Checks `backupd` process, aborts if active |
|
| Time Machine | Local snapshots, backups | Checks `backupd` process, aborts if active |
|
||||||
| VPN & Proxy | Shadowsocks, V2Ray, Tailscale, Clash | Protects network configs |
|
| VPN & Proxy | Shadowsocks, V2Ray, Tailscale, Clash | Protects network configs |
|
||||||
| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, sessions |
|
| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, and sessions |
|
||||||
| Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped |
|
| Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped |
|
||||||
|
|
||||||
**Orphaned Helper Cleanup (`opt_startup_items_cleanup`):**
|
**Orphaned Helper Cleanup (`opt_startup_items_cleanup`):**
|
||||||
|
|
||||||
Removes LaunchAgents/Daemons whose associated app has been uninstalled:
|
Removes LaunchAgents/Daemons whose associated app has been uninstalled:
|
||||||
|
|
||||||
- Checks `AssociatedBundleIdentifiers` to detect orphans
|
- Checks `AssociatedBundleIdentifiers` to detect orphans.
|
||||||
- Skips all `com.apple.*` system items
|
- Skips all `com.apple.*` system items.
|
||||||
- Skips paths under `/System/*`, `/usr/bin/*`, `/usr/lib/*`, `/usr/sbin/*`, `/Library/Apple/*`
|
- Skips paths under `/System/*`, `/usr/bin/*`, `/usr/lib/*`, `/usr/sbin/*`, `/Library/Apple/*`.
|
||||||
- Uses `safe_remove` / `safe_sudo_remove` with path validation
|
- Uses `safe_remove` / `safe_sudo_remove` with path validation.
|
||||||
- Unloads service via `launchctl` before deletion
|
- Unloads service via `launchctl` before deletion.
|
||||||
- `mdfind` operations have 10-second timeout protection
|
- **Timeout Protection:** 10-second limit on `mdfind` operations.
|
||||||
|
|
||||||
**Code:** `lib/optimize/tasks.sh:opt_startup_items_cleanup()`
|
**Code:** `lib/optimize/tasks.sh:opt_startup_items_cleanup()`
|
||||||
|
|
||||||
@@ -206,9 +188,9 @@ Removes LaunchAgents/Daemons whose associated app has been uninstalled:
|
|||||||
| Network Interface Reset | Atomic execution blocks | Wi-Fi/AirDrop restored to pre-operation state |
|
| Network Interface Reset | Atomic execution blocks | Wi-Fi/AirDrop restored to pre-operation state |
|
||||||
| Swap Clearing | Daemon restart | `dynamic_pager` handles recovery safely |
|
| Swap Clearing | Daemon restart | `dynamic_pager` handles recovery safely |
|
||||||
| Volume Scanning | Timeout + filesystem check | Auto-skip unresponsive NFS/SMB/AFP mounts |
|
| Volume Scanning | Timeout + filesystem check | Auto-skip unresponsive NFS/SMB/AFP mounts |
|
||||||
| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids 30-120s delay) |
|
| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids long delays) |
|
||||||
| Network Volume Check | `diskutil info` with timeout | Prevents hangs on slow/dead mounts |
|
| Network Volume Check | `diskutil info` with timeout | Prevents hangs on slow/dead mounts |
|
||||||
| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages running |
|
| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages active |
|
||||||
| dyld Cache Update | 24-hour freshness check + 180s timeout | Skips if recently updated |
|
| dyld Cache Update | 24-hour freshness check + 180s timeout | Skips if recently updated |
|
||||||
| App Bundle Search | 10s timeout on mdfind | Fallback to standard paths |
|
| App Bundle Search | 10s timeout on mdfind | Fallback to standard paths |
|
||||||
|
|
||||||
@@ -230,10 +212,10 @@ run_with_timeout 5 diskutil info "$mount_point" || skip_volume
|
|||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|
||||||
- Simulates entire operation without filesystem modifications
|
- Simulates the entire operation without modifying a single file.
|
||||||
- Lists every file/directory that **would** be deleted
|
- Lists every file/directory that **would** be deleted.
|
||||||
- Calculates total space that **would** be freed
|
- Calculates total space that **would** be freed.
|
||||||
- Zero risk - no actual deletion commands executed
|
- **Zero risk** - no actual deletion commands are executed.
|
||||||
|
|
||||||
### Custom Whitelists
|
### Custom Whitelists
|
||||||
|
|
||||||
@@ -247,19 +229,19 @@ run_with_timeout 5 diskutil info "$mount_point" || skip_volume
|
|||||||
~/Library/Application Support/CriticalApp
|
~/Library/Application Support/CriticalApp
|
||||||
```
|
```
|
||||||
|
|
||||||
- Paths are **unconditionally protected**
|
- Paths are **unconditionally protected**.
|
||||||
- Applies to all operations (clean, optimize, uninstall)
|
- Applies to all operations (clean, optimize, uninstall).
|
||||||
- Supports absolute paths and `~` expansion
|
- Supports absolute paths and `~` expansion.
|
||||||
|
|
||||||
**Code:** `lib/core/file_ops.sh:is_whitelisted()`
|
**Code:** `lib/core/file_ops.sh:is_whitelisted()`
|
||||||
|
|
||||||
### Interactive Confirmations
|
### Interactive Confirmations
|
||||||
|
|
||||||
Required for:
|
We mandate confirmation for:
|
||||||
|
|
||||||
- Uninstalling system-scope applications
|
- Uninstalling system-scope applications.
|
||||||
- Removing large data directories (>1GB)
|
- Removing large data directories (>1GB).
|
||||||
- Deleting items from shared vendor folders
|
- Deleting items from shared vendor folders.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,33 +273,33 @@ bats tests/security.bats # Run specific suite
|
|||||||
| Standard | Implementation |
|
| Standard | Implementation |
|
||||||
|----------|----------------|
|
|----------|----------------|
|
||||||
| OWASP Secure Coding | Input validation, least privilege, defense-in-depth |
|
| OWASP Secure Coding | Input validation, least privilege, defense-in-depth |
|
||||||
| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components while allowing `..` in directory names (Firefox compatibility) |
|
| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components, safely handles `..` in directory names |
|
||||||
| CWE-78 (Command Injection) | Control character filtering |
|
| CWE-78 (Command Injection) | Control character filtering |
|
||||||
| CWE-59 (Link Following) | Symlink detection before privileged operations |
|
| CWE-59 (Link Following) | Symlink detection before privileged operations |
|
||||||
| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC |
|
| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC |
|
||||||
|
|
||||||
### Security Development Lifecycle
|
### Security Development Lifecycle
|
||||||
|
|
||||||
- **Static Analysis:** shellcheck for all shell scripts
|
- **Static Analysis:** `shellcheck` runs on all shell scripts.
|
||||||
- **Code Review:** All changes reviewed by maintainers
|
- **Code Review:** All changes are manually reviewed by maintainers.
|
||||||
- **Dependency Scanning:** Minimal external dependencies, all vetted
|
- **Dependency Scanning:** Minimal external dependencies, all carefully vetted.
|
||||||
|
|
||||||
### Known Limitations
|
### Known Limitations
|
||||||
|
|
||||||
| Limitation | Impact | Mitigation |
|
| Limitation | Impact | Mitigation |
|
||||||
|------------|--------|------------|
|
|------------|--------|------------|
|
||||||
| Requires `sudo` for system caches | Initial friction | Clear documentation |
|
| Requires `sudo` for system caches | Initial friction | Clear documentation explaining why |
|
||||||
| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` available |
|
| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` is always available |
|
||||||
| No undo functionality | Deleted files unrecoverable | Dry-run mode, warnings |
|
| No undo functionality | Deleted files are unrecoverable | Dry-run mode and warnings are clear |
|
||||||
| English-only name matching | May miss non-English apps | Bundle ID fallback |
|
| English-only name matching | May miss non-English apps | Fallback to Bundle ID matching |
|
||||||
|
|
||||||
**Intentionally Out of Scope (Safety):**
|
**Intentionally Out of Scope (Safety):**
|
||||||
|
|
||||||
- Automatic deletion of user documents/media
|
- Automatic deletion of user documents/media.
|
||||||
- Encryption key stores or password managers
|
- Encryption key stores or password managers.
|
||||||
- System configuration files (`/etc/*`)
|
- System configuration files (`/etc/*`).
|
||||||
- Browser history or cookies
|
- Browser history or cookies.
|
||||||
- Git repository cleanup
|
- Git repository cleanup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -325,7 +307,7 @@ bats tests/security.bats # Run specific suite
|
|||||||
|
|
||||||
### System Binaries
|
### System Binaries
|
||||||
|
|
||||||
Mole relies on standard macOS system binaries (all SIP-protected):
|
Mole relies on standard, SIP-protected macOS system binaries:
|
||||||
|
|
||||||
| Binary | Purpose | Fallback |
|
| Binary | Purpose | Fallback |
|
||||||
|--------|---------|----------|
|
|--------|---------|----------|
|
||||||
@@ -347,14 +329,14 @@ The compiled Go binary (`analyze-go`) includes:
|
|||||||
|
|
||||||
**Supply Chain Security:**
|
**Supply Chain Security:**
|
||||||
|
|
||||||
- All dependencies pinned to specific versions
|
- All dependencies are pinned to specific versions.
|
||||||
- Regular security audits
|
- Regular security audits.
|
||||||
- No transitive dependencies with known CVEs
|
- No transitive dependencies with known CVEs.
|
||||||
- **Automated Releases**: Binaries compiled via GitHub Actions and signed
|
- **Automated Releases**: Binaries are compiled and signed via GitHub Actions.
|
||||||
- **Source Only**: Repository contains no pre-compiled binaries
|
- **Source Only**: The repository contains no pre-compiled binaries.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Certification:** This security audit certifies that Mole implements industry-standard defensive programming practices and adheres to macOS security guidelines. The architecture prioritizes system stability and data integrity over aggressive optimization.
|
**Our Commitment:** This document certifies that Mole implements industry-standard defensive programming practices and strictly adheres to macOS security guidelines. We prioritize system stability and data integrity above all else.
|
||||||
|
|
||||||
*For security concerns or vulnerability reports, please contact the maintainers via GitHub Issues.*
|
*For security concerns or vulnerability reports, please open an issue or contact the maintainers directly.*
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ func TestScanPathConcurrentBasic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeletePathWithProgress(t *testing.T) {
|
func TestDeletePathWithProgress(t *testing.T) {
|
||||||
|
// Skip in CI environments where Finder may not be available.
|
||||||
|
if os.Getenv("CI") != "" {
|
||||||
|
t.Skip("Skipping Finder-dependent test in CI")
|
||||||
|
}
|
||||||
|
|
||||||
parent := t.TempDir()
|
parent := t.TempDir()
|
||||||
target := filepath.Join(parent, "target")
|
target := filepath.Join(parent, "target")
|
||||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
@@ -107,18 +112,15 @@ func TestDeletePathWithProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var counter int64
|
var counter int64
|
||||||
count, err := deletePathWithProgress(target, &counter)
|
count, err := trashPathWithProgress(target, &counter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("deletePathWithProgress returned error: %v", err)
|
t.Fatalf("trashPathWithProgress returned error: %v", err)
|
||||||
}
|
}
|
||||||
if count != int64(len(files)) {
|
if count != int64(len(files)) {
|
||||||
t.Fatalf("expected %d files removed, got %d", len(files), count)
|
t.Fatalf("expected %d files trashed, got %d", len(files), count)
|
||||||
}
|
|
||||||
if got := atomic.LoadInt64(&counter); got != count {
|
|
||||||
t.Fatalf("counter mismatch: want %d, got %d", count, got)
|
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected target to be removed, stat err=%v", err)
|
t.Fatalf("expected target to be moved to Trash, stat err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const trashTimeout = 30 * time.Second
|
||||||
|
|
||||||
func deletePathCmd(path string, counter *int64) tea.Cmd {
|
func deletePathCmd(path string, counter *int64) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
count, err := deletePathWithProgress(path, counter)
|
count, err := trashPathWithProgress(path, counter)
|
||||||
return deleteProgressMsg{
|
return deleteProgressMsg{
|
||||||
done: true,
|
done: true,
|
||||||
err: err,
|
err: err,
|
||||||
@@ -23,20 +28,20 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteMultiplePathsCmd deletes paths and aggregates results.
|
// deleteMultiplePathsCmd moves paths to Trash and aggregates results.
|
||||||
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
var totalCount int64
|
var totalCount int64
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
// Delete deeper paths first to avoid parent/child conflicts.
|
// Process deeper paths first to avoid parent/child conflicts.
|
||||||
pathsToDelete := append([]string(nil), paths...)
|
pathsToDelete := append([]string(nil), paths...)
|
||||||
sort.Slice(pathsToDelete, func(i, j int) bool {
|
sort.Slice(pathsToDelete, func(i, j int) bool {
|
||||||
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
|
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, path := range pathsToDelete {
|
for _, path := range pathsToDelete {
|
||||||
count, err := deletePathWithProgress(path, counter)
|
count, err := trashPathWithProgress(path, counter)
|
||||||
totalCount += count
|
totalCount += count
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -72,48 +77,70 @@ func (e *multiDeleteError) Error() string {
|
|||||||
return strings.Join(e.errors[:min(3, len(e.errors))], "; ")
|
return strings.Join(e.errors[:min(3, len(e.errors))], "; ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func deletePathWithProgress(root string, counter *int64) (int64, error) {
|
// trashPathWithProgress moves a path to Trash using Finder.
|
||||||
var count int64
|
// This allows users to recover accidentally deleted files.
|
||||||
var firstErr error
|
func trashPathWithProgress(root string, counter *int64) (int64, error) {
|
||||||
|
// Verify path exists (use Lstat to handle broken symlinks).
|
||||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
info, err := os.Lstat(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Skip permission errors but continue.
|
return 0, err
|
||||||
if os.IsPermission(err) {
|
|
||||||
if firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
if firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count items for progress reporting.
|
||||||
|
var count int64
|
||||||
|
if info.IsDir() {
|
||||||
|
_ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !d.IsDir() {
|
if !d.IsDir() {
|
||||||
if removeErr := os.Remove(path); removeErr == nil {
|
|
||||||
count++
|
count++
|
||||||
if counter != nil {
|
if counter != nil {
|
||||||
atomic.StoreInt64(counter, count)
|
atomic.StoreInt64(counter, count)
|
||||||
}
|
}
|
||||||
} else if firstErr == nil {
|
|
||||||
firstErr = removeErr
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
count = 1
|
||||||
|
if counter != nil {
|
||||||
|
atomic.StoreInt64(counter, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to Trash using Finder AppleScript.
|
||||||
|
if err := moveToTrash(root); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveToTrash uses macOS Finder to move a file/directory to Trash.
|
||||||
|
// This is the safest method as it uses the system's native trash mechanism.
|
||||||
|
func moveToTrash(path string) error {
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape path for AppleScript (handle quotes and backslashes).
|
||||||
|
escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\")
|
||||||
|
escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"")
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`tell application "Finder" to delete POSIX file "%s"`, escapedPath)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), trashTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "osascript", "-e", script)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("timeout moving to Trash")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to move to Trash: %s", strings.TrimSpace(string(output)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
|
|
||||||
if removeErr := os.RemoveAll(root); removeErr != nil {
|
|
||||||
if firstErr == nil {
|
|
||||||
firstErr = removeErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, firstErr
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,47 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestTrashPathWithProgress(t *testing.T) {
|
||||||
|
// Skip in CI environments where Finder may not be available.
|
||||||
|
if os.Getenv("CI") != "" {
|
||||||
|
t.Skip("Skipping Finder-dependent test in CI")
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := t.TempDir()
|
||||||
|
target := filepath.Join(parent, "target")
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []string{
|
||||||
|
filepath.Join(target, "one.txt"),
|
||||||
|
filepath.Join(target, "two.txt"),
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if err := os.WriteFile(f, []byte("content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter int64
|
||||||
|
count, err := trashPathWithProgress(target, &counter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("trashPathWithProgress returned error: %v", err)
|
||||||
|
}
|
||||||
|
if count != int64(len(files)) {
|
||||||
|
t.Fatalf("expected %d files trashed, got %d", len(files), count)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected target to be moved to Trash, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
|
func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
|
||||||
|
// Skip in CI environments where Finder may not be available.
|
||||||
|
if os.Getenv("CI") != "" {
|
||||||
|
t.Skip("Skipping Finder-dependent test in CI")
|
||||||
|
}
|
||||||
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
parent := filepath.Join(base, "parent")
|
parent := filepath.Join(base, "parent")
|
||||||
child := filepath.Join(parent, "child")
|
child := filepath.Join(parent, "child")
|
||||||
@@ -32,12 +72,16 @@ func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", progress.err)
|
t.Fatalf("unexpected error: %v", progress.err)
|
||||||
}
|
}
|
||||||
if progress.count != 2 {
|
if progress.count != 2 {
|
||||||
t.Fatalf("expected 2 files deleted, got %d", progress.count)
|
t.Fatalf("expected 2 files trashed, got %d", progress.count)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(parent); !os.IsNotExist(err) {
|
if _, err := os.Stat(parent); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected parent to be removed, err=%v", err)
|
t.Fatalf("expected parent to be moved to Trash, err=%v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(child); !os.IsNotExist(err) {
|
}
|
||||||
t.Fatalf("expected child to be removed, err=%v", err)
|
|
||||||
|
func TestMoveToTrashNonExistent(t *testing.T) {
|
||||||
|
err := moveToTrash("/nonexistent/path/that/does/not/exist")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.deleting && m.deleteCount != nil {
|
if m.deleting && m.deleteCount != nil {
|
||||||
count := atomic.LoadInt64(m.deleteCount)
|
count := atomic.LoadInt64(m.deleteCount)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
m.status = fmt.Sprintf("Deleting... %s items removed", formatNumber(count))
|
m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, tickCmd()
|
return m, tickCmd()
|
||||||
@@ -530,7 +530,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Delete confirm flow.
|
// Delete confirm flow.
|
||||||
if m.deleteConfirm {
|
if m.deleteConfirm {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "delete", "backspace":
|
case "enter":
|
||||||
m.deleteConfirm = false
|
m.deleteConfirm = false
|
||||||
m.deleting = true
|
m.deleting = true
|
||||||
var deleteCount int64
|
var deleteCount int64
|
||||||
|
|||||||
@@ -390,12 +390,12 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if deleteCount > 1 {
|
if deleteCount > 1 {
|
||||||
fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress ⌫ again | ESC cancel%s\n",
|
fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress Enter to confirm | ESC cancel%s\n",
|
||||||
colorRed, colorReset,
|
colorRed, colorReset,
|
||||||
deleteCount, humanizeBytes(totalDeleteSize),
|
deleteCount, humanizeBytes(totalDeleteSize),
|
||||||
colorGray, colorReset)
|
colorGray, colorReset)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n",
|
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress Enter to confirm | ESC cancel%s\n",
|
||||||
colorRed, colorReset,
|
colorRed, colorReset,
|
||||||
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size),
|
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size),
|
||||||
colorGray, colorReset)
|
colorGray, colorReset)
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ scan_installed_apps() {
|
|||||||
"/Applications"
|
"/Applications"
|
||||||
"/System/Applications"
|
"/System/Applications"
|
||||||
"$HOME/Applications"
|
"$HOME/Applications"
|
||||||
|
# Homebrew Cask locations
|
||||||
|
"/opt/homebrew/Caskroom"
|
||||||
|
"/usr/local/Caskroom"
|
||||||
|
# Setapp applications
|
||||||
|
"$HOME/Library/Application Support/Setapp/Applications"
|
||||||
)
|
)
|
||||||
# Temp dir avoids write contention across parallel scans.
|
# Temp dir avoids write contention across parallel scans.
|
||||||
local scan_tmp_dir=$(create_temp_dir)
|
local scan_tmp_dir=$(create_temp_dir)
|
||||||
@@ -117,6 +122,10 @@ scan_installed_apps() {
|
|||||||
(
|
(
|
||||||
local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
|
local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
|
||||||
echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt"
|
echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt"
|
||||||
|
# Fallback: lsappinfo is more reliable than osascript
|
||||||
|
if command -v lsappinfo > /dev/null 2>&1; then
|
||||||
|
run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true
|
||||||
|
fi
|
||||||
) &
|
) &
|
||||||
pids+=($!)
|
pids+=($!)
|
||||||
(
|
(
|
||||||
@@ -138,25 +147,57 @@ scan_installed_apps() {
|
|||||||
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
|
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
|
||||||
debug_log "Scanned $app_count unique applications"
|
debug_log "Scanned $app_count unique applications"
|
||||||
}
|
}
|
||||||
|
# Sensitive data patterns that should never be treated as orphaned
|
||||||
|
# These patterns protect security-critical application data
|
||||||
|
readonly ORPHAN_NEVER_DELETE_PATTERNS=(
|
||||||
|
"*1password*" "*1Password*"
|
||||||
|
"*keychain*" "*Keychain*"
|
||||||
|
"*bitwarden*" "*Bitwarden*"
|
||||||
|
"*lastpass*" "*LastPass*"
|
||||||
|
"*keepass*" "*KeePass*"
|
||||||
|
"*dashlane*" "*Dashlane*"
|
||||||
|
"*enpass*" "*Enpass*"
|
||||||
|
"*ssh*" "*gpg*" "*gnupg*"
|
||||||
|
"com.apple.keychain*"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache file for mdfind results (Bash 3.2 compatible, no associative arrays)
|
||||||
|
ORPHAN_MDFIND_CACHE_FILE=""
|
||||||
|
|
||||||
# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file"
|
# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file"
|
||||||
is_bundle_orphaned() {
|
is_bundle_orphaned() {
|
||||||
local bundle_id="$1"
|
local bundle_id="$1"
|
||||||
local directory_path="$2"
|
local directory_path="$2"
|
||||||
local installed_bundles="$3"
|
local installed_bundles="$3"
|
||||||
|
|
||||||
|
# 1. Fast path: check protection list (in-memory, instant)
|
||||||
if should_protect_data "$bundle_id"; then
|
if should_protect_data "$bundle_id"; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 2. Fast path: check sensitive data patterns (in-memory, instant)
|
||||||
|
local bundle_lower
|
||||||
|
bundle_lower=$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]')
|
||||||
|
for pattern in "${ORPHAN_NEVER_DELETE_PATTERNS[@]}"; do
|
||||||
|
# shellcheck disable=SC2053
|
||||||
|
if [[ "$bundle_lower" == $pattern ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Fast path: check installed bundles file (file read, fast)
|
||||||
if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then
|
if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if should_protect_data "$bundle_id"; then
|
|
||||||
return 1
|
# 4. Fast path: hardcoded system components
|
||||||
fi
|
|
||||||
case "$bundle_id" in
|
case "$bundle_id" in
|
||||||
loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari)
|
loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari)
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# 5. Fast path: 60-day modification check (stat call, fast)
|
||||||
if [[ -e "$directory_path" ]]; then
|
if [[ -e "$directory_path" ]]; then
|
||||||
local last_modified_epoch=$(get_file_mtime "$directory_path")
|
local last_modified_epoch=$(get_file_mtime "$directory_path")
|
||||||
local current_epoch
|
local current_epoch
|
||||||
@@ -166,6 +207,37 @@ is_bundle_orphaned() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 6. Slow path: mdfind fallback with file-based caching (Bash 3.2 compatible)
|
||||||
|
# This catches apps installed in non-standard locations
|
||||||
|
if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then
|
||||||
|
# Initialize cache file if needed
|
||||||
|
if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then
|
||||||
|
ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX")
|
||||||
|
register_temp_file "$ORPHAN_MDFIND_CACHE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check cache first (grep is fast for small files)
|
||||||
|
if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then
|
||||||
|
# Already checked, not found - continue to return 0
|
||||||
|
:
|
||||||
|
else
|
||||||
|
# Query mdfind with strict timeout (2 seconds max)
|
||||||
|
local app_exists
|
||||||
|
app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "")
|
||||||
|
if [[ -n "$app_exists" ]]; then
|
||||||
|
echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE"
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo "NOTFOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# All checks passed - this is an orphan
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
# Orphaned app data sweep.
|
# Orphaned app data sweep.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ readonly PURGE_TARGETS=(
|
|||||||
".dart_tool" # Flutter/Dart build cache
|
".dart_tool" # Flutter/Dart build cache
|
||||||
".zig-cache" # Zig
|
".zig-cache" # Zig
|
||||||
"zig-out" # Zig
|
"zig-out" # Zig
|
||||||
|
".angular" # Angular
|
||||||
|
".svelte-kit" # SvelteKit
|
||||||
|
".astro" # Astro
|
||||||
|
"coverage" # Code coverage reports
|
||||||
)
|
)
|
||||||
# Minimum age in days before considering for cleanup.
|
# Minimum age in days before considering for cleanup.
|
||||||
readonly MIN_AGE_DAYS=7
|
readonly MIN_AGE_DAYS=7
|
||||||
|
|||||||
Reference in New Issue
Block a user