Agent Skills: Bash Defensive Patterns

Use when writing or reviewing Bash scripts to apply defensive programming patterns including strict mode, proper error handling, safe variable handling, argument parsing, and idempotent design. Ensures scripts are robust, maintainable, and production-ready.

UncategorizedID: timbuchinger/loadout/bash-defensive-patterns

Install this agent skill to your local

pnpm dlx add-skill https://github.com/timbuchinger/loadout/tree/HEAD/skills/bash-defensive-patterns

Skill Files

Browse the full folder contents for bash-defensive-patterns.

Download Skill

Loading file tree…

skills/bash-defensive-patterns/SKILL.md

Skill Metadata

Name
bash-defensive-patterns
Description
Use when writing or reviewing Bash scripts to apply defensive programming patterns including strict mode, proper error handling, safe variable handling, argument parsing, and idempotent design. Ensures scripts are robust, maintainable, and production-ready.

Bash Defensive Patterns

Overview

Apply defensive programming patterns to write robust, maintainable Bash scripts that handle errors gracefully and fail safely.

Core principle: Scripts should be safe by default, fail fast, and provide clear error messages.

When to Use

  • Writing new Bash scripts
  • Reviewing existing shell scripts
  • Debugging script failures
  • Hardening automation scripts
  • Creating production-ready scripts

Defensive Foundations

1. Strict Mode (Always Required)

Use at the beginning of every script:

#!/bin/bash
set -Eeuo pipefail

What each flag does:

  • -e - Exit immediately if any command fails
  • -E - Inherit ERR trap by functions and subshells
  • -u - Treat unset variables as errors
  • -o pipefail - Exit if any command in a pipeline fails

2. Error Trapping

Catch and handle errors gracefully:

#!/bin/bash
set -Eeuo pipefail

cleanup() {
    local exit_code=$?
    echo "Cleanup triggered with exit code: $exit_code" >&2
    # Cleanup operations here
    rm -f "$TMPFILE" 2>/dev/null || true
}

trap cleanup EXIT
trap 'echo "Error on line $LINENO" >&2' ERR

3. Variable Quoting

Quote all variable expansions to prevent word splitting and globbing:

# Good - quoted
file_path="/path/to/my file.txt"
cp "$file_path" "$destination"

# Bad - unquoted (breaks with spaces)
cp $file_path $destination

# Use readonly for constants
readonly CONFIG_FILE="/etc/myapp/config"
readonly MAX_RETRIES=3

# Local variables in functions
function_name() {
    local -r required_arg="$1"
    local optional_arg="${2:-default}"
}

4. Array Handling

Use arrays properly for lists:

# Define arrays
files=("file1.txt" "file2.txt" "file3.txt")
items=("item 1" "item 2" "item 3")

# Iterate safely
for item in "${items[@]}"; do
    echo "Processing: $item"
done

# Read output into array safely
mapfile -t lines < <(some_command)
readarray -t numbers < <(seq 1 10)

5. Conditional Safety

Use [[ ]] for Bash-specific features, [ ] for POSIX:

# Bash - safer
if [[ -f "$file" && -r "$file" ]]; then
    content=$(<"$file")
fi

# POSIX - portable
if [ -f "$file" ] && [ -r "$file" ]; then
    content=$(cat "$file")
fi

# Test for existence before operations
if [[ -z "${VAR:-}" ]]; then
    echo "VAR is not set or is empty"
fi

Fundamental Patterns

Pattern 1: Safe Script Directory Detection

#!/bin/bash
set -Eeuo pipefail

# Correctly determine script directory
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
SCRIPT_NAME="$(basename -- "${BASH_SOURCE[0]}")"

echo "Script location: $SCRIPT_DIR/$SCRIPT_NAME"

Pattern 2: Comprehensive Function Template

#!/bin/bash
set -Eeuo pipefail

# Prefix for functions: handle_*, process_*, check_*, validate_*
# Include documentation and error handling

validate_file() {
    local -r file="$1"
    local -r message="${2:-File not found: $file}"

    if [[ ! -f "$file" ]]; then
        echo "ERROR: $message" >&2
        return 1
    fi
    return 0
}

process_files() {
    local -r input_dir="$1"
    local -r output_dir="$2"

    # Validate inputs
    [[ -d "$input_dir" ]] || { echo "ERROR: input_dir not a directory" >&2; return 1; }

    # Create output directory if needed
    mkdir -p "$output_dir" || { echo "ERROR: Cannot create output_dir" >&2; return 1; }

    # Process files safely
    while IFS= read -r -d '' file; do
        echo "Processing: $file"
        # Do work
    done < <(find "$input_dir" -maxdepth 1 -type f -print0)

    return 0
}

Pattern 3: Safe Temporary File Handling

#!/bin/bash
set -Eeuo pipefail

trap 'rm -rf -- "$TMPDIR"' EXIT

# Create temporary directory
TMPDIR=$(mktemp -d) || { echo "ERROR: Failed to create temp directory" >&2; exit 1; }

# Create temporary files in directory
TMPFILE1="$TMPDIR/temp1.txt"
TMPFILE2="$TMPDIR/temp2.txt"

# Use temporary files
touch "$TMPFILE1" "$TMPFILE2"

echo "Temp files created in: $TMPDIR"

Pattern 4: Robust Argument Parsing

#!/bin/bash
set -Eeuo pipefail

# Default values
VERBOSE=false
DRY_RUN=false
OUTPUT_FILE=""
THREADS=4

usage() {
    cat <<EOF
Usage: $0 [OPTIONS]

Options:
    -v, --verbose       Enable verbose output
    -d, --dry-run       Run without making changes
    -o, --output FILE   Output file path
    -j, --jobs NUM      Number of parallel jobs
    -h, --help          Show this help message
EOF
    exit "${1:-0}"
}

# Parse arguments
while [[ $# -gt 0 ]]; do
    case "$1" in
        -v|--verbose)
            VERBOSE=true
            shift
            ;;
        -d|--dry-run)
            DRY_RUN=true
            shift
            ;;
        -o|--output)
            OUTPUT_FILE="$2"
            shift 2
            ;;
        -j|--jobs)
            THREADS="$2"
            shift 2
            ;;
        -h|--help)
            usage 0
            ;;
        --)
            shift
            break
            ;;
        *)
            echo "ERROR: Unknown option: $1" >&2
            usage 1
            ;;
    esac
done

# Validate required arguments
[[ -n "$OUTPUT_FILE" ]] || { echo "ERROR: -o/--output is required" >&2; usage 1; }

Pattern 5: Structured Logging

#!/bin/bash
set -Eeuo pipefail

# Define logging functions
log_info() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*" >&2
}

log_warn() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] WARN: $*" >&2
}

log_error() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
}

log_debug() {
    if [[ "${DEBUG:-0}" == "1" ]]; then
        echo "[$(date +'%Y-%m-%d %H:%M:%S')] DEBUG: $*" >&2
    fi
}

# Usage examples
log_info "Starting script"
log_debug "Debug information"
log_warn "Warning message"
log_error "Error occurred"

Pattern 6: Process Orchestration with Signals

#!/bin/bash
set -Eeuo pipefail

# Track background processes
PIDS=()

cleanup() {
    log_info "Shutting down..."

    # Terminate all background processes
    for pid in "${PIDS[@]}"; do
        if kill -0 "$pid" 2>/dev/null; then
            kill -TERM "$pid" 2>/dev/null || true
        fi
    done

    # Wait for graceful shutdown
    for pid in "${PIDS[@]}"; do
        wait "$pid" 2>/dev/null || true
    done
}

trap cleanup SIGTERM SIGINT

# Start background tasks
background_task &
PIDS+=($!)

another_task &
PIDS+=($!)

# Wait for all background processes
wait

Pattern 7: Safe File Operations

#!/bin/bash
set -Eeuo pipefail

# Move files safely without overwriting
safe_move() {
    local -r source="$1"
    local -r dest="$2"

    if [[ ! -e "$source" ]]; then
        echo "ERROR: Source does not exist: $source" >&2
        return 1
    fi

    if [[ -e "$dest" ]]; then
        echo "ERROR: Destination already exists: $dest" >&2
        return 1
    fi

    mv "$source" "$dest"
}

# Safe directory cleanup
safe_rmdir() {
    local -r dir="$1"

    if [[ ! -d "$dir" ]]; then
        echo "ERROR: Not a directory: $dir" >&2
        return 1
    fi

    # Prompt before deletion
    rm -rI -- "$dir"
}

# Atomic file writes
atomic_write() {
    local -r target="$1"
    local -r tmpfile
    tmpfile=$(mktemp) || return 1

    # Write to temp file first
    cat > "$tmpfile"

    # Atomic rename
    mv "$tmpfile" "$target"
}

Pattern 8: Idempotent Script Design

#!/bin/bash
set -Eeuo pipefail

# Check if resource already exists
ensure_directory() {
    local -r dir="$1"

    if [[ -d "$dir" ]]; then
        log_info "Directory already exists: $dir"
        return 0
    fi

    mkdir -p "$dir" || {
        log_error "Failed to create directory: $dir"
        return 1
    }

    log_info "Created directory: $dir"
}

# Ensure configuration state
ensure_config() {
    local -r config_file="$1"
    local -r default_value="$2"

    if [[ ! -f "$config_file" ]]; then
        echo "$default_value" > "$config_file"
        log_info "Created config: $config_file"
    fi
}

# Rerunning script multiple times should be safe
ensure_directory "/var/cache/myapp"
ensure_config "/etc/myapp/config" "DEBUG=false"

Pattern 9: Safe Command Substitution

#!/bin/bash
set -Eeuo pipefail

# Use $() instead of backticks
name=$(<"$file")  # Modern, safe variable assignment from file
output=$(command -v python3)  # Get command location safely

# Handle command substitution with error checking
result=$(command -v node) || {
    log_error "node command not found"
    return 1
}

# For multiple lines
mapfile -t lines < <(grep "pattern" "$file")

# NUL-safe iteration
while IFS= read -r -d '' file; do
    echo "Processing: $file"
done < <(find /path -type f -print0)

Pattern 10: Dry-Run Support

#!/bin/bash
set -Eeuo pipefail

DRY_RUN="${DRY_RUN:-false}"

run_cmd() {
    if [[ "$DRY_RUN" == "true" ]]; then
        echo "[DRY RUN] Would execute: $*"
        return 0
    fi

    "$@"
}

# Usage examples
run_cmd cp "$source" "$dest"
run_cmd rm "$file"
run_cmd chown "$owner" "$target"

Advanced Defensive Techniques

Named Parameters Pattern

#!/bin/bash
set -Eeuo pipefail

process_data() {
    local input_file=""
    local output_dir=""
    local format="json"

    # Parse named parameters
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --input=*)
                input_file="${1#*=}"
                ;;
            --output=*)
                output_dir="${1#*=}"
                ;;
            --format=*)
                format="${1#*=}"
                ;;
            *)
                echo "ERROR: Unknown parameter: $1" >&2
                return 1
                ;;
        esac
        shift
    done

    # Validate required parameters
    [[ -n "$input_file" ]] || { echo "ERROR: --input is required" >&2; return 1; }
    [[ -n "$output_dir" ]] || { echo "ERROR: --output is required" >&2; return 1; }
}

Dependency Checking

#!/bin/bash
set -Eeuo pipefail

check_dependencies() {
    local -a missing_deps=()
    local -a required=("jq" "curl" "git")

    for cmd in "${required[@]}"; do
        if ! command -v "$cmd" &>/dev/null; then
            missing_deps+=("$cmd")
        fi
    done

    if [[ ${#missing_deps[@]} -gt 0 ]]; then
        echo "ERROR: Missing required commands: ${missing_deps[*]}" >&2
        return 1
    fi
}

check_dependencies

Real-World Examples

Example 1: Backup Script with Rotation

#!/bin/bash
set -Eeuo pipefail

# Configuration
readonly BACKUP_SOURCE="/var/www/html"
readonly BACKUP_DEST="/backup/website"
readonly MAX_BACKUPS=7
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# Setup logging
log_info() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*" >&2
}

log_error() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
}

# Cleanup temporary files on exit
cleanup() {
    if [[ -n "${TEMP_ARCHIVE:-}" && -f "$TEMP_ARCHIVE" ]]; then
        rm -f "$TEMP_ARCHIVE"
    fi
}

trap cleanup EXIT

# Validate source directory exists
[[ -d "$BACKUP_SOURCE" ]] || {
    log_error "Source directory not found: $BACKUP_SOURCE"
    exit 1
}

# Create backup destination if needed
mkdir -p "$BACKUP_DEST" || {
    log_error "Cannot create backup directory: $BACKUP_DEST"
    exit 1
}

# Create backup with atomic write
TEMP_ARCHIVE=$(mktemp) || exit 1
log_info "Creating backup archive..."

tar -czf "$TEMP_ARCHIVE" -C "$(dirname "$BACKUP_SOURCE")" "$(basename "$BACKUP_SOURCE")" || {
    log_error "Failed to create backup archive"
    exit 1
}

# Move to final location atomically
FINAL_BACKUP="$BACKUP_DEST/backup_$TIMESTAMP.tar.gz"
mv "$TEMP_ARCHIVE" "$FINAL_BACKUP"
log_info "Backup saved to: $FINAL_BACKUP"

# Rotate old backups
log_info "Rotating old backups..."
while IFS= read -r -d '' backup; do
    rm -f "$backup"
    log_info "Removed old backup: $(basename "$backup")"
done < <(find "$BACKUP_DEST" -name "backup_*.tar.gz" -type f -print0 | sort -z | head -z -n -"$MAX_BACKUPS")

log_info "Backup completed successfully"

Example 2: Deploy Script with Validation

#!/bin/bash
set -Eeuo pipefail

# Script configuration
DEPLOY_ENV="${DEPLOY_ENV:-staging}"
DRY_RUN="${DRY_RUN:-false}"

# Validate environment
case "$DEPLOY_ENV" in
    staging|production)
        readonly DEPLOY_PATH="/var/www/$DEPLOY_ENV"
        readonly SERVICE_NAME="app-$DEPLOY_ENV"
        ;;
    *)
        echo "ERROR: Invalid environment: $DEPLOY_ENV (must be staging or production)" >&2
        exit 1
        ;;
esac

# Check required commands
check_dependencies() {
    local -a required=("rsync" "systemctl" "git")
    local -a missing=()

    for cmd in "${required[@]}"; do
        if ! command -v "$cmd" &>/dev/null; then
            missing+=("$cmd")
        fi
    done

    if [[ ${#missing[@]} -gt 0 ]]; then
        echo "ERROR: Missing required commands: ${missing[*]}" >&2
        return 1
    fi
}

# Execute command with dry-run support
run_cmd() {
    if [[ "$DRY_RUN" == "true" ]]; then
        echo "[DRY RUN] Would execute: $*"
        return 0
    fi
    "$@"
}

# Main deployment
check_dependencies

echo "Deploying to: $DEPLOY_ENV"
echo "Deploy path: $DEPLOY_PATH"

# Create backup before deployment
BACKUP_DIR="$DEPLOY_PATH.backup.$(date +%Y%m%d_%H%M%S)"
run_cmd cp -a "$DEPLOY_PATH" "$BACKUP_DIR"

# Deploy new code
run_cmd rsync -av --delete ./build/ "$DEPLOY_PATH/"

# Restart service
run_cmd systemctl restart "$SERVICE_NAME"

echo "Deployment completed successfully"
echo "Backup available at: $BACKUP_DIR"

Example 3: Log Processing Pipeline

#!/bin/bash
set -Eeuo pipefail

# Process and analyze log files from multiple sources
readonly LOG_DIR="/var/log/apps"
readonly OUTPUT_FILE="/tmp/log_analysis_$(date +%Y%m%d).txt"
readonly ERROR_THRESHOLD=100

# Process logs safely
process_logs() {
    local -r log_pattern="$1"
    local error_count=0
    
    while IFS= read -r -d '' logfile; do
        if [[ ! -r "$logfile" ]]; then
            echo "WARNING: Cannot read file: $logfile" >&2
            continue
        fi
        
        # Count errors in this log file
        local file_errors
        file_errors=$(grep -c "ERROR" "$logfile" 2>/dev/null || echo "0")
        error_count=$((error_count + file_errors))
        
        echo "$(basename "$logfile"): $file_errors errors"
    done < <(find "$LOG_DIR" -name "$log_pattern" -type f -print0)
    
    echo "---"
    echo "Total errors: $error_count"
    
    # Alert if threshold exceeded
    if [[ $error_count -gt $ERROR_THRESHOLD ]]; then
        echo "WARNING: Error count ($error_count) exceeds threshold ($ERROR_THRESHOLD)" >&2
        return 1
    fi
    
    return 0
}

# Validate log directory exists
[[ -d "$LOG_DIR" ]] || {
    echo "ERROR: Log directory not found: $LOG_DIR" >&2
    exit 1
}

# Process logs and save results
{
    echo "Log Analysis Report - $(date)"
    echo "================================"
    echo ""
    process_logs "*.log"
} > "$OUTPUT_FILE"

echo "Analysis complete. Results saved to: $OUTPUT_FILE"

Best Practices Summary

  1. Always use strict mode - set -Eeuo pipefail
  2. Quote all variables - "$variable" prevents word splitting
  3. Use [[ ]] conditionals - More robust than [ ]
  4. Implement error trapping - Catch and handle errors gracefully
  5. Validate all inputs - Check file existence, permissions, formats
  6. Use functions for reusability - Prefix with meaningful names
  7. Implement structured logging - Include timestamps and levels
  8. Support dry-run mode - Allow users to preview changes
  9. Handle temporary files safely - Use mktemp, cleanup with trap
  10. Design for idempotency - Scripts should be safe to rerun
  11. Document requirements - List dependencies and minimum versions
  12. Test error paths - Ensure error handling works correctly
  13. Use command -v - Safer than which for checking executables
  14. Prefer printf over echo - More predictable across systems

Resources