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_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}