Make backup and restore safe to actually rely on

backup.sh named archives with %Y%m%d, so a second run on the same day
silently overwrote the first -- destroying a good backup at the moment
someone was trying to take another. Names now carry seconds, and an
existing path is refused rather than clobbered. The archive is also
written to a .partial name and renamed only on success, with a trap to
clean up, so an interrupted run cannot leave a truncated file that looks
like a backup.

restore.sh extracted with tar's defaults. An archive is untrusted input
-- whoever produced it chooses the paths, ownership and modes inside it
-- and extracting as root let the tarball dictate uid/gid and restore
setuid bits directly. Now --no-same-owner --no-same-permissions, with -P
still absent so tar keeps stripping leading "/" and refusing ".."
members. It also lists what it is about to extract and confirms first,
since it silently overwrote whatever was already in the target.

Both scripts also gained set -euo pipefail, and both replaced the
`cmd; if [ $? -eq 0 ]` pattern with a direct `if cmd; then`, which is
what that idiom was reaching for and gets wrong as soon as any command
is inserted between the two lines.

Verified end to end: two same-second-apart backups both survive, no
.partial residue, restore refuses non-interactively, and the round trip
diffs identical.
This commit is contained in:
2026-08-22 22:20:04 -07:00
parent e8c0a73dba
commit 1e676bb634
2 changed files with 60 additions and 15 deletions
+27 -7
View File
@@ -18,8 +18,10 @@
# Usage: backup.sh <source_directory> <destination_directory>
# Description: Creates a tar.gz archive of the source directory in the destination.
SRC="$1"
DEST="$2"
set -euo pipefail
SRC="${1:-}"
DEST="${2:-}"
if [ -z "$SRC" ] || [ -z "$DEST" ]; then
echo "Usage: $0 <source_directory> <destination_directory>"
exit 1
@@ -33,11 +35,29 @@ if [ ! -d "$DEST" ]; then
fi
base_name="$(basename "$SRC")"
date_str="$(date +%Y%m%d)"
# Seconds, not just the date. The old %Y%m%d name meant a second run on
# the same day silently overwrote the first -- losing a good backup at
# the exact moment someone was trying to take another one.
date_str="$(date +%Y%m%d-%H%M%S)"
archive_name="${base_name}-backup-${date_str}.tar.gz"
tar -czf "$DEST/$archive_name" -C "$(dirname "$SRC")" "$base_name"
if [ $? -eq 0 ]; then
echo "Backup successful: $DEST/$archive_name"
archive_path="$DEST/$archive_name"
if [ -e "$archive_path" ]; then
echo "Refusing to overwrite existing archive: $archive_path"
exit 1
fi
# Write to a partial name and rename only on success, so an interrupted
# run cannot leave a truncated file sitting there looking like a backup.
tmp_path="${archive_path}.partial"
trap 'rm -f -- "$tmp_path"' EXIT
if tar -czf "$tmp_path" -C "$(dirname "$SRC")" "$base_name"; then
mv -- "$tmp_path" "$archive_path"
trap - EXIT
echo "Backup successful: $archive_path"
ls -lh -- "$archive_path"
else
echo "Backup failed for $SRC"
echo "Backup failed for $SRC" >&2
exit 1
fi
+31 -6
View File
@@ -17,9 +17,13 @@
#
# Usage: restore.sh <backup_archive.tar.gz> [target_directory]
# Description: Extracts the tar.gz archive into the target directory (current dir if not specified).
#
# Extraction options are deliberately conservative -- see the tar call.
ARCHIVE="$1"
TARGET="$2"
set -euo pipefail
ARCHIVE="${1:-}"
TARGET="${2:-}"
if [ -z "$ARCHIVE" ]; then
echo "Usage: $0 <archive.tar.gz> [target_directory]"
exit 1
@@ -36,10 +40,31 @@ else
fi
fi
tar -xzf "$ARCHIVE" -C "$TARGET"
status=$?
if [ $status -eq 0 ]; then
# An archive is untrusted input: whoever produced it chooses the paths,
# the ownership and the modes inside it.
# --no-same-owner do not let the archive pick uid/gid. Extracting
# as root previously handed files to whatever
# owner the tarball named.
# --no-same-permissions apply the umask rather than restoring setuid
# bits straight out of the archive.
# -P is NOT used, so tar strips leading "/" and refuses ".." members.
echo "Contents to be extracted into $TARGET:"
tar -tzf "$ARCHIVE" | head -n 20
total="$(tar -tzf "$ARCHIVE" | grep -c . || true)"
[ "$total" -gt 20 ] && echo " ... and $((total - 20)) more entries"
if [ "${ASSUME_YES:-}" != "1" ]; then
if [ ! -t 0 ]; then
echo "Refusing to extract without confirmation; set ASSUME_YES=1 for unattended use." >&2
exit 1
fi
read -r -p "Extract $total entries into $TARGET, overwriting existing files? (yes/NO): " reply
[ "$reply" = "yes" ] || { echo "Cancelled."; exit 0; }
fi
if tar -xzf "$ARCHIVE" -C "$TARGET" --no-same-owner --no-same-permissions; then
echo "Restore successful to directory: $TARGET"
else
echo "Restore failed with error code $status"
echo "Restore failed" >&2
exit 1
fi