Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/bashkit/docs/live_mounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions crates/bashkit/docs/namespace_filesystems.md
Original file line number Diff line number Diff line change
@@ -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<dyn FileSystem>`. 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).
4 changes: 3 additions & 1 deletion crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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};
Expand Down
44 changes: 44 additions & 0 deletions crates/bashkit/examples/namespace_rebase.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
42 changes: 42 additions & 0 deletions crates/bashkit/examples/namespace_sandbox.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
3 changes: 3 additions & 0 deletions crates/bashkit/src/fs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
//!
Expand Down Expand Up @@ -400,6 +401,7 @@ mod backend;
mod limits;
mod memory;
mod mountable;
mod namespace;
mod overlay;
mod posix;
mod readonly;
Expand All @@ -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;
Expand Down
Loading