Virtual Filesystem Design
July 31, 2026 · View on GitHub
Status
Implemented
Decision
Two-layer filesystem abstraction:
| Layer | Trait/Type | Responsibility |
|---|---|---|
| Backend | FsBackend | Raw storage operations (minimal contract) |
| POSIX | FileSystem / PosixFs | POSIX-like semantics enforcement |
FsBackend handles raw storage without enforcing POSIX semantics; wrap with
PosixFs for type-safe behavior. See crates/bashkit/src/fs/ for trait
definitions and implementations.
Which Trait Should I Implement?
Do you need a custom filesystem?
│
├─ NO → Use InMemoryFs (default with Bash::new())
│
└─ YES → Is your storage simple (key-value, database, cloud)?
│
├─ YES → Implement FsBackend + wrap with PosixFs
│ (POSIX checks are automatic, less code)
│
└─ NO → Implement FileSystem directly
(full control, you handle all checks)
| Approach | Implement | POSIX Checks | Best For |
|---|---|---|---|
FsBackend + PosixFs | Raw storage only | Automatic | Databases, cloud, key-value stores |
FileSystem directly | Everything | Manual | Complex caching, custom semantics |
Implementations
InMemoryFs
HashMap<PathBuf, FsEntry>, thread-safe viaRwLock; no persistence- Initial directories:
/,/tmp,/home,/home/user,/dev - Special handling for
/dev/null,/dev/urandom,/dev/random - Mount files at build time via
BashBuilder::mount_text()/mount_readonly_text()
OverlayFs
- Copy-on-write layer over another FileSystem, whiteout tracking for deletes
- Useful for: temp modifications, testing, isolation
MountableFs
- Mount multiple filesystems at different paths
- Longest-prefix matching for nested mounts
- Always used as outermost FS layer for live mount/unmount support
NamespaceFs
- Static visible tree composed from arbitrary
FileSysteminstances - 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()andread_dir()metadata agree through rebasing - File and symlink copies may cross mounts when the destination is writable
- Cross-mount rename returns
ErrorKind::CrossesDevicesinstead 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 withPermissionDenied - Useful for inspection-only tool sessions where even in-memory writes to
/tmp, redirections,cp,mv,mkdir,rm, andchmodmust fail
RealFs (Optional, realfs feature)
- Direct access to a host directory as an
FsBackend - Two modes:
ReadOnly(safe) andReadWrite(dangerous) - Async backend operations use Tokio filesystem APIs and never perform synchronous host filesystem I/O on the runtime worker
RealFs::openis the async-safe constructor and preserves root validation and canonicalization semantics without blocking a current-thread runtime- The synchronous
RealFs::newconstructor is deprecated and retained only as a migration shim - Path traversal prevented via canonicalization + root prefix check
- New-path writes canonicalize the nearest existing ancestor before attaching a missing suffix, blocking symlink escapes through non-existent subpaths
- Builder:
mount_real_readonly[_at](),mount_real_readwrite(); CLI:--mount-ro/--mount-rw(host:vfssyntax for mount point)
Live Mount/Unmount
Every Bash instance wraps its filesystem stack in a MountableFs, enabling
post-build bash.mount(path, fs) / bash.unmount(path) without rebuilding
the interpreter.
FS Layering Stack
┌──────────────────────────────────┐
│ MountableFs (live mounts) │ ← Bash::mount() / unmount()
├──────────────────────────────────┤
│ ReadOnlyFs (optional) │ ← BashBuilder::readonly_filesystem()
├──────────────────────────────────┤
│ OverlayFs (text mounts) │ ← BashBuilder::mount_text()
├──────────────────────────────────┤
│ MountableFs (real mounts) │ ← BashBuilder::mount_real_*_at()
├──────────────────────────────────┤
│ Base filesystem │ ← InMemoryFs or custom
└──────────────────────────────────┘
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
Handled at the interpreter level, not filesystem. Security-critical: custom
filesystem implementations cannot intercept /dev/null behavior. Path
normalization handles bypass attempts.
/dev/urandom and /dev/random
Handled at filesystem level: return 8192 bytes of random data per read (bounded to prevent memory growth).
File Size Reporting
Metadata.size must be correct for ls -l, stat, test -s:
- Regular files: actual content length
- Empty files: 0
- Directories: always 0
- Both
stat()andread_dir()must return consistent sizes
POSIX Semantics Contract
All FileSystem implementations MUST enforce:
- No duplicate names (file and dir can't share path)
- Type-safe operations (
write_fileon dir → error) - Parent directory requirement (exception:
mkdir -p)
Symlink Handling
Symlinks are stored but intentionally not followed for security:
- Prevents symlink escape attacks (TM-ESC-002)
- Prevents symlink loop DoS (TM-DOS-011)
Binding API Parity
All language bindings must expose the same filesystem concepts:
files: { "/path": "content" } # text files (writable, in-memory)
mounts: [{ host_path, vfs_path?, writable? }] # real FS (read-only by default)
readonly_filesystem: bool # deny all VFS mutations after setup
FileSystem() # standalone in-memory filesystem
FileSystem.real(host_path, writable=false) # standalone real filesystem
# JS requires allowed_mount_paths
Runtime methods:
- host-path mount:
mount(host_path, vfs_path, writable=false) - filesystem mount:
mount(vfs_path, filesystem) unmount(vfs_path)
Native-extension interop is binding-specific but must preserve bashkit-owned filesystem objects when crossing the language runtime boundary:
- Python:
FileSystem.from_capsule(capsule),FileSystem.to_capsule() - Node.js:
FileSystem.fromExternal(external),FileSystem.toExternal()
Interop contract:
- The native Rust contract lives at
bashkit::interop::fsbehind theinteropcargo feature - The cross-addon payload must be a versioned
repr(C)handle + vtable - Do not expose
Arc<dyn FileSystem>or any addon-private Rust layout - Python capsules carry the stable owned handle directly
- Node interop values carry stable handle bytes plus an owner token
- On import, bashkit reconstructs a binding-owned
FileSystemwrapper from the stable handle payload
Safety: real mounts are read-only by default. Text files are writable
(sandboxed) unless the final session is wrapped with readonly_filesystem.
Alternatives Considered
- Real filesystem with chroot: rejected — requires root, not portable, no WASM.
- tokio::fs wrapper: rejected — always hits real FS, can't isolate or virtualize.
See also
- Bashkit Architecture — how the VFS is owned and shared
- Threat Model — path-escape threats the sandbox invariants answer
- Git Support — Git operations layered on the VFS
- SQLite Builtin — VfsIO backend bridging SQLite onto the VFS
- Python Package — binding-side mount API parity