From 1157d56b4a9782a9177af63f0d9c3a790aba9d8e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 16 Jul 2026 22:26:40 -0500 Subject: [PATCH] feat(fs): add generic filesystem namespaces --- crates/bashkit/docs/live_mounts.md | 1 + crates/bashkit/docs/namespace_filesystems.md | 89 +++ crates/bashkit/docs/threat-model.md | 4 +- crates/bashkit/examples/namespace_rebase.rs | 44 ++ crates/bashkit/examples/namespace_sandbox.rs | 42 ++ crates/bashkit/src/fs/mod.rs | 3 + crates/bashkit/src/fs/namespace.rs | 531 ++++++++++++++++++ crates/bashkit/src/fs/traits.rs | 3 +- crates/bashkit/src/lib.rs | 19 +- crates/bashkit/tests/integration/main.rs | 1 + .../tests/integration/namespace_fs_tests.rs | 420 ++++++++++++++ docs/filesystem.md | 35 ++ specs/threat-model.md | 9 + specs/vfs.md | 17 + 14 files changed, 1211 insertions(+), 7 deletions(-) create mode 100644 crates/bashkit/docs/namespace_filesystems.md create mode 100644 crates/bashkit/examples/namespace_rebase.rs create mode 100644 crates/bashkit/examples/namespace_sandbox.rs create mode 100644 crates/bashkit/src/fs/namespace.rs create mode 100644 crates/bashkit/tests/integration/namespace_fs_tests.rs diff --git a/crates/bashkit/docs/live_mounts.md b/crates/bashkit/docs/live_mounts.md index 72b5a2e9d..5a99fb035 100644 --- a/crates/bashkit/docs/live_mounts.md +++ b/crates/bashkit/docs/live_mounts.md @@ -195,6 +195,7 @@ assert_eq!(result.stdout, "2.0"); ## See Also - [`MountableFs`] — the underlying mount infrastructure +- [`NamespaceFs`] — static bounded namespaces with source-root rebasing and per-mount access - [`BashBuilder::mount_text`] — pre-build text file mounts - [`BashBuilder::readonly_filesystem`] — deny all VFS mutations after setup - [`BashBuilder::fs`] — custom filesystem injection diff --git a/crates/bashkit/docs/namespace_filesystems.md b/crates/bashkit/docs/namespace_filesystems.md new file mode 100644 index 000000000..a022f4a14 --- /dev/null +++ b/crates/bashkit/docs/namespace_filesystems.md @@ -0,0 +1,89 @@ +# Filesystem Namespace Guide + +[`NamespaceFs`] composes arbitrary [`FileSystem`] instances into one static, +visible path tree. It is useful for build sandboxes, tests, plugin hosts, +serverless functions, CI jobs, and shell sessions that need different storage +and permissions at different paths. + +## Build a Namespace + +```rust +use bashkit::{Bash, FileSystem, InMemoryFs, NamespaceFs}; +use std::path::Path; +use std::sync::Arc; + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let source = Arc::new(InMemoryFs::new()); +source.write_file(Path::new("/lib.rs"), b"pub fn run() {}\n").await?; +let build = Arc::new(InMemoryFs::new()); + +let namespace = NamespaceFs::builder() + .mount_readonly("/src", source)? + .mount_readwrite("/build", build.clone())? + .build(); +let mut bash = Bash::builder().fs(Arc::new(namespace)).build(); + +bash.exec("cp /src/lib.rs /build/lib.rs").await?; +assert_eq!(build.read_file(Path::new("/lib.rs")).await?, b"pub fn run() {}\n"); +# Ok(()) +# } +``` + +The builder accepts any `Arc`. Object ownership determines the +namespace lifetime: create a namespace per operation or retain and reuse it. + +## Rebase a Source Subtree + +Use `mount_readonly_from` or `mount_readwrite_from` when the visible target and +the source root differ: + +```rust +use bashkit::{FileSystem, InMemoryFs, NamespaceFs}; +use std::path::Path; +use std::sync::Arc; + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let repository = Arc::new(InMemoryFs::new()); +repository.mkdir(Path::new("/repo/src/generated"), true).await?; +repository.write_file(Path::new("/repo/src/lib.rs"), b"source").await?; +let generated = Arc::new(InMemoryFs::new()); + +let namespace = NamespaceFs::builder() + .mount_readonly_from("/workspace", repository, "/repo/src")? + .mount_readwrite("/workspace/generated", generated.clone())? + .build(); + +assert_eq!(namespace.read_file(Path::new("/workspace/lib.rs")).await?, b"source"); +namespace + .write_file(Path::new("/workspace/generated/output.rs"), b"output") + .await?; +assert_eq!(generated.read_file(Path::new("/output.rs")).await?, b"output"); +# Ok(()) +# } +``` + +Nested mounts use deterministic longest-prefix precedence. Missing ancestors +and mount points appear as directories in `exists`, `stat`, and `read_dir`. +Directory entries carry the same metadata returned by `stat`. + +## Cross-Mount Operations + +- File and symlink `copy` works across mounts. The destination must be writable. +- Directory and FIFO copies across mounts return `ErrorKind::Unsupported`. +- `rename` within one mount delegates to its filesystem. +- `rename` across mounts returns `ErrorKind::CrossesDevices`; Bashkit does not + emulate it with copy-delete because that cannot be atomic. + +Visible paths are normalized before mount selection and rebasing. A path cannot +use `..` to escape its source root, skip a nested mount, or bypass read-only +access. + +## See also + +- [`FileSystem`] — filesystem operation contract. +- [`MountableFs`] — dynamic live mounts with a fallback root filesystem. +- [`ReadOnlyFs`] — deny mutations for an entire filesystem. +- [Live mounts](live-mounts.md) — attach and detach filesystems after build. +- [VFS specification](https://github.com/everruns/bashkit/blob/main/specs/vfs.md). diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 1587c5cfe..6f159ec7c 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -169,6 +169,7 @@ Scripts may attempt to break out of the sandbox to access the host system. | OverlayFs upper() exposed (TM-ESC-013) | `upper()` returns unlimited FS | Restrict visibility | **MITIGATED** | | Custom builtins lost (TM-ESC-014) | `std::mem::take` empties builtins | Arc-cloned builtins | **FIXED** | | Symlink overlay rename (TM-ESC-016) | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks | **FIXED** | +| Namespace source-root or policy escape (TM-ESC-031) | `..` escapes a rebased or nested mount | Normalize before longest-prefix selection; join only the stripped suffix; enforce both mutation endpoints | MITIGATED | **Process Escape:** @@ -192,7 +193,8 @@ Scripts may attempt to break out of the sandbox to access the host system. **Virtual Filesystem:** Bashkit uses an in-memory virtual filesystem by default. Scripts cannot access the -real filesystem unless explicitly mounted via [`MountableFs`]. +real filesystem unless explicitly mounted via [`MountableFs`] or composed into a +bounded [`NamespaceFs`]. ```rust use bashkit::{Bash, InMemoryFs, MountableFs}; diff --git a/crates/bashkit/examples/namespace_rebase.rs b/crates/bashkit/examples/namespace_rebase.rs new file mode 100644 index 000000000..60962f7dc --- /dev/null +++ b/crates/bashkit/examples/namespace_rebase.rs @@ -0,0 +1,44 @@ +//! Rebase a source subtree and place a writable mount inside it. + +use bashkit::{Bash, FileSystem, InMemoryFs, NamespaceFs}; +use std::path::Path; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> bashkit::Result<()> { + let repository = Arc::new(InMemoryFs::new()); + repository + .mkdir(Path::new("/repo/src/generated"), true) + .await?; + repository + .write_file( + Path::new("/repo/src/lib.rs"), + b"pub fn answer() -> u8 { 42 }\n", + ) + .await?; + repository + .write_file(Path::new("/repo/private.txt"), b"not mounted") + .await?; + + let generated = Arc::new(InMemoryFs::new()); + let namespace = NamespaceFs::builder() + .mount_readonly_from("/workspace", repository, "/repo/src")? + .mount_readwrite("/workspace/generated", generated.clone())? + .build(); + + let mut bash = Bash::builder().fs(Arc::new(namespace)).build(); + let result = bash + .exec("cat /workspace/lib.rs; printf generated > /workspace/generated/schema.rs") + .await?; + assert_eq!(result.stdout, "pub fn answer() -> u8 { 42 }\n"); + assert_eq!( + generated.read_file(Path::new("/schema.rs")).await?, + b"generated" + ); + assert!(!bash.fs().exists(Path::new("/private.txt")).await?); + println!( + "{0}nested writable override created schema.rs", + result.stdout + ); + Ok(()) +} diff --git a/crates/bashkit/examples/namespace_sandbox.rs b/crates/bashkit/examples/namespace_sandbox.rs new file mode 100644 index 000000000..0270b6819 --- /dev/null +++ b/crates/bashkit/examples/namespace_sandbox.rs @@ -0,0 +1,42 @@ +//! Compose a generic build sandbox from independent filesystems. + +use bashkit::{Bash, FileSystem, InMemoryFs, NamespaceFs}; +use std::path::Path; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> bashkit::Result<()> { + let source = Arc::new(InMemoryFs::new()); + source + .write_file(Path::new("/main.rs"), b"fn main() {}\n") + .await?; + + let build = Arc::new(InMemoryFs::new()); + let cache = Arc::new(InMemoryFs::new()); + let temporary = Arc::new(InMemoryFs::new()); + let namespace = NamespaceFs::builder() + .mount_readonly("/src", source)? + .mount_readwrite("/build", build.clone())? + .mount_readwrite("/cache", cache.clone())? + .mount_readwrite("/tmp", temporary)? + .build(); + + let mut bash = Bash::builder().fs(Arc::new(namespace)).build(); + let result = bash + .exec( + "cat /src/main.rs; printf artifact > /build/app; \ + printf cached > /cache/index; printf scratch > /tmp/work", + ) + .await?; + assert_eq!(result.stdout, "fn main() {}\n"); + assert_eq!(build.read_file(Path::new("/app")).await?, b"artifact"); + assert_eq!(cache.read_file(Path::new("/index")).await?, b"cached"); + + let denied = bash.exec("printf changed > /src/main.rs").await?; + assert_ne!(denied.exit_code, 0); + println!( + "{}build, cache, and tmp are writable; src is read-only", + result.stdout + ); + Ok(()) +} diff --git a/crates/bashkit/src/fs/mod.rs b/crates/bashkit/src/fs/mod.rs index 9abcfea6c..70e5019dc 100644 --- a/crates/bashkit/src/fs/mod.rs +++ b/crates/bashkit/src/fs/mod.rs @@ -89,6 +89,7 @@ //! | [`InMemoryFs`] | HashMap-based storage with POSIX checks | Default, isolated execution | //! | [`OverlayFs`] | Copy-on-write layered filesystem | Templates, immutable bases | //! | [`MountableFs`] | Multiple filesystems at mount points | Complex multi-source setups | +//! | [`NamespaceFs`] | Static tree of rebased filesystem mounts | Build sandboxes, CI, plugin hosts | //! | [`ReadOnlyFs`] | Mutation-denying wrapper around another filesystem | Inspection-only sessions | //! | [`RealFs`] | Host directory access (`realfs` feature) | Expose host files to scripts | //! @@ -400,6 +401,7 @@ mod backend; mod limits; mod memory; mod mountable; +mod namespace; mod overlay; mod posix; mod readonly; @@ -412,6 +414,7 @@ pub use backend::FsBackend; pub use limits::{FsLimitExceeded, FsLimits, FsUsage}; pub use memory::{InMemoryFs, LazyLoader, VfsSnapshot}; pub use mountable::MountableFs; +pub use namespace::{NamespaceAccess, NamespaceFs, NamespaceFsBuilder}; pub use overlay::OverlayFs; pub use posix::PosixFs; pub use readonly::ReadOnlyFs; diff --git a/crates/bashkit/src/fs/namespace.rs b/crates/bashkit/src/fs/namespace.rs new file mode 100644 index 000000000..31a5b9a85 --- /dev/null +++ b/crates/bashkit/src/fs/namespace.rs @@ -0,0 +1,531 @@ +//! Static filesystem namespaces composed from arbitrary filesystem subtrees. +//! +//! Important decision: namespace paths are normalized before mount selection, +//! then appended beneath a normalized source root. This ordering makes `..` +//! unable to escape a source root or bypass a nested/read-only mount. + +use async_trait::async_trait; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Error as IoError, ErrorKind}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use super::{ + DirEntry, FileSystem, FileSystemExt, FileType, FsLimits, FsUsage, Metadata, ReadOnlyFs, + normalize_path, +}; +use crate::Result; +use crate::time_compat::SystemTime; + +/// Access granted to a filesystem mounted in a [`NamespaceFs`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NamespaceAccess { + /// Reads are allowed and every mutation is denied. + ReadOnly, + /// Reads and mutations are delegated to the mounted filesystem. + ReadWrite, +} + +struct NamespaceMount { + target: PathBuf, + source_root: PathBuf, + fs: Arc, + access: NamespaceAccess, +} + +struct PendingMount { + source_root: PathBuf, + fs: Arc, + access: NamespaceAccess, +} + +/// Builder for a static [`NamespaceFs`]. +#[derive(Default)] +pub struct NamespaceFsBuilder { + mounts: BTreeMap, +} + +impl NamespaceFsBuilder { + /// Mount the source filesystem's root at `target` with explicit access. + pub fn mount( + self, + target: impl AsRef, + fs: Arc, + access: NamespaceAccess, + ) -> Result { + self.mount_from(target, fs, "/", access) + } + + /// Mount `source_root` from `fs` at `target` with explicit access. + /// + /// Mount paths and source roots must be absolute POSIX paths. Adding the + /// same target again replaces the earlier binding deterministically. + pub fn mount_from( + mut self, + target: impl AsRef, + fs: Arc, + source_root: impl AsRef, + access: NamespaceAccess, + ) -> Result { + let target = validate_mount_path(target.as_ref(), "namespace target")?; + let source_root = validate_mount_path(source_root.as_ref(), "source root")?; + let fs = match access { + NamespaceAccess::ReadOnly => Arc::new(ReadOnlyFs::new(fs)) as Arc, + NamespaceAccess::ReadWrite => fs, + }; + self.mounts.insert( + target, + PendingMount { + source_root, + fs, + access, + }, + ); + Ok(self) + } + + /// Mount the source filesystem's root read-only at `target`. + pub fn mount_readonly(self, target: impl AsRef, fs: Arc) -> Result { + self.mount(target, fs, NamespaceAccess::ReadOnly) + } + + /// Mount `source_root` read-only at `target`. + pub fn mount_readonly_from( + self, + target: impl AsRef, + fs: Arc, + source_root: impl AsRef, + ) -> Result { + self.mount_from(target, fs, source_root, NamespaceAccess::ReadOnly) + } + + /// Mount the source filesystem's root read-write at `target`. + pub fn mount_readwrite( + self, + target: impl AsRef, + fs: Arc, + ) -> Result { + self.mount(target, fs, NamespaceAccess::ReadWrite) + } + + /// Mount `source_root` read-write at `target`. + pub fn mount_readwrite_from( + self, + target: impl AsRef, + fs: Arc, + source_root: impl AsRef, + ) -> Result { + self.mount_from(target, fs, source_root, NamespaceAccess::ReadWrite) + } + + /// Finish the namespace. The builder owns every mounted filesystem. + pub fn build(self) -> NamespaceFs { + let mut synthetic_dirs = BTreeSet::from([PathBuf::from("/")]); + let mounts = self + .mounts + .into_iter() + .map(|(target, pending)| { + let mut ancestor = target.parent(); + while let Some(path) = ancestor { + synthetic_dirs.insert(normalize_path(path)); + ancestor = path.parent(); + } + NamespaceMount { + target, + source_root: pending.source_root, + fs: pending.fs, + access: pending.access, + } + }) + .collect(); + + NamespaceFs { + mounts, + synthetic_dirs, + synthetic_metadata: Metadata { + file_type: FileType::Directory, + size: 0, + mode: 0o755, + modified: SystemTime::now(), + created: SystemTime::now(), + }, + limits: FsLimits::new(), + } + } +} + +/// One visible path tree composed from arbitrary [`FileSystem`] instances. +/// +/// The namespace is static after construction. Longest target-prefix wins for +/// nested mounts. Missing ancestors are exposed as synthetic directories. +/// Cross-mount file and symlink copies are supported; cross-mount rename is +/// rejected with [`ErrorKind::CrossesDevices`] because it cannot be atomic. +/// +/// # Example +/// +/// ```rust +/// use bashkit::{FileSystem, InMemoryFs, NamespaceFs}; +/// use std::path::Path; +/// use std::sync::Arc; +/// +/// # #[tokio::main] +/// # async fn main() -> bashkit::Result<()> { +/// let source = Arc::new(InMemoryFs::new()); +/// source.mkdir(Path::new("/project"), false).await?; +/// source +/// .write_file(Path::new("/project/input.txt"), b"input") +/// .await?; +/// let output = Arc::new(InMemoryFs::new()); +/// +/// let namespace = NamespaceFs::builder() +/// .mount_readonly_from("/src", source, "/project")? +/// .mount_readwrite("/build", output.clone())? +/// .build(); +/// +/// assert_eq!(namespace.read_file(Path::new("/src/input.txt")).await?, b"input"); +/// namespace.write_file(Path::new("/build/output.txt"), b"output").await?; +/// assert_eq!(output.read_file(Path::new("/output.txt")).await?, b"output"); +/// # Ok(()) +/// # } +/// ``` +pub struct NamespaceFs { + mounts: Vec, + synthetic_dirs: BTreeSet, + synthetic_metadata: Metadata, + limits: FsLimits, +} + +struct ResolvedPath<'a> { + index: usize, + mount: &'a NamespaceMount, + source_path: PathBuf, +} + +impl NamespaceFs { + /// Start an empty namespace builder. + pub fn builder() -> NamespaceFsBuilder { + NamespaceFsBuilder::default() + } + + fn normalized(&self, path: &Path) -> Result { + self.limits + .validate_path(path) + .map_err(|error| IoError::new(ErrorKind::InvalidInput, error.to_string()))?; + Ok(normalize_path(path)) + } + + // THREAT[TM-ESC-031]: selection happens on the normalized visible path; + // only a component-stripped relative suffix is appended to source_root. + fn resolve(&self, path: &Path) -> Option> { + self.mounts + .iter() + .enumerate() + .filter(|(_, mount)| path.starts_with(&mount.target)) + .max_by_key(|(_, mount)| mount.target.components().count()) + .map(|(index, mount)| { + let relative = path.strip_prefix(&mount.target).unwrap_or(Path::new("")); + let source_path = if relative.as_os_str().is_empty() { + mount.source_root.clone() + } else { + mount.source_root.join(relative) + }; + ResolvedPath { + index, + mount, + source_path, + } + }) + } + + fn is_exact_mount(&self, path: &Path) -> bool { + self.mounts.iter().any(|mount| mount.target == path) + } + + fn is_synthetic(&self, path: &Path) -> bool { + self.synthetic_dirs.contains(path) && !self.is_exact_mount(path) + } + + fn is_namespace_node(&self, path: &Path) -> bool { + self.synthetic_dirs.contains(path) || self.is_exact_mount(path) + } + + fn outside_read_error() -> crate::Error { + IoError::new(ErrorKind::NotFound, "path is outside namespace mounts").into() + } + + fn outside_write_error() -> crate::Error { + IoError::new( + ErrorKind::PermissionDenied, + "path is outside writable namespace mounts", + ) + .into() + } + + fn cross_mount_error() -> crate::Error { + IoError::new( + ErrorKind::CrossesDevices, + "cross-mount rename is not atomic", + ) + .into() + } + + fn require_writable(resolved: &ResolvedPath<'_>) -> Result<()> { + if resolved.mount.access == NamespaceAccess::ReadOnly { + return Err( + IoError::new(ErrorKind::PermissionDenied, "filesystem is read-only").into(), + ); + } + Ok(()) + } + + fn child_mount_paths(&self, path: &Path) -> Vec { + let mut children = BTreeSet::new(); + for mount in &self.mounts { + let Ok(relative) = mount.target.strip_prefix(path) else { + continue; + }; + let Some(Component::Normal(name)) = relative.components().next() else { + continue; + }; + children.insert(if path == Path::new("/") { + PathBuf::from("/").join(name) + } else { + path.join(name) + }); + } + children.into_iter().collect() + } + + fn resolve_writable(&self, path: &Path) -> Result> { + let path = self.normalized(path)?; + if self.is_synthetic(&path) { + return Err(Self::outside_write_error()); + } + let resolved = self.resolve(&path).ok_or_else(Self::outside_write_error)?; + Self::require_writable(&resolved)?; + Ok(resolved) + } +} + +#[async_trait] +impl FileSystem for NamespaceFs { + async fn read_file(&self, path: &Path) -> Result> { + let path = self.normalized(path)?; + if self.is_synthetic(&path) { + return Err(super::fs_errors::is_a_directory()); + } + let resolved = self.resolve(&path).ok_or_else(Self::outside_read_error)?; + resolved.mount.fs.read_file(&resolved.source_path).await + } + + async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> { + let resolved = self.resolve_writable(path)?; + resolved + .mount + .fs + .write_file(&resolved.source_path, content) + .await + } + + async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> { + let resolved = self.resolve_writable(path)?; + resolved + .mount + .fs + .append_file(&resolved.source_path, content) + .await + } + + async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> { + let resolved = self.resolve_writable(path)?; + resolved + .mount + .fs + .mkdir(&resolved.source_path, recursive) + .await + } + + async fn remove(&self, path: &Path, recursive: bool) -> Result<()> { + let path = self.normalized(path)?; + if self.is_namespace_node(&path) { + return Err(Self::outside_write_error()); + } + let resolved = self.resolve_writable(&path)?; + resolved + .mount + .fs + .remove(&resolved.source_path, recursive) + .await + } + + async fn stat(&self, path: &Path) -> Result { + let path = self.normalized(path)?; + if self.is_synthetic(&path) { + return Ok(self.synthetic_metadata.clone()); + } + let resolved = self.resolve(&path).ok_or_else(Self::outside_read_error)?; + resolved.mount.fs.stat(&resolved.source_path).await + } + + async fn read_dir(&self, path: &Path) -> Result> { + let path = self.normalized(path)?; + let mut entries = if self.is_synthetic(&path) { + Vec::new() + } else { + let resolved = self.resolve(&path).ok_or_else(Self::outside_read_error)?; + resolved.mount.fs.read_dir(&resolved.source_path).await? + }; + + for child_path in self.child_mount_paths(&path) { + let name = child_path + .file_name() + .expect("child mount path has a name") + .to_string_lossy() + .into_owned(); + let metadata = self.stat(&child_path).await?; + if let Some(entry) = entries.iter_mut().find(|entry| entry.name == name) { + entry.metadata = metadata; + } else { + entries.push(DirEntry { name, metadata }); + } + } + entries.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(entries) + } + + async fn exists(&self, path: &Path) -> Result { + let path = self.normalized(path)?; + if self.is_synthetic(&path) { + return Ok(true); + } + let Some(resolved) = self.resolve(&path) else { + return Ok(false); + }; + resolved.mount.fs.exists(&resolved.source_path).await + } + + async fn rename(&self, from: &Path, to: &Path) -> Result<()> { + let from = self.normalized(from)?; + let to = self.normalized(to)?; + if self.is_namespace_node(&from) || self.is_namespace_node(&to) { + return Err(Self::outside_write_error()); + } + let from = self.resolve(&from).ok_or_else(Self::outside_write_error)?; + let to = self.resolve(&to).ok_or_else(Self::outside_write_error)?; + Self::require_writable(&from)?; + Self::require_writable(&to)?; + if from.index != to.index { + return Err(Self::cross_mount_error()); + } + from.mount + .fs + .rename(&from.source_path, &to.source_path) + .await + } + + async fn copy(&self, from: &Path, to: &Path) -> Result<()> { + let from = self.normalized(from)?; + let to = self.normalized(to)?; + if self.is_synthetic(&from) { + return Err(super::fs_errors::is_a_directory()); + } + let from = self.resolve(&from).ok_or_else(Self::outside_read_error)?; + let to = self.resolve(&to).ok_or_else(Self::outside_write_error)?; + Self::require_writable(&to)?; + if from.index == to.index { + return from.mount.fs.copy(&from.source_path, &to.source_path).await; + } + + let metadata = from.mount.fs.stat(&from.source_path).await?; + match metadata.file_type { + FileType::File => { + let content = from.mount.fs.read_file(&from.source_path).await?; + to.mount.fs.write_file(&to.source_path, &content).await + } + FileType::Symlink => { + let target = from.mount.fs.read_link(&from.source_path).await?; + to.mount.fs.symlink(&target, &to.source_path).await + } + FileType::Directory => Err(IoError::new( + ErrorKind::Unsupported, + "cross-mount directory copy is not supported", + ) + .into()), + FileType::Fifo => Err(IoError::new( + ErrorKind::Unsupported, + "cross-mount FIFO copy is not supported", + ) + .into()), + } + } + + async fn symlink(&self, target: &Path, link: &Path) -> Result<()> { + let resolved = self.resolve_writable(link)?; + resolved + .mount + .fs + .symlink(target, &resolved.source_path) + .await + } + + async fn read_link(&self, path: &Path) -> Result { + let path = self.normalized(path)?; + if self.is_synthetic(&path) { + return Err(IoError::new(ErrorKind::InvalidInput, "not a symlink").into()); + } + let resolved = self.resolve(&path).ok_or_else(Self::outside_read_error)?; + resolved.mount.fs.read_link(&resolved.source_path).await + } + + async fn chmod(&self, path: &Path, mode: u32) -> Result<()> { + let resolved = self.resolve_writable(path)?; + resolved.mount.fs.chmod(&resolved.source_path, mode).await + } + + async fn set_modified_time(&self, path: &Path, time: SystemTime) -> Result<()> { + let resolved = self.resolve_writable(path)?; + resolved + .mount + .fs + .set_modified_time(&resolved.source_path, time) + .await + } +} + +#[async_trait] +impl FileSystemExt for NamespaceFs { + fn usage(&self) -> FsUsage { + self.mounts + .iter() + .fold(FsUsage::default(), |mut total, mount| { + let usage = mount.fs.usage(); + total.total_bytes += usage.total_bytes; + total.file_count += usage.file_count; + total.dir_count += usage.dir_count; + total + }) + } + + fn limits(&self) -> FsLimits { + self.limits.clone() + } + + async fn mkfifo(&self, path: &Path, mode: u32) -> Result<()> { + let resolved = self.resolve_writable(path)?; + resolved.mount.fs.mkfifo(&resolved.source_path, mode).await + } +} + +fn validate_mount_path(path: &Path, label: &str) -> Result { + if !path.as_os_str().as_encoded_bytes().starts_with(b"/") { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!("{label} must be an absolute POSIX path"), + ) + .into()); + } + FsLimits::new() + .validate_path(path) + .map_err(|error| IoError::new(ErrorKind::InvalidInput, error.to_string()))?; + Ok(normalize_path(path)) +} diff --git a/crates/bashkit/src/fs/traits.rs b/crates/bashkit/src/fs/traits.rs index dcd82fbda..5736f58f7 100644 --- a/crates/bashkit/src/fs/traits.rs +++ b/crates/bashkit/src/fs/traits.rs @@ -228,11 +228,12 @@ pub trait FileSystemExt: Send + Sync { /// /// # Built-in Implementations /// -/// Bashkit provides three implementations: +/// Bashkit provides several implementations: /// /// - [`InMemoryFs`](crate::InMemoryFs) - HashMap-based in-memory storage /// - [`OverlayFs`](crate::OverlayFs) - Copy-on-write layered filesystem /// - [`MountableFs`](crate::MountableFs) - Multiple mount points +/// - [`NamespaceFs`](crate::NamespaceFs) - Static tree of rebased mounts #[async_trait] pub trait FileSystem: FileSystemExt { /// Read a file's contents as bytes. diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 97bc0dafa..3c59407e8 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -9,7 +9,7 @@ //! //! - **POSIX compliant** - Substantial IEEE 1003.1-2024 Shell Command Language compliance //! - **Sandboxed, in-process execution** - No real filesystem access by default -//! - **Virtual filesystem** - [`InMemoryFs`], [`OverlayFs`], [`MountableFs`] +//! - **Virtual filesystem** - [`InMemoryFs`], [`OverlayFs`], [`MountableFs`], [`NamespaceFs`] //! - **Resource limits** - Command count, loop iterations, function depth //! - **Network allowlist** - Control HTTP access per-domain //! - **Custom builtins** - Extend with domain-specific commands @@ -237,11 +237,12 @@ //! //! # Virtual Filesystem //! -//! Bashkit provides three filesystem implementations: +//! Bashkit provides several filesystem implementations: //! //! - [`InMemoryFs`]: Simple in-memory filesystem (default) //! - [`OverlayFs`]: Copy-on-write overlay for layered storage //! - [`MountableFs`]: Mount multiple filesystems at different paths +//! - [`NamespaceFs`]: Compose a static tree from rebased filesystem mounts //! //! See the `fs` module documentation for details and examples. //! @@ -391,12 +392,15 @@ //! - `git_workflow.rs` - Git operations on the virtual filesystem //! - `python_scripts.rs` - Embedded Python with VFS bridging //! - `python_external_functions.rs` - Python callbacks into host functions +//! - `namespace_sandbox.rs` - Static read-only/read-write build namespace +//! - `namespace_rebase.rs` - Source-root rebasing with a nested writable override //! //! # Guides //! //! - [`custom_builtins_guide`] - Creating custom builtins //! - [`compatibility_scorecard`] - Feature parity tracking //! - [`live_mounts_guide`] - Live mount/unmount on running instances +//! - [`namespace_filesystems_guide`] - Static namespaces with rebasing and per-mount access //! - `python_guide` - Embedded Python (Monty) guide (requires `python` feature) //! - `logging_guide` - Structured logging with security (requires `logging` feature) //! @@ -463,9 +467,10 @@ pub use credential::Credential; pub use error::{Error, Result}; pub use fs::{ DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage, - InMemoryFs, LazyLoader, Metadata, MountableFs, OverlayFs, PosixFs, ReadOnlyFs, - SearchCapabilities, SearchCapable, SearchMatch, SearchProvider, SearchQuery, SearchResults, - VfsSnapshot, normalize_path, verify_filesystem_requirements, + InMemoryFs, LazyLoader, Metadata, MountableFs, NamespaceAccess, NamespaceFs, + NamespaceFsBuilder, OverlayFs, PosixFs, ReadOnlyFs, SearchCapabilities, SearchCapable, + SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsSnapshot, normalize_path, + verify_filesystem_requirements, }; #[cfg(feature = "realfs")] pub use fs::{RealFs, RealFsMode}; @@ -3291,6 +3296,10 @@ pub mod ssh_guide {} #[doc = include_str!("../docs/live_mounts.md")] pub mod live_mounts_guide {} +/// Guide to composing static filesystem namespaces. +#[doc = include_str!("../docs/namespace_filesystems.md")] +pub mod namespace_filesystems_guide {} + /// Logging guide for Bashkit. /// /// This guide covers configuring structured logging, log levels, security diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 6ee3a90a6..76c822055 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -66,6 +66,7 @@ pub mod limitations_doc_tests; pub mod limitations_evidence_tests; pub mod live_mount_tests; pub mod mkfifo_tests; +pub mod namespace_fs_tests; pub mod nested_subscript_tests; pub mod network_security_tests; pub mod output_truncation_tests; diff --git a/crates/bashkit/tests/integration/namespace_fs_tests.rs b/crates/bashkit/tests/integration/namespace_fs_tests.rs new file mode 100644 index 000000000..4b642e0aa --- /dev/null +++ b/crates/bashkit/tests/integration/namespace_fs_tests.rs @@ -0,0 +1,420 @@ +use bashkit::{ + Error, FileSystem, FileSystemExt, FileType, InMemoryFs, NamespaceAccess, NamespaceFs, + ReadOnlyFs, +}; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::Arc; +use std::time::SystemTime; + +async fn source_with(path: &str, content: &[u8]) -> Arc { + let fs = Arc::new(InMemoryFs::new()); + let parent = Path::new(path).parent().unwrap(); + fs.mkdir(parent, true).await.unwrap(); + fs.write_file(Path::new(path), content).await.unwrap(); + fs +} + +#[tokio::test] +async fn namespace_mounts_arbitrary_filesystems_with_longest_prefix_precedence() { + let parent = source_with("/parent.txt", b"parent").await; + parent.mkdir(Path::new("/nested"), false).await.unwrap(); + parent + .write_file(Path::new("/nested/hidden.txt"), b"hidden") + .await + .unwrap(); + let nested = source_with("/visible.txt", b"nested").await; + + let namespace = NamespaceFs::builder() + .mount_readwrite("/workspace", parent.clone()) + .unwrap() + .mount_readwrite("/workspace/nested", nested.clone()) + .unwrap() + .build(); + + assert_eq!( + namespace + .read_file(Path::new("/workspace/parent.txt")) + .await + .unwrap(), + b"parent" + ); + assert_eq!( + namespace + .read_file(Path::new("/workspace/nested/visible.txt")) + .await + .unwrap(), + b"nested" + ); + assert!( + !namespace + .exists(Path::new("/workspace/nested/hidden.txt")) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn namespace_lists_synthetic_ancestors_and_mount_points_consistently() { + let source = source_with("/project/file.txt", b"hello").await; + let namespace = NamespaceFs::builder() + .mount_from( + "/sandbox/work/src", + source, + "/project", + NamespaceAccess::ReadOnly, + ) + .unwrap() + .build(); + + for path in ["/", "/sandbox", "/sandbox/work", "/sandbox/work/src"] { + let metadata = namespace.stat(Path::new(path)).await.unwrap(); + assert_eq!(metadata.file_type, FileType::Directory, "{path}"); + assert!(namespace.exists(Path::new(path)).await.unwrap(), "{path}"); + } + + let root = namespace.read_dir(Path::new("/")).await.unwrap(); + assert_eq!( + root.iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["sandbox"] + ); + + let work = namespace + .read_dir(Path::new("/sandbox/work")) + .await + .unwrap(); + let src = work.iter().find(|entry| entry.name == "src").unwrap(); + assert_eq!( + src.metadata.file_type, + namespace + .stat(Path::new("/sandbox/work/src")) + .await + .unwrap() + .file_type + ); + assert_eq!(src.metadata.size, 0); + + let mounted = namespace + .read_dir(Path::new("/sandbox/work/src")) + .await + .unwrap(); + let file = mounted + .iter() + .find(|entry| entry.name == "file.txt") + .unwrap(); + assert_eq!(file.metadata.size, 5); + assert_eq!( + file.metadata.size, + namespace + .stat(Path::new("/sandbox/work/src/file.txt")) + .await + .unwrap() + .size + ); +} + +#[tokio::test] +async fn namespace_rebases_source_root_and_keeps_nested_writable_override() { + let source = source_with("/repo/src/lib.rs", b"readonly").await; + source + .mkdir(Path::new("/repo/src/generated"), false) + .await + .unwrap(); + source + .write_file(Path::new("/repo/private.txt"), b"private") + .await + .unwrap(); + let generated = Arc::new(InMemoryFs::new()); + + let namespace = NamespaceFs::builder() + .mount_readonly_from("/src", source.clone(), "/repo/src") + .unwrap() + .mount_readwrite("/src/generated", generated.clone()) + .unwrap() + .build(); + + assert_eq!( + namespace.read_file(Path::new("/src/lib.rs")).await.unwrap(), + b"readonly" + ); + assert!(!namespace.exists(Path::new("/private.txt")).await.unwrap()); + assert!( + namespace + .write_file(Path::new("/src/lib.rs"), b"blocked") + .await + .is_err() + ); + namespace + .write_file(Path::new("/src/generated/output.rs"), b"generated") + .await + .unwrap(); + assert_eq!( + generated.read_file(Path::new("/output.rs")).await.unwrap(), + b"generated" + ); +} + +#[tokio::test] +async fn tm_esc_031_namespace_readonly_mount_denies_every_mutation_without_bypass() { + let source = source_with("/file.txt", b"original").await; + let namespace = NamespaceFs::builder() + .mount_readonly("/ro", source.clone()) + .unwrap() + .mount_readwrite("/rw", Arc::new(InMemoryFs::new())) + .unwrap() + .build(); + + let operations = [ + namespace + .write_file(Path::new("/ro/file.txt"), b"changed") + .await, + namespace + .append_file(Path::new("/ro/file.txt"), b"changed") + .await, + namespace.remove(Path::new("/ro/file.txt"), false).await, + namespace.chmod(Path::new("/ro/file.txt"), 0o777).await, + namespace + .rename(Path::new("/ro/file.txt"), Path::new("/rw/file.txt")) + .await, + namespace.mkdir(Path::new("/ro/new"), false).await, + namespace + .copy(Path::new("/rw/missing"), Path::new("/ro/new")) + .await, + namespace + .symlink(Path::new("/target"), Path::new("/ro/link")) + .await, + namespace + .set_modified_time(Path::new("/ro/file.txt"), SystemTime::now()) + .await, + namespace.mkfifo(Path::new("/ro/fifo"), 0o644).await, + ]; + for result in operations { + let Error::Io(error) = result.unwrap_err() else { + panic!("expected I/O permission error"); + }; + assert_eq!(error.kind(), ErrorKind::PermissionDenied); + } + assert_eq!( + source.read_file(Path::new("/file.txt")).await.unwrap(), + b"original" + ); +} + +#[tokio::test] +async fn tm_esc_031_namespace_normalizes_without_source_or_nested_mount_escape() { + let source = source_with("/allowed/file.txt", b"allowed").await; + source + .write_file(Path::new("/secret.txt"), b"secret") + .await + .unwrap(); + let nested = source_with("/nested.txt", b"nested").await; + let namespace = NamespaceFs::builder() + .mount_readwrite_from("/visible", source, "/allowed") + .unwrap() + .mount_readwrite("/visible/nested", nested) + .unwrap() + .build(); + + assert_eq!( + namespace + .read_file(Path::new("/visible/./file.txt")) + .await + .unwrap(), + b"allowed" + ); + assert!( + !namespace + .exists(Path::new("/visible/../secret.txt")) + .await + .unwrap() + ); + assert!( + !namespace + .exists(Path::new("/visible/nested/../nested.txt")) + .await + .unwrap() + ); + assert_eq!( + namespace + .read_file(Path::new("/visible/other/../nested/nested.txt")) + .await + .unwrap(), + b"nested" + ); +} + +#[tokio::test] +async fn namespace_cross_mount_copy_is_supported_but_rename_is_typed_non_atomic_error() { + let source = source_with("/file.txt", b"copy me").await; + let destination = Arc::new(InMemoryFs::new()); + let namespace = NamespaceFs::builder() + .mount_readonly("/input", source.clone()) + .unwrap() + .mount_readwrite("/output", destination.clone()) + .unwrap() + .build(); + + namespace + .copy(Path::new("/input/file.txt"), Path::new("/output/file.txt")) + .await + .unwrap(); + assert_eq!( + destination.read_file(Path::new("/file.txt")).await.unwrap(), + b"copy me" + ); + + let Error::Io(error) = namespace + .rename(Path::new("/output/file.txt"), Path::new("/input/moved.txt")) + .await + .unwrap_err() + else { + panic!("expected typed cross-device error"); + }; + assert_eq!(error.kind(), ErrorKind::PermissionDenied); + + let other = Arc::new(InMemoryFs::new()); + let namespace = NamespaceFs::builder() + .mount_readwrite("/one", destination) + .unwrap() + .mount_readwrite("/two", other) + .unwrap() + .build(); + let Error::Io(error) = namespace + .rename(Path::new("/one/file.txt"), Path::new("/two/file.txt")) + .await + .unwrap_err() + else { + panic!("expected typed cross-device error"); + }; + assert_eq!(error.kind(), ErrorKind::CrossesDevices); +} + +#[tokio::test] +async fn namespace_uses_readonly_wrapper_semantics() { + let source: Arc = Arc::new(InMemoryFs::new()); + let readonly: Arc = Arc::new(ReadOnlyFs::new(source)); + let namespace = NamespaceFs::builder() + .mount("/wrapped", readonly, NamespaceAccess::ReadWrite) + .unwrap() + .build(); + + let Error::Io(error) = namespace + .write_file(Path::new("/wrapped/file.txt"), b"blocked") + .await + .unwrap_err() + else { + panic!("expected I/O error"); + }; + assert_eq!(error.kind(), ErrorKind::PermissionDenied); +} + +#[tokio::test] +async fn tm_esc_031_namespace_rejects_invalid_paths_and_protects_namespace_nodes() { + let source = source_with("/nested/file.txt", b"data").await; + assert!( + NamespaceFs::builder() + .mount_readwrite("relative", source.clone()) + .is_err() + ); + assert!( + NamespaceFs::builder() + .mount_readwrite_from("/valid", source.clone(), "relative") + .is_err() + ); + + let namespace = NamespaceFs::builder() + .mount_readwrite("/parent", source.clone()) + .unwrap() + .mount_readwrite("/parent/nested/mount", Arc::new(InMemoryFs::new())) + .unwrap() + .build(); + + for path in ["/parent", "/parent/nested", "/parent/nested/mount"] { + let Error::Io(error) = namespace.remove(Path::new(path), true).await.unwrap_err() else { + panic!("expected namespace-node protection"); + }; + assert_eq!(error.kind(), ErrorKind::PermissionDenied, "{path}"); + } + assert!(source.exists(Path::new("/nested/file.txt")).await.unwrap()); +} + +#[tokio::test] +async fn namespace_cross_mount_copy_preserves_symlinks() { + let source = Arc::new(InMemoryFs::new()); + source + .symlink(Path::new("/target"), Path::new("/link")) + .await + .unwrap(); + let destination = Arc::new(InMemoryFs::new()); + let namespace = NamespaceFs::builder() + .mount_readonly("/source", source) + .unwrap() + .mount_readwrite("/destination", destination.clone()) + .unwrap() + .build(); + + namespace + .copy(Path::new("/source/link"), Path::new("/destination/link")) + .await + .unwrap(); + assert_eq!( + destination.read_link(Path::new("/link")).await.unwrap(), + Path::new("/target") + ); +} + +#[tokio::test] +async fn namespace_same_mount_copy_and_rename_delegate_atomically() { + let source = source_with("/original.txt", b"data").await; + let namespace = NamespaceFs::builder() + .mount_readwrite("/work", source.clone()) + .unwrap() + .build(); + + namespace + .copy( + Path::new("/work/original.txt"), + Path::new("/work/copied.txt"), + ) + .await + .unwrap(); + namespace + .rename( + Path::new("/work/copied.txt"), + Path::new("/work/renamed.txt"), + ) + .await + .unwrap(); + + assert!(!source.exists(Path::new("/copied.txt")).await.unwrap()); + assert_eq!( + source.read_file(Path::new("/renamed.txt")).await.unwrap(), + b"data" + ); +} + +#[tokio::test] +async fn namespace_cross_mount_directory_copy_is_typed_unsupported_error() { + let source = Arc::new(InMemoryFs::new()); + source.mkdir(Path::new("/directory"), false).await.unwrap(); + let namespace = NamespaceFs::builder() + .mount_readonly("/source", source) + .unwrap() + .mount_readwrite("/destination", Arc::new(InMemoryFs::new())) + .unwrap() + .build(); + + let Error::Io(error) = namespace + .copy( + Path::new("/source/directory"), + Path::new("/destination/directory"), + ) + .await + .unwrap_err() + else { + panic!("expected typed unsupported error"); + }; + assert_eq!(error.kind(), ErrorKind::Unsupported); +} diff --git a/docs/filesystem.md b/docs/filesystem.md index 96d33cdf7..e238d3c25 100644 --- a/docs/filesystem.md +++ b/docs/filesystem.md @@ -66,6 +66,7 @@ semantics. | **InMemoryFs** | Default (`Bash::new()`). `HashMap`-backed, thread-safe, no persistence. Seeds `/`, `/tmp`, `/home`, `/home/user`, `/dev`. | | **OverlayFs** | Copy-on-write over another filesystem, with whiteout tracking for deletes. | | **MountableFs** | Mount multiple filesystems at different paths (longest-prefix match). Always the outermost layer, enabling [live mounts](live-mounts.md). | +| **NamespaceFs** | Compose a static visible tree from rebased filesystem subtrees, with per-mount access and synthetic ancestors. | | **ReadOnlyFs** | Delegates reads, denies every mutation with `PermissionDenied` — even writes to `/tmp`, `cp`, `mv`, `rm`, `chmod`. For inspection-only sessions. | | **RealFs** (`realfs` feature) | Direct access to a host directory. Read-only (safe) or read-write (dangerous); path traversal blocked by canonicalisation + root-prefix checks. | @@ -94,6 +95,39 @@ bashkit --mount-rw /host/out:/out -c 'echo hi > /out/f' # writable (dangerous) To freeze a session — including in-memory writes — wrap it with `readonly_filesystem()`. +## Composing a static namespace + +`NamespaceFs` creates an intentionally bounded path tree instead of a fallback +root plus live mounts. Each source may be rebased and independently read-only or +read-write: + +```rust +use bashkit::{Bash, FileSystem, InMemoryFs, NamespaceFs}; +use std::path::Path; +use std::sync::Arc; + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let repository = Arc::new(InMemoryFs::new()); +repository.mkdir(Path::new("/repo/src"), true).await?; +repository.write_file(Path::new("/repo/src/lib.rs"), b"source").await?; +let output = Arc::new(InMemoryFs::new()); + +let namespace = NamespaceFs::builder() + .mount_readonly_from("/src", repository, "/repo/src")? + .mount_readwrite("/build", output)? + .build(); +let mut bash = Bash::builder().fs(Arc::new(namespace)).build(); +assert_eq!(bash.exec("cat /src/lib.rs").await?.stdout, "source"); +# Ok(()) +# } +``` + +Nested targets use longest-prefix precedence. Missing ancestors and mount points +are visible as directories. Files and symlinks can be copied across mounts; +cross-mount rename reports a typed cross-device error because copy-delete is not +atomic. + ## Special files and symlinks - **`/dev/null`** is handled at the interpreter level (not the filesystem), so a @@ -117,5 +151,6 @@ readonly_filesystem: bool # deny all VFS mutations after s - [Security](security.md) — the boundaries built on top of the VFS. - [Live mounts](live-mounts.md) — attach and detach filesystems at runtime. +- [Filesystem namespaces](https://docs.rs/bashkit/latest/bashkit/namespace_filesystems_guide/) — compose and rebase static mount trees. - [Snapshotting](snapshotting.md) — serialise and restore VFS + shell state. - Spec: [`specs/vfs.md`](https://github.com/everruns/bashkit/blob/main/specs/vfs.md). diff --git a/specs/threat-model.md b/specs/threat-model.md index c70bf7c16..ceb2c5487 100644 --- a/specs/threat-model.md +++ b/specs/threat-model.md @@ -300,6 +300,7 @@ panicked. Resolved with `wrapping_*` ops, masked shift amounts, clamped exponent | TM-ESC-003 | Real FS access | Direct syscalls | No real FS by default; `RealFs` canonicalizes existing paths and nearest existing ancestors before attaching missing suffixes | **MITIGATED** | | TM-ESC-004 | Mount escape | Mount real paths | MountableFs controlled | **MITIGATED** | | TM-ESC-016 | Symlink escape via overlay rename | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks as symlinks | **FIXED** | +| TM-ESC-031 | Namespace source-root or policy escape | `..` selects a shorter mount, escapes a rebased source root, or bypasses a nested read-only mount | Normalize before longest-prefix selection; join only the stripped suffix; independently enforce both mutation endpoints | **MITIGATED** | | TM-FS-013 | Permissive RealFs mount default | `mount_real_readonly_at("/", …)` exposes whole host without `allowed_mount_paths` | Allowlist-first: `/`, `/etc`, `/root`, `/Users`, `/home`, `/dev`, `/proc`, `/sys`, `/run`, `/var/run`, `/boot`, `/private`, and any path component matching `.ssh`, `.aws`, `.kube`, `.docker`, `.gnupg`, `.gcloud` are refused unless explicitly allowlisted | **MITIGATED** | **Current Risk**: MEDIUM - Two open escape vectors (TM-ESC-012, TM-ESC-013) need remediation @@ -326,6 +327,14 @@ relaxation of symlink-following policy could expose the target. Fix: `rename`/`c symlinks via `stat()` and use `read_link()` + `symlink()` to preserve them. Same fix applied to `MountableFs` cross-mount operations. See `fs/overlay.rs` and `fs/mountable.rs`. +**TM-ESC-031**: `NamespaceFs` validates and normalizes each visible path before +mount selection. Rebasing joins only a component-stripped suffix beneath the +normalized source root. Nested mounts are selected after normalization, and +each mutating endpoint is checked independently. Regression tests: +`tm_esc_031_namespace_normalizes_without_source_or_nested_mount_escape`, +`tm_esc_031_namespace_readonly_mount_denies_every_mutation_without_bypass`, and +`tm_esc_031_namespace_rejects_invalid_paths_and_protects_namespace_nodes`. + #### 2.2 Process Escape | ID | Threat | Attack Vector | Mitigation | Status | diff --git a/specs/vfs.md b/specs/vfs.md index 9acb12484..9a143cafe 100644 --- a/specs/vfs.md +++ b/specs/vfs.md @@ -55,6 +55,20 @@ Do you need a custom filesystem? - Longest-prefix matching for nested mounts - Always used as outermost FS layer for live mount/unmount support +#### NamespaceFs +- Static visible tree composed from arbitrary `FileSystem` instances +- Builder supports absolute targets, source-root rebasing, and read-only or + read-write access per mount +- Longest target-prefix wins deterministically for nested mounts +- Missing ancestors and mount points are visible as synthetic directories; + `stat()` and `read_dir()` metadata agree through rebasing +- File and symlink copies may cross mounts when the destination is writable +- Cross-mount rename returns `ErrorKind::CrossesDevices` instead of non-atomic + copy-delete; cross-mount directory and FIFO copy is unsupported +- Visible paths are normalized before mount selection and source-root joining, + preventing traversal, source-root escape, nested-mount escape, and read-only bypass +- Object ownership defines lifetime; there is no command/session lifetime mode + #### ReadOnlyFs - Wraps another `FileSystem`, delegates read/stat/list, denies all mutations with `PermissionDenied` @@ -92,6 +106,9 @@ the interpreter. └──────────────────────────────────┘ ``` +`NamespaceFs` can be supplied as the base when callers need a bounded, +pre-composed tree. The usual outer `MountableFs` still enables later live mounts. + ### Special Device Files #### /dev/null