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