From 785032635a43e1ebab05691bcae5b9befd71b06d Mon Sep 17 00:00:00 2001 From: Luke Bullimore Date: Fri, 26 Dec 2025 02:54:56 +0000 Subject: [PATCH] feat: harden user file handling and gate LaunchServices rebuild (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add ensure_user_dir/ensure_user_file helpers in lib/core/base.sh, including sudo-aware ownership correction under the invoking user’s home - use the helpers across clean/optimize/purge/uninstall/whitelist to create cache and export files safely (no naked mkdir/touch), including log files and dry-run exports - ensure purge stats/count files and update message caches are pre-created with safe permissions - add Darwin version helpers and skip LaunchServices/dyld rebuild on macOS 15+, keeping the existing corruption protection for earlier versions - guard brew cache timestamp writes and TCC permission flags with safe file creation to avoid root-owned artifacts --- bin/clean.sh | 3 +- bin/optimize.sh | 2 +- bin/purge.sh | 4 +- bin/uninstall.sh | 3 +- bin/uninstall_lib.sh | 3 +- lib/check/all.sh | 3 +- lib/clean/brew.sh | 2 +- lib/clean/caches.sh | 3 +- lib/core/base.sh | 193 ++++++++++++++++++++++++++++++++++++++++ lib/core/log.sh | 8 +- lib/manage/whitelist.sh | 2 +- lib/optimize/tasks.sh | 13 ++- mole | 7 +- 13 files changed, 228 insertions(+), 18 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index a1c3aa6..43506b4 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -159,6 +159,7 @@ start_section() { # Write section header to export list in dry-run mode if [[ "$DRY_RUN" == "true" ]]; then + ensure_user_file "$EXPORT_LIST_FILE" echo "" >> "$EXPORT_LIST_FILE" echo "=== $1 ===" >> "$EXPORT_LIST_FILE" fi @@ -452,7 +453,7 @@ start_cleanup() { SYSTEM_CLEAN=false # Initialize export list file - mkdir -p "$(dirname "$EXPORT_LIST_FILE")" + ensure_user_file "$EXPORT_LIST_FILE" cat > "$EXPORT_LIST_FILE" << EOF # Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') # diff --git a/bin/optimize.sh b/bin/optimize.sh index d41b262..b6ff118 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -200,7 +200,7 @@ cleanup_path() { ensure_directory() { local raw_path="$1" local expanded_path="${raw_path/#\~/$HOME}" - mkdir -p "$expanded_path" > /dev/null 2>&1 || true + ensure_user_dir "$expanded_path" } count_local_snapshots() { diff --git a/bin/purge.sh b/bin/purge.sh index d06c2d2..f5f1c14 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -47,7 +47,9 @@ start_purge() { # Initialize stats file in user cache directory local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" - mkdir -p "$stats_dir" + ensure_user_dir "$stats_dir" + ensure_user_file "$stats_dir/purge_stats" + ensure_user_file "$stats_dir/purge_count" echo "0" > "$stats_dir/purge_stats" echo "0" > "$stats_dir/purge_count" } diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 0d35c6f..ce2502e 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -36,7 +36,7 @@ scan_applications() { local cache_ttl=86400 # 24 hours local force_rescan="${1:-false}" - mkdir -p "$cache_dir" 2> /dev/null + ensure_user_dir "$cache_dir" # Check if cache exists and is fresh if [[ $force_rescan == false && -f "$cache_file" ]]; then @@ -310,6 +310,7 @@ scan_applications() { fi fi + ensure_user_file "$cache_file" cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true if [[ -f "${temp_file}.sorted" ]]; then diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh index 92426af..50af88b 100755 --- a/bin/uninstall_lib.sh +++ b/bin/uninstall_lib.sh @@ -75,7 +75,7 @@ scan_applications() { local cache_ttl=86400 # 24 hours local force_rescan="${1:-false}" - mkdir -p "$cache_dir" 2> /dev/null + ensure_user_dir "$cache_dir" # Check if cache exists and is fresh if [[ $force_rescan == false && -f "$cache_file" ]]; then @@ -344,6 +344,7 @@ scan_applications() { fi # Save to cache (simplified - no metadata) + ensure_user_file "$cache_file" cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true # Return sorted file diff --git a/lib/check/all.sh b/lib/check/all.sh index 1d6b4ee..6c4cdd4 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -174,7 +174,7 @@ CACHE_DIR="${HOME}/.cache/mole" CACHE_TTL=600 # 10 minutes in seconds # Ensure cache directory exists -mkdir -p "$CACHE_DIR" 2> /dev/null || true +ensure_user_dir "$CACHE_DIR" clear_cache_file() { local file="$1" @@ -302,6 +302,7 @@ check_mole_update() { latest_version=$(curl -fsSL https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' || echo "") # Save to cache if [[ -n "$latest_version" ]]; then + ensure_user_file "$cache_file" echo "$latest_version" > "$cache_file" 2> /dev/null || true fi fi diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index dff204b..130b76d 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -116,7 +116,7 @@ clean_homebrew() { # Update cache timestamp on successful completion if [[ "$brew_success" == "true" || "$autoremove_success" == "true" ]]; then - mkdir -p "$(dirname "$brew_cache_file")" + ensure_user_file "$brew_cache_file" date +%s > "$brew_cache_file" fi } diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index 000a952..b6755df 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -52,8 +52,7 @@ check_tcc_permissions() { fi # Mark permissions as granted (won't prompt again) - mkdir -p "$(dirname "$permission_flag")" 2> /dev/null || true - touch "$permission_flag" 2> /dev/null || true + ensure_user_file "$permission_flag" } # Clean browser Service Worker cache, protecting web editing tools (capcut, photopea, pixlr) diff --git a/lib/core/base.sh b/lib/core/base.sh index 2337372..3831732 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -167,6 +167,25 @@ get_free_space() { command df -h / | awk 'NR==2 {print $4}' } +# Get Darwin kernel major version (e.g., 24 for 24.2.0) +get_darwin_major() { + local kernel + kernel=$(uname -r 2> /dev/null || true) + local major="${kernel%%.*}" + if [[ ! "$major" =~ ^[0-9]+$ ]]; then + major=0 + fi + echo "$major" +} + +# Check if Darwin kernel major version meets minimum +is_darwin_ge() { + local minimum="$1" + local major + major=$(get_darwin_major) + [[ "$major" -ge "$minimum" ]] +} + # Get optimal parallel jobs for operation type (scan|io|compute|default) get_optimal_parallel_jobs() { local operation_type="${1:-default}" @@ -185,6 +204,180 @@ get_optimal_parallel_jobs() { esac } +# ============================================================================ +# User Context Utilities +# ============================================================================ + +is_root_user() { + [[ "$(id -u)" == "0" ]] +} + +get_user_home() { + local user="$1" + local home="" + + if [[ -z "$user" ]]; then + echo "" + return 0 + fi + + if command -v dscl > /dev/null 2>&1; then + home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true) + fi + + if [[ -z "$home" ]]; then + home=$(eval echo "~$user" 2> /dev/null || true) + fi + + if [[ "$home" == "~"* ]]; then + home="" + fi + + echo "$home" +} + +get_invoking_user() { + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then + echo "$SUDO_USER" + return 0 + fi + echo "${USER:-}" +} + +get_invoking_uid() { + if [[ -n "${SUDO_UID:-}" ]]; then + echo "$SUDO_UID" + return 0 + fi + + local uid + uid=$(id -u 2> /dev/null || true) + echo "$uid" +} + +get_invoking_gid() { + if [[ -n "${SUDO_GID:-}" ]]; then + echo "$SUDO_GID" + return 0 + fi + + local gid + gid=$(id -g 2> /dev/null || true) + echo "$gid" +} + +get_invoking_home() { + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then + get_user_home "$SUDO_USER" + return 0 + fi + + echo "${HOME:-}" +} + +ensure_user_dir() { + local raw_path="$1" + if [[ -z "$raw_path" ]]; then + return 0 + fi + + local target_path="$raw_path" + if [[ "$target_path" == "~"* ]]; then + target_path="${target_path/#\~/$HOME}" + fi + + mkdir -p "$target_path" 2> /dev/null || true + + if ! is_root_user; then + return 0 + fi + + local sudo_user="${SUDO_USER:-}" + if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then + return 0 + fi + + local user_home + user_home=$(get_user_home "$sudo_user") + if [[ -z "$user_home" ]]; then + return 0 + fi + user_home="${user_home%/}" + + if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then + return 0 + fi + + local owner_uid="${SUDO_UID:-}" + local owner_gid="${SUDO_GID:-}" + if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then + owner_uid=$(id -u "$sudo_user" 2> /dev/null || true) + owner_gid=$(id -g "$sudo_user" 2> /dev/null || true) + fi + + if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then + return 0 + fi + + local dir="$target_path" + while [[ -n "$dir" && "$dir" != "/" ]]; do + chown "$owner_uid:$owner_gid" "$dir" 2> /dev/null || true + if [[ "$dir" == "$user_home" ]]; then + break + fi + dir=$(dirname "$dir") + if [[ "$dir" == "." ]]; then + break + fi + done +} + +ensure_user_file() { + local raw_path="$1" + if [[ -z "$raw_path" ]]; then + return 0 + fi + + local target_path="$raw_path" + if [[ "$target_path" == "~"* ]]; then + target_path="${target_path/#\~/$HOME}" + fi + + ensure_user_dir "$(dirname "$target_path")" + touch "$target_path" 2> /dev/null || true + + if ! is_root_user; then + return 0 + fi + + local sudo_user="${SUDO_USER:-}" + if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then + return 0 + fi + + local user_home + user_home=$(get_user_home "$sudo_user") + if [[ -z "$user_home" ]]; then + return 0 + fi + user_home="${user_home%/}" + + if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then + return 0 + fi + + local owner_uid="${SUDO_UID:-}" + local owner_gid="${SUDO_GID:-}" + if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then + owner_uid=$(id -u "$sudo_user" 2> /dev/null || true) + owner_gid=$(id -g "$sudo_user" 2> /dev/null || true) + fi + + if [[ -n "$owner_uid" && -n "$owner_gid" ]]; then + chown "$owner_uid:$owner_gid" "$target_path" 2> /dev/null || true + fi +} + # ============================================================================ # Formatting Utilities # ============================================================================ diff --git a/lib/core/log.sh b/lib/core/log.sh index 968abf4..20ead40 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -26,7 +26,10 @@ readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log" readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB # Ensure log directory exists -mkdir -p "$(dirname "$LOG_FILE")" 2> /dev/null || true +ensure_user_dir "$(dirname "$LOG_FILE")" +if is_root_user && [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then + ensure_user_file "$LOG_FILE" +fi # ============================================================================ # Log Rotation @@ -41,7 +44,7 @@ rotate_log_once() { local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}" if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true - touch "$LOG_FILE" 2> /dev/null || true + ensure_user_file "$LOG_FILE" fi } @@ -104,6 +107,7 @@ log_system_info() { export MOLE_SYS_INFO_LOGGED=1 # Reset debug log file for this new session + ensure_user_file "$DEBUG_LOG_FILE" : > "$DEBUG_LOG_FILE" # Start block in debug log file diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index da8abe2..8ac0f59 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -44,7 +44,7 @@ save_whitelist_patterns() { header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n# Add one pattern per line to keep items safe." fi - mkdir -p "$(dirname "$config_file")" + ensure_user_file "$config_file" echo -e "$header_text" > "$config_file" diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index d5a8717..ae044b8 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -17,9 +17,16 @@ flush_dns_cache() { # Rebuild databases and flush caches opt_system_maintenance() { - # DISABLED: Causes System Settings corruption - Issue #136 - echo -e "${GRAY}⊘${NC} LaunchServices rebuild disabled" - # run_with_timeout 10 /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user > /dev/null 2>&1 || true + local darwin_major + darwin_major=$(get_darwin_major) + + if [[ "$darwin_major" -ge 24 ]]; then + echo -e "${GRAY}⊘${NC} LaunchServices/dyld rebuild skipped on macOS 15+ (Darwin ${darwin_major})" + else + # DISABLED: Causes System Settings corruption - Issue #136 + echo -e "${GRAY}⊘${NC} LaunchServices rebuild disabled" + # run_with_timeout 10 /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user > /dev/null 2>&1 || true + fi echo -e "${BLUE}${ICON_ARROW}${NC} Clearing DNS cache..." if flush_dns_cache; then diff --git a/mole b/mole index 7dee524..1c1147f 100755 --- a/mole +++ b/mole @@ -58,7 +58,8 @@ is_homebrew_install() { # Check for updates (non-blocking, always check in background) check_for_updates() { local msg_cache="$HOME/.cache/mole/update_message" - mkdir -p "$(dirname "$msg_cache")" 2> /dev/null + ensure_user_dir "$(dirname "$msg_cache")" + ensure_user_file "$msg_cache" # Background version check # Always check in background, display result from previous check @@ -634,11 +635,11 @@ interactive_main_menu() { if [[ -n "$tty_name" ]]; then local flag_file local cache_dir="$HOME/.cache/mole" - mkdir -p "$cache_dir" 2> /dev/null + ensure_user_dir "$cache_dir" flag_file="$cache_dir/intro_$(echo "$tty_name" | tr -c '[:alnum:]_' '_')" if [[ ! -f "$flag_file" ]]; then animate_mole_intro - touch "$flag_file" 2> /dev/null || true + ensure_user_file "$flag_file" fi fi fi