Skip to main content

document_svg/document/
jsonpatch.rs

1//! Bounded RFC 6902 JSON Patch previews.
2//!
3//! A JSON Patch document is an ordered array of operation objects. This adapter
4//! validates the operation shape and renders op/path/from plus value presence
5//! and type; it never applies the patch, evaluates JSON Pointers, or displays
6//! value payloads.
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_JSONPATCH_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_JSONPATCH_DEPTH: usize = 100;
18const MAX_JSONPATCH_VALUES: usize = 300_000;
19const MAX_JSONPATCH_OPERATIONS: usize = 200_000;
20const MAX_JSONPATCH_STRING_BYTES: usize = 2 * 1024 * 1024;
21
22pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
23    let text = String::from_utf8_lossy(prefix);
24    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
25    trimmed.starts_with('[')
26        && text.contains("\"op\"")
27        && text.contains("\"path\"")
28        && [
29            "\"add\"",
30            "\"remove\"",
31            "\"replace\"",
32            "\"move\"",
33            "\"copy\"",
34            "\"test\"",
35        ]
36        .iter()
37        .any(|op| text.contains(op))
38}
39
40struct JsonPatchPageSink<'a> {
41    inner: &'a mut dyn PageConsumer,
42    warnings: &'a [String],
43}
44
45impl PageConsumer for JsonPatchPageSink<'_> {
46    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
47        page.source_format = "jsonpatch".into();
48        if page.title.is_empty() {
49            page.title = "JSON Patch".into();
50        }
51        page.description =
52            "RFC 6902 JSON Patch operations are rendered as inert metadata; target documents and value payloads are not modified or displayed".into();
53        for warning in self.warnings {
54            page.warn(warning.clone());
55        }
56        self.inner.consume(page)
57    }
58}
59
60pub(crate) fn convert(
61    path: &Path,
62    options: &ConvertOptions,
63    sink: &mut dyn PageConsumer,
64) -> Result<Vec<String>> {
65    let bytes = read_limited_file(
66        path,
67        options.max_input_bytes.min(MAX_JSONPATCH_BYTES),
68        "JSON Patch input",
69    )?;
70    let text = String::from_utf8(bytes)
71        .map_err(|error| Error::InvalidInput(format!("JSON Patch must be UTF-8 JSON: {error}")))?;
72    let (table, metadata, warnings) = parse(&text)?;
73    let blocks = vec![
74        HtmlBlock::Heading {
75            level: 1,
76            text: "JSON Patch".into(),
77        },
78        HtmlBlock::Paragraph { text: metadata },
79        HtmlBlock::Table(table),
80    ];
81    let mut page_sink = JsonPatchPageSink {
82        inner: sink,
83        warnings: &warnings,
84    };
85    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
86    Ok(warnings)
87}
88
89fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
90    if text.len() as u64 > MAX_JSONPATCH_BYTES {
91        return Err(Error::LimitExceeded(format!(
92            "JSON Patch exceeds {MAX_JSONPATCH_BYTES} bytes"
93        )));
94    }
95    preflight_depth(text)?;
96    let value: Value = serde_json::from_str(text)
97        .map_err(|error| Error::InvalidInput(format!("invalid JSON Patch: {error}")))?;
98    let mut values = 0usize;
99    count_values(&value, 0, &mut values)?;
100    let operations = value
101        .as_array()
102        .ok_or_else(|| Error::InvalidInput("JSON Patch root must be an array".into()))?;
103    if operations.len() > MAX_JSONPATCH_OPERATIONS {
104        return Err(Error::LimitExceeded(format!(
105            "JSON Patch operations exceed {MAX_JSONPATCH_OPERATIONS}"
106        )));
107    }
108    let mut rows = Vec::new();
109    let mut counts = [0usize; 6];
110    for (index, operation) in operations.iter().enumerate() {
111        let object = operation.as_object().ok_or_else(|| {
112            Error::InvalidInput(format!(
113                "JSON Patch operation {} must be an object",
114                index + 1
115            ))
116        })?;
117        let op = object.get("op").and_then(Value::as_str).ok_or_else(|| {
118            Error::InvalidInput(format!("JSON Patch operation {} requires op", index + 1))
119        })?;
120        let op_index = match op {
121            "add" => 0,
122            "remove" => 1,
123            "replace" => 2,
124            "move" => 3,
125            "copy" => 4,
126            "test" => 5,
127            _ => {
128                return Err(Error::InvalidInput(format!(
129                    "JSON Patch operation {} has unsupported op {op}",
130                    index + 1
131                )));
132            }
133        };
134        counts[op_index] = counts[op_index].saturating_add(1);
135        let path = object.get("path").and_then(Value::as_str).ok_or_else(|| {
136            Error::InvalidInput(format!("JSON Patch operation {} requires path", index + 1))
137        })?;
138        let from = object.get("from").and_then(Value::as_str);
139        if matches!(op, "move" | "copy") && from.is_none() {
140            return Err(Error::InvalidInput(format!(
141                "JSON Patch {op} operation {} requires from",
142                index + 1
143            )));
144        }
145        if matches!(op, "add" | "replace" | "test") && !object.contains_key("value") {
146            return Err(Error::InvalidInput(format!(
147                "JSON Patch {op} operation {} requires value",
148                index + 1
149            )));
150        }
151        let value_type = object.get("value").map(json_type).unwrap_or("—").to_owned();
152        rows.push(vec![
153            (index + 1).to_string(),
154            truncate(op),
155            truncate(path),
156            from.map_or_else(|| "—".into(), truncate),
157            value_type,
158        ]);
159    }
160    if rows.is_empty() {
161        rows.push(vec![
162            "—".into(),
163            "(no operations)".into(),
164            "—".into(),
165            "—".into(),
166            "—".into(),
167        ]);
168    }
169    let metadata = format!(
170        "Operations: {}\nAdd: {}\nRemove: {}\nReplace: {}\nMove: {}\nCopy: {}\nTest: {}",
171        operations.len(),
172        counts[0],
173        counts[1],
174        counts[2],
175        counts[3],
176        counts[4],
177        counts[5]
178    );
179    let warnings = vec![
180        "JSON Patch value payloads are omitted; operations are never applied, JSON Pointers are not evaluated, and no target document or external resource is opened".into(),
181        "RFC 6902 operation order and target-document semantics remain inert metadata".into(),
182    ];
183    Ok((
184        TableData {
185            headers: vec![
186                "#".into(),
187                "Op".into(),
188                "Path".into(),
189                "From".into(),
190                "Value type".into(),
191            ],
192            rows,
193            alignments: vec![TableAlign::Left; 5],
194            raw_source: String::new(),
195        },
196        metadata,
197        warnings,
198    ))
199}
200
201fn json_type(value: &Value) -> &'static str {
202    match value {
203        Value::Null => "null",
204        Value::Bool(_) => "boolean",
205        Value::Number(_) => "number",
206        Value::String(_) => "string",
207        Value::Array(_) => "array",
208        Value::Object(_) => "object",
209    }
210}
211
212fn truncate(value: &str) -> String {
213    if value.len() <= MAX_JSONPATCH_STRING_BYTES {
214        return value.to_owned();
215    }
216    let mut end = MAX_JSONPATCH_STRING_BYTES;
217    while !value.is_char_boundary(end) {
218        end -= 1;
219    }
220    format!("{}…", &value[..end])
221}
222
223fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
224    if depth > MAX_JSONPATCH_DEPTH {
225        return Err(Error::LimitExceeded(format!(
226            "JSON Patch nesting exceeds {MAX_JSONPATCH_DEPTH} levels"
227        )));
228    }
229    *count = count.saturating_add(1);
230    if *count > MAX_JSONPATCH_VALUES {
231        return Err(Error::LimitExceeded(format!(
232            "JSON Patch contains more than {MAX_JSONPATCH_VALUES} values"
233        )));
234    }
235    match value {
236        Value::Array(values) => {
237            for item in values {
238                count_values(item, depth + 1, count)?;
239            }
240        }
241        Value::Object(map) => {
242            for item in map.values() {
243                count_values(item, depth + 1, count)?;
244            }
245        }
246        Value::String(value) if value.len() > MAX_JSONPATCH_STRING_BYTES => {
247            return Err(Error::LimitExceeded(format!(
248                "JSON Patch string exceeds {MAX_JSONPATCH_STRING_BYTES} bytes"
249            )));
250        }
251        _ => {}
252    }
253    Ok(())
254}
255
256fn preflight_depth(text: &str) -> Result<()> {
257    let mut depth = 0usize;
258    let mut quoted = false;
259    let mut escaped = false;
260    for byte in text.bytes() {
261        if quoted {
262            if escaped {
263                escaped = false;
264            } else if byte == b'\\' {
265                escaped = true;
266            } else if byte == b'"' {
267                quoted = false;
268            }
269            continue;
270        }
271        match byte {
272            b'"' => quoted = true,
273            b'{' | b'[' => {
274                depth += 1;
275                if depth > MAX_JSONPATCH_DEPTH {
276                    return Err(Error::LimitExceeded(format!(
277                        "JSON Patch nesting exceeds {MAX_JSONPATCH_DEPTH} levels"
278                    )));
279                }
280            }
281            b'}' | b']' => depth = depth.saturating_sub(1),
282            _ => {}
283        }
284    }
285    Ok(())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn recognizes_json_patch() {
294        assert!(looks_like_prefix(
295            br#"[{"op":"replace","path":"/a","value":1}]"#
296        ));
297        assert!(!looks_like_prefix(br#"[{"path":"/a","value":1}]"#));
298    }
299
300    #[test]
301    fn validates_operations_without_displaying_values() {
302        let (table, metadata, warnings) = parse(
303            r#"[{"op":"add","path":"/token","value":"very-secret"},{"op":"move","from":"/a","path":"/b"},{"op":"remove","path":"/old"}]"#,
304        )
305        .unwrap();
306        assert!(metadata.contains("Operations: 3"));
307        assert_eq!(table.rows[0][4], "string");
308        assert_eq!(table.rows[1][3], "/a");
309        assert!(
310            !table
311                .rows
312                .iter()
313                .flatten()
314                .any(|value| value.contains("secret"))
315        );
316        assert!(warnings.iter().any(|warning| warning.contains("never")));
317    }
318}