Skip to main content

solid_pod_rs/
ldp.rs

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