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 const SPARQL_UPDATE_MAX_BYTES: usize = 1_048_576; pub fn apply_sparql_patch(target: Graph, update: &str) -> Result<PatchOutcome, PodError> {
1073 if update.len() > SPARQL_UPDATE_MAX_BYTES {
1074 return Err(PodError::BadRequest(format!(
1075 "SPARQL-Update body exceeds {} byte limit ({} bytes)",
1076 SPARQL_UPDATE_MAX_BYTES,
1077 update.len(),
1078 )));
1079 }
1080
1081 use spargebra::term::{
1082 GraphName, GraphNamePattern, GroundQuad, GroundQuadPattern, GroundSubject, GroundTerm,
1083 GroundTermPattern, NamedNodePattern, Quad, Subject, Term as SpTerm,
1084 };
1085 use spargebra::{GraphUpdateOperation, Update};
1086
1087 let parsed = Update::parse(update, None)
1088 .map_err(|e| PodError::Unsupported(format!("SPARQL parse error: {e}")))?;
1089
1090 fn build_literal(value: String, datatype: Option<String>, language: Option<String>) -> Term {
1097 let datatype = datatype.filter(|d| d != iri::XSD_STRING);
1098 Term::Literal {
1099 value,
1100 datatype,
1101 language,
1102 }
1103 }
1104
1105 fn map_subject(s: &Subject) -> Option<Term> {
1106 match s {
1107 Subject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1108 Subject::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1109 #[allow(unreachable_patterns)]
1110 _ => None,
1111 }
1112 }
1113 fn map_term(t: &SpTerm) -> Option<Term> {
1114 match t {
1115 SpTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1116 SpTerm::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1117 SpTerm::Literal(lit) => {
1118 let value = lit.value().to_string();
1119 if let Some(lang) = lit.language() {
1120 Some(build_literal(value, None, Some(lang.to_string())))
1121 } else {
1122 Some(build_literal(
1123 value,
1124 Some(lit.datatype().as_str().to_string()),
1125 None,
1126 ))
1127 }
1128 }
1129 #[allow(unreachable_patterns)]
1130 _ => None,
1131 }
1132 }
1133 fn map_ground_subject(s: &GroundSubject) -> Option<Term> {
1134 match s {
1135 GroundSubject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1136 #[allow(unreachable_patterns)]
1137 _ => None,
1138 }
1139 }
1140 fn map_ground_term(t: &GroundTerm) -> Option<Term> {
1141 match t {
1142 GroundTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1143 GroundTerm::Literal(lit) => {
1144 let value = lit.value().to_string();
1145 if let Some(lang) = lit.language() {
1146 Some(build_literal(value, None, Some(lang.to_string())))
1147 } else {
1148 Some(build_literal(
1149 value,
1150 Some(lit.datatype().as_str().to_string()),
1151 None,
1152 ))
1153 }
1154 }
1155 #[allow(unreachable_patterns)]
1156 _ => None,
1157 }
1158 }
1159 fn map_ground_term_pattern(t: &GroundTermPattern) -> Option<Term> {
1160 match t {
1161 GroundTermPattern::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1162 GroundTermPattern::Literal(lit) => {
1163 let value = lit.value().to_string();
1164 if let Some(lang) = lit.language() {
1165 Some(build_literal(value, None, Some(lang.to_string())))
1166 } else {
1167 Some(build_literal(
1168 value,
1169 Some(lit.datatype().as_str().to_string()),
1170 None,
1171 ))
1172 }
1173 }
1174 _ => None,
1175 }
1176 }
1177
1178 fn quad_to_triple(q: &Quad) -> Option<Triple> {
1179 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1180 return None;
1181 }
1182 Some(Triple::new(
1183 map_subject(&q.subject)?,
1184 Term::Iri(q.predicate.as_str().to_string()),
1185 map_term(&q.object)?,
1186 ))
1187 }
1188 fn ground_quad_to_triple(q: &GroundQuad) -> Option<Triple> {
1189 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1190 return None;
1191 }
1192 Some(Triple::new(
1193 map_ground_subject(&q.subject)?,
1194 Term::Iri(q.predicate.as_str().to_string()),
1195 map_ground_term(&q.object)?,
1196 ))
1197 }
1198 fn ground_quad_pattern_to_triple(q: &GroundQuadPattern) -> Option<Triple> {
1199 if !matches!(q.graph_name, GraphNamePattern::DefaultGraph) {
1200 return None;
1201 }
1202 let predicate = match &q.predicate {
1203 NamedNodePattern::NamedNode(n) => Term::Iri(n.as_str().to_string()),
1204 NamedNodePattern::Variable(_) => return None,
1205 };
1206 Some(Triple::new(
1207 map_ground_term_pattern(&q.subject)?,
1208 predicate,
1209 map_ground_term_pattern(&q.object)?,
1210 ))
1211 }
1212
1213 let mut graph = target;
1214 let mut inserted = 0usize;
1215 let mut deleted = 0usize;
1216
1217 for op in &parsed.operations {
1218 match op {
1219 GraphUpdateOperation::InsertData { data } => {
1220 for q in data {
1221 if let Some(tr) = quad_to_triple(q) {
1222 if !graph.contains(&tr) {
1223 graph.insert(tr);
1224 inserted += 1;
1225 }
1226 }
1227 }
1228 }
1229 GraphUpdateOperation::DeleteData { data } => {
1230 for q in data {
1231 if let Some(tr) = ground_quad_to_triple(q) {
1232 if graph.remove(&tr) {
1233 deleted += 1;
1234 }
1235 }
1236 }
1237 }
1238 GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
1239 for q in delete {
1240 if let Some(tr) = ground_quad_pattern_to_triple(q) {
1241 if graph.remove(&tr) {
1242 deleted += 1;
1243 }
1244 }
1245 }
1246 for q in insert {
1247 let gqp = match convert_quad_pattern_to_ground(q) {
1252 Some(g) => g,
1253 None => continue,
1254 };
1255 if let Some(tr) = ground_quad_pattern_to_triple(&gqp) {
1256 if !graph.contains(&tr) {
1257 graph.insert(tr);
1258 inserted += 1;
1259 }
1260 }
1261 }
1262 }
1263 _ => {
1264 return Err(PodError::Unsupported(format!(
1265 "unsupported SPARQL operation: {op:?}"
1266 )));
1267 }
1268 }
1269 }
1270
1271 Ok(PatchOutcome {
1272 graph,
1273 inserted,
1274 deleted,
1275 })
1276}
1277
1278fn convert_quad_pattern_to_ground(
1279 q: &spargebra::term::QuadPattern,
1280) -> Option<spargebra::term::GroundQuadPattern> {
1281 use spargebra::term::{
1282 GraphNamePattern, GroundQuadPattern, GroundTermPattern, NamedNodePattern, TermPattern,
1283 };
1284
1285 let subject = match &q.subject {
1286 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1287 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1288 _ => return None,
1289 };
1290 let predicate = match &q.predicate {
1291 NamedNodePattern::NamedNode(n) => NamedNodePattern::NamedNode(n.clone()),
1292 NamedNodePattern::Variable(_) => return None,
1293 };
1294 let object = match &q.object {
1295 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1296 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1297 _ => return None,
1298 };
1299 let graph_name = match &q.graph_name {
1300 GraphNamePattern::DefaultGraph => GraphNamePattern::DefaultGraph,
1301 GraphNamePattern::NamedNode(n) => GraphNamePattern::NamedNode(n.clone()),
1302 GraphNamePattern::Variable(_) => return None,
1303 };
1304 Some(GroundQuadPattern {
1305 subject,
1306 predicate,
1307 object,
1308 graph_name,
1309 })
1310}
1311
1312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1319pub enum ConditionalOutcome {
1320 Proceed,
1322 PreconditionFailed,
1325 NotModified,
1328}
1329
1330pub fn evaluate_preconditions(
1342 method: &str,
1343 current_etag: Option<&str>,
1344 if_match: Option<&str>,
1345 if_none_match: Option<&str>,
1346) -> ConditionalOutcome {
1347 let method_upper = method.to_ascii_uppercase();
1348 let safe = method_upper == "GET" || method_upper == "HEAD";
1349
1350 if let Some(im) = if_match {
1351 let raw = im.trim();
1352 if raw == "*" {
1353 if current_etag.is_none() {
1354 return ConditionalOutcome::PreconditionFailed;
1355 }
1356 } else {
1357 let wanted = parse_etag_list(raw);
1358 match current_etag {
1359 None => return ConditionalOutcome::PreconditionFailed,
1360 Some(cur) => {
1361 if !wanted.iter().any(|w| w == cur || w == "*") {
1362 return ConditionalOutcome::PreconditionFailed;
1363 }
1364 }
1365 }
1366 }
1367 }
1368
1369 if let Some(inm) = if_none_match {
1370 let raw = inm.trim();
1371 if raw == "*" {
1372 if current_etag.is_some() {
1373 if safe {
1374 return ConditionalOutcome::NotModified;
1375 }
1376 return ConditionalOutcome::PreconditionFailed;
1377 }
1378 } else {
1379 let wanted = parse_etag_list(raw);
1380 if let Some(cur) = current_etag {
1381 if wanted.iter().any(|w| w == cur) {
1382 if safe {
1383 return ConditionalOutcome::NotModified;
1384 }
1385 return ConditionalOutcome::PreconditionFailed;
1386 }
1387 }
1388 }
1389 }
1390
1391 ConditionalOutcome::Proceed
1392}
1393
1394fn parse_etag_list(input: &str) -> Vec<String> {
1395 input
1396 .split(',')
1397 .map(|s| s.trim())
1398 .filter(|s| !s.is_empty())
1399 .map(|s| {
1400 let s = s.strip_prefix("W/").unwrap_or(s);
1402 s.trim_matches('"').to_string()
1403 })
1404 .collect()
1405}
1406
1407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1413pub struct ByteRange {
1414 pub start: u64,
1415 pub end: u64,
1416}
1417
1418impl ByteRange {
1419 pub fn length(&self) -> u64 {
1420 self.end.saturating_sub(self.start) + 1
1421 }
1422 pub fn content_range(&self, total: u64) -> String {
1425 format!("bytes {}-{}/{}", self.start, self.end, total)
1426 }
1427}
1428
1429pub fn parse_range_header(
1439 header: Option<&str>,
1440 total: u64,
1441) -> Result<Option<ByteRange>, PodError> {
1442 let raw = match header {
1443 Some(v) if !v.trim().is_empty() => v.trim(),
1444 _ => return Ok(None),
1445 };
1446 let spec = raw
1447 .strip_prefix("bytes=")
1448 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1449 if spec.contains(',') {
1450 return Err(PodError::Unsupported(
1451 "multi-range requests not supported".into(),
1452 ));
1453 }
1454 let (start_s, end_s) = spec
1455 .split_once('-')
1456 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1457 if total == 0 {
1458 return Err(PodError::PreconditionFailed(
1459 "range request against empty resource".into(),
1460 ));
1461 }
1462
1463 let range = if start_s.is_empty() {
1464 let suffix: u64 = end_s
1466 .parse()
1467 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1468 if suffix == 0 {
1469 return Err(PodError::PreconditionFailed("zero suffix length".into()));
1470 }
1471 let start = total.saturating_sub(suffix);
1472 ByteRange {
1473 start,
1474 end: total - 1,
1475 }
1476 } else {
1477 let start: u64 = start_s
1478 .parse()
1479 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1480 let end = if end_s.is_empty() {
1481 total - 1
1482 } else {
1483 let v: u64 = end_s
1484 .parse()
1485 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1486 v.min(total - 1)
1487 };
1488 if start > end {
1489 return Err(PodError::PreconditionFailed(format!(
1490 "unsatisfiable range: {start}-{end}"
1491 )));
1492 }
1493 if start >= total {
1494 return Err(PodError::PreconditionFailed(format!(
1495 "range start {start} >= total {total}"
1496 )));
1497 }
1498 ByteRange { start, end }
1499 };
1500 Ok(Some(range))
1501}
1502
1503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1507pub enum RangeOutcome {
1508 Full,
1509 Partial(ByteRange),
1510 NotSatisfiable,
1511}
1512
1513pub fn parse_range_header_v2(
1518 header: Option<&str>,
1519 total: u64,
1520) -> 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 { start: total.saturating_sub(suffix), end: total - 1 }
1545 } else {
1546 let start: u64 = start_s
1547 .parse()
1548 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1549 let end = if end_s.is_empty() {
1550 total - 1
1551 } else {
1552 let v: u64 = end_s
1553 .parse()
1554 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1555 v.min(total - 1)
1556 };
1557 if start > end || start >= total {
1558 return Ok(RangeOutcome::NotSatisfiable);
1559 }
1560 ByteRange { start, end }
1561 };
1562 Ok(RangeOutcome::Partial(range))
1563}
1564
1565pub fn slice_range(body: &[u8], range: ByteRange) -> &[u8] {
1569 let end_excl = (range.end as usize + 1).min(body.len());
1570 let start = (range.start as usize).min(end_excl);
1571 &body[start..end_excl]
1572}
1573
1574#[derive(Debug, Clone)]
1588pub struct OptionsResponse {
1589 pub allow: Vec<&'static str>,
1590 pub accept_post: Option<&'static str>,
1591 pub accept_patch: &'static str,
1592 pub accept_ranges: &'static str,
1593 pub cache_control: &'static str,
1594}
1595
1596pub const ACCEPT_PATCH: &str = "text/n3, application/sparql-update, application/json-patch+json";
1598
1599pub fn options_for(path: &str) -> OptionsResponse {
1600 let container = is_container(path);
1601 let mut allow = vec!["GET", "HEAD", "OPTIONS"];
1602 if container {
1603 allow.push("POST");
1604 allow.push("PUT");
1605 } else {
1606 allow.push("PUT");
1607 allow.push("PATCH");
1608 }
1609 allow.push("DELETE");
1610 OptionsResponse {
1611 allow,
1612 accept_post: if container { Some(ACCEPT_POST) } else { None },
1613 accept_patch: ACCEPT_PATCH,
1614 accept_ranges: if container { "none" } else { "bytes" },
1619 cache_control: CACHE_CONTROL_RDF,
1623 }
1624}
1625
1626pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1641 let container = is_container(path);
1642 let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1643 h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1644 h.push(("Accept-Put", "*/*".into()));
1645 h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1646 h.push((
1647 "Link",
1648 format!("<{}.acl>; rel=\"acl\"", path.trim_end_matches('/')),
1649 ));
1650 h.push(("Vary", vary_header(conneg_enabled).into()));
1651 if conneg_enabled {
1656 h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1657 }
1658 if container {
1659 h.push(("Accept-Post", ACCEPT_POST.into()));
1660 }
1661 h
1662}
1663
1664pub fn vary_header(conneg_enabled: bool) -> &'static str {
1669 if conneg_enabled {
1670 "Accept, Authorization, Origin"
1671 } else {
1672 "Authorization, Origin"
1673 }
1674}
1675
1676pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1685
1686pub fn is_rdf_content_type(content_type: &str) -> bool {
1692 let base = content_type
1693 .split(';')
1694 .next()
1695 .unwrap_or("")
1696 .trim()
1697 .to_ascii_lowercase();
1698 matches!(
1699 base.as_str(),
1700 "text/turtle"
1701 | "application/turtle"
1702 | "application/x-turtle"
1703 | "application/ld+json"
1704 | "application/json+ld"
1705 | "application/n-triples"
1706 | "text/plain+ntriples"
1707 | "text/n3"
1708 | "application/trig"
1709 )
1710}
1711
1712pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1717 if is_rdf_content_type(content_type) {
1718 Some(CACHE_CONTROL_RDF)
1719 } else {
1720 None
1721 }
1722}
1723
1724pub fn apply_json_patch(
1734 target: &mut serde_json::Value,
1735 patch: &serde_json::Value,
1736) -> Result<(), PodError> {
1737 let ops = patch
1738 .as_array()
1739 .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1740 for op in ops {
1741 let op_name = op
1742 .get("op")
1743 .and_then(|v| v.as_str())
1744 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1745 let path = op
1746 .get("path")
1747 .and_then(|v| v.as_str())
1748 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1749 match op_name {
1750 "add" => {
1751 let value = op
1752 .get("value")
1753 .cloned()
1754 .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1755 json_pointer_set(target, path, value, true)?;
1756 }
1757 "replace" => {
1758 let value = op
1759 .get("value")
1760 .cloned()
1761 .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1762 json_pointer_set(target, path, value, false)?;
1763 }
1764 "remove" => {
1765 json_pointer_remove(target, path)?;
1766 }
1767 "test" => {
1768 let value = op
1769 .get("value")
1770 .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1771 let actual = json_pointer_get(target, path)
1772 .ok_or_else(|| PodError::PreconditionFailed(format!("test path missing: {path}")))?;
1773 if actual != value {
1774 return Err(PodError::PreconditionFailed(format!(
1775 "test failed at {path}"
1776 )));
1777 }
1778 }
1779 "copy" => {
1780 let from = op
1781 .get("from")
1782 .and_then(|v| v.as_str())
1783 .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1784 let value = json_pointer_get(target, from)
1785 .cloned()
1786 .ok_or_else(|| PodError::PreconditionFailed(format!("copy from missing: {from}")))?;
1787 json_pointer_set(target, path, value, true)?;
1788 }
1789 "move" => {
1790 let from = op
1791 .get("from")
1792 .and_then(|v| v.as_str())
1793 .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
1794 let value = json_pointer_get(target, from)
1795 .cloned()
1796 .ok_or_else(|| PodError::PreconditionFailed(format!("move from missing: {from}")))?;
1797 json_pointer_remove(target, from)?;
1798 json_pointer_set(target, path, value, true)?;
1799 }
1800 other => {
1801 return Err(PodError::Unsupported(format!(
1802 "unsupported JSON Patch op: {other}"
1803 )));
1804 }
1805 }
1806 }
1807 Ok(())
1808}
1809
1810fn json_pointer_get<'a>(
1811 target: &'a serde_json::Value,
1812 path: &str,
1813) -> Option<&'a serde_json::Value> {
1814 if path.is_empty() {
1815 return Some(target);
1816 }
1817 target.pointer(path)
1818}
1819
1820fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
1821 if path.is_empty() {
1822 return Err(PodError::Unsupported("cannot remove root".into()));
1823 }
1824 let (parent_path, last) = split_pointer(path);
1825 let parent = target
1826 .pointer_mut(&parent_path)
1827 .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
1828 match parent {
1829 serde_json::Value::Object(m) => {
1830 m.remove(&last).ok_or_else(|| {
1831 PodError::PreconditionFailed(format!("remove key missing: {path}"))
1832 })?;
1833 Ok(())
1834 }
1835 serde_json::Value::Array(a) => {
1836 let idx: usize = last.parse().map_err(|_| {
1837 PodError::Unsupported(format!("remove array index not numeric: {last}"))
1838 })?;
1839 if idx >= a.len() {
1840 return Err(PodError::PreconditionFailed(format!(
1841 "remove array out of bounds: {idx}"
1842 )));
1843 }
1844 a.remove(idx);
1845 Ok(())
1846 }
1847 _ => Err(PodError::PreconditionFailed(format!(
1848 "remove target is not container: {path}"
1849 ))),
1850 }
1851}
1852
1853fn json_pointer_set(
1854 target: &mut serde_json::Value,
1855 path: &str,
1856 value: serde_json::Value,
1857 add_mode: bool,
1858) -> Result<(), PodError> {
1859 if path.is_empty() {
1860 *target = value;
1861 return Ok(());
1862 }
1863 let (parent_path, last) = split_pointer(path);
1864 let parent = target
1865 .pointer_mut(&parent_path)
1866 .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
1867 match parent {
1868 serde_json::Value::Object(m) => {
1869 if !add_mode && !m.contains_key(&last) {
1870 return Err(PodError::PreconditionFailed(format!(
1871 "replace missing key: {path}"
1872 )));
1873 }
1874 m.insert(last, value);
1875 Ok(())
1876 }
1877 serde_json::Value::Array(a) => {
1878 if last == "-" {
1879 a.push(value);
1880 return Ok(());
1881 }
1882 let idx: usize = last.parse().map_err(|_| {
1883 PodError::Unsupported(format!("array index not numeric: {last}"))
1884 })?;
1885 if add_mode {
1886 if idx > a.len() {
1887 return Err(PodError::PreconditionFailed(format!(
1888 "array add out of bounds: {idx}"
1889 )));
1890 }
1891 a.insert(idx, value);
1892 } else {
1893 if idx >= a.len() {
1894 return Err(PodError::PreconditionFailed(format!(
1895 "array replace out of bounds: {idx}"
1896 )));
1897 }
1898 a[idx] = value;
1899 }
1900 Ok(())
1901 }
1902 _ => Err(PodError::PreconditionFailed(format!(
1903 "set parent not container: {path}"
1904 ))),
1905 }
1906}
1907
1908fn split_pointer(path: &str) -> (String, String) {
1909 match path.rfind('/') {
1910 Some(pos) => {
1911 let parent = path[..pos].to_string();
1912 let last_raw = &path[pos + 1..];
1913 let last = last_raw.replace("~1", "/").replace("~0", "~");
1914 (parent, last)
1915 }
1916 None => (String::new(), path.to_string()),
1917 }
1918}
1919
1920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1922pub enum PatchDialect {
1923 N3,
1924 SparqlUpdate,
1925 JsonPatch,
1926}
1927
1928pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
1929 let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
1930 match m.as_str() {
1931 "text/n3" | "application/n3" => Some(PatchDialect::N3),
1932 "application/sparql-update" | "application/sparql-update+update" => {
1933 Some(PatchDialect::SparqlUpdate)
1934 }
1935 "application/json-patch+json" => Some(PatchDialect::JsonPatch),
1936 _ => None,
1937 }
1938}
1939
1940#[derive(Debug)]
1958pub enum PatchCreateOutcome {
1959 Created { inserted: usize, graph: Graph },
1961 Applied {
1964 inserted: usize,
1965 deleted: usize,
1966 graph: Graph,
1967 },
1968}
1969
1970pub fn apply_patch_to_absent(
1974 dialect: PatchDialect,
1975 body: &str,
1976) -> Result<PatchCreateOutcome, PodError> {
1977 match dialect {
1978 PatchDialect::N3 => {
1979 let outcome = apply_n3_patch(Graph::new(), body)?;
1980 Ok(PatchCreateOutcome::Created {
1981 inserted: outcome.inserted,
1982 graph: outcome.graph,
1983 })
1984 }
1985 PatchDialect::SparqlUpdate => {
1986 let outcome = apply_sparql_patch(Graph::new(), body)?;
1987 Ok(PatchCreateOutcome::Created {
1988 inserted: outcome.inserted,
1989 graph: outcome.graph,
1990 })
1991 }
1992 PatchDialect::JsonPatch => Err(PodError::Unsupported(
1993 "JSON Patch on absent resource".into(),
1994 )),
1995 }
1996}
1997
1998#[cfg(feature = "tokio-runtime")]
2003#[async_trait]
2004pub trait LdpContainerOps: Storage {
2005 async fn container_representation(
2006 &self,
2007 path: &str,
2008 ) -> Result<serde_json::Value, PodError> {
2009 let children = self.list(path).await?;
2010 Ok(render_container(path, &children))
2011 }
2012}
2013
2014#[cfg(feature = "tokio-runtime")]
2015impl<T: Storage + ?Sized> LdpContainerOps for T {}
2016
2017#[cfg(test)]
2022mod tests {
2023 use super::*;
2024
2025 #[test]
2026 fn is_container_detects_trailing_slash() {
2027 assert!(is_container("/"));
2028 assert!(is_container("/media/"));
2029 assert!(!is_container("/file.txt"));
2030 }
2031
2032 #[test]
2033 fn link_headers_include_acl_and_describedby() {
2034 let hdrs = link_headers("/profile/card");
2035 assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2036 assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2037 assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2038 assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2039 assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2040 }
2041
2042 #[test]
2043 fn link_headers_root_exposes_pim_storage() {
2044 let hdrs = link_headers("/");
2045 let joined = hdrs.join(",");
2046 assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2047 }
2048
2049 #[test]
2050 fn link_headers_skip_describedby_on_meta() {
2051 let hdrs = link_headers("/foo.meta");
2052 assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2053 }
2054
2055 #[test]
2056 fn link_headers_skip_acl_on_acl() {
2057 let hdrs = link_headers("/profile/card.acl");
2058 assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2059 }
2060
2061 #[test]
2062 fn prefer_minimal_container_parsed() {
2063 let p = PreferHeader::parse(
2064 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2065 );
2066 assert!(p.include_minimal);
2067 assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2068 }
2069
2070 #[test]
2071 fn prefer_contained_iris_parsed() {
2072 let p = PreferHeader::parse(
2073 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2074 );
2075 assert!(p.include_contained_iris);
2076 assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2077 }
2078
2079 #[test]
2080 fn negotiate_prefers_explicit_turtle() {
2081 assert_eq!(
2082 negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2083 RdfFormat::Turtle
2084 );
2085 }
2086
2087 #[test]
2088 fn negotiate_falls_back_to_turtle() {
2089 assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2090 assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2091 }
2092
2093 #[test]
2094 fn negotiate_picks_jsonld_when_highest() {
2095 assert_eq!(
2096 negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2097 RdfFormat::JsonLd
2098 );
2099 }
2100
2101 #[test]
2102 fn ntriples_roundtrip() {
2103 let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2104 let g = Graph::parse_ntriples(nt).unwrap();
2105 assert_eq!(g.len(), 1);
2106 let out = g.to_ntriples();
2107 assert!(out.contains("<http://a/s>"));
2108 }
2109
2110 #[test]
2111 fn server_managed_triples_include_ldp_contains() {
2112 let now = chrono::Utc::now();
2113 let members = vec!["a.txt".to_string(), "sub/".to_string()];
2114 let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2115 let nt = g.to_ntriples();
2116 assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2117 assert!(nt.contains("http://x/y/a.txt"));
2118 assert!(nt.contains("http://x/y/sub/"));
2119 }
2120
2121 #[test]
2122 fn find_illegal_server_managed_flags_ldp_contains() {
2123 let mut g = Graph::new();
2124 g.insert(Triple::new(
2125 Term::iri("http://r/"),
2126 Term::iri(iri::LDP_CONTAINS),
2127 Term::iri("http://r/x"),
2128 ));
2129 let illegal = find_illegal_server_managed(&g);
2130 assert_eq!(illegal.len(), 1);
2131 }
2132
2133 #[test]
2134 fn render_container_minimal_omits_contains() {
2135 let prefer = PreferHeader {
2136 representation: ContainerRepresentation::MinimalContainer,
2137 include_minimal: true,
2138 include_contained_iris: false,
2139 omit_membership: true,
2140 };
2141 let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2142 assert!(v.get("ldp:contains").is_none());
2143 }
2144
2145 #[test]
2146 fn render_container_turtle_emits_types() {
2147 let v = render_container_turtle("/x/", &[], PreferHeader::default());
2148 assert!(v.contains("ldp:BasicContainer"));
2149 }
2150
2151 #[test]
2152 fn n3_patch_insert_and_delete() {
2153 let mut g = Graph::new();
2154 g.insert(Triple::new(
2155 Term::iri("http://s/a"),
2156 Term::iri("http://p/keep"),
2157 Term::literal("v"),
2158 ));
2159 g.insert(Triple::new(
2160 Term::iri("http://s/a"),
2161 Term::iri("http://p/drop"),
2162 Term::literal("old"),
2163 ));
2164
2165 let patch = r#"
2166 _:r a solid:InsertDeletePatch ;
2167 solid:deletes {
2168 <http://s/a> <http://p/drop> "old" .
2169 } ;
2170 solid:inserts {
2171 <http://s/a> <http://p/new> "shiny" .
2172 } .
2173 "#;
2174 let outcome = apply_n3_patch(g, patch).unwrap();
2175 assert_eq!(outcome.inserted, 1);
2176 assert_eq!(outcome.deleted, 1);
2177 assert!(outcome.graph.contains(&Triple::new(
2178 Term::iri("http://s/a"),
2179 Term::iri("http://p/new"),
2180 Term::literal("shiny"),
2181 )));
2182 assert!(!outcome.graph.contains(&Triple::new(
2183 Term::iri("http://s/a"),
2184 Term::iri("http://p/drop"),
2185 Term::literal("old"),
2186 )));
2187 }
2188
2189 #[test]
2190 fn n3_patch_where_failure_returns_precondition() {
2191 let g = Graph::new();
2192 let patch = r#"
2193 _:r solid:where { <http://s/a> <http://p/need> "x" . } ;
2194 solid:inserts { <http://s/a> <http://p/added> "y" . } .
2195 "#;
2196 let err = apply_n3_patch(g, patch).err().unwrap();
2197 assert!(matches!(err, PodError::PreconditionFailed(_)));
2198 }
2199
2200 #[test]
2201 fn sparql_insert_data() {
2202 let g = Graph::new();
2203 let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2204 let outcome = apply_sparql_patch(g, update).unwrap();
2205 assert_eq!(outcome.inserted, 1);
2206 assert_eq!(outcome.graph.len(), 1);
2207 }
2208
2209 #[test]
2210 fn sparql_delete_data() {
2211 let mut g = Graph::new();
2212 g.insert(Triple::new(
2213 Term::iri("http://s"),
2214 Term::iri("http://p"),
2215 Term::literal("v"),
2216 ));
2217 let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2218 let outcome = apply_sparql_patch(g, update).unwrap();
2219 assert_eq!(outcome.deleted, 1);
2220 assert!(outcome.graph.is_empty());
2221 }
2222
2223 #[test]
2224 fn patch_dialect_detection() {
2225 assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2226 assert_eq!(
2227 patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2228 Some(PatchDialect::SparqlUpdate)
2229 );
2230 assert_eq!(patch_dialect_from_mime("text/plain"), None);
2231 }
2232
2233 #[test]
2234 fn slug_uses_valid_value() {
2235 let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2236 assert_eq!(out, "/photos/cat.jpg");
2237 }
2238
2239 #[test]
2240 fn slug_rejects_slashes() {
2241 let err = resolve_slug("/photos/", Some("a/b"));
2242 assert!(matches!(err, Err(PodError::BadRequest(_))));
2243 }
2244
2245 #[test]
2246 fn render_container_shapes_jsonld() {
2247 let members = vec!["one.txt".to_string(), "sub/".to_string()];
2248 let v = render_container("/docs/", &members);
2249 assert!(v.get("@context").is_some());
2250 assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2251 }
2252
2253 #[test]
2254 fn preconditions_if_match_star_passes_when_resource_exists() {
2255 let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2256 assert_eq!(got, ConditionalOutcome::Proceed);
2257 }
2258
2259 #[test]
2260 fn preconditions_if_match_star_fails_when_resource_absent() {
2261 let got = evaluate_preconditions("PUT", None, Some("*"), None);
2262 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2263 }
2264
2265 #[test]
2266 fn preconditions_if_match_mismatch_412() {
2267 let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2268 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2269 }
2270
2271 #[test]
2272 fn preconditions_if_none_match_match_on_get_returns_304() {
2273 let got =
2274 evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2275 assert_eq!(got, ConditionalOutcome::NotModified);
2276 }
2277
2278 #[test]
2279 fn preconditions_if_none_match_on_put_when_exists_fails() {
2280 let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2281 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2282 }
2283
2284 #[test]
2285 fn preconditions_if_none_match_on_put_when_absent_passes() {
2286 let got = evaluate_preconditions("PUT", None, None, Some("*"));
2287 assert_eq!(got, ConditionalOutcome::Proceed);
2288 }
2289
2290 #[test]
2291 fn range_parses_start_end() {
2292 let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2293 assert_eq!(r.start, 0);
2294 assert_eq!(r.end, 99);
2295 assert_eq!(r.length(), 100);
2296 }
2297
2298 #[test]
2299 fn range_parses_open_ended() {
2300 let r = parse_range_header(Some("bytes=500-"), 1000).unwrap().unwrap();
2301 assert_eq!(r.start, 500);
2302 assert_eq!(r.end, 999);
2303 }
2304
2305 #[test]
2306 fn range_parses_suffix() {
2307 let r = parse_range_header(Some("bytes=-200"), 1000).unwrap().unwrap();
2308 assert_eq!(r.start, 800);
2309 assert_eq!(r.end, 999);
2310 }
2311
2312 #[test]
2313 fn range_rejects_unsatisfiable() {
2314 let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2315 assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2316 }
2317
2318 #[test]
2319 fn range_content_range_header_value() {
2320 let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2321 assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2322 }
2323
2324 #[test]
2325 fn options_container_includes_post_and_accept_post() {
2326 let o = options_for("/photos/");
2327 assert!(o.allow.contains(&"POST"));
2328 assert!(o.accept_post.is_some());
2329 assert_eq!(o.accept_ranges, "none");
2333 assert_eq!(o.cache_control, "private, no-cache, must-revalidate");
2336 }
2337
2338 #[test]
2339 fn options_resource_includes_put_patch_no_post() {
2340 let o = options_for("/photos/cat.jpg");
2341 assert!(o.allow.contains(&"PUT"));
2342 assert!(o.allow.contains(&"PATCH"));
2343 assert!(!o.allow.contains(&"POST"));
2344 assert!(o.accept_post.is_none());
2345 assert!(o.accept_patch.contains("sparql-update"));
2346 assert!(o.accept_patch.contains("json-patch"));
2347 assert_eq!(o.cache_control, CACHE_CONTROL_RDF);
2348 }
2349
2350 #[test]
2351 fn cache_control_present_for_turtle() {
2352 assert_eq!(
2353 cache_control_for("text/turtle"),
2354 Some("private, no-cache, must-revalidate")
2355 );
2356 assert_eq!(
2357 cache_control_for("text/turtle; charset=utf-8"),
2358 Some(CACHE_CONTROL_RDF)
2359 );
2360 }
2361
2362 #[test]
2363 fn cache_control_present_for_jsonld() {
2364 assert_eq!(
2365 cache_control_for("application/ld+json"),
2366 Some(CACHE_CONTROL_RDF)
2367 );
2368 assert_eq!(
2369 cache_control_for("application/ld+json; profile=\"http://www.w3.org/ns/json-ld#compacted\""),
2370 Some(CACHE_CONTROL_RDF)
2371 );
2372 }
2373
2374 #[test]
2375 fn cache_control_present_for_ntriples() {
2376 assert_eq!(
2377 cache_control_for("application/n-triples"),
2378 Some(CACHE_CONTROL_RDF)
2379 );
2380 assert_eq!(cache_control_for("text/n3"), Some(CACHE_CONTROL_RDF));
2381 assert_eq!(
2382 cache_control_for("application/trig"),
2383 Some(CACHE_CONTROL_RDF)
2384 );
2385 }
2386
2387 #[test]
2388 fn cache_control_absent_for_octet_stream() {
2389 assert_eq!(cache_control_for("application/octet-stream"), None);
2390 assert!(!is_rdf_content_type("application/octet-stream"));
2391 }
2392
2393 #[test]
2394 fn cache_control_absent_for_image_png() {
2395 assert_eq!(cache_control_for("image/png"), None);
2396 assert_eq!(cache_control_for("image/jpeg"), None);
2397 assert_eq!(cache_control_for("video/mp4"), None);
2398 assert!(!is_rdf_content_type("image/png"));
2399 }
2400
2401 #[test]
2402 fn cache_control_not_found_headers_conneg_enabled_emits_rdf_directive() {
2403 let h = not_found_headers("/data/thing", true);
2404 let found = h
2405 .iter()
2406 .find(|(k, _)| *k == "Cache-Control")
2407 .map(|(_, v)| v.as_str());
2408 assert_eq!(found, Some("private, no-cache, must-revalidate"));
2409 }
2410
2411 #[test]
2412 fn cache_control_not_found_headers_conneg_disabled_omits_directive() {
2413 let h = not_found_headers("/data/thing", false);
2414 assert!(h.iter().all(|(k, _)| *k != "Cache-Control"));
2415 }
2416
2417 #[test]
2418 fn json_patch_add_and_replace() {
2419 let mut v = serde_json::json!({ "name": "alice" });
2420 let patch = serde_json::json!([
2421 { "op": "add", "path": "/age", "value": 30 },
2422 { "op": "replace", "path": "/name", "value": "bob" }
2423 ]);
2424 apply_json_patch(&mut v, &patch).unwrap();
2425 assert_eq!(v["name"], "bob");
2426 assert_eq!(v["age"], 30);
2427 }
2428
2429 #[test]
2430 fn json_patch_remove() {
2431 let mut v = serde_json::json!({ "name": "alice", "age": 30 });
2432 let patch = serde_json::json!([
2433 { "op": "remove", "path": "/age" }
2434 ]);
2435 apply_json_patch(&mut v, &patch).unwrap();
2436 assert!(v.get("age").is_none());
2437 }
2438
2439 #[test]
2440 fn json_patch_test_failure_returns_precondition() {
2441 let mut v = serde_json::json!({ "name": "alice" });
2442 let patch = serde_json::json!([
2443 { "op": "test", "path": "/name", "value": "bob" }
2444 ]);
2445 let err = apply_json_patch(&mut v, &patch).unwrap_err();
2446 assert!(matches!(err, PodError::PreconditionFailed(_)));
2447 }
2448
2449 #[test]
2450 fn json_patch_dialect_detection() {
2451 assert_eq!(
2452 patch_dialect_from_mime("application/json-patch+json"),
2453 Some(PatchDialect::JsonPatch)
2454 );
2455 }
2456}