# VFS
Deft's **virtual filesystem layer** sits behind the `fs/` namespace and
lets ordinary file operations route to different backends — another
directory, a remote host over ssh — based on the *leading path
segment* of the path. It is modelled on TCL's VFS, adapted to Deft's
conventions: mounts are declared as data (in the prelude, like
everything else project-level), and call sites stay ordinary Deft
strings.
[vfs/mount "prod" %{ :kind :ssh :host "10.0.0.5" :root "/srv/app" }] [fs/read "prod/etc/hostname"] # read /srv/app/etc/hostname on 10.0.0.5 [fs/write "prod/config.toml" "..."] [fs/list "prod"] # bare mount name = the mount root [fs/read "/etc/hostname"] # no mount named → native, as always
There is no new path syntax to learn: a path whose first segment
matches a mount name routes to that mount's driver; every other path
behaves exactly as before.
## Declaring Mounts
### In the prelude
The `:mounts` map in `.deft/prelude.dft` is the idiomatic home for
mounts. **Value type decides** what a mount means:
- **string value** — a classic import mount: `import "/..."`
resolves through the directory. Never intercepted by `fs/`.
- **map or `%Local{}`/`%Ssh{}` value** — a VFS mount: `fs/*` paths
under the name route to its driver. Never an import mount.
%Prelude{
:mounts %{
:scripts "scripts" # import mount
:docs %Local{ :root "/usr/share/doc" } # VFS: local dir
:prod %{ :kind :ssh :host "10.0.0.5" :root "/srv/app"
:user "root" :port 22 } } }
Prelude mounts are seeded at startup into a process-global table, so
every app thread sees them — including child runtimes that skip the
prelude load.
### At runtime
[vfs/mount "prod" %{ :kind :ssh :host "10.0.0.5" :root "/srv/app" }] [vfs/unmount "prod"] [vfs/list] # → %{ :prod %{ :kind "ssh" :host ... :root ... } }
`vfs/mount` publishes on the `"vfs"` pub/sub topic
(`%{ :name :value :old }` under `:data`), mirroring the `config/*`
reactivity pattern. Runtime mounts additionally write through to the
shared config registry under `vfs.mount.` for visibility
tooling.
## Mount Specs
| Field | Local | ssh | |
|---|---|---|---|
| `:kind` | `:local` | `:ssh` | required (or use `%Local{}`/`%Ssh{}`) |
| `:root` | local anchor dir | remote anchor dir | required for local |
| `:host` | — | hostname or `user@host` | required for ssh |
| `:user` | — | ssh user | optional |
| `:port` | — | port (default 22) | optional |
| `:identity` | — | key file | optional |
| `:jump` | — | jump-host (ssh `-J`) | optional |
| `:options` | — | extra `-o` options, `@{"Opt=val"}` | optional |
## Path Semantics
- **Longest prefix wins**: with mounts `deft` and `deft/ui`, the path
`deft/ui/button.dft` routes to `deft/ui`; `deft/other/x` routes to
`deft`.
- **Exact segments**: `products/x` does *not* match a mount named
`prod`.
- **A mount is a chroot**: `..` cannot escape — `prod/../../etc`
resolves *inside* the mount root.
- **Bare mount names are the root**: `fs/list "prod"` lists the mount
root itself.
- **Predicates follow**: `fs/absolute?` and `fs/abs` treat
mount-anchored paths as fully qualified (`fs/abs` is the identity);
`fs/dir`, `fs/base`, `fs/ext`, `fs/join` operate on the virtual
string as usual.
## What Routes and What Doesn't
All whole-file `fs/*` verbs route through mounts: `fs/read`,
`fs/write`, `fs/append`, `fs/read-lines`, `fs/write-lines`,
`fs/stat`, `fs/list`, `fs/readdir`, `fs/glob`, `fs/exists?`,
`fs/file?`, `fs/dir?`, `fs/mkdir`, `fs/remove`, `fs/rename` (same
mount), `fs/copy` (across mounts too — it composes read+write).
Clean `%Err` on mounts (v1):
- `fs/watch` — inotify is local-only; remote watching needs polling.
- `fs/link` — symlinks don't map to remote semantics.
- `fs/glob` with `:recurse` — no driver walk yet.
- `fs/rename` **across** two different mounts.
Host-scoped by design (never virtual): `fs/tempdir`, `fs/home`,
`fs/tmp-dir`, `fs/tmp-path`, `fs/local-path`, `fs/local-dir`. The
`sqlite/*` namespace reads through its own vendored engine and does
not route through mounts — materialize a remote file to a local temp
path first if you need to open a remote database.
## The ssh Backend
The `:ssh` backend shells out to the system `ssh` client, one process
per operation. Everything you already know about ssh applies for free:
keys and agent auth, `~/.ssh/config` aliases, `known_hosts`, jump
hosts, `ControlMaster` connection reuse.
Defaults `-o BatchMode=yes -o ConnectTimeout=10` are applied *before*
`:options`, so a mount can override either (ssh honours the last `-o`).
BatchMode means keys or agent only — an interactive password prompt
would fail fast rather than hang a script, which is the right behavior
for a headless runtime.
The remote side needs nothing but a POSIX shell with GNU or BusyBox
`stat` and `ls` (any typical Linux or BSD userland). File content
travels on `cat`'s stdin/stdout — no temp files — and remote paths are
shell-quoted, so spaces and metacharacters are safe.
Because every operation spawns a process, per-call latency is
ssh-round-trip scale. Fine for config files, logs, and deploy
scripts; a persistent sftp session is the planned upgrade behind the
same interface.
## Sandbox Note
An ssh mount needs subprocess and network access, which the Landlock
sandbox (`DEFT_SANDBOX=1`) denies. The prelude loader warns when an
ssh mount is declared in a sandboxed session.
## Examples
### Deploy script
[vfs/mount "prod" %{ :kind :ssh :host "prod.example" :root "/srv/app" }] def local-hash [fs/read "build/SHA"] def remote-hash [fs/read "prod/SHA"] if ($local-hash != $remote-hash) { [fs/write "prod/SHA" $local-hash] [fs/copy "build/app.tar.gz" "prod/app.tar.gz"] }
### Reading a doc directory under a short name
[vfs/mount "docs" %Local{ :root "/usr/share/doc" }] [fs/glob "docs" "*.md"]
## See Also
- [Prelude](prelude) — `:mounts`, where declarative mounts live
- [Config](config) — the registry pattern `vfs/*` reactivity follows
- [Sandbox](sandbox) — Landlock interplay
- Reference: `vfs/mount`, `vfs/unmount`, `vfs/list`