Skip to main content

solid_pod_rs/
ldp.rs

1//! Linked Data Platform (LDP) resource and container semantics.
2//!
3//! Phase 2 scope:
4//!
5//! - Full `Link` header set (type + acl + describedby + storage root).
6//! - `Prefer` header parsing (PreferMinimalContainer, PreferContainedIRIs).
7//! - `Accept-Post` header for containers.
8//! - PATCH via N3 (solid-protocol PATCH): `insert`, `delete`, `where`.
9//! - PATCH via SPARQL-Update (`INSERT DATA`, `DELETE DATA`, `DELETE WHERE`).
10//! - Content negotiation: Turtle, JSON-LD, N-Triples, RDF/XML.
11//! - Server-managed triples (`dc:modified`, `stat:size`, `ldp:contains`).
12//! - `.meta` sidecar resolution.
13//!
14//! The module intentionally uses a tiny, in-crate RDF triple model so
15//! the crate stays free of the heavier `sophia` / `oxigraph` dep trees.
16//! Only Turtle-subset parsing required for real-world Solid Pod PATCH
17//! flows is supported; client-supplied RDF is parsed via the N-Triples
18//! fallback whenever the Turtle fast path hits something exotic.
19
20use std::collections::BTreeSet;
21use std::fmt::Write as _;
22
23#[cfg(feature = "tokio-runtime")]
24use async_trait::async_trait;
25use serde::Serialize;
26
27use crate::error::PodError;
28// `Storage` lives behind `tokio-runtime` — the LDP parsers themselves
29// are pure and compile under `core`, only the storage-driven container
30// representation helper at the bottom of this file needs the trait.
31#[cfg(feature = "tokio-runtime")]
32use crate::storage::Storage;
33
34/// Well-known IRI constants used in LDP, WAC, and server-managed triples.
35pub mod iri {
36    /// LDP Resource type IRI.
37    pub const LDP_RESOURCE: &str = "http://www.w3.org/ns/ldp#Resource";
38    /// LDP Container type IRI.
39    pub const LDP_CONTAINER: &str = "http://www.w3.org/ns/ldp#Container";
40    /// LDP BasicContainer type IRI (the only container type Solid uses).
41    pub const LDP_BASIC_CONTAINER: &str = "http://www.w3.org/ns/ldp#BasicContainer";
42    /// LDP namespace prefix.
43    pub const LDP_NS: &str = "http://www.w3.org/ns/ldp#";
44    /// `ldp:contains` predicate linking a container to its children.
45    pub const LDP_CONTAINS: &str = "http://www.w3.org/ns/ldp#contains";
46    /// Prefer token for minimal container responses (omit containment triples).
47    pub const LDP_PREFER_MINIMAL_CONTAINER: &str =
48        "http://www.w3.org/ns/ldp#PreferMinimalContainer";
49    /// Prefer token for contained-IRIs-only responses.
50    pub const LDP_PREFER_CONTAINED_IRIS: &str =
51        "http://www.w3.org/ns/ldp#PreferContainedIRIs";
52    /// Prefer token for membership triples.
53    pub const LDP_PREFER_MEMBERSHIP: &str = "http://www.w3.org/ns/ldp#PreferMembership";
54
55    /// Dublin Core Terms namespace prefix.
56    pub const DCTERMS_NS: &str = "http://purl.org/dc/terms/";
57    /// `dcterms:modified` predicate for last-modification timestamps.
58    pub const DCTERMS_MODIFIED: &str = "http://purl.org/dc/terms/modified";
59
60    /// POSIX stat namespace prefix.
61    pub const STAT_NS: &str = "http://www.w3.org/ns/posix/stat#";
62    /// `stat:size` predicate for resource byte size.
63    pub const STAT_SIZE: &str = "http://www.w3.org/ns/posix/stat#size";
64    /// `stat:mtime` predicate for POSIX modification time.
65    pub const STAT_MTIME: &str = "http://www.w3.org/ns/posix/stat#mtime";
66
67    /// XSD `dateTime` datatype IRI.
68    pub const XSD_DATETIME: &str = "http://www.w3.org/2001/XMLSchema#dateTime";
69    /// XSD `integer` datatype IRI.
70    pub const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
71    /// XSD `string` datatype IRI.
72    pub const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
73
74    /// PIM Storage type IRI (`pim:Storage`).
75    pub const PIM_STORAGE: &str = "http://www.w3.org/ns/pim/space#Storage";
76    /// PIM storage relation IRI (`pim:storage`), used in root `Link` headers.
77    pub const PIM_STORAGE_REL: &str = "http://www.w3.org/ns/pim/space#storage";
78
79    /// WAC (Web Access Control) namespace prefix.
80    pub const ACL_NS: &str = "http://www.w3.org/ns/auth/acl#";
81}
82
83/// MIME types recognised by the content negotiator. The order matters:
84/// the first format that matches the `Accept` header wins, and if the
85/// client provides `*/*` the server defaults to Turtle.
86pub const ACCEPT_POST: &str = "text/turtle, application/ld+json, application/n-triples";
87
88/// Return whether a path addresses an LDP container.
89pub fn is_container(path: &str) -> bool {
90    path == "/" || path.ends_with('/')
91}
92
93/// Return whether a path addresses an ACL sidecar.
94pub fn is_acl_path(path: &str) -> bool {
95    path.ends_with(".acl")
96}
97
98/// Return whether a path addresses a `.meta` sidecar.
99pub fn is_meta_path(path: &str) -> bool {
100    path.ends_with(".meta")
101}
102
103/// Compute the `.meta` sidecar for a resource.
104pub fn meta_sidecar_for(path: &str) -> String {
105    if is_meta_path(path) {
106        path.to_string()
107    } else {
108        format!("{path}.meta")
109    }
110}
111
112/// Build the full set of `Link` headers for a given resource path.
113///
114/// Emits:
115/// - `<ldp:Resource>; rel="type"` always.
116/// - `<ldp:Container>; rel="type"` + `<ldp:BasicContainer>; rel="type"` for containers.
117/// - `<path.acl>; rel="acl"` for every resource except the ACL itself.
118/// - `<path.meta>; rel="describedby"` for every non-meta resource.
119/// - `</>; rel="http://www.w3.org/ns/pim/space#storage"` for the pod root.
120pub fn link_headers(path: &str) -> Vec<String> {
121    let mut out = Vec::new();
122    if is_container(path) {
123        out.push(format!("<{}>; rel=\"type\"", iri::LDP_BASIC_CONTAINER));
124        out.push(format!("<{}>; rel=\"type\"", iri::LDP_CONTAINER));
125        out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
126    } else {
127        out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
128    }
129    if !is_acl_path(path) {
130        let acl_target = format!("{path}.acl");
131        out.push(format!("<{acl_target}>; rel=\"acl\""));
132    }
133    if !is_meta_path(path) && !is_acl_path(path) {
134        let meta_target = meta_sidecar_for(path);
135        out.push(format!("<{meta_target}>; rel=\"describedby\""));
136    }
137    if path == "/" {
138        out.push(format!("</>; rel=\"{}\"", iri::PIM_STORAGE_REL));
139    }
140    out
141}
142
143/// Maximum byte length of a client-supplied `Slug` header. JSS caps at
144/// 255 bytes (POSIX filename limit); we match for interop.
145pub const MAX_SLUG_BYTES: usize = 255;
146
147/// Resolve the target path when POSTing to a container.
148///
149/// Validation rules (JSS parity):
150/// * `Slug` absent or empty → UUID-v4 fallback.
151/// * Non-empty `Slug` must be ≤ 255 bytes, must not contain `/`, `..`,
152///   or `\0`, and every character must match `[A-Za-z0-9._-]`.
153///
154/// Invalid slugs return `Err(PodError::BadRequest)` so the client sees a
155/// `400` and can correct, instead of silently receiving a UUID path.
156pub fn resolve_slug(container: &str, slug: Option<&str>) -> Result<String, PodError> {
157    let join = |name: &str| {
158        if container.ends_with('/') {
159            format!("{container}{name}")
160        } else {
161            format!("{container}/{name}")
162        }
163    };
164    match slug {
165        Some(s) if !s.is_empty() => {
166            if s.len() > MAX_SLUG_BYTES {
167                return Err(PodError::BadRequest(format!(
168                    "slug exceeds {MAX_SLUG_BYTES} bytes"
169                )));
170            }
171            if s.contains('/') || s.contains("..") || s.contains('\0') {
172                return Err(PodError::BadRequest(format!("invalid slug: {s:?}")));
173            }
174            if !s
175                .chars()
176                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
177            {
178                return Err(PodError::BadRequest(format!(
179                    "slug contains disallowed character: {s:?}"
180                )));
181            }
182            Ok(join(s))
183        }
184        _ => Ok(join(&uuid::Uuid::new_v4().to_string())),
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Prefer header parsing (RFC 7240 + LDP 4.2.2)
190// ---------------------------------------------------------------------------
191
192/// What portions of a container representation the client wants.
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
194pub enum ContainerRepresentation {
195    /// Membership triples + metadata (default).
196    #[default]
197    Full,
198    /// `ldp:contains` + container metadata only.
199    MinimalContainer,
200    /// Only the list of contained IRIs, no server metadata.
201    ContainedIRIsOnly,
202}
203
204/// Parsed `Prefer` header value. Non-`return=representation` preferences
205/// are ignored (the LDP spec allows this).
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
207pub struct PreferHeader {
208    pub representation: ContainerRepresentation,
209    pub include_minimal: bool,
210    pub include_contained_iris: bool,
211    pub omit_membership: bool,
212}
213
214impl PreferHeader {
215    /// Parse a `Prefer` header value per RFC 7240 (tolerant).
216    pub fn parse(value: &str) -> Self {
217        let mut out = PreferHeader::default();
218        // Preferences are separated by `,` at the top level.
219        for pref in value.split(',') {
220            let pref = pref.trim();
221            if pref.is_empty() {
222                continue;
223            }
224            // Tokens are separated by `;`.
225            let mut parts = pref.split(';').map(|s| s.trim());
226            let head = match parts.next() {
227                Some(h) => h,
228                None => continue,
229            };
230            if !head.eq_ignore_ascii_case("return=representation") {
231                continue;
232            }
233            for token in parts {
234                if let Some(val) = token
235                    .strip_prefix("include=")
236                    .or_else(|| token.strip_prefix("include ="))
237                {
238                    let unq = val.trim().trim_matches('"');
239                    for iri in unq.split_whitespace() {
240                        if iri == iri::LDP_PREFER_MINIMAL_CONTAINER {
241                            out.include_minimal = true;
242                            out.representation = ContainerRepresentation::MinimalContainer;
243                        } else if iri == iri::LDP_PREFER_CONTAINED_IRIS {
244                            out.include_contained_iris = true;
245                            out.representation = ContainerRepresentation::ContainedIRIsOnly;
246                        }
247                    }
248                } else if let Some(val) = token
249                    .strip_prefix("omit=")
250                    .or_else(|| token.strip_prefix("omit ="))
251                {
252                    let unq = val.trim().trim_matches('"');
253                    for iri in unq.split_whitespace() {
254                        if iri == iri::LDP_PREFER_MEMBERSHIP {
255                            out.omit_membership = true;
256                        }
257                    }
258                }
259            }
260        }
261        out
262    }
263}
264
265// ---------------------------------------------------------------------------
266// Content negotiation
267// ---------------------------------------------------------------------------
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum RdfFormat {
271    Turtle,
272    JsonLd,
273    NTriples,
274    RdfXml,
275}
276
277impl RdfFormat {
278    pub fn mime(&self) -> &'static str {
279        match self {
280            RdfFormat::Turtle => "text/turtle",
281            RdfFormat::JsonLd => "application/ld+json",
282            RdfFormat::NTriples => "application/n-triples",
283            RdfFormat::RdfXml => "application/rdf+xml",
284        }
285    }
286
287    pub fn from_mime(mime: &str) -> Option<Self> {
288        let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
289        match mime.as_str() {
290            "text/turtle" | "application/turtle" | "application/x-turtle" => {
291                Some(RdfFormat::Turtle)
292            }
293            "application/ld+json" | "application/json+ld" => Some(RdfFormat::JsonLd),
294            "application/n-triples" | "text/plain+ntriples" => Some(RdfFormat::NTriples),
295            "application/rdf+xml" => Some(RdfFormat::RdfXml),
296            _ => None,
297        }
298    }
299}
300
301/// Pick the best RDF format based on an `Accept` header.
302///
303/// q-values are respected; on ties Turtle wins. `*/*` falls back to Turtle.
304pub fn negotiate_format(accept: Option<&str>) -> RdfFormat {
305    let accept = match accept {
306        Some(a) if !a.trim().is_empty() => a,
307        _ => return RdfFormat::Turtle,
308    };
309
310    let mut best: Option<(f32, RdfFormat)> = None;
311    for entry in accept.split(',') {
312        let entry = entry.trim();
313        if entry.is_empty() {
314            continue;
315        }
316        let mut parts = entry.split(';').map(|s| s.trim());
317        let mime = match parts.next() {
318            Some(m) => m.to_ascii_lowercase(),
319            None => continue,
320        };
321        let mut q: f32 = 1.0;
322        for token in parts {
323            if let Some(v) = token.strip_prefix("q=") {
324                if let Ok(parsed) = v.parse::<f32>() {
325                    q = parsed;
326                }
327            }
328        }
329        let format = match mime.as_str() {
330            "text/turtle" | "application/turtle" => Some(RdfFormat::Turtle),
331            "application/ld+json" => Some(RdfFormat::JsonLd),
332            "application/n-triples" => Some(RdfFormat::NTriples),
333            "application/rdf+xml" => Some(RdfFormat::RdfXml),
334            "*/*" | "application/*" | "text/*" => Some(RdfFormat::Turtle),
335            _ => None,
336        };
337        if let Some(f) = format {
338            match best {
339                None => best = Some((q, f)),
340                Some((bq, _)) if q > bq => best = Some((q, f)),
341                _ => {}
342            }
343        }
344    }
345    best.map(|(_, f)| f).unwrap_or(RdfFormat::Turtle)
346}
347
348/// Infer a content-type for auxiliary "dotfile" resources (`.acl`, `.meta`,
349/// and their `*.acl` / `*.meta` suffix variants) whose extension-based
350/// lookup would otherwise fail.
351///
352/// Mirrors JSS `src/util/Conversion.ts::getContentTypeFromExtension`
353/// post-PR #294 (commit `de02f15`), which patched the same class of bug
354/// arising from `path.extname('.acl') === ''` — leading-dot filenames
355/// have no "extension" in the Node sense, so the table lookup returned
356/// undefined and conneg rejected the resource.
357///
358/// Returns `Some("application/ld+json")` for canonical Solid ACL/meta
359/// resources; `None` otherwise (callers should fall back to
360/// `application/octet-stream`).
361///
362/// Matching rules (suffix-only, never substring):
363///   * basename is exactly `.acl` or `.meta`
364///   * basename ends with `.acl` or `.meta` (e.g. `foo.acl`, `data.meta`)
365///   * names that merely *contain* `.acl`/`.meta` mid-string
366///     (e.g. `not.aclfile`, `foo.acl.bak`) do NOT match.
367pub fn infer_dotfile_content_type(path: &str) -> Option<&'static str> {
368    // Extract the basename: strip trailing slashes, then take the last
369    // path segment. Empty input or a pure slash run yields None.
370    let trimmed = path.trim_end_matches('/');
371    if trimmed.is_empty() {
372        return None;
373    }
374    let basename = trimmed
375        .rsplit('/')
376        .next()
377        .filter(|s| !s.is_empty())?;
378
379    // Suffix test, per JSS's `.acl` / `.meta` matcher. Includes the
380    // bare-name case (`.acl`, `.meta`) because `str::ends_with` is true
381    // for equal strings.
382    if basename.ends_with(".acl") || basename.ends_with(".meta") {
383        Some("application/ld+json")
384    } else {
385        None
386    }
387}
388
389#[cfg(test)]
390mod infer_dotfile_tests {
391    use super::infer_dotfile_content_type;
392
393    #[test]
394    fn infer_dotfile_content_type_acl_file_returns_jsonld() {
395        assert_eq!(
396            infer_dotfile_content_type("/.acl"),
397            Some("application/ld+json")
398        );
399        assert_eq!(
400            infer_dotfile_content_type("/pods/alice/foo.acl"),
401            Some("application/ld+json")
402        );
403        assert_eq!(
404            infer_dotfile_content_type(".acl"),
405            Some("application/ld+json")
406        );
407    }
408
409    #[test]
410    fn infer_dotfile_content_type_meta_file_returns_jsonld() {
411        assert_eq!(
412            infer_dotfile_content_type("/.meta"),
413            Some("application/ld+json")
414        );
415        assert_eq!(
416            infer_dotfile_content_type("/pods/alice/foo.meta"),
417            Some("application/ld+json")
418        );
419    }
420
421    #[test]
422    fn infer_dotfile_content_type_dotted_midname_returns_none() {
423        // Mid-name `.acl.` / `.meta.` must not trigger — JSS's suffix
424        // match would miss these too.
425        assert_eq!(infer_dotfile_content_type("/foo.acl.bak"), None);
426        assert_eq!(infer_dotfile_content_type("/foo.meta.bak"), None);
427    }
428
429    #[test]
430    fn infer_dotfile_content_type_substring_only_returns_none() {
431        // `.acl` / `.meta` appearing as a substring, not a suffix.
432        assert_eq!(infer_dotfile_content_type("/not.aclfile"), None);
433        assert_eq!(infer_dotfile_content_type("/some.metainfo"), None);
434        assert_eq!(infer_dotfile_content_type("/plain.txt"), None);
435    }
436
437    #[test]
438    fn infer_dotfile_content_type_trailing_slash_stripped() {
439        // Container path ending in `/` — strip before basename extract.
440        assert_eq!(
441            infer_dotfile_content_type("/pods/alice/foo.acl/"),
442            Some("application/ld+json")
443        );
444        assert_eq!(infer_dotfile_content_type("/"), None);
445        assert_eq!(infer_dotfile_content_type(""), None);
446    }
447}
448
449// ---------------------------------------------------------------------------
450// In-crate RDF triple model (minimal, sufficient for PATCH evaluation)
451// ---------------------------------------------------------------------------
452
453#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
454pub enum Term {
455    Iri(String),
456    BlankNode(String),
457    Literal {
458        value: String,
459        datatype: Option<String>,
460        language: Option<String>,
461    },
462}
463
464impl Term {
465    pub fn iri(i: impl Into<String>) -> Self {
466        Term::Iri(i.into())
467    }
468    pub fn blank(b: impl Into<String>) -> Self {
469        Term::BlankNode(b.into())
470    }
471    pub fn literal(v: impl Into<String>) -> Self {
472        Term::Literal {
473            value: v.into(),
474            datatype: None,
475            language: None,
476        }
477    }
478    pub fn typed_literal(v: impl Into<String>, dt: impl Into<String>) -> Self {
479        Term::Literal {
480            value: v.into(),
481            datatype: Some(dt.into()),
482            language: None,
483        }
484    }
485
486    fn write_ntriples(&self, out: &mut String) {
487        match self {
488            Term::Iri(i) => {
489                out.push('<');
490                out.push_str(i);
491                out.push('>');
492            }
493            Term::BlankNode(b) => {
494                out.push_str("_:");
495                out.push_str(b);
496            }
497            Term::Literal {
498                value,
499                datatype,
500                language,
501            } => {
502                out.push('"');
503                for c in value.chars() {
504                    match c {
505                        '\\' => out.push_str("\\\\"),
506                        '"' => out.push_str("\\\""),
507                        '\n' => out.push_str("\\n"),
508                        '\r' => out.push_str("\\r"),
509                        '\t' => out.push_str("\\t"),
510                        _ => out.push(c),
511                    }
512                }
513                out.push('"');
514                if let Some(lang) = language {
515                    out.push('@');
516                    out.push_str(lang);
517                } else if let Some(dt) = datatype {
518                    out.push_str("^^<");
519                    out.push_str(dt);
520                    out.push('>');
521                }
522            }
523        }
524    }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
528pub struct Triple {
529    pub subject: Term,
530    pub predicate: Term,
531    pub object: Term,
532}
533
534impl Triple {
535    pub fn new(subject: Term, predicate: Term, object: Term) -> Self {
536        Self {
537            subject,
538            predicate,
539            object,
540        }
541    }
542}
543
544/// Minimal RDF graph — a sorted set of triples.
545#[derive(Debug, Clone, Default, PartialEq, Eq)]
546pub struct Graph {
547    triples: BTreeSet<Triple>,
548}
549
550impl Graph {
551    pub fn new() -> Self {
552        Self {
553            triples: BTreeSet::new(),
554        }
555    }
556
557    pub fn from_triples(triples: impl IntoIterator<Item = Triple>) -> Self {
558        let mut g = Self::new();
559        for t in triples {
560            g.insert(t);
561        }
562        g
563    }
564
565    pub fn insert(&mut self, triple: Triple) {
566        self.triples.insert(triple);
567    }
568
569    pub fn remove(&mut self, triple: &Triple) -> bool {
570        self.triples.remove(triple)
571    }
572
573    pub fn contains(&self, triple: &Triple) -> bool {
574        self.triples.contains(triple)
575    }
576
577    pub fn len(&self) -> usize {
578        self.triples.len()
579    }
580
581    pub fn is_empty(&self) -> bool {
582        self.triples.is_empty()
583    }
584
585    pub fn triples(&self) -> impl Iterator<Item = &Triple> {
586        self.triples.iter()
587    }
588
589    /// Extend with all triples from another graph.
590    pub fn extend(&mut self, other: &Graph) {
591        for t in &other.triples {
592            self.triples.insert(t.clone());
593        }
594    }
595
596    /// Remove every triple in `other` that is present in `self`.
597    pub fn subtract(&mut self, other: &Graph) {
598        for t in &other.triples {
599            self.triples.remove(t);
600        }
601    }
602
603    /// Serialise to N-Triples.
604    pub fn to_ntriples(&self) -> String {
605        let mut out = String::new();
606        for t in &self.triples {
607            t.subject.write_ntriples(&mut out);
608            out.push(' ');
609            t.predicate.write_ntriples(&mut out);
610            out.push(' ');
611            t.object.write_ntriples(&mut out);
612            out.push_str(" .\n");
613        }
614        out
615    }
616
617    /// Parse N-Triples — supports the full EBNF subset used by PATCH.
618    pub fn parse_ntriples(input: &str) -> Result<Self, PodError> {
619        let mut g = Graph::new();
620        for (i, line) in input.lines().enumerate() {
621            let line = line.trim();
622            if line.is_empty() || line.starts_with('#') {
623                continue;
624            }
625            let t = parse_nt_line(line)
626                .map_err(|e| PodError::Unsupported(format!("N-Triples line {}: {e}", i + 1)))?;
627            g.insert(t);
628        }
629        Ok(g)
630    }
631}
632
633fn parse_nt_line(line: &str) -> Result<Triple, String> {
634    let line = line.trim_end_matches('.').trim();
635    let (subject, rest) = read_term(line)?;
636    let rest = rest.trim_start();
637    let (predicate, rest) = read_term(rest)?;
638    let rest = rest.trim_start();
639    let (object, _rest) = read_term(rest)?;
640    Ok(Triple::new(subject, predicate, object))
641}
642
643fn read_term(input: &str) -> Result<(Term, &str), String> {
644    let input = input.trim_start();
645    if let Some(rest) = input.strip_prefix('<') {
646        let end = rest.find('>').ok_or_else(|| "unterminated IRI".to_string())?;
647        let iri = &rest[..end];
648        Ok((Term::Iri(iri.to_string()), &rest[end + 1..]))
649    } else if let Some(rest) = input.strip_prefix("_:") {
650        let end = rest
651            .find(|c: char| c.is_whitespace() || c == '.')
652            .unwrap_or(rest.len());
653        Ok((Term::BlankNode(rest[..end].to_string()), &rest[end..]))
654    } else if input.starts_with('"') {
655        read_literal(input)
656    } else {
657        Err(format!("unexpected char: {}", input.chars().next().unwrap_or('?')))
658    }
659}
660
661fn read_literal(input: &str) -> Result<(Term, &str), String> {
662    let bytes = input.as_bytes();
663    if bytes.first() != Some(&b'"') {
664        return Err("expected '\"'".to_string());
665    }
666    let mut i = 1usize;
667    let mut value = String::new();
668    while i < bytes.len() {
669        match bytes[i] {
670            b'\\' if i + 1 < bytes.len() => {
671                match bytes[i + 1] {
672                    b'n' => value.push('\n'),
673                    b't' => value.push('\t'),
674                    b'r' => value.push('\r'),
675                    b'"' => value.push('"'),
676                    b'\\' => value.push('\\'),
677                    other => value.push(other as char),
678                }
679                i += 2;
680            }
681            b'"' => {
682                i += 1;
683                break;
684            }
685            other => {
686                value.push(other as char);
687                i += 1;
688            }
689        }
690    }
691    let rest = &input[i..];
692    let (datatype, language, rest) = if let Some(r) = rest.strip_prefix("^^<") {
693        let end = r.find('>').ok_or_else(|| "unterminated datatype IRI".to_string())?;
694        (Some(r[..end].to_string()), None, &r[end + 1..])
695    } else if let Some(r) = rest.strip_prefix('@') {
696        let end = r
697            .find(|c: char| c.is_whitespace() || c == '.')
698            .unwrap_or(r.len());
699        (None, Some(r[..end].to_string()), &r[end..])
700    } else {
701        (None, None, rest)
702    };
703    Ok((
704        Term::Literal {
705            value,
706            datatype,
707            language,
708        },
709        rest,
710    ))
711}
712
713// ---------------------------------------------------------------------------
714// Server-managed triples
715// ---------------------------------------------------------------------------
716
717/// Compute the server-managed triples for a resource (`dc:modified`,
718/// `stat:size`, and for containers `ldp:contains` entries).
719pub fn server_managed_triples(
720    resource_iri: &str,
721    modified: chrono::DateTime<chrono::Utc>,
722    size: u64,
723    is_container_flag: bool,
724    contained: &[String],
725) -> Graph {
726    let mut g = Graph::new();
727    let subject = Term::iri(resource_iri);
728
729    g.insert(Triple::new(
730        subject.clone(),
731        Term::iri(iri::DCTERMS_MODIFIED),
732        Term::typed_literal(modified.to_rfc3339(), iri::XSD_DATETIME),
733    ));
734    g.insert(Triple::new(
735        subject.clone(),
736        Term::iri(iri::STAT_SIZE),
737        Term::typed_literal(size.to_string(), iri::XSD_INTEGER),
738    ));
739    g.insert(Triple::new(
740        subject.clone(),
741        Term::iri(iri::STAT_MTIME),
742        Term::typed_literal(modified.timestamp().to_string(), iri::XSD_INTEGER),
743    ));
744
745    if is_container_flag {
746        for child in contained {
747            let base = if resource_iri.ends_with('/') {
748                resource_iri.to_string()
749            } else {
750                format!("{resource_iri}/")
751            };
752            g.insert(Triple::new(
753                subject.clone(),
754                Term::iri(iri::LDP_CONTAINS),
755                Term::iri(format!("{base}{child}")),
756            ));
757        }
758    }
759    g
760}
761
762/// List of predicates clients are not allowed to set directly. These
763/// are overwritten by the server on PUT.
764pub const SERVER_MANAGED_PREDICATES: &[&str] = &[
765    iri::DCTERMS_MODIFIED,
766    iri::STAT_SIZE,
767    iri::STAT_MTIME,
768    iri::LDP_CONTAINS,
769];
770
771/// Return the list of client-supplied triples that attempt to set
772/// server-managed predicates. These MUST be ignored at PUT time.
773pub fn find_illegal_server_managed(graph: &Graph) -> Vec<Triple> {
774    graph
775        .triples()
776        .filter(|t| {
777            if let Term::Iri(p) = &t.predicate {
778                SERVER_MANAGED_PREDICATES.iter().any(|sm| sm == p)
779            } else {
780                false
781            }
782        })
783        .cloned()
784        .collect()
785}
786
787// ---------------------------------------------------------------------------
788// Container representation (JSON-LD + Turtle)
789// ---------------------------------------------------------------------------
790
791#[derive(Debug, Serialize)]
792pub struct ContainerMember {
793    #[serde(rename = "@id")]
794    pub id: String,
795    #[serde(rename = "@type")]
796    pub types: Vec<&'static str>,
797}
798
799/// Render a container as JSON-LD respecting a `Prefer` header.
800pub fn render_container_jsonld(
801    container_path: &str,
802    members: &[String],
803    prefer: PreferHeader,
804) -> serde_json::Value {
805    let base = if container_path.ends_with('/') {
806        container_path.to_string()
807    } else {
808        format!("{container_path}/")
809    };
810
811    match prefer.representation {
812        ContainerRepresentation::ContainedIRIsOnly => serde_json::json!({
813            "@id": container_path,
814            "ldp:contains": members
815                .iter()
816                .map(|m| serde_json::json!({"@id": format!("{base}{m}")}))
817                .collect::<Vec<_>>(),
818        }),
819        ContainerRepresentation::MinimalContainer => serde_json::json!({
820            "@context": {
821                "ldp": iri::LDP_NS,
822                "dcterms": iri::DCTERMS_NS,
823            },
824            "@id": container_path,
825            "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
826        }),
827        ContainerRepresentation::Full => {
828            let contains: Vec<ContainerMember> = members
829                .iter()
830                .map(|m| {
831                    let is_dir = m.ends_with('/');
832                    ContainerMember {
833                        id: format!("{base}{m}"),
834                        types: if is_dir {
835                            vec![iri::LDP_BASIC_CONTAINER, iri::LDP_CONTAINER, iri::LDP_RESOURCE]
836                        } else {
837                            vec![iri::LDP_RESOURCE]
838                        },
839                    }
840                })
841                .collect();
842            serde_json::json!({
843                "@context": {
844                    "ldp": iri::LDP_NS,
845                    "dcterms": iri::DCTERMS_NS,
846                    "contains": { "@id": "ldp:contains", "@type": "@id" },
847                },
848                "@id": container_path,
849                "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
850                "ldp:contains": contains,
851            })
852        }
853    }
854}
855
856/// Backwards-compatible alias for the Phase 1 API.
857pub fn render_container(container_path: &str, members: &[String]) -> serde_json::Value {
858    render_container_jsonld(container_path, members, PreferHeader::default())
859}
860
861/// Render a container as Turtle.
862pub fn render_container_turtle(
863    container_path: &str,
864    members: &[String],
865    prefer: PreferHeader,
866) -> String {
867    let base = if container_path.ends_with('/') {
868        container_path.to_string()
869    } else {
870        format!("{container_path}/")
871    };
872    let mut out = String::new();
873    let _ = writeln!(out, "@prefix ldp: <{}> .", iri::LDP_NS);
874    let _ = writeln!(out, "@prefix dcterms: <{}> .", iri::DCTERMS_NS);
875    let _ = writeln!(out);
876    match prefer.representation {
877        ContainerRepresentation::ContainedIRIsOnly => {
878            let _ = writeln!(out, "<{container_path}> ldp:contains");
879            let list: Vec<String> = members
880                .iter()
881                .map(|m| format!("    <{base}{m}>"))
882                .collect();
883            let _ = writeln!(out, "{} .", list.join(",\n"));
884        }
885        ContainerRepresentation::MinimalContainer => {
886            let _ = writeln!(
887                out,
888                "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ."
889            );
890        }
891        ContainerRepresentation::Full => {
892            let _ = writeln!(
893                out,
894                "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ;"
895            );
896            if members.is_empty() {
897                // Drop the trailing `;` from the previous line.
898                let fixed = out.trim_end().trim_end_matches(';').to_string();
899                out = fixed;
900                out.push_str(" .\n");
901            } else {
902                let list: Vec<String> = members
903                    .iter()
904                    .map(|m| format!("    ldp:contains <{base}{m}>"))
905                    .collect();
906                let _ = writeln!(out, "{} .", list.join(" ;\n"));
907            }
908        }
909    }
910    out
911}
912
913// ---------------------------------------------------------------------------
914// PATCH — N3 and SPARQL-Update
915// ---------------------------------------------------------------------------
916
917/// Outcome of evaluating a PATCH request.
918#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct PatchOutcome {
920    /// Graph after the patch was applied.
921    pub graph: Graph,
922    /// Number of triples inserted.
923    pub inserted: usize,
924    /// Number of triples deleted.
925    pub deleted: usize,
926}
927
928/// Apply a solid-protocol N3 PATCH document to `target`.
929///
930/// Recognised clauses:
931///
932/// ```text
933/// _:rename a solid:InsertDeletePatch ;
934///   solid:inserts { <#s> <#p> <#o> . } ;
935///   solid:deletes { <#s> <#p> <#o> . } ;
936///   solid:where   { <#s> <#p> ?var . } .
937/// ```
938///
939/// The parser is deliberately permissive: it hunts for `insert` /
940/// `delete` / `where` blocks delimited by curly braces anywhere in the
941/// body. The contents of each block are parsed as N-Triples.
942pub fn apply_n3_patch(target: Graph, patch: &str) -> Result<PatchOutcome, PodError> {
943    let inserts = extract_block(patch, &["insert", "inserts", "solid:inserts"]).unwrap_or_default();
944    let deletes = extract_block(patch, &["delete", "deletes", "solid:deletes"]).unwrap_or_default();
945    let where_clause = extract_block(patch, &["where", "solid:where"]);
946
947    let insert_graph = if !inserts.is_empty() {
948        Graph::parse_ntriples(&strip_braces(&inserts))?
949    } else {
950        Graph::new()
951    };
952    let delete_graph = if !deletes.is_empty() {
953        Graph::parse_ntriples(&strip_braces(&deletes))?
954    } else {
955        Graph::new()
956    };
957
958    // WHERE clause: every triple must be present in the target graph,
959    // otherwise the PATCH fails. Variables (`?foo`) are treated as
960    // existential — we currently require them to match exactly any
961    // existing predicate/subject/object, so the simple empty-WHERE
962    // and literal-WHERE flows both work.
963    if let Some(wc) = where_clause {
964        if !wc.trim().is_empty() {
965            let where_graph = Graph::parse_ntriples(&strip_braces(&wc))?;
966            for t in where_graph.triples() {
967                if !target.contains(t) {
968                    return Err(PodError::PreconditionFailed(format!(
969                        "WHERE clause triple missing: {t:?}"
970                    )));
971                }
972            }
973        }
974    }
975
976    let mut graph = target;
977    let inserted_count = insert_graph.len();
978    let deleted_count = delete_graph
979        .triples()
980        .filter(|t| graph.contains(t))
981        .count();
982    graph.subtract(&delete_graph);
983    graph.extend(&insert_graph);
984
985    Ok(PatchOutcome {
986        graph,
987        inserted: inserted_count,
988        deleted: deleted_count,
989    })
990}
991
992fn extract_block(source: &str, keywords: &[&str]) -> Option<String> {
993    // Treat the keyword match as a word boundary on the left (so
994    // `solid:inserts` matches but `InsertDeletePatch` does not), and
995    // require the keyword to be followed — ignoring whitespace — by
996    // an opening brace. This prevents the `insert`/`delete` substrings
997    // inside `solid:InsertDeletePatch` from being mistaken for block
998    // keywords.
999    let lower = source.to_ascii_lowercase();
1000    let bytes = lower.as_bytes();
1001    for kw in keywords {
1002        let needle = kw.to_ascii_lowercase();
1003        let mut search_from = 0usize;
1004        while let Some(pos) = lower[search_from..].find(&needle) {
1005            let abs = search_from + pos;
1006            let after_kw = abs + needle.len();
1007            search_from = abs + needle.len();
1008
1009            // Left boundary: the char before must not be an ASCII
1010            // alphanumeric (so we don't match inside a longer word).
1011            // Colons and underscores are allowed so `solid:inserts`
1012            // still matches after the `solid:` prefix.
1013            let left_ok = if abs == 0 {
1014                true
1015            } else {
1016                let prev = bytes[abs - 1];
1017                !(prev.is_ascii_alphanumeric() || prev == b'_')
1018            };
1019            if !left_ok {
1020                continue;
1021            }
1022
1023            // The next non-whitespace char must be `{`.
1024            let tail = &source[after_kw..];
1025            let trimmed = tail.trim_start();
1026            if !trimmed.starts_with('{') {
1027                continue;
1028            }
1029            let open = after_kw + (tail.len() - trimmed.len());
1030
1031            // Find the matching close brace.
1032            let mut depth = 0i32;
1033            let mut end = None;
1034            for (i, c) in source[open..].char_indices() {
1035                match c {
1036                    '{' => depth += 1,
1037                    '}' => {
1038                        depth -= 1;
1039                        if depth == 0 {
1040                            end = Some(open + i + 1);
1041                            break;
1042                        }
1043                    }
1044                    _ => {}
1045                }
1046            }
1047            if let Some(e) = end {
1048                return Some(source[open..e].to_string());
1049            }
1050        }
1051    }
1052    None
1053}
1054
1055fn strip_braces(block: &str) -> String {
1056    let t = block.trim();
1057    let t = t.strip_prefix('{').unwrap_or(t);
1058    let t = t.strip_suffix('}').unwrap_or(t);
1059    t.trim().to_string()
1060}
1061
1062/// Maximum size (in bytes) of a SPARQL-Update body the server will
1063/// attempt to parse.  Inputs larger than this are rejected before
1064/// reaching the parser, preventing DoS through pathologically large or
1065/// deeply nested SPARQL documents.
1066pub const SPARQL_UPDATE_MAX_BYTES: usize = 1_048_576; // 1 MiB
1067
1068/// Apply a SPARQL 1.1 Update document (`INSERT DATA`, `DELETE DATA`,
1069/// `DELETE WHERE`) to `target` using `spargebra` for parsing.
1070///
1071/// Rejects inputs exceeding [`SPARQL_UPDATE_MAX_BYTES`] before parsing.
1072pub fn apply_sparql_patch(target: Graph, update: &str) -> Result<PatchOutcome, PodError> {
1073    if update.len() > SPARQL_UPDATE_MAX_BYTES {
1074        return Err(PodError::BadRequest(format!(
1075            "SPARQL-Update body exceeds {} byte limit ({} bytes)",
1076            SPARQL_UPDATE_MAX_BYTES,
1077            update.len(),
1078        )));
1079    }
1080
1081    use spargebra::term::{
1082        GraphName, GraphNamePattern, GroundQuad, GroundQuadPattern, GroundSubject, GroundTerm,
1083        GroundTermPattern, NamedNodePattern, Quad, Subject, Term as SpTerm,
1084    };
1085    use spargebra::{GraphUpdateOperation, Update};
1086
1087    let parsed = Update::parse(update, None)
1088        .map_err(|e| PodError::Unsupported(format!("SPARQL parse error: {e}")))?;
1089
1090    // Our in-crate `Term::literal` helper stores plain literals with
1091    // `datatype: None`, matching the N-Triples fast path. spargebra,
1092    // however, canonicalises every plain (non-language-tagged) literal
1093    // to `xsd:string` per RDF 1.1. Normalise back to `None` so graphs
1094    // built via `Term::literal` compare equal to graphs produced by
1095    // SPARQL parsing.
1096    fn build_literal(value: String, datatype: Option<String>, language: Option<String>) -> Term {
1097        let datatype = datatype.filter(|d| d != iri::XSD_STRING);
1098        Term::Literal {
1099            value,
1100            datatype,
1101            language,
1102        }
1103    }
1104
1105    fn map_subject(s: &Subject) -> Option<Term> {
1106        match s {
1107            Subject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1108            Subject::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1109            #[allow(unreachable_patterns)]
1110            _ => None,
1111        }
1112    }
1113    fn map_term(t: &SpTerm) -> Option<Term> {
1114        match t {
1115            SpTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1116            SpTerm::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1117            SpTerm::Literal(lit) => {
1118                let value = lit.value().to_string();
1119                if let Some(lang) = lit.language() {
1120                    Some(build_literal(value, None, Some(lang.to_string())))
1121                } else {
1122                    Some(build_literal(
1123                        value,
1124                        Some(lit.datatype().as_str().to_string()),
1125                        None,
1126                    ))
1127                }
1128            }
1129            #[allow(unreachable_patterns)]
1130            _ => None,
1131        }
1132    }
1133    fn map_ground_subject(s: &GroundSubject) -> Option<Term> {
1134        match s {
1135            GroundSubject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1136            #[allow(unreachable_patterns)]
1137            _ => None,
1138        }
1139    }
1140    fn map_ground_term(t: &GroundTerm) -> Option<Term> {
1141        match t {
1142            GroundTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1143            GroundTerm::Literal(lit) => {
1144                let value = lit.value().to_string();
1145                if let Some(lang) = lit.language() {
1146                    Some(build_literal(value, None, Some(lang.to_string())))
1147                } else {
1148                    Some(build_literal(
1149                        value,
1150                        Some(lit.datatype().as_str().to_string()),
1151                        None,
1152                    ))
1153                }
1154            }
1155            #[allow(unreachable_patterns)]
1156            _ => None,
1157        }
1158    }
1159    fn map_ground_term_pattern(t: &GroundTermPattern) -> Option<Term> {
1160        match t {
1161            GroundTermPattern::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1162            GroundTermPattern::Literal(lit) => {
1163                let value = lit.value().to_string();
1164                if let Some(lang) = lit.language() {
1165                    Some(build_literal(value, None, Some(lang.to_string())))
1166                } else {
1167                    Some(build_literal(
1168                        value,
1169                        Some(lit.datatype().as_str().to_string()),
1170                        None,
1171                    ))
1172                }
1173            }
1174            _ => None,
1175        }
1176    }
1177
1178    fn quad_to_triple(q: &Quad) -> Option<Triple> {
1179        if !matches!(q.graph_name, GraphName::DefaultGraph) {
1180            return None;
1181        }
1182        Some(Triple::new(
1183            map_subject(&q.subject)?,
1184            Term::Iri(q.predicate.as_str().to_string()),
1185            map_term(&q.object)?,
1186        ))
1187    }
1188    fn ground_quad_to_triple(q: &GroundQuad) -> Option<Triple> {
1189        if !matches!(q.graph_name, GraphName::DefaultGraph) {
1190            return None;
1191        }
1192        Some(Triple::new(
1193            map_ground_subject(&q.subject)?,
1194            Term::Iri(q.predicate.as_str().to_string()),
1195            map_ground_term(&q.object)?,
1196        ))
1197    }
1198    fn ground_quad_pattern_to_triple(q: &GroundQuadPattern) -> Option<Triple> {
1199        if !matches!(q.graph_name, GraphNamePattern::DefaultGraph) {
1200            return None;
1201        }
1202        let predicate = match &q.predicate {
1203            NamedNodePattern::NamedNode(n) => Term::Iri(n.as_str().to_string()),
1204            NamedNodePattern::Variable(_) => return None,
1205        };
1206        Some(Triple::new(
1207            map_ground_term_pattern(&q.subject)?,
1208            predicate,
1209            map_ground_term_pattern(&q.object)?,
1210        ))
1211    }
1212
1213    let mut graph = target;
1214    let mut inserted = 0usize;
1215    let mut deleted = 0usize;
1216
1217    for op in &parsed.operations {
1218        match op {
1219            GraphUpdateOperation::InsertData { data } => {
1220                for q in data {
1221                    if let Some(tr) = quad_to_triple(q) {
1222                        if !graph.contains(&tr) {
1223                            graph.insert(tr);
1224                            inserted += 1;
1225                        }
1226                    }
1227                }
1228            }
1229            GraphUpdateOperation::DeleteData { data } => {
1230                for q in data {
1231                    if let Some(tr) = ground_quad_to_triple(q) {
1232                        if graph.remove(&tr) {
1233                            deleted += 1;
1234                        }
1235                    }
1236                }
1237            }
1238            GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
1239                for q in delete {
1240                    if let Some(tr) = ground_quad_pattern_to_triple(q) {
1241                        if graph.remove(&tr) {
1242                            deleted += 1;
1243                        }
1244                    }
1245                }
1246                for q in insert {
1247                    // Only insert triples whose template is fully
1248                    // ground (no variable bindings). Templates with
1249                    // variables require WHERE-clause resolution,
1250                    // which the pod does not implement for PATCH.
1251                    let gqp = match convert_quad_pattern_to_ground(q) {
1252                        Some(g) => g,
1253                        None => continue,
1254                    };
1255                    if let Some(tr) = ground_quad_pattern_to_triple(&gqp) {
1256                        if !graph.contains(&tr) {
1257                            graph.insert(tr);
1258                            inserted += 1;
1259                        }
1260                    }
1261                }
1262            }
1263            _ => {
1264                return Err(PodError::Unsupported(format!(
1265                    "unsupported SPARQL operation: {op:?}"
1266                )));
1267            }
1268        }
1269    }
1270
1271    Ok(PatchOutcome {
1272        graph,
1273        inserted,
1274        deleted,
1275    })
1276}
1277
1278fn convert_quad_pattern_to_ground(
1279    q: &spargebra::term::QuadPattern,
1280) -> Option<spargebra::term::GroundQuadPattern> {
1281    use spargebra::term::{
1282        GraphNamePattern, GroundQuadPattern, GroundTermPattern, NamedNodePattern, TermPattern,
1283    };
1284
1285    let subject = match &q.subject {
1286        TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1287        TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1288        _ => return None,
1289    };
1290    let predicate = match &q.predicate {
1291        NamedNodePattern::NamedNode(n) => NamedNodePattern::NamedNode(n.clone()),
1292        NamedNodePattern::Variable(_) => return None,
1293    };
1294    let object = match &q.object {
1295        TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1296        TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1297        _ => return None,
1298    };
1299    let graph_name = match &q.graph_name {
1300        GraphNamePattern::DefaultGraph => GraphNamePattern::DefaultGraph,
1301        GraphNamePattern::NamedNode(n) => GraphNamePattern::NamedNode(n.clone()),
1302        GraphNamePattern::Variable(_) => return None,
1303    };
1304    Some(GroundQuadPattern {
1305        subject,
1306        predicate,
1307        object,
1308        graph_name,
1309    })
1310}
1311
1312// ---------------------------------------------------------------------------
1313// Conditional requests (RFC 7232: If-Match / If-None-Match / If-Modified-Since)
1314// ---------------------------------------------------------------------------
1315
1316/// Outcome of evaluating conditional request headers against a current
1317/// resource ETag.
1318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1319pub enum ConditionalOutcome {
1320    /// The request may proceed.
1321    Proceed,
1322    /// Request must fail with `412 Precondition Failed` (e.g.
1323    /// `If-Match` mismatch).
1324    PreconditionFailed,
1325    /// Request should return `304 Not Modified` (GET/HEAD only with
1326    /// `If-None-Match`).
1327    NotModified,
1328}
1329
1330/// Evaluate `If-Match` and `If-None-Match` precondition headers against
1331/// the current ETag of a resource. The caller passes whatever is
1332/// observed on the storage side; `None` for the ETag means the
1333/// resource does not exist.
1334///
1335/// * `If-Match: *` matches any existing resource (fails if absent).
1336/// * `If-None-Match: *` fails if the resource exists.
1337/// * `If-Match: "etag1", "etag2"` — pass if any matches.
1338/// * `If-None-Match: "etag1", "etag2"` — for GET/HEAD a match means
1339///   `NotModified`; for any other method a match means
1340///   `PreconditionFailed`.
1341pub fn evaluate_preconditions(
1342    method: &str,
1343    current_etag: Option<&str>,
1344    if_match: Option<&str>,
1345    if_none_match: Option<&str>,
1346) -> ConditionalOutcome {
1347    let method_upper = method.to_ascii_uppercase();
1348    let safe = method_upper == "GET" || method_upper == "HEAD";
1349
1350    if let Some(im) = if_match {
1351        let raw = im.trim();
1352        if raw == "*" {
1353            if current_etag.is_none() {
1354                return ConditionalOutcome::PreconditionFailed;
1355            }
1356        } else {
1357            let wanted = parse_etag_list(raw);
1358            match current_etag {
1359                None => return ConditionalOutcome::PreconditionFailed,
1360                Some(cur) => {
1361                    if !wanted.iter().any(|w| w == cur || w == "*") {
1362                        return ConditionalOutcome::PreconditionFailed;
1363                    }
1364                }
1365            }
1366        }
1367    }
1368
1369    if let Some(inm) = if_none_match {
1370        let raw = inm.trim();
1371        if raw == "*" {
1372            if current_etag.is_some() {
1373                if safe {
1374                    return ConditionalOutcome::NotModified;
1375                }
1376                return ConditionalOutcome::PreconditionFailed;
1377            }
1378        } else {
1379            let wanted = parse_etag_list(raw);
1380            if let Some(cur) = current_etag {
1381                if wanted.iter().any(|w| w == cur) {
1382                    if safe {
1383                        return ConditionalOutcome::NotModified;
1384                    }
1385                    return ConditionalOutcome::PreconditionFailed;
1386                }
1387            }
1388        }
1389    }
1390
1391    ConditionalOutcome::Proceed
1392}
1393
1394fn parse_etag_list(input: &str) -> Vec<String> {
1395    input
1396        .split(',')
1397        .map(|s| s.trim())
1398        .filter(|s| !s.is_empty())
1399        .map(|s| {
1400            // Strip weak-etag prefix + surrounding double quotes.
1401            let s = s.strip_prefix("W/").unwrap_or(s);
1402            s.trim_matches('"').to_string()
1403        })
1404        .collect()
1405}
1406
1407// ---------------------------------------------------------------------------
1408// Byte-range requests (RFC 7233)
1409// ---------------------------------------------------------------------------
1410
1411/// A parsed byte range. `end` is inclusive per RFC 7233 §2.1.
1412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1413pub struct ByteRange {
1414    pub start: u64,
1415    pub end: u64,
1416}
1417
1418impl ByteRange {
1419    pub fn length(&self) -> u64 {
1420        self.end.saturating_sub(self.start) + 1
1421    }
1422    /// Render as the `Content-Range` header value (without the
1423    /// `Content-Range: ` prefix).
1424    pub fn content_range(&self, total: u64) -> String {
1425        format!("bytes {}-{}/{}", self.start, self.end, total)
1426    }
1427}
1428
1429/// Parse a `Range:` header value of the form `bytes=start-end` or
1430/// `bytes=start-` or `bytes=-suffix`. Multi-range is intentionally
1431/// not supported — Solid Pods treat non-rangeable media (JSON-LD,
1432/// Turtle) as opaque and the binary path is the only consumer.
1433///
1434/// Returns `Ok(None)` when the header is absent, `Err` when the header
1435/// is syntactically valid but unsatisfiable (clients must receive
1436/// `416 Range Not Satisfiable`), and `Ok(Some(range))` for the
1437/// happy path.
1438pub fn parse_range_header(
1439    header: Option<&str>,
1440    total: u64,
1441) -> Result<Option<ByteRange>, PodError> {
1442    let raw = match header {
1443        Some(v) if !v.trim().is_empty() => v.trim(),
1444        _ => return Ok(None),
1445    };
1446    let spec = raw
1447        .strip_prefix("bytes=")
1448        .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1449    if spec.contains(',') {
1450        return Err(PodError::Unsupported(
1451            "multi-range requests not supported".into(),
1452        ));
1453    }
1454    let (start_s, end_s) = spec
1455        .split_once('-')
1456        .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1457    if total == 0 {
1458        return Err(PodError::PreconditionFailed(
1459            "range request against empty resource".into(),
1460        ));
1461    }
1462
1463    let range = if start_s.is_empty() {
1464        // suffix: `bytes=-500`
1465        let suffix: u64 = end_s
1466            .parse()
1467            .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1468        if suffix == 0 {
1469            return Err(PodError::PreconditionFailed("zero suffix length".into()));
1470        }
1471        let start = total.saturating_sub(suffix);
1472        ByteRange {
1473            start,
1474            end: total - 1,
1475        }
1476    } else {
1477        let start: u64 = start_s
1478            .parse()
1479            .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1480        let end = if end_s.is_empty() {
1481            total - 1
1482        } else {
1483            let v: u64 = end_s
1484                .parse()
1485                .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1486            v.min(total - 1)
1487        };
1488        if start > end {
1489            return Err(PodError::PreconditionFailed(format!(
1490                "unsatisfiable range: {start}-{end}"
1491            )));
1492        }
1493        if start >= total {
1494            return Err(PodError::PreconditionFailed(format!(
1495                "range start {start} >= total {total}"
1496            )));
1497        }
1498        ByteRange { start, end }
1499    };
1500    Ok(Some(range))
1501}
1502
1503/// Outcome of evaluating `Range:` against a known resource length.
1504/// `Full` → 200 (no `Range:` header); `Partial` → 206; `NotSatisfiable`
1505/// → 416 (not 412, which the old [`parse_range_header`] conflated).
1506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1507pub enum RangeOutcome {
1508    Full,
1509    Partial(ByteRange),
1510    NotSatisfiable,
1511}
1512
1513/// JSS-parity range parser. Same grammar as [`parse_range_header`] but
1514/// maps "empty body + header present" and "range past end" to
1515/// `NotSatisfiable`. Malformed headers still return `Err` so callers
1516/// can reply `400`.
1517pub fn parse_range_header_v2(
1518    header: Option<&str>,
1519    total: u64,
1520) -> Result<RangeOutcome, PodError> {
1521    let raw = match header {
1522        Some(v) if !v.trim().is_empty() => v.trim(),
1523        _ => return Ok(RangeOutcome::Full),
1524    };
1525    let spec = raw
1526        .strip_prefix("bytes=")
1527        .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1528    if spec.contains(',') {
1529        return Err(PodError::Unsupported("multi-range not supported".into()));
1530    }
1531    let (start_s, end_s) = spec
1532        .split_once('-')
1533        .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1534    if total == 0 {
1535        return Ok(RangeOutcome::NotSatisfiable);
1536    }
1537    let range = if start_s.is_empty() {
1538        let suffix: u64 = end_s
1539            .parse()
1540            .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1541        if suffix == 0 {
1542            return Ok(RangeOutcome::NotSatisfiable);
1543        }
1544        ByteRange { start: total.saturating_sub(suffix), end: total - 1 }
1545    } else {
1546        let start: u64 = start_s
1547            .parse()
1548            .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1549        let end = if end_s.is_empty() {
1550            total - 1
1551        } else {
1552            let v: u64 = end_s
1553                .parse()
1554                .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1555            v.min(total - 1)
1556        };
1557        if start > end || start >= total {
1558            return Ok(RangeOutcome::NotSatisfiable);
1559        }
1560        ByteRange { start, end }
1561    };
1562    Ok(RangeOutcome::Partial(range))
1563}
1564
1565/// Slice a body buffer to a byte range. The slice is a zero-copy
1566/// view; callers are expected to `copy_from_slice` or similar when
1567/// returning it through an HTTP framework.
1568pub fn slice_range(body: &[u8], range: ByteRange) -> &[u8] {
1569    let end_excl = (range.end as usize + 1).min(body.len());
1570    let start = (range.start as usize).min(end_excl);
1571    &body[start..end_excl]
1572}
1573
1574// ---------------------------------------------------------------------------
1575// OPTIONS response (RFC 7231 §4.3.7)
1576// ---------------------------------------------------------------------------
1577
1578/// Build the set of values returned on OPTIONS for a Solid resource.
1579///
1580/// * `Allow` advertises methods the resource supports.
1581/// * `Accept-Post` is set for containers.
1582/// * `Accept-Patch` advertises supported PATCH dialects.
1583/// * `Accept-Ranges: bytes` is always advertised so binary resources
1584///   can be sliced with `Range:` requests.
1585/// * `Cache-Control` mirrors the RDF variant policy since OPTIONS
1586///   responses describe the RDF-shaped conneg surface (JSS #315).
1587#[derive(Debug, Clone)]
1588pub struct OptionsResponse {
1589    pub allow: Vec<&'static str>,
1590    pub accept_post: Option<&'static str>,
1591    pub accept_patch: &'static str,
1592    pub accept_ranges: &'static str,
1593    pub cache_control: &'static str,
1594}
1595
1596/// `Accept-Patch` advertising the PATCH dialects supported.
1597pub const ACCEPT_PATCH: &str = "text/n3, application/sparql-update, application/json-patch+json";
1598
1599pub fn options_for(path: &str) -> OptionsResponse {
1600    let container = is_container(path);
1601    let mut allow = vec!["GET", "HEAD", "OPTIONS"];
1602    if container {
1603        allow.push("POST");
1604        allow.push("PUT");
1605    } else {
1606        allow.push("PUT");
1607        allow.push("PATCH");
1608    }
1609    allow.push("DELETE");
1610    OptionsResponse {
1611        allow,
1612        accept_post: if container { Some(ACCEPT_POST) } else { None },
1613        accept_patch: ACCEPT_PATCH,
1614        // Containers are not byte-rangeable — they render server-side
1615        // RDF representations. Only leaf resources carry bytes that a
1616        // `Range:` request can meaningfully slice. JSS advertises
1617        // `Accept-Ranges: none` on containers; we match.
1618        accept_ranges: if container { "none" } else { "bytes" },
1619        // OPTIONS describes the RDF-shaped conneg surface on Solid
1620        // resources. Shared caches must not fuse auth-variant responses,
1621        // and clients revalidate via ETag on every use (JSS #315).
1622        cache_control: CACHE_CONTROL_RDF,
1623    }
1624}
1625
1626/// Build the header set returned on `404 Not Found` for an LDP path.
1627///
1628/// JSS emits a rich discovery header set on 404 so that clients can
1629/// drive a PUT-to-create or POST-to-container flow without a second
1630/// OPTIONS round trip:
1631///
1632/// * `Allow` — methods the server will *accept* on this path. DELETE is
1633///   intentionally omitted because the resource does not exist.
1634/// * `Accept-Put: */*` — PUT accepts any content type (spec default).
1635/// * `Link: <path.acl>; rel="acl"` — ACL discovery.
1636/// * `Vary` — includes `Accept` when content negotiation is enabled so
1637///   caches key on it.
1638/// * `Accept-Post` — only for containers; advertises the RDF formats
1639///   usable as POST bodies.
1640pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1641    let container = is_container(path);
1642    let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1643    h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1644    h.push(("Accept-Put", "*/*".into()));
1645    h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1646    h.push((
1647        "Link",
1648        format!("<{}.acl>; rel=\"acl\"", path.trim_end_matches('/')),
1649    ));
1650    h.push(("Vary", vary_header(conneg_enabled).into()));
1651    // When conneg is enabled, the 404 advertises an RDF-shaped future
1652    // response surface (Allow/Accept-Post/Accept-Patch list RDF types).
1653    // Emit RDF Cache-Control so intermediaries cannot fuse the 404 with
1654    // a later 200 authenticated body. Mirrors JSS #315.
1655    if conneg_enabled {
1656        h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1657    }
1658    if container {
1659        h.push(("Accept-Post", ACCEPT_POST.into()));
1660    }
1661    h
1662}
1663
1664/// Value of the `Vary:` header depending on whether content negotiation
1665/// is enabled. `Authorization` and `Origin` are always listed so shared
1666/// caches never collapse an authenticated and an anonymous response
1667/// onto the same cache entry.
1668pub fn vary_header(conneg_enabled: bool) -> &'static str {
1669    if conneg_enabled {
1670        "Accept, Authorization, Origin"
1671    } else {
1672        "Authorization, Origin"
1673    }
1674}
1675
1676/// RFC 7234 `Cache-Control` directive for RDF response variants.
1677///
1678/// Emits `private, no-cache, must-revalidate` so shared caches never
1679/// serve one authenticated user's response to another. ETag-based
1680/// revalidation stays cheap (304). Mirrors JSS `RDF_CACHE_CONTROL`
1681/// in `src/handlers/resource.js` after PR #315 (commit 76fc5c6).
1682/// Binary blobs (images, uploads) are NOT RDF and keep their default
1683/// caching posture — callers decide.
1684pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1685
1686/// Return `true` if `content_type` identifies an RDF serialisation the
1687/// server emits through content negotiation or stores natively. Matches
1688/// the formats advertised in [`ACCEPT_POST`] plus `text/n3` and
1689/// `application/trig` (JSS parity). Parameters (e.g. `; charset=utf-8`)
1690/// are tolerated.
1691pub fn is_rdf_content_type(content_type: &str) -> bool {
1692    let base = content_type
1693        .split(';')
1694        .next()
1695        .unwrap_or("")
1696        .trim()
1697        .to_ascii_lowercase();
1698    matches!(
1699        base.as_str(),
1700        "text/turtle"
1701            | "application/turtle"
1702            | "application/x-turtle"
1703            | "application/ld+json"
1704            | "application/json+ld"
1705            | "application/n-triples"
1706            | "text/plain+ntriples"
1707            | "text/n3"
1708            | "application/trig"
1709    )
1710}
1711
1712/// Return the `Cache-Control` header value appropriate for a response
1713/// of the supplied `content_type`, or `None` to leave the header
1714/// unset. RDF variants always get [`CACHE_CONTROL_RDF`]; non-RDF
1715/// payloads (binary blobs, images, etc.) are left to caller policy.
1716pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1717    if is_rdf_content_type(content_type) {
1718        Some(CACHE_CONTROL_RDF)
1719    } else {
1720        None
1721    }
1722}
1723
1724// ---------------------------------------------------------------------------
1725// JSON Patch (RFC 6902) — applied to the JSON representation of a
1726// resource. Keeps the surface intentionally small: `add`, `remove`,
1727// `replace`, `test`. `copy` and `move` are implemented on top.
1728// ---------------------------------------------------------------------------
1729
1730/// Apply a JSON Patch document (RFC 6902) to a `serde_json::Value` in
1731/// place. Returns `Err(PodError::PreconditionFailed)` when a `test`
1732/// operation fails, `Err(PodError::Unsupported)` for malformed patches.
1733pub fn apply_json_patch(
1734    target: &mut serde_json::Value,
1735    patch: &serde_json::Value,
1736) -> Result<(), PodError> {
1737    let ops = patch
1738        .as_array()
1739        .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1740    for op in ops {
1741        let op_name = op
1742            .get("op")
1743            .and_then(|v| v.as_str())
1744            .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1745        let path = op
1746            .get("path")
1747            .and_then(|v| v.as_str())
1748            .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1749        match op_name {
1750            "add" => {
1751                let value = op
1752                    .get("value")
1753                    .cloned()
1754                    .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1755                json_pointer_set(target, path, value, /* add_mode = */ true)?;
1756            }
1757            "replace" => {
1758                let value = op
1759                    .get("value")
1760                    .cloned()
1761                    .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1762                json_pointer_set(target, path, value, /* add_mode = */ false)?;
1763            }
1764            "remove" => {
1765                json_pointer_remove(target, path)?;
1766            }
1767            "test" => {
1768                let value = op
1769                    .get("value")
1770                    .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1771                let actual = json_pointer_get(target, path)
1772                    .ok_or_else(|| PodError::PreconditionFailed(format!("test path missing: {path}")))?;
1773                if actual != value {
1774                    return Err(PodError::PreconditionFailed(format!(
1775                        "test failed at {path}"
1776                    )));
1777                }
1778            }
1779            "copy" => {
1780                let from = op
1781                    .get("from")
1782                    .and_then(|v| v.as_str())
1783                    .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1784                let value = json_pointer_get(target, from)
1785                    .cloned()
1786                    .ok_or_else(|| PodError::PreconditionFailed(format!("copy from missing: {from}")))?;
1787                json_pointer_set(target, path, value, true)?;
1788            }
1789            "move" => {
1790                let from = op
1791                    .get("from")
1792                    .and_then(|v| v.as_str())
1793                    .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
1794                let value = json_pointer_get(target, from)
1795                    .cloned()
1796                    .ok_or_else(|| PodError::PreconditionFailed(format!("move from missing: {from}")))?;
1797                json_pointer_remove(target, from)?;
1798                json_pointer_set(target, path, value, true)?;
1799            }
1800            other => {
1801                return Err(PodError::Unsupported(format!(
1802                    "unsupported JSON Patch op: {other}"
1803                )));
1804            }
1805        }
1806    }
1807    Ok(())
1808}
1809
1810fn json_pointer_get<'a>(
1811    target: &'a serde_json::Value,
1812    path: &str,
1813) -> Option<&'a serde_json::Value> {
1814    if path.is_empty() {
1815        return Some(target);
1816    }
1817    target.pointer(path)
1818}
1819
1820fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
1821    if path.is_empty() {
1822        return Err(PodError::Unsupported("cannot remove root".into()));
1823    }
1824    let (parent_path, last) = split_pointer(path);
1825    let parent = target
1826        .pointer_mut(&parent_path)
1827        .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
1828    match parent {
1829        serde_json::Value::Object(m) => {
1830            m.remove(&last).ok_or_else(|| {
1831                PodError::PreconditionFailed(format!("remove key missing: {path}"))
1832            })?;
1833            Ok(())
1834        }
1835        serde_json::Value::Array(a) => {
1836            let idx: usize = last.parse().map_err(|_| {
1837                PodError::Unsupported(format!("remove array index not numeric: {last}"))
1838            })?;
1839            if idx >= a.len() {
1840                return Err(PodError::PreconditionFailed(format!(
1841                    "remove array out of bounds: {idx}"
1842                )));
1843            }
1844            a.remove(idx);
1845            Ok(())
1846        }
1847        _ => Err(PodError::PreconditionFailed(format!(
1848            "remove target is not container: {path}"
1849        ))),
1850    }
1851}
1852
1853fn json_pointer_set(
1854    target: &mut serde_json::Value,
1855    path: &str,
1856    value: serde_json::Value,
1857    add_mode: bool,
1858) -> Result<(), PodError> {
1859    if path.is_empty() {
1860        *target = value;
1861        return Ok(());
1862    }
1863    let (parent_path, last) = split_pointer(path);
1864    let parent = target
1865        .pointer_mut(&parent_path)
1866        .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
1867    match parent {
1868        serde_json::Value::Object(m) => {
1869            if !add_mode && !m.contains_key(&last) {
1870                return Err(PodError::PreconditionFailed(format!(
1871                    "replace missing key: {path}"
1872                )));
1873            }
1874            m.insert(last, value);
1875            Ok(())
1876        }
1877        serde_json::Value::Array(a) => {
1878            if last == "-" {
1879                a.push(value);
1880                return Ok(());
1881            }
1882            let idx: usize = last.parse().map_err(|_| {
1883                PodError::Unsupported(format!("array index not numeric: {last}"))
1884            })?;
1885            if add_mode {
1886                if idx > a.len() {
1887                    return Err(PodError::PreconditionFailed(format!(
1888                        "array add out of bounds: {idx}"
1889                    )));
1890                }
1891                a.insert(idx, value);
1892            } else {
1893                if idx >= a.len() {
1894                    return Err(PodError::PreconditionFailed(format!(
1895                        "array replace out of bounds: {idx}"
1896                    )));
1897                }
1898                a[idx] = value;
1899            }
1900            Ok(())
1901        }
1902        _ => Err(PodError::PreconditionFailed(format!(
1903            "set parent not container: {path}"
1904        ))),
1905    }
1906}
1907
1908fn split_pointer(path: &str) -> (String, String) {
1909    match path.rfind('/') {
1910        Some(pos) => {
1911            let parent = path[..pos].to_string();
1912            let last_raw = &path[pos + 1..];
1913            let last = last_raw.replace("~1", "/").replace("~0", "~");
1914            (parent, last)
1915        }
1916        None => (String::new(), path.to_string()),
1917    }
1918}
1919
1920/// Pick a PATCH dialect from the `Content-Type` header.
1921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1922pub enum PatchDialect {
1923    N3,
1924    SparqlUpdate,
1925    JsonPatch,
1926}
1927
1928pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
1929    let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
1930    match m.as_str() {
1931        "text/n3" | "application/n3" => Some(PatchDialect::N3),
1932        "application/sparql-update" | "application/sparql-update+update" => {
1933            Some(PatchDialect::SparqlUpdate)
1934        }
1935        "application/json-patch+json" => Some(PatchDialect::JsonPatch),
1936        _ => None,
1937    }
1938}
1939
1940// ---------------------------------------------------------------------------
1941// PATCH against absent resource (JSS parity).
1942//
1943// JSS seeds an empty graph when a PATCH targets a path that does not
1944// yet exist and returns `201 Created`. JSON Patch is deliberately
1945// rejected on absent resources because RFC 6902 operates on an existing
1946// JSON document and `add`/`replace` at the root (`""`) would silently
1947// accept any shape — better to make the client issue a PUT first.
1948// ---------------------------------------------------------------------------
1949
1950/// Outcome of applying a PATCH to a path that had no prior resource.
1951///
1952/// * `Created { .. }` — graph was seeded successfully; caller should
1953///   persist it and respond with `201 Created`.
1954/// * `Applied { .. }` — unused on the absent path today but reserved so
1955///   callers can match exhaustively in the same enum they use for the
1956///   non-absent code path.
1957#[derive(Debug)]
1958pub enum PatchCreateOutcome {
1959    /// Patch applied to a newly-seeded empty graph.
1960    Created { inserted: usize, graph: Graph },
1961    /// Patch applied to an existing graph (for symmetry; not produced
1962    /// by `apply_patch_to_absent`).
1963    Applied {
1964        inserted: usize,
1965        deleted: usize,
1966        graph: Graph,
1967    },
1968}
1969
1970/// Apply a PATCH document to an absent resource by seeding an empty
1971/// graph and running the dialect-specific patcher. JSON Patch is
1972/// unsupported in this path.
1973pub fn apply_patch_to_absent(
1974    dialect: PatchDialect,
1975    body: &str,
1976) -> Result<PatchCreateOutcome, PodError> {
1977    match dialect {
1978        PatchDialect::N3 => {
1979            let outcome = apply_n3_patch(Graph::new(), body)?;
1980            Ok(PatchCreateOutcome::Created {
1981                inserted: outcome.inserted,
1982                graph: outcome.graph,
1983            })
1984        }
1985        PatchDialect::SparqlUpdate => {
1986            let outcome = apply_sparql_patch(Graph::new(), body)?;
1987            Ok(PatchCreateOutcome::Created {
1988                inserted: outcome.inserted,
1989                graph: outcome.graph,
1990            })
1991        }
1992        PatchDialect::JsonPatch => Err(PodError::Unsupported(
1993            "JSON Patch on absent resource".into(),
1994        )),
1995    }
1996}
1997
1998// ---------------------------------------------------------------------------
1999// LdpContainerOps trait (backwards compatible)
2000// ---------------------------------------------------------------------------
2001
2002#[cfg(feature = "tokio-runtime")]
2003#[async_trait]
2004pub trait LdpContainerOps: Storage {
2005    async fn container_representation(
2006        &self,
2007        path: &str,
2008    ) -> Result<serde_json::Value, PodError> {
2009        let children = self.list(path).await?;
2010        Ok(render_container(path, &children))
2011    }
2012}
2013
2014#[cfg(feature = "tokio-runtime")]
2015impl<T: Storage + ?Sized> LdpContainerOps for T {}
2016
2017// ---------------------------------------------------------------------------
2018// Tests
2019// ---------------------------------------------------------------------------
2020
2021#[cfg(test)]
2022mod tests {
2023    use super::*;
2024
2025    #[test]
2026    fn is_container_detects_trailing_slash() {
2027        assert!(is_container("/"));
2028        assert!(is_container("/media/"));
2029        assert!(!is_container("/file.txt"));
2030    }
2031
2032    #[test]
2033    fn link_headers_include_acl_and_describedby() {
2034        let hdrs = link_headers("/profile/card");
2035        assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2036        assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2037        assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2038        assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2039        assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2040    }
2041
2042    #[test]
2043    fn link_headers_root_exposes_pim_storage() {
2044        let hdrs = link_headers("/");
2045        let joined = hdrs.join(",");
2046        assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2047    }
2048
2049    #[test]
2050    fn link_headers_skip_describedby_on_meta() {
2051        let hdrs = link_headers("/foo.meta");
2052        assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2053    }
2054
2055    #[test]
2056    fn link_headers_skip_acl_on_acl() {
2057        let hdrs = link_headers("/profile/card.acl");
2058        assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2059    }
2060
2061    #[test]
2062    fn prefer_minimal_container_parsed() {
2063        let p = PreferHeader::parse(
2064            "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2065        );
2066        assert!(p.include_minimal);
2067        assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2068    }
2069
2070    #[test]
2071    fn prefer_contained_iris_parsed() {
2072        let p = PreferHeader::parse(
2073            "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2074        );
2075        assert!(p.include_contained_iris);
2076        assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2077    }
2078
2079    #[test]
2080    fn negotiate_prefers_explicit_turtle() {
2081        assert_eq!(
2082            negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2083            RdfFormat::Turtle
2084        );
2085    }
2086
2087    #[test]
2088    fn negotiate_falls_back_to_turtle() {
2089        assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2090        assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2091    }
2092
2093    #[test]
2094    fn negotiate_picks_jsonld_when_highest() {
2095        assert_eq!(
2096            negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2097            RdfFormat::JsonLd
2098        );
2099    }
2100
2101    #[test]
2102    fn ntriples_roundtrip() {
2103        let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2104        let g = Graph::parse_ntriples(nt).unwrap();
2105        assert_eq!(g.len(), 1);
2106        let out = g.to_ntriples();
2107        assert!(out.contains("<http://a/s>"));
2108    }
2109
2110    #[test]
2111    fn server_managed_triples_include_ldp_contains() {
2112        let now = chrono::Utc::now();
2113        let members = vec!["a.txt".to_string(), "sub/".to_string()];
2114        let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2115        let nt = g.to_ntriples();
2116        assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2117        assert!(nt.contains("http://x/y/a.txt"));
2118        assert!(nt.contains("http://x/y/sub/"));
2119    }
2120
2121    #[test]
2122    fn find_illegal_server_managed_flags_ldp_contains() {
2123        let mut g = Graph::new();
2124        g.insert(Triple::new(
2125            Term::iri("http://r/"),
2126            Term::iri(iri::LDP_CONTAINS),
2127            Term::iri("http://r/x"),
2128        ));
2129        let illegal = find_illegal_server_managed(&g);
2130        assert_eq!(illegal.len(), 1);
2131    }
2132
2133    #[test]
2134    fn render_container_minimal_omits_contains() {
2135        let prefer = PreferHeader {
2136            representation: ContainerRepresentation::MinimalContainer,
2137            include_minimal: true,
2138            include_contained_iris: false,
2139            omit_membership: true,
2140        };
2141        let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2142        assert!(v.get("ldp:contains").is_none());
2143    }
2144
2145    #[test]
2146    fn render_container_turtle_emits_types() {
2147        let v = render_container_turtle("/x/", &[], PreferHeader::default());
2148        assert!(v.contains("ldp:BasicContainer"));
2149    }
2150
2151    #[test]
2152    fn n3_patch_insert_and_delete() {
2153        let mut g = Graph::new();
2154        g.insert(Triple::new(
2155            Term::iri("http://s/a"),
2156            Term::iri("http://p/keep"),
2157            Term::literal("v"),
2158        ));
2159        g.insert(Triple::new(
2160            Term::iri("http://s/a"),
2161            Term::iri("http://p/drop"),
2162            Term::literal("old"),
2163        ));
2164
2165        let patch = r#"
2166            _:r a solid:InsertDeletePatch ;
2167              solid:deletes {
2168                <http://s/a> <http://p/drop> "old" .
2169              } ;
2170              solid:inserts {
2171                <http://s/a> <http://p/new> "shiny" .
2172              } .
2173        "#;
2174        let outcome = apply_n3_patch(g, patch).unwrap();
2175        assert_eq!(outcome.inserted, 1);
2176        assert_eq!(outcome.deleted, 1);
2177        assert!(outcome.graph.contains(&Triple::new(
2178            Term::iri("http://s/a"),
2179            Term::iri("http://p/new"),
2180            Term::literal("shiny"),
2181        )));
2182        assert!(!outcome.graph.contains(&Triple::new(
2183            Term::iri("http://s/a"),
2184            Term::iri("http://p/drop"),
2185            Term::literal("old"),
2186        )));
2187    }
2188
2189    #[test]
2190    fn n3_patch_where_failure_returns_precondition() {
2191        let g = Graph::new();
2192        let patch = r#"
2193            _:r solid:where   { <http://s/a> <http://p/need> "x" . } ;
2194                solid:inserts { <http://s/a> <http://p/added> "y" . } .
2195        "#;
2196        let err = apply_n3_patch(g, patch).err().unwrap();
2197        assert!(matches!(err, PodError::PreconditionFailed(_)));
2198    }
2199
2200    #[test]
2201    fn sparql_insert_data() {
2202        let g = Graph::new();
2203        let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2204        let outcome = apply_sparql_patch(g, update).unwrap();
2205        assert_eq!(outcome.inserted, 1);
2206        assert_eq!(outcome.graph.len(), 1);
2207    }
2208
2209    #[test]
2210    fn sparql_delete_data() {
2211        let mut g = Graph::new();
2212        g.insert(Triple::new(
2213            Term::iri("http://s"),
2214            Term::iri("http://p"),
2215            Term::literal("v"),
2216        ));
2217        let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2218        let outcome = apply_sparql_patch(g, update).unwrap();
2219        assert_eq!(outcome.deleted, 1);
2220        assert!(outcome.graph.is_empty());
2221    }
2222
2223    #[test]
2224    fn patch_dialect_detection() {
2225        assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2226        assert_eq!(
2227            patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2228            Some(PatchDialect::SparqlUpdate)
2229        );
2230        assert_eq!(patch_dialect_from_mime("text/plain"), None);
2231    }
2232
2233    #[test]
2234    fn slug_uses_valid_value() {
2235        let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2236        assert_eq!(out, "/photos/cat.jpg");
2237    }
2238
2239    #[test]
2240    fn slug_rejects_slashes() {
2241        let err = resolve_slug("/photos/", Some("a/b"));
2242        assert!(matches!(err, Err(PodError::BadRequest(_))));
2243    }
2244
2245    #[test]
2246    fn render_container_shapes_jsonld() {
2247        let members = vec!["one.txt".to_string(), "sub/".to_string()];
2248        let v = render_container("/docs/", &members);
2249        assert!(v.get("@context").is_some());
2250        assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2251    }
2252
2253    #[test]
2254    fn preconditions_if_match_star_passes_when_resource_exists() {
2255        let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2256        assert_eq!(got, ConditionalOutcome::Proceed);
2257    }
2258
2259    #[test]
2260    fn preconditions_if_match_star_fails_when_resource_absent() {
2261        let got = evaluate_preconditions("PUT", None, Some("*"), None);
2262        assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2263    }
2264
2265    #[test]
2266    fn preconditions_if_match_mismatch_412() {
2267        let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2268        assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2269    }
2270
2271    #[test]
2272    fn preconditions_if_none_match_match_on_get_returns_304() {
2273        let got =
2274            evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2275        assert_eq!(got, ConditionalOutcome::NotModified);
2276    }
2277
2278    #[test]
2279    fn preconditions_if_none_match_on_put_when_exists_fails() {
2280        let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2281        assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2282    }
2283
2284    #[test]
2285    fn preconditions_if_none_match_on_put_when_absent_passes() {
2286        let got = evaluate_preconditions("PUT", None, None, Some("*"));
2287        assert_eq!(got, ConditionalOutcome::Proceed);
2288    }
2289
2290    #[test]
2291    fn range_parses_start_end() {
2292        let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2293        assert_eq!(r.start, 0);
2294        assert_eq!(r.end, 99);
2295        assert_eq!(r.length(), 100);
2296    }
2297
2298    #[test]
2299    fn range_parses_open_ended() {
2300        let r = parse_range_header(Some("bytes=500-"), 1000).unwrap().unwrap();
2301        assert_eq!(r.start, 500);
2302        assert_eq!(r.end, 999);
2303    }
2304
2305    #[test]
2306    fn range_parses_suffix() {
2307        let r = parse_range_header(Some("bytes=-200"), 1000).unwrap().unwrap();
2308        assert_eq!(r.start, 800);
2309        assert_eq!(r.end, 999);
2310    }
2311
2312    #[test]
2313    fn range_rejects_unsatisfiable() {
2314        let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2315        assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2316    }
2317
2318    #[test]
2319    fn range_content_range_header_value() {
2320        let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2321        assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2322    }
2323
2324    #[test]
2325    fn options_container_includes_post_and_accept_post() {
2326        let o = options_for("/photos/");
2327        assert!(o.allow.contains(&"POST"));
2328        assert!(o.accept_post.is_some());
2329        // JSS parity: containers advertise `Accept-Ranges: none` because
2330        // container representations are server-generated RDF, not
2331        // byte-rangeable.
2332        assert_eq!(o.accept_ranges, "none");
2333        // JSS parity row 157 (#315): OPTIONS carries the RDF cache
2334        // directive so shared caches don't fuse auth variants.
2335        assert_eq!(o.cache_control, "private, no-cache, must-revalidate");
2336    }
2337
2338    #[test]
2339    fn options_resource_includes_put_patch_no_post() {
2340        let o = options_for("/photos/cat.jpg");
2341        assert!(o.allow.contains(&"PUT"));
2342        assert!(o.allow.contains(&"PATCH"));
2343        assert!(!o.allow.contains(&"POST"));
2344        assert!(o.accept_post.is_none());
2345        assert!(o.accept_patch.contains("sparql-update"));
2346        assert!(o.accept_patch.contains("json-patch"));
2347        assert_eq!(o.cache_control, CACHE_CONTROL_RDF);
2348    }
2349
2350    #[test]
2351    fn cache_control_present_for_turtle() {
2352        assert_eq!(
2353            cache_control_for("text/turtle"),
2354            Some("private, no-cache, must-revalidate")
2355        );
2356        assert_eq!(
2357            cache_control_for("text/turtle; charset=utf-8"),
2358            Some(CACHE_CONTROL_RDF)
2359        );
2360    }
2361
2362    #[test]
2363    fn cache_control_present_for_jsonld() {
2364        assert_eq!(
2365            cache_control_for("application/ld+json"),
2366            Some(CACHE_CONTROL_RDF)
2367        );
2368        assert_eq!(
2369            cache_control_for("application/ld+json; profile=\"http://www.w3.org/ns/json-ld#compacted\""),
2370            Some(CACHE_CONTROL_RDF)
2371        );
2372    }
2373
2374    #[test]
2375    fn cache_control_present_for_ntriples() {
2376        assert_eq!(
2377            cache_control_for("application/n-triples"),
2378            Some(CACHE_CONTROL_RDF)
2379        );
2380        assert_eq!(cache_control_for("text/n3"), Some(CACHE_CONTROL_RDF));
2381        assert_eq!(
2382            cache_control_for("application/trig"),
2383            Some(CACHE_CONTROL_RDF)
2384        );
2385    }
2386
2387    #[test]
2388    fn cache_control_absent_for_octet_stream() {
2389        assert_eq!(cache_control_for("application/octet-stream"), None);
2390        assert!(!is_rdf_content_type("application/octet-stream"));
2391    }
2392
2393    #[test]
2394    fn cache_control_absent_for_image_png() {
2395        assert_eq!(cache_control_for("image/png"), None);
2396        assert_eq!(cache_control_for("image/jpeg"), None);
2397        assert_eq!(cache_control_for("video/mp4"), None);
2398        assert!(!is_rdf_content_type("image/png"));
2399    }
2400
2401    #[test]
2402    fn cache_control_not_found_headers_conneg_enabled_emits_rdf_directive() {
2403        let h = not_found_headers("/data/thing", true);
2404        let found = h
2405            .iter()
2406            .find(|(k, _)| *k == "Cache-Control")
2407            .map(|(_, v)| v.as_str());
2408        assert_eq!(found, Some("private, no-cache, must-revalidate"));
2409    }
2410
2411    #[test]
2412    fn cache_control_not_found_headers_conneg_disabled_omits_directive() {
2413        let h = not_found_headers("/data/thing", false);
2414        assert!(h.iter().all(|(k, _)| *k != "Cache-Control"));
2415    }
2416
2417    #[test]
2418    fn json_patch_add_and_replace() {
2419        let mut v = serde_json::json!({ "name": "alice" });
2420        let patch = serde_json::json!([
2421            { "op": "add", "path": "/age", "value": 30 },
2422            { "op": "replace", "path": "/name", "value": "bob" }
2423        ]);
2424        apply_json_patch(&mut v, &patch).unwrap();
2425        assert_eq!(v["name"], "bob");
2426        assert_eq!(v["age"], 30);
2427    }
2428
2429    #[test]
2430    fn json_patch_remove() {
2431        let mut v = serde_json::json!({ "name": "alice", "age": 30 });
2432        let patch = serde_json::json!([
2433            { "op": "remove", "path": "/age" }
2434        ]);
2435        apply_json_patch(&mut v, &patch).unwrap();
2436        assert!(v.get("age").is_none());
2437    }
2438
2439    #[test]
2440    fn json_patch_test_failure_returns_precondition() {
2441        let mut v = serde_json::json!({ "name": "alice" });
2442        let patch = serde_json::json!([
2443            { "op": "test", "path": "/name", "value": "bob" }
2444        ]);
2445        let err = apply_json_patch(&mut v, &patch).unwrap_err();
2446        assert!(matches!(err, PodError::PreconditionFailed(_)));
2447    }
2448
2449    #[test]
2450    fn json_patch_dialect_detection() {
2451        assert_eq!(
2452            patch_dialect_from_mime("application/json-patch+json"),
2453            Some(PatchDialect::JsonPatch)
2454        );
2455    }
2456}