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 fn is_container(path: &str) -> bool {
89 path == "/" || path.ends_with('/')
90}
91
92pub fn is_acl_path(path: &str) -> bool {
94 path.ends_with(".acl")
95}
96
97pub fn is_meta_path(path: &str) -> bool {
99 path.ends_with(".meta")
100}
101
102pub fn meta_sidecar_for(path: &str) -> String {
104 if is_meta_path(path) {
105 path.to_string()
106 } else {
107 format!("{path}.meta")
108 }
109}
110
111pub fn link_headers(path: &str) -> Vec<String> {
120 let mut out = Vec::new();
121 if is_container(path) {
122 out.push(format!("<{}>; rel=\"type\"", iri::LDP_BASIC_CONTAINER));
123 out.push(format!("<{}>; rel=\"type\"", iri::LDP_CONTAINER));
124 out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
125 } else {
126 out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
127 }
128 if !is_acl_path(path) {
129 let acl_target = format!("{path}.acl");
130 out.push(format!("<{acl_target}>; rel=\"acl\""));
131 }
132 if !is_meta_path(path) && !is_acl_path(path) {
133 let meta_target = meta_sidecar_for(path);
134 out.push(format!("<{meta_target}>; rel=\"describedby\""));
135 }
136 if path == "/" {
137 out.push(format!("</>; rel=\"{}\"", iri::PIM_STORAGE_REL));
138 }
139 out
140}
141
142pub const MAX_SLUG_BYTES: usize = 255;
145
146pub fn resolve_slug(container: &str, slug: Option<&str>) -> Result<String, PodError> {
156 let join = |name: &str| {
157 if container.ends_with('/') {
158 format!("{container}{name}")
159 } else {
160 format!("{container}/{name}")
161 }
162 };
163 match slug {
164 Some(s) if !s.is_empty() => {
165 if s.len() > MAX_SLUG_BYTES {
166 return Err(PodError::BadRequest(format!(
167 "slug exceeds {MAX_SLUG_BYTES} bytes"
168 )));
169 }
170 if s.contains('/') || s.contains("..") || s.contains('\0') {
171 return Err(PodError::BadRequest(format!("invalid slug: {s:?}")));
172 }
173 if !s
174 .chars()
175 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
176 {
177 return Err(PodError::BadRequest(format!(
178 "slug contains disallowed character: {s:?}"
179 )));
180 }
181 Ok(join(s))
182 }
183 _ => Ok(join(&uuid::Uuid::new_v4().to_string())),
184 }
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
193pub enum ContainerRepresentation {
194 #[default]
196 Full,
197 MinimalContainer,
199 ContainedIRIsOnly,
201}
202
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
206pub struct PreferHeader {
207 pub representation: ContainerRepresentation,
208 pub include_minimal: bool,
209 pub include_contained_iris: bool,
210 pub omit_membership: bool,
211}
212
213impl PreferHeader {
214 pub fn parse(value: &str) -> Self {
216 let mut out = PreferHeader::default();
217 for pref in value.split(',') {
219 let pref = pref.trim();
220 if pref.is_empty() {
221 continue;
222 }
223 let mut parts = pref.split(';').map(|s| s.trim());
225 let head = match parts.next() {
226 Some(h) => h,
227 None => continue,
228 };
229 if !head.eq_ignore_ascii_case("return=representation") {
230 continue;
231 }
232 for token in parts {
233 if let Some(val) = token
234 .strip_prefix("include=")
235 .or_else(|| token.strip_prefix("include ="))
236 {
237 let unq = val.trim().trim_matches('"');
238 for iri in unq.split_whitespace() {
239 if iri == iri::LDP_PREFER_MINIMAL_CONTAINER {
240 out.include_minimal = true;
241 out.representation = ContainerRepresentation::MinimalContainer;
242 } else if iri == iri::LDP_PREFER_CONTAINED_IRIS {
243 out.include_contained_iris = true;
244 out.representation = ContainerRepresentation::ContainedIRIsOnly;
245 }
246 }
247 } else if let Some(val) = token
248 .strip_prefix("omit=")
249 .or_else(|| token.strip_prefix("omit ="))
250 {
251 let unq = val.trim().trim_matches('"');
252 for iri in unq.split_whitespace() {
253 if iri == iri::LDP_PREFER_MEMBERSHIP {
254 out.omit_membership = true;
255 }
256 }
257 }
258 }
259 }
260 out
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum RdfFormat {
270 Turtle,
271 JsonLd,
272 NTriples,
273 RdfXml,
274}
275
276impl RdfFormat {
277 pub fn mime(&self) -> &'static str {
278 match self {
279 RdfFormat::Turtle => "text/turtle",
280 RdfFormat::JsonLd => "application/ld+json",
281 RdfFormat::NTriples => "application/n-triples",
282 RdfFormat::RdfXml => "application/rdf+xml",
283 }
284 }
285
286 pub fn from_mime(mime: &str) -> Option<Self> {
287 let mime = mime
288 .split(';')
289 .next()
290 .unwrap_or("")
291 .trim()
292 .to_ascii_lowercase();
293 match mime.as_str() {
294 "text/turtle" | "application/turtle" | "application/x-turtle" => {
295 Some(RdfFormat::Turtle)
296 }
297 "application/ld+json" | "application/json+ld" => Some(RdfFormat::JsonLd),
298 "application/n-triples" | "text/plain+ntriples" => Some(RdfFormat::NTriples),
299 "application/rdf+xml" => Some(RdfFormat::RdfXml),
300 _ => None,
301 }
302 }
303}
304
305pub fn negotiate_format(accept: Option<&str>) -> RdfFormat {
309 let accept = match accept {
310 Some(a) if !a.trim().is_empty() => a,
311 _ => return RdfFormat::Turtle,
312 };
313
314 let mut best: Option<(f32, RdfFormat)> = None;
315 for entry in accept.split(',') {
316 let entry = entry.trim();
317 if entry.is_empty() {
318 continue;
319 }
320 let mut parts = entry.split(';').map(|s| s.trim());
321 let mime = match parts.next() {
322 Some(m) => m.to_ascii_lowercase(),
323 None => continue,
324 };
325 let mut q: f32 = 1.0;
326 for token in parts {
327 if let Some(v) = token.strip_prefix("q=") {
328 if let Ok(parsed) = v.parse::<f32>() {
329 q = parsed;
330 }
331 }
332 }
333 let format = match mime.as_str() {
334 "text/turtle" | "application/turtle" => Some(RdfFormat::Turtle),
335 "application/ld+json" => Some(RdfFormat::JsonLd),
336 "application/n-triples" => Some(RdfFormat::NTriples),
337 "application/rdf+xml" => Some(RdfFormat::RdfXml),
338 "*/*" | "application/*" | "text/*" => Some(RdfFormat::Turtle),
339 _ => None,
340 };
341 if let Some(f) = format {
342 match best {
343 None => best = Some((q, f)),
344 Some((bq, _)) if q > bq => best = Some((q, f)),
345 _ => {}
346 }
347 }
348 }
349 best.map(|(_, f)| f).unwrap_or(RdfFormat::Turtle)
350}
351
352pub fn infer_dotfile_content_type(path: &str) -> Option<&'static str> {
372 let trimmed = path.trim_end_matches('/');
375 if trimmed.is_empty() {
376 return None;
377 }
378 let basename = trimmed.rsplit('/').next().filter(|s| !s.is_empty())?;
379
380 if basename.ends_with(".acl") || basename.ends_with(".meta") {
384 Some("application/ld+json")
385 } else {
386 None
387 }
388}
389
390#[cfg(test)]
391mod infer_dotfile_tests {
392 use super::infer_dotfile_content_type;
393
394 #[test]
395 fn infer_dotfile_content_type_acl_file_returns_jsonld() {
396 assert_eq!(
397 infer_dotfile_content_type("/.acl"),
398 Some("application/ld+json")
399 );
400 assert_eq!(
401 infer_dotfile_content_type("/pods/alice/foo.acl"),
402 Some("application/ld+json")
403 );
404 assert_eq!(
405 infer_dotfile_content_type(".acl"),
406 Some("application/ld+json")
407 );
408 }
409
410 #[test]
411 fn infer_dotfile_content_type_meta_file_returns_jsonld() {
412 assert_eq!(
413 infer_dotfile_content_type("/.meta"),
414 Some("application/ld+json")
415 );
416 assert_eq!(
417 infer_dotfile_content_type("/pods/alice/foo.meta"),
418 Some("application/ld+json")
419 );
420 }
421
422 #[test]
423 fn infer_dotfile_content_type_dotted_midname_returns_none() {
424 assert_eq!(infer_dotfile_content_type("/foo.acl.bak"), None);
427 assert_eq!(infer_dotfile_content_type("/foo.meta.bak"), None);
428 }
429
430 #[test]
431 fn infer_dotfile_content_type_substring_only_returns_none() {
432 assert_eq!(infer_dotfile_content_type("/not.aclfile"), None);
434 assert_eq!(infer_dotfile_content_type("/some.metainfo"), None);
435 assert_eq!(infer_dotfile_content_type("/plain.txt"), None);
436 }
437
438 #[test]
439 fn infer_dotfile_content_type_trailing_slash_stripped() {
440 assert_eq!(
442 infer_dotfile_content_type("/pods/alice/foo.acl/"),
443 Some("application/ld+json")
444 );
445 assert_eq!(infer_dotfile_content_type("/"), None);
446 assert_eq!(infer_dotfile_content_type(""), None);
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
455pub enum Term {
456 Iri(String),
457 BlankNode(String),
458 Literal {
459 value: String,
460 datatype: Option<String>,
461 language: Option<String>,
462 },
463}
464
465impl Term {
466 pub fn iri(i: impl Into<String>) -> Self {
467 Term::Iri(i.into())
468 }
469 pub fn blank(b: impl Into<String>) -> Self {
470 Term::BlankNode(b.into())
471 }
472 pub fn literal(v: impl Into<String>) -> Self {
473 Term::Literal {
474 value: v.into(),
475 datatype: None,
476 language: None,
477 }
478 }
479 pub fn typed_literal(v: impl Into<String>, dt: impl Into<String>) -> Self {
480 Term::Literal {
481 value: v.into(),
482 datatype: Some(dt.into()),
483 language: None,
484 }
485 }
486
487 fn write_ntriples(&self, out: &mut String) {
488 match self {
489 Term::Iri(i) => {
490 out.push('<');
491 out.push_str(i);
492 out.push('>');
493 }
494 Term::BlankNode(b) => {
495 out.push_str("_:");
496 out.push_str(b);
497 }
498 Term::Literal {
499 value,
500 datatype,
501 language,
502 } => {
503 out.push('"');
504 for c in value.chars() {
505 match c {
506 '\\' => out.push_str("\\\\"),
507 '"' => out.push_str("\\\""),
508 '\n' => out.push_str("\\n"),
509 '\r' => out.push_str("\\r"),
510 '\t' => out.push_str("\\t"),
511 _ => out.push(c),
512 }
513 }
514 out.push('"');
515 if let Some(lang) = language {
516 out.push('@');
517 out.push_str(lang);
518 } else if let Some(dt) = datatype {
519 out.push_str("^^<");
520 out.push_str(dt);
521 out.push('>');
522 }
523 }
524 }
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
529pub struct Triple {
530 pub subject: Term,
531 pub predicate: Term,
532 pub object: Term,
533}
534
535impl Triple {
536 pub fn new(subject: Term, predicate: Term, object: Term) -> Self {
537 Self {
538 subject,
539 predicate,
540 object,
541 }
542 }
543}
544
545#[derive(Debug, Clone, Default, PartialEq, Eq)]
547pub struct Graph {
548 triples: BTreeSet<Triple>,
549}
550
551impl Graph {
552 pub fn new() -> Self {
553 Self {
554 triples: BTreeSet::new(),
555 }
556 }
557
558 pub fn from_triples(triples: impl IntoIterator<Item = Triple>) -> Self {
559 let mut g = Self::new();
560 for t in triples {
561 g.insert(t);
562 }
563 g
564 }
565
566 pub fn insert(&mut self, triple: Triple) {
567 self.triples.insert(triple);
568 }
569
570 pub fn remove(&mut self, triple: &Triple) -> bool {
571 self.triples.remove(triple)
572 }
573
574 pub fn contains(&self, triple: &Triple) -> bool {
575 self.triples.contains(triple)
576 }
577
578 pub fn len(&self) -> usize {
579 self.triples.len()
580 }
581
582 pub fn is_empty(&self) -> bool {
583 self.triples.is_empty()
584 }
585
586 pub fn triples(&self) -> impl Iterator<Item = &Triple> {
587 self.triples.iter()
588 }
589
590 pub fn extend(&mut self, other: &Graph) {
592 for t in &other.triples {
593 self.triples.insert(t.clone());
594 }
595 }
596
597 pub fn subtract(&mut self, other: &Graph) {
599 for t in &other.triples {
600 self.triples.remove(t);
601 }
602 }
603
604 pub fn to_ntriples(&self) -> String {
606 let mut out = String::new();
607 for t in &self.triples {
608 t.subject.write_ntriples(&mut out);
609 out.push(' ');
610 t.predicate.write_ntriples(&mut out);
611 out.push(' ');
612 t.object.write_ntriples(&mut out);
613 out.push_str(" .\n");
614 }
615 out
616 }
617
618 pub fn parse_ntriples(input: &str) -> Result<Self, PodError> {
620 let mut g = Graph::new();
621 for (i, line) in input.lines().enumerate() {
622 let line = line.trim();
623 if line.is_empty() || line.starts_with('#') {
624 continue;
625 }
626 let t = parse_nt_line(line)
627 .map_err(|e| PodError::Unsupported(format!("N-Triples line {}: {e}", i + 1)))?;
628 g.insert(t);
629 }
630 Ok(g)
631 }
632}
633
634fn parse_nt_line(line: &str) -> Result<Triple, String> {
635 let line = line.trim_end_matches('.').trim();
636 let (subject, rest) = read_term(line)?;
637 let rest = rest.trim_start();
638 let (predicate, rest) = read_term(rest)?;
639 let rest = rest.trim_start();
640 let (object, _rest) = read_term(rest)?;
641 Ok(Triple::new(subject, predicate, object))
642}
643
644fn read_term(input: &str) -> Result<(Term, &str), String> {
645 let input = input.trim_start();
646 if let Some(rest) = input.strip_prefix('<') {
647 let end = rest
648 .find('>')
649 .ok_or_else(|| "unterminated IRI".to_string())?;
650 let iri = &rest[..end];
651 Ok((Term::Iri(iri.to_string()), &rest[end + 1..]))
652 } else if let Some(rest) = input.strip_prefix("_:") {
653 let end = rest
654 .find(|c: char| c.is_whitespace() || c == '.')
655 .unwrap_or(rest.len());
656 Ok((Term::BlankNode(rest[..end].to_string()), &rest[end..]))
657 } else if input.starts_with('"') {
658 read_literal(input)
659 } else {
660 Err(format!(
661 "unexpected char: {}",
662 input.chars().next().unwrap_or('?')
663 ))
664 }
665}
666
667fn read_literal(input: &str) -> Result<(Term, &str), String> {
668 let bytes = input.as_bytes();
669 if bytes.first() != Some(&b'"') {
670 return Err("expected '\"'".to_string());
671 }
672 let mut i = 1usize;
673 let mut value = String::new();
674 while i < bytes.len() {
675 match bytes[i] {
676 b'\\' if i + 1 < bytes.len() => {
677 match bytes[i + 1] {
678 b'n' => value.push('\n'),
679 b't' => value.push('\t'),
680 b'r' => value.push('\r'),
681 b'"' => value.push('"'),
682 b'\\' => value.push('\\'),
683 other => value.push(other as char),
684 }
685 i += 2;
686 }
687 b'"' => {
688 i += 1;
689 break;
690 }
691 other => {
692 value.push(other as char);
693 i += 1;
694 }
695 }
696 }
697 let rest = &input[i..];
698 let (datatype, language, rest) = if let Some(r) = rest.strip_prefix("^^<") {
699 let end = r
700 .find('>')
701 .ok_or_else(|| "unterminated datatype IRI".to_string())?;
702 (Some(r[..end].to_string()), None, &r[end + 1..])
703 } else if let Some(r) = rest.strip_prefix('@') {
704 let end = r
705 .find(|c: char| c.is_whitespace() || c == '.')
706 .unwrap_or(r.len());
707 (None, Some(r[..end].to_string()), &r[end..])
708 } else {
709 (None, None, rest)
710 };
711 Ok((
712 Term::Literal {
713 value,
714 datatype,
715 language,
716 },
717 rest,
718 ))
719}
720
721pub fn server_managed_triples(
728 resource_iri: &str,
729 modified: chrono::DateTime<chrono::Utc>,
730 size: u64,
731 is_container_flag: bool,
732 contained: &[String],
733) -> Graph {
734 let mut g = Graph::new();
735 let subject = Term::iri(resource_iri);
736
737 g.insert(Triple::new(
738 subject.clone(),
739 Term::iri(iri::DCTERMS_MODIFIED),
740 Term::typed_literal(modified.to_rfc3339(), iri::XSD_DATETIME),
741 ));
742 g.insert(Triple::new(
743 subject.clone(),
744 Term::iri(iri::STAT_SIZE),
745 Term::typed_literal(size.to_string(), iri::XSD_INTEGER),
746 ));
747 g.insert(Triple::new(
748 subject.clone(),
749 Term::iri(iri::STAT_MTIME),
750 Term::typed_literal(modified.timestamp().to_string(), iri::XSD_INTEGER),
751 ));
752
753 if is_container_flag {
754 for child in contained {
755 let base = if resource_iri.ends_with('/') {
756 resource_iri.to_string()
757 } else {
758 format!("{resource_iri}/")
759 };
760 g.insert(Triple::new(
761 subject.clone(),
762 Term::iri(iri::LDP_CONTAINS),
763 Term::iri(format!("{base}{child}")),
764 ));
765 }
766 }
767 g
768}
769
770pub const SERVER_MANAGED_PREDICATES: &[&str] = &[
773 iri::DCTERMS_MODIFIED,
774 iri::STAT_SIZE,
775 iri::STAT_MTIME,
776 iri::LDP_CONTAINS,
777];
778
779pub fn find_illegal_server_managed(graph: &Graph) -> Vec<Triple> {
782 graph
783 .triples()
784 .filter(|t| {
785 if let Term::Iri(p) = &t.predicate {
786 SERVER_MANAGED_PREDICATES.iter().any(|sm| sm == p)
787 } else {
788 false
789 }
790 })
791 .cloned()
792 .collect()
793}
794
795#[derive(Debug, Serialize)]
800pub struct ContainerMember {
801 #[serde(rename = "@id")]
802 pub id: String,
803 #[serde(rename = "@type")]
804 pub types: Vec<&'static str>,
805}
806
807pub fn render_container_jsonld(
809 container_path: &str,
810 members: &[String],
811 prefer: PreferHeader,
812) -> serde_json::Value {
813 let base = if container_path.ends_with('/') {
814 container_path.to_string()
815 } else {
816 format!("{container_path}/")
817 };
818
819 match prefer.representation {
820 ContainerRepresentation::ContainedIRIsOnly => serde_json::json!({
821 "@id": container_path,
822 "ldp:contains": members
823 .iter()
824 .map(|m| serde_json::json!({"@id": format!("{base}{m}")}))
825 .collect::<Vec<_>>(),
826 }),
827 ContainerRepresentation::MinimalContainer => serde_json::json!({
828 "@context": {
829 "ldp": iri::LDP_NS,
830 "dcterms": iri::DCTERMS_NS,
831 },
832 "@id": container_path,
833 "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
834 }),
835 ContainerRepresentation::Full => {
836 let contains: Vec<ContainerMember> = members
837 .iter()
838 .map(|m| {
839 let is_dir = m.ends_with('/');
840 ContainerMember {
841 id: format!("{base}{m}"),
842 types: if is_dir {
843 vec![
844 iri::LDP_BASIC_CONTAINER,
845 iri::LDP_CONTAINER,
846 iri::LDP_RESOURCE,
847 ]
848 } else {
849 vec![iri::LDP_RESOURCE]
850 },
851 }
852 })
853 .collect();
854 serde_json::json!({
855 "@context": {
856 "ldp": iri::LDP_NS,
857 "dcterms": iri::DCTERMS_NS,
858 "contains": { "@id": "ldp:contains", "@type": "@id" },
859 },
860 "@id": container_path,
861 "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
862 "ldp:contains": contains,
863 })
864 }
865 }
866}
867
868pub fn render_container(container_path: &str, members: &[String]) -> serde_json::Value {
870 render_container_jsonld(container_path, members, PreferHeader::default())
871}
872
873pub fn render_container_turtle(
875 container_path: &str,
876 members: &[String],
877 prefer: PreferHeader,
878) -> String {
879 let base = if container_path.ends_with('/') {
880 container_path.to_string()
881 } else {
882 format!("{container_path}/")
883 };
884 let mut out = String::new();
885 let _ = writeln!(out, "@prefix ldp: <{}> .", iri::LDP_NS);
886 let _ = writeln!(out, "@prefix dcterms: <{}> .", iri::DCTERMS_NS);
887 let _ = writeln!(out);
888 match prefer.representation {
889 ContainerRepresentation::ContainedIRIsOnly => {
890 let _ = writeln!(out, "<{container_path}> ldp:contains");
891 let list: Vec<String> = members.iter().map(|m| format!(" <{base}{m}>")).collect();
892 let _ = writeln!(out, "{} .", list.join(",\n"));
893 }
894 ContainerRepresentation::MinimalContainer => {
895 let _ = writeln!(
896 out,
897 "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ."
898 );
899 }
900 ContainerRepresentation::Full => {
901 let _ = writeln!(
902 out,
903 "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ;"
904 );
905 if members.is_empty() {
906 let fixed = out.trim_end().trim_end_matches(';').to_string();
908 out = fixed;
909 out.push_str(" .\n");
910 } else {
911 let list: Vec<String> = members
912 .iter()
913 .map(|m| format!(" ldp:contains <{base}{m}>"))
914 .collect();
915 let _ = writeln!(out, "{} .", list.join(" ;\n"));
916 }
917 }
918 }
919 out
920}
921
922#[derive(Debug, Clone, PartialEq, Eq)]
928pub struct PatchOutcome {
929 pub graph: Graph,
931 pub inserted: usize,
933 pub deleted: usize,
935}
936
937pub fn apply_n3_patch(target: Graph, patch: &str) -> Result<PatchOutcome, PodError> {
952 let inserts = extract_block(patch, &["insert", "inserts", "solid:inserts"]).unwrap_or_default();
953 let deletes = extract_block(patch, &["delete", "deletes", "solid:deletes"]).unwrap_or_default();
954 let where_clause = extract_block(patch, &["where", "solid:where"]);
955
956 let insert_graph = if !inserts.is_empty() {
957 Graph::parse_ntriples(&strip_braces(&inserts))?
958 } else {
959 Graph::new()
960 };
961 let delete_graph = if !deletes.is_empty() {
962 Graph::parse_ntriples(&strip_braces(&deletes))?
963 } else {
964 Graph::new()
965 };
966
967 if let Some(wc) = where_clause {
973 if !wc.trim().is_empty() {
974 let where_graph = Graph::parse_ntriples(&strip_braces(&wc))?;
975 for t in where_graph.triples() {
976 if !target.contains(t) {
977 return Err(PodError::PreconditionFailed(format!(
978 "WHERE clause triple missing: {t:?}"
979 )));
980 }
981 }
982 }
983 }
984
985 let mut graph = target;
986 let inserted_count = insert_graph.len();
987 let deleted_count = delete_graph.triples().filter(|t| graph.contains(t)).count();
988 graph.subtract(&delete_graph);
989 graph.extend(&insert_graph);
990
991 Ok(PatchOutcome {
992 graph,
993 inserted: inserted_count,
994 deleted: deleted_count,
995 })
996}
997
998fn extract_block(source: &str, keywords: &[&str]) -> Option<String> {
999 let lower = source.to_ascii_lowercase();
1006 let bytes = lower.as_bytes();
1007 for kw in keywords {
1008 let needle = kw.to_ascii_lowercase();
1009 let mut search_from = 0usize;
1010 while let Some(pos) = lower[search_from..].find(&needle) {
1011 let abs = search_from + pos;
1012 let after_kw = abs + needle.len();
1013 search_from = abs + needle.len();
1014
1015 let left_ok = if abs == 0 {
1020 true
1021 } else {
1022 let prev = bytes[abs - 1];
1023 !(prev.is_ascii_alphanumeric() || prev == b'_')
1024 };
1025 if !left_ok {
1026 continue;
1027 }
1028
1029 let tail = &source[after_kw..];
1031 let trimmed = tail.trim_start();
1032 if !trimmed.starts_with('{') {
1033 continue;
1034 }
1035 let open = after_kw + (tail.len() - trimmed.len());
1036
1037 let mut depth = 0i32;
1039 let mut end = None;
1040 for (i, c) in source[open..].char_indices() {
1041 match c {
1042 '{' => depth += 1,
1043 '}' => {
1044 depth -= 1;
1045 if depth == 0 {
1046 end = Some(open + i + 1);
1047 break;
1048 }
1049 }
1050 _ => {}
1051 }
1052 }
1053 if let Some(e) = end {
1054 return Some(source[open..e].to_string());
1055 }
1056 }
1057 }
1058 None
1059}
1060
1061fn strip_braces(block: &str) -> String {
1062 let t = block.trim();
1063 let t = t.strip_prefix('{').unwrap_or(t);
1064 let t = t.strip_suffix('}').unwrap_or(t);
1065 t.trim().to_string()
1066}
1067
1068pub const SPARQL_UPDATE_MAX_BYTES: usize = 1_048_576; pub fn apply_sparql_patch(target: Graph, update: &str) -> Result<PatchOutcome, PodError> {
1079 if update.len() > SPARQL_UPDATE_MAX_BYTES {
1080 return Err(PodError::BadRequest(format!(
1081 "SPARQL-Update body exceeds {} byte limit ({} bytes)",
1082 SPARQL_UPDATE_MAX_BYTES,
1083 update.len(),
1084 )));
1085 }
1086
1087 use spargebra::term::{
1088 GraphName, GraphNamePattern, GroundQuad, GroundQuadPattern, GroundSubject, GroundTerm,
1089 GroundTermPattern, NamedNodePattern, Quad, Subject, Term as SpTerm,
1090 };
1091 use spargebra::{GraphUpdateOperation, Update};
1092
1093 let parsed = Update::parse(update, None)
1094 .map_err(|e| PodError::Unsupported(format!("SPARQL parse error: {e}")))?;
1095
1096 fn build_literal(value: String, datatype: Option<String>, language: Option<String>) -> Term {
1103 let datatype = datatype.filter(|d| d != iri::XSD_STRING);
1104 Term::Literal {
1105 value,
1106 datatype,
1107 language,
1108 }
1109 }
1110
1111 fn map_subject(s: &Subject) -> Option<Term> {
1112 match s {
1113 Subject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1114 Subject::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1115 #[allow(unreachable_patterns)]
1116 _ => None,
1117 }
1118 }
1119 fn map_term(t: &SpTerm) -> Option<Term> {
1120 match t {
1121 SpTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1122 SpTerm::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1123 SpTerm::Literal(lit) => {
1124 let value = lit.value().to_string();
1125 if let Some(lang) = lit.language() {
1126 Some(build_literal(value, None, Some(lang.to_string())))
1127 } else {
1128 Some(build_literal(
1129 value,
1130 Some(lit.datatype().as_str().to_string()),
1131 None,
1132 ))
1133 }
1134 }
1135 #[allow(unreachable_patterns)]
1136 _ => None,
1137 }
1138 }
1139 fn map_ground_subject(s: &GroundSubject) -> Option<Term> {
1140 match s {
1141 GroundSubject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1142 #[allow(unreachable_patterns)]
1143 _ => None,
1144 }
1145 }
1146 fn map_ground_term(t: &GroundTerm) -> Option<Term> {
1147 match t {
1148 GroundTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1149 GroundTerm::Literal(lit) => {
1150 let value = lit.value().to_string();
1151 if let Some(lang) = lit.language() {
1152 Some(build_literal(value, None, Some(lang.to_string())))
1153 } else {
1154 Some(build_literal(
1155 value,
1156 Some(lit.datatype().as_str().to_string()),
1157 None,
1158 ))
1159 }
1160 }
1161 #[allow(unreachable_patterns)]
1162 _ => None,
1163 }
1164 }
1165 fn map_ground_term_pattern(t: &GroundTermPattern) -> Option<Term> {
1166 match t {
1167 GroundTermPattern::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1168 GroundTermPattern::Literal(lit) => {
1169 let value = lit.value().to_string();
1170 if let Some(lang) = lit.language() {
1171 Some(build_literal(value, None, Some(lang.to_string())))
1172 } else {
1173 Some(build_literal(
1174 value,
1175 Some(lit.datatype().as_str().to_string()),
1176 None,
1177 ))
1178 }
1179 }
1180 _ => None,
1181 }
1182 }
1183
1184 fn quad_to_triple(q: &Quad) -> Option<Triple> {
1185 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1186 return None;
1187 }
1188 Some(Triple::new(
1189 map_subject(&q.subject)?,
1190 Term::Iri(q.predicate.as_str().to_string()),
1191 map_term(&q.object)?,
1192 ))
1193 }
1194 fn ground_quad_to_triple(q: &GroundQuad) -> Option<Triple> {
1195 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1196 return None;
1197 }
1198 Some(Triple::new(
1199 map_ground_subject(&q.subject)?,
1200 Term::Iri(q.predicate.as_str().to_string()),
1201 map_ground_term(&q.object)?,
1202 ))
1203 }
1204 fn ground_quad_pattern_to_triple(q: &GroundQuadPattern) -> Option<Triple> {
1205 if !matches!(q.graph_name, GraphNamePattern::DefaultGraph) {
1206 return None;
1207 }
1208 let predicate = match &q.predicate {
1209 NamedNodePattern::NamedNode(n) => Term::Iri(n.as_str().to_string()),
1210 NamedNodePattern::Variable(_) => return None,
1211 };
1212 Some(Triple::new(
1213 map_ground_term_pattern(&q.subject)?,
1214 predicate,
1215 map_ground_term_pattern(&q.object)?,
1216 ))
1217 }
1218
1219 let mut graph = target;
1220 let mut inserted = 0usize;
1221 let mut deleted = 0usize;
1222
1223 for op in &parsed.operations {
1224 match op {
1225 GraphUpdateOperation::InsertData { data } => {
1226 for q in data {
1227 if let Some(tr) = quad_to_triple(q) {
1228 if !graph.contains(&tr) {
1229 graph.insert(tr);
1230 inserted += 1;
1231 }
1232 }
1233 }
1234 }
1235 GraphUpdateOperation::DeleteData { data } => {
1236 for q in data {
1237 if let Some(tr) = ground_quad_to_triple(q) {
1238 if graph.remove(&tr) {
1239 deleted += 1;
1240 }
1241 }
1242 }
1243 }
1244 GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
1245 for q in delete {
1246 if let Some(tr) = ground_quad_pattern_to_triple(q) {
1247 if graph.remove(&tr) {
1248 deleted += 1;
1249 }
1250 }
1251 }
1252 for q in insert {
1253 let gqp = match convert_quad_pattern_to_ground(q) {
1258 Some(g) => g,
1259 None => continue,
1260 };
1261 if let Some(tr) = ground_quad_pattern_to_triple(&gqp) {
1262 if !graph.contains(&tr) {
1263 graph.insert(tr);
1264 inserted += 1;
1265 }
1266 }
1267 }
1268 }
1269 _ => {
1270 return Err(PodError::Unsupported(format!(
1271 "unsupported SPARQL operation: {op:?}"
1272 )));
1273 }
1274 }
1275 }
1276
1277 Ok(PatchOutcome {
1278 graph,
1279 inserted,
1280 deleted,
1281 })
1282}
1283
1284fn convert_quad_pattern_to_ground(
1285 q: &spargebra::term::QuadPattern,
1286) -> Option<spargebra::term::GroundQuadPattern> {
1287 use spargebra::term::{
1288 GraphNamePattern, GroundQuadPattern, GroundTermPattern, NamedNodePattern, TermPattern,
1289 };
1290
1291 let subject = match &q.subject {
1292 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1293 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1294 _ => return None,
1295 };
1296 let predicate = match &q.predicate {
1297 NamedNodePattern::NamedNode(n) => NamedNodePattern::NamedNode(n.clone()),
1298 NamedNodePattern::Variable(_) => return None,
1299 };
1300 let object = match &q.object {
1301 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1302 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1303 _ => return None,
1304 };
1305 let graph_name = match &q.graph_name {
1306 GraphNamePattern::DefaultGraph => GraphNamePattern::DefaultGraph,
1307 GraphNamePattern::NamedNode(n) => GraphNamePattern::NamedNode(n.clone()),
1308 GraphNamePattern::Variable(_) => return None,
1309 };
1310 Some(GroundQuadPattern {
1311 subject,
1312 predicate,
1313 object,
1314 graph_name,
1315 })
1316}
1317
1318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1325pub enum ConditionalOutcome {
1326 Proceed,
1328 PreconditionFailed,
1331 NotModified,
1334}
1335
1336pub fn evaluate_preconditions(
1348 method: &str,
1349 current_etag: Option<&str>,
1350 if_match: Option<&str>,
1351 if_none_match: Option<&str>,
1352) -> ConditionalOutcome {
1353 let method_upper = method.to_ascii_uppercase();
1354 let safe = method_upper == "GET" || method_upper == "HEAD";
1355
1356 if let Some(im) = if_match {
1357 let raw = im.trim();
1358 if raw == "*" {
1359 if current_etag.is_none() {
1360 return ConditionalOutcome::PreconditionFailed;
1361 }
1362 } else {
1363 let wanted = parse_etag_list(raw);
1364 match current_etag {
1365 None => return ConditionalOutcome::PreconditionFailed,
1366 Some(cur) => {
1367 if !wanted.iter().any(|w| w == cur || w == "*") {
1368 return ConditionalOutcome::PreconditionFailed;
1369 }
1370 }
1371 }
1372 }
1373 }
1374
1375 if let Some(inm) = if_none_match {
1376 let raw = inm.trim();
1377 if raw == "*" {
1378 if current_etag.is_some() {
1379 if safe {
1380 return ConditionalOutcome::NotModified;
1381 }
1382 return ConditionalOutcome::PreconditionFailed;
1383 }
1384 } else {
1385 let wanted = parse_etag_list(raw);
1386 if let Some(cur) = current_etag {
1387 if wanted.iter().any(|w| w == cur) {
1388 if safe {
1389 return ConditionalOutcome::NotModified;
1390 }
1391 return ConditionalOutcome::PreconditionFailed;
1392 }
1393 }
1394 }
1395 }
1396
1397 ConditionalOutcome::Proceed
1398}
1399
1400fn parse_etag_list(input: &str) -> Vec<String> {
1401 input
1402 .split(',')
1403 .map(|s| s.trim())
1404 .filter(|s| !s.is_empty())
1405 .map(|s| {
1406 let s = s.strip_prefix("W/").unwrap_or(s);
1408 s.trim_matches('"').to_string()
1409 })
1410 .collect()
1411}
1412
1413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1419pub struct ByteRange {
1420 pub start: u64,
1421 pub end: u64,
1422}
1423
1424impl ByteRange {
1425 pub fn length(&self) -> u64 {
1426 self.end.saturating_sub(self.start) + 1
1427 }
1428 pub fn content_range(&self, total: u64) -> String {
1431 format!("bytes {}-{}/{}", self.start, self.end, total)
1432 }
1433}
1434
1435pub fn parse_range_header(header: Option<&str>, total: u64) -> Result<Option<ByteRange>, PodError> {
1445 let raw = match header {
1446 Some(v) if !v.trim().is_empty() => v.trim(),
1447 _ => return Ok(None),
1448 };
1449 let spec = raw
1450 .strip_prefix("bytes=")
1451 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1452 if spec.contains(',') {
1453 return Err(PodError::Unsupported(
1454 "multi-range requests not supported".into(),
1455 ));
1456 }
1457 let (start_s, end_s) = spec
1458 .split_once('-')
1459 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1460 if total == 0 {
1461 return Err(PodError::PreconditionFailed(
1462 "range request against empty resource".into(),
1463 ));
1464 }
1465
1466 let range = if start_s.is_empty() {
1467 let suffix: u64 = end_s
1469 .parse()
1470 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1471 if suffix == 0 {
1472 return Err(PodError::PreconditionFailed("zero suffix length".into()));
1473 }
1474 let start = total.saturating_sub(suffix);
1475 ByteRange {
1476 start,
1477 end: total - 1,
1478 }
1479 } else {
1480 let start: u64 = start_s
1481 .parse()
1482 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1483 let end = if end_s.is_empty() {
1484 total - 1
1485 } else {
1486 let v: u64 = end_s
1487 .parse()
1488 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1489 v.min(total - 1)
1490 };
1491 if start > end {
1492 return Err(PodError::PreconditionFailed(format!(
1493 "unsatisfiable range: {start}-{end}"
1494 )));
1495 }
1496 if start >= total {
1497 return Err(PodError::PreconditionFailed(format!(
1498 "range start {start} >= total {total}"
1499 )));
1500 }
1501 ByteRange { start, end }
1502 };
1503 Ok(Some(range))
1504}
1505
1506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1510pub enum RangeOutcome {
1511 Full,
1512 Partial(ByteRange),
1513 NotSatisfiable,
1514}
1515
1516pub fn parse_range_header_v2(header: Option<&str>, total: u64) -> Result<RangeOutcome, PodError> {
1521 let raw = match header {
1522 Some(v) if !v.trim().is_empty() => v.trim(),
1523 _ => return Ok(RangeOutcome::Full),
1524 };
1525 let spec = raw
1526 .strip_prefix("bytes=")
1527 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1528 if spec.contains(',') {
1529 return Err(PodError::Unsupported("multi-range not supported".into()));
1530 }
1531 let (start_s, end_s) = spec
1532 .split_once('-')
1533 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1534 if total == 0 {
1535 return Ok(RangeOutcome::NotSatisfiable);
1536 }
1537 let range = if start_s.is_empty() {
1538 let suffix: u64 = end_s
1539 .parse()
1540 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1541 if suffix == 0 {
1542 return Ok(RangeOutcome::NotSatisfiable);
1543 }
1544 ByteRange {
1545 start: total.saturating_sub(suffix),
1546 end: total - 1,
1547 }
1548 } else {
1549 let start: u64 = start_s
1550 .parse()
1551 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1552 let end = if end_s.is_empty() {
1553 total - 1
1554 } else {
1555 let v: u64 = end_s
1556 .parse()
1557 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1558 v.min(total - 1)
1559 };
1560 if start > end || start >= total {
1561 return Ok(RangeOutcome::NotSatisfiable);
1562 }
1563 ByteRange { start, end }
1564 };
1565 Ok(RangeOutcome::Partial(range))
1566}
1567
1568pub fn slice_range(body: &[u8], range: ByteRange) -> &[u8] {
1572 let end_excl = (range.end as usize + 1).min(body.len());
1573 let start = (range.start as usize).min(end_excl);
1574 &body[start..end_excl]
1575}
1576
1577#[derive(Debug, Clone)]
1591pub struct OptionsResponse {
1592 pub allow: Vec<&'static str>,
1593 pub accept_post: Option<&'static str>,
1594 pub accept_patch: &'static str,
1595 pub accept_ranges: &'static str,
1596 pub cache_control: &'static str,
1597}
1598
1599pub const ACCEPT_PATCH: &str = "text/n3, application/sparql-update, application/json-patch+json";
1601
1602pub fn options_for(path: &str) -> OptionsResponse {
1603 let container = is_container(path);
1604 let mut allow = vec!["GET", "HEAD", "OPTIONS"];
1605 if container {
1606 allow.push("POST");
1607 allow.push("PUT");
1608 } else {
1609 allow.push("PUT");
1610 allow.push("PATCH");
1611 }
1612 allow.push("DELETE");
1613 OptionsResponse {
1614 allow,
1615 accept_post: if container { Some(ACCEPT_POST) } else { None },
1616 accept_patch: ACCEPT_PATCH,
1617 accept_ranges: if container { "none" } else { "bytes" },
1622 cache_control: CACHE_CONTROL_RDF,
1626 }
1627}
1628
1629pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1644 let container = is_container(path);
1645 let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1646 h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1647 h.push(("Accept-Put", "*/*".into()));
1648 h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1649 h.push((
1650 "Link",
1651 format!("<{}.acl>; rel=\"acl\"", path.trim_end_matches('/')),
1652 ));
1653 h.push(("Vary", vary_header(conneg_enabled).into()));
1654 if conneg_enabled {
1659 h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1660 }
1661 if container {
1662 h.push(("Accept-Post", ACCEPT_POST.into()));
1663 }
1664 h
1665}
1666
1667pub fn vary_header(conneg_enabled: bool) -> &'static str {
1672 if conneg_enabled {
1673 "Accept, Authorization, Origin"
1674 } else {
1675 "Authorization, Origin"
1676 }
1677}
1678
1679pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1688
1689pub fn is_rdf_content_type(content_type: &str) -> bool {
1695 let base = content_type
1696 .split(';')
1697 .next()
1698 .unwrap_or("")
1699 .trim()
1700 .to_ascii_lowercase();
1701 matches!(
1702 base.as_str(),
1703 "text/turtle"
1704 | "application/turtle"
1705 | "application/x-turtle"
1706 | "application/ld+json"
1707 | "application/json+ld"
1708 | "application/n-triples"
1709 | "text/plain+ntriples"
1710 | "text/n3"
1711 | "application/trig"
1712 )
1713}
1714
1715pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1720 if is_rdf_content_type(content_type) {
1721 Some(CACHE_CONTROL_RDF)
1722 } else {
1723 None
1724 }
1725}
1726
1727pub fn apply_json_patch(
1737 target: &mut serde_json::Value,
1738 patch: &serde_json::Value,
1739) -> Result<(), PodError> {
1740 let ops = patch
1741 .as_array()
1742 .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1743 for op in ops {
1744 let op_name = op
1745 .get("op")
1746 .and_then(|v| v.as_str())
1747 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1748 let path = op
1749 .get("path")
1750 .and_then(|v| v.as_str())
1751 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1752 match op_name {
1753 "add" => {
1754 let value = op
1755 .get("value")
1756 .cloned()
1757 .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1758 json_pointer_set(target, path, value, true)?;
1759 }
1760 "replace" => {
1761 let value = op
1762 .get("value")
1763 .cloned()
1764 .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1765 json_pointer_set(target, path, value, false)?;
1766 }
1767 "remove" => {
1768 json_pointer_remove(target, path)?;
1769 }
1770 "test" => {
1771 let value = op
1772 .get("value")
1773 .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1774 let actual = json_pointer_get(target, path).ok_or_else(|| {
1775 PodError::PreconditionFailed(format!("test path missing: {path}"))
1776 })?;
1777 if actual != value {
1778 return Err(PodError::PreconditionFailed(format!(
1779 "test failed at {path}"
1780 )));
1781 }
1782 }
1783 "copy" => {
1784 let from = op
1785 .get("from")
1786 .and_then(|v| v.as_str())
1787 .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1788 let value = json_pointer_get(target, from).cloned().ok_or_else(|| {
1789 PodError::PreconditionFailed(format!("copy from missing: {from}"))
1790 })?;
1791 json_pointer_set(target, path, value, true)?;
1792 }
1793 "move" => {
1794 let from = op
1795 .get("from")
1796 .and_then(|v| v.as_str())
1797 .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
1798 let value = json_pointer_get(target, from).cloned().ok_or_else(|| {
1799 PodError::PreconditionFailed(format!("move from missing: {from}"))
1800 })?;
1801 json_pointer_remove(target, from)?;
1802 json_pointer_set(target, path, value, true)?;
1803 }
1804 other => {
1805 return Err(PodError::Unsupported(format!(
1806 "unsupported JSON Patch op: {other}"
1807 )));
1808 }
1809 }
1810 }
1811 Ok(())
1812}
1813
1814fn json_pointer_get<'a>(
1815 target: &'a serde_json::Value,
1816 path: &str,
1817) -> Option<&'a serde_json::Value> {
1818 if path.is_empty() {
1819 return Some(target);
1820 }
1821 target.pointer(path)
1822}
1823
1824fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
1825 if path.is_empty() {
1826 return Err(PodError::Unsupported("cannot remove root".into()));
1827 }
1828 let (parent_path, last) = split_pointer(path);
1829 let parent = target
1830 .pointer_mut(&parent_path)
1831 .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
1832 match parent {
1833 serde_json::Value::Object(m) => {
1834 m.remove(&last).ok_or_else(|| {
1835 PodError::PreconditionFailed(format!("remove key missing: {path}"))
1836 })?;
1837 Ok(())
1838 }
1839 serde_json::Value::Array(a) => {
1840 let idx: usize = last.parse().map_err(|_| {
1841 PodError::Unsupported(format!("remove array index not numeric: {last}"))
1842 })?;
1843 if idx >= a.len() {
1844 return Err(PodError::PreconditionFailed(format!(
1845 "remove array out of bounds: {idx}"
1846 )));
1847 }
1848 a.remove(idx);
1849 Ok(())
1850 }
1851 _ => Err(PodError::PreconditionFailed(format!(
1852 "remove target is not container: {path}"
1853 ))),
1854 }
1855}
1856
1857fn json_pointer_set(
1858 target: &mut serde_json::Value,
1859 path: &str,
1860 value: serde_json::Value,
1861 add_mode: bool,
1862) -> Result<(), PodError> {
1863 if path.is_empty() {
1864 *target = value;
1865 return Ok(());
1866 }
1867 let (parent_path, last) = split_pointer(path);
1868 let parent = target
1869 .pointer_mut(&parent_path)
1870 .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
1871 match parent {
1872 serde_json::Value::Object(m) => {
1873 if !add_mode && !m.contains_key(&last) {
1874 return Err(PodError::PreconditionFailed(format!(
1875 "replace missing key: {path}"
1876 )));
1877 }
1878 m.insert(last, value);
1879 Ok(())
1880 }
1881 serde_json::Value::Array(a) => {
1882 if last == "-" {
1883 a.push(value);
1884 return Ok(());
1885 }
1886 let idx: usize = last
1887 .parse()
1888 .map_err(|_| PodError::Unsupported(format!("array index not numeric: {last}")))?;
1889 if add_mode {
1890 if idx > a.len() {
1891 return Err(PodError::PreconditionFailed(format!(
1892 "array add out of bounds: {idx}"
1893 )));
1894 }
1895 a.insert(idx, value);
1896 } else {
1897 if idx >= a.len() {
1898 return Err(PodError::PreconditionFailed(format!(
1899 "array replace out of bounds: {idx}"
1900 )));
1901 }
1902 a[idx] = value;
1903 }
1904 Ok(())
1905 }
1906 _ => Err(PodError::PreconditionFailed(format!(
1907 "set parent not container: {path}"
1908 ))),
1909 }
1910}
1911
1912fn split_pointer(path: &str) -> (String, String) {
1913 match path.rfind('/') {
1914 Some(pos) => {
1915 let parent = path[..pos].to_string();
1916 let last_raw = &path[pos + 1..];
1917 let last = last_raw.replace("~1", "/").replace("~0", "~");
1918 (parent, last)
1919 }
1920 None => (String::new(), path.to_string()),
1921 }
1922}
1923
1924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1926pub enum PatchDialect {
1927 N3,
1928 SparqlUpdate,
1929 JsonPatch,
1930}
1931
1932pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
1933 let m = mime
1934 .split(';')
1935 .next()
1936 .unwrap_or("")
1937 .trim()
1938 .to_ascii_lowercase();
1939 match m.as_str() {
1940 "text/n3" | "application/n3" => Some(PatchDialect::N3),
1941 "application/sparql-update" | "application/sparql-update+update" => {
1942 Some(PatchDialect::SparqlUpdate)
1943 }
1944 "application/json-patch+json" => Some(PatchDialect::JsonPatch),
1945 _ => None,
1946 }
1947}
1948
1949#[derive(Debug)]
1967pub enum PatchCreateOutcome {
1968 Created { inserted: usize, graph: Graph },
1970 Applied {
1973 inserted: usize,
1974 deleted: usize,
1975 graph: Graph,
1976 },
1977}
1978
1979pub fn apply_patch_to_absent(
1983 dialect: PatchDialect,
1984 body: &str,
1985) -> Result<PatchCreateOutcome, PodError> {
1986 match dialect {
1987 PatchDialect::N3 => {
1988 let outcome = apply_n3_patch(Graph::new(), body)?;
1989 Ok(PatchCreateOutcome::Created {
1990 inserted: outcome.inserted,
1991 graph: outcome.graph,
1992 })
1993 }
1994 PatchDialect::SparqlUpdate => {
1995 let outcome = apply_sparql_patch(Graph::new(), body)?;
1996 Ok(PatchCreateOutcome::Created {
1997 inserted: outcome.inserted,
1998 graph: outcome.graph,
1999 })
2000 }
2001 PatchDialect::JsonPatch => Err(PodError::Unsupported(
2002 "JSON Patch on absent resource".into(),
2003 )),
2004 }
2005}
2006
2007#[cfg(feature = "tokio-runtime")]
2012#[async_trait]
2013pub trait LdpContainerOps: Storage {
2014 async fn container_representation(&self, path: &str) -> Result<serde_json::Value, PodError> {
2015 let children = self.list(path).await?;
2016 Ok(render_container(path, &children))
2017 }
2018}
2019
2020#[cfg(feature = "tokio-runtime")]
2021impl<T: Storage + ?Sized> LdpContainerOps for T {}
2022
2023#[cfg(test)]
2028mod tests {
2029 use super::*;
2030
2031 #[test]
2032 fn is_container_detects_trailing_slash() {
2033 assert!(is_container("/"));
2034 assert!(is_container("/media/"));
2035 assert!(!is_container("/file.txt"));
2036 }
2037
2038 #[test]
2039 fn link_headers_include_acl_and_describedby() {
2040 let hdrs = link_headers("/profile/card");
2041 assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2042 assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2043 assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2044 assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2045 assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2046 }
2047
2048 #[test]
2049 fn link_headers_root_exposes_pim_storage() {
2050 let hdrs = link_headers("/");
2051 let joined = hdrs.join(",");
2052 assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2053 }
2054
2055 #[test]
2056 fn link_headers_skip_describedby_on_meta() {
2057 let hdrs = link_headers("/foo.meta");
2058 assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2059 }
2060
2061 #[test]
2062 fn link_headers_skip_acl_on_acl() {
2063 let hdrs = link_headers("/profile/card.acl");
2064 assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2065 }
2066
2067 #[test]
2068 fn prefer_minimal_container_parsed() {
2069 let p = PreferHeader::parse(
2070 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2071 );
2072 assert!(p.include_minimal);
2073 assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2074 }
2075
2076 #[test]
2077 fn prefer_contained_iris_parsed() {
2078 let p = PreferHeader::parse(
2079 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2080 );
2081 assert!(p.include_contained_iris);
2082 assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2083 }
2084
2085 #[test]
2086 fn negotiate_prefers_explicit_turtle() {
2087 assert_eq!(
2088 negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2089 RdfFormat::Turtle
2090 );
2091 }
2092
2093 #[test]
2094 fn negotiate_falls_back_to_turtle() {
2095 assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2096 assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2097 }
2098
2099 #[test]
2100 fn negotiate_picks_jsonld_when_highest() {
2101 assert_eq!(
2102 negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2103 RdfFormat::JsonLd
2104 );
2105 }
2106
2107 #[test]
2108 fn ntriples_roundtrip() {
2109 let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2110 let g = Graph::parse_ntriples(nt).unwrap();
2111 assert_eq!(g.len(), 1);
2112 let out = g.to_ntriples();
2113 assert!(out.contains("<http://a/s>"));
2114 }
2115
2116 #[test]
2117 fn server_managed_triples_include_ldp_contains() {
2118 let now = chrono::Utc::now();
2119 let members = vec!["a.txt".to_string(), "sub/".to_string()];
2120 let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2121 let nt = g.to_ntriples();
2122 assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2123 assert!(nt.contains("http://x/y/a.txt"));
2124 assert!(nt.contains("http://x/y/sub/"));
2125 }
2126
2127 #[test]
2128 fn find_illegal_server_managed_flags_ldp_contains() {
2129 let mut g = Graph::new();
2130 g.insert(Triple::new(
2131 Term::iri("http://r/"),
2132 Term::iri(iri::LDP_CONTAINS),
2133 Term::iri("http://r/x"),
2134 ));
2135 let illegal = find_illegal_server_managed(&g);
2136 assert_eq!(illegal.len(), 1);
2137 }
2138
2139 #[test]
2140 fn render_container_minimal_omits_contains() {
2141 let prefer = PreferHeader {
2142 representation: ContainerRepresentation::MinimalContainer,
2143 include_minimal: true,
2144 include_contained_iris: false,
2145 omit_membership: true,
2146 };
2147 let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2148 assert!(v.get("ldp:contains").is_none());
2149 }
2150
2151 #[test]
2152 fn render_container_turtle_emits_types() {
2153 let v = render_container_turtle("/x/", &[], PreferHeader::default());
2154 assert!(v.contains("ldp:BasicContainer"));
2155 }
2156
2157 #[test]
2158 fn n3_patch_insert_and_delete() {
2159 let mut g = Graph::new();
2160 g.insert(Triple::new(
2161 Term::iri("http://s/a"),
2162 Term::iri("http://p/keep"),
2163 Term::literal("v"),
2164 ));
2165 g.insert(Triple::new(
2166 Term::iri("http://s/a"),
2167 Term::iri("http://p/drop"),
2168 Term::literal("old"),
2169 ));
2170
2171 let patch = r#"
2172 _:r a solid:InsertDeletePatch ;
2173 solid:deletes {
2174 <http://s/a> <http://p/drop> "old" .
2175 } ;
2176 solid:inserts {
2177 <http://s/a> <http://p/new> "shiny" .
2178 } .
2179 "#;
2180 let outcome = apply_n3_patch(g, patch).unwrap();
2181 assert_eq!(outcome.inserted, 1);
2182 assert_eq!(outcome.deleted, 1);
2183 assert!(outcome.graph.contains(&Triple::new(
2184 Term::iri("http://s/a"),
2185 Term::iri("http://p/new"),
2186 Term::literal("shiny"),
2187 )));
2188 assert!(!outcome.graph.contains(&Triple::new(
2189 Term::iri("http://s/a"),
2190 Term::iri("http://p/drop"),
2191 Term::literal("old"),
2192 )));
2193 }
2194
2195 #[test]
2196 fn n3_patch_where_failure_returns_precondition() {
2197 let g = Graph::new();
2198 let patch = r#"
2199 _:r solid:where { <http://s/a> <http://p/need> "x" . } ;
2200 solid:inserts { <http://s/a> <http://p/added> "y" . } .
2201 "#;
2202 let err = apply_n3_patch(g, patch).err().unwrap();
2203 assert!(matches!(err, PodError::PreconditionFailed(_)));
2204 }
2205
2206 #[test]
2207 fn sparql_insert_data() {
2208 let g = Graph::new();
2209 let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2210 let outcome = apply_sparql_patch(g, update).unwrap();
2211 assert_eq!(outcome.inserted, 1);
2212 assert_eq!(outcome.graph.len(), 1);
2213 }
2214
2215 #[test]
2216 fn sparql_delete_data() {
2217 let mut g = Graph::new();
2218 g.insert(Triple::new(
2219 Term::iri("http://s"),
2220 Term::iri("http://p"),
2221 Term::literal("v"),
2222 ));
2223 let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2224 let outcome = apply_sparql_patch(g, update).unwrap();
2225 assert_eq!(outcome.deleted, 1);
2226 assert!(outcome.graph.is_empty());
2227 }
2228
2229 #[test]
2230 fn patch_dialect_detection() {
2231 assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2232 assert_eq!(
2233 patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2234 Some(PatchDialect::SparqlUpdate)
2235 );
2236 assert_eq!(patch_dialect_from_mime("text/plain"), None);
2237 }
2238
2239 #[test]
2240 fn slug_uses_valid_value() {
2241 let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2242 assert_eq!(out, "/photos/cat.jpg");
2243 }
2244
2245 #[test]
2246 fn slug_rejects_slashes() {
2247 let err = resolve_slug("/photos/", Some("a/b"));
2248 assert!(matches!(err, Err(PodError::BadRequest(_))));
2249 }
2250
2251 #[test]
2252 fn render_container_shapes_jsonld() {
2253 let members = vec!["one.txt".to_string(), "sub/".to_string()];
2254 let v = render_container("/docs/", &members);
2255 assert!(v.get("@context").is_some());
2256 assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2257 }
2258
2259 #[test]
2260 fn preconditions_if_match_star_passes_when_resource_exists() {
2261 let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2262 assert_eq!(got, ConditionalOutcome::Proceed);
2263 }
2264
2265 #[test]
2266 fn preconditions_if_match_star_fails_when_resource_absent() {
2267 let got = evaluate_preconditions("PUT", None, Some("*"), None);
2268 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2269 }
2270
2271 #[test]
2272 fn preconditions_if_match_mismatch_412() {
2273 let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2274 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2275 }
2276
2277 #[test]
2278 fn preconditions_if_none_match_match_on_get_returns_304() {
2279 let got = evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2280 assert_eq!(got, ConditionalOutcome::NotModified);
2281 }
2282
2283 #[test]
2284 fn preconditions_if_none_match_on_put_when_exists_fails() {
2285 let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2286 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2287 }
2288
2289 #[test]
2290 fn preconditions_if_none_match_on_put_when_absent_passes() {
2291 let got = evaluate_preconditions("PUT", None, None, Some("*"));
2292 assert_eq!(got, ConditionalOutcome::Proceed);
2293 }
2294
2295 #[test]
2296 fn range_parses_start_end() {
2297 let r = parse_range_header(Some("bytes=0-99"), 1000)
2298 .unwrap()
2299 .unwrap();
2300 assert_eq!(r.start, 0);
2301 assert_eq!(r.end, 99);
2302 assert_eq!(r.length(), 100);
2303 }
2304
2305 #[test]
2306 fn range_parses_open_ended() {
2307 let r = parse_range_header(Some("bytes=500-"), 1000)
2308 .unwrap()
2309 .unwrap();
2310 assert_eq!(r.start, 500);
2311 assert_eq!(r.end, 999);
2312 }
2313
2314 #[test]
2315 fn range_parses_suffix() {
2316 let r = parse_range_header(Some("bytes=-200"), 1000)
2317 .unwrap()
2318 .unwrap();
2319 assert_eq!(r.start, 800);
2320 assert_eq!(r.end, 999);
2321 }
2322
2323 #[test]
2324 fn range_rejects_unsatisfiable() {
2325 let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2326 assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2327 }
2328
2329 #[test]
2330 fn range_content_range_header_value() {
2331 let r = parse_range_header(Some("bytes=0-99"), 1000)
2332 .unwrap()
2333 .unwrap();
2334 assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2335 }
2336
2337 #[test]
2338 fn options_container_includes_post_and_accept_post() {
2339 let o = options_for("/photos/");
2340 assert!(o.allow.contains(&"POST"));
2341 assert!(o.accept_post.is_some());
2342 assert_eq!(o.accept_ranges, "none");
2346 assert_eq!(o.cache_control, "private, no-cache, must-revalidate");
2349 }
2350
2351 #[test]
2352 fn options_resource_includes_put_patch_no_post() {
2353 let o = options_for("/photos/cat.jpg");
2354 assert!(o.allow.contains(&"PUT"));
2355 assert!(o.allow.contains(&"PATCH"));
2356 assert!(!o.allow.contains(&"POST"));
2357 assert!(o.accept_post.is_none());
2358 assert!(o.accept_patch.contains("sparql-update"));
2359 assert!(o.accept_patch.contains("json-patch"));
2360 assert_eq!(o.cache_control, CACHE_CONTROL_RDF);
2361 }
2362
2363 #[test]
2364 fn cache_control_present_for_turtle() {
2365 assert_eq!(
2366 cache_control_for("text/turtle"),
2367 Some("private, no-cache, must-revalidate")
2368 );
2369 assert_eq!(
2370 cache_control_for("text/turtle; charset=utf-8"),
2371 Some(CACHE_CONTROL_RDF)
2372 );
2373 }
2374
2375 #[test]
2376 fn cache_control_present_for_jsonld() {
2377 assert_eq!(
2378 cache_control_for("application/ld+json"),
2379 Some(CACHE_CONTROL_RDF)
2380 );
2381 assert_eq!(
2382 cache_control_for(
2383 "application/ld+json; profile=\"http://www.w3.org/ns/json-ld#compacted\""
2384 ),
2385 Some(CACHE_CONTROL_RDF)
2386 );
2387 }
2388
2389 #[test]
2390 fn cache_control_present_for_ntriples() {
2391 assert_eq!(
2392 cache_control_for("application/n-triples"),
2393 Some(CACHE_CONTROL_RDF)
2394 );
2395 assert_eq!(cache_control_for("text/n3"), Some(CACHE_CONTROL_RDF));
2396 assert_eq!(
2397 cache_control_for("application/trig"),
2398 Some(CACHE_CONTROL_RDF)
2399 );
2400 }
2401
2402 #[test]
2403 fn cache_control_absent_for_octet_stream() {
2404 assert_eq!(cache_control_for("application/octet-stream"), None);
2405 assert!(!is_rdf_content_type("application/octet-stream"));
2406 }
2407
2408 #[test]
2409 fn cache_control_absent_for_image_png() {
2410 assert_eq!(cache_control_for("image/png"), None);
2411 assert_eq!(cache_control_for("image/jpeg"), None);
2412 assert_eq!(cache_control_for("video/mp4"), None);
2413 assert!(!is_rdf_content_type("image/png"));
2414 }
2415
2416 #[test]
2417 fn cache_control_not_found_headers_conneg_enabled_emits_rdf_directive() {
2418 let h = not_found_headers("/data/thing", true);
2419 let found = h
2420 .iter()
2421 .find(|(k, _)| *k == "Cache-Control")
2422 .map(|(_, v)| v.as_str());
2423 assert_eq!(found, Some("private, no-cache, must-revalidate"));
2424 }
2425
2426 #[test]
2427 fn cache_control_not_found_headers_conneg_disabled_omits_directive() {
2428 let h = not_found_headers("/data/thing", false);
2429 assert!(h.iter().all(|(k, _)| *k != "Cache-Control"));
2430 }
2431
2432 #[test]
2433 fn json_patch_add_and_replace() {
2434 let mut v = serde_json::json!({ "name": "alice" });
2435 let patch = serde_json::json!([
2436 { "op": "add", "path": "/age", "value": 30 },
2437 { "op": "replace", "path": "/name", "value": "bob" }
2438 ]);
2439 apply_json_patch(&mut v, &patch).unwrap();
2440 assert_eq!(v["name"], "bob");
2441 assert_eq!(v["age"], 30);
2442 }
2443
2444 #[test]
2445 fn json_patch_remove() {
2446 let mut v = serde_json::json!({ "name": "alice", "age": 30 });
2447 let patch = serde_json::json!([
2448 { "op": "remove", "path": "/age" }
2449 ]);
2450 apply_json_patch(&mut v, &patch).unwrap();
2451 assert!(v.get("age").is_none());
2452 }
2453
2454 #[test]
2455 fn json_patch_test_failure_returns_precondition() {
2456 let mut v = serde_json::json!({ "name": "alice" });
2457 let patch = serde_json::json!([
2458 { "op": "test", "path": "/name", "value": "bob" }
2459 ]);
2460 let err = apply_json_patch(&mut v, &patch).unwrap_err();
2461 assert!(matches!(err, PodError::PreconditionFailed(_)));
2462 }
2463
2464 #[test]
2465 fn json_patch_dialect_detection() {
2466 assert_eq!(
2467 patch_dialect_from_mime("application/json-patch+json"),
2468 Some(PatchDialect::JsonPatch)
2469 );
2470 }
2471}