#!/usr/bin/env bash
# Regression tests for lib/shim-guard.sh and the PATH shims that use it. Every shim in
# bin/ wraps a command by its own name, so a resolution landing on another copy of a
# shim makes the wrapper invoke itself without bound — the fork loop that took the
# workstation down twice.
#
# Sections 1-2 are pure function tests (no shim is spawned) and run anywhere. Section 3
# spawns shims against a PATH holding nothing but shim copies — the pre-fix shape forks
# without bound there, so it runs ONLY in the disposable dangerlab guest. See
# "Projects/0 DOCS/dangerlab.md":
#   ssh debian1 '/home/user/dangerlab/dangerlab-run --payload <dir> --timeout 300 \
#                  -- bash claude/tests/shim-guard.test.sh'
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LIB="$ROOT/lib/shim-guard.sh"
PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n     %s\n' "$1" "$2"; }

TMP=$(mktemp -d "${TMPDIR:-/tmp}/shimguard-test-XXXX")
trap 'rm -rf "$TMP"' EXIT

SHIMS=(_git-guard-shim.sh _cpu-guard-shim.sh _tmpjail-shim.sh _kill-guard-shim.sh)

# 1. Every shim carries the marker its siblings' resolvers reject it by.
missing=""
for s in "${SHIMS[@]}"; do
  grep -qF 'OD_PATH_SHIM_MARKER' "$ROOT/bin/$s" || missing="$missing $s"
done
[[ -z "$missing" ]] && ok "every shim carries OD_PATH_SHIM_MARKER" \
  || bad "every shim carries OD_PATH_SHIM_MARKER" "missing in:$missing"

# 2. Resolver and re-entry bound, exercised as functions.
mkdir -p "$TMP/shimdir" "$TMP/copydir" "$TMP/realdir"
cp "$ROOT/bin/_cpu-guard-shim.sh" "$TMP/shimdir/node"
cp "$ROOT/bin/_cpu-guard-shim.sh" "$TMP/copydir/node"
printf '#!/usr/bin/env bash\necho real\n' > "$TMP/realdir/node"
chmod +x "$TMP/shimdir/node" "$TMP/copydir/node" "$TMP/realdir/node"

# shellcheck source=../lib/shim-guard.sh
source "$LIB"

shim_is_shim "$TMP/copydir/node" && ok "shim_is_shim detects a shim copy" \
  || bad "shim_is_shim detects a shim copy" "marker scan missed $TMP/copydir/node"
shim_is_shim "$TMP/realdir/node" && bad "shim_is_shim clears a non-shim" "false positive" \
  || ok "shim_is_shim clears a non-shim"

got=$(PATH="$TMP/copydir:$TMP/realdir" shim_resolve_real node "$TMP/shimdir")
[[ "$got" == "$TMP/realdir/node" ]] \
  && ok "resolver skips a shim copy and returns the real binary" \
  || bad "resolver skips a shim copy" "got '$got'"

if PATH="$TMP/copydir" shim_resolve_real node "$TMP/shimdir" >/dev/null; then
  bad "resolver refuses a shim-only PATH" "it returned a candidate"
else
  ok "resolver refuses a shim-only PATH"
fi

out=$(bash -c 'source "$1"; shim_guard_enter node; echo "first=$SHIM_REENTRY depth=$_OD_SHIM_DEPTH_node"; shim_guard_enter node; echo "second=$SHIM_REENTRY depth=$_OD_SHIM_DEPTH_node"' _ "$LIB")
[[ "$out" == $'first=0 depth=1\nsecond=1 depth=2' ]] \
  && ok "same-process re-entry stays bounded" \
  || bad "same-process re-entry stays bounded" "got '$out'"

out=$(bash -c 'source "$1"; shim_guard_enter node; export _OD_SHIM_APPLIED_node_$BASHPID; bash -c '\''source "$1"; shim_guard_enter node; echo "reentry=$SHIM_REENTRY depth=$_OD_SHIM_DEPTH_node"'\'' _ "$1"; :' _ "$LIB")
[[ "$out" == "reentry=0 depth=2" ]] \
  && ok "child process does not inherit applied re-entry state" \
  || bad "child process does not inherit applied re-entry state" "got '$out'"

out=$(_OD_SHIM_DEPTH_node=1 bash -c 'source "$1"; shim_guard_enter node; echo "reentry=$SHIM_REENTRY depth=$_OD_SHIM_DEPTH_node"' _ "$LIB")
[[ "$out" == "reentry=0 depth=2" ]] \
  && ok "a second entry increments the bound" \
  || bad "a second entry increments the bound" "got '$out'"

out=$(bash -c 'source "$1"; shim_guard_enter node; shim_guard_clear node; echo "depth=${_OD_SHIM_DEPTH_node-unset}"' _ "$LIB")
[[ "$out" == "depth=unset" ]] \
  && ok "clearing drops the bound" \
  || bad "clearing drops the bound" "got '$out'"

cat >"$TMP/chain.sh" <<'CHAIN'
source "$1"
shim_guard_enter node
shim_guard_clear node
export CHAIN_N=$(( ${CHAIN_N:-0} + 1 ))
if ((CHAIN_N >= 5)); then echo reached; exit 0; fi
exec bash "$0" "$1"
CHAIN
out=$(bash "$TMP/chain.sh" "$LIB" 2>&1)
rc=$?
[[ $rc -eq 0 && "$out" == reached ]] \
  && ok "a real process tree nests past the bound once each frame clears" \
  || bad "a real process tree nests past the bound" "rc=$rc out='$out'"

: >"$TMP/unreadable"; chmod 000 "$TMP/unreadable"
shim_is_shim "$TMP/unreadable" \
  && ok "shim_is_shim treats an unreadable candidate as a shim" \
  || bad "shim_is_shim fails closed on an unreadable candidate" "it cleared $TMP/unreadable"
chmod 644 "$TMP/unreadable"

out=$(_OD_SHIM_DEPTH_node=2 bash -c 'source "$1"; shim_guard_enter node; echo reached' _ "$LIB" 2>&1)
rc=$?
[[ $rc -eq 79 && "$out" == *"re-entered at depth"* && "$out" != *reached* ]] \
  && ok "past the bound the guard exits 79 without proceeding" \
  || bad "past the bound the guard exits 79" "rc=$rc out='$out'"

# 3. End-to-end: a shimmed npm script with an env node shebang must route node through its
# own guard rather than treating npm's marker as node re-entry.
mkdir -p "$TMP/npm-route/bin" "$TMP/npm-route/lib" "$TMP/npm-home/.claude/lib" "$TMP/npm-real"
cp "$ROOT/bin/_cpu-guard-shim.sh" "$TMP/npm-route/bin/npm"
cp "$ROOT/bin/_cpu-guard-shim.sh" "$TMP/npm-route/bin/node"
cp "$LIB" "$TMP/npm-route/lib/shim-guard.sh"
printf '#!/usr/bin/env bash\nprintf "guard:%s\\n" "$1" >&2\nexec "$@"\n' > "$TMP/npm-home/.claude/lib/cpu-guard.sh"
printf '#!/usr/bin/env node\nconst { execFileSync } = require("node:child_process");\nprocess.stdout.write(execFileSync("node", ["-e", "process.stdout.write(\\\"child\\\")"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }));\n' > "$TMP/npm-real/npm"
chmod +x "$TMP/npm-route/bin/npm" "$TMP/npm-route/bin/node" "$TMP/npm-home/.claude/lib/cpu-guard.sh" "$TMP/npm-real/npm"
if out=$(HOME="$TMP/npm-home" PATH="$TMP/npm-route/bin:$TMP/npm-real:/usr/bin:/bin" "$TMP/npm-route/bin/npm" 2>&1); then
  rc=0
else
  rc=$?
fi
[[ $rc -eq 0 && "$out" == *"child" && "$(grep -c '^guard:' <<<"$out")" -eq 3 ]] \
  && ok "npm env-node child receives an independent guard" \
  || bad "npm env-node child receives an independent guard" "rc=$rc got '$out'"

# 4. Behavioral: a PATH holding only shim copies. Forks by design.
if [[ "$(hostname 2>/dev/null)" != dangerlab ]]; then
  printf 'SKIP shim-only PATH fork regression (dangerlab only — never on a workstation)\n'
else
  BEFORE=$(ps -u "$(id -u)" --no-headers 2>/dev/null | wc -l)
  for s in _cpu-guard-shim.sh _tmpjail-shim.sh _kill-guard-shim.sh; do
    case "$s" in
      _cpu-guard-shim.sh) cmd=node ;;
      _tmpjail-shim.sh) cmd=codex ;;
      _kill-guard-shim.sh) cmd=pkill ;;
    esac
    d="$TMP/only-$cmd"
    mkdir -p "$d/bin" "$d/lib" "$d/other"
    cp "$ROOT/bin/$s" "$d/bin/$cmd"; cp "$ROOT/bin/$s" "$d/other/$cmd"
    cp "$LIB" "$d/lib/shim-guard.sh"
    chmod +x "$d/bin/$cmd" "$d/other/$cmd"
    (
      ulimit -u $((BEFORE + 150)) 2>/dev/null
      exec timeout 20 env PATH="$d/bin:$d/other:/usr/bin:/bin" HOME="$TMP/nohome" \
        bash "$d/bin/$cmd" --version
    ) >"$TMP/out" 2>"$TMP/err"
    rc=$?
    AFTER=$(ps -u "$(id -u)" --no-headers 2>/dev/null | wc -l)
    if [[ $rc -ne 124 && $((AFTER - BEFORE)) -lt 20 ]]; then
      ok "$s on a shim-only PATH terminates without a fork loop (rc=$rc)"
    else
      bad "$s on a shim-only PATH terminates" "rc=$rc procs=$BEFORE->$AFTER err=$(head -c 300 "$TMP/err")"
    fi
  done
fi

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]]
