From cf7a603ee54d533c022ad551a6292cfe30a545e2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 31 Jul 2026 07:43:35 -0400 Subject: [PATCH] fix(deploy): refuse deploy when rsync destination overlaps the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy-to-target.sh rsyncs with --delete to TARGET_DIR=/home/archipelago/archy, which is a symlink to /home/archipelago/Projects/archy. archi-dev-box is this same machine over loopback SSH, so deploying from the main checkout is a no-op (source and destination resolve identically) — but deploying from a git worktree nested under it mirrored that worktree onto the main checkout and deleted everything else: ~1810 tracked files, the deploying worktree itself mid-run, a running dev server, and two concurrent sessions' uncommitted work. Guard compares /etc/machine-id across the SSH hop and, when source and destination are on the same host, refuses if either path contains the other. Identical paths still pass, so normal local deploys are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/deploy-to-target.sh | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index 69824a6d..365b68fb 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -564,6 +564,41 @@ fi # Sync code section_start + +# GUARD (2026-07-31 incident): the rsync below is `--delete`, and TARGET_DIR +# (/home/archipelago/archy) is a SYMLINK to /home/archipelago/Projects/archy on +# archi-dev-box — which is this same machine over loopback SSH. Deploying from +# the main checkout is a harmless no-op (source and destination resolve to the +# same directory), but deploying from anywhere else on this host — a git +# worktree under .claude/worktrees/, a copy, a subdirectory — makes rsync mirror +# that source ONTO the main checkout and delete everything not present in it. +# That is exactly what happened: ~1810 tracked files deleted, the deploying +# 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). +_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 + fi +fi + progress "Syncing code" rsync -avz --delete \ -e "ssh $SSH_OPTS" \