aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 09:10:38 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 09:10:38 -0400
commit3ef8c7a0544f372f0d3c63c8055517e0c64347ec (patch)
tree0ca98245dac009854ca92213e106b1ed8b65123b
parent195833b9db23fd5e5bfdaf17bfae5849e9958a82 (diff)
downloadrsyncshot-3ef8c7a0544f372f0d3c63c8055517e0c64347ec.tar.gz
rsyncshot-3ef8c7a0544f372f0d3c63c8055517e0c64347ec.zip
fix: graceful remote skip, quieter backups, accurate snapshot counts
A scheduled backup to an unreachable remote now skips (exit 0) instead of erroring, so cron stops logging a failure when the NAS or laptop is offline. Authentication and host-key failures still error loudly, so a genuinely broken backup isn't masked as offline. The actual backup drops rsync -v for --info=stats1, so the logfile gets a per-run summary instead of every transferred file path. The dryrun stays verbose. list and status no longer count the latest/ working directory as a snapshot. Snapshot directories are now read-only beyond the top level (find -type d). Only directories are touched, never the hard-linked files, so permissions don't ripple across snapshots. Previously-unchecked rotation steps (orphan cleanup, the latest/ touch, the read-only chmod) now warn instead of failing silently. The remote snapshot listing falls back to BSD stat when GNU stat isn't available.
-rwxr-xr-xrsyncshot43
-rwxr-xr-xtests/cases/test_backup.sh52
-rw-r--r--tests/cases/test_remote.sh28
-rw-r--r--tests/cases/test_rsync_flags.sh29
4 files changed, 140 insertions, 12 deletions
diff --git a/rsyncshot b/rsyncshot
index 4a6140d..f48054d 100755
--- a/rsyncshot
+++ b/rsyncshot
@@ -434,10 +434,12 @@ list_snapshots()
cd '$BASE_PATH'
found=0
for dir in */ ; do
+ if [ \"\$dir\" = 'latest/' ]; then continue; fi
if [ -d \"\$dir\" ] && [ \"\$dir\" != '*/' ]; then
found=1
name=\$(basename \"\$dir\")
mtime=\$(stat -c '%y' \"\$dir\" 2>/dev/null | cut -d'.' -f1)
+ [ -z \"\$mtime\" ] && mtime=\$(stat -f '%Sm' \"\$dir\" 2>/dev/null)
size=\$(du -sh \"\$dir\" 2>/dev/null | cut -f1)
printf '%-20s %s %s\n' \"\$name\" \"\$mtime\" \"\$size\"
fi
@@ -609,7 +611,7 @@ show_status()
echo "Snapshots:"
if [ "$MODE" = "remote" ]; then
if ssh "${SSH_OPTS[@]}" "$REMOTE_HOST" "true" 2>/dev/null; then
- SNAP_COUNT=$(run_cmd "ls -d '$REMOTE_DEST'/*/ 2>/dev/null | wc -l" 2>/dev/null || echo 0)
+ SNAP_COUNT=$(run_cmd "ls -d '$REMOTE_DEST'/*/ 2>/dev/null | grep -v '/latest/\$' | wc -l" 2>/dev/null || echo 0)
if [ "$SNAP_COUNT" -gt 0 ]; then
echo " $SNAP_COUNT snapshot(s) found (run 'rsyncshot list' for details)"
else
@@ -620,7 +622,7 @@ show_status()
fi
else
if [ -d "$MOUNTDIR/$HOSTNAME" ]; then
- SNAP_COUNT=$(find "$MOUNTDIR/$HOSTNAME" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
+ SNAP_COUNT=$(find "$MOUNTDIR/$HOSTNAME" -mindepth 1 -maxdepth 1 -type d ! -name latest 2>/dev/null | wc -l)
if [ "$SNAP_COUNT" -gt 0 ]; then
echo " $SNAP_COUNT snapshot(s) found (run 'rsyncshot list' for details)"
else
@@ -944,8 +946,20 @@ if [ "$MODE" = "remote" ]; then
if [ -n "$SSH_IDENTITY_FILE" ]; then
SSH_OPTS+=(-i "$SSH_IDENTITY_FILE")
fi
- if ! ssh "${SSH_OPTS[@]}" "$REMOTE_HOST" "true" 2>/dev/null; then
- error "Cannot connect to $REMOTE_HOST via SSH."
+ # An unreachable remote is expected for a scheduled backup (laptop asleep,
+ # VPN down, NAS offline) and shouldn't be logged as a failure. But a broken
+ # setup (revoked key, changed host key) must surface loudly — a backup tool
+ # that silently no-ops forever is the worst failure mode. Distinguish the two
+ # by the SSH error: auth and host-key problems are real errors; everything
+ # else (timeout, DNS, connection refused) is transient, so skip with exit 0.
+ if ! SSH_ERR=$(ssh "${SSH_OPTS[@]}" "$REMOTE_HOST" "true" 2>&1); then
+ case "$SSH_ERR" in
+ *"Permission denied"*|*"Host key verification failed"*|*"IDENTIFICATION HAS CHANGED"*)
+ error "Cannot connect to $REMOTE_HOST (authentication or host-key failure): $SSH_ERR"
+ ;;
+ esac
+ log "Remote $REMOTE_HOST is unreachable - skipping backup, will retry next run."
+ exit 0
fi
else
# Local mode: verify mount point exists
@@ -1020,8 +1034,9 @@ while IFS= read -r SOURCE || [ -n "$SOURCE" ]; do
"$SOURCE" "$DESTINATION"/latest
RSYNC_EXIT=$?
else
- # Actual backup
- rsync -avhR --numeric-ids --times --timeout=600 \
+ # Actual backup. No -v: per-file output would balloon the logfile on every
+ # run. --info=stats1 logs a one-block transfer summary instead.
+ rsync -ahR --numeric-ids --info=stats1 --times --timeout=600 \
--delete --delete-excluded \
--exclude-from="$EXCLUDES" \
"$SOURCE" "$DESTINATION"/latest
@@ -1086,8 +1101,8 @@ fi
BEYOND=$((MAX+1))
if run_cmd "[ -d '$BASE_PATH/$TYPE.$BEYOND' ]"; then
log "Cleaning up orphaned snapshot: $TYPE.$BEYOND"
- run_cmd "chmod -R u+w '$BASE_PATH/$TYPE.$BEYOND'"
- run_cmd "$RM -rf '$BASE_PATH/$TYPE.$BEYOND'"
+ run_cmd "chmod -R u+w '$BASE_PATH/$TYPE.$BEYOND'" || true
+ run_cmd "$RM -rf '$BASE_PATH/$TYPE.$BEYOND'" || log "Warning: could not remove orphaned snapshot $TYPE.$BEYOND"
fi
# --- Rotate existing snapshots (newest to oldest to avoid overwriting) ---
@@ -1102,7 +1117,7 @@ for (( start=$((MAX)); start>=0; start-- )); do
done
# --- Update timestamp on latest/ directory ---
-run_cmd "touch '$BASE_PATH/latest'"
+run_cmd "touch '$BASE_PATH/latest'" || log "Warning: could not update timestamp on latest/"
# --- Create new snapshot using hard links ---
# cp -al creates a copy where all files are hard links to the originals.
@@ -1114,7 +1129,15 @@ if ! run_cmd "$CP -al '$BASE_PATH/latest' '$BASE_PATH/$TYPE.0'"; then
fi
# --- Make snapshot read-only to prevent accidental modification ---
-run_cmd "chmod -w '$BASE_PATH/$TYPE.0'"
+# Mark every directory in the snapshot read-only, not just the top. Removing
+# write on a directory blocks adding, deleting, or renaming its entries, which is
+# what protects the snapshot from tampering. Only directories are touched, never
+# files: cp -al hard-links files (shared inodes across snapshots), so chmod-ing a
+# file here would change its permissions in every other snapshot too. Directories
+# are created fresh by cp -al, so they're safe to chmod. Best-effort: some remote
+# filesystems (ZFS/ACL) may reject it, which is non-fatal. The rotation restores
+# write with chmod -R u+w before deleting the oldest snapshot.
+run_cmd "find '$BASE_PATH/$TYPE.0' -type d -exec chmod a-w {} +" || log "Warning: could not mark $TYPE.0 read-only"
# ==============================================================================
# COMPLETION
diff --git a/tests/cases/test_backup.sh b/tests/cases/test_backup.sh
index af01221..607942e 100755
--- a/tests/cases/test_backup.sh
+++ b/tests/cases/test_backup.sh
@@ -246,6 +246,56 @@ EOF
}
# ------------------------------------------------------------------------------
+# Test: nested directories in a snapshot are read-only (not just the top dir)
+# ------------------------------------------------------------------------------
+test_snapshot_nested_dirs_readonly() {
+ setup_test_env
+
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
+
+ # A nested directory inside the snapshot (stored under its full path by -R)
+ local nested="$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0$TEST_SOURCE_DIR/home"
+ local perms
+ perms=$(stat -c '%A' "$nested" 2>/dev/null)
+ if [[ "$perms" == *w* ]]; then
+ echo "FAIL: nested snapshot dir should be read-only (perms: $perms)"
+ teardown_test_env
+ return 1
+ fi
+
+ teardown_test_env
+ return 0
+}
+
+# ------------------------------------------------------------------------------
+# Test: status counts snapshots without counting the latest/ working dir
+# ------------------------------------------------------------------------------
+test_status_excludes_latest_from_count() {
+ setup_test_env
+
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ # One backup creates MANUAL.0 plus the latest/ working dir
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" status 2>&1)
+
+ assert_contains "$output" "1 snapshot(s) found" "status should count 1 snapshot, not counting latest/" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
# Run tests
# ------------------------------------------------------------------------------
run_backup_tests() {
@@ -261,6 +311,8 @@ run_backup_tests() {
run_test "backup command works as alias" test_backup_command
run_test "excludes files matching patterns" test_excludes_patterns
run_test "same-basename sources don't collide" test_no_basename_collision
+ run_test "status count excludes latest/" test_status_excludes_latest_from_count
+ run_test "nested snapshot dirs are read-only" test_snapshot_nested_dirs_readonly
}
# Run if executed directly
diff --git a/tests/cases/test_remote.sh b/tests/cases/test_remote.sh
index c2ee75e..d623009 100644
--- a/tests/cases/test_remote.sh
+++ b/tests/cases/test_remote.sh
@@ -38,6 +38,30 @@ EOF
}
# ------------------------------------------------------------------------------
+# An unreachable remote skips gracefully (exit 0), not an error
+# ------------------------------------------------------------------------------
+# Needs no real remote: an unresolvable .invalid host makes the SSH check fail,
+# so this runs unconditionally (not behind the localhost gate).
+test_remote_unreachable_skips_gracefully() {
+ setup_test_env
+ cat > "$TEST_CONFIG_DIR/config" <<EOF
+REMOTE_HOST="rsyncshot-nonexistent.invalid"
+REMOTE_PATH="/tmp/rsyncshot-noremote"
+EOF
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local output rc
+ output=$(INSTALLHOME="$TEST_CONFIG_DIR" LOCKFILE="$TEST_DIR/lock" "$SCRIPT_PATH" manual 1 2>&1)
+ rc=$?
+
+ teardown_test_env
+
+ assert_exit_code 0 "$rc" "unreachable remote should skip gracefully (exit 0)" || return 1
+ assert_contains "$output" "unreachable" "should log the unreachable skip" || return 1
+}
+
+# ------------------------------------------------------------------------------
# Run tests
# ------------------------------------------------------------------------------
run_remote_tests() {
@@ -45,8 +69,10 @@ run_remote_tests() {
echo "Running remote (SSH) tests..."
echo "------------------------------------------------------------"
+ run_test "unreachable remote skips gracefully" test_remote_unreachable_skips_gracefully
+
if ! remote_available; then
- echo " SKIP: root cannot ssh to localhost with key auth — skipping remote tests"
+ echo " SKIP: root cannot ssh to localhost with key auth — skipping live remote tests"
return 0
fi
diff --git a/tests/cases/test_rsync_flags.sh b/tests/cases/test_rsync_flags.sh
index 768b432..691352d 100644
--- a/tests/cases/test_rsync_flags.sh
+++ b/tests/cases/test_rsync_flags.sh
@@ -36,6 +36,32 @@ test_rsync_flags_numeric_ids_and_relative() {
}
# ------------------------------------------------------------------------------
+# A real backup is quiet: no -v (would balloon the log), uses --info=stats1
+# ------------------------------------------------------------------------------
+test_rsync_flags_backup_quiet() {
+ setup_test_env
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local shim
+ shim=$(make_command_shim rsync)
+
+ PATH="$shim:$PATH" INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 \
+ LOCKFILE="$TEST_DIR/lock" "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
+
+ local args
+ args=$(cat "$shim/rsync.args" 2>/dev/null)
+
+ assert_contains "$args" "--info=stats1" "real backup should use --info=stats1" || { rm -rf "$shim"; teardown_test_env; return 1; }
+ assert_contains "$args" "-ahR" "real backup flag should be -ahR (relative, no verbose)" || { rm -rf "$shim"; teardown_test_env; return 1; }
+ assert_not_contains "$args" "-avhR" "real backup should not pass -v" || { rm -rf "$shim"; teardown_test_env; return 1; }
+
+ rm -rf "$shim"
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
# Run tests
# ------------------------------------------------------------------------------
run_rsync_flags_tests() {
@@ -43,7 +69,8 @@ run_rsync_flags_tests() {
echo "Running rsync flag tests..."
echo "------------------------------------------------------------"
- run_test "rsync uses --numeric-ids and -R" test_rsync_flags_numeric_ids_and_relative
+ run_test "rsync uses --numeric-ids and -R (dryrun)" test_rsync_flags_numeric_ids_and_relative
+ run_test "real backup is quiet (--info=stats1, no -v)" test_rsync_flags_backup_quiet
}
# Run if executed directly