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