diff --git a/Rsync/rsync.sh b/Rsync/rsync.sh index eb240d4..9412e06 100755 --- a/Rsync/rsync.sh +++ b/Rsync/rsync.sh @@ -41,9 +41,59 @@ # using version-stable field names. # # ============================================================================================== +# DESIGN PRINCIPLES +# ============================================================================================== +# +# Deletion Is Opt-In, Never Inherited +# DEFAULT_RSYNC_OPTS deliberately omits --delete. Media shares only ever gain files +# here; the arr cleanup scripts own deletion and are the only things that understand +# whether a file is genuinely orphaned. --delete appears in exactly two places: a +# profile that sets it explicitly, and the push pass of --merge-run. +# +# Profiles Replace, Not Extend +# PROFILE_RSYNC_OPTS does not inherit DEFAULT_RSYNC_OPTS. A profile states its full +# flag set, so reading one profile tells you exactly what will run — no tracing +# through a base list to discover an inherited --delete. +# +# Local Is Authoritative +# In --merge-run the remote may contribute content the local lacks, but never a +# competing version. Pass 1 pulls only with --ignore-existing, so the push in pass 2 +# can safely be authoritative. Reversing that order would let the remote overwrite +# local files before the delete pass. +# +# Pre-flight Before Payload +# Connectivity, version parity, remote rootfs, remote share, remote disks and drive +# temperature are all checked before a byte moves. A transfer aborted halfway is more +# expensive to reason about than one that never started. +# +# Bounded, Resumable Transfers +# No single attempt may exceed RSYNC_MAX_RUNTIME_HOURS. This is safe only because +# --partial is in the default opts: a terminated transfer resumes rather than +# restarting, so bounding it costs nothing and prevents one huge or stuck transfer +# from holding its lock indefinitely and starving every other profile of a turn. +# +# Silent on Success +# Only failures produce visible output. A quiet run is a successful one. +# +# ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # +# Root Enforcement +# rsync over SSH as root and container stop/start both require it. +# +# Docker Presence Check +# Verified before any profile container operations. +# +# Source Path Depth Guard +# The source must be an absolute path at least three levels deep. It is pushed to +# root@remote at the same absolute path and --merge-run adds --delete, so a truncated +# argument is a remote-side hazard: /mnt/user would sync every share at once. +# +# Source Existence Check +# The local directory must exist. Without this, --merge-run's pull pass would create +# a mistyped directory, populate it from the remote, then push it back with --delete. +# # Global Rsync Gate # check_rsync_enabled() — RSYNC_ENABLED=false exits cleanly before any operation. # @@ -72,8 +122,23 @@ # acquire_rsync_lock() — per-profile lock prevents parallel runs of the same profile. # Global concurrent limit prevents too many simultaneous rsync processes. # -# Notification Validated -# platform_require_cmd confirms the notify script is present before use. +# Runtime Ceiling +# RSYNC_MAX_RUNTIME_HOURS terminates a single attempt that overruns, releasing the +# per-profile lock for the next scheduled run. --partial in the default opts means the +# paused transfer resumes rather than restarting from scratch. +# +# Merge-Run Ordering +# Pass 1 pulls remote-unique content with --ignore-existing before pass 2 pushes with +# --delete. Content the remote had and the local did not is preserved locally before +# anything is deleted remotely. +# +# Merge-Run Delete Interlock +# --delete is applied only when pass 1 completed. Its entire justification is that the +# local is now the authoritative superset, and a capped or failed pull means it is not: +# the remote still holds content the local never received. On an incomplete pull the +# push proceeds without --delete and notifies, so local content still propagates while +# nothing remote-unique is destroyed. The delete happens on a later run whose pull +# succeeded. # # ============================================================================================== # CONFIGURATION @@ -176,6 +241,25 @@ parse_args "${RAW_ARGS[@]}" exit 1 } +# ── Source path guards ──────────────────────────────────────────────────────────────────────── +# This script pushes to root@remote at the same absolute path, and --merge-run adds --delete on +# the push pass. A truncated or mistyped source is therefore a remote-side data hazard, not just +# a local no-op: /mnt/user would sync every share at once, and / would target the filesystem +# root. Every real job path (see HOST*_*_SYNC_SHARES) is at least three levels deep. +_rsync_depth="${DIRECTORY//[^\/]/}" +if [[ "$DIRECTORY" != /* || "${#_rsync_depth}" -lt 3 ]]; then + error "Refusing unsafe source path: '$DIRECTORY' — expected an absolute path at least 3 levels deep" + exit 1 +fi +unset _rsync_depth + +# Without this, --merge-run's pull pass would create a mistyped local directory, fill it with +# remote content, then push it back with --delete. +if [[ ! -d "$DIRECTORY" ]]; then + error "Source directory does not exist locally: $DIRECTORY" + exit 1 +fi + # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== @@ -380,23 +464,39 @@ if [[ "$MERGE_RUN" == true ]]; then _pull_exit=$? _pull_bytes=$(echo "$_pull_output" | \ awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}') + _pull_complete=false if [[ "$_pull_exit" -eq 0 ]]; then echo "$ICON_DONE Merge pass 1 complete — ${_pull_bytes:-0} bytes pulled" + _pull_complete=true elif [[ "$_pull_exit" -eq 124 ]]; then # Same cap as the main push — never let one direction of the merge run unbounded. # Files already completed before the timeout stay pulled; next scheduled run # picks up whatever's still remote-unique (no --partial here, so an in-flight # file at the moment of the kill is discarded, not corrupted). - warn "Merge pass 1 exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap — continuing with push, will resume pull next scheduled run" + warn "Merge pass 1 exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap — pull incomplete" else - warn "Merge pass 1 failed (exit $_pull_exit) — continuing with push" + warn "Merge pass 1 failed (exit $_pull_exit)" echo "$_pull_output" | grep -iE "error|rsync:|permission denied" | while read -r _line; do warn " $_line" done fi - unset _pull_opts _pull_output _pull_exit _pull_bytes - # Pass 2: push with --delete — local is now the authoritative superset - RSYNC_OPTS+=(--delete) + + # Pass 2: push with --delete — but ONLY if pass 1 actually finished. + # --delete is justified solely by "local is now the authoritative superset", and that + # premise holds only on a complete pull. If pass 1 was capped or failed, the remote still + # holds content the local never received; deleting it here would destroy it permanently + # and make the "resume the pull next run" promise impossible to keep. Push without + # --delete instead — local content still propagates, nothing remote-unique is lost, and + # the delete happens on a later run whose pull completed. + if [[ "$_pull_complete" == true ]]; then + RSYNC_OPTS+=(--delete) + else + warn "Skipping --delete this run — pass 1 did not complete, remote may hold content the local lacks" + warn "Local content still pushes; the delete pass runs once a full pull succeeds" + notify "Merge run on $(hostname) pushed without --delete — pass 1 incomplete for $PROFILE_NAME" \ + "Rsync" "warning" + fi + unset _pull_opts _pull_output _pull_exit _pull_bytes _pull_complete fi START=$(date +%s)