#!/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 operations # Checks: non-empty, absolute, no traversal, no control chars, not system dir # # Args: $1 - path to validate # Returns: 0 if safe, 1 if unsafe 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 path is absolute if [[ "$path" != /* ]]; then log_error "Path validation failed: path must be absolute: $path" return 1 fi # Check for path traversal attempts 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 # Check path isn't critical system directory case "$path" in / | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions) log_error "Path validation failed: critical system directory: $path" return 1 ;; esac return 0 } # ============================================================================ # Safe Removal Operations # ============================================================================ # Safe wrapper around rm -rf with path validation # # Args: # $1 - path to remove # $2 - silent mode (optional, default: false) # # Returns: 0 on success, 1 on failure 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 debug_log "Removing: $path" # Perform the deletion if rm -rf "$path" 2> /dev/null; then # SAFE: safe_remove implementation return 0 else [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" return 1 fi } # Safe sudo remove with additional symlink protection # # Args: $1 - path to remove # Returns: 0 on success, 1 on failure 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 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 find delete with depth limit and validation # # Args: # $1 - base directory # $2 - file pattern (e.g., "*.log") # $3 - age in days (0 = all files, default: 7) # $4 - type filter ("f" or "d", default: "f") # # Returns: 0 on success, 1 on failure 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)" # Execute find with safety limits (maxdepth 5 covers most app cache structures) if [[ "$age_days" -eq 0 ]]; then # Delete all matching files without time restriction command find "$base_dir" \ -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ -delete 2> /dev/null || true else # Delete files older than age_days command find "$base_dir" \ -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ -mtime "+$age_days" \ -delete 2> /dev/null || true fi return 0 } # Safe sudo find delete (same as safe_find_delete but with sudo) # # Args: same as safe_find_delete # Returns: 0 on success, 1 on failure safe_sudo_find_delete() { local base_dir="$1" local pattern="$2" local age_days="${3:-7}" local type_filter="${4:-f}" # Validate base directory 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 (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" # Execute find with sudo if [[ "$age_days" -eq 0 ]]; then sudo find "$base_dir" \ -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ -delete 2> /dev/null || true else sudo find "$base_dir" \ -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ -mtime "+$age_days" \ -delete 2> /dev/null || true fi return 0 } # ============================================================================ # Size Calculation # ============================================================================ # Get path size in kilobytes # Uses timeout protection to prevent du from hanging on large directories # # Args: $1 - path # Returns: size in KB (0 if path doesn't exist) get_path_size_kb() { local path="$1" [[ -z "$path" || ! -e "$path" ]] && { echo "0" return } # Direct execution without timeout overhead - critical for performance in loops local size size=$(command du -sk "$path" 2> /dev/null | awk '{print $1}') echo "${size:-0}" } # Calculate total size of multiple paths # # Args: $1 - newline-separated list of paths # Returns: total size in KB 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" }