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