use crate::cases::Case;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind")]
pub enum Evidence {
HttpStatus(u16),
HeaderPresent { name: String, value: Option<String> },
HeaderMissing { name: String },
BodyMutatedBeforeVerification,
SignatureMismatch,
ClockDriftSecs { observed: i64, tolerance_secs: u64 },
RetryAfterSecs(u64),
RateLimitObserved { observed_rps: u32, limit_rps: u32 },
DnsResolutionFailed { host: String, message: String },
TlsHandshakeFailed { peer: String, reason: String },
ConnectionTimeout { elapsed_ms: u64, timeout_ms: u64 },
JsonValidationError {
field: Option<String>,
message: String,
},
}
pub fn collect_evidence(case: &Case, log_text: &str) -> Vec<Evidence> {
let mut out = Vec::new();
if matches!(case.context.dns_resolved, Some(false)) {
let host = case
.context
.dns_host
.clone()
.unwrap_or_else(|| "<unknown host>".into());
let message = case
.context
.dns_error
.clone()
.unwrap_or_else(|| "name resolution failed".into());
out.push(Evidence::DnsResolutionFailed { host, message });
}
if matches!(case.context.tls_handshake_failed, Some(true)) {
let peer = case
.context
.tls_peer
.clone()
.unwrap_or_else(|| "<unknown peer>".into());
let reason = case
.context
.tls_failure_reason
.clone()
.unwrap_or_else(|| "tls handshake failed".into());
out.push(Evidence::TlsHandshakeFailed { peer, reason });
}
if case.response.is_none() {
if let (Some(elapsed), Some(timeout)) = (
case.context.elapsed_ms_before_abort,
case.request.timeout_ms,
) {
if elapsed >= timeout {
out.push(Evidence::ConnectionTimeout {
elapsed_ms: elapsed,
timeout_ms: timeout,
});
}
}
}
if let (Some(skew), Some(tol)) = (
case.context.client_clock_skew_secs,
case.context.signature_tolerance_secs,
) {
if skew.unsigned_abs() > tol {
out.push(Evidence::ClockDriftSecs {
observed: skew.abs(),
tolerance_secs: tol,
});
}
}
if matches!(case.context.body_mutated_before_verification, Some(true)) {
out.push(Evidence::BodyMutatedBeforeVerification);
}
if let Some(resp) = &case.response {
out.push(Evidence::HttpStatus(resp.status));
if let Some(retry_after) = resp.headers.get("Retry-After") {
if let Ok(n) = retry_after.parse::<u64>() {
out.push(Evidence::RetryAfterSecs(n));
}
}
if let Some(err) = parse_validation_error_body(&resp.body_summary) {
out.push(err);
}
}
let auth_header = "Authorization";
if case.request.headers.contains_key(auth_header) {
out.push(Evidence::HeaderPresent {
name: auth_header.into(),
value: Some("***".into()),
});
} else {
out.push(Evidence::HeaderMissing {
name: auth_header.into(),
});
}
for ev in parse_log(log_text) {
if is_redundant_with(&out, &ev) {
continue;
}
out.push(ev);
}
out
}
fn is_redundant_with(existing: &[Evidence], candidate: &Evidence) -> bool {
if existing.contains(candidate) {
return true;
}
match candidate {
Evidence::JsonValidationError { field, .. } => existing.iter().any(|e| {
matches!(
e,
Evidence::JsonValidationError { field: f, .. } if f == field
)
}),
Evidence::DnsResolutionFailed { host, .. } => existing.iter().any(|e| {
matches!(
e,
Evidence::DnsResolutionFailed { host: h, .. } if h == host
)
}),
Evidence::TlsHandshakeFailed { peer, .. } => existing.iter().any(|e| {
matches!(
e,
Evidence::TlsHandshakeFailed { peer: p, .. } if p == peer
)
}),
_ => false,
}
}
pub fn parse_log(log_text: &str) -> Vec<Evidence> {
let mut out = Vec::new();
for raw_line in log_text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if line.contains("reason=signature_mismatch")
|| line.contains("signature verification failed")
{
push_unique(&mut out, Evidence::SignatureMismatch);
}
if line.contains("body_modified=true") || line.contains("body_mutated=true") {
push_unique(&mut out, Evidence::BodyMutatedBeforeVerification);
}
if let (Some(observed), Some(tol)) = (
extract_kv_i64(line, "drift_secs"),
extract_kv_u64(line, "tolerance_secs"),
) {
push_unique(
&mut out,
Evidence::ClockDriftSecs {
observed: observed.abs(),
tolerance_secs: tol,
},
);
}
if line.contains("schema validation failed") {
let field = extract_kv_str(line, "field");
push_unique(
&mut out,
Evidence::JsonValidationError {
field,
message: "schema validation failed".into(),
},
);
}
if line.contains("burst above limit") {
if let Some(retry) = extract_kv_u64(line, "retry_after_secs") {
push_unique(&mut out, Evidence::RetryAfterSecs(retry));
}
if let (Some(observed), Some(limit)) = (
extract_kv_u32(line, "observed_rps"),
extract_kv_u32(line, "limit_rps"),
) {
push_unique(
&mut out,
Evidence::RateLimitObserved {
observed_rps: observed,
limit_rps: limit,
},
);
}
}
if line.contains("name resolution failed") {
if let Some(host) = extract_kv_str(line, "host") {
let message =
extract_kv_str(line, "error").unwrap_or_else(|| "no such host".into());
push_unique(&mut out, Evidence::DnsResolutionFailed { host, message });
}
}
if line.contains("tls handshake failed") {
if let Some(peer) = extract_kv_str(line, "peer") {
let reason =
extract_kv_str(line, "error").unwrap_or_else(|| "tls handshake failed".into());
push_unique(&mut out, Evidence::TlsHandshakeFailed { peer, reason });
}
}
if line.contains("upstream timeout") {
if let (Some(elapsed), Some(timeout)) = (
extract_kv_u64(line, "elapsed_ms"),
extract_kv_u64(line, "timeout_ms"),
) {
push_unique(
&mut out,
Evidence::ConnectionTimeout {
elapsed_ms: elapsed,
timeout_ms: timeout,
},
);
}
}
}
out
}
fn push_unique(out: &mut Vec<Evidence>, ev: Evidence) {
if !out.contains(&ev) {
out.push(ev);
}
}
fn parse_validation_error_body(body: &str) -> Option<Evidence> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
let err = value.get("error")?;
let code = err.get("code")?.as_str()?;
if code == "validation_failed" {
let field = err
.get("field")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("validation failed")
.to_string();
Some(Evidence::JsonValidationError { field, message })
} else {
None
}
}
fn extract_kv_str(line: &str, key: &str) -> Option<String> {
let needle = format!("{key}=");
let mut search_from = 0;
let start = loop {
let rel = line[search_from..].find(&needle)?;
let abs = search_from + rel;
let preceded_by_boundary = abs == 0
|| line[..abs]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
if preceded_by_boundary {
break abs + needle.len();
}
search_from = abs + 1;
};
let rest = &line[start..];
if let Some(stripped) = rest.strip_prefix('"') {
let end = stripped.find('"')?;
Some(stripped[..end].to_string())
} else {
let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
Some(rest[..end].to_string())
}
}
fn extract_kv_u64(line: &str, key: &str) -> Option<u64> {
extract_kv_str(line, key).and_then(|s| s.parse().ok())
}
fn extract_kv_u32(line: &str, key: &str) -> Option<u32> {
extract_kv_str(line, key).and_then(|s| s.parse().ok())
}
fn extract_kv_i64(line: &str, key: &str) -> Option<i64> {
extract_kv_str(line, key).and_then(|s| s.parse().ok())
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn parse_log_extracts_signature_mismatch_and_drift() {
let log = "2026-05-11T08:04:40.005Z DEBUG webhook.verify msg=\"computing HMAC\" \
drift_secs=360 tolerance_secs=300\n\
2026-05-11T08:04:40.006Z WARN webhook.verify \
msg=\"signature verification failed\" reason=signature_mismatch";
let ev = parse_log(log);
assert!(ev.contains(&Evidence::SignatureMismatch));
assert!(ev.contains(&Evidence::ClockDriftSecs {
observed: 360,
tolerance_secs: 300
}));
}
#[test]
fn parse_log_extracts_dns_failure() {
let log = "2026-05-11T08:08:20.140Z ERROR http.client \
msg=\"name resolution failed\" host=api.exmaple.com error=\"no such host\"";
let ev = parse_log(log);
assert_eq!(
ev,
vec![Evidence::DnsResolutionFailed {
host: "api.exmaple.com".into(),
message: "no such host".into(),
}]
);
}
#[test]
fn parse_log_extracts_rate_limit_burst() {
let log = "2026-05-11T08:03:20.000Z WARN ratelimit msg=\"burst above limit\" \
account=acct_*** observed_rps=112 limit_rps=100 retry_after_secs=12";
let ev = parse_log(log);
assert!(ev.contains(&Evidence::RetryAfterSecs(12)));
assert!(ev.contains(&Evidence::RateLimitObserved {
observed_rps: 112,
limit_rps: 100
}));
}
#[test]
fn parse_log_extracts_timeout() {
let log = "2026-05-11T08:06:45.012Z WARN http.client msg=\"upstream timeout\" \
elapsed_ms=5012 timeout_ms=5000";
let ev = parse_log(log);
assert_eq!(
ev,
vec![Evidence::ConnectionTimeout {
elapsed_ms: 5012,
timeout_ms: 5000
}]
);
}
#[test]
fn parse_log_extracts_validation_error() {
let log = "2026-05-11T08:01:40.022Z WARN charges.handler \
msg=\"schema validation failed\" field=amount expected=integer got=string";
let ev = parse_log(log);
assert_eq!(
ev,
vec![Evidence::JsonValidationError {
field: Some("amount".into()),
message: "schema validation failed".into(),
}]
);
}
#[test]
fn extract_kv_handles_quoted_value_with_spaces() {
let line = "msg=\"value with spaces\" host=example.com";
assert_eq!(
extract_kv_str(line, "msg"),
Some("value with spaces".into())
);
assert_eq!(extract_kv_str(line, "host"), Some("example.com".into()));
}
#[test]
fn extract_kv_returns_none_for_absent_key() {
let line = "host=example.com retry_after_secs=5";
assert_eq!(extract_kv_str(line, "absent"), None);
assert_eq!(extract_kv_u64(line, "absent"), None);
}
#[test]
fn extract_kv_rejects_malformed_numerics() {
let line = "elapsed_ms=oops timeout_ms=not_a_number";
assert_eq!(extract_kv_u64(line, "elapsed_ms"), None);
assert_eq!(extract_kv_u64(line, "timeout_ms"), None);
}
#[test]
fn extract_kv_does_not_match_inside_a_longer_key() {
let line = "prefixed_key=should_not_match key=found";
assert_eq!(extract_kv_str(line, "key"), Some("found".into()));
}
#[test]
fn parse_log_ignores_blank_and_unknown_lines() {
let log = "\n\n\
2026-05-11T08:00:00.000Z INFO http.server msg=\"healthz ok\"\n\
\n";
assert!(parse_log(log).is_empty());
}
#[test]
fn parse_log_does_not_emit_phantom_tls_for_abort_line() {
let log = "2026-05-11T08:11:40.142Z ERROR http.client msg=\"tls handshake failed\" peer=api.example.com error=\"certificate has expired\"\n\
2026-05-11T08:11:40.156Z WARN http.client msg=\"aborting request: tls handshake failed\" elapsed_ms=156";
let ev = parse_log(log);
assert_eq!(
ev,
vec![Evidence::TlsHandshakeFailed {
peer: "api.example.com".into(),
reason: "certificate has expired".into(),
}],
"abort line without `peer=` must not produce a second TlsHandshakeFailed"
);
}
#[test]
fn parse_log_does_not_emit_phantom_dns_for_abort_line() {
let log = "2026-05-11T08:08:20.140Z ERROR http.client msg=\"name resolution failed\" host=api.exmaple.com error=\"no such host\"\n\
2026-05-11T08:08:20.142Z WARN http.client msg=\"aborting request: name resolution failed\" elapsed_ms=142";
let ev = parse_log(log);
assert_eq!(
ev,
vec![Evidence::DnsResolutionFailed {
host: "api.exmaple.com".into(),
message: "no such host".into(),
}],
"abort line without `host=` must not produce a second DnsResolutionFailed"
);
}
}