#!/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. This crate once carried a test named
# `param_backtracking_truncates_params_on_failure` that was green and did not notice
# when the behaviour it was named for was deleted. 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.
#
# 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 nosniff    # 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 connection-ceiling max_connections_ceilings_concurrent_requests src/app.rs \
  'Semaphore::new(app.max_connections)' 'Semaphore::new(usize::MAX >> 4)' \
  --test connection_bounds

check header-timeout-per-message a_stall_on_the_second_request_of_a_connection_still_times_out src/app.rs \
  'builder.header_read_timeout(header_read_timeout);' '' \
  --test connection_bounds

check oversized-path-rejected an_oversized_path_is_rejected src/app.rs \
  'const MAX_PATH_LEN: usize = 8_192;' 'const MAX_PATH_LEN: usize = usize::MAX;' \
  --test connection_bounds

check oversized-query-rejected an_oversized_query_is_rejected src/app.rs \
  'const MAX_QUERY_LEN: usize = 4_096;' 'const MAX_QUERY_LEN: usize = usize::MAX;' \
  --test connection_bounds

check body-limit-content-length an_oversized_content_length_is_refused_before_the_body_is_sent src/body.rs \
  'if len > max {
					return Err(ServeError::new(413, "request body too large"));
				}' 'let _ = len;' \
  --test body_limits

check body-limit-chunked-overrun a_chunked_body_that_overruns_the_limit_is_rejected src/body.rs \
  'let limited = Limited::new(body, max);' 'let limited = Limited::new(body, usize::MAX);' \
  --test body_limits

# `--test fallback` alongside: a fallback is a response shape too, and the point of
# putting the seam inside `route_inner` is that it inherits this without doing anything.
check header-block-bounded an_oversized_header_block_is_refused_on_its_size src/app.rs \
  '	builder.max_buf_size(app_for_conn.max_header_bytes);' \
  '' \
  --test connection_bounds

check nosniff-every-response nosniff src/app.rs \
  '		headers
			.entry(hyper::header::X_CONTENT_TYPE_OPTIONS)
			.or_insert(HeaderValue::from_static("nosniff"));' '' \
  --test response_headers --test fallback

check framing-headers-refused connection_owned_headers_are_refused src/app.rs \
  'if CONNECTION_OWNED_HEADERS.contains(&name) {' 'if false {' \
  --test response_headers

check cors-on-error-exits error_responses_carry_cors_headers_too src/app.rs \
  'cfg.apply_to_response(resp, req_origin);' \
  'if resp.status().is_success() { cfg.apply_to_response(resp, req_origin); }' \
  --test cors

check cors-credentialed-wildcard credentialed_wildcard_rejected_in_debug src/cors.rs \
  'return Err(CorsConfigError::CredentialedWildcard);' '{}' \
  --lib

check preflight-only-real-routes cors_preflight_only_for_registered_routes src/app.rs \
  'if self.router.segments_exist(&segments) {' 'if true {' \
  --test cors --test fallback

check 5xx-body-sanitized a_5xx_reports_its_internal_message_while_the_client_body_stays_sanitized src/app.rs \
  '		"internal server error"' '		message' \
  --test logging

check handler-panic-reported a_handler_panic_is_reported src/app.rs \
  '						report_if_panicked(&app.log, joined);' '' \
  --test logging

check shutdown-drain-bounded a_wedged_handler_does_not_hold_shutdown_open_forever src/app.rs \
  'let drained = tokio::time::timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT, async {' \
  'let drained = tokio::time::timeout(Duration::from_secs(86_400), async {' \
  --test shutdown

check head-length-matches-get head_reports_the_length_of_the_get_it_mirrors src/app.rs \
  'if !parts.headers.contains_key(hyper::header::CONTENT_LENGTH) {
				if let Some(len) = HttpBody::size_hint(&body).exact() {
					parts.headers.insert(hyper::header::CONTENT_LENGTH, len.into());
				}
			}' 'let _ = &body;' \
  --test response_framing

check host-required-on-http11 http_1_1_without_host_is_refused src/app.rs \
  '		if req.version() == hyper::Version::HTTP_11
			&& req.uri().authority().is_none()
			&& !req.headers().contains_key(hyper::header::HOST)
		{
			return (self.error_handler)(StatusCode::BAD_REQUEST, "missing host header");
		}' '' \
  --test adversarial

check connect-step-bounded a_transport_that_never_completes_does_not_hold_its_slot src/app.rs \
  '								let negotiated =
									tokio::time::timeout(connect_timeout, connect(stream)).await;' \
  '								let negotiated = Ok::<_, ()>(connect(stream).await);' \
  --test transport

# The mutation reproduces the real defect rather than merely moving the call: decoding the
# whole path and splitting afterwards, which is what `mini-static` did until 0.32.0 and is
# what makes `%2F` a separator.
check fallback-gets-router-segments the_fallback_receives_the_routers_own_segments src/app.rs \
  '					req.extensions_mut().insert(PathSegments(segments));' \
  '					let whole = percent_encoding::percent_decode_str(&path).decode_utf8_lossy().into_owned();
					let decoded_first: Vec<String> = whole.split(char::from(47)).filter(|s| !s.is_empty()).map(str::to_string).collect();
					req.extensions_mut().insert(PathSegments(decoded_first));' \
  --test fallback

check fallback-is-wrapped middleware_wraps_the_fallback src/app.rs \
  '		self.fallback = Some(self.apply_middlewares(handler));' \
  '		self.fallback = Some(handler);' \
  --test fallback

check upgrade-keeps-its-permit an_upgraded_connection_still_counts_against_max_connections src/app.rs \
  '		match tokio::time::timeout(UPGRADE_HANDOFF_TIMEOUT, upgrade).await {
			Ok(Ok(upgraded)) => callback.run(TokioIo::new(upgraded)).await,' \
  '		tokio::spawn(async move { if let Ok(u) = upgrade.await { callback.run(TokioIo::new(u)).await; } });
		#[allow(unreachable_code)]
		match tokio::time::timeout(UPGRADE_HANDOFF_TIMEOUT, std::future::pending::<Result<hyper::upgrade::Upgraded, hyper::Error>>()).await {
			Ok(Ok(upgraded)) => { let _ = upgraded; },' \
  --test upgrade

check ephemeral-bind-loopback ephemeral_bind_addr_is_loopback_only src/app.rs \
  '	(std::net::Ipv4Addr::LOCALHOST, 0).into()' \
  '	(std::net::Ipv4Addr::UNSPECIFIED, 0).into()' \
  --lib

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