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