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;
28#[cfg(feature = "tokio-runtime")]
32use crate::storage::Storage;
33
34pub mod iri {
36 pub const LDP_RESOURCE: &str = "http://www.w3.org/ns/ldp#Resource";
38 pub const LDP_CONTAINER: &str = "http://www.w3.org/ns/ldp#Container";
40 pub const LDP_BASIC_CONTAINER: &str = "http://www.w3.org/ns/ldp#BasicContainer";
42 pub const LDP_NS: &str = "http://www.w3.org/ns/ldp#";
44 pub const LDP_CONTAINS: &str = "http://www.w3.org/ns/ldp#contains";
46 pub const LDP_PREFER_MINIMAL_CONTAINER: &str =
48 "http://www.w3.org/ns/ldp#PreferMinimalContainer";
49 pub const LDP_PREFER_CONTAINED_IRIS: &str = "http://www.w3.org/ns/ldp#PreferContainedIRIs";
51 pub const LDP_PREFER_MEMBERSHIP: &str = "http://www.w3.org/ns/ldp#PreferMembership";
53
54 pub const DCTERMS_NS: &str = "http://purl.org/dc/terms/";
56 pub const DCTERMS_MODIFIED: &str = "http://purl.org/dc/terms/modified";
58
59 pub const STAT_NS: &str = "http://www.w3.org/ns/posix/stat#";
61 pub const STAT_SIZE: &str = "http://www.w3.org/ns/posix/stat#size";
63 pub const STAT_MTIME: &str = "http://www.w3.org/ns/posix/stat#mtime";
65
66 pub const XSD_DATETIME: &str = "http://www.w3.org/2001/XMLSchema#dateTime";
68 pub const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
70 pub const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
72
73 pub const PIM_STORAGE: &str = "http://www.w3.org/ns/pim/space#Storage";
75 pub const PIM_STORAGE_REL: &str = "http://www.w3.org/ns/pim/space#storage";
77
78 pub const ACL_NS: &str = "http://www.w3.org/ns/auth/acl#";
80}
81
82pub const ACCEPT_POST: &str = "text/turtle, application/ld+json, application/n-triples";
86
87pub const ACCEPT_POST_CONNEG: &str =
93 "text/turtle, application/ld+json, application/n-triples, text/n3";
94
95pub 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
110pub fn is_container(path: &str) -> bool {
112 path == "/" || path.ends_with('/')
113}
114
115pub fn is_acl_path(path: &str) -> bool {
117 path.ends_with(".acl")
118}
119
120pub fn is_meta_path(path: &str) -> bool {
122 path.ends_with(".meta")
123}
124
125pub 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
134pub 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
165pub const MAX_SLUG_BYTES: usize = 255;
168
169pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
217pub enum ContainerRepresentation {
218 #[default]
220 Full,
221 MinimalContainer,
223 ContainedIRIsOnly,
225}
226
227#[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 pub fn parse(value: &str) -> Self {
240 let mut out = PreferHeader::default();
241 for pref in value.split(',') {
243 let pref = pref.trim();
244 if pref.is_empty() {
245 continue;
246 }
247 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#[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
329pub 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
376pub fn infer_dotfile_content_type(path: &str) -> Option<&'static str> {
396 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 if basename.ends_with(".acl") || basename.ends_with(".meta") {
408 Some("application/ld+json")
409 } else {
410 None
411 }
412}
413
414pub 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 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 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 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 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#[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#[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 pub fn extend(&mut self, other: &Graph) {
705 for t in &other.triples {
706 self.triples.insert(t.clone());
707 }
708 }
709
710 pub fn subtract(&mut self, other: &Graph) {
712 for t in &other.triples {
713 self.triples.remove(t);
714 }
715 }
716
717 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 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 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 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
919pub 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
968pub const SERVER_MANAGED_PREDICATES: &[&str] = &[
971 iri::DCTERMS_MODIFIED,
972 iri::STAT_SIZE,
973 iri::STAT_MTIME,
974 iri::LDP_CONTAINS,
975];
976
977pub 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#[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
1005pub 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
1066pub fn render_container(container_path: &str, members: &[String]) -> serde_json::Value {
1068 render_container_jsonld(container_path, members, PreferHeader::default())
1069}
1070
1071pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
1126pub struct PatchOutcome {
1127 pub graph: Graph,
1129 pub inserted: usize,
1131 pub deleted: usize,
1133}
1134
1135pub 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 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 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 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 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 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
1266pub const SPARQL_UPDATE_MAX_BYTES: usize = 1_048_576; pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1523pub enum ConditionalOutcome {
1524 Proceed,
1526 PreconditionFailed,
1529 NotModified,
1532}
1533
1534pub 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 let s = s.strip_prefix("W/").unwrap_or(s);
1606 s.trim_matches('"').to_string()
1607 })
1608 .collect()
1609}
1610
1611#[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 pub fn content_range(&self, total: u64) -> String {
1629 format!("bytes {}-{}/{}", self.start, self.end, total)
1630 }
1631}
1632
1633pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1708pub enum RangeOutcome {
1709 Full,
1710 Partial(ByteRange),
1711 NotSatisfiable,
1712}
1713
1714pub 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
1766pub 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#[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
1797pub 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
1804pub 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 accept_ranges: if container { "none" } else { "bytes" },
1830 cache_control: CACHE_CONTROL_RDF,
1834 }
1835}
1836
1837pub 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 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
1875pub fn vary_header(conneg_enabled: bool) -> &'static str {
1880 if conneg_enabled {
1881 "Accept, Authorization, Origin"
1882 } else {
1883 "Authorization, Origin"
1884 }
1885}
1886
1887pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1896
1897pub 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
1923pub 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
1943pub const CACHE_CONTROL_PRIVATE: &str = "private, no-store";
1950
1951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1958pub enum ResponseAudience {
1959 Public,
1962 Private,
1965}
1966
1967pub 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
1991pub 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, 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, 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#[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#[derive(Debug)]
2231pub enum PatchCreateOutcome {
2232 Created { inserted: usize, graph: Graph },
2234 Applied {
2237 inserted: usize,
2238 deleted: usize,
2239 graph: Graph,
2240 },
2241}
2242
2243pub 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#[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#[cfg(test)]
2292mod tests {
2293 use super::*;
2294
2295 #[test]
2296 fn blank_node_subjects_survive_ntriples_and_jsonld_round_trip() {
2297 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 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}