#!/bin/zsh # backup-drive.zsh — copy-only, manifest-driven backup of the media drive to X10. # # Semantics (DP-3: copy-only — the 2TB stays the media host): # - rsync --checksum --partial --append-verify, NO --remove-source-files, # NO --delete (PLAN-13: resumable; PLAN-09: hash-only dedup upstream) # - consumes the unique manifest from dedup-drive.zsh # - structural dest assertion: dest must equal $DRIVE_BACKUP_DEST/ # for every manifest row (PLAN-17) — enforced by construction via # rsync --files-from --relative # - ledger with POST-COPY dest sha256 for EVERY file (DP-7 decided: no size cap) # - pre-copy no-writers + no-*.part checks (PLAN-01/06) # # Modes: --status | (dry-run, default) | --apply # Test hook: --src-root / --dest-root override (sandbox harness only). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/backup3" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/drive-backup" mkdir -p "$STATE_DIR/logs" RUNLOG="$STATE_DIR/logs/run-$(date +%Y%m%d-%H%M%S).log" touch "$RUNLOG" source "$CONFIG_DIR/backup3.conf" # X10 UUID, RSYNC_OPTS source "$CONFIG_DIR/drives.conf" # SRC/SRC_UUID, DRIVE_BACKUP_DEST APPLY=0 MODE=run MANIFEST="" SRC_OVERRIDE="" DEST_OVERRIDE="" STATE_OVERRIDE="" usage() { cat <<'EOF' usage: backup-drive.zsh [--apply] --manifest FILE [--src-root PATH --dest-root PATH] default (no --apply) = dry-run: WOULD-COPY lines + structural dest assertion --manifest unique manifest from dedup-drive.zsh (NUL records) --src-root TEST HOOK ONLY: override source root (harness) --dest-root TEST HOOK ONLY: override dest root (harness) --state-dir TEST HOOK ONLY: override state dir (harness; keeps real ~/.local/state/drive-backup untouched during tests) EOF } while (( $# )); do case "$1" in --apply) APPLY=1; shift ;; --manifest) MANIFEST="$2"; shift 2 ;; --src-root) SRC_OVERRIDE="$2"; shift 2 ;; --dest-root) DEST_OVERRIDE="$2"; shift 2 ;; --state-dir) STATE_OVERRIDE="$2"; shift 2 ;; --status) MODE=status; shift ;; -h|--help) usage; exit 0 ;; *) echo "unknown arg: $1" >&2; usage; exit 2 ;; esac done log() { echo "$*" | tee -a "$RUNLOG"; } HOST="$(hostname | tr '[:upper:]' '[:lower:]')" case "$HOST" in ole-blu*|ole*blu*) MACHINE=ole-blu ;; mac-studio*|mac*studio*) MACHINE=mac-studio ;; *) MACHINE="$HOST" ;; esac SRC="$SRC_OVERRIDE" DEST="$DEST_OVERRIDE" if [[ -z "$SRC" ]]; then SRC="$SRC_ROOT"; fi if [[ -z "$DEST" ]]; then DEST="$DRIVE_BACKUP_DEST"; fi if [[ -n "$STATE_OVERRIDE" ]]; then STATE_DIR="$STATE_OVERRIDE"; mkdir -p "$STATE_DIR/logs"; fi src_mounted() { [[ -d "$SRC" ]] || return 1 if [[ -n "$SRC_OVERRIDE" ]]; then return 0; fi local u u="$(diskutil info "$SRC" 2>/dev/null | sed -nE 's/^[[:space:]]*Disk \/ Partition UUID:[[:space:]]*([0-9A-F-]+).*/\1/p')" [[ "$u" == "$SRC_UUID" ]] } # mount check is on the DRIVE root, not the dest subdir (which may not exist # on first run). Production: X10_ROOT UUID. Test override: dirname of dest. x10_mounted() { local probe="$X10_ROOT" if [[ -n "$DEST_OVERRIDE" ]]; then probe="$(dirname "$DEST")"; fi [[ -d "$probe" ]] || return 1 if [[ -n "$DEST_OVERRIDE" ]]; then return 0; fi local u u="$(diskutil info "$probe" 2>/dev/null | sed -nE 's/^[[:space:]]*Disk \/ Partition UUID:[[:space:]]*([0-9A-F-]+).*/\1/p')" [[ "$u" == "$X10_UUID" ]] } ledger="$STATE_DIR/ledger-$(basename "$SRC").csv" [[ -f "$ledger" ]] || echo "ts|machine|volume|source|dest|size|sha256" > "$ledger" if [[ "$MODE" == status ]]; then echo "machine: $MACHINE (host=$HOST)" echo "source: $SRC $([[ -d "$SRC" ]] && echo ok || echo MISSING)" echo " uuid match: $(src_mounted && echo yes || echo NO)" echo "dest: $DEST $([[ -d "$DEST" ]] && echo ok || echo MISSING)" echo " X10 uuid: $(x10_mounted && echo yes || echo NO)" echo "manifest: ${MANIFEST:-none} $([[ -n "$MANIFEST" && -f "$MANIFEST" ]] && echo "($(grep -c . "$MANIFEST" 2>/dev/null || echo 0) records)" || true)" echo "ledger: $ledger ($([[ -f "$ledger" ]] && wc -l < "$ledger" || echo 0) lines)" exit 0 fi [[ -n "$MANIFEST" && -f "$MANIFEST" ]] || { echo "--manifest required and must exist" >&2; exit 2; } src_mounted || { echo "source not mounted or UUID mismatch: $SRC" >&2; exit 1; } x10_mounted || { echo "dest (X10) not mounted or UUID mismatch" >&2; exit 1; } # --- pre-copy safety checks (PLAN-01/06) --- if (( APPLY )); then mkdir -p "$DEST" # dest subdir created only in apply (dry-run purity) # no concurrent writers on the dest drive (backup3 launchd must be unloaded) local_writers_probe="$X10_ROOT" if [[ -n "$DEST_OVERRIDE" ]]; then local_writers_probe="$(dirname "$DEST")"; fi local_writers="$(lsof +D "$local_writers_probe" 2>/dev/null | grep -cE 'REG|DIR' || true)" (( local_writers == 0 )) || { echo "ABORT: $local_writers open handles on $local_writers_probe — is backup3 launchd unloaded?" >&2; exit 1; } # no partial files on the source (DP-7) local parts parts="$(find "$SRC" -name '*.part' 2>/dev/null | wc -l | tr -d ' ')" (( parts == 0 )) || { echo "ABORT: $parts .part files on source — clean before apply (DP-7)" >&2; exit 1; } fi # --- build relative-path list from manifest (NUL-safe) --- REL_LIST="$STATE_DIR/relpaths-$(date +%s).txt" python3 - "$MANIFEST" "$SRC" "$REL_LIST" <<'PYEOF' import os, sys manifest, src, out = sys.argv[1:4] src_abs = os.path.abspath(src) + "/" with open(manifest, "rb") as f: fields = f.read().split(b"\0") with open(out, "wb") as g: for i in range(0, len(fields) - 2, 3): sha, size, p = fields[i], fields[i + 1], fields[i + 2] if len(sha) == 64 and size.isdigit(): path = os.fsdecode(p) if not path.startswith(src_abs): print(f"FATAL: manifest path outside source root: {path}", file=sys.stderr) sys.exit(3) g.write(os.fsencode(path[len(src_abs):]) + b"\0") PYEOF [[ -s "$REL_LIST" ]] || { echo "manifest produced no relative paths" >&2; exit 2; } # --- structural dest assertion (PLAN-17): every row's dest must be $DEST/ --- python3 - "$REL_LIST" "$DEST" <<'PYEOF' import os, sys relfile, dest = sys.argv[1:3] dest_abs = os.path.abspath(dest) n = 0 with open(relfile, "rb") as f: for rec in f.read().split(b"\0"): if not rec: continue rel = os.fsdecode(rec) full = os.path.join(dest_abs, rel) if not full.startswith(dest_abs + "/"): print(f"FATAL: dest escapes backup root: {full}", file=sys.stderr) sys.exit(3) n += 1 print(f"structural assertion OK: {n} rows -> {dest_abs}/") PYEOF # --- rsync copy (copy-only, resumable, checksum) --- # NOTE: macOS ships openrsync (2.6.9-compatible) — no --append-verify exists. # --partial keeps interrupted transfers; re-runs re-copy them from scratch # (correct, just slower). --checksum stays the correctness guarantee. RSYNC_ARGS=(-rt --no-perms --no-owner --no-group --modify-window=2 --checksum --partial --exclude=.DS_Store --exclude='._*' --exclude=.localized --exclude=node_modules --from0 --files-from="$REL_LIST" --relative --out-format='%n') (( APPLY )) || RSYNC_ARGS+=(--dry-run) log "=== backup-drive start ($MACHINE, apply=$APPLY) $(date +%F_%T) ===" if (( APPLY )); then rsync "${RSYNC_ARGS[@]}" -- "$SRC/" "$DEST/" 2>>"$RUNLOG" || { err=$?; echo "rsync failed ($err)" >&2; exit $err; } log "rsync complete" # post-copy dest hash EVERY file (DP-7) + ledger rows # ledger is pipe-CSV; paths are backslash-escaped (\\ \n \|) so hostile # filenames (|, newline) cannot break the row structure (PLAN-08) python3 - "$MANIFEST" "$SRC" "$DEST" "$ledger" "$MACHINE" "$(basename "$SRC")" <<'PYEOF' import hashlib, os, sys, time manifest, src, dest, ledger, machine, vol = sys.argv[1:7] src_abs = os.path.abspath(src) + "/" dest_abs = os.path.abspath(dest) def esc(s): return s.replace("\\", "\\\\").replace("\n", "\\n").replace("|", "\\|") def unesc(s): out, i = [], 0 while i < len(s): if s[i] == "\\" and i + 1 < len(s): out.append({"n": "\n", "|": "|", "\\": "\\"}.get(s[i + 1], s[i + 1])) i += 2 else: out.append(s[i]) i += 1 return "".join(out) def split_esc(s): """Split on unescaped '|' only (escaped \| stays inside the field).""" out, cur, i = [], [], 0 while i < len(s): if s[i] == "\\" and i + 1 < len(s): cur.append(s[i]); cur.append(s[i + 1]); i += 2 elif s[i] == "|": out.append("".join(cur)); cur = []; i += 1 else: cur.append(s[i]); i += 1 out.append("".join(cur)) return out existing = set() if os.path.exists(ledger): with open(ledger) as f: for line in f: parts = split_esc(line.rstrip("\n")) if len(parts) >= 6: existing.add(unesc(parts[3])) new_rows = 0 with open(manifest, "rb") as f: fields = f.read().split(b"\0") with open(ledger, "a") as g: for i in range(0, len(fields) - 2, 3): sha, size, p = fields[i], fields[i + 1], fields[i + 2] if len(sha) != 64 or not size.isdigit(): continue rel = os.fsdecode(p)[len(src_abs):] src_path = os.fsdecode(p) if src_path in existing: continue dst_path = os.path.join(dest_abs, rel) if not os.path.isfile(dst_path): print(f"LEDGER WARN: dest missing {dst_path}", file=sys.stderr) continue h = hashlib.sha256() with open(dst_path, "rb") as df: while True: b = df.read(1 << 20) if not b: break h.update(b) size = os.path.getsize(dst_path) g.write(f"{time.strftime('%F_%T')}|{machine}|{vol}|{esc(src_path)}|{esc(dst_path)}|{size}|{h.hexdigest()}\n") new_rows += 1 print(f"ledger: {new_rows} new rows (post-copy dest hashes)") PYEOF # manifest rows absent from ledger == 0 (PLAN-13/18); count is the ONLY # stdout line so the zsh capture is a bare integer (paths go to stderr) absent="$(python3 - "$MANIFEST" "$ledger" <<'PYEOF' import sys manifest, ledger = sys.argv[1:3] def unesc(s): out, i = [], 0 while i < len(s): if s[i] == "\\" and i + 1 < len(s): out.append({"n": "\n", "|": "|", "\\": "\\"}.get(s[i + 1], s[i + 1])) i += 2 else: out.append(s[i]) i += 1 return "".join(out) def split_esc(s): """Split on unescaped '|' only (escaped \| stays inside the field).""" out, cur, i = [], [], 0 while i < len(s): if s[i] == "\\" and i + 1 < len(s): cur.append(s[i]); cur.append(s[i + 1]); i += 2 elif s[i] == "|": out.append("".join(cur)); cur = []; i += 1 else: cur.append(s[i]); i += 1 out.append("".join(cur)) return out led = set() with open(ledger) as f: for line in f: parts = split_esc(line.rstrip("\n")) if len(parts) >= 6: led.add(unesc(parts[3])) n = 0 with open(manifest, "rb") as f: fields = f.read().split(b"\0") for i in range(0, len(fields) - 2, 3): sha, size, p = fields[i], fields[i + 1], fields[i + 2] if len(sha) == 64 and size.isdigit(): path = p.decode("utf-8", "surrogateescape") if path not in led: n += 1 print(path, file=sys.stderr) print(n) PYEOF )" (( absent == 0 )) || { echo "FAIL: $absent manifest rows missing from ledger" >&2; exit 1; } log "ledger complete: all manifest rows ledgered" else rsync "${RSYNC_ARGS[@]}" -- "$SRC/" "$DEST/" 2>>"$RUNLOG" | sed 's/^/WOULD-COPY /' log "dry-run complete" fi log "=== backup-drive done. run log: $RUNLOG ==="