From f3cbf161805232f412422442985acd4681227bd7 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 30 Oct 2026 00:00:16 +0100 Subject: [PATCH] fix(vm): keep caller shares out of the reserved guest namespace A caller share targeting /.ecrou-io (or inside it) shadowed the capture share's files, and one at /.ecrou-exit or '/' shadowed the exit handshake; collect_shares never looked at guest targets. The capture share itself still owns the namespace by design; workspace and --share are validated like any other caller share. The overlap check takes the rootfs path directly. --- crates/ecrou/src/vm.rs | 102 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 2fec061..289595f 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -179,7 +179,7 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result Result, ) -> anyhow::Result> { @@ -308,6 +308,7 @@ fn collect_shares( all.push(ws.clone()); } all.extend(options.shares.iter().cloned()); + let caller_count = all.len(); // The capture share rides last, after every caller share. if let Some(cap) = capture { all.push(cap); @@ -321,12 +322,18 @@ fn collect_shares( share.host.display() )); } - if rootfs.dir().starts_with(&share.host) || share.host.starts_with(rootfs.dir()) { + if rootfs_dir.starts_with(&share.host) || share.host.starts_with(rootfs_dir) { return Err(anyhow!( "share {} overlaps the scratch rootfs", share.host.display() )); } + // The capture share owns the reserved namespace by design; + // caller shares may not touch it, or a mount would shadow the + // control paths the init and the exit handshake rely on. + if i < caller_count { + reject_reserved_namespace(&share.guest)?; + } out.push(( share.clone(), GuestShare { @@ -339,6 +346,32 @@ fn collect_shares( Ok(out) } +/// Reject a caller share whose guest target would shadow a reserved +/// guest path: the capture directory and the exit file. Equal to a +/// reserved path, inside one, or containing one (a share at `/` +/// shadows everything) are all refused. +fn reject_reserved_namespace(target: &str) -> anyhow::Result<()> { + let reserved = [crate::guest::CAPTURE_DIR, crate::guest::EXIT_FILE]; + if target == "/" { + return Err(anyhow!( + "share target / is reserved by ecrou: it would shadow {} and {}", + crate::guest::CAPTURE_DIR, + crate::guest::EXIT_FILE + )); + } + for path in reserved { + if target == path + || target.starts_with(&format!("{path}/")) + || path.starts_with(&format!("{target}/")) + { + return Err(anyhow!( + "share target {target} is reserved by ecrou ({path}); pick another guest path" + )); + } + } + Ok(()) +} + /// The guest payload: the caller's argv, or the rootfs's default shell. fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec { let shell = utils::detect_shell(rootfs_dir).to_string(); @@ -443,6 +476,69 @@ mod tests { dir } + #[test] + fn caller_shares_cannot_take_the_reserved_namespace() { + let scratch = tempfile::tempdir().unwrap(); + let host = tempfile::tempdir().unwrap(); + + let share = |guest: &str| Share { + host: host.path().to_path_buf(), + guest: guest.to_string(), + read_only: true, + }; + // Equal to a reserved path, inside one, or containing one. + for guest in [ + crate::guest::CAPTURE_DIR, + &format!("{}/stdout", crate::guest::CAPTURE_DIR), + crate::guest::EXIT_FILE, + "/", + ] { + let options = RunOptions { + shares: vec![share(guest)], + ..Default::default() + }; + let err = format!( + "{:#}", + collect_shares(scratch.path(), &options, None).unwrap_err() + ); + assert!(err.contains("reserved by ecrou"), "{err}"); + } + // The workspace is a caller share like any other. + let options = RunOptions { + workspace: Some(share(crate::guest::CAPTURE_DIR)), + ..Default::default() + }; + let err = format!( + "{:#}", + collect_shares(scratch.path(), &options, None).unwrap_err() + ); + assert!(err.contains("reserved by ecrou"), "{err}"); + } + + #[test] + fn the_capture_share_may_use_the_reserved_namespace() { + let scratch = tempfile::tempdir().unwrap(); + let host = tempfile::tempdir().unwrap(); + + let options = RunOptions { + workspace: Some(Share { + host: host.path().to_path_buf(), + guest: "/workspace".to_string(), + read_only: false, + }), + ..Default::default() + }; + let capture = Share { + host: host.path().join("out"), + guest: crate::guest::CAPTURE_DIR.to_string(), + read_only: false, + }; + std::fs::create_dir_all(host.path().join("out")).unwrap(); + let pairs = collect_shares(scratch.path(), &options, Some(capture)).unwrap(); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[1].1.target, crate::guest::CAPTURE_DIR); + } + #[test] fn payload_defaults_to_the_image_shell() { let rootfs = shell_rootfs();