Skip to main content

document_svg/document/
json.rs

1//! Streaming, bounded JSON configuration/data preview.
2//!
3//! Values are rendered as inert path/type/value text; no JSON field or URL is
4//! executed, resolved, or fetched. The visitor avoids retaining a full generic
5//! value tree in memory.
6
7use std::fmt;
8use std::path::Path;
9
10use serde::de::Error as DeError;
11use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
12
13use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
14use crate::document::html::{HtmlBlock, render_blocks_to_pages};
15use crate::error::{Error, Result};
16
17const MAX_JSON_BYTES: u64 = 64 * 1024 * 1024;
18const MAX_JSON_DEPTH: usize = 100;
19const MAX_JSON_VALUES: usize = 200_000;
20const MAX_JSON_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_JSON_PATH_BYTES: usize = 4 * 1024;
22const MAX_JSON_RENDERED_TEXT_BYTES: usize = 32 * 1024 * 1024;
23const MAX_JSON_OUTPUT_BLOCKS: usize = 200_000;
24
25pub(crate) fn looks_like_json_prefix(prefix: &[u8]) -> bool {
26    let text = String::from_utf8_lossy(prefix);
27    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
28    trimmed.starts_with('{') || trimmed.starts_with('[')
29}
30
31#[derive(Default)]
32struct PreviewState {
33    blocks: Vec<HtmlBlock>,
34    value_count: usize,
35    text_bytes: usize,
36    truncated_paths: usize,
37    normalized_number_count: usize,
38    limit_error: Option<String>,
39}
40
41pub(crate) fn convert(
42    path: &Path,
43    options: &ConvertOptions,
44    sink: &mut dyn PageConsumer,
45) -> Result<Vec<String>> {
46    let bytes = read_limited_file(
47        path,
48        options.max_input_bytes.min(MAX_JSON_BYTES),
49        "JSON input",
50    )?;
51    let text = String::from_utf8(bytes)
52        .map_err(|error| Error::InvalidInput(format!("JSON input must be UTF-8: {error}")))?;
53    let (blocks, warnings) = parse_json_blocks(&text)?;
54    let mut page_sink = JsonPageSink {
55        inner: sink,
56        warnings: &warnings,
57    };
58    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
59    Ok(warnings)
60}
61
62struct JsonPageSink<'a> {
63    inner: &'a mut dyn PageConsumer,
64    warnings: &'a [String],
65}
66
67impl PageConsumer for JsonPageSink<'_> {
68    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
69        page.source_format = "json".into();
70        if page.title.is_empty() {
71            page.title = "JSON data".into();
72        }
73        for warning in self.warnings {
74            page.warn(warning.clone());
75        }
76        self.inner.consume(page)
77    }
78}
79
80pub(crate) fn parse_json_blocks(text: &str) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
81    if text.len() as u64 > MAX_JSON_BYTES {
82        return Err(Error::LimitExceeded(format!(
83            "JSON input exceeds {MAX_JSON_BYTES} bytes"
84        )));
85    }
86    let mut state = PreviewState::default();
87    let mut deserializer = serde_json::Deserializer::from_str(text);
88    let parse_result = JsonSeed {
89        state: &mut state,
90        path: "$".into(),
91        depth: 0,
92    }
93    .deserialize(&mut deserializer);
94    if let Err(error) = parse_result {
95        if let Some(limit) = state.limit_error.take() {
96            return Err(Error::LimitExceeded(limit));
97        }
98        return Err(Error::InvalidInput(format!("invalid JSON input: {error}")));
99    }
100    deserializer
101        .end()
102        .map_err(|error| Error::InvalidInput(format!("invalid trailing JSON data: {error}")))?;
103
104    let mut blocks = vec![HtmlBlock::Heading {
105        level: 1,
106        text: "JSON data".into(),
107    }];
108    blocks.append(&mut state.blocks);
109    let mut warnings = Vec::new();
110    if state.truncated_paths > 0 {
111        warnings.push(format!(
112            "{0} JSON field path(s) were truncated to {1} bytes for display",
113            state.truncated_paths, MAX_JSON_PATH_BYTES
114        ));
115    }
116    if state.normalized_number_count > 0 {
117        warnings.push(format!(
118            "{} JSON decimal/exponent number(s) were normalized for display; original number spelling is not retained",
119            state.normalized_number_count
120        ));
121    }
122    Ok((blocks, warnings))
123}
124
125struct JsonSeed<'a> {
126    state: &'a mut PreviewState,
127    path: String,
128    depth: usize,
129}
130
131impl<'de> DeserializeSeed<'de> for JsonSeed<'_> {
132    type Value = ();
133
134    fn deserialize<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
135    where
136        D: serde::Deserializer<'de>,
137    {
138        if self.depth > MAX_JSON_DEPTH {
139            return self
140                .state
141                .limit::<D::Error>(format!("JSON nesting exceeds {MAX_JSON_DEPTH} levels"));
142        }
143        self.state.value_count = self.state.value_count.saturating_add(1);
144        if self.state.value_count > MAX_JSON_VALUES {
145            return self
146                .state
147                .limit::<D::Error>(format!("JSON contains more than {MAX_JSON_VALUES} values"));
148        }
149        deserializer.deserialize_any(JsonVisitor {
150            state: self.state,
151            path: self.path,
152            depth: self.depth,
153        })
154    }
155}
156
157struct JsonVisitor<'a> {
158    state: &'a mut PreviewState,
159    path: String,
160    depth: usize,
161}
162
163impl<'de> Visitor<'de> for JsonVisitor<'_> {
164    type Value = ();
165
166    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167        formatter.write_str("a JSON value")
168    }
169
170    fn visit_bool<E>(self, value: bool) -> std::result::Result<(), E>
171    where
172        E: de::Error,
173    {
174        self.state
175            .add_scalar::<E>(&self.path, "boolean", if value { "true" } else { "false" })
176    }
177
178    fn visit_i64<E>(self, value: i64) -> std::result::Result<(), E>
179    where
180        E: de::Error,
181    {
182        self.state
183            .add_scalar::<E>(&self.path, "number", &value.to_string())
184    }
185
186    fn visit_u64<E>(self, value: u64) -> std::result::Result<(), E>
187    where
188        E: de::Error,
189    {
190        self.state
191            .add_scalar::<E>(&self.path, "number", &value.to_string())
192    }
193
194    fn visit_f64<E>(self, value: f64) -> std::result::Result<(), E>
195    where
196        E: de::Error,
197    {
198        self.state.normalized_number_count = self.state.normalized_number_count.saturating_add(1);
199        self.state
200            .add_scalar::<E>(&self.path, "number", &value.to_string())
201    }
202
203    fn visit_str<E>(self, value: &str) -> std::result::Result<(), E>
204    where
205        E: de::Error,
206    {
207        self.state.add_json_string::<E>(&self.path, value)
208    }
209
210    fn visit_string<E>(self, value: String) -> std::result::Result<(), E>
211    where
212        E: de::Error,
213    {
214        self.state.add_json_string::<E>(&self.path, &value)
215    }
216
217    fn visit_unit<E>(self) -> std::result::Result<(), E>
218    where
219        E: de::Error,
220    {
221        self.state.add_scalar::<E>(&self.path, "null", "null")
222    }
223
224    fn visit_none<E>(self) -> std::result::Result<(), E>
225    where
226        E: de::Error,
227    {
228        self.state.add_scalar::<E>(&self.path, "null", "null")
229    }
230
231    fn visit_some<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
232    where
233        D: serde::Deserializer<'de>,
234    {
235        JsonSeed {
236            state: self.state,
237            path: self.path,
238            depth: self.depth,
239        }
240        .deserialize(deserializer)
241    }
242
243    fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<(), A::Error>
244    where
245        A: SeqAccess<'de>,
246    {
247        let mut index = 0usize;
248        while let Some(()) = sequence.next_element_seed(JsonSeed {
249            state: self.state,
250            path: format!("{}[{index}]", self.path),
251            depth: self.depth + 1,
252        })? {
253            index = index.saturating_add(1);
254        }
255        if index == 0 {
256            self.state
257                .add_scalar::<A::Error>(&self.path, "array", "[]")?;
258        }
259        Ok(())
260    }
261
262    fn visit_map<A>(self, mut object: A) -> std::result::Result<(), A::Error>
263    where
264        A: MapAccess<'de>,
265    {
266        let mut count = 0usize;
267        while let Some(key) = object.next_key::<String>()? {
268            let path = join_path(&self.path, &key, self.state).map_err(A::Error::custom)?;
269            object.next_value_seed(JsonSeed {
270                state: self.state,
271                path,
272                depth: self.depth + 1,
273            })?;
274            count = count.saturating_add(1);
275        }
276        if count == 0 {
277            self.state
278                .add_scalar::<A::Error>(&self.path, "object", "{}")?;
279        }
280        Ok(())
281    }
282}
283
284impl PreviewState {
285    fn limit<E: de::Error>(&mut self, message: String) -> std::result::Result<(), E> {
286        self.limit_error = Some(message.clone());
287        Err(E::custom(message))
288    }
289
290    fn add_scalar<E: de::Error>(
291        &mut self,
292        path: &str,
293        kind: &str,
294        value: &str,
295    ) -> std::result::Result<(), E> {
296        let line = format!("{path} ({kind}): {value}");
297        self.add_line::<E>(line)
298    }
299
300    fn add_json_string<E: de::Error>(
301        &mut self,
302        path: &str,
303        value: &str,
304    ) -> std::result::Result<(), E> {
305        if value.len() > MAX_JSON_STRING_BYTES {
306            return self.limit::<E>(format!("JSON string exceeds {MAX_JSON_STRING_BYTES} bytes"));
307        }
308        let encoded = serde_json::to_string(value).map_err(E::custom)?;
309        self.add_scalar::<E>(path, "string", &encoded)
310    }
311
312    fn add_line<E: de::Error>(&mut self, line: String) -> std::result::Result<(), E> {
313        if self.blocks.len() >= MAX_JSON_OUTPUT_BLOCKS {
314            return self.limit::<E>(format!(
315                "JSON preview exceeds {MAX_JSON_OUTPUT_BLOCKS} output values"
316            ));
317        }
318        let text_bytes = self.text_bytes.saturating_add(line.len());
319        if text_bytes > MAX_JSON_RENDERED_TEXT_BYTES {
320            return self.limit::<E>(format!(
321                "JSON preview text exceeds {MAX_JSON_RENDERED_TEXT_BYTES} bytes"
322            ));
323        }
324        self.text_bytes = text_bytes;
325        self.blocks.push(HtmlBlock::Paragraph { text: line });
326        Ok(())
327    }
328}
329
330fn join_path(
331    path: &str,
332    key: &str,
333    state: &mut PreviewState,
334) -> std::result::Result<String, &'static str> {
335    if key.len() > MAX_JSON_STRING_BYTES {
336        state.limit_error = Some(format!(
337            "JSON object key exceeds {MAX_JSON_STRING_BYTES} bytes"
338        ));
339        return Err("JSON object key exceeds the size limit");
340    }
341    let simple_key = !key.is_empty()
342        && key.chars().enumerate().all(|(index, character)| {
343            character == '_'
344                || character == '$'
345                || character.is_ascii_alphanumeric() && (index > 0 || !character.is_ascii_digit())
346        });
347    let child = if simple_key {
348        format!("{path}.{key}")
349    } else {
350        let encoded = serde_json::to_string(key).unwrap_or_else(|_| "\"?\"".into());
351        format!("{path}[{encoded}]")
352    };
353    if child.len() > MAX_JSON_PATH_BYTES {
354        state.truncated_paths = state.truncated_paths.saturating_add(1);
355        let mut end = MAX_JSON_PATH_BYTES;
356        while !child.is_char_boundary(end) {
357            end -= 1;
358        }
359        Ok(child[..end].to_owned())
360    } else {
361        Ok(child)
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn previews_nested_json_values_and_escapes_script_like_strings() {
371        let input = r#"{"service":{"enabled":true,"ports":[8080,8081]},"message":"<script>alert(1)</script>","empty":{}}"#;
372        let (blocks, _) = parse_json_blocks(input).unwrap();
373        let text = blocks
374            .iter()
375            .filter_map(|block| match block {
376                HtmlBlock::Heading { text, .. } | HtmlBlock::Paragraph { text } => {
377                    Some(text.as_str())
378                }
379                _ => None,
380            })
381            .collect::<Vec<_>>();
382        assert!(text.contains(&"$.service.enabled (boolean): true"));
383        assert!(text.contains(&"$.service.ports[1] (number): 8081"));
384        assert!(text.contains(&"$.empty (object): {}"));
385        assert!(text.contains(&r#"$.message (string): "<script>alert(1)</script>""#));
386    }
387
388    #[test]
389    fn rejects_invalid_json_and_limits_depth() {
390        assert!(matches!(
391            parse_json_blocks("{\"key\":}"),
392            Err(Error::InvalidInput(_))
393        ));
394        let nested = format!(
395            "{}0{}",
396            "[".repeat(MAX_JSON_DEPTH + 2),
397            "]".repeat(MAX_JSON_DEPTH + 2)
398        );
399        assert!(matches!(
400            parse_json_blocks(&nested),
401            Err(Error::LimitExceeded(_))
402        ));
403    }
404
405    #[test]
406    fn warns_when_decimal_number_spelling_is_normalized() {
407        let (blocks, warnings) = parse_json_blocks("{\"ratio\":1.5000}").unwrap();
408        assert!(
409            warnings
410                .iter()
411                .any(|warning| warning.contains("number(s) were normalized"))
412        );
413        assert!(blocks.iter().any(|block| matches!(block, HtmlBlock::Paragraph { text } if text == "$.ratio (number): 1.5")));
414    }
415
416    #[test]
417    fn does_not_confuse_structured_json_with_known_json_formats() {
418        let config = br#"{"service":{"port":8080}}"#;
419        assert!(looks_like_json_prefix(config));
420        assert!(!looks_like_json_prefix(b"not json"));
421    }
422}