1use crate::model::{Capture, Entry};
2use serde::Serialize;
3
4#[derive(Debug, Serialize)]
5pub struct ValidateResult {
6 pub har_version: String,
7 pub creator: String,
8 pub entry_count: usize,
9 pub pct_with_timings: f64,
10 pub pct_with_resp_body: f64,
11 pub pct_post_with_req_body: f64,
12 pub with_auth: bool,
13 pub with_cookies: bool,
14 pub anomalies: Vec<Anomaly>,
15 pub sanitized: bool,
16 pub sufficiency_notes: Vec<String>,
17}
18
19#[derive(Debug, Serialize)]
20pub struct Anomaly {
21 pub kind: String,
22 pub count: usize,
23}
24
25fn has_header(e: &Entry, name: &str) -> bool {
26 e.req_headers
27 .iter()
28 .any(|(n, _)| n.eq_ignore_ascii_case(name))
29}
30
31fn has_body(b: &Option<String>) -> bool {
32 b.as_deref().is_some_and(|s| !s.is_empty())
33}
34
35pub fn compute_validate(cap: &Capture) -> ValidateResult {
37 let n = cap.entries.len();
38 let denom = n.max(1) as f64;
39
40 let with_timings = cap
41 .entries
42 .iter()
43 .filter(|e| e.timings.wait > 0.0 || e.timings.receive > 0.0 || e.timings.send > 0.0)
44 .count();
45 let with_resp_body = cap
46 .entries
47 .iter()
48 .filter(|e| has_body(&e.resp_body))
49 .count();
50
51 let posts: Vec<&Entry> = cap
52 .entries
53 .iter()
54 .filter(|e| {
55 matches!(
56 e.method.to_ascii_uppercase().as_str(),
57 "POST" | "PUT" | "PATCH"
58 )
59 })
60 .collect();
61 let posts_with_body = posts.iter().filter(|e| has_body(&e.req_body)).count();
62
63 let with_auth = cap.entries.iter().any(|e| has_header(e, "authorization"));
64 let with_cookies = cap.entries.iter().any(|e| has_header(e, "cookie"));
65
66 let count = |pred: &dyn Fn(&Entry) -> bool, kind: &str| -> Option<Anomaly> {
67 let c = cap.entries.iter().filter(|e| pred(e)).count();
68 if c > 0 {
69 Some(Anomaly {
70 kind: kind.to_string(),
71 count: c,
72 })
73 } else {
74 None
75 }
76 };
77 let mut anomalies = Vec::new();
78 if let Some(a) = count(&|e| e.status == 0, "status-0") {
79 anomalies.push(a);
80 }
81 if let Some(a) = count(&|e| e.method.is_empty(), "missing-method") {
82 anomalies.push(a);
83 }
84 if let Some(a) = count(
85 &|e| e.duration_ms == 0.0 && has_body(&e.resp_body),
86 "zero-duration-with-body",
87 ) {
88 anomalies.push(a);
89 }
90 if let Some(a) = count(
91 &|e| e.sizes.resp_body < -1 || e.sizes.req_body < -1 || e.sizes.resp_content < -1,
92 "negative-size",
93 ) {
94 anomalies.push(a);
95 }
96
97 let pct_with_resp_body = with_resp_body as f64 / denom;
98 let sanitized = !with_auth && !with_cookies && pct_with_resp_body < 0.10;
99
100 let mut notes = Vec::new();
101 if pct_with_resp_body < 0.10 {
102 notes.push(
103 "few/no response bodies captured — `errors`/`search`/`extract` limited".to_string(),
104 );
105 }
106 if !with_auth {
107 notes.push("no Authorization headers — `auth`/`jwt` limited".to_string());
108 }
109 if !posts.is_empty() && posts_with_body == 0 {
110 notes
111 .push("no request bodies on POST/PUT/PATCH — `diff` body verdicts limited".to_string());
112 }
113 if with_timings == 0 {
114 notes.push("no timing data — `slowest`/`startup` limited".to_string());
115 }
116
117 ValidateResult {
118 har_version: cap.meta.har_version.clone(),
119 creator: cap.meta.creator.clone(),
120 entry_count: n,
121 pct_with_timings: with_timings as f64 / denom,
122 pct_with_resp_body,
123 pct_post_with_req_body: if posts.is_empty() {
124 0.0
125 } else {
126 posts_with_body as f64 / posts.len() as f64
127 },
128 with_auth,
129 with_cookies,
130 anomalies,
131 sanitized,
132 sufficiency_notes: notes,
133 }
134}
135
136fn pct(v: f64) -> String {
137 format!("{:.0}%", v * 100.0)
138}
139
140pub fn render_validate_text(r: &ValidateResult) -> String {
142 let mut out = String::new();
143 out.push_str("== wiretrail validate ==\n");
144 out.push_str(&format!(
145 "HAR {} via {} ({} entries)\n",
146 r.har_version, r.creator, r.entry_count
147 ));
148 out.push_str(&format!(
149 "with timings: {} · response bodies: {} · POST req bodies: {}\n",
150 pct(r.pct_with_timings),
151 pct(r.pct_with_resp_body),
152 pct(r.pct_post_with_req_body)
153 ));
154 out.push_str(&format!(
155 "auth headers: {} · cookies: {}\n",
156 r.with_auth, r.with_cookies
157 ));
158 if r.sanitized {
159 out.push_str("sanitized: yes (no auth/cookies and few response bodies)\n");
160 }
161 if !r.anomalies.is_empty() {
162 out.push_str("\nanomalies:\n");
163 for a in &r.anomalies {
164 out.push_str(&format!(" {}: {}\n", a.kind, a.count));
165 }
166 }
167 if !r.sufficiency_notes.is_empty() {
168 out.push_str("\nsufficiency:\n");
169 for n in &r.sufficiency_notes {
170 out.push_str(&format!(" - {n}\n"));
171 }
172 }
173 out
174}
175
176#[cfg(test)]
177mod tests {
178 use super::compute_validate;
179 use crate::model::{sample_capture, sample_entry};
180
181 #[test]
182 fn flags_sanitized_capture_with_no_bodies_or_auth() {
183 let cap = sample_capture(vec![
184 sample_entry(0, "api.x", "GET", "/a", 200),
185 sample_entry(1, "api.x", "GET", "/b", 200),
186 ]);
187 let r = compute_validate(&cap);
188 assert!(r.sanitized);
189 assert!(!r.with_auth);
190 assert_eq!(r.pct_with_resp_body, 0.0);
191 assert!(
192 r.sufficiency_notes
193 .iter()
194 .any(|n| n.contains("response bodies"))
195 );
196 }
197
198 #[test]
199 fn detects_status_zero_anomaly() {
200 let cap = sample_capture(vec![sample_entry(0, "api.x", "GET", "/a", 0)]);
201 let r = compute_validate(&cap);
202 assert!(
203 r.anomalies
204 .iter()
205 .any(|a| a.kind == "status-0" && a.count == 1)
206 );
207 }
208
209 #[test]
210 fn reports_body_and_auth_presence() {
211 let mut e = sample_entry(0, "api.x", "POST", "/a", 200);
212 e.req_headers = vec![("Authorization".into(), "Bearer x".into())];
213 e.req_body = Some(r#"{"k":1}"#.into());
214 e.resp_body = Some(r#"{"ok":true}"#.into());
215 let r = compute_validate(&sample_capture(vec![e]));
216 assert!(r.with_auth);
217 assert_eq!(r.pct_with_resp_body, 1.0);
218 assert_eq!(r.pct_post_with_req_body, 1.0);
219 assert!(!r.sanitized);
220 }
221}