From f1b8d24f08896001445e9a9a1f4a20464683ad9f Mon Sep 17 00:00:00 2001 From: Artem Yarulin Date: Tue, 3 Feb 2026 21:12:24 +0200 Subject: [PATCH] =?UTF-8?q?virtual-fs:=20Hide=20package=20contents=20under?= =?UTF-8?q?=20bind=E2=80=91mounts=20by=20filtering=20overlay=20secondaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/virtual-fs/src/overlay_fs.rs | 127 ++++++++++++++++++++++++--- lib/wasix/src/runners/wasi_common.rs | 57 +++++++++++- 2 files changed, 173 insertions(+), 11 deletions(-) diff --git a/lib/virtual-fs/src/overlay_fs.rs b/lib/virtual-fs/src/overlay_fs.rs index 6f6734df3cc4..37632b28d80f 100644 --- a/lib/virtual-fs/src/overlay_fs.rs +++ b/lib/virtual-fs/src/overlay_fs.rs @@ -65,6 +65,7 @@ use crate::{ pub struct OverlayFileSystem { primary: Arc

, secondaries: S, + opaque_prefixes: Vec, } impl OverlayFileSystem @@ -78,6 +79,21 @@ where OverlayFileSystem { primary: Arc::new(primary), secondaries, + opaque_prefixes: Vec::new(), + } + } + + /// Create a new [`FileSystem`] with opaque prefixes that hide any secondary + /// entries under those paths. + pub fn new_with_opaque_prefixes( + primary: P, + secondaries: S, + opaque_prefixes: Vec, + ) -> Self { + OverlayFileSystem { + primary: Arc::new(primary), + secondaries, + opaque_prefixes, } } @@ -96,8 +112,31 @@ where &mut self.secondaries } + fn is_opaque(&self, path: &Path) -> bool { + let normalized = if path.is_absolute() { + path.to_path_buf() + } else { + Path::new("/").join(path) + }; + + self.opaque_prefixes + .iter() + .any(|prefix| normalized.starts_with(prefix)) + } + + fn secondaries_iter<'a>( + &'a self, + path: &Path, + ) -> Box + 'a> { + if self.is_opaque(path) { + Box::new(std::iter::empty()) + } else { + Box::new(self.secondaries.filesystems().into_iter()) + } + } + fn permission_error_or_not_found(&self, path: &Path) -> Result<(), FsError> { - for fs in self.secondaries.filesystems() { + for fs in self.secondaries_iter(path) { if ops::exists(fs, path) { return Err(FsError::PermissionDenied); } @@ -132,7 +171,7 @@ where } // Otherwise scan the secondaries - for fs in self.secondaries.filesystems() { + for fs in self.secondaries_iter(path) { match fs.readlink(path) { Err(e) if should_continue(e) => continue, other => return other, @@ -148,7 +187,7 @@ where let mut white_outs = HashSet::new(); let filesystems = std::iter::once(&self.primary as &(dyn FileSystem + Send)) - .chain(self.secondaries().filesystems()); + .chain(self.secondaries_iter(path)); for fs in filesystems { match fs.read_dir(path) { @@ -239,7 +278,7 @@ where // If the directory is contained in a secondary file system then we need to create a // whiteout file so that it is suppressed and is no longer returned in `readdir` calls. - let had_at_least_one_success = self.secondaries.filesystems().into_iter().any(|fs| { + let had_at_least_one_success = self.secondaries_iter(path).any(|fs| { fs.read_dir(path).is_ok() && ops::create_white_out(&self.primary, path).is_ok() }); @@ -297,7 +336,8 @@ where // the secondaries, in which case we need to copy it to the // primary rather than rename it if !had_at_least_one_success { - for fs in self.secondaries.filesystems() { + let secondaries: Vec<_> = self.secondaries_iter(&from).collect(); + for fs in secondaries { if fs.metadata(&from).is_ok() { ops::copy_reference_ext(fs, &self.primary, &from, &to).await?; had_at_least_one_success = true; @@ -309,7 +349,8 @@ where // If the rename operation was a success then we need to update any // whiteout files on the primary before we return success. if had_at_least_one_success { - for fs in self.secondaries.filesystems() { + let secondaries: Vec<_> = self.secondaries_iter(&from).collect(); + for fs in secondaries { if fs.metadata(&from).is_ok() { tracing::trace!( path=%from.display(), @@ -347,7 +388,7 @@ where } // Otherwise scan the secondaries - for fs in self.secondaries.filesystems() { + for fs in self.secondaries_iter(path) { match fs.metadata(path) { Err(e) if should_continue(e) => continue, other => return other, @@ -376,7 +417,7 @@ where } // Otherwise scan the secondaries - for fs in self.secondaries.filesystems() { + for fs in self.secondaries_iter(path) { match fs.symlink_metadata(path) { Err(e) if should_continue(e) => continue, other => return other, @@ -395,7 +436,7 @@ where // If the file is contained in a secondary then then we need to create a // whiteout file so that it is suppressed. - let had_at_least_one_success = self.secondaries.filesystems().into_iter().any(|fs| { + let had_at_least_one_success = self.secondaries_iter(path).any(|fs| { fs.metadata(path).is_ok() && ops::create_white_out(&self.primary, path).is_ok() }); @@ -503,7 +544,7 @@ where // If the file is on a secondary then we should open it if !ops::has_white_out(&self.primary, path) { - for fs in self.secondaries.filesystems() { + for fs in self.secondaries_iter(path) { let mut sub_conf = conf.clone(); sub_conf.create = false; sub_conf.create_new = false; @@ -1169,6 +1210,72 @@ mod tests { ); } + #[test] + fn overlay_opaque_prefix_hides_secondaries() { + let primary = MemFS::default(); + let secondary = MemFS::default(); + + ops::create_dir_all(&primary, "/app/wp-content").unwrap(); + primary + .new_open_options() + .create(true) + .write(true) + .open("/app/wp-content/host.txt") + .unwrap(); + + ops::create_dir_all(&secondary, "/app/wp-content/themes/twentyten").unwrap(); + + let overlay = OverlayFileSystem::new_with_opaque_prefixes( + primary, + [secondary], + vec![PathBuf::from("/app/wp-content")], + ); + + let entries: Vec<_> = overlay + .read_dir(Path::new("/app/wp-content")) + .unwrap() + .map(|entry| entry.unwrap().path) + .collect(); + + assert_eq!(entries, vec![PathBuf::from("/app/wp-content/host.txt")]); + assert_eq!( + overlay + .metadata(Path::new("/app/wp-content/themes")) + .unwrap_err(), + FsError::EntryNotFound + ); + } + + #[test] + fn overlay_opaque_prefix_prevents_parent_copy_up_on_create() { + let primary = MemFS::default(); + let secondary = MemFS::default(); + + ops::create_dir_all(&secondary, "/app/wp-content/themes").unwrap(); + + let overlay = OverlayFileSystem::new_with_opaque_prefixes( + primary, + [secondary], + vec![PathBuf::from("/app/wp-content")], + ); + + let err = overlay + .new_open_options() + .create(true) + .write(true) + .open("/app/wp-content/themes/foo.txt") + .unwrap_err(); + assert_eq!(err, FsError::EntryNotFound); + + assert_eq!( + overlay + .primary() + .metadata(Path::new("/app/wp-content/themes")) + .unwrap_err(), + FsError::EntryNotFound + ); + } + #[tokio::test] async fn remove_directory() { let primary = MemFS::default(); diff --git a/lib/wasix/src/runners/wasi_common.rs b/lib/wasix/src/runners/wasi_common.rs index 2dd326a0a57a..a1727e6cada7 100644 --- a/lib/wasix/src/runners/wasi_common.rs +++ b/lib/wasix/src/runners/wasi_common.rs @@ -203,6 +203,24 @@ fn prepare_filesystem( mounted_dirs: &[MountedDirectory], container_fs: Option, ) -> Result { + let opaque_prefixes = mounted_dirs + .iter() + .map(|dir| { + let mut guest_path = PathBuf::from(&dir.guest); + if guest_path.is_relative() { + guest_path = apply_relative_path_mounting_hack(&guest_path); + } + root_fs + .canonicalize_unchecked(&guest_path) + .with_context(|| { + format!( + "Unable to canonicalize guest path '{}'", + guest_path.display() + ) + }) + }) + .collect::, Error>>()?; + if !mounted_dirs.is_empty() { build_directory_mappings(&mut root_fs, mounted_dirs)?; } @@ -218,7 +236,7 @@ fn prepare_filesystem( let fs = if let Some(container) = container_fs { let container = RelativeOrAbsolutePathHack(container); - let fs = OverlayFileSystem::new(root_fs, [container]); + let fs = OverlayFileSystem::new_with_opaque_prefixes(root_fs, [container], opaque_prefixes); WasiFsRoot::Overlay(Arc::new(fs)) } else { WasiFsRoot::Sandbox(root_fs) @@ -430,6 +448,43 @@ mod tests { } } + #[tokio::test] + #[cfg_attr(not(feature = "host-fs"), ignore)] + async fn mapped_directory_replaces_container_path() { + let temp = TempDir::new().unwrap(); + let mapping = [MountedDirectory::from(MappedDirectory { + guest: "/app/wp-content".to_string(), + host: temp.path().to_path_buf(), + })]; + + let container = wasmer_package::utils::from_bytes(PYTHON).unwrap(); + let webc_fs = virtual_fs::WebcVolumeFileSystem::mount_all(&container); + let union_fs = UnionFileSystem::new(); + union_fs + .mount("webc".to_string(), Path::new("/"), Box::new(webc_fs)) + .unwrap(); + + let root_fs = RootFileSystemBuilder::default().build(); + let fs = prepare_filesystem(root_fs, &mapping, Some(union_fs)).unwrap(); + + assert!(matches!(fs, WasiFsRoot::Overlay(_))); + if let WasiFsRoot::Overlay(overlay_fs) = &fs { + use virtual_fs::FileSystem; + assert!( + overlay_fs + .metadata("/app/wp-content".as_ref()) + .unwrap() + .is_dir() + ); + assert_eq!( + overlay_fs + .metadata("/app/wp-content/themes".as_ref()) + .unwrap_err(), + virtual_fs::FsError::EntryNotFound + ); + } + } + #[tokio::test] #[cfg_attr(not(feature = "host-fs"), ignore)] async fn convert_mapped_directory_to_mounted_directory() {