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