Skip to main content

browser_automation_cli/
json_util.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Shared JSON / NDJSON helpers (RFC 8259 + I-JSON-oriented CLI contracts).
3//!
4//! Rules (`rules_rust_json_e_ndjson`):
5//! - Machine-to-machine interop uses strict RFC 8259 via `serde_json` (not JSON5)
6//! - Strip UTF-8 BOM before parse (serde_json rejects `\u{FEFF}` at root)
7//! - Bound untrusted file / line size before allocating full buffers
8//! - NDJSON = one complete JSON value per LF line; compact (no pretty print)
9//! - Prefer typed structs at domain boundaries; `Value` only for dynamic agent steps
10//!
11//! Content-Type notes (this product is a **CLI**, not an HTTP server):
12//! - stdout JSON envelopes are single-line compact objects (`application/json` semantics)
13//! - `--json-steps` emits NDJSON (`application/x-ndjson` / `application/jsonl` semantics)
14//! - HTTP Content-Type headers are N/A until an HTTP surface exists
15
16use std::fs::{self, File};
17use std::io::{self, BufWriter, Write};
18use std::path::Path;
19
20use serde::de::DeserializeOwned;
21use serde::Serialize;
22use serde_json::Value;
23
24use crate::error::{CliError, ErrorKind};
25
26/// Hard ceiling for a single JSON / NDJSON **script or manifest** file (untrusted path).
27pub const MAX_JSON_FILE_BYTES: u64 = 32 * 1024 * 1024;
28
29/// Hard ceiling for one NDJSON line (DoS / accidental multi-MB line).
30pub const MAX_NDJSON_LINE_BYTES: usize = 1024 * 1024;
31
32/// Soft ceiling for CLI flag payloads (`--fields-json`, cookie JSON, etc.).
33pub const MAX_CLI_JSON_PAYLOAD_BYTES: usize = 4 * 1024 * 1024;
34
35/// UTF-8 BOM (`U+FEFF`) as bytes.
36const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
37
38/// Strip a leading UTF-8 BOM from a string slice (idempotent).
39///
40/// Windows editors and some HTTP clients still emit BOM; RFC 8259 JSON does not
41/// allow it, and `serde_json::from_str` fails with a syntax error at column 1.
42#[inline]
43pub fn strip_utf8_bom(s: &str) -> &str {
44    s.strip_prefix('\u{FEFF}').unwrap_or(s)
45}
46
47/// Strip a leading UTF-8 BOM from a byte slice.
48#[inline]
49pub fn strip_utf8_bom_bytes(bytes: &[u8]) -> &[u8] {
50    bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes)
51}
52
53/// Parse JSON from a UTF-8 string after BOM strip.
54pub fn from_str<T: DeserializeOwned>(s: &str) -> Result<T, serde_json::Error> {
55    serde_json::from_str(strip_utf8_bom(s.trim_start_matches('\u{FEFF}')))
56}
57
58/// Parse JSON from bytes after BOM strip (validates UTF-8 when needed via `from_slice`).
59pub fn from_slice<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, serde_json::Error> {
60    serde_json::from_slice(strip_utf8_bom_bytes(bytes))
61}
62
63/// Parse a dynamic [`Value`] from a string (BOM-aware).
64pub fn value_from_str(s: &str) -> Result<Value, serde_json::Error> {
65    from_str(s)
66}
67
68/// Map a `serde_json` parse error into a domain [`CliError`] with context.
69///
70/// Takes `&serde_json::Error` (Display/line/column only) — never consumes the
71/// error value when the caller still needs it (rules: smallest permission).
72pub fn map_parse_err(ctx: &str, e: &serde_json::Error) -> CliError {
73    CliError::new(
74        ErrorKind::Data,
75        format!("{ctx}: invalid JSON (line {} column {}): {e}", e.line(), e.column()),
76    )
77}
78
79/// Parse CLI flag / inline payload JSON with size guard + BOM strip.
80pub fn parse_cli_json_value(raw: &str, ctx: &str) -> Result<Value, CliError> {
81    if raw.len() > MAX_CLI_JSON_PAYLOAD_BYTES {
82        return Err(CliError::with_suggestion(
83            ErrorKind::Data,
84            format!(
85                "{ctx}: JSON payload too large ({} bytes > {MAX_CLI_JSON_PAYLOAD_BYTES})",
86                raw.len()
87            ),
88            "Pass a smaller payload or a file path when the command supports one",
89        ));
90    }
91    value_from_str(raw).map_err(|e| map_parse_err(ctx, &e))
92}
93
94/// Read a UTF-8 text file with an explicit byte ceiling (metadata + full read).
95///
96/// Returns the file body **without** a leading BOM (stripped after read).
97pub fn read_text_file_limited(path: &Path, max_bytes: u64) -> Result<String, CliError> {
98    let meta = fs::metadata(path).map_err(|e| {
99        CliError::new(
100            ErrorKind::Io,
101            format!("stat {}: {e}", path.display()),
102        )
103    })?;
104    let len = meta.len();
105    if len > max_bytes {
106        return Err(CliError::with_suggestion(
107            ErrorKind::Data,
108            format!(
109                "file {} is too large ({} bytes > {max_bytes})",
110                path.display(),
111                len
112            ),
113            "Split the input or raise the product limit only after measuring need",
114        ));
115    }
116    let mut raw = String::new();
117    raw.try_reserve_exact(len as usize).map_err(|e| {
118        CliError::new(
119            ErrorKind::Software,
120            format!("reserve {} bytes for {}: {e}", len, path.display()),
121        )
122    })?;
123    let file = File::open(path).map_err(|e| {
124        CliError::new(
125            ErrorKind::Io,
126            format!("open {}: {e}", path.display()),
127        )
128    })?;
129    let mut reader = io::BufReader::new(file);
130    use std::io::Read;
131    reader.read_to_string(&mut raw).map_err(|e| {
132        CliError::new(
133            ErrorKind::Io,
134            format!("read {}: {e}", path.display()),
135        )
136    })?;
137    // Own a BOM-free string for downstream `from_str` / line iterators.
138    if raw.starts_with('\u{FEFF}') {
139        Ok(raw.trim_start_matches('\u{FEFF}').to_string())
140    } else {
141        Ok(raw)
142    }
143}
144
145/// Read + parse a typed JSON file (BOM + size limited).
146pub fn read_json_file<T: DeserializeOwned>(path: &Path, max_bytes: u64) -> Result<T, CliError> {
147    let raw = read_text_file_limited(path, max_bytes)?;
148    from_str(&raw).map_err(|e| {
149        map_parse_err(&format!("parse {}", path.display()), &e)
150    })
151}
152
153/// Read + parse a dynamic JSON [`Value`] from a file.
154pub fn read_json_value_file(path: &Path, max_bytes: u64) -> Result<Value, CliError> {
155    read_json_file(path, max_bytes)
156}
157
158/// Reject an NDJSON line that exceeds the per-line ceiling.
159pub fn check_ndjson_line_len(line: &str, lineno: usize) -> Result<(), CliError> {
160    if line.len() > MAX_NDJSON_LINE_BYTES {
161        return Err(CliError::with_suggestion(
162            ErrorKind::Data,
163            format!(
164                "NDJSON line {lineno}: line too large ({} bytes > {MAX_NDJSON_LINE_BYTES})",
165                line.len()
166            ),
167            "Split the record or use a whole-file JSON array for large steps",
168        ));
169    }
170    Ok(())
171}
172
173/// Serialize `value` as **compact** JSON (machine interop; never pretty).
174pub fn to_compact_string<T: Serialize>(value: &T) -> Result<String, CliError> {
175    serde_json::to_string(value).map_err(|e| {
176        CliError::new(ErrorKind::Software, format!("json encode: {e}"))
177    })
178}
179
180/// Atomic JSON write: temp file in same directory → `BufWriter` → flush → rename.
181///
182/// `pretty = true` only for human-edited artifacts (state dumps, MITM capture review).
183/// Machine pipelines should pass `pretty = false`.
184pub fn write_json_file_atomic<T: Serialize>(
185    path: &Path,
186    value: &T,
187    pretty: bool,
188) -> Result<(), CliError> {
189    if let Some(parent) = path.parent() {
190        if !parent.as_os_str().is_empty() {
191            fs::create_dir_all(parent).map_err(|e| {
192                CliError::new(
193                    ErrorKind::Io,
194                    format!("create parent {}: {e}", parent.display()),
195                )
196            })?;
197        }
198    }
199    let tmp = path.with_extension(format!(
200        "{}.tmp",
201        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
202    ));
203    // Prefer a unique tmp when extension rewrite collides.
204    let tmp = if tmp == path {
205        path.with_extension("json.tmp")
206    } else {
207        tmp
208    };
209    {
210        let file = File::create(&tmp).map_err(|e| {
211            CliError::new(ErrorKind::Io, format!("create temp {}: {e}", tmp.display()))
212        })?;
213        let mut writer = BufWriter::new(file);
214        if pretty {
215            serde_json::to_writer_pretty(&mut writer, value).map_err(|e| {
216                CliError::new(ErrorKind::Software, format!("json encode: {e}"))
217            })?;
218        } else {
219            serde_json::to_writer(&mut writer, value).map_err(|e| {
220                CliError::new(ErrorKind::Software, format!("json encode: {e}"))
221            })?;
222        }
223        writer.write_all(b"\n").map_err(|e| {
224            CliError::new(ErrorKind::Io, format!("json trailing newline: {e}"))
225        })?;
226        writer.flush().map_err(|e| {
227            CliError::new(ErrorKind::Io, format!("json flush: {e}"))
228        })?;
229        writer
230            .into_inner()
231            .map_err(|e| CliError::new(ErrorKind::Io, format!("json into_inner: {e}")))?
232            .sync_all()
233            .map_err(|e| CliError::new(ErrorKind::Io, format!("json fsync: {e}")))?;
234    }
235    fs::rename(&tmp, path).map_err(|e| {
236        let _ = fs::remove_file(&tmp);
237        CliError::new(
238            ErrorKind::Io,
239            format!("rename {} → {}: {e}", tmp.display(), path.display()),
240        )
241    })?;
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249    use std::io::Write;
250    use tempfile::NamedTempFile;
251
252    #[test]
253    fn strips_bom_from_str() {
254        let with_bom = "\u{FEFF}{\"ok\":true}";
255        let v: Value = from_str(with_bom).expect("bom parse");
256        assert_eq!(v, json!({"ok": true}));
257    }
258
259    #[test]
260    fn strips_bom_from_bytes() {
261        let mut bytes = UTF8_BOM.to_vec();
262        bytes.extend_from_slice(br#"{"n":1}"#);
263        let v: Value = from_slice(&bytes).expect("bom bytes");
264        assert_eq!(v["n"], 1);
265    }
266
267    #[test]
268    fn rejects_oversized_cli_payload() {
269        let huge = format!("{{\"x\":\"{}\"}}", "a".repeat(MAX_CLI_JSON_PAYLOAD_BYTES));
270        let err = parse_cli_json_value(&huge, "test").unwrap_err();
271        assert_eq!(err.kind(), ErrorKind::Data);
272    }
273
274    #[test]
275    fn read_file_limited_and_bom() {
276        let mut f = NamedTempFile::new().unwrap();
277        f.write_all(UTF8_BOM).unwrap();
278        f.write_all(br#"{"a":1}"#).unwrap();
279        f.flush().unwrap();
280        let v: Value = read_json_file(f.path(), MAX_JSON_FILE_BYTES).unwrap();
281        assert_eq!(v["a"], 1);
282    }
283
284    #[test]
285    fn ndjson_line_limit() {
286        let line = "x".repeat(MAX_NDJSON_LINE_BYTES + 1);
287        assert!(check_ndjson_line_len(&line, 1).is_err());
288        assert!(check_ndjson_line_len("{}", 1).is_ok());
289    }
290
291    #[test]
292    fn compact_roundtrip() {
293        let v = json!({"schema_version": 1, "ok": true});
294        let s = to_compact_string(&v).unwrap();
295        assert!(!s.contains('\n'));
296        assert!(!s.contains("  "));
297        let back: Value = from_str(&s).unwrap();
298        assert_eq!(back, v);
299    }
300}