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 = 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 * 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>> {
if let Some(cl) = resp.content_length() {
if cl > limit as u64 {
return None;
}
}
match resp.bytes().await {
Ok(bytes) => {
if bytes.len() > limit {
None
} else {
Some(bytes.to_vec())
}
}
Err(_) => Some(Vec::new()),
}
}
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)));
}
}