mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 20:19:45 +00:00
Previously, apps matching DATA_PROTECTED_BUNDLES patterns (VPNs, dev tools, etc.) could not be uninstalled because should_protect_path blocked their deletion. Now use MOLE_UNINSTALL_MODE to distinguish between cleanup and explicit uninstall, allowing users to remove these apps when they choose to while still protecting their data during normal cleanup operations. Also allow deletion of installer receipts in /private/var/db/receipts/.
416 lines
14 KiB
Bash
416 lines
14 KiB
Bash
#!/bin/bash
|
|
# Mole - File Operations
|
|
# Safe file and directory manipulation with validation
|
|
|
|
set -euo pipefail
|
|
|
|
# Prevent multiple sourcing
|
|
if [[ -n "${MOLE_FILE_OPS_LOADED:-}" ]]; then
|
|
return 0
|
|
fi
|
|
readonly MOLE_FILE_OPS_LOADED=1
|
|
|
|
# Ensure dependencies are loaded
|
|
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then
|
|
# shellcheck source=lib/core/base.sh
|
|
source "$_MOLE_CORE_DIR/base.sh"
|
|
fi
|
|
if [[ -z "${MOLE_LOG_LOADED:-}" ]]; then
|
|
# shellcheck source=lib/core/log.sh
|
|
source "$_MOLE_CORE_DIR/log.sh"
|
|
fi
|
|
if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then
|
|
# shellcheck source=lib/core/timeout.sh
|
|
source "$_MOLE_CORE_DIR/timeout.sh"
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Path Validation
|
|
# ============================================================================
|
|
|
|
# Validate path for deletion (absolute, no traversal, not system dir)
|
|
validate_path_for_deletion() {
|
|
local path="$1"
|
|
|
|
# Check path is not empty
|
|
if [[ -z "$path" ]]; then
|
|
log_error "Path validation failed: empty path"
|
|
return 1
|
|
fi
|
|
|
|
# Check symlink target if path is a symbolic link
|
|
if [[ -L "$path" ]]; then
|
|
local link_target
|
|
link_target=$(readlink "$path" 2> /dev/null) || {
|
|
log_error "Cannot read symlink: $path"
|
|
return 1
|
|
}
|
|
|
|
# Resolve relative symlinks to absolute paths for validation
|
|
local resolved_target="$link_target"
|
|
if [[ "$link_target" != /* ]]; then
|
|
local link_dir
|
|
link_dir=$(dirname "$path")
|
|
resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") || resolved_target=""
|
|
fi
|
|
|
|
# Validate resolved target against protected paths
|
|
if [[ -n "$resolved_target" ]]; then
|
|
case "$resolved_target" in
|
|
/System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*)
|
|
log_error "Symlink points to protected system path: $path -> $resolved_target"
|
|
return 1
|
|
;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
# Check path is absolute
|
|
if [[ "$path" != /* ]]; then
|
|
log_error "Path validation failed: path must be absolute: $path"
|
|
return 1
|
|
fi
|
|
|
|
# Check for path traversal attempts
|
|
# Only reject .. when it appears as a complete path component (/../ or /.. or ../)
|
|
# This allows legitimate directory names containing .. (e.g., Firefox's "name..files")
|
|
if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then
|
|
log_error "Path validation failed: path traversal not allowed: $path"
|
|
return 1
|
|
fi
|
|
|
|
# Check path doesn't contain dangerous characters
|
|
if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then
|
|
log_error "Path validation failed: contains control characters: $path"
|
|
return 1
|
|
fi
|
|
|
|
# Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt)
|
|
case "$path" in
|
|
/System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
# Allow known safe paths under /private
|
|
case "$path" in
|
|
/private/tmp | /private/tmp/* | \
|
|
/private/var/tmp | /private/var/tmp/* | \
|
|
/private/var/log | /private/var/log/* | \
|
|
/private/var/folders | /private/var/folders/* | \
|
|
/private/var/db/diagnostics | /private/var/db/diagnostics/* | \
|
|
/private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \
|
|
/private/var/db/powerlog | /private/var/db/powerlog/* | \
|
|
/private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \
|
|
/private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
# Check path isn't critical system directory
|
|
case "$path" in
|
|
/ | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions)
|
|
log_error "Path validation failed: critical system directory: $path"
|
|
return 1
|
|
;;
|
|
/private)
|
|
log_error "Path validation failed: critical system directory: $path"
|
|
return 1
|
|
;;
|
|
/etc | /etc/* | /private/etc | /private/etc/*)
|
|
log_error "Path validation failed: /etc contains critical system files: $path"
|
|
return 1
|
|
;;
|
|
/var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*)
|
|
log_error "Path validation failed: /var/db contains system databases: $path"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
# Check if path is protected (keychains, system settings, etc)
|
|
if declare -f should_protect_path > /dev/null 2>&1; then
|
|
if should_protect_path "$path"; then
|
|
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
|
log_warning "Path validation: protected path skipped: $path"
|
|
fi
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# ============================================================================
|
|
# Safe Removal Operations
|
|
# ============================================================================
|
|
|
|
# Safe wrapper around rm -rf with validation
|
|
safe_remove() {
|
|
local path="$1"
|
|
local silent="${2:-false}"
|
|
|
|
# Validate path
|
|
if ! validate_path_for_deletion "$path"; then
|
|
return 1
|
|
fi
|
|
|
|
# Check if path exists
|
|
if [[ ! -e "$path" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
# Dry-run mode: log but don't delete
|
|
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
|
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
|
local file_type="file"
|
|
[[ -d "$path" ]] && file_type="directory"
|
|
[[ -L "$path" ]] && file_type="symlink"
|
|
|
|
local file_size=""
|
|
local file_age=""
|
|
|
|
if [[ -e "$path" ]]; then
|
|
local size_kb
|
|
size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0")
|
|
if [[ "$size_kb" -gt 0 ]]; then
|
|
file_size=$(bytes_to_human "$((size_kb * 1024))")
|
|
fi
|
|
|
|
if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then
|
|
local mod_time
|
|
mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0")
|
|
local now
|
|
now=$(date +%s 2> /dev/null || echo "0")
|
|
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
|
|
file_age=$(((now - mod_time) / 86400))
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
debug_file_action "[DRY RUN] Would remove" "$path" "$file_size" "$file_age"
|
|
else
|
|
debug_log "[DRY RUN] Would remove: $path"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
debug_log "Removing: $path"
|
|
|
|
# Perform the deletion
|
|
# Use || to capture the exit code so set -e won't abort on rm failures
|
|
local error_msg
|
|
local rm_exit=0
|
|
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove
|
|
|
|
if [[ $rm_exit -eq 0 ]]; then
|
|
return 0
|
|
else
|
|
# Check if it's a permission error
|
|
if [[ "$error_msg" == *"Permission denied"* ]] || [[ "$error_msg" == *"Operation not permitted"* ]]; then
|
|
MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
|
MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1))
|
|
export MOLE_PERMISSION_DENIED_COUNT
|
|
debug_log "Permission denied: $path (may need Full Disk Access)"
|
|
else
|
|
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
|
|
fi
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Safe sudo removal with symlink protection
|
|
safe_sudo_remove() {
|
|
local path="$1"
|
|
|
|
# Validate path
|
|
if ! validate_path_for_deletion "$path"; then
|
|
log_error "Path validation failed for sudo remove: $path"
|
|
return 1
|
|
fi
|
|
|
|
# Check if path exists
|
|
if [[ ! -e "$path" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
# Additional check: reject symlinks for sudo operations
|
|
if [[ -L "$path" ]]; then
|
|
log_error "Refusing to sudo remove symlink: $path"
|
|
return 1
|
|
fi
|
|
|
|
# Dry-run mode: log but don't delete
|
|
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
|
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
|
local file_type="file"
|
|
[[ -d "$path" ]] && file_type="directory"
|
|
|
|
local file_size=""
|
|
local file_age=""
|
|
|
|
if sudo test -e "$path" 2> /dev/null; then
|
|
local size_kb
|
|
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
|
if [[ "$size_kb" -gt 0 ]]; then
|
|
file_size=$(bytes_to_human "$((size_kb * 1024))")
|
|
fi
|
|
|
|
if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then
|
|
local mod_time
|
|
mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0")
|
|
local now
|
|
now=$(date +%s 2> /dev/null || echo "0")
|
|
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
|
|
file_age=$(((now - mod_time) / 86400))
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
debug_file_action "[DRY RUN] Would remove (sudo)" "$path" "$file_size" "$file_age"
|
|
else
|
|
debug_log "[DRY RUN] Would remove (sudo): $path"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
debug_log "Removing (sudo): $path"
|
|
|
|
# Perform the deletion
|
|
if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation
|
|
return 0
|
|
else
|
|
log_error "Failed to remove (sudo): $path"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# Safe Find and Delete Operations
|
|
# ============================================================================
|
|
|
|
# Safe file discovery and deletion with depth and age limits
|
|
safe_find_delete() {
|
|
local base_dir="$1"
|
|
local pattern="$2"
|
|
local age_days="${3:-7}"
|
|
local type_filter="${4:-f}"
|
|
|
|
# Validate base directory exists and is not a symlink
|
|
if [[ ! -d "$base_dir" ]]; then
|
|
log_error "Directory does not exist: $base_dir"
|
|
return 1
|
|
fi
|
|
|
|
if [[ -L "$base_dir" ]]; then
|
|
log_error "Refusing to search symlinked directory: $base_dir"
|
|
return 1
|
|
fi
|
|
|
|
# Validate type filter
|
|
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
|
|
log_error "Invalid type filter: $type_filter (must be 'f' or 'd')"
|
|
return 1
|
|
fi
|
|
|
|
debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
|
|
|
|
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
|
|
if [[ "$age_days" -gt 0 ]]; then
|
|
find_args+=("-mtime" "+$age_days")
|
|
fi
|
|
|
|
# Iterate results to respect should_protect_path
|
|
while IFS= read -r -d '' match; do
|
|
if should_protect_path "$match"; then
|
|
continue
|
|
fi
|
|
safe_remove "$match" true || true
|
|
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
|
|
|
return 0
|
|
}
|
|
|
|
# Safe sudo discovery and deletion
|
|
safe_sudo_find_delete() {
|
|
local base_dir="$1"
|
|
local pattern="$2"
|
|
local age_days="${3:-7}"
|
|
local type_filter="${4:-f}"
|
|
|
|
# Validate base directory (use sudo for permission-restricted dirs)
|
|
if ! sudo test -d "$base_dir" 2> /dev/null; then
|
|
debug_log "Directory does not exist (skipping): $base_dir"
|
|
return 0
|
|
fi
|
|
|
|
if sudo test -L "$base_dir" 2> /dev/null; then
|
|
log_error "Refusing to search symlinked directory: $base_dir"
|
|
return 1
|
|
fi
|
|
|
|
# Validate type filter
|
|
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
|
|
log_error "Invalid type filter: $type_filter (must be 'f' or 'd')"
|
|
return 1
|
|
fi
|
|
|
|
debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
|
|
|
|
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
|
|
if [[ "$age_days" -gt 0 ]]; then
|
|
find_args+=("-mtime" "+$age_days")
|
|
fi
|
|
|
|
# Iterate results to respect should_protect_path
|
|
while IFS= read -r -d '' match; do
|
|
if should_protect_path "$match"; then
|
|
continue
|
|
fi
|
|
safe_sudo_remove "$match" || true
|
|
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
|
|
|
return 0
|
|
}
|
|
|
|
# ============================================================================
|
|
# Size Calculation
|
|
# ============================================================================
|
|
|
|
# Get path size in KB (returns 0 if not found)
|
|
get_path_size_kb() {
|
|
local path="$1"
|
|
[[ -z "$path" || ! -e "$path" ]] && {
|
|
echo "0"
|
|
return
|
|
}
|
|
# Direct execution without timeout overhead - critical for performance in loops
|
|
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
|
|
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
|
|
local size
|
|
size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
|
|
|
|
# Ensure size is a valid number (fix for non-numeric du output)
|
|
if [[ "$size" =~ ^[0-9]+$ ]]; then
|
|
echo "$size"
|
|
else
|
|
echo "0"
|
|
fi
|
|
}
|
|
|
|
# Calculate total size for multiple paths
|
|
calculate_total_size() {
|
|
local files="$1"
|
|
local total_kb=0
|
|
|
|
while IFS= read -r file; do
|
|
if [[ -n "$file" && -e "$file" ]]; then
|
|
local size_kb
|
|
size_kb=$(get_path_size_kb "$file")
|
|
((total_kb += size_kb))
|
|
fi
|
|
done <<< "$files"
|
|
|
|
echo "$total_kb"
|
|
}
|