#!/usr/bin/env bash
# verify-guarantees.sh — prove each security guarantee in THREAT_MODEL.md is tested.
#
# A green test proves only that it passes. THREAT_MODEL.md claims something stronger than
# "there is a test": it claims the test *fails* when the guarantee is removed. This script
# is that claim, executable.
#
# This crate learned the difference the hard way. Its suite was 219 tests green while
# `/admin%2Fconfig` reached a nested file through an encoded separator, and when the tests
# for the fix were first written, three of four passed with the guard deleted — they were
# asserting 404 against a root where the file did not exist, or measuring the dotfile
# check instead of the traversal check. Green is not evidence.
#
# For each row: patch the source to remove the guarantee, run only the test that is
# supposed to catch it, assert the test FAILS, restore the source. A mutation whose
# test still passes is a finding — the test is vacuous and the row must not be claimed.
#
# Usage:  ./verify-guarantees.sh            # every guarantee
#         ./verify-guarantees.sh traversal-rejected   # one, by id
#
# Exit 0 = every mutation was caught. Exit 1 = at least one was not.
set -uo pipefail

cd "$(dirname "$0")"
FILTER="${1:-}"
PASS=0
FAIL=0
declare -a FAILED_IDS=()

# Source files are restored from git, so a dirty tree would be destroyed by a failed run.
if ! git diff --quiet -- src/ 2>/dev/null; then
  echo "REFUSING: src/ has uncommitted changes; this script restores it with git checkout."
  exit 1
fi

restore() { git checkout -- src/ 2>/dev/null; }
trap restore EXIT INT TERM

# mutate <file> <python-expression-file> — applies a patch, asserting its anchor exists.
# An anchor that no longer matches means the code moved and the mutation silently became
# a no-op, which would report a vacuous test as verified. That must be a hard error.
apply() {
  local file=$1 old=$2 new=$3
  python3 - "$file" "$old" "$new" <<'PY'
import sys
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
src = open(path).read()
if old not in src:
    sys.stderr.write(f"ANCHOR MISSING in {path}: {old[:70]!r}\n")
    sys.exit(2)
open(path, 'w').write(src.replace(old, new, 1))
PY
}

# check <id> <test-filter> <file> <old> <new> [test-target-args...]
check() {
  local id=$1 test_filter=$2 file=$3 old=$4 new=$5
  shift 5
  if [ -n "$FILTER" ] && [ "$FILTER" != "$id" ]; then return 0; fi

  printf '%-34s ' "$id"
  if ! apply "$file" "$old" "$new"; then
    echo "ERROR (anchor missing — code moved, mutation would be a no-op)"
    FAIL=$((FAIL + 1)); FAILED_IDS+=("$id(anchor)"); restore; return 0
  fi

  # The mutation must make THIS test fail. Compile errors count as "not caught":
  # a mutation that does not build has not demonstrated anything about the test.
  local out
  out=$(cargo test "$@" "$test_filter" 2>&1)
  restore

  # Order matters. A failing test makes cargo print "error: test failed", so checking
  # for /^error/ first would misread every caught mutation as a build failure — which is
  # exactly what the first run of this script did.
  if echo "$out" | grep -q "test result: FAILED"; then
    echo "caught"
    PASS=$((PASS + 1))
  elif echo "$out" | grep -qE "test result: ok\. 0 passed"; then
    # The filter matched no test at all, so nothing was verified. Silent success here
    # is how a renamed test would quietly stop guarding its guarantee.
    echo "ERROR (test filter matched nothing — renamed or deleted?)"
    FAIL=$((FAIL + 1)); FAILED_IDS+=("$id(no-such-test)")
  elif echo "$out" | grep -q "test result: ok"; then
    echo "NOT CAUGHT — the test passes without the guarantee"
    FAIL=$((FAIL + 1)); FAILED_IDS+=("$id(vacuous)")
  else
    echo "INCONCLUSIVE (mutation did not build)"
    FAIL=$((FAIL + 1)); FAILED_IDS+=("$id(build)")
  fi
}

echo "== Mutating each guarantee; every one must be caught by its test =="
check traversal-rejected resolve_traversal_attempt_rejected src/resolve.rs \
  '    if decoded == ".." {' \
  '    if false {' \
  --test resolve --test respond

check encoded-separator-refused an_encoded_separator_does_not_reach_a_nested_file src/resolve.rs \
  "    if decoded.contains('/') || decoded.contains('\\\\') || decoded.contains('\0') {" \
  "    if decoded.contains('\\\\') || decoded.contains('\0') {" \
  --test encoded_separator

check encoded-backslash-refused an_encoded_backslash_is_refused src/resolve.rs \
  "    if decoded.contains('/') || decoded.contains('\\\\') || decoded.contains('\0') {" \
  "    if decoded.contains('/') || decoded.contains('\0') {" \
  --test encoded_separator

check nul-byte-rejected resolve_null_byte_rejected src/resolve.rs \
  "    if decoded.contains('/') || decoded.contains('\\\\') || decoded.contains('\0') {" \
  "    if decoded.contains('/') || decoded.contains('\\\\') {" \
  --test resolve

check containment-verified-on-fd resolve_rejects_symlink_escaping_root src/resolve.rs \
  '    if !real.starts_with(root_canon) {' \
  '    if false {' \
  --test resolve

check hidden-files-denied dotfiles_are_denied_by_default src/resolve.rs \
  '    if hidden == HiddenFiles::Deny && has_hidden_segment(segments) {' \
  '    if false {' \
  --test hidden_files

check well-known-exempt well_known_is_served_despite_its_leading_dot src/resolve.rs \
  '        let is_well_known_root = index == 0 && segment.as_ref() == WELL_KNOWN;' \
  '        let is_well_known_root = false;' \
  --test hidden_files

check segments-from-a-router-are-checked a_segment_containing_a_separator_is_refused src/resolve.rs \
  '            check_segment(segment.as_ref(), request_path)?;' \
  '            let _ = request_path;' \
  --test respond --test composed

check nosniff-on-every-response every_response_carries_nosniff_and_success_returns_the_real_file_content src/server.rs \
  '        .header("X-Content-Type-Options", "nosniff")' \
  '' \
  --test http_responses

check computed-headers-refused server_computed_headers_are_refused src/server.rs \
  '        if SERVER_COMPUTED_HEADERS.contains(&name) {' \
  '    if false {' \
  --test response_headers

check injection-cap-enforced an_html_page_over_the_cap_is_served_unmodified_and_logged src/server.rs \
  '        let html_injection = wants_injection && metadata.len() <= MAX_INJECTABLE_HTML_BYTES;' \
  '        let html_injection = wants_injection;' \
  --test injection_cap



check ephemeral-bind-loopback the_ephemeral_bind_address_is_loopback src/server.rs \
  'const EPHEMERAL_BIND_IP: std::net::Ipv4Addr = std::net::Ipv4Addr::LOCALHOST;' \
  'const EPHEMERAL_BIND_IP: std::net::Ipv4Addr = std::net::Ipv4Addr::UNSPECIFIED;' \
  --lib


check shutdown-drain-bounded live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout src/server.rs \
  '        if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
            self.accept_task.abort();
        }' \
  '        let _ = (&mut self.accept_task).await;' \
  --test live_reload

check 404-page-must-be-inside-root a_page_outside_the_root_is_rejected src/server.rs \
  '        if !canon.starts_with(&self.root_canon) {' \
  '    if false {' \
  --test not_found_page


check range-unsatisfiable-is-416 range_out_of_bounds_returns_416 src/server.rs \
  '                        .status(StatusCode::RANGE_NOT_SATISFIABLE)' \
  '                        .status(StatusCode::OK)' \
  --test range

echo ""
echo "caught: $PASS   not caught: $FAIL"
if [ "$FAIL" -ne 0 ]; then
  echo "UNVERIFIED: ${FAILED_IDS[*]}"
  echo "A guarantee whose mutation is not caught must not be claimed in THREAT_MODEL.md."
  exit 1
fi
echo "Every guarantee's mutation was caught by its own test."
