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