diff options
| -rwxr-xr-x | rsyncshot | 43 | ||||
| -rwxr-xr-x | tests/cases/test_backup.sh | 52 | ||||
| -rw-r--r-- | tests/cases/test_remote.sh | 28 | ||||
| -rw-r--r-- | tests/cases/test_rsync_flags.sh | 29 |
4 files changed, 140 insertions, 12 deletions
@@ -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 |
