use reqwest::Client;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
const MAX_BODY_BYTES: usize = 256 * 1024;
const BASELINE_PROBE_COUNT: usize = 3;
#[derive(Debug, Clone)]
pub struct BaselineFingerprint {
pub status: u16,
pub avg_body_len: usize,
pub hashes: Vec<u64>,
}
pub async fn establish(client: &Client, base: &str) -> Option<BaselineFingerprint> {
let base = base.trim_end_matches('/');
let mut statuses = Vec::with_capacity(BASELINE_PROBE_COUNT);
let mut lengths = Vec::with_capacity(BASELINE_PROBE_COUNT);
let mut hashes = Vec::with_capacity(BASELINE_PROBE_COUNT);
for i in 0..BASELINE_PROBE_COUNT {
let probe = format!("{}/.gossan-baseline-{:x}-{}", base, i, probe_nonce());
match client.get(&probe).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
statuses.push(status);
let bytes = match read_limited(resp, MAX_BODY_BYTES).await {
Some(b) => b,
None => {
lengths.push(MAX_BODY_BYTES);
hashes.push(hash_bytes(b"OVERSIZED"));
continue;
}
};
lengths.push(bytes.len());
hashes.push(normalized_hash(&bytes));
}
Err(_) => {
continue;
}
}
}
if statuses.is_empty() {
return None;
}
let status = most_common(&statuses);
let avg_body_len = if lengths.is_empty() {
0
} else {
lengths.iter().sum::<usize>() / lengths.len()
};
Some(BaselineFingerprint {
status,
avg_body_len,
hashes,
})
}
pub fn is_likely_404(
status: u16,
body: &[u8],
baseline: Option<&BaselineFingerprint>,
strict: bool,
) -> bool {
let Some(base) = baseline else {
return status == 404;
};
if status != base.status {
return false;
}
let len_diff = if body.len() > base.avg_body_len {
body.len() - base.avg_body_len
} else {
base.avg_body_len - body.len()
};
let len_similar = len_diff < 200 || (len_diff.saturating_mul(100) / base.avg_body_len.max(1)) < 15;
let hash = normalized_hash(body);
let hash_match = base.hashes.iter().any(|h| *h == hash);
if strict {
len_similar && hash_match
} else {
len_similar || hash_match
}
}
pub fn is_catch_all(baseline: Option<&BaselineFingerprint>) -> bool {
baseline.map(|b| b.status == 200).unwrap_or(false)
}
pub async fn read_limited(resp: reqwest::Response, limit: usize) -> Option<Vec<u8>> {
use futures::StreamExt;
if let Some(cl) = resp.content_length() {
if cl > limit as u64 {
return None;
}
}
let mut buf: Vec<u8> = Vec::with_capacity(limit.min(8 * 1024));
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
tracing::warn!("response body stream error in read_limited: {e}");
return None;
}
};
if buf.len() + chunk.len() > limit {
return None;
}
buf.extend_from_slice(&chunk);
}
Some(buf)
}
pub async fn read_prefix(resp: reqwest::Response, limit: usize) -> Option<Vec<u8>> {
use futures::StreamExt;
let mut buf: Vec<u8> = Vec::with_capacity(limit.min(8 * 1024));
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
tracing::warn!("response body stream error in read_prefix: {e}");
return None;
}
};
let remaining = limit.saturating_sub(buf.len());
if remaining == 0 {
break;
}
let take = remaining.min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if buf.len() >= limit {
break;
}
}
Some(buf)
}
fn normalized_hash(bytes: &[u8]) -> u64 {
let text = String::from_utf8_lossy(bytes);
let normalized = text
.replace('\r', "")
.replace("\n\n", "\n")
.replace('\t', " ");
hash_bytes(normalized.as_bytes())
}
fn hash_bytes(bytes: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}
fn most_common(items: &[u16]) -> u16 {
let mut counts = std::collections::HashMap::new();
for &item in items {
*counts.entry(item).or_insert(0usize) += 1;
}
counts
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(v, _)| v)
.unwrap_or(404)
}
fn probe_nonce() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(42)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_baseline_falls_back_to_404() {
assert!(is_likely_404(404, b"not found", None, true));
assert!(!is_likely_404(200, b"ok", None, true));
}
#[test]
fn exact_match_is_soft_404() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 100,
hashes: vec![normalized_hash(b"SPA shell")],
};
assert!(is_likely_404(200, b"SPA shell", Some(&base), true));
}
#[test]
fn different_status_is_not_soft_404() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 100,
hashes: vec![normalized_hash(b"SPA shell")],
};
assert!(!is_likely_404(404, b"SPA shell", Some(&base), true));
}
#[test]
fn different_body_is_not_soft_404() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 1000,
hashes: vec![normalized_hash(b"SPA shell index html")],
};
assert!(!is_likely_404(200, b"{\"api\":\"v1\"}", Some(&base), true));
}
#[test]
fn length_similarity_catches_slightly_different_spa() {
let body = b"<html><head></head><body>SPA</body></html>";
let base = BaselineFingerprint {
status: 200,
avg_body_len: body.len() + 50,
hashes: vec![normalized_hash(body)],
};
assert!(is_likely_404(200, body, Some(&base), false));
}
#[test]
fn catch_all_detected_when_status_is_200() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 500,
hashes: vec![1, 2, 3],
};
assert!(is_catch_all(Some(&base)));
}
#[test]
fn not_catch_all_when_status_is_404() {
let base = BaselineFingerprint {
status: 404,
avg_body_len: 500,
hashes: vec![1, 2, 3],
};
assert!(!is_catch_all(Some(&base)));
}
#[test]
fn empty_body_with_matching_baseline_is_soft_404() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 0,
hashes: vec![normalized_hash(b"")],
};
assert!(is_likely_404(200, b"", Some(&base), true));
}
#[test]
fn strict_mode_requires_both_len_and_hash() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 100,
hashes: vec![normalized_hash(b"unique")],
};
let body = b"close enough length but different hash";
assert!(!is_likely_404(200, body, Some(&base), true));
assert!(is_likely_404(200, body, Some(&base), false));
}
#[test]
fn non_strict_mode_allows_hash_match_despite_len_diff() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 1000,
hashes: vec![normalized_hash(b"SPA shell")],
};
let body = b"SPA shell";
assert!(!is_likely_404(200, body, Some(&base), true));
assert!(is_likely_404(200, body, Some(&base), false));
}
#[test]
fn normalized_hash_ignores_whitespace_variations() {
let h1 = normalized_hash(b"hello\tworld");
let h2 = normalized_hash(b"hello world");
assert_eq!(h1, h2);
let h3 = normalized_hash(b"hello\r\nworld");
let h4 = normalized_hash(b"hello\nworld");
assert_eq!(h3, h4);
}
#[test]
fn hash_bytes_is_deterministic_and_case_sensitive() {
let h1 = hash_bytes(b"test");
let h2 = hash_bytes(b"test");
assert_eq!(h1, h2);
let h3 = hash_bytes(b"TEST");
assert_ne!(h1, h3);
}
#[tokio::test]
async fn read_limited_returns_body_when_under_cap() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
let result = read_limited(resp, 64 * 1024).await;
assert_eq!(result.as_deref(), Some(&b"hello world"[..]));
}
#[tokio::test]
async fn read_limited_stream_error_returns_none() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![]))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
assert_eq!(read_limited(resp, 64).await.as_deref(), Some(&b""[..]));
}
#[tokio::test]
async fn read_prefix_returns_first_n_even_when_body_larger() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let mut payload = b"JAVA PROFILE 1.0.2".to_vec();
payload.extend(std::iter::repeat(b'X').take(64 * 1024));
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
let prefix = read_prefix(resp, 16).await.expect("prefix");
assert_eq!(&prefix[..], b"JAVA PROFILE 1.0");
}
#[tokio::test]
async fn read_limited_rejects_body_exceeding_cap() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let payload = vec![b'A'; 1024 * 1024];
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
let result = read_limited(resp, 64 * 1024).await;
assert!(
result.is_none(),
"read_limited returned Some(len={:?}) for a body larger than the cap. \
OOM guard regressed",
result.as_ref().map(Vec::len)
);
}
#[test]
fn normalized_hash_is_deterministic_within_process() {
let body = b"<html><body>Not found</body></html>";
let h1 = normalized_hash(body);
let h2 = normalized_hash(body);
let h3 = normalized_hash(body);
assert_eq!(h1, h2, "normalized_hash must be deterministic within a process");
assert_eq!(h1, h3, "normalized_hash must be deterministic within a process");
}
#[tokio::test]
async fn read_limited_cap_zero_empty_body_is_some_empty() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
let result = read_limited(resp, 0).await;
assert_eq!(result.as_deref(), Some(&b""[..]),
"read_limited with cap=0 and empty body should return Some([])");
}
#[tokio::test]
async fn read_limited_cap_exact_body_size() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let body = b"hello".to_vec();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
let result = read_limited(resp, body.len()).await;
assert_eq!(result.as_deref(), Some(body.as_slice()),
"read_limited with cap == body.len() must return the full body");
}
#[tokio::test]
async fn read_limited_cap_one_under_body_size_returns_none() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let body = b"hello".to_vec(); Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let resp = reqwest::get(server.uri()).await.expect("request");
let result = read_limited(resp, body.len() - 1).await;
assert!(result.is_none(),
"read_limited with cap one under body size must return None (not silently truncate)");
}
#[test]
fn is_likely_404_never_suppresses_on_status_mismatch() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 5,
hashes: vec![normalized_hash(b"hello")],
};
assert!(!is_likely_404(404, b"hello", Some(&base), false),
"status mismatch must always mean NOT a soft-404 (finding must not be suppressed)");
assert!(!is_likely_404(301, b"hello", Some(&base), false),
"status mismatch must always mean NOT a soft-404");
}
#[test]
fn is_likely_404_survives_extreme_len_diff() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 1,
hashes: vec![normalized_hash(b"x")],
};
let huge_body = vec![b'a'; 50_000_000];
assert!(!is_likely_404(200, &huge_body, Some(&base), true));
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_exact_match_is_soft_404(status in 100u16..600, body in proptest::collection::vec(any::<u8>(), 0..4096)) {
let base = BaselineFingerprint {
status,
avg_body_len: body.len(),
hashes: vec![normalized_hash(&body)],
};
prop_assert!(is_likely_404(status, &body, Some(&base), true));
}
#[test]
fn prop_status_mismatch_never_soft_404(
base_status in 100u16..600,
probe_status in 100u16..600,
body in proptest::collection::vec(any::<u8>(), 0..4096)
) {
let base = BaselineFingerprint {
status: base_status,
avg_body_len: body.len(),
hashes: vec![normalized_hash(&body)],
};
let result = is_likely_404(probe_status, &body, Some(&base), true);
if base_status == probe_status {
prop_assert!(result);
} else {
prop_assert!(!result);
}
}
#[test]
fn prop_empty_baseline_never_panics(
status in 100u16..600,
body in proptest::collection::vec(any::<u8>(), 0..4096)
) {
let _ = is_likely_404(status, &body, None, true);
let _ = is_likely_404(status, &body, None, false);
}
#[test]
fn prop_read_limited_never_panics_on_small_limit(
limit in 0usize..1024,
data in proptest::collection::vec(any::<u8>(), 0..2048)
) {
let mut buf: Vec<u8> = Vec::with_capacity(limit.min(8 * 1024));
if data.len() <= limit {
buf.extend_from_slice(&data);
}
prop_assert!(buf.len() <= limit || buf.is_empty());
}
}
}
#[test]
fn most_common_single_element() {
assert_eq!(most_common(&[200]), 200);
}
#[test]
fn most_common_all_identical() {
assert_eq!(most_common(&[404, 404, 404]), 404);
}
#[test]
fn most_common_majority() {
assert_eq!(most_common(&[200, 404, 200, 200, 404]), 200);
}
#[test]
fn most_common_empty_returns_fallback() {
assert_eq!(most_common(&[]), 404);
}
#[test]
fn most_common_all_distinct_returns_a_value_not_panic() {
let result = most_common(&[200, 301, 404]);
assert!(
[200u16, 301, 404].contains(&result),
"most_common on all-distinct must return one of the input values, got {result}"
);
}
#[test]
fn normalized_hash_empty_does_not_panic() {
let h = normalized_hash(b"");
let _ = h;
}
#[test]
fn normalized_hash_double_newline_collapsed() {
let a = normalized_hash(b"hello\n\nworld");
let b = normalized_hash(b"hello\nworld");
assert_eq!(a, b, "double newlines should be treated same as single");
}
#[test]
fn normalized_hash_tab_to_space_collapsed() {
let a = normalized_hash(b"hello\tworld");
let b = normalized_hash(b"hello world");
assert_eq!(a, b, "tab and space should produce same hash");
}
#[test]
fn normalized_hash_cr_stripped() {
let a = normalized_hash(b"hello\r\nworld");
let b = normalized_hash(b"hello\nworld");
assert_eq!(a, b, "\\r stripped. CRLF and LF must hash the same");
}
#[test]
fn normalized_hash_very_long_input_does_not_panic() {
let big = vec![b'a'; 1_000_000];
let _ = normalized_hash(&big);
}
#[test]
fn is_likely_404_len_diff_exactly_199_is_similar() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 400,
hashes: vec![normalized_hash(b"unique_baseline_content")],
};
let body_len_201 = vec![b'x'; 201]; assert!(is_likely_404(200, &body_len_201, Some(&base), false));
}
#[test]
fn is_likely_404_len_diff_exactly_200_falls_to_percent_check() {
let base = BaselineFingerprint {
status: 200,
avg_body_len: 400,
hashes: vec![normalized_hash(b"unique_baseline")],
};
let body = vec![b'y'; 200]; assert!(!is_likely_404(200, &body, Some(&base), false));
}
#[test]
fn is_catch_all_returns_false_for_none_baseline() {
assert!(!is_catch_all(None));
}
#[test]
fn is_catch_all_false_for_301_baseline() {
let base = BaselineFingerprint {
status: 301,
avg_body_len: 0,
hashes: vec![],
};
assert!(!is_catch_all(Some(&base)));
}
}