1use std::collections::BTreeSet;
21use std::fmt::Write as _;
22
23#[cfg(feature = "tokio-runtime")]
24use async_trait::async_trait;
25use serde::Serialize;
26
27use crate::error::PodError;
28pub use crate::n3_patch::{N3Patch, PatchTerm, PatchTriple};
29#[cfg(feature = "tokio-runtime")]
33use crate::storage::Storage;
34
35pub mod iri {
37 pub const LDP_RESOURCE: &str = "http://www.w3.org/ns/ldp#Resource";
39 pub const LDP_CONTAINER: &str = "http://www.w3.org/ns/ldp#Container";
41 pub const LDP_BASIC_CONTAINER: &str = "http://www.w3.org/ns/ldp#BasicContainer";
43 pub const LDP_NS: &str = "http://www.w3.org/ns/ldp#";
45 pub const LDP_CONTAINS: &str = "http://www.w3.org/ns/ldp#contains";
47 pub const LDP_PREFER_MINIMAL_CONTAINER: &str =
49 "http://www.w3.org/ns/ldp#PreferMinimalContainer";
50 pub const LDP_PREFER_CONTAINED_IRIS: &str = "http://www.w3.org/ns/ldp#PreferContainedIRIs";
52 pub const LDP_PREFER_MEMBERSHIP: &str = "http://www.w3.org/ns/ldp#PreferMembership";
54
55 pub const DCTERMS_NS: &str = "http://purl.org/dc/terms/";
57 pub const DCTERMS_MODIFIED: &str = "http://purl.org/dc/terms/modified";
59
60 pub const STAT_NS: &str = "http://www.w3.org/ns/posix/stat#";
62 pub const STAT_SIZE: &str = "http://www.w3.org/ns/posix/stat#size";
64 pub const STAT_MTIME: &str = "http://www.w3.org/ns/posix/stat#mtime";
66
67 pub const XSD_DATETIME: &str = "http://www.w3.org/2001/XMLSchema#dateTime";
69 pub const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
71 pub const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
73
74 pub const PIM_STORAGE: &str = "http://www.w3.org/ns/pim/space#Storage";
76 pub const PIM_STORAGE_REL: &str = "http://www.w3.org/ns/pim/space#storage";
78
79 pub const ACL_NS: &str = "http://www.w3.org/ns/auth/acl#";
81}
82
83pub const ACCEPT_POST: &str = "text/turtle, application/ld+json, application/n-triples";
87
88pub const ACCEPT_POST_CONNEG: &str =
94 "text/turtle, application/ld+json, application/n-triples, text/n3";
95
96pub fn accept_post_for(conneg_enabled: bool) -> &'static str {
104 if conneg_enabled {
105 ACCEPT_POST_CONNEG
106 } else {
107 ACCEPT_POST
108 }
109}
110
111pub fn is_container(path: &str) -> bool {
113 path == "/" || path.ends_with('/')
114}
115
116pub fn is_acl_path(path: &str) -> bool {
118 path.ends_with(".acl")
119}
120
121pub fn is_meta_path(path: &str) -> bool {
123 path.ends_with(".meta")
124}
125
126pub fn meta_sidecar_for(path: &str) -> String {
128 if is_meta_path(path) {
129 path.to_string()
130 } else {
131 format!("{path}.meta")
132 }
133}
134
135pub fn link_headers(path: &str) -> Vec<String> {
144 let mut out = Vec::new();
145 if is_container(path) {
146 out.push(format!("<{}>; rel=\"type\"", iri::LDP_BASIC_CONTAINER));
147 out.push(format!("<{}>; rel=\"type\"", iri::LDP_CONTAINER));
148 out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
149 } else {
150 out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
151 }
152 if !is_acl_path(path) {
153 let acl_target = crate::wac::acl_sidecar_key(path);
154 out.push(format!("<{acl_target}>; rel=\"acl\""));
155 }
156 if !is_meta_path(path) && !is_acl_path(path) {
157 let meta_target = meta_sidecar_for(path);
158 out.push(format!("<{meta_target}>; rel=\"describedby\""));
159 }
160 if path == "/" {
161 out.push(format!("</>; rel=\"{}\"", iri::PIM_STORAGE_REL));
162 }
163 out
164}
165
166pub const MAX_SLUG_BYTES: usize = 255;
169
170pub fn resolve_slug(container: &str, slug: Option<&str>) -> Result<String, PodError> {
181 let join = |name: &str| {
182 if container.ends_with('/') {
183 format!("{container}{name}")
184 } else {
185 format!("{container}/{name}")
186 }
187 };
188 match slug {
189 Some(s) if !s.is_empty() => {
190 if s.len() > MAX_SLUG_BYTES {
191 return Err(PodError::BadRequest(format!(
192 "slug exceeds {MAX_SLUG_BYTES} bytes"
193 )));
194 }
195 if s.contains('/') || s.contains("..") || s == "." || s.contains('\0') {
196 return Err(PodError::BadRequest(format!("invalid slug: {s:?}")));
197 }
198 if !s
199 .chars()
200 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
201 {
202 return Err(PodError::BadRequest(format!(
203 "slug contains disallowed character: {s:?}"
204 )));
205 }
206 Ok(join(s))
207 }
208 _ => Ok(join(&uuid::Uuid::new_v4().to_string())),
209 }
210}
211
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
218pub enum ContainerRepresentation {
219 #[default]
221 Full,
222 MinimalContainer,
224 ContainedIRIsOnly,
226}
227
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
231pub struct PreferHeader {
232 pub representation: ContainerRepresentation,
233 pub include_minimal: bool,
234 pub include_contained_iris: bool,
235 pub omit_membership: bool,
236}
237
238impl PreferHeader {
239 pub fn parse(value: &str) -> Self {
241 let mut out = PreferHeader::default();
242 for pref in value.split(',') {
244 let pref = pref.trim();
245 if pref.is_empty() {
246 continue;
247 }
248 let mut parts = pref.split(';').map(|s| s.trim());
250 let head = match parts.next() {
251 Some(h) => h,
252 None => continue,
253 };
254 if !head.eq_ignore_ascii_case("return=representation") {
255 continue;
256 }
257 for token in parts {
258 if let Some(val) = token
259 .strip_prefix("include=")
260 .or_else(|| token.strip_prefix("include ="))
261 {
262 let unq = val.trim().trim_matches('"');
263 for iri in unq.split_whitespace() {
264 if iri == iri::LDP_PREFER_MINIMAL_CONTAINER {
265 out.include_minimal = true;
266 out.representation = ContainerRepresentation::MinimalContainer;
267 } else if iri == iri::LDP_PREFER_CONTAINED_IRIS {
268 out.include_contained_iris = true;
269 out.representation = ContainerRepresentation::ContainedIRIsOnly;
270 }
271 }
272 } else if let Some(val) = token
273 .strip_prefix("omit=")
274 .or_else(|| token.strip_prefix("omit ="))
275 {
276 let unq = val.trim().trim_matches('"');
277 for iri in unq.split_whitespace() {
278 if iri == iri::LDP_PREFER_MEMBERSHIP {
279 out.omit_membership = true;
280 }
281 }
282 }
283 }
284 }
285 out
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum RdfFormat {
295 Turtle,
296 JsonLd,
297 NTriples,
298 RdfXml,
299}
300
301impl RdfFormat {
302 pub fn mime(&self) -> &'static str {
303 match self {
304 RdfFormat::Turtle => "text/turtle",
305 RdfFormat::JsonLd => "application/ld+json",
306 RdfFormat::NTriples => "application/n-triples",
307 RdfFormat::RdfXml => "application/rdf+xml",
308 }
309 }
310
311 pub fn from_mime(mime: &str) -> Option<Self> {
312 let mime = mime
313 .split(';')
314 .next()
315 .unwrap_or("")
316 .trim()
317 .to_ascii_lowercase();
318 match mime.as_str() {
319 "text/turtle" | "application/turtle" | "application/x-turtle" => {
320 Some(RdfFormat::Turtle)
321 }
322 "application/ld+json" | "application/json+ld" => Some(RdfFormat::JsonLd),
323 "application/n-triples" | "text/plain+ntriples" => Some(RdfFormat::NTriples),
324 "application/rdf+xml" => Some(RdfFormat::RdfXml),
325 _ => None,
326 }
327 }
328}
329
330pub fn negotiate_format(accept: Option<&str>) -> RdfFormat {
334 let accept = match accept {
335 Some(a) if !a.trim().is_empty() => a,
336 _ => return RdfFormat::Turtle,
337 };
338
339 let mut best: Option<(f32, RdfFormat)> = None;
340 for entry in accept.split(',') {
341 let entry = entry.trim();
342 if entry.is_empty() {
343 continue;
344 }
345 let mut parts = entry.split(';').map(|s| s.trim());
346 let mime = match parts.next() {
347 Some(m) => m.to_ascii_lowercase(),
348 None => continue,
349 };
350 let mut q: f32 = 1.0;
351 for token in parts {
352 if let Some(v) = token.strip_prefix("q=") {
353 if let Ok(parsed) = v.parse::<f32>() {
354 q = parsed;
355 }
356 }
357 }
358 let format = match mime.as_str() {
359 "text/turtle" | "application/turtle" => Some(RdfFormat::Turtle),
360 "application/ld+json" => Some(RdfFormat::JsonLd),
361 "application/n-triples" => Some(RdfFormat::NTriples),
362 "application/rdf+xml" => Some(RdfFormat::RdfXml),
363 "*/*" | "application/*" | "text/*" => Some(RdfFormat::Turtle),
364 _ => None,
365 };
366 if let Some(f) = format {
367 match best {
368 None => best = Some((q, f)),
369 Some((bq, _)) if q > bq => best = Some((q, f)),
370 _ => {}
371 }
372 }
373 }
374 best.map(|(_, f)| f).unwrap_or(RdfFormat::Turtle)
375}
376
377pub fn infer_dotfile_content_type(path: &str) -> Option<&'static str> {
397 let trimmed = path.trim_end_matches('/');
400 if trimmed.is_empty() {
401 return None;
402 }
403 let basename = trimmed.rsplit('/').next().filter(|s| !s.is_empty())?;
404
405 if basename.ends_with(".acl") || basename.ends_with(".meta") {
409 Some("application/ld+json")
410 } else {
411 None
412 }
413}
414
415pub fn guess_content_type(path: &str) -> String {
430 if let Some(ct) = infer_dotfile_content_type(path) {
431 return ct.to_string();
432 }
433
434 let trimmed = path.trim_end_matches('/');
435 let basename = trimmed.rsplit('/').next().unwrap_or(trimmed);
436 let ext = basename
437 .rsplit_once('.')
438 .map(|(_, e)| e.to_ascii_lowercase())
439 .unwrap_or_default();
440
441 let override_ct = match ext.as_str() {
444 "jsonld" => Some("application/ld+json"),
445 "ttl" => Some("text/turtle"),
446 "n3" => Some("text/n3"),
447 "nt" => Some("application/n-triples"),
448 "rdf" => Some("application/rdf+xml"),
449 "nq" => Some("application/n-quads"),
450 "trig" => Some("application/trig"),
451 "m3u" => Some("audio/mpegurl"),
452 "pls" => Some("audio/x-scpls"),
453 _ => None,
454 };
455 if let Some(ct) = override_ct {
456 return ct.to_string();
457 }
458
459 mime_guess::from_path(trimmed)
460 .first_raw()
461 .map(|s| s.to_string())
462 .unwrap_or_else(|| "application/octet-stream".to_string())
463}
464
465#[cfg(test)]
466mod guess_content_type_tests {
467 use super::guess_content_type;
468
469 #[test]
470 fn solid_overrides_take_priority() {
471 assert_eq!(guess_content_type("/data.ttl"), "text/turtle");
472 assert_eq!(guess_content_type("/card.jsonld"), "application/ld+json");
473 assert_eq!(guess_content_type("/g.nq"), "application/n-quads");
474 assert_eq!(guess_content_type("/list.m3u"), "audio/mpegurl");
475 }
476
477 #[test]
478 fn dotfiles_resolve_to_jsonld() {
479 assert_eq!(guess_content_type("/.acl"), "application/ld+json");
480 assert_eq!(
481 guess_content_type("/publicTypeIndex.jsonld.acl"),
482 "application/ld+json"
483 );
484 }
485
486 #[test]
487 fn mime_db_covers_media_and_web() {
488 assert_eq!(guess_content_type("/app/index.html"), "text/html");
489 assert_eq!(guess_content_type("/song.mp3"), "audio/mpeg");
490 assert_eq!(guess_content_type("/clip.mp4"), "video/mp4");
491 assert_eq!(guess_content_type("/img.png"), "image/png");
492 }
493
494 #[test]
495 fn unknown_extension_falls_back_to_octet_stream() {
496 assert_eq!(
497 guess_content_type("/blob.xyzzy"),
498 "application/octet-stream"
499 );
500 assert_eq!(guess_content_type("/noext"), "application/octet-stream");
501 }
502}
503
504#[cfg(test)]
505mod infer_dotfile_tests {
506 use super::infer_dotfile_content_type;
507
508 #[test]
509 fn infer_dotfile_content_type_acl_file_returns_jsonld() {
510 assert_eq!(
511 infer_dotfile_content_type("/.acl"),
512 Some("application/ld+json")
513 );
514 assert_eq!(
515 infer_dotfile_content_type("/pods/alice/foo.acl"),
516 Some("application/ld+json")
517 );
518 assert_eq!(
519 infer_dotfile_content_type(".acl"),
520 Some("application/ld+json")
521 );
522 }
523
524 #[test]
525 fn infer_dotfile_content_type_meta_file_returns_jsonld() {
526 assert_eq!(
527 infer_dotfile_content_type("/.meta"),
528 Some("application/ld+json")
529 );
530 assert_eq!(
531 infer_dotfile_content_type("/pods/alice/foo.meta"),
532 Some("application/ld+json")
533 );
534 }
535
536 #[test]
537 fn infer_dotfile_content_type_dotted_midname_returns_none() {
538 assert_eq!(infer_dotfile_content_type("/foo.acl.bak"), None);
541 assert_eq!(infer_dotfile_content_type("/foo.meta.bak"), None);
542 }
543
544 #[test]
545 fn infer_dotfile_content_type_substring_only_returns_none() {
546 assert_eq!(infer_dotfile_content_type("/not.aclfile"), None);
548 assert_eq!(infer_dotfile_content_type("/some.metainfo"), None);
549 assert_eq!(infer_dotfile_content_type("/plain.txt"), None);
550 }
551
552 #[test]
553 fn infer_dotfile_content_type_trailing_slash_stripped() {
554 assert_eq!(
556 infer_dotfile_content_type("/pods/alice/foo.acl/"),
557 Some("application/ld+json")
558 );
559 assert_eq!(infer_dotfile_content_type("/"), None);
560 assert_eq!(infer_dotfile_content_type(""), None);
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
569pub enum Term {
570 Iri(String),
571 BlankNode(String),
572 Literal {
573 value: String,
574 datatype: Option<String>,
575 language: Option<String>,
576 },
577}
578
579impl Term {
580 pub fn iri(i: impl Into<String>) -> Self {
581 Term::Iri(i.into())
582 }
583 pub fn blank(b: impl Into<String>) -> Self {
584 Term::BlankNode(b.into())
585 }
586 pub fn literal(v: impl Into<String>) -> Self {
587 Term::Literal {
588 value: v.into(),
589 datatype: None,
590 language: None,
591 }
592 }
593 pub fn typed_literal(v: impl Into<String>, dt: impl Into<String>) -> Self {
594 Term::Literal {
595 value: v.into(),
596 datatype: Some(dt.into()),
597 language: None,
598 }
599 }
600 pub fn lang_literal(v: impl Into<String>, lang: impl Into<String>) -> Self {
602 Term::Literal {
603 value: v.into(),
604 datatype: None,
605 language: Some(lang.into()),
606 }
607 }
608
609 fn write_ntriples(&self, out: &mut String) {
610 match self {
611 Term::Iri(i) => {
612 out.push('<');
613 out.push_str(i);
614 out.push('>');
615 }
616 Term::BlankNode(b) => {
617 out.push_str("_:");
618 out.push_str(b);
619 }
620 Term::Literal {
621 value,
622 datatype,
623 language,
624 } => {
625 out.push('"');
626 for c in value.chars() {
627 match c {
628 '\\' => out.push_str("\\\\"),
629 '"' => out.push_str("\\\""),
630 '\n' => out.push_str("\\n"),
631 '\r' => out.push_str("\\r"),
632 '\t' => out.push_str("\\t"),
633 _ => out.push(c),
634 }
635 }
636 out.push('"');
637 if let Some(lang) = language {
638 out.push('@');
639 out.push_str(lang);
640 } else if let Some(dt) = datatype {
641 out.push_str("^^<");
642 out.push_str(dt);
643 out.push('>');
644 }
645 }
646 }
647 }
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
651pub struct Triple {
652 pub subject: Term,
653 pub predicate: Term,
654 pub object: Term,
655}
656
657impl Triple {
658 pub fn new(subject: Term, predicate: Term, object: Term) -> Self {
659 Self {
660 subject,
661 predicate,
662 object,
663 }
664 }
665}
666
667#[derive(Debug, Clone, Default, PartialEq, Eq)]
669pub struct Graph {
670 triples: BTreeSet<Triple>,
671}
672
673impl Graph {
674 pub fn new() -> Self {
675 Self {
676 triples: BTreeSet::new(),
677 }
678 }
679
680 pub fn from_triples(triples: impl IntoIterator<Item = Triple>) -> Self {
681 let mut g = Self::new();
682 for t in triples {
683 g.insert(t);
684 }
685 g
686 }
687
688 pub fn insert(&mut self, triple: Triple) {
689 self.triples.insert(triple);
690 }
691
692 pub fn remove(&mut self, triple: &Triple) -> bool {
693 self.triples.remove(triple)
694 }
695
696 pub fn contains(&self, triple: &Triple) -> bool {
697 self.triples.contains(triple)
698 }
699
700 pub fn len(&self) -> usize {
701 self.triples.len()
702 }
703
704 pub fn is_empty(&self) -> bool {
705 self.triples.is_empty()
706 }
707
708 pub fn triples(&self) -> impl Iterator<Item = &Triple> {
709 self.triples.iter()
710 }
711
712 pub fn extend(&mut self, other: &Graph) {
714 for t in &other.triples {
715 self.triples.insert(t.clone());
716 }
717 }
718
719 pub fn subtract(&mut self, other: &Graph) {
721 for t in &other.triples {
722 self.triples.remove(t);
723 }
724 }
725
726 pub fn to_ntriples(&self) -> String {
728 let mut out = String::new();
729 for t in &self.triples {
730 t.subject.write_ntriples(&mut out);
731 out.push(' ');
732 t.predicate.write_ntriples(&mut out);
733 out.push(' ');
734 t.object.write_ntriples(&mut out);
735 out.push_str(" .\n");
736 }
737 out
738 }
739
740 pub fn to_jsonld(&self) -> serde_json::Value {
748 const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
749
750 fn node_ref(term: &Term) -> Option<serde_json::Value> {
751 match term {
752 Term::Iri(i) => Some(serde_json::json!({ "@id": i })),
753 Term::BlankNode(b) => Some(serde_json::json!({ "@id": format!("_:{b}") })),
754 Term::Literal { .. } => None,
755 }
756 }
757
758 fn object_value(term: &Term) -> serde_json::Value {
759 match term {
760 Term::Iri(i) => serde_json::json!({ "@id": i }),
761 Term::BlankNode(b) => serde_json::json!({ "@id": format!("_:{b}") }),
762 Term::Literal {
763 value,
764 datatype,
765 language,
766 } => {
767 let mut obj = serde_json::Map::new();
768 obj.insert("@value".into(), serde_json::Value::String(value.clone()));
769 if let Some(lang) = language {
770 obj.insert("@language".into(), serde_json::Value::String(lang.clone()));
771 } else if let Some(dt) = datatype {
772 obj.insert("@type".into(), serde_json::Value::String(dt.clone()));
773 }
774 serde_json::Value::Object(obj)
775 }
776 }
777 }
778
779 fn subject_id(term: &Term) -> String {
780 match term {
781 Term::Iri(i) => i.clone(),
782 Term::BlankNode(b) => format!("_:{b}"),
783 Term::Literal { value, .. } => value.clone(),
784 }
785 }
786
787 let mut nodes: Vec<serde_json::Map<String, serde_json::Value>> = Vec::new();
790 let mut current_id: Option<String> = None;
791 for t in &self.triples {
792 let sid = subject_id(&t.subject);
793 if current_id.as_deref() != Some(sid.as_str()) {
794 let mut node = serde_json::Map::new();
795 node.insert("@id".into(), serde_json::Value::String(sid.clone()));
796 nodes.push(node);
797 current_id = Some(sid);
798 }
799 let node = nodes.last_mut().expect("node pushed above");
800
801 if let Term::Iri(p) = &t.predicate {
802 if p == RDF_TYPE {
803 if let Some(type_ref) = node_ref(&t.object) {
804 if let Some(serde_json::Value::String(id)) = type_ref.get("@id").cloned() {
805 node.entry("@type")
806 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
807 .as_array_mut()
808 .expect("@type is an array")
809 .push(serde_json::Value::String(id));
810 continue;
811 }
812 }
813 }
814 node.entry(p.clone())
815 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
816 .as_array_mut()
817 .expect("predicate value is an array")
818 .push(object_value(&t.object));
819 }
820 }
821
822 serde_json::Value::Array(nodes.into_iter().map(serde_json::Value::Object).collect())
823 }
824
825 pub fn parse_ntriples(input: &str) -> Result<Self, PodError> {
827 let mut g = Graph::new();
828 for (i, line) in input.lines().enumerate() {
829 let line = line.trim();
830 if line.is_empty() || line.starts_with('#') {
831 continue;
832 }
833 let t = parse_nt_line(line)
834 .map_err(|e| PodError::Unsupported(format!("N-Triples line {}: {e}", i + 1)))?;
835 g.insert(t);
836 }
837 Ok(g)
838 }
839}
840
841fn parse_nt_line(line: &str) -> Result<Triple, String> {
842 let line = line.trim_end_matches('.').trim();
843 let (subject, rest) = read_term(line)?;
844 let rest = rest.trim_start();
845 let (predicate, rest) = read_term(rest)?;
846 let rest = rest.trim_start();
847 let (object, rest) = read_term(rest)?;
848 let rest = rest.trim_start();
853 let rest = rest.strip_prefix('.').unwrap_or(rest).trim_start();
854 if !rest.is_empty() && !rest.starts_with('#') {
855 return Err(format!(
856 "unexpected content after the object: {}",
857 rest.trim()
858 ));
859 }
860 Ok(Triple::new(subject, predicate, object))
861}
862
863fn read_term(input: &str) -> Result<(Term, &str), String> {
864 let input = input.trim_start();
865 if let Some(rest) = input.strip_prefix('<') {
866 let end = rest
867 .find('>')
868 .ok_or_else(|| "unterminated IRI".to_string())?;
869 let iri = &rest[..end];
870 Ok((Term::Iri(iri.to_string()), &rest[end + 1..]))
871 } else if let Some(rest) = input.strip_prefix("_:") {
872 let end = rest
873 .find(|c: char| c.is_whitespace() || c == '.')
874 .unwrap_or(rest.len());
875 Ok((Term::BlankNode(rest[..end].to_string()), &rest[end..]))
876 } else if input.starts_with('"') {
877 read_literal(input)
878 } else {
879 Err(format!(
880 "unexpected char: {}",
881 input.chars().next().unwrap_or('?')
882 ))
883 }
884}
885
886fn read_literal(input: &str) -> Result<(Term, &str), String> {
887 let mut chars = input.char_indices();
891 if chars.next().map(|(_, c)| c) != Some('"') {
892 return Err("expected '\"'".to_string());
893 }
894 let mut value = String::new();
895 let mut i = input.len();
896 let mut closed = false;
897 while let Some((pos, c)) = chars.next() {
898 match c {
899 '"' => {
900 i = pos + 1;
901 closed = true;
902 break;
903 }
904 '\\' => {
905 let (_, esc) = chars.next().ok_or_else(|| "dangling escape".to_string())?;
906 match esc {
907 'n' => value.push('\n'),
908 't' => value.push('\t'),
909 'r' => value.push('\r'),
910 'b' => value.push('\u{8}'),
911 'f' => value.push('\u{c}'),
912 '"' | '\'' | '\\' => value.push(esc),
913 'u' | 'U' => {
914 let width = if esc == 'u' { 4 } else { 8 };
915 let hex: String = chars.by_ref().take(width).map(|(_, h)| h).collect();
916 let code = u32::from_str_radix(&hex, 16)
917 .ok()
918 .filter(|_| hex.len() == width)
919 .ok_or_else(|| format!("bad \\{esc} escape"))?;
920 value.push(
921 char::from_u32(code)
922 .ok_or_else(|| format!("bad code point {code:x}"))?,
923 );
924 }
925 other => return Err(format!("unknown escape \\{other}")),
926 }
927 }
928 other => value.push(other),
929 }
930 }
931 if !closed {
932 return Err("unterminated literal".to_string());
933 }
934 let rest = &input[i..];
935 let (datatype, language, rest) = if let Some(r) = rest.strip_prefix("^^<") {
936 let end = r
937 .find('>')
938 .ok_or_else(|| "unterminated datatype IRI".to_string())?;
939 (Some(r[..end].to_string()), None, &r[end + 1..])
940 } else if let Some(r) = rest.strip_prefix('@') {
941 let end = r
942 .find(|c: char| c.is_whitespace() || c == '.')
943 .unwrap_or(r.len());
944 (None, Some(r[..end].to_string()), &r[end..])
945 } else {
946 (None, None, rest)
947 };
948 Ok((
949 Term::Literal {
950 value,
951 datatype,
952 language,
953 },
954 rest,
955 ))
956}
957
958pub fn server_managed_triples(
965 resource_iri: &str,
966 modified: chrono::DateTime<chrono::Utc>,
967 size: u64,
968 is_container_flag: bool,
969 contained: &[String],
970) -> Graph {
971 let mut g = Graph::new();
972 let subject = Term::iri(resource_iri);
973
974 g.insert(Triple::new(
975 subject.clone(),
976 Term::iri(iri::DCTERMS_MODIFIED),
977 Term::typed_literal(modified.to_rfc3339(), iri::XSD_DATETIME),
978 ));
979 g.insert(Triple::new(
980 subject.clone(),
981 Term::iri(iri::STAT_SIZE),
982 Term::typed_literal(size.to_string(), iri::XSD_INTEGER),
983 ));
984 g.insert(Triple::new(
985 subject.clone(),
986 Term::iri(iri::STAT_MTIME),
987 Term::typed_literal(modified.timestamp().to_string(), iri::XSD_INTEGER),
988 ));
989
990 if is_container_flag {
991 for child in contained {
992 let base = if resource_iri.ends_with('/') {
993 resource_iri.to_string()
994 } else {
995 format!("{resource_iri}/")
996 };
997 g.insert(Triple::new(
998 subject.clone(),
999 Term::iri(iri::LDP_CONTAINS),
1000 Term::iri(format!("{base}{child}")),
1001 ));
1002 }
1003 }
1004 g
1005}
1006
1007pub const SERVER_MANAGED_PREDICATES: &[&str] = &[
1010 iri::DCTERMS_MODIFIED,
1011 iri::STAT_SIZE,
1012 iri::STAT_MTIME,
1013 iri::LDP_CONTAINS,
1014];
1015
1016pub fn find_illegal_server_managed(graph: &Graph) -> Vec<Triple> {
1019 graph
1020 .triples()
1021 .filter(|t| {
1022 if let Term::Iri(p) = &t.predicate {
1023 SERVER_MANAGED_PREDICATES.iter().any(|sm| sm == p)
1024 } else {
1025 false
1026 }
1027 })
1028 .cloned()
1029 .collect()
1030}
1031
1032#[derive(Debug, Serialize)]
1037pub struct ContainerMember {
1038 #[serde(rename = "@id")]
1039 pub id: String,
1040 #[serde(rename = "@type")]
1041 pub types: Vec<&'static str>,
1042}
1043
1044pub fn render_container_jsonld(
1046 container_path: &str,
1047 members: &[String],
1048 prefer: PreferHeader,
1049) -> serde_json::Value {
1050 let base = if container_path.ends_with('/') {
1051 container_path.to_string()
1052 } else {
1053 format!("{container_path}/")
1054 };
1055
1056 match prefer.representation {
1057 ContainerRepresentation::ContainedIRIsOnly => serde_json::json!({
1058 "@id": container_path,
1059 "ldp:contains": members
1060 .iter()
1061 .map(|m| serde_json::json!({"@id": format!("{base}{m}")}))
1062 .collect::<Vec<_>>(),
1063 }),
1064 ContainerRepresentation::MinimalContainer => serde_json::json!({
1065 "@context": {
1066 "ldp": iri::LDP_NS,
1067 "dcterms": iri::DCTERMS_NS,
1068 },
1069 "@id": container_path,
1070 "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
1071 }),
1072 ContainerRepresentation::Full => {
1073 let contains: Vec<ContainerMember> = members
1074 .iter()
1075 .map(|m| {
1076 let is_dir = m.ends_with('/');
1077 ContainerMember {
1078 id: format!("{base}{m}"),
1079 types: if is_dir {
1080 vec![
1081 iri::LDP_BASIC_CONTAINER,
1082 iri::LDP_CONTAINER,
1083 iri::LDP_RESOURCE,
1084 ]
1085 } else {
1086 vec![iri::LDP_RESOURCE]
1087 },
1088 }
1089 })
1090 .collect();
1091 serde_json::json!({
1092 "@context": {
1093 "ldp": iri::LDP_NS,
1094 "dcterms": iri::DCTERMS_NS,
1095 "contains": { "@id": "ldp:contains", "@type": "@id" },
1096 },
1097 "@id": container_path,
1098 "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
1099 "ldp:contains": contains,
1100 })
1101 }
1102 }
1103}
1104
1105pub fn render_container(container_path: &str, members: &[String]) -> serde_json::Value {
1107 render_container_jsonld(container_path, members, PreferHeader::default())
1108}
1109
1110pub fn render_container_turtle(
1112 container_path: &str,
1113 members: &[String],
1114 prefer: PreferHeader,
1115) -> String {
1116 let base = if container_path.ends_with('/') {
1117 container_path.to_string()
1118 } else {
1119 format!("{container_path}/")
1120 };
1121 let mut out = String::new();
1122 let _ = writeln!(out, "@prefix ldp: <{}> .", iri::LDP_NS);
1123 let _ = writeln!(out, "@prefix dcterms: <{}> .", iri::DCTERMS_NS);
1124 let _ = writeln!(out);
1125 match prefer.representation {
1126 ContainerRepresentation::ContainedIRIsOnly => {
1127 let _ = writeln!(out, "<{container_path}> ldp:contains");
1128 let list: Vec<String> = members.iter().map(|m| format!(" <{base}{m}>")).collect();
1129 let _ = writeln!(out, "{} .", list.join(",\n"));
1130 }
1131 ContainerRepresentation::MinimalContainer => {
1132 let _ = writeln!(
1133 out,
1134 "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ."
1135 );
1136 }
1137 ContainerRepresentation::Full => {
1138 let _ = writeln!(
1139 out,
1140 "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ;"
1141 );
1142 if members.is_empty() {
1143 let fixed = out.trim_end().trim_end_matches(';').to_string();
1145 out = fixed;
1146 out.push_str(" .\n");
1147 } else {
1148 let list: Vec<String> = members
1149 .iter()
1150 .map(|m| format!(" ldp:contains <{base}{m}>"))
1151 .collect();
1152 let _ = writeln!(out, "{} .", list.join(" ;\n"));
1153 }
1154 }
1155 }
1156 out
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Eq)]
1165pub struct PatchOutcome {
1166 pub graph: Graph,
1168 pub inserted: usize,
1170 pub deleted: usize,
1172}
1173
1174pub fn apply_n3_patch(target: Graph, patch: &str) -> Result<PatchOutcome, PodError> {
1199 N3Patch::parse(patch, None)?.apply(target)
1200}
1201
1202pub const SPARQL_UPDATE_MAX_BYTES: usize = 1_048_576; pub fn apply_sparql_patch(target: Graph, update: &str) -> Result<PatchOutcome, PodError> {
1213 if update.len() > SPARQL_UPDATE_MAX_BYTES {
1214 return Err(PodError::BadRequest(format!(
1215 "SPARQL-Update body exceeds {} byte limit ({} bytes)",
1216 SPARQL_UPDATE_MAX_BYTES,
1217 update.len(),
1218 )));
1219 }
1220
1221 use spargebra::term::{
1222 GraphName, GraphNamePattern, GroundQuad, GroundQuadPattern, GroundSubject, GroundTerm,
1223 GroundTermPattern, NamedNodePattern, Quad, Subject, Term as SpTerm,
1224 };
1225 use spargebra::{GraphUpdateOperation, Update};
1226
1227 let parsed = Update::parse(update, None)
1228 .map_err(|e| PodError::Unsupported(format!("SPARQL parse error: {e}")))?;
1229
1230 fn build_literal(value: String, datatype: Option<String>, language: Option<String>) -> Term {
1237 let datatype = datatype.filter(|d| d != iri::XSD_STRING);
1238 Term::Literal {
1239 value,
1240 datatype,
1241 language,
1242 }
1243 }
1244
1245 fn map_subject(s: &Subject) -> Option<Term> {
1246 match s {
1247 Subject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1248 Subject::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1249 #[allow(unreachable_patterns)]
1250 _ => None,
1251 }
1252 }
1253 fn map_term(t: &SpTerm) -> Option<Term> {
1254 match t {
1255 SpTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1256 SpTerm::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1257 SpTerm::Literal(lit) => {
1258 let value = lit.value().to_string();
1259 if let Some(lang) = lit.language() {
1260 Some(build_literal(value, None, Some(lang.to_string())))
1261 } else {
1262 Some(build_literal(
1263 value,
1264 Some(lit.datatype().as_str().to_string()),
1265 None,
1266 ))
1267 }
1268 }
1269 #[allow(unreachable_patterns)]
1270 _ => None,
1271 }
1272 }
1273 fn map_ground_subject(s: &GroundSubject) -> Option<Term> {
1274 match s {
1275 GroundSubject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1276 #[allow(unreachable_patterns)]
1277 _ => None,
1278 }
1279 }
1280 fn map_ground_term(t: &GroundTerm) -> Option<Term> {
1281 match t {
1282 GroundTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1283 GroundTerm::Literal(lit) => {
1284 let value = lit.value().to_string();
1285 if let Some(lang) = lit.language() {
1286 Some(build_literal(value, None, Some(lang.to_string())))
1287 } else {
1288 Some(build_literal(
1289 value,
1290 Some(lit.datatype().as_str().to_string()),
1291 None,
1292 ))
1293 }
1294 }
1295 #[allow(unreachable_patterns)]
1296 _ => None,
1297 }
1298 }
1299 fn map_ground_term_pattern(t: &GroundTermPattern) -> Option<Term> {
1300 match t {
1301 GroundTermPattern::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1302 GroundTermPattern::Literal(lit) => {
1303 let value = lit.value().to_string();
1304 if let Some(lang) = lit.language() {
1305 Some(build_literal(value, None, Some(lang.to_string())))
1306 } else {
1307 Some(build_literal(
1308 value,
1309 Some(lit.datatype().as_str().to_string()),
1310 None,
1311 ))
1312 }
1313 }
1314 _ => None,
1315 }
1316 }
1317
1318 fn quad_to_triple(q: &Quad) -> Option<Triple> {
1319 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1320 return None;
1321 }
1322 Some(Triple::new(
1323 map_subject(&q.subject)?,
1324 Term::Iri(q.predicate.as_str().to_string()),
1325 map_term(&q.object)?,
1326 ))
1327 }
1328 fn ground_quad_to_triple(q: &GroundQuad) -> Option<Triple> {
1329 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1330 return None;
1331 }
1332 Some(Triple::new(
1333 map_ground_subject(&q.subject)?,
1334 Term::Iri(q.predicate.as_str().to_string()),
1335 map_ground_term(&q.object)?,
1336 ))
1337 }
1338 fn ground_quad_pattern_to_triple(q: &GroundQuadPattern) -> Option<Triple> {
1339 if !matches!(q.graph_name, GraphNamePattern::DefaultGraph) {
1340 return None;
1341 }
1342 let predicate = match &q.predicate {
1343 NamedNodePattern::NamedNode(n) => Term::Iri(n.as_str().to_string()),
1344 NamedNodePattern::Variable(_) => return None,
1345 };
1346 Some(Triple::new(
1347 map_ground_term_pattern(&q.subject)?,
1348 predicate,
1349 map_ground_term_pattern(&q.object)?,
1350 ))
1351 }
1352
1353 let mut graph = target;
1354 let mut inserted = 0usize;
1355 let mut deleted = 0usize;
1356
1357 for op in &parsed.operations {
1358 match op {
1359 GraphUpdateOperation::InsertData { data } => {
1360 for q in data {
1361 if let Some(tr) = quad_to_triple(q) {
1362 if !graph.contains(&tr) {
1363 graph.insert(tr);
1364 inserted += 1;
1365 }
1366 }
1367 }
1368 }
1369 GraphUpdateOperation::DeleteData { data } => {
1370 for q in data {
1371 if let Some(tr) = ground_quad_to_triple(q) {
1372 if graph.remove(&tr) {
1373 deleted += 1;
1374 }
1375 }
1376 }
1377 }
1378 GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
1379 for q in delete {
1380 if let Some(tr) = ground_quad_pattern_to_triple(q) {
1381 if graph.remove(&tr) {
1382 deleted += 1;
1383 }
1384 }
1385 }
1386 for q in insert {
1387 let gqp = match convert_quad_pattern_to_ground(q) {
1392 Some(g) => g,
1393 None => continue,
1394 };
1395 if let Some(tr) = ground_quad_pattern_to_triple(&gqp) {
1396 if !graph.contains(&tr) {
1397 graph.insert(tr);
1398 inserted += 1;
1399 }
1400 }
1401 }
1402 }
1403 _ => {
1404 return Err(PodError::Unsupported(format!(
1405 "unsupported SPARQL operation: {op:?}"
1406 )));
1407 }
1408 }
1409 }
1410
1411 Ok(PatchOutcome {
1412 graph,
1413 inserted,
1414 deleted,
1415 })
1416}
1417
1418fn convert_quad_pattern_to_ground(
1419 q: &spargebra::term::QuadPattern,
1420) -> Option<spargebra::term::GroundQuadPattern> {
1421 use spargebra::term::{
1422 GraphNamePattern, GroundQuadPattern, GroundTermPattern, NamedNodePattern, TermPattern,
1423 };
1424
1425 let subject = match &q.subject {
1426 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1427 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1428 _ => return None,
1429 };
1430 let predicate = match &q.predicate {
1431 NamedNodePattern::NamedNode(n) => NamedNodePattern::NamedNode(n.clone()),
1432 NamedNodePattern::Variable(_) => return None,
1433 };
1434 let object = match &q.object {
1435 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1436 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1437 _ => return None,
1438 };
1439 let graph_name = match &q.graph_name {
1440 GraphNamePattern::DefaultGraph => GraphNamePattern::DefaultGraph,
1441 GraphNamePattern::NamedNode(n) => GraphNamePattern::NamedNode(n.clone()),
1442 GraphNamePattern::Variable(_) => return None,
1443 };
1444 Some(GroundQuadPattern {
1445 subject,
1446 predicate,
1447 object,
1448 graph_name,
1449 })
1450}
1451
1452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459pub enum ConditionalOutcome {
1460 Proceed,
1462 PreconditionFailed,
1465 NotModified,
1468}
1469
1470pub fn evaluate_preconditions(
1486 method: &str,
1487 current_etag: Option<&str>,
1488 if_match: Option<&str>,
1489 if_none_match: Option<&str>,
1490) -> ConditionalOutcome {
1491 let method_upper = method.to_ascii_uppercase();
1492 let safe = method_upper == "GET" || method_upper == "HEAD";
1493
1494 if let Some(im) = if_match {
1495 let raw = im.trim();
1496 if raw == "*" {
1497 if current_etag.is_none() {
1498 return ConditionalOutcome::PreconditionFailed;
1499 }
1500 } else {
1501 let wanted = parse_etag_list(raw, true);
1503 match current_etag {
1504 None => return ConditionalOutcome::PreconditionFailed,
1505 Some(cur) => {
1506 if !wanted.iter().any(|w| w == cur) {
1507 return ConditionalOutcome::PreconditionFailed;
1508 }
1509 }
1510 }
1511 }
1512 }
1513
1514 if let Some(inm) = if_none_match {
1515 let raw = inm.trim();
1516 if raw == "*" {
1517 if current_etag.is_some() {
1518 if safe {
1519 return ConditionalOutcome::NotModified;
1520 }
1521 return ConditionalOutcome::PreconditionFailed;
1522 }
1523 } else {
1524 let wanted = parse_etag_list(raw, false);
1526 if let Some(cur) = current_etag {
1527 if wanted.iter().any(|w| w == cur) {
1528 if safe {
1529 return ConditionalOutcome::NotModified;
1530 }
1531 return ConditionalOutcome::PreconditionFailed;
1532 }
1533 }
1534 }
1535 }
1536
1537 ConditionalOutcome::Proceed
1538}
1539
1540fn parse_etag_list(input: &str, strong: bool) -> Vec<String> {
1544 input
1545 .split(',')
1546 .map(|s| s.trim())
1547 .filter(|s| !s.is_empty())
1548 .filter_map(|s| match s.strip_prefix("W/") {
1549 Some(_) if strong => None,
1550 Some(weak) => Some(weak),
1551 None => Some(s),
1552 })
1553 .map(|s| s.trim_matches('"').to_string())
1554 .collect()
1555}
1556
1557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1563pub struct ByteRange {
1564 pub start: u64,
1565 pub end: u64,
1566}
1567
1568impl ByteRange {
1569 pub fn length(&self) -> u64 {
1570 self.end.saturating_sub(self.start) + 1
1571 }
1572 pub fn content_range(&self, total: u64) -> String {
1575 format!("bytes {}-{}/{}", self.start, self.end, total)
1576 }
1577}
1578
1579pub fn parse_range_header(header: Option<&str>, total: u64) -> Result<Option<ByteRange>, PodError> {
1589 let raw = match header {
1590 Some(v) if !v.trim().is_empty() => v.trim(),
1591 _ => return Ok(None),
1592 };
1593 let spec = raw
1594 .strip_prefix("bytes=")
1595 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1596 if spec.contains(',') {
1597 return Err(PodError::Unsupported(
1598 "multi-range requests not supported".into(),
1599 ));
1600 }
1601 let (start_s, end_s) = spec
1602 .split_once('-')
1603 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1604 if total == 0 {
1605 return Err(PodError::PreconditionFailed(
1606 "range request against empty resource".into(),
1607 ));
1608 }
1609
1610 let range = if start_s.is_empty() {
1611 let suffix: u64 = end_s
1613 .parse()
1614 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1615 if suffix == 0 {
1616 return Err(PodError::PreconditionFailed("zero suffix length".into()));
1617 }
1618 let start = total.saturating_sub(suffix);
1619 ByteRange {
1620 start,
1621 end: total - 1,
1622 }
1623 } else {
1624 let start: u64 = start_s
1625 .parse()
1626 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1627 let end = if end_s.is_empty() {
1628 total - 1
1629 } else {
1630 let v: u64 = end_s
1631 .parse()
1632 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1633 v.min(total - 1)
1634 };
1635 if start > end {
1636 return Err(PodError::PreconditionFailed(format!(
1637 "unsatisfiable range: {start}-{end}"
1638 )));
1639 }
1640 if start >= total {
1641 return Err(PodError::PreconditionFailed(format!(
1642 "range start {start} >= total {total}"
1643 )));
1644 }
1645 ByteRange { start, end }
1646 };
1647 Ok(Some(range))
1648}
1649
1650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1654pub enum RangeOutcome {
1655 Full,
1656 Partial(ByteRange),
1657 NotSatisfiable,
1658}
1659
1660pub fn parse_range_header_v2(header: Option<&str>, total: u64) -> Result<RangeOutcome, PodError> {
1665 let raw = match header {
1666 Some(v) if !v.trim().is_empty() => v.trim(),
1667 _ => return Ok(RangeOutcome::Full),
1668 };
1669 let spec = raw
1670 .strip_prefix("bytes=")
1671 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1672 if spec.contains(',') {
1673 return Err(PodError::Unsupported("multi-range not supported".into()));
1674 }
1675 let (start_s, end_s) = spec
1676 .split_once('-')
1677 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1678 if total == 0 {
1679 return Ok(RangeOutcome::NotSatisfiable);
1680 }
1681 let range = if start_s.is_empty() {
1682 let suffix: u64 = end_s
1683 .parse()
1684 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1685 if suffix == 0 {
1686 return Ok(RangeOutcome::NotSatisfiable);
1687 }
1688 ByteRange {
1689 start: total.saturating_sub(suffix),
1690 end: total - 1,
1691 }
1692 } else {
1693 let start: u64 = start_s
1694 .parse()
1695 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1696 let end = if end_s.is_empty() {
1697 total - 1
1698 } else {
1699 let v: u64 = end_s
1700 .parse()
1701 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1702 v.min(total - 1)
1703 };
1704 if start > end || start >= total {
1705 return Ok(RangeOutcome::NotSatisfiable);
1706 }
1707 ByteRange { start, end }
1708 };
1709 Ok(RangeOutcome::Partial(range))
1710}
1711
1712pub fn slice_range(body: &[u8], range: ByteRange) -> &[u8] {
1716 let end_excl = (range.end as usize + 1).min(body.len());
1717 let start = (range.start as usize).min(end_excl);
1718 &body[start..end_excl]
1719}
1720
1721#[derive(Debug, Clone)]
1735pub struct OptionsResponse {
1736 pub allow: Vec<&'static str>,
1737 pub accept_post: Option<&'static str>,
1738 pub accept_patch: &'static str,
1739 pub accept_ranges: &'static str,
1740 pub cache_control: &'static str,
1741}
1742
1743pub const ACCEPT_PATCH: &str = "text/n3, application/sparql-update, application/json-patch+json";
1745
1746pub fn options_for(path: &str) -> OptionsResponse {
1747 options_for_conneg(path, false)
1748}
1749
1750pub fn options_for_conneg(path: &str, conneg_enabled: bool) -> OptionsResponse {
1753 let container = is_container(path);
1754 let mut allow = vec!["GET", "HEAD", "OPTIONS"];
1755 if container {
1756 allow.push("POST");
1757 allow.push("PUT");
1758 } else {
1759 allow.push("PUT");
1760 allow.push("PATCH");
1761 }
1762 allow.push("DELETE");
1763 OptionsResponse {
1764 allow,
1765 accept_post: if container {
1766 Some(accept_post_for(conneg_enabled))
1767 } else {
1768 None
1769 },
1770 accept_patch: ACCEPT_PATCH,
1771 accept_ranges: if container { "none" } else { "bytes" },
1776 cache_control: CACHE_CONTROL_RDF,
1780 }
1781}
1782
1783pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1798 let container = is_container(path);
1799 let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1800 h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1801 h.push(("Accept-Put", "*/*".into()));
1802 h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1803 h.push((
1804 "Link",
1805 format!("<{}>; rel=\"acl\"", crate::wac::acl_sidecar_key(path)),
1806 ));
1807 h.push(("Vary", vary_header(conneg_enabled).into()));
1808 if conneg_enabled {
1813 h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1814 }
1815 if container {
1816 h.push(("Accept-Post", accept_post_for(conneg_enabled).into()));
1817 }
1818 h
1819}
1820
1821pub fn vary_header(conneg_enabled: bool) -> &'static str {
1826 if conneg_enabled {
1827 "Accept, Authorization, Origin"
1828 } else {
1829 "Authorization, Origin"
1830 }
1831}
1832
1833pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1842
1843pub fn is_rdf_content_type(content_type: &str) -> bool {
1849 let base = content_type
1850 .split(';')
1851 .next()
1852 .unwrap_or("")
1853 .trim()
1854 .to_ascii_lowercase();
1855 matches!(
1856 base.as_str(),
1857 "text/turtle"
1858 | "application/turtle"
1859 | "application/x-turtle"
1860 | "application/ld+json"
1861 | "application/json+ld"
1862 | "application/n-triples"
1863 | "text/plain+ntriples"
1864 | "text/n3"
1865 | "application/trig"
1866 )
1867}
1868
1869pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1882 if is_rdf_content_type(content_type) {
1883 Some(CACHE_CONTROL_RDF)
1884 } else {
1885 None
1886 }
1887}
1888
1889pub const CACHE_CONTROL_PRIVATE: &str = "private, no-store";
1896
1897#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1904pub enum ResponseAudience {
1905 Public,
1908 Private,
1911}
1912
1913pub fn cache_control_for_response(
1928 content_type: &str,
1929 audience: ResponseAudience,
1930) -> Option<&'static str> {
1931 match audience {
1932 ResponseAudience::Private => Some(CACHE_CONTROL_PRIVATE),
1933 ResponseAudience::Public => cache_control_for(content_type),
1934 }
1935}
1936
1937pub fn apply_json_patch(
1947 target: &mut serde_json::Value,
1948 patch: &serde_json::Value,
1949) -> Result<(), PodError> {
1950 let ops = patch
1951 .as_array()
1952 .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1953 for op in ops {
1954 let op_name = op
1955 .get("op")
1956 .and_then(|v| v.as_str())
1957 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1958 let path = op
1959 .get("path")
1960 .and_then(|v| v.as_str())
1961 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1962 match op_name {
1963 "add" => {
1964 let value = op
1965 .get("value")
1966 .cloned()
1967 .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1968 json_pointer_set(target, path, value, true)?;
1969 }
1970 "replace" => {
1971 let value = op
1972 .get("value")
1973 .cloned()
1974 .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1975 json_pointer_set(target, path, value, false)?;
1976 }
1977 "remove" => {
1978 json_pointer_remove(target, path)?;
1979 }
1980 "test" => {
1981 let value = op
1982 .get("value")
1983 .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1984 let actual = json_pointer_get(target, path).ok_or_else(|| {
1985 PodError::PreconditionFailed(format!("test path missing: {path}"))
1986 })?;
1987 if actual != value {
1988 return Err(PodError::PreconditionFailed(format!(
1989 "test failed at {path}"
1990 )));
1991 }
1992 }
1993 "copy" => {
1994 let from = op
1995 .get("from")
1996 .and_then(|v| v.as_str())
1997 .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1998 let value = json_pointer_get(target, from).cloned().ok_or_else(|| {
1999 PodError::PreconditionFailed(format!("copy from missing: {from}"))
2000 })?;
2001 json_pointer_set(target, path, value, true)?;
2002 }
2003 "move" => {
2004 let from = op
2005 .get("from")
2006 .and_then(|v| v.as_str())
2007 .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
2008 let value = json_pointer_get(target, from).cloned().ok_or_else(|| {
2009 PodError::PreconditionFailed(format!("move from missing: {from}"))
2010 })?;
2011 json_pointer_remove(target, from)?;
2012 json_pointer_set(target, path, value, true)?;
2013 }
2014 other => {
2015 return Err(PodError::Unsupported(format!(
2016 "unsupported JSON Patch op: {other}"
2017 )));
2018 }
2019 }
2020 }
2021 Ok(())
2022}
2023
2024fn json_pointer_get<'a>(
2025 target: &'a serde_json::Value,
2026 path: &str,
2027) -> Option<&'a serde_json::Value> {
2028 if path.is_empty() {
2029 return Some(target);
2030 }
2031 target.pointer(path)
2032}
2033
2034fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
2035 if path.is_empty() {
2036 return Err(PodError::Unsupported("cannot remove root".into()));
2037 }
2038 let (parent_path, last) = split_pointer(path);
2039 let parent = target
2040 .pointer_mut(&parent_path)
2041 .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
2042 match parent {
2043 serde_json::Value::Object(m) => {
2044 m.remove(&last).ok_or_else(|| {
2045 PodError::PreconditionFailed(format!("remove key missing: {path}"))
2046 })?;
2047 Ok(())
2048 }
2049 serde_json::Value::Array(a) => {
2050 let idx: usize = last.parse().map_err(|_| {
2051 PodError::Unsupported(format!("remove array index not numeric: {last}"))
2052 })?;
2053 if idx >= a.len() {
2054 return Err(PodError::PreconditionFailed(format!(
2055 "remove array out of bounds: {idx}"
2056 )));
2057 }
2058 a.remove(idx);
2059 Ok(())
2060 }
2061 _ => Err(PodError::PreconditionFailed(format!(
2062 "remove target is not container: {path}"
2063 ))),
2064 }
2065}
2066
2067fn json_pointer_set(
2068 target: &mut serde_json::Value,
2069 path: &str,
2070 value: serde_json::Value,
2071 add_mode: bool,
2072) -> Result<(), PodError> {
2073 if path.is_empty() {
2074 *target = value;
2075 return Ok(());
2076 }
2077 let (parent_path, last) = split_pointer(path);
2078 let parent = target
2079 .pointer_mut(&parent_path)
2080 .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
2081 match parent {
2082 serde_json::Value::Object(m) => {
2083 if !add_mode && !m.contains_key(&last) {
2084 return Err(PodError::PreconditionFailed(format!(
2085 "replace missing key: {path}"
2086 )));
2087 }
2088 m.insert(last, value);
2089 Ok(())
2090 }
2091 serde_json::Value::Array(a) => {
2092 if last == "-" {
2093 a.push(value);
2094 return Ok(());
2095 }
2096 let idx: usize = last
2097 .parse()
2098 .map_err(|_| PodError::Unsupported(format!("array index not numeric: {last}")))?;
2099 if add_mode {
2100 if idx > a.len() {
2101 return Err(PodError::PreconditionFailed(format!(
2102 "array add out of bounds: {idx}"
2103 )));
2104 }
2105 a.insert(idx, value);
2106 } else {
2107 if idx >= a.len() {
2108 return Err(PodError::PreconditionFailed(format!(
2109 "array replace out of bounds: {idx}"
2110 )));
2111 }
2112 a[idx] = value;
2113 }
2114 Ok(())
2115 }
2116 _ => Err(PodError::PreconditionFailed(format!(
2117 "set parent not container: {path}"
2118 ))),
2119 }
2120}
2121
2122fn split_pointer(path: &str) -> (String, String) {
2123 match path.rfind('/') {
2124 Some(pos) => {
2125 let parent = path[..pos].to_string();
2126 let last_raw = &path[pos + 1..];
2127 let last = last_raw.replace("~1", "/").replace("~0", "~");
2128 (parent, last)
2129 }
2130 None => (String::new(), path.to_string()),
2131 }
2132}
2133
2134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2136pub enum PatchDialect {
2137 N3,
2138 SparqlUpdate,
2139 JsonPatch,
2140}
2141
2142pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
2143 let m = mime
2144 .split(';')
2145 .next()
2146 .unwrap_or("")
2147 .trim()
2148 .to_ascii_lowercase();
2149 match m.as_str() {
2150 "text/n3" | "application/n3" => Some(PatchDialect::N3),
2151 "application/sparql-update" | "application/sparql-update+update" => {
2152 Some(PatchDialect::SparqlUpdate)
2153 }
2154 "application/json-patch+json" => Some(PatchDialect::JsonPatch),
2155 _ => None,
2156 }
2157}
2158
2159#[derive(Debug)]
2177pub enum PatchCreateOutcome {
2178 Created { inserted: usize, graph: Graph },
2180 Applied {
2183 inserted: usize,
2184 deleted: usize,
2185 graph: Graph,
2186 },
2187}
2188
2189pub fn apply_patch_to_absent(
2193 dialect: PatchDialect,
2194 body: &str,
2195) -> Result<PatchCreateOutcome, PodError> {
2196 match dialect {
2197 PatchDialect::N3 => {
2198 let outcome = apply_n3_patch(Graph::new(), body)?;
2199 Ok(PatchCreateOutcome::Created {
2200 inserted: outcome.inserted,
2201 graph: outcome.graph,
2202 })
2203 }
2204 PatchDialect::SparqlUpdate => {
2205 let outcome = apply_sparql_patch(Graph::new(), body)?;
2206 Ok(PatchCreateOutcome::Created {
2207 inserted: outcome.inserted,
2208 graph: outcome.graph,
2209 })
2210 }
2211 PatchDialect::JsonPatch => Err(PodError::Unsupported(
2212 "JSON Patch on absent resource".into(),
2213 )),
2214 }
2215}
2216
2217#[cfg(feature = "tokio-runtime")]
2222#[async_trait]
2223pub trait LdpContainerOps: Storage {
2224 async fn container_representation(&self, path: &str) -> Result<serde_json::Value, PodError> {
2225 let children = self.list(path).await?;
2226 Ok(render_container(path, &children))
2227 }
2228}
2229
2230#[cfg(feature = "tokio-runtime")]
2231impl<T: Storage + ?Sized> LdpContainerOps for T {}
2232
2233#[cfg(test)]
2238mod tests {
2239 use super::*;
2240
2241 #[test]
2242 fn ntriples_trailing_comment_is_valid_but_trailing_triples_are_not() {
2243 let g = Graph::parse_ntriples("<http://a> <http://b> \"c\" . # note\n").unwrap();
2244 assert_eq!(g.len(), 1);
2245 assert!(Graph::parse_ntriples(
2246 "<http://a> <http://b> <http://c> ; <http://d> <http://e> .\n"
2247 )
2248 .is_err());
2249 assert!(Graph::parse_ntriples(
2250 "<http://a> <http://b> <http://c> . <http://a> <http://b> <http://d> .\n"
2251 )
2252 .is_err());
2253 }
2254
2255 #[test]
2256 fn blank_node_subjects_survive_ntriples_and_jsonld_round_trip() {
2257 let mut g = Graph::new();
2263 g.insert(Triple::new(
2264 Term::blank("b0"),
2265 Term::iri("http://xmlns.com/foaf/0.1/name"),
2266 Term::literal("Alice"),
2267 ));
2268 g.insert(Triple::new(
2269 Term::iri("http://ex.org/doc"),
2270 Term::iri("http://ex.org/author"),
2271 Term::blank("b0"),
2272 ));
2273
2274 let nt = g.to_ntriples();
2275 let reparsed = Graph::parse_ntriples(&nt).expect("re-parse own N-Triples");
2276 assert_eq!(
2277 reparsed.triples, g.triples,
2278 "blank-node subject must survive an N-Triples round trip"
2279 );
2280
2281 let jsonld = g.to_jsonld();
2282 let ids: Vec<&str> = jsonld
2283 .as_array()
2284 .expect("expanded form is an array")
2285 .iter()
2286 .map(|n| n["@id"].as_str().expect("@id string"))
2287 .collect();
2288 assert!(
2289 ids.contains(&"_:b0"),
2290 "blank subject must be `_:`-prefixed in JSON-LD @id, got {ids:?}"
2291 );
2292 let obj_ref = jsonld
2293 .as_array()
2294 .unwrap()
2295 .iter()
2296 .find(|n| n["@id"] == "http://ex.org/doc")
2297 .and_then(|n| n["http://ex.org/author"][0]["@id"].as_str())
2298 .expect("object reference present");
2299 assert_eq!(obj_ref, "_:b0", "blank object reference keeps the prefix");
2300 }
2301
2302 #[test]
2303 fn is_container_detects_trailing_slash() {
2304 assert!(is_container("/"));
2305 assert!(is_container("/media/"));
2306 assert!(!is_container("/file.txt"));
2307 }
2308
2309 #[test]
2310 fn link_headers_include_acl_and_describedby() {
2311 let hdrs = link_headers("/profile/card");
2312 assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2313 assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2314 assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2315 assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2316 assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2317 }
2318
2319 #[test]
2320 fn link_headers_root_exposes_pim_storage() {
2321 let hdrs = link_headers("/");
2322 let joined = hdrs.join(",");
2323 assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2324 }
2325
2326 #[test]
2327 fn link_headers_skip_describedby_on_meta() {
2328 let hdrs = link_headers("/foo.meta");
2329 assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2330 }
2331
2332 #[test]
2333 fn link_headers_skip_acl_on_acl() {
2334 let hdrs = link_headers("/profile/card.acl");
2335 assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2336 }
2337
2338 #[test]
2339 fn prefer_minimal_container_parsed() {
2340 let p = PreferHeader::parse(
2341 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2342 );
2343 assert!(p.include_minimal);
2344 assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2345 }
2346
2347 #[test]
2348 fn prefer_contained_iris_parsed() {
2349 let p = PreferHeader::parse(
2350 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2351 );
2352 assert!(p.include_contained_iris);
2353 assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2354 }
2355
2356 #[test]
2357 fn negotiate_prefers_explicit_turtle() {
2358 assert_eq!(
2359 negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2360 RdfFormat::Turtle
2361 );
2362 }
2363
2364 #[test]
2365 fn negotiate_falls_back_to_turtle() {
2366 assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2367 assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2368 }
2369
2370 #[test]
2371 fn negotiate_picks_jsonld_when_highest() {
2372 assert_eq!(
2373 negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2374 RdfFormat::JsonLd
2375 );
2376 }
2377
2378 #[test]
2379 fn ntriples_roundtrip() {
2380 let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2381 let g = Graph::parse_ntriples(nt).unwrap();
2382 assert_eq!(g.len(), 1);
2383 let out = g.to_ntriples();
2384 assert!(out.contains("<http://a/s>"));
2385 }
2386
2387 #[test]
2388 fn server_managed_triples_include_ldp_contains() {
2389 let now = chrono::Utc::now();
2390 let members = vec!["a.txt".to_string(), "sub/".to_string()];
2391 let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2392 let nt = g.to_ntriples();
2393 assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2394 assert!(nt.contains("http://x/y/a.txt"));
2395 assert!(nt.contains("http://x/y/sub/"));
2396 }
2397
2398 #[test]
2399 fn find_illegal_server_managed_flags_ldp_contains() {
2400 let mut g = Graph::new();
2401 g.insert(Triple::new(
2402 Term::iri("http://r/"),
2403 Term::iri(iri::LDP_CONTAINS),
2404 Term::iri("http://r/x"),
2405 ));
2406 let illegal = find_illegal_server_managed(&g);
2407 assert_eq!(illegal.len(), 1);
2408 }
2409
2410 #[test]
2411 fn render_container_minimal_omits_contains() {
2412 let prefer = PreferHeader {
2413 representation: ContainerRepresentation::MinimalContainer,
2414 include_minimal: true,
2415 include_contained_iris: false,
2416 omit_membership: true,
2417 };
2418 let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2419 assert!(v.get("ldp:contains").is_none());
2420 }
2421
2422 #[test]
2423 fn render_container_turtle_emits_types() {
2424 let v = render_container_turtle("/x/", &[], PreferHeader::default());
2425 assert!(v.contains("ldp:BasicContainer"));
2426 }
2427
2428 #[test]
2429 fn n3_patch_insert_and_delete() {
2430 let mut g = Graph::new();
2431 g.insert(Triple::new(
2432 Term::iri("http://s/a"),
2433 Term::iri("http://p/keep"),
2434 Term::literal("v"),
2435 ));
2436 g.insert(Triple::new(
2437 Term::iri("http://s/a"),
2438 Term::iri("http://p/drop"),
2439 Term::literal("old"),
2440 ));
2441
2442 let patch = r#"
2443 @prefix solid: <http://www.w3.org/ns/solid/terms#> .
2444 _:r a solid:InsertDeletePatch ;
2445 solid:deletes {
2446 <http://s/a> <http://p/drop> "old" .
2447 } ;
2448 solid:inserts {
2449 <http://s/a> <http://p/new> "shiny" .
2450 } .
2451 "#;
2452 let outcome = apply_n3_patch(g, patch).unwrap();
2453 assert_eq!(outcome.inserted, 1);
2454 assert_eq!(outcome.deleted, 1);
2455 assert!(outcome.graph.contains(&Triple::new(
2456 Term::iri("http://s/a"),
2457 Term::iri("http://p/new"),
2458 Term::literal("shiny"),
2459 )));
2460 assert!(!outcome.graph.contains(&Triple::new(
2461 Term::iri("http://s/a"),
2462 Term::iri("http://p/drop"),
2463 Term::literal("old"),
2464 )));
2465 }
2466
2467 #[test]
2468 fn n3_patch_where_failure_is_a_conflict() {
2469 let g = Graph::new();
2473 let patch = r#"
2474 @prefix solid: <http://www.w3.org/ns/solid/terms#> .
2475 _:r solid:where { <http://s/a> <http://p/need> "x" . } ;
2476 solid:inserts { <http://s/a> <http://p/added> "y" . } .
2477 "#;
2478 let err = apply_n3_patch(g, patch).err().unwrap();
2479 assert!(matches!(err, PodError::Conflict(_)));
2480 }
2481
2482 #[test]
2483 fn sparql_insert_data() {
2484 let g = Graph::new();
2485 let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2486 let outcome = apply_sparql_patch(g, update).unwrap();
2487 assert_eq!(outcome.inserted, 1);
2488 assert_eq!(outcome.graph.len(), 1);
2489 }
2490
2491 #[test]
2492 fn sparql_delete_data() {
2493 let mut g = Graph::new();
2494 g.insert(Triple::new(
2495 Term::iri("http://s"),
2496 Term::iri("http://p"),
2497 Term::literal("v"),
2498 ));
2499 let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2500 let outcome = apply_sparql_patch(g, update).unwrap();
2501 assert_eq!(outcome.deleted, 1);
2502 assert!(outcome.graph.is_empty());
2503 }
2504
2505 #[test]
2506 fn patch_dialect_detection() {
2507 assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2508 assert_eq!(
2509 patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2510 Some(PatchDialect::SparqlUpdate)
2511 );
2512 assert_eq!(patch_dialect_from_mime("text/plain"), None);
2513 }
2514
2515 #[test]
2516 fn slug_uses_valid_value() {
2517 let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2518 assert_eq!(out, "/photos/cat.jpg");
2519 }
2520
2521 #[test]
2522 fn slug_rejects_slashes() {
2523 let err = resolve_slug("/photos/", Some("a/b"));
2524 assert!(matches!(err, Err(PodError::BadRequest(_))));
2525 }
2526
2527 #[test]
2528 fn render_container_shapes_jsonld() {
2529 let members = vec!["one.txt".to_string(), "sub/".to_string()];
2530 let v = render_container("/docs/", &members);
2531 assert!(v.get("@context").is_some());
2532 assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2533 }
2534
2535 #[test]
2536 fn preconditions_if_match_star_passes_when_resource_exists() {
2537 let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2538 assert_eq!(got, ConditionalOutcome::Proceed);
2539 }
2540
2541 #[test]
2542 fn preconditions_if_match_star_fails_when_resource_absent() {
2543 let got = evaluate_preconditions("PUT", None, Some("*"), None);
2544 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2545 }
2546
2547 #[test]
2548 fn if_match_uses_strong_comparison() {
2549 assert_eq!(
2551 evaluate_preconditions("PUT", Some("etag123"), Some("W/\"etag123\""), None),
2552 ConditionalOutcome::PreconditionFailed
2553 );
2554 assert_eq!(
2555 evaluate_preconditions(
2556 "PATCH",
2557 Some("etag123"),
2558 Some("W/\"etag123\", \"etag123\""),
2559 None
2560 ),
2561 ConditionalOutcome::Proceed,
2562 "a strong tag in the list still matches"
2563 );
2564 assert_eq!(
2567 evaluate_preconditions("PUT", Some("etag123"), Some("\"other\", *"), None),
2568 ConditionalOutcome::PreconditionFailed
2569 );
2570 assert_eq!(
2571 evaluate_preconditions("PUT", Some("etag123"), Some("\"*\""), None),
2572 ConditionalOutcome::PreconditionFailed,
2573 "a quoted \"*\" is an opaque tag, not a wildcard"
2574 );
2575 assert_eq!(
2577 evaluate_preconditions("GET", Some("etag123"), None, Some("W/\"etag123\"")),
2578 ConditionalOutcome::NotModified
2579 );
2580 }
2581
2582 #[test]
2583 fn preconditions_if_match_mismatch_412() {
2584 let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2585 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2586 }
2587
2588 #[test]
2589 fn preconditions_if_none_match_match_on_get_returns_304() {
2590 let got = evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2591 assert_eq!(got, ConditionalOutcome::NotModified);
2592 }
2593
2594 #[test]
2595 fn preconditions_if_none_match_on_put_when_exists_fails() {
2596 let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2597 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2598 }
2599
2600 #[test]
2601 fn preconditions_if_none_match_on_put_when_absent_passes() {
2602 let got = evaluate_preconditions("PUT", None, None, Some("*"));
2603 assert_eq!(got, ConditionalOutcome::Proceed);
2604 }
2605
2606 #[test]
2607 fn range_parses_start_end() {
2608 let r = parse_range_header(Some("bytes=0-99"), 1000)
2609 .unwrap()
2610 .unwrap();
2611 assert_eq!(r.start, 0);
2612 assert_eq!(r.end, 99);
2613 assert_eq!(r.length(), 100);
2614 }
2615
2616 #[test]
2617 fn range_parses_open_ended() {
2618 let r = parse_range_header(Some("bytes=500-"), 1000)
2619 .unwrap()
2620 .unwrap();
2621 assert_eq!(r.start, 500);
2622 assert_eq!(r.end, 999);
2623 }
2624
2625 #[test]
2626 fn range_parses_suffix() {
2627 let r = parse_range_header(Some("bytes=-200"), 1000)
2628 .unwrap()
2629 .unwrap();
2630 assert_eq!(r.start, 800);
2631 assert_eq!(r.end, 999);
2632 }
2633
2634 #[test]
2635 fn range_rejects_unsatisfiable() {
2636 let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2637 assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2638 }
2639
2640 #[test]
2641 fn range_content_range_header_value() {
2642 let r = parse_range_header(Some("bytes=0-99"), 1000)
2643 .unwrap()
2644 .unwrap();
2645 assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2646 }
2647
2648 #[test]
2649 fn options_container_includes_post_and_accept_post() {
2650 let o = options_for("/photos/");
2651 assert!(o.allow.contains(&"POST"));
2652 assert!(o.accept_post.is_some());
2653 assert_eq!(o.accept_ranges, "none");
2657 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 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}