Skip to main content

solid_pod_rs/
n3_patch.rs

1//! Solid N3 Patch, parsed with the full N3 grammar and applied as triples.
2//!
3//! Port of JSS `src/patch/quads.js` (PR #633, JSS `0.0.224`). The patch body is
4//! parsed with [`oxttl`]'s N3 parser against the resource URL, so prefixed
5//! names, `a`, `;`/`,` lists, typed and language-tagged literals, long strings
6//! and relative IRIs all survive. Semantics follow the Solid Protocol and
7//! JSS: a `solid:where` clause must match the document exactly once, every
8//! triple in `solid:deletes` must be present, variables are bound by the
9//! `where` clause, and blank nodes from the patch are minted fresh so they
10//! never merge with the document's own.
11//!
12//! The public surface is re-exported from [`crate::ldp`].
13
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use oxttl::n3::{N3Parser, N3Term};
17
18use crate::error::PodError;
19use crate::ldp::{Graph, PatchOutcome, Term, Triple};
20use crate::rdf_input::{literal_to_term, MAX_RDF_INPUT_BYTES};
21
22const SOLID: &str = "http://www.w3.org/ns/solid/terms#";
23const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
24
25/// Most triple patterns a `solid:where` formula may hold. Solid clients send
26/// a handful; the bound keeps evaluation, a join over the document, small.
27const MAX_WHERE_PATTERNS: usize = 32;
28
29/// Most partial bindings `where` evaluation keeps between patterns.
30/// Patterns are joined most-selective-first, so a selective clause stays far
31/// below it; a clause whose evaluation exceeds it is refused (409) as too
32/// broad, even where its final answer might have been unique.
33const MAX_PARTIAL_BINDINGS: usize = 10_000;
34
35/// Most pattern-against-triple comparisons one `where` evaluation's join
36/// makes. Terms are interned to integer ids first, so each comparison is
37/// constant-time whatever the length of the IRIs and literals involved.
38const MAX_MATCH_STEPS: usize = 10_000_000;
39
40/// A position in a patch triple: a concrete RDF term or a `?variable`.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum PatchTerm {
43    /// A concrete IRI, blank node or literal.
44    Term(Term),
45    /// A variable, named without its leading `?`.
46    Variable(String),
47}
48
49/// One triple pattern from a `solid:inserts`, `solid:deletes` or
50/// `solid:where` formula.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PatchTriple {
53    /// Subject position.
54    pub subject: PatchTerm,
55    /// Predicate position.
56    pub predicate: PatchTerm,
57    /// Object position.
58    pub object: PatchTerm,
59}
60
61/// A parsed Solid N3 Patch (`solid:InsertDeletePatch`).
62///
63/// Parsing and applying are separate so a server can authorise from the
64/// parsed patch (an insert-only patch needs only `acl:Append`) and then apply
65/// it inside its write lock.
66///
67/// ```
68/// use solid_pod_rs::ldp::{Graph, N3Patch};
69///
70/// let patch = N3Patch::parse(
71///     "@prefix solid: <http://www.w3.org/ns/solid/terms#>.\n\
72///      @prefix sioc: <http://rdfs.org/sioc/ns#>.\n\
73///      _:p a solid:InsertDeletePatch; solid:inserts { <#m1> sioc:content \"hello\" }.",
74///     Some("https://pod.example/chat.ttl"),
75/// )
76/// .unwrap();
77/// assert!(patch.is_insert_only());
78/// let out = patch.apply(Graph::new()).unwrap();
79/// assert_eq!(out.inserted, 1);
80/// ```
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct N3Patch {
83    /// Triples to add, after variable substitution.
84    pub inserts: Vec<PatchTriple>,
85    /// Triples to remove, after variable substitution; each must be present.
86    pub deletes: Vec<PatchTriple>,
87    /// Patterns that must match the document exactly once.
88    pub where_clause: Vec<PatchTriple>,
89}
90
91impl N3Patch {
92    /// Parse an N3 Patch body.
93    ///
94    /// `base_iri` is the absolute URL of the resource being patched; relative
95    /// IRIs resolve against it. With `None`, a relative IRI is a syntax error.
96    ///
97    /// The patch resource is any subject typed `solid:InsertDeletePatch` or
98    /// carrying `solid:inserts`, `solid:deletes` or `solid:where`; the triples
99    /// of each formula it names are collected. An empty formula `{ }` is a
100    /// no-op.
101    ///
102    /// # Errors
103    ///
104    /// [`PodError::BadRequest`] for a syntax error, a bad base IRI, a body
105    /// with no patch resource, or a literal in subject or predicate position;
106    /// [`PodError::PayloadTooLarge`] above
107    /// [`MAX_RDF_INPUT_BYTES`](crate::rdf_input::MAX_RDF_INPUT_BYTES).
108    pub fn parse(body: &str, base_iri: Option<&str>) -> Result<Self, PodError> {
109        if body.len() > MAX_RDF_INPUT_BYTES {
110            return Err(PodError::PayloadTooLarge(format!(
111                "N3 Patch of {} bytes exceeds the {MAX_RDF_INPUT_BYTES} byte limit",
112                body.len()
113            )));
114        }
115        let mut parser = N3Parser::new();
116        if let Some(base) = base_iri {
117            parser = parser
118                .with_base_iri(base)
119                .map_err(|e| bad(format!("invalid base IRI: {e}")))?;
120        }
121        let mut quads = Vec::new();
122        for item in parser.for_slice(body.as_bytes()) {
123            quads.push(item.map_err(|e| bad(format!("Invalid N3 Patch: {e}")))?);
124        }
125
126        let is_patch_prop = |p: &N3Term| {
127            matches!(p, N3Term::NamedNode(n) if [
128                format!("{SOLID}inserts"),
129                format!("{SOLID}deletes"),
130                format!("{SOLID}where"),
131            ].iter().any(|x| x == n.as_str()))
132        };
133        let is_patch_type = |p: &N3Term, o: &N3Term| {
134            matches!(p, N3Term::NamedNode(n) if n.as_str() == RDF_TYPE)
135                && matches!(o, N3Term::NamedNode(n) if n.as_str() == format!("{SOLID}InsertDeletePatch"))
136        };
137        let top: Vec<_> = quads
138            .iter()
139            .filter(|q| matches!(q.graph_name, oxrdf::GraphName::DefaultGraph))
140            .collect();
141        let patch_subjects: Vec<&N3Term> = top
142            .iter()
143            .filter(|q| is_patch_type(&q.predicate, &q.object) || is_patch_prop(&q.predicate))
144            .map(|q| &q.subject)
145            .collect();
146        if patch_subjects.is_empty() {
147            return Err(bad("Invalid N3 Patch: no solid:InsertDeletePatch found"));
148        }
149
150        let formula = |prop: &str| -> Result<Vec<PatchTriple>, PodError> {
151            let predicate = format!("{SOLID}{prop}");
152            let graphs: Vec<&str> = top
153                .iter()
154                .filter(|q| patch_subjects.contains(&&q.subject))
155                .filter(|q| matches!(&q.predicate, N3Term::NamedNode(n) if n.as_str() == predicate))
156                .filter_map(|q| match &q.object {
157                    N3Term::BlankNode(b) => Some(b.as_str()),
158                    _ => None,
159                })
160                .collect();
161            quads
162                .iter()
163                .filter(|q| {
164                    matches!(&q.graph_name, oxrdf::GraphName::BlankNode(g) if graphs.contains(&g.as_str()))
165                })
166                .map(|q| {
167                    Ok(PatchTriple {
168                        subject: pattern_term(&q.subject, "subject")?,
169                        predicate: pattern_term(&q.predicate, "predicate")?,
170                        object: pattern_term(&q.object, "object")?,
171                    })
172                })
173                .collect()
174        };
175
176        let where_clause = formula("where")?;
177        if where_clause.len() > MAX_WHERE_PATTERNS {
178            return Err(bad(format!(
179                "Invalid N3 Patch: the where clause has {} patterns, more than {MAX_WHERE_PATTERNS}",
180                where_clause.len()
181            )));
182        }
183        Ok(Self {
184            inserts: formula("inserts")?,
185            deletes: formula("deletes")?,
186            where_clause,
187        })
188    }
189
190    /// True when the patch only adds triples (no deletes, no `where`), which
191    /// the Solid Protocol authorises with `acl:Append` alone.
192    #[must_use]
193    pub fn is_insert_only(&self) -> bool {
194        self.deletes.is_empty() && self.where_clause.is_empty()
195    }
196
197    /// Apply the patch to `target`.
198    ///
199    /// # Errors
200    ///
201    /// [`PodError::Conflict`] (HTTP 409) when the `where` clause matches the
202    /// document zero times or more than once (including when it is too
203    /// broad to evaluate within a fixed budget), when a triple to delete is
204    /// absent, or when a variable is used without being bound by `where`.
205    pub fn apply(&self, target: Graph) -> Result<PatchOutcome, PodError> {
206        let bindings = if self.where_clause.is_empty() {
207            Vec::new()
208        } else {
209            let found = solutions(&self.where_clause, &target)?;
210            match found.as_slice() {
211                [only] => only
212                    .iter()
213                    .map(|(name, term)| ((*name).to_string(), (*term).clone()))
214                    .collect(),
215                [] => {
216                    return Err(PodError::Conflict(
217                        "the patch where clause does not match the resource".into(),
218                    ))
219                }
220                _ => {
221                    return Err(PodError::Conflict(
222                        "the patch where clause matches more than once".into(),
223                    ))
224                }
225            }
226        };
227
228        let mut fresh = FreshBlanks::default();
229        let deletes = self
230            .deletes
231            .iter()
232            .map(|t| substitute(t, &bindings, &mut fresh))
233            .collect::<Result<Vec<_>, _>>()?;
234        let inserts = self
235            .inserts
236            .iter()
237            .map(|t| substitute(t, &bindings, &mut fresh))
238            .collect::<Result<Vec<_>, _>>()?;
239
240        let mut graph = target;
241        let mut removal = Graph::new();
242        for d in &deletes {
243            if !graph.contains(d) {
244                return Err(PodError::Conflict(format!(
245                    "triple to delete not found: {d:?}"
246                )));
247            }
248            removal.insert(d.clone());
249        }
250        graph.subtract(&removal);
251        let mut addition = Graph::new();
252        for i in inserts {
253            addition.insert(i);
254        }
255        let inserted = addition.len();
256        graph.extend(&addition);
257
258        Ok(PatchOutcome {
259            graph,
260            inserted,
261            deleted: removal.len(),
262        })
263    }
264}
265
266fn bad(msg: impl Into<String>) -> PodError {
267    PodError::BadRequest(msg.into())
268}
269
270fn pattern_term(t: &N3Term, role: &str) -> Result<PatchTerm, PodError> {
271    let term = match t {
272        N3Term::Variable(v) => return Ok(PatchTerm::Variable(v.as_str().to_string())),
273        N3Term::NamedNode(n) => Term::Iri(n.as_str().to_string()),
274        N3Term::BlankNode(b) => Term::BlankNode(b.as_str().to_string()),
275        N3Term::Literal(l) => {
276            if role != "object" {
277                return Err(bad(format!("a literal is not allowed in {role} position")));
278            }
279            literal_to_term(l)
280        }
281        #[allow(unreachable_patterns)]
282        _ => return Err(bad(format!("unsupported N3 term in {role} position"))),
283    };
284    if role == "predicate" && !matches!(term, Term::Iri(_)) {
285        return Err(bad("a patch predicate must be an IRI or a variable"));
286    }
287    Ok(PatchTerm::Term(term))
288}
289
290/// Variable bindings owned by the applied patch.
291type Bindings = Vec<(String, Term)>;
292
293fn bound<'a>(b: &'a Bindings, name: &str) -> Option<&'a Term> {
294    b.iter().find(|(k, _)| k == name).map(|(_, v)| v)
295}
296
297/// A `where` solution: each variable name with the document term bound to it.
298type BorrowedBindings<'a> = Vec<(&'a str, &'a Term)>;
299
300/// Marks an unbound variable slot in an interned binding.
301const UNBOUND: u32 = u32::MAX;
302
303/// One position of a `where` pattern, interned against the document.
304#[derive(Clone, Copy)]
305enum Slot {
306    /// A constant, by term id; `None` when the document never holds it, so
307    /// the pattern cannot match.
308    Const(Option<u32>),
309    /// A variable, by index into the binding.
310    Var(usize),
311}
312
313/// Extend interned binding `b` so pattern `p` matches triple `t`, or `None`.
314/// Constant-time; allocates only on a match.
315fn extend(p: &[Slot; 3], t: &[u32; 3], b: &[u32]) -> Option<Vec<u32>> {
316    let mut tentative: [(usize, u32); 3] = [(usize::MAX, UNBOUND); 3];
317    for i in 0..3 {
318        match p[i] {
319            Slot::Const(Some(id)) if id == t[i] => {}
320            Slot::Const(_) => return None,
321            Slot::Var(v) => {
322                let earlier = if b[v] != UNBOUND {
323                    Some(b[v])
324                } else {
325                    tentative[..i]
326                        .iter()
327                        .find(|(k, _)| *k == v)
328                        .map(|(_, id)| *id)
329                };
330                match earlier {
331                    Some(id) if id != t[i] => return None,
332                    Some(_) => {}
333                    None => tentative[i] = (v, t[i]),
334                }
335            }
336        }
337    }
338    let mut next = b.to_vec();
339    for (v, id) in tentative {
340        if v != usize::MAX {
341            next[v] = id;
342        }
343    }
344    Some(next)
345}
346
347/// The bindings of the `where` patterns against the document, stopping at
348/// two: the caller accepts exactly one, so a second already decides.
349///
350/// Every document term is interned to an integer id once (hashing, linear in
351/// the document's size), so the join compares ids in constant time. Each
352/// pattern's matches are counted once, then patterns are joined greedily:
353/// next, a pattern sharing a variable already bound (avoiding cartesian
354/// products), fewest counted matches first. The answer and the cost do not
355/// depend on the order the client wrote the patterns in. The graph is a set,
356/// so a binding extended by a pattern it fully binds matches at most one
357/// triple; distinct triples therefore give distinct bindings.
358///
359/// # Errors
360///
361/// [`PodError::Conflict`] when the join would exceed
362/// [`MAX_PARTIAL_BINDINGS`] or [`MAX_MATCH_STEPS`].
363fn solutions<'a>(
364    patterns: &'a [PatchTriple],
365    g: &'a Graph,
366) -> Result<Vec<BorrowedBindings<'a>>, PodError> {
367    let too_broad = || PodError::Conflict("the patch where clause is too broad to evaluate".into());
368
369    let mut ids: std::collections::HashMap<&'a Term, u32> = std::collections::HashMap::new();
370    let mut terms: Vec<&'a Term> = Vec::new();
371    let mut intern = |t: &'a Term| -> u32 {
372        *ids.entry(t).or_insert_with(|| {
373            terms.push(t);
374            u32::try_from(terms.len() - 1).unwrap_or(UNBOUND)
375        })
376    };
377    let triples: Vec<[u32; 3]> = g
378        .triples()
379        .map(|t| [intern(&t.subject), intern(&t.predicate), intern(&t.object)])
380        .collect();
381
382    let mut var_names: Vec<&'a str> = Vec::new();
383    let compiled: Vec<[Slot; 3]> = patterns
384        .iter()
385        .map(|p| {
386            [&p.subject, &p.predicate, &p.object].map(|term| match term {
387                PatchTerm::Term(t) => Slot::Const(ids.get(t).copied()),
388                PatchTerm::Variable(name) => {
389                    Slot::Var(match var_names.iter().position(|n| *n == name.as_str()) {
390                        Some(i) => i,
391                        None => {
392                            var_names.push(name.as_str());
393                            var_names.len() - 1
394                        }
395                    })
396                }
397            })
398        })
399        .collect();
400    let empty = vec![UNBOUND; var_names.len()];
401    // Matches per pattern on its own: one linear scan each, at most
402    // MAX_WHERE_PATTERNS of them, outside the join budget.
403    let counts: Vec<usize> = compiled
404        .iter()
405        .map(|p| {
406            triples
407                .iter()
408                .filter(|t| extend(p, t, &empty).is_some())
409                .count()
410        })
411        .collect();
412
413    let mut remaining: Vec<usize> = (0..compiled.len()).collect();
414    let mut bound = vec![false; var_names.len()];
415    let mut steps = 0usize;
416    let mut partial: Vec<Vec<u32>> = vec![empty.clone()];
417    while !remaining.is_empty() {
418        let pick = (0..remaining.len())
419            .min_by_key(|&i| {
420                let p = &compiled[remaining[i]];
421                let joined = p.iter().any(|s| matches!(s, Slot::Var(v) if bound[*v]));
422                (!joined, counts[remaining[i]], i)
423            })
424            .unwrap_or(0);
425        let p = compiled[remaining.remove(pick)];
426        for s in p {
427            if let Slot::Var(v) = s {
428                bound[v] = true;
429            }
430        }
431        let last = remaining.is_empty();
432        let mut next = Vec::new();
433        'extend: for b in &partial {
434            for t in &triples {
435                steps += 1;
436                if steps > MAX_MATCH_STEPS {
437                    return Err(too_broad());
438                }
439                if let Some(nb) = extend(&p, t, b) {
440                    next.push(nb);
441                    if last && next.len() > 1 {
442                        break 'extend;
443                    }
444                    if next.len() > MAX_PARTIAL_BINDINGS {
445                        return Err(too_broad());
446                    }
447                }
448            }
449        }
450        partial = next;
451        if partial.is_empty() {
452            break;
453        }
454    }
455    Ok(partial
456        .into_iter()
457        .map(|b| {
458            b.into_iter()
459                .enumerate()
460                .filter(|(_, id)| *id != UNBOUND)
461                .map(|(v, id)| (var_names[v], terms[id as usize]))
462                .collect()
463        })
464        .collect())
465}
466
467/// Renames the patch's blank nodes to labels no document can already hold.
468#[derive(Default)]
469struct FreshBlanks {
470    map: Vec<(String, String)>,
471}
472
473impl FreshBlanks {
474    fn get(&mut self, label: &str) -> Term {
475        static COUNTER: AtomicU64 = AtomicU64::new(0);
476        if let Some((_, fresh)) = self.map.iter().find(|(l, _)| l == label) {
477            return Term::BlankNode(fresh.clone());
478        }
479        let nonce = std::time::SystemTime::now()
480            .duration_since(std::time::UNIX_EPOCH)
481            .map(|d| d.as_nanos())
482            .unwrap_or(0);
483        let fresh = format!("p{}_{nonce:x}", COUNTER.fetch_add(1, Ordering::Relaxed));
484        self.map.push((label.to_string(), fresh.clone()));
485        Term::BlankNode(fresh)
486    }
487}
488
489fn substitute(t: &PatchTriple, b: &Bindings, fresh: &mut FreshBlanks) -> Result<Triple, PodError> {
490    let mut term = |p: &PatchTerm| -> Result<Term, PodError> {
491        match p {
492            PatchTerm::Variable(name) => bound(b, name)
493                .cloned()
494                .ok_or_else(|| PodError::Conflict(format!("unbound variable ?{name} in patch"))),
495            PatchTerm::Term(Term::BlankNode(label)) => Ok(fresh.get(label)),
496            PatchTerm::Term(t) => Ok(t.clone()),
497        }
498    };
499    let subject = term(&t.subject)?;
500    let predicate = term(&t.predicate)?;
501    let object = term(&t.object)?;
502    if matches!(subject, Term::Literal { .. }) || !matches!(predicate, Term::Iri(_)) {
503        return Err(PodError::Conflict(
504            "a variable bound to a literal cannot be a subject or predicate".into(),
505        ));
506    }
507    Ok(Triple::new(subject, predicate, object))
508}