1use std::{
29 io::Read,
30 path::{Component, Path, PathBuf},
31 sync::{
32 atomic::{AtomicU64, Ordering},
33 Arc,
34 },
35 time::UNIX_EPOCH,
36};
37
38use murmur_artifact::{ContainmentClass, ExportMode, FileExport, PeerFilesExport};
39use serde::Serialize;
40
41use crate::{errors::RuntimeError, trace::ResourceTraceAppender};
42
43pub const RESOURCE_PATH_PREFIX: &str = "/resources/";
45
46const FILES_ROUTE: &str = "files";
48
49#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DeclaredExport {
60 pub declared_root: String,
63 pub root: PathBuf,
65 pub mode: ExportMode,
66 pub max_bytes: u64,
69}
70
71impl DeclaredExport {
72 pub fn resolve(accessible_workdir: &Path, export: &FileExport) -> Self {
77 Self {
78 declared_root: export.root.clone(),
79 root: accessible_workdir.join(&export.root),
80 mode: export.mode,
81 max_bytes: export.max_bytes,
82 }
83 }
84
85 pub fn for_peer_files(accessible_workdir: &Path, export: &PeerFilesExport) -> Self {
93 Self {
94 declared_root: export.root.clone(),
95 root: accessible_workdir.join(&export.root),
96 mode: ExportMode::ReadOnly,
97 max_bytes: export.max_bytes,
98 }
99 }
100}
101
102pub fn check_peer_files_root(
105 accessible_workdir: &Path,
106 export: &PeerFilesExport,
107) -> Result<(), RuntimeError> {
108 check_root_within_workdir(accessible_workdir, "exports.peer_files.root", &export.root)
109}
110
111pub fn check_export_root(
119 accessible_workdir: &Path,
120 export: &FileExport,
121) -> Result<(), RuntimeError> {
122 check_root_within_workdir(accessible_workdir, "exports.files.root", &export.root)
123}
124
125fn check_root_within_workdir(
126 accessible_workdir: &Path,
127 field: &str,
128 root: &str,
129) -> Result<(), RuntimeError> {
130 let Ok(workdir_canon) = std::fs::canonicalize(accessible_workdir) else {
131 return Ok(());
134 };
135 let Ok(resolved) = std::fs::canonicalize(workdir_canon.join(root)) else {
136 return Ok(());
137 };
138 if !resolved.starts_with(&workdir_canon) {
139 return Err(RuntimeError::ExportRootOutsideWorkdir {
140 field: field.to_string(),
141 declared: root.to_string(),
142 resolved: resolved.display().to_string(),
143 workdir: workdir_canon.display().to_string(),
144 });
145 }
146 Ok(())
147}
148
149pub struct ResourcePlane {
157 export: Option<DeclaredExport>,
160 containment_achieved: ContainmentClass,
163 generation: Arc<AtomicU64>,
166 trace: Option<Arc<ResourceTraceAppender>>,
169}
170
171impl ResourcePlane {
172 pub fn new(
178 accessible_workdir: &Path,
179 export: Option<&FileExport>,
180 containment_achieved: ContainmentClass,
181 generation: Arc<AtomicU64>,
182 trace: Option<Arc<ResourceTraceAppender>>,
183 ) -> Self {
184 Self::with_export(
185 export.map(|export| DeclaredExport::resolve(accessible_workdir, export)),
186 containment_achieved,
187 generation,
188 trace,
189 )
190 }
191
192 pub fn with_export(
194 export: Option<DeclaredExport>,
195 containment_achieved: ContainmentClass,
196 generation: Arc<AtomicU64>,
197 trace: Option<Arc<ResourceTraceAppender>>,
198 ) -> Self {
199 Self {
200 export,
201 containment_achieved,
202 generation,
203 trace,
204 }
205 }
206
207 pub fn generation(&self) -> u64 {
215 self.generation.load(Ordering::SeqCst)
216 }
217
218 pub fn containment_achieved(&self) -> ContainmentClass {
219 self.containment_achieved
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ResourceResponse {
231 pub status: u16,
232 pub headers: Vec<(String, String)>,
233 pub body: Vec<u8>,
234}
235
236impl ResourceResponse {
237 pub(crate) fn framed(status: u16, mut headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
244 headers.push(("content-length".to_string(), body.len().to_string()));
245 Self {
246 status,
247 headers,
248 body,
249 }
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254pub struct ListResponse {
255 pub root: String,
256 pub mode: ExportMode,
257 pub max_bytes: u64,
258 pub generation: u64,
259 pub containment_achieved: ContainmentClass,
260 pub entries: Vec<ListEntry>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
266pub struct ListEntry {
267 pub path: String,
269 pub size_bytes: u64,
270 pub mtime_ms: u64,
271 pub sha256: String,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ReadResponse {
280 pub bytes: Vec<u8>,
281 pub sha256: String,
282 pub mtime_ms: u64,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum ResourceError {
289 NoResourcePlane,
291 NotFound,
292 OutsideRoot,
293 SymlinkRefused,
295 NotARegularFile,
296 TooLarge {
297 max_bytes: u64,
298 },
299 MethodNotAllowed,
300 IoError(String),
301}
302
303impl ResourceError {
304 pub fn code(&self) -> &'static str {
307 match self {
308 ResourceError::NoResourcePlane => "no_resource_plane",
309 ResourceError::NotFound => "not_found",
310 ResourceError::OutsideRoot => "outside_root",
311 ResourceError::SymlinkRefused => "symlink_refused",
312 ResourceError::NotARegularFile => "not_a_regular_file",
313 ResourceError::TooLarge { .. } => "too_large",
314 ResourceError::MethodNotAllowed => "method_not_allowed",
315 ResourceError::IoError(_) => "io_error",
316 }
317 }
318
319 pub fn status(&self) -> u16 {
320 match self {
321 ResourceError::NoResourcePlane | ResourceError::NotFound => 404,
322 ResourceError::OutsideRoot
323 | ResourceError::SymlinkRefused
324 | ResourceError::NotARegularFile => 403,
325 ResourceError::TooLarge { .. } => 413,
326 ResourceError::MethodNotAllowed => 405,
327 ResourceError::IoError(_) => 500,
328 }
329 }
330
331 pub fn message(&self) -> String {
332 match self {
333 ResourceError::NoResourcePlane => {
334 "this capsule declares no exports.files block, so it has no resource plane"
335 .to_string()
336 }
337 ResourceError::NotFound => "no such file under the export root".to_string(),
338 ResourceError::OutsideRoot => {
339 "the requested path resolves outside the export root".to_string()
340 }
341 ResourceError::SymlinkRefused => {
342 "a symlink was encountered and the achieved containment class is scoped".to_string()
343 }
344 ResourceError::NotARegularFile => {
345 "the requested path is not a regular file".to_string()
346 }
347 ResourceError::TooLarge { max_bytes } => {
348 format!("file exceeds the declared exports.files.max_bytes of {max_bytes} bytes")
349 }
350 ResourceError::MethodNotAllowed => {
351 "the resource plane serves GET only; it has no write path".to_string()
352 }
353 ResourceError::IoError(detail) => format!("read failed: {detail}"),
354 }
355 }
356}
357
358fn io_to_resource_error(error: &std::io::Error) -> ResourceError {
359 match error.kind() {
360 std::io::ErrorKind::NotFound => ResourceError::NotFound,
361 std::io::ErrorKind::NotADirectory => ResourceError::NotFound,
364 std::io::ErrorKind::PermissionDenied => ResourceError::IoError(error.to_string()),
365 _ if error.raw_os_error() == Some(libc::ELOOP) => ResourceError::SymlinkRefused,
366 _ => ResourceError::IoError(error.to_string()),
367 }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum SymlinkPolicy {
374 Refuse,
377 FollowWithinRoot,
380}
381
382pub fn symlink_policy(class: ContainmentClass) -> SymlinkPolicy {
395 match class {
396 ContainmentClass::Scoped => SymlinkPolicy::Refuse,
397 ContainmentClass::Sealed | ContainmentClass::Advisory => SymlinkPolicy::FollowWithinRoot,
398 }
399}
400
401fn percent_decode(raw: &str) -> Vec<u8> {
410 let bytes = raw.as_bytes();
411 let mut out = Vec::with_capacity(bytes.len());
412 let mut index = 0;
413 while index < bytes.len() {
414 if bytes[index] == b'%' && index + 2 < bytes.len() {
415 let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
416 .ok()
417 .and_then(|pair| u8::from_str_radix(pair, 16).ok());
418 if let Some(byte) = hex {
419 out.push(byte);
420 index += 3;
421 continue;
422 }
423 }
424 out.push(bytes[index]);
425 index += 1;
426 }
427 out
428}
429
430pub(crate) fn export_relpath_components(raw: &str) -> Result<Vec<String>, ResourceError> {
438 let decoded = percent_decode(raw);
439 if decoded.is_empty() || decoded.contains(&0) {
440 return Err(ResourceError::OutsideRoot);
441 }
442 let decoded = String::from_utf8(decoded).map_err(|_| ResourceError::OutsideRoot)?;
443 if decoded.starts_with('/') || Path::new(&decoded).is_absolute() {
444 return Err(ResourceError::OutsideRoot);
445 }
446 let mut components = Vec::new();
449 for part in decoded.strip_suffix('/').unwrap_or(&decoded).split('/') {
450 if part.is_empty() || part == "." || part == ".." {
451 return Err(ResourceError::OutsideRoot);
452 }
453 components.push(part.to_string());
454 }
455 if components.is_empty() {
456 return Err(ResourceError::OutsideRoot);
457 }
458 for component in &components {
461 if Path::new(component)
462 .components()
463 .any(|c| !matches!(c, Component::Normal(_)))
464 {
465 return Err(ResourceError::OutsideRoot);
466 }
467 }
468 Ok(components)
469}
470
471fn resolve_beneath(
477 root_canon: &Path,
478 components: &[String],
479 policy: SymlinkPolicy,
480) -> Result<PathBuf, ResourceError> {
481 let mut target = root_canon.to_path_buf();
482 if policy == SymlinkPolicy::Refuse {
483 for component in components {
487 target.push(component);
488 let metadata =
489 std::fs::symlink_metadata(&target).map_err(|e| io_to_resource_error(&e))?;
490 if metadata.file_type().is_symlink() {
491 return Err(ResourceError::SymlinkRefused);
492 }
493 }
494 } else {
495 for component in components {
496 target.push(component);
497 }
498 }
499 let canonical = std::fs::canonicalize(&target).map_err(|e| io_to_resource_error(&e))?;
500 if !canonical.starts_with(root_canon) {
501 return Err(ResourceError::OutsideRoot);
502 }
503 Ok(canonical)
504}
505
506pub(crate) fn open_no_follow(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> {
513 use std::os::unix::fs::OpenOptionsExt;
514
515 let file = std::fs::OpenOptions::new()
516 .read(true)
517 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
518 .open(path)?;
519 let metadata = file.metadata()?;
520 Ok((file, metadata))
521}
522
523fn open_regular_file(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), ResourceError> {
526 let (file, metadata) = open_no_follow(path).map_err(|e| io_to_resource_error(&e))?;
527 if !metadata.is_file() {
528 return Err(ResourceError::NotARegularFile);
529 }
530 Ok((file, metadata))
531}
532
533fn mtime_ms(metadata: &std::fs::Metadata) -> u64 {
534 metadata
535 .modified()
536 .ok()
537 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
538 .map(|d| d.as_millis() as u64)
539 .unwrap_or(0)
540}
541
542pub(crate) fn resolve_relpath_beneath_root(
552 export: &DeclaredExport,
553 relpath: &str,
554 policy: SymlinkPolicy,
555) -> Result<(PathBuf, String), ResourceError> {
556 let components = export_relpath_components(relpath)?;
557 let root_canon = std::fs::canonicalize(&export.root).map_err(|e| io_to_resource_error(&e))?;
558 let target = resolve_beneath(&root_canon, &components, policy)?;
559 let relative = target
560 .strip_prefix(&root_canon)
561 .map_err(|_| ResourceError::OutsideRoot)?
562 .components()
563 .map(|component| component.as_os_str().to_string_lossy().into_owned())
564 .collect::<Vec<_>>()
565 .join("/");
566 if relative.is_empty() {
567 return Err(ResourceError::OutsideRoot);
568 }
569 Ok((target, relative))
570}
571
572pub(crate) fn read_export_file_with_policy(
582 export: &DeclaredExport,
583 relpath: &str,
584 policy: SymlinkPolicy,
585) -> Result<ReadResponse, ResourceError> {
586 let (target, _) = resolve_relpath_beneath_root(export, relpath, policy)?;
587
588 let (file, metadata) = open_regular_file(&target)?;
589 if metadata.len() > export.max_bytes {
590 return Err(ResourceError::TooLarge {
591 max_bytes: export.max_bytes,
592 });
593 }
594 let mtime = mtime_ms(&metadata);
595
596 let mut bytes = Vec::with_capacity(metadata.len() as usize);
599 let mut limited = file.take(export.max_bytes.saturating_add(1));
600 limited
601 .read_to_end(&mut bytes)
602 .map_err(|e| io_to_resource_error(&e))?;
603 if bytes.len() as u64 > export.max_bytes {
604 return Err(ResourceError::TooLarge {
605 max_bytes: export.max_bytes,
606 });
607 }
608
609 let sha256 = murmur_artifact::sha256_hex(&bytes);
610 Ok(ReadResponse {
611 bytes,
612 sha256,
613 mtime_ms: mtime,
614 })
615}
616
617pub(crate) fn read_file_beneath_root(
629 root: &Path,
630 relpath: &str,
631 max_bytes: u64,
632 policy: SymlinkPolicy,
633) -> Result<ReadResponse, ResourceError> {
634 let export = DeclaredExport {
635 declared_root: ".".to_string(),
636 root: root.to_path_buf(),
637 mode: ExportMode::ReadOnly,
638 max_bytes,
639 };
640 read_export_file_with_policy(&export, relpath, policy)
641}
642
643fn list_export_files(
651 export: &DeclaredExport,
652 policy: SymlinkPolicy,
653) -> Result<Vec<ListEntry>, ResourceError> {
654 let root_canon = match std::fs::canonicalize(&export.root) {
655 Ok(path) => path,
656 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
657 Err(error) => return Err(io_to_resource_error(&error)),
658 };
659 if !root_canon.is_dir() {
660 return Ok(Vec::new());
661 }
662
663 let mut entries = Vec::new();
664 let mut pending = vec![(root_canon.clone(), String::new())];
665 while let Some((dir, prefix)) = pending.pop() {
666 let reader = match std::fs::read_dir(&dir) {
667 Ok(reader) => reader,
668 Err(_) => continue,
671 };
672 for entry in reader.flatten() {
673 let name = entry.file_name().to_string_lossy().to_string();
674 let relative = if prefix.is_empty() {
675 name.clone()
676 } else {
677 format!("{prefix}/{name}")
678 };
679 let Ok(file_type) = entry.file_type() else {
680 continue;
681 };
682 let path = entry.path();
683
684 if file_type.is_symlink() {
685 if policy == SymlinkPolicy::Refuse {
686 continue;
687 }
688 let Ok(canonical) = std::fs::canonicalize(&path) else {
692 continue;
693 };
694 if !canonical.starts_with(&root_canon) {
695 continue;
696 }
697 if let Some(entry) = describe_entry(&canonical, relative) {
698 entries.push(entry);
699 }
700 continue;
701 }
702
703 if file_type.is_dir() {
704 pending.push((path, relative));
705 } else if file_type.is_file() {
706 if let Some(entry) = describe_entry(&path, relative) {
707 entries.push(entry);
708 }
709 }
710 }
711 }
712 entries.sort_by(|a, b| a.path.cmp(&b.path));
713 Ok(entries)
714}
715
716fn describe_entry(path: &Path, relative: String) -> Option<ListEntry> {
720 let (mut file, metadata) = open_regular_file(path).ok()?;
721 let mtime = mtime_ms(&metadata);
722 let (size_bytes, sha256) = murmur_artifact::sha256_hex_of_reader(&mut file).ok()?;
725 Some(ListEntry {
726 path: relative,
727 size_bytes,
728 mtime_ms: mtime,
729 sha256,
730 })
731}
732
733pub async fn handle_resource_request(
743 plane: &ResourcePlane,
744 method: &str,
745 raw_path: &str,
746) -> ResourceResponse {
747 let path = raw_path.split('?').next().unwrap_or("");
748 let Some(rest) = path.strip_prefix(RESOURCE_PATH_PREFIX) else {
749 return error_response(&ResourceError::NotFound, None);
750 };
751
752 if rest == FILES_ROUTE || rest == "files/" {
753 return handle_list(plane, method).await;
754 }
755 if let Some(relpath) = rest.strip_prefix("files/") {
756 return handle_read(plane, method, relpath).await;
757 }
758 if method != "GET" {
762 return error_response(&ResourceError::MethodNotAllowed, None);
763 }
764 error_response(&ResourceError::NotFound, None)
765}
766
767fn servable_export<'a>(
772 plane: &'a ResourcePlane,
773 method: &str,
774) -> Result<&'a DeclaredExport, ResourceError> {
775 if method != "GET" {
776 return Err(ResourceError::MethodNotAllowed);
777 }
778 plane.export.as_ref().ok_or(ResourceError::NoResourcePlane)
779}
780
781async fn handle_list(plane: &ResourcePlane, method: &str) -> ResourceResponse {
782 let generation = plane.generation();
783 let class = plane.containment_achieved;
784
785 let export = match servable_export(plane, method) {
786 Ok(export) => export.clone(),
787 Err(error) => return refuse_list(plane, "", &error, generation, class).await,
790 };
791
792 let listed = {
793 let export = export.clone();
794 let policy = symlink_policy(class);
795 tokio::task::spawn_blocking(move || list_export_files(&export, policy))
796 .await
797 .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
798 };
799
800 let entries = match listed {
801 Ok(entries) => entries,
802 Err(error) => {
803 return refuse_list(plane, &export.declared_root, &error, generation, class).await
804 }
805 };
806
807 if let Some(trace) = &plane.trace {
808 trace
809 .write_resource_list(
810 &export.declared_root,
811 entries.len(),
812 entries.iter().map(|entry| entry.size_bytes).sum(),
813 generation,
814 class,
815 "ok",
816 None,
817 )
818 .await;
819 }
820
821 let body = ListResponse {
822 root: export.declared_root.clone(),
823 mode: export.mode,
824 max_bytes: export.max_bytes,
825 generation,
826 containment_achieved: class,
827 entries,
828 };
829 ResourceResponse::framed(
830 200,
831 vec![
832 ("content-type".to_string(), "application/json".to_string()),
833 ("x-murmur-generation".to_string(), generation.to_string()),
834 ("x-murmur-containment".to_string(), class.to_string()),
835 ("x-murmur-export-root".to_string(), export.declared_root),
836 ],
837 serde_json::to_vec(&body).unwrap_or_default(),
838 )
839}
840
841async fn handle_read(plane: &ResourcePlane, method: &str, relpath: &str) -> ResourceResponse {
842 let generation = plane.generation();
843 let class = plane.containment_achieved;
844 let traced_path = decoded_request_path(relpath);
848
849 let export = match servable_export(plane, method) {
850 Ok(export) => export.clone(),
851 Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
852 };
853
854 let read = {
855 let export = export.clone();
856 let policy = symlink_policy(class);
857 let relpath = relpath.to_string();
858 tokio::task::spawn_blocking(move || read_export_file_with_policy(&export, &relpath, policy))
859 .await
860 .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
861 };
862
863 let response = match read {
864 Ok(response) => response,
865 Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
866 };
867
868 if let Some(trace) = &plane.trace {
869 trace
870 .write_resource_read(
871 &traced_path,
872 "ok",
873 Some(response.bytes.len() as u64),
874 Some(response.sha256.clone()),
875 generation,
876 class,
877 None,
878 )
879 .await;
880 }
881
882 ResourceResponse::framed(
883 200,
884 vec![
885 (
886 "content-type".to_string(),
887 "application/octet-stream".to_string(),
888 ),
889 (
890 "etag".to_string(),
891 format!("\"sha256:{}\"", response.sha256),
892 ),
893 (
894 "x-murmur-mtime-ms".to_string(),
895 response.mtime_ms.to_string(),
896 ),
897 ("x-murmur-generation".to_string(), generation.to_string()),
898 ("x-murmur-containment".to_string(), class.to_string()),
899 ("x-murmur-export-root".to_string(), export.declared_root),
900 ],
901 response.bytes,
902 )
903}
904
905async fn refuse_list(
906 plane: &ResourcePlane,
907 root: &str,
908 error: &ResourceError,
909 generation: u64,
910 class: ContainmentClass,
911) -> ResourceResponse {
912 if let Some(trace) = &plane.trace {
913 trace
914 .write_resource_list(
915 root,
916 0,
917 0,
918 generation,
919 class,
920 error.code(),
921 Some(error.message()),
922 )
923 .await;
924 }
925 error_response(error, Some(generation))
926}
927
928async fn refuse_read(
929 plane: &ResourcePlane,
930 path: &str,
931 error: &ResourceError,
932 generation: u64,
933 class: ContainmentClass,
934) -> ResourceResponse {
935 if let Some(trace) = &plane.trace {
936 trace
937 .write_resource_read(
938 path,
939 error.code(),
940 None,
941 None,
942 generation,
943 class,
944 Some(error.message()),
945 )
946 .await;
947 }
948 error_response(error, Some(generation))
949}
950
951fn decoded_request_path(raw: &str) -> String {
954 String::from_utf8_lossy(&percent_decode(raw)).into_owned()
955}
956
957fn error_response(error: &ResourceError, generation: Option<u64>) -> ResourceResponse {
958 let body = serde_json::json!({"error": error.code(), "message": error.message()});
959 let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
960 if let Some(generation) = generation {
961 headers.push(("x-murmur-generation".to_string(), generation.to_string()));
962 }
963 if matches!(error, ResourceError::MethodNotAllowed) {
964 headers.push(("allow".to_string(), "GET".to_string()));
965 }
966 ResourceResponse::framed(
967 error.status(),
968 headers,
969 serde_json::to_vec(&body).unwrap_or_default(),
970 )
971}
972
973pub fn reason_phrase(status: u16) -> &'static str {
976 match status {
977 200 => "OK",
978 400 => "Bad Request",
979 401 => "Unauthorized",
980 403 => "Forbidden",
981 404 => "Not Found",
982 405 => "Method Not Allowed",
983 409 => "Conflict",
984 410 => "Gone",
985 413 => "Payload Too Large",
986 415 => "Unsupported Media Type",
987 422 => "Unprocessable Entity",
988 _ => "Internal Server Error",
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995 use std::io::Write;
996
997 fn export_for(root: &Path, max_bytes: u64) -> DeclaredExport {
998 DeclaredExport {
999 declared_root: "out/".to_string(),
1000 root: root.to_path_buf(),
1001 mode: ExportMode::ReadOnly,
1002 max_bytes,
1003 }
1004 }
1005
1006 #[test]
1009 fn percent_decoding_happens_before_validation() {
1010 assert_eq!(
1013 export_relpath_components("%2e%2e%2fsecret.txt"),
1014 Err(ResourceError::OutsideRoot)
1015 );
1016 assert_eq!(
1017 export_relpath_components("%2E%2E/secret.txt"),
1018 Err(ResourceError::OutsideRoot)
1019 );
1020 }
1021
1022 #[test]
1023 fn percent_decoding_accepts_ordinary_escapes() {
1024 assert_eq!(
1025 export_relpath_components("sub%20dir/report.md").unwrap(),
1026 vec!["sub dir".to_string(), "report.md".to_string()]
1027 );
1028 }
1029
1030 #[test]
1031 fn a_lone_percent_is_left_as_written() {
1032 assert_eq!(percent_decode("100%"), b"100%".to_vec());
1033 assert_eq!(percent_decode("%zz"), b"%zz".to_vec());
1034 }
1035
1036 #[test]
1037 fn every_escaping_shape_is_refused() {
1038 for path in [
1039 "",
1040 "..",
1041 "../secret.txt",
1042 "out/../../secret.txt",
1043 "/etc/passwd",
1044 "/",
1045 "./report.md",
1046 "a//b",
1047 "a/./b",
1048 "a/../b",
1049 "report.md%00.txt",
1050 "%00",
1051 ] {
1052 assert_eq!(
1053 export_relpath_components(path),
1054 Err(ResourceError::OutsideRoot),
1055 "path {path:?} must be refused"
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn a_trailing_slash_names_the_same_file() {
1062 assert_eq!(
1063 export_relpath_components("dir/report.md/").unwrap(),
1064 vec!["dir".to_string(), "report.md".to_string()]
1065 );
1066 }
1067
1068 #[test]
1069 fn ordinary_relative_paths_survive() {
1070 assert_eq!(
1071 export_relpath_components("a/b/c.txt").unwrap(),
1072 vec!["a".to_string(), "b".to_string(), "c.txt".to_string()]
1073 );
1074 }
1075
1076 #[test]
1079 fn symlink_policy_covers_every_containment_class() {
1080 assert_eq!(
1081 symlink_policy(ContainmentClass::Scoped),
1082 SymlinkPolicy::Refuse
1083 );
1084 assert_eq!(
1085 symlink_policy(ContainmentClass::Sealed),
1086 SymlinkPolicy::FollowWithinRoot
1087 );
1088 assert_eq!(
1089 symlink_policy(ContainmentClass::Advisory),
1090 SymlinkPolicy::FollowWithinRoot
1091 );
1092 }
1093
1094 #[test]
1095 fn an_escaping_symlink_is_refused_at_every_class() {
1096 let dir = tempfile::tempdir().unwrap();
1097 let secret = dir.path().join("secret.txt");
1098 std::fs::write(&secret, b"top secret").unwrap();
1099 let root = dir.path().join("out");
1100 std::fs::create_dir(&root).unwrap();
1101 std::os::unix::fs::symlink(&secret, root.join("escape.txt")).unwrap();
1102 let export = export_for(&root, 1024);
1103
1104 for class in [
1105 ContainmentClass::Advisory,
1106 ContainmentClass::Scoped,
1107 ContainmentClass::Sealed,
1108 ] {
1109 let error = read_export_file_with_policy(&export, "escape.txt", symlink_policy(class))
1110 .expect_err("an escaping symlink must never be served");
1111 let expected = match class {
1112 ContainmentClass::Scoped => ResourceError::SymlinkRefused,
1113 _ => ResourceError::OutsideRoot,
1114 };
1115 assert_eq!(error, expected, "class {class}");
1116
1117 let entries = list_export_files(&export, symlink_policy(class)).unwrap();
1118 assert!(
1119 entries.is_empty(),
1120 "class {class}: an escaping symlink must not be listed; got {entries:?}"
1121 );
1122 }
1123 }
1124
1125 #[test]
1126 fn an_in_root_symlink_is_refused_under_scoped_and_followed_otherwise() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let root = dir.path().join("out");
1129 std::fs::create_dir(&root).unwrap();
1130 std::fs::write(root.join("real.txt"), b"real bytes").unwrap();
1131 std::os::unix::fs::symlink("real.txt", root.join("alias.txt")).unwrap();
1132 let export = export_for(&root, 1024);
1133
1134 assert_eq!(
1135 read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::Refuse),
1136 Err(ResourceError::SymlinkRefused)
1137 );
1138 assert_eq!(
1139 list_export_files(&export, SymlinkPolicy::Refuse)
1140 .unwrap()
1141 .iter()
1142 .map(|entry| entry.path.clone())
1143 .collect::<Vec<_>>(),
1144 vec!["real.txt".to_string()]
1145 );
1146
1147 let followed =
1148 read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::FollowWithinRoot)
1149 .unwrap();
1150 assert_eq!(followed.bytes, b"real bytes");
1151 assert_eq!(
1152 list_export_files(&export, SymlinkPolicy::FollowWithinRoot)
1153 .unwrap()
1154 .len(),
1155 2
1156 );
1157 }
1158
1159 #[test]
1160 fn a_symlinked_directory_on_the_path_is_refused_under_scoped() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let root = dir.path().join("out");
1163 std::fs::create_dir_all(root.join("real")).unwrap();
1164 std::fs::write(root.join("real/report.md"), b"hello").unwrap();
1165 std::os::unix::fs::symlink("real", root.join("alias")).unwrap();
1166 let export = export_for(&root, 1024);
1167
1168 assert_eq!(
1169 read_export_file_with_policy(&export, "alias/report.md", SymlinkPolicy::Refuse),
1170 Err(ResourceError::SymlinkRefused)
1171 );
1172 }
1173
1174 #[test]
1177 fn a_read_hashes_the_bytes_it_serves() {
1178 let dir = tempfile::tempdir().unwrap();
1179 let root = dir.path().join("out");
1180 std::fs::create_dir(&root).unwrap();
1181 std::fs::write(root.join("report.md"), b"# report\n").unwrap();
1182 let response = read_export_file_with_policy(
1183 &export_for(&root, 1024),
1184 "report.md",
1185 SymlinkPolicy::FollowWithinRoot,
1186 )
1187 .unwrap();
1188 assert_eq!(response.bytes, b"# report\n");
1189 assert_eq!(response.sha256, murmur_artifact::sha256_hex(b"# report\n"));
1190 assert!(response.mtime_ms > 0);
1191 }
1192
1193 #[test]
1194 fn max_bytes_refuses_the_read_but_not_the_listing() {
1195 let dir = tempfile::tempdir().unwrap();
1196 let root = dir.path().join("out");
1197 std::fs::create_dir(&root).unwrap();
1198 std::fs::write(root.join("small.txt"), vec![b'a'; 100]).unwrap();
1199 std::fs::write(root.join("big.bin"), vec![b'b'; 4096]).unwrap();
1200 let export = export_for(&root, 1024);
1201
1202 assert_eq!(
1203 read_export_file_with_policy(&export, "big.bin", SymlinkPolicy::FollowWithinRoot),
1204 Err(ResourceError::TooLarge { max_bytes: 1024 })
1205 );
1206 assert_eq!(
1207 read_export_file_with_policy(&export, "small.txt", SymlinkPolicy::FollowWithinRoot)
1208 .unwrap()
1209 .bytes
1210 .len(),
1211 100
1212 );
1213
1214 let entries = list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap();
1215 let big = entries.iter().find(|e| e.path == "big.bin").unwrap();
1216 assert_eq!(
1217 big.size_bytes, 4096,
1218 "discovery must not hide an oversized file"
1219 );
1220 }
1221
1222 #[test]
1223 fn a_directory_is_not_a_regular_file() {
1224 let dir = tempfile::tempdir().unwrap();
1225 let root = dir.path().join("out");
1226 std::fs::create_dir_all(root.join("nested")).unwrap();
1227 assert_eq!(
1228 read_export_file_with_policy(
1229 &export_for(&root, 1024),
1230 "nested",
1231 SymlinkPolicy::FollowWithinRoot
1232 ),
1233 Err(ResourceError::NotARegularFile)
1234 );
1235 }
1236
1237 #[test]
1238 fn a_missing_root_lists_empty_and_reads_not_found() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let root = dir.path().join("out");
1241 let export = export_for(&root, 1024);
1242 assert_eq!(
1243 list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap(),
1244 Vec::new()
1245 );
1246 assert_eq!(
1247 read_export_file_with_policy(&export, "report.md", SymlinkPolicy::FollowWithinRoot),
1248 Err(ResourceError::NotFound)
1249 );
1250 }
1251
1252 #[test]
1253 fn a_listing_is_recursive_and_sorted() {
1254 let dir = tempfile::tempdir().unwrap();
1255 let root = dir.path().join("out");
1256 std::fs::create_dir_all(root.join("b/c")).unwrap();
1257 std::fs::write(root.join("z.txt"), b"z").unwrap();
1258 std::fs::write(root.join("b/y.txt"), b"y").unwrap();
1259 std::fs::write(root.join("b/c/x.txt"), b"x").unwrap();
1260 let entries = list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse).unwrap();
1261 assert_eq!(
1262 entries
1263 .iter()
1264 .map(|entry| entry.path.clone())
1265 .collect::<Vec<_>>(),
1266 vec![
1267 "b/c/x.txt".to_string(),
1268 "b/y.txt".to_string(),
1269 "z.txt".to_string()
1270 ]
1271 );
1272 assert_eq!(entries[2].sha256, murmur_artifact::sha256_hex(b"z"));
1273 }
1274
1275 #[test]
1276 fn a_fifo_is_refused_without_blocking() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let root = dir.path().join("out");
1279 std::fs::create_dir(&root).unwrap();
1280 let fifo = root.join("pipe");
1281 let status = std::process::Command::new("mkfifo")
1282 .arg(&fifo)
1283 .status()
1284 .expect("mkfifo should be runnable");
1285 if !status.success() {
1286 return;
1287 }
1288 assert_eq!(
1289 read_export_file_with_policy(
1290 &export_for(&root, 1024),
1291 "pipe",
1292 SymlinkPolicy::FollowWithinRoot
1293 ),
1294 Err(ResourceError::NotARegularFile)
1295 );
1296 assert!(
1297 list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse)
1298 .unwrap()
1299 .is_empty()
1300 );
1301 }
1302
1303 #[test]
1306 fn every_error_maps_to_its_documented_status() {
1307 for (error, code, status) in [
1308 (ResourceError::NoResourcePlane, "no_resource_plane", 404),
1309 (ResourceError::NotFound, "not_found", 404),
1310 (ResourceError::OutsideRoot, "outside_root", 403),
1311 (ResourceError::SymlinkRefused, "symlink_refused", 403),
1312 (ResourceError::NotARegularFile, "not_a_regular_file", 403),
1313 (ResourceError::TooLarge { max_bytes: 1 }, "too_large", 413),
1314 (ResourceError::MethodNotAllowed, "method_not_allowed", 405),
1315 (ResourceError::IoError("x".into()), "io_error", 500),
1316 ] {
1317 assert_eq!(error.code(), code);
1318 assert_eq!(error.status(), status);
1319 }
1320 }
1321
1322 fn file_export(root: &str) -> FileExport {
1325 FileExport {
1326 root: root.to_string(),
1327 mode: ExportMode::ReadOnly,
1328 max_bytes: 1024,
1329 }
1330 }
1331
1332 #[test]
1335 fn a_root_that_already_escapes_the_workdir_refuses_the_launch() {
1336 let workdir = tempfile::tempdir().unwrap();
1337 let outside = tempfile::tempdir().unwrap();
1338 std::os::unix::fs::symlink(outside.path(), workdir.path().join("out")).unwrap();
1339
1340 let error = check_export_root(workdir.path(), &file_export("out/")).unwrap_err();
1341 let RuntimeError::ExportRootOutsideWorkdir {
1342 declared, resolved, ..
1343 } = &error
1344 else {
1345 panic!("expected ExportRootOutsideWorkdir, got {error:?}");
1346 };
1347 assert_eq!(declared, "out/");
1350 assert_eq!(
1351 Path::new(resolved),
1352 std::fs::canonicalize(outside.path()).unwrap()
1353 );
1354 }
1355
1356 #[test]
1359 fn a_root_that_does_not_exist_yet_is_accepted() {
1360 let workdir = tempfile::tempdir().unwrap();
1361 assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1362
1363 std::fs::create_dir(workdir.path().join("out")).unwrap();
1364 assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1365 }
1366
1367 #[test]
1370 fn a_workdir_that_does_not_exist_yet_is_accepted() {
1371 let parent = tempfile::tempdir().unwrap();
1372 let missing = parent.path().join("not-created-yet");
1373 assert!(check_export_root(&missing, &file_export("out/")).is_ok());
1374 }
1375
1376 #[test]
1379 fn a_root_symlinked_within_the_workdir_is_accepted() {
1380 let workdir = tempfile::tempdir().unwrap();
1381 std::fs::create_dir(workdir.path().join("real")).unwrap();
1382 std::os::unix::fs::symlink(workdir.path().join("real"), workdir.path().join("out"))
1383 .unwrap();
1384
1385 assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1386 }
1387
1388 fn plane_for(export: Option<DeclaredExport>, class: ContainmentClass) -> ResourcePlane {
1391 ResourcePlane::with_export(export, class, Arc::new(AtomicU64::new(0)), None)
1392 }
1393
1394 #[tokio::test]
1395 async fn an_undeclared_plane_denies_both_verbs() {
1396 let plane = plane_for(None, ContainmentClass::Advisory);
1397 for path in ["/resources/files", "/resources/files/report.md"] {
1398 let response = handle_resource_request(&plane, "GET", path).await;
1399 assert_eq!(response.status, 404);
1400 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1401 assert_eq!(body["error"], "no_resource_plane");
1402 }
1403 }
1404
1405 #[tokio::test]
1406 async fn every_write_verb_is_refused_with_an_allow_header() {
1407 let dir = tempfile::tempdir().unwrap();
1408 let root = dir.path().join("out");
1409 std::fs::create_dir(&root).unwrap();
1410 std::fs::write(root.join("report.md"), b"hello").unwrap();
1411 let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
1412
1413 for method in ["POST", "PUT", "PATCH", "DELETE"] {
1414 let response =
1415 handle_resource_request(&plane, method, "/resources/files/report.md").await;
1416 assert_eq!(response.status, 405, "{method}");
1417 assert!(response
1418 .headers
1419 .iter()
1420 .any(|(name, value)| name == "allow" && value == "GET"));
1421 }
1422 assert_eq!(
1423 std::fs::read(root.join("report.md")).unwrap(),
1424 b"hello".to_vec()
1425 );
1426 assert!(!root.join("new.txt").exists());
1427 }
1428
1429 #[tokio::test]
1430 async fn a_read_carries_its_validator_and_provenance() {
1431 let dir = tempfile::tempdir().unwrap();
1432 let root = dir.path().join("out");
1433 std::fs::create_dir(&root).unwrap();
1434 let mut file = std::fs::File::create(root.join("report.md")).unwrap();
1435 file.write_all(b"# report\n").unwrap();
1436 drop(file);
1437
1438 let generation = Arc::new(AtomicU64::new(7));
1439 let plane = ResourcePlane::with_export(
1440 Some(export_for(&root, 1024)),
1441 ContainmentClass::Scoped,
1442 Arc::clone(&generation),
1443 None,
1444 );
1445 let response = handle_resource_request(&plane, "GET", "/resources/files/report.md").await;
1446 assert_eq!(response.status, 200);
1447 assert_eq!(response.body, b"# report\n");
1448 let header = |name: &str| {
1449 response
1450 .headers
1451 .iter()
1452 .find(|(key, _)| key == name)
1453 .map(|(_, value)| value.clone())
1454 .unwrap_or_default()
1455 };
1456 assert_eq!(
1457 header("etag"),
1458 format!("\"sha256:{}\"", murmur_artifact::sha256_hex(b"# report\n"))
1459 );
1460 assert_eq!(header("x-murmur-generation"), "7");
1461 assert_eq!(header("x-murmur-containment"), "scoped");
1462 assert_eq!(header("x-murmur-export-root"), "out/");
1463 }
1464
1465 #[tokio::test]
1466 async fn a_query_string_does_not_select_a_generation() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let root = dir.path().join("out");
1469 std::fs::create_dir(&root).unwrap();
1470 std::fs::write(root.join("report.md"), b"now").unwrap();
1471 let plane = ResourcePlane::with_export(
1472 Some(export_for(&root, 1024)),
1473 ContainmentClass::Advisory,
1474 Arc::new(AtomicU64::new(3)),
1475 None,
1476 );
1477 let response =
1478 handle_resource_request(&plane, "GET", "/resources/files/report.md?generation=1").await;
1479 assert_eq!(response.status, 200);
1480 assert_eq!(response.body, b"now");
1481 }
1482
1483 #[tokio::test]
1484 async fn a_list_reports_the_declared_block_verbatim() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let root = dir.path().join("out");
1487 std::fs::create_dir(&root).unwrap();
1488 std::fs::write(root.join("report.md"), b"hello").unwrap();
1489 let plane = plane_for(Some(export_for(&root, 4096)), ContainmentClass::Advisory);
1490 let response = handle_resource_request(&plane, "GET", "/resources/files").await;
1491 assert_eq!(response.status, 200);
1492 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1493 assert_eq!(body["root"], "out/");
1494 assert_eq!(body["mode"], "read-only");
1495 assert_eq!(body["max_bytes"], 4096);
1496 assert_eq!(body["generation"], 0);
1497 assert_eq!(body["containment_achieved"], "advisory");
1498 assert_eq!(body["entries"][0]["path"], "report.md");
1499 assert_eq!(body["entries"][0]["size_bytes"], 5);
1500 }
1501
1502 #[tokio::test]
1503 async fn an_unknown_route_under_the_prefix_is_not_found() {
1504 let plane = plane_for(None, ContainmentClass::Advisory);
1505 let response = handle_resource_request(&plane, "GET", "/resources/secrets").await;
1506 assert_eq!(response.status, 404);
1507 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1508 assert_eq!(body["error"], "not_found");
1509 }
1510
1511 #[tokio::test]
1513 async fn an_unknown_route_still_answers_the_method_first() {
1514 let plane = plane_for(None, ContainmentClass::Advisory);
1515 let response = handle_resource_request(&plane, "PUT", "/resources/secrets").await;
1516 assert_eq!(response.status, 405);
1517 assert!(response
1518 .headers
1519 .iter()
1520 .any(|(name, value)| name == "allow" && value == "GET"));
1521 }
1522
1523 #[tokio::test]
1526 async fn every_response_carries_its_own_content_length() {
1527 let dir = tempfile::tempdir().unwrap();
1528 let root = dir.path().join("out");
1529 std::fs::create_dir(&root).unwrap();
1530 std::fs::write(root.join("report.md"), b"hello").unwrap();
1531 let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
1532
1533 for (method, path) in [
1534 ("GET", "/resources/files"),
1535 ("GET", "/resources/files/report.md"),
1536 ("GET", "/resources/files/../secret.txt"),
1537 ("PUT", "/resources/files/report.md"),
1538 ] {
1539 let response = handle_resource_request(&plane, method, path).await;
1540 let declared = response
1541 .headers
1542 .iter()
1543 .find(|(name, _)| name == "content-length")
1544 .map(|(_, value)| value.clone())
1545 .unwrap_or_else(|| panic!("{method} {path} must declare a content-length"));
1546 assert_eq!(
1547 declared,
1548 response.body.len().to_string(),
1549 "{method} {path}: content-length must match the body served"
1550 );
1551 }
1552 }
1553}