#!/usr/bin/env bash
# Red/green proof that the git-guard PATH shim no longer contains the C215 fork bomb.
#
# C215 (see "Projects/0 DOCS/2026-08-07-incident-git-shim-fork-bomb-paralysis.md"):
# _git-guard-shim.sh at 544366d3 resolved the "real" git by walking PATH, skipping only
# its OWN directory. A second copy of the shim anywhere else on PATH therefore became
# its "real git", and the shim assigned that copy to WT_GIT_BIN. wt_main_root then runs
# "$WT_GIT_BIN" -C <dir> rev-parse --path-format=absolute --git-common-dir inside a
# command substitution — a FORK, not an exec — and the child is another shim that forks
# its own classification call. That fanout, not the exec tail, is what reached 14,200
# processes and 13,772 refused forks.
#
# Case 1 (RED) runs the frozen 544366d3 body under exactly that hostile PATH and
# requires the storm to actually happen. A green case 1 would mean the fixture no
# longer reproduces the bug, so cases 2-4 would prove nothing.
# Cases 2-4 run the CURRENT body against the same hostile PATH and against a poisoned
# pin, and require bounded termination.
#
# The containment is the disposable dangerlab clone — it is destroyed after this run
# however the run ends. `ulimit -u` below is an INSTRUMENT, not the safety story: it
# only makes the red terminate with readable evidence instead of running to the guest's
# own ceiling. Sampling is deliberately fork-free (bash globs over /proc, a fifo for the
# tick) because RLIMIT_NPROC is per-UID: once the storm saturates it, anything that has
# to fork to observe the storm dies with it. That is the C215 paralysis in miniature.
#
# lab-only: bash tests/run-in-lab.sh git-guard-forkbomb.dangerlab.sh 420
set -uo pipefail
. "$(dirname "$0")/lib/danger-guard.sh" 2>/dev/null || true
command -v require_danger_lab >/dev/null || {
  printf 'REFUSED: %s is exhaustion-class and lib/danger-guard.sh did not load, so it cannot verify its host.\n' \
    "$(basename -- "$0")" >&2
  exit 70
}
require_danger_lab "$0"

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TESTS="$(cd "$(dirname "$0")" && pwd)"
FIXTURE="$TESTS/fixtures/git-guard-shim-544366d3.sh"
FIXTURE_MD5="bc6686967bcf7c4792b56729e6184e5c"
CURRENT="$ROOT/bin/_git-guard-shim.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"; }

[[ -r "$FIXTURE" ]] || { printf 'missing fixture: %s\n' "$FIXTURE" >&2; exit 2; }
GOT="$(md5sum "$FIXTURE" | cut -d' ' -f1)"
[[ "$GOT" == "$FIXTURE_MD5" ]] || {
  printf 'fixture drifted: %s is md5 %s, the incident recorded %s — the red would no longer be the C215 body.\n' \
    "$FIXTURE" "$GOT" "$FIXTURE_MD5" >&2
  exit 2
}
printf 'fixture md5 %s (544366d3, the recursing body)\ncurrent  md5 %s (%s)\n\n' \
  "$GOT" "$(md5sum "$CURRENT" | cut -d' ' -f1)" "$CURRENT"

REALGIT="$(PATH=/usr/bin:/bin command -v git)"
[[ -n "$REALGIT" ]] || { echo "no system git in /usr/bin:/bin" >&2; exit 2; }

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

# A repo that opted into worktree isolation: the shape the guard block classifies.
REPO="$TMP/repo"; mkdir -p "$REPO/.worktrees"
"$REALGIT" -C "$REPO" init -q
"$REALGIT" -C "$REPO" config user.email t@t
"$REALGIT" -C "$REPO" config user.name t
"$REALGIT" -C "$REPO" config commit.gpgsign false
echo x > "$REPO/f"; "$REALGIT" -C "$REPO" add -A; "$REALGIT" -C "$REPO" commit -qm init

# stage_shim <dir> <body> — a self-contained shim install: bin/git plus the sibling
# libs both bodies source relative to their own bin/.
stage_shim() {
  local dir="$1" body="$2"
  mkdir -p "$dir/bin" "$dir/hooks/lib" "$dir/lib"
  install -m 755 "$body" "$dir/bin/git"
  install -m 644 "$ROOT/hooks/lib/worktree-guard-lib.sh" "$dir/hooks/lib/worktree-guard-lib.sh"
  install -m 644 "$ROOT/lib/shim-guard.sh" "$dir/lib/shim-guard.sh"
}

FIFO="$TMP/tick"; mkfifo "$FIFO"
SAMPLER=""
PEAK_FILE="$TMP/peak"; CMD_FILE="$TMP/cmds"

# Fork-free sampler: process count from a glob over /proc, argv from /proc/<pid>/cmdline
# read with builtins, and the tick from a blocking read on a fifo. No subprocess, so it
# keeps reporting after RLIMIT_NPROC is saturated.
sampler_start() {
  : > "$PEAK_FILE"; : > "$CMD_FILE"
  (
    exec 9<>"$FIFO"
    # A pid can exit between the glob and the read; that race is noise, not signal.
    exec 2>/dev/null
    shopt -s nullglob
    local -a pids parts
    local p field line n
    while :; do
      pids=(/proc/[0-9]*)
      n=${#pids[@]}
      printf '%s\n' "$n" >> "$PEAK_FILE"
      for p in "${pids[@]}"; do
        parts=()
        while IFS= read -r -d '' field; do parts+=("$field"); done < "$p/cmdline" 2>/dev/null
        ((${#parts[@]})) || continue
        line="${parts[*]}"
        [[ "$line" == *"rev-parse --path-format=absolute"* ]] && printf '%s\n' "$line" >> "$CMD_FILE"
      done
      read -t 0.1 -u 9 || true
    done
  ) &
  SAMPLER=$!
}
sampler_stop() { [[ -n "$SAMPLER" ]] && kill "$SAMPLER" 2>/dev/null; wait "$SAMPLER" 2>/dev/null; SAMPLER=""; }
peak() { sort -n "$PEAK_FILE" 2>/dev/null | tail -1; }
baseline() { head -1 "$PEAK_FILE" 2>/dev/null; }

UID_PROCS=$(ps -u "$(id -u)" --no-headers 2>/dev/null | wc -l)
NPROC_CAP=$((UID_PROCS + 150))

# run_case <label> <timeout> <home> <path> <shim-to-invoke> <git-args...>
# Sets RC, OUT, ERR, PEAK, BASE, STORM_CMDS.
run_case() {
  local label="$1" tmo="$2" home="$3" path="$4" shim="$5"; shift 5
  sampler_start
  (
    ulimit -u "$NPROC_CAP" 2>/dev/null
    cd "$REPO" && exec timeout "$tmo" env PATH="$path" HOME="$home" \
      AGENT_BUILD_SCOPE_ACTIVE=1 bash "$shim" "$@"
  ) >"$TMP/out" 2>"$TMP/err"
  RC=$?
  sampler_stop
  PEAK=$(peak); BASE=$(baseline)
  STORM_CMDS=$(wc -l < "$CMD_FILE" 2>/dev/null || echo 0)
  OUT="$(cat "$TMP/out")"; ERR="$(head -c 2000 "$TMP/err")"
  printf -- '--- %s: rc=%s procs %s->%s (cap %s) classification-calls-seen=%s\n' \
    "$label" "$RC" "$BASE" "$PEAK" "$NPROC_CAP" "$STORM_CMDS"
}

# ---------------------------------------------------------------- case 1: RED
# Two installs of the 544366d3 body on one PATH. Each skips only its own dir, so each
# resolves the other as "the real git" and the classification call forks into it.
stage_shim "$TMP/redA" "$FIXTURE"
stage_shim "$TMP/redB" "$FIXTURE"
HOSTILE_RED="$TMP/redA/bin:$TMP/redB/bin:/usr/bin:/bin"
run_case "case 1 RED  pre-fix body, hostile PATH" 20 "$TMP/redhome" "$HOSTILE_RED" "$TMP/redA/bin/git" status
RED_GROWTH=$(( ${PEAK:-0} - ${BASE:-0} ))
RED_CMDS="$STORM_CMDS"
RED_SAMPLE="$(head -1 "$CMD_FILE" 2>/dev/null)"
if (( RED_GROWTH >= 50 )) && (( RED_CMDS > 0 )); then
  ok "pre-fix body reproduces the C215 fork storm (+$RED_GROWTH processes, rc=$RC)"
  printf '     observed: %s\n' "$RED_SAMPLE"
else
  bad "pre-fix body reproduces the C215 fork storm" \
    "growth=$RED_GROWTH classification-calls=$RED_CMDS rc=$RC — the fixture did not recurse, so the green below proves nothing"
fi

# ---------------------------------------------------------------- case 2: GREEN
# The SAME hostile PATH, the current body, pinned to the real git. The current body
# never reads PATH, so the poisoned entries are unreachable.
GHOME="$TMP/greenhome"
mkdir -p "$GHOME/.local/state/overdeck/shim-real"
printf '%s\n' "$REALGIT" > "$GHOME/.local/state/overdeck/shim-real/git"
stage_shim "$TMP/grnA" "$CURRENT"
stage_shim "$TMP/grnB" "$CURRENT"
HOSTILE_GRN="$TMP/grnA/bin:$TMP/grnB/bin:/usr/bin:/bin"
run_case "case 2 GRN  current body, same hostile PATH" 20 "$GHOME" "$HOSTILE_GRN" "$TMP/grnA/bin/git" status
GRN_GROWTH=$(( ${PEAK:-0} - ${BASE:-0} ))
if [[ $RC -eq 0 ]] && (( GRN_GROWTH < 20 )) && [[ "$OUT" == *"nothing to commit"* || "$OUT" == *"On branch"* ]]; then
  ok "current body ignores the poisoned PATH and runs the real git (+$GRN_GROWTH processes, rc=0)"
else
  bad "current body ignores the poisoned PATH" "rc=$RC growth=$GRN_GROWTH out=$OUT err=$ERR"
fi

# ---------------------------------------------------------------- case 3: GREEN
# The guard still does its job under that hostile PATH — no fail-open.
run_case "case 3 GRN  current body, hostile PATH, destructive verb" 20 "$GHOME" "$HOSTILE_GRN" "$TMP/grnA/bin/git" restore .
if [[ $RC -eq 77 && "$ERR" == *"git-guard"* ]]; then
  ok "main-checkout guard still blocks under the poisoned PATH (rc=77)"
else
  bad "main-checkout guard still blocks under the poisoned PATH" "rc=$RC err=$ERR"
fi

# ---------------------------------------------------------------- case 4: GREEN
# Poisoned PIN, not PATH: the pin itself names a copy of the shim. Resolution must
# refuse it and terminate — neither forking (C215) nor exec-looping forever, which is
# the same paralysis by a slower route.
PHOME="$TMP/pinhome"
mkdir -p "$PHOME/.local/state/overdeck/shim-real"
stage_shim "$TMP/pinshim" "$CURRENT"
printf '%s\n' "$TMP/pinshim/bin/git" > "$PHOME/.local/state/overdeck/shim-real/git"
run_case "case 4 GRN  current body, pin points at a shim" 20 "$PHOME" "/usr/bin:/bin" "$TMP/grnA/bin/git" status
PIN_GROWTH=$(( ${PEAK:-0} - ${BASE:-0} ))
if [[ $RC -ne 124 ]] && (( PIN_GROWTH < 20 )) \
  && { [[ "$ERR" == *"install-git-guard-real"* ]] || [[ "$ERR" == *"re-entered at depth"* ]]; }; then
  ok "poisoned pin terminates with a diagnostic (rc=$RC, +$PIN_GROWTH processes)"
else
  bad "poisoned pin terminates with a diagnostic" \
    "rc=$RC growth=$PIN_GROWTH err=$ERR — rc=124 means it never returned: git would hang forever, not fork-bomb"
fi

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