diff --git a/src/backup.sh b/src/backup.sh index 42d95ef..8957ab0 100755 --- a/src/backup.sh +++ b/src/backup.sh @@ -1,368 +1,895 @@ #!/usr/bin/env bash # Script Name: backup.sh -# Description: A robust, menu-driven backup utility that backs up user-selected directories -# to a chosen location, with optional compression (tar.gz) and encryption (GPG). -# Supports backup retention policies and automated cron job setup. -# Usage: ./backup.sh +# Description: Creates reliable filesystem backups with optional compression, +# symmetric GPG encryption, retention cleanup, and an interactive +# menu for ad-hoc or cron-friendly usage. +# Usage: +# ./backup.sh +# ./backup.sh --auto --source "$HOME/Documents" --dest /mnt/backups --compress +# ./backup.sh --auto --dest /mnt/backups --exclude '*.cache' --retention-daily 14 set -euo pipefail IFS=$'\n\t' -# GLOBAL VARIABLES -CURRENT_BACKUP_FILE="" -VERBOSE=0 - -# COLORS -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' +############################################################################### +# Globals +############################################################################### +SCRIPT_NAME="$(basename "$0")" +SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +HOST_TAG="$(hostname -s 2>/dev/null || hostname 2>/dev/null || printf 'host')" +HOST_TAG="${HOST_TAG//[^[:alnum:]._-]/-}" -# DEFAULT DIRECTORIES -DEFAULT_DIRS=( +DEFAULT_SOURCE_DIRS=( "$HOME/Documents" "$HOME/Downloads" "$HOME/Desktop" ) -# TRAP FOR CLEANUP -trap cleanup INT TERM EXIT +SOURCE_DIRS=() +EXCLUDE_PATTERNS=() +TARGET_DIR="" + +COMPRESS=false +ENCRYPT=false +GPG_PASSPHRASE="" +GPG_PASSPHRASE_FILE="" + +RETENTION_DAILY=7 +RETENTION_WEEKLY=4 +RETENTION_MONTHLY=3 + +AUTO_MODE=false +BACKUP_REQUESTED=false +QUIET=false +VERBOSE=false +NO_COLOR=false + +CURRENT_ARTIFACT="" +LOCK_DIR="" +BACKUP_SUCCEEDED=false ############################################################################### -# cleanup: Cleanup function triggered on exit or interruption +# Logging ############################################################################### -cleanup() { - local exit_code=$? - if [[ $exit_code -ne 0 ]]; then - log ERROR "Script encountered an error. Cleaning up..." +supports_color() { + [[ "$NO_COLOR" != true && -t 2 ]] +} + +color_code() { + case "$1" in + INFO) printf '34' ;; + WARN) printf '33' ;; + ERROR) printf '31' ;; + DEBUG) printf '36' ;; + *) printf '0' ;; + esac +} + +log_msg() { + local level="$1" + shift + local message="$*" + local timestamp plain rendered label + + [[ "$QUIET" == true && "$level" == "INFO" ]] && return 0 + [[ "$VERBOSE" != true && "$level" == "DEBUG" ]] && return 0 + + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + plain="[$timestamp] [$level] $message" + + if supports_color; then + label="$(printf '\e[%sm%s\e[0m' "$(color_code "$level")" "$level")" + rendered="[$timestamp] [$label] $message" else - log INFO "Script completed successfully." + rendered="$plain" fi - # Remove any partial backup if something failed - if [[ -n "${CURRENT_BACKUP_FILE:-}" && -f "${CURRENT_BACKUP_FILE:-}" ]]; then - log INFO "Removing incomplete backup file: ${CURRENT_BACKUP_FILE}" - rm -f "${CURRENT_BACKUP_FILE}" - fi + printf '%s\n' "$rendered" >&2 +} - exit $exit_code +die() { + log_msg ERROR "$*" + exit 1 } ############################################################################### -# log: Function for standardized log messages +# Cleanup ############################################################################### -log() { - local level="$1" - shift - local msg="$*" +cleanup() { + local exit_code=$? - case "$level" in - ERROR) echo -e "${RED}[ERROR] $msg${NC}" >&2 ;; - WARN) echo -e "${RED}[WARN] $msg${NC}" ;; - INFO) [[ $VERBOSE -ge 0 ]] && echo -e "${GREEN}[INFO] $msg${NC}" ;; - DEBUG) [[ $VERBOSE -ge 1 ]] && echo -e "[DEBUG] $msg" ;; - esac + trap - EXIT INT TERM + + if [[ $exit_code -ne 0 && "$BACKUP_SUCCEEDED" != true && -n "$CURRENT_ARTIFACT" && -e "$CURRENT_ARTIFACT" ]]; then + log_msg WARN "Removing incomplete backup artifact: $CURRENT_ARTIFACT" + rm -rf -- "$CURRENT_ARTIFACT" + fi + + if [[ -n "$LOCK_DIR" && -d "$LOCK_DIR" ]]; then + rmdir -- "$LOCK_DIR" 2>/dev/null || true + fi + + exit "$exit_code" } +trap cleanup EXIT INT TERM + ############################################################################### -# press_enter_to_continue: Pauses until user presses Enter +# Usage ############################################################################### +print_usage() { + cat </dev/null; then - log ERROR "Dependency '$dep' is required but not installed." - exit 1 +require_command() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: $cmd" +} + +canonical_path() { + local path="$1" + + if [[ -d "$path" ]]; then + ( + cd "$path" >/dev/null 2>&1 && + pwd -P + ) + return + fi + + if [[ -e "$path" ]]; then + ( + cd "$(dirname "$path")" >/dev/null 2>&1 && + printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")" + ) + return + fi + + if [[ -d "$(dirname "$path")" ]]; then + ( + cd "$(dirname "$path")" >/dev/null 2>&1 && + printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")" + ) + return + fi + + printf '%s\n' "$path" +} + +is_non_negative_integer() { + [[ "$1" =~ ^[0-9]+$ ]] +} + +validate_retention_value() { + local label="$1" + local value="$2" + + is_non_negative_integer "$value" || die "$label must be a non-negative integer." +} + +validate_backup_target() { + local target_canon source source_canon + + target_canon="$(canonical_path "$TARGET_DIR")" + for source in "${SOURCE_DIRS[@]}"; do + [[ -d "$source" ]] || continue + source_canon="$(canonical_path "$source")" + case "$target_canon/" in + "$source_canon/"*) + die "Backup destination must not be inside source directory: $source" + ;; + esac + done +} + +validate_configuration() { + local source valid_sources=0 + + require_command rsync + require_command tar + require_command date + require_command find + require_command hostname + require_command mktemp + + [[ -n "$TARGET_DIR" ]] || die "Backup destination is required." + mkdir -p -- "$TARGET_DIR" + + validate_retention_value "Daily retention" "$RETENTION_DAILY" + validate_retention_value "Weekly retention" "$RETENTION_WEEKLY" + validate_retention_value "Monthly retention" "$RETENTION_MONTHLY" + + if [[ ${#SOURCE_DIRS[@]} -eq 0 ]]; then + die "At least one source file or directory is required." + fi + + for source in "${SOURCE_DIRS[@]}"; do + if [[ -e "$source" ]]; then + valid_sources=$((valid_sources + 1)) + else + log_msg WARN "Source does not exist and will be skipped: $source" fi done + + [[ $valid_sources -gt 0 ]] || die "None of the configured sources exist." + + if [[ "$ENCRYPT" == true ]]; then + require_command gpg + + if [[ -n "$GPG_PASSPHRASE" && -n "$GPG_PASSPHRASE_FILE" ]]; then + die "Use either --gpg-passphrase or --gpg-passphrase-file, not both." + fi + + if [[ -n "$GPG_PASSPHRASE_FILE" ]]; then + [[ -f "$GPG_PASSPHRASE_FILE" ]] || die "GPG passphrase file not found: $GPG_PASSPHRASE_FILE" + elif [[ -z "$GPG_PASSPHRASE" ]]; then + die "Encryption requires --gpg-passphrase or --gpg-passphrase-file." + fi + fi + + validate_backup_target } ############################################################################### -# detect_usb_drives: Returns a list of mountpoints for removable drives +# Interactive selection ############################################################################### +load_default_sources() { + local dir + + SOURCE_DIRS=() + for dir in "${DEFAULT_SOURCE_DIRS[@]}"; do + [[ -e "$dir" ]] || continue + SOURCE_DIRS+=("$dir") + done +} + +read_paths_into_array() { + local -n target_ref="$1" + local prompt="$2" + local value="" + + while true; do + read -r -p "$prompt" value + [[ -z "$value" ]] && break + target_ref+=("$value") + done +} + +select_source_directories() { + local choice="" + local extras=() + + load_default_sources + + echo + echo "Default backup sources:" + if [[ ${#SOURCE_DIRS[@]} -eq 0 ]]; then + echo " - No default directories currently exist on this machine." + else + printf ' - %s\n' "${SOURCE_DIRS[@]}" + fi + + echo + echo "K) Keep current defaults" + echo "A) Add more paths" + echo "C) Choose custom paths only" + read -r -p "Choose [K/A/C]: " choice + + case "$choice" in + [Aa]*) + echo "Enter one path per line. Submit an empty line when done." + read_paths_into_array extras "Additional source path: " + SOURCE_DIRS+=("${extras[@]}") + ;; + [Cc]*) + SOURCE_DIRS=() + echo "Enter one path per line. Submit an empty line when done." + read_paths_into_array SOURCE_DIRS "Source path: " + ;; + *) + ;; + esac +} + detect_usb_drives() { - lsblk -o NAME,MOUNTPOINT,RM -nr | awk '$3=="1" && $2!="" {print $2}' + command -v lsblk >/dev/null 2>&1 || return 0 + lsblk -nr -o RM,MOUNTPOINT | awk '$1=="1" && $2!="" {print $2}' } -############################################################################### -# select_target_directory: Lets user pick a destination (custom path or USB) -############################################################################### select_target_directory() { + local choice="" custom_path="" usb_choice="" + local usb_drives=() + while true; do echo - echo "Select Backup Destination:" + echo "Select backup destination:" echo "1) Enter a custom path" echo "2) Choose from detected USB drives" echo "3) Cancel" read -r -p "Enter your choice [1-3]: " choice + case "$choice" in 1) - read -r -p "Enter full path for backup destination: " custom_path - echo "$custom_path" - return + read -r -p "Enter destination directory: " custom_path + [[ -n "$custom_path" ]] || { + echo "Destination cannot be empty." + continue + } + TARGET_DIR="$custom_path" + return 0 ;; 2) mapfile -t usb_drives < <(detect_usb_drives) if [[ ${#usb_drives[@]} -eq 0 ]]; then - log WARN "No USB drives detected. Connect a USB or select a custom path." + echo "No mounted removable drives detected." continue fi - echo - echo "Detected USB mount points:" - local i=1 - for drive in "${usb_drives[@]}"; do - echo "$i) $drive" - ((i++)) - done - read -r -p "Select a USB drive by number: " usb_choice + + printf '%s\n' "${usb_drives[@]}" | nl -w1 -s') ' + read -r -p "Select a drive by number: " usb_choice if [[ "$usb_choice" =~ ^[0-9]+$ ]] && (( usb_choice >= 1 && usb_choice <= ${#usb_drives[@]} )); then - echo "${usb_drives[$((usb_choice-1))]}" - return - else - log WARN "Invalid USB drive selection." + TARGET_DIR="${usb_drives[$((usb_choice - 1))]}" + return 0 fi + echo "Invalid drive selection." ;; 3) - echo "Operation canceled." - return + return 1 ;; *) - log WARN "Invalid choice. Try again." + echo "Invalid choice." ;; esac done } -############################################################################### -# select_source_directories: Lets user pick which directories to back up -############################################################################### -select_source_directories() { - echo - echo "Default directories to back up:" - for dir in "${DEFAULT_DIRS[@]}"; do - echo " - $dir" - done +prompt_backup_settings() { + local retention_value="" + + COMPRESS=false + ENCRYPT=false + GPG_PASSPHRASE="" + GPG_PASSPHRASE_FILE="" + EXCLUDE_PATTERNS=() + RETENTION_DAILY=7 + RETENTION_WEEKLY=4 + RETENTION_MONTHLY=3 + echo - echo "K) Keep these defaults" - echo "A) Add more directories to these defaults" - echo "C) Customize from scratch" - read -r -p "Choose [K/A/C]: " ans - case "$ans" in - [Kk]*) - echo "${DEFAULT_DIRS[@]}" - ;; - [Aa]*) - read -r -p "Enter additional directories (space-separated): " extra_dirs - echo "${DEFAULT_DIRS[@]}" - echo "$extra_dirs" - ;; - [Cc]*) - read -r -p "Enter the directories you want to back up (space-separated): " custom_dirs - echo "$custom_dirs" - ;; - *) - echo "${DEFAULT_DIRS[@]}" - ;; - esac + if prompt_yes_no "Enable compression? [Y/n]: " "y"; then + COMPRESS=true + fi + + if prompt_yes_no "Enable encryption? [y/N]: " "n"; then + ENCRYPT=true + read -r -p "Use a passphrase file instead of typing the passphrase? [y/N]: " retention_value + if [[ "$retention_value" =~ ^[Yy]$ ]]; then + read -r -p "Path to passphrase file: " GPG_PASSPHRASE_FILE + else + read -r -s -p "Enter GPG passphrase: " GPG_PASSPHRASE + echo + [[ -n "$GPG_PASSPHRASE" ]] || die "Encryption passphrase cannot be empty." + fi + fi + + if prompt_yes_no "Add rsync exclude patterns? [y/N]: " "n"; then + echo "Enter one exclude pattern per line. Submit an empty line when done." + read_paths_into_array EXCLUDE_PATTERNS "Exclude pattern: " + fi + + read -r -p "Daily retention in days [7]: " retention_value + RETENTION_DAILY="${retention_value:-7}" + read -r -p "Weekly retention in weeks [4]: " retention_value + RETENTION_WEEKLY="${retention_value:-4}" + read -r -p "Monthly retention in months [3]: " retention_value + RETENTION_MONTHLY="${retention_value:-3}" } ############################################################################### -# compress_backup: Compresses the backup folder into a tar.gz +# Backup implementation ############################################################################### -compress_backup() { - local dir_path="$1" - local tar_file="${dir_path}.tar.gz" - log INFO "Compressing backup directory: $dir_path" - tar -czf "$tar_file" -C "$(dirname "$dir_path")" "$(basename "$dir_path")" - rm -rf "$dir_path" - CURRENT_BACKUP_FILE="$tar_file" - log INFO "Compression complete: $tar_file" +backup_base_name() { + local name="$1" + + name="${name%.tar.gz.gpg}" + name="${name%.tar.gpg}" + name="${name%.tar.gz}" + name="${name%.tar}" + name="${name%.gpg}" + printf '%s\n' "$name" } -############################################################################### -# encrypt_backup: Encrypts the backup file using GPG and passphrase -############################################################################### -encrypt_backup() { - local file_path="$1" - local passphrase="$2" - local enc_file="${file_path}.gpg" - log INFO "Encrypting backup file: $file_path" - echo "$passphrase" | gpg --batch --yes --passphrase-fd 0 --symmetric "$file_path" - rm -f "$file_path" - CURRENT_BACKUP_FILE="$enc_file" - log INFO "Encryption complete: $enc_file" +timestamp_to_epoch() { + local stamp="$1" + date -u -d "${stamp:0:4}-${stamp:4:2}-${stamp:6:2} ${stamp:9:2}:${stamp:11:2}:${stamp:13:2} UTC" +%s } -############################################################################### -# advanced_retention_cleanup: -# Allows daily, weekly, and monthly retention logic. -# - Retains daily backups for X days -# - Retains weekly backups for Y weeks -# - Retains monthly backups for Z months -############################################################################### -advanced_retention_cleanup() { - local backup_dir="$1" - local daily_days="$2" - local weekly_weeks="$3" - local monthly_months="$4" - - log INFO "Performing advanced retention cleanup." - log INFO "Daily: $daily_days days, Weekly: $weekly_weeks weeks, Monthly: $monthly_months months." - - find_daily_files() { - find "$backup_dir" -maxdepth 1 -type f -name "backup_*" -mtime +"$daily_days" - } - find_weekly_files() { - # Weekly backups can be older, but let's keep at least one per week for Y weeks - # This is done by comparing the file's creation time to older than 7*Y days, - # but skipping if we haven't found at least one backup in each 7-day block. - # We'll do a simpler approach: first remove anything older than 7*Y, but daily - # logic might have already removed many. Then we keep one per week in that range. - local older_than_days=$(( weekly_weeks * 7 )) - find "$backup_dir" -maxdepth 1 -type f -name "backup_*" -mtime +"$older_than_days" - } - find_monthly_files() { - # Keep monthly backups for Z months - local older_than_days=$(( monthly_months * 30 )) - find "$backup_dir" -maxdepth 1 -type f -name "backup_*" -mtime +"$older_than_days" - } - - # Remove daily backups older than daily_days - if [[ "$daily_days" -gt 0 ]]; then - while IFS= read -r old_file; do - log INFO "Removing daily-old file: $old_file" - rm -f "$old_file" - done < <(find_daily_files || true) +create_lock() { + LOCK_DIR="${TARGET_DIR%/}/.backup.lock" + if mkdir -- "$LOCK_DIR" 2>/dev/null; then + return 0 fi + die "Another backup appears to be running for $TARGET_DIR" +} + +build_shell_command() { + local IFS=' ' + local rendered=() + local arg="" quoted="" + + for arg in "$@"; do + printf -v quoted '%q' "$arg" + rendered+=("$quoted") + done + + printf '%s' "${rendered[*]}" +} + +write_metadata() { + local snapshot_dir="$1" + local manifest_path="$snapshot_dir/backup_manifest.txt" + local source="" + local pattern="" + + { + printf 'backup_name=%s\n' "$(basename "$snapshot_dir")" + printf 'created_at_utc=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + printf 'host=%s\n' "$HOST_TAG" + printf 'compress=%s\n' "$COMPRESS" + printf 'encrypt=%s\n' "$ENCRYPT" + printf 'retention_daily=%s\n' "$RETENTION_DAILY" + printf 'retention_weekly=%s\n' "$RETENTION_WEEKLY" + printf 'retention_monthly=%s\n' "$RETENTION_MONTHLY" + printf 'sources:\n' + for source in "${SOURCE_DIRS[@]}"; do + printf ' - %s\n' "$source" + done + if [[ ${#EXCLUDE_PATTERNS[@]} -gt 0 ]]; then + printf 'excludes:\n' + for pattern in "${EXCLUDE_PATTERNS[@]}"; do + printf ' - %s\n' "$pattern" + done + fi + } >"$manifest_path" +} + +sync_source() { + local source="$1" + local snapshot_dir="$2" + local relative_path destination_dir parent_dir + local rsync_args=(--archive --human-readable) + local pattern="" + + for pattern in "${EXCLUDE_PATTERNS[@]}"; do + rsync_args+=("--exclude=$pattern") + done - # Remove weekly backups older than weekly_weeks * 7 days - if [[ "$weekly_weeks" -gt 0 ]]; then - while IFS= read -r old_file; do - log INFO "Removing weekly-old file: $old_file" - rm -f "$old_file" - done < <(find_weekly_files || true) + if [[ -d "$source" ]]; then + relative_path="${source#/}" + destination_dir="$snapshot_dir/files/$relative_path" + mkdir -p -- "$destination_dir" + log_msg INFO "Backing up directory: $source" + rsync "${rsync_args[@]}" -- "$source/" "$destination_dir/" + return 0 fi - # Remove monthly backups older than monthly_months * 30 days - if [[ "$monthly_months" -gt 0 ]]; then - while IFS= read -r old_file; do - log INFO "Removing monthly-old file: $old_file" - rm -f "$old_file" - done < <(find_monthly_files || true) + if [[ -f "$source" ]]; then + relative_path="${source#/}" + parent_dir="$snapshot_dir/files/$(dirname "$relative_path")" + mkdir -p -- "$parent_dir" + log_msg INFO "Backing up file: $source" + rsync "${rsync_args[@]}" -- "$source" "$parent_dir/" + return 0 fi - log INFO "Retention cleanup completed." + log_msg WARN "Skipping unsupported or missing source: $source" + return 1 } -############################################################################### -# create_backup: Orchestrates the backup creation process -############################################################################### -create_backup() { - local source_dirs=("$1") # Actually an array but space-joined - local dest_dir="$2" - local compress="$3" - local encrypt="$4" - local gpg_pass="$5" - local daily_retention="$6" - local weekly_retention="$7" - local monthly_retention="$8" - - log INFO "Starting backup..." - local timestamp - timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - local backup_folder_name="backup_${timestamp}" - local backup_folder_path="${dest_dir}/${backup_folder_name}" - - CURRENT_BACKUP_FILE="$backup_folder_path" - mkdir -p "$backup_folder_path" +package_backup() { + local snapshot_dir="$1" + local backup_name="$2" + local artifact="$snapshot_dir" + local archive_path="" encrypted_path="" + + if [[ "$COMPRESS" == true || "$ENCRYPT" == true ]]; then + if [[ "$COMPRESS" == true ]]; then + archive_path="${TARGET_DIR%/}/${backup_name}.tar.gz" + CURRENT_ARTIFACT="$archive_path" + log_msg INFO "Compressing backup to: $archive_path" + tar -czf "$archive_path" -C "$(dirname "$snapshot_dir")" "$(basename "$snapshot_dir")" + else + archive_path="${TARGET_DIR%/}/${backup_name}.tar" + CURRENT_ARTIFACT="$archive_path" + log_msg INFO "Packing backup to: $archive_path" + tar -cf "$archive_path" -C "$(dirname "$snapshot_dir")" "$(basename "$snapshot_dir")" + fi - local dir - for dir in "${source_dirs[@]}"; do - log INFO "Backing up: $dir" - if [[ -d "$dir" ]]; then - rsync -a --delete "${dir}/" "${backup_folder_path}/$(basename "$dir")" + rm -rf -- "$snapshot_dir" + artifact="$archive_path" + fi + + if [[ "$ENCRYPT" == true ]]; then + encrypted_path="${artifact}.gpg" + CURRENT_ARTIFACT="$encrypted_path" + log_msg INFO "Encrypting backup to: $encrypted_path" + + if [[ -n "$GPG_PASSPHRASE_FILE" ]]; then + gpg --batch --yes --pinentry-mode loopback \ + --passphrase-file "$GPG_PASSPHRASE_FILE" \ + --symmetric \ + --output "$encrypted_path" \ + "$artifact" else - log WARN "Skipping $dir (not found or not a directory)." + gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$GPG_PASSPHRASE" \ + --symmetric \ + --output "$encrypted_path" \ + "$artifact" fi - done - if [[ "$compress" == "true" ]]; then - compress_backup "$backup_folder_path" + rm -f -- "$artifact" + artifact="$encrypted_path" fi - if [[ "$encrypt" == "true" ]]; then - encrypt_backup "$CURRENT_BACKUP_FILE" "$gpg_pass" + CURRENT_ARTIFACT="$artifact" +} + +apply_retention_policy() { + local target_dir="$1" + local now_epoch daily_cutoff weekly_cutoff monthly_cutoff + local item name base_name stamp epoch week_key month_key + local -a candidates=() entries=() + declare -A kept_weeks=() + declare -A kept_months=() + + if (( RETENTION_DAILY == 0 && RETENTION_WEEKLY == 0 && RETENTION_MONTHLY == 0 )); then + log_msg INFO "Retention disabled; keeping all existing backups." + return 0 fi - log INFO "Backup complete: $CURRENT_BACKUP_FILE" - advanced_retention_cleanup "$dest_dir" "$daily_retention" "$weekly_retention" "$monthly_retention" + now_epoch="$(date -u +%s)" + daily_cutoff=$(( now_epoch - (RETENTION_DAILY * 86400) )) + weekly_cutoff=$(( now_epoch - (RETENTION_WEEKLY * 7 * 86400) )) + monthly_cutoff=$(( now_epoch - (RETENTION_MONTHLY * 31 * 86400) )) + + while IFS= read -r item; do + [[ -n "$item" ]] && candidates+=("$item") + done < <(find "$target_dir" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -name 'backup_*' -printf '%f\n') + + [[ ${#candidates[@]} -gt 0 ]] || return 0 + + for name in "${candidates[@]}"; do + base_name="$(backup_base_name "$name")" + stamp="${base_name#backup_}" + stamp="${stamp%%_*}" + [[ "$stamp" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || continue + epoch="$(timestamp_to_epoch "$stamp")" + entries+=("${epoch}"$'\t'"${name}") + done + + [[ ${#entries[@]} -gt 0 ]] || return 0 + + mapfile -t entries < <(printf '%s\n' "${entries[@]}" | sort -r) + + for item in "${entries[@]}"; do + epoch="${item%%$'\t'*}" + name="${item#*$'\t'}" + + if (( RETENTION_DAILY > 0 )) && (( epoch >= daily_cutoff )); then + continue + fi + + if (( RETENTION_WEEKLY > 0 )) && (( epoch >= weekly_cutoff )); then + week_key="$(date -u -d "@$epoch" +%G-%V)" + if [[ -z "${kept_weeks[$week_key]+x}" ]]; then + kept_weeks[$week_key]=1 + continue + fi + fi + + if (( RETENTION_MONTHLY > 0 )) && (( epoch >= monthly_cutoff )); then + month_key="$(date -u -d "@$epoch" +%Y-%m)" + if [[ -z "${kept_months[$month_key]+x}" ]]; then + kept_months[$month_key]=1 + continue + fi + fi + + log_msg INFO "Removing expired backup: ${target_dir%/}/$name" + target_dir=${target_dir%/} + rm -rf -- "${target_dir:?}/$name" + done +} + +create_backup() { + local timestamp backup_name snapshot_dir source copied_count=0 + + validate_configuration + create_lock + + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + backup_name="backup_${timestamp}_${HOST_TAG}" + snapshot_dir="${TARGET_DIR%/}/${backup_name}" + CURRENT_ARTIFACT="$snapshot_dir" + + mkdir -p -- "$snapshot_dir/files" + write_metadata "$snapshot_dir" + + for source in "${SOURCE_DIRS[@]}"; do + if sync_source "$source" "$snapshot_dir"; then + copied_count=$((copied_count + 1)) + fi + done + + [[ $copied_count -gt 0 ]] || die "No sources were successfully backed up." + + package_backup "$snapshot_dir" "$backup_name" + BACKUP_SUCCEEDED=true + + log_msg INFO "Backup created successfully: $CURRENT_ARTIFACT" + apply_retention_policy "$TARGET_DIR" } ############################################################################### -# configure_cron_job: -# Sets up a cron entry for non-interactive backups. -# This script relies on environment variables for truly automated backups. +# Cron helper ############################################################################### configure_cron_job() { + local cron_sources=() + local cron_excludes=() + local cron_dest="" + local cron_compress=false + local cron_encrypt=false + local cron_passphrase_file="" + local cron_daily=7 + local cron_weekly=4 + local cron_monthly=3 + local hour="" minute="" value="" + local command=("$SCRIPT_PATH" "--auto" "--no-color") + local cron_line="" + echo - read -r -p "Enter hour (0-23) for daily backup: " hour - read -r -p "Enter minute (0-59) for daily backup: " minute - read -r -p "Enter full path to this script: " script_path - - # Example environment-based line in crontab: - # DAILY_RETENTION=7 WEEKLY_RETENTION=4 MONTHLY_RETENTION=12 \ - # SOURCE_DIRS=\"/home/user/Documents /home/user/Desktop\" \ - # TARGET_DIR=\"/media/usb\" COMPRESS=true ENCRYPT=false \ - # GPG_PASSPHRASE=\"secret\" bash /path/to/backup.sh --auto - # - # The user should place their chosen environment variables in the line below. - # - # For demonstration, we add a line that calls the script with a placeholder - # environment. Adjust to your own environment variables or remove them. - local cron_line="${minute} ${hour} * * * DAILY_RETENTION=7 WEEKLY_RETENTION=4 MONTHLY_RETENTION=1 SOURCE_DIRS=\"\$HOME/Documents \$HOME/Downloads \$HOME/Desktop\" TARGET_DIR=\"/media/usb\" COMPRESS=true ENCRYPT=false GPG_PASSPHRASE=\"secret\" bash \"${script_path}\" --auto" - - (crontab -l 2>/dev/null || true; echo "$cron_line") | crontab - - log INFO "Cron job added to run daily at $hour:$minute." + echo "Configure cron backup job" + echo "Enter one source path per line. Submit an empty line when done." + read_paths_into_array cron_sources "Source path: " + if [[ ${#cron_sources[@]} -eq 0 ]]; then + load_default_sources + cron_sources=("${SOURCE_DIRS[@]}") + fi + + while [[ -z "$cron_dest" ]]; do + read -r -p "Destination directory: " cron_dest + done + + if prompt_yes_no "Enable compression for cron runs? [Y/n]: " "y"; then + cron_compress=true + fi + + if prompt_yes_no "Add exclude patterns? [y/N]: " "n"; then + echo "Enter one exclude pattern per line. Submit an empty line when done." + read_paths_into_array cron_excludes "Exclude pattern: " + fi + + if prompt_yes_no "Enable encryption for cron runs? [y/N]: " "n"; then + cron_encrypt=true + while [[ -z "$cron_passphrase_file" ]]; do + read -r -p "Path to passphrase file: " cron_passphrase_file + done + fi + + read -r -p "Daily retention in days [7]: " value + cron_daily="${value:-7}" + read -r -p "Weekly retention in weeks [4]: " value + cron_weekly="${value:-4}" + read -r -p "Monthly retention in months [3]: " value + cron_monthly="${value:-3}" + + while true; do + read -r -p "Hour for daily backup [0-23]: " hour + [[ "$hour" =~ ^([01]?[0-9]|2[0-3])$ ]] && break + echo "Invalid hour." + done + + while true; do + read -r -p "Minute for daily backup [0-59]: " minute + [[ "$minute" =~ ^([0-5]?[0-9])$ ]] && break + echo "Invalid minute." + done + + command+=("--dest" "$cron_dest" "--retention-daily" "$cron_daily" "--retention-weekly" "$cron_weekly" "--retention-monthly" "$cron_monthly") + + if [[ "$cron_compress" == true ]]; then + command+=("--compress") + fi + + if [[ "$cron_encrypt" == true ]]; then + command+=("--encrypt" "--gpg-passphrase-file" "$cron_passphrase_file") + fi + + for value in "${cron_sources[@]}"; do + command+=("--source" "$value") + done + + for value in "${cron_excludes[@]}"; do + command+=("--exclude" "$value") + done + + cron_line="${minute} ${hour} * * * $(build_shell_command "${command[@]}")" + (crontab -l 2>/dev/null || true; printf '%s\n' "$cron_line") | crontab - + log_msg INFO "Cron job added: $cron_line" } ############################################################################### -# auto_mode: Non-interactive mode for cron +# CLI parsing ############################################################################### -auto_mode() { - local daily_retention="${DAILY_RETENTION:-7}" - local weekly_retention="${WEEKLY_RETENTION:-4}" - local monthly_retention="${MONTHLY_RETENTION:-3}" - local source_dirs_str="${SOURCE_DIRS:-"$HOME/Documents $HOME/Downloads $HOME/Desktop"}" - local target_dir="${TARGET_DIR:-"/tmp/backups"}" - local compress="${COMPRESS:-"false"}" - local encrypt="${ENCRYPT:-"false"}" - local passphrase="${GPG_PASSPHRASE:-""}" - - IFS=' ' read -r -a source_array <<< "$source_dirs_str" - - create_backup \ - "${source_array[@]}" \ - "$target_dir" \ - "$compress" \ - "$encrypt" \ - "$passphrase" \ - "$daily_retention" \ - "$weekly_retention" \ - "$monthly_retention" +load_defaults_if_needed() { + [[ ${#SOURCE_DIRS[@]} -gt 0 ]] && return 0 + load_default_sources +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --source) + [[ -n "${2-}" ]] || die "--source requires a path." + SOURCE_DIRS+=("$2") + BACKUP_REQUESTED=true + shift 2 + ;; + --dest) + [[ -n "${2-}" ]] || die "--dest requires a directory." + TARGET_DIR="$2" + BACKUP_REQUESTED=true + shift 2 + ;; + --exclude) + [[ -n "${2-}" ]] || die "--exclude requires a pattern." + EXCLUDE_PATTERNS+=("$2") + BACKUP_REQUESTED=true + shift 2 + ;; + --compress) + COMPRESS=true + BACKUP_REQUESTED=true + shift + ;; + --encrypt) + ENCRYPT=true + BACKUP_REQUESTED=true + shift + ;; + --gpg-passphrase) + [[ -n "${2-}" ]] || die "--gpg-passphrase requires a value." + GPG_PASSPHRASE="$2" + BACKUP_REQUESTED=true + shift 2 + ;; + --gpg-passphrase-file) + [[ -n "${2-}" ]] || die "--gpg-passphrase-file requires a path." + GPG_PASSPHRASE_FILE="$2" + BACKUP_REQUESTED=true + shift 2 + ;; + --retention-daily) + [[ -n "${2-}" ]] || die "--retention-daily requires a value." + RETENTION_DAILY="$2" + BACKUP_REQUESTED=true + shift 2 + ;; + --retention-weekly) + [[ -n "${2-}" ]] || die "--retention-weekly requires a value." + RETENTION_WEEKLY="$2" + BACKUP_REQUESTED=true + shift 2 + ;; + --retention-monthly) + [[ -n "${2-}" ]] || die "--retention-monthly requires a value." + RETENTION_MONTHLY="$2" + BACKUP_REQUESTED=true + shift 2 + ;; + --auto) + AUTO_MODE=true + shift + ;; + -q|--quiet) + QUIET=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + --no-color) + NO_COLOR=true + shift + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + die "Unknown option: $1" + ;; + esac + done } ############################################################################### -# main_menu: Interactive menu for normal usage +# Interactive menu ############################################################################### +run_interactive_backup() { + select_source_directories + if [[ ${#SOURCE_DIRS[@]} -eq 0 ]]; then + log_msg WARN "No sources selected. Backup canceled." + return 0 + fi + + if ! select_target_directory; then + log_msg WARN "No destination selected. Backup canceled." + return 0 + fi + + prompt_backup_settings + create_backup +} + main_menu() { + local choice="" + while true; do clear echo "=========================================" @@ -372,65 +899,15 @@ main_menu() { echo "2) Configure Automated Cron Job" echo "3) Quit" echo - read -r -p "Enter your choice [1-3]: " main_choice - case "$main_choice" in - 1) - mapfile -t source_dirs < <(select_source_directories) - if [[ ${#source_dirs[@]} -eq 0 ]]; then - log WARN "No directories selected. Backup canceled." - press_enter_to_continue - continue - fi - local target_dir - target_dir="$(select_target_directory)" - if [[ -z "$target_dir" ]]; then - log WARN "No target directory selected. Backup canceled." - press_enter_to_continue - continue - fi + read -r -p "Enter your choice [1-3]: " choice - local compress="false" - local encrypt="false" - local gpg_passphrase="" - local daily_retention=7 - local weekly_retention=4 - local monthly_retention=3 - - echo - read -r -p "Enable compression? (y/n): " ans - if [[ "$ans" =~ ^[Yy]$ ]]; then - compress="true" - fi - echo - read -r -p "Enable encryption? (y/n): " ans - if [[ "$ans" =~ ^[Yy]$ ]]; then - encrypt="true" - read -r -s -p "Enter GPG passphrase: " gpg_passphrase - echo - if [[ -z "$gpg_passphrase" ]]; then - log ERROR "Encryption passphrase cannot be empty." - press_enter_to_continue - continue - fi - fi - echo - echo "Enter retention policy for old backups." - read -r -p "Daily retention (days): " daily_retention - read -r -p "Weekly retention (weeks): " weekly_retention - read -r -p "Monthly retention (months): " monthly_retention - - create_backup \ - "${source_dirs[@]}" \ - "$target_dir" \ - "$compress" \ - "$encrypt" \ - "$gpg_passphrase" \ - "$daily_retention" \ - "$weekly_retention" \ - "$monthly_retention" + case "$choice" in + 1) + run_interactive_backup press_enter_to_continue ;; 2) + require_command crontab configure_cron_job press_enter_to_continue ;; @@ -439,7 +916,7 @@ main_menu() { break ;; *) - log WARN "Invalid choice." + log_msg WARN "Invalid choice." press_enter_to_continue ;; esac @@ -447,20 +924,14 @@ main_menu() { } ############################################################################### -# ENTRY POINT +# Entry point ############################################################################### -check_dependencies +parse_args "$@" -if [[ "${1:-}" == "-v" ]]; then - VERBOSE=1 - shift -fi - -# Non-interactive mode for automation -if [[ "${1:-}" == "--auto" ]]; then - auto_mode +if [[ "$AUTO_MODE" == true || "$BACKUP_REQUESTED" == true ]]; then + load_defaults_if_needed + create_backup exit 0 fi main_menu -