Skip to main content

har/analysis/
validate.rs

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
35/// Assess HAR quality and analysis sufficiency.
36pub 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
140/// Render the validation report as deterministic terminal text.
141pub 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}