Skip to main content

solid_pod_rs/
rdf_input.rs

1//! Turtle and Notation3 request-body parsing into the in-crate
2//! [`ldp::Graph`](crate::ldp::Graph) triple model.
3//!
4//! JSS accepts `text/turtle` and `text/n3` as input representations when
5//! content negotiation is on: `src/rdf/conneg.js` lists both in
6//! `SUPPORTED_INPUT` (lines 16, 25) and `toJsonLd` routes either media type
7//! through the n3.js based `turtleToJsonLd` in `src/rdf/turtle.js`
8//! (conneg.js 144-145). This module is the Rust counterpart. It parses with
9//! [`oxttl`], the Turtle/N3 sibling of the `oxrdf`/`spargebra` crates the
10//! crate already depends on, and converts the result into the existing
11//! triple model, so no second RDF stack is introduced.
12//!
13//! # Policy
14//!
15//! * Input is bounded: [`MAX_RDF_INPUT_BYTES`] and [`MAX_RDF_TRIPLES`].
16//! * The base IRI is taken from the request URL so relative references
17//!   (`<>`, `<#me>`, `<../x>`) resolve exactly as a client expects.
18//! * `text/n3` is parsed with the N3 grammar but only the RDF-compatible
19//!   subset is accepted. Quoted graphs (formulas) and variables are
20//!   rejected with [`PodError::Unsupported`] (HTTP 415), as are the
21//!   `log:implies` / `log:impliedBy` rules that need them. Literal
22//!   subjects and any non-IRI predicate are rejected with
23//!   [`PodError::BadRequest`] (HTTP 400) because they are not RDF triples.
24//! * A syntax error is a [`PodError::BadRequest`] (HTTP 400).
25//!
26//! # Example
27//!
28//! ```
29//! use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
30//!
31//! let g = parse_rdf_body(
32//!     RdfInputSyntax::Turtle,
33//!     br#"<#me> a <http://xmlns.com/foaf/0.1/Person> ."#,
34//!     "https://pod.example/profile/card",
35//! )
36//! .unwrap();
37//! assert_eq!(g.len(), 1);
38//! ```
39
40use oxttl::n3::{N3Parser, N3Term};
41use oxttl::TurtleParser;
42
43use crate::error::PodError;
44use crate::ldp::{Graph, Term, Triple};
45
46/// Largest accepted Turtle / N3 document, in bytes.
47pub const MAX_RDF_INPUT_BYTES: usize = 4 * 1024 * 1024;
48
49/// Largest number of triples a single document may expand to. Guards
50/// against small inputs that fan out (deeply nested collections).
51pub const MAX_RDF_TRIPLES: usize = 250_000;
52
53const LOG_IMPLIES: &str = "http://www.w3.org/2000/10/swap/log#implies";
54const LOG_IMPLIED_BY: &str = "http://www.w3.org/2000/10/swap/log#impliedBy";
55const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
56const RDF_LANG_STRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
57
58/// Concrete input syntax of a request body.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RdfInputSyntax {
61    /// `text/turtle` (Turtle 1.1).
62    Turtle,
63    /// `text/n3` (Notation3, restricted to its RDF-compatible subset).
64    N3,
65}
66
67impl RdfInputSyntax {
68    /// Map a `Content-Type` header value (parameters ignored, case
69    /// insensitive) to an input syntax. `application/turtle` and
70    /// `application/x-turtle` are accepted as Turtle aliases.
71    ///
72    /// ```
73    /// use solid_pod_rs::rdf_input::RdfInputSyntax;
74    /// assert_eq!(RdfInputSyntax::from_content_type("text/n3; charset=utf-8"), Some(RdfInputSyntax::N3));
75    /// assert_eq!(RdfInputSyntax::from_content_type("application/json"), None);
76    /// ```
77    pub fn from_content_type(content_type: &str) -> Option<Self> {
78        let mime = content_type
79            .split(';')
80            .next()
81            .unwrap_or("")
82            .trim()
83            .to_ascii_lowercase();
84        match mime.as_str() {
85            "text/turtle" | "application/turtle" | "application/x-turtle" => Some(Self::Turtle),
86            "text/n3" => Some(Self::N3),
87            _ => None,
88        }
89    }
90}
91
92fn bad(msg: impl Into<String>) -> PodError {
93    PodError::BadRequest(msg.into())
94}
95
96fn push_checked(g: &mut Graph, t: Triple) -> Result<(), PodError> {
97    g.insert(t);
98    if g.len() > MAX_RDF_TRIPLES {
99        return Err(PodError::PayloadTooLarge(format!(
100            "RDF document expands to more than {MAX_RDF_TRIPLES} triples"
101        )));
102    }
103    Ok(())
104}
105
106pub(crate) fn literal_to_term(l: &oxrdf::Literal) -> Term {
107    let value = l.value().to_string();
108    if let Some(lang) = l.language() {
109        return Term::Literal {
110            value,
111            datatype: None,
112            language: Some(lang.to_string()),
113        };
114    }
115    let dt = l.datatype().as_str();
116    if dt == XSD_STRING || dt == RDF_LANG_STRING {
117        Term::Literal {
118            value,
119            datatype: None,
120            language: None,
121        }
122    } else {
123        Term::Literal {
124            value,
125            datatype: Some(dt.to_string()),
126            language: None,
127        }
128    }
129}
130
131fn subject_to_term(s: &oxrdf::Subject) -> Result<Term, PodError> {
132    match s {
133        oxrdf::Subject::NamedNode(n) => Ok(Term::Iri(n.as_str().to_string())),
134        oxrdf::Subject::BlankNode(b) => Ok(Term::BlankNode(b.as_str().to_string())),
135        #[allow(unreachable_patterns)]
136        _ => Err(PodError::Unsupported(
137            "quoted triples are not supported".into(),
138        )),
139    }
140}
141
142fn term_to_term(t: &oxrdf::Term) -> Result<Term, PodError> {
143    match t {
144        oxrdf::Term::NamedNode(n) => Ok(Term::Iri(n.as_str().to_string())),
145        oxrdf::Term::BlankNode(b) => Ok(Term::BlankNode(b.as_str().to_string())),
146        oxrdf::Term::Literal(l) => Ok(literal_to_term(l)),
147        #[allow(unreachable_patterns)]
148        _ => Err(PodError::Unsupported(
149            "quoted triples are not supported".into(),
150        )),
151    }
152}
153
154fn n3_term(t: &N3Term, role: &str) -> Result<Term, PodError> {
155    match t {
156        N3Term::NamedNode(n) => Ok(Term::Iri(n.as_str().to_string())),
157        N3Term::BlankNode(b) => Ok(Term::BlankNode(b.as_str().to_string())),
158        N3Term::Literal(l) => Ok(literal_to_term(l)),
159        N3Term::Variable(v) => Err(PodError::Unsupported(format!(
160            "N3 variable ?{} in {role} position is not supported",
161            v.as_str()
162        ))),
163        #[allow(unreachable_patterns)]
164        _ => Err(PodError::Unsupported(format!(
165            "unsupported N3 term in {role} position"
166        ))),
167    }
168}
169
170/// Parse a Turtle or N3 body into a [`Graph`].
171///
172/// `base_iri` is the absolute request URL (scheme, authority and path) and
173/// is used to resolve relative IRIs. Returns:
174///
175/// * [`PodError::PayloadTooLarge`] over [`MAX_RDF_INPUT_BYTES`] bytes or
176///   [`MAX_RDF_TRIPLES`] triples;
177/// * [`PodError::BadRequest`] for a bad base IRI, a syntax error, a literal
178///   subject or a non-IRI predicate;
179/// * [`PodError::Unsupported`] for N3 formulas, variables and rules.
180pub fn parse_rdf_body(
181    syntax: RdfInputSyntax,
182    body: &[u8],
183    base_iri: &str,
184) -> Result<Graph, PodError> {
185    if body.len() > MAX_RDF_INPUT_BYTES {
186        return Err(PodError::PayloadTooLarge(format!(
187            "RDF body of {} bytes exceeds the {MAX_RDF_INPUT_BYTES} byte limit",
188            body.len()
189        )));
190    }
191    if std::str::from_utf8(body).is_err() {
192        return Err(bad("RDF body is not valid UTF-8"));
193    }
194    match syntax {
195        RdfInputSyntax::Turtle => parse_turtle_inner(body, base_iri),
196        RdfInputSyntax::N3 => parse_n3_inner(body, base_iri),
197    }
198}
199
200/// Parse a Turtle 1.1 document. Convenience wrapper over [`parse_rdf_body`].
201pub fn parse_turtle(body: &[u8], base_iri: &str) -> Result<Graph, PodError> {
202    parse_rdf_body(RdfInputSyntax::Turtle, body, base_iri)
203}
204
205/// Parse a Notation3 document (RDF-compatible subset). Convenience wrapper
206/// over [`parse_rdf_body`].
207pub fn parse_n3(body: &[u8], base_iri: &str) -> Result<Graph, PodError> {
208    parse_rdf_body(RdfInputSyntax::N3, body, base_iri)
209}
210
211fn parse_turtle_inner(body: &[u8], base_iri: &str) -> Result<Graph, PodError> {
212    let parser = TurtleParser::new()
213        .with_base_iri(base_iri)
214        .map_err(|e| bad(format!("invalid base IRI: {e}")))?;
215    let mut g = Graph::new();
216    for item in parser.for_slice(body) {
217        let t = item.map_err(|e| bad(format!("Turtle syntax error: {e}")))?;
218        let triple = Triple::new(
219            subject_to_term(&t.subject)?,
220            Term::Iri(t.predicate.as_str().to_string()),
221            term_to_term(&t.object)?,
222        );
223        push_checked(&mut g, triple)?;
224    }
225    Ok(g)
226}
227
228fn parse_n3_inner(body: &[u8], base_iri: &str) -> Result<Graph, PodError> {
229    let parser = N3Parser::new()
230        .with_base_iri(base_iri)
231        .map_err(|e| bad(format!("invalid base IRI: {e}")))?;
232    let mut g = Graph::new();
233    for item in parser.for_slice(body) {
234        let q = item.map_err(|e| bad(format!("N3 syntax error: {e}")))?;
235        if !matches!(q.graph_name, oxrdf::GraphName::DefaultGraph) {
236            return Err(PodError::Unsupported(
237                "N3 formulas (quoted graphs) are not supported".into(),
238            ));
239        }
240        let subject = n3_term(&q.subject, "subject")?;
241        let predicate = n3_term(&q.predicate, "predicate")?;
242        let object = n3_term(&q.object, "object")?;
243        if matches!(subject, Term::Literal { .. }) {
244            return Err(bad("literal subjects are not valid RDF"));
245        }
246        match &predicate {
247            Term::Iri(p) if p == LOG_IMPLIES || p == LOG_IMPLIED_BY => {
248                return Err(PodError::Unsupported(
249                    "N3 rules (log:implies) are not supported".into(),
250                ));
251            }
252            Term::Iri(_) => {}
253            _ => return Err(bad("predicate must be an IRI")),
254        }
255        push_checked(&mut g, Triple::new(subject, predicate, object))?;
256    }
257    Ok(g)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    const BASE: &str = "https://pod.example/data/doc";
265
266    fn nt(g: &Graph) -> String {
267        g.to_ntriples()
268    }
269
270    #[test]
271    fn turtle_resolves_relative_against_request_url() {
272        let g = parse_turtle(b"<> <#p> <../o> .", BASE).unwrap();
273        let t = g.triples().next().unwrap();
274        assert_eq!(t.subject, Term::iri(BASE));
275        assert_eq!(t.predicate, Term::iri("https://pod.example/data/doc#p"));
276        assert_eq!(t.object, Term::iri("https://pod.example/o"));
277    }
278
279    #[test]
280    fn turtle_prefixes_literals_and_lists() {
281        let g = parse_turtle(
282            b"@prefix ex: <http://e/> . ex:s ex:n 1 ; ex:l \"hi\"@en ; ex:t \"x\" ; ex:d \"1\"^^ex:T .",
283            BASE,
284        )
285        .unwrap();
286        let s = nt(&g);
287        assert!(s.contains("<http://e/n> \"1\"^^<http://www.w3.org/2001/XMLSchema#integer>"));
288        assert!(s.contains("\"hi\"@en"));
289        assert!(s.contains("<http://e/t> \"x\" ."));
290        assert!(s.contains("\"1\"^^<http://e/T>"));
291    }
292
293    #[test]
294    fn syntax_error_is_bad_request() {
295        assert!(matches!(
296            parse_turtle(b"<a> <b> .", BASE),
297            Err(PodError::BadRequest(_))
298        ));
299    }
300
301    #[test]
302    fn invalid_utf8_is_bad_request() {
303        assert!(matches!(
304            parse_turtle(&[0xff, 0xfe, 0x20], BASE),
305            Err(PodError::BadRequest(_))
306        ));
307    }
308
309    #[test]
310    fn oversize_body_is_rejected_before_parsing() {
311        let big = vec![b' '; MAX_RDF_INPUT_BYTES + 1];
312        assert!(matches!(
313            parse_turtle(&big, BASE),
314            Err(PodError::PayloadTooLarge(_))
315        ));
316    }
317
318    #[test]
319    fn bad_base_is_bad_request() {
320        assert!(matches!(
321            parse_turtle(b"<a:b> <a:c> <a:d> .", "not an iri"),
322            Err(PodError::BadRequest(_))
323        ));
324    }
325
326    #[test]
327    fn n3_plain_triples_parse() {
328        let g = parse_n3(b"@prefix : <http://e/> . :a :b :c , :d .", BASE).unwrap();
329        assert_eq!(g.len(), 2);
330    }
331
332    #[test]
333    fn n3_formula_is_unsupported() {
334        let r = parse_n3(b"@prefix : <http://e/> . { :a :b :c } :says :x .", BASE);
335        assert!(matches!(r, Err(PodError::Unsupported(_))), "{r:?}");
336    }
337
338    #[test]
339    fn n3_variable_is_unsupported() {
340        let r = parse_n3(b"@prefix : <http://e/> . ?x :b :c .", BASE);
341        assert!(matches!(r, Err(PodError::Unsupported(_))), "{r:?}");
342    }
343
344    #[test]
345    fn n3_rule_is_unsupported() {
346        let r = parse_n3(
347            b"@prefix : <http://e/> . { ?x :a :b } => { ?x :c :d } .",
348            BASE,
349        );
350        assert!(matches!(r, Err(PodError::Unsupported(_))), "{r:?}");
351        let r = parse_n3(b"@prefix : <http://e/> . {} => {} .", BASE);
352        assert!(r.is_err(), "empty-formula rule must be refused: {r:?}");
353    }
354
355    #[test]
356    fn n3_literal_subject_is_bad_request() {
357        let r = parse_n3(b"\"x\" <http://e/p> <http://e/o> .", BASE);
358        assert!(matches!(r, Err(PodError::BadRequest(_))), "{r:?}");
359    }
360
361    #[test]
362    fn content_type_mapping() {
363        assert_eq!(
364            RdfInputSyntax::from_content_type("Text/Turtle;charset=utf-8"),
365            Some(RdfInputSyntax::Turtle)
366        );
367        assert_eq!(
368            RdfInputSyntax::from_content_type("text/n3"),
369            Some(RdfInputSyntax::N3)
370        );
371        assert_eq!(RdfInputSyntax::from_content_type("text/plain"), None);
372    }
373}