mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:04:42 +00:00
feat: Enhance app protection with centralized critical component checks, improve UI string width calculation, refine analysis and cleaning logic, and add new tests.
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Mole Security Audit Report
|
# Mole Security Audit Report
|
||||||
|
|
||||||
**Date:** December 18, 2025
|
**Date:** December 22, 2025
|
||||||
|
|
||||||
**Audited Version:** Current `main` branch (V1.13.10)
|
**Audited Version:** Current `main` branch (V1.14.0)
|
||||||
|
|
||||||
**Status:** Passed
|
**Status:** Passed
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ The interactive analyzer (`mo analyze`) operates on a different security model f
|
|||||||
- **Manual Confirmation**: Deletions are not automated; they require explicit user selection and confirmation.
|
- **Manual Confirmation**: Deletions are not automated; they require explicit user selection and confirmation.
|
||||||
- **OS-Level Enforcement**: Unlike the automated scripts, the analyzer relies on the operating system's built-in protections (e.g., inability to delete `/System` due to Read-Only Volume or SIP) rather than a hardcoded application-level blocklist.
|
- **OS-Level Enforcement**: Unlike the automated scripts, the analyzer relies on the operating system's built-in protections (e.g., inability to delete `/System` due to Read-Only Volume or SIP) rather than a hardcoded application-level blocklist.
|
||||||
|
|
||||||
## 3. Conservative Cleaning Logic
|
## 3. Conservative Cleaning Logic (Updated)
|
||||||
|
|
||||||
Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentionally conservative:
|
Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentionally conservative:
|
||||||
|
|
||||||
@@ -60,20 +60,20 @@ Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentio
|
|||||||
- **System Integrity Protection (SIP) Awareness**
|
- **System Integrity Protection (SIP) Awareness**
|
||||||
Mole respects macOS SIP. It detects if SIP is enabled and automatically skips protected directories (like `/Library/Updates`) to avoid triggering permission errors.
|
Mole respects macOS SIP. It detects if SIP is enabled and automatically skips protected directories (like `/Library/Updates`) to avoid triggering permission errors.
|
||||||
|
|
||||||
- **Spotlight Preservation (Critical Fix)**
|
- **Spotlight & System Settings Preservation**
|
||||||
User-level Spotlight caches (`~/Library/Metadata/CoreSpotlight`) are strictly excluded from automated cleaning. This prevents corruption of System Settings and ensures stable UI performance for indexed searches.
|
User-level Spotlight caches (`~/Library/Metadata/CoreSpotlight`) remain excluded to prevent UI corruption. New centralized `is_critical_system_component` guarding System Settings / Control Center / Background Task Management / SFL / TCC prevents accidental cleanup even when names change across macOS versions.
|
||||||
|
|
||||||
- **Time Machine Preservation**
|
- **Time Machine Preservation**
|
||||||
Before cleaning failed backups, Mole checks for the `backupd` process. If a backup is currently running, the cleanup task is strictly **aborted** to prevent data corruption.
|
Before cleaning failed backups, Mole checks for the `backupd` process and uses strict timeouts to avoid hangs. Cleanup is **aborted** if a backup is running or the destination is unresponsive.
|
||||||
|
|
||||||
- **VPN & Proxy Protection**
|
- **VPN & Proxy Protection**
|
||||||
Mole includes a comprehensive protection layer for VPN and Proxy applications (e.g., Shadowsocks, V2Ray, Tailscale). It protects both their application bundles and data directories from automated cleanup to prevent network configuration loss.
|
Mole includes a comprehensive protection layer for VPN and Proxy applications (e.g., Shadowsocks, V2Ray, Tailscale). It protects both their application bundles and data directories from automated cleanup to prevent network configuration loss.
|
||||||
|
|
||||||
- **AI & LLM Data Protection (New in v1.12.25)**
|
- **AI & LLM Data Protection**
|
||||||
Mole now explicitly protects data for AI tools (Cursor, Claude, ChatGPT, Ollama, LM Studio, etc.). Both the automated cleaning logic (`bin/clean.sh`) and orphan detection (`lib/core/app_protection.sh`) exclude these applications to prevent loss of:
|
Mole explicitly protects data for AI tools (Cursor, Claude, ChatGPT, Ollama, LM Studio, etc.). Automated cleaning and orphan detection exclude these apps to prevent loss of models, tokens, sessions, and configs.
|
||||||
- Local LLM models (which can be gigabytes in size).
|
|
||||||
- Authentication tokens and session states.
|
- **Safer Globbing**
|
||||||
- Chat history and local configurations.
|
Automated cleanup loops now use scoped `nullglob` to avoid unintended literal patterns when directories are empty, reducing edge-case surprises.
|
||||||
|
|
||||||
## 4. Atomic Operations & Crash Safety
|
## 4. Atomic Operations & Crash Safety
|
||||||
|
|
||||||
|
|||||||
BIN
bin/analyze-go
BIN
bin/analyze-go
Binary file not shown.
BIN
bin/status-go
BIN
bin/status-go
Binary file not shown.
@@ -27,44 +27,6 @@ total_items=0
|
|||||||
files_cleaned=0
|
files_cleaned=0
|
||||||
total_size_cleaned=0
|
total_size_cleaned=0
|
||||||
|
|
||||||
# Compact the "last used" descriptor for aligned summaries
|
|
||||||
format_last_used_summary() {
|
|
||||||
local value="$1"
|
|
||||||
|
|
||||||
case "$value" in
|
|
||||||
"" | "Unknown")
|
|
||||||
echo "Unknown"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
"Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old")
|
|
||||||
echo "$value"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then
|
|
||||||
echo "${BASH_REMATCH[1]}d ago"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then
|
|
||||||
echo "${BASH_REMATCH[1]}w ago"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then
|
|
||||||
echo "${BASH_REMATCH[1]}m ago"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then
|
|
||||||
echo "${BASH_REMATCH[1]}m ago"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then
|
|
||||||
echo "${BASH_REMATCH[1]}y ago"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "$value"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Scan applications and collect information
|
# Scan applications and collect information
|
||||||
scan_applications() {
|
scan_applications() {
|
||||||
# Application scan with intelligent caching (24h TTL)
|
# Application scan with intelligent caching (24h TTL)
|
||||||
@@ -211,9 +173,9 @@ scan_applications() {
|
|||||||
local last_used_epoch=0
|
local last_used_epoch=0
|
||||||
|
|
||||||
if [[ -d "$app_path" ]]; then
|
if [[ -d "$app_path" ]]; then
|
||||||
# Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed
|
# Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed
|
||||||
local metadata_date
|
local metadata_date
|
||||||
metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
||||||
|
|
||||||
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
|
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
|
||||||
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ scan_applications() {
|
|||||||
local last_used_epoch=0
|
local last_used_epoch=0
|
||||||
|
|
||||||
if [[ -d "$app_path" ]]; then
|
if [[ -d "$app_path" ]]; then
|
||||||
# Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed
|
# Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed
|
||||||
local metadata_date
|
local metadata_date
|
||||||
metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
||||||
|
|
||||||
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
|
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
|
||||||
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
@@ -28,10 +29,19 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
|||||||
var totalCount int64
|
var totalCount int64
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
for _, path := range paths {
|
// Delete deeper paths first to avoid parent removal triggering child not-exist errors
|
||||||
|
pathsToDelete := append([]string(nil), paths...)
|
||||||
|
sort.Slice(pathsToDelete, func(i, j int) bool {
|
||||||
|
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, path := range pathsToDelete {
|
||||||
count, err := deletePathWithProgress(path, counter)
|
count, err := deletePathWithProgress(path, counter)
|
||||||
totalCount += count
|
totalCount += count
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue // Parent already removed - not an actionable error
|
||||||
|
}
|
||||||
errors = append(errors, err.Error())
|
errors = append(errors, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
cmd/analyze/delete_test.go
Normal file
45
cmd/analyze/delete_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
parent := filepath.Join(base, "parent")
|
||||||
|
child := filepath.Join(parent, "child")
|
||||||
|
|
||||||
|
// Create structure:
|
||||||
|
// parent/fileA
|
||||||
|
// parent/child/fileC
|
||||||
|
if err := os.MkdirAll(child, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(parent, "fileA"), []byte("a"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fileA: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(child, "fileC"), []byte("c"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fileC: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter int64
|
||||||
|
msg := deleteMultiplePathsCmd([]string{parent, child}, &counter)()
|
||||||
|
progress, ok := msg.(deleteProgressMsg)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected deleteProgressMsg, got %T", msg)
|
||||||
|
}
|
||||||
|
if progress.err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", progress.err)
|
||||||
|
}
|
||||||
|
if progress.count != 2 {
|
||||||
|
t.Fatalf("expected 2 files deleted, got %d", progress.count)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(parent); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected parent to be removed, err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(child); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected child to be removed, err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -579,10 +579,12 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(pathsToDelete) == 1 {
|
if len(pathsToDelete) == 1 {
|
||||||
m.status = fmt.Sprintf("Deleting %s...", filepath.Base(pathsToDelete[0]))
|
targetPath := pathsToDelete[0]
|
||||||
} else {
|
m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath))
|
||||||
m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete))
|
return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete))
|
||||||
return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd())
|
return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd())
|
||||||
case "esc", "q":
|
case "esc", "q":
|
||||||
// Cancel delete with ESC or Q
|
// Cancel delete with ESC or Q
|
||||||
|
|||||||
@@ -607,49 +607,64 @@ func getDirectorySizeFromDu(path string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) {
|
func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
runDuSize := func(target string) (int64, error) {
|
||||||
defer cancel()
|
if _, err := os.Stat(target); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{"-sk"}
|
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
||||||
// macOS du uses -I to ignore files/directories matching a pattern
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "du", "-sk", target)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return 0, fmt.Errorf("du timeout after %v", duTimeout)
|
||||||
|
}
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("du failed: %v", err)
|
||||||
|
}
|
||||||
|
fields := strings.Fields(stdout.String())
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0, fmt.Errorf("du output empty")
|
||||||
|
}
|
||||||
|
kb, err := strconv.ParseInt(fields[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to parse du output: %v", err)
|
||||||
|
}
|
||||||
|
if kb <= 0 {
|
||||||
|
return 0, fmt.Errorf("du size invalid: %d", kb)
|
||||||
|
}
|
||||||
|
return kb * 1024, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library"
|
||||||
if excludePath != "" {
|
if excludePath != "" {
|
||||||
// Extract just the directory name from the full path
|
totalSize, err := runDuSize(path)
|
||||||
excludeName := filepath.Base(excludePath)
|
if err != nil {
|
||||||
args = append(args, "-I", excludeName)
|
return 0, err
|
||||||
}
|
|
||||||
args = append(args, path)
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "du", args...)
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
return 0, fmt.Errorf("du timeout after %v", duTimeout)
|
|
||||||
}
|
}
|
||||||
if stderr.Len() > 0 {
|
excludeSize, err := runDuSize(excludePath)
|
||||||
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
excludeSize = 0
|
||||||
}
|
}
|
||||||
return 0, fmt.Errorf("du failed: %v", err)
|
if excludeSize > totalSize {
|
||||||
|
excludeSize = 0
|
||||||
|
}
|
||||||
|
return totalSize - excludeSize, nil
|
||||||
}
|
}
|
||||||
fields := strings.Fields(stdout.String())
|
|
||||||
if len(fields) == 0 {
|
return runDuSize(path)
|
||||||
return 0, fmt.Errorf("du output empty")
|
|
||||||
}
|
|
||||||
kb, err := strconv.ParseInt(fields[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to parse du output: %v", err)
|
|
||||||
}
|
|
||||||
if kb <= 0 {
|
|
||||||
return 0, fmt.Errorf("du size invalid: %d", kb)
|
|
||||||
}
|
|
||||||
return kb * 1024, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDirectoryLogicalSize(path string) (int64, error) {
|
|
||||||
return getDirectoryLogicalSizeWithExclude(path, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) {
|
func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
45
cmd/analyze/scanner_test.go
Normal file
45
cmd/analyze/scanner_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFileWithSize(t *testing.T, path string, size int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
content := make([]byte, size)
|
||||||
|
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
homeFile := filepath.Join(base, "fileA")
|
||||||
|
libFile := filepath.Join(base, "Library", "fileB")
|
||||||
|
projectLibFile := filepath.Join(base, "Projects", "Library", "fileC")
|
||||||
|
|
||||||
|
writeFileWithSize(t, homeFile, 100)
|
||||||
|
writeFileWithSize(t, libFile, 200)
|
||||||
|
writeFileWithSize(t, projectLibFile, 300)
|
||||||
|
|
||||||
|
total, err := getDirectoryLogicalSizeWithExclude(base, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getDirectoryLogicalSizeWithExclude (no exclude) error: %v", err)
|
||||||
|
}
|
||||||
|
if total != 600 {
|
||||||
|
t.Fatalf("expected total 600 bytes, got %d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
excluding, err := getDirectoryLogicalSizeWithExclude(base, filepath.Join(base, "Library"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getDirectoryLogicalSizeWithExclude (exclude Library) error: %v", err)
|
||||||
|
}
|
||||||
|
if excluding != 400 {
|
||||||
|
t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -484,10 +484,17 @@ clean_project_artifacts() {
|
|||||||
local path="$1"
|
local path="$1"
|
||||||
|
|
||||||
# Find the project root by looking for direct child of search paths
|
# Find the project root by looking for direct child of search paths
|
||||||
local search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
|
local search_roots=()
|
||||||
|
if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then
|
||||||
|
search_roots=("${PURGE_SEARCH_PATHS[@]}")
|
||||||
|
else
|
||||||
|
search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
|
||||||
|
fi
|
||||||
|
|
||||||
for root in "${search_roots[@]}"; do
|
for root in "${search_roots[@]}"; do
|
||||||
if [[ "$path" == "$root/"* ]]; then
|
# Normalize trailing slash for consistent matching
|
||||||
|
root="${root%/}"
|
||||||
|
if [[ -n "$root" && "$path" == "$root/"* ]]; then
|
||||||
# Remove root prefix and get first directory component
|
# Remove root prefix and get first directory component
|
||||||
local relative_path="${path#"$root"/}"
|
local relative_path="${path#"$root"/}"
|
||||||
# Extract first directory name
|
# Extract first directory name
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ clean_sandboxed_app_caches() {
|
|||||||
local containers_dir="$HOME/Library/Containers"
|
local containers_dir="$HOME/Library/Containers"
|
||||||
[[ ! -d "$containers_dir" ]] && return 0
|
[[ ! -d "$containers_dir" ]] && return 0
|
||||||
|
|
||||||
|
# Enable nullglob for safe globbing; restore afterwards
|
||||||
|
local _ng_state
|
||||||
|
_ng_state=$(shopt -p nullglob || true)
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
if [[ -t 1 ]]; then
|
if [[ -t 1 ]]; then
|
||||||
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning sandboxed apps..."
|
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning sandboxed apps..."
|
||||||
fi
|
fi
|
||||||
@@ -146,6 +151,9 @@ clean_sandboxed_app_caches() {
|
|||||||
((total_items++))
|
((total_items++))
|
||||||
note_activity
|
note_activity
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Restore nullglob to previous state
|
||||||
|
eval "$_ng_state"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Process a single container cache directory (reduces nesting)
|
# Process a single container cache directory (reduces nesting)
|
||||||
@@ -155,14 +163,10 @@ process_container_cache() {
|
|||||||
|
|
||||||
# Extract bundle ID and check protection status early
|
# Extract bundle ID and check protection status early
|
||||||
local bundle_id=$(basename "$container_dir")
|
local bundle_id=$(basename "$container_dir")
|
||||||
local bundle_id_lower=$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')
|
if is_critical_system_component "$bundle_id"; then
|
||||||
|
|
||||||
# Check explicit critical system components (case-insensitive regex)
|
|
||||||
if [[ "$bundle_id_lower" =~ backgroundtaskmanagement || "$bundle_id_lower" =~ loginitems || "$bundle_id_lower" =~ systempreferences || "$bundle_id_lower" =~ systemsettings || "$bundle_id_lower" =~ settings || "$bundle_id_lower" =~ preferences || "$bundle_id_lower" =~ controlcenter || "$bundle_id_lower" =~ biometrickit || "$bundle_id_lower" =~ sfl || "$bundle_id_lower" =~ tcc ]]; then
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')"; then
|
||||||
if should_protect_data "$bundle_id" || should_protect_data "$bundle_id_lower"; then
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -180,10 +184,14 @@ process_container_cache() {
|
|||||||
|
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
if [[ "$DRY_RUN" != "true" ]]; then
|
||||||
# Clean contents safely (rm -rf is restricted by safe_remove)
|
# Clean contents safely (rm -rf is restricted by safe_remove)
|
||||||
|
local _ng_item_state
|
||||||
|
_ng_item_state=$(shopt -p nullglob || true)
|
||||||
|
shopt -s nullglob
|
||||||
for item in "$cache_dir"/*; do
|
for item in "$cache_dir"/*; do
|
||||||
[[ -e "$item" ]] || continue
|
[[ -e "$item" ]] || continue
|
||||||
safe_remove "$item" true || true
|
safe_remove "$item" true || true
|
||||||
done
|
done
|
||||||
|
eval "$_ng_item_state"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -259,6 +267,9 @@ clean_application_support_logs() {
|
|||||||
local found_any=false
|
local found_any=false
|
||||||
|
|
||||||
# Clean log directories and cache patterns
|
# Clean log directories and cache patterns
|
||||||
|
local _ng_app_state
|
||||||
|
_ng_app_state=$(shopt -p nullglob || true)
|
||||||
|
shopt -s nullglob
|
||||||
for app_dir in ~/Library/Application\ Support/*; do
|
for app_dir in ~/Library/Application\ Support/*; do
|
||||||
[[ -d "$app_dir" ]] || continue
|
[[ -d "$app_dir" ]] || continue
|
||||||
|
|
||||||
@@ -276,7 +287,7 @@ clean_application_support_logs() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$app_name_lower" =~ backgroundtaskmanagement || "$app_name_lower" =~ loginitems || "$app_name_lower" =~ systempreferences || "$app_name_lower" =~ systemsettings || "$app_name_lower" =~ settings || "$app_name_lower" =~ preferences || "$app_name_lower" =~ controlcenter || "$app_name_lower" =~ biometrickit || "$app_name_lower" =~ sfl || "$app_name_lower" =~ tcc ]]; then
|
if is_critical_system_component "$app_name"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -291,15 +302,20 @@ clean_application_support_logs() {
|
|||||||
found_any=true
|
found_any=true
|
||||||
|
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
if [[ "$DRY_RUN" != "true" ]]; then
|
||||||
|
local _ng_candidate_state
|
||||||
|
_ng_candidate_state=$(shopt -p nullglob || true)
|
||||||
|
shopt -s nullglob
|
||||||
for item in "$candidate"/*; do
|
for item in "$candidate"/*; do
|
||||||
[[ -e "$item" ]] || continue
|
[[ -e "$item" ]] || continue
|
||||||
safe_remove "$item" true > /dev/null 2>&1 || true
|
safe_remove "$item" true > /dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
|
eval "$_ng_candidate_state"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
eval "$_ng_app_state"
|
||||||
|
|
||||||
# Clean Group Containers logs
|
# Clean Group Containers logs
|
||||||
local known_group_containers=(
|
local known_group_containers=(
|
||||||
|
|||||||
@@ -435,6 +435,24 @@ readonly DATA_PROTECTED_BUNDLES=(
|
|||||||
"org.sparkle-project.Sparkle" # Sparkle (update framework)
|
"org.sparkle-project.Sparkle" # Sparkle (update framework)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Centralized check for critical system components (case-insensitive)
|
||||||
|
is_critical_system_component() {
|
||||||
|
local token="$1"
|
||||||
|
[[ -z "$token" ]] && return 1
|
||||||
|
|
||||||
|
local lower
|
||||||
|
lower=$(echo "$token" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
*backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc* )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# Legacy function - preserved for backward compatibility
|
# Legacy function - preserved for backward compatibility
|
||||||
# Use should_protect_from_uninstall() or should_protect_data() instead
|
# Use should_protect_from_uninstall() or should_protect_data() instead
|
||||||
readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
|
readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ get_display_width() {
|
|||||||
local padding=$((extra_bytes / 2))
|
local padding=$((extra_bytes / 2))
|
||||||
width=$((char_count + padding))
|
width=$((char_count + padding))
|
||||||
|
|
||||||
|
# Adjust for zero-width joiners and emoji variation selectors (common in filenames/emojis)
|
||||||
|
# These characters add bytes but no visible width; subtract their count if present.
|
||||||
|
local zwj=$'\u200d' # zero-width joiner
|
||||||
|
local vs16=$'\ufe0f' # emoji variation selector
|
||||||
|
local zero_width=0
|
||||||
|
|
||||||
|
local without_zwj=${str//$zwj/}
|
||||||
|
zero_width=$((zero_width + (char_count - ${#without_zwj})))
|
||||||
|
|
||||||
|
local without_vs=${str//$vs16/}
|
||||||
|
zero_width=$((zero_width + (char_count - ${#without_vs})))
|
||||||
|
|
||||||
|
if ((zero_width > 0 && width > zero_width)); then
|
||||||
|
width=$((width - zero_width))
|
||||||
|
fi
|
||||||
|
|
||||||
echo "$width"
|
echo "$width"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
tests/app_protection.bats
Normal file
32
tests/app_protection.bats
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
|
||||||
|
setup_file() {
|
||||||
|
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
|
||||||
|
export PROJECT_ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "is_critical_system_component matches known system services" {
|
||||||
|
run bash --noprofile --norc <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
source "$PROJECT_ROOT/lib/core/app_protection.sh"
|
||||||
|
is_critical_system_component "backgroundtaskmanagement" && echo "yes"
|
||||||
|
is_critical_system_component "SystemSettings" && echo "yes"
|
||||||
|
EOF
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "${lines[0]}" == "yes" ]]
|
||||||
|
[[ "${lines[1]}" == "yes" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "is_critical_system_component ignores non-system names" {
|
||||||
|
run bash --noprofile --norc <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
source "$PROJECT_ROOT/lib/core/app_protection.sh"
|
||||||
|
if is_critical_system_component "myapp"; then
|
||||||
|
echo "bad"
|
||||||
|
else
|
||||||
|
echo "ok"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == "ok" ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user