feat(13-09): widen same-host deploy guard from containment-only to any mismatch
Adds assert_safe_same_host_deploy(local_src, remote_dst) to lib/common.sh: pure, no SSH inside it, callable directly from a test with fixed inputs. Returns 0 only when the two already-resolved paths are equal; refuses (non-zero, message naming both paths + the 2026-07-31 incident) on any other same-host mismatch. This closes a real gap in the 2026-07-31 incident's original fix: the old guard's two `case` blocks refused only containment (source-in-destination or destination-in-source). A SIBLING directory — for example this very worktree, archy-phase13, deploying onto TARGET_DIR's resolved symlink target (archy, the main checkout) — is neither contained by nor containing of the destination, so the old guard let it through and `rsync --delete` would have mirrored the sibling onto the main checkout, deleting everything the sibling lacks. Found while retargeting deploy-to-target.sh for D-19, not a D-19 effect itself. deploy-to-target.sh's guard block now calls assert_safe_same_host_deploy instead of the two inline containment-only case blocks (old logic removed, not left dead alongside the new call). tests/production-quality/deploy-guard-same-host.sh pins all five <behavior> cases (identical/contained/containing/sibling/unrelated) against the function with no SSH, no rsync, no real deploy — including the exact archy-phase13-vs-archy pair as the sibling-directory regression pin. Manually confirmed non-vacuous: flipping the sibling fixture's expectation to "allow" makes the test fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
073bf6f33d
commit
c3bffbd557
@@ -576,26 +576,17 @@ section_start
|
||||
# worktree destroyed mid-run, a running dev server killed, and two concurrent
|
||||
# sessions' uncommitted work lost permanently.
|
||||
#
|
||||
# Refuse the deploy when source and destination are on the same host AND either
|
||||
# path contains the other. Identical paths are allowed (the normal local case).
|
||||
# Refuse the deploy when source and destination are on the same host and
|
||||
# their resolved paths differ AT ALL — not just when one contains the
|
||||
# other. See assert_safe_same_host_deploy in lib/common.sh for the full
|
||||
# incident history and the sibling-directory gap this widening closes.
|
||||
_LOCAL_SRC="$(readlink -f "$PROJECT_DIR")"
|
||||
_REMOTE_ID="$(ssh $SSH_OPTS "$TARGET_HOST" 'cat /etc/machine-id 2>/dev/null' 2>/dev/null || true)"
|
||||
_LOCAL_ID="$(cat /etc/machine-id 2>/dev/null || true)"
|
||||
if [ -n "$_REMOTE_ID" ] && [ "$_REMOTE_ID" = "$_LOCAL_ID" ]; then
|
||||
_REMOTE_DST="$(ssh $SSH_OPTS "$TARGET_HOST" "readlink -f '$TARGET_DIR'" 2>/dev/null || true)"
|
||||
if [ -n "$_REMOTE_DST" ] && [ "$_REMOTE_DST" != "$_LOCAL_SRC" ]; then
|
||||
case "$_LOCAL_SRC/" in
|
||||
"$_REMOTE_DST"/*)
|
||||
echo "FATAL: refusing to deploy. Source '$_LOCAL_SRC' is INSIDE the deploy destination '$_REMOTE_DST' on this same host." >&2
|
||||
echo " 'rsync --delete' would delete the destination's contents (including this source) — the 2026-07-31 data-loss incident." >&2
|
||||
echo " Run this script from the main checkout ($_REMOTE_DST), not from a worktree or copy." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
case "$_REMOTE_DST/" in
|
||||
"$_LOCAL_SRC"/*)
|
||||
echo "FATAL: refusing to deploy. Destination '$_REMOTE_DST' is INSIDE the source '$_LOCAL_SRC' on this same host." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
if [ -n "$_REMOTE_DST" ]; then
|
||||
assert_safe_same_host_deploy "$_LOCAL_SRC" "$_REMOTE_DST" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -157,6 +157,48 @@ wait_for_container() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Same-host deploy safety guard (2026-07-31 incident, widened 13-09) ──
|
||||
|
||||
# Refuse a same-host deploy whose resolved source and destination differ.
|
||||
#
|
||||
# The 2026-07-31 incident: a same-host `rsync --delete` deploy whose source
|
||||
# was INSIDE the destination (a worktree under the main checkout) mirrored
|
||||
# the worktree onto the main checkout and deleted ~1810 tracked files, a
|
||||
# running dev server, and two sessions' uncommitted work. The original fix
|
||||
# refused only that containment shape (source-in-destination or
|
||||
# destination-in-source). It missed SIBLING directories that share a parent
|
||||
# but neither contains the other — e.g. archy-phase13 (this worktree) as
|
||||
# source and archy (the main checkout, TARGET_DIR's resolved symlink
|
||||
# target) as destination — which is the identical rsync --delete hazard
|
||||
# through a shape the old two-case guard let through.
|
||||
#
|
||||
# This function takes two ALREADY-RESOLVED (`readlink -f`) absolute paths
|
||||
# and makes no SSH calls itself — same-host detection stays in the caller
|
||||
# (deploy-to-target.sh already does it via /etc/machine-id). It returns 0
|
||||
# only when the two paths are equal; every other case is refused, not just
|
||||
# the two containment shapes. Any resolved-path mismatch on the same host
|
||||
# is the same rsync --delete hazard regardless of shape, so "refuse unless
|
||||
# equal" is both the widening and a simplification.
|
||||
#
|
||||
# Usage: assert_safe_same_host_deploy <local_src> <remote_dst>
|
||||
assert_safe_same_host_deploy() {
|
||||
local local_src="$1" remote_dst="$2"
|
||||
|
||||
if [ -z "$local_src" ] || [ -z "$remote_dst" ]; then
|
||||
log_error "assert_safe_same_host_deploy: both paths are required (local_src='$local_src' remote_dst='$remote_dst')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$local_src" = "$remote_dst" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "FATAL: refusing to deploy. Source '$local_src' and destination '$remote_dst' are on the same host but resolve to DIFFERENT paths." >&2
|
||||
echo " 'rsync --delete' would mirror one onto the other and delete whatever the source lacks — the 2026-07-31 data-loss incident (and the sibling-directory shape its original fix missed)." >&2
|
||||
echo " Run this script from the deploy destination itself ('$remote_dst'), not from a worktree, sibling checkout, or copy." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Section timing ─────────────────────────────────────────────────────
|
||||
|
||||
# Track elapsed time for deploy sections.
|
||||
|
||||
Reference in New Issue
Block a user