mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 16:49:41 +00:00
- Add path traversal protection in BOM receipt parsing - Remove invalid ~/Library/LaunchDaemons path references - Strengthen LaunchAgents matching (min 5 chars, exclude com.apple.*) - Add 300s timeout to brew cask uninstall to prevent hangs Addresses security review findings from V1.21.0 audit.
208 lines
6.7 KiB
Bash
208 lines
6.7 KiB
Bash
#!/bin/bash
|
|
# Mole - Homebrew Cask Uninstallation Support
|
|
# Detects Homebrew-managed casks via Caskroom linkage and uninstalls them via brew
|
|
|
|
set -euo pipefail
|
|
|
|
# Prevent multiple sourcing
|
|
if [[ -n "${MOLE_BREW_UNINSTALL_LOADED:-}" ]]; then
|
|
return 0
|
|
fi
|
|
readonly MOLE_BREW_UNINSTALL_LOADED=1
|
|
|
|
# Resolve a path to its absolute real path (follows symlinks)
|
|
# Args: $1 - path to resolve
|
|
# Returns: Absolute resolved path, or empty string on failure
|
|
resolve_path() {
|
|
local p="$1"
|
|
[[ -e "$p" ]] || return 1
|
|
|
|
# macOS 12.3+ and Linux have realpath
|
|
if realpath "$p" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
# Fallback: use cd -P to resolve directory, then append basename
|
|
local dir base
|
|
dir=$(cd -P "$(dirname "$p")" 2>/dev/null && pwd) || return 1
|
|
base=$(basename "$p")
|
|
echo "$dir/$base"
|
|
}
|
|
|
|
# Check if Homebrew is installed and accessible
|
|
# Returns: 0 if brew is available, 1 otherwise
|
|
is_homebrew_available() {
|
|
command -v brew >/dev/null 2>&1
|
|
}
|
|
|
|
# Extract cask token from a Caskroom path
|
|
# Args: $1 - path (must be inside Caskroom)
|
|
# Prints: cask token to stdout
|
|
# Returns: 0 if valid token extracted, 1 otherwise
|
|
_extract_cask_token_from_path() {
|
|
local path="$1"
|
|
|
|
# Check if path is inside Caskroom
|
|
case "$path" in
|
|
/opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
|
|
# Extract token from path: /opt/homebrew/Caskroom/<token>/<version>/...
|
|
local token
|
|
token="${path#*/Caskroom/}" # Remove everything up to and including Caskroom/
|
|
token="${token%%/*}" # Take only the first path component
|
|
|
|
# Validate token looks like a valid cask name (lowercase alphanumeric with hyphens)
|
|
if [[ -n "$token" && "$token" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
|
echo "$token"
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Stage 1: Deterministic detection via fully resolved path
|
|
# Fast, no false positives - follows all symlinks
|
|
_detect_cask_via_resolved_path() {
|
|
local app_path="$1"
|
|
local resolved
|
|
if resolved=$(resolve_path "$app_path") && [[ -n "$resolved" ]]; then
|
|
_extract_cask_token_from_path "$resolved" && return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Stage 2: Search Caskroom by app bundle name using find
|
|
# Catches apps where the .app in /Applications doesn't link to Caskroom
|
|
# Only succeeds if exactly one cask matches (avoids wrong uninstall)
|
|
_detect_cask_via_caskroom_search() {
|
|
local app_bundle_name="$1"
|
|
[[ -z "$app_bundle_name" ]] && return 1
|
|
|
|
local -a tokens=()
|
|
local room match token
|
|
|
|
for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do
|
|
[[ -d "$room" ]] || continue
|
|
while IFS= read -r match; do
|
|
[[ -n "$match" ]] || continue
|
|
token=$(_extract_cask_token_from_path "$match" 2>/dev/null) || continue
|
|
[[ -n "$token" ]] && tokens+=("$token")
|
|
done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2>/dev/null)
|
|
done
|
|
|
|
# Need at least one token
|
|
((${#tokens[@]} > 0)) || return 1
|
|
|
|
# Deduplicate and check count
|
|
local -a uniq
|
|
IFS=$'\n' read -r -d '' -a uniq < <(printf '%s\n' "${tokens[@]}" | sort -u && printf '\0') || true
|
|
|
|
# Only succeed if exactly one unique token found and it's installed
|
|
if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then
|
|
HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "${uniq[0]}" || return 1
|
|
echo "${uniq[0]}"
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Stage 3: Check if app_path is a direct symlink to Caskroom
|
|
_detect_cask_via_symlink_check() {
|
|
local app_path="$1"
|
|
[[ -L "$app_path" ]] || return 1
|
|
|
|
local target
|
|
target=$(readlink "$app_path" 2>/dev/null) || return 1
|
|
_extract_cask_token_from_path "$target"
|
|
}
|
|
|
|
# Stage 4: Query brew list --cask and verify with brew info (slowest fallback)
|
|
_detect_cask_via_brew_list() {
|
|
local app_path="$1"
|
|
local app_bundle_name="$2"
|
|
local app_name_lower
|
|
app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]')
|
|
|
|
local cask_name
|
|
cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -Fix "$app_name_lower") || return 1
|
|
|
|
# Verify this cask actually owns this app path
|
|
HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2>/dev/null | grep -qF "$app_path" || return 1
|
|
echo "$cask_name"
|
|
}
|
|
|
|
# Get Homebrew cask name for an app
|
|
# Uses multi-stage detection (fast to slow, deterministic to heuristic):
|
|
# 1. Resolve symlinks fully, check if path is in Caskroom (fast, deterministic)
|
|
# 2. Search Caskroom by app bundle name using find
|
|
# 3. Check if app is a direct symlink to Caskroom
|
|
# 4. Query brew list --cask and verify with brew info (slowest)
|
|
#
|
|
# Args: $1 - app_path
|
|
# Prints: cask token to stdout if brew-managed
|
|
# Returns: 0 if Homebrew-managed, 1 otherwise
|
|
get_brew_cask_name() {
|
|
local app_path="$1"
|
|
[[ -z "$app_path" || ! -e "$app_path" ]] && return 1
|
|
is_homebrew_available || return 1
|
|
|
|
local app_bundle_name
|
|
app_bundle_name=$(basename "$app_path")
|
|
|
|
# Try each detection method in order (fast to slow)
|
|
_detect_cask_via_resolved_path "$app_path" && return 0
|
|
_detect_cask_via_caskroom_search "$app_bundle_name" && return 0
|
|
_detect_cask_via_symlink_check "$app_path" && return 0
|
|
_detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0
|
|
|
|
return 1
|
|
}
|
|
|
|
# Uninstall a Homebrew cask and verify removal
|
|
# Args: $1 - cask_name, $2 - app_path (optional, for verification)
|
|
# Returns: 0 on success, 1 on failure
|
|
brew_uninstall_cask() {
|
|
local cask_name="$1"
|
|
local app_path="${2:-}"
|
|
|
|
is_homebrew_available || return 1
|
|
[[ -z "$cask_name" ]] && return 1
|
|
|
|
debug_log "Attempting brew uninstall --cask $cask_name"
|
|
|
|
# Ensure we have sudo access if needed, to prevent brew from hanging on password prompt
|
|
if ! sudo -n true 2>/dev/null; then
|
|
sudo -v
|
|
fi
|
|
|
|
local uninstall_ok=false
|
|
local brew_exit=0
|
|
|
|
# Run with timeout to prevent hangs from problematic cask scripts
|
|
if run_with_timeout 300 \
|
|
env HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \
|
|
brew uninstall --cask "$cask_name" 2>&1; then
|
|
uninstall_ok=true
|
|
else
|
|
brew_exit=$?
|
|
debug_log "brew uninstall timeout or failed with exit code: $brew_exit"
|
|
fi
|
|
|
|
# Verify removal
|
|
local cask_gone=true app_gone=true
|
|
HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "$cask_name" && cask_gone=false
|
|
[[ -n "$app_path" && -e "$app_path" ]] && app_gone=false
|
|
|
|
# Success: uninstall worked and both are gone, or already uninstalled
|
|
if $cask_gone && $app_gone; then
|
|
debug_log "Successfully uninstalled cask '$cask_name'"
|
|
return 0
|
|
fi
|
|
|
|
debug_log "brew uninstall failed: cask_gone=$cask_gone app_gone=$app_gone"
|
|
return 1
|
|
}
|