mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 12:41:46 +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
|
||||
|
||||
**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
|
||||
|
||||
@@ -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.
|
||||
- **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:
|
||||
|
||||
@@ -60,20 +60,20 @@ Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentio
|
||||
- **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.
|
||||
|
||||
- **Spotlight Preservation (Critical Fix)**
|
||||
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.
|
||||
- **Spotlight & System Settings Preservation**
|
||||
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**
|
||||
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**
|
||||
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)**
|
||||
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:
|
||||
- Local LLM models (which can be gigabytes in size).
|
||||
- Authentication tokens and session states.
|
||||
- Chat history and local configurations.
|
||||
- **AI & LLM Data Protection**
|
||||
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.
|
||||
|
||||
- **Safer Globbing**
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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() {
|
||||
# Application scan with intelligent caching (24h TTL)
|
||||
@@ -211,9 +173,9 @@ scan_applications() {
|
||||
local last_used_epoch=0
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -28,10 +29,19 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
||||
var totalCount int64
|
||||
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)
|
||||
totalCount += count
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue // Parent already removed - not an actionable 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 {
|
||||
m.status = fmt.Sprintf("Deleting %s...", filepath.Base(pathsToDelete[0]))
|
||||
} else {
|
||||
m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete))
|
||||
targetPath := pathsToDelete[0]
|
||||
m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath))
|
||||
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())
|
||||
case "esc", "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) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
||||
defer cancel()
|
||||
runDuSize := func(target string) (int64, error) {
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
args := []string{"-sk"}
|
||||
// macOS du uses -I to ignore files/directories matching a pattern
|
||||
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
||||
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 != "" {
|
||||
// Extract just the directory name from the full path
|
||||
excludeName := filepath.Base(excludePath)
|
||||
args = append(args, "-I", excludeName)
|
||||
}
|
||||
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)
|
||||
totalSize, err := runDuSize(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
|
||||
excludeSize, err := runDuSize(excludePath)
|
||||
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 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
|
||||
|
||||
return runDuSize(path)
|
||||
}
|
||||
|
||||
func getDirectoryLogicalSize(path string) (int64, error) {
|
||||
return getDirectoryLogicalSizeWithExclude(path, "")
|
||||
}
|
||||
|
||||
func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) {
|
||||
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"
|
||||
|
||||
# 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
|
||||
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
|
||||
local relative_path="${path#"$root"/}"
|
||||
# Extract first directory name
|
||||
|
||||
@@ -119,6 +119,11 @@ clean_sandboxed_app_caches() {
|
||||
local containers_dir="$HOME/Library/Containers"
|
||||
[[ ! -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
|
||||
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning sandboxed apps..."
|
||||
fi
|
||||
@@ -146,6 +151,9 @@ clean_sandboxed_app_caches() {
|
||||
((total_items++))
|
||||
note_activity
|
||||
fi
|
||||
|
||||
# Restore nullglob to previous state
|
||||
eval "$_ng_state"
|
||||
}
|
||||
|
||||
# Process a single container cache directory (reduces nesting)
|
||||
@@ -155,14 +163,10 @@ process_container_cache() {
|
||||
|
||||
# Extract bundle ID and check protection status early
|
||||
local bundle_id=$(basename "$container_dir")
|
||||
local bundle_id_lower=$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# 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
|
||||
if is_critical_system_component "$bundle_id"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if should_protect_data "$bundle_id" || should_protect_data "$bundle_id_lower"; then
|
||||
if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -180,10 +184,14 @@ process_container_cache() {
|
||||
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
# 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
|
||||
[[ -e "$item" ]] || continue
|
||||
safe_remove "$item" true || true
|
||||
done
|
||||
eval "$_ng_item_state"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -259,6 +267,9 @@ clean_application_support_logs() {
|
||||
local found_any=false
|
||||
|
||||
# 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
|
||||
[[ -d "$app_dir" ]] || continue
|
||||
|
||||
@@ -276,7 +287,7 @@ clean_application_support_logs() {
|
||||
continue
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -291,15 +302,20 @@ clean_application_support_logs() {
|
||||
found_any=true
|
||||
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
local _ng_candidate_state
|
||||
_ng_candidate_state=$(shopt -p nullglob || true)
|
||||
shopt -s nullglob
|
||||
for item in "$candidate"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
safe_remove "$item" true > /dev/null 2>&1 || true
|
||||
done
|
||||
eval "$_ng_candidate_state"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
eval "$_ng_app_state"
|
||||
|
||||
# Clean Group Containers logs
|
||||
local known_group_containers=(
|
||||
|
||||
@@ -435,6 +435,24 @@ readonly DATA_PROTECTED_BUNDLES=(
|
||||
"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
|
||||
# Use should_protect_from_uninstall() or should_protect_data() instead
|
||||
readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
|
||||
|
||||
@@ -60,6 +60,22 @@ get_display_width() {
|
||||
local padding=$((extra_bytes / 2))
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
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