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
357pub(crate) fn 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 {
396 match class {
397 ContainmentClass::Scoped => SymlinkPolicy::Refuse,
398 ContainmentClass::Sealed | ContainmentClass::Advisory | ContainmentClass::Unconfined => {
399 SymlinkPolicy::FollowWithinRoot
400 }
401 }
402}
403
404fn percent_decode(raw: &str) -> Vec<u8> {
413 let bytes = raw.as_bytes();
414 let mut out = Vec::with_capacity(bytes.len());
415 let mut index = 0;
416 while index < bytes.len() {
417 if bytes[index] == b'%' && index + 2 < bytes.len() {
418 let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
419 .ok()
420 .and_then(|pair| u8::from_str_radix(pair, 16).ok());
421 if let Some(byte) = hex {
422 out.push(byte);
423 index += 3;
424 continue;
425 }
426 }
427 out.push(bytes[index]);
428 index += 1;
429 }
430 out
431}
432
433pub(crate) fn export_relpath_components(raw: &str) -> Result<Vec<String>, ResourceError> {
441 let decoded = percent_decode(raw);
442 if decoded.is_empty() || decoded.contains(&0) {
443 return Err(ResourceError::OutsideRoot);
444 }
445 let decoded = String::from_utf8(decoded).map_err(|_| ResourceError::OutsideRoot)?;
446 if decoded.starts_with('/') || Path::new(&decoded).is_absolute() {
447 return Err(ResourceError::OutsideRoot);
448 }
449 let mut components = Vec::new();
452 for part in decoded.strip_suffix('/').unwrap_or(&decoded).split('/') {
453 if part.is_empty() || part == "." || part == ".." {
454 return Err(ResourceError::OutsideRoot);
455 }
456 components.push(part.to_string());
457 }
458 if components.is_empty() {
459 return Err(ResourceError::OutsideRoot);
460 }
461 for component in &components {
464 if Path::new(component)
465 .components()
466 .any(|c| !matches!(c, Component::Normal(_)))
467 {
468 return Err(ResourceError::OutsideRoot);
469 }
470 }
471 Ok(components)
472}
473
474fn resolve_beneath(
480 root_canon: &Path,
481 components: &[String],
482 policy: SymlinkPolicy,
483) -> Result<PathBuf, ResourceError> {
484 let mut target = root_canon.to_path_buf();
485 if policy == SymlinkPolicy::Refuse {
486 for component in components {
490 target.push(component);
491 let metadata =
492 std::fs::symlink_metadata(&target).map_err(|e| io_to_resource_error(&e))?;
493 if metadata.file_type().is_symlink() {
494 return Err(ResourceError::SymlinkRefused);
495 }
496 }
497 } else {
498 for component in components {
499 target.push(component);
500 }
501 }
502 let canonical = std::fs::canonicalize(&target).map_err(|e| io_to_resource_error(&e))?;
503 if !canonical.starts_with(root_canon) {
504 return Err(ResourceError::OutsideRoot);
505 }
506 Ok(canonical)
507}
508
509pub(crate) fn open_no_follow(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> {
516 use std::os::unix::fs::OpenOptionsExt;
517
518 let file = std::fs::OpenOptions::new()
519 .read(true)
520 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
521 .open(path)?;
522 let metadata = file.metadata()?;
523 Ok((file, metadata))
524}
525
526fn open_regular_file(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), ResourceError> {
529 let (file, metadata) = open_no_follow(path).map_err(|e| io_to_resource_error(&e))?;
530 if !metadata.is_file() {
531 return Err(ResourceError::NotARegularFile);
532 }
533 Ok((file, metadata))
534}
535
536fn mtime_ms(metadata: &std::fs::Metadata) -> u64 {
537 metadata
538 .modified()
539 .ok()
540 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
541 .map(|d| d.as_millis() as u64)
542 .unwrap_or(0)
543}
544
545pub(crate) fn resolve_relpath_beneath_root(
555 export: &DeclaredExport,
556 relpath: &str,
557 policy: SymlinkPolicy,
558) -> Result<(PathBuf, String), ResourceError> {
559 let components = export_relpath_components(relpath)?;
560 let root_canon = std::fs::canonicalize(&export.root).map_err(|e| io_to_resource_error(&e))?;
561 let target = resolve_beneath(&root_canon, &components, policy)?;
562 let relative = target
563 .strip_prefix(&root_canon)
564 .map_err(|_| ResourceError::OutsideRoot)?
565 .components()
566 .map(|component| component.as_os_str().to_string_lossy().into_owned())
567 .collect::<Vec<_>>()
568 .join("/");
569 if relative.is_empty() {
570 return Err(ResourceError::OutsideRoot);
571 }
572 Ok((target, relative))
573}
574
575pub(crate) fn read_export_file_with_policy(
585 export: &DeclaredExport,
586 relpath: &str,
587 policy: SymlinkPolicy,
588) -> Result<ReadResponse, ResourceError> {
589 let (target, _) = resolve_relpath_beneath_root(export, relpath, policy)?;
590
591 let (file, metadata) = open_regular_file(&target)?;
592 if metadata.len() > export.max_bytes {
593 return Err(ResourceError::TooLarge {
594 max_bytes: export.max_bytes,
595 });
596 }
597 let mtime = mtime_ms(&metadata);
598
599 let mut bytes = Vec::with_capacity(metadata.len() as usize);
602 let mut limited = file.take(export.max_bytes.saturating_add(1));
603 limited
604 .read_to_end(&mut bytes)
605 .map_err(|e| io_to_resource_error(&e))?;
606 if bytes.len() as u64 > export.max_bytes {
607 return Err(ResourceError::TooLarge {
608 max_bytes: export.max_bytes,
609 });
610 }
611
612 let sha256 = murmur_artifact::sha256_hex(&bytes);
613 Ok(ReadResponse {
614 bytes,
615 sha256,
616 mtime_ms: mtime,
617 })
618}
619
620pub(crate) fn read_file_beneath_root(
632 root: &Path,
633 relpath: &str,
634 max_bytes: u64,
635 policy: SymlinkPolicy,
636) -> Result<ReadResponse, ResourceError> {
637 let export = DeclaredExport {
638 declared_root: ".".to_string(),
639 root: root.to_path_buf(),
640 mode: ExportMode::ReadOnly,
641 max_bytes,
642 };
643 read_export_file_with_policy(&export, relpath, policy)
644}
645
646fn list_export_files(
654 export: &DeclaredExport,
655 policy: SymlinkPolicy,
656) -> Result<Vec<ListEntry>, ResourceError> {
657 let root_canon = match std::fs::canonicalize(&export.root) {
658 Ok(path) => path,
659 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
660 Err(error) => return Err(io_to_resource_error(&error)),
661 };
662 if !root_canon.is_dir() {
663 return Ok(Vec::new());
664 }
665
666 let mut entries = Vec::new();
667 let mut pending = vec![(root_canon.clone(), String::new())];
668 while let Some((dir, prefix)) = pending.pop() {
669 let reader = match std::fs::read_dir(&dir) {
670 Ok(reader) => reader,
671 Err(_) => continue,
674 };
675 for entry in reader.flatten() {
676 let name = entry.file_name().to_string_lossy().to_string();
677 let relative = if prefix.is_empty() {
678 name.clone()
679 } else {
680 format!("{prefix}/{name}")
681 };
682 let Ok(file_type) = entry.file_type() else {
683 continue;
684 };
685 let path = entry.path();
686
687 if file_type.is_symlink() {
688 if policy == SymlinkPolicy::Refuse {
689 continue;
690 }
691 let Ok(canonical) = std::fs::canonicalize(&path) else {
695 continue;
696 };
697 if !canonical.starts_with(&root_canon) {
698 continue;
699 }
700 if let Some(entry) = describe_entry(&canonical, relative) {
701 entries.push(entry);
702 }
703 continue;
704 }
705
706 if file_type.is_dir() {
707 pending.push((path, relative));
708 } else if file_type.is_file() {
709 if let Some(entry) = describe_entry(&path, relative) {
710 entries.push(entry);
711 }
712 }
713 }
714 }
715 entries.sort_by(|a, b| a.path.cmp(&b.path));
716 Ok(entries)
717}
718
719fn describe_entry(path: &Path, relative: String) -> Option<ListEntry> {
723 let (mut file, metadata) = open_regular_file(path).ok()?;
724 let mtime = mtime_ms(&metadata);
725 let (size_bytes, sha256) = murmur_artifact::sha256_hex_of_reader(&mut file).ok()?;
728 Some(ListEntry {
729 path: relative,
730 size_bytes,
731 mtime_ms: mtime,
732 sha256,
733 })
734}
735
736pub async fn handle_resource_request(
746 plane: &ResourcePlane,
747 method: &str,
748 raw_path: &str,
749) -> ResourceResponse {
750 let path = raw_path.split('?').next().unwrap_or("");
751 let Some(rest) = path.strip_prefix(RESOURCE_PATH_PREFIX) else {
752 return error_response(&ResourceError::NotFound, None);
753 };
754
755 if rest == FILES_ROUTE || rest == "files/" {
756 return handle_list(plane, method).await;
757 }
758 if let Some(relpath) = rest.strip_prefix("files/") {
759 return handle_read(plane, method, relpath).await;
760 }
761 if method != "GET" {
765 return error_response(&ResourceError::MethodNotAllowed, None);
766 }
767 error_response(&ResourceError::NotFound, None)
768}
769
770fn servable_export<'a>(
775 plane: &'a ResourcePlane,
776 method: &str,
777) -> Result<&'a DeclaredExport, ResourceError> {
778 if method != "GET" {
779 return Err(ResourceError::MethodNotAllowed);
780 }
781 plane.export.as_ref().ok_or(ResourceError::NoResourcePlane)
782}
783
784async fn handle_list(plane: &ResourcePlane, method: &str) -> ResourceResponse {
785 let generation = plane.generation();
786 let class = plane.containment_achieved;
787
788 let export = match servable_export(plane, method) {
789 Ok(export) => export.clone(),
790 Err(error) => return refuse_list(plane, "", &error, generation, class).await,
793 };
794
795 let listed = {
796 let export = export.clone();
797 let policy = symlink_policy(class);
798 tokio::task::spawn_blocking(move || list_export_files(&export, policy))
799 .await
800 .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
801 };
802
803 let entries = match listed {
804 Ok(entries) => entries,
805 Err(error) => {
806 return refuse_list(plane, &export.declared_root, &error, generation, class).await
807 }
808 };
809
810 if let Some(trace) = &plane.trace {
811 trace
812 .write_resource_list(
813 &export.declared_root,
814 entries.len(),
815 entries.iter().map(|entry| entry.size_bytes).sum(),
816 generation,
817 class,
818 "ok",
819 None,
820 )
821 .await;
822 }
823
824 let body = ListResponse {
825 root: export.declared_root.clone(),
826 mode: export.mode,
827 max_bytes: export.max_bytes,
828 generation,
829 containment_achieved: class,
830 entries,
831 };
832 ResourceResponse::framed(
833 200,
834 vec![
835 ("content-type".to_string(), "application/json".to_string()),
836 ("x-murmur-generation".to_string(), generation.to_string()),
837 ("x-murmur-containment".to_string(), class.to_string()),
838 ("x-murmur-export-root".to_string(), export.declared_root),
839 ],
840 serde_json::to_vec(&body).unwrap_or_default(),
841 )
842}
843
844async fn handle_read(plane: &ResourcePlane, method: &str, relpath: &str) -> ResourceResponse {
845 let generation = plane.generation();
846 let class = plane.containment_achieved;
847 let traced_path = decoded_request_path(relpath);
851
852 let export = match servable_export(plane, method) {
853 Ok(export) => export.clone(),
854 Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
855 };
856
857 let read = {
858 let export = export.clone();
859 let policy = symlink_policy(class);
860 let relpath = relpath.to_string();
861 tokio::task::spawn_blocking(move || read_export_file_with_policy(&export, &relpath, policy))
862 .await
863 .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
864 };
865
866 let response = match read {
867 Ok(response) => response,
868 Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
869 };
870
871 if let Some(trace) = &plane.trace {
872 trace
873 .write_resource_read(
874 &traced_path,
875 "ok",
876 Some(response.bytes.len() as u64),
877 Some(response.sha256.clone()),
878 generation,
879 class,
880 None,
881 )
882 .await;
883 }
884
885 ResourceResponse::framed(
886 200,
887 vec![
888 (
889 "content-type".to_string(),
890 "application/octet-stream".to_string(),
891 ),
892 (
893 "etag".to_string(),
894 format!("\"sha256:{}\"", response.sha256),
895 ),
896 (
897 "x-murmur-mtime-ms".to_string(),
898 response.mtime_ms.to_string(),
899 ),
900 ("x-murmur-generation".to_string(), generation.to_string()),
901 ("x-murmur-containment".to_string(), class.to_string()),
902 ("x-murmur-export-root".to_string(), export.declared_root),
903 ],
904 response.bytes,
905 )
906}
907
908async fn refuse_list(
909 plane: &ResourcePlane,
910 root: &str,
911 error: &ResourceError,
912 generation: u64,
913 class: ContainmentClass,
914) -> ResourceResponse {
915 if let Some(trace) = &plane.trace {
916 trace
917 .write_resource_list(
918 root,
919 0,
920 0,
921 generation,
922 class,
923 error.code(),
924 Some(error.message()),
925 )
926 .await;
927 }
928 error_response(error, Some(generation))
929}
930
931async fn refuse_read(
932 plane: &ResourcePlane,
933 path: &str,
934 error: &ResourceError,
935 generation: u64,
936 class: ContainmentClass,
937) -> ResourceResponse {
938 if let Some(trace) = &plane.trace {
939 trace
940 .write_resource_read(
941 path,
942 error.code(),
943 None,
944 None,
945 generation,
946 class,
947 Some(error.message()),
948 )
949 .await;
950 }
951 error_response(error, Some(generation))
952}
953
954fn decoded_request_path(raw: &str) -> String {
957 String::from_utf8_lossy(&percent_decode(raw)).into_owned()
958}
959
960fn error_response(error: &ResourceError, generation: Option<u64>) -> ResourceResponse {
961 let body = serde_json::json!({"error": error.code(), "message": error.message()});
962 let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
963 if let Some(generation) = generation {
964 headers.push(("x-murmur-generation".to_string(), generation.to_string()));
965 }
966 if matches!(error, ResourceError::MethodNotAllowed) {
967 headers.push(("allow".to_string(), "GET".to_string()));
968 }
969 ResourceResponse::framed(
970 error.status(),
971 headers,
972 serde_json::to_vec(&body).unwrap_or_default(),
973 )
974}
975
976pub fn reason_phrase(status: u16) -> &'static str {
979 match status {
980 200 => "OK",
981 400 => "Bad Request",
982 401 => "Unauthorized",
983 403 => "Forbidden",
984 404 => "Not Found",
985 405 => "Method Not Allowed",
986 409 => "Conflict",
987 410 => "Gone",
988 413 => "Payload Too Large",
989 415 => "Unsupported Media Type",
990 422 => "Unprocessable Entity",
991 _ => "Internal Server Error",
992 }
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998 use std::io::Write;
999
1000 fn export_for(root: &Path, max_bytes: u64) -> DeclaredExport {
1001 DeclaredExport {
1002 declared_root: "out/".to_string(),
1003 root: root.to_path_buf(),
1004 mode: ExportMode::ReadOnly,
1005 max_bytes,
1006 }
1007 }
1008
1009 #[test]
1012 fn percent_decoding_happens_before_validation() {
1013 assert_eq!(
1016 export_relpath_components("%2e%2e%2fsecret.txt"),
1017 Err(ResourceError::OutsideRoot)
1018 );
1019 assert_eq!(
1020 export_relpath_components("%2E%2E/secret.txt"),
1021 Err(ResourceError::OutsideRoot)
1022 );
1023 }
1024
1025 #[test]
1026 fn percent_decoding_accepts_ordinary_escapes() {
1027 assert_eq!(
1028 export_relpath_components("sub%20dir/report.md").unwrap(),
1029 vec!["sub dir".to_string(), "report.md".to_string()]
1030 );
1031 }
1032
1033 #[test]
1034 fn a_lone_percent_is_left_as_written() {
1035 assert_eq!(percent_decode("100%"), b"100%".to_vec());
1036 assert_eq!(percent_decode("%zz"), b"%zz".to_vec());
1037 }
1038
1039 #[test]
1040 fn every_escaping_shape_is_refused() {
1041 for path in [
1042 "",
1043 "..",
1044 "../secret.txt",
1045 "out/../../secret.txt",
1046 "/etc/passwd",
1047 "/",
1048 "./report.md",
1049 "a//b",
1050 "a/./b",
1051 "a/../b",
1052 "report.md%00.txt",
1053 "%00",
1054 ] {
1055 assert_eq!(
1056 export_relpath_components(path),
1057 Err(ResourceError::OutsideRoot),
1058 "path {path:?} must be refused"
1059 );
1060 }
1061 }
1062
1063 #[test]
1064 fn a_trailing_slash_names_the_same_file() {
1065 assert_eq!(
1066 export_relpath_components("dir/report.md/").unwrap(),
1067 vec!["dir".to_string(), "report.md".to_string()]
1068 );
1069 }
1070
1071 #[test]
1072 fn ordinary_relative_paths_survive() {
1073 assert_eq!(
1074 export_relpath_components("a/b/c.txt").unwrap(),
1075 vec!["a".to_string(), "b".to_string(), "c.txt".to_string()]
1076 );
1077 }
1078
1079 #[test]
1082 fn symlink_policy_covers_every_containment_class() {
1083 assert_eq!(
1084 symlink_policy(ContainmentClass::Scoped),
1085 SymlinkPolicy::Refuse
1086 );
1087 assert_eq!(
1088 symlink_policy(ContainmentClass::Sealed),
1089 SymlinkPolicy::FollowWithinRoot
1090 );
1091 assert_eq!(
1092 symlink_policy(ContainmentClass::Advisory),
1093 SymlinkPolicy::FollowWithinRoot
1094 );
1095 }
1096
1097 #[test]
1098 fn an_escaping_symlink_is_refused_at_every_class() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let secret = dir.path().join("secret.txt");
1101 std::fs::write(&secret, b"top secret").unwrap();
1102 let root = dir.path().join("out");
1103 std::fs::create_dir(&root).unwrap();
1104 std::os::unix::fs::symlink(&secret, root.join("escape.txt")).unwrap();
1105 let export = export_for(&root, 1024);
1106
1107 for class in [
1108 ContainmentClass::Advisory,
1109 ContainmentClass::Scoped,
1110 ContainmentClass::Sealed,
1111 ] {
1112 let error = read_export_file_with_policy(&export, "escape.txt", symlink_policy(class))
1113 .expect_err("an escaping symlink must never be served");
1114 let expected = match class {
1115 ContainmentClass::Scoped => ResourceError::SymlinkRefused,
1116 _ => ResourceError::OutsideRoot,
1117 };
1118 assert_eq!(error, expected, "class {class}");
1119
1120 let entries = list_export_files(&export, symlink_policy(class)).unwrap();
1121 assert!(
1122 entries.is_empty(),
1123 "class {class}: an escaping symlink must not be listed; got {entries:?}"
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn an_in_root_symlink_is_refused_under_scoped_and_followed_otherwise() {
1130 let dir = tempfile::tempdir().unwrap();
1131 let root = dir.path().join("out");
1132 std::fs::create_dir(&root).unwrap();
1133 std::fs::write(root.join("real.txt"), b"real bytes").unwrap();
1134 std::os::unix::fs::symlink("real.txt", root.join("alias.txt")).unwrap();
1135 let export = export_for(&root, 1024);
1136
1137 assert_eq!(
1138 read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::Refuse),
1139 Err(ResourceError::SymlinkRefused)
1140 );
1141 assert_eq!(
1142 list_export_files(&export, SymlinkPolicy::Refuse)
1143 .unwrap()
1144 .iter()
1145 .map(|entry| entry.path.clone())
1146 .collect::<Vec<_>>(),
1147 vec!["real.txt".to_string()]
1148 );
1149
1150 let followed =
1151 read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::FollowWithinRoot)
1152 .unwrap();
1153 assert_eq!(followed.bytes, b"real bytes");
1154 assert_eq!(
1155 list_export_files(&export, SymlinkPolicy::FollowWithinRoot)
1156 .unwrap()
1157 .len(),
1158 2
1159 );
1160 }
1161
1162 #[test]
1163 fn a_symlinked_directory_on_the_path_is_refused_under_scoped() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let root = dir.path().join("out");
1166 std::fs::create_dir_all(root.join("real")).unwrap();
1167 std::fs::write(root.join("real/report.md"), b"hello").unwrap();
1168 std::os::unix::fs::symlink("real", root.join("alias")).unwrap();
1169 let export = export_for(&root, 1024);
1170
1171 assert_eq!(
1172 read_export_file_with_policy(&export, "alias/report.md", SymlinkPolicy::Refuse),
1173 Err(ResourceError::SymlinkRefused)
1174 );
1175 }
1176
1177 #[test]
1180 fn a_read_hashes_the_bytes_it_serves() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let root = dir.path().join("out");
1183 std::fs::create_dir(&root).unwrap();
1184 std::fs::write(root.join("report.md"), b"# report\n").unwrap();
1185 let response = read_export_file_with_policy(
1186 &export_for(&root, 1024),
1187 "report.md",
1188 SymlinkPolicy::FollowWithinRoot,
1189 )
1190 .unwrap();
1191 assert_eq!(response.bytes, b"# report\n");
1192 assert_eq!(response.sha256, murmur_artifact::sha256_hex(b"# report\n"));
1193 assert!(response.mtime_ms > 0);
1194 }
1195
1196 #[test]
1197 fn max_bytes_refuses_the_read_but_not_the_listing() {
1198 let dir = tempfile::tempdir().unwrap();
1199 let root = dir.path().join("out");
1200 std::fs::create_dir(&root).unwrap();
1201 std::fs::write(root.join("small.txt"), vec![b'a'; 100]).unwrap();
1202 std::fs::write(root.join("big.bin"), vec![b'b'; 4096]).unwrap();
1203 let export = export_for(&root, 1024);
1204
1205 assert_eq!(
1206 read_export_file_with_policy(&export, "big.bin", SymlinkPolicy::FollowWithinRoot),
1207 Err(ResourceError::TooLarge { max_bytes: 1024 })
1208 );
1209 assert_eq!(
1210 read_export_file_with_policy(&export, "small.txt", SymlinkPolicy::FollowWithinRoot)
1211 .unwrap()
1212 .bytes
1213 .len(),
1214 100
1215 );
1216
1217 let entries = list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap();
1218 let big = entries.iter().find(|e| e.path == "big.bin").unwrap();
1219 assert_eq!(
1220 big.size_bytes, 4096,
1221 "discovery must not hide an oversized file"
1222 );
1223 }
1224
1225 #[test]
1226 fn a_directory_is_not_a_regular_file() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let root = dir.path().join("out");
1229 std::fs::create_dir_all(root.join("nested")).unwrap();
1230 assert_eq!(
1231 read_export_file_with_policy(
1232 &export_for(&root, 1024),
1233 "nested",
1234 SymlinkPolicy::FollowWithinRoot
1235 ),
1236 Err(ResourceError::NotARegularFile)
1237 );
1238 }
1239
1240 #[test]
1241 fn a_missing_root_lists_empty_and_reads_not_found() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let root = dir.path().join("out");
1244 let export = export_for(&root, 1024);
1245 assert_eq!(
1246 list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap(),
1247 Vec::new()
1248 );
1249 assert_eq!(
1250 read_export_file_with_policy(&export, "report.md", SymlinkPolicy::FollowWithinRoot),
1251 Err(ResourceError::NotFound)
1252 );
1253 }
1254
1255 #[test]
1256 fn a_listing_is_recursive_and_sorted() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let root = dir.path().join("out");
1259 std::fs::create_dir_all(root.join("b/c")).unwrap();
1260 std::fs::write(root.join("z.txt"), b"z").unwrap();
1261 std::fs::write(root.join("b/y.txt"), b"y").unwrap();
1262 std::fs::write(root.join("b/c/x.txt"), b"x").unwrap();
1263 let entries = list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse).unwrap();
1264 assert_eq!(
1265 entries
1266 .iter()
1267 .map(|entry| entry.path.clone())
1268 .collect::<Vec<_>>(),
1269 vec![
1270 "b/c/x.txt".to_string(),
1271 "b/y.txt".to_string(),
1272 "z.txt".to_string()
1273 ]
1274 );
1275 assert_eq!(entries[2].sha256, murmur_artifact::sha256_hex(b"z"));
1276 }
1277
1278 #[test]
1279 fn a_fifo_is_refused_without_blocking() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let root = dir.path().join("out");
1282 std::fs::create_dir(&root).unwrap();
1283 let fifo = root.join("pipe");
1284 let status = std::process::Command::new("mkfifo")
1285 .arg(&fifo)
1286 .status()
1287 .expect("mkfifo should be runnable");
1288 if !status.success() {
1289 return;
1290 }
1291 assert_eq!(
1292 read_export_file_with_policy(
1293 &export_for(&root, 1024),
1294 "pipe",
1295 SymlinkPolicy::FollowWithinRoot
1296 ),
1297 Err(ResourceError::NotARegularFile)
1298 );
1299 assert!(
1300 list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse)
1301 .unwrap()
1302 .is_empty()
1303 );
1304 }
1305
1306 #[test]
1309 fn every_error_maps_to_its_documented_status() {
1310 for (error, code, status) in [
1311 (ResourceError::NoResourcePlane, "no_resource_plane", 404),
1312 (ResourceError::NotFound, "not_found", 404),
1313 (ResourceError::OutsideRoot, "outside_root", 403),
1314 (ResourceError::SymlinkRefused, "symlink_refused", 403),
1315 (ResourceError::NotARegularFile, "not_a_regular_file", 403),
1316 (ResourceError::TooLarge { max_bytes: 1 }, "too_large", 413),
1317 (ResourceError::MethodNotAllowed, "method_not_allowed", 405),
1318 (ResourceError::IoError("x".into()), "io_error", 500),
1319 ] {
1320 assert_eq!(error.code(), code);
1321 assert_eq!(error.status(), status);
1322 }
1323 }
1324
1325 fn file_export(root: &str) -> FileExport {
1328 FileExport {
1329 root: root.to_string(),
1330 mode: ExportMode::ReadOnly,
1331 max_bytes: 1024,
1332 }
1333 }
1334
1335 #[test]
1338 fn a_root_that_already_escapes_the_workdir_refuses_the_launch() {
1339 let workdir = tempfile::tempdir().unwrap();
1340 let outside = tempfile::tempdir().unwrap();
1341 std::os::unix::fs::symlink(outside.path(), workdir.path().join("out")).unwrap();
1342
1343 let error = check_export_root(workdir.path(), &file_export("out/")).unwrap_err();
1344 let RuntimeError::ExportRootOutsideWorkdir {
1345 declared, resolved, ..
1346 } = &error
1347 else {
1348 panic!("expected ExportRootOutsideWorkdir, got {error:?}");
1349 };
1350 assert_eq!(declared, "out/");
1353 assert_eq!(
1354 Path::new(resolved),
1355 std::fs::canonicalize(outside.path()).unwrap()
1356 );
1357 }
1358
1359 #[test]
1362 fn a_root_that_does_not_exist_yet_is_accepted() {
1363 let workdir = tempfile::tempdir().unwrap();
1364 assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1365
1366 std::fs::create_dir(workdir.path().join("out")).unwrap();
1367 assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1368 }
1369
1370 #[test]
1373 fn a_workdir_that_does_not_exist_yet_is_accepted() {
1374 let parent = tempfile::tempdir().unwrap();
1375 let missing = parent.path().join("not-created-yet");
1376 assert!(check_export_root(&missing, &file_export("out/")).is_ok());
1377 }
1378
1379 #[test]
1382 fn a_root_symlinked_within_the_workdir_is_accepted() {
1383 let workdir = tempfile::tempdir().unwrap();
1384 std::fs::create_dir(workdir.path().join("real")).unwrap();
1385 std::os::unix::fs::symlink(workdir.path().join("real"), workdir.path().join("out"))
1386 .unwrap();
1387
1388 assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1389 }
1390
1391 fn plane_for(export: Option<DeclaredExport>, class: ContainmentClass) -> ResourcePlane {
1394 ResourcePlane::with_export(export, class, Arc::new(AtomicU64::new(0)), None)
1395 }
1396
1397 #[tokio::test]
1398 async fn an_undeclared_plane_denies_both_verbs() {
1399 let plane = plane_for(None, ContainmentClass::Advisory);
1400 for path in ["/resources/files", "/resources/files/report.md"] {
1401 let response = handle_resource_request(&plane, "GET", path).await;
1402 assert_eq!(response.status, 404);
1403 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1404 assert_eq!(body["error"], "no_resource_plane");
1405 }
1406 }
1407
1408 #[tokio::test]
1409 async fn every_write_verb_is_refused_with_an_allow_header() {
1410 let dir = tempfile::tempdir().unwrap();
1411 let root = dir.path().join("out");
1412 std::fs::create_dir(&root).unwrap();
1413 std::fs::write(root.join("report.md"), b"hello").unwrap();
1414 let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
1415
1416 for method in ["POST", "PUT", "PATCH", "DELETE"] {
1417 let response =
1418 handle_resource_request(&plane, method, "/resources/files/report.md").await;
1419 assert_eq!(response.status, 405, "{method}");
1420 assert!(response
1421 .headers
1422 .iter()
1423 .any(|(name, value)| name == "allow" && value == "GET"));
1424 }
1425 assert_eq!(
1426 std::fs::read(root.join("report.md")).unwrap(),
1427 b"hello".to_vec()
1428 );
1429 assert!(!root.join("new.txt").exists());
1430 }
1431
1432 #[tokio::test]
1433 async fn a_read_carries_its_validator_and_provenance() {
1434 let dir = tempfile::tempdir().unwrap();
1435 let root = dir.path().join("out");
1436 std::fs::create_dir(&root).unwrap();
1437 let mut file = std::fs::File::create(root.join("report.md")).unwrap();
1438 file.write_all(b"# report\n").unwrap();
1439 drop(file);
1440
1441 let generation = Arc::new(AtomicU64::new(7));
1442 let plane = ResourcePlane::with_export(
1443 Some(export_for(&root, 1024)),
1444 ContainmentClass::Scoped,
1445 Arc::clone(&generation),
1446 None,
1447 );
1448 let response = handle_resource_request(&plane, "GET", "/resources/files/report.md").await;
1449 assert_eq!(response.status, 200);
1450 assert_eq!(response.body, b"# report\n");
1451 let header = |name: &str| {
1452 response
1453 .headers
1454 .iter()
1455 .find(|(key, _)| key == name)
1456 .map(|(_, value)| value.clone())
1457 .unwrap_or_default()
1458 };
1459 assert_eq!(
1460 header("etag"),
1461 format!("\"sha256:{}\"", murmur_artifact::sha256_hex(b"# report\n"))
1462 );
1463 assert_eq!(header("x-murmur-generation"), "7");
1464 assert_eq!(header("x-murmur-containment"), "scoped");
1465 assert_eq!(header("x-murmur-export-root"), "out/");
1466 }
1467
1468 #[tokio::test]
1469 async fn a_query_string_does_not_select_a_generation() {
1470 let dir = tempfile::tempdir().unwrap();
1471 let root = dir.path().join("out");
1472 std::fs::create_dir(&root).unwrap();
1473 std::fs::write(root.join("report.md"), b"now").unwrap();
1474 let plane = ResourcePlane::with_export(
1475 Some(export_for(&root, 1024)),
1476 ContainmentClass::Advisory,
1477 Arc::new(AtomicU64::new(3)),
1478 None,
1479 );
1480 let response =
1481 handle_resource_request(&plane, "GET", "/resources/files/report.md?generation=1").await;
1482 assert_eq!(response.status, 200);
1483 assert_eq!(response.body, b"now");
1484 }
1485
1486 #[tokio::test]
1487 async fn a_list_reports_the_declared_block_verbatim() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let root = dir.path().join("out");
1490 std::fs::create_dir(&root).unwrap();
1491 std::fs::write(root.join("report.md"), b"hello").unwrap();
1492 let plane = plane_for(Some(export_for(&root, 4096)), ContainmentClass::Advisory);
1493 let response = handle_resource_request(&plane, "GET", "/resources/files").await;
1494 assert_eq!(response.status, 200);
1495 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1496 assert_eq!(body["root"], "out/");
1497 assert_eq!(body["mode"], "read-only");
1498 assert_eq!(body["max_bytes"], 4096);
1499 assert_eq!(body["generation"], 0);
1500 assert_eq!(body["containment_achieved"], "advisory");
1501 assert_eq!(body["entries"][0]["path"], "report.md");
1502 assert_eq!(body["entries"][0]["size_bytes"], 5);
1503 }
1504
1505 #[tokio::test]
1506 async fn an_unknown_route_under_the_prefix_is_not_found() {
1507 let plane = plane_for(None, ContainmentClass::Advisory);
1508 let response = handle_resource_request(&plane, "GET", "/resources/secrets").await;
1509 assert_eq!(response.status, 404);
1510 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1511 assert_eq!(body["error"], "not_found");
1512 }
1513
1514 #[tokio::test]
1516 async fn an_unknown_route_still_answers_the_method_first() {
1517 let plane = plane_for(None, ContainmentClass::Advisory);
1518 let response = handle_resource_request(&plane, "PUT", "/resources/secrets").await;
1519 assert_eq!(response.status, 405);
1520 assert!(response
1521 .headers
1522 .iter()
1523 .any(|(name, value)| name == "allow" && value == "GET"));
1524 }
1525
1526 #[tokio::test]
1529 async fn every_response_carries_its_own_content_length() {
1530 let dir = tempfile::tempdir().unwrap();
1531 let root = dir.path().join("out");
1532 std::fs::create_dir(&root).unwrap();
1533 std::fs::write(root.join("report.md"), b"hello").unwrap();
1534 let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
1535
1536 for (method, path) in [
1537 ("GET", "/resources/files"),
1538 ("GET", "/resources/files/report.md"),
1539 ("GET", "/resources/files/../secret.txt"),
1540 ("PUT", "/resources/files/report.md"),
1541 ] {
1542 let response = handle_resource_request(&plane, method, path).await;
1543 let declared = response
1544 .headers
1545 .iter()
1546 .find(|(name, _)| name == "content-length")
1547 .map(|(_, value)| value.clone())
1548 .unwrap_or_else(|| panic!("{method} {path} must declare a content-length"));
1549 assert_eq!(
1550 declared,
1551 response.body.len().to_string(),
1552 "{method} {path}: content-length must match the body served"
1553 );
1554 }
1555 }
1556}