diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 9fdd65f..7909cef 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -39,6 +39,25 @@ validate_path_for_deletion() { 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 + } + + # If symlink points to absolute path, validate target + if [[ "$link_target" == /* ]]; then + case "$link_target" in + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + log_error "Symlink points to protected system path: $path -> $link_target" + return 1 + ;; + esac + fi + fi + # Check path is absolute if [[ "$path" != /* ]]; then log_error "Path validation failed: path must be absolute: $path" @@ -61,47 +80,47 @@ validate_path_for_deletion() { # 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 - ;; + /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/*) - return 0 - ;; + /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/*) + 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 - ;; + / | /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 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" @@ -144,16 +163,16 @@ safe_remove() { if [[ -e "$path" ]]; then local size_kb - size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") + 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") + mod_time=$(stat -f%m "$path" 2>/dev/null || echo "0") local now - now=$(date +%s 2> /dev/null || echo "0") + now=$(date +%s 2>/dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -221,18 +240,18 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2> /dev/null; then + 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") + 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 + 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") + mod_time=$(sudo stat -f%m "$path" 2>/dev/null || echo "0") local now - now=$(date +%s 2> /dev/null || echo "0") + now=$(date +%s 2>/dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -249,7 +268,7 @@ safe_sudo_remove() { debug_log "Removing (sudo): $path" # Perform the deletion - if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation + if sudo rm -rf "$path" 2>/dev/null; then # SAFE: safe_sudo_remove implementation return 0 else log_error "Failed to remove (sudo): $path" @@ -298,7 +317,7 @@ safe_find_delete() { continue fi safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) return 0 } @@ -311,12 +330,12 @@ safe_sudo_find_delete() { local type_filter="${4:-f}" # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2> /dev/null; then + 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 + if sudo test -L "$base_dir" 2>/dev/null; then log_error "Refusing to search symlinked directory: $base_dir" return 1 fi @@ -340,7 +359,7 @@ safe_sudo_find_delete() { continue fi safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) return 0 } @@ -360,7 +379,7 @@ get_path_size_kb() { # 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) + 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 @@ -381,7 +400,7 @@ calculate_total_size() { size_kb=$(get_path_size_kb "$file") ((total_kb += size_kb)) fi - done <<< "$files" + done <<<"$files" echo "$total_kb" } diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index a34aa23..9d558bd 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -30,6 +30,32 @@ SENSITIVE_DATA_REGEX=$( echo "${SENSITIVE_DATA_PATTERNS[*]}" ) +# High-performance sensitive data detection (pure Bash, no subprocess) +# Faster than grep for batch operations, especially when processing many apps +has_sensitive_data() { + local files="$1" + [[ -z "$files" ]] && return 1 + + while IFS= read -r file; do + [[ -z "$file" ]] && continue + + # Use Bash native pattern matching (faster than spawning grep) + case "$file" in + */.warp* | */.config/* | */themes/* | */settings/* | */User\ Data/* | \ + */.ssh/* | */.gnupg/* | */Documents/* | */Preferences/*.plist | \ + */Desktop/* | */Downloads/* | */Movies/* | */Music/* | */Pictures/* | \ + */.password* | */.token* | */.auth* | */keychain* | \ + */Passwords/* | */Accounts/* | */Cookies/* | \ + */.aws/* | */.docker/config.json | */.kube/* | \ + */credentials/* | */secrets/*) + return 0 # Found sensitive data + ;; + esac + done <<<"$files" + + return 1 # Not found +} + # Decode and validate base64 file list (safe for set -e). decode_file_list() { local encoded="$1" @@ -37,8 +63,8 @@ decode_file_list() { local decoded # macOS uses -D, GNU uses -d. Always return 0 for set -e safety. - if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then - if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -D 2>/dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -d 2>/dev/null); then log_error "Failed to decode file list for $app_name" >&2 echo "" return 0 # Return success with empty string @@ -57,7 +83,7 @@ decode_file_list() { echo "" return 0 # Return success with empty string fi - done <<< "$decoded" + done <<<"$decoded" echo "$decoded" return 0 @@ -73,20 +99,20 @@ stop_launch_services() { if [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - launchctl unload "$plist" 2> /dev/null || true - done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + launchctl unload "$plist" 2>/dev/null || true + done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) fi if [[ "$has_system_files" == "true" ]]; then if [[ -d /Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + sudo launchctl unload "$plist" 2>/dev/null || true + done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) fi if [[ -d /Library/LaunchDaemons ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + sudo launchctl unload "$plist" 2>/dev/null || true + done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) fi fi } @@ -108,7 +134,7 @@ remove_login_item() { local escaped_name="${clean_name//\\/\\\\}" escaped_name="${escaped_name//\"/\\\"}" - osascript <<- EOF > /dev/null 2>&1 || true + osascript <<-EOF >/dev/null 2>&1 || true tell application "System Events" try set itemCount to count of login items @@ -142,9 +168,9 @@ remove_file_list() { if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then - sudo rm "$file" 2> /dev/null && ((++count)) || true + sudo rm "$file" 2>/dev/null && ((++count)) || true else - rm "$file" 2> /dev/null && ((++count)) || true + rm "$file" 2>/dev/null && ((++count)) || true fi else if [[ "$use_sudo" == "true" ]]; then @@ -153,7 +179,7 @@ remove_file_list() { safe_remove "$file" true && ((++count)) || true fi fi - done <<< "$file_list" + done <<<"$file_list" echo "$count" } @@ -177,15 +203,15 @@ batch_uninstall_applications() { if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue - IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" + IFS='|' read -r _ app_path app_name bundle_id _ _ <<<"$selected_app" # Check running app by bundle executable if available. local exec_name="" if [[ -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2>/dev/null || echo "") fi local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" > /dev/null 2>&1; then + if pgrep -x "$check_pattern" >/dev/null 2>&1; then running_apps+=("$app_name") fi @@ -230,7 +256,7 @@ batch_uninstall_applications() { # Check for sensitive user data once. local has_sensitive_data="false" - if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then + if has_sensitive_data "$related_files"; then has_sensitive_data="true" fi @@ -252,7 +278,7 @@ batch_uninstall_applications() { # Warn if user data is detected. local has_user_data=false for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail" + IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<<"$detail" if [[ "$has_sensitive_data" == "true" ]]; then has_user_data=true break @@ -265,7 +291,7 @@ batch_uninstall_applications() { fi for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<<"$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -288,7 +314,7 @@ batch_uninstall_applications() { fi ((file_count++)) fi - done <<< "$related_files" + done <<<"$related_files" # Show system files (limit to 5). local sys_file_count=0 @@ -299,7 +325,7 @@ batch_uninstall_applications() { fi ((sys_file_count++)) fi - done <<< "$system_files" + done <<<"$system_files" local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) @@ -325,24 +351,24 @@ batch_uninstall_applications() { IFS= read -r -s -n1 key || key="" drain_pending_input # Clean up any escape sequence remnants case "$key" in - $'\e' | q | Q) - echo "" - echo "" - return 0 - ;; - "" | $'\n' | $'\r' | y | Y) - echo "" # Move to next line - ;; - *) - echo "" - echo "" - return 0 - ;; + $'\e' | q | Q) + echo "" + echo "" + return 0 + ;; + "" | $'\n' | $'\r' | y | Y) + echo "" # Move to next line + ;; + *) + echo "" + echo "" + return 0 + ;; esac # Request sudo if needed. if [[ ${#sudo_apps[@]} -gt 0 ]]; then - if ! sudo -n true 2> /dev/null; then + if ! sudo -n true 2>/dev/null; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" log_error "Admin access denied" @@ -352,12 +378,12 @@ batch_uninstall_applications() { # Keep sudo alive during uninstall. parent_pid=$$ (while true; do - if ! kill -0 "$parent_pid" 2> /dev/null; then + if ! kill -0 "$parent_pid" 2>/dev/null; then exit 0 fi sudo -n true sleep 60 - done 2> /dev/null) & + done 2>/dev/null) & sudo_keepalive_pid=$! fi @@ -369,7 +395,7 @@ batch_uninstall_applications() { local current_index=0 for detail in "${app_details[@]}"; do ((current_index++)) - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<<"$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local reason="" @@ -432,24 +458,24 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then - remove_file_list "$related_files" "false" > /dev/null + remove_file_list "$related_files" "false" >/dev/null # If brew successfully uninstalled the cask, avoid deleting # system-level files Mole discovered. Brew manages its own # receipts/symlinks and we don't want to fight it. if [[ "$used_brew_successfully" != "true" ]]; then - remove_file_list "$system_files" "true" > /dev/null + remove_file_list "$system_files" "true" >/dev/null fi # Clean up macOS defaults (preference domains). if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then - if defaults read "$bundle_id" &> /dev/null; then - defaults delete "$bundle_id" 2> /dev/null || true + if defaults read "$bundle_id" &>/dev/null; then + defaults delete "$bundle_id" 2>/dev/null || true fi # ByHost preferences (machine-specific). if [[ -d ~/Library/Preferences/ByHost ]]; then - find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true + find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2>/dev/null || true fi fi @@ -547,11 +573,11 @@ batch_uninstall_applications() { if [[ $failed_count -eq 1 ]]; then local first_reason=${failed_items[0]#*:} case "$first_reason" in - still*running*) reason_summary="is still running" ;; - remove*failed*) reason_summary="could not be removed" ;; - permission*denied*) reason_summary="permission denied" ;; - owned*by*) reason_summary="$first_reason (try with sudo)" ;; - *) reason_summary="$first_reason" ;; + still*running*) reason_summary="is still running" ;; + remove*failed*) reason_summary="could not be removed" ;; + permission*denied*) reason_summary="permission denied" ;; + owned*by*) reason_summary="$first_reason (try with sudo)" ;; + *) reason_summary="$first_reason" ;; esac fi summary_details+=("Failed: ${RED}${failed_list}${NC} ${reason_summary}") @@ -579,7 +605,7 @@ batch_uninstall_applications() { fi local autoremove_output removed_count - autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null) || true removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) removed_count=${removed_count:-0} @@ -597,7 +623,7 @@ batch_uninstall_applications() { if [[ $success_count -gt 0 ]]; then local -a removed_paths=() for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" + IFS='|' read -r app_name app_path _ _ _ _ <<<"$detail" for success_name in "${success_items[@]}"; do if [[ "$success_name" == "$app_name" ]]; then removed_paths+=("$app_path") @@ -606,21 +632,21 @@ batch_uninstall_applications() { done done if [[ ${#removed_paths[@]} -gt 0 ]]; then - remove_apps_from_dock "${removed_paths[@]}" 2> /dev/null || true + remove_apps_from_dock "${removed_paths[@]}" 2>/dev/null || true fi fi # Clean up sudo keepalive if it was started. if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true + kill "$sudo_keepalive_pid" 2>/dev/null || true + wait "$sudo_keepalive_pid" 2>/dev/null || true sudo_keepalive_pid="" fi # Invalidate cache if any apps were successfully uninstalled. if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" - rm -f "$cache_file" 2> /dev/null || true + rm -f "$cache_file" 2>/dev/null || true fi ((total_size_cleaned += total_size_freed))