1use 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}