diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | .shellcheckrc | 7 | ||||
| -rwxr-xr-x | rsyncshot | 171 | ||||
| -rwxr-xr-x | tests/cases/test_backup.sh | 73 | ||||
| -rwxr-xr-x | tests/cases/test_cron.sh | 8 | ||||
| -rw-r--r-- | tests/cases/test_functions.sh | 146 | ||||
| -rw-r--r-- | tests/cases/test_remote.sh | 60 | ||||
| -rw-r--r-- | tests/cases/test_rsync_flags.sh | 53 | ||||
| -rwxr-xr-x | tests/cases/test_validation.sh | 56 | ||||
| -rwxr-xr-x | tests/lib/test_helpers.sh | 51 | ||||
| -rwxr-xr-x | tests/test_rsyncshot.sh | 4 |
11 files changed, 558 insertions, 73 deletions
@@ -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 @@ -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" |
