1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum Evidence {
16 HttpResponse {
18 status: u16,
20 headers: Vec<(String, String)>,
22 body_excerpt: Option<String>,
24 },
25
26 DnsRecord {
28 record_type: String,
30 value: String,
32 },
33
34 Banner {
36 raw: String,
38 },
39
40 JsSnippet {
42 url: String,
44 line: usize,
46 snippet: String,
48 },
49
50 Certificate {
52 subject: String,
54 san: Vec<String>,
56 issuer: String,
58 expires: String,
60 },
61
62 CodeSnippet {
64 file: String,
66 line: usize,
68 column: Option<usize>,
70 snippet: String,
72 language: Option<String>,
74 },
75
76 HttpRequest {
78 method: String,
80 url: String,
82 headers: Vec<(String, String)>,
84 body: Option<String>,
86 },
87
88 PatternMatch {
90 pattern: String,
92 matched: String,
94 },
95
96 Raw(String),
98}
99
100impl Evidence {
101 pub fn http_status(status: u16) -> Result<Self, &'static str> {
103 if !(100..=599).contains(&status) {
104 return Err(
105 "HTTP status code must be between 100 and 599. Fix: pass a valid RFC HTTP status code.",
106 );
107 }
108 Ok(Self::HttpResponse {
109 status,
110 headers: vec![],
111 body_excerpt: None,
112 })
113 }
114
115 #[must_use]
117 pub fn code(
118 file: impl Into<String>,
119 line: usize,
120 snippet: impl Into<String>,
121 column: Option<usize>,
122 language: Option<String>,
123 ) -> Self {
124 Self::CodeSnippet {
125 file: file.into(),
126 line,
127 column,
128 snippet: snippet.into(),
129 language,
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn serde_tagged() {
140 let ev = Evidence::HttpResponse {
141 status: 403,
142 headers: vec![("server".into(), "cloudflare".into())],
143 body_excerpt: Some("blocked".into()),
144 };
145 let json = serde_json::to_value(&ev).unwrap();
146 assert_eq!(json["type"], "http_response");
147 assert_eq!(json["status"], 403);
148 }
149
150 #[test]
151 fn code_snippet_roundtrip() {
152 let ev = Evidence::code("src/main.rs", 42, "let key = \"AKIA...\";", None, None);
153 let json = serde_json::to_string(&ev).unwrap();
154 let back: Evidence = serde_json::from_str(&json).unwrap();
155 if let Evidence::CodeSnippet {
156 file,
157 line,
158 snippet,
159 ..
160 } = back
161 {
162 assert_eq!(file, "src/main.rs");
163 assert_eq!(line, 42);
164 assert_eq!(snippet, "let key = \"AKIA...\";");
165 } else {
166 panic!("wrong variant");
167 }
168 }
169
170 #[test]
171 fn helper_constructors_roundtrip() {
172 let ev = Evidence::http_status(201).unwrap();
173 let json = serde_json::to_string(&ev).unwrap();
174 let back: Evidence = serde_json::from_str(&json).unwrap();
175 if let Evidence::HttpResponse {
176 status,
177 headers,
178 body_excerpt,
179 } = back
180 {
181 assert_eq!(status, 201);
182 assert!(headers.is_empty());
183 assert!(body_excerpt.is_none());
184 } else {
185 panic!("wrong variant");
186 }
187
188 let snippet = Evidence::code("lib.rs", 10, "secret = 'x'", None, None);
189 let json = serde_json::to_string(&snippet).unwrap();
190 let back: Evidence = serde_json::from_str(&json).unwrap();
191 if let Evidence::CodeSnippet { line, snippet, .. } = back {
192 assert_eq!(line, 10);
193 assert!(snippet.contains("secret"));
194 } else {
195 panic!("wrong variant");
196 }
197 }
198
199 #[test]
200 fn serde_multiple_evidence_variants() {
201 let samples = vec![
202 Evidence::HttpRequest {
203 method: "GET".into(),
204 url: "https://example.com/login".into(),
205 headers: vec![("host".into(), "example.com".into())],
206 body: Some("a=1".into()),
207 },
208 Evidence::Certificate {
209 subject: "CN=example".into(),
210 san: vec!["DNS:example.com".into()],
211 issuer: "Let's Encrypt".into(),
212 expires: "2028-01-01".into(),
213 },
214 Evidence::PatternMatch {
215 pattern: "api_key=[A-Za-z]+".into(),
216 matched: "api_key=abc".into(),
217 },
218 ];
219
220 for sample in samples {
221 let json = serde_json::to_string(&sample).unwrap();
222 let back: Evidence = serde_json::from_str(&json).unwrap();
223 match (sample, back) {
224 (
225 Evidence::HttpRequest { method: m1, .. },
226 Evidence::HttpRequest { method: m2, .. },
227 ) => {
228 assert_eq!(m1, m2);
229 }
230 (
231 Evidence::Certificate { subject: s1, .. },
232 Evidence::Certificate { subject: s2, .. },
233 ) => {
234 assert_eq!(s1, s2);
235 }
236 (
237 Evidence::PatternMatch { pattern: p1, .. },
238 Evidence::PatternMatch { pattern: p2, .. },
239 ) => {
240 assert_eq!(p1, p2);
241 }
242 _ => panic!("roundtrip mismatch"),
243 }
244 }
245 }
246}