11.3. Defensive Scripting#

11.3.1. Common Pitfalls#

1. Not quoting variables with user input

# Dangerous
rm -rf $user_dir  # If $user_dir contains spaces, breaks
rm -rf "$user_dir"  # Correct, even with spaces

2. Missing output redirects

# Problem: Errors silently lost
tar -czf backup.tar.gz /var 2>&1 > /dev/null
# Both stdout and stderr lost!

# Better: Keep error output
tar -czf backup.tar.gz /var > /tmp/backup.log 2>&1
if [[ $? -ne 0 ]]; then
  cat /tmp/backup.log >&2
  exit 1
fi

3. Assuming commands exist

# Bad: jq might not be installed
jq '.field' input.json

# Good: Check first
if ! command -v jq &>/dev/null; then
  echo "Error: jq not installed" >&2
  exit 1
fi
jq '.field' input.json

4. Not handling partial success

# Bad: Stop at first failure
for file in *.txt; do
  process_file "$file" || exit 1  # Stops on first error
done

# Better: Continue but track failures
failures=0
for file in *.txt; do
  process_file "$file" || ((failures++))
done

if [[ $failures -gt 0 ]]; then
  echo "Failed to process $failures files" >&2
  exit 1
fi

11.3.2. Real-World Example: Defensive System Update Script#

#!/bin/bash
set -euo pipefail

# DEFENSIVE UPDATE SCRIPT
# Requirements: sudo, network, /tmp, 500MB disk space

readonly SCRIPT=$(basename "$0")
readonly BACKUP_DIR="/backups"
readonly UPDATE_LOG="/var/log/system_update.log"

# 1. Verify preconditions
verify_prerequisites() {
  # Check root
  [[ $EUID -eq 0 ]] || {
    echo "Error: must run as root" >&2
    exit 1
  }
  
  # Check disk space (need 500MB)
  local free_space=$(df /tmp | awk 'NR==2 {print int($4/1024)}')
  [[ $free_space -gt 500 ]] || {
    echo "Error: insufficient disk space (need 500MB, have ${free_space}MB)" >&2
    exit 1
  }
  
  # Check network
  ping -c 1 8.8.8.8 &>/dev/null || {
    echo "Error: no network connectivity" >&2
    exit 1
  }
}

# 2. Create backup
create_backup() {
  mkdir -p "$BACKUP_DIR" || return 1
  local backup_file="$BACKUP_DIR/system_$(date +%Y%m%d_%H%M%S).tar.gz"
  
  tar -czf "$backup_file" /etc /home || {
    echo "Error: backup failed" >&2
    return 1
  }
  
  echo "Backup created: $backup_file"
}

# 3. Cleanup on error
handle_error() {
  echo "[$(date)] Update failed at line $1" >> "$UPDATE_LOG"
  echo "Error occurred. Check $UPDATE_LOG for details" >&2
  exit 1
}

trap 'handle_error $LINENO' ERR

# Run verification and backup
verify_prerequisites
create_backup

# 4. Perform updates
echo "[$(date)] Starting system update..." >> "$UPDATE_LOG"
apt-get update -qq || { echo "Update failed" >&2; exit 1; }
apt-get upgrade -y || { echo "Upgrade failed" >&2; exit 1; }
echo "[$(date)] Update completed successfully" >> "$UPDATE_LOG"

11.3.3. State Management and Assumptions#

11.3.3.1. Document Assumptions#

#!/bin/bash
# ASSUMPTIONS:
# - Script must run as root
# - /var/log/app/ must exist
# - Database password in /etc/app.conf
# - Network connectivity required

# Verify assumptions at startup
verify_environment() {
  [[ $EUID -eq 0 ]] || { echo "Must run as root" >&2; exit 1; }
  [[ -d "/var/log/app" ]] || { echo "Log directory missing" >&2; exit 1; }
  [[ -r "/etc/app.conf" ]] || { echo "Config not readable" >&2; exit 1; }
  
  # Test connectivity
  ping -c 1 8.8.8.8 &>/dev/null || {
    echo "Warning: No network connectivity" >&2
    # Continue anyway, but note the issue
  }
}

verify_environment || exit 1

11.3.3.2. Idempotent Scripts#

# Good practice: Script can run multiple times safely
backup_system() {
  local backup_dir="/backups"
  
  # Create only if needed
  [[ -d "$backup_dir" ]] || mkdir -p "$backup_dir" || return 1
  
  # Use date for unique backups
  local backup_file="$backup_dir/system_$(date +%Y%m%d_%H%M%S).tar.gz"
  
  tar -czf "$backup_file" /etc /home 2>/dev/null || return 1
  
  # Cleanup old backups (keep last 30 days)
  find "$backup_dir" -name "system_*.tar.gz" -mtime +30 -delete
  
  return 0
}

11.3.4. Preventing Command Injection#

11.3.4.1. Avoid eval#

# DANGEROUS: User input in eval
user_input=$1
eval "result=$user_input"  # User could inject: $(rm -rf /)

# Safe alternatives
case "$user_input" in
  add)    do_add;;
  remove) do_remove;;
  *)      echo "Unknown command" >&2; exit 1;;
esac

11.3.4.2. Proper Argument Passing#

# Bad: Command string passed as single argument
process_data "grep 'pattern' file.txt | sort"  # Could be manipulated

# Good: Arguments as separate parameters
process_data "pattern" "file.txt"  # Safe, no string parsing

11.3.4.3. Escaping User Input#

# If you must pass user input to a command
user_pattern=$1

# Use proper quoting
grep -- "$user_pattern" file.txt  # -- prevents pattern from being flag

# For filenames
cp "$user_file" "$backup_dir/" 2>/dev/null || {
  echo "Copy failed for: $user_file" >&2
  exit 1
}

11.3.5. Defensive Programming Patterns#

11.3.5.1. Quoting Variables#

# Bad: Variable expansion without quotes
rm -rf $backup_dir/*  # DISASTER if $backup_dir is unset!

# Good: Always quote variables
rm -rf "$backup_dir"/*  # Safe, errors on unset

# Better: Check before using
[[ -n "$backup_dir" ]] || { echo "Backup dir not set" >&2; exit 1; }
rm -rf "$backup_dir"/*

11.3.5.2. Array Handling#

# Process array safely
files=("$@")  # Quote to preserve spaces in filenames

# Check array is not empty
if [[ ${#files[@]} -eq 0 ]]; then
  echo "No files provided" >&2
  exit 1
fi

# Safe iteration
for file in "${files[@]}"; do
  [[ -f "$file" ]] || { echo "Not a file: $file" >&2; continue; }
  process "$file"
done

11.3.5.3. Safe Temporary Files#

# Bad: Predictable filename
temp_file="/tmp/mytemp.txt"
echo "data" > "$temp_file"  # Security risk!

# Good: Use mktemp
temp_file=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
trap "rm -f '$temp_file'" EXIT

echo "data" > "$temp_file"

11.3.6. Input Validation#

Always validate user input before using it:

11.3.6.1. Checking for Required Arguments#

#!/bin/bash

process_file() {
  local file=$1
  
  # Check if argument provided
  if [[ -z "$file" ]]; then
    echo "Error: filename required" >&2
    return 1
  fi
  
  # Check if file exists
  if [[ ! -f "$file" ]]; then
    echo "Error: file not found: $file" >&2
    return 1
  fi
  
  # Check if file is readable
  if [[ ! -r "$file" ]]; then
    echo "Error: file not readable: $file" >&2
    return 1
  fi
  
  # Now safe to process
  grep pattern "$file"
}

11.3.6.2. Validating Parameter Values#

#!/bin/bash

set_log_level() {
  local level=$1
  
  # Whitelist valid values
  case "$level" in
    debug|info|warn|error)
      LOG_LEVEL="$level"
      ;;
    *)
      echo "Error: invalid log level: $level" >&2
      echo "Valid levels: debug, info, warn, error" >&2
      return 1
      ;;
  esac
}

# Usage
set_log_level "debug" || exit 1