From 3801a2c6f1df33b4fc31d8f0810092517c91b3ec Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Mon, 8 Jun 2026 13:57:07 +0200 Subject: [PATCH 01/14] fix: do not list symlinks --- lib/wasix/src/syscalls/wasi/fd_readdir.rs | 42 ++++++++++++------- .../wasm_tests/path_tests/symlink/d1/d2l | 1 + .../wasm_tests/path_tests/symlink/d2/test.txt | 1 + .../wasm_tests/path_tests/symlink/main.c | 34 +++++++++++++++ 4 files changed, 62 insertions(+), 16 deletions(-) create mode 120000 lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l create mode 100644 lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt create mode 100644 lib/wasix/tests/wasm_tests/path_tests/symlink/main.c diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index 7a351a1cb844..e71c86e68bbc 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -1,6 +1,10 @@ use super::*; use crate::syscalls::*; +fn should_list_in_readdir(filetype: Filetype) -> bool { + filetype != Filetype::SymbolicLink +} + /// ### `fd_readdir()` /// Read data from directory specified by file descriptor /// Inputs: @@ -51,30 +55,36 @@ pub fn fd_readdir( .collect::, _>>() .map_err(fs_error_into_wasi_err) ); - let mut entry_vec = wasi_try_ok!( - fs_info + let fs_entries = fs_info + .into_iter() + .map(|entry| { + let filename = entry.file_name().to_string_lossy().to_string(); + trace!("getting file: {:?}", filename); + let filetype = virtual_file_type_to_wasi_file_type( + entry.file_type().map_err(fs_error_into_wasi_err)?, + ); + Ok(should_list_in_readdir(filetype).then_some(( + filename, filetype, 0, // TODO: inode + ))) + }) + .collect::>, Errno>>(); + let mut entry_vec: Vec<(String, Filetype, u64)> = wasi_try_ok!(fs_entries) .into_iter() - .map(|entry| { - let filename = entry.file_name().to_string_lossy().to_string(); - trace!("getting file: {:?}", filename); - let filetype = virtual_file_type_to_wasi_file_type( - entry.file_type().map_err(fs_error_into_wasi_err)?, - ); - Ok(( - filename, filetype, 0, // TODO: inode - )) - }) - .collect::, _>>() - ); + .flatten() + .collect(); let entry_names: std::collections::HashSet<_> = entry_vec.iter().map(|(name, _, _)| name.clone()).collect(); entry_vec.extend( entries .iter() .filter(|(name, _)| !entry_names.contains(*name)) - .map(|(name, inode)| { + .filter_map(|(name, inode)| { let stat = inode.stat.read().unwrap(); - (name.clone(), stat.st_filetype, stat.st_ino) + should_list_in_readdir(stat.st_filetype).then_some(( + name.clone(), + stat.st_filetype, + stat.st_ino, + )) }), ); // adding . and .. special folders diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l b/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l new file mode 120000 index 000000000000..c7f0c93adb2c --- /dev/null +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l @@ -0,0 +1 @@ +/run/media/fliqqr/HD/wasmer/wasmer/lib/wasix/tests/wasm_tests/path_tests/symlink/d2 \ No newline at end of file diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt b/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt new file mode 100644 index 000000000000..45b983be36b7 --- /dev/null +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt @@ -0,0 +1 @@ +hi diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c b/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c new file mode 100644 index 000000000000..01475c9e5ee3 --- /dev/null +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c @@ -0,0 +1,34 @@ +//#ExpectedStdout: 0 + +#include +#include +#include +#include +#include +#include + +static void assert_dir_has_no_entry(const char *path, const char *forbidden) +{ + DIR *dir = opendir(path); + assert(dir != NULL); + + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) + { + assert(strcmp(entry->d_name, forbidden) != 0); + } + + assert(closedir(dir) == 0); +} + +int main(void) +{ + assert_dir_has_no_entry("./d1", "d2l"); + + errno = 0; + assert(open("./d1/d2l/test.txt", O_RDONLY) < 0); + assert(errno == ENOENT); + + printf("0"); + return 0; +} From 70b560749ad07a84d9e5f3d5d38a473f624dd66c Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Wed, 17 Jun 2026 16:38:03 +0200 Subject: [PATCH 02/14] fix: sandbox escape symlinks showing in fd_readdir --- lib/virtual-fs/src/host_fs.rs | 4 + lib/wasix/src/fs/mod.rs | 696 ++++++++++++++++++ lib/wasix/src/syscalls/wasi/fd_readdir.rs | 134 ++-- .../wasm_tests/path_tests/symlink/d1/d2l | 2 +- .../wasm_tests/path_tests/symlink/d1/outside | 1 + .../wasm_tests/path_tests/symlink/d2/test.txt | 2 +- .../wasm_tests/path_tests/symlink/main.c | 33 +- 7 files changed, 813 insertions(+), 59 deletions(-) create mode 120000 lib/wasix/tests/wasm_tests/path_tests/symlink/d1/outside diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index c765c4d318dc..b04d72e8c129 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -104,6 +104,10 @@ impl FileSystem { Ok(FileSystem { handle, root }) } + pub fn root_path(&self) -> &Path { + &self.root + } + fn prepare_path(&self, path: &Path) -> Result { let path = normalize_path(path); diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index 4dc443aaa2a2..58bd1c2ddd46 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -23,6 +23,7 @@ mod path_posix; use std::{ borrow::Cow, collections::{HashMap, HashSet}, + ffi::OsString, ops::{Deref, DerefMut}, path::{Path, PathBuf}, pin::Pin, @@ -123,6 +124,54 @@ pub const FS_STDOUT_INO: Inode = Inode(11); pub const FS_STDERR_INO: Inode = Inode(12); pub const FS_ROOT_INO: Inode = Inode(13); +#[cfg(feature = "host-fs")] +fn canonicalize_existing_prefix(path: &Path) -> std::io::Result { + let mut existing_prefix = virtual_fs::host_fs::normalize_path(path); + let mut missing_suffix = Vec::::new(); + + loop { + match std::fs::symlink_metadata(&existing_prefix) { + Ok(_) => { + let mut resolved = virtual_fs::host_fs::canonicalize(&existing_prefix)?; + for component in missing_suffix.iter().rev() { + resolved.push(component); + } + return Ok(resolved); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + let Some(component) = existing_prefix.file_name() else { + return Err(err); + }; + missing_suffix.push(component.to_os_string()); + if !existing_prefix.pop() { + return Err(err); + } + } + Err(err) => return Err(err), + } + } +} + +#[cfg(feature = "host-fs")] +fn resolved_path_contained_within(path: &Path, root: &Path) -> bool { + let canonical_root = match virtual_fs::host_fs::canonicalize(root) { + Ok(root) => root, + Err(_) => return false, + }; + + match std::fs::canonicalize(path) { + Ok(canonical_target) => canonical_target.starts_with(&canonical_root), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + canonicalize_existing_prefix(path) + .is_ok_and(|resolved_target| resolved_target.starts_with(&canonical_root)) + } + Err(err) if matches!(err.raw_os_error(), Some(libc::ELOOP)) => { + virtual_fs::host_fs::normalize_path(path).starts_with(&canonical_root) + } + Err(_) => false, + } +} + const STDIN_DEFAULT_RIGHTS: Rights = { // This might seem a bit overenineered, but it's the only way I // discovered for getting the values in a const environment @@ -155,6 +204,13 @@ const STDERR_DEFAULT_RIGHTS: Rights = STDOUT_DEFAULT_RIGHTS; /// the number of symlinks that can be traversed when resolving a path pub const MAX_SYMLINKS: u32 = 128; +#[derive(Debug, Clone)] +enum HostSymlinkPolicy { + Visible, + HiddenEscape, + ResolvedGuestPath(PathBuf), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Inode(u64); @@ -1706,6 +1762,19 @@ impl WasiFs { .strip_prefix(&PosixPath::from_path(&mount_path)) .ok_or(Errno::Perm)?; let symlink_parent = symlink_relative.parent().into_path_buf(); + if let Some(policy) = self.host_symlink_policy_for_guest_path( + Path::new(symlink_path.as_str()), + Some(&mount_path), + ) { + match policy { + HostSymlinkPolicy::HiddenEscape => return Err(Errno::Perm), + HostSymlinkPolicy::ResolvedGuestPath(path) => { + return Ok((VIRTUAL_ROOT_FD, path)); + } + HostSymlinkPolicy::Visible => {} + } + } + let contained_target = if relative_posix.is_absolute() { let stripped = relative_posix .strip_prefix(&PosixPath::from_path(&mount_entry.source_path)) @@ -1739,6 +1808,201 @@ impl WasiFs { )) } + pub(crate) fn readdir_entry_visible( + &self, + inodes: &WasiInodes, + fd: WasiFd, + dir_path: Option<&Path>, + filename: &str, + filetype: Filetype, + ) -> bool { + if filetype != Filetype::SymbolicLink { + return true; + } + + if let Some(dir_path) = dir_path { + let preopen_visibility = self.host_preopen_symlink_visibility(fd, dir_path, filename); + if matches!(preopen_visibility, Some(false)) { + return false; + } + + let guest_path = dir_path.join(filename); + if let Some(policy) = self.host_symlink_policy_for_guest_path(&guest_path, None) { + return !matches!(policy, HostSymlinkPolicy::HiddenEscape); + } + + if let Some(visible) = preopen_visibility { + return visible; + } + } + + if let Ok(inode) = self.get_inode_at_path(inodes, fd, filename, false) { + let guard = inode.read(); + let Kind::Symlink { + symlink_kind, + path_to_symlink, + relative_path, + } = guard.deref() + else { + return true; + }; + + return !matches!( + self.resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path), + Err(Errno::Perm) + ); + } + + let Some(dir_path) = dir_path else { + return true; + }; + self.host_preopen_symlink_visibility(fd, dir_path, filename) + .unwrap_or(true) + } + + fn host_symlink_policy_for_guest_path( + &self, + guest_symlink_path: &Path, + known_mount_path: Option<&Path>, + ) -> Option { + #[cfg(not(feature = "host-fs"))] + { + let _ = (guest_symlink_path, known_mount_path); + None + } + + #[cfg(feature = "host-fs")] + { + let guest_symlink = PosixPath::from_path(guest_symlink_path); + let mount_entry = self + .root_fs + .root() + .mount_entries() + .into_iter() + .filter(|entry| { + if let Some(mount_path) = known_mount_path + && entry.path != mount_path + { + return false; + } + + guest_symlink + .strip_prefix(&PosixPath::from_path(&entry.path)) + .is_some() + }) + .max_by_key(|entry| PosixPath::from_path(&entry.path).as_str().len())?; + let host_fs = mount_entry + .fs + .upcast_any_ref() + .downcast_ref::()?; + let host_root = host_fs.root_path(); + let mount_path = PosixPath::from_path(&mount_entry.path); + let host_mount_root = if mount_entry.source_path == Path::new("/") { + host_root.to_path_buf() + } else { + host_root.join(mount_entry.source_path.strip_prefix(Path::new("/")).ok()?) + }; + let symlink_relative = guest_symlink.strip_prefix(&mount_path)?; + let host_symlink_path = host_mount_root.join(symlink_relative.as_str()); + let raw_target = std::fs::read_link(&host_symlink_path).ok()?; + + if raw_target.is_absolute() { + let normalized_target = virtual_fs::host_fs::normalize_path(&raw_target); + let resolved_target = normalized_target + .strip_prefix(&host_mount_root) + .ok() + .map(|stripped| stripped.to_path_buf()) + .or_else(|| { + virtual_fs::host_fs::canonicalize(&raw_target) + .ok()? + .strip_prefix(&host_mount_root) + .ok() + .map(|stripped| stripped.to_path_buf()) + }); + + return Some(match resolved_target { + Some(stripped) => HostSymlinkPolicy::ResolvedGuestPath( + mount_path + .join(&PosixPath::from_path(&stripped)) + .into_path_buf(), + ), + None => HostSymlinkPolicy::HiddenEscape, + }); + } + + let target = host_symlink_path + .parent() + .unwrap_or(&host_mount_root) + .join(&raw_target); + let contained = resolved_path_contained_within(&target, &host_mount_root); + + Some(if contained { + HostSymlinkPolicy::Visible + } else { + HostSymlinkPolicy::HiddenEscape + }) + } + } + + fn host_preopen_symlink_visibility( + &self, + fd: WasiFd, + dir_path: &Path, + filename: &str, + ) -> Option { + #[cfg(not(feature = "host-fs"))] + { + let _ = (fd, dir_path, filename); + None + } + + #[cfg(feature = "host-fs")] + { + let dir_fd = self.get_fd(fd).ok()?; + let preopen_root = self.preopen_host_root(&dir_fd.inode)?; + let entry_path = dir_path.join(filename); + let link_value = std::fs::read_link(&entry_path).ok()?; + let target = if link_value.is_absolute() { + link_value + } else { + entry_path + .parent() + .unwrap_or(&preopen_root) + .join(link_value) + }; + let normalized_root = virtual_fs::host_fs::normalize_path(&preopen_root); + let normalized_target = virtual_fs::host_fs::normalize_path(&target); + + if !normalized_target.starts_with(&normalized_root) { + return Some(false); + } + + Some(resolved_path_contained_within(&target, &preopen_root)) + } + } + + fn preopen_host_root(&self, dir_inode: &InodeGuard) -> Option { + let mut current = dir_inode.clone(); + + loop { + if current.is_preopened { + let guard = current.read(); + if let Kind::Dir { path, .. } = guard.deref() { + return Some(path.clone()); + } + } + + let parent = { + let guard = current.read(); + let Kind::Dir { parent, .. } = guard.deref() else { + return None; + }; + parent.upgrade()? + }; + current = parent; + } + } + pub(crate) fn rebase_symlink_location(&self, new_symlink_path: &Path) -> PathBuf { PosixPath::from_path(new_symlink_path) .strip_root_prefix() @@ -2938,6 +3202,18 @@ mod tests { virtual_fs::WebcVolumeFileSystem::new(volume) } + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + fn absolute_host_mount_with_nested_symlink_fixture() -> (tempfile::TempDir, PathBuf) { + let mount_root = tempdir().unwrap(); + let nested_dir = mount_root.path().join("d1"); + std::fs::create_dir_all(&nested_dir).unwrap(); + std::fs::write(mount_root.path().join("inside.txt"), b"inside").unwrap(); + std::os::unix::fs::symlink("../inside.txt", nested_dir.join("inside-link")).unwrap(); + std::os::unix::fs::symlink("../..", nested_dir.join("outside")).unwrap(); + + (mount_root, nested_dir) + } + #[tokio::test] async fn test_relative_path_to_absolute() { let inodes = WasiInodes::new(); @@ -3214,6 +3490,426 @@ mod tests { )); } + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + #[tokio::test] + async fn readdir_hides_only_host_symlinks_that_escape_the_mount() { + let root_dir = tempdir().unwrap(); + let sandbox_dir = root_dir.path().join("sandbox"); + let outside_dir = root_dir.path().join("outside-dir"); + std::fs::create_dir_all(&sandbox_dir).unwrap(); + std::fs::create_dir_all(&outside_dir).unwrap(); + std::fs::write(sandbox_dir.join("inside.txt"), b"inside").unwrap(); + std::fs::write(root_dir.path().join("outside.txt"), b"outside").unwrap(); + std::fs::write(outside_dir.join("outside.txt"), b"outside").unwrap(); + std::os::unix::fs::symlink("inside.txt", sandbox_dir.join("inside-link")).unwrap(); + std::os::unix::fs::symlink("../outside.txt", sandbox_dir.join("outside-link")).unwrap(); + std::os::unix::fs::symlink("../outside-dir", sandbox_dir.join("pivot")).unwrap(); + std::os::unix::fs::symlink("pivot/outside.txt", sandbox_dir.join("chained-link")).unwrap(); + std::os::unix::fs::symlink("pivot/missing.txt", sandbox_dir.join("broken-chained-link")) + .unwrap(); + std::os::unix::fs::symlink("loop-b", sandbox_dir.join("loop-a")).unwrap(); + std::os::unix::fs::symlink("loop-a", sandbox_dir.join("loop-b")).unwrap(); + + let host_fs = + virtual_fs::host_fs::FileSystem::new(tokio::runtime::Handle::current(), &sandbox_dir) + .unwrap(); + let mount_fs = virtual_fs::MountFileSystem::new(); + mount_fs + .mount( + Path::new("/"), + Arc::new(RootFileSystemBuilder::default().build_tmp()), + ) + .unwrap(); + mount_fs + .mount( + Path::new("/sandbox"), + Arc::new(host_fs) as Arc, + ) + .unwrap(); + + let inodes = WasiInodes::new(); + let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); + let wasi_fs = + WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); + + assert!(wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/sandbox")), + "inside-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/sandbox")), + "outside-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/sandbox")), + "chained-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/sandbox")), + "broken-chained-link", + Filetype::SymbolicLink, + )); + assert!(wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/sandbox")), + "loop-a", + Filetype::SymbolicLink, + )); + assert!(wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/sandbox")), + "loop-b", + Filetype::SymbolicLink, + )); + } + + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + #[tokio::test] + async fn readdir_filters_host_symlinks_for_direct_preopens() { + let preopen_dir = tempdir().unwrap(); + let outside_dir = tempdir().unwrap(); + std::fs::write(preopen_dir.path().join("inside.txt"), b"inside").unwrap(); + std::fs::write(outside_dir.path().join("outside.txt"), b"outside").unwrap(); + std::os::unix::fs::symlink("inside.txt", preopen_dir.path().join("inside-link")).unwrap(); + std::os::unix::fs::symlink(outside_dir.path(), preopen_dir.path().join("outside-link")) + .unwrap(); + std::os::unix::fs::symlink(outside_dir.path(), preopen_dir.path().join("pivot")).unwrap(); + std::os::unix::fs::symlink("pivot/outside.txt", preopen_dir.path().join("chained-link")) + .unwrap(); + std::os::unix::fs::symlink( + "pivot/missing.txt", + preopen_dir.path().join("broken-chained-link"), + ) + .unwrap(); + std::os::unix::fs::symlink("missing.txt", preopen_dir.path().join("broken-link")).unwrap(); + std::os::unix::fs::symlink("loop-b", preopen_dir.path().join("loop-a")).unwrap(); + std::os::unix::fs::symlink("loop-a", preopen_dir.path().join("loop-b")).unwrap(); + + let host_fs = + virtual_fs::host_fs::FileSystem::new(tokio::runtime::Handle::current(), Path::new("/")) + .unwrap(); + let inodes = WasiInodes::new(); + let fs_backing = WasiFsRoot::from_filesystem(Arc::new(host_fs)); + let wasi_fs = WasiFs::new_with_preopen( + &inodes, + &[PreopenedDir { + path: preopen_dir.path().to_path_buf(), + alias: None, + read: true, + write: true, + create: false, + }], + &[], + fs_backing, + ) + .unwrap(); + + let fd = *wasi_fs.preopen_fds.read().unwrap().last().unwrap(); + + assert!(wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "inside-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "outside-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "chained-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "broken-chained-link", + Filetype::SymbolicLink, + )); + assert!(wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "broken-link", + Filetype::SymbolicLink, + )); + assert!(wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "loop-a", + Filetype::SymbolicLink, + )); + assert!(wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "loop-b", + Filetype::SymbolicLink, + )); + } + + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + #[tokio::test] + async fn readdir_hides_host_symlinks_that_escape_from_nested_fd_under_absolute_mount() { + let (mount_root, nested_dir) = absolute_host_mount_with_nested_symlink_fixture(); + + let host_fs = virtual_fs::host_fs::FileSystem::new( + tokio::runtime::Handle::current(), + mount_root.path(), + ) + .unwrap(); + let mount_fs = virtual_fs::MountFileSystem::new(); + mount_fs + .mount( + Path::new("/"), + Arc::new(RootFileSystemBuilder::default().build_tmp()), + ) + .unwrap(); + mount_fs + .mount( + mount_root.path(), + Arc::new(host_fs) as Arc, + ) + .unwrap(); + + let inodes = WasiInodes::new(); + let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); + let wasi_fs = WasiFs::new_with_preopen( + &inodes, + &[], + &[mount_root.path().to_string_lossy().to_string()], + fs_backing, + ) + .unwrap(); + let preopen_fd = *wasi_fs.preopen_fds.read().unwrap().last().unwrap(); + + let nested_inode = wasi_fs + .get_inode_at_path(&inodes, preopen_fd, "d1", true) + .unwrap(); + let nested_fd = wasi_fs + .create_fd( + ALL_RIGHTS, + ALL_RIGHTS, + Fdflags::empty(), + Fdflagsext::empty(), + Fd::READ, + nested_inode, + ) + .unwrap(); + + assert!(wasi_fs.readdir_entry_visible( + &inodes, + nested_fd, + Some(&nested_dir), + "inside-link", + Filetype::SymbolicLink, + )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + nested_fd, + Some(&nested_dir), + "outside", + Filetype::SymbolicLink, + )); + } + + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + #[tokio::test] + async fn subtree_mount_symlink_policy_uses_mount_source_path() { + let root_dir = tempdir().unwrap(); + let subtree_dir = root_dir.path().join("sandbox/pkg"); + std::fs::create_dir_all(&subtree_dir).unwrap(); + std::fs::write(root_dir.path().join("sandbox/secret.txt"), b"secret").unwrap(); + std::fs::write(subtree_dir.join("target.txt"), b"inside").unwrap(); + std::os::unix::fs::symlink(subtree_dir.join("target.txt"), subtree_dir.join("abs-link")) + .unwrap(); + std::os::unix::fs::symlink( + root_dir.path().join("sandbox/pkg/../secret.txt"), + subtree_dir.join("escape-link"), + ) + .unwrap(); + + let host_fs = virtual_fs::host_fs::FileSystem::new( + tokio::runtime::Handle::current(), + root_dir.path(), + ) + .unwrap(); + let mount_fs = virtual_fs::MountFileSystem::new(); + mount_fs + .mount( + Path::new("/"), + Arc::new(RootFileSystemBuilder::default().build_tmp()), + ) + .unwrap(); + mount_fs + .mount_with_source( + Path::new("/pkg"), + Path::new("/sandbox/pkg"), + Arc::new(host_fs) as Arc, + ) + .unwrap(); + + let inodes = WasiInodes::new(); + let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); + let wasi_fs = + WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); + + assert!(wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/pkg")), + "abs-link", + Filetype::SymbolicLink, + )); + + let link_inode = wasi_fs + .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/abs-link", false) + .unwrap(); + let guard = link_inode.read(); + let Kind::Symlink { + symlink_kind, + path_to_symlink, + relative_path, + } = guard.deref() + else { + panic!("expected symlink inode"); + }; + let (_, resolved_target) = wasi_fs + .resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path) + .unwrap(); + assert_eq!(resolved_target, Path::new("/pkg/target.txt")); + + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + crate::VIRTUAL_ROOT_FD, + Some(Path::new("/pkg")), + "escape-link", + Filetype::SymbolicLink, + )); + + let escape_inode = wasi_fs + .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/escape-link", false) + .unwrap(); + let guard = escape_inode.read(); + let Kind::Symlink { + symlink_kind, + path_to_symlink, + relative_path, + } = guard.deref() + else { + panic!("expected symlink inode"); + }; + assert_eq!( + wasi_fs.resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path), + Err(Errno::Perm) + ); + } + + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + #[tokio::test] + async fn readdir_prefers_host_mount_policy_over_broader_preopen() { + let (mount_root, nested_dir) = absolute_host_mount_with_nested_symlink_fixture(); + + let host_fs = virtual_fs::host_fs::FileSystem::new( + tokio::runtime::Handle::current(), + mount_root.path(), + ) + .unwrap(); + let mount_fs = virtual_fs::MountFileSystem::new(); + mount_fs + .mount( + Path::new("/"), + Arc::new(RootFileSystemBuilder::default().build_tmp()), + ) + .unwrap(); + mount_fs + .mount( + mount_root.path(), + Arc::new(host_fs) as Arc, + ) + .unwrap(); + + let inodes = WasiInodes::new(); + let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); + let wasi_fs = WasiFs::new_with_preopen( + &inodes, + &[PreopenedDir { + path: PathBuf::from("/"), + alias: None, + read: true, + write: false, + create: false, + }], + &[mount_root.path().to_string_lossy().to_string()], + fs_backing, + ) + .unwrap(); + + let root_preopen_fd = *wasi_fs + .preopen_fds + .read() + .unwrap() + .iter() + .find(|&&fd| { + let guard = wasi_fs.get_fd(fd).unwrap(); + matches!( + guard.inode.read().deref(), + Kind::Dir { path, .. } if path == Path::new("/") + ) + }) + .unwrap(); + let nested_from_root = mount_root + .path() + .join("d1") + .strip_prefix(Path::new("/")) + .unwrap() + .to_string_lossy() + .to_string(); + let nested_inode = wasi_fs + .get_inode_at_path(&inodes, root_preopen_fd, &nested_from_root, true) + .unwrap(); + let nested_fd = wasi_fs + .create_fd( + ALL_RIGHTS, + ALL_RIGHTS, + Fdflags::empty(), + Fdflagsext::empty(), + Fd::READ, + nested_inode, + ) + .unwrap(); + + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + nested_fd, + Some(&nested_dir), + "outside", + Filetype::SymbolicLink, + )); + } + #[tokio::test] async fn webc_backing_symlink_resolves_to_target_entry() { let inodes = WasiInodes::new(); diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index e71c86e68bbc..e68a038ea209 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -1,10 +1,6 @@ use super::*; use crate::syscalls::*; -fn should_list_in_readdir(filetype: Filetype) -> bool { - filetype != Filetype::SymbolicLink -} - /// ### `fd_readdir()` /// Read data from directory specified by file descriptor /// Inputs: @@ -32,7 +28,7 @@ pub fn fd_readdir( WasiEnv::do_pending_operations(&mut ctx)?; let env = ctx.data(); - let (memory, mut state) = unsafe { env.get_memory_and_wasi_state(&ctx, 0) }; + let (memory, mut state, inodes) = unsafe { env.get_memory_and_wasi_state_and_inodes(&ctx, 0) }; // TODO: figure out how this is supposed to work; // is it supposed to pack the buffer full every time until it can't? or do one at a time? @@ -41,50 +37,96 @@ pub fn fd_readdir( let working_dir = wasi_try_ok!(state.fs.get_fd(fd)); let mut buf_idx = 0usize; - let entries: Vec<(String, Filetype, u64)> = { + let (dir_path, cached_entries, is_root): ( + Option, + Vec<(String, InodeGuard)>, + bool, + ) = { let guard = working_dir.inode.read(); match guard.deref() { - Kind::Dir { path, entries, .. } => { + Kind::Dir { path, entries, .. } => ( + Some(path.clone()), + entries + .iter() + .map(|(name, inode)| (name.clone(), inode.clone())) + .collect(), + false, + ), + Kind::Root { entries } => ( + None, + entries + .iter() + .map(|(name, inode)| (name.clone(), inode.clone())) + .collect(), + true, + ), + Kind::File { .. } + | Kind::Symlink { .. } + | Kind::Buffer { .. } + | Kind::Socket { .. } + | Kind::PipeRx { .. } + | Kind::PipeTx { .. } + | Kind::DuplexPipe { .. } + | Kind::EventNotifications { .. } + | Kind::Epoll { .. } => return Ok(Errno::Notdir), + } + }; + + let format_entry_name = |name: &str| { + if !is_root || name.starts_with('/') { + name.to_string() + } else { + format!("/{name}") + } + }; + + let entries: Vec<(String, Filetype, u64)> = { + match dir_path { + Some(path) => { trace!("reading dir {:?}", path); - // TODO: refactor this code - // we need to support multiple calls, - // simple and obviously correct implementation for now: - // maintain consistent order via lexacographic sorting + // TODO: we need to support multiple calls. Keep ordering stable + // with a lexicographic sort for now. let fs_info = wasi_try_ok!( - wasi_try_ok!(state.fs_read_dir(path)) + wasi_try_ok!(state.fs_read_dir(&path)) .collect::, _>>() .map_err(fs_error_into_wasi_err) ); + let mut entry_names = std::collections::HashSet::new(); let fs_entries = fs_info .into_iter() .map(|entry| { let filename = entry.file_name().to_string_lossy().to_string(); + entry_names.insert(filename.clone()); trace!("getting file: {:?}", filename); let filetype = virtual_file_type_to_wasi_file_type( entry.file_type().map_err(fs_error_into_wasi_err)?, ); - Ok(should_list_in_readdir(filetype).then_some(( - filename, filetype, 0, // TODO: inode - ))) + Ok(state + .fs + .readdir_entry_visible(&inodes, fd, Some(&path), &filename, filetype) + .then_some(( + filename, filetype, 0, // TODO: inode + ))) }) .collect::>, Errno>>(); - let mut entry_vec: Vec<(String, Filetype, u64)> = wasi_try_ok!(fs_entries) - .into_iter() - .flatten() - .collect(); - let entry_names: std::collections::HashSet<_> = - entry_vec.iter().map(|(name, _, _)| name.clone()).collect(); + let mut entry_vec: Vec<(String, Filetype, u64)> = + wasi_try_ok!(fs_entries).into_iter().flatten().collect(); entry_vec.extend( - entries + cached_entries .iter() - .filter(|(name, _)| !entry_names.contains(*name)) + .filter(|(name, _)| !entry_names.contains(name)) .filter_map(|(name, inode)| { let stat = inode.stat.read().unwrap(); - should_list_in_readdir(stat.st_filetype).then_some(( - name.clone(), - stat.st_filetype, - stat.st_ino, - )) + state + .fs + .readdir_entry_visible( + &inodes, + fd, + Some(&path), + name, + stat.st_filetype, + ) + .then_some((format_entry_name(name), stat.st_filetype, stat.st_ino)) }), ); // adding . and .. special folders @@ -94,37 +136,21 @@ pub fn fd_readdir( entry_vec.sort_by(|a, b| a.0.cmp(&b.0)); entry_vec } - Kind::Root { entries } => { + None => { trace!("reading root"); - let sorted_entries = { - let mut entry_vec: Vec<(String, InodeGuard)> = entries - .iter() - .map(|(a, b)| (a.clone(), b.clone())) - .collect(); - entry_vec.sort_by(|a, b| a.0.cmp(&b.0)); - entry_vec - }; - sorted_entries + let mut entry_vec: Vec<(String, Filetype, u64)> = cached_entries .into_iter() - .map(|(name, inode)| { + .filter_map(|(name, inode)| { let stat = inode.stat.read().unwrap(); - ( - format!("/{}", inode.name.read().unwrap().as_ref()), - stat.st_filetype, - stat.st_ino, - ) + state + .fs + .readdir_entry_visible(&inodes, fd, None, &name, stat.st_filetype) + .then_some((format_entry_name(&name), stat.st_filetype, stat.st_ino)) }) - .collect() + .collect(); + entry_vec.sort_by(|a, b| a.0.cmp(&b.0)); + entry_vec } - Kind::File { .. } - | Kind::Symlink { .. } - | Kind::Buffer { .. } - | Kind::Socket { .. } - | Kind::PipeRx { .. } - | Kind::PipeTx { .. } - | Kind::DuplexPipe { .. } - | Kind::EventNotifications { .. } - | Kind::Epoll { .. } => return Ok(Errno::Notdir), } }; diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l b/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l index c7f0c93adb2c..03fe081bd968 120000 --- a/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/d2l @@ -1 +1 @@ -/run/media/fliqqr/HD/wasmer/wasmer/lib/wasix/tests/wasm_tests/path_tests/symlink/d2 \ No newline at end of file +../d2 \ No newline at end of file diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/outside b/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/outside new file mode 120000 index 000000000000..c25bddb6dd46 --- /dev/null +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/d1/outside @@ -0,0 +1 @@ +../.. \ No newline at end of file diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt b/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt index 45b983be36b7..1f08ed76e67d 100644 --- a/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/d2/test.txt @@ -1 +1 @@ -hi +Symlink test diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c b/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c index 01475c9e5ee3..5ce1893378f8 100644 --- a/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c @@ -6,6 +6,27 @@ #include #include #include +#include + +static void assert_dir_has_entry(const char *path, const char *expected) +{ + DIR *dir = opendir(path); + assert(dir != NULL); + + int found = 0; + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) + { + if (strcmp(entry->d_name, expected) == 0) + { + found = 1; + break; + } + } + + assert(closedir(dir) == 0); + assert(found); +} static void assert_dir_has_no_entry(const char *path, const char *forbidden) { @@ -23,11 +44,17 @@ static void assert_dir_has_no_entry(const char *path, const char *forbidden) int main(void) { - assert_dir_has_no_entry("./d1", "d2l"); + assert_dir_has_entry("./d1", "d2l"); + assert_dir_has_no_entry("./d1", "outside"); + + errno = 0; + int fd = open("./d1/d2l/test.txt", O_RDONLY); + assert(fd >= 0); + assert(close(fd) == 0); errno = 0; - assert(open("./d1/d2l/test.txt", O_RDONLY) < 0); - assert(errno == ENOENT); + fd = open("./d1/outside/main.c", O_RDONLY); + assert(fd < 0); printf("0"); return 0; From 1082b0128084ea465d85df58979a5bff95a01328 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Wed, 17 Jun 2026 17:18:29 +0200 Subject: [PATCH 03/14] fix: rm raw OS error checking & fix lint --- lib/wasix/src/fs/mod.rs | 177 +++++++++--------- lib/wasix/src/syscalls/wasi/fd_readdir.rs | 6 +- .../wasm_tests/path_tests/symlink/main.c | 76 ++++---- 3 files changed, 124 insertions(+), 135 deletions(-) diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index 58bd1c2ddd46..00272f7d207c 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -23,7 +23,6 @@ mod path_posix; use std::{ borrow::Cow, collections::{HashMap, HashSet}, - ffi::OsString, ops::{Deref, DerefMut}, path::{Path, PathBuf}, pin::Pin, @@ -124,54 +123,6 @@ pub const FS_STDOUT_INO: Inode = Inode(11); pub const FS_STDERR_INO: Inode = Inode(12); pub const FS_ROOT_INO: Inode = Inode(13); -#[cfg(feature = "host-fs")] -fn canonicalize_existing_prefix(path: &Path) -> std::io::Result { - let mut existing_prefix = virtual_fs::host_fs::normalize_path(path); - let mut missing_suffix = Vec::::new(); - - loop { - match std::fs::symlink_metadata(&existing_prefix) { - Ok(_) => { - let mut resolved = virtual_fs::host_fs::canonicalize(&existing_prefix)?; - for component in missing_suffix.iter().rev() { - resolved.push(component); - } - return Ok(resolved); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - let Some(component) = existing_prefix.file_name() else { - return Err(err); - }; - missing_suffix.push(component.to_os_string()); - if !existing_prefix.pop() { - return Err(err); - } - } - Err(err) => return Err(err), - } - } -} - -#[cfg(feature = "host-fs")] -fn resolved_path_contained_within(path: &Path, root: &Path) -> bool { - let canonical_root = match virtual_fs::host_fs::canonicalize(root) { - Ok(root) => root, - Err(_) => return false, - }; - - match std::fs::canonicalize(path) { - Ok(canonical_target) => canonical_target.starts_with(&canonical_root), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - canonicalize_existing_prefix(path) - .is_ok_and(|resolved_target| resolved_target.starts_with(&canonical_root)) - } - Err(err) if matches!(err.raw_os_error(), Some(libc::ELOOP)) => { - virtual_fs::host_fs::normalize_path(path).starts_with(&canonical_root) - } - Err(_) => false, - } -} - const STDIN_DEFAULT_RIGHTS: Rights = { // This might seem a bit overenineered, but it's the only way I // discovered for getting the values in a const environment @@ -1902,45 +1853,27 @@ impl WasiFs { } else { host_root.join(mount_entry.source_path.strip_prefix(Path::new("/")).ok()?) }; + let host_mount_root = virtual_fs::host_fs::normalize_path(&host_mount_root); let symlink_relative = guest_symlink.strip_prefix(&mount_path)?; let host_symlink_path = host_mount_root.join(symlink_relative.as_str()); let raw_target = std::fs::read_link(&host_symlink_path).ok()?; + let target = + self.host_symlink_target_path(&host_mount_root, &host_symlink_path, &raw_target); + if !self.host_symlink_chain_stays_within(&host_mount_root, target.clone()) { + return Some(HostSymlinkPolicy::HiddenEscape); + } if raw_target.is_absolute() { - let normalized_target = virtual_fs::host_fs::normalize_path(&raw_target); - let resolved_target = normalized_target - .strip_prefix(&host_mount_root) - .ok() - .map(|stripped| stripped.to_path_buf()) - .or_else(|| { - virtual_fs::host_fs::canonicalize(&raw_target) - .ok()? - .strip_prefix(&host_mount_root) - .ok() - .map(|stripped| stripped.to_path_buf()) - }); - - return Some(match resolved_target { - Some(stripped) => HostSymlinkPolicy::ResolvedGuestPath( + return target.strip_prefix(&host_mount_root).ok().map(|stripped| { + HostSymlinkPolicy::ResolvedGuestPath( mount_path - .join(&PosixPath::from_path(&stripped)) + .join(&PosixPath::from_path(stripped)) .into_path_buf(), - ), - None => HostSymlinkPolicy::HiddenEscape, + ) }); } - let target = host_symlink_path - .parent() - .unwrap_or(&host_mount_root) - .join(&raw_target); - let contained = resolved_path_contained_within(&target, &host_mount_root); - - Some(if contained { - HostSymlinkPolicy::Visible - } else { - HostSymlinkPolicy::HiddenEscape - }) + Some(HostSymlinkPolicy::Visible) } } @@ -1959,25 +1892,87 @@ impl WasiFs { #[cfg(feature = "host-fs")] { let dir_fd = self.get_fd(fd).ok()?; - let preopen_root = self.preopen_host_root(&dir_fd.inode)?; + let preopen_root = + virtual_fs::host_fs::normalize_path(&self.preopen_host_root(&dir_fd.inode)?); let entry_path = dir_path.join(filename); let link_value = std::fs::read_link(&entry_path).ok()?; - let target = if link_value.is_absolute() { - link_value - } else { - entry_path - .parent() - .unwrap_or(&preopen_root) - .join(link_value) + let target = self.host_symlink_target_path(&preopen_root, &entry_path, &link_value); + Some(self.host_symlink_chain_stays_within(&preopen_root, target)) + } + } + + #[cfg(feature = "host-fs")] + fn host_symlink_target_path( + &self, + root: &Path, + symlink_path: &Path, + target: &Path, + ) -> PathBuf { + let target = if target.is_absolute() { + target.to_path_buf() + } else { + symlink_path.parent().unwrap_or(root).join(target) + }; + + virtual_fs::host_fs::normalize_path(&target) + } + + #[cfg(feature = "host-fs")] + fn host_symlink_chain_stays_within(&self, root: &Path, target: PathBuf) -> bool { + let normalized_root = virtual_fs::host_fs::normalize_path(root); + let mut current = target; + let mut visited = HashSet::new(); + let mut symlink_count = 0; + + loop { + if !current.starts_with(&normalized_root) { + return false; + } + + if !visited.insert(current.clone()) || symlink_count >= MAX_SYMLINKS { + return true; + } + + let relative = match current.strip_prefix(&normalized_root) { + Ok(relative) => relative, + Err(_) => return false, }; - let normalized_root = virtual_fs::host_fs::normalize_path(&preopen_root); - let normalized_target = virtual_fs::host_fs::normalize_path(&target); + let mut inspected = normalized_root.clone(); + let mut components = relative.components(); + let mut followed_symlink = false; + + while let Some(component) = components.next() { + inspected.push(component.as_os_str()); - if !normalized_target.starts_with(&normalized_root) { - return Some(false); + let metadata = match std::fs::symlink_metadata(&inspected) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return true, + Err(_) => return false, + }; + + if !metadata.file_type().is_symlink() { + continue; + } + + let raw_target = match std::fs::read_link(&inspected) { + Ok(target) => target, + Err(_) => return false, + }; + let mut next = + self.host_symlink_target_path(&normalized_root, &inspected, &raw_target); + if !components.as_path().as_os_str().is_empty() { + next = next.join(components.as_path()); + } + + symlink_count += 1; + current = virtual_fs::host_fs::normalize_path(&next); + followed_symlink = true; + break; } - Some(resolved_path_contained_within(&target, &preopen_root)) + if !followed_symlink { + return true; + } } } diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index e68a038ea209..bbbd571243b3 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -103,7 +103,7 @@ pub fn fd_readdir( ); Ok(state .fs - .readdir_entry_visible(&inodes, fd, Some(&path), &filename, filetype) + .readdir_entry_visible(inodes, fd, Some(&path), &filename, filetype) .then_some(( filename, filetype, 0, // TODO: inode ))) @@ -120,7 +120,7 @@ pub fn fd_readdir( state .fs .readdir_entry_visible( - &inodes, + inodes, fd, Some(&path), name, @@ -144,7 +144,7 @@ pub fn fd_readdir( let stat = inode.stat.read().unwrap(); state .fs - .readdir_entry_visible(&inodes, fd, None, &name, stat.st_filetype) + .readdir_entry_visible(inodes, fd, None, &name, stat.st_filetype) .then_some((format_entry_name(&name), stat.st_filetype, stat.st_ino)) }) .collect(); diff --git a/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c b/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c index 5ce1893378f8..8265beafe13c 100644 --- a/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c +++ b/lib/wasix/tests/wasm_tests/path_tests/symlink/main.c @@ -3,59 +3,53 @@ #include #include #include -#include -#include #include +#include +#include #include -static void assert_dir_has_entry(const char *path, const char *expected) -{ - DIR *dir = opendir(path); - assert(dir != NULL); - - int found = 0; - struct dirent *entry; - while ((entry = readdir(dir)) != NULL) - { - if (strcmp(entry->d_name, expected) == 0) - { - found = 1; - break; - } +static void assert_dir_has_entry(const char* path, const char* expected) { + DIR* dir = opendir(path); + assert(dir != NULL); + + int found = 0; + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, expected) == 0) { + found = 1; + break; } + } - assert(closedir(dir) == 0); - assert(found); + assert(closedir(dir) == 0); + assert(found); } -static void assert_dir_has_no_entry(const char *path, const char *forbidden) -{ - DIR *dir = opendir(path); - assert(dir != NULL); +static void assert_dir_has_no_entry(const char* path, const char* forbidden) { + DIR* dir = opendir(path); + assert(dir != NULL); - struct dirent *entry; - while ((entry = readdir(dir)) != NULL) - { - assert(strcmp(entry->d_name, forbidden) != 0); - } + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + assert(strcmp(entry->d_name, forbidden) != 0); + } - assert(closedir(dir) == 0); + assert(closedir(dir) == 0); } -int main(void) -{ - assert_dir_has_entry("./d1", "d2l"); - assert_dir_has_no_entry("./d1", "outside"); +int main(void) { + assert_dir_has_entry("./d1", "d2l"); + assert_dir_has_no_entry("./d1", "outside"); - errno = 0; - int fd = open("./d1/d2l/test.txt", O_RDONLY); - assert(fd >= 0); - assert(close(fd) == 0); + errno = 0; + int fd = open("./d1/d2l/test.txt", O_RDONLY); + assert(fd >= 0); + assert(close(fd) == 0); - errno = 0; - fd = open("./d1/outside/main.c", O_RDONLY); - assert(fd < 0); + errno = 0; + fd = open("./d1/outside/main.c", O_RDONLY); + assert(fd < 0); - printf("0"); - return 0; + printf("0"); + return 0; } From 96699fa0a2e94bb876f4487eb21aa4f23663b0a5 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Wed, 17 Jun 2026 17:34:44 +0200 Subject: [PATCH 04/14] chore: fix lint --- lib/wasix/src/fs/mod.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index 00272f7d207c..c71edacf074c 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -1902,12 +1902,7 @@ impl WasiFs { } #[cfg(feature = "host-fs")] - fn host_symlink_target_path( - &self, - root: &Path, - symlink_path: &Path, - target: &Path, - ) -> PathBuf { + fn host_symlink_target_path(&self, root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { let target = if target.is_absolute() { target.to_path_buf() } else { From 9535494a7d592b765bd9166166bcc9e0d9f00212 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Wed, 17 Jun 2026 18:35:15 +0200 Subject: [PATCH 05/14] fix: restore root entry formatting --- lib/wasix/src/syscalls/wasi/fd_readdir.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index bbbd571243b3..787e6a2f439d 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -145,7 +145,11 @@ pub fn fd_readdir( state .fs .readdir_entry_visible(inodes, fd, None, &name, stat.st_filetype) - .then_some((format_entry_name(&name), stat.st_filetype, stat.st_ino)) + .then(|| { + let display_name = + format!("/{}", inode.name.read().unwrap().as_ref()); + (display_name, stat.st_filetype, stat.st_ino) + }) }) .collect(); entry_vec.sort_by(|a, b| a.0.cmp(&b.0)); From 294562133519eb549e77e31e92abab78751ad6b8 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Tue, 23 Jun 2026 15:11:21 +0200 Subject: [PATCH 06/14] fix: refactor & host-backed checks for all FS --- lib/virtual-fs/src/arc_fs.rs | 8 + lib/virtual-fs/src/host_fs.rs | 178 ++++++++++++++++- lib/virtual-fs/src/lib.rs | 18 ++ lib/virtual-fs/src/mount_fs.rs | 320 +++++++++++++++++++++++++++++- lib/virtual-fs/src/overlay_fs.rs | 35 ++++ lib/virtual-fs/src/passthru_fs.rs | 8 + lib/wasix/src/fs/mod.rs | 262 ------------------------ 7 files changed, 555 insertions(+), 274 deletions(-) diff --git a/lib/virtual-fs/src/arc_fs.rs b/lib/virtual-fs/src/arc_fs.rs index d3ca55c82b09..6766cf055621 100644 --- a/lib/virtual-fs/src/arc_fs.rs +++ b/lib/virtual-fs/src/arc_fs.rs @@ -62,6 +62,14 @@ impl FileSystem for ArcFileSystem { self.fs.remove_file(path) } + fn is_host_backed(&self) -> bool { + self.fs.is_host_backed() + } + + fn is_host_backed_path(&self, path: &Path) -> bool { + self.fs.is_host_backed_path(path) + } + fn new_open_options(&self) -> OpenOptions<'_> { self.fs.new_open_options() } diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index b04d72e8c129..80ddad0531c1 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -1,6 +1,6 @@ use crate::{ - DirEntry, FileType, FsError, Metadata, OpenOptions, OpenOptionsConfig, ReadDir, Result, - VirtualFile, + DirEntry, FileType, FsError, MAX_SYMLINK_TRAVERSAL_DEPTH, Metadata, OpenOptions, + OpenOptionsConfig, ReadDir, Result, VirtualFile, }; use bytes::{Buf, Bytes}; use futures::future::BoxFuture; @@ -97,6 +97,76 @@ fn host_root_relative_target(root: &Path, target: PathBuf) -> PathBuf { target } +fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { + let target = if target.is_absolute() { + target.to_path_buf() + } else { + symlink_path.parent().unwrap_or(root).join(target) + }; + + normalize_path(&target) +} + +fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { + let normalized_root = normalize_path(root); + let mut current = target; + let mut visited = std::collections::HashSet::new(); + let mut symlink_count = 0; + + loop { + if !current.starts_with(&normalized_root) { + return false; + } + + if !visited.insert(current.clone()) { + return true; + } + if symlink_count >= MAX_SYMLINK_TRAVERSAL_DEPTH { + return false; + } + + let relative = match current.strip_prefix(&normalized_root) { + Ok(relative) => relative, + Err(_) => return false, + }; + let mut inspected = normalized_root.clone(); + let mut components = relative.components(); + let mut followed_symlink = false; + + while let Some(component) = components.next() { + inspected.push(component.as_os_str()); + + let metadata = match fs::symlink_metadata(&inspected) { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => return true, + Err(_) => return false, + }; + + if !metadata.file_type().is_symlink() { + continue; + } + + let raw_target = match fs::read_link(&inspected) { + Ok(target) => target, + Err(_) => return false, + }; + let mut next = symlink_target_path(&normalized_root, &inspected, &raw_target); + if !components.as_path().as_os_str().is_empty() { + next = next.join(components.as_path()); + } + + symlink_count += 1; + current = normalize_path(&next); + followed_symlink = true; + break; + } + + if !followed_symlink { + return true; + } + } +} + impl FileSystem { pub fn new(handle: Handle, root: impl Into) -> Result { let root = canonicalize(&root.into())?; @@ -108,6 +178,19 @@ impl FileSystem { &self.root } + fn symlink_entry_visible(&self, path: &Path, metadata: &fs::Metadata) -> bool { + if !metadata.file_type().is_symlink() { + return true; + } + + let target = match fs::read_link(path) { + Ok(target) => symlink_target_path(&self.root, path, &target), + Err(_) => return false, + }; + + symlink_chain_stays_within(&self.root, target) + } + fn prepare_path(&self, path: &Path) -> Result { let path = normalize_path(path); @@ -139,9 +222,14 @@ impl crate::FileSystem for FileSystem { let path = self.prepare_path(path)?; let read_dir = fs::read_dir(path)?; - let mut data = read_dir + let data = read_dir .map(|entry| { let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + + if !self.symlink_entry_visible(&entry.path(), &metadata) { + return Ok(None); + } let path = entry .path() @@ -150,15 +238,14 @@ impl crate::FileSystem for FileSystem { .to_owned(); let path = Path::new("/").join(path); - let metadata = fs::symlink_metadata(entry.path())?; - - Ok(DirEntry { + Ok(Some(DirEntry { path, metadata: Ok(metadata.try_into()?), - }) + })) }) - .collect::, io::Error>>() + .collect::>, io::Error>>() .map_err::(Into::into)?; + let mut data = data.into_iter().flatten().collect::>(); data.sort_by(|a, b| a.path.file_name().cmp(&b.path.file_name())); Ok(ReadDir::new(data)) } @@ -256,6 +343,10 @@ impl crate::FileSystem for FileSystem { fs::remove_file(path).map_err(Into::into) } + fn is_host_backed(&self) -> bool { + true + } + fn new_open_options(&self) -> OpenOptions<'_> { OpenOptions::new(self) } @@ -949,9 +1040,10 @@ mod tests { use tokio::runtime::Handle; use super::FileSystem; - use crate::FileSystem as FileSystemTrait; use crate::FsError; + use crate::{ArcFileSystem, FileSystem as FileSystemTrait, OverlayFileSystem}; use std::path::Path; + use std::sync::Arc; #[tokio::test] async fn test_new_filesystem() { @@ -1082,13 +1174,79 @@ mod tests { ); } - fn read_dir_names(fs: &FileSystem, path: impl AsRef) -> Vec { + fn read_dir_names(fs: &F, path: impl AsRef) -> Vec { fs.read_dir(path.as_ref()) .unwrap() .filter_map(|entry| Some(entry.ok()?.file_name().to_str()?.to_string())) .collect::>() } + #[cfg(unix)] + fn host_symlink_visibility_fixture() -> (TempDir, FileSystem) { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("root"); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(root.join("inside.txt"), b"inside").unwrap(); + std::fs::write(outside.join("outside.txt"), b"outside").unwrap(); + std::os::unix::fs::symlink("inside.txt", root.join("inside-link")).unwrap(); + std::os::unix::fs::symlink(root.join("inside.txt"), root.join("inside-absolute")).unwrap(); + std::os::unix::fs::symlink("../outside/outside.txt", root.join("outside-relative")) + .unwrap(); + std::os::unix::fs::symlink(outside.join("outside.txt"), root.join("outside-absolute")) + .unwrap(); + std::os::unix::fs::symlink("../outside", root.join("pivot")).unwrap(); + std::os::unix::fs::symlink("pivot/outside.txt", root.join("chained-escape")).unwrap(); + std::os::unix::fs::symlink("missing.txt", root.join("broken-link")).unwrap(); + std::os::unix::fs::symlink("loop-b", root.join("loop-a")).unwrap(); + std::os::unix::fs::symlink("loop-a", root.join("loop-b")).unwrap(); + + let fs = FileSystem::new(Handle::current(), root).unwrap(); + (temp, fs) + } + + #[cfg(unix)] + fn assert_host_symlink_visibility(fs: &F) { + let names = read_dir_names(fs, "/"); + assert!(names.contains(&"inside.txt".to_string())); + assert!(names.contains(&"inside-link".to_string())); + assert!(names.contains(&"inside-absolute".to_string())); + assert!(names.contains(&"broken-link".to_string())); + assert!(names.contains(&"loop-a".to_string())); + assert!(names.contains(&"loop-b".to_string())); + assert!(!names.contains(&"outside-relative".to_string())); + assert!(!names.contains(&"outside-absolute".to_string())); + assert!(!names.contains(&"pivot".to_string())); + assert!(!names.contains(&"chained-escape".to_string())); + } + + #[cfg(unix)] + #[tokio::test] + async fn read_dir_hides_host_symlinks_that_escape_root() { + let (_temp, fs) = host_symlink_visibility_fixture(); + + assert_host_symlink_visibility(&fs); + } + + #[cfg(unix)] + #[tokio::test] + async fn arc_file_system_preserves_host_symlink_visibility_policy() { + let (_temp, fs) = host_symlink_visibility_fixture(); + let fs = ArcFileSystem::new(Arc::new(fs)); + + assert_host_symlink_visibility(&fs); + } + + #[cfg(unix)] + #[tokio::test] + async fn overlay_file_system_preserves_host_symlink_visibility_policy() { + let (_temp, fs) = host_symlink_visibility_fixture(); + let fs = OverlayFileSystem::new(crate::mem_fs::FileSystem::default(), [fs]); + + assert_host_symlink_visibility(&fs); + } + #[tokio::test] async fn test_rename() { let temp: TempDir = TempDir::new().unwrap(); diff --git a/lib/virtual-fs/src/lib.rs b/lib/virtual-fs/src/lib.rs index 5ad36bbdfbd1..68ddec6ed01d 100644 --- a/lib/virtual-fs/src/lib.rs +++ b/lib/virtual-fs/src/lib.rs @@ -59,6 +59,8 @@ mod webc_volume_fs; pub mod limiter; +pub(crate) const MAX_SYMLINK_TRAVERSAL_DEPTH: usize = 128; + pub use arc_box_file::*; pub use arc_file::*; pub use arc_fs::*; @@ -113,6 +115,14 @@ pub trait FileSystem: fmt::Debug + Send + Sync + 'static + Upcastable { fn symlink_metadata(&self, path: &Path) -> Result; fn remove_file(&self, path: &Path) -> Result<()>; + fn is_host_backed(&self) -> bool { + false + } + + fn is_host_backed_path(&self, _path: &Path) -> bool { + self.is_host_backed() + } + fn new_open_options(&self) -> OpenOptions<'_>; } @@ -169,6 +179,14 @@ where (**self).remove_file(path) } + fn is_host_backed(&self) -> bool { + (**self).is_host_backed() + } + + fn is_host_backed_path(&self, path: &Path) -> bool { + (**self).is_host_backed_path(path) + } + fn new_open_options(&self) -> OpenOptions<'_> { (**self).new_open_options() } diff --git a/lib/virtual-fs/src/mount_fs.rs b/lib/virtual-fs/src/mount_fs.rs index 39b4d2c06b5b..2ac1d4194cb8 100644 --- a/lib/virtual-fs/src/mount_fs.rs +++ b/lib/virtual-fs/src/mount_fs.rs @@ -60,6 +60,7 @@ impl ExactNode { #[derive(Debug, Clone)] struct ResolvedMount { mount_path: PathBuf, + source_path: PathBuf, delegated_path: PathBuf, fs: DynFileSystem, } @@ -282,6 +283,7 @@ impl MountFileSystem { let mut node = &*root; let mut best = Self::mounted(node).map(|mount| ResolvedMount { mount_path: PathBuf::from("/"), + source_path: mount.source_path.clone(), delegated_path: mount.source_path.join( Self::absolute_path(&components) .strip_prefix("/") @@ -299,6 +301,7 @@ impl MountFileSystem { if let Some(mount) = Self::mounted(node) { best = Some(ResolvedMount { mount_path: Self::absolute_path(&components[..=index]), + source_path: mount.source_path.clone(), delegated_path: mount.source_path.join( Self::absolute_path(&components[index + 1..]) .strip_prefix("/") @@ -324,26 +327,160 @@ impl MountFileSystem { } } + fn symlink_target_path(source_root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { + if target.is_absolute() { + target.to_path_buf() + } else { + symlink_path.parent().unwrap_or(source_root).join(target) + } + } + + fn normalize_mount_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::from("/"); + + for component in path.components() { + match component { + std::path::Component::RootDir | std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + if normalized.as_os_str().is_empty() { + normalized.push("/"); + } + } + std::path::Component::Normal(part) => normalized.push(part), + std::path::Component::Prefix(_) => {} + } + } + + normalized + } + + fn symlink_chain_stays_within_source( + fs: &(dyn FileSystem + Send + Sync), + source_root: &Path, + target: PathBuf, + ) -> bool { + let source_root = Self::normalize_mount_path(source_root); + let mut current = Self::normalize_mount_path(&target); + let mut visited = BTreeSet::new(); + let mut symlink_count = 0; + + loop { + if !current.starts_with(&source_root) { + return false; + } + + if !visited.insert(current.clone()) { + return true; + } + if symlink_count >= MAX_SYMLINK_TRAVERSAL_DEPTH { + return false; + } + + let relative = match current.strip_prefix(&source_root) { + Ok(relative) => relative, + Err(_) => return false, + }; + let mut inspected = source_root.clone(); + let mut components = relative.components(); + let mut followed_symlink = false; + + while let Some(component) = components.next() { + inspected.push(component.as_os_str()); + + let metadata = match fs.symlink_metadata(&inspected) { + Ok(metadata) => metadata, + Err(FsError::EntryNotFound) => return true, + Err(_) => return false, + }; + + if !metadata.ft.is_symlink() { + continue; + } + + let raw_target = match fs.readlink(&inspected) { + Ok(target) => target, + Err(_) => return false, + }; + let mut next = Self::symlink_target_path(&source_root, &inspected, &raw_target); + if !components.as_path().as_os_str().is_empty() { + next = next.join(components.as_path()); + } + + symlink_count += 1; + current = Self::normalize_mount_path(&next); + followed_symlink = true; + break; + } + + if !followed_symlink { + return true; + } + } + } + + fn filter_symlink_entries_to_source( + entries: &mut ReadDir, + fs: &(dyn FileSystem + Send + Sync), + source_root: &Path, + ) { + entries.data.retain(|entry| { + let Ok(metadata) = entry.metadata() else { + return true; + }; + + if !metadata.ft.is_symlink() { + return true; + } + + if !fs.is_host_backed_path(&entry.path) { + return true; + } + + let target = match fs.readlink(&entry.path) { + Ok(target) => Self::symlink_target_path(source_root, &entry.path, &target), + Err(_) => return false, + }; + + Self::symlink_chain_stays_within_source(fs, source_root, target) + }); + } + + fn should_filter_symlink_entries_to_source(fs: &(dyn FileSystem + Send + Sync)) -> bool { + fs.is_host_backed() + } + fn read_dir_from_exact_node(&self, node: &ExactNode) -> Result { let mut entries = Vec::new(); let backing = if let Some(fs) = &node.fs { Some(( + fs.clone(), fs.read_dir(&node.source_path), + node.source_path.clone(), Cow::Borrowed(node.source_path.as_path()), )) } else { self.resolve_mount(&node.path).map(|resolved| { ( + resolved.fs.clone(), resolved.fs.read_dir(&resolved.delegated_path), + resolved.source_path, Cow::Owned(resolved.delegated_path), ) }) }; - if let Some((base_entries, source_path)) = backing { + if let Some((fs, base_entries, source_root, source_path)) = backing { match base_entries { Ok(mut base_entries) => { + if Self::should_filter_symlink_entries_to_source(fs.as_ref()) { + Self::filter_symlink_entries_to_source( + &mut base_entries, + fs.as_ref(), + &source_root, + ); + } Self::rebase_entries(&mut base_entries, &source_path, &node.path); entries.extend(base_entries.data.into_iter().filter(|entry| { entry @@ -465,6 +602,22 @@ impl MountFileSystem { } impl FileSystem for MountFileSystem { + fn is_host_backed(&self) -> bool { + self.mount_entries() + .into_iter() + .any(|entry| entry.fs.is_host_backed()) + } + + fn is_host_backed_path(&self, path: &Path) -> bool { + let Ok(path) = self.prepare_path(path) else { + return false; + }; + + self.resolve_mount(path) + .map(|resolved| resolved.fs.is_host_backed_path(&resolved.delegated_path)) + .unwrap_or(false) + } + fn readlink(&self, path: &Path) -> Result { let path = self.prepare_path(path)?; @@ -494,6 +647,13 @@ impl FileSystem for MountFileSystem { match self.resolve_mount(path.clone()) { Some(resolved) => { let mut entries = resolved.fs.read_dir(&resolved.delegated_path)?; + if Self::should_filter_symlink_entries_to_source(resolved.fs.as_ref()) { + Self::filter_symlink_entries_to_source( + &mut entries, + resolved.fs.as_ref(), + &resolved.source_path, + ); + } Self::rebase_entries( &mut entries, &resolved.delegated_path, @@ -777,7 +937,10 @@ mod tests { use tokio::io::AsyncWriteExt; - use crate::{FileSystem as FileSystemTrait, FsError, MountFileSystem, TmpFileSystem, mem_fs}; + use crate::{ + ArcFileSystem, FileSystem as FileSystemTrait, FsError, MountFileSystem, OverlayFileSystem, + TmpFileSystem, mem_fs, + }; use super::{FileOpener, OpenOptionsConfig}; @@ -1600,6 +1763,159 @@ mod tests { assert_eq!(read_dir_names(&fs, "/runtime"), vec!["lib.py".to_string()]); } + #[tokio::test] + async fn test_mount_with_source_path_preserves_virtual_symlinks_outside_subtree() { + let fs = MountFileSystem::new(); + + let source = TmpFileSystem::new(); + source.create_dir(Path::new("/pkg")).unwrap(); + source.create_dir(Path::new("/shared")).unwrap(); + source + .new_open_options() + .write(true) + .create_new(true) + .open(Path::new("/shared/lib.py")) + .unwrap(); + source + .create_symlink(Path::new("/shared/lib.py"), Path::new("/pkg/absolute-link")) + .unwrap(); + source + .create_symlink( + Path::new("../shared/lib.py"), + Path::new("/pkg/relative-link"), + ) + .unwrap(); + + fs.mount_with_source(Path::new("/runtime"), Path::new("/pkg"), Arc::new(source)) + .unwrap(); + + let names = read_dir_names(&fs, "/runtime"); + assert!(names.contains(&"absolute-link".to_string())); + assert!(names.contains(&"relative-link".to_string())); + } + + #[cfg(unix)] + fn host_source_with_escaping_symlinks() -> (tempfile::TempDir, crate::host_fs::FileSystem) { + let temp = tempfile::TempDir::new().unwrap(); + let sandbox = temp.path().join("sandbox"); + let pkg = sandbox.join("pkg"); + let nested = pkg.join("nested"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(pkg.join("inside.txt"), b"inside").unwrap(); + std::fs::write(sandbox.join("secret.txt"), b"secret").unwrap(); + + std::os::unix::fs::symlink("inside.txt", pkg.join("inside-link")).unwrap(); + std::os::unix::fs::symlink("../inside.txt", nested.join("nested-inside-link")).unwrap(); + std::os::unix::fs::symlink("../secret.txt", pkg.join("escape-relative")).unwrap(); + std::os::unix::fs::symlink(sandbox.join("secret.txt"), pkg.join("escape-absolute")) + .unwrap(); + std::os::unix::fs::symlink("..", pkg.join("pivot")).unwrap(); + std::os::unix::fs::symlink("pivot/secret.txt", pkg.join("escape-chained")).unwrap(); + + let source = + crate::host_fs::FileSystem::new(tokio::runtime::Handle::current(), temp.path()) + .unwrap(); + (temp, source) + } + + #[cfg(unix)] + fn assert_mount_with_source_path_hides_symlinks_escaping_subtree( + source: Arc, + ) { + let fs = MountFileSystem::new(); + fs.mount_with_source(Path::new("/runtime"), Path::new("/sandbox/pkg"), source) + .unwrap(); + + let names = read_dir_names(&fs, "/runtime"); + assert!(names.contains(&"inside.txt".to_string())); + assert!(names.contains(&"inside-link".to_string())); + assert!(!names.contains(&"escape-relative".to_string())); + assert!(!names.contains(&"escape-absolute".to_string())); + assert!(!names.contains(&"pivot".to_string())); + assert!(!names.contains(&"escape-chained".to_string())); + + let nested_names = read_dir_names(&fs, "/runtime/nested"); + assert!(nested_names.contains(&"nested-inside-link".to_string())); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_mount_with_source_path_hides_symlinks_escaping_subtree() { + let (_temp, source) = host_source_with_escaping_symlinks(); + + assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(source)); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_mount_with_source_path_hides_escaping_symlinks_from_arc_host_source() { + let (_temp, source) = host_source_with_escaping_symlinks(); + let source = ArcFileSystem::new(Arc::new(source)); + + assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(source)); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_mount_with_source_path_hides_escaping_symlinks_from_overlay_host_source() { + let (_temp, source) = host_source_with_escaping_symlinks(); + let source = OverlayFileSystem::new(mem_fs::FileSystem::default(), [source]); + + assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(source)); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_mount_with_source_path_hides_escaping_symlinks_from_nested_mount_host_source() { + let (_temp, source) = host_source_with_escaping_symlinks(); + let source = Arc::new(source); + let nested = MountFileSystem::new(); + nested.mount(Path::new("/"), source).unwrap(); + + assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(nested)); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_mount_with_source_path_preserves_virtual_symlinks_from_mixed_overlay_source() { + let (_temp, host) = host_source_with_escaping_symlinks(); + let primary = TmpFileSystem::new(); + primary.create_dir(Path::new("/sandbox")).unwrap(); + primary.create_dir(Path::new("/sandbox/pkg")).unwrap(); + primary.create_dir(Path::new("/shared")).unwrap(); + primary + .new_open_options() + .write(true) + .create_new(true) + .open(Path::new("/shared/lib.py")) + .unwrap(); + primary + .create_symlink( + Path::new("/shared/lib.py"), + Path::new("/sandbox/pkg/virtual-link"), + ) + .unwrap(); + let source = OverlayFileSystem::new(primary, [host]); + + let fs = MountFileSystem::new(); + fs.mount_with_source( + Path::new("/runtime"), + Path::new("/sandbox/pkg"), + Arc::new(source), + ) + .unwrap(); + + let names = read_dir_names(&fs, "/runtime"); + assert!(names.contains(&"virtual-link".to_string())); + assert!(names.contains(&"inside.txt".to_string())); + assert!(names.contains(&"inside-link".to_string())); + assert!(!names.contains(&"escape-relative".to_string())); + assert!(!names.contains(&"escape-absolute".to_string())); + assert!(!names.contains(&"pivot".to_string())); + assert!(!names.contains(&"escape-chained".to_string())); + } + #[tokio::test] async fn test_nested_mount_inside_tree_preserves_sibling_files() { let fs = MountFileSystem::new(); diff --git a/lib/virtual-fs/src/overlay_fs.rs b/lib/virtual-fs/src/overlay_fs.rs index de9366bf4785..767726a45f1b 100644 --- a/lib/virtual-fs/src/overlay_fs.rs +++ b/lib/virtual-fs/src/overlay_fs.rs @@ -605,6 +605,41 @@ where self.permission_error_or_not_found(path) } + fn is_host_backed(&self) -> bool { + self.primary.as_ref().is_host_backed() + || self + .secondaries + .filesystems() + .into_iter() + .any(FileSystem::is_host_backed) + } + + fn is_host_backed_path(&self, path: &Path) -> bool { + if ops::is_white_out(path).is_some() { + return false; + } + + match self.primary.symlink_metadata(path) { + Ok(_) => return self.primary.is_host_backed_path(path), + Err(e) if should_continue(e) => {} + Err(_) => return false, + } + + if ops::has_white_out(&self.primary, path) { + return false; + } + + for fs in self.secondaries.filesystems() { + match fs.symlink_metadata(path) { + Ok(_) => return fs.is_host_backed_path(path), + Err(e) if should_continue(e) => continue, + Err(_) => return false, + } + } + + false + } + fn new_open_options(&self) -> OpenOptions<'_> { OpenOptions::new(self) } diff --git a/lib/virtual-fs/src/passthru_fs.rs b/lib/virtual-fs/src/passthru_fs.rs index a0d3efe9488d..bbdd73631cbf 100644 --- a/lib/virtual-fs/src/passthru_fs.rs +++ b/lib/virtual-fs/src/passthru_fs.rs @@ -65,6 +65,14 @@ impl FileSystem for PassthruFileSystem { self.fs.remove_file(path) } + fn is_host_backed(&self) -> bool { + self.fs.is_host_backed() + } + + fn is_host_backed_path(&self, path: &Path) -> bool { + self.fs.is_host_backed_path(path) + } + fn new_open_options(&self) -> OpenOptions<'_> { self.fs.new_open_options() } diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index c71edacf074c..3f0bf5259f0c 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -3192,18 +3192,6 @@ mod tests { virtual_fs::WebcVolumeFileSystem::new(volume) } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - fn absolute_host_mount_with_nested_symlink_fixture() -> (tempfile::TempDir, PathBuf) { - let mount_root = tempdir().unwrap(); - let nested_dir = mount_root.path().join("d1"); - std::fs::create_dir_all(&nested_dir).unwrap(); - std::fs::write(mount_root.path().join("inside.txt"), b"inside").unwrap(); - std::os::unix::fs::symlink("../inside.txt", nested_dir.join("inside-link")).unwrap(); - std::os::unix::fs::symlink("../..", nested_dir.join("outside")).unwrap(); - - (mount_root, nested_dir) - } - #[tokio::test] async fn test_relative_path_to_absolute() { let inodes = WasiInodes::new(); @@ -3480,92 +3468,6 @@ mod tests { )); } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - #[tokio::test] - async fn readdir_hides_only_host_symlinks_that_escape_the_mount() { - let root_dir = tempdir().unwrap(); - let sandbox_dir = root_dir.path().join("sandbox"); - let outside_dir = root_dir.path().join("outside-dir"); - std::fs::create_dir_all(&sandbox_dir).unwrap(); - std::fs::create_dir_all(&outside_dir).unwrap(); - std::fs::write(sandbox_dir.join("inside.txt"), b"inside").unwrap(); - std::fs::write(root_dir.path().join("outside.txt"), b"outside").unwrap(); - std::fs::write(outside_dir.join("outside.txt"), b"outside").unwrap(); - std::os::unix::fs::symlink("inside.txt", sandbox_dir.join("inside-link")).unwrap(); - std::os::unix::fs::symlink("../outside.txt", sandbox_dir.join("outside-link")).unwrap(); - std::os::unix::fs::symlink("../outside-dir", sandbox_dir.join("pivot")).unwrap(); - std::os::unix::fs::symlink("pivot/outside.txt", sandbox_dir.join("chained-link")).unwrap(); - std::os::unix::fs::symlink("pivot/missing.txt", sandbox_dir.join("broken-chained-link")) - .unwrap(); - std::os::unix::fs::symlink("loop-b", sandbox_dir.join("loop-a")).unwrap(); - std::os::unix::fs::symlink("loop-a", sandbox_dir.join("loop-b")).unwrap(); - - let host_fs = - virtual_fs::host_fs::FileSystem::new(tokio::runtime::Handle::current(), &sandbox_dir) - .unwrap(); - let mount_fs = virtual_fs::MountFileSystem::new(); - mount_fs - .mount( - Path::new("/"), - Arc::new(RootFileSystemBuilder::default().build_tmp()), - ) - .unwrap(); - mount_fs - .mount( - Path::new("/sandbox"), - Arc::new(host_fs) as Arc, - ) - .unwrap(); - - let inodes = WasiInodes::new(); - let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); - let wasi_fs = - WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); - - assert!(wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/sandbox")), - "inside-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/sandbox")), - "outside-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/sandbox")), - "chained-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/sandbox")), - "broken-chained-link", - Filetype::SymbolicLink, - )); - assert!(wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/sandbox")), - "loop-a", - Filetype::SymbolicLink, - )); - assert!(wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/sandbox")), - "loop-b", - Filetype::SymbolicLink, - )); - } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] #[tokio::test] async fn readdir_filters_host_symlinks_for_direct_preopens() { @@ -3660,71 +3562,6 @@ mod tests { )); } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - #[tokio::test] - async fn readdir_hides_host_symlinks_that_escape_from_nested_fd_under_absolute_mount() { - let (mount_root, nested_dir) = absolute_host_mount_with_nested_symlink_fixture(); - - let host_fs = virtual_fs::host_fs::FileSystem::new( - tokio::runtime::Handle::current(), - mount_root.path(), - ) - .unwrap(); - let mount_fs = virtual_fs::MountFileSystem::new(); - mount_fs - .mount( - Path::new("/"), - Arc::new(RootFileSystemBuilder::default().build_tmp()), - ) - .unwrap(); - mount_fs - .mount( - mount_root.path(), - Arc::new(host_fs) as Arc, - ) - .unwrap(); - - let inodes = WasiInodes::new(); - let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); - let wasi_fs = WasiFs::new_with_preopen( - &inodes, - &[], - &[mount_root.path().to_string_lossy().to_string()], - fs_backing, - ) - .unwrap(); - let preopen_fd = *wasi_fs.preopen_fds.read().unwrap().last().unwrap(); - - let nested_inode = wasi_fs - .get_inode_at_path(&inodes, preopen_fd, "d1", true) - .unwrap(); - let nested_fd = wasi_fs - .create_fd( - ALL_RIGHTS, - ALL_RIGHTS, - Fdflags::empty(), - Fdflagsext::empty(), - Fd::READ, - nested_inode, - ) - .unwrap(); - - assert!(wasi_fs.readdir_entry_visible( - &inodes, - nested_fd, - Some(&nested_dir), - "inside-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - nested_fd, - Some(&nested_dir), - "outside", - Filetype::SymbolicLink, - )); - } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] #[tokio::test] async fn subtree_mount_symlink_policy_uses_mount_source_path() { @@ -3766,14 +3603,6 @@ mod tests { let wasi_fs = WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); - assert!(wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/pkg")), - "abs-link", - Filetype::SymbolicLink, - )); - let link_inode = wasi_fs .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/abs-link", false) .unwrap(); @@ -3791,14 +3620,6 @@ mod tests { .unwrap(); assert_eq!(resolved_target, Path::new("/pkg/target.txt")); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - crate::VIRTUAL_ROOT_FD, - Some(Path::new("/pkg")), - "escape-link", - Filetype::SymbolicLink, - )); - let escape_inode = wasi_fs .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/escape-link", false) .unwrap(); @@ -3817,89 +3638,6 @@ mod tests { ); } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - #[tokio::test] - async fn readdir_prefers_host_mount_policy_over_broader_preopen() { - let (mount_root, nested_dir) = absolute_host_mount_with_nested_symlink_fixture(); - - let host_fs = virtual_fs::host_fs::FileSystem::new( - tokio::runtime::Handle::current(), - mount_root.path(), - ) - .unwrap(); - let mount_fs = virtual_fs::MountFileSystem::new(); - mount_fs - .mount( - Path::new("/"), - Arc::new(RootFileSystemBuilder::default().build_tmp()), - ) - .unwrap(); - mount_fs - .mount( - mount_root.path(), - Arc::new(host_fs) as Arc, - ) - .unwrap(); - - let inodes = WasiInodes::new(); - let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); - let wasi_fs = WasiFs::new_with_preopen( - &inodes, - &[PreopenedDir { - path: PathBuf::from("/"), - alias: None, - read: true, - write: false, - create: false, - }], - &[mount_root.path().to_string_lossy().to_string()], - fs_backing, - ) - .unwrap(); - - let root_preopen_fd = *wasi_fs - .preopen_fds - .read() - .unwrap() - .iter() - .find(|&&fd| { - let guard = wasi_fs.get_fd(fd).unwrap(); - matches!( - guard.inode.read().deref(), - Kind::Dir { path, .. } if path == Path::new("/") - ) - }) - .unwrap(); - let nested_from_root = mount_root - .path() - .join("d1") - .strip_prefix(Path::new("/")) - .unwrap() - .to_string_lossy() - .to_string(); - let nested_inode = wasi_fs - .get_inode_at_path(&inodes, root_preopen_fd, &nested_from_root, true) - .unwrap(); - let nested_fd = wasi_fs - .create_fd( - ALL_RIGHTS, - ALL_RIGHTS, - Fdflags::empty(), - Fdflagsext::empty(), - Fd::READ, - nested_inode, - ) - .unwrap(); - - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - nested_fd, - Some(&nested_dir), - "outside", - Filetype::SymbolicLink, - )); - } - #[tokio::test] async fn webc_backing_symlink_resolves_to_target_entry() { let inodes = WasiInodes::new(); From 079f4f17df0a8e55298b2262640a23ca172bf528 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Thu, 23 Jul 2026 09:48:23 +0200 Subject: [PATCH 07/14] fix: better guards --- lib/virtual-fs/src/trace_fs.rs | 8 ++ lib/wasix/src/fs/mod.rs | 242 ++++++++++++++++++++++++++++++--- 2 files changed, 232 insertions(+), 18 deletions(-) diff --git a/lib/virtual-fs/src/trace_fs.rs b/lib/virtual-fs/src/trace_fs.rs index 667bc84c5bc3..2815daea5134 100644 --- a/lib/virtual-fs/src/trace_fs.rs +++ b/lib/virtual-fs/src/trace_fs.rs @@ -83,6 +83,14 @@ where self.0.remove_file(path) } + fn is_host_backed(&self) -> bool { + self.0.is_host_backed() + } + + fn is_host_backed_path(&self, path: &std::path::Path) -> bool { + self.0.is_host_backed_path(path) + } + #[tracing::instrument(level = "trace", skip(self))] fn new_open_options(&self) -> crate::OpenOptions<'_> { crate::OpenOptions::new(self) diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index 3f0bf5259f0c..59941e9273f9 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -1842,29 +1842,28 @@ impl WasiFs { .is_some() }) .max_by_key(|entry| PosixPath::from_path(&entry.path).as_str().len())?; - let host_fs = mount_entry - .fs - .upcast_any_ref() - .downcast_ref::()?; - let host_root = host_fs.root_path(); let mount_path = PosixPath::from_path(&mount_entry.path); - let host_mount_root = if mount_entry.source_path == Path::new("/") { - host_root.to_path_buf() - } else { - host_root.join(mount_entry.source_path.strip_prefix(Path::new("/")).ok()?) - }; - let host_mount_root = virtual_fs::host_fs::normalize_path(&host_mount_root); + let source_root = mount_entry.source_path.clone(); let symlink_relative = guest_symlink.strip_prefix(&mount_path)?; - let host_symlink_path = host_mount_root.join(symlink_relative.as_str()); - let raw_target = std::fs::read_link(&host_symlink_path).ok()?; - let target = - self.host_symlink_target_path(&host_mount_root, &host_symlink_path, &raw_target); - if !self.host_symlink_chain_stays_within(&host_mount_root, target.clone()) { + let symlink_path = source_root.join(symlink_relative.as_str()); + + if !mount_entry.fs.is_host_backed_path(&symlink_path) { + return None; + } + + let raw_target = mount_entry.fs.readlink(&symlink_path).ok()?; + let target = self.backing_symlink_target_path(&source_root, &symlink_path, &raw_target); + if !self.backing_symlink_chain_stays_within_source( + mount_entry.fs.as_ref(), + &source_root, + target.clone(), + ) { return Some(HostSymlinkPolicy::HiddenEscape); } if raw_target.is_absolute() { - return target.strip_prefix(&host_mount_root).ok().map(|stripped| { + let source_root = Self::normalize_backing_path(&source_root); + return target.strip_prefix(&source_root).ok().map(|stripped| { HostSymlinkPolicy::ResolvedGuestPath( mount_path .join(&PosixPath::from_path(stripped)) @@ -1901,6 +1900,110 @@ impl WasiFs { } } + #[cfg(feature = "host-fs")] + fn normalize_backing_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::from("/"); + + for component in path.components() { + match component { + std::path::Component::RootDir | std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + if normalized.as_os_str().is_empty() { + normalized.push("/"); + } + } + std::path::Component::Normal(part) => normalized.push(part), + std::path::Component::Prefix(_) => {} + } + } + + normalized + } + + #[cfg(feature = "host-fs")] + fn backing_symlink_target_path( + &self, + root: &Path, + symlink_path: &Path, + target: &Path, + ) -> PathBuf { + let target = if target.is_absolute() { + target.to_path_buf() + } else { + symlink_path.parent().unwrap_or(root).join(target) + }; + + Self::normalize_backing_path(&target) + } + + #[cfg(feature = "host-fs")] + fn backing_symlink_chain_stays_within_source( + &self, + fs: &(dyn FileSystem + Send + Sync), + root: &Path, + target: PathBuf, + ) -> bool { + let normalized_root = Self::normalize_backing_path(root); + let mut current = Self::normalize_backing_path(&target); + let mut visited = HashSet::new(); + let mut symlink_count = 0; + + loop { + if !current.starts_with(&normalized_root) { + return false; + } + + if !visited.insert(current.clone()) { + return true; + } + if symlink_count >= MAX_SYMLINKS { + return false; + } + + let relative = match current.strip_prefix(&normalized_root) { + Ok(relative) => relative, + Err(_) => return false, + }; + let mut inspected = normalized_root.clone(); + let mut components = relative.components(); + let mut followed_symlink = false; + + while let Some(component) = components.next() { + inspected.push(component.as_os_str()); + + let metadata = match fs.symlink_metadata(&inspected) { + Ok(metadata) => metadata, + Err(FsError::EntryNotFound) => return true, + Err(_) => return false, + }; + + if !metadata.ft.is_symlink() { + continue; + } + + let raw_target = match fs.readlink(&inspected) { + Ok(target) => target, + Err(_) => return false, + }; + let mut next = + self.backing_symlink_target_path(&normalized_root, &inspected, &raw_target); + if !components.as_path().as_os_str().is_empty() { + next = next.join(components.as_path()); + } + + symlink_count += 1; + current = Self::normalize_backing_path(&next); + followed_symlink = true; + break; + } + + if !followed_symlink { + return true; + } + } + } + #[cfg(feature = "host-fs")] fn host_symlink_target_path(&self, root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { let target = if target.is_absolute() { @@ -1924,9 +2027,12 @@ impl WasiFs { return false; } - if !visited.insert(current.clone()) || symlink_count >= MAX_SYMLINKS { + if !visited.insert(current.clone()) { return true; } + if symlink_count >= MAX_SYMLINKS { + return false; + } let relative = match current.strip_prefix(&normalized_root) { Ok(relative) => relative, @@ -3489,6 +3595,19 @@ mod tests { std::os::unix::fs::symlink("missing.txt", preopen_dir.path().join("broken-link")).unwrap(); std::os::unix::fs::symlink("loop-b", preopen_dir.path().join("loop-a")).unwrap(); std::os::unix::fs::symlink("loop-a", preopen_dir.path().join("loop-b")).unwrap(); + std::os::unix::fs::symlink("limit-0", preopen_dir.path().join("limit-link")).unwrap(); + for i in 0..MAX_SYMLINKS { + std::os::unix::fs::symlink( + format!("limit-{}", i + 1), + preopen_dir.path().join(format!("limit-{i}")), + ) + .unwrap(); + } + std::os::unix::fs::symlink( + outside_dir.path(), + preopen_dir.path().join(format!("limit-{MAX_SYMLINKS}")), + ) + .unwrap(); let host_fs = virtual_fs::host_fs::FileSystem::new(tokio::runtime::Handle::current(), Path::new("/")) @@ -3560,6 +3679,13 @@ mod tests { "loop-b", Filetype::SymbolicLink, )); + assert!(!wasi_fs.readdir_entry_visible( + &inodes, + fd, + Some(preopen_dir.path()), + "limit-link", + Filetype::SymbolicLink, + )); } #[cfg(all(unix, feature = "host-fs", feature = "sys"))] @@ -4002,4 +4128,84 @@ mod tests { Errno::Noent ); } + + #[cfg(all(unix, feature = "host-fs", feature = "sys"))] + #[tokio::test] + async fn subtree_mount_symlink_policy_supports_wrapped_host_source() { + let root_dir = tempdir().unwrap(); + let subtree_dir = root_dir.path().join("sandbox/pkg"); + std::fs::create_dir_all(&subtree_dir).unwrap(); + std::fs::write(root_dir.path().join("sandbox/secret.txt"), b"secret").unwrap(); + std::fs::write(subtree_dir.join("target.txt"), b"inside").unwrap(); + std::os::unix::fs::symlink(subtree_dir.join("target.txt"), subtree_dir.join("abs-link")) + .unwrap(); + std::os::unix::fs::symlink( + root_dir.path().join("sandbox/pkg/../secret.txt"), + subtree_dir.join("escape-link"), + ) + .unwrap(); + + let host_fs = virtual_fs::host_fs::FileSystem::new( + tokio::runtime::Handle::current(), + root_dir.path(), + ) + .unwrap(); + let wrapped_host = + ArcFileSystem::new(Arc::new(host_fs) as Arc); + let overlay_fs = + OverlayFileSystem::new(RootFileSystemBuilder::default().build_tmp(), [wrapped_host]); + let mount_fs = virtual_fs::MountFileSystem::new(); + mount_fs + .mount( + Path::new("/"), + Arc::new(RootFileSystemBuilder::default().build_tmp()), + ) + .unwrap(); + mount_fs + .mount_with_source( + Path::new("/pkg"), + Path::new("/sandbox/pkg"), + Arc::new(overlay_fs) as Arc, + ) + .unwrap(); + + let inodes = WasiInodes::new(); + let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); + let wasi_fs = + WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); + + let link_inode = wasi_fs + .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/abs-link", false) + .unwrap(); + let guard = link_inode.read(); + let Kind::Symlink { + symlink_kind, + path_to_symlink, + relative_path, + } = guard.deref() + else { + panic!("expected symlink inode"); + }; + let (_, resolved_target) = wasi_fs + .resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path) + .unwrap(); + assert_eq!(resolved_target, Path::new("/pkg/target.txt")); + + let escape_inode = wasi_fs + .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/escape-link", false) + .unwrap(); + let guard = escape_inode.read(); + let Kind::Symlink { + symlink_kind, + path_to_symlink, + relative_path, + } = guard.deref() + else { + panic!("expected symlink inode"); + }; + assert_eq!( + wasi_fs.resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path), + Err(Errno::Perm) + ); + } } From a01cbbbcf53fac00d9f2e981748a4c4270712632 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Tue, 23 Jun 2026 16:28:43 +0200 Subject: [PATCH 08/14] fix: more cfg refactor cleanup --- lib/virtual-fs/src/arc_fs.rs | 4 + lib/virtual-fs/src/host_fs.rs | 48 +++-- lib/virtual-fs/src/lib.rs | 14 ++ lib/virtual-fs/src/mount_fs.rs | 92 +++++++++ lib/virtual-fs/src/overlay_fs.rs | 27 ++- lib/virtual-fs/src/passthru_fs.rs | 4 + lib/virtual-fs/src/trace_fs.rs | 5 + lib/wasix/src/fs/mod.rs | 313 ++---------------------------- 8 files changed, 202 insertions(+), 305 deletions(-) diff --git a/lib/virtual-fs/src/arc_fs.rs b/lib/virtual-fs/src/arc_fs.rs index 6766cf055621..5bfb9803a889 100644 --- a/lib/virtual-fs/src/arc_fs.rs +++ b/lib/virtual-fs/src/arc_fs.rs @@ -58,6 +58,10 @@ impl FileSystem for ArcFileSystem { self.fs.symlink_metadata(path) } + fn symlink_policy(&self, path: &Path) -> Result { + self.fs.symlink_policy(path) + } + fn remove_file(&self, path: &Path) -> Result<()> { self.fs.remove_file(path) } diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index 80ddad0531c1..d8d1cf333192 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -1,6 +1,6 @@ use crate::{ DirEntry, FileType, FsError, MAX_SYMLINK_TRAVERSAL_DEPTH, Metadata, OpenOptions, - OpenOptionsConfig, ReadDir, Result, VirtualFile, + OpenOptionsConfig, ReadDir, Result, SymlinkPolicy, VirtualFile, }; use bytes::{Buf, Bytes}; use futures::future::BoxFuture; @@ -167,6 +167,32 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { } } +pub fn symlink_policy_at(root: &Path, path: &Path) -> Result { + let root = normalize_path(root); + let path = normalize_path(path); + + if !path.starts_with(&root) { + return Err(FsError::InvalidInput); + } + + let metadata = fs::symlink_metadata(&path)?; + + if !metadata.file_type().is_symlink() { + return Ok(SymlinkPolicy::Visible); + } + + let target = match fs::read_link(&path) { + Ok(target) => symlink_target_path(&root, &path, &target), + Err(_) => return Ok(SymlinkPolicy::Hidden), + }; + + if symlink_chain_stays_within(&root, target) { + Ok(SymlinkPolicy::Visible) + } else { + Ok(SymlinkPolicy::Hidden) + } +} + impl FileSystem { pub fn new(handle: Handle, root: impl Into) -> Result { let root = canonicalize(&root.into())?; @@ -179,16 +205,11 @@ impl FileSystem { } fn symlink_entry_visible(&self, path: &Path, metadata: &fs::Metadata) -> bool { - if !metadata.file_type().is_symlink() { - return true; - } - - let target = match fs::read_link(path) { - Ok(target) => symlink_target_path(&self.root, path, &target), - Err(_) => return false, - }; - - symlink_chain_stays_within(&self.root, target) + !metadata.file_type().is_symlink() + || matches!( + symlink_policy_at(&self.root, path), + Ok(SymlinkPolicy::Visible | SymlinkPolicy::ResolvedPath(_)) + ) } fn prepare_path(&self, path: &Path) -> Result { @@ -366,6 +387,11 @@ impl crate::FileSystem for FileSystem { .and_then(TryInto::try_into) .map_err(Into::into) } + + fn symlink_policy(&self, path: &Path) -> Result { + let path = self.prepare_path(path)?; + symlink_policy_at(&self.root, &path) + } } impl TryInto for std::fs::Metadata { diff --git a/lib/virtual-fs/src/lib.rs b/lib/virtual-fs/src/lib.rs index 68ddec6ed01d..520b3d9c1aeb 100644 --- a/lib/virtual-fs/src/lib.rs +++ b/lib/virtual-fs/src/lib.rs @@ -96,6 +96,13 @@ pub trait CloneableVirtualFile: VirtualFile + Clone {} pub use ops::{copy_reference, copy_reference_ext, create_dir_all, walk}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SymlinkPolicy { + Visible, + Hidden, + ResolvedPath(PathBuf), +} + pub trait FileSystem: fmt::Debug + Send + Sync + 'static + Upcastable { fn readlink(&self, path: &Path) -> Result; fn read_dir(&self, path: &Path) -> Result; @@ -113,6 +120,9 @@ pub trait FileSystem: fmt::Debug + Send + Sync + 'static + Upcastable { /// Currently identical to `metadata` because symlinks aren't implemented /// yet. fn symlink_metadata(&self, path: &Path) -> Result; + fn symlink_policy(&self, path: &Path) -> Result { + self.symlink_metadata(path).map(|_| SymlinkPolicy::Visible) + } fn remove_file(&self, path: &Path) -> Result<()>; fn is_host_backed(&self) -> bool { @@ -175,6 +185,10 @@ where (**self).symlink_metadata(path) } + fn symlink_policy(&self, path: &Path) -> Result { + (**self).symlink_policy(path) + } + fn remove_file(&self, path: &Path) -> Result<()> { (**self).remove_file(path) } diff --git a/lib/virtual-fs/src/mount_fs.rs b/lib/virtual-fs/src/mount_fs.rs index 2ac1d4194cb8..562f6c06bb7a 100644 --- a/lib/virtual-fs/src/mount_fs.rs +++ b/lib/virtual-fs/src/mount_fs.rs @@ -450,6 +450,64 @@ impl MountFileSystem { fs.is_host_backed() } + fn rebase_symlink_policy_path(source_root: &Path, mount_path: &Path, target: &Path) -> PathBuf { + let source_root = Self::normalize_mount_path(source_root); + let target = Self::normalize_mount_path(target); + + match target.strip_prefix(&source_root) { + Ok(stripped) => mount_path.join(stripped), + Err(_) => target, + } + } + + fn rebase_symlink_policy( + policy: SymlinkPolicy, + source_root: &Path, + mount_path: &Path, + ) -> SymlinkPolicy { + match policy { + SymlinkPolicy::ResolvedPath(path) => SymlinkPolicy::ResolvedPath( + Self::rebase_symlink_policy_path(source_root, mount_path, &path), + ), + policy => policy, + } + } + + fn symlink_policy_from_mount( + fs: &(dyn FileSystem + Send + Sync), + source_root: &Path, + mount_path: &Path, + delegated_path: &Path, + ) -> Result { + let policy = fs.symlink_policy(delegated_path)?; + if matches!(policy, SymlinkPolicy::Hidden) { + return Ok(policy); + } + + if Self::should_filter_symlink_entries_to_source(fs) + && fs.is_host_backed_path(delegated_path) + { + let metadata = fs.symlink_metadata(delegated_path)?; + if metadata.ft.is_symlink() { + let raw_target = match fs.readlink(delegated_path) { + Ok(target) => target, + Err(_) => return Ok(SymlinkPolicy::Hidden), + }; + let target = Self::symlink_target_path(source_root, delegated_path, &raw_target); + if !Self::symlink_chain_stays_within_source(fs, source_root, target.clone()) { + return Ok(SymlinkPolicy::Hidden); + } + if raw_target.is_absolute() { + return Ok(SymlinkPolicy::ResolvedPath( + Self::rebase_symlink_policy_path(source_root, mount_path, &target), + )); + } + } + } + + Ok(Self::rebase_symlink_policy(policy, source_root, mount_path)) + } + fn read_dir_from_exact_node(&self, node: &ExactNode) -> Result { let mut entries = Vec::new(); @@ -865,6 +923,40 @@ impl FileSystem for MountFileSystem { } } + fn symlink_policy(&self, path: &Path) -> Result { + let path = self.prepare_path(path)?; + + if let Some(node) = self.exact_node(&path) { + return if let Some(fs) = node.fs { + match Self::symlink_policy_from_mount( + fs.as_ref(), + &node.source_path, + &node.path, + &node.source_path, + ) { + Err(error) if Self::should_fallback_to_synthetic_dir(&error) => { + Ok(SymlinkPolicy::Visible) + } + result => result, + } + } else if node.has_children() { + Ok(SymlinkPolicy::Visible) + } else { + Err(FsError::EntryNotFound) + }; + } + + match self.resolve_mount(path) { + Some(resolved) => Self::symlink_policy_from_mount( + resolved.fs.as_ref(), + &resolved.source_path, + &resolved.mount_path, + &resolved.delegated_path, + ), + None => Err(FsError::EntryNotFound), + } + } + fn remove_file(&self, path: &Path) -> Result<()> { let path = self.prepare_path(path)?; diff --git a/lib/virtual-fs/src/overlay_fs.rs b/lib/virtual-fs/src/overlay_fs.rs index 767726a45f1b..0ffbeb9483b9 100644 --- a/lib/virtual-fs/src/overlay_fs.rs +++ b/lib/virtual-fs/src/overlay_fs.rs @@ -14,7 +14,7 @@ use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; use crate::{ FileOpener, FileSystem, FileSystems, FsError, Metadata, OpenOptions, OpenOptionsConfig, - ReadDir, VirtualFile, ops, + ReadDir, SymlinkPolicy, VirtualFile, ops, }; fn unlink_overlay_path

(primary: &Arc

, path: &Path) -> Result<(), FsError> @@ -580,6 +580,31 @@ where Err(FsError::EntryNotFound) } + fn symlink_policy(&self, path: &Path) -> crate::Result { + if ops::is_white_out(path).is_some() { + return Err(FsError::EntryNotFound); + } + + match self.primary.symlink_policy(path) { + Ok(policy) => return Ok(policy), + Err(e) if should_continue(e) => {} + Err(e) => return Err(e), + } + + if ops::has_white_out(&self.primary, path) { + return Err(FsError::EntryNotFound); + } + + for fs in self.secondaries.filesystems() { + match fs.symlink_policy(path) { + Err(e) if should_continue(e) => continue, + other => return other, + } + } + + Err(FsError::EntryNotFound) + } + fn remove_file(&self, path: &Path) -> Result<(), FsError> { // It is not possible to delete whiteout files directly, instead // one must delete the original file diff --git a/lib/virtual-fs/src/passthru_fs.rs b/lib/virtual-fs/src/passthru_fs.rs index bbdd73631cbf..11fe3938ec34 100644 --- a/lib/virtual-fs/src/passthru_fs.rs +++ b/lib/virtual-fs/src/passthru_fs.rs @@ -61,6 +61,10 @@ impl FileSystem for PassthruFileSystem { self.fs.symlink_metadata(path) } + fn symlink_policy(&self, path: &Path) -> Result { + self.fs.symlink_policy(path) + } + fn remove_file(&self, path: &Path) -> Result<()> { self.fs.remove_file(path) } diff --git a/lib/virtual-fs/src/trace_fs.rs b/lib/virtual-fs/src/trace_fs.rs index 2815daea5134..becbda21e291 100644 --- a/lib/virtual-fs/src/trace_fs.rs +++ b/lib/virtual-fs/src/trace_fs.rs @@ -78,6 +78,11 @@ where self.0.symlink_metadata(path) } + #[tracing::instrument(level = "trace", skip(self), err)] + fn symlink_policy(&self, path: &std::path::Path) -> crate::Result { + self.0.symlink_policy(path) + } + #[tracing::instrument(level = "trace", skip(self), err)] fn remove_file(&self, path: &std::path::Path) -> crate::Result<()> { self.0.remove_file(path) diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index 59941e9273f9..f9a7fcabd6a3 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -155,13 +155,6 @@ const STDERR_DEFAULT_RIGHTS: Rights = STDOUT_DEFAULT_RIGHTS; /// the number of symlinks that can be traversed when resolving a path pub const MAX_SYMLINKS: u32 = 128; -#[derive(Debug, Clone)] -enum HostSymlinkPolicy { - Visible, - HiddenEscape, - ResolvedGuestPath(PathBuf), -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Inode(u64); @@ -1695,56 +1688,25 @@ impl WasiFs { let symlink_path_buf = PosixPath::new("/").join(&PosixPath::from_path(path_to_symlink)); let symlink_path = symlink_path_buf.as_posix_path(); - let mount_entry = self - .root_fs - .root() - .mount_entries() - .into_iter() - .filter(|entry| { - symlink_path - .strip_prefix(&PosixPath::from_path(&entry.path)) - .is_some() - }) - .max_by_key(|entry| PosixPath::from_path(&entry.path).as_str().len()) - .ok_or(Errno::Perm)?; - let mount_path = mount_entry.path; - - let symlink_relative = symlink_path - .strip_prefix(&PosixPath::from_path(&mount_path)) - .ok_or(Errno::Perm)?; - let symlink_parent = symlink_relative.parent().into_path_buf(); - if let Some(policy) = self.host_symlink_policy_for_guest_path( - Path::new(symlink_path.as_str()), - Some(&mount_path), - ) { + if let Some(policy) = + self.symlink_policy_for_guest_path(Path::new(symlink_path.as_str())) + { match policy { - HostSymlinkPolicy::HiddenEscape => return Err(Errno::Perm), - HostSymlinkPolicy::ResolvedGuestPath(path) => { + virtual_fs::SymlinkPolicy::Hidden => return Err(Errno::Perm), + virtual_fs::SymlinkPolicy::ResolvedPath(path) => { return Ok((VIRTUAL_ROOT_FD, path)); } - HostSymlinkPolicy::Visible => {} + virtual_fs::SymlinkPolicy::Visible => {} } } - let contained_target = if relative_posix.is_absolute() { - let stripped = relative_posix - .strip_prefix(&PosixPath::from_path(&mount_entry.source_path)) - .ok_or(Errno::Perm)?; - PosixPathBuf::from(stripped.as_str().to_owned()) - } else { - PosixPathBuf::resolve_relative( - &PosixPath::from_path(&symlink_parent), - &relative_posix, - false, - )? - }; + if relative_posix.is_absolute() { + return Ok((VIRTUAL_ROOT_FD, relative_path.to_owned())); + } - return Ok(( - VIRTUAL_ROOT_FD, - PosixPath::from_path(&mount_path) - .join(&contained_target.as_posix_path()) - .into_path_buf(), - )); + PosixPath::from_path(path_to_symlink) + .parent() + .into_path_buf() } }; @@ -1778,8 +1740,8 @@ impl WasiFs { } let guest_path = dir_path.join(filename); - if let Some(policy) = self.host_symlink_policy_for_guest_path(&guest_path, None) { - return !matches!(policy, HostSymlinkPolicy::HiddenEscape); + if let Some(policy) = self.symlink_policy_for_guest_path(&guest_path) { + return !matches!(policy, virtual_fs::SymlinkPolicy::Hidden); } if let Some(visible) = preopen_visibility { @@ -1811,69 +1773,11 @@ impl WasiFs { .unwrap_or(true) } - fn host_symlink_policy_for_guest_path( + fn symlink_policy_for_guest_path( &self, guest_symlink_path: &Path, - known_mount_path: Option<&Path>, - ) -> Option { - #[cfg(not(feature = "host-fs"))] - { - let _ = (guest_symlink_path, known_mount_path); - None - } - - #[cfg(feature = "host-fs")] - { - let guest_symlink = PosixPath::from_path(guest_symlink_path); - let mount_entry = self - .root_fs - .root() - .mount_entries() - .into_iter() - .filter(|entry| { - if let Some(mount_path) = known_mount_path - && entry.path != mount_path - { - return false; - } - - guest_symlink - .strip_prefix(&PosixPath::from_path(&entry.path)) - .is_some() - }) - .max_by_key(|entry| PosixPath::from_path(&entry.path).as_str().len())?; - let mount_path = PosixPath::from_path(&mount_entry.path); - let source_root = mount_entry.source_path.clone(); - let symlink_relative = guest_symlink.strip_prefix(&mount_path)?; - let symlink_path = source_root.join(symlink_relative.as_str()); - - if !mount_entry.fs.is_host_backed_path(&symlink_path) { - return None; - } - - let raw_target = mount_entry.fs.readlink(&symlink_path).ok()?; - let target = self.backing_symlink_target_path(&source_root, &symlink_path, &raw_target); - if !self.backing_symlink_chain_stays_within_source( - mount_entry.fs.as_ref(), - &source_root, - target.clone(), - ) { - return Some(HostSymlinkPolicy::HiddenEscape); - } - - if raw_target.is_absolute() { - let source_root = Self::normalize_backing_path(&source_root); - return target.strip_prefix(&source_root).ok().map(|stripped| { - HostSymlinkPolicy::ResolvedGuestPath( - mount_path - .join(&PosixPath::from_path(stripped)) - .into_path_buf(), - ) - }); - } - - Some(HostSymlinkPolicy::Visible) - } + ) -> Option { + self.root_fs.root().symlink_policy(guest_symlink_path).ok() } fn host_preopen_symlink_visibility( @@ -1894,186 +1798,9 @@ impl WasiFs { let preopen_root = virtual_fs::host_fs::normalize_path(&self.preopen_host_root(&dir_fd.inode)?); let entry_path = dir_path.join(filename); - let link_value = std::fs::read_link(&entry_path).ok()?; - let target = self.host_symlink_target_path(&preopen_root, &entry_path, &link_value); - Some(self.host_symlink_chain_stays_within(&preopen_root, target)) - } - } - - #[cfg(feature = "host-fs")] - fn normalize_backing_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::from("/"); - - for component in path.components() { - match component { - std::path::Component::RootDir | std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - normalized.pop(); - if normalized.as_os_str().is_empty() { - normalized.push("/"); - } - } - std::path::Component::Normal(part) => normalized.push(part), - std::path::Component::Prefix(_) => {} - } - } - - normalized - } - - #[cfg(feature = "host-fs")] - fn backing_symlink_target_path( - &self, - root: &Path, - symlink_path: &Path, - target: &Path, - ) -> PathBuf { - let target = if target.is_absolute() { - target.to_path_buf() - } else { - symlink_path.parent().unwrap_or(root).join(target) - }; - - Self::normalize_backing_path(&target) - } - - #[cfg(feature = "host-fs")] - fn backing_symlink_chain_stays_within_source( - &self, - fs: &(dyn FileSystem + Send + Sync), - root: &Path, - target: PathBuf, - ) -> bool { - let normalized_root = Self::normalize_backing_path(root); - let mut current = Self::normalize_backing_path(&target); - let mut visited = HashSet::new(); - let mut symlink_count = 0; - - loop { - if !current.starts_with(&normalized_root) { - return false; - } - - if !visited.insert(current.clone()) { - return true; - } - if symlink_count >= MAX_SYMLINKS { - return false; - } - - let relative = match current.strip_prefix(&normalized_root) { - Ok(relative) => relative, - Err(_) => return false, - }; - let mut inspected = normalized_root.clone(); - let mut components = relative.components(); - let mut followed_symlink = false; - - while let Some(component) = components.next() { - inspected.push(component.as_os_str()); - - let metadata = match fs.symlink_metadata(&inspected) { - Ok(metadata) => metadata, - Err(FsError::EntryNotFound) => return true, - Err(_) => return false, - }; - - if !metadata.ft.is_symlink() { - continue; - } - - let raw_target = match fs.readlink(&inspected) { - Ok(target) => target, - Err(_) => return false, - }; - let mut next = - self.backing_symlink_target_path(&normalized_root, &inspected, &raw_target); - if !components.as_path().as_os_str().is_empty() { - next = next.join(components.as_path()); - } - - symlink_count += 1; - current = Self::normalize_backing_path(&next); - followed_symlink = true; - break; - } - - if !followed_symlink { - return true; - } - } - } - - #[cfg(feature = "host-fs")] - fn host_symlink_target_path(&self, root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { - let target = if target.is_absolute() { - target.to_path_buf() - } else { - symlink_path.parent().unwrap_or(root).join(target) - }; - - virtual_fs::host_fs::normalize_path(&target) - } - - #[cfg(feature = "host-fs")] - fn host_symlink_chain_stays_within(&self, root: &Path, target: PathBuf) -> bool { - let normalized_root = virtual_fs::host_fs::normalize_path(root); - let mut current = target; - let mut visited = HashSet::new(); - let mut symlink_count = 0; - - loop { - if !current.starts_with(&normalized_root) { - return false; - } - - if !visited.insert(current.clone()) { - return true; - } - if symlink_count >= MAX_SYMLINKS { - return false; - } - - let relative = match current.strip_prefix(&normalized_root) { - Ok(relative) => relative, - Err(_) => return false, - }; - let mut inspected = normalized_root.clone(); - let mut components = relative.components(); - let mut followed_symlink = false; - - while let Some(component) = components.next() { - inspected.push(component.as_os_str()); - - let metadata = match std::fs::symlink_metadata(&inspected) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return true, - Err(_) => return false, - }; - - if !metadata.file_type().is_symlink() { - continue; - } - - let raw_target = match std::fs::read_link(&inspected) { - Ok(target) => target, - Err(_) => return false, - }; - let mut next = - self.host_symlink_target_path(&normalized_root, &inspected, &raw_target); - if !components.as_path().as_os_str().is_empty() { - next = next.join(components.as_path()); - } - - symlink_count += 1; - current = virtual_fs::host_fs::normalize_path(&next); - followed_symlink = true; - break; - } - - if !followed_symlink { - return true; - } + virtual_fs::host_fs::symlink_policy_at(&preopen_root, &entry_path) + .ok() + .map(|policy| !matches!(policy, virtual_fs::SymlinkPolicy::Hidden)) } } From c1973bf750f0c2d916c738c232644466d424a247 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Tue, 23 Jun 2026 16:50:42 +0200 Subject: [PATCH 09/14] fix: final final refactor and regression fixes --- lib/virtual-fs/src/host_fs.rs | 47 ++++++++++++----- lib/virtual-fs/src/lib.rs | 1 + lib/virtual-fs/src/mount_fs.rs | 61 ++++++++++++++++++----- lib/virtual-fs/src/path.rs | 51 +++++++++++++++++++ lib/wasix/src/syscalls/wasi/fd_readdir.rs | 43 +++++++++++----- 5 files changed, 165 insertions(+), 38 deletions(-) create mode 100644 lib/virtual-fs/src/path.rs diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index d8d1cf333192..52f0993076a0 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -1,6 +1,6 @@ use crate::{ DirEntry, FileType, FsError, MAX_SYMLINK_TRAVERSAL_DEPTH, Metadata, OpenOptions, - OpenOptionsConfig, ReadDir, Result, SymlinkPolicy, VirtualFile, + OpenOptionsConfig, ReadDir, Result, SymlinkPolicy, VirtualFile, path, }; use bytes::{Buf, Bytes}; use futures::future::BoxFuture; @@ -97,14 +97,9 @@ fn host_root_relative_target(root: &Path, target: PathBuf) -> PathBuf { target } -fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { - let target = if target.is_absolute() { - target.to_path_buf() - } else { - symlink_path.parent().unwrap_or(root).join(target) - }; - - normalize_path(&target) +fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> Option { + let base = symlink_path.parent().unwrap_or(root); + path::resolve_path_within(root, base, target, normalize_path) } fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { @@ -150,13 +145,24 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { Ok(target) => target, Err(_) => return false, }; - let mut next = symlink_target_path(&normalized_root, &inspected, &raw_target); + let mut next = match symlink_target_path(&normalized_root, &inspected, &raw_target) { + Some(next) => next, + None => return false, + }; if !components.as_path().as_os_str().is_empty() { - next = next.join(components.as_path()); + next = match path::resolve_path_within( + &normalized_root, + &next, + components.as_path(), + normalize_path, + ) { + Some(next) => next, + None => return false, + }; } symlink_count += 1; - current = normalize_path(&next); + current = next; followed_symlink = true; break; } @@ -182,7 +188,10 @@ pub fn symlink_policy_at(root: &Path, path: &Path) -> Result { } let target = match fs::read_link(&path) { - Ok(target) => symlink_target_path(&root, &path, &target), + Ok(target) => match symlink_target_path(&root, &path, &target) { + Some(target) => target, + None => return Ok(SymlinkPolicy::Hidden), + }, Err(_) => return Ok(SymlinkPolicy::Hidden), }; @@ -1222,6 +1231,16 @@ mod tests { .unwrap(); std::os::unix::fs::symlink(outside.join("outside.txt"), root.join("outside-absolute")) .unwrap(); + std::os::unix::fs::symlink( + "../outside/../root/inside.txt", + root.join("leave-reenter-relative"), + ) + .unwrap(); + std::os::unix::fs::symlink( + root.join("../outside/../root/inside.txt"), + root.join("leave-reenter-absolute"), + ) + .unwrap(); std::os::unix::fs::symlink("../outside", root.join("pivot")).unwrap(); std::os::unix::fs::symlink("pivot/outside.txt", root.join("chained-escape")).unwrap(); std::os::unix::fs::symlink("missing.txt", root.join("broken-link")).unwrap(); @@ -1243,6 +1262,8 @@ mod tests { assert!(names.contains(&"loop-b".to_string())); assert!(!names.contains(&"outside-relative".to_string())); assert!(!names.contains(&"outside-absolute".to_string())); + assert!(!names.contains(&"leave-reenter-relative".to_string())); + assert!(!names.contains(&"leave-reenter-absolute".to_string())); assert!(!names.contains(&"pivot".to_string())); assert!(!names.contains(&"chained-escape".to_string())); } diff --git a/lib/virtual-fs/src/lib.rs b/lib/virtual-fs/src/lib.rs index 520b3d9c1aeb..21e420baf4f7 100644 --- a/lib/virtual-fs/src/lib.rs +++ b/lib/virtual-fs/src/lib.rs @@ -41,6 +41,7 @@ pub mod mem_fs; pub mod mount_fs; pub mod null_file; pub mod passthru_fs; +pub(crate) mod path; pub mod random_file; pub mod special_file; pub mod tmp_fs; diff --git a/lib/virtual-fs/src/mount_fs.rs b/lib/virtual-fs/src/mount_fs.rs index 562f6c06bb7a..e63c24163d37 100644 --- a/lib/virtual-fs/src/mount_fs.rs +++ b/lib/virtual-fs/src/mount_fs.rs @@ -327,14 +327,6 @@ impl MountFileSystem { } } - fn symlink_target_path(source_root: &Path, symlink_path: &Path, target: &Path) -> PathBuf { - if target.is_absolute() { - target.to_path_buf() - } else { - symlink_path.parent().unwrap_or(source_root).join(target) - } - } - fn normalize_mount_path(path: &Path) -> PathBuf { let mut normalized = PathBuf::from("/"); @@ -355,6 +347,15 @@ impl MountFileSystem { normalized } + fn symlink_target_path( + source_root: &Path, + symlink_path: &Path, + target: &Path, + ) -> Option { + let base = symlink_path.parent().unwrap_or(source_root); + path::resolve_path_within(source_root, base, target, Self::normalize_mount_path) + } + fn symlink_chain_stays_within_source( fs: &(dyn FileSystem + Send + Sync), source_root: &Path, @@ -402,13 +403,25 @@ impl MountFileSystem { Ok(target) => target, Err(_) => return false, }; - let mut next = Self::symlink_target_path(&source_root, &inspected, &raw_target); + let mut next = + match Self::symlink_target_path(&source_root, &inspected, &raw_target) { + Some(next) => next, + None => return false, + }; if !components.as_path().as_os_str().is_empty() { - next = next.join(components.as_path()); + next = match path::resolve_path_within( + &source_root, + &next, + components.as_path(), + Self::normalize_mount_path, + ) { + Some(next) => next, + None => return false, + }; } symlink_count += 1; - current = Self::normalize_mount_path(&next); + current = next; followed_symlink = true; break; } @@ -438,7 +451,10 @@ impl MountFileSystem { } let target = match fs.readlink(&entry.path) { - Ok(target) => Self::symlink_target_path(source_root, &entry.path, &target), + Ok(target) => match Self::symlink_target_path(source_root, &entry.path, &target) { + Some(target) => target, + None => return false, + }, Err(_) => return false, }; @@ -493,7 +509,11 @@ impl MountFileSystem { Ok(target) => target, Err(_) => return Ok(SymlinkPolicy::Hidden), }; - let target = Self::symlink_target_path(source_root, delegated_path, &raw_target); + let target = + match Self::symlink_target_path(source_root, delegated_path, &raw_target) { + Some(target) => target, + None => return Ok(SymlinkPolicy::Hidden), + }; if !Self::symlink_chain_stays_within_source(fs, source_root, target.clone()) { return Ok(SymlinkPolicy::Hidden); } @@ -1894,6 +1914,7 @@ mod tests { let nested = pkg.join("nested"); std::fs::create_dir_all(&pkg).unwrap(); std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(sandbox.join("outside")).unwrap(); std::fs::write(pkg.join("inside.txt"), b"inside").unwrap(); std::fs::write(sandbox.join("secret.txt"), b"secret").unwrap(); @@ -1902,6 +1923,16 @@ mod tests { std::os::unix::fs::symlink("../secret.txt", pkg.join("escape-relative")).unwrap(); std::os::unix::fs::symlink(sandbox.join("secret.txt"), pkg.join("escape-absolute")) .unwrap(); + std::os::unix::fs::symlink( + "../outside/../pkg/inside.txt", + pkg.join("leave-reenter-relative"), + ) + .unwrap(); + std::os::unix::fs::symlink( + pkg.join("../outside/../pkg/inside.txt"), + pkg.join("leave-reenter-absolute"), + ) + .unwrap(); std::os::unix::fs::symlink("..", pkg.join("pivot")).unwrap(); std::os::unix::fs::symlink("pivot/secret.txt", pkg.join("escape-chained")).unwrap(); @@ -1924,6 +1955,8 @@ mod tests { assert!(names.contains(&"inside-link".to_string())); assert!(!names.contains(&"escape-relative".to_string())); assert!(!names.contains(&"escape-absolute".to_string())); + assert!(!names.contains(&"leave-reenter-relative".to_string())); + assert!(!names.contains(&"leave-reenter-absolute".to_string())); assert!(!names.contains(&"pivot".to_string())); assert!(!names.contains(&"escape-chained".to_string())); @@ -2004,6 +2037,8 @@ mod tests { assert!(names.contains(&"inside-link".to_string())); assert!(!names.contains(&"escape-relative".to_string())); assert!(!names.contains(&"escape-absolute".to_string())); + assert!(!names.contains(&"leave-reenter-relative".to_string())); + assert!(!names.contains(&"leave-reenter-absolute".to_string())); assert!(!names.contains(&"pivot".to_string())); assert!(!names.contains(&"escape-chained".to_string())); } diff --git a/lib/virtual-fs/src/path.rs b/lib/virtual-fs/src/path.rs new file mode 100644 index 000000000000..1996c230457f --- /dev/null +++ b/lib/virtual-fs/src/path.rs @@ -0,0 +1,51 @@ +use std::path::{Component, Path, PathBuf}; + +pub(crate) fn resolve_path_within( + root: &Path, + base: &Path, + target: &Path, + normalize: impl Fn(&Path) -> PathBuf, +) -> Option { + let root = normalize(root); + let base = normalize(base); + if !base.starts_with(&root) { + return None; + } + + let (mut resolved, target) = if target.is_absolute() { + let stripped = if root == Path::new("/") { + target.strip_prefix(Path::new("/")).ok()? + } else { + target.strip_prefix(&root).ok()? + }; + (root.clone(), stripped) + } else { + (base, target) + }; + + for component in target.components() { + match component { + Component::Prefix(..) | Component::RootDir => return None, + Component::CurDir => {} + Component::ParentDir => { + if resolved == root { + if root.parent().is_none() { + continue; + } + return None; + } + if !resolved.pop() || !resolved.starts_with(&root) { + return None; + } + } + Component::Normal(part) => { + resolved.push(part); + if !resolved.starts_with(&root) { + return None; + } + } + } + } + + Some(resolved) +} diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index 787e6a2f439d..19da58dda257 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -72,14 +72,6 @@ pub fn fd_readdir( } }; - let format_entry_name = |name: &str| { - if !is_root || name.starts_with('/') { - name.to_string() - } else { - format!("/{name}") - } - }; - let entries: Vec<(String, Filetype, u64)> = { match dir_path { Some(path) => { @@ -126,7 +118,11 @@ pub fn fd_readdir( name, stat.st_filetype, ) - .then_some((format_entry_name(name), stat.st_filetype, stat.st_ino)) + .then_some(( + format_entry_name(name, is_root), + stat.st_filetype, + stat.st_ino, + )) }), ); // adding . and .. special folders @@ -146,9 +142,11 @@ pub fn fd_readdir( .fs .readdir_entry_visible(inodes, fd, None, &name, stat.st_filetype) .then(|| { - let display_name = - format!("/{}", inode.name.read().unwrap().as_ref()); - (display_name, stat.st_filetype, stat.st_ino) + ( + format_entry_name(&name, true), + stat.st_filetype, + stat.st_ino, + ) }) }) .collect(); @@ -196,3 +194,24 @@ pub fn fd_readdir( wasi_try_mem_ok!(bufused_ref.write(buf_idx)); Ok(Errno::Success) } + +fn format_entry_name(name: &str, is_root: bool) -> String { + if !is_root || name.starts_with('/') { + name.to_string() + } else { + format!("/{name}") + } +} + +#[cfg(test)] +mod tests { + use super::format_entry_name; + + #[test] + fn root_entry_names_keep_existing_absolute_prefix() { + assert_eq!(format_entry_name("/", true), "/"); + assert_eq!(format_entry_name("/foo", true), "/foo"); + assert_eq!(format_entry_name("foo", true), "/foo"); + assert_eq!(format_entry_name("foo", false), "foo"); + } +} From b55fcc1616b0eb9ed90c0f5022dfee46fe3f4da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C4=8Euri=C5=A1?= Date: Tue, 23 Jun 2026 19:54:32 +0200 Subject: [PATCH 10/14] fix: failing tests & addressed comments --- lib/virtual-fs/src/host_fs.rs | 14 ++++++++++++- lib/wasix/src/syscalls/wasi/fd_readdir.rs | 24 +++++++++++++++-------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index 52f0993076a0..b07fea373ba3 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -99,7 +99,19 @@ fn host_root_relative_target(root: &Path, target: PathBuf) -> PathBuf { fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> Option { let base = symlink_path.parent().unwrap_or(root); - path::resolve_path_within(root, base, target, normalize_path) + path::resolve_path_within(root, base, target, normalize_path).or_else(|| { + if target.is_absolute() + && !target + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(..))) + { + canonicalize(target) + .ok() + .filter(|target| target.starts_with(root)) + } else { + None + } + }) } fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index 19da58dda257..fd4e60331e49 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -93,12 +93,20 @@ pub fn fd_readdir( let filetype = virtual_file_type_to_wasi_file_type( entry.file_type().map_err(fs_error_into_wasi_err)?, ); - Ok(state - .fs - .readdir_entry_visible(inodes, fd, Some(&path), &filename, filetype) - .then_some(( + if state.fs.readdir_entry_visible( + inodes, + fd, + Some(&path), + &filename, + filetype, + ) { + entry_names.insert(filename.clone()); + Ok(Some(( filename, filetype, 0, // TODO: inode ))) + } else { + Ok(None) + } }) .collect::>, Errno>>(); let mut entry_vec: Vec<(String, Filetype, u64)> = @@ -196,7 +204,7 @@ pub fn fd_readdir( } fn format_entry_name(name: &str, is_root: bool) -> String { - if !is_root || name.starts_with('/') { + if !is_root { name.to_string() } else { format!("/{name}") @@ -208,9 +216,9 @@ mod tests { use super::format_entry_name; #[test] - fn root_entry_names_keep_existing_absolute_prefix() { - assert_eq!(format_entry_name("/", true), "/"); - assert_eq!(format_entry_name("/foo", true), "/foo"); + fn root_entry_names_are_prefixed_for_virtual_root() { + assert_eq!(format_entry_name("/", true), "//"); + assert_eq!(format_entry_name(".", true), "/."); assert_eq!(format_entry_name("foo", true), "/foo"); assert_eq!(format_entry_name("foo", false), "foo"); } From 611839261a0fc2aa09d4c27dea41cfa160fbbe7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C4=8Euri=C5=A1?= Date: Wed, 24 Jun 2026 12:28:18 +0200 Subject: [PATCH 11/14] refactor: moved host path resolution visibility to symlink policy --- lib/virtual-fs/src/arc_fs.rs | 8 ---- lib/virtual-fs/src/host_fs.rs | 35 ++++++-------- lib/virtual-fs/src/lib.rs | 16 ------- lib/virtual-fs/src/mount_fs.rs | 76 +++++++++---------------------- lib/virtual-fs/src/overlay_fs.rs | 35 -------------- lib/virtual-fs/src/passthru_fs.rs | 8 ---- lib/virtual-fs/src/trace_fs.rs | 8 ---- lib/wasix/src/fs/mod.rs | 73 +++++++++++++++++++++-------- 8 files changed, 89 insertions(+), 170 deletions(-) diff --git a/lib/virtual-fs/src/arc_fs.rs b/lib/virtual-fs/src/arc_fs.rs index 5bfb9803a889..19b9b591d834 100644 --- a/lib/virtual-fs/src/arc_fs.rs +++ b/lib/virtual-fs/src/arc_fs.rs @@ -66,14 +66,6 @@ impl FileSystem for ArcFileSystem { self.fs.remove_file(path) } - fn is_host_backed(&self) -> bool { - self.fs.is_host_backed() - } - - fn is_host_backed_path(&self, path: &Path) -> bool { - self.fs.is_host_backed_path(path) - } - fn new_open_options(&self) -> OpenOptions<'_> { self.fs.new_open_options() } diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index b07fea373ba3..10809715bb8a 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -114,7 +114,7 @@ fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> Optio }) } -fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { +fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option { let normalized_root = normalize_path(root); let mut current = target; let mut visited = std::collections::HashSet::new(); @@ -122,19 +122,19 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { loop { if !current.starts_with(&normalized_root) { - return false; + return None; } if !visited.insert(current.clone()) { - return true; + return None; } if symlink_count >= MAX_SYMLINK_TRAVERSAL_DEPTH { - return false; + return None; } let relative = match current.strip_prefix(&normalized_root) { Ok(relative) => relative, - Err(_) => return false, + Err(_) => return None, }; let mut inspected = normalized_root.clone(); let mut components = relative.components(); @@ -145,8 +145,7 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { let metadata = match fs::symlink_metadata(&inspected) { Ok(metadata) => metadata, - Err(err) if err.kind() == io::ErrorKind::NotFound => return true, - Err(_) => return false, + Err(_) => return None, }; if !metadata.file_type().is_symlink() { @@ -155,11 +154,11 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { let raw_target = match fs::read_link(&inspected) { Ok(target) => target, - Err(_) => return false, + Err(_) => return None, }; let mut next = match symlink_target_path(&normalized_root, &inspected, &raw_target) { Some(next) => next, - None => return false, + None => return None, }; if !components.as_path().as_os_str().is_empty() { next = match path::resolve_path_within( @@ -169,7 +168,7 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { normalize_path, ) { Some(next) => next, - None => return false, + None => return None, }; } @@ -180,7 +179,7 @@ fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { } if !followed_symlink { - return true; + return Some(current); } } } @@ -207,8 +206,8 @@ pub fn symlink_policy_at(root: &Path, path: &Path) -> Result { Err(_) => return Ok(SymlinkPolicy::Hidden), }; - if symlink_chain_stays_within(&root, target) { - Ok(SymlinkPolicy::Visible) + if let Some(target) = symlink_chain_target_within(&root, target) { + Ok(SymlinkPolicy::ResolvedPath(target)) } else { Ok(SymlinkPolicy::Hidden) } @@ -385,10 +384,6 @@ impl crate::FileSystem for FileSystem { fs::remove_file(path).map_err(Into::into) } - fn is_host_backed(&self) -> bool { - true - } - fn new_open_options(&self) -> OpenOptions<'_> { OpenOptions::new(self) } @@ -1269,9 +1264,9 @@ mod tests { assert!(names.contains(&"inside.txt".to_string())); assert!(names.contains(&"inside-link".to_string())); assert!(names.contains(&"inside-absolute".to_string())); - assert!(names.contains(&"broken-link".to_string())); - assert!(names.contains(&"loop-a".to_string())); - assert!(names.contains(&"loop-b".to_string())); + assert!(!names.contains(&"broken-link".to_string())); + assert!(!names.contains(&"loop-a".to_string())); + assert!(!names.contains(&"loop-b".to_string())); assert!(!names.contains(&"outside-relative".to_string())); assert!(!names.contains(&"outside-absolute".to_string())); assert!(!names.contains(&"leave-reenter-relative".to_string())); diff --git a/lib/virtual-fs/src/lib.rs b/lib/virtual-fs/src/lib.rs index 21e420baf4f7..697724ff4acf 100644 --- a/lib/virtual-fs/src/lib.rs +++ b/lib/virtual-fs/src/lib.rs @@ -126,14 +126,6 @@ pub trait FileSystem: fmt::Debug + Send + Sync + 'static + Upcastable { } fn remove_file(&self, path: &Path) -> Result<()>; - fn is_host_backed(&self) -> bool { - false - } - - fn is_host_backed_path(&self, _path: &Path) -> bool { - self.is_host_backed() - } - fn new_open_options(&self) -> OpenOptions<'_>; } @@ -194,14 +186,6 @@ where (**self).remove_file(path) } - fn is_host_backed(&self) -> bool { - (**self).is_host_backed() - } - - fn is_host_backed_path(&self, path: &Path) -> bool { - (**self).is_host_backed_path(path) - } - fn new_open_options(&self) -> OpenOptions<'_> { (**self).new_open_options() } diff --git a/lib/virtual-fs/src/mount_fs.rs b/lib/virtual-fs/src/mount_fs.rs index e63c24163d37..731f8f998ab7 100644 --- a/lib/virtual-fs/src/mount_fs.rs +++ b/lib/virtual-fs/src/mount_fs.rs @@ -436,6 +436,7 @@ impl MountFileSystem { entries: &mut ReadDir, fs: &(dyn FileSystem + Send + Sync), source_root: &Path, + mount_path: &Path, ) { entries.data.retain(|entry| { let Ok(metadata) = entry.metadata() else { @@ -446,26 +447,13 @@ impl MountFileSystem { return true; } - if !fs.is_host_backed_path(&entry.path) { - return true; - } - - let target = match fs.readlink(&entry.path) { - Ok(target) => match Self::symlink_target_path(source_root, &entry.path, &target) { - Some(target) => target, - None => return false, - }, - Err(_) => return false, - }; - - Self::symlink_chain_stays_within_source(fs, source_root, target) + !matches!( + Self::symlink_policy_from_mount(fs, source_root, mount_path, &entry.path), + Ok(SymlinkPolicy::Hidden) | Err(_) + ) }); } - fn should_filter_symlink_entries_to_source(fs: &(dyn FileSystem + Send + Sync)) -> bool { - fs.is_host_backed() - } - fn rebase_symlink_policy_path(source_root: &Path, mount_path: &Path, target: &Path) -> PathBuf { let source_root = Self::normalize_mount_path(source_root); let target = Self::normalize_mount_path(target); @@ -500,9 +488,7 @@ impl MountFileSystem { return Ok(policy); } - if Self::should_filter_symlink_entries_to_source(fs) - && fs.is_host_backed_path(delegated_path) - { + if matches!(policy, SymlinkPolicy::ResolvedPath(_)) { let metadata = fs.symlink_metadata(delegated_path)?; if metadata.ft.is_symlink() { let raw_target = match fs.readlink(delegated_path) { @@ -517,11 +503,9 @@ impl MountFileSystem { if !Self::symlink_chain_stays_within_source(fs, source_root, target.clone()) { return Ok(SymlinkPolicy::Hidden); } - if raw_target.is_absolute() { - return Ok(SymlinkPolicy::ResolvedPath( - Self::rebase_symlink_policy_path(source_root, mount_path, &target), - )); - } + return Ok(SymlinkPolicy::ResolvedPath( + Self::rebase_symlink_policy_path(source_root, mount_path, &target), + )); } } @@ -552,13 +536,12 @@ impl MountFileSystem { if let Some((fs, base_entries, source_root, source_path)) = backing { match base_entries { Ok(mut base_entries) => { - if Self::should_filter_symlink_entries_to_source(fs.as_ref()) { - Self::filter_symlink_entries_to_source( - &mut base_entries, - fs.as_ref(), - &source_root, - ); - } + Self::filter_symlink_entries_to_source( + &mut base_entries, + fs.as_ref(), + &source_root, + &node.path, + ); Self::rebase_entries(&mut base_entries, &source_path, &node.path); entries.extend(base_entries.data.into_iter().filter(|entry| { entry @@ -680,22 +663,6 @@ impl MountFileSystem { } impl FileSystem for MountFileSystem { - fn is_host_backed(&self) -> bool { - self.mount_entries() - .into_iter() - .any(|entry| entry.fs.is_host_backed()) - } - - fn is_host_backed_path(&self, path: &Path) -> bool { - let Ok(path) = self.prepare_path(path) else { - return false; - }; - - self.resolve_mount(path) - .map(|resolved| resolved.fs.is_host_backed_path(&resolved.delegated_path)) - .unwrap_or(false) - } - fn readlink(&self, path: &Path) -> Result { let path = self.prepare_path(path)?; @@ -725,13 +692,12 @@ impl FileSystem for MountFileSystem { match self.resolve_mount(path.clone()) { Some(resolved) => { let mut entries = resolved.fs.read_dir(&resolved.delegated_path)?; - if Self::should_filter_symlink_entries_to_source(resolved.fs.as_ref()) { - Self::filter_symlink_entries_to_source( - &mut entries, - resolved.fs.as_ref(), - &resolved.source_path, - ); - } + Self::filter_symlink_entries_to_source( + &mut entries, + resolved.fs.as_ref(), + &resolved.source_path, + &resolved.mount_path, + ); Self::rebase_entries( &mut entries, &resolved.delegated_path, diff --git a/lib/virtual-fs/src/overlay_fs.rs b/lib/virtual-fs/src/overlay_fs.rs index 0ffbeb9483b9..82ca1335c444 100644 --- a/lib/virtual-fs/src/overlay_fs.rs +++ b/lib/virtual-fs/src/overlay_fs.rs @@ -630,41 +630,6 @@ where self.permission_error_or_not_found(path) } - fn is_host_backed(&self) -> bool { - self.primary.as_ref().is_host_backed() - || self - .secondaries - .filesystems() - .into_iter() - .any(FileSystem::is_host_backed) - } - - fn is_host_backed_path(&self, path: &Path) -> bool { - if ops::is_white_out(path).is_some() { - return false; - } - - match self.primary.symlink_metadata(path) { - Ok(_) => return self.primary.is_host_backed_path(path), - Err(e) if should_continue(e) => {} - Err(_) => return false, - } - - if ops::has_white_out(&self.primary, path) { - return false; - } - - for fs in self.secondaries.filesystems() { - match fs.symlink_metadata(path) { - Ok(_) => return fs.is_host_backed_path(path), - Err(e) if should_continue(e) => continue, - Err(_) => return false, - } - } - - false - } - fn new_open_options(&self) -> OpenOptions<'_> { OpenOptions::new(self) } diff --git a/lib/virtual-fs/src/passthru_fs.rs b/lib/virtual-fs/src/passthru_fs.rs index 11fe3938ec34..2abbfda67910 100644 --- a/lib/virtual-fs/src/passthru_fs.rs +++ b/lib/virtual-fs/src/passthru_fs.rs @@ -69,14 +69,6 @@ impl FileSystem for PassthruFileSystem { self.fs.remove_file(path) } - fn is_host_backed(&self) -> bool { - self.fs.is_host_backed() - } - - fn is_host_backed_path(&self, path: &Path) -> bool { - self.fs.is_host_backed_path(path) - } - fn new_open_options(&self) -> OpenOptions<'_> { self.fs.new_open_options() } diff --git a/lib/virtual-fs/src/trace_fs.rs b/lib/virtual-fs/src/trace_fs.rs index becbda21e291..107af0f1fd7e 100644 --- a/lib/virtual-fs/src/trace_fs.rs +++ b/lib/virtual-fs/src/trace_fs.rs @@ -88,14 +88,6 @@ where self.0.remove_file(path) } - fn is_host_backed(&self) -> bool { - self.0.is_host_backed() - } - - fn is_host_backed_path(&self, path: &std::path::Path) -> bool { - self.0.is_host_backed_path(path) - } - #[tracing::instrument(level = "trace", skip(self))] fn new_open_options(&self) -> crate::OpenOptions<'_> { crate::OpenOptions::new(self) diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index f9a7fcabd6a3..be864aa30496 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -1734,7 +1734,7 @@ impl WasiFs { } if let Some(dir_path) = dir_path { - let preopen_visibility = self.host_preopen_symlink_visibility(fd, dir_path, filename); + let preopen_visibility = self.preopen_symlink_visibility(fd, dir_path, filename); if matches!(preopen_visibility, Some(false)) { return false; } @@ -1769,7 +1769,7 @@ impl WasiFs { let Some(dir_path) = dir_path else { return true; }; - self.host_preopen_symlink_visibility(fd, dir_path, filename) + self.preopen_symlink_visibility(fd, dir_path, filename) .unwrap_or(true) } @@ -1780,31 +1780,55 @@ impl WasiFs { self.root_fs.root().symlink_policy(guest_symlink_path).ok() } - fn host_preopen_symlink_visibility( + fn preopen_symlink_visibility( &self, fd: WasiFd, dir_path: &Path, filename: &str, ) -> Option { - #[cfg(not(feature = "host-fs"))] - { - let _ = (fd, dir_path, filename); - None - } + let dir_fd = self.get_fd(fd).ok()?; + let preopen_root = self.preopen_root_path(&dir_fd.inode)?; + let entry_path = dir_path.join(filename); + let policy = self.root_fs.root().symlink_policy(&entry_path).ok(); #[cfg(feature = "host-fs")] - { - let dir_fd = self.get_fd(fd).ok()?; - let preopen_root = - virtual_fs::host_fs::normalize_path(&self.preopen_host_root(&dir_fd.inode)?); - let entry_path = dir_path.join(filename); - virtual_fs::host_fs::symlink_policy_at(&preopen_root, &entry_path) - .ok() - .map(|policy| !matches!(policy, virtual_fs::SymlinkPolicy::Hidden)) + let policy = policy + .or_else(|| virtual_fs::host_fs::symlink_policy_at(&preopen_root, &entry_path).ok()); + + match policy? { + virtual_fs::SymlinkPolicy::Hidden => Some(false), + virtual_fs::SymlinkPolicy::ResolvedPath(path) => { + let canonical_preopen_root = std::fs::canonicalize(&preopen_root).ok(); + let target = Self::canonicalize_existing_prefix(&path).unwrap_or(path); + Some( + target.starts_with(&preopen_root) + || canonical_preopen_root + .as_ref() + .is_some_and(|root| target.starts_with(root)), + ) + } + virtual_fs::SymlinkPolicy::Visible => Some(true), + } + } + + fn canonicalize_existing_prefix(path: &Path) -> Option { + let mut current = path.to_path_buf(); + let mut suffix = PathBuf::new(); + + loop { + if let Ok(canonical) = std::fs::canonicalize(¤t) { + return Some(canonical.join(suffix)); + } + + let file_name = current.file_name()?.to_os_string(); + suffix = Path::new(&file_name).join(suffix); + if !current.pop() { + return None; + } } } - fn preopen_host_root(&self, dir_inode: &InodeGuard) -> Option { + fn preopen_root_path(&self, dir_inode: &InodeGuard) -> Option { let mut current = dir_inode.clone(); loop { @@ -3355,6 +3379,15 @@ mod tests { ) .unwrap(); + wasi_fs + .root_fs + .root() + .set_mount( + Path::new("/"), + Arc::new(RootFileSystemBuilder::default().build_tmp()), + ) + .unwrap(); + let fd = *wasi_fs.preopen_fds.read().unwrap().last().unwrap(); assert!(wasi_fs.readdir_entry_visible( @@ -3385,21 +3418,21 @@ mod tests { "broken-chained-link", Filetype::SymbolicLink, )); - assert!(wasi_fs.readdir_entry_visible( + assert!(!wasi_fs.readdir_entry_visible( &inodes, fd, Some(preopen_dir.path()), "broken-link", Filetype::SymbolicLink, )); - assert!(wasi_fs.readdir_entry_visible( + assert!(!wasi_fs.readdir_entry_visible( &inodes, fd, Some(preopen_dir.path()), "loop-a", Filetype::SymbolicLink, )); - assert!(wasi_fs.readdir_entry_visible( + assert!(!wasi_fs.readdir_entry_visible( &inodes, fd, Some(preopen_dir.path()), From 210ff7a7fc59e725a13956a759058164cf8f307d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C4=8Euri=C5=A1?= Date: Wed, 24 Jun 2026 12:51:58 +0200 Subject: [PATCH 12/14] chore: fix lint --- lib/virtual-fs/src/host_fs.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index 10809715bb8a..5d5e593d0a54 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -156,20 +156,14 @@ fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option Ok(target) => target, Err(_) => return None, }; - let mut next = match symlink_target_path(&normalized_root, &inspected, &raw_target) { - Some(next) => next, - None => return None, - }; + let mut next = symlink_target_path(&normalized_root, &inspected, &raw_target)?; if !components.as_path().as_os_str().is_empty() { - next = match path::resolve_path_within( + next = path::resolve_path_within( &normalized_root, &next, components.as_path(), normalize_path, - ) { - Some(next) => next, - None => return None, - }; + )?; } symlink_count += 1; From 3094118d263853cc62411d657947cf18cacdd2d4 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Thu, 23 Jul 2026 11:44:07 +0200 Subject: [PATCH 13/14] fix: remove symlink policy --- lib/virtual-fs/src/arc_fs.rs | 4 - lib/virtual-fs/src/host_fs.rs | 236 +++++++++--- lib/virtual-fs/src/lib.rs | 17 - lib/virtual-fs/src/mount_fs.rs | 355 +++++------------- lib/virtual-fs/src/overlay_fs.rs | 27 +- lib/virtual-fs/src/passthru_fs.rs | 4 - lib/virtual-fs/src/path.rs | 51 --- lib/virtual-fs/src/trace_fs.rs | 5 - lib/wasix/src/fs/mod.rs | 433 +--------------------- lib/wasix/src/fs/path_posix.rs | 4 - lib/wasix/src/syscalls/wasi/fd_readdir.rs | 66 +--- 11 files changed, 309 insertions(+), 893 deletions(-) delete mode 100644 lib/virtual-fs/src/path.rs diff --git a/lib/virtual-fs/src/arc_fs.rs b/lib/virtual-fs/src/arc_fs.rs index 19b9b591d834..d3ca55c82b09 100644 --- a/lib/virtual-fs/src/arc_fs.rs +++ b/lib/virtual-fs/src/arc_fs.rs @@ -58,10 +58,6 @@ impl FileSystem for ArcFileSystem { self.fs.symlink_metadata(path) } - fn symlink_policy(&self, path: &Path) -> Result { - self.fs.symlink_policy(path) - } - fn remove_file(&self, path: &Path) -> Result<()> { self.fs.remove_file(path) } diff --git a/lib/virtual-fs/src/host_fs.rs b/lib/virtual-fs/src/host_fs.rs index 5d5e593d0a54..c5b46197a240 100644 --- a/lib/virtual-fs/src/host_fs.rs +++ b/lib/virtual-fs/src/host_fs.rs @@ -1,6 +1,6 @@ use crate::{ - DirEntry, FileType, FsError, MAX_SYMLINK_TRAVERSAL_DEPTH, Metadata, OpenOptions, - OpenOptionsConfig, ReadDir, Result, SymlinkPolicy, VirtualFile, path, + DirEntry, FileType, FsError, Metadata, OpenOptions, OpenOptionsConfig, ReadDir, Result, + VirtualFile, }; use bytes::{Buf, Bytes}; use futures::future::BoxFuture; @@ -16,6 +16,8 @@ use tokio::fs as tfs; use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; use tokio::runtime::Handle; +const MAX_SYMLINK_TRAVERSAL_DEPTH: usize = 128; + #[derive(Debug, Clone)] pub struct FileSystem { handle: Handle, @@ -97,9 +99,57 @@ fn host_root_relative_target(root: &Path, target: PathBuf) -> PathBuf { target } +/// Resolves `target` (as seen from `base`) while ensuring every intermediate +/// step of the resolution stays within `root`. Returns `None` as soon as the +/// path would leave `root`. +fn resolve_path_within(root: &Path, base: &Path, target: &Path) -> Option { + let root = normalize_path(root); + let base = normalize_path(base); + if !base.starts_with(&root) { + return None; + } + + let (mut resolved, target) = if target.is_absolute() { + let stripped = if root == Path::new("/") { + target.strip_prefix(Path::new("/")).ok()? + } else { + target.strip_prefix(&root).ok()? + }; + (root.clone(), stripped) + } else { + (base, target) + }; + + for component in target.components() { + match component { + Component::Prefix(..) | Component::RootDir => return None, + Component::CurDir => {} + Component::ParentDir => { + if resolved == root { + if root.parent().is_none() { + continue; + } + return None; + } + if !resolved.pop() || !resolved.starts_with(&root) { + return None; + } + } + Component::Normal(part) => { + resolved.push(part); + if !resolved.starts_with(&root) { + return None; + } + } + } + } + + Some(resolved) +} + fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> Option { let base = symlink_path.parent().unwrap_or(root); - path::resolve_path_within(root, base, target, normalize_path).or_else(|| { + resolve_path_within(root, base, target).or_else(|| { if target.is_absolute() && !target .components() @@ -114,7 +164,7 @@ fn symlink_target_path(root: &Path, symlink_path: &Path, target: &Path) -> Optio }) } -fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option { +fn symlink_chain_stays_within(root: &Path, target: PathBuf) -> bool { let normalized_root = normalize_path(root); let mut current = target; let mut visited = std::collections::HashSet::new(); @@ -122,19 +172,19 @@ fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option loop { if !current.starts_with(&normalized_root) { - return None; + return false; } if !visited.insert(current.clone()) { - return None; + return false; } if symlink_count >= MAX_SYMLINK_TRAVERSAL_DEPTH { - return None; + return false; } let relative = match current.strip_prefix(&normalized_root) { Ok(relative) => relative, - Err(_) => return None, + Err(_) => return false, }; let mut inspected = normalized_root.clone(); let mut components = relative.components(); @@ -145,7 +195,7 @@ fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option let metadata = match fs::symlink_metadata(&inspected) { Ok(metadata) => metadata, - Err(_) => return None, + Err(_) => return false, }; if !metadata.file_type().is_symlink() { @@ -154,16 +204,17 @@ fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option let raw_target = match fs::read_link(&inspected) { Ok(target) => target, - Err(_) => return None, + Err(_) => return false, + }; + let mut next = match symlink_target_path(&normalized_root, &inspected, &raw_target) { + Some(next) => next, + None => return false, }; - let mut next = symlink_target_path(&normalized_root, &inspected, &raw_target)?; if !components.as_path().as_os_str().is_empty() { - next = path::resolve_path_within( - &normalized_root, - &next, - components.as_path(), - normalize_path, - )?; + next = match resolve_path_within(&normalized_root, &next, components.as_path()) { + Some(next) => next, + None => return false, + }; } symlink_count += 1; @@ -173,12 +224,19 @@ fn symlink_chain_target_within(root: &Path, target: PathBuf) -> Option } if !followed_symlink { - return Some(current); + return true; } } } -pub fn symlink_policy_at(root: &Path, path: &Path) -> Result { +/// Reports whether following the symlink at `path` would leave `root` (in +/// which case the symlink must be hidden from guests). Broken links and loops +/// count as escaping since they cannot be proven to stay inside `root`. +/// +/// Errors with `InvalidInput` when `path` is not a symlink and `EntryNotFound` +/// when it does not exist; `read_link` reports both, so a single call +/// validates the input and fetches the target. +fn symlink_escapes_root(root: &Path, path: &Path) -> Result { let root = normalize_path(root); let path = normalize_path(path); @@ -186,25 +244,13 @@ pub fn symlink_policy_at(root: &Path, path: &Path) -> Result { return Err(FsError::InvalidInput); } - let metadata = fs::symlink_metadata(&path)?; - - if !metadata.file_type().is_symlink() { - return Ok(SymlinkPolicy::Visible); - } - - let target = match fs::read_link(&path) { - Ok(target) => match symlink_target_path(&root, &path, &target) { - Some(target) => target, - None => return Ok(SymlinkPolicy::Hidden), - }, - Err(_) => return Ok(SymlinkPolicy::Hidden), + let raw_target = fs::read_link(&path)?; + let target = match symlink_target_path(&root, &path, &raw_target) { + Some(target) => target, + None => return Ok(true), }; - if let Some(target) = symlink_chain_target_within(&root, target) { - Ok(SymlinkPolicy::ResolvedPath(target)) - } else { - Ok(SymlinkPolicy::Hidden) - } + Ok(!symlink_chain_stays_within(&root, target)) } impl FileSystem { @@ -214,16 +260,12 @@ impl FileSystem { Ok(FileSystem { handle, root }) } - pub fn root_path(&self) -> &Path { - &self.root - } - fn symlink_entry_visible(&self, path: &Path, metadata: &fs::Metadata) -> bool { + // Unlike `ensure_no_hidden_symlink`, which propagates errors to the + // caller, this listing filter must not abort a whole `read_dir`, so + // it fails closed and simply omits the entry on error. !metadata.file_type().is_symlink() - || matches!( - symlink_policy_at(&self.root, path), - Ok(SymlinkPolicy::Visible | SymlinkPolicy::ResolvedPath(_)) - ) + || matches!(symlink_escapes_root(&self.root, path), Ok(false)) } fn prepare_path(&self, path: &Path) -> Result { @@ -241,8 +283,44 @@ impl FileSystem { let path = self.root.join(path); debug_assert!(path.starts_with(&self.root)); + self.ensure_no_hidden_symlink(&path)?; Ok(path) } + + /// Symlinks that escape the filesystem root are hidden from directory + /// listings, and any path that names one — as the final component or + /// anywhere along the way — fails with `PermissionDenied` (the sandbox + /// errno WASI mandates for escaping symlinks). This keeps directory + /// listings, metadata and open consistent. + /// + /// Note this is a check-then-use: a link swapped in between this walk + /// and the actual operation is still followed by the host OS. Closing + /// that race requires `openat2(RESOLVE_BENEATH)`-style resolution. + fn ensure_no_hidden_symlink(&self, path: &Path) -> Result<()> { + let relative = path + .strip_prefix(&self.root) + .map_err(|_| FsError::InvalidInput)?; + + let mut current = self.root.clone(); + for component in relative.components() { + current.push(component.as_os_str()); + + let Ok(metadata) = fs::symlink_metadata(¤t) else { + // Missing components either surface through the actual + // operation or are about to be created by it. + return Ok(()); + }; + if !metadata.file_type().is_symlink() { + continue; + } + + if symlink_escapes_root(&self.root, ¤t)? { + return Err(FsError::PermissionDenied); + } + } + + Ok(()) + } } impl crate::FileSystem for FileSystem { @@ -397,11 +475,6 @@ impl crate::FileSystem for FileSystem { .and_then(TryInto::try_into) .map_err(Into::into) } - - fn symlink_policy(&self, path: &Path) -> Result { - let path = self.prepare_path(path)?; - symlink_policy_at(&self.root, &path) - } } impl TryInto for std::fs::Metadata { @@ -1277,6 +1350,71 @@ mod tests { assert_host_symlink_visibility(&fs); } + #[cfg(unix)] + #[tokio::test] + async fn hidden_symlinks_behave_as_missing_entries() { + let (_temp, fs) = host_symlink_visibility_fixture(); + + // Visible symlinks keep working. + assert!(fs.readlink(Path::new("/inside-link")).is_ok()); + assert!( + fs.symlink_metadata(Path::new("/inside-link")) + .unwrap() + .ft + .is_symlink() + ); + assert!( + fs.metadata(Path::new("/inside-absolute")) + .unwrap() + .is_file() + ); + + for hidden in [ + "/outside-relative", + "/outside-absolute", + "/leave-reenter-relative", + "/leave-reenter-absolute", + "/pivot", + "/chained-escape", + "/broken-link", + "/loop-a", + ] { + let hidden = Path::new(hidden); + assert_eq!(fs.readlink(hidden).unwrap_err(), FsError::PermissionDenied); + assert_eq!( + fs.symlink_metadata(hidden).unwrap_err(), + FsError::PermissionDenied + ); + assert_eq!(fs.metadata(hidden).unwrap_err(), FsError::PermissionDenied); + assert_eq!( + fs.new_open_options() + .read(true) + .open(hidden) + .map(|_| ()) + .unwrap_err(), + FsError::PermissionDenied + ); + assert_eq!( + fs.remove_file(hidden).unwrap_err(), + FsError::PermissionDenied + ); + } + + // Paths traversing a hidden symlink are unreachable as well. + assert_eq!( + fs.metadata(Path::new("/pivot/outside.txt")).unwrap_err(), + FsError::PermissionDenied + ); + assert_eq!( + fs.new_open_options() + .read(true) + .open(Path::new("/pivot/outside.txt")) + .map(|_| ()) + .unwrap_err(), + FsError::PermissionDenied + ); + } + #[cfg(unix)] #[tokio::test] async fn arc_file_system_preserves_host_symlink_visibility_policy() { diff --git a/lib/virtual-fs/src/lib.rs b/lib/virtual-fs/src/lib.rs index 697724ff4acf..5ad36bbdfbd1 100644 --- a/lib/virtual-fs/src/lib.rs +++ b/lib/virtual-fs/src/lib.rs @@ -41,7 +41,6 @@ pub mod mem_fs; pub mod mount_fs; pub mod null_file; pub mod passthru_fs; -pub(crate) mod path; pub mod random_file; pub mod special_file; pub mod tmp_fs; @@ -60,8 +59,6 @@ mod webc_volume_fs; pub mod limiter; -pub(crate) const MAX_SYMLINK_TRAVERSAL_DEPTH: usize = 128; - pub use arc_box_file::*; pub use arc_file::*; pub use arc_fs::*; @@ -97,13 +94,6 @@ pub trait CloneableVirtualFile: VirtualFile + Clone {} pub use ops::{copy_reference, copy_reference_ext, create_dir_all, walk}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SymlinkPolicy { - Visible, - Hidden, - ResolvedPath(PathBuf), -} - pub trait FileSystem: fmt::Debug + Send + Sync + 'static + Upcastable { fn readlink(&self, path: &Path) -> Result; fn read_dir(&self, path: &Path) -> Result; @@ -121,9 +111,6 @@ pub trait FileSystem: fmt::Debug + Send + Sync + 'static + Upcastable { /// Currently identical to `metadata` because symlinks aren't implemented /// yet. fn symlink_metadata(&self, path: &Path) -> Result; - fn symlink_policy(&self, path: &Path) -> Result { - self.symlink_metadata(path).map(|_| SymlinkPolicy::Visible) - } fn remove_file(&self, path: &Path) -> Result<()>; fn new_open_options(&self) -> OpenOptions<'_>; @@ -178,10 +165,6 @@ where (**self).symlink_metadata(path) } - fn symlink_policy(&self, path: &Path) -> Result { - (**self).symlink_policy(path) - } - fn remove_file(&self, path: &Path) -> Result<()> { (**self).remove_file(path) } diff --git a/lib/virtual-fs/src/mount_fs.rs b/lib/virtual-fs/src/mount_fs.rs index 731f8f998ab7..aacd3daae5b8 100644 --- a/lib/virtual-fs/src/mount_fs.rs +++ b/lib/virtual-fs/src/mount_fs.rs @@ -1,6 +1,6 @@ -//! A mount-topology filesystem that routes operations by path, -//! its not as simple as TmpFs. not currently used but was used by -//! the previously implementation of Deploy - now using TmpFs +//! A mount-topology filesystem that routes operations by path to the +//! filesystems mounted under it. Used to assemble the guest's root +//! filesystem out of package volumes and host directories. use crate::*; @@ -126,6 +126,15 @@ impl MountFileSystem { self.mount_with_source(path, Path::new("/"), fs) } + /// Mounts `fs` at `path`, exposing only the subtree under `source_path`. + /// + /// Note that the mount does not confine symlinks to the source subtree: + /// a symlink may name a target outside `source_path`, and it resolves in + /// whatever namespace the caller assembles (readlink targets under + /// `source_path` are translated into the mount's namespace, everything + /// else is reported verbatim). Hiding symlinks that escape a sandbox is + /// the backing filesystem's responsibility — for host directories, root + /// the `host_fs::FileSystem` at the directory being exposed. pub fn mount_with_source( &self, path: impl AsRef, @@ -327,221 +336,42 @@ impl MountFileSystem { } } - fn normalize_mount_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::from("/"); - - for component in path.components() { - match component { - std::path::Component::RootDir | std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - normalized.pop(); - if normalized.as_os_str().is_empty() { - normalized.push("/"); - } - } - std::path::Component::Normal(part) => normalized.push(part), - std::path::Component::Prefix(_) => {} - } + /// Symlink targets reported by a mounted filesystem are expressed in that + /// filesystem's own namespace. Absolute targets that fall under the + /// mount's source path are translated into the mount's namespace; + /// anything else is reported verbatim and resolves in the guest's + /// namespace. + fn rebase_symlink_target(target: PathBuf, source_path: &Path, mount_path: &Path) -> PathBuf { + if !target.is_absolute() { + return target; } - normalized - } - - fn symlink_target_path( - source_root: &Path, - symlink_path: &Path, - target: &Path, - ) -> Option { - let base = symlink_path.parent().unwrap_or(source_root); - path::resolve_path_within(source_root, base, target, Self::normalize_mount_path) - } - - fn symlink_chain_stays_within_source( - fs: &(dyn FileSystem + Send + Sync), - source_root: &Path, - target: PathBuf, - ) -> bool { - let source_root = Self::normalize_mount_path(source_root); - let mut current = Self::normalize_mount_path(&target); - let mut visited = BTreeSet::new(); - let mut symlink_count = 0; - - loop { - if !current.starts_with(&source_root) { - return false; - } - - if !visited.insert(current.clone()) { - return true; - } - if symlink_count >= MAX_SYMLINK_TRAVERSAL_DEPTH { - return false; - } - - let relative = match current.strip_prefix(&source_root) { - Ok(relative) => relative, - Err(_) => return false, - }; - let mut inspected = source_root.clone(); - let mut components = relative.components(); - let mut followed_symlink = false; - - while let Some(component) = components.next() { - inspected.push(component.as_os_str()); - - let metadata = match fs.symlink_metadata(&inspected) { - Ok(metadata) => metadata, - Err(FsError::EntryNotFound) => return true, - Err(_) => return false, - }; - - if !metadata.ft.is_symlink() { - continue; - } - - let raw_target = match fs.readlink(&inspected) { - Ok(target) => target, - Err(_) => return false, - }; - let mut next = - match Self::symlink_target_path(&source_root, &inspected, &raw_target) { - Some(next) => next, - None => return false, - }; - if !components.as_path().as_os_str().is_empty() { - next = match path::resolve_path_within( - &source_root, - &next, - components.as_path(), - Self::normalize_mount_path, - ) { - Some(next) => next, - None => return false, - }; - } - - symlink_count += 1; - current = next; - followed_symlink = true; - break; - } - - if !followed_symlink { - return true; - } - } - } - - fn filter_symlink_entries_to_source( - entries: &mut ReadDir, - fs: &(dyn FileSystem + Send + Sync), - source_root: &Path, - mount_path: &Path, - ) { - entries.data.retain(|entry| { - let Ok(metadata) = entry.metadata() else { - return true; - }; - - if !metadata.ft.is_symlink() { - return true; - } - - !matches!( - Self::symlink_policy_from_mount(fs, source_root, mount_path, &entry.path), - Ok(SymlinkPolicy::Hidden) | Err(_) - ) - }); - } - - fn rebase_symlink_policy_path(source_root: &Path, mount_path: &Path, target: &Path) -> PathBuf { - let source_root = Self::normalize_mount_path(source_root); - let target = Self::normalize_mount_path(target); - - match target.strip_prefix(&source_root) { + match target.strip_prefix(source_path) { Ok(stripped) => mount_path.join(stripped), Err(_) => target, } } - fn rebase_symlink_policy( - policy: SymlinkPolicy, - source_root: &Path, - mount_path: &Path, - ) -> SymlinkPolicy { - match policy { - SymlinkPolicy::ResolvedPath(path) => SymlinkPolicy::ResolvedPath( - Self::rebase_symlink_policy_path(source_root, mount_path, &path), - ), - policy => policy, - } - } - - fn symlink_policy_from_mount( - fs: &(dyn FileSystem + Send + Sync), - source_root: &Path, - mount_path: &Path, - delegated_path: &Path, - ) -> Result { - let policy = fs.symlink_policy(delegated_path)?; - if matches!(policy, SymlinkPolicy::Hidden) { - return Ok(policy); - } - - if matches!(policy, SymlinkPolicy::ResolvedPath(_)) { - let metadata = fs.symlink_metadata(delegated_path)?; - if metadata.ft.is_symlink() { - let raw_target = match fs.readlink(delegated_path) { - Ok(target) => target, - Err(_) => return Ok(SymlinkPolicy::Hidden), - }; - let target = - match Self::symlink_target_path(source_root, delegated_path, &raw_target) { - Some(target) => target, - None => return Ok(SymlinkPolicy::Hidden), - }; - if !Self::symlink_chain_stays_within_source(fs, source_root, target.clone()) { - return Ok(SymlinkPolicy::Hidden); - } - return Ok(SymlinkPolicy::ResolvedPath( - Self::rebase_symlink_policy_path(source_root, mount_path, &target), - )); - } - } - - Ok(Self::rebase_symlink_policy(policy, source_root, mount_path)) - } - fn read_dir_from_exact_node(&self, node: &ExactNode) -> Result { let mut entries = Vec::new(); let backing = if let Some(fs) = &node.fs { Some(( - fs.clone(), fs.read_dir(&node.source_path), - node.source_path.clone(), Cow::Borrowed(node.source_path.as_path()), )) } else { self.resolve_mount(&node.path).map(|resolved| { ( - resolved.fs.clone(), resolved.fs.read_dir(&resolved.delegated_path), - resolved.source_path, Cow::Owned(resolved.delegated_path), ) }) }; - if let Some((fs, base_entries, source_root, source_path)) = backing { + if let Some((base_entries, source_path)) = backing { match base_entries { Ok(mut base_entries) => { - Self::filter_symlink_entries_to_source( - &mut base_entries, - fs.as_ref(), - &source_root, - &node.path, - ); Self::rebase_entries(&mut base_entries, &source_path, &node.path); entries.extend(base_entries.data.into_iter().filter(|entry| { entry @@ -676,7 +506,14 @@ impl FileSystem for MountFileSystem { } match self.resolve_mount(path) { - Some(resolved) => resolved.fs.readlink(&resolved.delegated_path), + Some(resolved) => { + let target = resolved.fs.readlink(&resolved.delegated_path)?; + Ok(Self::rebase_symlink_target( + target, + &resolved.source_path, + &resolved.mount_path, + )) + } None => Err(FsError::EntryNotFound), } } @@ -692,12 +529,6 @@ impl FileSystem for MountFileSystem { match self.resolve_mount(path.clone()) { Some(resolved) => { let mut entries = resolved.fs.read_dir(&resolved.delegated_path)?; - Self::filter_symlink_entries_to_source( - &mut entries, - resolved.fs.as_ref(), - &resolved.source_path, - &resolved.mount_path, - ); Self::rebase_entries( &mut entries, &resolved.delegated_path, @@ -909,40 +740,6 @@ impl FileSystem for MountFileSystem { } } - fn symlink_policy(&self, path: &Path) -> Result { - let path = self.prepare_path(path)?; - - if let Some(node) = self.exact_node(&path) { - return if let Some(fs) = node.fs { - match Self::symlink_policy_from_mount( - fs.as_ref(), - &node.source_path, - &node.path, - &node.source_path, - ) { - Err(error) if Self::should_fallback_to_synthetic_dir(&error) => { - Ok(SymlinkPolicy::Visible) - } - result => result, - } - } else if node.has_children() { - Ok(SymlinkPolicy::Visible) - } else { - Err(FsError::EntryNotFound) - }; - } - - match self.resolve_mount(path) { - Some(resolved) => Self::symlink_policy_from_mount( - resolved.fs.as_ref(), - &resolved.source_path, - &resolved.mount_path, - &resolved.delegated_path, - ), - None => Err(FsError::EntryNotFound), - } - } - fn remove_file(&self, path: &Path) -> Result<()> { let path = self.prepare_path(path)?; @@ -1872,6 +1669,54 @@ mod tests { assert!(names.contains(&"relative-link".to_string())); } + #[tokio::test] + async fn test_readlink_rebases_absolute_targets_into_mount_namespace() { + let source = TmpFileSystem::new(); + source.create_dir(Path::new("/pkg")).unwrap(); + source.create_dir(Path::new("/shared")).unwrap(); + source + .new_open_options() + .write(true) + .create_new(true) + .open(Path::new("/pkg/target.txt")) + .unwrap(); + source + .create_symlink( + Path::new("/pkg/target.txt"), + Path::new("/pkg/absolute-link"), + ) + .unwrap(); + source + .create_symlink(Path::new("target.txt"), Path::new("/pkg/relative-link")) + .unwrap(); + source + .create_symlink(Path::new("/shared/lib.py"), Path::new("/pkg/outside-link")) + .unwrap(); + + let fs = MountFileSystem::new(); + fs.mount_with_source(Path::new("/runtime"), Path::new("/pkg"), Arc::new(source)) + .unwrap(); + + // Absolute targets under the mount's source path are translated into + // the mount's namespace. + assert_eq!( + fs.readlink(Path::new("/runtime/absolute-link")).unwrap(), + Path::new("/runtime/target.txt") + ); + // Relative targets resolve against the link's parent and need no + // translation. + assert_eq!( + fs.readlink(Path::new("/runtime/relative-link")).unwrap(), + Path::new("target.txt") + ); + // Absolute targets outside the source path are reported verbatim and + // resolve in the guest's namespace. + assert_eq!( + fs.readlink(Path::new("/runtime/outside-link")).unwrap(), + Path::new("/shared/lib.py") + ); + } + #[cfg(unix)] fn host_source_with_escaping_symlinks() -> (tempfile::TempDir, crate::host_fs::FileSystem) { let temp = tempfile::TempDir::new().unwrap(); @@ -1902,19 +1747,21 @@ mod tests { std::os::unix::fs::symlink("..", pkg.join("pivot")).unwrap(); std::os::unix::fs::symlink("pivot/secret.txt", pkg.join("escape-chained")).unwrap(); + // The host filesystem is rooted at the `pkg` directory, so every + // escaping symlink above points outside the filesystem's root and + // must be hidden by host_fs itself — mount_fs simply passes the + // already-filtered entries through, wrappers included. let source = - crate::host_fs::FileSystem::new(tokio::runtime::Handle::current(), temp.path()) - .unwrap(); + crate::host_fs::FileSystem::new(tokio::runtime::Handle::current(), pkg).unwrap(); (temp, source) } #[cfg(unix)] - fn assert_mount_with_source_path_hides_symlinks_escaping_subtree( + fn assert_mount_hides_symlinks_escaping_host_root( source: Arc, ) { let fs = MountFileSystem::new(); - fs.mount_with_source(Path::new("/runtime"), Path::new("/sandbox/pkg"), source) - .unwrap(); + fs.mount(Path::new("/runtime"), source).unwrap(); let names = read_dir_names(&fs, "/runtime"); assert!(names.contains(&"inside.txt".to_string())); @@ -1932,48 +1779,46 @@ mod tests { #[cfg(unix)] #[tokio::test] - async fn test_mount_with_source_path_hides_symlinks_escaping_subtree() { + async fn test_mount_hides_host_symlinks_escaping_host_root() { let (_temp, source) = host_source_with_escaping_symlinks(); - assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(source)); + assert_mount_hides_symlinks_escaping_host_root(Arc::new(source)); } #[cfg(unix)] #[tokio::test] - async fn test_mount_with_source_path_hides_escaping_symlinks_from_arc_host_source() { + async fn test_mount_hides_escaping_host_symlinks_from_arc_host_source() { let (_temp, source) = host_source_with_escaping_symlinks(); let source = ArcFileSystem::new(Arc::new(source)); - assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(source)); + assert_mount_hides_symlinks_escaping_host_root(Arc::new(source)); } #[cfg(unix)] #[tokio::test] - async fn test_mount_with_source_path_hides_escaping_symlinks_from_overlay_host_source() { + async fn test_mount_hides_escaping_host_symlinks_from_overlay_host_source() { let (_temp, source) = host_source_with_escaping_symlinks(); let source = OverlayFileSystem::new(mem_fs::FileSystem::default(), [source]); - assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(source)); + assert_mount_hides_symlinks_escaping_host_root(Arc::new(source)); } #[cfg(unix)] #[tokio::test] - async fn test_mount_with_source_path_hides_escaping_symlinks_from_nested_mount_host_source() { + async fn test_mount_hides_escaping_host_symlinks_from_nested_mount_host_source() { let (_temp, source) = host_source_with_escaping_symlinks(); let source = Arc::new(source); let nested = MountFileSystem::new(); nested.mount(Path::new("/"), source).unwrap(); - assert_mount_with_source_path_hides_symlinks_escaping_subtree(Arc::new(nested)); + assert_mount_hides_symlinks_escaping_host_root(Arc::new(nested)); } #[cfg(unix)] #[tokio::test] - async fn test_mount_with_source_path_preserves_virtual_symlinks_from_mixed_overlay_source() { + async fn test_mount_preserves_virtual_symlinks_from_mixed_overlay_source() { let (_temp, host) = host_source_with_escaping_symlinks(); let primary = TmpFileSystem::new(); - primary.create_dir(Path::new("/sandbox")).unwrap(); - primary.create_dir(Path::new("/sandbox/pkg")).unwrap(); primary.create_dir(Path::new("/shared")).unwrap(); primary .new_open_options() @@ -1982,20 +1827,12 @@ mod tests { .open(Path::new("/shared/lib.py")) .unwrap(); primary - .create_symlink( - Path::new("/shared/lib.py"), - Path::new("/sandbox/pkg/virtual-link"), - ) + .create_symlink(Path::new("/shared/lib.py"), Path::new("/virtual-link")) .unwrap(); let source = OverlayFileSystem::new(primary, [host]); let fs = MountFileSystem::new(); - fs.mount_with_source( - Path::new("/runtime"), - Path::new("/sandbox/pkg"), - Arc::new(source), - ) - .unwrap(); + fs.mount(Path::new("/runtime"), Arc::new(source)).unwrap(); let names = read_dir_names(&fs, "/runtime"); assert!(names.contains(&"virtual-link".to_string())); diff --git a/lib/virtual-fs/src/overlay_fs.rs b/lib/virtual-fs/src/overlay_fs.rs index 82ca1335c444..de9366bf4785 100644 --- a/lib/virtual-fs/src/overlay_fs.rs +++ b/lib/virtual-fs/src/overlay_fs.rs @@ -14,7 +14,7 @@ use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; use crate::{ FileOpener, FileSystem, FileSystems, FsError, Metadata, OpenOptions, OpenOptionsConfig, - ReadDir, SymlinkPolicy, VirtualFile, ops, + ReadDir, VirtualFile, ops, }; fn unlink_overlay_path

(primary: &Arc

, path: &Path) -> Result<(), FsError> @@ -580,31 +580,6 @@ where Err(FsError::EntryNotFound) } - fn symlink_policy(&self, path: &Path) -> crate::Result { - if ops::is_white_out(path).is_some() { - return Err(FsError::EntryNotFound); - } - - match self.primary.symlink_policy(path) { - Ok(policy) => return Ok(policy), - Err(e) if should_continue(e) => {} - Err(e) => return Err(e), - } - - if ops::has_white_out(&self.primary, path) { - return Err(FsError::EntryNotFound); - } - - for fs in self.secondaries.filesystems() { - match fs.symlink_policy(path) { - Err(e) if should_continue(e) => continue, - other => return other, - } - } - - Err(FsError::EntryNotFound) - } - fn remove_file(&self, path: &Path) -> Result<(), FsError> { // It is not possible to delete whiteout files directly, instead // one must delete the original file diff --git a/lib/virtual-fs/src/passthru_fs.rs b/lib/virtual-fs/src/passthru_fs.rs index 2abbfda67910..a0d3efe9488d 100644 --- a/lib/virtual-fs/src/passthru_fs.rs +++ b/lib/virtual-fs/src/passthru_fs.rs @@ -61,10 +61,6 @@ impl FileSystem for PassthruFileSystem { self.fs.symlink_metadata(path) } - fn symlink_policy(&self, path: &Path) -> Result { - self.fs.symlink_policy(path) - } - fn remove_file(&self, path: &Path) -> Result<()> { self.fs.remove_file(path) } diff --git a/lib/virtual-fs/src/path.rs b/lib/virtual-fs/src/path.rs deleted file mode 100644 index 1996c230457f..000000000000 --- a/lib/virtual-fs/src/path.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::path::{Component, Path, PathBuf}; - -pub(crate) fn resolve_path_within( - root: &Path, - base: &Path, - target: &Path, - normalize: impl Fn(&Path) -> PathBuf, -) -> Option { - let root = normalize(root); - let base = normalize(base); - if !base.starts_with(&root) { - return None; - } - - let (mut resolved, target) = if target.is_absolute() { - let stripped = if root == Path::new("/") { - target.strip_prefix(Path::new("/")).ok()? - } else { - target.strip_prefix(&root).ok()? - }; - (root.clone(), stripped) - } else { - (base, target) - }; - - for component in target.components() { - match component { - Component::Prefix(..) | Component::RootDir => return None, - Component::CurDir => {} - Component::ParentDir => { - if resolved == root { - if root.parent().is_none() { - continue; - } - return None; - } - if !resolved.pop() || !resolved.starts_with(&root) { - return None; - } - } - Component::Normal(part) => { - resolved.push(part); - if !resolved.starts_with(&root) { - return None; - } - } - } - } - - Some(resolved) -} diff --git a/lib/virtual-fs/src/trace_fs.rs b/lib/virtual-fs/src/trace_fs.rs index 107af0f1fd7e..667bc84c5bc3 100644 --- a/lib/virtual-fs/src/trace_fs.rs +++ b/lib/virtual-fs/src/trace_fs.rs @@ -78,11 +78,6 @@ where self.0.symlink_metadata(path) } - #[tracing::instrument(level = "trace", skip(self), err)] - fn symlink_policy(&self, path: &std::path::Path) -> crate::Result { - self.0.symlink_policy(path) - } - #[tracing::instrument(level = "trace", skip(self), err)] fn remove_file(&self, path: &std::path::Path) -> crate::Result<()> { self.0.remove_file(path) diff --git a/lib/wasix/src/fs/mod.rs b/lib/wasix/src/fs/mod.rs index be864aa30496..7d6858a93190 100644 --- a/lib/wasix/src/fs/mod.rs +++ b/lib/wasix/src/fs/mod.rs @@ -1439,8 +1439,7 @@ impl WasiFs { let metadata = self .root_fs .symlink_metadata(&entry_path_buf) - .ok() - .ok_or(Errno::Noent)?; + .map_err(fs_error_into_wasi_err)?; let file_type = metadata.file_type(); if file_type.is_dir() { // load DIR @@ -1476,8 +1475,7 @@ impl WasiFs { let link_value = self .root_fs .readlink(&entry_path_buf) - .ok() - .ok_or(Errno::Noent)?; + .map_err(fs_error_into_wasi_err)?; debug!("attempting to decompose path {:?}", link_value); ComponentResolution::BackingSymlink { file: entry_path_buf, @@ -1685,21 +1683,6 @@ impl WasiFs { .parent() .into_path_buf(), SymlinkKind::Backing => { - let symlink_path_buf = - PosixPath::new("/").join(&PosixPath::from_path(path_to_symlink)); - let symlink_path = symlink_path_buf.as_posix_path(); - if let Some(policy) = - self.symlink_policy_for_guest_path(Path::new(symlink_path.as_str())) - { - match policy { - virtual_fs::SymlinkPolicy::Hidden => return Err(Errno::Perm), - virtual_fs::SymlinkPolicy::ResolvedPath(path) => { - return Ok((VIRTUAL_ROOT_FD, path)); - } - virtual_fs::SymlinkPolicy::Visible => {} - } - } - if relative_posix.is_absolute() { return Ok((VIRTUAL_ROOT_FD, relative_path.to_owned())); } @@ -1721,135 +1704,6 @@ impl WasiFs { )) } - pub(crate) fn readdir_entry_visible( - &self, - inodes: &WasiInodes, - fd: WasiFd, - dir_path: Option<&Path>, - filename: &str, - filetype: Filetype, - ) -> bool { - if filetype != Filetype::SymbolicLink { - return true; - } - - if let Some(dir_path) = dir_path { - let preopen_visibility = self.preopen_symlink_visibility(fd, dir_path, filename); - if matches!(preopen_visibility, Some(false)) { - return false; - } - - let guest_path = dir_path.join(filename); - if let Some(policy) = self.symlink_policy_for_guest_path(&guest_path) { - return !matches!(policy, virtual_fs::SymlinkPolicy::Hidden); - } - - if let Some(visible) = preopen_visibility { - return visible; - } - } - - if let Ok(inode) = self.get_inode_at_path(inodes, fd, filename, false) { - let guard = inode.read(); - let Kind::Symlink { - symlink_kind, - path_to_symlink, - relative_path, - } = guard.deref() - else { - return true; - }; - - return !matches!( - self.resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path), - Err(Errno::Perm) - ); - } - - let Some(dir_path) = dir_path else { - return true; - }; - self.preopen_symlink_visibility(fd, dir_path, filename) - .unwrap_or(true) - } - - fn symlink_policy_for_guest_path( - &self, - guest_symlink_path: &Path, - ) -> Option { - self.root_fs.root().symlink_policy(guest_symlink_path).ok() - } - - fn preopen_symlink_visibility( - &self, - fd: WasiFd, - dir_path: &Path, - filename: &str, - ) -> Option { - let dir_fd = self.get_fd(fd).ok()?; - let preopen_root = self.preopen_root_path(&dir_fd.inode)?; - let entry_path = dir_path.join(filename); - - let policy = self.root_fs.root().symlink_policy(&entry_path).ok(); - #[cfg(feature = "host-fs")] - let policy = policy - .or_else(|| virtual_fs::host_fs::symlink_policy_at(&preopen_root, &entry_path).ok()); - - match policy? { - virtual_fs::SymlinkPolicy::Hidden => Some(false), - virtual_fs::SymlinkPolicy::ResolvedPath(path) => { - let canonical_preopen_root = std::fs::canonicalize(&preopen_root).ok(); - let target = Self::canonicalize_existing_prefix(&path).unwrap_or(path); - Some( - target.starts_with(&preopen_root) - || canonical_preopen_root - .as_ref() - .is_some_and(|root| target.starts_with(root)), - ) - } - virtual_fs::SymlinkPolicy::Visible => Some(true), - } - } - - fn canonicalize_existing_prefix(path: &Path) -> Option { - let mut current = path.to_path_buf(); - let mut suffix = PathBuf::new(); - - loop { - if let Ok(canonical) = std::fs::canonicalize(¤t) { - return Some(canonical.join(suffix)); - } - - let file_name = current.file_name()?.to_os_string(); - suffix = Path::new(&file_name).join(suffix); - if !current.pop() { - return None; - } - } - } - - fn preopen_root_path(&self, dir_inode: &InodeGuard) -> Option { - let mut current = dir_inode.clone(); - - loop { - if current.is_preopened { - let guard = current.read(); - if let Kind::Dir { path, .. } = guard.deref() { - return Some(path.clone()); - } - } - - let parent = { - let guard = current.read(); - let Kind::Dir { parent, .. } = guard.deref() else { - return None; - }; - parent.upgrade()? - }; - current = parent; - } - } - pub(crate) fn rebase_symlink_location(&self, new_symlink_path: &Path) -> PathBuf { PosixPath::from_path(new_symlink_path) .strip_root_prefix() @@ -3213,13 +3067,15 @@ mod tests { let literal_link = wasi_fs .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/host/dir2", false) .unwrap(); + // The mount rebases the absolute host target into the guest's + // namespace, so the link value is already mount-relative. assert!(matches!( literal_link.read().deref(), Kind::Symlink { symlink_kind: SymlinkKind::Backing, relative_path, .. - } if relative_path == Path::new("/dir1") + } if relative_path == Path::new("/host/dir1") )); let followed_dir = wasi_fs @@ -3325,205 +3181,6 @@ mod tests { )); } - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - #[tokio::test] - async fn readdir_filters_host_symlinks_for_direct_preopens() { - let preopen_dir = tempdir().unwrap(); - let outside_dir = tempdir().unwrap(); - std::fs::write(preopen_dir.path().join("inside.txt"), b"inside").unwrap(); - std::fs::write(outside_dir.path().join("outside.txt"), b"outside").unwrap(); - std::os::unix::fs::symlink("inside.txt", preopen_dir.path().join("inside-link")).unwrap(); - std::os::unix::fs::symlink(outside_dir.path(), preopen_dir.path().join("outside-link")) - .unwrap(); - std::os::unix::fs::symlink(outside_dir.path(), preopen_dir.path().join("pivot")).unwrap(); - std::os::unix::fs::symlink("pivot/outside.txt", preopen_dir.path().join("chained-link")) - .unwrap(); - std::os::unix::fs::symlink( - "pivot/missing.txt", - preopen_dir.path().join("broken-chained-link"), - ) - .unwrap(); - std::os::unix::fs::symlink("missing.txt", preopen_dir.path().join("broken-link")).unwrap(); - std::os::unix::fs::symlink("loop-b", preopen_dir.path().join("loop-a")).unwrap(); - std::os::unix::fs::symlink("loop-a", preopen_dir.path().join("loop-b")).unwrap(); - std::os::unix::fs::symlink("limit-0", preopen_dir.path().join("limit-link")).unwrap(); - for i in 0..MAX_SYMLINKS { - std::os::unix::fs::symlink( - format!("limit-{}", i + 1), - preopen_dir.path().join(format!("limit-{i}")), - ) - .unwrap(); - } - std::os::unix::fs::symlink( - outside_dir.path(), - preopen_dir.path().join(format!("limit-{MAX_SYMLINKS}")), - ) - .unwrap(); - - let host_fs = - virtual_fs::host_fs::FileSystem::new(tokio::runtime::Handle::current(), Path::new("/")) - .unwrap(); - let inodes = WasiInodes::new(); - let fs_backing = WasiFsRoot::from_filesystem(Arc::new(host_fs)); - let wasi_fs = WasiFs::new_with_preopen( - &inodes, - &[PreopenedDir { - path: preopen_dir.path().to_path_buf(), - alias: None, - read: true, - write: true, - create: false, - }], - &[], - fs_backing, - ) - .unwrap(); - - wasi_fs - .root_fs - .root() - .set_mount( - Path::new("/"), - Arc::new(RootFileSystemBuilder::default().build_tmp()), - ) - .unwrap(); - - let fd = *wasi_fs.preopen_fds.read().unwrap().last().unwrap(); - - assert!(wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "inside-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "outside-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "chained-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "broken-chained-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "broken-link", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "loop-a", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "loop-b", - Filetype::SymbolicLink, - )); - assert!(!wasi_fs.readdir_entry_visible( - &inodes, - fd, - Some(preopen_dir.path()), - "limit-link", - Filetype::SymbolicLink, - )); - } - - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - #[tokio::test] - async fn subtree_mount_symlink_policy_uses_mount_source_path() { - let root_dir = tempdir().unwrap(); - let subtree_dir = root_dir.path().join("sandbox/pkg"); - std::fs::create_dir_all(&subtree_dir).unwrap(); - std::fs::write(root_dir.path().join("sandbox/secret.txt"), b"secret").unwrap(); - std::fs::write(subtree_dir.join("target.txt"), b"inside").unwrap(); - std::os::unix::fs::symlink(subtree_dir.join("target.txt"), subtree_dir.join("abs-link")) - .unwrap(); - std::os::unix::fs::symlink( - root_dir.path().join("sandbox/pkg/../secret.txt"), - subtree_dir.join("escape-link"), - ) - .unwrap(); - - let host_fs = virtual_fs::host_fs::FileSystem::new( - tokio::runtime::Handle::current(), - root_dir.path(), - ) - .unwrap(); - let mount_fs = virtual_fs::MountFileSystem::new(); - mount_fs - .mount( - Path::new("/"), - Arc::new(RootFileSystemBuilder::default().build_tmp()), - ) - .unwrap(); - mount_fs - .mount_with_source( - Path::new("/pkg"), - Path::new("/sandbox/pkg"), - Arc::new(host_fs) as Arc, - ) - .unwrap(); - - let inodes = WasiInodes::new(); - let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); - let wasi_fs = - WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); - - let link_inode = wasi_fs - .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/abs-link", false) - .unwrap(); - let guard = link_inode.read(); - let Kind::Symlink { - symlink_kind, - path_to_symlink, - relative_path, - } = guard.deref() - else { - panic!("expected symlink inode"); - }; - let (_, resolved_target) = wasi_fs - .resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path) - .unwrap(); - assert_eq!(resolved_target, Path::new("/pkg/target.txt")); - - let escape_inode = wasi_fs - .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/escape-link", false) - .unwrap(); - let guard = escape_inode.read(); - let Kind::Symlink { - symlink_kind, - path_to_symlink, - relative_path, - } = guard.deref() - else { - panic!("expected symlink inode"); - }; - assert_eq!( - wasi_fs.resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path), - Err(Errno::Perm) - ); - } - #[tokio::test] async fn webc_backing_symlink_resolves_to_target_entry() { let inodes = WasiInodes::new(); @@ -3888,84 +3545,4 @@ mod tests { Errno::Noent ); } - - #[cfg(all(unix, feature = "host-fs", feature = "sys"))] - #[tokio::test] - async fn subtree_mount_symlink_policy_supports_wrapped_host_source() { - let root_dir = tempdir().unwrap(); - let subtree_dir = root_dir.path().join("sandbox/pkg"); - std::fs::create_dir_all(&subtree_dir).unwrap(); - std::fs::write(root_dir.path().join("sandbox/secret.txt"), b"secret").unwrap(); - std::fs::write(subtree_dir.join("target.txt"), b"inside").unwrap(); - std::os::unix::fs::symlink(subtree_dir.join("target.txt"), subtree_dir.join("abs-link")) - .unwrap(); - std::os::unix::fs::symlink( - root_dir.path().join("sandbox/pkg/../secret.txt"), - subtree_dir.join("escape-link"), - ) - .unwrap(); - - let host_fs = virtual_fs::host_fs::FileSystem::new( - tokio::runtime::Handle::current(), - root_dir.path(), - ) - .unwrap(); - let wrapped_host = - ArcFileSystem::new(Arc::new(host_fs) as Arc); - let overlay_fs = - OverlayFileSystem::new(RootFileSystemBuilder::default().build_tmp(), [wrapped_host]); - let mount_fs = virtual_fs::MountFileSystem::new(); - mount_fs - .mount( - Path::new("/"), - Arc::new(RootFileSystemBuilder::default().build_tmp()), - ) - .unwrap(); - mount_fs - .mount_with_source( - Path::new("/pkg"), - Path::new("/sandbox/pkg"), - Arc::new(overlay_fs) as Arc, - ) - .unwrap(); - - let inodes = WasiInodes::new(); - let fs_backing = WasiFsRoot::from_mount_fs(mount_fs); - let wasi_fs = - WasiFs::new_with_preopen(&inodes, &[], &["/".to_string()], fs_backing).unwrap(); - - let link_inode = wasi_fs - .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/abs-link", false) - .unwrap(); - let guard = link_inode.read(); - let Kind::Symlink { - symlink_kind, - path_to_symlink, - relative_path, - } = guard.deref() - else { - panic!("expected symlink inode"); - }; - let (_, resolved_target) = wasi_fs - .resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path) - .unwrap(); - assert_eq!(resolved_target, Path::new("/pkg/target.txt")); - - let escape_inode = wasi_fs - .get_inode_at_path(&inodes, crate::VIRTUAL_ROOT_FD, "/pkg/escape-link", false) - .unwrap(); - let guard = escape_inode.read(); - let Kind::Symlink { - symlink_kind, - path_to_symlink, - relative_path, - } = guard.deref() - else { - panic!("expected symlink inode"); - }; - assert_eq!( - wasi_fs.resolve_symlink_target_path(*symlink_kind, path_to_symlink, relative_path), - Err(Errno::Perm) - ); - } } diff --git a/lib/wasix/src/fs/path_posix.rs b/lib/wasix/src/fs/path_posix.rs index 5674063c5d6f..9023dc863bdc 100644 --- a/lib/wasix/src/fs/path_posix.rs +++ b/lib/wasix/src/fs/path_posix.rs @@ -182,10 +182,6 @@ impl PosixPathBuf { } } - pub(crate) fn as_posix_path(&self) -> PosixPath<'_> { - PosixPath::new(&self.path) - } - pub(crate) fn as_str(&self) -> &str { &self.path } diff --git a/lib/wasix/src/syscalls/wasi/fd_readdir.rs b/lib/wasix/src/syscalls/wasi/fd_readdir.rs index fd4e60331e49..090e3eb4f7f9 100644 --- a/lib/wasix/src/syscalls/wasi/fd_readdir.rs +++ b/lib/wasix/src/syscalls/wasi/fd_readdir.rs @@ -28,7 +28,7 @@ pub fn fd_readdir( WasiEnv::do_pending_operations(&mut ctx)?; let env = ctx.data(); - let (memory, mut state, inodes) = unsafe { env.get_memory_and_wasi_state_and_inodes(&ctx, 0) }; + let (memory, mut state) = unsafe { env.get_memory_and_wasi_state(&ctx, 0) }; // TODO: figure out how this is supposed to work; // is it supposed to pack the buffer full every time until it can't? or do one at a time? @@ -83,54 +83,33 @@ pub fn fd_readdir( .collect::, _>>() .map_err(fs_error_into_wasi_err) ); - let mut entry_names = std::collections::HashSet::new(); let fs_entries = fs_info .into_iter() .map(|entry| { let filename = entry.file_name().to_string_lossy().to_string(); - entry_names.insert(filename.clone()); trace!("getting file: {:?}", filename); let filetype = virtual_file_type_to_wasi_file_type( entry.file_type().map_err(fs_error_into_wasi_err)?, ); - if state.fs.readdir_entry_visible( - inodes, - fd, - Some(&path), - &filename, - filetype, - ) { - entry_names.insert(filename.clone()); - Ok(Some(( - filename, filetype, 0, // TODO: inode - ))) - } else { - Ok(None) - } + Ok(( + filename, filetype, 0, // TODO: inode + )) }) - .collect::>, Errno>>(); - let mut entry_vec: Vec<(String, Filetype, u64)> = - wasi_try_ok!(fs_entries).into_iter().flatten().collect(); + .collect::, Errno>>(); + let mut entry_vec: Vec<(String, Filetype, u64)> = wasi_try_ok!(fs_entries); + let entry_names: std::collections::HashSet<_> = + entry_vec.iter().map(|(name, _, _)| name.clone()).collect(); entry_vec.extend( cached_entries .iter() .filter(|(name, _)| !entry_names.contains(name)) - .filter_map(|(name, inode)| { + .map(|(name, inode)| { let stat = inode.stat.read().unwrap(); - state - .fs - .readdir_entry_visible( - inodes, - fd, - Some(&path), - name, - stat.st_filetype, - ) - .then_some(( - format_entry_name(name, is_root), - stat.st_filetype, - stat.st_ino, - )) + ( + format_entry_name(name, is_root), + stat.st_filetype, + stat.st_ino, + ) }), ); // adding . and .. special folders @@ -144,18 +123,13 @@ pub fn fd_readdir( trace!("reading root"); let mut entry_vec: Vec<(String, Filetype, u64)> = cached_entries .into_iter() - .filter_map(|(name, inode)| { + .map(|(name, inode)| { let stat = inode.stat.read().unwrap(); - state - .fs - .readdir_entry_visible(inodes, fd, None, &name, stat.st_filetype) - .then(|| { - ( - format_entry_name(&name, true), - stat.st_filetype, - stat.st_ino, - ) - }) + ( + format_entry_name(&name, true), + stat.st_filetype, + stat.st_ino, + ) }) .collect(); entry_vec.sort_by(|a, b| a.0.cmp(&b.0)); From 82fb25035a7f35ed71453ea4d27f6047f7f3fb68 Mon Sep 17 00:00:00 2001 From: Fliqqr Date: Thu, 23 Jul 2026 12:00:26 +0200 Subject: [PATCH 14/14] fix: windows compat --- lib/virtual-fs/src/mount_fs.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/virtual-fs/src/mount_fs.rs b/lib/virtual-fs/src/mount_fs.rs index aacd3daae5b8..0cb0aa506632 100644 --- a/lib/virtual-fs/src/mount_fs.rs +++ b/lib/virtual-fs/src/mount_fs.rs @@ -342,7 +342,10 @@ impl MountFileSystem { /// anything else is reported verbatim and resolves in the guest's /// namespace. fn rebase_symlink_target(target: PathBuf, source_path: &Path, mount_path: &Path) -> PathBuf { - if !target.is_absolute() { + // Guest paths are unix-style even on Windows, where `is_absolute` + // would demand a drive prefix — `has_root` matches "/..." on every + // platform. + if !target.has_root() { return target; }