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 } else {
1584 allow.push("PUT");
1585 allow.push("PATCH");
1586 }
1587 allow.push("DELETE");
1588 OptionsResponse {
1589 allow,
1590 accept_post: if container { Some(ACCEPT_POST) } else { None },
1591 accept_patch: ACCEPT_PATCH,
1592 accept_ranges: if container { "none" } else { "bytes" },
1597 cache_control: CACHE_CONTROL_RDF,
1601 }
1602}
1603
1604pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1619 let container = is_container(path);
1620 let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1621 h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1622 h.push(("Accept-Put", "*/*".into()));
1623 h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1624 h.push((
1625 "Link",
1626 format!("<{}.acl>; rel=\"acl\"", path.trim_end_matches('/')),
1627 ));
1628 h.push(("Vary", vary_header(conneg_enabled).into()));
1629 if conneg_enabled {
1634 h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1635 }
1636 if container {
1637 h.push(("Accept-Post", ACCEPT_POST.into()));
1638 }
1639 h
1640}
1641
1642pub fn vary_header(conneg_enabled: bool) -> &'static str {
1647 if conneg_enabled {
1648 "Accept, Authorization, Origin"
1649 } else {
1650 "Authorization, Origin"
1651 }
1652}
1653
1654pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1663
1664pub fn is_rdf_content_type(content_type: &str) -> bool {
1670 let base = content_type
1671 .split(';')
1672 .next()
1673 .unwrap_or("")
1674 .trim()
1675 .to_ascii_lowercase();
1676 matches!(
1677 base.as_str(),
1678 "text/turtle"
1679 | "application/turtle"
1680 | "application/x-turtle"
1681 | "application/ld+json"
1682 | "application/json+ld"
1683 | "application/n-triples"
1684 | "text/plain+ntriples"
1685 | "text/n3"
1686 | "application/trig"
1687 )
1688}
1689
1690pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1695 if is_rdf_content_type(content_type) {
1696 Some(CACHE_CONTROL_RDF)
1697 } else {
1698 None
1699 }
1700}
1701
1702pub fn apply_json_patch(
1712 target: &mut serde_json::Value,
1713 patch: &serde_json::Value,
1714) -> Result<(), PodError> {
1715 let ops = patch
1716 .as_array()
1717 .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1718 for op in ops {
1719 let op_name = op
1720 .get("op")
1721 .and_then(|v| v.as_str())
1722 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1723 let path = op
1724 .get("path")
1725 .and_then(|v| v.as_str())
1726 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1727 match op_name {
1728 "add" => {
1729 let value = op
1730 .get("value")
1731 .cloned()
1732 .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1733 json_pointer_set(target, path, value, true)?;
1734 }
1735 "replace" => {
1736 let value = op
1737 .get("value")
1738 .cloned()
1739 .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1740 json_pointer_set(target, path, value, false)?;
1741 }
1742 "remove" => {
1743 json_pointer_remove(target, path)?;
1744 }
1745 "test" => {
1746 let value = op
1747 .get("value")
1748 .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1749 let actual = json_pointer_get(target, path)
1750 .ok_or_else(|| PodError::PreconditionFailed(format!("test path missing: {path}")))?;
1751 if actual != value {
1752 return Err(PodError::PreconditionFailed(format!(
1753 "test failed at {path}"
1754 )));
1755 }
1756 }
1757 "copy" => {
1758 let from = op
1759 .get("from")
1760 .and_then(|v| v.as_str())
1761 .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1762 let value = json_pointer_get(target, from)
1763 .cloned()
1764 .ok_or_else(|| PodError::PreconditionFailed(format!("copy from missing: {from}")))?;
1765 json_pointer_set(target, path, value, true)?;
1766 }
1767 "move" => {
1768 let from = op
1769 .get("from")
1770 .and_then(|v| v.as_str())
1771 .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
1772 let value = json_pointer_get(target, from)
1773 .cloned()
1774 .ok_or_else(|| PodError::PreconditionFailed(format!("move from missing: {from}")))?;
1775 json_pointer_remove(target, from)?;
1776 json_pointer_set(target, path, value, true)?;
1777 }
1778 other => {
1779 return Err(PodError::Unsupported(format!(
1780 "unsupported JSON Patch op: {other}"
1781 )));
1782 }
1783 }
1784 }
1785 Ok(())
1786}
1787
1788fn json_pointer_get<'a>(
1789 target: &'a serde_json::Value,
1790 path: &str,
1791) -> Option<&'a serde_json::Value> {
1792 if path.is_empty() {
1793 return Some(target);
1794 }
1795 target.pointer(path)
1796}
1797
1798fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
1799 if path.is_empty() {
1800 return Err(PodError::Unsupported("cannot remove root".into()));
1801 }
1802 let (parent_path, last) = split_pointer(path);
1803 let parent = target
1804 .pointer_mut(&parent_path)
1805 .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
1806 match parent {
1807 serde_json::Value::Object(m) => {
1808 m.remove(&last).ok_or_else(|| {
1809 PodError::PreconditionFailed(format!("remove key missing: {path}"))
1810 })?;
1811 Ok(())
1812 }
1813 serde_json::Value::Array(a) => {
1814 let idx: usize = last.parse().map_err(|_| {
1815 PodError::Unsupported(format!("remove array index not numeric: {last}"))
1816 })?;
1817 if idx >= a.len() {
1818 return Err(PodError::PreconditionFailed(format!(
1819 "remove array out of bounds: {idx}"
1820 )));
1821 }
1822 a.remove(idx);
1823 Ok(())
1824 }
1825 _ => Err(PodError::PreconditionFailed(format!(
1826 "remove target is not container: {path}"
1827 ))),
1828 }
1829}
1830
1831fn json_pointer_set(
1832 target: &mut serde_json::Value,
1833 path: &str,
1834 value: serde_json::Value,
1835 add_mode: bool,
1836) -> Result<(), PodError> {
1837 if path.is_empty() {
1838 *target = value;
1839 return Ok(());
1840 }
1841 let (parent_path, last) = split_pointer(path);
1842 let parent = target
1843 .pointer_mut(&parent_path)
1844 .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
1845 match parent {
1846 serde_json::Value::Object(m) => {
1847 if !add_mode && !m.contains_key(&last) {
1848 return Err(PodError::PreconditionFailed(format!(
1849 "replace missing key: {path}"
1850 )));
1851 }
1852 m.insert(last, value);
1853 Ok(())
1854 }
1855 serde_json::Value::Array(a) => {
1856 if last == "-" {
1857 a.push(value);
1858 return Ok(());
1859 }
1860 let idx: usize = last.parse().map_err(|_| {
1861 PodError::Unsupported(format!("array index not numeric: {last}"))
1862 })?;
1863 if add_mode {
1864 if idx > a.len() {
1865 return Err(PodError::PreconditionFailed(format!(
1866 "array add out of bounds: {idx}"
1867 )));
1868 }
1869 a.insert(idx, value);
1870 } else {
1871 if idx >= a.len() {
1872 return Err(PodError::PreconditionFailed(format!(
1873 "array replace out of bounds: {idx}"
1874 )));
1875 }
1876 a[idx] = value;
1877 }
1878 Ok(())
1879 }
1880 _ => Err(PodError::PreconditionFailed(format!(
1881 "set parent not container: {path}"
1882 ))),
1883 }
1884}
1885
1886fn split_pointer(path: &str) -> (String, String) {
1887 match path.rfind('/') {
1888 Some(pos) => {
1889 let parent = path[..pos].to_string();
1890 let last_raw = &path[pos + 1..];
1891 let last = last_raw.replace("~1", "/").replace("~0", "~");
1892 (parent, last)
1893 }
1894 None => (String::new(), path.to_string()),
1895 }
1896}
1897
1898#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1900pub enum PatchDialect {
1901 N3,
1902 SparqlUpdate,
1903 JsonPatch,
1904}
1905
1906pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
1907 let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
1908 match m.as_str() {
1909 "text/n3" | "application/n3" => Some(PatchDialect::N3),
1910 "application/sparql-update" | "application/sparql-update+update" => {
1911 Some(PatchDialect::SparqlUpdate)
1912 }
1913 "application/json-patch+json" => Some(PatchDialect::JsonPatch),
1914 _ => None,
1915 }
1916}
1917
1918#[derive(Debug)]
1936pub enum PatchCreateOutcome {
1937 Created { inserted: usize, graph: Graph },
1939 Applied {
1942 inserted: usize,
1943 deleted: usize,
1944 graph: Graph,
1945 },
1946}
1947
1948pub fn apply_patch_to_absent(
1952 dialect: PatchDialect,
1953 body: &str,
1954) -> Result<PatchCreateOutcome, PodError> {
1955 match dialect {
1956 PatchDialect::N3 => {
1957 let outcome = apply_n3_patch(Graph::new(), body)?;
1958 Ok(PatchCreateOutcome::Created {
1959 inserted: outcome.inserted,
1960 graph: outcome.graph,
1961 })
1962 }
1963 PatchDialect::SparqlUpdate => {
1964 let outcome = apply_sparql_patch(Graph::new(), body)?;
1965 Ok(PatchCreateOutcome::Created {
1966 inserted: outcome.inserted,
1967 graph: outcome.graph,
1968 })
1969 }
1970 PatchDialect::JsonPatch => Err(PodError::Unsupported(
1971 "JSON Patch on absent resource".into(),
1972 )),
1973 }
1974}
1975
1976#[async_trait]
1981pub trait LdpContainerOps: Storage {
1982 async fn container_representation(
1983 &self,
1984 path: &str,
1985 ) -> Result<serde_json::Value, PodError> {
1986 let children = self.list(path).await?;
1987 Ok(render_container(path, &children))
1988 }
1989}
1990
1991impl<T: Storage + ?Sized> LdpContainerOps for T {}
1992
1993#[cfg(test)]
1998mod tests {
1999 use super::*;
2000
2001 #[test]
2002 fn is_container_detects_trailing_slash() {
2003 assert!(is_container("/"));
2004 assert!(is_container("/media/"));
2005 assert!(!is_container("/file.txt"));
2006 }
2007
2008 #[test]
2009 fn link_headers_include_acl_and_describedby() {
2010 let hdrs = link_headers("/profile/card");
2011 assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2012 assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2013 assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2014 assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2015 assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2016 }
2017
2018 #[test]
2019 fn link_headers_root_exposes_pim_storage() {
2020 let hdrs = link_headers("/");
2021 let joined = hdrs.join(",");
2022 assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2023 }
2024
2025 #[test]
2026 fn link_headers_skip_describedby_on_meta() {
2027 let hdrs = link_headers("/foo.meta");
2028 assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2029 }
2030
2031 #[test]
2032 fn link_headers_skip_acl_on_acl() {
2033 let hdrs = link_headers("/profile/card.acl");
2034 assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2035 }
2036
2037 #[test]
2038 fn prefer_minimal_container_parsed() {
2039 let p = PreferHeader::parse(
2040 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2041 );
2042 assert!(p.include_minimal);
2043 assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2044 }
2045
2046 #[test]
2047 fn prefer_contained_iris_parsed() {
2048 let p = PreferHeader::parse(
2049 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2050 );
2051 assert!(p.include_contained_iris);
2052 assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2053 }
2054
2055 #[test]
2056 fn negotiate_prefers_explicit_turtle() {
2057 assert_eq!(
2058 negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2059 RdfFormat::Turtle
2060 );
2061 }
2062
2063 #[test]
2064 fn negotiate_falls_back_to_turtle() {
2065 assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2066 assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2067 }
2068
2069 #[test]
2070 fn negotiate_picks_jsonld_when_highest() {
2071 assert_eq!(
2072 negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2073 RdfFormat::JsonLd
2074 );
2075 }
2076
2077 #[test]
2078 fn ntriples_roundtrip() {
2079 let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2080 let g = Graph::parse_ntriples(nt).unwrap();
2081 assert_eq!(g.len(), 1);
2082 let out = g.to_ntriples();
2083 assert!(out.contains("<http://a/s>"));
2084 }
2085
2086 #[test]
2087 fn server_managed_triples_include_ldp_contains() {
2088 let now = chrono::Utc::now();
2089 let members = vec!["a.txt".to_string(), "sub/".to_string()];
2090 let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2091 let nt = g.to_ntriples();
2092 assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2093 assert!(nt.contains("http://x/y/a.txt"));
2094 assert!(nt.contains("http://x/y/sub/"));
2095 }
2096
2097 #[test]
2098 fn find_illegal_server_managed_flags_ldp_contains() {
2099 let mut g = Graph::new();
2100 g.insert(Triple::new(
2101 Term::iri("http://r/"),
2102 Term::iri(iri::LDP_CONTAINS),
2103 Term::iri("http://r/x"),
2104 ));
2105 let illegal = find_illegal_server_managed(&g);
2106 assert_eq!(illegal.len(), 1);
2107 }
2108
2109 #[test]
2110 fn render_container_minimal_omits_contains() {
2111 let prefer = PreferHeader {
2112 representation: ContainerRepresentation::MinimalContainer,
2113 include_minimal: true,
2114 include_contained_iris: false,
2115 omit_membership: true,
2116 };
2117 let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2118 assert!(v.get("ldp:contains").is_none());
2119 }
2120
2121 #[test]
2122 fn render_container_turtle_emits_types() {
2123 let v = render_container_turtle("/x/", &[], PreferHeader::default());
2124 assert!(v.contains("ldp:BasicContainer"));
2125 }
2126
2127 #[test]
2128 fn n3_patch_insert_and_delete() {
2129 let mut g = Graph::new();
2130 g.insert(Triple::new(
2131 Term::iri("http://s/a"),
2132 Term::iri("http://p/keep"),
2133 Term::literal("v"),
2134 ));
2135 g.insert(Triple::new(
2136 Term::iri("http://s/a"),
2137 Term::iri("http://p/drop"),
2138 Term::literal("old"),
2139 ));
2140
2141 let patch = r#"
2142 _:r a solid:InsertDeletePatch ;
2143 solid:deletes {
2144 <http://s/a> <http://p/drop> "old" .
2145 } ;
2146 solid:inserts {
2147 <http://s/a> <http://p/new> "shiny" .
2148 } .
2149 "#;
2150 let outcome = apply_n3_patch(g, patch).unwrap();
2151 assert_eq!(outcome.inserted, 1);
2152 assert_eq!(outcome.deleted, 1);
2153 assert!(outcome.graph.contains(&Triple::new(
2154 Term::iri("http://s/a"),
2155 Term::iri("http://p/new"),
2156 Term::literal("shiny"),
2157 )));
2158 assert!(!outcome.graph.contains(&Triple::new(
2159 Term::iri("http://s/a"),
2160 Term::iri("http://p/drop"),
2161 Term::literal("old"),
2162 )));
2163 }
2164
2165 #[test]
2166 fn n3_patch_where_failure_returns_precondition() {
2167 let g = Graph::new();
2168 let patch = r#"
2169 _:r solid:where { <http://s/a> <http://p/need> "x" . } ;
2170 solid:inserts { <http://s/a> <http://p/added> "y" . } .
2171 "#;
2172 let err = apply_n3_patch(g, patch).err().unwrap();
2173 assert!(matches!(err, PodError::PreconditionFailed(_)));
2174 }
2175
2176 #[test]
2177 fn sparql_insert_data() {
2178 let g = Graph::new();
2179 let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2180 let outcome = apply_sparql_patch(g, update).unwrap();
2181 assert_eq!(outcome.inserted, 1);
2182 assert_eq!(outcome.graph.len(), 1);
2183 }
2184
2185 #[test]
2186 fn sparql_delete_data() {
2187 let mut g = Graph::new();
2188 g.insert(Triple::new(
2189 Term::iri("http://s"),
2190 Term::iri("http://p"),
2191 Term::literal("v"),
2192 ));
2193 let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2194 let outcome = apply_sparql_patch(g, update).unwrap();
2195 assert_eq!(outcome.deleted, 1);
2196 assert!(outcome.graph.is_empty());
2197 }
2198
2199 #[test]
2200 fn patch_dialect_detection() {
2201 assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2202 assert_eq!(
2203 patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2204 Some(PatchDialect::SparqlUpdate)
2205 );
2206 assert_eq!(patch_dialect_from_mime("text/plain"), None);
2207 }
2208
2209 #[test]
2210 fn slug_uses_valid_value() {
2211 let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2212 assert_eq!(out, "/photos/cat.jpg");
2213 }
2214
2215 #[test]
2216 fn slug_rejects_slashes() {
2217 let err = resolve_slug("/photos/", Some("a/b"));
2218 assert!(matches!(err, Err(PodError::BadRequest(_))));
2219 }
2220
2221 #[test]
2222 fn render_container_shapes_jsonld() {
2223 let members = vec!["one.txt".to_string(), "sub/".to_string()];
2224 let v = render_container("/docs/", &members);
2225 assert!(v.get("@context").is_some());
2226 assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2227 }
2228
2229 #[test]
2230 fn preconditions_if_match_star_passes_when_resource_exists() {
2231 let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2232 assert_eq!(got, ConditionalOutcome::Proceed);
2233 }
2234
2235 #[test]
2236 fn preconditions_if_match_star_fails_when_resource_absent() {
2237 let got = evaluate_preconditions("PUT", None, Some("*"), None);
2238 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2239 }
2240
2241 #[test]
2242 fn preconditions_if_match_mismatch_412() {
2243 let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2244 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2245 }
2246
2247 #[test]
2248 fn preconditions_if_none_match_match_on_get_returns_304() {
2249 let got =
2250 evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2251 assert_eq!(got, ConditionalOutcome::NotModified);
2252 }
2253
2254 #[test]
2255 fn preconditions_if_none_match_on_put_when_exists_fails() {
2256 let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2257 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2258 }
2259
2260 #[test]
2261 fn preconditions_if_none_match_on_put_when_absent_passes() {
2262 let got = evaluate_preconditions("PUT", None, None, Some("*"));
2263 assert_eq!(got, ConditionalOutcome::Proceed);
2264 }
2265
2266 #[test]
2267 fn range_parses_start_end() {
2268 let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2269 assert_eq!(r.start, 0);
2270 assert_eq!(r.end, 99);
2271 assert_eq!(r.length(), 100);
2272 }
2273
2274 #[test]
2275 fn range_parses_open_ended() {
2276 let r = parse_range_header(Some("bytes=500-"), 1000).unwrap().unwrap();
2277 assert_eq!(r.start, 500);
2278 assert_eq!(r.end, 999);
2279 }
2280
2281 #[test]
2282 fn range_parses_suffix() {
2283 let r = parse_range_header(Some("bytes=-200"), 1000).unwrap().unwrap();
2284 assert_eq!(r.start, 800);
2285 assert_eq!(r.end, 999);
2286 }
2287
2288 #[test]
2289 fn range_rejects_unsatisfiable() {
2290 let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2291 assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2292 }
2293
2294 #[test]
2295 fn range_content_range_header_value() {
2296 let r = parse_range_header(Some("bytes=0-99"), 1000).unwrap().unwrap();
2297 assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2298 }
2299
2300 #[test]
2301 fn options_container_includes_post_and_accept_post() {
2302 let o = options_for("/photos/");
2303 assert!(o.allow.contains(&"POST"));
2304 assert!(o.accept_post.is_some());
2305 assert_eq!(o.accept_ranges, "none");
2309 assert_eq!(o.cache_control, "private, no-cache, must-revalidate");
2312 }
2313
2314 #[test]
2315 fn options_resource_includes_put_patch_no_post() {
2316 let o = options_for("/photos/cat.jpg");
2317 assert!(o.allow.contains(&"PUT"));
2318 assert!(o.allow.contains(&"PATCH"));
2319 assert!(!o.allow.contains(&"POST"));
2320 assert!(o.accept_post.is_none());
2321 assert!(o.accept_patch.contains("sparql-update"));
2322 assert!(o.accept_patch.contains("json-patch"));
2323 assert_eq!(o.cache_control, CACHE_CONTROL_RDF);
2324 }
2325
2326 #[test]
2327 fn cache_control_present_for_turtle() {
2328 assert_eq!(
2329 cache_control_for("text/turtle"),
2330 Some("private, no-cache, must-revalidate")
2331 );
2332 assert_eq!(
2333 cache_control_for("text/turtle; charset=utf-8"),
2334 Some(CACHE_CONTROL_RDF)
2335 );
2336 }
2337
2338 #[test]
2339 fn cache_control_present_for_jsonld() {
2340 assert_eq!(
2341 cache_control_for("application/ld+json"),
2342 Some(CACHE_CONTROL_RDF)
2343 );
2344 assert_eq!(
2345 cache_control_for("application/ld+json; profile=\"http://www.w3.org/ns/json-ld#compacted\""),
2346 Some(CACHE_CONTROL_RDF)
2347 );
2348 }
2349
2350 #[test]
2351 fn cache_control_present_for_ntriples() {
2352 assert_eq!(
2353 cache_control_for("application/n-triples"),
2354 Some(CACHE_CONTROL_RDF)
2355 );
2356 assert_eq!(cache_control_for("text/n3"), Some(CACHE_CONTROL_RDF));
2357 assert_eq!(
2358 cache_control_for("application/trig"),
2359 Some(CACHE_CONTROL_RDF)
2360 );
2361 }
2362
2363 #[test]
2364 fn cache_control_absent_for_octet_stream() {
2365 assert_eq!(cache_control_for("application/octet-stream"), None);
2366 assert!(!is_rdf_content_type("application/octet-stream"));
2367 }
2368
2369 #[test]
2370 fn cache_control_absent_for_image_png() {
2371 assert_eq!(cache_control_for("image/png"), None);
2372 assert_eq!(cache_control_for("image/jpeg"), None);
2373 assert_eq!(cache_control_for("video/mp4"), None);
2374 assert!(!is_rdf_content_type("image/png"));
2375 }
2376
2377 #[test]
2378 fn cache_control_not_found_headers_conneg_enabled_emits_rdf_directive() {
2379 let h = not_found_headers("/data/thing", true);
2380 let found = h
2381 .iter()
2382 .find(|(k, _)| *k == "Cache-Control")
2383 .map(|(_, v)| v.as_str());
2384 assert_eq!(found, Some("private, no-cache, must-revalidate"));
2385 }
2386
2387 #[test]
2388 fn cache_control_not_found_headers_conneg_disabled_omits_directive() {
2389 let h = not_found_headers("/data/thing", false);
2390 assert!(h.iter().all(|(k, _)| *k != "Cache-Control"));
2391 }
2392
2393 #[test]
2394 fn json_patch_add_and_replace() {
2395 let mut v = serde_json::json!({ "name": "alice" });
2396 let patch = serde_json::json!([
2397 { "op": "add", "path": "/age", "value": 30 },
2398 { "op": "replace", "path": "/name", "value": "bob" }
2399 ]);
2400 apply_json_patch(&mut v, &patch).unwrap();
2401 assert_eq!(v["name"], "bob");
2402 assert_eq!(v["age"], 30);
2403 }
2404
2405 #[test]
2406 fn json_patch_remove() {
2407 let mut v = serde_json::json!({ "name": "alice", "age": 30 });
2408 let patch = serde_json::json!([
2409 { "op": "remove", "path": "/age" }
2410 ]);
2411 apply_json_patch(&mut v, &patch).unwrap();
2412 assert!(v.get("age").is_none());
2413 }
2414
2415 #[test]
2416 fn json_patch_test_failure_returns_precondition() {
2417 let mut v = serde_json::json!({ "name": "alice" });
2418 let patch = serde_json::json!([
2419 { "op": "test", "path": "/name", "value": "bob" }
2420 ]);
2421 let err = apply_json_patch(&mut v, &patch).unwrap_err();
2422 assert!(matches!(err, PodError::PreconditionFailed(_)));
2423 }
2424
2425 #[test]
2426 fn json_patch_dialect_detection() {
2427 assert_eq!(
2428 patch_dialect_from_mime("application/json-patch+json"),
2429 Some(PatchDialect::JsonPatch)
2430 );
2431 }
2432}