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    } else {
1584        allow.push("PUT");
1585        allow.push("PATCH");
1586    }
1587    allow.push("DELETE");
1588    OptionsResponse {
1589        allow,
1590        accept_post: if container { Some(ACCEPT_POST) } else { None },
1591        accept_patch: ACCEPT_PATCH,
1592        // Containers are not byte-rangeable — they render server-side
1593        // RDF representations. Only leaf resources carry bytes that a
1594        // `Range:` request can meaningfully slice. JSS advertises
1595        // `Accept-Ranges: none` on containers; we match.
1596        accept_ranges: if container { "none" } else { "bytes" },
1597        // OPTIONS describes the RDF-shaped conneg surface on Solid
1598        // resources. Shared caches must not fuse auth-variant responses,
1599        // and clients revalidate via ETag on every use (JSS #315).
1600        cache_control: CACHE_CONTROL_RDF,
1601    }
1602}
1603
1604/// Build the header set returned on `404 Not Found` for an LDP path.
1605///
1606/// JSS emits a rich discovery header set on 404 so that clients can
1607/// drive a PUT-to-create or POST-to-container flow without a second
1608/// OPTIONS round trip:
1609///
1610/// * `Allow` — methods the server will *accept* on this path. DELETE is
1611///   intentionally omitted because the resource does not exist.
1612/// * `Accept-Put: */*` — PUT accepts any content type (spec default).
1613/// * `Link: <path.acl>; rel="acl"` — ACL discovery.
1614/// * `Vary` — includes `Accept` when content negotiation is enabled so
1615///   caches key on it.
1616/// * `Accept-Post` — only for containers; advertises the RDF formats
1617///   usable as POST bodies.
1618pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1619    let container = is_container(path);
1620    let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1621    h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1622    h.push(("Accept-Put", "*/*".into()));
1623    h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1624    h.push((
1625        "Link",
1626        format!("<{}.acl>; rel=\"acl\"", path.trim_end_matches('/')),
1627    ));
1628    h.push(("Vary", vary_header(conneg_enabled).into()));
1629    // When conneg is enabled, the 404 advertises an RDF-shaped future
1630    // response surface (Allow/Accept-Post/Accept-Patch list RDF types).
1631    // Emit RDF Cache-Control so intermediaries cannot fuse the 404 with
1632    // a later 200 authenticated body. Mirrors JSS #315.
1633    if conneg_enabled {
1634        h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1635    }
1636    if container {
1637        h.push(("Accept-Post", ACCEPT_POST.into()));
1638    }
1639    h
1640}
1641
1642/// Value of the `Vary:` header depending on whether content negotiation
1643/// is enabled. `Authorization` and `Origin` are always listed so shared
1644/// caches never collapse an authenticated and an anonymous response
1645/// onto the same cache entry.
1646pub fn vary_header(conneg_enabled: bool) -> &'static str {
1647    if conneg_enabled {
1648        "Accept, Authorization, Origin"
1649    } else {
1650        "Authorization, Origin"
1651    }
1652}
1653
1654/// RFC 7234 `Cache-Control` directive for RDF response variants.
1655///
1656/// Emits `private, no-cache, must-revalidate` so shared caches never
1657/// serve one authenticated user's response to another. ETag-based
1658/// revalidation stays cheap (304). Mirrors JSS `RDF_CACHE_CONTROL`
1659/// in `src/handlers/resource.js` after PR #315 (commit 76fc5c6).
1660/// Binary blobs (images, uploads) are NOT RDF and keep their default
1661/// caching posture — callers decide.
1662pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1663
1664/// Return `true` if `content_type` identifies an RDF serialisation the
1665/// server emits through content negotiation or stores natively. Matches
1666/// the formats advertised in [`ACCEPT_POST`] plus `text/n3` and
1667/// `application/trig` (JSS parity). Parameters (e.g. `; charset=utf-8`)
1668/// are tolerated.
1669pub fn is_rdf_content_type(content_type: &str) -> bool {
1670    let base = content_type
1671        .split(';')
1672        .next()
1673        .unwrap_or("")
1674        .trim()
1675        .to_ascii_lowercase();
1676    matches!(
1677        base.as_str(),
1678        "text/turtle"
1679            | "application/turtle"
1680            | "application/x-turtle"
1681            | "application/ld+json"
1682            | "application/json+ld"
1683            | "application/n-triples"
1684            | "text/plain+ntriples"
1685            | "text/n3"
1686            | "application/trig"
1687    )
1688}
1689
1690/// Return the `Cache-Control` header value appropriate for a response
1691/// of the supplied `content_type`, or `None` to leave the header
1692/// unset. RDF variants always get [`CACHE_CONTROL_RDF`]; non-RDF
1693/// payloads (binary blobs, images, etc.) are left to caller policy.
1694pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1695    if is_rdf_content_type(content_type) {
1696        Some(CACHE_CONTROL_RDF)
1697    } else {
1698        None
1699    }
1700}
1701
1702// ---------------------------------------------------------------------------
1703// JSON Patch (RFC 6902) — applied to the JSON representation of a
1704// resource. Keeps the surface intentionally small: `add`, `remove`,
1705// `replace`, `test`. `copy` and `move` are implemented on top.
1706// ---------------------------------------------------------------------------
1707
1708/// Apply a JSON Patch document (RFC 6902) to a `serde_json::Value` in
1709/// place. Returns `Err(PodError::PreconditionFailed)` when a `test`
1710/// operation fails, `Err(PodError::Unsupported)` for malformed patches.
1711pub fn apply_json_patch(
1712    target: &mut serde_json::Value,
1713    patch: &serde_json::Value,
1714) -> Result<(), PodError> {
1715    let ops = patch
1716        .as_array()
1717        .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1718    for op in ops {
1719        let op_name = op
1720            .get("op")
1721            .and_then(|v| v.as_str())
1722            .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1723        let path = op
1724            .get("path")
1725            .and_then(|v| v.as_str())
1726            .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1727        match op_name {
1728            "add" => {
1729                let value = op
1730                    .get("value")
1731                    .cloned()
1732                    .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1733                json_pointer_set(target, path, value, /* add_mode = */ true)?;
1734            }
1735            "replace" => {
1736                let value = op
1737                    .get("value")
1738                    .cloned()
1739                    .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1740                json_pointer_set(target, path, value, /* add_mode = */ false)?;
1741            }
1742            "remove" => {
1743                json_pointer_remove(target, path)?;
1744            }
1745            "test" => {
1746                let value = op
1747                    .get("value")
1748                    .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1749                let actual = json_pointer_get(target, path)
1750                    .ok_or_else(|| PodError::PreconditionFailed(format!("test path missing: {path}")))?;
1751                if actual != value {
1752                    return Err(PodError::PreconditionFailed(format!(
1753                        "test failed at {path}"
1754                    )));
1755                }
1756            }
1757            "copy" => {
1758                let from = op
1759                    .get("from")
1760                    .and_then(|v| v.as_str())
1761                    .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1762                let value = json_pointer_get(target, from)
1763                    .cloned()
1764                    .ok_or_else(|| PodError::PreconditionFailed(format!("copy from missing: {from}")))?;
1765                json_pointer_set(target, path, value, true)?;
1766            }
1767            "move" => {
1768                let from = op
1769                    .get("from")
1770                    .and_then(|v| v.as_str())
1771                    .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
1772                let value = json_pointer_get(target, from)
1773                    .cloned()
1774                    .ok_or_else(|| PodError::PreconditionFailed(format!("move from missing: {from}")))?;
1775                json_pointer_remove(target, from)?;
1776                json_pointer_set(target, path, value, true)?;
1777            }
1778            other => {
1779                return Err(PodError::Unsupported(format!(
1780                    "unsupported JSON Patch op: {other}"
1781                )));
1782            }
1783        }
1784    }
1785    Ok(())
1786}
1787
1788fn json_pointer_get<'a>(
1789    target: &'a serde_json::Value,
1790    path: &str,
1791) -> Option<&'a serde_json::Value> {
1792    if path.is_empty() {
1793        return Some(target);
1794    }
1795    target.pointer(path)
1796}
1797
1798fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
1799    if path.is_empty() {
1800        return Err(PodError::Unsupported("cannot remove root".into()));
1801    }
1802    let (parent_path, last) = split_pointer(path);
1803    let parent = target
1804        .pointer_mut(&parent_path)
1805        .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
1806    match parent {
1807        serde_json::Value::Object(m) => {
1808            m.remove(&last).ok_or_else(|| {
1809                PodError::PreconditionFailed(format!("remove key missing: {path}"))
1810            })?;
1811            Ok(())
1812        }
1813        serde_json::Value::Array(a) => {
1814            let idx: usize = last.parse().map_err(|_| {
1815                PodError::Unsupported(format!("remove array index not numeric: {last}"))
1816            })?;
1817            if idx >= a.len() {
1818                return Err(PodError::PreconditionFailed(format!(
1819                    "remove array out of bounds: {idx}"
1820                )));
1821            }
1822            a.remove(idx);
1823            Ok(())
1824        }
1825        _ => Err(PodError::PreconditionFailed(format!(
1826            "remove target is not container: {path}"
1827        ))),
1828    }
1829}
1830
1831fn json_pointer_set(
1832    target: &mut serde_json::Value,
1833    path: &str,
1834    value: serde_json::Value,
1835    add_mode: bool,
1836) -> Result<(), PodError> {
1837    if path.is_empty() {
1838        *target = value;
1839        return Ok(());
1840    }
1841    let (parent_path, last) = split_pointer(path);
1842    let parent = target
1843        .pointer_mut(&parent_path)
1844        .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
1845    match parent {
1846        serde_json::Value::Object(m) => {
1847            if !add_mode && !m.contains_key(&last) {
1848                return Err(PodError::PreconditionFailed(format!(
1849                    "replace missing key: {path}"
1850                )));
1851            }
1852            m.insert(last, value);
1853            Ok(())
1854        }
1855        serde_json::Value::Array(a) => {
1856            if last == "-" {
1857                a.push(value);
1858                return Ok(());
1859            }
1860            let idx: usize = last.parse().map_err(|_| {
1861                PodError::Unsupported(format!("array index not numeric: {last}"))
1862            })?;
1863            if add_mode {
1864                if idx > a.len() {
1865                    return Err(PodError::PreconditionFailed(format!(
1866                        "array add out of bounds: {idx}"
1867                    )));
1868                }
1869                a.insert(idx, value);
1870            } else {
1871                if idx >= a.len() {
1872                    return Err(PodError::PreconditionFailed(format!(
1873                        "array replace out of bounds: {idx}"
1874                    )));
1875                }
1876                a[idx] = value;
1877            }
1878            Ok(())
1879        }
1880        _ => Err(PodError::PreconditionFailed(format!(
1881            "set parent not container: {path}"
1882        ))),
1883    }
1884}
1885
1886fn split_pointer(path: &str) -> (String, String) {
1887    match path.rfind('/') {
1888        Some(pos) => {
1889            let parent = path[..pos].to_string();
1890            let last_raw = &path[pos + 1..];
1891            let last = last_raw.replace("~1", "/").replace("~0", "~");
1892            (parent, last)
1893        }
1894        None => (String::new(), path.to_string()),
1895    }
1896}
1897
1898/// Pick a PATCH dialect from the `Content-Type` header.
1899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1900pub enum PatchDialect {
1901    N3,
1902    SparqlUpdate,
1903    JsonPatch,
1904}
1905
1906pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
1907    let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
1908    match m.as_str() {
1909        "text/n3" | "application/n3" => Some(PatchDialect::N3),
1910        "application/sparql-update" | "application/sparql-update+update" => {
1911            Some(PatchDialect::SparqlUpdate)
1912        }
1913        "application/json-patch+json" => Some(PatchDialect::JsonPatch),
1914        _ => None,
1915    }
1916}
1917
1918// ---------------------------------------------------------------------------
1919// PATCH against absent resource (JSS parity).
1920//
1921// JSS seeds an empty graph when a PATCH targets a path that does not
1922// yet exist and returns `201 Created`. JSON Patch is deliberately
1923// rejected on absent resources because RFC 6902 operates on an existing
1924// JSON document and `add`/`replace` at the root (`""`) would silently
1925// accept any shape — better to make the client issue a PUT first.
1926// ---------------------------------------------------------------------------
1927
1928/// Outcome of applying a PATCH to a path that had no prior resource.
1929///
1930/// * `Created { .. }` — graph was seeded successfully; caller should
1931///   persist it and respond with `201 Created`.
1932/// * `Applied { .. }` — unused on the absent path today but reserved so
1933///   callers can match exhaustively in the same enum they use for the
1934///   non-absent code path.
1935#[derive(Debug)]
1936pub enum PatchCreateOutcome {
1937    /// Patch applied to a newly-seeded empty graph.
1938    Created { inserted: usize, graph: Graph },
1939    /// Patch applied to an existing graph (for symmetry; not produced
1940    /// by `apply_patch_to_absent`).
1941    Applied {
1942        inserted: usize,
1943        deleted: usize,
1944        graph: Graph,
1945    },
1946}
1947
1948/// Apply a PATCH document to an absent resource by seeding an empty
1949/// graph and running the dialect-specific patcher. JSON Patch is
1950/// unsupported in this path.
1951pub fn apply_patch_to_absent(
1952    dialect: PatchDialect,
1953    body: &str,
1954) -> Result<PatchCreateOutcome, PodError> {
1955    match dialect {
1956        PatchDialect::N3 => {
1957            let outcome = apply_n3_patch(Graph::new(), body)?;
1958            Ok(PatchCreateOutcome::Created {
1959                inserted: outcome.inserted,
1960                graph: outcome.graph,
1961            })
1962        }
1963        PatchDialect::SparqlUpdate => {
1964            let outcome = apply_sparql_patch(Graph::new(), body)?;
1965            Ok(PatchCreateOutcome::Created {
1966                inserted: outcome.inserted,
1967                graph: outcome.graph,
1968            })
1969        }
1970        PatchDialect::JsonPatch => Err(PodError::Unsupported(
1971            "JSON Patch on absent resource".into(),
1972        )),
1973    }
1974}
1975
1976// ---------------------------------------------------------------------------
1977// LdpContainerOps trait (backwards compatible)
1978// ---------------------------------------------------------------------------
1979
1980#[async_trait]
1981pub trait LdpContainerOps: Storage {
1982    async fn container_representation(
1983        &self,
1984        path: &str,
1985    ) -> Result<serde_json::Value, PodError> {
1986        let children = self.list(path).await?;
1987        Ok(render_container(path, &children))
1988    }
1989}
1990
1991impl<T: Storage + ?Sized> LdpContainerOps for T {}
1992
1993// ---------------------------------------------------------------------------
1994// Tests
1995// ---------------------------------------------------------------------------
1996
1997#[cfg(test)]
1998mod tests {
1999    use super::*;
2000
2001    #[test]
2002    fn is_container_detects_trailing_slash() {
2003        assert!(is_container("/"));
2004        assert!(is_container("/media/"));
2005        assert!(!is_container("/file.txt"));
2006    }
2007
2008    #[test]
2009    fn link_headers_include_acl_and_describedby() {
2010        let hdrs = link_headers("/profile/card");
2011        assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2012        assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2013        assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2014        assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2015        assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2016    }
2017
2018    #[test]
2019    fn link_headers_root_exposes_pim_storage() {
2020        let hdrs = link_headers("/");
2021        let joined = hdrs.join(",");
2022        assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2023    }
2024
2025    #[test]
2026    fn link_headers_skip_describedby_on_meta() {
2027        let hdrs = link_headers("/foo.meta");
2028        assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2029    }
2030
2031    #[test]
2032    fn link_headers_skip_acl_on_acl() {
2033        let hdrs = link_headers("/profile/card.acl");
2034        assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2035    }
2036
2037    #[test]
2038    fn prefer_minimal_container_parsed() {
2039        let p = PreferHeader::parse(
2040            "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2041        );
2042        assert!(p.include_minimal);
2043        assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2044    }
2045
2046    #[test]
2047    fn prefer_contained_iris_parsed() {
2048        let p = PreferHeader::parse(
2049            "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2050        );
2051        assert!(p.include_contained_iris);
2052        assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2053    }
2054
2055    #[test]
2056    fn negotiate_prefers_explicit_turtle() {
2057        assert_eq!(
2058            negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2059            RdfFormat::Turtle
2060        );
2061    }
2062
2063    #[test]
2064    fn negotiate_falls_back_to_turtle() {
2065        assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2066        assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2067    }
2068
2069    #[test]
2070    fn negotiate_picks_jsonld_when_highest() {
2071        assert_eq!(
2072            negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2073            RdfFormat::JsonLd
2074        );
2075    }
2076
2077    #[test]
2078    fn ntriples_roundtrip() {
2079        let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2080        let g = Graph::parse_ntriples(nt).unwrap();
2081        assert_eq!(g.len(), 1);
2082        let out = g.to_ntriples();
2083        assert!(out.contains("<http://a/s>"));
2084    }
2085
2086    #[test]
2087    fn server_managed_triples_include_ldp_contains() {
2088        let now = chrono::Utc::now();
2089        let members = vec!["a.txt".to_string(), "sub/".to_string()];
2090        let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2091        let nt = g.to_ntriples();
2092        assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2093        assert!(nt.contains("http://x/y/a.txt"));
2094        assert!(nt.contains("http://x/y/sub/"));
2095    }
2096
2097    #[test]
2098    fn find_illegal_server_managed_flags_ldp_contains() {
2099        let mut g = Graph::new();
2100        g.insert(Triple::new(
2101            Term::iri("http://r/"),
2102            Term::iri(iri::LDP_CONTAINS),
2103            Term::iri("http://r/x"),
2104        ));
2105        let illegal = find_illegal_server_managed(&g);
2106        assert_eq!(illegal.len(), 1);
2107    }
2108
2109    #[test]
2110    fn render_container_minimal_omits_contains() {
2111        let prefer = PreferHeader {
2112            representation: ContainerRepresentation::MinimalContainer,
2113            include_minimal: true,
2114            include_contained_iris: false,
2115            omit_membership: true,
2116        };
2117        let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2118        assert!(v.get("ldp:contains").is_none());
2119    }
2120
2121    #[test]
2122    fn render_container_turtle_emits_types() {
2123        let v = render_container_turtle("/x/", &[], PreferHeader::default());
2124        assert!(v.contains("ldp:BasicContainer"));
2125    }
2126
2127    #[test]
2128    fn n3_patch_insert_and_delete() {
2129        let mut g = Graph::new();
2130        g.insert(Triple::new(
2131            Term::iri("http://s/a"),
2132            Term::iri("http://p/keep"),
2133            Term::literal("v"),
2134        ));
2135        g.insert(Triple::new(
2136            Term::iri("http://s/a"),
2137            Term::iri("http://p/drop"),
2138            Term::literal("old"),
2139        ));
2140
2141        let patch = r#"
2142            _:r a solid:InsertDeletePatch ;
2143              solid:deletes {
2144                <http://s/a> <http://p/drop> "old" .
2145              } ;
2146              solid:inserts {
2147                <http://s/a> <http://p/new> "shiny" .
2148              } .
2149        "#;
2150        let outcome = apply_n3_patch(g, patch).unwrap();
2151        assert_eq!(outcome.inserted, 1);
2152        assert_eq!(outcome.deleted, 1);
2153        assert!(outcome.graph.contains(&Triple::new(
2154            Term::iri("http://s/a"),
2155            Term::iri("http://p/new"),
2156            Term::literal("shiny"),
2157        )));
2158        assert!(!outcome.graph.contains(&Triple::new(
2159            Term::iri("http://s/a"),
2160            Term::iri("http://p/drop"),
2161            Term::literal("old"),
2162        )));
2163    }
2164
2165    #[test]
2166    fn n3_patch_where_failure_returns_precondition() {
2167        let g = Graph::new();
2168        let patch = r#"
2169            _:r solid:where   { <http://s/a> <http://p/need> "x" . } ;
2170                solid:inserts { <http://s/a> <http://p/added> "y" . } .
2171        "#;
2172        let err = apply_n3_patch(g, patch).err().unwrap();
2173        assert!(matches!(err, PodError::PreconditionFailed(_)));
2174    }
2175
2176    #[test]
2177    fn sparql_insert_data() {
2178        let g = Graph::new();
2179        let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2180        let outcome = apply_sparql_patch(g, update).unwrap();
2181        assert_eq!(outcome.inserted, 1);
2182        assert_eq!(outcome.graph.len(), 1);
2183    }
2184
2185    #[test]
2186    fn sparql_delete_data() {
2187        let mut g = Graph::new();
2188        g.insert(Triple::new(
2189            Term::iri("http://s"),
2190            Term::iri("http://p"),
2191            Term::literal("v"),
2192        ));
2193        let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2194        let outcome = apply_sparql_patch(g, update).unwrap();
2195        assert_eq!(outcome.deleted, 1);
2196        assert!(outcome.graph.is_empty());
2197    }
2198
2199    #[test]
2200    fn patch_dialect_detection() {
2201        assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2202        assert_eq!(
2203            patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2204            Some(PatchDialect::SparqlUpdate)
2205        );
2206        assert_eq!(patch_dialect_from_mime("text/plain"), None);
2207    }
2208
2209    #[test]
2210    fn slug_uses_valid_value() {
2211        let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2212        assert_eq!(out, "/photos/cat.jpg");
2213    }
2214
2215    #[test]
2216    fn slug_rejects_slashes() {
2217        let err = resolve_slug("/photos/", Some("a/b"));
2218        assert!(matches!(err, Err(PodError::BadRequest(_))));
2219    }
2220
2221    #[test]
2222    fn render_container_shapes_jsonld() {
2223        let members = vec!["one.txt".to_string(), "sub/".to_string()];
2224        let v = render_container("/docs/", &members);
2225        assert!(v.get("@context").is_some());
2226        assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2227    }
2228
2229    #[test]
2230    fn preconditions_if_match_star_passes_when_resource_exists() {
2231        let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2232        assert_eq!(got, ConditionalOutcome::Proceed);
2233    }
2234
2235    #[test]
2236    fn preconditions_if_match_star_fails_when_resource_absent() {
2237        let got = evaluate_preconditions("PUT", None, Some("*"), None);
2238        assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2239    }
2240
2241    #[test]
2242    fn preconditions_if_match_mismatch_412() {
2243        let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2244        assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2245    }
2246
2247    #[test]
2248    fn preconditions_if_none_match_match_on_get_returns_304() {
2249        let got =
2250            evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2251        assert_eq!(got, ConditionalOutcome::NotModified);
2252    }
2253
2254    #[test]
2255    fn preconditions_if_none_match_on_put_when_exists_fails() {
2256        let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2257        assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2258    }
2259
2260    #[test]
2261    fn preconditions_if_none_match_on_put_when_absent_passes() {
2262        let got = evaluate_preconditions("PUT", None, None, Some("*"));
2263        assert_eq!(got, ConditionalOutcome::Proceed);
2264    }
2265
2266    #[test]
2267    fn range_parses_start_end() {
2268        let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2269        assert_eq!(r.start, 0);
2270        assert_eq!(r.end, 99);
2271        assert_eq!(r.length(), 100);
2272    }
2273
2274    #[test]
2275    fn range_parses_open_ended() {
2276        let r = parse_range_header(Some("bytes=500-"), 1000).unwrap().unwrap();
2277        assert_eq!(r.start, 500);
2278        assert_eq!(r.end, 999);
2279    }
2280
2281    #[test]
2282    fn range_parses_suffix() {
2283        let r = parse_range_header(Some("bytes=-200"), 1000).unwrap().unwrap();
2284        assert_eq!(r.start, 800);
2285        assert_eq!(r.end, 999);
2286    }
2287
2288    #[test]
2289    fn range_rejects_unsatisfiable() {
2290        let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2291        assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2292    }
2293
2294    #[test]
2295    fn range_content_range_header_value() {
2296        let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2297        assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2298    }
2299
2300    #[test]
2301    fn options_container_includes_post_and_accept_post() {
2302        let o = options_for("/photos/");
2303        assert!(o.allow.contains(&"POST"));
2304        assert!(o.accept_post.is_some());
2305        // JSS parity: containers advertise `Accept-Ranges: none` because
2306        // container representations are server-generated RDF, not
2307        // byte-rangeable.
2308        assert_eq!(o.accept_ranges, "none");
2309        // JSS parity row 157 (#315): OPTIONS carries the RDF cache
2310        // directive so shared caches don't fuse auth variants.
2311        assert_eq!(o.cache_control, "private, no-cache, must-revalidate");
2312    }
2313
2314    #[test]
2315    fn options_resource_includes_put_patch_no_post() {
2316        let o = options_for("/photos/cat.jpg");
2317        assert!(o.allow.contains(&"PUT"));
2318        assert!(o.allow.contains(&"PATCH"));
2319        assert!(!o.allow.contains(&"POST"));
2320        assert!(o.accept_post.is_none());
2321        assert!(o.accept_patch.contains("sparql-update"));
2322        assert!(o.accept_patch.contains("json-patch"));
2323        assert_eq!(o.cache_control, CACHE_CONTROL_RDF);
2324    }
2325
2326    #[test]
2327    fn cache_control_present_for_turtle() {
2328        assert_eq!(
2329            cache_control_for("text/turtle"),
2330            Some("private, no-cache, must-revalidate")
2331        );
2332        assert_eq!(
2333            cache_control_for("text/turtle; charset=utf-8"),
2334            Some(CACHE_CONTROL_RDF)
2335        );
2336    }
2337
2338    #[test]
2339    fn cache_control_present_for_jsonld() {
2340        assert_eq!(
2341            cache_control_for("application/ld+json"),
2342            Some(CACHE_CONTROL_RDF)
2343        );
2344        assert_eq!(
2345            cache_control_for("application/ld+json; profile=\"http://www.w3.org/ns/json-ld#compacted\""),
2346            Some(CACHE_CONTROL_RDF)
2347        );
2348    }
2349
2350    #[test]
2351    fn cache_control_present_for_ntriples() {
2352        assert_eq!(
2353            cache_control_for("application/n-triples"),
2354            Some(CACHE_CONTROL_RDF)
2355        );
2356        assert_eq!(cache_control_for("text/n3"), Some(CACHE_CONTROL_RDF));
2357        assert_eq!(
2358            cache_control_for("application/trig"),
2359            Some(CACHE_CONTROL_RDF)
2360        );
2361    }
2362
2363    #[test]
2364    fn cache_control_absent_for_octet_stream() {
2365        assert_eq!(cache_control_for("application/octet-stream"), None);
2366        assert!(!is_rdf_content_type("application/octet-stream"));
2367    }
2368
2369    #[test]
2370    fn cache_control_absent_for_image_png() {
2371        assert_eq!(cache_control_for("image/png"), None);
2372        assert_eq!(cache_control_for("image/jpeg"), None);
2373        assert_eq!(cache_control_for("video/mp4"), None);
2374        assert!(!is_rdf_content_type("image/png"));
2375    }
2376
2377    #[test]
2378    fn cache_control_not_found_headers_conneg_enabled_emits_rdf_directive() {
2379        let h = not_found_headers("/data/thing", true);
2380        let found = h
2381            .iter()
2382            .find(|(k, _)| *k == "Cache-Control")
2383            .map(|(_, v)| v.as_str());
2384        assert_eq!(found, Some("private, no-cache, must-revalidate"));
2385    }
2386
2387    #[test]
2388    fn cache_control_not_found_headers_conneg_disabled_omits_directive() {
2389        let h = not_found_headers("/data/thing", false);
2390        assert!(h.iter().all(|(k, _)| *k != "Cache-Control"));
2391    }
2392
2393    #[test]
2394    fn json_patch_add_and_replace() {
2395        let mut v = serde_json::json!({ "name": "alice" });
2396        let patch = serde_json::json!([
2397            { "op": "add", "path": "/age", "value": 30 },
2398            { "op": "replace", "path": "/name", "value": "bob" }
2399        ]);
2400        apply_json_patch(&mut v, &patch).unwrap();
2401        assert_eq!(v["name"], "bob");
2402        assert_eq!(v["age"], 30);
2403    }
2404
2405    #[test]
2406    fn json_patch_remove() {
2407        let mut v = serde_json::json!({ "name": "alice", "age": 30 });
2408        let patch = serde_json::json!([
2409            { "op": "remove", "path": "/age" }
2410        ]);
2411        apply_json_patch(&mut v, &patch).unwrap();
2412        assert!(v.get("age").is_none());
2413    }
2414
2415    #[test]
2416    fn json_patch_test_failure_returns_precondition() {
2417        let mut v = serde_json::json!({ "name": "alice" });
2418        let patch = serde_json::json!([
2419            { "op": "test", "path": "/name", "value": "bob" }
2420        ]);
2421        let err = apply_json_patch(&mut v, &patch).unwrap_err();
2422        assert!(matches!(err, PodError::PreconditionFailed(_)));
2423    }
2424
2425    #[test]
2426    fn json_patch_dialect_detection() {
2427        assert_eq!(
2428            patch_dialect_from_mime("application/json-patch+json"),
2429            Some(PatchDialect::JsonPatch)
2430        );
2431    }
2432}