1use std::path::Path;
9
10use serde_json::Value;
11
12use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
13use crate::document::html::{HtmlBlock, render_blocks_to_pages_with_warnings};
14use crate::error::{Error, Result};
15use crate::table::{TableAlign, TableData};
16
17const MAX_JSONLD_BYTES: u64 = 64 * 1024 * 1024;
18const MAX_JSONLD_DEPTH: usize = 100;
19const MAX_JSONLD_VALUES: usize = 200_000;
20const MAX_JSONLD_STATEMENTS: usize = 200_000;
21const MAX_JSONLD_TERM_CHARS: usize = 512;
22const MAX_JSONLD_TEXT_BYTES: usize = 64 * 1024 * 1024;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
27 let text = String::from_utf8_lossy(bytes);
28 let has_context = text.contains("\"@context\"");
29 let has_graph_or_node =
30 text.contains("\"@graph\"") || text.contains("\"@id\"") || text.contains("\"@type\"");
31 has_context && has_graph_or_node
32}
33
34pub(crate) fn convert(
35 path: &Path,
36 options: &ConvertOptions,
37 sink: &mut dyn PageConsumer,
38) -> Result<Vec<String>> {
39 let bytes = read_limited_file(
40 path,
41 options.max_input_bytes.min(MAX_JSONLD_BYTES),
42 "JSON-LD input",
43 )?;
44 let text = std::str::from_utf8(&bytes)
45 .map_err(|error| Error::InvalidInput(format!("JSON-LD input must be UTF-8: {error}")))?;
46 let (rows, has_graph, warnings) = parse_jsonld(text)?;
47 if rows.is_empty() {
48 return Err(Error::InvalidInput(
49 "JSON-LD input contains no renderable node statements".into(),
50 ));
51 }
52 let mut headers = vec!["Subject".into(), "Predicate".into(), "Object".into()];
53 if has_graph {
54 headers.push("Graph".into());
55 }
56 let mut table_rows = rows;
57 if has_graph {
58 for row in &mut table_rows {
59 row.resize(4, String::new());
60 }
61 }
62 let table = TableData {
63 headers,
64 rows: table_rows,
65 alignments: vec![TableAlign::Left; if has_graph { 4 } else { 3 }],
66 raw_source: String::new(),
67 };
68 let blocks = vec![
69 HtmlBlock::Heading {
70 level: 1,
71 text: "JSON-LD statements".into(),
72 },
73 HtmlBlock::Table(table),
74 ];
75 render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
76 Ok(dedup_warnings(warnings))
77}
78
79struct WalkState {
80 rows: Vec<Vec<String>>,
81 has_graph: bool,
82 warnings: Vec<String>,
83 value_count: usize,
84 text_bytes: usize,
85 blank_counter: usize,
86 contexts: usize,
87 remote_contexts: usize,
88 imports: usize,
89}
90
91fn parse_jsonld(text: &str) -> Result<(Vec<Vec<String>>, bool, Vec<String>)> {
92 if text.len() as u64 > MAX_JSONLD_BYTES {
93 return Err(Error::LimitExceeded(format!(
94 "JSON-LD input exceeds {MAX_JSONLD_BYTES} bytes"
95 )));
96 }
97 preflight_json_depth(text)?;
98 let value: Value = serde_json::from_str(text)
99 .map_err(|error| Error::InvalidInput(format!("invalid JSON-LD input: {error}")))?;
100 let mut state = WalkState {
101 rows: Vec::new(),
102 has_graph: false,
103 warnings: Vec::new(),
104 value_count: 0,
105 text_bytes: 0,
106 blank_counter: 0,
107 contexts: 0,
108 remote_contexts: 0,
109 imports: 0,
110 };
111 walk_document(&value, None, 0, &mut state)?;
112 if state.contexts > 0 {
113 state.warnings.push(format!(
114 "JSON-LD @context was retained as compact terms without namespace expansion ({})",
115 state.contexts
116 ));
117 }
118 if state.remote_contexts > 0 {
119 state.warnings.push(format!(
120 "{} remote JSON-LD context reference(s) were not fetched",
121 state.remote_contexts
122 ));
123 }
124 if state.imports > 0 {
125 state.warnings.push(format!(
126 "{} JSON-LD @import directive(s) were not resolved",
127 state.imports
128 ));
129 }
130 if state.has_graph {
131 for row in &mut state.rows {
132 row.resize(4, String::new());
133 }
134 }
135 Ok((state.rows, state.has_graph, state.warnings))
136}
137
138fn preflight_json_depth(text: &str) -> Result<()> {
141 let mut depth = 0usize;
142 let mut quoted = false;
143 let mut escaped = false;
144 for byte in text.bytes() {
145 if quoted {
146 if escaped {
147 escaped = false;
148 } else if byte == b'\\' {
149 escaped = true;
150 } else if byte == b'"' {
151 quoted = false;
152 }
153 continue;
154 }
155 match byte {
156 b'"' => quoted = true,
157 b'{' | b'[' => {
158 depth = depth.saturating_add(1);
159 if depth > MAX_JSONLD_DEPTH {
160 return Err(Error::LimitExceeded(format!(
161 "JSON-LD nesting exceeds {MAX_JSONLD_DEPTH} levels"
162 )));
163 }
164 }
165 b'}' | b']' => depth = depth.saturating_sub(1),
166 _ => {}
167 }
168 }
169 Ok(())
170}
171
172fn walk_document(
173 value: &Value,
174 graph: Option<&str>,
175 depth: usize,
176 state: &mut WalkState,
177) -> Result<()> {
178 check_depth_and_count(depth, state)?;
179 match value {
180 Value::Array(values) => {
181 for item in values {
182 walk_document(item, graph, depth + 1, state)?;
183 }
184 }
185 Value::Object(object) => {
186 if let Some(context) = object.get("@context") {
187 record_context(context, state);
188 }
189 if let Some(included) = object.get("@included") {
190 walk_document(included, graph, depth + 1, state)?;
191 }
192 if let Some(graph_value) = object.get("@graph") {
193 let graph_name = object.get("@id").and_then(value_term);
194 let active_graph = graph_name.as_deref().or(graph);
195 if active_graph.is_some() {
196 state.has_graph = true;
197 }
198 walk_document(graph_value, active_graph, depth + 1, state)?;
199 if object.keys().any(|key| !key.starts_with('@')) {
201 walk_node(object, graph, depth, state)?;
202 }
203 } else {
204 walk_node(object, graph, depth, state)?;
205 }
206 }
207 _ => push_warning_once(state, "JSON-LD top-level scalar value was omitted"),
208 }
209 Ok(())
210}
211
212fn walk_node(
213 object: &serde_json::Map<String, Value>,
214 graph: Option<&str>,
215 depth: usize,
216 state: &mut WalkState,
217) -> Result<()> {
218 let has_node_content = object.keys().any(|key| {
219 !matches!(
220 key.as_str(),
221 "@context" | "@graph" | "@included" | "@index" | "@reverse"
222 )
223 });
224 if !has_node_content {
225 return Ok(());
226 }
227 let subject = if let Some(id) = object.get("@id").and_then(value_term) {
228 id
229 } else {
230 state.blank_counter = state.blank_counter.saturating_add(1);
231 format!("_:b{}", state.blank_counter)
232 };
233 if let Some(types) = object.get("@type") {
234 for value in values(types) {
235 push_statement(
236 state,
237 &subject,
238 "@type",
239 &value_term(value).unwrap_or_default(),
240 graph,
241 )?;
242 }
243 }
244 if object.contains_key("@reverse") {
245 push_warning_once(
246 state,
247 "JSON-LD @reverse properties were omitted from the preview",
248 );
249 }
250 for (predicate, value) in object {
251 if predicate.starts_with('@') {
252 continue;
253 }
254 for item in values(value) {
255 let object_term = object_value_term(item, depth + 1, graph, state)?;
256 push_statement(state, &subject, predicate, &object_term, graph)?;
257 }
258 }
259 Ok(())
260}
261
262fn object_value_term(
263 value: &Value,
264 depth: usize,
265 graph: Option<&str>,
266 state: &mut WalkState,
267) -> Result<String> {
268 check_depth_and_count(depth, state)?;
269 if let Some(id) = value.get("@id").and_then(value_term) {
270 if let Value::Object(object) = value
271 && object.keys().any(|key| !key.starts_with('@'))
272 {
273 walk_node(object, graph, depth + 1, state)?;
274 }
275 return Ok(id);
276 }
277 if let Some(literal) = value.get("@value") {
278 let mut term = value_term(literal).unwrap_or_default();
279 if let Some(language) = value.get("@language").and_then(value_term) {
280 term.push_str(" @");
281 term.push_str(&language);
282 }
283 if let Some(datatype) = value.get("@type").and_then(value_term) {
284 term.push_str(" ^^ ");
285 term.push_str(&datatype);
286 }
287 return Ok(truncate(&term));
288 }
289 if let Some(list) = value.get("@list") {
290 let mut items = Vec::new();
291 for item in values(list) {
292 let term = if item.is_object() {
293 object_value_term(item, depth + 1, graph, state)?
294 } else {
295 value_term(item).unwrap_or_default()
296 };
297 items.push(truncate(&term));
298 }
299 return Ok(truncate(&format!("[{}]", items.join(", "))));
300 }
301 if value.is_object() {
302 state.blank_counter = state.blank_counter.saturating_add(1);
303 let blank = format!("_:b{}", state.blank_counter);
304 if let Value::Object(object) = value {
305 walk_node(object, graph, depth + 1, state)?;
306 }
307 return Ok(blank);
308 }
309 Ok(truncate(&value_term(value).unwrap_or_default()))
310}
311
312fn push_statement(
313 state: &mut WalkState,
314 subject: &str,
315 predicate: &str,
316 object: &str,
317 graph: Option<&str>,
318) -> Result<()> {
319 if state.rows.len() >= MAX_JSONLD_STATEMENTS {
320 return Err(Error::LimitExceeded(format!(
321 "JSON-LD exceeds {MAX_JSONLD_STATEMENTS} statements"
322 )));
323 }
324 let mut row = vec![truncate(subject), truncate(predicate), truncate(object)];
325 if let Some(graph) = graph {
326 state.has_graph = true;
327 row.push(truncate(graph));
328 } else if state.has_graph {
329 row.push(String::new());
330 }
331 state.text_bytes = state
332 .text_bytes
333 .saturating_add(row.iter().map(String::len).sum::<usize>());
334 if state.text_bytes > MAX_JSONLD_TEXT_BYTES {
335 return Err(Error::LimitExceeded(format!(
336 "JSON-LD rendered text exceeds {MAX_JSONLD_TEXT_BYTES} bytes"
337 )));
338 }
339 state.rows.push(row);
340 Ok(())
341}
342
343fn record_context(value: &Value, state: &mut WalkState) {
344 state.contexts = state.contexts.saturating_add(1);
345 for item in values(value) {
346 if item
347 .as_str()
348 .is_some_and(|text| text.starts_with("http://") || text.starts_with("https://"))
349 {
350 state.remote_contexts = state.remote_contexts.saturating_add(1);
351 }
352 if let Value::Object(object) = item
353 && object.contains_key("@import")
354 {
355 state.imports = state.imports.saturating_add(1);
356 }
357 }
358}
359
360fn values(value: &Value) -> Vec<&Value> {
361 value
362 .as_array()
363 .map(Vec::as_slice)
364 .unwrap_or_else(|| std::slice::from_ref(value))
365 .iter()
366 .collect()
367}
368
369fn value_term(value: &Value) -> Option<String> {
370 match value {
371 Value::String(text) => Some(text.clone()),
372 Value::Number(number) => Some(number.to_string()),
373 Value::Bool(value) => Some(value.to_string()),
374 Value::Null => Some("null".into()),
375 _ => None,
376 }
377}
378
379fn truncate(value: &str) -> String {
380 value.chars().take(MAX_JSONLD_TERM_CHARS).collect()
381}
382
383fn check_depth_and_count(depth: usize, state: &mut WalkState) -> Result<()> {
384 if depth > MAX_JSONLD_DEPTH {
385 return Err(Error::LimitExceeded(format!(
386 "JSON-LD nesting exceeds {MAX_JSONLD_DEPTH} levels"
387 )));
388 }
389 state.value_count = state.value_count.saturating_add(1);
390 if state.value_count > MAX_JSONLD_VALUES {
391 return Err(Error::LimitExceeded(format!(
392 "JSON-LD contains more than {MAX_JSONLD_VALUES} values"
393 )));
394 }
395 Ok(())
396}
397
398fn dedup_warnings(mut warnings: Vec<String>) -> Vec<String> {
399 warnings.sort();
400 warnings.dedup();
401 warnings
402}
403
404fn push_warning_once(state: &mut WalkState, warning: &str) {
405 if !state.warnings.iter().any(|item| item == warning) {
406 state.warnings.push(warning.to_string());
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::{looks_like_prefix, parse_jsonld, preflight_json_depth};
413
414 #[test]
415 fn renders_context_nodes_literals_and_named_graphs() {
416 let source = r#"{
417 "@context": {"name": "https://schema.org/name"},
418 "@graph": [
419 {"@id": "urn:book:1", "@type": "Book", "name": "A title", "author": {"@id": "urn:person:1"}},
420 {"@id": "urn:graph:1", "@graph": [{"@id": "urn:book:2", "name": {"@value": "Deux", "@language": "fr"}}]}
421 ]
422 }"#;
423 let (rows, has_graph, warnings) = parse_jsonld(source).unwrap();
424 assert!(has_graph);
425 assert!(
426 rows.iter()
427 .any(|row| row[1] == "name" && row[2] == "A title")
428 );
429 assert!(rows.iter().any(|row| row[2] == "urn:person:1"));
430 assert!(rows.iter().any(|row| row[3] == "urn:graph:1"));
431 assert!(warnings.iter().any(|warning| warning.contains("@context")));
432 }
433
434 #[test]
435 fn recognizes_jsonld_shape_without_confusing_plain_json() {
436 assert!(looks_like_prefix(b"{\"@context\":{},\"@id\":\"x\"}"));
437 assert!(!looks_like_prefix(b"{\"context\":{},\"id\":\"x\"}"));
438 }
439
440 #[test]
441 fn rejects_excessive_json_nesting_before_value_parsing() {
442 let nested = "[".repeat(101) + &"]".repeat(101);
443 let error = preflight_json_depth(&nested).unwrap_err();
444 assert!(error.to_string().contains("nesting"));
445 }
446
447 #[test]
448 fn reports_remote_contexts_without_fetching_them() {
449 let source = r#"{"@context":"https://example.invalid/context.jsonld","@id":"urn:x","name":"offline"}"#;
450 let (rows, _, warnings) = parse_jsonld(source).unwrap();
451 assert_eq!(rows[0][2], "offline");
452 assert!(
453 warnings
454 .iter()
455 .any(|warning| warning.contains("not fetched"))
456 );
457 }
458}