aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xrsyncshot60
-rwxr-xr-xtests/cases/test_cron.sh30
-rw-r--r--tests/cases/test_locking.sh107
-rwxr-xr-xtests/test_rsyncshot.sh1
4 files changed, 189 insertions, 9 deletions
diff --git a/rsyncshot b/rsyncshot
index 492a982..4a6140d 100755
--- a/rsyncshot
+++ b/rsyncshot
@@ -149,11 +149,16 @@ RM="rm"
# ------------------------------------------------------------------------------
# CONCURRENCY CONTROL
# ------------------------------------------------------------------------------
-# Use flock to prevent multiple instances from running simultaneously.
-# This is important when cron might trigger a new run before the previous
-# one completes (e.g., slow network, large backup).
+# rsyncshot acquires an exclusive lock internally (see acquire_lock) before the
+# backup phase, so a slow backup overrunning the next cron tick can't overlap.
+# Cron entries therefore do NOT need an external flock wrapper.
+#
+# LOCKFILE is the lock path. When unset, it's chosen at runtime (below): the
+# first of /run/lock, /var/lock, /tmp that exists and is writable. /run/lock and
+# /var/lock are root-only, which avoids the symlink-truncation risk of a
+# predictable name in world-writable /tmp. Override in the config file if needed.
-FLOCKCHECK="flock -x /tmp/rsyncshot.lock -c"
+LOCKFILE="${LOCKFILE:-}"
# ------------------------------------------------------------------------------
# DEFAULT CRON SCHEDULE
@@ -242,6 +247,18 @@ require_destination_configured()
derive_paths
+# Choose a default lock file if neither the config nor the environment set one.
+# Prefer root-only dirs over world-writable /tmp.
+if [ -z "$LOCKFILE" ]; then
+ for _lockdir in /run/lock /var/lock /tmp; do
+ if [ -d "$_lockdir" ] && [ -w "$_lockdir" ]; then
+ LOCKFILE="$_lockdir/rsyncshot.lock"
+ break
+ fi
+ done
+ : "${LOCKFILE:=/tmp/rsyncshot.lock}"
+fi
+
# ==============================================================================
# UTILITY FUNCTIONS
# ==============================================================================
@@ -314,6 +331,23 @@ log()
}
# ------------------------------------------------------------------------------
+# acquire_lock() - Take an exclusive, non-blocking lock for the backup phase
+# ------------------------------------------------------------------------------
+# Opens LOCKFILE on fd 200 and flock -n's it. The lock is held until the script
+# exits (fd 200 auto-closes), so a second concurrent backup fails fast rather
+# than overlapping. Only the real backup path calls this; help, list, status,
+# and dryrun never lock, so they're never blocked by a running backup.
+
+acquire_lock()
+{
+ exec 200>"$LOCKFILE" || error "cannot open lock file $LOCKFILE"
+ if ! flock -n 200; then
+ log "Another rsyncshot instance is already running (lock held on $LOCKFILE) - exiting."
+ exit 1
+ fi
+}
+
+# ------------------------------------------------------------------------------
# run_cmd() - Execute a command locally or remotely based on mode
# ------------------------------------------------------------------------------
# Arguments:
@@ -738,10 +772,10 @@ EOF
CRONTEMP=$(mktemp)
crontab -l 2>/dev/null | grep -v "rsyncshot" > "$CRONTEMP" || true
{
- echo "# rsyncshot automated backups"
- echo "$CRON_H $FLOCKCHECK '$SCRIPTLOC hourly 22 >> $LOGFILE 2>&1'"
- echo "$CRON_D $FLOCKCHECK '$SCRIPTLOC daily 6 >> $LOGFILE 2>&1'"
- echo "$CRON_W $FLOCKCHECK '$SCRIPTLOC weekly 51 >> $LOGFILE 2>&1'"
+ echo "# rsyncshot automated backups (locks internally; no external wrapper needed)"
+ echo "$CRON_H $SCRIPTLOC hourly 22 >> $LOGFILE 2>&1"
+ echo "$CRON_D $SCRIPTLOC daily 6 >> $LOGFILE 2>&1"
+ echo "$CRON_W $SCRIPTLOC weekly 51 >> $LOGFILE 2>&1"
} >> "$CRONTEMP"
crontab "$CRONTEMP"
$RM "$CRONTEMP"
@@ -807,7 +841,7 @@ if ! command -v rsync &> /dev/null; then
fi
if ! command -v flock &> /dev/null; then
- echo "ERROR: flock is not installed (required for cron job locking)." 1>&2
+ echo "ERROR: flock is not installed (required for concurrency locking)." 1>&2
echo ""
echo "Install flock using your package manager:"
echo " Arch Linux: sudo pacman -S util-linux"
@@ -888,6 +922,14 @@ if [ ! -f "$EXCLUDES" ]; then error "Exclude file $EXCLUDES not found."; fi
# --- Refuse to operate against a filesystem root ---
require_destination_configured
+# --- Acquire exclusive lock before any destructive or mutating work ---
+# Real backups only; dryrun and read-only commands are never blocked. Taken
+# before the mode-specific validation so even the local mount attempt is
+# serialized.
+if [ "$DRYRUN" = false ]; then
+ acquire_lock
+fi
+
# --- Mode-specific destination validation ---
if [ "$MODE" = "remote" ]; then
# Verify SSH identity file exists if specified
diff --git a/tests/cases/test_cron.sh b/tests/cases/test_cron.sh
index 5c9bbfa..7e38060 100755
--- a/tests/cases/test_cron.sh
+++ b/tests/cases/test_cron.sh
@@ -119,6 +119,35 @@ EOF
}
# ------------------------------------------------------------------------------
+# Test: Cron entries no longer wrap rsyncshot in an external flock
+# ------------------------------------------------------------------------------
+# rsyncshot locks internally now, so setup should write bare cron commands.
+test_cron_no_flock_wrapper() {
+ setup_test_env
+ save_crontab
+
+ crontab -r 2>/dev/null || true
+
+ cat > "$TEST_CONFIG_DIR/config" << EOF
+REMOTE_HOST=""
+MOUNTDIR="$TEST_BACKUP_DIR"
+EOF
+ create_test_includes
+ create_test_excludes
+
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true
+
+ local crontab_content
+ crontab_content=$(crontab -l 2>/dev/null)
+
+ restore_crontab
+ teardown_test_env
+
+ assert_not_contains "$crontab_content" "flock" "cron entries should not wrap rsyncshot in flock (internal locking)" || return 1
+ assert_contains "$crontab_content" "rsyncshot" "cron should still contain rsyncshot entries" || return 1
+}
+
+# ------------------------------------------------------------------------------
# Run tests
# ------------------------------------------------------------------------------
run_cron_tests() {
@@ -129,6 +158,7 @@ run_cron_tests() {
run_test "setup adds cron jobs" test_setup_adds_cron_jobs
run_test "repeated setup doesn't duplicate entries" test_no_duplicate_cron_entries
run_test "setup preserves existing cron jobs" test_preserves_existing_cron
+ run_test "cron entries have no flock wrapper" test_cron_no_flock_wrapper
}
# Run if executed directly
diff --git a/tests/cases/test_locking.sh b/tests/cases/test_locking.sh
new file mode 100644
index 0000000..4667d58
--- /dev/null
+++ b/tests/cases/test_locking.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Concurrency Locking Tests
+# ==============================================================================
+# rsyncshot acquires an exclusive lock before the backup phase. These tests hold
+# the lock in the test process (a separate open file description), then run the
+# script with LOCKFILE pointed at the same file, so the script's flock -n fails
+# exactly as a second concurrent backup would.
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# A real backup refuses to start while another holds the lock
+# ------------------------------------------------------------------------------
+test_refuses_when_lock_held() {
+ setup_test_env
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local lock="$TEST_DIR/rsyncshot.lock"
+ exec 9>"$lock"
+ if ! flock -n 9; then
+ echo "FAIL: test could not acquire the lock to set up the scenario"
+ exec 9>&-
+ teardown_test_env
+ return 1
+ fi
+
+ local output rc
+ output=$(INSTALLHOME="$TEST_CONFIG_DIR" LOCKFILE="$lock" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+ rc=$?
+ exec 9>&- # release the lock for later tests
+
+ assert_exit_code 1 "$rc" "should exit 1 when another instance holds the lock" || { teardown_test_env; return 1; }
+ assert_contains "$output" "already running" "should report the lock contention" || { teardown_test_env; return 1; }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# A dryrun is read-only and must NOT be blocked by a held lock
+# ------------------------------------------------------------------------------
+test_dryrun_not_blocked_by_lock() {
+ setup_test_env
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local lock="$TEST_DIR/rsyncshot.lock"
+ exec 9>"$lock"
+ if ! flock -n 9; then
+ echo "FAIL: test could not acquire the lock to set up the scenario"
+ exec 9>&-
+ teardown_test_env
+ return 1
+ fi
+
+ local rc
+ INSTALLHOME="$TEST_CONFIG_DIR" LOCKFILE="$lock" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 >/dev/null 2>&1
+ rc=$?
+ exec 9>&-
+
+ assert_exit_code 0 "$rc" "dryrun should not be blocked by a held lock" || { teardown_test_env; return 1; }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Two sequential real backups both succeed (lock is released on exit)
+# ------------------------------------------------------------------------------
+test_sequential_runs_each_acquire_lock() {
+ setup_test_env
+ create_test_config >/dev/null
+ create_test_includes >/dev/null
+ create_test_excludes >/dev/null
+
+ local lock="$TEST_DIR/rsyncshot.lock"
+ INSTALLHOME="$TEST_CONFIG_DIR" LOCKFILE="$lock" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 2 >/dev/null 2>&1
+ local rc1=$?
+ INSTALLHOME="$TEST_CONFIG_DIR" LOCKFILE="$lock" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 2 >/dev/null 2>&1
+ local rc2=$?
+
+ assert_exit_code 0 "$rc1" "first run should succeed and release the lock" || { teardown_test_env; return 1; }
+ assert_exit_code 0 "$rc2" "second run should acquire the freed lock and succeed" || { teardown_test_env; return 1; }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_locking_tests() {
+ echo ""
+ echo "Running locking tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "refuses to run when the lock is held" test_refuses_when_lock_held
+ run_test "dryrun is not blocked by the lock" test_dryrun_not_blocked_by_lock
+ run_test "sequential runs each acquire the lock" test_sequential_runs_each_acquire_lock
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_locking_tests
+ print_summary
+fi
diff --git a/tests/test_rsyncshot.sh b/tests/test_rsyncshot.sh
index 95a0032..857dd95 100755
--- a/tests/test_rsyncshot.sh
+++ b/tests/test_rsyncshot.sh
@@ -95,6 +95,7 @@ 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"
+run_test_file "$SCRIPT_DIR/cases/test_locking.sh" "locking"
if [ "$QUICK" = false ]; then
run_test_file "$SCRIPT_DIR/cases/test_backup.sh" "backup"