diff --git a/install.sh b/install.sh index 4a64081..d18261a 100755 --- a/install.sh +++ b/install.sh @@ -14,16 +14,26 @@ readonly SCRIPT_VERSION="2.0.0" # Constants readonly REQUIRED_SPACE_KB=51200 # 50MB in KB -readonly MIN_BASH_VERSION=4 +readonly MIN_BASH_VERSION=3 readonly CHECKSUM_FILE=".checksums" readonly CONFIG_FILE=".superclaude.conf" -# Colors for output -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly RED='\033[0;31m' -readonly BLUE='\033[0;34m' -readonly NC='\033[0m' # No Color +# Colors for output - detect terminal support +if [[ -t 1 ]] && [[ "$(tput colors 2>/dev/null)" -ge 8 ]]; then + # Terminal supports colors + readonly GREEN='\033[0;32m' + readonly YELLOW='\033[1;33m' + readonly RED='\033[0;31m' + readonly BLUE='\033[0;34m' + readonly NC='\033[0m' # No Color +else + # No color support + readonly GREEN='' + readonly YELLOW='' + readonly RED='' + readonly BLUE='' + readonly NC='' +fi # Configuration patterns readonly -a CUSTOMIZABLE_CONFIGS=("CLAUDE.md" "RULES.md" "PERSONAS.md" "MCP.md") @@ -40,9 +50,7 @@ LOG_FILE="" VERIFICATION_FAILURES=0 ROLLBACK_ON_FAILURE=true BACKUP_DIR="" - -# Command availability cache -declare -A COMMAND_CACHE +INSTALLATION_PHASE=false # Original working directory ORIGINAL_DIR=$(pwd) @@ -54,19 +62,25 @@ cleanup() { # Return to original directory cd "$ORIGINAL_DIR" 2>/dev/null || true - # Clean up temp directory - if [[ $exit_code -ne 0 ]] && [[ -n "$TEMP_DIR" ]] && [[ -d "$TEMP_DIR" ]]; then - rm -rf "$TEMP_DIR" 2>/dev/null || true + # Generate error report if there were issues + if [[ $exit_code -ne 0 ]] || [[ $ERROR_COUNT -gt 0 ]] || [[ $WARNING_COUNT -gt 0 ]]; then + generate_error_report fi - # Rollback on failure if enabled - if [[ $exit_code -ne 0 ]] && [[ "$ROLLBACK_ON_FAILURE" = true ]] && [[ -n "$BACKUP_DIR" ]]; then - rollback_installation + # Rollback on failure if enabled and we're in installation phase + if [[ $exit_code -ne 0 ]] && [[ "${ROLLBACK_ON_FAILURE:-true}" = true ]] && [[ -n "$BACKUP_DIR" ]] && [[ "${INSTALLATION_PHASE:-false}" = true ]]; then + echo -e "${YELLOW}Installation failed, attempting rollback...${NC}" >&2 + if rollback_installation; then + echo -e "${GREEN}Rollback completed successfully${NC}" >&2 + else + echo -e "${RED}Rollback failed - manual intervention required${NC}" >&2 + echo -e "${YELLOW}Backup available at: $BACKUP_DIR${NC}" >&2 + fi fi exit $exit_code } -trap cleanup EXIT INT TERM +trap cleanup EXIT INT TERM HUP QUIT # Exception patterns - files/patterns to never delete during cleanup EXCEPTION_PATTERNS=( @@ -92,19 +106,89 @@ PRESERVE_FILES=( ) # Function: check_command -# Description: Check if a command exists (with caching) +# Description: Check if a command exists # Parameters: $1 - command name # Returns: 0 if command exists, 1 otherwise check_command() { - local cmd=$1 - if [[ -z "${COMMAND_CACHE[$cmd]:-}" ]]; then - if command -v "$cmd" &> /dev/null; then - COMMAND_CACHE[$cmd]="yes" - else - COMMAND_CACHE[$cmd]="no" - fi + local cmd="$1" + + # Validate input + if [[ -z "$cmd" ]]; then + log_error "check_command: Command name cannot be empty" + return 1 fi - [[ "${COMMAND_CACHE[$cmd]}" == "yes" ]] + + # Check for dangerous command patterns (enhanced security) + if [[ "$cmd" =~ [;&|`$(){}\"\'\\] ]] || [[ "$cmd" =~ \.\.|^/ ]] || [[ "$cmd" =~ [[:space:]] ]]; then + log_error "check_command: Invalid command name contains dangerous characters: $cmd" + return 1 + fi + + command -v "$cmd" &> /dev/null +} + +# Function: compare_versions +# Description: Compare two semantic versions +# Parameters: $1 - version1, $2 - version2 +# Returns: 0 if version1 < version2, 1 if version1 >= version2 +compare_versions() { + local version1="$1" + local version2="$2" + + # Validate input parameters + if [[ -z "$version1" ]] || [[ -z "$version2" ]]; then + log_error "compare_versions: Both version parameters are required" + return 1 + fi + + # Validate version format (basic semantic version pattern) + if [[ ! "$version1" =~ ^[0-9]+(\.[0-9]+)*([.-][a-zA-Z0-9]+)*$ ]]; then + log_error "compare_versions: Invalid version format: $version1" + return 1 + fi + + if [[ ! "$version2" =~ ^[0-9]+(\.[0-9]+)*([.-][a-zA-Z0-9]+)*$ ]]; then + log_error "compare_versions: Invalid version format: $version2" + return 1 + fi + + # Handle identical versions + if [[ "$version1" == "$version2" ]]; then + return 1 + fi + + # Split versions into arrays + local v1_parts v2_parts + IFS='.' read -ra v1_parts <<< "$version1" || { + log_error "compare_versions: Failed to parse version1: $version1" + return 1 + } + IFS='.' read -ra v2_parts <<< "$version2" || { + log_error "compare_versions: Failed to parse version2: $version2" + return 1 + } + + # Compare each part + for i in {0..2}; do + local v1_part="${v1_parts[$i]:-0}" + local v2_part="${v2_parts[$i]:-0}" + + # Remove any non-numeric suffixes for comparison + v1_part="${v1_part%%[!0-9]*}" + v2_part="${v2_part%%[!0-9]*}" + + # Validate that we have numeric values + if [[ ! "$v1_part" =~ ^[0-9]+$ ]]; then v1_part=0; fi + if [[ ! "$v2_part" =~ ^[0-9]+$ ]]; then v2_part=0; fi + + if ((v1_part < v2_part)); then + return 0 + elif ((v1_part > v2_part)); then + return 1 + fi + done + + return 1 } # Function: rollback_installation @@ -118,41 +202,153 @@ rollback_installation() { fi echo -e "${YELLOW}Rolling back installation...${NC}" >&2 + log_verbose "Backup directory: $BACKUP_DIR" + log_verbose "Install directory: $INSTALL_DIR" - # Remove failed installation - if [[ -d "$INSTALL_DIR" ]]; then - rm -rf "$INSTALL_DIR" 2>/dev/null || { - log_error "Failed to remove failed installation" - return 1 - } + # Validate backup directory contents before proceeding + if [[ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]]; then + log_error "Backup directory is empty, cannot rollback" + return 1 fi - # Restore backup - mv "$BACKUP_DIR" "$INSTALL_DIR" 2>/dev/null || { - log_error "Failed to restore backup" + # Create a temporary directory for safe operations + local temp_dir + temp_dir=$(mktemp -d 2>/dev/null) || { + log_error "Failed to create temporary directory for rollback" return 1 } + # Remove failed installation safely + if [[ -d "$INSTALL_DIR" ]]; then + log_verbose "Moving failed installation to temporary location" + if ! mv "$INSTALL_DIR" "$temp_dir/failed_install" 2>/dev/null; then + log_error "Failed to move failed installation" + rm -rf "$temp_dir" 2>/dev/null + return 1 + fi + fi + + # Restore backup + log_verbose "Restoring backup to installation directory" + if ! mv "$BACKUP_DIR" "$INSTALL_DIR" 2>/dev/null; then + log_error "Failed to restore backup" + # Try to restore the failed installation + if [[ -d "$temp_dir/failed_install" ]]; then + mv "$temp_dir/failed_install" "$INSTALL_DIR" 2>/dev/null || true + fi + rm -rf "$temp_dir" 2>/dev/null + return 1 + fi + + # Clean up temporary directory + rm -rf "$temp_dir" 2>/dev/null + + # Clear the backup directory variable to prevent accidental use + BACKUP_DIR="" + echo -e "${GREEN}Installation rolled back successfully${NC}" >&2 return 0 } +# Function: validate_directory_path +# Description: Validate directory path for security and sanity +# Parameters: $1 - directory path +# Returns: 0 if valid, 1 if invalid +validate_directory_path() { + local dir_path="$1" + + # Check if path is empty + if [[ -z "$dir_path" ]]; then + log_error "Directory path cannot be empty" + return 1 + fi + + # Check for dangerous paths + local dangerous_paths=("/" "/bin" "/sbin" "/usr" "/usr/bin" "/usr/sbin" "/etc" "/sys" "/proc" "/dev" "/boot" "/lib" "/lib64") + for dangerous in "${dangerous_paths[@]}"; do + if [[ "$dir_path" == "$dangerous" ]] || [[ "$dir_path" == "$dangerous"/* ]]; then + log_error "Installation to system directory not allowed: $dir_path" + return 1 + fi + done + + # Check for path traversal attempts + if [[ "$dir_path" =~ \.\./|/\.\. ]]; then + log_error "Path traversal not allowed in directory path: $dir_path" + return 1 + fi + + # Check for null bytes or other dangerous characters + if [[ "$dir_path" =~ $'\0'|[[:cntrl:]] ]]; then + log_error "Invalid characters in directory path: $dir_path" + return 1 + fi + + return 0 +} + # Function: load_config # Description: Load configuration from file if exists # Parameters: $1 - config file path # Returns: 0 on success load_config() { local config_file="$1" - if [[ -f "$config_file" ]] && [[ -r "$config_file" ]]; then - # Source config file in a subshell to validate - if (source "$config_file" 2>/dev/null); then - source "$config_file" - log_verbose "Loaded configuration from $config_file" - else - log_error "Invalid configuration file: $config_file" + + # Validate input parameter + if [[ -z "$config_file" ]]; then + log_error "load_config: Configuration file path cannot be empty" + return 1 + fi + + # Security checks + if [[ ! -f "$config_file" ]]; then + log_error "Configuration file not found: $config_file" + return 1 + fi + + if [[ ! -r "$config_file" ]]; then + log_error "Cannot read configuration file: $config_file" + return 1 + fi + + # Check file size (prevent loading extremely large files) + local file_size + if command -v stat >/dev/null 2>&1; then + file_size=$(stat -c%s "$config_file" 2>/dev/null || stat -f%z "$config_file" 2>/dev/null || echo "0") + if [[ "$file_size" -gt 10240 ]]; then # 10KB limit + log_error "Configuration file too large (>10KB): $config_file" return 1 fi fi + + # Check file ownership (warn if not owned by current user) + local file_owner="" + if command -v stat >/dev/null 2>&1; then + # Try GNU stat first, then BSD stat + file_owner=$(stat -c "%U" "$config_file" 2>/dev/null || stat -f "%Su" "$config_file" 2>/dev/null || echo "") + if [[ -n "$file_owner" ]] && [[ "$file_owner" != "$(whoami)" ]]; then + log_warning "Configuration file is owned by $file_owner, not current user" + fi + else + log_verbose "stat utility not available, skipping ownership check" + fi + + # Check for suspicious patterns (enhanced security) + if grep -qE '(\$\(|\$\{|`|;[[:space:]]*rm|;[[:space:]]*exec|;[[:space:]]*eval|\|\||&&|>[^>]|<[^<]|nc[[:space:]]|wget[[:space:]]|curl[[:space:]].*\||bash[[:space:]]*<|sh[[:space:]]*<)' "$config_file"; then + log_error "Configuration file contains potentially dangerous commands" + return 1 + fi + + # Source config file in a subshell to validate + if (source "$config_file" 2>/dev/null); then + # Only source if validation passed + source "$config_file" + log_verbose "Loaded configuration from $config_file" + else + log_error "Invalid configuration file: $config_file" + return 1 + fi + return 0 } @@ -217,30 +413,89 @@ log_verbose() { fi } +# Global error tracking +ERROR_COUNT=0 +WARNING_COUNT=0 +declare -a ERROR_DETAILS=() +declare -a WARNING_DETAILS=() + # Function: log_error -# Description: Log an error message to stderr -# Parameters: $1 - message +# Description: Log an error message to stderr and track for reporting +# Parameters: $1 - message, $2 - optional context # Returns: None log_error() { local message="$1" + local context="${2:-unknown}" + + ((ERROR_COUNT++)) + ERROR_DETAILS+=("[$context] $message") + if [[ -n "$LOG_FILE" ]]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $message" >> "$LOG_FILE" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] [$context] $message" >> "$LOG_FILE" fi echo -e "${RED}[ERROR]${NC} $message" >&2 } # Function: log_warning -# Description: Log a warning message to stderr -# Parameters: $1 - message +# Description: Log a warning message to stderr and track for reporting +# Parameters: $1 - message, $2 - optional context # Returns: None log_warning() { local message="$1" + local context="${2:-unknown}" + + ((WARNING_COUNT++)) + WARNING_DETAILS+=("[$context] $message") + if [[ -n "$LOG_FILE" ]]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARNING] $message" >> "$LOG_FILE" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARNING] [$context] $message" >> "$LOG_FILE" fi echo -e "${YELLOW}[WARNING]${NC} $message" >&2 } +# Function: generate_error_report +# Description: Generate a comprehensive error and warning report +# Parameters: None +# Returns: None +generate_error_report() { + if [[ $ERROR_COUNT -eq 0 ]] && [[ $WARNING_COUNT -eq 0 ]]; then + return 0 + fi + + echo "" + echo -e "${BLUE}=== Installation Report ===${NC}" + echo "Timestamp: $(date '+%Y-%m-%d %H:%M:%S')" + echo "Script Version: $SCRIPT_VERSION" + echo "Installation Directory: $INSTALL_DIR" + echo "" + + if [[ $ERROR_COUNT -gt 0 ]]; then + echo -e "${RED}Errors ($ERROR_COUNT):${NC}" + for error in "${ERROR_DETAILS[@]}"; do + echo " • $error" + done + echo "" + fi + + if [[ $WARNING_COUNT -gt 0 ]]; then + echo -e "${YELLOW}Warnings ($WARNING_COUNT):${NC}" + for warning in "${WARNING_DETAILS[@]}"; do + echo " • $warning" + done + echo "" + fi + + # Recommendations based on errors/warnings + if [[ $ERROR_COUNT -gt 0 ]]; then + echo -e "${BLUE}Recommendations:${NC}" + echo " • Check file permissions and ownership" + echo " • Verify disk space availability" + echo " • Ensure all required commands are installed" + echo " • Review log file for detailed information: ${LOG_FILE:-not specified}" + echo "" + fi +} + # Function: is_exception # Description: Check if a file matches any exception pattern # Parameters: $1 - file path @@ -281,27 +536,72 @@ verify_file_integrity() { local src_file="$1" local dest_file="$2" + # Validate input parameters + if [[ -z "$src_file" ]] || [[ -z "$dest_file" ]]; then + log_error "verify_file_integrity: Both source and destination files required" + return 1 + fi + + # Check if files exist and are readable + if [[ ! -f "$src_file" ]]; then + log_error "verify_file_integrity: Source file does not exist: $src_file" + return 1 + fi + + if [[ ! -r "$src_file" ]]; then + log_error "verify_file_integrity: Cannot read source file: $src_file" + return 1 + fi + + if [[ ! -f "$dest_file" ]]; then + log_error "verify_file_integrity: Destination file does not exist: $dest_file" + return 1 + fi + + if [[ ! -r "$dest_file" ]]; then + log_error "verify_file_integrity: Cannot read destination file: $dest_file" + return 1 + fi + # If sha256sum is not available, skip verification if ! check_command sha256sum; then log_verbose "sha256sum not available, skipping integrity check" return 0 fi - # Calculate checksums - local src_checksum=$(sha256sum "$src_file" 2>/dev/null | awk '{print $1}') - local dest_checksum=$(sha256sum "$dest_file" 2>/dev/null | awk '{print $1}') + # Calculate checksums with error handling + local src_checksum dest_checksum + + if ! src_checksum=$(sha256sum "$src_file" 2>/dev/null | awk '{print $1}'); then + log_error "verify_file_integrity: Failed to calculate checksum for source: $src_file" + return 1 + fi + + if ! dest_checksum=$(sha256sum "$dest_file" 2>/dev/null | awk '{print $1}'); then + log_error "verify_file_integrity: Failed to calculate checksum for destination: $dest_file" + return 1 + fi # Verify checksums match if [[ -z "$src_checksum" ]] || [[ -z "$dest_checksum" ]]; then - log_verbose "Could not calculate checksums for $src_file" + log_error "verify_file_integrity: Empty checksums calculated" + return 1 + fi + + # Validate checksum format (64 hex characters) + if [[ ! "$src_checksum" =~ ^[a-f0-9]{64}$ ]] || [[ ! "$dest_checksum" =~ ^[a-f0-9]{64}$ ]]; then + log_error "verify_file_integrity: Invalid checksum format" return 1 fi if [[ "$src_checksum" != "$dest_checksum" ]]; then - log_verbose "Checksum mismatch: $src_file ($src_checksum) != $dest_file ($dest_checksum)" + log_error "verify_file_integrity: Checksum mismatch" + log_error " Source: $src_file ($src_checksum)" + log_error " Dest: $dest_file ($dest_checksum)" return 1 fi + log_verbose "File integrity verified: $dest_file" return 0 } @@ -311,21 +611,71 @@ verify_file_integrity() { # Returns: List of files (one per line) get_source_files() { local source_root="$1" - local current_dir=$(pwd) - cd "$source_root" || return 1 - # Find all files, excluding .git and backup directories - find . -type f \ - -not -path "./.git*" \ - -not -path "./backup.*" \ - -not -name "install.sh" \ - -not -name "README.md" \ - -not -name "LICENSE" \ + # Validate input parameter + if [[ -z "$source_root" ]]; then + log_error "get_source_files: Source root directory required" + return 1 + fi + + # Validate that source root exists and is a directory + if [[ ! -d "$source_root" ]]; then + log_error "get_source_files: Source root is not a directory: $source_root" + return 1 + fi + + local current_dir + if ! current_dir=$(pwd); then + log_error "get_source_files: Failed to get current directory" + return 1 + fi + + # Change to source directory with error handling + if ! cd "$source_root"; then + log_error "get_source_files: Cannot access source directory: $source_root" + return 1 + fi + + # Validate that .claude directory exists + if [[ ! -d ".claude" ]]; then + log_error "get_source_files: .claude directory not found in source root" + cd "$current_dir" || log_warning "Failed to return to original directory" + return 1 + fi + + # Find all files in .claude directory and map them to root with error handling + local file_list + if ! file_list=$(find .claude -type f \ + -not -path "*/.git*" \ + -not -path "*/backup.*" \ + -not -path "*/log/*" \ + -not -path "*/logs/*" \ + -not -path "*/.log/*" \ + -not -path "*/.logs/*" \ -not -name "*.log" \ + -not -name "*.logs" \ -not -name "settings.local.json" \ - | sed 's|^\./||' | sort + 2>/dev/null | sed 's|^\.claude/||' | sort); then + log_error "get_source_files: Failed to enumerate files in .claude directory" + cd "$current_dir" || log_warning "Failed to return to original directory" + return 1 + fi - cd "$current_dir" || return 1 + # Output the file list + echo "$file_list" + + # Also include CLAUDE.md from root if it exists + if [[ -f "CLAUDE.md" ]]; then + echo "CLAUDE.md" + fi + + # Return to original directory with error handling + if ! cd "$current_dir"; then + log_error "get_source_files: Failed to return to original directory: $current_dir" + return 1 + fi + + return 0 } # Function: get_installed_files @@ -337,9 +687,9 @@ get_installed_files() { local current_dir=$(pwd) cd "$install_dir" || return 1 - # Find all files, excluding backups + # Find all files, excluding backups (match actual backup pattern) find . -type f \ - -not -path "./backup.*" \ + -not -path "./superclaude-backup.*" \ | sed 's|^\./||' | sort cd "$current_dir" || return 1 @@ -359,10 +709,15 @@ check_for_updates() { log "Checking for SuperClaude updates..." - # Get latest release info - local release_info=$(curl -s "$repo_url" 2>/dev/null) - if [[ -z "$release_info" ]] || [[ "$release_info" == *"Not Found"* ]]; then - log_error "Failed to check for updates" + # Get latest release info with timeout + local release_info + if ! release_info=$(timeout 30 curl -s --max-time 30 --connect-timeout 10 "$repo_url" 2>/dev/null); then + log_error "Failed to check for updates (network timeout or error)" + return 2 + fi + + if [[ -z "$release_info" ]] || [[ "$release_info" == *"Not Found"* ]] || [[ "$release_info" == *"API rate limit"* ]]; then + log_error "Failed to check for updates (empty response or API limit)" return 2 fi @@ -376,8 +731,8 @@ check_for_updates() { log "Current version: $SCRIPT_VERSION" log "Latest version: $latest_version" - # Compare versions (simple string comparison) - if [[ "$SCRIPT_VERSION" < "$latest_version" ]]; then + # Compare versions using semantic version comparison + if compare_versions "$SCRIPT_VERSION" "$latest_version"; then echo -e "${YELLOW}Update available!${NC}" echo "Download: https://github.com/nshkrdotcom/SuperClaude/releases/latest" return 0 @@ -481,7 +836,7 @@ run_preflight_checks() { detect_platform # Check required commands - local required_commands=("find" "comm" "cmp" "sort" "uniq" "basename" "dirname") + local required_commands=("find" "comm" "cmp" "sort" "uniq" "basename" "dirname" "grep" "awk" "sed") local missing_commands=() for cmd in "${required_commands[@]}"; do @@ -490,16 +845,21 @@ run_preflight_checks() { fi done + # Check for timeout command (used for network operations) + if ! command -v timeout &> /dev/null; then + log_verbose "timeout command not available, network operations may hang" + fi + if [[ ${#missing_commands[@]} -gt 0 ]]; then - log_error "Missing required commands: ${missing_commands[*]}" + log_error "Missing required commands: ${missing_commands[*]}" "preflight-check" echo "Please install the missing commands and try again." exit 1 fi - # Check bash version (need at least 4.0 for associative arrays) + # Check bash version local bash_major_version="${BASH_VERSION%%.*}" if [[ -z "$bash_major_version" ]] || [[ "$bash_major_version" -lt "$MIN_BASH_VERSION" ]]; then - log_error "Bash version $MIN_BASH_VERSION.0 or higher required (current: ${BASH_VERSION:-unknown})" + log_error "Bash version $MIN_BASH_VERSION.0 or higher required (current: ${BASH_VERSION:-unknown})" "preflight-check" exit 1 fi @@ -507,9 +867,24 @@ run_preflight_checks() { if [[ ! "$DRY_RUN" = true ]]; then local install_parent=$(dirname "$INSTALL_DIR") if [[ -d "$install_parent" ]]; then - local available_space=$(df -k "$install_parent" 2>/dev/null | awk 'NR==2 {print $4}') + # Get available space - handle different df output formats + local available_space="" + if command -v df &>/dev/null; then + # Try POSIX-compliant df first + available_space=$(df -P -k "$install_parent" 2>/dev/null | awk 'NR==2 && NF>=4 {print $4}') + # If that fails, try without -P flag + if [[ -z "$available_space" ]] || [[ ! "$available_space" =~ ^[0-9]+$ ]]; then + available_space=$(df -k "$install_parent" 2>/dev/null | awk 'NR==2 && NF>=4 {print $4}') + fi + # Final fallback - try to parse any numeric value from df output + if [[ -z "$available_space" ]] || [[ ! "$available_space" =~ ^[0-9]+$ ]]; then + available_space=$(df "$install_parent" 2>/dev/null | awk '/[0-9]/ {for(i=1;i<=NF;i++) if($i ~ /^[0-9]+$/ && $i > 1000) print $i; exit}') + fi + else + log_verbose "df utility not available, skipping disk space check" + fi if [[ -n "$available_space" ]] && [[ "$available_space" -lt "$REQUIRED_SPACE_KB" ]]; then - log_error "Insufficient disk space. Need at least $((REQUIRED_SPACE_KB / 1024))MB free." + log_error "Insufficient disk space. Need at least $((REQUIRED_SPACE_KB / 1024))MB free." "disk-space-check" exit 1 fi fi @@ -553,6 +928,17 @@ load_default_config while [[ $# -gt 0 ]]; do case $1 in --dir) + if [[ -z "$2" ]] || [[ "$2" == --* ]]; then + log_error "--dir requires a directory argument" + exit 1 + fi + + # Validate the directory path + if ! validate_directory_path "$2"; then + log_error "Invalid installation directory: $2" + exit 1 + fi + INSTALL_DIR="$2" shift 2 ;; @@ -581,6 +967,10 @@ while [[ $# -gt 0 ]]; do shift ;; --config) + if [[ -z "$2" ]] || [[ "$2" == --* ]]; then + log_error "--config requires a file argument" + exit 1 + fi if [[ -f "$2" ]]; then load_config "$2" else @@ -598,15 +988,41 @@ while [[ $# -gt 0 ]]; do exit $? ;; --log) + if [[ -z "$2" ]] || [[ "$2" == --* ]]; then + log_error "--log requires a file argument" + exit 1 + fi + + # Validate log file path + if [[ "$2" =~ $'\0'|[[:cntrl:]] ]]; then + log_error "Invalid characters in log file path: $2" + exit 1 + fi + + # Check for path traversal in log file + if [[ "$2" =~ \.\./|/\.\. ]]; then + log_error "Path traversal not allowed in log file path: $2" + exit 1 + fi + LOG_FILE="$2" # Create log directory if needed log_dir=$(dirname "$LOG_FILE") if [[ ! -d "$log_dir" ]]; then - mkdir -p "$log_dir" || { - echo -e "${RED}Error: Cannot create log directory $log_dir${NC}" + if ! mkdir -p "$log_dir" 2>/dev/null; then + log_error "Cannot create log directory: $log_dir" + log_error "Check permissions and path validity" exit 1 - } + fi fi + + # Test if we can write to the log file + if ! touch "$LOG_FILE" 2>/dev/null; then + log_error "Cannot write to log file: $LOG_FILE" + log_error "Check permissions and directory existence" + exit 1 + fi + shift 2 ;; --version) @@ -673,7 +1089,7 @@ if [[ "$UNINSTALL_MODE" = true ]]; then current_dir=$(pwd) cd "$INSTALL_DIR" || exit 1 - # Process files and count them properly (avoid subshell issue) + # Process files and count them properly (fixed variable scope issue) while IFS= read -r installed_file; do installed_file="${installed_file#./}" # Remove leading ./ @@ -682,7 +1098,19 @@ if [[ "$UNINSTALL_MODE" = true ]]; then ((preserved_count++)) else # Check if this file exists in source (is a SuperClaude file) - if [[ -f "$current_dir/$installed_file" ]] || [[ "$installed_file" =~ \.expanded$ ]]; then + # Validate current_dir path to prevent path traversal + if [[ "$current_dir" =~ \.\. ]]; then + log_error "Invalid current directory path detected: $current_dir" + continue + fi + + # Only remove files that we know we installed + if [[ -f ".claude/$installed_file" ]] || \ + [[ "$installed_file" == "CLAUDE.md" && -f "CLAUDE.md" ]] || \ + [[ "$installed_file" == "VERSION" ]] || \ + [[ "$installed_file" == ".checksums" ]] || \ + [[ "$installed_file" =~ ^commands/ ]] || \ + [[ "$installed_file" =~ ^shared/ ]]; then if [[ "$DRY_RUN" = true ]]; then echo " Would remove: $installed_file" else @@ -735,11 +1163,11 @@ if [[ "$VERIFY_MODE" = true ]]; then fi # Check if we're in SuperClaude directory - if [ ! -f "CLAUDE.md" ] || [ ! -d ".claude/commands" ]; then + if [ ! -f "CLAUDE.md" ] || [ ! -d ".claude" ] || [ ! -d ".claude/commands" ]; then echo -e "${RED}Error: This script must be run from the SuperClaude directory${NC}" echo "" echo "Expected files not found. Please ensure you are in the root SuperClaude directory." - echo "Missing: $([ ! -f "CLAUDE.md" ] && echo "CLAUDE.md ")$([ ! -d ".claude/commands" ] && echo ".claude/commands/")" + echo "Missing: $([ ! -f "CLAUDE.md" ] && echo "CLAUDE.md ")$([ ! -d ".claude" ] && echo ".claude/ ")$([ ! -d ".claude/commands" ] && echo ".claude/commands/")" echo "" echo "Solution: cd to the SuperClaude directory and run: ./install.sh --verify-checksums" exit 1 @@ -846,30 +1274,30 @@ echo "" # Run pre-flight checks run_preflight_checks -# Check write permissions (using atomic test) -parent_for_write=$(dirname "$INSTALL_DIR") -write_test_file="" - -if [[ -d "$INSTALL_DIR" ]]; then - # Directory exists, test write permission atomically - write_test_file="$INSTALL_DIR/.write_test_$$" - if ! touch "$write_test_file" 2>/dev/null; then - log_error "No write permission for $INSTALL_DIR" - exit 1 +# Check write permissions (using atomic test) - skip in dry run mode +if [[ "$DRY_RUN" != true ]]; then + parent_for_write=$(dirname "$INSTALL_DIR") + write_test_file="" + + if [[ -d "$INSTALL_DIR" ]]; then + # Directory exists, test write permission atomically using mktemp + write_test_file=$(mktemp "$INSTALL_DIR/.write_test_XXXXXX" 2>/dev/null) || { + log_error "No write permission for $INSTALL_DIR" + exit 1 + } + rm -f "$write_test_file" 2>/dev/null + else + # Directory doesn't exist, test parent write permission + if [[ ! -d "$parent_for_write" ]]; then + log_error "Parent directory does not exist: $parent_for_write" + exit 1 + fi + write_test_file=$(mktemp "$parent_for_write/.write_test_XXXXXX" 2>/dev/null) || { + log_error "No write permission to create $INSTALL_DIR" + exit 1 + } + rm -f "$write_test_file" 2>/dev/null fi - rm -f "$write_test_file" 2>/dev/null -else - # Directory doesn't exist, test parent write permission - if [[ ! -d "$parent_for_write" ]]; then - log_error "Parent directory does not exist: $parent_for_write" - exit 1 - fi - write_test_file="$parent_for_write/.write_test_$$" - if ! touch "$write_test_file" 2>/dev/null; then - log_error "No write permission to create $INSTALL_DIR" - exit 1 - fi - rm -f "$write_test_file" 2>/dev/null fi # Confirmation prompt (skip if --force) @@ -889,11 +1317,11 @@ fi echo "" # Check if we're in SuperClaude directory -if [ ! -f "CLAUDE.md" ] || [ ! -d ".claude/commands" ]; then +if [ ! -f "CLAUDE.md" ] || [ ! -d ".claude" ] || [ ! -d ".claude/commands" ]; then echo -e "${RED}Error: This script must be run from the SuperClaude directory${NC}" echo "" echo "Expected files not found. Please ensure you are in the root SuperClaude directory." - echo "Missing: $([ ! -f "CLAUDE.md" ] && echo "CLAUDE.md ")$([ ! -d ".claude/commands" ] && echo ".claude/commands/")" + echo "Missing: $([ ! -f "CLAUDE.md" ] && echo "CLAUDE.md ")$([ ! -d ".claude" ] && echo ".claude/ ")$([ ! -d ".claude/commands" ] && echo ".claude/commands/")" echo "" echo "Solution: cd to the SuperClaude directory and run: ./install.sh" exit 1 @@ -933,24 +1361,80 @@ if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then if [ "$backup_choice" = "y" ]; then # Create backup directory with secure random suffix backup_timestamp=$(date +%Y%m%d_%H%M%S) - backup_random=$(head -c 8 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n' || echo "$$") - BACKUP_DIR="$(dirname "$INSTALL_DIR")/superclaude-backup.${backup_timestamp}.${backup_random}" - mkdir -p "$BACKUP_DIR" + # Generate cryptographically secure random suffix - try multiple methods + backup_random="" + local random_bytes="" - # Backup ALL existing files + # Try multiple secure random sources + if [[ -r /dev/urandom ]]; then + random_bytes=$(head -c 16 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n') + elif command -v openssl &>/dev/null; then + random_bytes=$(openssl rand -hex 16 2>/dev/null) + elif [[ -r /dev/random ]]; then + # Use /dev/random as fallback (may block) + random_bytes=$(timeout 5 head -c 16 /dev/random 2>/dev/null | od -An -tx1 | tr -d ' \n') + fi + + # Generate additional entropy from multiple sources + if [[ -n "$random_bytes" ]]; then + backup_random="$random_bytes" + else + # High-entropy fallback using multiple sources (improved) + local entropy_sources="$(date +%s%N 2>/dev/null)$$${RANDOM}${BASHPID:-$$}$(ps -eo pid,ppid,time 2>/dev/null | md5sum 2>/dev/null | cut -c1-8)" + backup_random=$(printf "%s" "$entropy_sources" | sha256sum 2>/dev/null | cut -c1-16) + fi + + # Ensure backup_random is not empty + backup_random="${backup_random:-$(date +%s)$$}" + + # Create backup directory with restrictive permissions + BACKUP_DIR="$(dirname "$INSTALL_DIR")/superclaude-backup.${backup_timestamp}.${backup_random}" + if ! mkdir -p "$BACKUP_DIR"; then + log_error "Failed to create backup directory: $BACKUP_DIR" + exit 1 + fi + + # Set restrictive permissions on backup directory (owner only) + chmod 700 "$BACKUP_DIR" || log_warning "Failed to set restrictive permissions on backup directory" + + # Backup ALL existing files including hidden files echo "Backing up all existing files..." - # Copy everything except backup directories - for item in "$INSTALL_DIR"/*; do - basename_item=$(basename "$item") - # Skip backup directories to avoid nested backups - if [[ ! "$basename_item" =~ ^backup\. ]]; then - if [ -e "$item" ]; then - cp -r "$item" "$BACKUP_DIR/" + # Use find to include hidden files and handle special cases + if ! cd "$INSTALL_DIR"; then + log_error "Failed to enter installation directory: $INSTALL_DIR" + log_error "Check permissions and directory existence" + exit 1 + fi + + # Find all files and directories, including hidden ones + find . -mindepth 1 -maxdepth 1 \( -name "superclaude-backup.*" -prune \) -o -print0 | \ + while IFS= read -r -d '' item; do + # Copy preserving permissions and symlinks, with security checks + if [[ -e "$item" ]]; then + # Validate that item is within the installation directory (prevent symlink attacks) + local real_item + if command -v realpath &>/dev/null; then + real_item=$(realpath "$item" 2>/dev/null) + local real_install_dir=$(realpath "$INSTALL_DIR" 2>/dev/null) + if [[ -n "$real_item" ]] && [[ -n "$real_install_dir" ]] && [[ "$real_item" != "$real_install_dir"/* ]]; then + log_warning "Skipping backup of suspicious item outside install dir: $item" + continue + fi fi + + cp -rP "$item" "$BACKUP_DIR/" || { + log_warning "Failed to backup: $item" + } fi done + if ! cd "$ORIGINAL_DIR"; then + log_error "Failed to return to original directory: $ORIGINAL_DIR" + log_error "This may affect subsequent operations" + # Don't exit here as backup was successful + fi + echo -e "${GREEN}Backed up existing files to: $BACKUP_DIR${NC}" fi elif [ -d "$INSTALL_DIR" ]; then @@ -979,16 +1463,33 @@ else echo "Installing SuperClaude..." fi +# Mark that we're entering the installation phase +INSTALLATION_PHASE=true + # Create directory structure dynamically based on source if [[ "$DRY_RUN" != true ]]; then echo "Creating directory structure..." - # Find all directories in source and create them in destination - find . -type d \ - -not -path "./.git*" \ - -not -path "./backup.*" \ - -not -name "." \ + # Find all directories in .claude and create them in destination (without .claude prefix) + find .claude -type d \ + -not -path "*/.git*" \ + -not -path "*/backup.*" \ + -not -path "*/log" \ + -not -path "*/log/*" \ + -not -path "*/logs" \ + -not -path "*/logs/*" \ + -not -path "*/.log" \ + -not -path "*/.log/*" \ + -not -path "*/.logs" \ + -not -path "*/.logs/*" \ | while read -r dir; do - mkdir -p "$INSTALL_DIR/${dir#./}" + # Strip .claude/ prefix + target_dir="${dir#.claude/}" + if [[ -n "$target_dir" ]] && [[ "$target_dir" != "." ]]; then + mkdir -p "$INSTALL_DIR/$target_dir" || { + log_error "Failed to create directory: $INSTALL_DIR/$target_dir" + exit 1 + } + fi done else echo "Would create directory structure..." @@ -1001,6 +1502,24 @@ copy_with_update_check() { local basename_file=$(basename "$src_file") local copy_performed=false local target_file="$dest_file" + local retry_count=0 + local max_retries=3 + + # Validate inputs + if [[ -z "$src_file" ]] || [[ -z "$dest_file" ]]; then + log_error "copy_with_update_check: Source and destination files required" + return 1 + fi + + if [[ ! -f "$src_file" ]]; then + log_error "copy_with_update_check: Source file does not exist: $src_file" + return 1 + fi + + if [[ ! -r "$src_file" ]]; then + log_error "copy_with_update_check: Cannot read source file: $src_file" + return 1 + fi if [[ "$UPDATE_MODE" = true ]] && [[ -f "$dest_file" ]]; then # Check if file differs from source @@ -1017,37 +1536,92 @@ copy_with_update_check() { if [[ "$is_customizable" = true ]]; then echo " Preserving customized $basename_file (new version: $basename_file.new)" if [[ "$DRY_RUN" != true ]]; then - cp "$src_file" "$dest_file.new" - target_file="$dest_file.new" - copy_performed=true + # Retry copy operation with recovery + while [[ $retry_count -lt $max_retries ]]; do + if cp "$src_file" "$dest_file.new" 2>/dev/null; then + target_file="$dest_file.new" + copy_performed=true + break + else + ((retry_count++)) + log_warning "Copy attempt $retry_count failed for $basename_file.new, retrying..." + sleep 1 + fi + done + + if [[ $retry_count -eq $max_retries ]]; then + log_error "Failed to copy after $max_retries attempts: $src_file to $dest_file.new" + return 1 + fi fi else if [[ "$DRY_RUN" != true ]]; then - cp "$src_file" "$dest_file" - copy_performed=true + # Retry copy operation with recovery + while [[ $retry_count -lt $max_retries ]]; do + if cp "$src_file" "$dest_file" 2>/dev/null; then + copy_performed=true + break + else + ((retry_count++)) + log_warning "Copy attempt $retry_count failed for $basename_file, retrying..." + sleep 1 + fi + done + + if [[ $retry_count -eq $max_retries ]]; then + log_error "Failed to copy after $max_retries attempts: $src_file to $dest_file" + return 1 + fi fi fi else if [[ "$DRY_RUN" != true ]]; then - cp "$src_file" "$dest_file" - copy_performed=true + # File is identical, still copy to ensure permissions are correct + if cp "$src_file" "$dest_file" 2>/dev/null; then + copy_performed=true + else + log_warning "Failed to update identical file: $basename_file" + # This is non-critical, don't fail the installation + fi fi fi else if [[ "$DRY_RUN" != true ]]; then - cp "$src_file" "$dest_file" - copy_performed=true + # Retry copy operation with recovery + while [[ $retry_count -lt $max_retries ]]; do + if cp "$src_file" "$dest_file" 2>/dev/null; then + copy_performed=true + break + else + ((retry_count++)) + log_warning "Copy attempt $retry_count failed for $basename_file, retrying..." + sleep 1 + fi + done + + if [[ $retry_count -eq $max_retries ]]; then + log_error "Failed to copy after $max_retries attempts: $src_file to $dest_file" + return 1 + fi fi fi - # Verify integrity after copy + # Verify integrity after copy with recovery if [[ "$copy_performed" = true ]] && [[ "$DRY_RUN" != true ]]; then if ! verify_file_integrity "$src_file" "$target_file"; then - log_error "Integrity verification failed for $basename_file" - ((VERIFICATION_FAILURES++)) - return 1 + log_warning "Initial integrity verification failed for $basename_file, attempting recovery..." + + # Try to re-copy the file once more + if cp "$src_file" "$target_file" 2>/dev/null && verify_file_integrity "$src_file" "$target_file"; then + log_verbose "Recovery successful: integrity verified for $basename_file" + else + log_error "Integrity verification failed for $basename_file after recovery attempt" + ((VERIFICATION_FAILURES++)) + return 1 + fi + else + log_verbose "Integrity verified for $basename_file" fi - log_verbose "Integrity verified for $basename_file" fi return 0 @@ -1065,18 +1639,34 @@ preserved_count=0 while IFS= read -r file; do if [[ -n "$file" ]]; then ((current_file++)) - src_file="./$file" + + # Determine source file path + if [[ "$file" == "CLAUDE.md" ]]; then + src_file="./$file" + else + src_file="./.claude/$file" + fi + dest_file="$INSTALL_DIR/$file" # Show progress if [[ "$VERBOSE" = true ]] || [[ $((current_file % 10)) -eq 0 ]]; then - printf "\r Progress: [%3d/%3d] Processing: %-50s" "$current_file" "$total_files" "${file:0:50}" + # Check if terminal supports carriage return + if [[ -t 1 ]] && command -v tput &>/dev/null && tput cr &>/dev/null; then + printf "\r Progress: [%3d/%3d] Processing: %-50s" "$current_file" "$total_files" "${file:0:50}" + else + # Fallback for terminals without \r support + echo " Progress: [$current_file/$total_files] Processing: ${file:0:50}" + fi fi # Create parent directory if needed dest_dir=$(dirname "$dest_file") if [[ "$DRY_RUN" != true ]]; then - mkdir -p "$dest_dir" + mkdir -p "$dest_dir" || { + log_error "Failed to create directory: $dest_dir" + continue + } fi # Check if this is a preserved user file @@ -1089,8 +1679,10 @@ while IFS= read -r file; do copy_with_update_check "$src_file" "$dest_file" # Make scripts executable - if [[ "$file" == *.sh ]]; then - chmod +x "$dest_file" + if [[ "$file" == *.sh ]] || [[ "$file" == *.py ]] || [[ "$file" == *.rb ]] || [[ "$file" == *.pl ]]; then + chmod +x "$dest_file" || { + log_warning "Failed to set executable permission on $dest_file" + } fi fi ((copied_count++)) @@ -1099,7 +1691,9 @@ while IFS= read -r file; do done < <(get_source_files ".") # Clear progress line and show summary -printf "\r%-80s\r" " " +if [[ -t 1 ]] && command -v tput &>/dev/null && tput cr &>/dev/null; then + printf "\r%-80s\r" " " +fi echo " Files copied: $copied_count" echo " Files preserved: $preserved_count" @@ -1122,8 +1716,13 @@ if command -v sha256sum &> /dev/null && [[ "$DRY_RUN" != true ]]; then > "$checksums_file" # Generate checksums for all installed files - cd "$INSTALL_DIR" || exit 1 - find . -type f -not -path "./backup.*" -not -name ".checksums" | sort | while read -r file; do + if ! cd "$INSTALL_DIR"; then + log_error "Failed to enter installation directory for checksum generation: $INSTALL_DIR" + log_warning "Skipping checksum generation" + else + + # Use process substitution to avoid subshell issue + while IFS= read -r file; do # Skip empty files if [[ -s "$file" ]]; then checksum=$(sha256sum "$file" 2>/dev/null | awk '{print $1}') @@ -1131,10 +1730,12 @@ if command -v sha256sum &> /dev/null && [[ "$DRY_RUN" != true ]]; then echo "$checksum $file" >> "$checksums_file" fi fi - done - cd "$ORIGINAL_DIR" || { log_error "Failed to return to original directory"; exit 1; } + done < <(find . -type f -not -path "./superclaude-backup.*" -not -name ".checksums" | sort) - log_verbose "Generated checksums file at $checksums_file" + cd "$ORIGINAL_DIR" || { log_error "Failed to return to original directory"; exit 1; } + + log_verbose "Generated checksums file at $checksums_file" + fi else log_verbose "sha256sum not available or dry run mode, skipping checksum generation" fi @@ -1161,7 +1762,7 @@ echo -e " Claude shared files: ${GREEN}$claude_shared${NC}" # Verify critical files exist critical_files_ok=true -for critical_file in "CLAUDE.md" ".claude/commands" ".claude/shared"; do +for critical_file in "CLAUDE.md" "commands" "shared"; do if [[ ! -e "$INSTALL_DIR/$critical_file" ]]; then echo -e "${YELLOW}Warning: Critical file/directory missing: $critical_file${NC}" critical_files_ok=false @@ -1170,6 +1771,9 @@ done # Check if installation was successful if [ "$actual_files" -ge "$expected_files" ] && [ "$critical_files_ok" = true ] && [ $VERIFICATION_FAILURES -eq 0 ]; then + # Mark installation phase as complete + INSTALLATION_PHASE=false + echo "" if [[ "$UPDATE_MODE" = true ]]; then echo -e "${GREEN}✓ SuperClaude updated successfully!${NC}" @@ -1201,6 +1805,10 @@ if [ "$actual_files" -ge "$expected_files" ] && [ "$critical_files_ok" = true ] echo "" fi echo "For more information, see README.md" + + # Preserve BACKUP_DIR for user reference but mark installation as complete + INSTALLATION_PHASE=false + log_verbose "Installation completed successfully, rollback disabled" else echo "" echo -e "${RED}✗ Installation may be incomplete${NC}"