Skip to main content

document_svg/document/
jsonld.rs

1//! Bounded JSON-LD 1.1 linked-data preview.
2//!
3//! This is deliberately a presentation adapter rather than a JSON-LD
4//! expansion engine. It keeps compact terms as written, displays node
5//! relationships as subject/predicate/object rows, and never dereferences
6//! remote contexts or executes framing/inference.
7
8use 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
24/// Returns true for a JSON prefix that has the characteristic JSON-LD context
25/// and graph/node keywords, while avoiding broad generic-JSON classification.
26pub(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
138/// Reject deeply nested JSON before `serde_json` builds its value tree. This
139/// keeps hostile nesting from consuming the parser's call stack first.
140fn 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                // A graph object can also carry an ordinary node type/property.
200                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}