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