From 3ef8c7a0544f372f0d3c63c8055517e0c64347ec Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 25 Jun 2026 09:10:38 -0400 Subject: 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. --- tests/cases/test_backup.sh | 52 +++++++++++++++++++++++++++++++++++++++++ tests/cases/test_remote.sh | 28 +++++++++++++++++++++- tests/cases/test_rsync_flags.sh | 29 ++++++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) (limited to 'tests/cases') 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 @@ -245,6 +245,56 @@ EOF teardown_test_env } +# ------------------------------------------------------------------------------ +# 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 # ------------------------------------------------------------------------------ @@ -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 @@ -37,6 +37,30 @@ EOF teardown_test_env } +# ------------------------------------------------------------------------------ +# 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" </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 # ------------------------------------------------------------------------------ @@ -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 @@ -35,6 +35,32 @@ test_rsync_flags_numeric_ids_and_relative() { teardown_test_env } +# ------------------------------------------------------------------------------ +# 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 # ------------------------------------------------------------------------------ @@ -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 -- cgit v1.2.3