1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, 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 #[must_use]
103 pub fn http_status(status: u16) -> Self {
104 Self::HttpResponse {
105 status,
106 headers: vec![],
107 body_excerpt: None,
108 }
109 }
110
111 #[must_use]
113 pub fn code(file: impl Into<String>, line: usize, snippet: impl Into<String>) -> Self {
114 Self::CodeSnippet {
115 file: file.into(),
116 line,
117 column: None,
118 snippet: snippet.into(),
119 language: None,
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn serde_tagged() {
130 let ev = Evidence::HttpResponse {
131 status: 403,
132 headers: vec![("server".into(), "cloudflare".into())],
133 body_excerpt: Some("blocked".into()),
134 };
135 let json = serde_json::to_value(&ev).unwrap();
136 assert_eq!(json["type"], "http_response");
137 assert_eq!(json["status"], 403);
138 }
139
140 #[test]
141 fn code_snippet_roundtrip() {
142 let ev = Evidence::code("src/main.rs", 42, "let key = \"AKIA...\";");
143 let json = serde_json::to_string(&ev).unwrap();
144 let back: Evidence = serde_json::from_str(&json).unwrap();
145 if let Evidence::CodeSnippet {
146 file,
147 line,
148 snippet,
149 ..
150 } = back
151 {
152 assert_eq!(file, "src/main.rs");
153 assert_eq!(line, 42);
154 assert!(snippet.contains("AKIA"));
155 } else {
156 panic!("wrong variant");
157 }
158 }
159
160 #[test]
161 fn helper_constructors_roundtrip() {
162 let ev = Evidence::http_status(201);
163 let json = serde_json::to_string(&ev).unwrap();
164 let back: Evidence = serde_json::from_str(&json).unwrap();
165 if let Evidence::HttpResponse {
166 status,
167 headers,
168 body_excerpt,
169 } = back
170 {
171 assert_eq!(status, 201);
172 assert!(headers.is_empty());
173 assert!(body_excerpt.is_none());
174 } else {
175 panic!("wrong variant");
176 }
177
178 let snippet = Evidence::code("lib.rs", 10, "secret = 'x'");
179 let json = serde_json::to_string(&snippet).unwrap();
180 let back: Evidence = serde_json::from_str(&json).unwrap();
181 if let Evidence::CodeSnippet { line, snippet, .. } = back {
182 assert_eq!(line, 10);
183 assert!(snippet.contains("secret"));
184 } else {
185 panic!("wrong variant");
186 }
187 }
188
189 #[test]
190 fn serde_multiple_evidence_variants() {
191 let samples = vec![
192 Evidence::HttpRequest {
193 method: "GET".into(),
194 url: "https://example.com/login".into(),
195 headers: vec![("host".into(), "example.com".into())],
196 body: Some("a=1".into()),
197 },
198 Evidence::Certificate {
199 subject: "CN=example".into(),
200 san: vec!["DNS:example.com".into()],
201 issuer: "Let's Encrypt".into(),
202 expires: "2028-01-01".into(),
203 },
204 Evidence::PatternMatch {
205 pattern: "api_key=[A-Za-z]+".into(),
206 matched: "api_key=abc".into(),
207 },
208 ];
209
210 for sample in samples {
211 let json = serde_json::to_string(&sample).unwrap();
212 let back: Evidence = serde_json::from_str(&json).unwrap();
213 match (sample, back) {
214 (
215 Evidence::HttpRequest { method: m1, .. },
216 Evidence::HttpRequest { method: m2, .. },
217 ) => {
218 assert_eq!(m1, m2);
219 }
220 (
221 Evidence::Certificate { subject: s1, .. },
222 Evidence::Certificate { subject: s2, .. },
223 ) => {
224 assert_eq!(s1, s2);
225 }
226 (
227 Evidence::PatternMatch { pattern: p1, .. },
228 Evidence::PatternMatch { pattern: p2, .. },
229 ) => {
230 assert_eq!(p1, p2);
231 }
232 _ => panic!("roundtrip mismatch"),
233 }
234 }
235 }
236}