aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.shellcheckrc7
-rwxr-xr-xrsyncshot171
-rwxr-xr-xtests/cases/test_backup.sh73
-rwxr-xr-xtests/cases/test_cron.sh8
-rw-r--r--tests/cases/test_functions.sh146
-rw-r--r--tests/cases/test_remote.sh60
-rw-r--r--tests/cases/test_rsync_flags.sh53
-rwxr-xr-xtests/cases/test_validation.sh56
-rwxr-xr-xtests/lib/test_helpers.sh51
-rwxr-xr-xtests/test_rsyncshot.sh4
11 files changed, 558 insertions, 73 deletions
diff --git a/.gitignore b/.gitignore
index dde50ad..9cb6ca1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
.ai/
inbox/
+.claude/
+CLAUDE.md
diff --git a/.shellcheckrc b/.shellcheckrc
new file mode 100644
index 0000000..0ac34f0
--- /dev/null
+++ b/.shellcheckrc
@@ -0,0 +1,7 @@
+# Project shellcheck configuration.
+#
+# Disable the "can't follow sourced file" infos. They are not actionable here:
+# - rsyncshot sources /etc/rsyncshot/config, which doesn't exist at lint time.
+# - the test suite sources lib/test_helpers.sh via a runtime-computed path.
+# Both are correct at runtime; shellcheck simply can't resolve them statically.
+disable=SC1090,SC1091
diff --git a/rsyncshot b/rsyncshot
index 712de21..492a982 100755
--- a/rsyncshot
+++ b/rsyncshot
@@ -131,14 +131,20 @@ INCLUDES="$INSTALLHOME/include.txt" # Directories to back up (one per line)
EXCLUDES="$INSTALLHOME/exclude.txt" # Patterns to exclude (one per line)
# ------------------------------------------------------------------------------
-# COMMAND ALIASES
+# COMMAND ALIASES (resolved per-mode by derive_paths)
# ------------------------------------------------------------------------------
-# Use absolute paths to avoid issues with shell aliases that might add
-# interactive prompts (e.g., alias rm='rm -i'). Only used in local mode.
-
-CP="/usr/bin/cp"
-MV="/usr/bin/mv"
-RM="/usr/bin/rm"
+# These are the cp/mv/rm used by the rotation phase, in BOTH modes. derive_paths
+# sets them appropriately:
+# - Local mode: the real binary path (via command -v), so a shell alias
+# (e.g. alias rm='rm -i') can't inject a prompt, without hardcoding /usr/bin
+# (cp lives in /bin on macOS/BSD).
+# - Remote mode: bare names, resolved by the remote host's PATH (its cp/mv/rm
+# may not live under /usr/bin, e.g. FreeBSD/TrueNAS).
+# The values below are only fallbacks if derive_paths is never called.
+
+CP="cp"
+MV="mv"
+RM="rm"
# ------------------------------------------------------------------------------
# CONCURRENCY CONTROL
@@ -172,25 +178,69 @@ CRON_W="0 12 * * 7 " # Weekly: noon, Sunday
# This allows customization without editing the script.
if [ -f "$CONFIGFILE" ]; then
- # shellcheck source=/etc/rsyncshot/config
+ # shellcheck source=/dev/null
source "$CONFIGFILE"
fi
# ------------------------------------------------------------------------------
# DERIVED PATHS (auto-configured based on mode)
# ------------------------------------------------------------------------------
-# These are calculated from the settings above. Do not modify directly.
+# derive_paths() computes MODE, DESTINATION, REMOTE_DEST and the cp/mv/rm command
+# names from the settings above. It is a function (rather than inline) so tests
+# can source the script with RSYNCSHOT_SOURCE_ONLY=1 and re-derive paths under
+# different settings without running the main flow.
-if [ -n "$REMOTE_HOST" ]; then
- # Remote mode: backup destination is on a remote server via SSH
- REMOTE_DEST="$REMOTE_PATH/$HOSTNAME" # Full path on remote server
- DESTINATION="$REMOTE_HOST:$REMOTE_DEST" # rsync-compatible remote path
- MODE="remote"
-else
- # Local mode: backup destination is a locally mounted filesystem
- DESTINATION="$MOUNTDIR/$HOSTNAME" # Local backup path
- MODE="local"
-fi
+derive_paths()
+{
+ if [ -n "$REMOTE_HOST" ]; then
+ # Remote mode: backup destination is on a remote server via SSH
+ REMOTE_DEST="$REMOTE_PATH/$HOSTNAME" # Full path on remote server
+ DESTINATION="$REMOTE_HOST:$REMOTE_DEST" # rsync-compatible remote path
+ MODE="remote"
+ CP="cp"; MV="mv"; RM="rm" # resolved by remote PATH
+ else
+ # Local mode: backup destination is a locally mounted filesystem
+ DESTINATION="$MOUNTDIR/$HOSTNAME" # Local backup path
+ MODE="local"
+ CP=$(command -v cp || echo cp)
+ MV=$(command -v mv || echo mv)
+ RM=$(command -v rm || echo rm)
+ fi
+}
+
+# is_mounted TARGET [MOUNTS_FILE] — true if TARGET is an exact mount point.
+# Reads /proc/mounts by default; pass a fixture file as the 2nd arg for tests.
+# Matches field 2 exactly (spaces are encoded as \040 in /proc/mounts) so
+# /media/backup does NOT match /media/backup2 and regex metacharacters in the
+# path stay literal.
+is_mounted()
+{
+ local target="$1"
+ local mounts="${2:-/proc/mounts}"
+ [ -r "$mounts" ] || return 1
+ # Encode spaces as \040 to match /proc/mounts' field 2. Pass via the
+ # environment (ENVIRON) rather than awk -v, because -v processes backslash
+ # escapes and would turn \040 back into a literal space before comparison.
+ local target_esc=${target// /\\040}
+ RSYNCSHOT_MNT="$target_esc" awk '$2 == ENVIRON["RSYNCSHOT_MNT"] { found = 1 } END { exit(found ? 0 : 1) }' "$mounts"
+}
+
+# require_destination_configured — abort before any write if the destination
+# would resolve to a filesystem root. A blank REMOTE_PATH/MOUNTDIR otherwise
+# makes DESTINATION "/$HOSTNAME" (remote or local root), which the rotation
+# phase would then rm -rf / --delete against.
+require_destination_configured()
+{
+ if [ "$MODE" = "remote" ]; then
+ [ -n "$REMOTE_PATH" ] || error "REMOTE_PATH is empty — refusing to use the remote filesystem root as the backup destination."
+ case "$REMOTE_PATH" in /*) ;; *) error "REMOTE_PATH must be an absolute path: $REMOTE_PATH" ;; esac
+ else
+ [ -n "$MOUNTDIR" ] || error "MOUNTDIR is empty — refusing to use the local filesystem root as the backup destination."
+ case "$MOUNTDIR" in /*) ;; *) error "MOUNTDIR must be an absolute path: $MOUNTDIR" ;; esac
+ fi
+}
+
+derive_paths
# ==============================================================================
# UTILITY FUNCTIONS
@@ -279,11 +329,14 @@ run_cmd()
{
if [ "$MODE" = "remote" ]; then
if [ -n "$SSH_IDENTITY_FILE" ]; then
+ # shellcheck disable=SC2029 # command string is meant to expand on the remote shell
ssh -i "$SSH_IDENTITY_FILE" "$REMOTE_HOST" "$@"
else
+ # shellcheck disable=SC2029
ssh "$REMOTE_HOST" "$@"
fi
else
+ # shellcheck disable=SC2294 # eval mirrors the remote shell parsing the same command string
eval "$@"
fi
}
@@ -318,18 +371,18 @@ list_snapshots()
{
# Verify we can access the destination
if [ "$MODE" = "remote" ]; then
- SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=5"
+ SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=5)
if [ -n "$SSH_IDENTITY_FILE" ]; then
- SSH_OPTS="$SSH_OPTS -i $SSH_IDENTITY_FILE"
+ SSH_OPTS+=(-i "$SSH_IDENTITY_FILE")
fi
- if ! ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then
+ if ! ssh "${SSH_OPTS[@]}" "$REMOTE_HOST" "true" 2>/dev/null; then
error "Cannot connect to $REMOTE_HOST via SSH."
fi
else
if [ ! -d "$MOUNTDIR" ]; then
error "$MOUNTDIR doesn't exist."
fi
- if ! grep -qs "$MOUNTDIR" /proc/mounts 2>/dev/null; then
+ if ! is_mounted "$MOUNTDIR"; then
error "$MOUNTDIR is not mounted."
fi
fi
@@ -365,6 +418,10 @@ list_snapshots()
run_cmd "$LIST_CMD"
echo ""
+ echo "Note: sizes are apparent per-snapshot usage. Unchanged files are hard-linked"
+ echo "and shared across snapshots, so these figures overlap and do not sum to the"
+ echo "actual disk used."
+ echo ""
}
# ------------------------------------------------------------------------------
@@ -399,14 +456,14 @@ show_status()
if [ -f "$INCLUDES" ]; then
# Count non-empty, non-comment lines
- INCLUDE_COUNT=$(grep -cv '^[[:space:]]*#\|^[[:space:]]*$' "$INCLUDES" 2>/dev/null || echo 0)
+ INCLUDE_COUNT=$(grep -cv '^[[:space:]]*#\|^[[:space:]]*$' "$INCLUDES" 2>/dev/null)
echo " ✓ Include file: $INCLUDES ($INCLUDE_COUNT directories)"
else
echo " ✗ Include file NOT found: $INCLUDES"
fi
if [ -f "$EXCLUDES" ]; then
- EXCLUDE_COUNT=$(grep -cv '^#\|^$' "$EXCLUDES" 2>/dev/null || echo 0)
+ EXCLUDE_COUNT=$(grep -cv '^#\|^$' "$EXCLUDES" 2>/dev/null)
echo " ✓ Exclude file: $EXCLUDES ($EXCLUDE_COUNT patterns)"
else
echo " ✗ Exclude file NOT found: $EXCLUDES"
@@ -432,11 +489,11 @@ show_status()
fi
# Test SSH connectivity
- SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=5"
+ SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=5)
if [ -n "$SSH_IDENTITY_FILE" ]; then
- SSH_OPTS="$SSH_OPTS -i $SSH_IDENTITY_FILE"
+ SSH_OPTS+=(-i "$SSH_IDENTITY_FILE")
fi
- if ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then
+ if ssh "${SSH_OPTS[@]}" "$REMOTE_HOST" "true" 2>/dev/null; then
echo " ✓ SSH connection: OK"
# Check if backup directory exists
@@ -454,7 +511,7 @@ show_status()
if [ -d "$MOUNTDIR" ]; then
echo " ✓ Mount point exists"
- if grep -qs "$MOUNTDIR" /proc/mounts 2>/dev/null; then
+ if is_mounted "$MOUNTDIR"; then
echo " ✓ Filesystem mounted"
if [ -d "$MOUNTDIR/$HOSTNAME" ]; then
@@ -473,7 +530,7 @@ show_status()
# --- Cron jobs ---
echo ""
echo "Scheduled Backups:"
- CRON_JOBS=$(crontab -l 2>/dev/null | grep -c "rsyncshot" || echo 0)
+ CRON_JOBS=$(crontab -l 2>/dev/null | grep -c "rsyncshot")
if [ "$CRON_JOBS" -gt 0 ]; then
echo " ✓ $CRON_JOBS cron job(s) installed:"
crontab -l 2>/dev/null | grep "rsyncshot" | while read -r line; do
@@ -505,7 +562,8 @@ show_status()
LAST_BACKUP=$(grep "completed successfully" "$LOGFILE" 2>/dev/null | tail -1)
if [ -n "$LAST_BACKUP" ]; then
# Extract timestamp from [YYYY-MM-DD HH:MM:SS] format (portable, no Perl regex)
- LAST_TIME=$(echo "$LAST_BACKUP" | sed 's/.*\[\([^]]*\)\].*/\1/')
+ LAST_TIME=${LAST_BACKUP#*[}
+ LAST_TIME=${LAST_TIME%%]*}
echo " Last successful backup: $LAST_TIME"
fi
else
@@ -516,7 +574,7 @@ show_status()
echo ""
echo "Snapshots:"
if [ "$MODE" = "remote" ]; then
- if ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; 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)
if [ "$SNAP_COUNT" -gt 0 ]; then
echo " $SNAP_COUNT snapshot(s) found (run 'rsyncshot list' for details)"
@@ -528,7 +586,7 @@ show_status()
fi
else
if [ -d "$MOUNTDIR/$HOSTNAME" ]; then
- SNAP_COUNT=$(ls -d "$MOUNTDIR/$HOSTNAME"/*/ 2>/dev/null | wc -l || echo 0)
+ SNAP_COUNT=$(find "$MOUNTDIR/$HOSTNAME" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
if [ "$SNAP_COUNT" -gt 0 ]; then
echo " $SNAP_COUNT snapshot(s) found (run 'rsyncshot list' for details)"
else
@@ -559,15 +617,18 @@ show_status()
setup()
{
+ # --- Refuse to operate against a filesystem root ---
+ require_destination_configured
+
# --- Verify destination is accessible ---
if [ "$MODE" = "remote" ]; then
# Remote mode: test SSH connectivity with key-based auth
echo "Testing SSH connection to $REMOTE_HOST..."
- SSH_TEST_OPTS="-o BatchMode=yes -o ConnectTimeout=5"
+ SSH_TEST_OPTS=(-o BatchMode=yes -o ConnectTimeout=5)
if [ -n "$SSH_IDENTITY_FILE" ]; then
- SSH_TEST_OPTS="$SSH_TEST_OPTS -i $SSH_IDENTITY_FILE"
+ SSH_TEST_OPTS+=(-i "$SSH_IDENTITY_FILE")
fi
- if ! ssh $SSH_TEST_OPTS "$REMOTE_HOST" "echo 'SSH connection successful'" 2>/dev/null; then
+ if ! ssh "${SSH_TEST_OPTS[@]}" "$REMOTE_HOST" "echo 'SSH connection successful'" 2>/dev/null; then
error "Cannot connect to $REMOTE_HOST via SSH. Ensure SSH key auth is configured."
fi
else
@@ -714,6 +775,14 @@ EOF
# MAIN SCRIPT
# ==============================================================================
+# --- Test hook: stop here when sourced for unit testing ---
+# Tests do `RSYNCSHOT_SOURCE_ONLY=1 source ./rsyncshot` to get the functions and
+# derived config without executing the main flow (root check, backup, rotation).
+if [ "${RSYNCSHOT_SOURCE_ONLY:-0}" = "1" ]; then
+ # shellcheck disable=SC2317 # the exit is reachable when run (not sourced)
+ return 0 2>/dev/null || exit 0
+fi
+
# --- Handle help command (before root check, so anyone can view help) ---
TYPE=$(tr '[:lower:]' '[:upper:]' <<< "$1")
if [ "$TYPE" = "HELP" ]; then help; exit; fi
@@ -800,6 +869,7 @@ if [ "$TYPE" = "SETUP" ]; then setup; exit; fi
# --- Validate second argument (retention count) ---
# Must be numeric only (e.g., 24 for 24 hourly snapshots)
if ! [[ $2 =~ ^[0-9]+$ ]]; then error "max snapshots must be a number (e.g., 24)."; fi
+if [ "$2" -lt 1 ]; then error "max snapshots must be at least 1."; fi
MAX=$(($2-1)) # Convert count to max index (e.g., 24 -> 23 for indices 0-23)
# --- Validate include file exists and contains valid directories ---
@@ -815,6 +885,9 @@ done < "$INCLUDES"
# --- Validate exclude file exists ---
if [ ! -f "$EXCLUDES" ]; then error "Exclude file $EXCLUDES not found."; fi
+# --- Refuse to operate against a filesystem root ---
+require_destination_configured
+
# --- Mode-specific destination validation ---
if [ "$MODE" = "remote" ]; then
# Verify SSH identity file exists if specified
@@ -825,11 +898,11 @@ if [ "$MODE" = "remote" ]; then
# Remote mode: verify SSH connectivity
# Uses BatchMode to fail immediately if key auth doesn't work
# Uses ConnectTimeout to fail quickly if host is unreachable
- SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10"
+ SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10)
if [ -n "$SSH_IDENTITY_FILE" ]; then
- SSH_OPTS="$SSH_OPTS -i $SSH_IDENTITY_FILE"
+ SSH_OPTS+=(-i "$SSH_IDENTITY_FILE")
fi
- if ! ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then
+ if ! ssh "${SSH_OPTS[@]}" "$REMOTE_HOST" "true" 2>/dev/null; then
error "Cannot connect to $REMOTE_HOST via SSH."
fi
else
@@ -840,9 +913,9 @@ else
# This supports fstab entries with 'noauto' option
# RSYNCSHOT_SKIP_MOUNT_CHECK can be set for testing purposes
if [ "${RSYNCSHOT_SKIP_MOUNT_CHECK:-}" != "1" ]; then
- if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then
+ if ! is_mounted "$MOUNTDIR"; then
mount "$MOUNTDIR" >> /dev/null 2>&1
- if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then
+ if ! is_mounted "$MOUNTDIR"; then
error "$MOUNTDIR not mounted and mount attempt failed."
fi
fi
@@ -864,6 +937,14 @@ fi
# -a Archive mode (preserves permissions, timestamps, symlinks, etc.)
# -v Verbose output
# -h Human-readable sizes
+# -R Relative: store each source under its full path (e.g.
+# /usr/local/bin -> latest/usr/local/bin). Preserves the absolute
+# path and prevents two sources with the same basename (e.g.
+# /usr/local/bin and /usr/bin) from colliding into latest/bin and
+# clobbering each other via --delete.
+# --numeric-ids Keep numeric uid/gid instead of remapping by name on the
+# destination — preserves /etc and /home ownership on restore even
+# when the remote has a different passwd/group database.
# --times Preserve modification times
# --delete Delete files in destination that don't exist in source
# --delete-excluded Also delete excluded files from destination
@@ -876,7 +957,9 @@ RSYNC_FAILED=false
# Set RSYNC_RSH environment variable if using custom SSH identity
# rsync automatically uses this variable for the remote shell command
if [ "$MODE" = "remote" ] && [ -n "$SSH_IDENTITY_FILE" ]; then
- export RSYNC_RSH="ssh -i $SSH_IDENTITY_FILE"
+ # Single-quote the path so rsync's quote-aware parsing of RSYNC_RSH handles
+ # an identity path containing spaces.
+ export RSYNC_RSH="ssh -i '$SSH_IDENTITY_FILE'"
fi
# Read include file and sync each directory
@@ -888,7 +971,7 @@ while IFS= read -r SOURCE || [ -n "$SOURCE" ]; do
if [ "$DRYRUN" = true ]; then
# Dry run: show what would be transferred
- rsync -avh --times --timeout=600 \
+ rsync -avhR --numeric-ids --times --timeout=600 \
--delete --delete-excluded \
--exclude-from="$EXCLUDES" \
--dry-run \
@@ -896,7 +979,7 @@ while IFS= read -r SOURCE || [ -n "$SOURCE" ]; do
RSYNC_EXIT=$?
else
# Actual backup
- rsync -avh --times --timeout=600 \
+ rsync -avhR --numeric-ids --times --timeout=600 \
--delete --delete-excluded \
--exclude-from="$EXCLUDES" \
"$SOURCE" "$DESTINATION"/latest
diff --git a/tests/cases/test_backup.sh b/tests/cases/test_backup.sh
index 1ab4fbd..af01221 100755
--- a/tests/cases/test_backup.sh
+++ b/tests/cases/test_backup.sh
@@ -15,9 +15,7 @@ test_creates_backup_structure() {
create_test_includes
create_test_excludes
- local output
- output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
- local exit_code=$?
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
# Check directory structure
assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME" "should create hostname dir" || {
@@ -46,15 +44,14 @@ test_copies_files() {
create_test_includes
create_test_excludes
- local output
- output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
# Check files were copied
- assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should copy file1.txt" || {
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/file1.txt" "should copy file1.txt" || {
teardown_test_env
return 1
}
- assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/etc/test.conf" "should copy test.conf" || {
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/etc/test.conf" "should copy test.conf" || {
teardown_test_env
return 1
}
@@ -72,8 +69,7 @@ test_snapshot_readonly() {
create_test_includes
create_test_excludes
- local output
- output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
# Check snapshot directory has no write permission
# Note: We use stat to check actual permissions because -w always returns true for root
@@ -100,8 +96,8 @@ test_rotation() {
create_test_excludes
# Run backup twice
- sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null
- sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 >/dev/null 2>&1
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 >/dev/null 2>&1
# Should have MANUAL.0 and MANUAL.1
assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || {
@@ -127,8 +123,8 @@ test_retention_limit() {
create_test_excludes
# Run backup 4 times with retention of 3
- for i in 1 2 3 4; do
- sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null
+ for _ in 1 2 3 4; do
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 >/dev/null 2>&1
done
# Should have MANUAL.0, MANUAL.1, MANUAL.2 but NOT MANUAL.3
@@ -162,9 +158,7 @@ test_backup_command() {
create_test_includes
create_test_excludes
- local output
- output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" backup 2>&1)
- local exit_code=$?
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" backup >/dev/null 2>&1
# Should create MANUAL.0 (backup is alias for manual 1)
assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "backup should create MANUAL.0" || {
@@ -194,20 +188,56 @@ EOF
echo "temp" > "$TEST_SOURCE_DIR/home/testuser/temp.tmp"
echo "log" > "$TEST_SOURCE_DIR/home/testuser/app.log"
- local output
- output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
# Excluded files should not exist in backup
- assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/temp.tmp" "should exclude .tmp files" || {
+ assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/temp.tmp" "should exclude .tmp files" || {
teardown_test_env
return 1
}
- assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/app.log" "should exclude .log files" || {
+ assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/app.log" "should exclude .log files" || {
teardown_test_env
return 1
}
# Regular files should still exist
- assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should include regular files" || {
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/file1.txt" "should include regular files" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Same-basename sources don't collide (relative paths)
+# ------------------------------------------------------------------------------
+# Two includes with the same basename ("bin") previously both mapped to
+# latest/bin, and the second sync's --delete wiped the first. With -R each is
+# stored under its full path, so both survive.
+test_no_basename_collision() {
+ setup_test_env
+
+ mkdir -p "$TEST_SOURCE_DIR/opt/bin" "$TEST_SOURCE_DIR/usr/bin"
+ echo "from-opt" > "$TEST_SOURCE_DIR/opt/bin/optfile"
+ echo "from-usr" > "$TEST_SOURCE_DIR/usr/bin/usrfile"
+
+ cat > "$TEST_CONFIG_DIR/config" <<EOF
+REMOTE_HOST=""
+MOUNTDIR="$TEST_BACKUP_DIR"
+EOF
+ cat > "$TEST_CONFIG_DIR/include.txt" <<EOF
+$TEST_SOURCE_DIR/opt/bin
+$TEST_SOURCE_DIR/usr/bin
+EOF
+ create_test_excludes >/dev/null
+
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1
+
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/opt/bin/optfile" "opt/bin should survive" || {
+ teardown_test_env
+ return 1
+ }
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/usr/bin/usrfile" "usr/bin should survive (no collision)" || {
teardown_test_env
return 1
}
@@ -230,6 +260,7 @@ run_backup_tests() {
run_test "respects retention limit" test_retention_limit
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 if executed directly
diff --git a/tests/cases/test_cron.sh b/tests/cases/test_cron.sh
index 2fdb6f5..5c9bbfa 100755
--- a/tests/cases/test_cron.sh
+++ b/tests/cases/test_cron.sh
@@ -39,7 +39,7 @@ EOF
create_test_excludes
# Run setup (will fail on some checks but should still add cron)
- sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true
# Check crontab contains rsyncshot entries
local crontab_content
@@ -72,8 +72,8 @@ EOF
create_test_excludes
# Run setup twice
- sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
- sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true
# Count rsyncshot entries
local crontab_content hourly_count
@@ -105,7 +105,7 @@ EOF
create_test_excludes
# Run setup
- sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true
# Check custom job still exists
local crontab_content
diff --git a/tests/cases/test_functions.sh b/tests/cases/test_functions.sh
new file mode 100644
index 0000000..cd607ab
--- /dev/null
+++ b/tests/cases/test_functions.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Unit Tests for Internal Functions (is_mounted, derive_paths)
+# ==============================================================================
+# These source the script with RSYNCSHOT_SOURCE_ONLY=1 so the functions are
+# defined without running the main flow, then exercise them directly. Each test
+# runs the source + assertions in a subshell so the derived globals and config
+# variables don't leak into other suites.
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# is_mounted: exact mount point matches
+# ------------------------------------------------------------------------------
+test_is_mounted_exact_match() {
+ setup_test_env
+ local mounts="$TEST_DIR/mounts"
+ cat > "$mounts" <<EOF
+/dev/sda1 /media/backup ext4 rw,relatime 0 0
+/dev/sdb1 /home ext4 rw,relatime 0 0
+EOF
+ (
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH"
+ is_mounted "/media/backup" "$mounts"
+ )
+ local rc=$?
+ teardown_test_env
+ assert_exit_code 0 "$rc" "exact mount point should match" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# is_mounted: a prefix is NOT a match (the substring-grep bug)
+# ------------------------------------------------------------------------------
+test_is_mounted_no_substring_match() {
+ setup_test_env
+ local mounts="$TEST_DIR/mounts"
+ cat > "$mounts" <<EOF
+/dev/sda1 /media/backup2 ext4 rw,relatime 0 0
+EOF
+ (
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH"
+ is_mounted "/media/backup" "$mounts"
+ )
+ local rc=$?
+ teardown_test_env
+ assert_exit_code 1 "$rc" "/media/backup must NOT match /media/backup2" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# is_mounted: mount points with spaces (encoded as \040 in /proc/mounts)
+# ------------------------------------------------------------------------------
+test_is_mounted_handles_spaces() {
+ setup_test_env
+ local mounts="$TEST_DIR/mounts"
+ printf '/dev/sda1 /media/my\\040backup ext4 rw 0 0\n' > "$mounts"
+ (
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH"
+ is_mounted "/media/my backup" "$mounts"
+ )
+ local rc=$?
+ teardown_test_env
+ assert_exit_code 0 "$rc" "mount point with spaces should match" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# is_mounted: target is treated literally, not as a regex
+# ------------------------------------------------------------------------------
+test_is_mounted_target_is_literal() {
+ setup_test_env
+ local mounts="$TEST_DIR/mounts"
+ cat > "$mounts" <<EOF
+/dev/sda1 /media/backup ext4 rw 0 0
+EOF
+ (
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH"
+ is_mounted "/media/b.ckup" "$mounts"
+ )
+ local rc=$?
+ teardown_test_env
+ assert_exit_code 1 "$rc" "regex metacharacters in target must be literal" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# derive_paths: remote mode uses bare cp/mv/rm (resolved by remote PATH)
+# ------------------------------------------------------------------------------
+test_derive_paths_remote() {
+ setup_test_env
+ cat > "$TEST_CONFIG_DIR/config" <<EOF
+REMOTE_HOST="truenas"
+REMOTE_PATH="/mnt/vault/Backups"
+EOF
+ (
+ # The script sources the config and runs derive_paths during load.
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH"
+ assert_equals "remote" "$MODE" "should be remote mode" &&
+ assert_equals "truenas:/mnt/vault/Backups/$HOSTNAME" "$DESTINATION" "remote destination" &&
+ assert_equals "cp" "$CP" "remote cp is bare (remote PATH resolves it)" &&
+ assert_equals "mv" "$MV" "remote mv is bare" &&
+ assert_equals "rm" "$RM" "remote rm is bare"
+ )
+ local rc=$?
+ teardown_test_env
+ return $rc
+}
+
+# ------------------------------------------------------------------------------
+# derive_paths: local mode resolves cp/mv/rm to real absolute binaries
+# ------------------------------------------------------------------------------
+test_derive_paths_local() {
+ setup_test_env
+ cat > "$TEST_CONFIG_DIR/config" <<EOF
+REMOTE_HOST=""
+MOUNTDIR="/media/backup"
+EOF
+ (
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH"
+ assert_equals "local" "$MODE" "should be local mode" &&
+ assert_equals "/media/backup/$HOSTNAME" "$DESTINATION" "local destination" &&
+ assert_contains "$CP" "/" "local cp resolves to an absolute path, not a bare name"
+ )
+ local rc=$?
+ teardown_test_env
+ return $rc
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_functions_tests() {
+ echo ""
+ echo "Running function unit tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "is_mounted exact match" test_is_mounted_exact_match
+ run_test "is_mounted rejects prefix (no substring match)" test_is_mounted_no_substring_match
+ run_test "is_mounted handles spaces" test_is_mounted_handles_spaces
+ run_test "is_mounted treats target literally" test_is_mounted_target_is_literal
+ run_test "derive_paths remote mode" test_derive_paths_remote
+ run_test "derive_paths local mode" test_derive_paths_local
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_functions_tests
+ print_summary
+fi
diff --git a/tests/cases/test_remote.sh b/tests/cases/test_remote.sh
new file mode 100644
index 0000000..c2ee75e
--- /dev/null
+++ b/tests/cases/test_remote.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Remote (SSH) Mode Tests
+# ==============================================================================
+# First coverage of the remote path — backup + rotation run over SSH. The whole
+# transfer and rotation happen via ssh to localhost, which exercises run_cmd's
+# remote branch and the bare cp/mv/rm rotation commands. Gated: if root can't
+# ssh to localhost with key auth, the suite skips rather than fails, so machines
+# without loopback SSH still pass.
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+remote_available() {
+ ssh -o BatchMode=yes -o ConnectTimeout=5 localhost true 2>/dev/null
+}
+
+# ------------------------------------------------------------------------------
+# Backup + rotation over SSH-to-localhost
+# ------------------------------------------------------------------------------
+test_remote_backup_and_rotation() {
+ setup_test_env
+ cat > "$TEST_CONFIG_DIR/config" <<EOF
+REMOTE_HOST="localhost"
+REMOTE_PATH="$TEST_BACKUP_DIR"
+EOF
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ # Two runs to exercise rotation over SSH (MANUAL.0 rotates to MANUAL.1)
+ INSTALLHOME="$TEST_CONFIG_DIR" "$SCRIPT_PATH" manual 2 >/dev/null 2>&1
+ INSTALLHOME="$TEST_CONFIG_DIR" "$SCRIPT_PATH" manual 2 >/dev/null 2>&1
+
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "remote backup should create MANUAL.0" || { teardown_test_env; return 1; }
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "remote rotation should produce MANUAL.1" || { teardown_test_env; return 1; }
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/file1.txt" "remote backup should copy files" || { teardown_test_env; return 1; }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_remote_tests() {
+ echo ""
+ echo "Running remote (SSH) tests..."
+ echo "------------------------------------------------------------"
+
+ if ! remote_available; then
+ echo " SKIP: root cannot ssh to localhost with key auth — skipping remote tests"
+ return 0
+ fi
+
+ run_test "remote backup + rotation over SSH" test_remote_backup_and_rotation
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_remote_tests
+ print_summary
+fi
diff --git a/tests/cases/test_rsync_flags.sh b/tests/cases/test_rsync_flags.sh
new file mode 100644
index 0000000..768b432
--- /dev/null
+++ b/tests/cases/test_rsync_flags.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# rsync Invocation Flag Tests
+# ==============================================================================
+# Uses the command-capture shim to intercept rsync and assert on the flags the
+# script passes, without performing a real transfer. Runs a dryrun so only rsync
+# is invoked (no rotation, no cp/mv/rm).
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# rsync is called with --numeric-ids (ownership fidelity) and -R (relative paths)
+# ------------------------------------------------------------------------------
+test_rsync_flags_numeric_ids_and_relative() {
+ 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 \
+ "$SCRIPT_PATH" dryrun manual 1 >/dev/null 2>&1
+
+ local args
+ args=$(cat "$shim/rsync.args" 2>/dev/null)
+
+ assert_file_exists "$shim/rsync.args" "rsync shim should have been invoked" || { rm -rf "$shim"; teardown_test_env; return 1; }
+ assert_contains "$args" "--numeric-ids" "rsync should be called with --numeric-ids" || { rm -rf "$shim"; teardown_test_env; return 1; }
+ assert_contains "$args" "-avhR" "rsync should be called with -R (relative paths, in -avhR)" || { rm -rf "$shim"; teardown_test_env; return 1; }
+ assert_contains "$args" "--dry-run" "dryrun should pass --dry-run" || { rm -rf "$shim"; teardown_test_env; return 1; }
+
+ rm -rf "$shim"
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_rsync_flags_tests() {
+ echo ""
+ echo "Running rsync flag tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "rsync uses --numeric-ids and -R" test_rsync_flags_numeric_ids_and_relative
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_rsync_flags_tests
+ print_summary
+fi
diff --git a/tests/cases/test_validation.sh b/tests/cases/test_validation.sh
index 03da676..9a89787 100755
--- a/tests/cases/test_validation.sh
+++ b/tests/cases/test_validation.sh
@@ -80,6 +80,59 @@ test_accepts_valid_snapshot_types() {
}
# ------------------------------------------------------------------------------
+# Test: Rejects a retention count of zero
+# ------------------------------------------------------------------------------
+test_rejects_zero_count() {
+ local output
+ output=$(sudo "$SCRIPT_PATH" "manual" "0" 2>&1)
+ local exit_code=$?
+
+ assert_exit_code 1 "$exit_code" "should reject count 0" || return 1
+ assert_contains "$output" "at least 1" "should explain minimum count" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Refuses an empty MOUNTDIR (would resolve to the filesystem root)
+# ------------------------------------------------------------------------------
+test_rejects_empty_mountdir() {
+ setup_test_env
+ cat > "$TEST_CONFIG_DIR/config" <<EOF
+REMOTE_HOST=""
+MOUNTDIR=""
+EOF
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+ local exit_code=$?
+
+ teardown_test_env
+
+ assert_exit_code 1 "$exit_code" "empty MOUNTDIR should be rejected" || return 1
+ assert_contains "$output" "MOUNTDIR is empty" "should explain the empty-MOUNTDIR refusal" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: status produces no stderr noise (the grep "0\n0" integer-test bug)
+# ------------------------------------------------------------------------------
+test_status_no_stderr_noise() {
+ setup_test_env
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local stderr_file="$TEST_DIR/status.stderr"
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" status >/dev/null 2>"$stderr_file"
+ local err
+ err=$(cat "$stderr_file")
+
+ teardown_test_env
+
+ assert_no_stderr "$err" "status should emit no stderr (no 'integer expression' noise)" || return 1
+}
+
+# ------------------------------------------------------------------------------
# Run tests
# ------------------------------------------------------------------------------
run_validation_tests() {
@@ -95,6 +148,9 @@ run_validation_tests() {
run_test "rejects alphabetic retention count" test_rejects_alpha_retention_count
run_test "rejects mixed retention count" test_rejects_mixed_retention_count
run_test "accepts valid snapshot types" test_accepts_valid_snapshot_types
+ run_test "rejects retention count of zero" test_rejects_zero_count
+ run_test "refuses empty MOUNTDIR" test_rejects_empty_mountdir
+ run_test "status emits no stderr noise" test_status_no_stderr_noise
teardown_test_env
}
diff --git a/tests/lib/test_helpers.sh b/tests/lib/test_helpers.sh
index 726d788..d58895b 100755
--- a/tests/lib/test_helpers.sh
+++ b/tests/lib/test_helpers.sh
@@ -6,6 +6,7 @@
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
+# shellcheck disable=SC2034 # reserved for future use, kept alongside RED/GREEN
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
@@ -131,9 +132,9 @@ assert_exit_code() {
assert_contains() {
local haystack="$1"
local needle="$2"
- local message="${3:-Output should contain '$needle'}"
+ local message="${3:-Output should contain: $needle}"
- if echo "$haystack" | grep -q "$needle"; then
+ if echo "$haystack" | grep -qF -- "$needle"; then
return 0
else
echo -e "${RED}FAIL${NC}: $message"
@@ -146,9 +147,9 @@ assert_contains() {
assert_not_contains() {
local haystack="$1"
local needle="$2"
- local message="${3:-Output should not contain '$needle'}"
+ local message="${3:-Output should not contain: $needle}"
- if ! echo "$haystack" | grep -q "$needle"; then
+ if ! echo "$haystack" | grep -qF -- "$needle"; then
return 0
else
echo -e "${RED}FAIL${NC}: $message"
@@ -206,6 +207,48 @@ assert_dir_not_exists() {
fi
}
+assert_no_stderr() {
+ local stderr_content="$1"
+ local message="${2:-Should produce no stderr}"
+
+ if [ -z "$stderr_content" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ echo " stderr was: $stderr_content"
+ return 1
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Command-capture shim
+# ------------------------------------------------------------------------------
+# make_command_shim CMD [CMD...] — create a directory of fake commands that
+# record their argv (one argument per line, "---" between invocations) to
+# <dir>/<cmd>.args and exit 0. Put the dir at the front of PATH to intercept
+# those commands and assert on HOW they were called, without real I/O, network,
+# or transfers. Echoes the shim directory path; the caller removes it.
+#
+# Usage:
+# shim=$(make_command_shim rsync)
+# PATH="$shim:$PATH" "$SCRIPT_PATH" dryrun manual 1
+# assert_contains "$(cat "$shim/rsync.args")" "--numeric-ids" ...
+# rm -rf "$shim"
+make_command_shim() {
+ local shim_dir
+ shim_dir=$(mktemp -d)
+ local cmd
+ for cmd in "$@"; do
+ cat > "$shim_dir/$cmd" <<SHIM
+#!/usr/bin/env bash
+{ printf '%s\n' "\$@"; echo "---"; } >> "$shim_dir/${cmd}.args"
+exit 0
+SHIM
+ chmod +x "$shim_dir/$cmd"
+ done
+ echo "$shim_dir"
+}
+
# ------------------------------------------------------------------------------
# Test Runner
# ------------------------------------------------------------------------------
diff --git a/tests/test_rsyncshot.sh b/tests/test_rsyncshot.sh
index 15f2a99..95a0032 100755
--- a/tests/test_rsyncshot.sh
+++ b/tests/test_rsyncshot.sh
@@ -22,6 +22,7 @@ QUICK=false
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
+ # shellcheck disable=SC2034 # reserved flag; verbose output not yet wired
VERBOSE=true
shift
;;
@@ -90,12 +91,15 @@ run_test_file() {
# Run test suites
run_test_file "$SCRIPT_DIR/cases/test_validation.sh" "validation"
+run_test_file "$SCRIPT_DIR/cases/test_functions.sh" "functions"
run_test_file "$SCRIPT_DIR/cases/test_includes.sh" "includes"
run_test_file "$SCRIPT_DIR/cases/test_dryrun.sh" "dryrun"
+run_test_file "$SCRIPT_DIR/cases/test_rsync_flags.sh" "rsync_flags"
if [ "$QUICK" = false ]; then
run_test_file "$SCRIPT_DIR/cases/test_backup.sh" "backup"
run_test_file "$SCRIPT_DIR/cases/test_cron.sh" "cron"
+ run_test_file "$SCRIPT_DIR/cases/test_remote.sh" "remote"
else
echo ""
echo "Skipping slow tests (backup, cron) - use without --quick to run all"