Skip to main content

document_svg/document/
har.rs

1//! Bounded HTTP Archive (HAR 1.2) network-log previews.
2//!
3//! HAR files can contain credentials, cookies and response bodies. This
4//! adapter intentionally renders only an inert request/response inventory:
5//! sensitive query values are masked, headers/cookies/bodies are omitted, and
6//! no URL or network transaction is ever followed.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_HAR_BYTES: u64 = 128 * 1024 * 1024;
17const MAX_HAR_DEPTH: usize = 100;
18const MAX_HAR_VALUES: usize = 400_000;
19const MAX_HAR_ENTRIES: usize = 200_000;
20const MAX_HAR_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_HAR_TEXT_BYTES: usize = 64 * 1024 * 1024;
22
23pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
24    let text = String::from_utf8_lossy(prefix);
25    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
26    trimmed.starts_with('{')
27        && text.contains("\"log\"")
28        && text.contains("\"entries\"")
29        && text.contains("\"version\"")
30}
31
32struct HarPageSink<'a> {
33    inner: &'a mut dyn PageConsumer,
34    warnings: &'a [String],
35}
36
37impl PageConsumer for HarPageSink<'_> {
38    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
39        page.source_format = "har".into();
40        if page.title.is_empty() {
41            page.title = "HAR network archive".into();
42        }
43        page.description =
44            "HAR request and response metadata are rendered safely; headers, cookies and bodies are omitted".into();
45        for warning in self.warnings {
46            page.warn(warning.clone());
47        }
48        self.inner.consume(page)
49    }
50}
51
52pub(crate) fn convert(
53    path: &Path,
54    options: &ConvertOptions,
55    sink: &mut dyn PageConsumer,
56) -> Result<Vec<String>> {
57    let bytes = read_limited_file(
58        path,
59        options.max_input_bytes.min(MAX_HAR_BYTES),
60        "HAR input",
61    )?;
62    let text = String::from_utf8(bytes)
63        .map_err(|error| Error::InvalidInput(format!("HAR input must be UTF-8: {error}")))?;
64    let (table, metadata, warnings) = parse_har(&text)?;
65    let blocks = vec![
66        HtmlBlock::Heading {
67            level: 1,
68            text: "HAR network archive".into(),
69        },
70        HtmlBlock::Paragraph { text: metadata },
71        HtmlBlock::Table(table),
72    ];
73    let mut page_sink = HarPageSink {
74        inner: sink,
75        warnings: &warnings,
76    };
77    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
78    Ok(warnings)
79}
80
81fn parse_har(text: &str) -> Result<(TableData, String, Vec<String>)> {
82    if text.len() as u64 > MAX_HAR_BYTES || text.len() > MAX_HAR_TEXT_BYTES {
83        return Err(Error::LimitExceeded(format!(
84            "HAR input exceeds {MAX_HAR_BYTES} bytes"
85        )));
86    }
87    preflight_depth(text)?;
88    let value: Value = serde_json::from_str(text)
89        .map_err(|error| Error::InvalidInput(format!("invalid HAR JSON: {error}")))?;
90    let mut count = 0usize;
91    count_values(&value, 0, &mut count)?;
92    let root = value
93        .as_object()
94        .ok_or_else(|| Error::InvalidInput("HAR root must be an object".into()))?;
95    let log = root
96        .get("log")
97        .and_then(Value::as_object)
98        .ok_or_else(|| Error::InvalidInput("HAR root requires a log object".into()))?;
99    let version = log
100        .get("version")
101        .and_then(Value::as_str)
102        .ok_or_else(|| Error::InvalidInput("HAR log requires a version string".into()))?;
103    let entries = log
104        .get("entries")
105        .and_then(Value::as_array)
106        .ok_or_else(|| Error::InvalidInput("HAR log requires an entries array".into()))?;
107    if entries.len() > MAX_HAR_ENTRIES {
108        return Err(Error::LimitExceeded(format!(
109            "HAR entries exceed {MAX_HAR_ENTRIES}"
110        )));
111    }
112    let creator = log
113        .get("creator")
114        .and_then(Value::as_object)
115        .map(|v| {
116            let name = v.get("name").and_then(Value::as_str).unwrap_or_default();
117            let version = v.get("version").and_then(Value::as_str).unwrap_or_default();
118            if name.is_empty() {
119                String::new()
120            } else if version.is_empty() {
121                name.into()
122            } else {
123                format!("{name} {version}")
124            }
125        })
126        .unwrap_or_default();
127    let pages = log
128        .get("pages")
129        .and_then(Value::as_array)
130        .map_or(0, Vec::len);
131    let mut rows = Vec::with_capacity(entries.len().min(MAX_HAR_ENTRIES));
132    let mut masked_values = 0usize;
133    let mut omitted_bodies = 0usize;
134    let mut total_time = 0.0f64;
135    for entry in entries {
136        let Some(entry) = entry.as_object() else {
137            continue;
138        };
139        let request = entry
140            .get("request")
141            .and_then(Value::as_object)
142            .ok_or_else(|| Error::InvalidInput("HAR entry is missing request object".into()))?;
143        let response = entry.get("response").and_then(Value::as_object);
144        let method = request
145            .get("method")
146            .and_then(Value::as_str)
147            .unwrap_or("?")
148            .to_ascii_uppercase();
149        let url = request
150            .get("url")
151            .and_then(Value::as_str)
152            .unwrap_or("(missing URL)");
153        let (safe_url, masked) = mask_url(url);
154        masked_values += masked;
155        let status = response
156            .and_then(|v| v.get("status"))
157            .and_then(Value::as_i64)
158            .map_or_else(|| "-".into(), |v| v.to_string());
159        let mime = response
160            .and_then(|v| v.get("content"))
161            .and_then(Value::as_object)
162            .and_then(|v| v.get("mimeType"))
163            .and_then(Value::as_str)
164            .unwrap_or_default();
165        let response_size = response
166            .and_then(|v| v.get("content"))
167            .and_then(Value::as_object)
168            .and_then(|v| v.get("size"))
169            .and_then(Value::as_i64)
170            .or_else(|| {
171                response
172                    .and_then(|v| v.get("bodySize"))
173                    .and_then(Value::as_i64)
174            })
175            .map_or_else(|| "-".into(), |v| v.to_string());
176        let request_size = request
177            .get("bodySize")
178            .and_then(Value::as_i64)
179            .map_or_else(|| "-".into(), |v| v.to_string());
180        if request.get("postData").is_some() || response.and_then(|v| v.get("content")).is_some() {
181            omitted_bodies += 1;
182        }
183        let time = entry.get("time").and_then(Value::as_f64).unwrap_or(0.0);
184        if time.is_finite() {
185            total_time += time;
186        }
187        rows.push(vec![
188            method,
189            safe_url,
190            if mime.is_empty() {
191                status
192            } else {
193                format!("{status} {mime}")
194            },
195            format!("{request_size} / {response_size}"),
196            format_time(time),
197        ]);
198    }
199    let mut warnings = vec![
200        "HAR may contain privacy/security-sensitive data; headers, cookies, request bodies, response bodies and query values with secret-like names are omitted or masked".into(),
201        "HAR URLs are displayed only and are never fetched or replayed".into(),
202    ];
203    if masked_values > 0 {
204        warnings.push(format!(
205            "{masked_values} HAR URL query value(s) were masked"
206        ));
207    }
208    if omitted_bodies > 0 {
209        warnings.push(format!(
210            "{omitted_bodies} HAR entr{} had request or response body data omitted",
211            if omitted_bodies == 1 { "y" } else { "ies" }
212        ));
213    }
214    let mut metadata = vec![
215        format!("HAR version: {version}"),
216        format!("Entries: {}", rows.len()),
217        format!("Pages: {pages}"),
218    ];
219    if !creator.is_empty() {
220        metadata.push(format!("Creator: {creator}"));
221    }
222    metadata.push(format!(
223        "Total recorded time: {} ms",
224        format_time(total_time)
225    ));
226    Ok((
227        TableData {
228            headers: vec![
229                "Method".into(),
230                "URL".into(),
231                "Status / MIME".into(),
232                "Req / resp bytes".into(),
233                "Time ms".into(),
234            ],
235            rows,
236            alignments: vec![TableAlign::Left; 5],
237            raw_source: String::new(),
238        },
239        metadata.join("\n"),
240        warnings,
241    ))
242}
243
244fn mask_url(url: &str) -> (String, usize) {
245    let Some((prefix, query)) = url.split_once('?') else {
246        return (url.to_owned(), 0);
247    };
248    let mut count = 0usize;
249    let masked = query
250        .split('&')
251        .map(|part| {
252            let Some((key, value)) = part.split_once('=') else {
253                return part.to_owned();
254            };
255            if is_sensitive_name(key) {
256                count += 1;
257                format!("{key}=***")
258            } else {
259                format!("{key}={value}")
260            }
261        })
262        .collect::<Vec<_>>()
263        .join("&");
264    (format!("{prefix}?{masked}"), count)
265}
266
267fn is_sensitive_name(name: &str) -> bool {
268    let name = name.to_ascii_lowercase().replace(['-', '_'], "");
269    [
270        "token",
271        "secret",
272        "password",
273        "apikey",
274        "authorization",
275        "cookie",
276        "session",
277        "credential",
278    ]
279    .iter()
280    .any(|needle| name.contains(needle))
281}
282
283fn format_time(value: f64) -> String {
284    if !value.is_finite() || value < 0.0 {
285        return "-".into();
286    }
287    format!("{value:.2}")
288}
289
290fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
291    if depth > MAX_HAR_DEPTH {
292        return Err(Error::LimitExceeded(format!(
293            "HAR nesting exceeds {MAX_HAR_DEPTH} levels"
294        )));
295    }
296    *count = count.saturating_add(1);
297    if *count > MAX_HAR_VALUES {
298        return Err(Error::LimitExceeded(format!(
299            "HAR document contains more than {MAX_HAR_VALUES} values"
300        )));
301    }
302    match value {
303        Value::Array(values) => {
304            for item in values {
305                count_values(item, depth + 1, count)?;
306            }
307        }
308        Value::Object(map) => {
309            for item in map.values() {
310                count_values(item, depth + 1, count)?;
311            }
312        }
313        Value::String(text) if text.len() > MAX_HAR_STRING_BYTES => {
314            return Err(Error::LimitExceeded(format!(
315                "HAR string exceeds {MAX_HAR_STRING_BYTES} bytes"
316            )));
317        }
318        _ => {}
319    }
320    Ok(())
321}
322
323fn preflight_depth(text: &str) -> Result<()> {
324    let mut depth = 0usize;
325    let mut quoted = false;
326    let mut escaped = false;
327    for byte in text.bytes() {
328        if quoted {
329            if escaped {
330                escaped = false;
331            } else if byte == b'\\' {
332                escaped = true;
333            } else if byte == b'"' {
334                quoted = false;
335            }
336            continue;
337        }
338        match byte {
339            b'"' => quoted = true,
340            b'{' | b'[' => {
341                depth += 1;
342                if depth > MAX_HAR_DEPTH {
343                    return Err(Error::LimitExceeded(format!(
344                        "HAR nesting exceeds {MAX_HAR_DEPTH} levels"
345                    )));
346                }
347            }
348            b'}' | b']' => depth = depth.saturating_sub(1),
349            _ => {}
350        }
351    }
352    Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn previews_har_entries_and_masks_sensitive_query_values() {
361        let source = r#"{"log":{"version":"1.2","creator":{"name":"Browser","version":"1"},"pages":[],"entries":[{"time":12.345,"request":{"method":"GET","url":"https://api.example.invalid/pets?api_key=secret&limit=2","headers":[{"name":"Authorization","value":"Bearer token"}],"cookies":[{"name":"sid","value":"cookie"}],"bodySize":0,"postData":{"text":"secret body"}},"response":{"status":200,"content":{"mimeType":"application/json","size":42,"text":"secret response"}}}]}}"#;
362        let (table, metadata, warnings) = parse_har(source).unwrap();
363        assert!(metadata.contains("HAR version: 1.2"));
364        assert_eq!(table.rows[0][0], "GET");
365        assert!(table.rows[0][1].contains("api_key=***"));
366        assert!(!table.rows[0][1].contains("secret"));
367        assert!(
368            warnings
369                .iter()
370                .any(|warning| warning.contains("never fetched"))
371        );
372    }
373
374    #[test]
375    fn rejects_non_har_json() {
376        assert!(parse_har("{\"log\":{\"version\":\"1.2\"}}").is_err());
377    }
378}