2025-12-08 15:33:52 +08:00
|
|
|
#!/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
|
|
|
|
|
|
2026-02-02 17:06:00 +08:00
|
|
|
# Error codes for removal operations
|
|
|
|
|
readonly MOLE_ERR_SIP_PROTECTED=10
|
|
|
|
|
readonly MOLE_ERR_AUTH_FAILED=11
|
|
|
|
|
readonly MOLE_ERR_READONLY_FS=12
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
# 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
|
|
|
|
|
|
2026-02-02 17:06:00 +08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Utility Functions
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
# Format duration in seconds to human readable string (e.g., "5 days", "2 months")
|
|
|
|
|
format_duration_human() {
|
|
|
|
|
local seconds="${1:-0}"
|
|
|
|
|
[[ ! "$seconds" =~ ^[0-9]+$ ]] && seconds=0
|
|
|
|
|
|
|
|
|
|
local days=$((seconds / 86400))
|
|
|
|
|
|
|
|
|
|
if [[ $days -eq 0 ]]; then
|
|
|
|
|
echo "today"
|
|
|
|
|
elif [[ $days -eq 1 ]]; then
|
|
|
|
|
echo "1 day"
|
|
|
|
|
elif [[ $days -lt 7 ]]; then
|
|
|
|
|
echo "${days} days"
|
|
|
|
|
elif [[ $days -lt 30 ]]; then
|
|
|
|
|
local weeks=$((days / 7))
|
|
|
|
|
[[ $weeks -eq 1 ]] && echo "1 week" || echo "${weeks} weeks"
|
|
|
|
|
elif [[ $days -lt 365 ]]; then
|
|
|
|
|
local months=$((days / 30))
|
|
|
|
|
[[ $months -eq 1 ]] && echo "1 month" || echo "${months} months"
|
|
|
|
|
else
|
|
|
|
|
local years=$((days / 365))
|
|
|
|
|
[[ $years -eq 1 ]] && echo "1 year" || echo "${years} years"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Path Validation
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Validate path for deletion (absolute, no traversal, not system dir)
|
2025-12-08 15:33:52 +08:00
|
|
|
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
|
|
|
|
|
|
2026-01-17 09:09:11 +08:00
|
|
|
# Check symlink target if path is a symbolic link
|
|
|
|
|
if [[ -L "$path" ]]; then
|
|
|
|
|
local link_target
|
2026-01-26 07:24:38 +00:00
|
|
|
link_target=$(readlink "$path" 2> /dev/null) || {
|
2026-01-17 09:09:11 +08:00
|
|
|
log_error "Cannot read symlink: $path"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 09:49:42 +08:00
|
|
|
# Resolve relative symlinks to absolute paths for validation
|
|
|
|
|
local resolved_target="$link_target"
|
|
|
|
|
if [[ "$link_target" != /* ]]; then
|
|
|
|
|
local link_dir
|
|
|
|
|
link_dir=$(dirname "$path")
|
2026-01-26 07:24:38 +00:00
|
|
|
resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") || resolved_target=""
|
2026-01-17 09:49:42 +08:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Validate resolved target against protected paths
|
|
|
|
|
if [[ -n "$resolved_target" ]]; then
|
|
|
|
|
case "$resolved_target" in
|
2026-03-10 15:27:24 +08:00
|
|
|
/ | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | \
|
|
|
|
|
/usr | /usr/bin | /usr/bin/* | /usr/lib | /usr/lib/* | \
|
|
|
|
|
/etc | /etc/* | /private/etc | /private/etc/* | \
|
|
|
|
|
/Library/Extensions | /Library/Extensions/*)
|
2026-01-26 07:24:38 +00:00
|
|
|
log_error "Symlink points to protected system path: $path -> $resolved_target"
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
2026-01-17 09:09:11 +08:00
|
|
|
esac
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
# 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
|
2026-01-06 09:51:34 +08:00
|
|
|
# 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
|
2025-12-08 15:33:52 +08:00
|
|
|
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
|
|
|
|
|
|
2025-12-28 21:30:39 +08:00
|
|
|
# Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt)
|
|
|
|
|
case "$path" in
|
2026-01-26 07:24:38 +00:00
|
|
|
/System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*)
|
|
|
|
|
return 0
|
|
|
|
|
;;
|
2025-12-28 21:30:39 +08:00
|
|
|
esac
|
|
|
|
|
|
fix(uninstall): enhance receipt file processing safety and prevent system file deletion
CRITICAL SECURITY FIX
Enhanced the receipt file parsing in uninstall operations to prevent
accidental deletion of critical system files while maintaining deep
cleanup capabilities.
Changes:
- Tightened whitelist in find_app_receipt_files() to exclude /Users/*,
/usr/*, and /opt/* broad patterns
- Added explicit blacklist for /private/* with safe exceptions for
logs, temp files, and diagnostic data
- Integrated should_protect_path() check for additional protection
- Added file deduplication with sort -u to prevent duplicate deletions
- Removed dry-run feature from batch uninstall (unused entry point)
Path Protection:
✅ Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries
✅ Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths
✅ Additional: Keychain files, system preferences via should_protect_path()
This fixes a critical security issue where parsing .bom receipt files
could result in deletion of system files like /etc/passwd and /var/db/*,
leading to system corruption and data loss.
Affects: V1.12.14 and later versions
Testing: Validated against critical system paths, all blocked correctly
2026-01-15 21:01:11 +08:00
|
|
|
# Allow known safe paths under /private
|
|
|
|
|
case "$path" in
|
2026-01-26 07:24:38 +00:00
|
|
|
/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
|
|
|
|
|
;;
|
fix(uninstall): enhance receipt file processing safety and prevent system file deletion
CRITICAL SECURITY FIX
Enhanced the receipt file parsing in uninstall operations to prevent
accidental deletion of critical system files while maintaining deep
cleanup capabilities.
Changes:
- Tightened whitelist in find_app_receipt_files() to exclude /Users/*,
/usr/*, and /opt/* broad patterns
- Added explicit blacklist for /private/* with safe exceptions for
logs, temp files, and diagnostic data
- Integrated should_protect_path() check for additional protection
- Added file deduplication with sort -u to prevent duplicate deletions
- Removed dry-run feature from batch uninstall (unused entry point)
Path Protection:
✅ Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries
✅ Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths
✅ Additional: Keychain files, system preferences via should_protect_path()
This fixes a critical security issue where parsing .bom receipt files
could result in deletion of system files like /etc/passwd and /var/db/*,
leading to system corruption and data loss.
Affects: V1.12.14 and later versions
Testing: Validated against critical system paths, all blocked correctly
2026-01-15 21:01:11 +08:00
|
|
|
esac
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
# Check path isn't critical system directory
|
|
|
|
|
case "$path" in
|
2026-01-26 07:24:38 +00:00
|
|
|
/ | /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
|
|
|
|
|
;;
|
2025-12-08 15:33:52 +08:00
|
|
|
esac
|
|
|
|
|
|
fix(uninstall): enhance receipt file processing safety and prevent system file deletion
CRITICAL SECURITY FIX
Enhanced the receipt file parsing in uninstall operations to prevent
accidental deletion of critical system files while maintaining deep
cleanup capabilities.
Changes:
- Tightened whitelist in find_app_receipt_files() to exclude /Users/*,
/usr/*, and /opt/* broad patterns
- Added explicit blacklist for /private/* with safe exceptions for
logs, temp files, and diagnostic data
- Integrated should_protect_path() check for additional protection
- Added file deduplication with sort -u to prevent duplicate deletions
- Removed dry-run feature from batch uninstall (unused entry point)
Path Protection:
✅ Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries
✅ Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths
✅ Additional: Keychain files, system preferences via should_protect_path()
This fixes a critical security issue where parsing .bom receipt files
could result in deletion of system files like /etc/passwd and /var/db/*,
leading to system corruption and data loss.
Affects: V1.12.14 and later versions
Testing: Validated against critical system paths, all blocked correctly
2026-01-15 21:01:11 +08:00
|
|
|
# Check if path is protected (keychains, system settings, etc)
|
2026-01-26 07:24:38 +00:00
|
|
|
if declare -f should_protect_path > /dev/null 2>&1; then
|
fix(uninstall): enhance receipt file processing safety and prevent system file deletion
CRITICAL SECURITY FIX
Enhanced the receipt file parsing in uninstall operations to prevent
accidental deletion of critical system files while maintaining deep
cleanup capabilities.
Changes:
- Tightened whitelist in find_app_receipt_files() to exclude /Users/*,
/usr/*, and /opt/* broad patterns
- Added explicit blacklist for /private/* with safe exceptions for
logs, temp files, and diagnostic data
- Integrated should_protect_path() check for additional protection
- Added file deduplication with sort -u to prevent duplicate deletions
- Removed dry-run feature from batch uninstall (unused entry point)
Path Protection:
✅ Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries
✅ Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths
✅ Additional: Keychain files, system preferences via should_protect_path()
This fixes a critical security issue where parsing .bom receipt files
could result in deletion of system files like /etc/passwd and /var/db/*,
leading to system corruption and data loss.
Affects: V1.12.14 and later versions
Testing: Validated against critical system paths, all blocked correctly
2026-01-15 21:01:11 +08:00
|
|
|
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
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Safe Removal Operations
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Safe wrapper around rm -rf with validation
|
2025-12-08 15:33:52 +08:00
|
|
|
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
|
|
|
|
|
|
2025-12-30 14:38:02 +08:00
|
|
|
# Dry-run mode: log but don't delete
|
|
|
|
|
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
2026-01-04 17:30:36 +08:00
|
|
|
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
|
2026-01-26 07:24:38 +00:00
|
|
|
size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0")
|
2026-01-04 17:30:36 +08:00
|
|
|
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
|
2026-01-26 07:24:38 +00:00
|
|
|
mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0")
|
2026-01-04 17:30:36 +08:00
|
|
|
local now
|
2026-01-26 07:24:38 +00:00
|
|
|
now=$(date +%s 2> /dev/null || echo "0")
|
2026-01-04 17:30:36 +08:00
|
|
|
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
|
2026-01-04 09:52:09 +00:00
|
|
|
file_age=$(((now - mod_time) / 86400))
|
2026-01-04 17:30:36 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
debug_file_action "[DRY RUN] Would remove" "$path" "$file_size" "$file_age"
|
|
|
|
|
else
|
|
|
|
|
debug_log "[DRY RUN] Would remove: $path"
|
|
|
|
|
fi
|
2025-12-30 14:38:02 +08:00
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
debug_log "Removing: $path"
|
|
|
|
|
|
2026-01-26 15:22:07 +08:00
|
|
|
# Calculate size before deletion for logging
|
|
|
|
|
local size_kb=0
|
|
|
|
|
local size_human=""
|
|
|
|
|
if oplog_enabled; then
|
|
|
|
|
if [[ -e "$path" ]]; then
|
2026-01-26 07:24:38 +00:00
|
|
|
size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0")
|
2026-01-26 15:22:07 +08:00
|
|
|
if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then
|
2026-01-26 07:24:38 +00:00
|
|
|
size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB")
|
2026-01-26 15:22:07 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
# Perform the deletion
|
2025-12-29 14:27:47 +08:00
|
|
|
# Use || to capture the exit code so set -e won't abort on rm failures
|
|
|
|
|
local error_msg
|
|
|
|
|
local rm_exit=0
|
2025-12-29 15:15:52 +08:00
|
|
|
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove
|
2025-12-29 14:27:47 +08:00
|
|
|
|
2026-02-27 11:18:53 +08:00
|
|
|
# Preserve interrupt semantics so callers can abort long-running deletions.
|
|
|
|
|
if [[ $rm_exit -ge 128 ]]; then
|
|
|
|
|
return "$rm_exit"
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-29 14:27:47 +08:00
|
|
|
if [[ $rm_exit -eq 0 ]]; then
|
2026-01-26 15:22:07 +08:00
|
|
|
# Log successful removal
|
|
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human"
|
2025-12-08 15:33:52 +08:00
|
|
|
return 0
|
|
|
|
|
else
|
2025-12-29 14:27:47 +08:00
|
|
|
# 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
|
2026-01-26 14:36:06 +08:00
|
|
|
debug_log "Permission denied: $path, may need Full Disk Access"
|
2026-01-26 15:22:07 +08:00
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "permission denied"
|
2025-12-29 14:27:47 +08:00
|
|
|
else
|
|
|
|
|
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
|
2026-01-26 15:22:07 +08:00
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "error"
|
2025-12-29 14:27:47 +08:00
|
|
|
fi
|
2025-12-08 15:33:52 +08:00
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 17:06:00 +08:00
|
|
|
# Safe symlink removal (for pre-validated symlinks only)
|
|
|
|
|
safe_remove_symlink() {
|
|
|
|
|
local path="$1"
|
|
|
|
|
local use_sudo="${2:-false}"
|
|
|
|
|
|
|
|
|
|
if [[ ! -L "$path" ]]; then
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
|
|
|
|
debug_log "[DRY RUN] Would remove symlink: $path"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
local rm_exit=0
|
|
|
|
|
if [[ "$use_sudo" == "true" ]]; then
|
|
|
|
|
sudo rm "$path" 2> /dev/null || rm_exit=$?
|
|
|
|
|
else
|
|
|
|
|
rm "$path" 2> /dev/null || rm_exit=$?
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ $rm_exit -eq 0 ]]; then
|
|
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "symlink"
|
|
|
|
|
return 0
|
|
|
|
|
else
|
|
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "symlink removal failed"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Safe sudo removal with symlink protection
|
2025-12-08 15:33:52 +08:00
|
|
|
safe_sudo_remove() {
|
|
|
|
|
local path="$1"
|
|
|
|
|
|
|
|
|
|
if ! validate_path_for_deletion "$path"; then
|
|
|
|
|
log_error "Path validation failed for sudo remove: $path"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ ! -e "$path" ]]; then
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ -L "$path" ]]; then
|
|
|
|
|
log_error "Refusing to sudo remove symlink: $path"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-30 14:38:02 +08:00
|
|
|
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
2026-01-04 17:30:36 +08:00
|
|
|
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
|
|
|
|
local file_type="file"
|
|
|
|
|
[[ -d "$path" ]] && file_type="directory"
|
|
|
|
|
|
|
|
|
|
local file_size=""
|
|
|
|
|
local file_age=""
|
|
|
|
|
|
2026-01-26 07:24:38 +00:00
|
|
|
if sudo test -e "$path" 2> /dev/null; then
|
2026-01-04 17:30:36 +08:00
|
|
|
local size_kb
|
2026-01-30 15:06:30 +08:00
|
|
|
size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
2026-01-04 17:30:36 +08:00
|
|
|
if [[ "$size_kb" -gt 0 ]]; then
|
|
|
|
|
file_size=$(bytes_to_human "$((size_kb * 1024))")
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-26 07:24:38 +00:00
|
|
|
if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then
|
2026-01-04 17:30:36 +08:00
|
|
|
local mod_time
|
2026-01-26 07:24:38 +00:00
|
|
|
mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0")
|
2026-01-04 17:30:36 +08:00
|
|
|
local now
|
2026-01-26 07:24:38 +00:00
|
|
|
now=$(date +%s 2> /dev/null || echo "0")
|
2026-01-04 17:30:36 +08:00
|
|
|
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
|
2026-02-02 17:06:00 +08:00
|
|
|
local age_seconds=$((now - mod_time))
|
|
|
|
|
file_age=$(format_duration_human "$age_seconds")
|
2026-01-04 17:30:36 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-02-02 17:06:00 +08:00
|
|
|
log_info "[DRY-RUN] Would sudo remove: $file_type $path"
|
|
|
|
|
[[ -n "$file_size" ]] && log_info " Size: $file_size"
|
|
|
|
|
[[ -n "$file_age" ]] && log_info " Age: $file_age"
|
2026-01-04 17:30:36 +08:00
|
|
|
else
|
2026-02-02 17:06:00 +08:00
|
|
|
log_info "[DRY-RUN] Would sudo remove: $path"
|
2026-01-04 17:30:36 +08:00
|
|
|
fi
|
2025-12-30 14:38:02 +08:00
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-26 15:22:07 +08:00
|
|
|
local size_kb=0
|
|
|
|
|
local size_human=""
|
|
|
|
|
if oplog_enabled; then
|
2026-01-26 07:24:38 +00:00
|
|
|
if sudo test -e "$path" 2> /dev/null; then
|
2026-01-30 15:06:30 +08:00
|
|
|
size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
2026-01-26 15:22:07 +08:00
|
|
|
if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then
|
2026-01-26 07:24:38 +00:00
|
|
|
size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB")
|
2026-01-26 15:22:07 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-02-02 17:06:00 +08:00
|
|
|
local output
|
2026-02-07 17:48:55 +08:00
|
|
|
local ret=0
|
2026-02-07 11:21:37 +08:00
|
|
|
output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove
|
2026-02-02 17:06:00 +08:00
|
|
|
|
|
|
|
|
if [[ $ret -eq 0 ]]; then
|
2026-01-26 15:22:07 +08:00
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human"
|
2025-12-08 15:33:52 +08:00
|
|
|
return 0
|
|
|
|
|
fi
|
2026-02-02 17:06:00 +08:00
|
|
|
|
|
|
|
|
case "$output" in
|
|
|
|
|
*"Operation not permitted"*)
|
|
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sip/mdm protected"
|
|
|
|
|
return "$MOLE_ERR_SIP_PROTECTED"
|
|
|
|
|
;;
|
|
|
|
|
*"Read-only file system"*)
|
|
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "readonly filesystem"
|
|
|
|
|
return "$MOLE_ERR_READONLY_FS"
|
|
|
|
|
;;
|
2026-02-02 17:18:32 +08:00
|
|
|
*"Sorry, try again"* | *"incorrect passphrase"* | *"incorrect credentials"*)
|
2026-02-02 17:06:00 +08:00
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth failed"
|
|
|
|
|
return "$MOLE_ERR_AUTH_FAILED"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
log_error "Failed to remove, sudo: $path"
|
|
|
|
|
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error"
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
2025-12-08 15:33:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Safe Find and Delete Operations
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Safe file discovery and deletion with depth and age limits
|
2025-12-08 15:33:52 +08:00
|
|
|
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
|
2026-01-26 14:36:06 +08:00
|
|
|
log_error "Invalid type filter: $type_filter, must be 'f' or 'd'"
|
2025-12-08 15:33:52 +08:00
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-26 14:36:06 +08:00
|
|
|
debug_log "Finding in $base_dir: $pattern, age: ${age_days}d, type: $type_filter"
|
2025-12-08 15:33:52 +08:00
|
|
|
|
2025-12-18 20:17:03 +08:00
|
|
|
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
|
|
|
|
|
if [[ "$age_days" -gt 0 ]]; then
|
|
|
|
|
find_args+=("-mtime" "+$age_days")
|
2025-12-08 15:33:52 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-01-04 21:33:39 -05:00
|
|
|
# Iterate results to respect should_protect_path
|
2025-12-18 20:17:03 +08:00
|
|
|
while IFS= read -r -d '' match; do
|
2026-01-04 21:33:39 -05:00
|
|
|
if should_protect_path "$match"; then
|
|
|
|
|
continue
|
2025-12-18 20:17:03 +08:00
|
|
|
fi
|
|
|
|
|
safe_remove "$match" true || true
|
2026-01-26 07:24:38 +00:00
|
|
|
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
2025-12-18 20:17:03 +08:00
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Safe sudo discovery and deletion
|
2025-12-08 15:33:52 +08:00
|
|
|
safe_sudo_find_delete() {
|
|
|
|
|
local base_dir="$1"
|
|
|
|
|
local pattern="$2"
|
|
|
|
|
local age_days="${3:-7}"
|
|
|
|
|
local type_filter="${4:-f}"
|
|
|
|
|
|
2025-12-28 19:37:42 +08:00
|
|
|
# Validate base directory (use sudo for permission-restricted dirs)
|
2026-01-26 07:24:38 +00:00
|
|
|
if ! sudo test -d "$base_dir" 2> /dev/null; then
|
2026-01-26 14:36:06 +08:00
|
|
|
debug_log "Directory does not exist, skipping: $base_dir"
|
2025-12-28 19:37:42 +08:00
|
|
|
return 0
|
2025-12-08 15:33:52 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-01-26 07:24:38 +00:00
|
|
|
if sudo test -L "$base_dir" 2> /dev/null; then
|
2025-12-08 15:33:52 +08:00
|
|
|
log_error "Refusing to search symlinked directory: $base_dir"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Validate type filter
|
|
|
|
|
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
|
2026-01-26 14:36:06 +08:00
|
|
|
log_error "Invalid type filter: $type_filter, must be 'f' or 'd'"
|
2025-12-08 15:33:52 +08:00
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-26 14:36:06 +08:00
|
|
|
debug_log "Finding, sudo, in $base_dir: $pattern, age: ${age_days}d, type: $type_filter"
|
2025-12-08 15:33:52 +08:00
|
|
|
|
2026-02-04 16:18:13 +08:00
|
|
|
local find_args=("-maxdepth" "5")
|
|
|
|
|
# Skip -name if pattern is "*" (matches everything anyway, but adds overhead)
|
|
|
|
|
if [[ "$pattern" != "*" ]]; then
|
|
|
|
|
find_args+=("-name" "$pattern")
|
|
|
|
|
fi
|
|
|
|
|
find_args+=("-type" "$type_filter")
|
2025-12-18 20:17:03 +08:00
|
|
|
if [[ "$age_days" -gt 0 ]]; then
|
|
|
|
|
find_args+=("-mtime" "+$age_days")
|
2025-12-08 15:33:52 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-01-04 21:33:39 -05:00
|
|
|
# Iterate results to respect should_protect_path
|
2025-12-18 20:17:03 +08:00
|
|
|
while IFS= read -r -d '' match; do
|
2026-01-04 21:33:39 -05:00
|
|
|
if should_protect_path "$match"; then
|
|
|
|
|
continue
|
2025-12-18 20:17:03 +08:00
|
|
|
fi
|
|
|
|
|
safe_sudo_remove "$match" || true
|
2026-01-26 07:24:38 +00:00
|
|
|
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
2025-12-18 20:17:03 +08:00
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Size Calculation
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Get path size in KB (returns 0 if not found)
|
2025-12-08 15:33:52 +08:00
|
|
|
get_path_size_kb() {
|
|
|
|
|
local path="$1"
|
|
|
|
|
[[ -z "$path" || ! -e "$path" ]] && {
|
|
|
|
|
echo "0"
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-28 03:11:16 +00:00
|
|
|
|
2026-02-28 10:02:34 +08:00
|
|
|
# For .app bundles, prefer mdls logical size as it matches Finder
|
|
|
|
|
# (APFS clone/sparse files make 'du' severely underreport apps like Xcode)
|
|
|
|
|
if [[ "$path" == *.app || "$path" == *.app/ ]]; then
|
|
|
|
|
local mdls_size
|
|
|
|
|
mdls_size=$(mdls -name kMDItemLogicalSize -raw "$path" 2> /dev/null || true)
|
|
|
|
|
if [[ "$mdls_size" =~ ^[0-9]+$ && "$mdls_size" -gt 0 ]]; then
|
|
|
|
|
# Return in KB
|
|
|
|
|
echo "$((mdls_size / 1024))"
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-08 15:33:52 +08:00
|
|
|
local size
|
2026-01-28 11:43:39 +08:00
|
|
|
size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
|
2025-12-30 14:38:02 +08:00
|
|
|
|
|
|
|
|
if [[ "$size" =~ ^[0-9]+$ ]]; then
|
|
|
|
|
echo "$size"
|
|
|
|
|
else
|
2026-02-02 17:06:00 +08:00
|
|
|
[[ "${MO_DEBUG:-}" == "1" ]] && debug_log "get_path_size_kb: Failed to get size for $path (returned: $size)"
|
2025-12-30 14:38:02 +08:00
|
|
|
echo "0"
|
|
|
|
|
fi
|
2025-12-08 15:33:52 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-18 17:35:54 +08:00
|
|
|
# Calculate total size for multiple paths
|
2025-12-08 15:33:52 +08:00
|
|
|
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")
|
2026-02-28 11:10:18 +08:00
|
|
|
total_kb=$((total_kb + size_kb))
|
2025-12-08 15:33:52 +08:00
|
|
|
fi
|
2026-01-26 07:24:38 +00:00
|
|
|
done <<< "$files"
|
2025-12-08 15:33:52 +08:00
|
|
|
|
|
|
|
|
echo "$total_kb"
|
|
|
|
|
}
|
2026-02-02 17:06:00 +08:00
|
|
|
|
|
|
|
|
diagnose_removal_failure() {
|
|
|
|
|
local exit_code="$1"
|
|
|
|
|
local app_name="${2:-application}"
|
|
|
|
|
|
|
|
|
|
local reason=""
|
|
|
|
|
local suggestion=""
|
|
|
|
|
local touchid_file="/etc/pam.d/sudo"
|
|
|
|
|
|
|
|
|
|
case "$exit_code" in
|
|
|
|
|
"$MOLE_ERR_SIP_PROTECTED")
|
|
|
|
|
reason="protected by macOS (SIP/MDM)"
|
|
|
|
|
;;
|
|
|
|
|
"$MOLE_ERR_AUTH_FAILED")
|
|
|
|
|
reason="authentication failed"
|
|
|
|
|
if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then
|
2026-02-02 17:18:32 +08:00
|
|
|
suggestion="Check your credentials or restart Terminal"
|
2026-02-02 17:06:00 +08:00
|
|
|
else
|
|
|
|
|
suggestion="Try 'mole touchid' to enable fingerprint auth"
|
|
|
|
|
fi
|
|
|
|
|
;;
|
|
|
|
|
"$MOLE_ERR_READONLY_FS")
|
|
|
|
|
reason="filesystem is read-only"
|
|
|
|
|
suggestion="Check if disk needs repair"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
reason="permission denied"
|
|
|
|
|
if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then
|
|
|
|
|
suggestion="Try running again or check file ownership"
|
|
|
|
|
else
|
|
|
|
|
suggestion="Try 'mole touchid' or check with 'ls -l'"
|
|
|
|
|
fi
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
echo "$reason|$suggestion"
|
|
|
|
|
}
|