Virtual Filesystem Design

July 31, 2026 · View on GitHub

Status

Implemented

Decision

Two-layer filesystem abstraction:

LayerTrait/TypeResponsibility
BackendFsBackendRaw storage operations (minimal contract)
POSIXFileSystem / PosixFsPOSIX-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)
ApproachImplementPOSIX ChecksBest For
FsBackend + PosixFsRaw storage onlyAutomaticDatabases, cloud, key-value stores
FileSystem directlyEverythingManualComplex caching, custom semantics

Implementations

InMemoryFs

  • HashMap<PathBuf, FsEntry>, thread-safe via RwLock; 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 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
  • Useful for inspection-only tool sessions where even in-memory writes to /tmp, redirections, cp, mv, mkdir, rm, and chmod must fail

RealFs (Optional, realfs feature)

  • Direct access to a host directory as an FsBackend
  • Two modes: ReadOnly (safe) and ReadWrite (dangerous)
  • Async backend operations use Tokio filesystem APIs and never perform synchronous host filesystem I/O on the runtime worker
  • RealFs::open is the async-safe constructor and preserves root validation and canonicalization semantics without blocking a current-thread runtime
  • The synchronous RealFs::new constructor 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:vfs syntax 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() and read_dir() must return consistent sizes

POSIX Semantics Contract

All FileSystem implementations MUST enforce:

  1. No duplicate names (file and dir can't share path)
  2. Type-safe operations (write_file on dir → error)
  3. Parent directory requirement (exception: mkdir -p)

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::fs behind the interop cargo 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 FileSystem wrapper 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