#!/usr/bin/env bash
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
#
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
# outside the framework-owned manifest. Every operator-owned sentinel — including
# a deliberately UNANTICIPATED one the manifest never names — must survive
# byte-identical with an unchanged mtime (not even rewritten). Framework files
# must still update, and a retired framework file inside a shipped subtree must
# still be pruned. No operator secret value may appear in installer output.
#
# Keep mode is a SINGLE code path (sync_framework_keep, a manifest-driven cp
# overlay + scoped prune — no rsync). The matrix still runs twice, once with
# rsync on PATH and once with it hidden, to prove the keep path is genuinely
# rsync-independent: it must obey the manifest identically whether or not rsync
# happens to be installed (rsync --delete is only ever used by overwrite mode,
# which has no operator state to protect).
#
# It also runs a fail-closed matrix (#791 B2/B3): an empty, operator-only,
# malformed, or missing manifest must ABORT the upgrade loudly and leave every
# operator path untouched — never silently no-op to "complete".
#
# Usage:  bash test-upgrade-manifest-guard.sh
set -uo pipefail

FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"   # packages/mosaic/framework
INSTALL="$FW/install.sh"

pass=0; fail=0
chk() { if eval "$2"; then echo "  ✓ $1"; pass=$((pass + 1)); else echo "  ✗ $1"; fail=$((fail + 1)); fi; }

# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups)
# into a throwaway so a keep-mode upgrade under test never writes into the real
# ~/.local/state. This test asserts operator-surface fidelity, not backup content.
export XDG_STATE_HOME
XDG_STATE_HOME="$(mktemp -d)"
trap 'rm -rf "$XDG_STATE_HOME"' EXIT

SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'

# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
seed_home() {
  local H="$1"
  mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
           "$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
           "$H/unknown-operator-dir" "$H/guides"
  printf '# persona\n' > "$H/SOUL.md"          # marks a recognized existing install → keep mode
  printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
  printf '# operator policy\n' > "$H/policy/custom.md"
  printf '# soul overlay\n' > "$H/SOUL.local.md"
  printf '# operator memory\n' > "$H/memory/note.md"
  printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
  printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
  printf 'version: 2\nagents:\n  - name: coder0\n' > "$H/fleet/roster.yaml"
  printf '# harvester SOP\n' > "$H/harvester/sop.md"
  printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
  printf 'version: 1\nagents:\n  - name: mine\n' > "$H/fleet/my-fleet.yaml"

  # #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
  # populated ledger under fleet/run/sessions/ must survive the upgrade — a
  # runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
  # #797 writes it: a non-empty append journal + a non-empty compacted
  # projection, files 0600 under a 0700 dir.
  printf '%s\n%s\n%s\n' \
    '{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
    '{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
    '{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
    > "$H/fleet/run/sessions/events.ndjson"
  printf '%s\n' \
    '{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
    > "$H/fleet/run/sessions/ledger.json"
  chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
  chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"

  # A retired framework file inside a shipped subtree (absent from source) — must be pruned.
  printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
  echo 3 > "$H/.framework-version"
}

OPERATOR_SENTINELS=(
  "agents/coder0.conf"
  "policy/custom.md"
  "SOUL.local.md"
  "memory/note.md"
  "tools/_lib/credentials.json"
  "fleet/agents/coder0.env"
  "fleet/roster.yaml"
  "harvester/sop.md"
  "unknown-operator-dir/x"
  "fleet/my-fleet.yaml"
  # #797 Runtime Session Ledger — populated journal + projection must survive.
  "fleet/run/sessions/events.ndjson"
  "fleet/run/sessions/ledger.json"
)

run_matrix() {
  local label="$1"; shift          # extra env / PATH override applied to the run
  local H E OUT rel before_hash after_hash before_mt after_mt
  H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
  seed_home "$H"

  # Snapshot hash + mtime of every operator sentinel before the upgrade.
  for rel in "${OPERATOR_SENTINELS[@]}"; do
    sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
    stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
  done

  # Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
  local before_dirperm after_dirperm
  before_dirperm=$(stat -c %a "$H/fleet/run/sessions")

  # The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
  MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1

  # HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
  for rel in "${OPERATOR_SENTINELS[@]}"; do
    before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
    before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
    after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
    after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
    chk "[$label] operator sentinel survives byte-identical: $rel" \
      "[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
    chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
      "[ '$before_mt' = '$after_mt' ]"
  done

  # #797 assert 7: the ledger directory's permission bits are unchanged.
  after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
  chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
    "[ '$before_dirperm' = '$after_dirperm' ]"

  # Positive controls / negative controls — prove the test discriminates: the
  # upgrade DOES write and prune framework-owned paths, so the operator sentinels
  # (incl. the #797 ledger) survive because of the manifest, not because the
  # upgrade is a no-op.
  chk "[$label] positive control: framework file present after upgrade (guides synced)" \
    "[ -f '$H/guides/E2E-DELIVERY.md' ]"
  chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
    "[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
  chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"

  # Secret-safety: the operator secret value never appears in installer output.
  chk "[$label] operator secret value absent from installer stdout/stderr" \
    "! grep -q '$SECRET' '$OUT'"

  rm -rf "$H" "$E" "$OUT"
}

# Fail-closed matrix (#791 B2/B3 + blocker-1): run install.sh from a COPY of the
# framework so the shipped manifest can be corrupted. Every corruption must abort
# the upgrade non-zero with a manifest error, leaving all operator sentinels
# byte-identical AND on the SAME inode. The inode check is the load-bearing part:
# manifest validation is hoisted BEFORE make_snapshot/the restore trap, so a bad
# manifest must abort without ever snapshotting, deleting, and restoring the
# target. Were validation still armed under the ERR trap, restore_snapshot would
# rm -rf + rebuild the target — same bytes but a NEW inode (broken hard links,
# changed ctime), which a content-only hash would miss (#791 blocker-1).
run_failclosed() {
  local label="$1" mutate="$2"
  local SRC H E OUT rc rel before_hash after_hash before_ino after_ino key
  SRC=$(mktemp -d); H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
  cp -a "$FW/." "$SRC/"
  case "$mutate" in
    empty)         : > "$SRC/framework-manifest.txt" ;;
    operator-only) printf '[operator]\nSOUL.md\n*.local.md\n' > "$SRC/framework-manifest.txt" ;;
    malformed)     printf 'stray.md\n[framework]\nguides/**\n' > "$SRC/framework-manifest.txt" ;;
    degenerate)    printf '[framework]\n/\n./\n[operator]\nSOUL.md\n' > "$SRC/framework-manifest.txt" ;;
    missing)       rm -f "$SRC/framework-manifest.txt" ;;
  esac

  seed_home "$H"
  for rel in "${OPERATOR_SENTINELS[@]}"; do
    key=$(echo "$rel" | tr / _)
    sha256sum "$H/$rel" | awk '{print $1}' > "$E/$key.hash"
    stat -c '%i' "$H/$rel" > "$E/$key.ino"
  done

  MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$SRC/install.sh" >"$OUT" 2>&1
  rc=$?

  chk "[fail-closed:$label] upgrade aborts non-zero" "[ '$rc' -ne 0 ]"
  chk "[fail-closed:$label] refuses loudly with a manifest error" \
    "grep -qi 'manifest' '$OUT'"
  for rel in "${OPERATOR_SENTINELS[@]}"; do
    key=$(echo "$rel" | tr / _)
    before_hash=$(cat "$E/$key.hash")
    after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
    chk "[fail-closed:$label] operator sentinel untouched: $rel" \
      "[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
    before_ino=$(cat "$E/$key.ino")
    after_ino=$(stat -c '%i' "$H/$rel" 2>/dev/null)
    chk "[fail-closed:$label] operator sentinel not deleted/recreated (inode stable): $rel" \
      "[ -n '$after_ino' ] && [ '$before_ino' = '$after_ino' ]"
  done
  chk "[fail-closed:$label] operator secret value absent from output" \
    "! grep -q '$SECRET' '$OUT'"

  chmod -R u+w "$SRC" "$H" 2>/dev/null || true
  rm -rf "$SRC" "$H" "$E" "$OUT"
}

echo "#791 upgrade manifest guard (HARD GATE):"

# 1) rsync path (if available on this host).
if command -v rsync >/dev/null 2>&1; then
  run_matrix "rsync"
else
  echo "  · rsync not installed — skipping rsync-path matrix"
fi

# 2) rsync-absent path — hide rsync behind a scratch PATH. Keep mode never calls
#    rsync, so this must resolve identically to run (1); it proves the keep path
#    does not silently depend on rsync being installed. (Provide the coreutils the
#    installer needs on the stripped PATH.)
FBIN=$(mktemp -d)
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do
  p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
done
run_matrix "rsync-absent" env "PATH=$FBIN"
rm -rf "$FBIN"

# 3) fail-closed matrix (#791 B2/B3) — corrupt the shipped manifest four ways.
run_failclosed "empty-manifest"       empty
run_failclosed "operator-only"        operator-only
run_failclosed "malformed-manifest"   malformed
run_failclosed "degenerate-framework" degenerate
run_failclosed "missing-manifest"     missing

echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]
