#!/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

# --- the content cache. Population is a second way to reach a file, so every refusal the
# --- request path makes has to hold here too, and the hit path has to make them again.

check cache-holds-only-real-files a_symlink_to_a_file_outside_the_root_is_not_cacheable src/cache.rs \
  '    file_type.is_file()' \
  '    file_type.is_file() || file_type.is_symlink()' \
  --lib

check cache-walk-never-follows-a-link the_walk_does_not_descend_a_symlinked_directory src/cache.rs \
  '            if file_type.is_dir() {' \
  '            if entry.path().is_dir() {' \
  --lib

check cache-enumeration-bounded the_walk_stops_at_its_ceiling src/cache.rs \
  '            if found.len() >= ceiling {' \
  '            if false {' \
  --lib

check cache-budget-stops-rather-than-packs the_budget_stops_rather_than_packing src/cache.rs \
  '            truncated = true;
            break;' \
  '            truncated = true;
            continue;' \
  --lib

check cache-hit-makes-the-same-refusals a_cached_response_is_identical_to_an_uncached_one src/resolve.rs \
  '    if hidden == HiddenFiles::Deny && has_hidden_segment(&checked) {' \
  '    if false {' \
  --test cached_serving

check cache-refuses-live-reload a_cache_and_live_reload_cannot_both_be_enabled src/server.rs \
  '        (self.live_reload && self.content_cache.is_some()).then(|| {' \
  '        (false && self.live_reload).then(|| {' \
  --test server

# Both sidecar checks run --release deliberately. The guard they replaced was a
# `debug_assert_eq!`, so a debug-only run cannot tell "the containment check refused it"
# from "an assertion fired" — and the shipped behaviour is the release behaviour, which is
# where the bug lived for 29 published versions.
check sidecar-containment-verified-on-fd a_sidecar_symlinked_outside_the_root_is_declined src/resolve.rs \
  '    if !real.starts_with(root_canon) {
        return None;
    }
    let metadata = file.metadata().ok()?;' \
  '    let metadata = file.metadata().ok()?;' \
  --release --test sidecar_containment

check sidecar-refuses-irregular-files a_directory_named_like_a_sidecar_is_declined src/resolve.rs \
  '    if !metadata.is_file() {
        return None;
    }
    Some(ResolvedFile {
        file,
        metadata,
        path: real,
    })' \
  '    Some(ResolvedFile {
        file,
        metadata,
        path: real,
    })' \
  --release --test sidecar_containment

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."
