1use std::collections::BTreeSet;
21use std::fmt::Write as _;
22
23#[cfg(feature = "tokio-runtime")]
24use async_trait::async_trait;
25use serde::Serialize;
26
27use crate::error::PodError;
28#[cfg(feature = "tokio-runtime")]
32use crate::storage::Storage;
33
34pub mod iri {
36 pub const LDP_RESOURCE: &str = "http://www.w3.org/ns/ldp#Resource";
38 pub const LDP_CONTAINER: &str = "http://www.w3.org/ns/ldp#Container";
40 pub const LDP_BASIC_CONTAINER: &str = "http://www.w3.org/ns/ldp#BasicContainer";
42 pub const LDP_NS: &str = "http://www.w3.org/ns/ldp#";
44 pub const LDP_CONTAINS: &str = "http://www.w3.org/ns/ldp#contains";
46 pub const LDP_PREFER_MINIMAL_CONTAINER: &str =
48 "http://www.w3.org/ns/ldp#PreferMinimalContainer";
49 pub const LDP_PREFER_CONTAINED_IRIS: &str = "http://www.w3.org/ns/ldp#PreferContainedIRIs";
51 pub const LDP_PREFER_MEMBERSHIP: &str = "http://www.w3.org/ns/ldp#PreferMembership";
53
54 pub const DCTERMS_NS: &str = "http://purl.org/dc/terms/";
56 pub const DCTERMS_MODIFIED: &str = "http://purl.org/dc/terms/modified";
58
59 pub const STAT_NS: &str = "http://www.w3.org/ns/posix/stat#";
61 pub const STAT_SIZE: &str = "http://www.w3.org/ns/posix/stat#size";
63 pub const STAT_MTIME: &str = "http://www.w3.org/ns/posix/stat#mtime";
65
66 pub const XSD_DATETIME: &str = "http://www.w3.org/2001/XMLSchema#dateTime";
68 pub const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
70 pub const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
72
73 pub const PIM_STORAGE: &str = "http://www.w3.org/ns/pim/space#Storage";
75 pub const PIM_STORAGE_REL: &str = "http://www.w3.org/ns/pim/space#storage";
77
78 pub const ACL_NS: &str = "http://www.w3.org/ns/auth/acl#";
80}
81
82pub const ACCEPT_POST: &str = "text/turtle, application/ld+json, application/n-triples";
86
87pub fn is_container(path: &str) -> bool {
89 path == "/" || path.ends_with('/')
90}
91
92pub fn is_acl_path(path: &str) -> bool {
94 path.ends_with(".acl")
95}
96
97pub fn is_meta_path(path: &str) -> bool {
99 path.ends_with(".meta")
100}
101
102pub fn meta_sidecar_for(path: &str) -> String {
104 if is_meta_path(path) {
105 path.to_string()
106 } else {
107 format!("{path}.meta")
108 }
109}
110
111pub fn link_headers(path: &str) -> Vec<String> {
120 let mut out = Vec::new();
121 if is_container(path) {
122 out.push(format!("<{}>; rel=\"type\"", iri::LDP_BASIC_CONTAINER));
123 out.push(format!("<{}>; rel=\"type\"", iri::LDP_CONTAINER));
124 out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
125 } else {
126 out.push(format!("<{}>; rel=\"type\"", iri::LDP_RESOURCE));
127 }
128 if !is_acl_path(path) {
129 let acl_target = format!("{path}.acl");
130 out.push(format!("<{acl_target}>; rel=\"acl\""));
131 }
132 if !is_meta_path(path) && !is_acl_path(path) {
133 let meta_target = meta_sidecar_for(path);
134 out.push(format!("<{meta_target}>; rel=\"describedby\""));
135 }
136 if path == "/" {
137 out.push(format!("</>; rel=\"{}\"", iri::PIM_STORAGE_REL));
138 }
139 out
140}
141
142pub const MAX_SLUG_BYTES: usize = 255;
145
146pub fn resolve_slug(container: &str, slug: Option<&str>) -> Result<String, PodError> {
156 let join = |name: &str| {
157 if container.ends_with('/') {
158 format!("{container}{name}")
159 } else {
160 format!("{container}/{name}")
161 }
162 };
163 match slug {
164 Some(s) if !s.is_empty() => {
165 if s.len() > MAX_SLUG_BYTES {
166 return Err(PodError::BadRequest(format!(
167 "slug exceeds {MAX_SLUG_BYTES} bytes"
168 )));
169 }
170 if s.contains('/') || s.contains("..") || s.contains('\0') {
171 return Err(PodError::BadRequest(format!("invalid slug: {s:?}")));
172 }
173 if !s
174 .chars()
175 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
176 {
177 return Err(PodError::BadRequest(format!(
178 "slug contains disallowed character: {s:?}"
179 )));
180 }
181 Ok(join(s))
182 }
183 _ => Ok(join(&uuid::Uuid::new_v4().to_string())),
184 }
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
193pub enum ContainerRepresentation {
194 #[default]
196 Full,
197 MinimalContainer,
199 ContainedIRIsOnly,
201}
202
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
206pub struct PreferHeader {
207 pub representation: ContainerRepresentation,
208 pub include_minimal: bool,
209 pub include_contained_iris: bool,
210 pub omit_membership: bool,
211}
212
213impl PreferHeader {
214 pub fn parse(value: &str) -> Self {
216 let mut out = PreferHeader::default();
217 for pref in value.split(',') {
219 let pref = pref.trim();
220 if pref.is_empty() {
221 continue;
222 }
223 let mut parts = pref.split(';').map(|s| s.trim());
225 let head = match parts.next() {
226 Some(h) => h,
227 None => continue,
228 };
229 if !head.eq_ignore_ascii_case("return=representation") {
230 continue;
231 }
232 for token in parts {
233 if let Some(val) = token
234 .strip_prefix("include=")
235 .or_else(|| token.strip_prefix("include ="))
236 {
237 let unq = val.trim().trim_matches('"');
238 for iri in unq.split_whitespace() {
239 if iri == iri::LDP_PREFER_MINIMAL_CONTAINER {
240 out.include_minimal = true;
241 out.representation = ContainerRepresentation::MinimalContainer;
242 } else if iri == iri::LDP_PREFER_CONTAINED_IRIS {
243 out.include_contained_iris = true;
244 out.representation = ContainerRepresentation::ContainedIRIsOnly;
245 }
246 }
247 } else if let Some(val) = token
248 .strip_prefix("omit=")
249 .or_else(|| token.strip_prefix("omit ="))
250 {
251 let unq = val.trim().trim_matches('"');
252 for iri in unq.split_whitespace() {
253 if iri == iri::LDP_PREFER_MEMBERSHIP {
254 out.omit_membership = true;
255 }
256 }
257 }
258 }
259 }
260 out
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum RdfFormat {
270 Turtle,
271 JsonLd,
272 NTriples,
273 RdfXml,
274}
275
276impl RdfFormat {
277 pub fn mime(&self) -> &'static str {
278 match self {
279 RdfFormat::Turtle => "text/turtle",
280 RdfFormat::JsonLd => "application/ld+json",
281 RdfFormat::NTriples => "application/n-triples",
282 RdfFormat::RdfXml => "application/rdf+xml",
283 }
284 }
285
286 pub fn from_mime(mime: &str) -> Option<Self> {
287 let mime = mime
288 .split(';')
289 .next()
290 .unwrap_or("")
291 .trim()
292 .to_ascii_lowercase();
293 match mime.as_str() {
294 "text/turtle" | "application/turtle" | "application/x-turtle" => {
295 Some(RdfFormat::Turtle)
296 }
297 "application/ld+json" | "application/json+ld" => Some(RdfFormat::JsonLd),
298 "application/n-triples" | "text/plain+ntriples" => Some(RdfFormat::NTriples),
299 "application/rdf+xml" => Some(RdfFormat::RdfXml),
300 _ => None,
301 }
302 }
303}
304
305pub fn negotiate_format(accept: Option<&str>) -> RdfFormat {
309 let accept = match accept {
310 Some(a) if !a.trim().is_empty() => a,
311 _ => return RdfFormat::Turtle,
312 };
313
314 let mut best: Option<(f32, RdfFormat)> = None;
315 for entry in accept.split(',') {
316 let entry = entry.trim();
317 if entry.is_empty() {
318 continue;
319 }
320 let mut parts = entry.split(';').map(|s| s.trim());
321 let mime = match parts.next() {
322 Some(m) => m.to_ascii_lowercase(),
323 None => continue,
324 };
325 let mut q: f32 = 1.0;
326 for token in parts {
327 if let Some(v) = token.strip_prefix("q=") {
328 if let Ok(parsed) = v.parse::<f32>() {
329 q = parsed;
330 }
331 }
332 }
333 let format = match mime.as_str() {
334 "text/turtle" | "application/turtle" => Some(RdfFormat::Turtle),
335 "application/ld+json" => Some(RdfFormat::JsonLd),
336 "application/n-triples" => Some(RdfFormat::NTriples),
337 "application/rdf+xml" => Some(RdfFormat::RdfXml),
338 "*/*" | "application/*" | "text/*" => Some(RdfFormat::Turtle),
339 _ => None,
340 };
341 if let Some(f) = format {
342 match best {
343 None => best = Some((q, f)),
344 Some((bq, _)) if q > bq => best = Some((q, f)),
345 _ => {}
346 }
347 }
348 }
349 best.map(|(_, f)| f).unwrap_or(RdfFormat::Turtle)
350}
351
352pub fn infer_dotfile_content_type(path: &str) -> Option<&'static str> {
372 let trimmed = path.trim_end_matches('/');
375 if trimmed.is_empty() {
376 return None;
377 }
378 let basename = trimmed.rsplit('/').next().filter(|s| !s.is_empty())?;
379
380 if basename.ends_with(".acl") || basename.ends_with(".meta") {
384 Some("application/ld+json")
385 } else {
386 None
387 }
388}
389
390pub fn guess_content_type(path: &str) -> String {
405 if let Some(ct) = infer_dotfile_content_type(path) {
406 return ct.to_string();
407 }
408
409 let trimmed = path.trim_end_matches('/');
410 let basename = trimmed.rsplit('/').next().unwrap_or(trimmed);
411 let ext = basename
412 .rsplit_once('.')
413 .map(|(_, e)| e.to_ascii_lowercase())
414 .unwrap_or_default();
415
416 let override_ct = match ext.as_str() {
419 "jsonld" => Some("application/ld+json"),
420 "ttl" => Some("text/turtle"),
421 "n3" => Some("text/n3"),
422 "nt" => Some("application/n-triples"),
423 "rdf" => Some("application/rdf+xml"),
424 "nq" => Some("application/n-quads"),
425 "trig" => Some("application/trig"),
426 "m3u" => Some("audio/mpegurl"),
427 "pls" => Some("audio/x-scpls"),
428 _ => None,
429 };
430 if let Some(ct) = override_ct {
431 return ct.to_string();
432 }
433
434 mime_guess::from_path(trimmed)
435 .first_raw()
436 .map(|s| s.to_string())
437 .unwrap_or_else(|| "application/octet-stream".to_string())
438}
439
440#[cfg(test)]
441mod guess_content_type_tests {
442 use super::guess_content_type;
443
444 #[test]
445 fn solid_overrides_take_priority() {
446 assert_eq!(guess_content_type("/data.ttl"), "text/turtle");
447 assert_eq!(guess_content_type("/card.jsonld"), "application/ld+json");
448 assert_eq!(guess_content_type("/g.nq"), "application/n-quads");
449 assert_eq!(guess_content_type("/list.m3u"), "audio/mpegurl");
450 }
451
452 #[test]
453 fn dotfiles_resolve_to_jsonld() {
454 assert_eq!(guess_content_type("/.acl"), "application/ld+json");
455 assert_eq!(
456 guess_content_type("/publicTypeIndex.jsonld.acl"),
457 "application/ld+json"
458 );
459 }
460
461 #[test]
462 fn mime_db_covers_media_and_web() {
463 assert_eq!(guess_content_type("/app/index.html"), "text/html");
464 assert_eq!(guess_content_type("/song.mp3"), "audio/mpeg");
465 assert_eq!(guess_content_type("/clip.mp4"), "video/mp4");
466 assert_eq!(guess_content_type("/img.png"), "image/png");
467 }
468
469 #[test]
470 fn unknown_extension_falls_back_to_octet_stream() {
471 assert_eq!(guess_content_type("/blob.xyzzy"), "application/octet-stream");
472 assert_eq!(guess_content_type("/noext"), "application/octet-stream");
473 }
474}
475
476#[cfg(test)]
477mod infer_dotfile_tests {
478 use super::infer_dotfile_content_type;
479
480 #[test]
481 fn infer_dotfile_content_type_acl_file_returns_jsonld() {
482 assert_eq!(
483 infer_dotfile_content_type("/.acl"),
484 Some("application/ld+json")
485 );
486 assert_eq!(
487 infer_dotfile_content_type("/pods/alice/foo.acl"),
488 Some("application/ld+json")
489 );
490 assert_eq!(
491 infer_dotfile_content_type(".acl"),
492 Some("application/ld+json")
493 );
494 }
495
496 #[test]
497 fn infer_dotfile_content_type_meta_file_returns_jsonld() {
498 assert_eq!(
499 infer_dotfile_content_type("/.meta"),
500 Some("application/ld+json")
501 );
502 assert_eq!(
503 infer_dotfile_content_type("/pods/alice/foo.meta"),
504 Some("application/ld+json")
505 );
506 }
507
508 #[test]
509 fn infer_dotfile_content_type_dotted_midname_returns_none() {
510 assert_eq!(infer_dotfile_content_type("/foo.acl.bak"), None);
513 assert_eq!(infer_dotfile_content_type("/foo.meta.bak"), None);
514 }
515
516 #[test]
517 fn infer_dotfile_content_type_substring_only_returns_none() {
518 assert_eq!(infer_dotfile_content_type("/not.aclfile"), None);
520 assert_eq!(infer_dotfile_content_type("/some.metainfo"), None);
521 assert_eq!(infer_dotfile_content_type("/plain.txt"), None);
522 }
523
524 #[test]
525 fn infer_dotfile_content_type_trailing_slash_stripped() {
526 assert_eq!(
528 infer_dotfile_content_type("/pods/alice/foo.acl/"),
529 Some("application/ld+json")
530 );
531 assert_eq!(infer_dotfile_content_type("/"), None);
532 assert_eq!(infer_dotfile_content_type(""), None);
533 }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
541pub enum Term {
542 Iri(String),
543 BlankNode(String),
544 Literal {
545 value: String,
546 datatype: Option<String>,
547 language: Option<String>,
548 },
549}
550
551impl Term {
552 pub fn iri(i: impl Into<String>) -> Self {
553 Term::Iri(i.into())
554 }
555 pub fn blank(b: impl Into<String>) -> Self {
556 Term::BlankNode(b.into())
557 }
558 pub fn literal(v: impl Into<String>) -> Self {
559 Term::Literal {
560 value: v.into(),
561 datatype: None,
562 language: None,
563 }
564 }
565 pub fn typed_literal(v: impl Into<String>, dt: impl Into<String>) -> Self {
566 Term::Literal {
567 value: v.into(),
568 datatype: Some(dt.into()),
569 language: None,
570 }
571 }
572
573 fn write_ntriples(&self, out: &mut String) {
574 match self {
575 Term::Iri(i) => {
576 out.push('<');
577 out.push_str(i);
578 out.push('>');
579 }
580 Term::BlankNode(b) => {
581 out.push_str("_:");
582 out.push_str(b);
583 }
584 Term::Literal {
585 value,
586 datatype,
587 language,
588 } => {
589 out.push('"');
590 for c in value.chars() {
591 match c {
592 '\\' => out.push_str("\\\\"),
593 '"' => out.push_str("\\\""),
594 '\n' => out.push_str("\\n"),
595 '\r' => out.push_str("\\r"),
596 '\t' => out.push_str("\\t"),
597 _ => out.push(c),
598 }
599 }
600 out.push('"');
601 if let Some(lang) = language {
602 out.push('@');
603 out.push_str(lang);
604 } else if let Some(dt) = datatype {
605 out.push_str("^^<");
606 out.push_str(dt);
607 out.push('>');
608 }
609 }
610 }
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
615pub struct Triple {
616 pub subject: Term,
617 pub predicate: Term,
618 pub object: Term,
619}
620
621impl Triple {
622 pub fn new(subject: Term, predicate: Term, object: Term) -> Self {
623 Self {
624 subject,
625 predicate,
626 object,
627 }
628 }
629}
630
631#[derive(Debug, Clone, Default, PartialEq, Eq)]
633pub struct Graph {
634 triples: BTreeSet<Triple>,
635}
636
637impl Graph {
638 pub fn new() -> Self {
639 Self {
640 triples: BTreeSet::new(),
641 }
642 }
643
644 pub fn from_triples(triples: impl IntoIterator<Item = Triple>) -> Self {
645 let mut g = Self::new();
646 for t in triples {
647 g.insert(t);
648 }
649 g
650 }
651
652 pub fn insert(&mut self, triple: Triple) {
653 self.triples.insert(triple);
654 }
655
656 pub fn remove(&mut self, triple: &Triple) -> bool {
657 self.triples.remove(triple)
658 }
659
660 pub fn contains(&self, triple: &Triple) -> bool {
661 self.triples.contains(triple)
662 }
663
664 pub fn len(&self) -> usize {
665 self.triples.len()
666 }
667
668 pub fn is_empty(&self) -> bool {
669 self.triples.is_empty()
670 }
671
672 pub fn triples(&self) -> impl Iterator<Item = &Triple> {
673 self.triples.iter()
674 }
675
676 pub fn extend(&mut self, other: &Graph) {
678 for t in &other.triples {
679 self.triples.insert(t.clone());
680 }
681 }
682
683 pub fn subtract(&mut self, other: &Graph) {
685 for t in &other.triples {
686 self.triples.remove(t);
687 }
688 }
689
690 pub fn to_ntriples(&self) -> String {
692 let mut out = String::new();
693 for t in &self.triples {
694 t.subject.write_ntriples(&mut out);
695 out.push(' ');
696 t.predicate.write_ntriples(&mut out);
697 out.push(' ');
698 t.object.write_ntriples(&mut out);
699 out.push_str(" .\n");
700 }
701 out
702 }
703
704 pub fn to_jsonld(&self) -> serde_json::Value {
712 const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
713
714 fn node_ref(term: &Term) -> Option<serde_json::Value> {
715 match term {
716 Term::Iri(i) => Some(serde_json::json!({ "@id": i })),
717 Term::BlankNode(b) => Some(serde_json::json!({ "@id": format!("_:{b}") })),
718 Term::Literal { .. } => None,
719 }
720 }
721
722 fn object_value(term: &Term) -> serde_json::Value {
723 match term {
724 Term::Iri(i) => serde_json::json!({ "@id": i }),
725 Term::BlankNode(b) => serde_json::json!({ "@id": format!("_:{b}") }),
726 Term::Literal {
727 value,
728 datatype,
729 language,
730 } => {
731 let mut obj = serde_json::Map::new();
732 obj.insert("@value".into(), serde_json::Value::String(value.clone()));
733 if let Some(lang) = language {
734 obj.insert("@language".into(), serde_json::Value::String(lang.clone()));
735 } else if let Some(dt) = datatype {
736 obj.insert("@type".into(), serde_json::Value::String(dt.clone()));
737 }
738 serde_json::Value::Object(obj)
739 }
740 }
741 }
742
743 fn subject_id(term: &Term) -> String {
744 match term {
745 Term::Iri(i) => i.clone(),
746 Term::BlankNode(b) => format!("_:{b}"),
747 Term::Literal { value, .. } => value.clone(),
748 }
749 }
750
751 let mut nodes: Vec<serde_json::Map<String, serde_json::Value>> = Vec::new();
754 let mut current_id: Option<String> = None;
755 for t in &self.triples {
756 let sid = subject_id(&t.subject);
757 if current_id.as_deref() != Some(sid.as_str()) {
758 let mut node = serde_json::Map::new();
759 node.insert("@id".into(), serde_json::Value::String(sid.clone()));
760 nodes.push(node);
761 current_id = Some(sid);
762 }
763 let node = nodes.last_mut().expect("node pushed above");
764
765 if let Term::Iri(p) = &t.predicate {
766 if p == RDF_TYPE {
767 if let Some(type_ref) = node_ref(&t.object) {
768 if let Some(serde_json::Value::String(id)) =
769 type_ref.get("@id").cloned()
770 {
771 node.entry("@type")
772 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
773 .as_array_mut()
774 .expect("@type is an array")
775 .push(serde_json::Value::String(id));
776 continue;
777 }
778 }
779 }
780 node.entry(p.clone())
781 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
782 .as_array_mut()
783 .expect("predicate value is an array")
784 .push(object_value(&t.object));
785 }
786 }
787
788 serde_json::Value::Array(nodes.into_iter().map(serde_json::Value::Object).collect())
789 }
790
791 pub fn parse_ntriples(input: &str) -> Result<Self, PodError> {
793 let mut g = Graph::new();
794 for (i, line) in input.lines().enumerate() {
795 let line = line.trim();
796 if line.is_empty() || line.starts_with('#') {
797 continue;
798 }
799 let t = parse_nt_line(line)
800 .map_err(|e| PodError::Unsupported(format!("N-Triples line {}: {e}", i + 1)))?;
801 g.insert(t);
802 }
803 Ok(g)
804 }
805}
806
807fn parse_nt_line(line: &str) -> Result<Triple, String> {
808 let line = line.trim_end_matches('.').trim();
809 let (subject, rest) = read_term(line)?;
810 let rest = rest.trim_start();
811 let (predicate, rest) = read_term(rest)?;
812 let rest = rest.trim_start();
813 let (object, _rest) = read_term(rest)?;
814 Ok(Triple::new(subject, predicate, object))
815}
816
817fn read_term(input: &str) -> Result<(Term, &str), String> {
818 let input = input.trim_start();
819 if let Some(rest) = input.strip_prefix('<') {
820 let end = rest
821 .find('>')
822 .ok_or_else(|| "unterminated IRI".to_string())?;
823 let iri = &rest[..end];
824 Ok((Term::Iri(iri.to_string()), &rest[end + 1..]))
825 } else if let Some(rest) = input.strip_prefix("_:") {
826 let end = rest
827 .find(|c: char| c.is_whitespace() || c == '.')
828 .unwrap_or(rest.len());
829 Ok((Term::BlankNode(rest[..end].to_string()), &rest[end..]))
830 } else if input.starts_with('"') {
831 read_literal(input)
832 } else {
833 Err(format!(
834 "unexpected char: {}",
835 input.chars().next().unwrap_or('?')
836 ))
837 }
838}
839
840fn read_literal(input: &str) -> Result<(Term, &str), String> {
841 let bytes = input.as_bytes();
842 if bytes.first() != Some(&b'"') {
843 return Err("expected '\"'".to_string());
844 }
845 let mut i = 1usize;
846 let mut value = String::new();
847 while i < bytes.len() {
848 match bytes[i] {
849 b'\\' if i + 1 < bytes.len() => {
850 match bytes[i + 1] {
851 b'n' => value.push('\n'),
852 b't' => value.push('\t'),
853 b'r' => value.push('\r'),
854 b'"' => value.push('"'),
855 b'\\' => value.push('\\'),
856 other => value.push(other as char),
857 }
858 i += 2;
859 }
860 b'"' => {
861 i += 1;
862 break;
863 }
864 other => {
865 value.push(other as char);
866 i += 1;
867 }
868 }
869 }
870 let rest = &input[i..];
871 let (datatype, language, rest) = if let Some(r) = rest.strip_prefix("^^<") {
872 let end = r
873 .find('>')
874 .ok_or_else(|| "unterminated datatype IRI".to_string())?;
875 (Some(r[..end].to_string()), None, &r[end + 1..])
876 } else if let Some(r) = rest.strip_prefix('@') {
877 let end = r
878 .find(|c: char| c.is_whitespace() || c == '.')
879 .unwrap_or(r.len());
880 (None, Some(r[..end].to_string()), &r[end..])
881 } else {
882 (None, None, rest)
883 };
884 Ok((
885 Term::Literal {
886 value,
887 datatype,
888 language,
889 },
890 rest,
891 ))
892}
893
894pub fn server_managed_triples(
901 resource_iri: &str,
902 modified: chrono::DateTime<chrono::Utc>,
903 size: u64,
904 is_container_flag: bool,
905 contained: &[String],
906) -> Graph {
907 let mut g = Graph::new();
908 let subject = Term::iri(resource_iri);
909
910 g.insert(Triple::new(
911 subject.clone(),
912 Term::iri(iri::DCTERMS_MODIFIED),
913 Term::typed_literal(modified.to_rfc3339(), iri::XSD_DATETIME),
914 ));
915 g.insert(Triple::new(
916 subject.clone(),
917 Term::iri(iri::STAT_SIZE),
918 Term::typed_literal(size.to_string(), iri::XSD_INTEGER),
919 ));
920 g.insert(Triple::new(
921 subject.clone(),
922 Term::iri(iri::STAT_MTIME),
923 Term::typed_literal(modified.timestamp().to_string(), iri::XSD_INTEGER),
924 ));
925
926 if is_container_flag {
927 for child in contained {
928 let base = if resource_iri.ends_with('/') {
929 resource_iri.to_string()
930 } else {
931 format!("{resource_iri}/")
932 };
933 g.insert(Triple::new(
934 subject.clone(),
935 Term::iri(iri::LDP_CONTAINS),
936 Term::iri(format!("{base}{child}")),
937 ));
938 }
939 }
940 g
941}
942
943pub const SERVER_MANAGED_PREDICATES: &[&str] = &[
946 iri::DCTERMS_MODIFIED,
947 iri::STAT_SIZE,
948 iri::STAT_MTIME,
949 iri::LDP_CONTAINS,
950];
951
952pub fn find_illegal_server_managed(graph: &Graph) -> Vec<Triple> {
955 graph
956 .triples()
957 .filter(|t| {
958 if let Term::Iri(p) = &t.predicate {
959 SERVER_MANAGED_PREDICATES.iter().any(|sm| sm == p)
960 } else {
961 false
962 }
963 })
964 .cloned()
965 .collect()
966}
967
968#[derive(Debug, Serialize)]
973pub struct ContainerMember {
974 #[serde(rename = "@id")]
975 pub id: String,
976 #[serde(rename = "@type")]
977 pub types: Vec<&'static str>,
978}
979
980pub fn render_container_jsonld(
982 container_path: &str,
983 members: &[String],
984 prefer: PreferHeader,
985) -> serde_json::Value {
986 let base = if container_path.ends_with('/') {
987 container_path.to_string()
988 } else {
989 format!("{container_path}/")
990 };
991
992 match prefer.representation {
993 ContainerRepresentation::ContainedIRIsOnly => serde_json::json!({
994 "@id": container_path,
995 "ldp:contains": members
996 .iter()
997 .map(|m| serde_json::json!({"@id": format!("{base}{m}")}))
998 .collect::<Vec<_>>(),
999 }),
1000 ContainerRepresentation::MinimalContainer => serde_json::json!({
1001 "@context": {
1002 "ldp": iri::LDP_NS,
1003 "dcterms": iri::DCTERMS_NS,
1004 },
1005 "@id": container_path,
1006 "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
1007 }),
1008 ContainerRepresentation::Full => {
1009 let contains: Vec<ContainerMember> = members
1010 .iter()
1011 .map(|m| {
1012 let is_dir = m.ends_with('/');
1013 ContainerMember {
1014 id: format!("{base}{m}"),
1015 types: if is_dir {
1016 vec![
1017 iri::LDP_BASIC_CONTAINER,
1018 iri::LDP_CONTAINER,
1019 iri::LDP_RESOURCE,
1020 ]
1021 } else {
1022 vec![iri::LDP_RESOURCE]
1023 },
1024 }
1025 })
1026 .collect();
1027 serde_json::json!({
1028 "@context": {
1029 "ldp": iri::LDP_NS,
1030 "dcterms": iri::DCTERMS_NS,
1031 "contains": { "@id": "ldp:contains", "@type": "@id" },
1032 },
1033 "@id": container_path,
1034 "@type": [ "ldp:Container", "ldp:BasicContainer", "ldp:Resource" ],
1035 "ldp:contains": contains,
1036 })
1037 }
1038 }
1039}
1040
1041pub fn render_container(container_path: &str, members: &[String]) -> serde_json::Value {
1043 render_container_jsonld(container_path, members, PreferHeader::default())
1044}
1045
1046pub fn render_container_turtle(
1048 container_path: &str,
1049 members: &[String],
1050 prefer: PreferHeader,
1051) -> String {
1052 let base = if container_path.ends_with('/') {
1053 container_path.to_string()
1054 } else {
1055 format!("{container_path}/")
1056 };
1057 let mut out = String::new();
1058 let _ = writeln!(out, "@prefix ldp: <{}> .", iri::LDP_NS);
1059 let _ = writeln!(out, "@prefix dcterms: <{}> .", iri::DCTERMS_NS);
1060 let _ = writeln!(out);
1061 match prefer.representation {
1062 ContainerRepresentation::ContainedIRIsOnly => {
1063 let _ = writeln!(out, "<{container_path}> ldp:contains");
1064 let list: Vec<String> = members.iter().map(|m| format!(" <{base}{m}>")).collect();
1065 let _ = writeln!(out, "{} .", list.join(",\n"));
1066 }
1067 ContainerRepresentation::MinimalContainer => {
1068 let _ = writeln!(
1069 out,
1070 "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ."
1071 );
1072 }
1073 ContainerRepresentation::Full => {
1074 let _ = writeln!(
1075 out,
1076 "<{container_path}> a ldp:BasicContainer, ldp:Container, ldp:Resource ;"
1077 );
1078 if members.is_empty() {
1079 let fixed = out.trim_end().trim_end_matches(';').to_string();
1081 out = fixed;
1082 out.push_str(" .\n");
1083 } else {
1084 let list: Vec<String> = members
1085 .iter()
1086 .map(|m| format!(" ldp:contains <{base}{m}>"))
1087 .collect();
1088 let _ = writeln!(out, "{} .", list.join(" ;\n"));
1089 }
1090 }
1091 }
1092 out
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq)]
1101pub struct PatchOutcome {
1102 pub graph: Graph,
1104 pub inserted: usize,
1106 pub deleted: usize,
1108}
1109
1110pub fn apply_n3_patch(target: Graph, patch: &str) -> Result<PatchOutcome, PodError> {
1125 let inserts = extract_block(patch, &["insert", "inserts", "solid:inserts"]).unwrap_or_default();
1126 let deletes = extract_block(patch, &["delete", "deletes", "solid:deletes"]).unwrap_or_default();
1127 let where_clause = extract_block(patch, &["where", "solid:where"]);
1128
1129 let insert_graph = if !inserts.is_empty() {
1130 Graph::parse_ntriples(&strip_braces(&inserts))?
1131 } else {
1132 Graph::new()
1133 };
1134 let delete_graph = if !deletes.is_empty() {
1135 Graph::parse_ntriples(&strip_braces(&deletes))?
1136 } else {
1137 Graph::new()
1138 };
1139
1140 if let Some(wc) = where_clause {
1146 if !wc.trim().is_empty() {
1147 let where_graph = Graph::parse_ntriples(&strip_braces(&wc))?;
1148 for t in where_graph.triples() {
1149 if !target.contains(t) {
1150 return Err(PodError::PreconditionFailed(format!(
1151 "WHERE clause triple missing: {t:?}"
1152 )));
1153 }
1154 }
1155 }
1156 }
1157
1158 let mut graph = target;
1159 let inserted_count = insert_graph.len();
1160 let deleted_count = delete_graph.triples().filter(|t| graph.contains(t)).count();
1161 graph.subtract(&delete_graph);
1162 graph.extend(&insert_graph);
1163
1164 Ok(PatchOutcome {
1165 graph,
1166 inserted: inserted_count,
1167 deleted: deleted_count,
1168 })
1169}
1170
1171fn extract_block(source: &str, keywords: &[&str]) -> Option<String> {
1172 let lower = source.to_ascii_lowercase();
1179 let bytes = lower.as_bytes();
1180 for kw in keywords {
1181 let needle = kw.to_ascii_lowercase();
1182 let mut search_from = 0usize;
1183 while let Some(pos) = lower[search_from..].find(&needle) {
1184 let abs = search_from + pos;
1185 let after_kw = abs + needle.len();
1186 search_from = abs + needle.len();
1187
1188 let left_ok = if abs == 0 {
1193 true
1194 } else {
1195 let prev = bytes[abs - 1];
1196 !(prev.is_ascii_alphanumeric() || prev == b'_')
1197 };
1198 if !left_ok {
1199 continue;
1200 }
1201
1202 let tail = &source[after_kw..];
1204 let trimmed = tail.trim_start();
1205 if !trimmed.starts_with('{') {
1206 continue;
1207 }
1208 let open = after_kw + (tail.len() - trimmed.len());
1209
1210 let mut depth = 0i32;
1212 let mut end = None;
1213 for (i, c) in source[open..].char_indices() {
1214 match c {
1215 '{' => depth += 1,
1216 '}' => {
1217 depth -= 1;
1218 if depth == 0 {
1219 end = Some(open + i + 1);
1220 break;
1221 }
1222 }
1223 _ => {}
1224 }
1225 }
1226 if let Some(e) = end {
1227 return Some(source[open..e].to_string());
1228 }
1229 }
1230 }
1231 None
1232}
1233
1234fn strip_braces(block: &str) -> String {
1235 let t = block.trim();
1236 let t = t.strip_prefix('{').unwrap_or(t);
1237 let t = t.strip_suffix('}').unwrap_or(t);
1238 t.trim().to_string()
1239}
1240
1241pub const SPARQL_UPDATE_MAX_BYTES: usize = 1_048_576; pub fn apply_sparql_patch(target: Graph, update: &str) -> Result<PatchOutcome, PodError> {
1252 if update.len() > SPARQL_UPDATE_MAX_BYTES {
1253 return Err(PodError::BadRequest(format!(
1254 "SPARQL-Update body exceeds {} byte limit ({} bytes)",
1255 SPARQL_UPDATE_MAX_BYTES,
1256 update.len(),
1257 )));
1258 }
1259
1260 use spargebra::term::{
1261 GraphName, GraphNamePattern, GroundQuad, GroundQuadPattern, GroundSubject, GroundTerm,
1262 GroundTermPattern, NamedNodePattern, Quad, Subject, Term as SpTerm,
1263 };
1264 use spargebra::{GraphUpdateOperation, Update};
1265
1266 let parsed = Update::parse(update, None)
1267 .map_err(|e| PodError::Unsupported(format!("SPARQL parse error: {e}")))?;
1268
1269 fn build_literal(value: String, datatype: Option<String>, language: Option<String>) -> Term {
1276 let datatype = datatype.filter(|d| d != iri::XSD_STRING);
1277 Term::Literal {
1278 value,
1279 datatype,
1280 language,
1281 }
1282 }
1283
1284 fn map_subject(s: &Subject) -> Option<Term> {
1285 match s {
1286 Subject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1287 Subject::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1288 #[allow(unreachable_patterns)]
1289 _ => None,
1290 }
1291 }
1292 fn map_term(t: &SpTerm) -> Option<Term> {
1293 match t {
1294 SpTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1295 SpTerm::BlankNode(b) => Some(Term::BlankNode(b.as_str().to_string())),
1296 SpTerm::Literal(lit) => {
1297 let value = lit.value().to_string();
1298 if let Some(lang) = lit.language() {
1299 Some(build_literal(value, None, Some(lang.to_string())))
1300 } else {
1301 Some(build_literal(
1302 value,
1303 Some(lit.datatype().as_str().to_string()),
1304 None,
1305 ))
1306 }
1307 }
1308 #[allow(unreachable_patterns)]
1309 _ => None,
1310 }
1311 }
1312 fn map_ground_subject(s: &GroundSubject) -> Option<Term> {
1313 match s {
1314 GroundSubject::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1315 #[allow(unreachable_patterns)]
1316 _ => None,
1317 }
1318 }
1319 fn map_ground_term(t: &GroundTerm) -> Option<Term> {
1320 match t {
1321 GroundTerm::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1322 GroundTerm::Literal(lit) => {
1323 let value = lit.value().to_string();
1324 if let Some(lang) = lit.language() {
1325 Some(build_literal(value, None, Some(lang.to_string())))
1326 } else {
1327 Some(build_literal(
1328 value,
1329 Some(lit.datatype().as_str().to_string()),
1330 None,
1331 ))
1332 }
1333 }
1334 #[allow(unreachable_patterns)]
1335 _ => None,
1336 }
1337 }
1338 fn map_ground_term_pattern(t: &GroundTermPattern) -> Option<Term> {
1339 match t {
1340 GroundTermPattern::NamedNode(n) => Some(Term::Iri(n.as_str().to_string())),
1341 GroundTermPattern::Literal(lit) => {
1342 let value = lit.value().to_string();
1343 if let Some(lang) = lit.language() {
1344 Some(build_literal(value, None, Some(lang.to_string())))
1345 } else {
1346 Some(build_literal(
1347 value,
1348 Some(lit.datatype().as_str().to_string()),
1349 None,
1350 ))
1351 }
1352 }
1353 _ => None,
1354 }
1355 }
1356
1357 fn quad_to_triple(q: &Quad) -> Option<Triple> {
1358 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1359 return None;
1360 }
1361 Some(Triple::new(
1362 map_subject(&q.subject)?,
1363 Term::Iri(q.predicate.as_str().to_string()),
1364 map_term(&q.object)?,
1365 ))
1366 }
1367 fn ground_quad_to_triple(q: &GroundQuad) -> Option<Triple> {
1368 if !matches!(q.graph_name, GraphName::DefaultGraph) {
1369 return None;
1370 }
1371 Some(Triple::new(
1372 map_ground_subject(&q.subject)?,
1373 Term::Iri(q.predicate.as_str().to_string()),
1374 map_ground_term(&q.object)?,
1375 ))
1376 }
1377 fn ground_quad_pattern_to_triple(q: &GroundQuadPattern) -> Option<Triple> {
1378 if !matches!(q.graph_name, GraphNamePattern::DefaultGraph) {
1379 return None;
1380 }
1381 let predicate = match &q.predicate {
1382 NamedNodePattern::NamedNode(n) => Term::Iri(n.as_str().to_string()),
1383 NamedNodePattern::Variable(_) => return None,
1384 };
1385 Some(Triple::new(
1386 map_ground_term_pattern(&q.subject)?,
1387 predicate,
1388 map_ground_term_pattern(&q.object)?,
1389 ))
1390 }
1391
1392 let mut graph = target;
1393 let mut inserted = 0usize;
1394 let mut deleted = 0usize;
1395
1396 for op in &parsed.operations {
1397 match op {
1398 GraphUpdateOperation::InsertData { data } => {
1399 for q in data {
1400 if let Some(tr) = quad_to_triple(q) {
1401 if !graph.contains(&tr) {
1402 graph.insert(tr);
1403 inserted += 1;
1404 }
1405 }
1406 }
1407 }
1408 GraphUpdateOperation::DeleteData { data } => {
1409 for q in data {
1410 if let Some(tr) = ground_quad_to_triple(q) {
1411 if graph.remove(&tr) {
1412 deleted += 1;
1413 }
1414 }
1415 }
1416 }
1417 GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
1418 for q in delete {
1419 if let Some(tr) = ground_quad_pattern_to_triple(q) {
1420 if graph.remove(&tr) {
1421 deleted += 1;
1422 }
1423 }
1424 }
1425 for q in insert {
1426 let gqp = match convert_quad_pattern_to_ground(q) {
1431 Some(g) => g,
1432 None => continue,
1433 };
1434 if let Some(tr) = ground_quad_pattern_to_triple(&gqp) {
1435 if !graph.contains(&tr) {
1436 graph.insert(tr);
1437 inserted += 1;
1438 }
1439 }
1440 }
1441 }
1442 _ => {
1443 return Err(PodError::Unsupported(format!(
1444 "unsupported SPARQL operation: {op:?}"
1445 )));
1446 }
1447 }
1448 }
1449
1450 Ok(PatchOutcome {
1451 graph,
1452 inserted,
1453 deleted,
1454 })
1455}
1456
1457fn convert_quad_pattern_to_ground(
1458 q: &spargebra::term::QuadPattern,
1459) -> Option<spargebra::term::GroundQuadPattern> {
1460 use spargebra::term::{
1461 GraphNamePattern, GroundQuadPattern, GroundTermPattern, NamedNodePattern, TermPattern,
1462 };
1463
1464 let subject = match &q.subject {
1465 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1466 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1467 _ => return None,
1468 };
1469 let predicate = match &q.predicate {
1470 NamedNodePattern::NamedNode(n) => NamedNodePattern::NamedNode(n.clone()),
1471 NamedNodePattern::Variable(_) => return None,
1472 };
1473 let object = match &q.object {
1474 TermPattern::NamedNode(n) => GroundTermPattern::NamedNode(n.clone()),
1475 TermPattern::Literal(l) => GroundTermPattern::Literal(l.clone()),
1476 _ => return None,
1477 };
1478 let graph_name = match &q.graph_name {
1479 GraphNamePattern::DefaultGraph => GraphNamePattern::DefaultGraph,
1480 GraphNamePattern::NamedNode(n) => GraphNamePattern::NamedNode(n.clone()),
1481 GraphNamePattern::Variable(_) => return None,
1482 };
1483 Some(GroundQuadPattern {
1484 subject,
1485 predicate,
1486 object,
1487 graph_name,
1488 })
1489}
1490
1491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1498pub enum ConditionalOutcome {
1499 Proceed,
1501 PreconditionFailed,
1504 NotModified,
1507}
1508
1509pub fn evaluate_preconditions(
1521 method: &str,
1522 current_etag: Option<&str>,
1523 if_match: Option<&str>,
1524 if_none_match: Option<&str>,
1525) -> ConditionalOutcome {
1526 let method_upper = method.to_ascii_uppercase();
1527 let safe = method_upper == "GET" || method_upper == "HEAD";
1528
1529 if let Some(im) = if_match {
1530 let raw = im.trim();
1531 if raw == "*" {
1532 if current_etag.is_none() {
1533 return ConditionalOutcome::PreconditionFailed;
1534 }
1535 } else {
1536 let wanted = parse_etag_list(raw);
1537 match current_etag {
1538 None => return ConditionalOutcome::PreconditionFailed,
1539 Some(cur) => {
1540 if !wanted.iter().any(|w| w == cur || w == "*") {
1541 return ConditionalOutcome::PreconditionFailed;
1542 }
1543 }
1544 }
1545 }
1546 }
1547
1548 if let Some(inm) = if_none_match {
1549 let raw = inm.trim();
1550 if raw == "*" {
1551 if current_etag.is_some() {
1552 if safe {
1553 return ConditionalOutcome::NotModified;
1554 }
1555 return ConditionalOutcome::PreconditionFailed;
1556 }
1557 } else {
1558 let wanted = parse_etag_list(raw);
1559 if let Some(cur) = current_etag {
1560 if wanted.iter().any(|w| w == cur) {
1561 if safe {
1562 return ConditionalOutcome::NotModified;
1563 }
1564 return ConditionalOutcome::PreconditionFailed;
1565 }
1566 }
1567 }
1568 }
1569
1570 ConditionalOutcome::Proceed
1571}
1572
1573fn parse_etag_list(input: &str) -> Vec<String> {
1574 input
1575 .split(',')
1576 .map(|s| s.trim())
1577 .filter(|s| !s.is_empty())
1578 .map(|s| {
1579 let s = s.strip_prefix("W/").unwrap_or(s);
1581 s.trim_matches('"').to_string()
1582 })
1583 .collect()
1584}
1585
1586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1592pub struct ByteRange {
1593 pub start: u64,
1594 pub end: u64,
1595}
1596
1597impl ByteRange {
1598 pub fn length(&self) -> u64 {
1599 self.end.saturating_sub(self.start) + 1
1600 }
1601 pub fn content_range(&self, total: u64) -> String {
1604 format!("bytes {}-{}/{}", self.start, self.end, total)
1605 }
1606}
1607
1608pub fn parse_range_header(header: Option<&str>, total: u64) -> Result<Option<ByteRange>, PodError> {
1618 let raw = match header {
1619 Some(v) if !v.trim().is_empty() => v.trim(),
1620 _ => return Ok(None),
1621 };
1622 let spec = raw
1623 .strip_prefix("bytes=")
1624 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1625 if spec.contains(',') {
1626 return Err(PodError::Unsupported(
1627 "multi-range requests not supported".into(),
1628 ));
1629 }
1630 let (start_s, end_s) = spec
1631 .split_once('-')
1632 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1633 if total == 0 {
1634 return Err(PodError::PreconditionFailed(
1635 "range request against empty resource".into(),
1636 ));
1637 }
1638
1639 let range = if start_s.is_empty() {
1640 let suffix: u64 = end_s
1642 .parse()
1643 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1644 if suffix == 0 {
1645 return Err(PodError::PreconditionFailed("zero suffix length".into()));
1646 }
1647 let start = total.saturating_sub(suffix);
1648 ByteRange {
1649 start,
1650 end: total - 1,
1651 }
1652 } else {
1653 let start: u64 = start_s
1654 .parse()
1655 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1656 let end = if end_s.is_empty() {
1657 total - 1
1658 } else {
1659 let v: u64 = end_s
1660 .parse()
1661 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1662 v.min(total - 1)
1663 };
1664 if start > end {
1665 return Err(PodError::PreconditionFailed(format!(
1666 "unsatisfiable range: {start}-{end}"
1667 )));
1668 }
1669 if start >= total {
1670 return Err(PodError::PreconditionFailed(format!(
1671 "range start {start} >= total {total}"
1672 )));
1673 }
1674 ByteRange { start, end }
1675 };
1676 Ok(Some(range))
1677}
1678
1679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1683pub enum RangeOutcome {
1684 Full,
1685 Partial(ByteRange),
1686 NotSatisfiable,
1687}
1688
1689pub fn parse_range_header_v2(header: Option<&str>, total: u64) -> Result<RangeOutcome, PodError> {
1694 let raw = match header {
1695 Some(v) if !v.trim().is_empty() => v.trim(),
1696 _ => return Ok(RangeOutcome::Full),
1697 };
1698 let spec = raw
1699 .strip_prefix("bytes=")
1700 .ok_or_else(|| PodError::Unsupported(format!("unsupported Range unit: {raw}")))?;
1701 if spec.contains(',') {
1702 return Err(PodError::Unsupported("multi-range not supported".into()));
1703 }
1704 let (start_s, end_s) = spec
1705 .split_once('-')
1706 .ok_or_else(|| PodError::Unsupported(format!("malformed Range: {spec}")))?;
1707 if total == 0 {
1708 return Ok(RangeOutcome::NotSatisfiable);
1709 }
1710 let range = if start_s.is_empty() {
1711 let suffix: u64 = end_s
1712 .parse()
1713 .map_err(|e| PodError::Unsupported(format!("range suffix parse: {e}")))?;
1714 if suffix == 0 {
1715 return Ok(RangeOutcome::NotSatisfiable);
1716 }
1717 ByteRange {
1718 start: total.saturating_sub(suffix),
1719 end: total - 1,
1720 }
1721 } else {
1722 let start: u64 = start_s
1723 .parse()
1724 .map_err(|e| PodError::Unsupported(format!("range start parse: {e}")))?;
1725 let end = if end_s.is_empty() {
1726 total - 1
1727 } else {
1728 let v: u64 = end_s
1729 .parse()
1730 .map_err(|e| PodError::Unsupported(format!("range end parse: {e}")))?;
1731 v.min(total - 1)
1732 };
1733 if start > end || start >= total {
1734 return Ok(RangeOutcome::NotSatisfiable);
1735 }
1736 ByteRange { start, end }
1737 };
1738 Ok(RangeOutcome::Partial(range))
1739}
1740
1741pub fn slice_range(body: &[u8], range: ByteRange) -> &[u8] {
1745 let end_excl = (range.end as usize + 1).min(body.len());
1746 let start = (range.start as usize).min(end_excl);
1747 &body[start..end_excl]
1748}
1749
1750#[derive(Debug, Clone)]
1764pub struct OptionsResponse {
1765 pub allow: Vec<&'static str>,
1766 pub accept_post: Option<&'static str>,
1767 pub accept_patch: &'static str,
1768 pub accept_ranges: &'static str,
1769 pub cache_control: &'static str,
1770}
1771
1772pub const ACCEPT_PATCH: &str = "text/n3, application/sparql-update, application/json-patch+json";
1774
1775pub fn options_for(path: &str) -> OptionsResponse {
1776 let container = is_container(path);
1777 let mut allow = vec!["GET", "HEAD", "OPTIONS"];
1778 if container {
1779 allow.push("POST");
1780 allow.push("PUT");
1781 } else {
1782 allow.push("PUT");
1783 allow.push("PATCH");
1784 }
1785 allow.push("DELETE");
1786 OptionsResponse {
1787 allow,
1788 accept_post: if container { Some(ACCEPT_POST) } else { None },
1789 accept_patch: ACCEPT_PATCH,
1790 accept_ranges: if container { "none" } else { "bytes" },
1795 cache_control: CACHE_CONTROL_RDF,
1799 }
1800}
1801
1802pub fn not_found_headers(path: &str, conneg_enabled: bool) -> Vec<(&'static str, String)> {
1817 let container = is_container(path);
1818 let mut h: Vec<(&'static str, String)> = Vec::with_capacity(6);
1819 h.push(("Allow", "GET, HEAD, OPTIONS, PUT, PATCH".into()));
1820 h.push(("Accept-Put", "*/*".into()));
1821 h.push(("Accept-Patch", ACCEPT_PATCH.into()));
1822 h.push((
1823 "Link",
1824 format!("<{}.acl>; rel=\"acl\"", path.trim_end_matches('/')),
1825 ));
1826 h.push(("Vary", vary_header(conneg_enabled).into()));
1827 if conneg_enabled {
1832 h.push(("Cache-Control", CACHE_CONTROL_RDF.into()));
1833 }
1834 if container {
1835 h.push(("Accept-Post", ACCEPT_POST.into()));
1836 }
1837 h
1838}
1839
1840pub fn vary_header(conneg_enabled: bool) -> &'static str {
1845 if conneg_enabled {
1846 "Accept, Authorization, Origin"
1847 } else {
1848 "Authorization, Origin"
1849 }
1850}
1851
1852pub const CACHE_CONTROL_RDF: &str = "private, no-cache, must-revalidate";
1861
1862pub fn is_rdf_content_type(content_type: &str) -> bool {
1868 let base = content_type
1869 .split(';')
1870 .next()
1871 .unwrap_or("")
1872 .trim()
1873 .to_ascii_lowercase();
1874 matches!(
1875 base.as_str(),
1876 "text/turtle"
1877 | "application/turtle"
1878 | "application/x-turtle"
1879 | "application/ld+json"
1880 | "application/json+ld"
1881 | "application/n-triples"
1882 | "text/plain+ntriples"
1883 | "text/n3"
1884 | "application/trig"
1885 )
1886}
1887
1888pub fn cache_control_for(content_type: &str) -> Option<&'static str> {
1893 if is_rdf_content_type(content_type) {
1894 Some(CACHE_CONTROL_RDF)
1895 } else {
1896 None
1897 }
1898}
1899
1900pub fn apply_json_patch(
1910 target: &mut serde_json::Value,
1911 patch: &serde_json::Value,
1912) -> Result<(), PodError> {
1913 let ops = patch
1914 .as_array()
1915 .ok_or_else(|| PodError::Unsupported("JSON Patch must be an array".into()))?;
1916 for op in ops {
1917 let op_name = op
1918 .get("op")
1919 .and_then(|v| v.as_str())
1920 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'op'".into()))?;
1921 let path = op
1922 .get("path")
1923 .and_then(|v| v.as_str())
1924 .ok_or_else(|| PodError::Unsupported("JSON Patch op missing 'path'".into()))?;
1925 match op_name {
1926 "add" => {
1927 let value = op
1928 .get("value")
1929 .cloned()
1930 .ok_or_else(|| PodError::Unsupported("add requires value".into()))?;
1931 json_pointer_set(target, path, value, true)?;
1932 }
1933 "replace" => {
1934 let value = op
1935 .get("value")
1936 .cloned()
1937 .ok_or_else(|| PodError::Unsupported("replace requires value".into()))?;
1938 json_pointer_set(target, path, value, false)?;
1939 }
1940 "remove" => {
1941 json_pointer_remove(target, path)?;
1942 }
1943 "test" => {
1944 let value = op
1945 .get("value")
1946 .ok_or_else(|| PodError::Unsupported("test requires value".into()))?;
1947 let actual = json_pointer_get(target, path).ok_or_else(|| {
1948 PodError::PreconditionFailed(format!("test path missing: {path}"))
1949 })?;
1950 if actual != value {
1951 return Err(PodError::PreconditionFailed(format!(
1952 "test failed at {path}"
1953 )));
1954 }
1955 }
1956 "copy" => {
1957 let from = op
1958 .get("from")
1959 .and_then(|v| v.as_str())
1960 .ok_or_else(|| PodError::Unsupported("copy requires from".into()))?;
1961 let value = json_pointer_get(target, from).cloned().ok_or_else(|| {
1962 PodError::PreconditionFailed(format!("copy from missing: {from}"))
1963 })?;
1964 json_pointer_set(target, path, value, true)?;
1965 }
1966 "move" => {
1967 let from = op
1968 .get("from")
1969 .and_then(|v| v.as_str())
1970 .ok_or_else(|| PodError::Unsupported("move requires from".into()))?;
1971 let value = json_pointer_get(target, from).cloned().ok_or_else(|| {
1972 PodError::PreconditionFailed(format!("move from missing: {from}"))
1973 })?;
1974 json_pointer_remove(target, from)?;
1975 json_pointer_set(target, path, value, true)?;
1976 }
1977 other => {
1978 return Err(PodError::Unsupported(format!(
1979 "unsupported JSON Patch op: {other}"
1980 )));
1981 }
1982 }
1983 }
1984 Ok(())
1985}
1986
1987fn json_pointer_get<'a>(
1988 target: &'a serde_json::Value,
1989 path: &str,
1990) -> Option<&'a serde_json::Value> {
1991 if path.is_empty() {
1992 return Some(target);
1993 }
1994 target.pointer(path)
1995}
1996
1997fn json_pointer_remove(target: &mut serde_json::Value, path: &str) -> Result<(), PodError> {
1998 if path.is_empty() {
1999 return Err(PodError::Unsupported("cannot remove root".into()));
2000 }
2001 let (parent_path, last) = split_pointer(path);
2002 let parent = target
2003 .pointer_mut(&parent_path)
2004 .ok_or_else(|| PodError::PreconditionFailed(format!("remove path missing: {path}")))?;
2005 match parent {
2006 serde_json::Value::Object(m) => {
2007 m.remove(&last).ok_or_else(|| {
2008 PodError::PreconditionFailed(format!("remove key missing: {path}"))
2009 })?;
2010 Ok(())
2011 }
2012 serde_json::Value::Array(a) => {
2013 let idx: usize = last.parse().map_err(|_| {
2014 PodError::Unsupported(format!("remove array index not numeric: {last}"))
2015 })?;
2016 if idx >= a.len() {
2017 return Err(PodError::PreconditionFailed(format!(
2018 "remove array out of bounds: {idx}"
2019 )));
2020 }
2021 a.remove(idx);
2022 Ok(())
2023 }
2024 _ => Err(PodError::PreconditionFailed(format!(
2025 "remove target is not container: {path}"
2026 ))),
2027 }
2028}
2029
2030fn json_pointer_set(
2031 target: &mut serde_json::Value,
2032 path: &str,
2033 value: serde_json::Value,
2034 add_mode: bool,
2035) -> Result<(), PodError> {
2036 if path.is_empty() {
2037 *target = value;
2038 return Ok(());
2039 }
2040 let (parent_path, last) = split_pointer(path);
2041 let parent = target
2042 .pointer_mut(&parent_path)
2043 .ok_or_else(|| PodError::PreconditionFailed(format!("set parent missing: {path}")))?;
2044 match parent {
2045 serde_json::Value::Object(m) => {
2046 if !add_mode && !m.contains_key(&last) {
2047 return Err(PodError::PreconditionFailed(format!(
2048 "replace missing key: {path}"
2049 )));
2050 }
2051 m.insert(last, value);
2052 Ok(())
2053 }
2054 serde_json::Value::Array(a) => {
2055 if last == "-" {
2056 a.push(value);
2057 return Ok(());
2058 }
2059 let idx: usize = last
2060 .parse()
2061 .map_err(|_| PodError::Unsupported(format!("array index not numeric: {last}")))?;
2062 if add_mode {
2063 if idx > a.len() {
2064 return Err(PodError::PreconditionFailed(format!(
2065 "array add out of bounds: {idx}"
2066 )));
2067 }
2068 a.insert(idx, value);
2069 } else {
2070 if idx >= a.len() {
2071 return Err(PodError::PreconditionFailed(format!(
2072 "array replace out of bounds: {idx}"
2073 )));
2074 }
2075 a[idx] = value;
2076 }
2077 Ok(())
2078 }
2079 _ => Err(PodError::PreconditionFailed(format!(
2080 "set parent not container: {path}"
2081 ))),
2082 }
2083}
2084
2085fn split_pointer(path: &str) -> (String, String) {
2086 match path.rfind('/') {
2087 Some(pos) => {
2088 let parent = path[..pos].to_string();
2089 let last_raw = &path[pos + 1..];
2090 let last = last_raw.replace("~1", "/").replace("~0", "~");
2091 (parent, last)
2092 }
2093 None => (String::new(), path.to_string()),
2094 }
2095}
2096
2097#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2099pub enum PatchDialect {
2100 N3,
2101 SparqlUpdate,
2102 JsonPatch,
2103}
2104
2105pub fn patch_dialect_from_mime(mime: &str) -> Option<PatchDialect> {
2106 let m = mime
2107 .split(';')
2108 .next()
2109 .unwrap_or("")
2110 .trim()
2111 .to_ascii_lowercase();
2112 match m.as_str() {
2113 "text/n3" | "application/n3" => Some(PatchDialect::N3),
2114 "application/sparql-update" | "application/sparql-update+update" => {
2115 Some(PatchDialect::SparqlUpdate)
2116 }
2117 "application/json-patch+json" => Some(PatchDialect::JsonPatch),
2118 _ => None,
2119 }
2120}
2121
2122#[derive(Debug)]
2140pub enum PatchCreateOutcome {
2141 Created { inserted: usize, graph: Graph },
2143 Applied {
2146 inserted: usize,
2147 deleted: usize,
2148 graph: Graph,
2149 },
2150}
2151
2152pub fn apply_patch_to_absent(
2156 dialect: PatchDialect,
2157 body: &str,
2158) -> Result<PatchCreateOutcome, PodError> {
2159 match dialect {
2160 PatchDialect::N3 => {
2161 let outcome = apply_n3_patch(Graph::new(), body)?;
2162 Ok(PatchCreateOutcome::Created {
2163 inserted: outcome.inserted,
2164 graph: outcome.graph,
2165 })
2166 }
2167 PatchDialect::SparqlUpdate => {
2168 let outcome = apply_sparql_patch(Graph::new(), body)?;
2169 Ok(PatchCreateOutcome::Created {
2170 inserted: outcome.inserted,
2171 graph: outcome.graph,
2172 })
2173 }
2174 PatchDialect::JsonPatch => Err(PodError::Unsupported(
2175 "JSON Patch on absent resource".into(),
2176 )),
2177 }
2178}
2179
2180#[cfg(feature = "tokio-runtime")]
2185#[async_trait]
2186pub trait LdpContainerOps: Storage {
2187 async fn container_representation(&self, path: &str) -> Result<serde_json::Value, PodError> {
2188 let children = self.list(path).await?;
2189 Ok(render_container(path, &children))
2190 }
2191}
2192
2193#[cfg(feature = "tokio-runtime")]
2194impl<T: Storage + ?Sized> LdpContainerOps for T {}
2195
2196#[cfg(test)]
2201mod tests {
2202 use super::*;
2203
2204 #[test]
2205 fn is_container_detects_trailing_slash() {
2206 assert!(is_container("/"));
2207 assert!(is_container("/media/"));
2208 assert!(!is_container("/file.txt"));
2209 }
2210
2211 #[test]
2212 fn link_headers_include_acl_and_describedby() {
2213 let hdrs = link_headers("/profile/card");
2214 assert!(hdrs.iter().any(|h| h.contains("rel=\"type\"")));
2215 assert!(hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2216 assert!(hdrs.iter().any(|h| h.contains("/profile/card.acl")));
2217 assert!(hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2218 assert!(hdrs.iter().any(|h| h.contains("/profile/card.meta")));
2219 }
2220
2221 #[test]
2222 fn link_headers_root_exposes_pim_storage() {
2223 let hdrs = link_headers("/");
2224 let joined = hdrs.join(",");
2225 assert!(joined.contains("http://www.w3.org/ns/pim/space#storage"));
2226 }
2227
2228 #[test]
2229 fn link_headers_skip_describedby_on_meta() {
2230 let hdrs = link_headers("/foo.meta");
2231 assert!(!hdrs.iter().any(|h| h.contains("rel=\"describedby\"")));
2232 }
2233
2234 #[test]
2235 fn link_headers_skip_acl_on_acl() {
2236 let hdrs = link_headers("/profile/card.acl");
2237 assert!(!hdrs.iter().any(|h| h.contains("rel=\"acl\"")));
2238 }
2239
2240 #[test]
2241 fn prefer_minimal_container_parsed() {
2242 let p = PreferHeader::parse(
2243 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferMinimalContainer\"",
2244 );
2245 assert!(p.include_minimal);
2246 assert_eq!(p.representation, ContainerRepresentation::MinimalContainer);
2247 }
2248
2249 #[test]
2250 fn prefer_contained_iris_parsed() {
2251 let p = PreferHeader::parse(
2252 "return=representation; include=\"http://www.w3.org/ns/ldp#PreferContainedIRIs\"",
2253 );
2254 assert!(p.include_contained_iris);
2255 assert_eq!(p.representation, ContainerRepresentation::ContainedIRIsOnly);
2256 }
2257
2258 #[test]
2259 fn negotiate_prefers_explicit_turtle() {
2260 assert_eq!(
2261 negotiate_format(Some("application/ld+json;q=0.5, text/turtle;q=0.9")),
2262 RdfFormat::Turtle
2263 );
2264 }
2265
2266 #[test]
2267 fn negotiate_falls_back_to_turtle() {
2268 assert_eq!(negotiate_format(Some("*/*")), RdfFormat::Turtle);
2269 assert_eq!(negotiate_format(None), RdfFormat::Turtle);
2270 }
2271
2272 #[test]
2273 fn negotiate_picks_jsonld_when_highest() {
2274 assert_eq!(
2275 negotiate_format(Some("application/ld+json, text/turtle;q=0.5")),
2276 RdfFormat::JsonLd
2277 );
2278 }
2279
2280 #[test]
2281 fn ntriples_roundtrip() {
2282 let nt = "<http://a/s> <http://a/p> <http://a/o> .\n";
2283 let g = Graph::parse_ntriples(nt).unwrap();
2284 assert_eq!(g.len(), 1);
2285 let out = g.to_ntriples();
2286 assert!(out.contains("<http://a/s>"));
2287 }
2288
2289 #[test]
2290 fn server_managed_triples_include_ldp_contains() {
2291 let now = chrono::Utc::now();
2292 let members = vec!["a.txt".to_string(), "sub/".to_string()];
2293 let g = server_managed_triples("http://x/y/", now, 42, true, &members);
2294 let nt = g.to_ntriples();
2295 assert!(nt.contains("http://www.w3.org/ns/ldp#contains"));
2296 assert!(nt.contains("http://x/y/a.txt"));
2297 assert!(nt.contains("http://x/y/sub/"));
2298 }
2299
2300 #[test]
2301 fn find_illegal_server_managed_flags_ldp_contains() {
2302 let mut g = Graph::new();
2303 g.insert(Triple::new(
2304 Term::iri("http://r/"),
2305 Term::iri(iri::LDP_CONTAINS),
2306 Term::iri("http://r/x"),
2307 ));
2308 let illegal = find_illegal_server_managed(&g);
2309 assert_eq!(illegal.len(), 1);
2310 }
2311
2312 #[test]
2313 fn render_container_minimal_omits_contains() {
2314 let prefer = PreferHeader {
2315 representation: ContainerRepresentation::MinimalContainer,
2316 include_minimal: true,
2317 include_contained_iris: false,
2318 omit_membership: true,
2319 };
2320 let v = render_container_jsonld("/docs/", &["one.txt".into()], prefer);
2321 assert!(v.get("ldp:contains").is_none());
2322 }
2323
2324 #[test]
2325 fn render_container_turtle_emits_types() {
2326 let v = render_container_turtle("/x/", &[], PreferHeader::default());
2327 assert!(v.contains("ldp:BasicContainer"));
2328 }
2329
2330 #[test]
2331 fn n3_patch_insert_and_delete() {
2332 let mut g = Graph::new();
2333 g.insert(Triple::new(
2334 Term::iri("http://s/a"),
2335 Term::iri("http://p/keep"),
2336 Term::literal("v"),
2337 ));
2338 g.insert(Triple::new(
2339 Term::iri("http://s/a"),
2340 Term::iri("http://p/drop"),
2341 Term::literal("old"),
2342 ));
2343
2344 let patch = r#"
2345 _:r a solid:InsertDeletePatch ;
2346 solid:deletes {
2347 <http://s/a> <http://p/drop> "old" .
2348 } ;
2349 solid:inserts {
2350 <http://s/a> <http://p/new> "shiny" .
2351 } .
2352 "#;
2353 let outcome = apply_n3_patch(g, patch).unwrap();
2354 assert_eq!(outcome.inserted, 1);
2355 assert_eq!(outcome.deleted, 1);
2356 assert!(outcome.graph.contains(&Triple::new(
2357 Term::iri("http://s/a"),
2358 Term::iri("http://p/new"),
2359 Term::literal("shiny"),
2360 )));
2361 assert!(!outcome.graph.contains(&Triple::new(
2362 Term::iri("http://s/a"),
2363 Term::iri("http://p/drop"),
2364 Term::literal("old"),
2365 )));
2366 }
2367
2368 #[test]
2369 fn n3_patch_where_failure_returns_precondition() {
2370 let g = Graph::new();
2371 let patch = r#"
2372 _:r solid:where { <http://s/a> <http://p/need> "x" . } ;
2373 solid:inserts { <http://s/a> <http://p/added> "y" . } .
2374 "#;
2375 let err = apply_n3_patch(g, patch).err().unwrap();
2376 assert!(matches!(err, PodError::PreconditionFailed(_)));
2377 }
2378
2379 #[test]
2380 fn sparql_insert_data() {
2381 let g = Graph::new();
2382 let update = r#"INSERT DATA { <http://s> <http://p> "v" . }"#;
2383 let outcome = apply_sparql_patch(g, update).unwrap();
2384 assert_eq!(outcome.inserted, 1);
2385 assert_eq!(outcome.graph.len(), 1);
2386 }
2387
2388 #[test]
2389 fn sparql_delete_data() {
2390 let mut g = Graph::new();
2391 g.insert(Triple::new(
2392 Term::iri("http://s"),
2393 Term::iri("http://p"),
2394 Term::literal("v"),
2395 ));
2396 let update = r#"DELETE DATA { <http://s> <http://p> "v" . }"#;
2397 let outcome = apply_sparql_patch(g, update).unwrap();
2398 assert_eq!(outcome.deleted, 1);
2399 assert!(outcome.graph.is_empty());
2400 }
2401
2402 #[test]
2403 fn patch_dialect_detection() {
2404 assert_eq!(patch_dialect_from_mime("text/n3"), Some(PatchDialect::N3));
2405 assert_eq!(
2406 patch_dialect_from_mime("application/sparql-update; charset=utf-8"),
2407 Some(PatchDialect::SparqlUpdate)
2408 );
2409 assert_eq!(patch_dialect_from_mime("text/plain"), None);
2410 }
2411
2412 #[test]
2413 fn slug_uses_valid_value() {
2414 let out = resolve_slug("/photos/", Some("cat.jpg")).unwrap();
2415 assert_eq!(out, "/photos/cat.jpg");
2416 }
2417
2418 #[test]
2419 fn slug_rejects_slashes() {
2420 let err = resolve_slug("/photos/", Some("a/b"));
2421 assert!(matches!(err, Err(PodError::BadRequest(_))));
2422 }
2423
2424 #[test]
2425 fn render_container_shapes_jsonld() {
2426 let members = vec!["one.txt".to_string(), "sub/".to_string()];
2427 let v = render_container("/docs/", &members);
2428 assert!(v.get("@context").is_some());
2429 assert!(v.get("ldp:contains").unwrap().as_array().unwrap().len() == 2);
2430 }
2431
2432 #[test]
2433 fn preconditions_if_match_star_passes_when_resource_exists() {
2434 let got = evaluate_preconditions("PUT", Some("etag123"), Some("*"), None);
2435 assert_eq!(got, ConditionalOutcome::Proceed);
2436 }
2437
2438 #[test]
2439 fn preconditions_if_match_star_fails_when_resource_absent() {
2440 let got = evaluate_preconditions("PUT", None, Some("*"), None);
2441 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2442 }
2443
2444 #[test]
2445 fn preconditions_if_match_mismatch_412() {
2446 let got = evaluate_preconditions("PUT", Some("etag123"), Some("\"other\""), None);
2447 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2448 }
2449
2450 #[test]
2451 fn preconditions_if_none_match_match_on_get_returns_304() {
2452 let got = evaluate_preconditions("GET", Some("etag123"), None, Some("\"etag123\""));
2453 assert_eq!(got, ConditionalOutcome::NotModified);
2454 }
2455
2456 #[test]
2457 fn preconditions_if_none_match_on_put_when_exists_fails() {
2458 let got = evaluate_preconditions("PUT", Some("etag1"), None, Some("*"));
2459 assert_eq!(got, ConditionalOutcome::PreconditionFailed);
2460 }
2461
2462 #[test]
2463 fn preconditions_if_none_match_on_put_when_absent_passes() {
2464 let got = evaluate_preconditions("PUT", None, None, Some("*"));
2465 assert_eq!(got, ConditionalOutcome::Proceed);
2466 }
2467
2468 #[test]
2469 fn range_parses_start_end() {
2470 let r = parse_range_header(Some("bytes=0-99"), 1000)
2471 .unwrap()
2472 .unwrap();
2473 assert_eq!(r.start, 0);
2474 assert_eq!(r.end, 99);
2475 assert_eq!(r.length(), 100);
2476 }
2477
2478 #[test]
2479 fn range_parses_open_ended() {
2480 let r = parse_range_header(Some("bytes=500-"), 1000)
2481 .unwrap()
2482 .unwrap();
2483 assert_eq!(r.start, 500);
2484 assert_eq!(r.end, 999);
2485 }
2486
2487 #[test]
2488 fn range_parses_suffix() {
2489 let r = parse_range_header(Some("bytes=-200"), 1000)
2490 .unwrap()
2491 .unwrap();
2492 assert_eq!(r.start, 800);
2493 assert_eq!(r.end, 999);
2494 }
2495
2496 #[test]
2497 fn range_rejects_unsatisfiable() {
2498 let err = parse_range_header(Some("bytes=2000-3000"), 1000);
2499 assert!(matches!(err, Err(PodError::PreconditionFailed(_))));
2500 }
2501
2502 #[test]
2503 fn range_content_range_header_value() {
2504 let r = parse_range_header(Some("bytes=0-99"), 1000)
2505 .unwrap()
2506 .unwrap();
2507 assert_eq!(r.content_range(1000), "bytes 0-99/1000");
2508 }
2509
2510 #[test]
2511 fn options_container_includes_post_and_accept_post() {
2512 let o = options_for("/photos/");
2513 assert!(o.allow.contains(&"POST"));
2514 assert!(o.accept_post.is_some());
2515 assert_eq!(o.accept_ranges, "none");
2519 assert_eq!(o.cache_control, "private, no-cache, must-revalidate");
2522 }
2523
2524 #[test]
2525 fn options_resource_includes_put_patch_no_post() {
2526 let o = options_for("/photos/cat.jpg");
2527 assert!(o.allow.contains(&"PUT"));
2528 assert!(o.allow.contains(&"PATCH"));
2529 assert!(!o.allow.contains(&"POST"));
2530 assert!(o.accept_post.is_none());
2531 assert!(o.accept_patch.contains("sparql-update"));
2532 assert!(o.accept_patch.contains("json-patch"));
2533 assert_eq!(o.cache_control, CACHE_CONTROL_RDF);
2534 }
2535
2536 #[test]
2537 fn cache_control_present_for_turtle() {
2538 assert_eq!(
2539 cache_control_for("text/turtle"),
2540 Some("private, no-cache, must-revalidate")
2541 );
2542 assert_eq!(
2543 cache_control_for("text/turtle; charset=utf-8"),
2544 Some(CACHE_CONTROL_RDF)
2545 );
2546 }
2547
2548 #[test]
2549 fn cache_control_present_for_jsonld() {
2550 assert_eq!(
2551 cache_control_for("application/ld+json"),
2552 Some(CACHE_CONTROL_RDF)
2553 );
2554 assert_eq!(
2555 cache_control_for(
2556 "application/ld+json; profile=\"http://www.w3.org/ns/json-ld#compacted\""
2557 ),
2558 Some(CACHE_CONTROL_RDF)
2559 );
2560 }
2561
2562 #[test]
2563 fn cache_control_present_for_ntriples() {
2564 assert_eq!(
2565 cache_control_for("application/n-triples"),
2566 Some(CACHE_CONTROL_RDF)
2567 );
2568 assert_eq!(cache_control_for("text/n3"), Some(CACHE_CONTROL_RDF));
2569 assert_eq!(
2570 cache_control_for("application/trig"),
2571 Some(CACHE_CONTROL_RDF)
2572 );
2573 }
2574
2575 #[test]
2576 fn cache_control_absent_for_octet_stream() {
2577 assert_eq!(cache_control_for("application/octet-stream"), None);
2578 assert!(!is_rdf_content_type("application/octet-stream"));
2579 }
2580
2581 #[test]
2582 fn cache_control_absent_for_image_png() {
2583 assert_eq!(cache_control_for("image/png"), None);
2584 assert_eq!(cache_control_for("image/jpeg"), None);
2585 assert_eq!(cache_control_for("video/mp4"), None);
2586 assert!(!is_rdf_content_type("image/png"));
2587 }
2588
2589 #[test]
2590 fn cache_control_not_found_headers_conneg_enabled_emits_rdf_directive() {
2591 let h = not_found_headers("/data/thing", true);
2592 let found = h
2593 .iter()
2594 .find(|(k, _)| *k == "Cache-Control")
2595 .map(|(_, v)| v.as_str());
2596 assert_eq!(found, Some("private, no-cache, must-revalidate"));
2597 }
2598
2599 #[test]
2600 fn cache_control_not_found_headers_conneg_disabled_omits_directive() {
2601 let h = not_found_headers("/data/thing", false);
2602 assert!(h.iter().all(|(k, _)| *k != "Cache-Control"));
2603 }
2604
2605 #[test]
2606 fn json_patch_add_and_replace() {
2607 let mut v = serde_json::json!({ "name": "alice" });
2608 let patch = serde_json::json!([
2609 { "op": "add", "path": "/age", "value": 30 },
2610 { "op": "replace", "path": "/name", "value": "bob" }
2611 ]);
2612 apply_json_patch(&mut v, &patch).unwrap();
2613 assert_eq!(v["name"], "bob");
2614 assert_eq!(v["age"], 30);
2615 }
2616
2617 #[test]
2618 fn json_patch_remove() {
2619 let mut v = serde_json::json!({ "name": "alice", "age": 30 });
2620 let patch = serde_json::json!([
2621 { "op": "remove", "path": "/age" }
2622 ]);
2623 apply_json_patch(&mut v, &patch).unwrap();
2624 assert!(v.get("age").is_none());
2625 }
2626
2627 #[test]
2628 fn json_patch_test_failure_returns_precondition() {
2629 let mut v = serde_json::json!({ "name": "alice" });
2630 let patch = serde_json::json!([
2631 { "op": "test", "path": "/name", "value": "bob" }
2632 ]);
2633 let err = apply_json_patch(&mut v, &patch).unwrap_err();
2634 assert!(matches!(err, PodError::PreconditionFailed(_)));
2635 }
2636
2637 #[test]
2638 fn json_patch_dialect_detection() {
2639 assert_eq!(
2640 patch_dialect_from_mime("application/json-patch+json"),
2641 Some(PatchDialect::JsonPatch)
2642 );
2643 }
2644}