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:
archipelago
2026-08-04 04:19:49 -04:00
co-authored by Claude Opus 5
parent 073bf6f33d
commit c3bffbd557
3 changed files with 152 additions and 15 deletions
+6 -15
View File
@@ -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