1
0
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:
Tw93
2025-12-22 11:24:04 +08:00
parent 5c6e643b0c
commit d2dc68da90
16 changed files with 270 additions and 102 deletions

View File

@@ -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
analyze Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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")

View File

@@ -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")

View File

@@ -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())
} }
} }

View 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)
}
}

View File

@@ -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

View File

@@ -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

View 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)
}
}

View File

@@ -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

View File

@@ -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=(

View File

@@ -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[@]}")

View File

@@ -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
View 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" ]]
}