use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Evidence {
HttpResponse {
#[serde(deserialize_with = "deserialize_http_status")]
status: u16,
headers: Vec<(Arc<str>, Arc<str>)>,
body_excerpt: Option<Arc<str>>,
},
DnsRecord {
record_type: Arc<str>,
value: Arc<str>,
},
Banner {
raw: Arc<str>,
},
JsSnippet {
url: Arc<str>,
#[serde(deserialize_with = "deserialize_positive_usize")]
line: usize,
snippet: Arc<str>,
},
Certificate {
subject: Arc<str>,
san: Vec<Arc<str>>,
issuer: Arc<str>,
expires: Arc<str>,
},
CodeSnippet {
file: Arc<str>,
#[serde(deserialize_with = "deserialize_positive_usize")]
line: usize,
#[serde(default, deserialize_with = "deserialize_optional_positive_usize")]
column: Option<usize>,
snippet: Arc<str>,
language: Option<Arc<str>>,
},
HttpRequest {
method: Arc<str>,
url: Arc<str>,
headers: Vec<(Arc<str>, Arc<str>)>,
body: Option<Arc<str>>,
},
PatternMatch {
pattern: Arc<str>,
matched: Arc<str>,
},
Raw(Arc<str>),
}
impl Evidence {
pub fn http_status(status: u16) -> Result<Self, &'static str> {
if !(100..=599).contains(&status) {
return Err(
"HTTP status code must be between 100 and 599. Fix: pass a valid RFC HTTP status code.",
);
}
Ok(Self::HttpResponse {
status,
headers: vec![],
body_excerpt: None,
})
}
pub fn code(
file: impl Into<String>,
line: usize,
snippet: impl Into<String>,
column: Option<usize>,
language: Option<String>,
) -> Result<Self, &'static str> {
if line == 0 {
return Err(
"line values must be 1 or greater. Fix: pass a positive source line number.",
);
}
if let Some(0) = column {
return Err(
"column values must be 1 or greater. Fix: pass a positive source column number.",
);
}
Ok(Self::CodeSnippet {
file: Arc::from(file.into()),
line,
column,
snippet: Arc::from(snippet.into()),
language: language.map(Arc::from),
})
}
}
fn deserialize_http_status<'de, D>(deserializer: D) -> Result<u16, D::Error>
where
D: serde::Deserializer<'de>,
{
let status = u16::deserialize(deserializer)?;
if !(100..=599).contains(&status) {
return Err(serde::de::Error::custom(
"HTTP status code must be between 100 and 599. Fix: pass a valid RFC HTTP status code.",
));
}
Ok(status)
}
fn deserialize_positive_usize<'de, D>(deserializer: D) -> Result<usize, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = usize::deserialize(deserializer)?;
if value == 0 {
return Err(serde::de::Error::custom(
"line values must be 1 or greater. Fix: pass a positive source line number.",
));
}
Ok(value)
}
fn deserialize_optional_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<usize>::deserialize(deserializer)?;
match value {
Some(0) => Err(serde::de::Error::custom(
"column values must be 1 or greater. Fix: pass a positive source column number.",
)),
_ => Ok(value),
}
}
fn fmt_http_response(
f: &mut std::fmt::Formatter<'_>,
status: u16,
headers: &[(Arc<str>, Arc<str>)],
body_excerpt: Option<&Arc<str>>,
) -> std::fmt::Result {
let excerpt = body_excerpt.as_ref().map_or_else(
|| "none".to_string(),
|s| format!("<redacted,len={}>", s.len()),
);
write!(
f,
"http-response status={status} headers={} body_excerpt={excerpt}",
headers.len()
)
}
fn fmt_http_request(
f: &mut std::fmt::Formatter<'_>,
method: &str,
url: &str,
headers: &[(Arc<str>, Arc<str>)],
body: Option<&Arc<str>>,
) -> std::fmt::Result {
let body_info = body.as_ref().map_or_else(
|| "none".to_string(),
|b| format!("<redacted,len={}>", b.len()),
);
write!(
f,
"http-request:{method} {url} headers={} body={body_info}",
headers.len()
)
}
fn fmt_code_snippet(
f: &mut std::fmt::Formatter<'_>,
file: &str,
line: usize,
language: Option<&Arc<str>>,
) -> std::fmt::Result {
if let Some(lang) = language {
write!(f, "code-snippet:{file}:{line} [{lang}]")
} else {
write!(f, "code-snippet:{file}:{line}")
}
}
impl std::fmt::Display for Evidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::HttpResponse {
status,
headers,
body_excerpt,
} => fmt_http_response(f, *status, headers, body_excerpt.as_ref()),
Self::DnsRecord { record_type, .. } => write!(f, "dns:{record_type}"),
Self::Banner { raw } => write!(f, "banner<len={}>", raw.len()),
Self::JsSnippet { url, line, .. } => write!(f, "js-snippet:{url}:{line}"),
Self::Certificate {
subject,
issuer,
san,
..
} => write!(
f,
"certificate:{subject} issuer={issuer} san_count={}",
san.len()
),
Self::CodeSnippet {
file,
line,
language,
..
} => fmt_code_snippet(f, file, *line, language.as_ref()),
Self::HttpRequest {
method,
url,
headers,
body,
} => fmt_http_request(f, method, url, headers, body.as_ref()),
Self::PatternMatch { pattern, matched } => write!(
f,
"pattern-match:{pattern} => <redacted,len={}>",
matched.len()
),
Self::Raw(value) => write!(f, "raw:<redacted,len={}>", value.len()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_tagged() {
let ev = Evidence::HttpResponse {
status: 403,
headers: vec![("server".into(), "cloudflare".into())],
body_excerpt: Some("blocked".into()),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["type"], "http_response");
assert_eq!(json["status"], 403);
}
#[test]
fn code_snippet_roundtrip() {
let ev = Evidence::code("src/main.rs", 42, "let key = \"AKIA...\";", None, None).unwrap();
let json = serde_json::to_string(&ev).unwrap();
let back: Evidence = serde_json::from_str(&json).unwrap();
if let Evidence::CodeSnippet {
file,
line,
snippet,
..
} = back
{
assert_eq!(file.as_ref(), "src/main.rs");
assert_eq!(line, 42);
assert_eq!(snippet.as_ref(), "let key = \"AKIA...\";");
} else {
panic!("wrong variant");
}
}
#[test]
fn helper_constructors_roundtrip() {
let ev = Evidence::http_status(201).unwrap();
let json = serde_json::to_string(&ev).unwrap();
let back: Evidence = serde_json::from_str(&json).unwrap();
if let Evidence::HttpResponse {
status,
headers,
body_excerpt,
} = back
{
assert_eq!(status, 201);
assert!(headers.is_empty());
assert!(body_excerpt.is_none());
} else {
panic!("wrong variant");
}
let snippet = Evidence::code("lib.rs", 10, "secret = 'x'", None, None).unwrap();
let json = serde_json::to_string(&snippet).unwrap();
let back: Evidence = serde_json::from_str(&json).unwrap();
if let Evidence::CodeSnippet { line, snippet, .. } = back {
assert_eq!(line, 10);
assert!(snippet.contains("secret"));
} else {
panic!("wrong variant");
}
}
#[test]
fn serde_multiple_evidence_variants() {
let samples = vec![
Evidence::HttpRequest {
method: "GET".into(),
url: "https://example.com/login".into(),
headers: vec![("host".into(), "example.com".into())],
body: Some("a=1".into()),
},
Evidence::Certificate {
subject: "CN=example".into(),
san: vec!["DNS:example.com".into()],
issuer: "Let's Encrypt".into(),
expires: "2028-01-01".into(),
},
Evidence::PatternMatch {
pattern: "api_key=[A-Za-z]+".into(),
matched: "api_key=abc".into(),
},
];
for sample in samples {
let json = serde_json::to_string(&sample).unwrap();
let back: Evidence = serde_json::from_str(&json).unwrap();
match (sample, back) {
(
Evidence::HttpRequest { method: m1, .. },
Evidence::HttpRequest { method: m2, .. },
) => {
assert_eq!(m1, m2);
}
(
Evidence::Certificate { subject: s1, .. },
Evidence::Certificate { subject: s2, .. },
) => {
assert_eq!(s1, s2);
}
(
Evidence::PatternMatch { pattern: p1, .. },
Evidence::PatternMatch { pattern: p2, .. },
) => {
assert_eq!(p1, p2);
}
_ => panic!("roundtrip mismatch"),
}
}
}
}