diff --git a/lib/common.sh b/lib/common.sh index 1a216f1..9fff0c9 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -35,6 +35,62 @@ die() { exit 1 } +# Normalize redundant leading and trailing slashes without requiring the path +# to exist. This keeps legacy SQLite URLs containing five slashes compatible +# while producing the same path used by normal four-slash absolute URLs. +normalize_posix_path() { + local path="$1" + + while [[ "$path" == //* ]]; do + path="${path#/}" + done + while [[ "$path" != "/" && "$path" == */ ]]; do + path="${path%/}" + done + + printf '%s\n' "$path" +} + +# Return the filesystem path represented by a SQLAlchemy SQLite URL. +# sqlite:///relative.db -> relative.db +# sqlite:////absolute/db -> /absolute/db +# sqlite://///absolute/db -> /absolute/db (legacy installer output) +sqlite_database_path_from_url() { + local url="$1" + local url_part="" + local path="" + + [[ "$url" =~ ^sqlite[^:]*:// ]] || return 1 + + url_part="${url#*://}" + url_part="${url_part%%\?*}" + url_part="${url_part%%#*}" + + if [[ "$url_part" == //* ]]; then + path="/${url_part#//}" + elif [[ "$url_part" == /* ]]; then + path="${url_part#/}" + else + path="$url_part" + fi + + normalize_posix_path "$path" +} + +# Build a SQLAlchemy URL for an absolute SQLite database path. Stripping the +# path's leading slash before adding the URL prefix guarantees exactly four +# slashes after the scheme separator. +sqlite_absolute_database_url() { + local driver="$1" + local path="" + + path=$(normalize_posix_path "$2") + [[ "$driver" =~ ^sqlite([+][A-Za-z0-9_]+)?$ ]] || return 1 + [[ "$path" == /* ]] || return 1 + + printf '%s:////%s\n' "$driver" "${path#/}" +} + # Ensure a secret-bearing file (e.g. .env, TLS private key) is only readable by # its owner. Creates the file with 0600 if it is missing so callers can harden # it *before* writing secrets; tightens it to 0600 if it already exists. A diff --git a/lib/pasarguard-backup.sh b/lib/pasarguard-backup.sh index 61cef82..57b2798 100644 --- a/lib/pasarguard-backup.sh +++ b/lib/pasarguard-backup.sh @@ -144,7 +144,94 @@ pg_manifest_encode() { printf '%s\t%s\t%s\t%s\t%s' "$dbname" "$owner" "$has_ts" "$filename" "$ts_version" } +# Validate the complete database artifact that will be put in an archive. This +# is intentionally independent of a dump command's exit status: a process can +# be interrupted after writing a plausible-looking prefix, or its output can be +# truncated by a full filesystem. +postgres_backup_looks_restorable() { + local temp_dir="$1" + local expected_database="$2" + local layout="" + layout=$(pg_backup_layout "$temp_dir") + + if [ "$layout" = "single" ]; then + postgres_dump_looks_restorable "$temp_dir/db_backup.sql" + return + fi + [ "$layout" = "multi" ] || return 1 + + local dump_dir="$temp_dir/pg_dump" + local manifest="$dump_dir/manifest.tsv" + postgres_globals_dump_looks_complete "$dump_dir/globals.sql" || return 1 + [ -s "$manifest" ] || return 1 + + local manifest_line="" + local dbname="" + local has_timescaledb="" + local filename="" + local field_count=0 + local manifest_count=0 + local expected_found=false + while IFS= read -r manifest_line || [ -n "$manifest_line" ]; do + [ -n "$manifest_line" ] || return 1 + field_count=$(awk -F '\t' '{ print NF }' <<<"$manifest_line") + [ "$field_count" -eq 5 ] || return 1 + + dbname="${manifest_line%%$'\t'*}" + has_timescaledb=$(cut -f3 <<<"$manifest_line") + filename=$(cut -f4 <<<"$manifest_line") + [ -n "$dbname" ] || return 1 + [[ "$has_timescaledb" =~ ^[01]$ ]] || return 1 + [[ "$filename" =~ ^db-[0-9]{3}\.sql$ ]] || return 1 + postgres_dump_looks_restorable "$dump_dir/$filename" || return 1 + + manifest_count=$((manifest_count + 1)) + if [ "$dbname" = "$expected_database" ]; then + expected_found=true + fi + done <"$manifest" + + local dump_count=0 + dump_count=$(find "$dump_dir" -maxdepth 1 -type f -name 'db-[0-9][0-9][0-9].sql' | wc -l | awk '{ print $1 }') + [ "$manifest_count" -gt 0 ] && [ "$dump_count" -eq "$manifest_count" ] && [ "$expected_found" = true ] +} + +sqlite_snapshot_looks_restorable() { + local snapshot_file="$1" + [ -s "$snapshot_file" ] || return 1 + command -v sqlite3 >/dev/null 2>&1 || return 1 + + local integrity="" + integrity=$(sqlite3 "$snapshot_file" 'PRAGMA quick_check;' 2>/dev/null) || return 1 + [ "$integrity" = "ok" ] +} + +database_backup_looks_restorable() { + local db_type="$1" + local temp_dir="$2" + local expected_database="${3:-}" + local sqlite_file="${4:-}" + + case "$db_type" in + mysql | mariadb) + mysql_dump_looks_restorable "$temp_dir/db_backup.sql" + ;; + postgresql | timescaledb) + postgres_backup_looks_restorable "$temp_dir" "$expected_database" + ;; + sqlite) + [ -n "$sqlite_file" ] || return 1 + sqlite_snapshot_looks_restorable "$temp_dir/$(basename "$sqlite_file")" + ;; + *) + return 1 + ;; + esac +} + send_backup_to_telegram() { + local requested_timestamp="${1:-}" + if [ -f "$ENV_FILE" ]; then while IFS='=' read -r key value; do if [[ -z "$key" || "$key" =~ ^# ]]; then @@ -195,9 +282,19 @@ send_backup_to_telegram() { fi local backup_dir="$APP_DIR/backup" local latest_backup="" - latest_backup=$(find "$backup_dir" -maxdepth 1 -type f \ - \( -name 'backup_*.tar.gz' -o -name 'backup_*.zip' -o -name 'backup_*.z[0-9][0-9]' -o -name 'backup_*.part[0-9][0-9].zip' \) \ - -printf '%T@ %f\n' 2>/dev/null | sort -nr | head -n 1 | cut -d' ' -f2-) + if [ -n "$requested_timestamp" ]; then + if [[ ! "$requested_timestamp" =~ ^[0-9]{14}$ ]]; then + colorized_echo red "Invalid backup timestamp requested for upload." + return 1 + fi + latest_backup=$(find "$backup_dir" -maxdepth 1 -type f \ + \( -name "backup_${requested_timestamp}.zip" -o -name "backup_${requested_timestamp}.z[0-9][0-9]" -o -name "backup_${requested_timestamp}.part[0-9][0-9].zip" \) \ + -printf '%T@ %f\n' 2>/dev/null | sort -nr | head -n 1 | cut -d' ' -f2-) + else + latest_backup=$(find "$backup_dir" -maxdepth 1 -type f \ + \( -name 'backup_*.tar.gz' -o -name 'backup_*.zip' -o -name 'backup_*.z[0-9][0-9]' -o -name 'backup_*.part[0-9][0-9].zip' \) \ + -printf '%T@ %f\n' 2>/dev/null | sort -nr | head -n 1 | cut -d' ' -f2-) + fi if [ -z "$latest_backup" ]; then colorized_echo red "No backups found to send." @@ -851,14 +948,17 @@ remove_backup_service() { # PostgreSQL/TimescaleDB container into /pg_dump/. # Layout: globals.sql, db-NNN.sql per database, manifest.tsv # (dbnameownerhas_timescaledbfilenamets_version). -# Returns 0 when at least one database was dumped; otherwise removes the -# pg_dump dir and returns 1 so the caller can fall back to single-database mode. +# The configured application database is passed as argument 6. Returns 0 only +# when every enumerated database was dumped and that application database is in +# the manifest; otherwise removes the pg_dump directory and returns 1 so the +# caller can fall back to a validated single-database dump. pg_dump_all_user_databases() { local container_name="$1" local backup_user="$2" local backup_password="$3" local temp_dir="$4" local log_file="$5" + local expected_database="$6" local out_dir="$temp_dir/pg_dump" local manifest="$out_dir/manifest.tsv" @@ -871,13 +971,22 @@ pg_dump_all_user_databases() { rm -rf "$out_dir" return 1 fi + if ! postgres_globals_dump_looks_complete "$out_dir/globals.sql"; then + echo "pg_dumpall globals output failed completion validation" >>"$log_file" + rm -rf "$out_dir" + return 1 + fi # Enumerate real user databases (skip templates and the postgres maintenance DB). local databases="" - databases=$(docker exec -e PGPASSWORD="$backup_password" "$container_name" \ + if ! databases=$(docker exec -e PGPASSWORD="$backup_password" "$container_name" \ psql -U "$backup_user" -d postgres -At \ -c "SELECT datname FROM pg_database WHERE datistemplate = false AND datname <> 'postgres';" \ - 2>>"$log_file") + 2>>"$log_file"); then + echo "Could not enumerate user databases for multi-DB backup" >>"$log_file" + rm -rf "$out_dir" + return 1 + fi if [ -z "$databases" ]; then echo "No user databases enumerated for multi-DB backup" >>"$log_file" rm -rf "$out_dir" @@ -887,6 +996,7 @@ pg_dump_all_user_databases() { : >"$manifest" local index=0 local dumped=0 + local expected_found=false local dbname="" while IFS= read -r dbname; do [ -n "$dbname" ] || continue @@ -894,15 +1004,17 @@ pg_dump_all_user_databases() { local filename filename=$(pg_dump_index_filename "$index") - # Owner of this database (empty if it can't be determined; restore - # falls back to the admin role). + # Owner of this database. If it cannot be determined, the manifest + # would be incomplete, so abort and let the caller fall back to a + # validated single-database dump. local owner="" if ! owner=$(docker exec -e PGPASSWORD="$backup_password" "$container_name" \ psql -U "$backup_user" -d postgres -At \ -c "SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_database WHERE datname = '${dbname//\'/\'\'}';" \ 2>>"$log_file") || [ -z "$owner" ]; then - echo "Could not determine owner for database '$dbname'; restore will fall back to the admin role" >>"$log_file" - owner="" + echo "Could not determine owner for database '$dbname'; multi-DB backup is incomplete" >>"$log_file" + rm -rf "$out_dir" + return 1 fi # TimescaleDB presence + version for this database. One query gives both: @@ -921,35 +1033,41 @@ pg_dump_all_user_databases() { ts_version="$ext_check" fi else - echo "Could not check timescaledb extension for database '$dbname'; assuming not present" >>"$log_file" + echo "Could not check timescaledb extension for database '$dbname'; multi-DB backup is incomplete" >>"$log_file" + rm -rf "$out_dir" + return 1 fi # Dump this database. if ! docker exec -e PGPASSWORD="$backup_password" "$container_name" \ pg_dump -U "$backup_user" -d "$dbname" --clean --if-exists >"$out_dir/$filename" 2>>"$log_file"; then echo "pg_dump failed for database '$dbname'" >>"$log_file" - rm -f "$out_dir/$filename" - continue + rm -rf "$out_dir" + return 1 fi # Never trust an empty/garbage dump. if ! postgres_dump_looks_restorable "$out_dir/$filename"; then - echo "Dump for database '$dbname' failed content validation; skipping" >>"$log_file" - rm -f "$out_dir/$filename" - continue + echo "Dump for database '$dbname' failed completion/content validation" >>"$log_file" + rm -rf "$out_dir" + return 1 fi local line if ! line=$(pg_manifest_encode "$dbname" "$owner" "$has_ts" "$filename" "$ts_version"); then - echo "Database name '$dbname' is not manifest-safe; skipping" >>"$log_file" - rm -f "$out_dir/$filename" - continue + echo "Database name '$dbname' is not manifest-safe; multi-DB backup is incomplete" >>"$log_file" + rm -rf "$out_dir" + return 1 fi printf '%s\n' "$line" >>"$manifest" dumped=$((dumped + 1)) + if [ "$dbname" = "$expected_database" ]; then + expected_found=true + fi done <<<"$databases" - if [ "$dumped" -eq 0 ]; then + if [ "$dumped" -eq 0 ] || [ "$dumped" -ne "$index" ] || [ "$expected_found" != true ]; then + echo "Multi-DB backup did not include every database and the configured database '$expected_database'" >>"$log_file" rm -rf "$out_dir" return 1 fi @@ -974,6 +1092,7 @@ backup_command() { local split_threshold_bytes="${BACKUP_SPLIT_THRESHOLD_BYTES:-$split_size_bytes}" local staging_root="" local temp_dir="" + local sqlite_snapshot_dir="" local log_file="" # Keep the lock with the backup artifacts so it is not affected by sticky-dir # protections on /tmp when different users invoke the command. @@ -1053,6 +1172,9 @@ backup_command() { cleanup_backup_command() { rm -rf "$temp_dir" + if [ -n "$sqlite_snapshot_dir" ]; then + rm -rf "$sqlite_snapshot_dir" + fi if [ "$keep_log_file" != true ] && [ -n "$log_file" ]; then rm -f "$log_file" fi @@ -1130,26 +1252,11 @@ backup_command() { # Extract database type from scheme if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^sqlite ]]; then - db_type="sqlite" - # Extract SQLite file path - # SQLite URLs: sqlite:///relative/path or sqlite:////absolute/path - local sqlite_url_part="${SQLALCHEMY_DATABASE_URL#*://}" - sqlite_url_part="${sqlite_url_part%%\?*}" - sqlite_url_part="${sqlite_url_part%%#*}" - - # SQLite URL format: - # sqlite:////absolute/path (4 slashes = absolute path /path) - # After removing 'sqlite://', //absolute/path remains, convert to /absolute/path - if [[ "$sqlite_url_part" =~ ^//(.*)$ ]]; then - # Absolute path: sqlite:////absolute/path -> /absolute/path - sqlite_file="/${BASH_REMATCH[1]}" - elif [[ "$sqlite_url_part" =~ ^/(.*)$ ]]; then - # Could be absolute (sqlite:///path) or relative depending on context - # In practice, treat as absolute since SQLAlchemy uses 4 slashes for absolute - sqlite_file="/${BASH_REMATCH[1]}" - else - # Relative path (no leading slash) - sqlite_file="$sqlite_url_part" + db_type="sqlite" + if ! sqlite_file=$(sqlite_database_path_from_url "$SQLALCHEMY_DATABASE_URL") || [ -z "$sqlite_file" ]; then + sqlite_file="" + echo "Invalid SQLite SQLALCHEMY_DATABASE_URL: ${safe_sqlalchemy_url}" >>"$log_file" + error_messages+=("SQLite database URL is malformed; expected sqlite[+driver]:// followed by a database path.") fi elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^(mysql|mariadb|postgresql)[^:]*:// ]]; then # Extract scheme to determine type @@ -1251,11 +1358,23 @@ backup_command() { # Try root user with MYSQL_ROOT_PASSWORD first for all databases backup if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then colorized_echo blue "Backing up all MariaDB databases from container: $container_name (using root user)" - if docker exec -e MYSQL_PWD="$MYSQL_ROOT_PASSWORD" "$container_name" mariadb-dump -u root --all-databases --ignore-database=mysql --ignore-database=performance_schema --ignore-database=information_schema --ignore-database=sys --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + local mariadb_databases="" + local configured_db_found=false + mariadb_databases=$(docker exec -e MYSQL_PWD="$MYSQL_ROOT_PASSWORD" "$container_name" \ + mariadb -N -s -u root -e "SHOW DATABASES;" 2>>"$log_file") || mariadb_databases="" + while IFS= read -r _db_line; do + [ -n "$_db_line" ] || continue + if [ "$_db_line" = "$db_name" ]; then + configured_db_found=true + break + fi + done <<<"$mariadb_databases" + + if [ "$configured_db_found" = true ] && docker exec -e MYSQL_PWD="$MYSQL_ROOT_PASSWORD" "$container_name" mariadb-dump -u root --all-databases --ignore-database=mysql --ignore-database=performance_schema --ignore-database=information_schema --ignore-database=sys --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then colorized_echo green "MariaDB backup completed successfully (all databases)" else # Fallback to SQL URL credentials for specific database - colorized_echo yellow "Root backup failed, falling back to app user for specific database" + colorized_echo yellow "Root backup failed or did not include '$db_name'; falling back to app user for the configured database" local backup_user="${db_user:-${DB_USER:-}}" local backup_password="${db_password:-${DB_PASSWORD:-}}" @@ -1339,11 +1458,17 @@ backup_command() { # Collect DB names into an array so each is passed as one argument # (a name containing a space must not be word-split). local -a database_list=() + local configured_db_found=false while IFS= read -r _db_line; do - [ -n "$_db_line" ] && database_list+=("$_db_line") + if [ -n "$_db_line" ]; then + database_list+=("$_db_line") + if [ "$_db_line" = "$db_name" ]; then + configured_db_found=true + fi + fi done <<<"$databases" - if [ -z "$databases" ]; then - colorized_echo yellow "No user databases found, falling back to specific database backup" + if [ -z "$databases" ] || [ "$configured_db_found" != true ]; then + colorized_echo yellow "The configured database '$db_name' was not found in the root database list; falling back to a specific database backup" # Fallback to SQL URL credentials local backup_user="${db_user:-${DB_USER:-}}" local backup_password="${db_password:-${DB_PASSWORD:-}}" @@ -1441,7 +1566,7 @@ backup_command() { error_messages+=("PostgreSQL database name not found.") else colorized_echo blue "Backing up all PostgreSQL databases from container: $container_name (using user: $backup_user)" - if pg_dump_all_user_databases "$container_name" "$backup_user" "$backup_password" "$temp_dir" "$log_file"; then + if pg_dump_all_user_databases "$container_name" "$backup_user" "$backup_password" "$temp_dir" "$log_file" "$db_name"; then colorized_echo green "PostgreSQL backup completed successfully (all databases)" else colorized_echo yellow "Multi-database backup unavailable; falling back to single database '$db_name'." @@ -1512,7 +1637,7 @@ backup_command() { error_messages+=("TimescaleDB database name not found.") else colorized_echo blue "Backing up all TimescaleDB databases from container: $container_name (using user: $backup_user)" - if pg_dump_all_user_databases "$container_name" "$backup_user" "$backup_password" "$temp_dir" "$log_file"; then + if pg_dump_all_user_databases "$container_name" "$backup_user" "$backup_password" "$temp_dir" "$log_file" "$db_name"; then colorized_echo green "TimescaleDB backup completed successfully (all databases)" else colorized_echo yellow "Multi-database backup unavailable; falling back to single database '$db_name'." @@ -1533,7 +1658,10 @@ backup_command() { fi ;; sqlite) - if [ -f "$sqlite_file" ]; then + if [ -z "$sqlite_file" ]; then + # URL parsing already recorded the actionable error above. + : + elif [ -f "$sqlite_file" ]; then if ! command -v sqlite3 >/dev/null 2>&1; then detect_os # Best-effort: if sqlite3 can't be installed, continue (the @@ -1544,11 +1672,13 @@ backup_command() { local sqlite_basename=$(basename "$sqlite_file") if command -v sqlite3 >/dev/null 2>&1; then - if ! sqlite3 "$sqlite_file" ".backup '$temp_dir/$sqlite_basename'" >>"$log_file" 2>&1; then + if ! sqlite_snapshot_dir=$(mktemp -d "${staging_root}/pasarguard_sqlite_snapshot.XXXXXX"); then + error_messages+=("Failed to create protected SQLite snapshot staging directory.") + elif ! sqlite3 "$sqlite_file" ".backup '$sqlite_snapshot_dir/$sqlite_basename'" >>"$log_file" 2>&1; then error_messages+=("Failed to create SQLite backup snapshot.") fi - elif ! cp "$sqlite_file" "$temp_dir/$sqlite_basename" 2>>"$log_file"; then - error_messages+=("Failed to copy SQLite database.") + else + error_messages+=("sqlite3 is required to create a consistent SQLite backup snapshot.") fi else error_messages+=("SQLite database file not found at $sqlite_file.") @@ -1582,23 +1712,27 @@ backup_command() { # Ensure destination directory exists and is empty (already cleaned above, but be explicit) if [ -d "$DATA_DIR" ]; then local rsync_args=(-av --exclude 'xray-core' --exclude 'mysql' --exclude 'mariadb' --exclude 'postgresql' --exclude 'timescaledb') + local normalized_data_dir="" + normalized_data_dir=$(normalize_posix_path "$DATA_DIR") - if [ "$db_type" = "sqlite" ] && [ -n "$sqlite_file" ] && [[ "$sqlite_file" == "$DATA_DIR/"* ]]; then - local sqlite_relative_path="${sqlite_file#$DATA_DIR/}" + if [ "$db_type" = "sqlite" ] && [ -n "$sqlite_file" ] && [[ "$sqlite_file" == "$normalized_data_dir/"* ]]; then + local sqlite_relative_path="${sqlite_file#"$normalized_data_dir"/}" rsync_args+=(--exclude "$sqlite_relative_path") rsync_args+=(--exclude "${sqlite_relative_path}-wal") rsync_args+=(--exclude "${sqlite_relative_path}-shm") + rsync_args+=(--exclude "${sqlite_relative_path}-journal") echo "Excluding SQLite database from data directory copy: $sqlite_relative_path" >>"$log_file" fi if ! rsync "${rsync_args[@]}" "$DATA_DIR/" "$temp_dir/pasarguard_data/" >>"$log_file" 2>&1; then error_messages+=("Failed to copy data directory.") echo "Failed to copy data directory" >>"$log_file" - elif [ "$db_type" = "sqlite" ] && [ -n "$sqlite_file" ] && [[ "$sqlite_file" == "$DATA_DIR/"* ]]; then - local sqlite_relative_path="${sqlite_file#$DATA_DIR/}" + elif [ "$db_type" = "sqlite" ] && [ -n "$sqlite_file" ] && [[ "$sqlite_file" == "$normalized_data_dir/"* ]]; then + local sqlite_relative_path="${sqlite_file#"$normalized_data_dir"/}" rm -f "$temp_dir/pasarguard_data/$sqlite_relative_path" \ "$temp_dir/pasarguard_data/${sqlite_relative_path}-wal" \ - "$temp_dir/pasarguard_data/${sqlite_relative_path}-shm" 2>>"$log_file" || true + "$temp_dir/pasarguard_data/${sqlite_relative_path}-shm" \ + "$temp_dir/pasarguard_data/${sqlite_relative_path}-journal" 2>>"$log_file" || true fi else colorized_echo yellow "Data directory $DATA_DIR does not exist. Skipping data directory backup." @@ -1618,9 +1752,35 @@ backup_command() { fi fi + # Refuse to archive a database artifact unless the final staged copy is + # complete and contains the configured application database. Keep this + # gate after app/data copying because those operations also write into the + # staging directory and must not replace an already-validated artifact. + if [ -n "$db_type" ] && [ ${#error_messages[@]} -eq 0 ]; then + if [ "$db_type" = "sqlite" ] && [ -n "$sqlite_file" ]; then + local final_sqlite_basename="" + final_sqlite_basename=$(basename "$sqlite_file") + if [ -z "$sqlite_snapshot_dir" ] || [ ! -f "$sqlite_snapshot_dir/$final_sqlite_basename" ]; then + error_messages+=("Protected SQLite snapshot is missing before final archive staging.") + elif ! mv -f "$sqlite_snapshot_dir/$final_sqlite_basename" "$temp_dir/$final_sqlite_basename" 2>>"$log_file"; then + error_messages+=("Failed to move the protected SQLite snapshot into final archive staging.") + fi + rm -f "$temp_dir/${final_sqlite_basename}-wal" \ + "$temp_dir/${final_sqlite_basename}-shm" \ + "$temp_dir/${final_sqlite_basename}-journal" 2>>"$log_file" || true + fi + if [ ${#error_messages[@]} -eq 0 ] && ! database_backup_looks_restorable "$db_type" "$temp_dir" "$db_name" "$sqlite_file"; then + colorized_echo red "Database backup artifact failed completeness validation." + echo "Final staged database artifact failed completeness validation for $db_type" >>"$log_file" + error_messages+=("$db_type backup artifact is missing, truncated, corrupt, or does not contain the configured database.") + fi + fi + colorized_echo blue "Creating backup archive..." # Verify temp_dir exists and has content before creating archive - if [ ! -d "$temp_dir" ] || [ -z "$(ls -A "$temp_dir" 2>/dev/null)" ]; then + if [ ${#error_messages[@]} -gt 0 ]; then + echo "Skipping archive creation because the backup has errors." >>"$log_file" + elif [ ! -d "$temp_dir" ] || [ -z "$(ls -A "$temp_dir" 2>/dev/null)" ]; then error_messages+=("Temporary directory is empty or missing. Cannot create archive.") echo "Temporary directory is empty or missing: $temp_dir" >>"$log_file" elif ! (cd "$temp_dir" && zip -rq "$backup_file" .) 2>>"$log_file"; then @@ -1683,6 +1843,10 @@ backup_command() { fi if [ ${#error_messages[@]} -gt 0 ]; then + rm -f "$backup_file" + find "$backup_dir" -maxdepth 1 -type f \ + \( -name "backup_${timestamp}.z[0-9][0-9]" -o -name "backup_${timestamp}.part[0-9][0-9].zip" \) \ + -delete 2>/dev/null || true keep_log_file=true colorized_echo red "Backup completed with errors:" for error in "${error_messages[@]}"; do @@ -1712,7 +1876,7 @@ backup_command() { done fi if [ -f "$ENV_FILE" ]; then - send_backup_to_telegram "$backup_file" + send_backup_to_telegram "$timestamp" fi cleanup_backup_command } diff --git a/lib/pasarguard-restore.sh b/lib/pasarguard-restore.sh index 6187cc2..c2113b1 100644 --- a/lib/pasarguard-restore.sh +++ b/lib/pasarguard-restore.sh @@ -35,7 +35,27 @@ postgres_dump_looks_restorable() { local dump_file="$1" [ -s "$dump_file" ] || return 1 # Require at least one real schema/data statement, not just comments/SET. - grep -qiE '^[[:space:]]*(CREATE|COPY|INSERT|ALTER)[[:space:]]' "$dump_file" + grep -qiE '^[[:space:]]*(CREATE|COPY|INSERT|ALTER)[[:space:]]' "$dump_file" || return 1 + # pg_dump writes this only after completing the output. Requiring it keeps a + # dump truncated by a full disk or interrupted process from being accepted. + grep -qE '^-- PostgreSQL database dump complete([[:space:]]*)$' "$dump_file" +} + +# pg_dumpall uses a different completion marker for the globals-only file. +postgres_globals_dump_looks_complete() { + local dump_file="$1" + [ -s "$dump_file" ] || return 1 + grep -qE '^-- PostgreSQL database cluster dump complete([[:space:]]*)$' "$dump_file" +} + +# Both mysqldump and mariadb-dump emit a completion marker after a successful +# plain-SQL dump. This accepts either tool while rejecting empty and truncated +# files before they can be archived or restored. +mysql_dump_looks_restorable() { + local dump_file="$1" + [ -s "$dump_file" ] || return 1 + grep -qE '^-- (MySQL|MariaDB) dump ' "$dump_file" || return 1 + grep -qE '^-- Dump completed on ' "$dump_file" } # Detect the dump layout inside an extracted backup directory. @@ -253,6 +273,10 @@ restore_command() { local current_sqlalchemy_url="" local current_mysql_root_password="" local sqlite_basename="" + local sqlite_backup_source="" + local sqlite_safety_backup="" + local restore_timestamp="" + restore_timestamp=$(date +%Y%m%d%H%M%S) redact_database_url() { local url="$1" @@ -684,16 +708,11 @@ restore_command() { if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^sqlite ]]; then db_type="sqlite" colorized_echo green "✓ Detected SQLite database" - local sqlite_url_part="${SQLALCHEMY_DATABASE_URL#*://}" - sqlite_url_part="${sqlite_url_part%%\?*}" - sqlite_url_part="${sqlite_url_part%%#*}" - - if [[ "$sqlite_url_part" =~ ^//(.*)$ ]]; then - sqlite_file="/${BASH_REMATCH[1]}" - elif [[ "$sqlite_url_part" =~ ^/(.*)$ ]]; then - sqlite_file="/${BASH_REMATCH[1]}" - else - sqlite_file="$sqlite_url_part" + if ! sqlite_file=$(sqlite_database_path_from_url "$SQLALCHEMY_DATABASE_URL") || [ -z "$sqlite_file" ]; then + colorized_echo red "Invalid SQLite SQLALCHEMY_DATABASE_URL in backup; expected sqlite[+driver]:// followed by a database path." + echo "Invalid SQLite SQLALCHEMY_DATABASE_URL: $(redact_database_url "$SQLALCHEMY_DATABASE_URL")" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 fi colorized_echo blue "Database file: $sqlite_file" elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^(mysql|mariadb|postgresql)[^:]*:// ]]; then @@ -816,39 +835,53 @@ restore_command() { case $db_type in sqlite) sqlite_basename=$(basename "$sqlite_file") - local backup_source="" if [ -f "$temp_restore_dir/$sqlite_basename" ]; then - backup_source="$temp_restore_dir/$sqlite_basename" + sqlite_backup_source="$temp_restore_dir/$sqlite_basename" elif [ -f "$temp_restore_dir/db_backup.sqlite" ]; then - backup_source="$temp_restore_dir/db_backup.sqlite" + sqlite_backup_source="$temp_restore_dir/db_backup.sqlite" fi - if [ -z "$backup_source" ]; then + if [ -z "$sqlite_backup_source" ]; then colorized_echo red "SQLite backup file not found in backup archive (looked for $sqlite_basename or db_backup.sqlite)." rm -rf "$temp_restore_dir" exit 1 fi - rm -f "${sqlite_file}-wal" "${sqlite_file}-shm" 2>>"$log_file" || true - - if [ -f "$sqlite_file" ]; then - cp "$sqlite_file" "${sqlite_file}.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" + if ! command -v sqlite3 >/dev/null 2>&1; then + detect_os + try_install_package sqlite3 || true fi - - if cp "$backup_source" "$sqlite_file" 2>>"$log_file"; then - colorized_echo green "SQLite database restored successfully." - else - colorized_echo red "Failed to restore SQLite database." - echo "SQLite restore failed" >>"$log_file" + if ! command -v sqlite3 >/dev/null 2>&1; then + colorized_echo red "sqlite3 is required to validate the SQLite snapshot before restore. Install sqlite3 and run the restore again." + echo "sqlite3 unavailable; cannot validate $sqlite_backup_source" >>"$log_file" rm -rf "$temp_restore_dir" exit 1 fi + if ! sqlite_snapshot_looks_restorable "$sqlite_backup_source"; then + colorized_echo red "SQLite backup is corrupt or incomplete; aborting before replacing the current database." + echo "SQLite snapshot validation failed for $sqlite_backup_source" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + + if [ -f "$sqlite_file" ]; then + sqlite_safety_backup="$backup_dir/sqlite_before_restore_${restore_timestamp}_${sqlite_basename}" + if ! sqlite3 "$sqlite_file" ".backup '$sqlite_safety_backup'" >>"$log_file" 2>&1; then + colorized_echo red "Failed to create a safety snapshot of the current SQLite database; restore aborted." + echo "SQLite safety snapshot failed: $sqlite_file -> $sqlite_safety_backup" >>"$log_file" + rm -f "$sqlite_safety_backup" + rm -rf "$temp_restore_dir" + exit 1 + fi + colorized_echo blue "Current SQLite database saved to $sqlite_safety_backup" + fi ;; mariadb|mysql) - if [ ! -f "$temp_restore_dir/db_backup.sql" ]; then - colorized_echo red "Database backup file not found in backup archive." + if ! mysql_dump_looks_restorable "$temp_restore_dir/db_backup.sql"; then + colorized_echo red "Database backup is missing, truncated, or invalid; aborting before restore." + echo "MySQL/MariaDB dump validation failed for $temp_restore_dir/db_backup.sql" >>"$log_file" rm -rf "$temp_restore_dir" exit 1 fi @@ -968,6 +1001,13 @@ restore_command() { exit 1 fi + if [ "$pg_layout" = "multi" ] && ! postgres_backup_looks_restorable "$temp_restore_dir" "$db_name"; then + colorized_echo red "Multi-database backup is incomplete or does not contain the configured database; aborting before restore." + echo "Multi-database dump validation failed for $temp_restore_dir/pg_dump" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + if [ "$pg_layout" = "single" ]; then # Verify backup file is not empty and is readable if [ ! -s "$temp_restore_dir/db_backup.sql" ]; then @@ -1155,13 +1195,29 @@ restore_command() { exit 1 fi if [ "$db_type" = "sqlite" ] && [ -n "${sqlite_file:-}" ]; then - rm -f "${sqlite_file}-wal" "${sqlite_file}-shm" 2>>"$log_file" || true + rm -f "${sqlite_file}-wal" "${sqlite_file}-shm" "${sqlite_file}-journal" 2>>"$log_file" || true fi colorized_echo green "Data directory restored to $DATA_DIR." else colorized_echo yellow "No pasarguard_data directory found in backup. Skipping data restore." fi + # The data directory in legacy archives may contain a raw SQLite main file + # and WAL. Apply the consistent snapshot only after that directory has been + # restored so the raw copy can never overwrite the authoritative backup. + if [ "$db_type" = "sqlite" ]; then + mkdir -p "$(dirname "$sqlite_file")" + rm -f "${sqlite_file}-wal" "${sqlite_file}-shm" "${sqlite_file}-journal" 2>>"$log_file" || true + if cp "$sqlite_backup_source" "$sqlite_file" 2>>"$log_file"; then + colorized_echo green "SQLite database restored successfully." + else + colorized_echo red "Failed to restore SQLite database." + echo "SQLite restore failed" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + fi + # Restore app directory files (full app backup support) colorized_echo blue "Restoring app directory files..." if [ -d "$temp_restore_dir" ]; then diff --git a/pasarguard.sh b/pasarguard.sh index 76909ff..add9d0a 100755 --- a/pasarguard.sh +++ b/pasarguard.sh @@ -1067,7 +1067,8 @@ install_pasarguard() { db_driver_scheme="sqlite" fi - sed -i "s~\(SQLALCHEMY_DATABASE_URL = \).*~\1\"${db_driver_scheme}:////${DATA_DIR}/db.sqlite3\"~" "$APP_DIR/.env" + SQLALCHEMY_DATABASE_URL=$(sqlite_absolute_database_url "$db_driver_scheme" "$DATA_DIR/db.sqlite3") + sed -i "s~\(SQLALCHEMY_DATABASE_URL = \).*~\1\"${SQLALCHEMY_DATABASE_URL}\"~" "$APP_DIR/.env" fi diff --git a/tests/backup_restore_roundtrip.sh b/tests/backup_restore_roundtrip.sh index 9054ca3..b0c23b1 100644 --- a/tests/backup_restore_roundtrip.sh +++ b/tests/backup_restore_roundtrip.sh @@ -57,9 +57,21 @@ EXTRACTED_BACKUP_DIR="" COMBINED_BACKUP_ARCHIVE="" MULTIPART_SPLIT_SIZE_BYTES=2048 MULTIPART_SPLIT_THRESHOLD_BYTES=3072 +SQLITE_HOLDER_PID="" +SQLITE_HOLDER_READY="$WORK_DIR/sqlite-holder.ready" +SQLITE_HOLDER_STOP="$WORK_DIR/sqlite-holder.stop" + +stop_sqlite_holder() { + if [ -n "$SQLITE_HOLDER_PID" ] && kill -0 "$SQLITE_HOLDER_PID" 2>/dev/null; then + touch "$SQLITE_HOLDER_STOP" + wait "$SQLITE_HOLDER_PID" + fi + SQLITE_HOLDER_PID="" +} cleanup() { local exit_code=$? + stop_sqlite_holder if [ "$exit_code" -ne 0 ] && [ -d "$WORK_DIR" ]; then while IFS= read -r log_path; do [ -n "$log_path" ] || continue @@ -180,6 +192,7 @@ write_sqlite_env() { cat >"$ENV_FILE" <"$DATA_DIR/db.sqlite3-journal" } setup_mysql_container() { @@ -447,11 +487,7 @@ verify_backup_archive_contents() { expected_files=$'.env\ndb_backup.sql\ndocker-compose.yml\npasarguard_data/\npasarguard_data/payload.bin\npasarguard_data/sentinel.txt' fi - if [ "$DB_TYPE" = "sqlite" ]; then - assert_zip_contains_required_files "$archive_to_verify" "$expected_files" - else - assert_zip_contains_exact_files "$archive_to_verify" "$expected_files" - fi + assert_zip_contains_exact_files "$archive_to_verify" "$expected_files" assert_equals "$(sha256sum "$EXTRACTED_BACKUP_DIR/.env" | awk '{print $1}')" "$ORIGINAL_ENV_SHA" "Backed up .env contents changed." assert_equals "$(sha256sum "$EXTRACTED_BACKUP_DIR/docker-compose.yml" | awk '{print $1}')" "$ORIGINAL_COMPOSE_SHA" "Backed up docker-compose.yml contents changed." assert_equals "$(sha256sum "$EXTRACTED_BACKUP_DIR/pasarguard_data/sentinel.txt" | awk '{print $1}')" "$ORIGINAL_SENTINEL_SHA" "Backed up sentinel.txt contents changed." @@ -460,9 +496,12 @@ verify_backup_archive_contents() { if [ "$DB_TYPE" = "sqlite" ]; then assert_sqlite_integrity "$EXTRACTED_BACKUP_DIR/$sqlite_basename" assert_equals "$(sqlite_dump_sha "$EXTRACTED_BACKUP_DIR/$sqlite_basename")" "$ORIGINAL_SQLITE_DUMP_SHA" "Backed up SQLite database logical contents changed." - if [ -f "$EXTRACTED_BACKUP_DIR/pasarguard_data/$sqlite_basename" ]; then - assert_sqlite_integrity "$EXTRACTED_BACKUP_DIR/pasarguard_data/$sqlite_basename" - assert_equals "$(sqlite_dump_sha "$EXTRACTED_BACKUP_DIR/pasarguard_data/$sqlite_basename")" "$ORIGINAL_SQLITE_DUMP_SHA" "Archived SQLite data-dir database logical contents changed." + if [ -e "$EXTRACTED_BACKUP_DIR/pasarguard_data/$sqlite_basename" ] || \ + [ -e "$EXTRACTED_BACKUP_DIR/pasarguard_data/${sqlite_basename}-wal" ] || \ + [ -e "$EXTRACTED_BACKUP_DIR/pasarguard_data/${sqlite_basename}-shm" ] || \ + [ -e "$EXTRACTED_BACKUP_DIR/pasarguard_data/${sqlite_basename}-journal" ]; then + printf 'SQLite database or WAL/SHM/journal leaked into the raw data-directory copy.\n' >&2 + exit 1 fi elif [ "$DB_TYPE" = "postgresql" ] || [ "$DB_TYPE" = "timescaledb" ]; then assert_file_contains "$EXTRACTED_BACKUP_DIR/pg_dump/db-001.sql" "ci_roundtrip" @@ -484,6 +523,20 @@ verify_restored_files() { if [ "$DB_TYPE" = "sqlite" ]; then assert_sqlite_integrity "$DATA_DIR/db.sqlite3" assert_equals "$(sqlite_dump_sha "$DATA_DIR/db.sqlite3")" "$ORIGINAL_SQLITE_DUMP_SHA" "SQLite database logical contents were not restored from backup." + if [ -e "$DATA_DIR/db.sqlite3-journal" ]; then + printf 'A stale SQLite rollback journal survived the restore.\n' >&2 + exit 1 + fi + + local sqlite_safety_backup="" + sqlite_safety_backup=$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'sqlite_before_restore_*_db.sqlite3' | sort | tail -n 1) + if [ -z "$sqlite_safety_backup" ]; then + printf 'The pre-restore SQLite safety snapshot did not survive the data-directory restore.\n' >&2 + exit 1 + fi + assert_sqlite_integrity "$sqlite_safety_backup" + assert_equals "$(sqlite3 "$sqlite_safety_backup" 'SELECT value FROM ci_roundtrip WHERE id = 1;')" \ + "mutated" "Pre-restore SQLite safety snapshot did not preserve the replaced database." fi } @@ -631,6 +684,7 @@ main() { prepare_case backup_command verify_backup_created + stop_sqlite_holder mutate_database_after_backup mutate_files_after_backup run_restore diff --git a/tests/unit_lib_common.sh b/tests/unit_lib_common.sh index e3043de..1ff5bb7 100644 --- a/tests/unit_lib_common.sh +++ b/tests/unit_lib_common.sh @@ -82,6 +82,25 @@ assert_eq "$(stat -c '%a' "$secret_new")" "600" "harden_secret_file: appended co # Empty path is rejected without creating anything. if harden_secret_file ""; then fail "harden_secret_file: rejects empty path"; else pass "harden_secret_file: rejects empty path"; fi +# --- SQLite SQLAlchemy URL helpers --- +assert_eq "$(normalize_posix_path '/var/lib/pasarguard///')" "/var/lib/pasarguard" \ + "normalize_posix_path: removes repeated trailing slashes" +assert_eq "$(sqlite_database_path_from_url 'sqlite:///db.sqlite3')" "db.sqlite3" \ + "sqlite_database_path_from_url: relative path" +assert_eq "$(sqlite_database_path_from_url 'sqlite+aiosqlite:////var/lib/pasarguard/db.sqlite3')" \ + "/var/lib/pasarguard/db.sqlite3" "sqlite_database_path_from_url: absolute path" +assert_eq "$(sqlite_database_path_from_url 'sqlite+aiosqlite://///var/lib/pasarguard/db.sqlite3')" \ + "/var/lib/pasarguard/db.sqlite3" "sqlite_database_path_from_url: legacy five-slash path" +assert_eq "$(sqlite_database_path_from_url 'sqlite:////var/lib/pasarguard/db.sqlite3?mode=ro#fragment')" \ + "/var/lib/pasarguard/db.sqlite3" "sqlite_database_path_from_url: strips query and fragment" +if sqlite_database_path_from_url 'sqlite:/var/lib/pasarguard/db.sqlite3' >/dev/null; then + fail "sqlite_database_path_from_url: malformed URL rejected" +else + pass "sqlite_database_path_from_url: malformed URL rejected" +fi +assert_eq "$(sqlite_absolute_database_url 'sqlite+aiosqlite' '/var/lib/pasarguard/db.sqlite3')" \ + "sqlite+aiosqlite:////var/lib/pasarguard/db.sqlite3" "sqlite_absolute_database_url: exactly four slashes" + echo "" echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/unit_pasarguard.sh b/tests/unit_pasarguard.sh index 6b8160c..6ee4987 100644 --- a/tests/unit_pasarguard.sh +++ b/tests/unit_pasarguard.sh @@ -251,6 +251,120 @@ assert_eq "$_l_ts" "1" "manifest: legacy has_ts preserved" assert_false "manifest: rejects tab in ts_version" pg_manifest_encode "db" "o" "1" "f.sql" "$(printf '2\t7')" assert_false "manifest: rejects newline in ts_version" pg_manifest_encode "db" "o" "1" "f.sql" "$(printf '2\n7')" +# ----------------------------------------------------------------------- +# database backup completeness validation +# ----------------------------------------------------------------------- +DB_VALIDATION_DIR="$WORK_DIR/db-validation" +mkdir -p "$DB_VALIDATION_DIR" + +printf '%s\n' \ + '-- MySQL dump 10.13 Distrib 8.0.43' \ + 'CREATE TABLE users (id int);' \ + '-- Dump completed on 2026-08-01 12:00:00' >"$DB_VALIDATION_DIR/db_backup.sql" +assert_true "backup validation: complete MySQL dump accepted" \ + database_backup_looks_restorable mysql "$DB_VALIDATION_DIR" appdb "" +sed -i '$d' "$DB_VALIDATION_DIR/db_backup.sql" +assert_false "backup validation: truncated MySQL dump rejected" \ + database_backup_looks_restorable mysql "$DB_VALIDATION_DIR" appdb "" + +printf '%s\n' \ + '-- PostgreSQL database dump' \ + 'CREATE TABLE public.users (id integer);' \ + '-- PostgreSQL database dump complete' >"$DB_VALIDATION_DIR/db_backup.sql" +assert_true "backup validation: complete PostgreSQL single dump accepted" \ + database_backup_looks_restorable postgresql "$DB_VALIDATION_DIR" appdb "" +sed -i '$d' "$DB_VALIDATION_DIR/db_backup.sql" +assert_false "backup validation: truncated PostgreSQL single dump rejected" \ + database_backup_looks_restorable timescaledb "$DB_VALIDATION_DIR" appdb "" + +rm -f "$DB_VALIDATION_DIR/db_backup.sql" +mkdir -p "$DB_VALIDATION_DIR/pg_dump" +printf '%s\n' \ + '-- PostgreSQL database cluster dump' \ + 'CREATE ROLE appuser;' \ + '-- PostgreSQL database cluster dump complete' >"$DB_VALIDATION_DIR/pg_dump/globals.sql" +printf '%s\n' \ + '-- PostgreSQL database dump' \ + 'CREATE TABLE public.users (id integer);' \ + '-- PostgreSQL database dump complete' >"$DB_VALIDATION_DIR/pg_dump/db-001.sql" +printf '%s\n' "$(pg_manifest_encode appdb appuser 1 db-001.sql 2.27.2)" >"$DB_VALIDATION_DIR/pg_dump/manifest.tsv" +assert_true "backup validation: complete TimescaleDB multi dump accepted" \ + database_backup_looks_restorable timescaledb "$DB_VALIDATION_DIR" appdb "" +assert_false "backup validation: configured PostgreSQL database must be present" \ + database_backup_looks_restorable postgresql "$DB_VALIDATION_DIR" missingdb "" +printf '%s\n' "$(pg_manifest_encode appdb appuser 0 db-001.sql '')" >"$DB_VALIDATION_DIR/pg_dump/manifest.tsv" +assert_true "backup validation: PostgreSQL manifest with empty ts_version accepted" \ + database_backup_looks_restorable postgresql "$DB_VALIDATION_DIR" appdb "" + +SQLITE_VALIDATION_DIR="$WORK_DIR/sqlite-validation" +mkdir -p "$SQLITE_VALIDATION_DIR" +printf 'sqlite fixture\n' >"$SQLITE_VALIDATION_DIR/app.sqlite3" +sqlite3() { + [ "$2" = "PRAGMA quick_check;" ] || return 1 + printf 'ok\n' +} +assert_true "backup validation: SQLite quick_check success accepted" \ + database_backup_looks_restorable sqlite "$SQLITE_VALIDATION_DIR" "" /source/app.sqlite3 +sqlite3() { printf 'database disk image is malformed\n'; } +assert_false "backup validation: corrupt SQLite snapshot rejected" \ + database_backup_looks_restorable sqlite "$SQLITE_VALIDATION_DIR" "" /source/app.sqlite3 +unset -f sqlite3 + +# A multi-database PostgreSQL/TimescaleDB backup must be atomic. Previously the +# helper returned success as long as any one database dumped successfully. +MOCK_PG_FAIL_DB="" +docker() { + local joined="$*" + case "$joined" in + *" pg_dumpall "*) + printf '%s\n' '-- PostgreSQL database cluster dump' '-- PostgreSQL database cluster dump complete' + ;; + *"SELECT datname FROM pg_database"*) + printf '%s\n' appdb analytics + ;; + *"pg_get_userbyid"*) + printf 'appuser\n' + ;; + *"SELECT extversion FROM pg_extension"*) + return 0 + ;; + *" pg_dump "*) + if [ -n "$MOCK_PG_FAIL_DB" ] && [[ "$joined" == *" -d $MOCK_PG_FAIL_DB "* ]]; then + return 1 + fi + printf '%s\n' \ + '-- PostgreSQL database dump' \ + 'CREATE TABLE public.events (id integer);' \ + '-- PostgreSQL database dump complete' + ;; + *) + return 1 + ;; + esac +} + +PG_ATOMIC_DIR="$WORK_DIR/pg-atomic-success" +mkdir -p "$PG_ATOMIC_DIR" +assert_true "pg multi backup: every database plus configured database succeeds" \ + pg_dump_all_user_databases pg appuser pass "$PG_ATOMIC_DIR" "$WORK_DIR/pg-success.log" appdb +assert_true "pg multi backup: completed artifact passes final validation" \ + postgres_backup_looks_restorable "$PG_ATOMIC_DIR" appdb + +PG_PARTIAL_DIR="$WORK_DIR/pg-atomic-partial" +mkdir -p "$PG_PARTIAL_DIR" +MOCK_PG_FAIL_DB="analytics" +assert_false "pg multi backup: one failed database fails the whole operation" \ + pg_dump_all_user_databases pg appuser pass "$PG_PARTIAL_DIR" "$WORK_DIR/pg-partial.log" appdb +assert_false "pg multi backup: partial dump directory is removed" test -d "$PG_PARTIAL_DIR/pg_dump" + +PG_MISSING_DIR="$WORK_DIR/pg-atomic-missing-app" +mkdir -p "$PG_MISSING_DIR" +MOCK_PG_FAIL_DB="" +assert_false "pg multi backup: missing configured database fails the operation" \ + pg_dump_all_user_databases pg appuser pass "$PG_MISSING_DIR" "$WORK_DIR/pg-missing.log" missingdb +assert_false "pg multi backup: missing-app dump directory is removed" test -d "$PG_MISSING_DIR/pg_dump" +unset -f docker + # ----------------------------------------------------------------------- # get_acme_sh_binary # ----------------------------------------------------------------------- diff --git a/tests/unit_restore_archive_safety.sh b/tests/unit_restore_archive_safety.sh index ee225be..552b17d 100644 --- a/tests/unit_restore_archive_safety.sh +++ b/tests/unit_restore_archive_safety.sh @@ -71,9 +71,14 @@ CREATE TABLE public.users (id integer NOT NULL); COPY public.users (id) FROM stdin; 1 \. +-- PostgreSQL database dump complete EOF assert_true "postgres_dump_looks_restorable: real dump accepted" postgres_dump_looks_restorable "$good_dump" +truncated_dump="$WORK_DIR/truncated.sql" +sed '$d' "$good_dump" >"$truncated_dump" +assert_false "postgres_dump_looks_restorable: missing completion marker rejected" postgres_dump_looks_restorable "$truncated_dump" + empty_dump="$WORK_DIR/empty.sql"; : > "$empty_dump" assert_false "postgres_dump_looks_restorable: empty file rejected" postgres_dump_looks_restorable "$empty_dump" @@ -86,6 +91,22 @@ assert_false "postgres_dump_looks_restorable: no DDL/data rejected" postgres_dum assert_false "postgres_dump_looks_restorable: missing file rejected" postgres_dump_looks_restorable "$WORK_DIR/nope.sql" +good_globals="$WORK_DIR/globals.sql" +printf '%s\n' '-- PostgreSQL database cluster dump' 'CREATE ROLE appuser;' '-- PostgreSQL database cluster dump complete' >"$good_globals" +assert_true "postgres_globals_dump_looks_complete: completed dump accepted" postgres_globals_dump_looks_complete "$good_globals" +sed '$d' "$good_globals" >"$WORK_DIR/truncated-globals.sql" +assert_false "postgres_globals_dump_looks_complete: truncated dump rejected" postgres_globals_dump_looks_complete "$WORK_DIR/truncated-globals.sql" + +good_mysql="$WORK_DIR/mysql.sql" +printf '%s\n' '-- MySQL dump 10.13 Distrib 8.0.43' 'CREATE TABLE users (id int);' '-- Dump completed on 2026-08-01 12:00:00' >"$good_mysql" +assert_true "mysql_dump_looks_restorable: completed MySQL dump accepted" mysql_dump_looks_restorable "$good_mysql" +sed '$d' "$good_mysql" >"$WORK_DIR/truncated-mysql.sql" +assert_false "mysql_dump_looks_restorable: truncated MySQL dump rejected" mysql_dump_looks_restorable "$WORK_DIR/truncated-mysql.sql" + +good_mariadb="$WORK_DIR/mariadb.sql" +printf '%s\n' '-- MariaDB dump 10.19 Distrib 11.8.2-MariaDB' 'CREATE TABLE users (id int);' '-- Dump completed on 2026-08-01 12:00:00' >"$good_mariadb" +assert_true "mysql_dump_looks_restorable: completed MariaDB dump accepted" mysql_dump_looks_restorable "$good_mariadb" + echo "" echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] || exit 1