From ca9e5044a1678a3ff33f13582bd6498632113c3e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 04:26:44 +0000 Subject: Prevent rootfs copy from following symlinks --- src/rootfs.rs | 16 ++++++++++++++-- tests/rootfs_finalization.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/rootfs.rs b/src/rootfs.rs index 9246aa8..ea7d87b 100644 --- a/src/rootfs.rs +++ b/src/rootfs.rs @@ -173,8 +173,7 @@ fn copy_entry( if let Some(parent) = output.parent() { fs::create_dir_all(parent)?; } - fs::copy(source, &output) - .with_context(|| format!("copy {} to {}", source.display(), output.display()))?; + replace_with_file(source, &output)?; fs::set_permissions(&output, metadata.permissions())?; manifest.record(destination); } else { @@ -213,6 +212,19 @@ fn enable_service(rootfs: &Path, service: &ServiceName, manifest: &mut MutationM Ok(()) } +fn replace_with_file(source: &Path, output: &Path) -> Result<()> { + match fs::symlink_metadata(output) { + Ok(metadata) if metadata.file_type().is_dir() => { + bail!("cannot replace directory with file: {}", output.display()); + } + Ok(_) => fs::remove_file(output).with_context(|| format!("replace {}", output.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).with_context(|| format!("inspect {}", output.display())), + } + fs::copy(source, output).with_context(|| format!("copy {} to {}", source.display(), output.display()))?; + Ok(()) +} + fn replace_with_symlink(target: &Path, output: &Path) -> Result<()> { match fs::symlink_metadata(output) { Ok(metadata) if metadata.file_type().is_dir() => { diff --git a/tests/rootfs_finalization.rs b/tests/rootfs_finalization.rs index 3a98290..a450b7e 100644 --- a/tests/rootfs_finalization.rs +++ b/tests/rootfs_finalization.rs @@ -115,3 +115,35 @@ fn finalization_is_idempotent_for_matching_symlinks() { .iter() .any(|path| path == "etc/controller/controller-link")); } + +#[test] +fn finalization_replaces_a_destination_symlink_instead_of_following_it() { + let fixture = tempdir().expect("fixture directory"); + let source = fixture.path().join("overlay"); + fs::create_dir_all(&source).expect("create source tree"); + fs::write(source.join("controller.conf"), "new controller configuration\n") + .expect("write source file"); + let rootfs = tempdir().expect("rootfs directory"); + let outside = fixture.path().join("outside.conf"); + fs::write(&outside, "must not change\n").expect("write outside file"); + fs::create_dir_all(rootfs.path().join("etc/controller")).expect("create destination directory"); + symlink(&outside, rootfs.path().join("etc/controller/controller.conf")) + .expect("create destination symlink"); + let finalization = RootfsFinalization::new( + vec![CopyTree::new(&source, "etc/controller").expect("valid copy destination")], + InitrdOem::new(std::iter::empty::<&str>(), std::iter::empty::<&str>()), + vec![], + ); + + finalization.apply(rootfs.path()).expect("finalization replaces symlink safely"); + + assert_eq!(fs::read_to_string(&outside).expect("read outside file"), "must not change\n"); + assert!(fs::symlink_metadata(rootfs.path().join("etc/controller/controller.conf")) + .expect("inspect copied file") + .file_type() + .is_file()); + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/controller/controller.conf")).expect("read copied file"), + "new controller configuration\n" + ); +} -- cgit v1.2.3