Skip to main content

solid_pod_rs/
ldp.rs

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