#!/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 # Error codes for removal operations readonly MOLE_ERR_SIP_PROTECTED=10 readonly MOLE_ERR_AUTH_FAILED=11 readonly MOLE_ERR_READONLY_FS=12 # 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 # ============================================================================ # 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 } # ============================================================================ # 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" # Calculate size before deletion for logging local size_kb=0 local size_human="" if oplog_enabled; then if [[ -e "$path" ]]; then size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi fi fi # 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 # Log successful removal log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" 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" log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "permission denied" else [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "error" fi return 1 fi } # 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 } # Safe sudo removal with symlink protection 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 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 -skP "$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 local age_seconds=$((now - mod_time)) file_age=$(format_duration_human "$age_seconds") fi fi fi 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" else log_info "[DRY-RUN] Would sudo remove: $path" fi return 0 fi local size_kb=0 local size_human="" if oplog_enabled; then if sudo test -e "$path" 2> /dev/null; then size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi fi fi local output local ret output=$(sudo rm -rf "$path" 2>&1) # safe_remove ret=$? if [[ $ret -eq 0 ]]; then log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 fi 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" ;; *"Sorry, try again"* | *"incorrect passphrase"* | *"incorrect credentials"*) 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 } # ============================================================================ # 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") # Skip -name if pattern is "*" (matches everything anyway, but adds overhead) if [[ "$pattern" != "*" ]]; then find_args+=("-name" "$pattern") fi find_args+=("-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 } local size size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) if [[ "$size" =~ ^[0-9]+$ ]]; then echo "$size" else [[ "${MO_DEBUG:-}" == "1" ]] && debug_log "get_path_size_kb: Failed to get size for $path (returned: $size)" 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" } 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 suggestion="Check your credentials or restart Terminal" 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" }