# The VFS wire protocol > **Status:** built and spoken today between `runtime.fs` (the client) and the > filesystem BACKENDS (the FAT server). The mount router lives in the > **kernel** (`system/kernel/vfs.zig`): `fs_resolve` routes a path and either > serves it directly (the read-only /system initrd mount, via `fs_node`) or > redirects the caller to the owning backend's endpoint plus the rewritten > mount-relative path — after which the client speaks THIS protocol to the > backend, unchanged. The Zig source of truth is `system/vfs-protocol.zig` > (the `vfs-protocol` module), whose unit tests pin the sizes and values > below. This page is the **language-neutral wire specification** of that > contract — what a Rust or C client implements ([vdso.md](vdso.md) explains > why the IPC protocols, not the syscall numbers, are danos's public ABI). ## Transport A VFS exchange is one synchronous IPC rendezvous (`ipc_call`, docs/ipc.md): the client sends one message and blocks; the server replies with one message. The endpoint comes from the kernel's `fs_resolve` — which also hands back the path rewritten relative to the mount — not from a registry lookup. (Service id 1, the old userspace router, is retired.) - A message is at most **256 bytes** (`message_maximum`). - A request is a fixed 32-byte **Request** header followed by an inline payload of at most **224 bytes** (`maximum_payload`) — a path, or write bytes. There is no multi-message request: paths and single reads/writes must fit, and larger transfers loop (see *read* / *write*). - A reply is a fixed 24-byte **Reply** header followed by an inline payload — read bytes, a `FileStatus`, or a `DirectoryEntry`. - All integers are **little-endian**; layouts are C layout for x86-64 (`extern struct`), offsets given below so nothing need be inferred. The kernel resolves NAMES (the mount table) but never parses these messages — it moves the bytes; file state is entirely the backend's affair. With clients holding backend node ids directly, a backend records each open handle's owner and sweeps a dead client's handles via the published process exit events. ## Request header — 32 bytes | offset | size | field | meaning | |-------:|-----:|-------|---------| | 0 | 4 | `operation` | an **Operation** value (below) | | 4 | 4 | — | padding | | 8 | 8 | `node` | the server-side open-node id from a prior `open`; 0 for path-based operations | | 16 | 8 | `offset` | byte position for read/write; entry index (cursor) for readdir; else 0 | | 24 | 4 | `len` | payload length for path/write operations; requested byte count for read | | 28 | 4 | `flags` | open flags (below); else 0 | ## Reply header — 24 bytes | offset | size | field | meaning | |-------:|-----:|-------|---------| | 0 | 4 | `status` | **0 = success**, negative = failure (signed) | | 4 | 4 | — | padding | | 8 | 8 | `node` | the new open-node id (for `open`); else 0 | | 16 | 4 | `len` | reply payload length in bytes | | 20 | 4 | — | padding | On failure the router replies `status = -1`; a mounted backend's negative status is forwarded to the client verbatim. A richer errno vocabulary is future work — clients must treat *any* negative status as failure, not match on -1. ## Operations Values are append-only and never renumbered (the same evolution rule every danos protocol follows); an unrecognised operation gets a `status = -1` reply. | value | operation | request payload | reply | |------:|-----------|-----------------|-------| | 0 | `open` | the path (`len` = its length), `flags` as below | `node` = open-node id | | 1 | `close` | — (`node` set) | status only | | 2 | `read` | — (`node`, `offset`, `len` = wanted count) | `len` bytes read, payload = the bytes; `len` 0 at end of file | | 3 | `write` | the bytes (`node`, `offset`, `len` = count) | `len` = bytes accepted (may be short — loop) | | 4 | `status` | — (`node` set) | payload = **FileStatus** (24 bytes) | | 5 | `readdir` | — (`node` = a directory, `offset` = cursor) | payload = one **DirectoryEntry** + name; `len` 0 at end | | 6 | `mount` | the mount-point path; the backend endpoint rides as the call's **capability** | status only | | 7 | `unmount` | the mount-point path | status only | | 8 | `mkdir` | the path | status only | | 9 | `unlink` | the path | status only | | 10 | `rename` | old path, one `0x00`, new path (`len` = total) | status only | Notes per operation: - **open** — paths are absolute (`/mnt/usb/notes.txt`) or bare names (`greeting`); bare names resolve in the VFS's flat ramfs, absolute paths route through the mount table (below). The returned `node` is an id in the *router's* open table; clients never see a backend's own ids. - **read / write** — a single exchange moves at most 224 bytes (`maximum_payload`); the client loops, advancing `offset` by the returned `len`, until done (read) or the slice is written (write). A `write` reply shorter than requested is progress, not an error; a `len` of 0 means no forward progress — stop rather than spin. - **readdir** — `offset` is a **cursor: the entry index**, not a byte position. Each call returns exactly one entry; the client increments the cursor by 1. A reply with `len` 0 is end-of-directory. The directory must have been opened with the `directory` flag. - **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount` syscall now (a filesystem server passes its endpoint handle; possession is the capability, exactly the trust of the old cap-passing op). The op numbers stay reserved. Mount-prefix semantics are unchanged: prefixes match at path boundaries only (`/mnt/usb` never captures `/mnt/usbextra`), the longest matching prefix wins, and an optional backend-side rewrite prefix maps a mount into the backend's namespace (fat serves `/mnt/usb` from its volume root and `/var` from its `/var` subtree). - **rename** — same-directory rename only (the router requires old and new to resolve under one mount). ## Open flags Bitwise OR in `Request.flags`, meaningful for `open` only: | bit | name | meaning | |----:|------|---------| | 1 | `create` | create the file if it does not exist | | 2 | `directory` | open a directory node for `readdir` rather than a file | | 4 | `truncate` | truncate an existing file to zero length on open (replace, don't overwrite in place) | ## FileStatus — 24 bytes (the `status` reply payload) | offset | size | field | meaning | |-------:|-----:|-------|---------| | 0 | 8 | `size` | file size in bytes | | 8 | 4 | `kind` | a **NodeKind** value | | 12 | 4 | — | padding | | 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none | ## DirectoryEntry — 16 bytes + name (the `readdir` reply payload) | offset | size | field | meaning | |-------:|-----:|-------|---------| | 0 | 4 | `kind` | a **NodeKind** value | | 4 | 4 | `name_len` | length of the name that follows | | 8 | 8 | `size` | the entry's size in bytes | | 16 | `name_len` | name | the entry's name, not NUL-terminated | ## NodeKind Aligned to the FSH file-type table (docs/danos-file-system-hierarchy-FSH.md): | value | kind | |------:|------| | 0 | regular file | | 1 | directory | | 2 | character device | | 3 | block device | | 4 | symbolic link | | 5 | fifo | | 6 | socket | Clients should map unknown values to *regular* rather than reject — the table can grow. ## Lifetimes and trust Open-node ids live in the server. A client that dies without closing leaks nothing permanently: the VFS subscribes to the kernel's published process-exit events (docs/process-lifecycle.md) and releases a dead client's handles, closing forwarded backend nodes best-effort. Ids are plain integers, not capabilities — the VFS trusts its callers with each other's ids today, which is acceptable while every client is part of the system image and worth revisiting (per-client id namespaces) before third-party binaries arrive. ## Evolution rules What a non-Zig implementation may rely on, and what it must not: - Operation values, flag bits, `NodeKind` values, and struct layouts are **append-only and frozen once shipped** — the unit tests in `protocol.zig` pin them exactly so a refactor can't silently move them. - The 256-byte message ceiling is a property of the current IPC transport, not a promise; clients should read `maximum_payload`-shaped limits from the reply lengths they actually get (loop-until-done), not hard-code 224. - Negative statuses beyond -1 will appear (an errno vocabulary); success is exactly 0.