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.
This commit is contained in:
vhaudiquet committed 2026-10-30 00:00:43 +01:00
1 parent f9250accc0
commit f3cbf16180
1 file changed
+99 -3
+99 -3
View File
@@ -179,7 +179,7 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
}) })
.transpose() .transpose()
.map_err(Error::Other)?; .map_err(Error::Other)?;
let share_pairs = collect_shares(rootfs, options, capture_share).map_err(Error::Other)?; let share_pairs = collect_shares(rootfs.dir(), options, capture_share).map_err(Error::Other)?;
let shares = share_pairs let shares = share_pairs
.iter() .iter()
.map(|(_, gs)| gs.clone()) .map(|(_, gs)| gs.clone())
@@ -299,7 +299,7 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
/// must exist, and no share may overlap the scratch rootfs, or the /// must exist, and no share may overlap the scratch rootfs, or the
/// guest could rewrite its own init. /// guest could rewrite its own init.
fn collect_shares( fn collect_shares(
rootfs: &PreparedRootfs, rootfs_dir: &std::path::Path,
options: &RunOptions, options: &RunOptions,
capture: Option<Share>, capture: Option<Share>,
) -> anyhow::Result<Vec<(Share, GuestShare)>> { ) -> anyhow::Result<Vec<(Share, GuestShare)>> {
@@ -308,6 +308,7 @@ fn collect_shares(
all.push(ws.clone()); all.push(ws.clone());
} }
all.extend(options.shares.iter().cloned()); all.extend(options.shares.iter().cloned());
let caller_count = all.len();
// The capture share rides last, after every caller share. // The capture share rides last, after every caller share.
if let Some(cap) = capture { if let Some(cap) = capture {
all.push(cap); all.push(cap);
@@ -321,12 +322,18 @@ fn collect_shares(
share.host.display() 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!( return Err(anyhow!(
"share {} overlaps the scratch rootfs", "share {} overlaps the scratch rootfs",
share.host.display() 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(( out.push((
share.clone(), share.clone(),
GuestShare { GuestShare {
@@ -339,6 +346,32 @@ fn collect_shares(
Ok(out) 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. /// The guest payload: the caller's argv, or the rootfs's default shell.
fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> { fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> {
let shell = utils::detect_shell(rootfs_dir).to_string(); let shell = utils::detect_shell(rootfs_dir).to_string();
@@ -443,6 +476,69 @@ mod tests {
dir 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] #[test]
fn payload_defaults_to_the_image_shell() { fn payload_defaults_to_the_image_shell() {
let rootfs = shell_rootfs(); let rootfs = shell_rootfs();