summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p.zig4350
-rw-r--r--src/detached/server.zig155
-rw-r--r--src/fs9_service.zig496
-rw-r--r--src/main.zig33
-rw-r--r--src/pardes.zig17
5 files changed, 5044 insertions, 7 deletions
diff --git a/src/9p.zig b/src/9p.zig
new file mode 100644
index 00000000..8f78ed6b
--- /dev/null
+++ b/src/9p.zig
@@ -0,0 +1,4350 @@
+//! BASE 9P2000, ON THE WIRE AND NOTHING ELSE: the twenty-seven message types
+//! of the original protocol, encoded into a caller's buffer and decoded back
+//! out of one, with no allocator, no descriptor and no opinion about what any
+//! message means.
+//!
+//! WHY A SECOND CODEC in a tree that already has `src/detached/wire.zig`. That
+//! one is ours on both ends and can be renumbered by editing one file. This one
+//! is somebody else's: plan9port's `9p` command, Plan 9's own `mount`, Linux's
+//! v9fs and `ad` will all be talking to it, and not one of them will be
+//! rebuilt to suit us. So every number below is copied from a primary source
+//! with the file and line named, and the tests at the bottom assert LITERAL
+//! BYTES against `u9fs/convS2M.c` rather than only round-tripping — a codec
+//! that agrees with itself has proved nothing about interoperability.
+//!
+//! WHAT THIS DELIBERATELY IS NOT. There are three dialects; this is the first.
+//! * 9P2000.u adds a numeric errno to `Rerror`, `n_uid`/`extension` to
+//! `stat`, and Unix-flavoured `Tcreate`. We do not serve it. The tree is
+//! acme's and it is INVENTED — every error in it is one we chose the
+//! wording of, so a string is the whole error ABI and a number beside it
+//! would be a second spelling of a decision we already made
+//! (docs/registry.typ `9P-4`). It also costs a second dialect inside every
+//! one of the parsers below, because `.u` changes the LAYOUT of `Rerror`
+//! and `stat` rather than adding messages.
+//! * 9P2000.L replaces most of the protocol: `Tstatfs`, `Tlopen`, `Tgetattr`,
+//! `Tsymlink`, `Trename`, thirty-odd types and a POSIX file model. Our tree
+//! has no symlinks, no hard links, no device nodes and no block counts to
+//! report, so there is nothing on the other side of those messages to
+//! answer them with.
+//! * `Tsession`/`Tattach`-with-auth-blob from the 9P1 era, which u9fs still
+//! carries commented out (`convS2M.c:60-65,140-148`) and which no client
+//! built this century sends.
+//!
+//! TWO FACTS A READER MUST NOT GET WRONG, because both are silent when wrong:
+//!
+//! 1. `size[4]` INCLUDES ITSELF. `convS2M.c:216-224` computes `size` from
+//! `sizeS2M`, whose first line is `n += BIT32SZ; /* size */`, and then
+//! writes that number into the first four bytes. A reader that treats it
+//! as a payload length is four bytes out of step on every message and
+//! resynchronises never.
+//!
+//! 2. A `stat` HAS TWO LENGTHS IN FRONT OF IT. The record itself begins with
+//! `size[2]` which counts everything AFTER itself — `convD2M.c:48-51`,
+//! «note that length excludes count field itself», `PBIT16(p, ss-BIT16SZ)`
+//! — and `Rstat`/`Twstat` then wrap the whole record in ANOTHER `[2]`
+//! count, which is why Linux reads `Rstat` with the format string `"wS"`
+//! and throws the first `w` away into a variable literally named `ignored`
+//! (`linux/net/9p/client.c:1617,1633`), and writes `Twstat` as `"dwS"`
+//! (`client.c:1776`). So the outer count is `Stat.size() + 2`, never
+//! `Stat.size()`. `Stat` below owns both numbers and the test
+//! "9p: the stat double length" is the one that would catch it.
+//!
+//! FREESTANDING. No libc, no OS, no allocator, no threads: this file imports
+//! `std` for `mem.readInt`/`writeInt` and `debug.assert` and nothing more, so
+//! it compiles for `wasm32-freestanding` and for the board's
+//! `riscv32-freestanding` exactly as `wire.zig` does. Every integer on the wire
+//! has an explicit width and is little-endian; no `usize` reaches it, and no
+//! Zig struct is ever `@bitCast` onto it. Decoding BORROWS: every `[]const u8`
+//! in a decoded `Msg` points into the caller's buffer, which stays alive until
+//! the reply is written.
+//!
+//! MALFORMED INPUT IS REFUSED. This parser is fed by a socket, and a message
+//! misread rather than refused is an out-of-bounds index. Nothing below indexes
+//! without first checking; `Error` names every way a stream can be wrong.
+//!
+//! THE SERVER HALF IS APPENDED TO THIS FILE, the way `fuse.zig` keeps its wire
+//! structs and its transport together: a fid table, the dispatch onto
+//! `acmefs.zig`'s nine operations, and the msize handshake. Codec first, then
+//! the seam. Keeping them in one file is what makes it possible to change a
+//! layout and its only caller in one diff.
+//!
+//! Verified against `u9fs` (`fcall.h`, `convS2M.c`, `convM2S.c`, `convD2M.c`,
+//! `convM2D.c`), `linux/net/9p/{protocol,client}.c`, and
+//! `ad/crates/ninep/src/sansio/protocol.rs`.
+const std = @import("std");
+const assert = std.debug.assert;
+
+pub const Error = error{
+ /// The message ended inside a field, or `size` claims more bytes than the
+ /// caller handed over. Both are "not all of it has arrived", which is what
+ /// a stream reader wants to hear: buffer more and ask again.
+ Truncated,
+ /// A count larger than this protocol admits: `nwname > MAXWELEM`, a string
+ /// past 64 KiB, a `stat` past 64 KiB. Refused before anything is indexed.
+ Overlong,
+ /// A type byte 9P2000 does not define, or defines as illegal (`Terror`).
+ BadTag,
+ /// A field carrying a value it cannot mean — a `size` smaller than a
+ /// header, which is a number no encoder can have produced.
+ BadValue,
+ /// The message was decoded and bytes were left over, either inside `size`
+ /// or after it. A message that says more than its layout has room for is
+ /// not this message.
+ Trailing,
+ /// The encoder ran out of caller-supplied buffer. Nothing was written.
+ NoSpace,
+};
+
+// ---------------------------------------------------------------------------
+// message types
+// ---------------------------------------------------------------------------
+
+/// The type byte. Numbers from `u9fs/fcall.h:74-105`, which is the definitive
+/// list: `Tversion = 100` and every name after it takes the next value, so the
+/// gap at 106 is load-bearing and the enum below spells it rather than skipping
+/// it silently.
+///
+/// Non-exhaustive for the same reason `fuse.zig`'s `Opcode` is: `@enumFromInt`
+/// of an unlisted value into an exhaustive enum is undefined behaviour, which
+/// is the one bug in a protocol decoder that cannot be diagnosed from outside.
+/// A `.u` or `.L` client will hand us `Tstatfs = 8` or `Tlopen = 12`; that must
+/// arrive as a value we can refuse (`decode` returns `error.BadTag`) rather
+/// than as UB.
+///
+/// TWENTY-SEVEN REAL TYPES: thirteen T/R pairs, plus `Rerror`, which is a reply
+/// with no request. `Terror = 106` is the twenty-eighth number and is defined
+/// as illegal by the protocol — a client cannot ask for an error — so it is
+/// listed to keep the numbering honest and refused by name in `decode`.
+pub const Type = enum(u8) {
+ tversion = 100,
+ rversion = 101,
+ tauth = 102,
+ rauth = 103,
+ tattach = 104,
+ rattach = 105,
+ /// «Terror = 106, /* illegal */» — `fcall.h:82`. Never sent, never
+ /// accepted; here so that nobody re-derives 107 for `Rerror` by counting.
+ terror = 106,
+ rerror = 107,
+ tflush = 108,
+ rflush = 109,
+ twalk = 110,
+ rwalk = 111,
+ topen = 112,
+ ropen = 113,
+ tcreate = 114,
+ rcreate = 115,
+ tread = 116,
+ rread = 117,
+ twrite = 118,
+ rwrite = 119,
+ tclunk = 120,
+ rclunk = 121,
+ tremove = 122,
+ rremove = 123,
+ tstat = 124,
+ rstat = 125,
+ twstat = 126,
+ rwstat = 127,
+ _,
+};
+
+/// T-messages are EVEN, R-messages are ODD, all the way from `Tversion = 100`
+/// to `Rwstat = 127` (`fcall.h:74-105`), because the enum assigns each T an
+/// even number and lets its R take the next. So one byte tells a reader which
+/// direction a message is travelling, which makes a stream carrying both
+/// SELF-DEMUXING: `drawterm`'s single descriptor has requests going one way and
+/// replies coming back on it, and the parity alone separates them.
+///
+/// PaRDeS does not rely on that today — a connection has one role per side
+/// (docs/9p.typ §"Layering"), so a server only ever reads T and a client only
+/// ever reads R, and each refuses the other by name. This is here because the
+/// ENCODING GUARANTEES it and a future 9P-inside-the-wire arrangement over the
+/// board's UART would want it, and because a hand-typed number that breaks the
+/// parity is a bug the test at the bottom catches for free.
+pub fn isT(t: Type) bool {
+ return @intFromEnum(t) % 2 == 0;
+}
+
+// ---------------------------------------------------------------------------
+// constants
+// ---------------------------------------------------------------------------
+
+/// `size[4] type[1] tag[2]`, and `size` counts these seven bytes too. Public
+/// because the server half sizes its reply payloads against it: the largest
+/// `Rread` that fits an msize is `msize - header_len - 4`.
+pub const header_len: usize = 4 + 1 + 2;
+
+/// `QIDSZ` — `fcall.h:64`, `BIT8SZ+BIT32SZ+BIT64SZ`.
+pub const qid_len: usize = 1 + 4 + 8;
+
+/// `STATFIXLEN` — `fcall.h:66-68`. The fixed part of a `stat` INCLUDING its own
+/// leading `size[2]` and the four string count prefixes, excluding the string
+/// bytes. `BIT16SZ + QIDSZ + 5*BIT16SZ + 4*BIT32SZ + BIT64SZ` = 49.
+pub const stat_fixed: usize = 2 + qid_len + 5 * 2 + 4 * 4 + 8;
+
+/// `NOTAG` — `fcall.h:71`. The tag on `Tversion`/`Rversion`, which is the one
+/// exchange that happens before tags mean anything. Note that `fcall.h` writes
+/// it `~0U` and the wire field is two bytes, so it is 0xFFFF and not 0xFFFFFFFF.
+pub const notag: u16 = 0xFFFF;
+
+/// `NOFID` — `fcall.h:121-123`. `Tattach.afid` when no authentication fid was
+/// established, which is our only use of it: we serve `Tauth` a refusal.
+pub const nofid: u32 = 0xFFFF_FFFF;
+
+/// `MAXWELEM` — `fcall.h:2`. The most path elements one `Twalk` may carry, and
+/// a hard protocol bound rather than a buffer size: `convS2M.c:279-280` and
+/// `convM2S.c:170-171` both return failure above it, so a 17-element walk is
+/// refused by every implementation and must be split by the client.
+pub const max_welem: usize = 16;
+
+/// The smallest msize we may agree to. NOT from the protocol — 9P has no floor,
+/// and Plan 9's devmnt, plan9port's `9p` and our own client all accept 512
+/// (docs/registry.typ, `linux/net/9p/client.c:840-843`). This number exists
+/// because the LINUX KERNEL refuses to mount below it, and a mount that fails
+/// with `EINVAL` and no message is the worst diagnostic in the set.
+pub const min_msize: u32 = 4096;
+
+/// `IOHDRSZ` — `fcall.h:72`, «ample room for Twrite/Rread header (iounit)».
+/// The real `Rread` header is 11 bytes (`size[4] type[1] tag[2] count[4]`) and
+/// `Twrite`'s is 23; 24 is the slack both ends have agreed to reserve for
+/// thirty years, and `iounit` is quoted to clients as `msize - iohdrsz`.
+pub const iohdrsz: u32 = 24;
+
+/// `ERRMAX` — Plan 9's `libc.h:146`. The buffer a Plan 9 client has for an
+/// error string. ADVISORY here: `decode` does not refuse a longer `Rerror`,
+/// because refusing a peer's error message is the least useful moment to
+/// discover a length limit. The server half truncates its own to this.
+pub const errmax: usize = 128;
+
+// Qid type bits — `u9fs/plan9.h:156-161`, cross-checked against
+// `linux/include/net/9p/9p.h:344-352` which adds QTTMP = 0x04. These are the
+// top five bits of `Stat.mode` shifted down 24; see `dmdir` below.
+pub const qtdir: u8 = 0x80;
+pub const qtappend: u8 = 0x40;
+pub const qtexcl: u8 = 0x20;
+/// 0x10 is `QTMOUNT`, a mounted channel — a thing only a Plan 9 kernel has, and
+/// the reason the mode bits below have a gap at bit 28.
+pub const qtmount: u8 = 0x10;
+pub const qtauth: u8 = 0x08;
+pub const qttmp: u8 = 0x04;
+/// «plain file» — `plan9.h:161`. Zero, so a `Qid.type` of 0 is not "unset".
+pub const qtfile: u8 = 0x00;
+
+// Mode bits — `u9fs/plan9.h:164-170`, with DMAUTH and DMTMP from
+// `ad/crates/ninep/src/sansio/protocol.rs:486-495`: «bit 27 (DMAUTH) ... bit 26
+// (DMTMP) ... (Bit 28 is skipped for historical reasons)». That skipped bit is
+// `DMMOUNT`, which is why the top five type bits are not the top five mode
+// bits: they are DMDIR, DMAPPEND, DMEXCL, (gap), DMAUTH, DMTMP reproduced from
+// the top down into `Qid.type` as QTDIR, QTAPPEND, QTEXCL, QTAUTH, QTTMP.
+pub const dmdir: u32 = 0x8000_0000;
+pub const dmappend: u32 = 0x4000_0000;
+pub const dmexcl: u32 = 0x2000_0000;
+pub const dmmount: u32 = 0x1000_0000;
+pub const dmauth: u32 = 0x0800_0000;
+pub const dmtmp: u32 = 0x0400_0000;
+/// The rwx triples, and the ONLY part of `mode` that is a Unix permission. A
+/// server that hands the high bits to `chmod`, or a client that hands the low
+/// nine to a type test, has confused the two halves of one word.
+pub const dmperm: u32 = 0o777;
+
+comptime {
+ // The three widths every offset below is derived from. A drifted number
+ // here is a codec that agrees with nothing, so make it a compile error.
+ assert(header_len == 7);
+ assert(qid_len == 13);
+ assert(stat_fixed == 49);
+ // Parity is the protocol's, not a convention we maintain by hand.
+ for (std.enums.values(Type)) |t| {
+ const even = @intFromEnum(t) % 2 == 0;
+ assert(isT(t) == even);
+ assert(std.mem.startsWith(u8, @tagName(t), if (even) "t" else "r"));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// qid
+// ---------------------------------------------------------------------------
+
+/// The server's name for a file: `type[1] version[4] path[8]`, thirteen bytes,
+/// `convS2M.c:18-29`. Two files are the same file if and only if their qids
+/// are equal, which is the whole contract — `path` identifies the file for the
+/// life of the connection and `version` changes on every write, so a client
+/// caches against the pair and never against a pathname.
+pub const Qid = struct {
+ /// `qt*` bits. The high bits of `Stat.mode` shifted down 24.
+ type: u8,
+ version: u32,
+ path: u64,
+
+ /// Writes thirteen bytes and returns them. Takes the whole buffer and
+ /// returns the used slice, so a caller can chain without arithmetic.
+ pub fn encode(self: Qid, buf: []u8) Error![]u8 {
+ if (buf.len < qid_len) return error.NoSpace;
+ buf[0] = self.type;
+ std.mem.writeInt(u32, buf[1..5], self.version, .little);
+ std.mem.writeInt(u64, buf[5..13], self.path, .little);
+ return buf[0..qid_len];
+ }
+
+ /// Reads thirteen bytes. Refuses a shorter buffer rather than reading one:
+ /// `gqid` in `convM2S.c:26-37` returns nil for exactly this case.
+ pub fn decode(bytes: []const u8) Error!Qid {
+ if (bytes.len < qid_len) return error.Truncated;
+ return .{
+ .type = bytes[0],
+ .version = std.mem.readInt(u32, bytes[1..5], .little),
+ .path = std.mem.readInt(u64, bytes[5..13], .little),
+ };
+ }
+};
+
+// ---------------------------------------------------------------------------
+// stat
+// ---------------------------------------------------------------------------
+
+/// One directory entry, and the payload of `Rstat` and `Twstat`. Layout from
+/// `convD2M.c:56-83`:
+///
+/// ```
+/// size[2] type[2] dev[4] qid[13] mode[4] atime[4] mtime[4] length[8]
+/// name[s] uid[s] gid[s] muid[s]
+/// ```
+///
+/// where `[s]` is `n[2]` plus n bytes of UTF-8, NOT NUL-terminated. `size`
+/// counts everything after itself, so the record occupies `size() + 2` bytes;
+/// see the module header for why that matters twice over.
+///
+/// `type` and `dev` are Plan 9 kernel device identifiers and are meaningless
+/// off Plan 9 — u9fs sends zeros and so do we, but they are on the wire because
+/// the layout is fixed. `muid` is the uid of the last modifier; for a synthetic
+/// tree it is whoever attached.
+///
+/// A `Twstat` uses the sentinel "don't touch" values that acme(4) and
+/// `stat(5)` specify: an empty string, an all-ones integer. Nothing here
+/// interprets them; that is the server half's job.
+pub const Stat = struct {
+ type: u16,
+ dev: u32,
+ qid: Qid,
+ mode: u32,
+ atime: u32,
+ mtime: u32,
+ length: u64,
+ name: []const u8,
+ uid: []const u8,
+ gid: []const u8,
+ muid: []const u8,
+
+ /// The value that goes in the leading `size[2]`: every byte of the record
+ /// EXCEPT those two. `convD2M.c:46-50` computes `ss = STATFIXLEN + ns` and
+ /// then writes `ss - BIT16SZ`, so this is `stat_fixed - 2` plus the four
+ /// string bodies. Written out field by field rather than as 47, because a
+ /// number nobody can check against a layout is a comment that rots.
+ ///
+ /// Fallible: the prefix is two bytes, so a record whose strings do not fit
+ /// a u16 has no legal encoding and must be refused rather than wrapped.
+ pub fn size(self: Stat) Error!u16 {
+ const n =
+ 2 + // type
+ 4 + // dev
+ qid_len + // qid: type[1] version[4] path[8]
+ 4 + // mode
+ 4 + // atime
+ 4 + // mtime
+ 8 + // length
+ 2 + self.name.len +
+ 2 + self.uid.len +
+ 2 + self.gid.len +
+ 2 + self.muid.len;
+ assert(n >= stat_fixed - 2);
+ if (n > std.math.maxInt(u16)) return error.Overlong;
+ return @intCast(n);
+ }
+
+ /// Writes `size[2]` and the record, and returns the `size() + 2` bytes of
+ /// it. `assert` at the end is `convD2M.c:85-86`'s `if(ss != p - buf)`: the
+ /// two arithmetics are written separately and must agree.
+ pub fn encode(self: Stat, buf: []u8) Error![]u8 {
+ const n = try self.size();
+ const total = @as(usize, n) + 2;
+ if (buf.len < total) return error.NoSpace;
+ var w: Writer = .init(buf[0..total]);
+ try w.putU16(n);
+ try w.putU16(self.type);
+ try w.putU32(self.dev);
+ try w.putQid(self.qid);
+ try w.putU32(self.mode);
+ try w.putU32(self.atime);
+ try w.putU32(self.mtime);
+ try w.putU64(self.length);
+ try w.putString(self.name);
+ try w.putString(self.uid);
+ try w.putString(self.gid);
+ try w.putString(self.muid);
+ assert(w.n == total);
+ return buf[0..total];
+ }
+
+ /// Decodes exactly one record from `bytes`, which must be the whole of it —
+ /// prefix included — and nothing more. The strings BORROW from `bytes`.
+ ///
+ /// The equality check on the prefix is the second half of `statcheck`
+ /// (`convM2D.c:14-22`: walk the four counts, then `if(buf != ebuf) return
+ /// -1`). It is what makes the double length safe: the caller has already
+ /// bounded `bytes` by the OUTER count, so demanding that the INNER count
+ /// agree refuses the classic off-by-two in both directions instead of
+ /// trusting whichever one the sender got right.
+ pub fn decode(bytes: []const u8) Error!Stat {
+ var r: Reader = .init(bytes);
+ const n = try r.getU16();
+ const body = bytes.len - 2;
+ if (n > body) return error.Truncated;
+ if (n < body) return error.Trailing;
+ const self: Stat = .{
+ .type = try r.getU16(),
+ .dev = try r.getU32(),
+ .qid = try r.getQid(),
+ .mode = try r.getU32(),
+ .atime = try r.getU32(),
+ .mtime = try r.getU32(),
+ .length = try r.getU64(),
+ .name = try r.getString(),
+ .uid = try r.getString(),
+ .gid = try r.getString(),
+ .muid = try r.getString(),
+ };
+ try r.end();
+ return self;
+ }
+};
+
+// ---------------------------------------------------------------------------
+// messages
+// ---------------------------------------------------------------------------
+
+/// Every message base 9P2000 defines, with the fields it actually carries.
+/// Layouts from `convS2M.c:231-419` and `convM2S.c:73-375`, which are the two
+/// halves of the same table and disagree nowhere.
+///
+/// The tag names are the type names, so `msgType` is a mechanical mapping and
+/// not a table somebody maintains; a variant added here without a `Type` is a
+/// compile error.
+///
+/// NOT IN HERE: the message tag. A `Msg` is a message's CONTENT, and the tag is
+/// the transport's matching of a reply to a request — it is a parameter of
+/// `encode` and a field of `Decoded`. Putting it in the union would mean every
+/// server handler that builds a reply has to remember to copy it.
+///
+/// `Twalk` is the large variant at sixteen slices, so `Msg` is around 280 bytes
+/// on a 64-bit host. That is a value passed by const pointer in practice and it
+/// buys the thing that matters: a walk decodes with no allocator and no bound
+/// the caller has to have guessed.
+pub const Msg = union(enum) {
+ /// The first exchange, tagged `notag`. `msize` is the largest message
+ /// either end will send, INCLUDING the seven-byte header; `version` is
+ /// "9P2000" or a string starting with it.
+ tversion: struct { msize: u32, version: []const u8 },
+ /// The server's answer: `msize` no larger than the client's, and `version`
+ /// either "9P2000" or the literal "unknown" — which is a successful reply
+ /// meaning "no dialect in common", not an `Rerror`.
+ rversion: struct { msize: u32, version: []const u8 },
+
+ tauth: struct { afid: u32, uname: []const u8, aname: []const u8 },
+ /// `aqid` and not `qid`: `fcall.h:44` gives `Rauth` its own field, and
+ /// `convS2M.c:368-370` writes it. Same thirteen bytes, different meaning —
+ /// the qid of the auth FILE, not of the tree.
+ rauth: struct { aqid: Qid },
+
+ /// `afid` is `nofid` when the client did not authenticate.
+ tattach: struct { fid: u32, afid: u32, uname: []const u8, aname: []const u8 },
+ rattach: struct { qid: Qid },
+
+ /// A STRING and nothing else. Base 9P2000 has no numeric error code; the
+ /// `errno` field is 9P2000.u's, which this file does not serve. See the
+ /// module header.
+ rerror: struct { ename: []const u8 },
+
+ /// `oldtag` is a u16 like every tag, even though `fcall.h:14` declares
+ /// `oldtag` as u32 — `convS2M.c:251-253` writes it with `PBIT16`.
+ tflush: struct { oldtag: u16 },
+ rflush: void,
+
+ /// `wname[0..nwname]` are the path elements; anything past `nwname` is
+ /// undefined and neither encoded nor compared. A zero-element walk is
+ /// legal and means "clone `fid` into `newfid`".
+ twalk: struct {
+ fid: u32,
+ newfid: u32,
+ nwname: u16,
+ wname: [max_welem][]const u8 = @splat(""),
+ },
+ /// `nwqid` may be SHORTER than the request's `nwname`: a partial walk is a
+ /// successful `Rwalk` with fewer qids, and only a failure on the FIRST
+ /// element is an `Rerror`.
+ rwalk: struct {
+ nwqid: u16,
+ wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }),
+ },
+
+ /// `mode` is OREAD/OWRITE/ORDWR/OEXEC plus OTRUNC/ORCLOSE, one byte.
+ topen: struct { fid: u32, mode: u8 },
+ /// `iounit`: the largest atomic read or write, or 0 for "no promise". We
+ /// quote `msize - iohdrsz`.
+ ropen: struct { qid: Qid, iounit: u32 },
+
+ /// `perm` is the full mode word — `dmdir` and friends in the high bits,
+ /// `dmperm` in the low nine.
+ tcreate: struct { fid: u32, name: []const u8, perm: u32, mode: u8 },
+ rcreate: struct { qid: Qid, iounit: u32 },
+
+ tread: struct { fid: u32, offset: u64, count: u32 },
+ /// `count[4]` then the bytes, held as one slice because the count is the
+ /// slice's length and two ways to say one number is one way to disagree.
+ /// A reply longer than the request's `count` is a hard `-EIO` to Linux
+ /// (`net/9p/client.c:1475-1479`), so the server half clamps and this codec
+ /// carries whatever it is given.
+ rread: struct { data: []const u8 },
+
+ twrite: struct { fid: u32, offset: u64, data: []const u8 },
+ /// The count actually written, which may be short.
+ rwrite: struct { count: u32 },
+
+ tclunk: struct { fid: u32 },
+ rclunk: void,
+ tremove: struct { fid: u32 },
+ rremove: void,
+
+ tstat: struct { fid: u32 },
+ /// Carries a decoded `Stat`, not a blob, so the double length is computed
+ /// in one place — `encode` derives the outer count from `stat.size() + 2`
+ /// and `decode` demands they agree. u9fs keeps `nstat` and a `uchar*` here
+ /// (`fcall.h:40-41`) and pays for it with `statcheck` as a separate call
+ /// every caller must remember.
+ rstat: struct { stat: Stat },
+ twstat: struct { fid: u32, stat: Stat },
+ rwstat: void,
+
+ /// The type byte this message travels as. Mechanical, by name, so a
+ /// variant cannot acquire the wrong number.
+ pub fn msgType(msg: Msg) Type {
+ return switch (msg) {
+ inline else => |_, t| @field(Type, @tagName(t)),
+ };
+ }
+};
+
+/// One complete message off the wire: its tag and its content. The tag is
+/// separate for the reason `Msg`'s doc gives — a reply reuses the request's tag
+/// and never looks inside it.
+pub const Decoded = struct {
+ tag: u16,
+ msg: Msg,
+};
+
+/// How many bytes this message will be, once the caller has enough of it.
+/// `null` when there are fewer than four, which is the only answer a stream
+/// reader can act on: read more.
+///
+/// Deliberately UNVALIDATED. It is the raw `size` field, and it is peeked
+/// before the type byte has necessarily arrived, so there is nothing here to
+/// check it against. `decode` does the refusing; this only says how much to
+/// buffer, and a caller that compares the answer to its negotiated msize
+/// refuses an absurd claim before growing anything.
+pub fn frameLen(prefix: []const u8) ?u32 {
+ if (prefix.len < 4) return null;
+ return std.mem.readInt(u32, prefix[0..4], .little);
+}
+
+/// The whole message's byte count, `size` included, which IS the value of the
+/// `size` field. `sizeS2M` in `convS2M.c:38-208`, in the same order, so the two
+/// can be read side by side.
+///
+/// Computed before a single byte is written, which is what makes `encode`'s
+/// `NoSpace` clean: a caller whose buffer is one byte short gets an error and
+/// an untouched buffer, not a half-written message.
+fn totalLen(msg: Msg) Error!usize {
+ const body: usize = switch (msg) {
+ .tversion => |m| 4 + try stringLen(m.version),
+ .rversion => |m| 4 + try stringLen(m.version),
+ .tauth => |m| 4 + try stringLen(m.uname) + try stringLen(m.aname),
+ .rauth => qid_len,
+ .tattach => |m| 4 + 4 + try stringLen(m.uname) + try stringLen(m.aname),
+ .rattach => qid_len,
+ .rerror => |m| try stringLen(m.ename),
+ .tflush => 2,
+ .rflush => 0,
+ .twalk => |m| blk: {
+ // The bound is the protocol's, and both halves of u9fs return
+ // failure above it (`convS2M.c:279`, `convM2S.c:170`). On this side
+ // it is a caller bug — the array is sixteen long — so it asserts.
+ assert(m.nwname <= max_welem);
+ var n: usize = 4 + 4 + 2;
+ for (m.wname[0..m.nwname]) |name| n += try stringLen(name);
+ break :blk n;
+ },
+ .rwalk => |m| blk: {
+ assert(m.nwqid <= max_welem);
+ break :blk 2 + @as(usize, m.nwqid) * qid_len;
+ },
+ .topen => 4 + 1,
+ .ropen => qid_len + 4,
+ .tcreate => |m| 4 + try stringLen(m.name) + 4 + 1,
+ .rcreate => qid_len + 4,
+ .tread => 4 + 8 + 4,
+ .rread => |m| try dataLen(m.data),
+ .twrite => |m| 4 + 8 + try dataLen(m.data),
+ .rwrite => 4,
+ .tclunk => 4,
+ .rclunk => 0,
+ .tremove => 4,
+ .rremove => 0,
+ .tstat => 4,
+ // THE DOUBLE LENGTH, in the one place it is computed: the outer count,
+ // then the record, whose own prefix is inside `size() + 2`.
+ .rstat => |m| 2 + 2 + @as(usize, try m.stat.size()),
+ .twstat => |m| 4 + 2 + 2 + @as(usize, try m.stat.size()),
+ .rwstat => 0,
+ };
+ const total = header_len + body;
+ if (total > std.math.maxInt(u32)) return error.Overlong;
+ return total;
+}
+
+/// `stringsz` — `convS2M.c:31-36`. The count is two bytes, so a longer string
+/// has no encoding and is refused here rather than truncated silently.
+fn stringLen(s: []const u8) Error!usize {
+ if (s.len > std.math.maxInt(u16)) return error.Overlong;
+ return 2 + s.len;
+}
+
+/// The same for a `count[4]` payload: `Rread`'s and `Twrite`'s data.
+fn dataLen(d: []const u8) Error!usize {
+ if (d.len > std.math.maxInt(u32)) return error.Overlong;
+ return 4 + d.len;
+}
+
+/// Encodes one message into `buf` and returns the bytes of it, which start at
+/// `buf[0]` and are exactly `size` long.
+///
+/// `tag` is a parameter and not a field of `Msg`: a server handler builds a
+/// reply and the transport supplies the request's tag, so the two cannot drift.
+/// `notag` on anything but `Tversion`/`Rversion` is the caller's business.
+pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 {
+ const total = try totalLen(msg);
+ if (total > buf.len) return error.NoSpace;
+
+ // The writer is bounded to `total` and not to `buf`, so a disagreement
+ // between `totalLen` and the field walk below cannot scribble past the
+ // message — it becomes `NoSpace` here or the assert at the end.
+ var w: Writer = .init(buf[0..total]);
+ try w.putU32(@intCast(total));
+ try w.putByte(@intFromEnum(msg.msgType()));
+ try w.putU16(tag);
+
+ switch (msg) {
+ .tversion => |m| {
+ try w.putU32(m.msize);
+ try w.putString(m.version);
+ },
+ .rversion => |m| {
+ try w.putU32(m.msize);
+ try w.putString(m.version);
+ },
+ .tauth => |m| {
+ try w.putU32(m.afid);
+ try w.putString(m.uname);
+ try w.putString(m.aname);
+ },
+ .rauth => |m| try w.putQid(m.aqid),
+ .tattach => |m| {
+ try w.putU32(m.fid);
+ try w.putU32(m.afid);
+ try w.putString(m.uname);
+ try w.putString(m.aname);
+ },
+ .rattach => |m| try w.putQid(m.qid),
+ .rerror => |m| try w.putString(m.ename),
+ .tflush => |m| try w.putU16(m.oldtag),
+ .rflush => {},
+ .twalk => |m| {
+ try w.putU32(m.fid);
+ try w.putU32(m.newfid);
+ try w.putU16(m.nwname);
+ for (m.wname[0..m.nwname]) |name| try w.putString(name);
+ },
+ .rwalk => |m| {
+ try w.putU16(m.nwqid);
+ for (m.wqid[0..m.nwqid]) |qid| try w.putQid(qid);
+ },
+ .topen => |m| {
+ try w.putU32(m.fid);
+ try w.putByte(m.mode);
+ },
+ .ropen => |m| {
+ try w.putQid(m.qid);
+ try w.putU32(m.iounit);
+ },
+ .tcreate => |m| {
+ try w.putU32(m.fid);
+ try w.putString(m.name);
+ try w.putU32(m.perm);
+ try w.putByte(m.mode);
+ },
+ .rcreate => |m| {
+ try w.putQid(m.qid);
+ try w.putU32(m.iounit);
+ },
+ .tread => |m| {
+ try w.putU32(m.fid);
+ try w.putU64(m.offset);
+ try w.putU32(m.count);
+ },
+ .rread => |m| {
+ try w.putU32(@intCast(m.data.len));
+ try w.putBytes(m.data);
+ },
+ .twrite => |m| {
+ try w.putU32(m.fid);
+ try w.putU64(m.offset);
+ try w.putU32(@intCast(m.data.len));
+ try w.putBytes(m.data);
+ },
+ .rwrite => |m| try w.putU32(m.count),
+ .tclunk => |m| try w.putU32(m.fid),
+ .rclunk => {},
+ .tremove => |m| try w.putU32(m.fid),
+ .rremove => {},
+ .tstat => |m| try w.putU32(m.fid),
+ .rstat => |m| {
+ // Outer count first: the whole record, its own prefix included.
+ try w.putU16(try m.stat.size() + 2);
+ try w.putStat(m.stat);
+ },
+ .twstat => |m| {
+ try w.putU32(m.fid);
+ try w.putU16(try m.stat.size() + 2);
+ try w.putStat(m.stat);
+ },
+ .rwstat => {},
+ }
+
+ // `convS2M.c:420-421`: `if(size != p-ap) return 0`. The two arithmetics are
+ // deliberately separate and this is the only thing that keeps them honest.
+ assert(w.n == total);
+ return buf[0..total];
+}
+
+/// Decodes exactly one complete message. `bytes` must be the message and
+/// nothing else — `frameLen` is how a reader knows where that ends — and every
+/// slice in the result BORROWS from it.
+///
+/// Four ways this refuses, in the order the checks run, because the order is
+/// what makes a stream reader's life simple:
+/// * fewer than seven bytes, or `size` past the end -> `Truncated`, meaning
+/// "come back with more".
+/// * `size` short of the end -> `Trailing`. Two messages were handed over as
+/// one, which is a framing bug in the caller, not a short read.
+/// * a `size` that cannot hold a header -> `BadValue`. No encoder produced it.
+/// * a type byte 9P2000 does not define, or defines illegal -> `BadTag`.
+pub fn decode(bytes: []const u8) Error!Decoded {
+ if (bytes.len < header_len) return error.Truncated;
+ const size = std.mem.readInt(u32, bytes[0..4], .little);
+ // `convM2S.c:65-66` refuses this too, and it must be refused BEFORE the
+ // comparison against `bytes.len`: a size of 3 on a 3-byte buffer would
+ // otherwise slice a header out of nothing.
+ if (size < header_len) return error.BadValue;
+ if (size > bytes.len) return error.Truncated;
+ if (size < bytes.len) return error.Trailing;
+
+ const t: Type = @enumFromInt(bytes[4]);
+ const tag = std.mem.readInt(u16, bytes[5..7], .little);
+
+ // Bounded by `size` and not by `bytes`, which is the same thing here only
+ // because of the two checks above; keep it explicit so it stays true if a
+ // caller is ever allowed to pass a longer buffer.
+ var r: Reader = .init(bytes[header_len..size]);
+
+ const msg: Msg = switch (t) {
+ .tversion => .{ .tversion = .{ .msize = try r.getU32(), .version = try r.getString() } },
+ .rversion => .{ .rversion = .{ .msize = try r.getU32(), .version = try r.getString() } },
+ .tauth => .{ .tauth = .{
+ .afid = try r.getU32(),
+ .uname = try r.getString(),
+ .aname = try r.getString(),
+ } },
+ .rauth => .{ .rauth = .{ .aqid = try r.getQid() } },
+ .tattach => .{ .tattach = .{
+ .fid = try r.getU32(),
+ .afid = try r.getU32(),
+ .uname = try r.getString(),
+ .aname = try r.getString(),
+ } },
+ .rattach => .{ .rattach = .{ .qid = try r.getQid() } },
+ .rerror => .{ .rerror = .{ .ename = try r.getString() } },
+ .tflush => .{ .tflush = .{ .oldtag = try r.getU16() } },
+ .rflush => .rflush,
+ .twalk => blk: {
+ var m: Msg = .{ .twalk = .{
+ .fid = try r.getU32(),
+ .newfid = try r.getU32(),
+ .nwname = try r.getU16(),
+ } };
+ // Checked before the loop, so a hostile 65535 never reaches the
+ // array. `convM2S.c:170-171` does the same and for the same reason.
+ if (m.twalk.nwname > max_welem) return error.Overlong;
+ for (m.twalk.wname[0..m.twalk.nwname]) |*name| name.* = try r.getString();
+ break :blk m;
+ },
+ .rwalk => blk: {
+ var m: Msg = .{ .rwalk = .{ .nwqid = try r.getU16() } };
+ if (m.rwalk.nwqid > max_welem) return error.Overlong;
+ for (m.rwalk.wqid[0..m.rwalk.nwqid]) |*qid| qid.* = try r.getQid();
+ break :blk m;
+ },
+ .topen => .{ .topen = .{ .fid = try r.getU32(), .mode = try r.getByte() } },
+ .ropen => .{ .ropen = .{ .qid = try r.getQid(), .iounit = try r.getU32() } },
+ .tcreate => .{ .tcreate = .{
+ .fid = try r.getU32(),
+ .name = try r.getString(),
+ .perm = try r.getU32(),
+ .mode = try r.getByte(),
+ } },
+ .rcreate => .{ .rcreate = .{ .qid = try r.getQid(), .iounit = try r.getU32() } },
+ .tread => .{ .tread = .{
+ .fid = try r.getU32(),
+ .offset = try r.getU64(),
+ .count = try r.getU32(),
+ } },
+ .rread => .{ .rread = .{ .data = try r.getData() } },
+ .twrite => .{ .twrite = .{
+ .fid = try r.getU32(),
+ .offset = try r.getU64(),
+ .data = try r.getData(),
+ } },
+ .rwrite => .{ .rwrite = .{ .count = try r.getU32() } },
+ .tclunk => .{ .tclunk = .{ .fid = try r.getU32() } },
+ .rclunk => .rclunk,
+ .tremove => .{ .tremove = .{ .fid = try r.getU32() } },
+ .rremove => .rremove,
+ .tstat => .{ .tstat = .{ .fid = try r.getU32() } },
+ .rstat => .{ .rstat = .{ .stat = try Stat.decode(try r.getBlob16()) } },
+ .twstat => .{ .twstat = .{
+ .fid = try r.getU32(),
+ .stat = try Stat.decode(try r.getBlob16()),
+ } },
+ .rwstat => .rwstat,
+ // «Terror = 106, /* illegal */». A peer that sent one is not speaking
+ // 9P2000, and every other unlisted byte is a `.u`/`.L` message or
+ // noise. Both are refused here, which is also why `Type` is
+ // non-exhaustive: this switch is reachable with any byte.
+ .terror, _ => return error.BadTag,
+ };
+
+ try r.end();
+ return .{ .tag = tag, .msg = msg };
+}
+
+// ---------------------------------------------------------------------------
+// primitives
+// ---------------------------------------------------------------------------
+//
+// Explicit widths, little-endian, one field at a time. `GBIT*`/`PBIT*` in
+// `fcall.h:48-58` are the reference, and they are byte-at-a-time shifts for
+// exactly the reason this file does not blit a struct: the sender's word order
+// and padding are not the protocol.
+
+const Writer = struct {
+ buf: []u8,
+ n: usize = 0,
+
+ fn init(buf: []u8) Writer {
+ return .{ .buf = buf };
+ }
+
+ /// `n <= buf.len` is the invariant every putter preserves, which is what
+ /// makes the subtraction safe.
+ fn room(w: *Writer, k: usize) Error![]u8 {
+ if (w.buf.len - w.n < k) return error.NoSpace;
+ defer w.n += k;
+ return w.buf[w.n..][0..k];
+ }
+
+ fn putByte(w: *Writer, v: u8) Error!void {
+ (try w.room(1))[0] = v;
+ }
+
+ fn putU16(w: *Writer, v: u16) Error!void {
+ std.mem.writeInt(u16, (try w.room(2))[0..2], v, .little);
+ }
+
+ fn putU32(w: *Writer, v: u32) Error!void {
+ std.mem.writeInt(u32, (try w.room(4))[0..4], v, .little);
+ }
+
+ fn putU64(w: *Writer, v: u64) Error!void {
+ std.mem.writeInt(u64, (try w.room(8))[0..8], v, .little);
+ }
+
+ fn putBytes(w: *Writer, v: []const u8) Error!void {
+ @memcpy(try w.room(v.len), v);
+ }
+
+ /// `n[2]` then the bytes, NOT NUL-terminated — `pstring`, `convS2M.c:4-16`.
+ /// The length was already refused by `stringLen` before anything was
+ /// written, so this asserts rather than erroring: reaching it with a longer
+ /// string means `totalLen` and this switch disagree.
+ fn putString(w: *Writer, v: []const u8) Error!void {
+ assert(v.len <= std.math.maxInt(u16));
+ try w.putU16(@intCast(v.len));
+ try w.putBytes(v);
+ }
+
+ fn putQid(w: *Writer, v: Qid) Error!void {
+ _ = try v.encode(try w.room(qid_len));
+ }
+
+ fn putStat(w: *Writer, v: Stat) Error!void {
+ const total = @as(usize, try v.size()) + 2;
+ _ = try v.encode(try w.room(total));
+ }
+};
+
+const Reader = struct {
+ bytes: []const u8,
+ i: usize = 0,
+
+ fn init(bytes: []const u8) Reader {
+ return .{ .bytes = bytes };
+ }
+
+ /// The one place this file indexes, and the one place it can refuse to.
+ /// `i <= bytes.len` always, so the subtraction cannot wrap.
+ fn take(r: *Reader, n: usize) Error![]const u8 {
+ if (r.bytes.len - r.i < n) return error.Truncated;
+ defer r.i += n;
+ return r.bytes[r.i..][0..n];
+ }
+
+ fn getByte(r: *Reader) Error!u8 {
+ return (try r.take(1))[0];
+ }
+
+ fn getU16(r: *Reader) Error!u16 {
+ return std.mem.readInt(u16, (try r.take(2))[0..2], .little);
+ }
+
+ fn getU32(r: *Reader) Error!u32 {
+ return std.mem.readInt(u32, (try r.take(4))[0..4], .little);
+ }
+
+ fn getU64(r: *Reader) Error!u64 {
+ return std.mem.readInt(u64, (try r.take(8))[0..8], .little);
+ }
+
+ /// `gstring`, `convM2S.c:4-22`, minus the memmove: u9fs shuffles the bytes
+ /// down over the count to make room for a '\0' because its callers are C
+ /// string functions. Ours borrow, so the slice IS the string and the buffer
+ /// is untouched.
+ fn getString(r: *Reader) Error![]const u8 {
+ return r.take(try r.getU16());
+ }
+
+ /// `count[4]` then the bytes: `Rread`'s and `Twrite`'s payload. A count
+ /// past the message is `Truncated` and not a clamp — Linux clamps here
+ /// (`protocol.c:386-388`) and then has to catch the lie again in
+ /// `client.c:1475-1479`. Refusing once is cheaper and says more.
+ fn getData(r: *Reader) Error![]const u8 {
+ return r.take(try r.getU32());
+ }
+
+ /// `count[2]` then the bytes: the OUTER count of an `Rstat`/`Twstat` stat.
+ /// Slicing exactly here is what lets `Stat.decode` insist that the record's
+ /// own prefix agrees, which is the whole defence against the double length.
+ fn getBlob16(r: *Reader) Error![]const u8 {
+ return r.take(try r.getU16());
+ }
+
+ fn getQid(r: *Reader) Error!Qid {
+ return Qid.decode(try r.take(qid_len));
+ }
+
+ fn end(r: *Reader) Error!void {
+ if (r.i != r.bytes.len) return error.Trailing;
+ }
+};
+
+// ---------------------------------------------------------------------------
+// tests
+// ---------------------------------------------------------------------------
+//
+// Three obligations, and the third is the one that is usually skipped.
+//
+// 1. every message round-trips to an equal value, because a hand-written
+// codec is a codec whose two halves drift;
+// 2. every malformed shape is REFUSED and none of them panics, because this
+// parser is fed by a socket;
+// 3. the bytes are the RIGHT bytes. A round-trip test proves the encoder and
+// the decoder agree with each other and nothing about whether they agree
+// with plan9port's `9p`, which is who will actually be on the far end. So
+// four messages are hand-verified against `u9fs/convS2M.c` as literal
+// arrays with the line numbers attached.
+
+const testing = std.testing;
+
+fn roundTrip(buf: []u8, tag: u16, msg: Msg) !Msg {
+ const bytes = try encode(msg, tag, buf);
+ // The framing has to agree with the encoder before anything else is worth
+ // checking: `size` includes itself, so this is also the regression test for
+ // the first of the module header's two facts.
+ try testing.expectEqual(bytes.len, frameLen(bytes).?);
+ const got = try decode(bytes);
+ try testing.expectEqual(tag, got.tag);
+ try testing.expectEqual(msg.msgType(), got.msg.msgType());
+ try expectMsgEqual(msg, got.msg);
+ return got.msg;
+}
+
+fn expectStatEqual(want: Stat, have: Stat) !void {
+ try testing.expectEqual(want.type, have.type);
+ try testing.expectEqual(want.dev, have.dev);
+ try testing.expectEqual(want.qid, have.qid);
+ try testing.expectEqual(want.mode, have.mode);
+ try testing.expectEqual(want.atime, have.atime);
+ try testing.expectEqual(want.mtime, have.mtime);
+ try testing.expectEqual(want.length, have.length);
+ try testing.expectEqualStrings(want.name, have.name);
+ try testing.expectEqualStrings(want.uid, have.uid);
+ try testing.expectEqualStrings(want.gid, have.gid);
+ try testing.expectEqualStrings(want.muid, have.muid);
+}
+
+/// Field by field, because `std.meta.eql` is wrong here twice: a decoded slice
+/// points into the wire buffer and never compares equal by pointer, and
+/// `Twalk.wname` past `nwname` is scratch the decoder does not invent.
+fn expectMsgEqual(want: Msg, have: Msg) !void {
+ switch (want) {
+ .tversion => |w| {
+ try testing.expectEqual(w.msize, have.tversion.msize);
+ try testing.expectEqualStrings(w.version, have.tversion.version);
+ },
+ .rversion => |w| {
+ try testing.expectEqual(w.msize, have.rversion.msize);
+ try testing.expectEqualStrings(w.version, have.rversion.version);
+ },
+ .tauth => |w| {
+ try testing.expectEqual(w.afid, have.tauth.afid);
+ try testing.expectEqualStrings(w.uname, have.tauth.uname);
+ try testing.expectEqualStrings(w.aname, have.tauth.aname);
+ },
+ .rauth => |w| try testing.expectEqual(w.aqid, have.rauth.aqid),
+ .tattach => |w| {
+ try testing.expectEqual(w.fid, have.tattach.fid);
+ try testing.expectEqual(w.afid, have.tattach.afid);
+ try testing.expectEqualStrings(w.uname, have.tattach.uname);
+ try testing.expectEqualStrings(w.aname, have.tattach.aname);
+ },
+ .rattach => |w| try testing.expectEqual(w.qid, have.rattach.qid),
+ .rerror => |w| try testing.expectEqualStrings(w.ename, have.rerror.ename),
+ .tflush => |w| try testing.expectEqual(w.oldtag, have.tflush.oldtag),
+ .rflush, .rclunk, .rremove, .rwstat => {},
+ .twalk => |w| {
+ try testing.expectEqual(w.fid, have.twalk.fid);
+ try testing.expectEqual(w.newfid, have.twalk.newfid);
+ try testing.expectEqual(w.nwname, have.twalk.nwname);
+ for (w.wname[0..w.nwname], have.twalk.wname[0..w.nwname]) |a, b|
+ try testing.expectEqualStrings(a, b);
+ },
+ .rwalk => |w| {
+ try testing.expectEqual(w.nwqid, have.rwalk.nwqid);
+ for (w.wqid[0..w.nwqid], have.rwalk.wqid[0..w.nwqid]) |a, b|
+ try testing.expectEqual(a, b);
+ },
+ .topen => |w| {
+ try testing.expectEqual(w.fid, have.topen.fid);
+ try testing.expectEqual(w.mode, have.topen.mode);
+ },
+ .ropen => |w| {
+ try testing.expectEqual(w.qid, have.ropen.qid);
+ try testing.expectEqual(w.iounit, have.ropen.iounit);
+ },
+ .tcreate => |w| {
+ try testing.expectEqual(w.fid, have.tcreate.fid);
+ try testing.expectEqualStrings(w.name, have.tcreate.name);
+ try testing.expectEqual(w.perm, have.tcreate.perm);
+ try testing.expectEqual(w.mode, have.tcreate.mode);
+ },
+ .rcreate => |w| {
+ try testing.expectEqual(w.qid, have.rcreate.qid);
+ try testing.expectEqual(w.iounit, have.rcreate.iounit);
+ },
+ .tread => |w| {
+ try testing.expectEqual(w.fid, have.tread.fid);
+ try testing.expectEqual(w.offset, have.tread.offset);
+ try testing.expectEqual(w.count, have.tread.count);
+ },
+ .rread => |w| try testing.expectEqualStrings(w.data, have.rread.data),
+ .twrite => |w| {
+ try testing.expectEqual(w.fid, have.twrite.fid);
+ try testing.expectEqual(w.offset, have.twrite.offset);
+ try testing.expectEqualStrings(w.data, have.twrite.data);
+ },
+ .rwrite => |w| try testing.expectEqual(w.count, have.rwrite.count),
+ .tclunk => |w| try testing.expectEqual(w.fid, have.tclunk.fid),
+ .tremove => |w| try testing.expectEqual(w.fid, have.tremove.fid),
+ .tstat => |w| try testing.expectEqual(w.fid, have.tstat.fid),
+ .rstat => |w| try expectStatEqual(w.stat, have.rstat.stat),
+ .twstat => |w| {
+ try testing.expectEqual(w.fid, have.twstat.fid);
+ try expectStatEqual(w.stat, have.twstat.stat);
+ },
+ }
+}
+
+const sample_qid: Qid = .{ .type = qtdir, .version = 3, .path = 0x0102_0304_0506_0708 };
+
+const sample_stat: Stat = .{
+ .type = 0,
+ .dev = 0,
+ .qid = sample_qid,
+ .mode = dmdir | 0o755,
+ .atime = 1,
+ .mtime = 2,
+ .length = 0,
+ .name = "body",
+ .uid = "goblin",
+ .gid = "goblin",
+ .muid = "goblin",
+};
+
+test "9p: the type numbers and their parity are the protocol's own" {
+ // Copied from `u9fs/fcall.h:74-105`. Asserted as literals because a
+ // renumbering here is a codec that talks to nothing, and it must be a diff
+ // somebody reads rather than a silent change.
+ try testing.expectEqual(@as(u8, 100), @intFromEnum(Type.tversion));
+ try testing.expectEqual(@as(u8, 106), @intFromEnum(Type.terror));
+ try testing.expectEqual(@as(u8, 107), @intFromEnum(Type.rerror));
+ try testing.expectEqual(@as(u8, 126), @intFromEnum(Type.twstat));
+ try testing.expectEqual(@as(u8, 127), @intFromEnum(Type.rwstat));
+
+ // Twenty-eight numbers, 100..127 inclusive, no gaps and no strays.
+ try testing.expectEqual(@as(usize, 28), std.enums.values(Type).len);
+ for (std.enums.values(Type), 100..) |t, want| try testing.expectEqual(@as(u8, @intCast(want)), @intFromEnum(t));
+
+ try testing.expect(isT(.tversion));
+ try testing.expect(!isT(.rversion));
+ try testing.expect(isT(.twstat));
+ try testing.expect(!isT(.rwstat));
+
+ // `notag` is two bytes wide even though `fcall.h:71` writes `~0U`.
+ try testing.expectEqual(@as(u16, 0xFFFF), notag);
+ try testing.expectEqual(@as(u32, 0xFFFF_FFFF), nofid);
+ try testing.expectEqual(@as(usize, 16), max_welem);
+}
+
+test "9p: a qid is thirteen bytes" {
+ var buf: [32]u8 = undefined;
+ const bytes = try sample_qid.encode(&buf);
+ try testing.expectEqual(qid_len, bytes.len);
+ try testing.expectEqual(@as(usize, 13), bytes.len);
+ try testing.expectEqual(sample_qid, try Qid.decode(bytes));
+ // Twelve bytes is not a qid, and a decoder that read one anyway would be
+ // reading the next field's first byte as the top of `path`.
+ try testing.expectError(error.Truncated, Qid.decode(bytes[0..12]));
+ try testing.expectError(error.NoSpace, sample_qid.encode(buf[0..12]));
+}
+
+test "9p: an encoded stat is size() + 2 bytes" {
+ var buf: [256]u8 = undefined;
+ const bytes = try sample_stat.encode(&buf);
+ const n = try sample_stat.size();
+ try testing.expectEqual(@as(usize, n) + 2, bytes.len);
+ // `STATFIXLEN - BIT16SZ` plus the four string bodies: 47 + 4 + 6 + 6 + 6.
+ try testing.expectEqual(@as(u16, 69), n);
+ try testing.expectEqual(stat_fixed - 2 + 22, n);
+ // The prefix on the wire is the count EXCLUDING itself — `convD2M.c:48-51`.
+ try testing.expectEqual(n, std.mem.readInt(u16, bytes[0..2], .little));
+ try expectStatEqual(sample_stat, try Stat.decode(bytes));
+
+ // Empty strings still cost their counts: 47 and nothing more.
+ const bare: Stat = .{
+ .type = 0,
+ .dev = 0,
+ .qid = .{ .type = qtfile, .version = 0, .path = 0 },
+ .mode = 0,
+ .atime = 0,
+ .mtime = 0,
+ .length = 0,
+ .name = "",
+ .uid = "",
+ .gid = "",
+ .muid = "",
+ };
+ try testing.expectEqual(@as(u16, 47), try bare.size());
+ try testing.expectEqual(@as(usize, 49), (try bare.encode(&buf)).len);
+}
+
+test "9p: every message round-trips" {
+ var buf: [512]u8 = undefined;
+
+ _ = try roundTrip(&buf, notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } });
+ _ = try roundTrip(&buf, notag, .{ .rversion = .{ .msize = 8192, .version = "9P2000" } });
+ // "unknown" is a SUCCESSFUL Rversion meaning no dialect in common, and the
+ // codec must carry it like any other string rather than treat it as an
+ // error path.
+ _ = try roundTrip(&buf, notag, .{ .rversion = .{ .msize = min_msize, .version = "unknown" } });
+ _ = try roundTrip(&buf, 1, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } });
+ _ = try roundTrip(&buf, 1, .{ .rauth = .{ .aqid = .{ .type = qtauth, .version = 0, .path = 9 } } });
+ _ = try roundTrip(&buf, 2, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } });
+ _ = try roundTrip(&buf, 2, .{ .rattach = .{ .qid = sample_qid } });
+ _ = try roundTrip(&buf, 3, .{ .rerror = .{ .ename = "no such file" } });
+ _ = try roundTrip(&buf, 4, .{ .tflush = .{ .oldtag = 3 } });
+ _ = try roundTrip(&buf, 4, .rflush);
+ _ = try roundTrip(&buf, 5, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 2, .wname = .{ "7", "body" } ++ @as([max_welem - 2][]const u8, @splat("")) } });
+ _ = try roundTrip(&buf, 5, .{ .rwalk = .{ .nwqid = 2, .wqid = .{ sample_qid, sample_qid } ++ @as([max_welem - 2]Qid, @splat(sample_qid)) } });
+ _ = try roundTrip(&buf, 6, .{ .topen = .{ .fid = 1, .mode = 0 } });
+ _ = try roundTrip(&buf, 6, .{ .ropen = .{ .qid = sample_qid, .iounit = 8192 - iohdrsz } });
+ _ = try roundTrip(&buf, 7, .{ .tcreate = .{ .fid = 1, .name = "new", .perm = dmdir | 0o777, .mode = 2 } });
+ _ = try roundTrip(&buf, 7, .{ .rcreate = .{ .qid = sample_qid, .iounit = 0 } });
+ _ = try roundTrip(&buf, 8, .{ .tread = .{ .fid = 1, .offset = 0xdead_beef_cafe, .count = 4096 } });
+ _ = try roundTrip(&buf, 8, .{ .rread = .{ .data = "hello" } });
+ // A zero-byte Rread is end of file and not an error, which is exactly what
+ // docs/9p.typ promises a pty reader on exit.
+ _ = try roundTrip(&buf, 8, .{ .rread = .{ .data = "" } });
+ _ = try roundTrip(&buf, 9, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "Edit ,d" } });
+ _ = try roundTrip(&buf, 9, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "" } });
+ _ = try roundTrip(&buf, 9, .{ .rwrite = .{ .count = 7 } });
+ _ = try roundTrip(&buf, 10, .{ .tclunk = .{ .fid = 1 } });
+ _ = try roundTrip(&buf, 10, .rclunk);
+ _ = try roundTrip(&buf, 11, .{ .tremove = .{ .fid = 1 } });
+ _ = try roundTrip(&buf, 11, .rremove);
+ _ = try roundTrip(&buf, 12, .{ .tstat = .{ .fid = 1 } });
+ _ = try roundTrip(&buf, 12, .{ .rstat = .{ .stat = sample_stat } });
+ _ = try roundTrip(&buf, 13, .{ .twstat = .{ .fid = 1, .stat = sample_stat } });
+ _ = try roundTrip(&buf, 13, .rwstat);
+
+ // Every type that has a message got one. The count is the thirteen pairs
+ // plus Rerror; `Terror` is illegal and has no variant, which is what the
+ // arithmetic below is really asserting.
+ try testing.expectEqual(@as(usize, 27), @typeInfo(Msg).@"union".fields.len);
+ try testing.expectEqual(std.enums.values(Type).len - 1, @typeInfo(Msg).@"union".fields.len);
+}
+
+test "9p: empty and maximum-length strings survive the trip" {
+ var buf: [70_000]u8 = undefined;
+
+ // Empty is not absent: the count is still two bytes.
+ const empty = try roundTrip(&buf, 1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "", .aname = "" } });
+ try testing.expectEqual(@as(usize, 0), empty.tattach.uname.len);
+ try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2 + 2), (try encode(empty, 1, &buf)).len);
+
+ // The largest string a `n[2]` count can describe, and the one past it.
+ var big: [65_536]u8 = undefined;
+ @memset(&big, 'x');
+ const max = big[0..std.math.maxInt(u16)];
+ const got = try roundTrip(&buf, 1, .{ .rerror = .{ .ename = max } });
+ try testing.expectEqual(@as(usize, 65_535), got.rerror.ename.len);
+ try testing.expectError(error.Overlong, encode(.{ .rerror = .{ .ename = &big } }, 1, &buf));
+
+ // ...and a stat whose strings overflow its own two-byte prefix. Refused by
+ // `size()`, which is the only place that arithmetic happens.
+ var wide = sample_stat;
+ wide.name = max;
+ try testing.expectError(error.Overlong, wide.size());
+ try testing.expectError(error.Overlong, encode(.{ .rstat = .{ .stat = wide } }, 1, &buf));
+}
+
+test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" {
+ var buf: [512]u8 = undefined;
+
+ // Zero elements is a legal walk and means "clone the fid".
+ const zero = try roundTrip(&buf, 1, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 0 } });
+ try testing.expectEqual(@as(u16, 0), zero.twalk.nwname);
+ try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2), (try encode(zero, 1, &buf)).len);
+
+ _ = try roundTrip(&buf, 1, .{ .twalk = .{
+ .fid = 0,
+ .newfid = 1,
+ .nwname = 1,
+ .wname = .{"body"} ++ @as([max_welem - 1][]const u8, @splat("")),
+ } });
+
+ // MAXWELEM exactly, all distinct so a swapped index cannot pass.
+ const names: [max_welem][]const u8 = .{ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p" };
+ const full = try roundTrip(&buf, 1, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = max_welem, .wname = names } });
+ try testing.expectEqual(@as(u16, 16), full.twalk.nwname);
+ for (names, full.twalk.wname[0..max_welem]) |a, b| try testing.expectEqualStrings(a, b);
+ // Rwalk's bound is the same and its own.
+ _ = try roundTrip(&buf, 1, .{ .rwalk = .{ .nwqid = max_welem, .wqid = @splat(sample_qid) } });
+
+ // Seventeen. Hand-built, because the encoder's array cannot hold one — the
+ // point is that a PEER can send it and must be refused before the count
+ // reaches an array of sixteen.
+ var raw: [256]u8 = undefined;
+ const bad = blk: {
+ var w: Writer = .init(&raw);
+ try w.putU32(0); // patched below
+ try w.putByte(@intFromEnum(Type.twalk));
+ try w.putU16(1);
+ try w.putU32(0);
+ try w.putU32(1);
+ try w.putU16(17);
+ for (0..17) |i| try w.putString(&[_]u8{@intCast('a' + i)});
+ std.mem.writeInt(u32, raw[0..4], @intCast(w.n), .little);
+ break :blk raw[0..w.n];
+ };
+ try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2 + 17 * 3), bad.len);
+ try testing.expectError(error.Overlong, decode(bad));
+
+ // Same for Rwalk: seventeen qids is 221 bytes of legal-looking message.
+ const bad_r = blk: {
+ var w: Writer = .init(&raw);
+ try w.putU32(0);
+ try w.putByte(@intFromEnum(Type.rwalk));
+ try w.putU16(1);
+ try w.putU16(17);
+ for (0..17) |_| try w.putQid(sample_qid);
+ std.mem.writeInt(u32, raw[0..4], @intCast(w.n), .little);
+ break :blk raw[0..w.n];
+ };
+ try testing.expectError(error.Overlong, decode(bad_r));
+}
+
+test "9p: the stat double length" {
+ var buf: [512]u8 = undefined;
+
+ // THE fact. Rstat is `count[2]` then a record that begins with its own
+ // `size[2]`, and the outer number is the inner one plus two —
+ // `linux/net/9p/client.c:1633` reads it as "wS" and drops the first w.
+ var good: [512]u8 = undefined;
+ const n = blk: {
+ const bytes = try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf);
+ @memcpy(good[0..bytes.len], bytes);
+ break :blk bytes.len;
+ };
+ const inner = try sample_stat.size();
+ try testing.expectEqual(inner + 2, std.mem.readInt(u16, good[header_len..][0..2], .little));
+ try testing.expectEqual(inner, std.mem.readInt(u16, good[header_len + 2 ..][0..2], .little));
+ try testing.expectEqual(header_len + 2 + @as(usize, inner) + 2, n);
+
+ // Twstat wraps the same pair behind a fid — `client.c:1776`, "dwS".
+ const w_bytes = try encode(.{ .twstat = .{ .fid = 7, .stat = sample_stat } }, 1, &buf);
+ try testing.expectEqual(inner + 2, std.mem.readInt(u16, w_bytes[header_len + 4 ..][0..2], .little));
+ try testing.expectEqual(inner, std.mem.readInt(u16, w_bytes[header_len + 6 ..][0..2], .little));
+
+ // Now three ways to get it wrong, which is the whole reason `Stat.decode`
+ // is handed an exact slice instead of a cursor. Each is two bytes of edit
+ // on a message that is otherwise perfect, and each is refused.
+ var off: [512]u8 = undefined;
+
+ // THE CLASSIC: the outer count written without the +2, so the record's own
+ // prefix then claims two bytes more than the outer count allowed.
+ @memcpy(off[0..n], good[0..n]);
+ std.mem.writeInt(u16, off[header_len..][0..2], inner, .little);
+ try testing.expectError(error.Truncated, decode(off[0..n]));
+
+ // The outer count too large, which is the same mistake made twice.
+ @memcpy(off[0..n], good[0..n]);
+ std.mem.writeInt(u16, off[header_len..][0..2], inner + 4, .little);
+ try testing.expectError(error.Truncated, decode(off[0..n]));
+
+ // The INNER count wrong instead, in both directions: a record that claims
+ // more than the outer count fits, and one that leaves bytes over inside it.
+ @memcpy(off[0..n], good[0..n]);
+ std.mem.writeInt(u16, off[header_len + 2 ..][0..2], inner + 2, .little);
+ try testing.expectError(error.Truncated, decode(off[0..n]));
+
+ @memcpy(off[0..n], good[0..n]);
+ std.mem.writeInt(u16, off[header_len + 2 ..][0..2], inner - 1, .little);
+ try testing.expectError(error.Trailing, decode(off[0..n]));
+}
+
+/// Every prefix of a complete message must be refused, in both of the two
+/// shapes a short message arrives in:
+/// * off a socket, where `size` still claims the whole thing and the header
+/// check catches it;
+/// * as a message that LIES about being complete, where `size` agrees with
+/// the buffer and only the per-field walk can catch it. This is the one
+/// that exercises every field boundary, and the one an attacker sends.
+/// Neither may panic and neither may parse.
+fn expectTruncatedAtEveryBoundary(full: []const u8) !void {
+ var scratch: [1024]u8 = undefined;
+ var n: usize = 0;
+ while (n < full.len) : (n += 1) {
+ try testing.expectError(error.Truncated, decode(full[0..n]));
+ if (n < header_len) continue;
+ @memcpy(scratch[0..n], full[0..n]);
+ std.mem.writeInt(u32, scratch[0..4], @intCast(n), .little);
+ try testing.expectError(error.Truncated, decode(scratch[0..n]));
+ }
+ // The complete message, by contrast, is fine — otherwise the loop above
+ // would pass for a message that never decodes at all.
+ _ = try decode(full);
+}
+
+test "9p: truncation at every field boundary is refused" {
+ var buf: [512]u8 = undefined;
+
+ // Tversion: size, type, tag, msize, a count, a string.
+ try expectTruncatedAtEveryBoundary(try encode(
+ .{ .tversion = .{ .msize = 8192, .version = "9P2000" } },
+ notag,
+ &buf,
+ ));
+ // Twalk: two fids, a count, and then a loop of counted strings, which is
+ // the only variable-arity field in the protocol.
+ try expectTruncatedAtEveryBoundary(try encode(.{ .twalk = .{
+ .fid = 1,
+ .newfid = 2,
+ .nwname = 3,
+ .wname = .{ "usr", "", "bin" } ++ @as([max_welem - 3][]const u8, @splat("")),
+ } }, 1, &buf));
+ // Tread: the widest fixed body, and the one whose 8-byte offset a
+ // native-struct blit would misalign.
+ try expectTruncatedAtEveryBoundary(try encode(
+ .{ .tread = .{ .fid = 1, .offset = 0x0102_0304_0506_0708, .count = 8168 } },
+ 1,
+ &buf,
+ ));
+ // Rstat: both lengths, and every field of the record behind them.
+ try expectTruncatedAtEveryBoundary(try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf));
+ // Rread, whose count is a u32 and whose payload is the message's tail.
+ try expectTruncatedAtEveryBoundary(try encode(.{ .rread = .{ .data = "12345678" } }, 1, &buf));
+ // Rwalk, the other variable-arity body.
+ try expectTruncatedAtEveryBoundary(try encode(
+ .{ .rwalk = .{ .nwqid = 3, .wqid = @splat(sample_qid) } },
+ 1,
+ &buf,
+ ));
+ // Twstat: a fid in front of the double length.
+ try expectTruncatedAtEveryBoundary(try encode(.{ .twstat = .{ .fid = 1, .stat = sample_stat } }, 1, &buf));
+}
+
+test "9p: a size field that disagrees with the buffer is refused" {
+ var buf: [512]u8 = undefined;
+ const bytes = try encode(.{ .tclunk = .{ .fid = 1 } }, 1, &buf);
+ try testing.expectEqual(@as(usize, 11), bytes.len);
+
+ var raw: [64]u8 = undefined;
+ @memcpy(raw[0..bytes.len], bytes);
+
+ // Larger than the buffer: not all of it has arrived. Every value up to a
+ // hostile 4 GiB claim, which must not be believed for one instruction.
+ for ([_]u32{ 12, 13, 64, 1 << 20, std.math.maxInt(u32) }) |claim| {
+ std.mem.writeInt(u32, raw[0..4], claim, .little);
+ try testing.expectError(error.Truncated, decode(raw[0..bytes.len]));
+ }
+
+ // Smaller than the buffer: two messages handed over as one. The caller's
+ // framing is wrong, and silently decoding the first would hide it.
+ std.mem.writeInt(u32, raw[0..4], 10, .little);
+ try testing.expectError(error.Trailing, decode(raw[0..bytes.len]));
+
+ // Smaller than a header at all: a number no encoder produced. Refused
+ // before it is compared against the buffer, or a size of 3 on a 3-byte
+ // buffer would slice a header out of nothing.
+ for ([_]u32{ 0, 1, 6 }) |claim| {
+ std.mem.writeInt(u32, raw[0..4], claim, .little);
+ try testing.expectError(error.BadValue, decode(raw[0..bytes.len]));
+ try testing.expectError(error.BadValue, decode(raw[0..header_len]));
+ }
+}
+
+test "9p: an unknown or illegal type byte is refused" {
+ var buf: [512]u8 = undefined;
+ const bytes = try encode(.{ .tclunk = .{ .fid = 1 } }, 1, &buf);
+ var raw: [64]u8 = undefined;
+ @memcpy(raw[0..bytes.len], bytes);
+
+ // 106 is `Terror`, defined and illegal. 8 is 9P2000.L's `Tstatfs`, 12 is
+ // its `Tlopen`: dialects we do not serve, arriving as bytes we must refuse
+ // rather than `@enumFromInt` into an exhaustive enum.
+ for ([_]u8{ 0, 1, 8, 12, 99, 106, 128, 255 }) |t| {
+ raw[4] = t;
+ try testing.expectError(error.BadTag, decode(raw[0..bytes.len]));
+ }
+
+ // ...and the whole byte space, because the guarantee is total: a byte is
+ // either a type we decode into a message of exactly that type, or an
+ // error. Never a panic, and never a message of some OTHER type.
+ var t: u16 = 0;
+ while (t <= 255) : (t += 1) {
+ raw[4] = @intCast(t);
+ const defined = t >= 100 and t <= 127 and t != @intFromEnum(Type.terror);
+ if (decode(raw[0..bytes.len])) |got| {
+ try testing.expectEqual(@as(u8, @intCast(t)), @intFromEnum(got.msg.msgType()));
+ // Exactly four types have a four-byte body: `fid[4]` for the three
+ // T-messages and `count[4]` for Rwrite. Nothing else may decode
+ // out of these bytes, and a fifth name here would mean a layout
+ // above is wrong.
+ try testing.expect(t == @intFromEnum(Type.tclunk) or
+ t == @intFromEnum(Type.tremove) or
+ t == @intFromEnum(Type.tstat) or
+ t == @intFromEnum(Type.rwrite));
+ } else |err| {
+ // An undefined byte, or the illegal 106, is ALWAYS BadTag: it must
+ // never be diagnosed as a short body, because "read more" is the
+ // wrong advice for a peer speaking another dialect.
+ if (!defined) try testing.expectEqual(Error.BadTag, err);
+ }
+ }
+}
+
+test "9p: trailing bytes inside the size are refused" {
+ var raw: [64]u8 = undefined;
+
+ // A Tclunk whose `size` says twelve and whose body is five bytes: the fid
+ // decodes, and one byte is left over. `convM2S.c:377-381` refuses the same
+ // shape with `if(ap+size == p) return size; return 0;`.
+ var w: Writer = .init(&raw);
+ try w.putU32(12);
+ try w.putByte(@intFromEnum(Type.tclunk));
+ try w.putU16(1);
+ try w.putU32(7);
+ try w.putByte(0xAA);
+ try testing.expectEqual(@as(usize, 12), w.n);
+ try testing.expectError(error.Trailing, decode(raw[0..12]));
+
+ // Same for a body with room for a second copy of itself, which is how a
+ // 9P2000.u message with an extra field would arrive.
+ w = .init(&raw);
+ try w.putU32(header_len + 2 + 2);
+ try w.putByte(@intFromEnum(Type.tflush));
+ try w.putU16(1);
+ try w.putU16(3);
+ try w.putU16(3);
+ try testing.expectError(error.Trailing, decode(raw[0..w.n]));
+}
+
+test "9p: frameLen needs four bytes" {
+ var buf: [512]u8 = undefined;
+ const bytes = try encode(.{ .tread = .{ .fid = 1, .offset = 0, .count = 8168 } }, 1, &buf);
+ try testing.expectEqual(@as(usize, 23), bytes.len);
+
+ // Zero through three: the reader has nothing to act on but "read more".
+ for (0..4) |n| try testing.expectEqual(@as(?u32, null), frameLen(bytes[0..n]));
+ // Four is enough, and the answer is the whole message including the four.
+ try testing.expectEqual(@as(?u32, 23), frameLen(bytes[0..4]));
+ try testing.expectEqual(@as(?u32, 23), frameLen(bytes));
+
+ // Unvalidated on purpose: the type byte may not have arrived yet, so there
+ // is nothing to check the claim against. A caller compares it to its msize.
+ var raw: [4]u8 = .{ 0xFF, 0xFF, 0xFF, 0xFF };
+ try testing.expectEqual(@as(?u32, std.math.maxInt(u32)), frameLen(&raw));
+ raw = .{ 0, 0, 0, 0 };
+ try testing.expectEqual(@as(?u32, 0), frameLen(&raw));
+}
+
+test "9p: encode refuses a short buffer and writes nothing" {
+ var buf: [512]u8 = undefined;
+ const want = (try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf)).len;
+
+ // Every buffer from empty to one byte short, because the interesting one is
+ // not always the last: the message is sized before a byte is written, so
+ // all of them must leave the buffer untouched.
+ var n: usize = 0;
+ while (n < want) : (n += 1) {
+ var scratch: [512]u8 = @splat(0xAA);
+ try testing.expectError(error.NoSpace, encode(.{ .rstat = .{ .stat = sample_stat } }, 1, scratch[0..n]));
+ // NOTHING written, not even the size prefix — including past the end of
+ // the slice it was given, which is the byte a length bug would reach.
+ for (scratch) |b| try testing.expectEqual(@as(u8, 0xAA), b);
+ }
+
+ var exact: [512]u8 = @splat(0xAA);
+ try testing.expectEqual(want, (try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, exact[0..want])).len);
+ try testing.expectEqual(@as(u8, 0xAA), exact[want]);
+}
+
+test "9p: byte for byte against u9fs convS2M" {
+ var buf: [512]u8 = undefined;
+
+ // A round-trip test proves the two halves of THIS file agree. These four
+ // prove they agree with the reference implementation, which is what
+ // plan9port's `9p`, Plan 9's mount driver and Linux's v9fs are all
+ // compatible with. Each array was written out by hand from `convS2M.c` and
+ // the line is named.
+
+ // Tversion, `convS2M.c:236-240` with the header at :224-229.
+ // size[4]=19 type[1]=100 tag[2]=NOTAG msize[4]=8192 version[2+6]
+ // 19, not 12: `size` counts itself and the type and the tag.
+ try testing.expectEqualSlices(u8, &.{
+ 0x13, 0x00, 0x00, 0x00, // size = 19
+ 0x64, // Tversion = 100
+ 0xff, 0xff, // NOTAG
+ 0x00, 0x20, 0x00, 0x00, // msize = 8192
+ 0x06, 0x00, // n = 6
+ '9', 'P',
+ '2', '0',
+ '0', '0',
+ }, try encode(.{ .tversion = .{ .msize = 8192, .version = "9P2000" } }, notag, &buf));
+
+ // Twalk, `convS2M.c:272-283`: fid, newfid, nwname, then `pstring` each,
+ // and `pstring` (:4-16) writes `n[2]` with NO terminator.
+ try testing.expectEqualSlices(u8, &.{
+ 0x1b, 0x00, 0x00, 0x00, // size = 27
+ 0x6e, // Twalk = 110
+ 0x01, 0x00, // tag = 1
+ 0x01, 0x00, 0x00, 0x00, // fid = 1
+ 0x02, 0x00, 0x00, 0x00, // newfid = 2
+ 0x02, 0x00, // nwname = 2
+ 0x03, 0x00,
+ 'u', 's',
+ 'r', 0x03,
+ 0x00, 'b',
+ 'i', 'n',
+ }, try encode(.{ .twalk = .{
+ .fid = 1,
+ .newfid = 2,
+ .nwname = 2,
+ .wname = .{ "usr", "bin" } ++ @as([max_welem - 2][]const u8, @splat("")),
+ } }, 1, &buf));
+
+ // Rread, `convS2M.c:392-397`: count[4] then the bytes. The 11-byte header
+ // this implies is where `msize - 11` comes from (docs/registry.typ).
+ try testing.expectEqualSlices(u8, &.{
+ 0x0e, 0x00, 0x00, 0x00, // size = 14
+ 0x75, // Rread = 117
+ 0x09, 0x00, // tag = 9
+ 0x03, 0x00, 0x00, 0x00, // count = 3
+ 'a', 'b', 'c',
+ }, try encode(.{ .rread = .{ .data = "abc" } }, 9, &buf));
+
+ // Rstat, `convS2M.c:410-415` (`PBIT16(p, f->nstat)` then the blob) around
+ // `convD2M.c:50-83` (the record, whose own prefix is `ss - BIT16SZ`). THE
+ // double length, in bytes: 53 outside, 51 inside, 55 of body, 62 total.
+ const one: Stat = .{
+ .type = 0,
+ .dev = 0,
+ .qid = .{ .type = qtdir, .version = 1, .path = 2 },
+ .mode = dmdir | 0o755,
+ .atime = 3,
+ .mtime = 4,
+ .length = 0,
+ .name = "a",
+ .uid = "u",
+ .gid = "g",
+ .muid = "m",
+ };
+ try testing.expectEqual(@as(u16, 51), try one.size());
+ try testing.expectEqualSlices(u8, &.{
+ 0x3e, 0x00, 0x00, 0x00, // size = 62
+ 0x7d, // Rstat = 125
+ 0x07, 0x00, // tag = 7
+ 0x35, 0x00, // OUTER count = 53 = 51 + 2
+ 0x33, 0x00, // stat size = 51, excluding these two
+ 0x00, 0x00, // type
+ 0x00, 0x00, 0x00, 0x00, // dev
+ 0x80, // qid.type = QTDIR
+ 0x01, 0x00, 0x00, 0x00, // qid.version = 1
+ 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // qid.path = 2
+ 0xed, 0x01, 0x00, 0x80, // mode = DMDIR | 0755
+ 0x03, 0x00, 0x00, 0x00, // atime
+ 0x04, 0x00, 0x00, 0x00, // mtime
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // length
+ 0x01, 0x00, 'a', // name
+ 0x01, 0x00, 'u', // uid
+ 0x01, 0x00, 'g', // gid
+ 0x01, 0x00, 'm', // muid
+ }, try encode(.{ .rstat = .{ .stat = one } }, 7, &buf));
+
+ // The high five mode bits ARE the qid type bits, shifted down 24
+ // (`protocol.rs:486-495`). Asserted here rather than implemented, because
+ // this codec carries both fields and the server half sets them.
+ try testing.expectEqual(qtdir, @as(u8, @intCast(dmdir >> 24)));
+ try testing.expectEqual(qtappend, @as(u8, @intCast(dmappend >> 24)));
+ try testing.expectEqual(qtexcl, @as(u8, @intCast(dmexcl >> 24)));
+ try testing.expectEqual(qtauth, @as(u8, @intCast(dmauth >> 24)));
+ try testing.expectEqual(qttmp, @as(u8, @intCast(dmtmp >> 24)));
+ try testing.expectEqual(@as(u32, 0o777), dmperm);
+}
+
+// ===========================================================================
+// THE SERVER HALF
+// ===========================================================================
+//
+// Everything above is the wire. Everything below turns a stream of those
+// messages into `acmefs.Req` and back.
+//
+// IT IS A SANS-IO STATE MACHINE and it never touches a descriptor: the caller
+// pushes bytes in with `push`, pumps requests through the core with
+// `retry`/`next`/`reply`, and takes bytes out with `output`/`wrote`. There is
+// no socket here, no poll, no thread and no allocator, which is the whole
+// point — the same code serves a unix socket on Linux, a TCP connection from
+// another machine, and the board's UART, and the transport-specific part is
+// two syscalls in the caller.
+//
+// WHAT IT IS NOT. It is not a filesystem: every question about what a file
+// MEANS belongs to `acmefs.zig`, and this half knows only that a node id is a
+// u64, that some nodes are directories, and that a reply may say "ask me
+// later". There are exactly two exceptions, both named and both forced by the
+// absence of a kernel: the root's node id, which arrives as a parameter to
+// `init`, and `parentOf`, which is where `..` goes.
+//
+// Verified against `u9fs/u9fs.c` (the tree and the offset rules),
+// `linux/net/9p/{client,error}.c` (what a real client does with our answers),
+// `principia-softwarica/lib_networking/lib9p/srv.c` (flush ordering) and
+// `ad/crates/ninep/src/sansio/server.rs` (the scars in its git history).
+
+// ---------------------------------------------------------------------------
+// the error ABI
+// ---------------------------------------------------------------------------
+//
+// `Rerror` carries a STRING and base 9P2000 has no number beside it, so the
+// WORDING IS THIS SERVER'S ERROR ABI. Linux recovers an errno by exact match
+// against a fixed table and a miss is not `EIO` but `ESERVERFAULT`, which
+// userspace prints as "Unknown error 526" — so every string below is copied
+// character for character out of `linux/net/9p/error.c:41-171`, with the errno
+// it maps to named beside it.
+//
+// THIS WAS A CHOICE and `docs/registry.typ` `9P-4` left it open with three
+// candidates. This is OPTION A. Option B was to serve 9P2000.u and send the
+// number, which also restores `Tstatfs` — the one `acmefs.Op` with no
+// base-9P2000 message — and keeps a human-readable string for Plan 9 clients;
+// it costs a second dialect inside every parser above, because `.u` changes
+// the LAYOUT of `Rerror` and `stat` rather than adding messages. Option C was
+// acme's own wording (`Ebadctl`, `Ebadaddr`, `Ebadevent`), which is a table
+// miss for every one of them; that is what `ad` shipped, so under
+// `mount -t 9p` every error it can produce arrives as 526.
+//
+// The cost of option A is stated plainly: English kernel strings become this
+// project's error ABI, and a script reading `event` sees "Invalid argument"
+// where acme would have said something about a read too small. If `.u` is ever
+// served this table stays as it is — `.u`'s `Rerror` carries the string too.
+
+/// EBADF. The fid a message names was never walked to, or has been clunked.
+/// u9fs spells it `Ebadfid` (`u9fs.c:119`).
+pub const e_unknown_fid = "fid unknown or out of range";
+/// EBADF. `Tattach` or `Twalk` named a `newfid` that is already bound. u9fs
+/// `Efidactive` (`u9fs.c:124`).
+pub const e_fid_in_use = "fid already in use";
+/// EBADF. A fid used for something its state does not allow: read on a fid
+/// that was never opened, write on one opened `OREAD`, walk from an open one.
+/// u9fs `Ebadusefid` (`u9fs.c:121`), whose five uses are ours.
+pub const e_bad_use = "bad use of fid";
+/// ESPIPE. A directory read at an offset that is neither zero nor exactly
+/// where the last one ended. u9fs `Ebadoffset` (`u9fs.c:763`).
+pub const e_bad_offset = "bad offset in directory read";
+/// EACCES. The permission bits the core reported do not admit this open, or
+/// the message asked for something a generated tree cannot do: create, remove,
+/// remove-on-close, execute.
+pub const e_perm = "permission denied";
+/// ENOTDIR. A walk with names from a fid that is not a directory.
+pub const e_not_dir = "not a directory";
+/// ETXTBSY. A second `Topen` on one fid. The fid IS the open, so there is
+/// nothing for the second one to mean.
+pub const e_already_open = "file already open for I/O";
+/// ENAMETOOLONG. A walk element longer than `name_max`. No name in this tree
+/// is, so this is a client asking for something that cannot exist — refused on
+/// its length rather than looked up, because the fid has to be able to hold
+/// the name it lands on (`Rstat` carries it).
+pub const e_illegal_name = "illegal name";
+/// ENFILE. The fid table is full. Thirty-two is a lot of scripts.
+pub const e_too_many_fids = "Too many open files in system";
+/// EPROTO. Not 9P2000 on this connection: an R-message arriving at a server, a
+/// type byte no dialect we serve defines, a body that does not parse, a
+/// message larger than the negotiated msize, or anything at all before
+/// `Tversion`.
+pub const e_botch = "protocol botch";
+/// EINTR. What a flushed request is answered with, immediately before its
+/// `Rflush`. The same answer `fuse.zig:1338` gives a `FUSE_INTERRUPT`.
+pub const e_interrupted = "Interrupted system call";
+/// EPERM. A `Twstat` carrying a non-zero length. The core honours exactly one
+/// field and only the value zero (`acmefs.zig:1096-1104`), and this string
+/// says so in a wording Linux already knows.
+pub const e_trunc_only = "only support truncation to zero length";
+/// EPERM. A `Twstat` that would rename, or otherwise change the shape of a
+/// tree that follows the pane list.
+pub const e_wstat = "wstat prohibited";
+/// EAGAIN. The park table is full, or the core parked a request whose payload
+/// is too large to copy into a slot. Both are honest to a client: retry.
+pub const e_again = "Resource temporarily unavailable";
+/// EINVAL. A read whose count cannot hold the first thing the answer consists
+/// of — one directory entry. Refused rather than answered short, because a
+/// `Tread` returning zero bytes is END OF DIRECTORY and a client that believes
+/// it stops asking. The same rule `acmefs` already applies to an `event` read
+/// too small for one record, and `9P-17` records that this is what makes the
+/// clamp to `count` safe.
+pub const e_count_small = "Invalid argument";
+/// ENOENT. `Tattach` named an `aname`. There is one tree here and it has no
+/// name; a client that asked for a different one should learn that now rather
+/// than be handed this one (docs/9p.typ §12.4: "no `aname`").
+pub const e_no_tree = "No such file or directory";
+/// Not in Linux's table, and deliberately: Linux's client never sends `Tauth`
+/// at all, and Plan 9's `mount` treats an error here as "no authentication
+/// needed" and carries on. So this string is chosen for the human reading a
+/// Plan 9 error message rather than for `p9_errstr2errno`. u9fs says the same
+/// thing in a string that maps to zero — "not an error" — which is a subtlety
+/// we do not need. Real authentication is `Tauth` or a tunnel, and neither is
+/// ours (docs/9p.typ §10).
+pub const e_no_auth = "authentication not required";
+/// EINVAL. `Tversion` offered an msize too small to serve (see `msize_min`).
+/// `Rversion` has no way to say this — its `version` field means "no dialect
+/// in common", which is a different fact — and `Rerror` is a legal reply to
+/// any T-message, so this is the honest channel.
+pub const e_small_msize = "Invalid argument";
+
+/// The core's numeric errno as the string Linux turns back into that same
+/// number. Every value `acmefs.E` defines is here by name; anything else
+/// becomes EIO, because a number we did not choose to emit is a bug in this
+/// file and "Input/output error" is the one answer that is never misleading.
+pub fn errString(errno: u16) []const u8 {
+ return switch (errno) {
+ 1 => "Operation not permitted", // E.PERM, EPERM
+ 2 => "No such file or directory", // E.NOENT, ENOENT
+ 5 => "Input/output error", // E.IO, EIO
+ 12 => "Cannot allocate memory", // E.NOMEM, ENOMEM
+ 20 => "Not a directory", // E.NOTDIR, ENOTDIR
+ 22 => "Invalid argument", // E.INVAL, EINVAL
+ 23 => "Too many open files in system", // E.NFILE, ENFILE
+ 28 => "No space left on device", // E.NOSPC, ENOSPC
+ 38 => "Function not implemented", // E.NOSYS, ENOSYS
+ else => "Input/output error",
+ };
+}
+
+// ---------------------------------------------------------------------------
+// open modes
+// ---------------------------------------------------------------------------
+//
+// `Topen.mode`, from `u9fs/plan9.h:146-153`. The codec above carries the byte
+// and has no opinion about it; these are what the byte MEANS, which is the
+// server's business.
+
+/// The low two bits, which are a VALUE and not a mask: 0, 1, 2, 3.
+pub const oread: u8 = 0;
+pub const owrite: u8 = 1;
+pub const ordwr: u8 = 2;
+/// «execute, == read but check execute permission». Nothing in this tree is a
+/// program, so it is refused rather than treated as a read.
+pub const oexec: u8 = 3;
+/// Or'ed in. Truncate first — this is how a shell's `>` reaches a 9P server,
+/// and it maps onto `acmefs.Req.truncate` exactly as a `Twstat` with a zero
+/// length does (docs/registry.typ `FIX-1`).
+pub const otrunc: u8 = 16;
+/// Or'ed in, close on exec. A CLIENT-SIDE flag: Plan 9's kernel consumes it
+/// and never sends it, so a server that sees it may ignore it, and we do.
+pub const ocexec: u8 = 32;
+/// Or'ed in, remove on close. Refused: this tree's shape follows the pane list
+/// and there is nothing in it a client may remove.
+pub const orclose: u8 = 64;
+
+// ---------------------------------------------------------------------------
+// sizes
+// ---------------------------------------------------------------------------
+
+/// Fids one connection may hold at once. A FIXED ARRAY and not a map, costed
+/// in `docs/registry.typ` `9P-11`: a linear scan of thirty-two is about 0.4 µs
+/// and the wire is slower than that by orders of magnitude, so the map would
+/// buy nothing and cost an allocator this file does not have.
+///
+/// Overflow is a refusal (`e_too_many_fids`), not a queue.
+pub const max_fids: usize = 32;
+
+/// Requests that may be outstanding at once — in flight, or parked because the
+/// core answered `.again`. In practice this counts BLOCKED READERS: one slot
+/// per process sitting on `event`. The number is `src/fuse.zig:793`'s,
+/// unchanged, because `Status.again` means the same thing to both transports.
+pub const max_slots: usize = 32;
+
+/// Bytes of request payload a park slot owns. A parked request's `data` cannot
+/// go on borrowing the input buffer — the next message overwrites it — so it
+/// is copied in when it fits.
+///
+/// Smaller than `fuse.zig`'s 512, for a reason specific to this file: under
+/// FUSE a LOOKUP name is a payload and may be 255 bytes, while a 9P walk
+/// element is consumed inside the walk and never parks. What is left is a
+/// write, and the only writes that could conceivably block are a `ctl` verb
+/// line and an event write-back, both a few dozen bytes. A larger write that
+/// the core tries to park is answered `e_again` — honest, and by construction
+/// unreachable, since the core answers writes as transactions.
+pub const park_data_max: usize = 128;
+
+/// The longest name a fid may land on, and the longest `uname` we keep.
+///
+/// A BOUND rather than a buffer size: `Rstat` carries the file's name, so a
+/// fid has to hold the name it walked to, and this is the number that makes
+/// `msize_min` provable. Every name in the tree fits with room over — the
+/// longest are a pane's decimal serial and `errors` — so a walk element longer
+/// than this is refused as `e_illegal_name` rather than looked up and then
+/// truncated, which would make `Rstat` lie.
+pub const name_max: usize = 28;
+
+/// The smallest msize this server will agree to serve. DERIVED, not chosen:
+/// `Rwalk` with the protocol's sixteen qids is the largest reply whose size
+/// the client cannot influence after the handshake, so a connection that
+/// cannot hold one cannot be served at all.
+///
+/// Deliberately NOT `min_msize` (4096), which is the LINUX KERNEL's floor and
+/// nobody else's: Plan 9's devmnt, plan9port's `9p` and pardes's own client all
+/// accept 512, and the board would rather have the kilobytes back.
+pub const msize_min: u32 = header_len + 2 + max_welem * qid_len;
+
+comptime {
+ assert(msize_min == 217);
+ // The other two replies whose size the client does not choose: `Rstat`
+ // carries one record with four strings, and a directory read must fit at
+ // least one such record or it can never make progress. Both must clear
+ // `msize_min`, or the floor above is not a floor.
+ assert(header_len + 2 + stat_fixed + 4 * name_max <= msize_min);
+ assert(header_len + 4 + stat_fixed + 4 * name_max <= msize_min);
+ // A pane serial is a u60, so its decimal name is at most twenty digits and
+ // `parentOf` cannot overflow the buffer it formats into.
+ assert(name_max >= 20);
+ // Modes are a value in the low two bits with flags above them.
+ assert(oread | owrite | ordwr | oexec == 3);
+ assert(otrunc | ocexec | orclose == 112);
+}
+
+/// The server's name for a node.
+///
+/// `qid.version` IS ALWAYS ZERO, and this is a policy rather than a
+/// translation. It is the 9P equivalent of the `FOPEN_DIRECT_IO` that
+/// `src/fuse.zig:172-176` relies on, and it is server-side rather than advice
+/// to whoever mounts: Linux's client sets `P9L_DIRECT` — «no read or write
+/// cache» — for any file whose qid version is zero, whatever the cache mode,
+/// unless `ignoreqv` is passed explicitly (`linux/fs/9p/fid.h:52-53`,
+/// `v9fs.c:93`). A synthetic tree of live editor state has no business being
+/// cached: `body` changes under the reader's feet, `event` is a queue, and a
+/// cached lookup under `new/` would create one pane and then serve the same
+/// answer forever. Plan 9 needs nothing said to it — its cache is opt-in via
+/// `mount -c` (docs/registry.typ `9P-3`).
+///
+/// `qid.path` is the core's node id UNCHANGED, which is what makes the two
+/// transports agree: one integer is a FUSE nodeid, a `d_ino` and a qid path at
+/// once, and it never comes to mean a different file because pane serials are
+/// never reused (`acmefs.zig:231-237`).
+fn qidOf(node: u64, dir: bool) Qid {
+ return .{ .type = if (dir) qtdir else qtfile, .version = 0, .path = node };
+}
+
+/// Where `..` goes, and the ONE place in this file that decodes a node id.
+///
+/// WHY THIS IS HERE AT ALL, because it is the fact that gets lost: under FUSE
+/// the kernel resolves `.` and `..` in the pathname before a request is ever
+/// sent, which is what lets `acmefs.zig:840` say they «are the kernel's
+/// business, never ours». Under 9P THERE IS NO KERNEL. `Twalk` carries `..` as
+/// an ordinary name element, and a client that normalises a path, or walks up
+/// before walking down, sends it. Forwarding it to the core as a lookup would
+/// answer `ENOENT` and break `cd ..`, so the server answers it.
+///
+/// It can, without asking anything, because the tree has fixed depth and the
+/// node id says where you are. `acmefs.Node` is
+/// `packed struct(u64){ file: u4, serial: u60 }` (`acmefs.zig:238-240`), so
+/// `file` is the low four bits and `serial` is everything above them, and:
+///
+/// * at the root, `..` is the root. POSIX's rule and `intro(5)`'s: the root
+/// is its own parent, and this is not an error.
+/// * `serial == 0` is a top-level file (`index`, `cons`, `new`), whose
+/// parent is the root.
+/// * `file == 0` is `PaneFile.dir`, a pane's own directory, whose parent is
+/// the root.
+/// * anything else is a file inside a pane's directory, and its parent is
+/// that directory: the same serial with `file` cleared. Its NAME is the
+/// serial in decimal, which is how `acmefs`'s root lists it
+/// (`acmefs.zig:992-994`).
+///
+/// A well-behaved client only walks between directories, so the last case
+/// should never arrive; it is answered correctly rather than trusted away.
+///
+/// THE DEPTH ASSUMPTION IS THE WHOLE OF WHAT COULD ROT, and it is checked by
+/// the shape of the node id rather than by hope: `Node` has ONE `file: u4`, so
+/// a level below a pane's directory — `docs/9p.typ`'s `pty/` — cannot be
+/// encoded in a node id at all today. If that changes, this function is the
+/// one place that has to learn about it.
+fn parentOf(node: u64, root: u64, buf: *[name_max]u8) struct { node: u64, name_len: u8 } {
+ const serial = node >> 4;
+ const file = node & 0xF;
+ if (node == root or serial == 0 or file == 0) {
+ buf[0] = '/';
+ return .{ .node = root, .name_len = 1 };
+ }
+ // A u60 is twenty decimal digits at most and `name_max` is checked against
+ // that above, so the format cannot fail.
+ const name = std.fmt.bufPrint(buf, "{d}", .{serial}) catch unreachable;
+ return .{ .node = serial << 4, .name_len = @intCast(name.len) };
+}
+
+/// The permission bits a DIRECTORY ENTRY reports, and the only place in this
+/// file that reports a mode it was not told.
+///
+/// `acmefs`'s staging format for a readdir is `node[8] dir[1] namelen[1]
+/// name[]` (`acmefs.zig:942-957`) — the same record the FUSE transport decodes
+/// — and it carries no mode and no length, because under FUSE the kernel asks
+/// for those separately, with a `getattr` per entry it decides it wants. 9P
+/// puts a whole `stat` in a directory read, so the choice is between a
+/// `getattr` per entry — an extra round trip each, and a third msize buffer to
+/// hold the entries across it — and reporting the tree's own defaults here.
+///
+/// We report the defaults. `0o500` is what `TopFile.mode` and `PaneFile.mode`
+/// give every directory in the tree without exception; `0o600` is what
+/// `PaneFile.mode` gives every file but three; a length of zero is the true
+/// length of every file here but `body`, `tag` and `index`.
+///
+/// WHO SEES THE DIFFERENCE: only a client that reads permissions and sizes out
+/// of a DIRECTORY READ, which is Plan 9's `ls -l` and nothing else. Linux's
+/// v9fs takes names and qids from the read and stats each file separately,
+/// 9pfuse does the same, and `Tstat` here answers out of the core's own
+/// `getattr` — so `ls -l` through either of those is exact.
+pub const dirent_dir_perm: u16 = 0o500;
+pub const dirent_file_perm: u16 = 0o600;
+
+/// A 9P2000 server for one connection, over the filesystem ABI `fs`.
+///
+/// WHY THIS IS A GENERIC and not a plain struct that imports `acmefs.zig`:
+/// this file is freestanding-safe and must stay so — it compiles for
+/// `wasm32-freestanding` and the board's `riscv32-freestanding`, and
+/// `acmefs.zig` reaches `pardes.zig`, which reaches the build's generated
+/// modules. Importing it would also drag every test in that graph into
+/// `zig test src/9p.zig`. So the ABI arrives as a type parameter and the
+/// coupling is exactly three declarations:
+///
+/// * `fs.Req` with `tag, op, node, handle, off, size, data, truncate`
+/// * `fs.Reply` with `tag, status, errno, attr, handle, written`
+/// * `fs.Reply.Attr` with `node, dir, size, mode`
+///
+/// which is `acmefs`'s ABI verbatim, so the real instantiation is
+/// `Server(acmefs)` and it needs no translation layer at all. The `Op` and
+/// `Status` values are reached as enum literals (`.lookup`, `.again`), so they
+/// are checked against the real enums at that instantiation. The tests below
+/// instantiate it on a stub filesystem, which is how they run with no core.
+///
+/// THE THREE METHODS `src/fs_service.zig`'s `Transport` wants — `retry`,
+/// `next` and `reply` — are here with those names and those shapes, and the
+/// order contract is that file's: `retry()` to null first, then `next()` to
+/// null. `fs_service` is deliberately NOT imported (it is `std.c` and
+/// `pardes.zig` deep); the adapter that fills in a vtable is three functions
+/// in whoever owns the socket.
+///
+/// MEMORY, all of it caller-supplied or fixed: the two buffers, a fid table of
+/// `max_fids` and a park table of `max_slots`. No allocator, and nothing here
+/// grows.
+pub fn Server(comptime fs: type) type {
+ return struct {
+ const Self = @This();
+
+ /// Bytes the caller has pushed and we have not finished with.
+ /// `in[0..frame]` is the message being served when `frame != 0`, and
+ /// every slice a decoded `Msg` holds points into it — which is why
+ /// nothing compacts this buffer until that message is done with.
+ in: []u8,
+ /// Encoded replies, oldest first, as a byte FIFO. Every 9P message
+ /// carries its own length, so the queue needs no side table: the
+ /// caller writes `output()` and tells us how much went.
+ out: []u8,
+ /// The node id of the tree's root — `@intFromEnum(acmefs.TopFile.root)`
+ /// — and the one fact about the tree this file is told rather than
+ /// deriving. `Tattach` needs somewhere to start and 9P has no way to
+ /// ask for it.
+ root: u64,
+
+ in_len: usize = 0,
+ frame: u32 = 0,
+ out_len: usize = 0,
+ out_off: usize = 0,
+
+ /// Negotiated by `Tversion`; ZERO means not yet, and nothing but
+ /// `Tversion` is served in that state.
+ msize: u32 = 0,
+ /// The stream is not 9P and there is no resynchronising from it: stop
+ /// serving and let the caller close. Write-once, like `fuse.Fs.dead`.
+ dead: bool = false,
+
+ /// Whoever attached, for `Rstat`'s three name fields. The tree is
+ /// synthetic and has one owner: the client that opened the connection.
+ uname: [name_max]u8 = @splat(0),
+ uname_len: u8 = 0,
+
+ fids: [max_fids]Fid = @splat(.{}),
+ slots: [max_slots]Slot = @splat(.{}),
+ /// The message being served. At most one, which is what keeps the
+ /// walk's accumulated qids and the borrowed names in one place instead
+ /// of in thirty-two slots.
+ job: Job = .{},
+ /// Hands out `fs.Req.tag`s, and orders the park table. Never zero, so
+ /// that zero can mean "no request outstanding".
+ seq: u64 = 0,
+
+ /// What a 9P message is being turned into. The reply's SHAPE, which is
+ /// what `reply` needs and what `Op` alone does not say: a `getattr` is
+ /// a step of `Rattach`, of `Rwalk` and of `Rstat`.
+ const Kind = enum { none, attach, walk, open, read, readdir, write, clunk, remove, stat, wstat };
+
+ /// One fid: a name the client gave a place in the tree.
+ ///
+ /// `perm` and `dir` are cached from the attributes the walk that landed
+ /// here already answered, because `Topen` has to check permission
+ /// itself — there is no kernel above us doing it, and `acmefs.open`
+ /// deliberately does not (`acmefs.zig:1023-1031`). `name` is cached
+ /// because `Rstat` carries it and a node id does not.
+ const Fid = struct {
+ used: bool = false,
+ /// The client's number. `nofid` is never one.
+ fid: u32 = 0,
+ node: u64 = 0,
+ dir: bool = false,
+ /// Permission bits as the core last reported them, which is what
+ /// `Topen` is checked against.
+ perm: u16 = 0,
+ open: bool = false,
+ /// The `Topen` mode, valid when `open`.
+ omode: u8 = 0,
+ /// `acmefs`'s open handle, repeated on every read, write and
+ /// release.
+ handle: u32 = 0,
+ /// THE DIRECTORY CURSOR, in the two coordinate systems it has to
+ /// live in at once: `diroff` is the BYTE offset 9P requires the
+ /// next read to carry, and `dirindex` is the ENTRY INDEX `acmefs`
+ /// counts in (`acmefs.zig:972`, `var skip = req.off;`).
+ diroff: u64 = 0,
+ dirindex: u32 = 0,
+ /// This fid has no client any more and still owes the core a
+ /// `release`. See `orphan`.
+ orphan: bool = false,
+ name: [name_max]u8 = @splat(0),
+ name_len: u8 = 0,
+ };
+
+ /// A request the core would not answer yet. Lifted from
+ /// `src/fuse.zig:806-822` with the FUSE opcode replaced by the 9P tag
+ /// and the reply shape, because `Status.again` means the same thing to
+ /// both transports and this is where `docs/registry.typ` `9P-16` says
+ /// we beat the prior art.
+ const Slot = struct {
+ used: bool = false,
+ /// The core answered `.again`; `retry()` will offer it back.
+ parked: bool = false,
+ /// Already offered in this retry round. Reset when a round finds
+ /// nothing, which gives every parked request exactly one attempt
+ /// per frame instead of letting the oldest starve the rest.
+ retried: bool = false,
+ /// `req.data` points into `data` below rather than into `in`.
+ copied: bool = false,
+ /// Arrival order, so retries are FIFO: the reader that blocked
+ /// first is offered first.
+ seq: u64 = 0,
+ /// The client's tag, which is what `Tflush` names.
+ tag: u16 = 0,
+ kind: Kind = .none,
+ /// The client's fid NUMBER and not an index: the fid may be
+ /// clunked while this is parked, and a stale index would be a
+ /// stale pointer.
+ fid: u32 = 0,
+ /// What the client asked for, which is what the answer is clamped
+ /// to (`docs/registry.typ` `9P-17`).
+ count: u32 = 0,
+ req: fs.Req = undefined,
+ data: [park_data_max]u8 = undefined,
+ };
+
+ /// The message in flight, and the accumulated answer.
+ const Job = struct {
+ kind: Kind = .none,
+ tag: u16 = 0,
+ /// The `fs.Req.tag` of the step the core is holding, or zero.
+ req_tag: u64 = 0,
+ /// The step itself, kept so that a park has something to copy and
+ /// a retry has something to re-offer.
+ req: fs.Req = undefined,
+ step: u8 = 0,
+ fid: u32 = 0,
+ newfid: u32 = 0,
+ count: u32 = 0,
+ offset: u64 = 0,
+ omode: u8 = 0,
+ /// Where the walk has got to: the node, its attributes and its
+ /// name, all of which `Rwalk`'s last qid and the bound fid need.
+ node: u64 = 0,
+ dir: bool = false,
+ perm: u16 = 0,
+ name: [name_max]u8 = @splat(0),
+ name_len: u8 = 0,
+ nwname: u8 = 0,
+ nwqid: u8 = 0,
+ wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }),
+ /// The decoded T-message, BORROWING `in[0..frame]`: a walk's names
+ /// and a write's bytes live here and nowhere else.
+ msg: Msg = .rflush,
+ };
+
+ pub const Options = struct {
+ /// Room for one whole T-message. Caps the msize we will agree to,
+ /// with `out`.
+ in: []u8,
+ /// Room for two: one being written out and one being built. That
+ /// is what lets a reply be encoded the moment the core answers,
+ /// with no "can I write yet" question anywhere in this file.
+ out: []u8,
+ /// `@intFromEnum(acmefs.TopFile.root)`.
+ root: u64,
+ };
+
+ /// The buffers are the caller's, which is what "no allocator" means
+ /// here: the board hands over two static arrays, a desktop host hands
+ /// over two heap slices sized for a 128 KiB msize, and this file cannot
+ /// tell the difference. The msize follows from them and from the
+ /// client's `Tversion`; see `version`.
+ pub fn init(opts: Options) Self {
+ assert(opts.in.len >= msize_min);
+ assert(opts.out.len >= 2 * msize_min);
+ // Node zero is `acmefs.Node{}` — no file, no pane — and cannot be
+ // a root. A zero here would make every `..` land on nothing.
+ assert(opts.root != 0);
+ return .{ .in = opts.in, .out = opts.out, .root = opts.root };
+ }
+
+ /// The connection went away. Every open fid still owes the core a
+ /// `release`, and that debt outlives the connection: an `event` fid
+ /// dropped without one leaves the pane's reader count high forever,
+ /// which leaves the editor reporting button actions to a script that
+ /// is no longer there (`acmefs.zig:1053-1061`). So the fids are
+ /// ORPHANED rather than forgotten, and the caller keeps pumping
+ /// `next()` until it answers null.
+ pub fn hangup(s: *Self) void {
+ s.reset();
+ s.dead = true;
+ s.in_len = 0;
+ s.frame = 0;
+ s.out_len = 0;
+ s.out_off = 0;
+ }
+
+ /// What `Tversion` does to the connection, and what `hangup` does
+ /// first: «all fids are clunked and any outstanding I/O is abandoned»
+ /// (`version(5)`). The parked requests go without an answer, which is
+ /// exactly what abandoned means; the fids that are open become
+ /// orphans, because the core's side of an open is not the client's to
+ /// abandon.
+ fn reset(s: *Self) void {
+ for (&s.fids) |*f| {
+ if (!f.used) continue;
+ if (f.open) f.orphan = true else f.* = .{};
+ }
+ for (&s.slots) |*sl| sl.* = .{};
+ s.job = .{};
+ }
+
+ // -- bytes in, bytes out ---------------------------------------------
+
+ /// Take as much of `bytes` as there is room for, and answer how much.
+ /// A short answer is not an error and not a loss: it is the only
+ /// back-pressure a sans-io server has, and the caller re-offers the
+ /// tail after pumping. Bytes are APPENDED, so a message already being
+ /// served does not move.
+ pub fn push(s: *Self, bytes: []const u8) usize {
+ if (s.dead) return 0;
+ const n = @min(bytes.len, s.in.len - s.in_len);
+ @memcpy(s.in[s.in_len..][0..n], bytes[0..n]);
+ s.in_len += n;
+ return n;
+ }
+
+ /// The replies waiting to go, oldest first, as one contiguous run of
+ /// whole 9P messages. Valid until the next call to anything else here.
+ pub fn output(s: *const Self) []const u8 {
+ return s.out[s.out_off..s.out_len];
+ }
+
+ /// How many of `output()`'s bytes actually left. A partial write is
+ /// normal on a UART and on a full socket, and the remainder stays put.
+ pub fn wrote(s: *Self, n: usize) void {
+ assert(n <= s.out_len - s.out_off);
+ s.out_off += n;
+ if (s.out_off == s.out_len) {
+ s.out_off = 0;
+ s.out_len = 0;
+ }
+ }
+
+ /// Slide the unwritten tail down. Called only when room is wanted, so
+ /// the common case — a fully written queue, reset to empty by `wrote` —
+ /// never moves a byte.
+ fn compact(s: *Self) void {
+ assert(s.out_off <= s.out_len);
+ const n = s.out_len - s.out_off;
+ std.mem.copyForwards(u8, s.out[0..n], s.out[s.out_off..s.out_len]);
+ s.out_off = 0;
+ s.out_len = n;
+ }
+
+ /// THE RESERVATION RULE, and the reason no reply in this file can ever
+ /// fail to be written: a request is not handed to the core unless the
+ /// out queue already has room for the largest answer it could produce,
+ /// which is one msize. So `emit` cannot run out, a parked read that
+ /// completes cannot be dropped, and back-pressure lands where it can
+ /// be dealt with — `next()` and `retry()` answer null, the caller
+ /// writes some bytes, and the pump continues.
+ fn hasRoom(s: *Self) bool {
+ if (s.out_off != 0) s.compact();
+ return s.out.len - s.out_len >= @max(s.msize, msize_min);
+ }
+
+ /// Queue one reply. Infallible by the reservation rule above; if it
+ /// ever is not, the connection dies rather than the stream growing a
+ /// half-written message — a dropped reply hangs a client forever,
+ /// while a closed connection makes it fail and say so.
+ fn emit(s: *Self, tag: u16, msg: Msg) void {
+ const bytes = encode(msg, tag, s.out[s.out_len..]) catch {
+ s.dead = true;
+ return;
+ };
+ s.out_len += bytes.len;
+ }
+
+ fn fail(s: *Self, tag: u16, ename: []const u8) void {
+ assert(ename.len <= errmax);
+ s.emit(tag, .{ .rerror = .{ .ename = ename } });
+ }
+
+ /// The next `fs.Req.tag`. Unique for the life of the connection, which
+ /// is what lets `reply` find its target with no cooperation from the
+ /// core, and never zero.
+ fn tick(s: *Self) u64 {
+ s.seq += 1;
+ return s.seq;
+ }
+
+ fn findFid(s: *Self, fid: u32) ?usize {
+ for (&s.fids, 0..) |*f, i| if (f.used and !f.orphan and f.fid == fid) return i;
+ return null;
+ }
+
+ fn freeFid(s: *Self) ?usize {
+ for (&s.fids, 0..) |*f, i| if (!f.used) return i;
+ return null;
+ }
+
+ fn dropFid(s: *Self, fid: u32) void {
+ if (s.findFid(fid)) |i| s.fids[i] = .{};
+ }
+
+ fn findSlot(s: *Self, req_tag: u64) ?usize {
+ for (&s.slots, 0..) |*sl, i| if (sl.used and sl.req.tag == req_tag) return i;
+ return null;
+ }
+
+ fn freeSlot(s: *Self) ?usize {
+ for (&s.slots, 0..) |*sl, i| if (!sl.used) return i;
+ return null;
+ }
+
+ /// A parked request by the tag the CLIENT gave it, which is what
+ /// `Tflush` names.
+ fn findTag(s: *Self, tag: u16) ?usize {
+ for (&s.slots, 0..) |*sl, i| if (sl.used and sl.tag == tag) return i;
+ return null;
+ }
+
+ fn setUname(s: *Self, uname: []const u8) void {
+ const n = @min(uname.len, name_max);
+ @memcpy(s.uname[0..n], uname[0..n]);
+ s.uname_len = @intCast(n);
+ }
+
+ // -- the transport seam ----------------------------------------------
+
+ /// Offer parked requests back, one per call, in arrival order. Call in
+ /// a loop until null, once per frame, BEFORE `next()`: the null both
+ /// ends the round and resets it, so every parked request gets exactly
+ /// one attempt per frame and a permanently blocked reader cannot
+ /// starve the others. `src/fs_service.zig:196-208` is the contract and
+ /// `src/fuse.zig:1166` is the other implementation of it.
+ pub fn retry(s: *Self) ?fs.Req {
+ var best: ?usize = null;
+ for (&s.slots, 0..) |*sl, i| {
+ if (!sl.used or !sl.parked or sl.retried) continue;
+ if (best == null or sl.seq < s.slots[best.?].seq) best = i;
+ }
+ const i = best orelse {
+ for (&s.slots) |*sl| sl.retried = false;
+ return null;
+ };
+ // No room for the answer is the end of the round too, and it must
+ // reset it: leaving the flags set would make the next frame skip
+ // the requests this one never reached.
+ if (!s.hasRoom()) {
+ for (&s.slots) |*sl| sl.retried = false;
+ return null;
+ }
+ s.slots[i].retried = true;
+ // In flight again: `reply` re-parks it if the core still has
+ // nothing to say.
+ s.slots[i].parked = false;
+ return s.slots[i].req;
+ }
+
+ /// The next request off the wire, or null when there is nothing more to
+ /// do with the bytes pushed so far. Call in a loop until null.
+ ///
+ /// ONE 9P MESSAGE IS NOT ONE REQUEST, which is the whole reason this is
+ /// a state machine: a three-element `Twalk` is three lookups, a
+ /// `Topen` with `OTRUNC` is a truncate and then an open, and a
+ /// `Tversion` is none at all. So this pump decodes a message when it
+ /// needs one, hands out its steps as the core answers them, and
+ /// answers null only when the input is exhausted, the queue is full, or
+ /// the core is holding a step.
+ pub fn next(s: *Self) ?fs.Req {
+ while (true) {
+ if (s.job.kind != .none) {
+ // A step is out with the core; the caller owes us a
+ // `reply` before there is anything else to ask.
+ if (s.job.req_tag != 0) return null;
+ if (s.stepJob()) |req| return req;
+ // The job answered itself — a walk that finished, an error
+ // — and `stepJob` cleared it. Round again for the next
+ // message.
+ assert(s.job.kind == .none);
+ continue;
+ }
+ if (s.orphan()) |req| return req;
+ if (!s.hasRoom()) return null;
+ if (!s.startFrame()) return null;
+ }
+ }
+
+ /// Answer one request: queue the 9P reply it completes, advance the
+ /// message it is a step of, or park it. `bytes` is the payload the
+ /// core resolved and is borrowed for the duration of this call only —
+ /// the same rule `src/fs_service.zig:224-229` states for the FUSE
+ /// transport.
+ pub fn reply(s: *Self, r: *const fs.Reply, bytes: []const u8) void {
+ if (s.job.kind != .none and s.job.req_tag == r.tag) return s.jobReply(r, bytes);
+ if (s.findSlot(r.tag)) |i| return s.slotReply(i, r, bytes);
+ // An orphan's release, a park `Tversion` abandoned, or a request
+ // `Tflush` already answered. Nothing to say and nobody to say it
+ // to; `fuse.zig:1189` drops the same case for the same reason.
+ }
+
+ /// A `release` nobody is waiting for: the fid it belonged to is gone
+ /// (the connection dropped, or `Tversion` reset it) but the core's
+ /// open is not.
+ ///
+ /// The slot is freed HERE rather than when the answer lands, because
+ /// nothing in the answer is wanted and `reply` already ignores a tag it
+ /// no longer holds. That also means a release the core parks is
+ /// dropped, which is the same trade `fuse.zig` makes for a write: a
+ /// release is a transaction in this design and does not block.
+ fn orphan(s: *Self) ?fs.Req {
+ for (&s.fids) |*f| {
+ if (!f.used or !f.orphan) continue;
+ assert(f.open);
+ const req: fs.Req = .{
+ .tag = s.tick(),
+ .op = .release,
+ .node = f.node,
+ .handle = f.handle,
+ };
+ f.* = .{};
+ return req;
+ }
+ return null;
+ }
+
+ /// Decode the message at the head of `in` and start serving it. False
+ /// when there is not a whole one there yet.
+ ///
+ /// The frame stays in `in` for as long as the message is being served,
+ /// because every string in a decoded `Msg` points into it. The `defer`
+ /// is what makes that airtight: a message that answered itself here
+ /// releases the frame immediately, and one that became a job hands the
+ /// frame to the job, which releases it in `finishJob` or copies what it
+ /// needs in `parkJob`.
+ fn startFrame(s: *Self) bool {
+ assert(s.job.kind == .none);
+ assert(s.frame == 0);
+ if (s.dead) return false;
+ const len = frameLen(s.in[0..s.in_len]) orelse return false;
+ // A `size` no encoder produced, or one this connection could never
+ // buffer: either way the stream is not 9P and waiting for more of
+ // it is waiting forever.
+ if (len < header_len or len > s.in.len) {
+ s.dead = true;
+ return false;
+ }
+ if (len > s.in_len) return false;
+ s.frame = len;
+ defer if (s.job.kind == .none) s.dropFrame();
+
+ const got = decode(s.in[0..len]) catch {
+ // The tag sits at a fixed offset and survives every way the
+ // body can be wrong, so the client still gets an answer rather
+ // than a hang. `len >= header_len` was checked above.
+ s.fail(std.mem.readInt(u16, s.in[5..7], .little), e_botch);
+ return true;
+ };
+ // A server reads T-messages. An R-message here is a client on the
+ // wrong end of the connection, or the double-role link
+ // docs/9p.typ §7 tells us not to build.
+ if (!isT(got.msg.msgType())) {
+ s.fail(got.tag, e_botch);
+ return true;
+ }
+ // «The client must communicate the version before any other
+ // messages» — and until it has, there is no msize to bound
+ // anything by.
+ if (s.msize == 0 and got.msg != .tversion) {
+ s.fail(got.tag, e_botch);
+ return true;
+ }
+ if (s.msize != 0 and len > s.msize) {
+ s.fail(got.tag, e_botch);
+ return true;
+ }
+ s.dispatch(got);
+ return true;
+ }
+
+ /// Release the served frame and slide the rest of the input down. The
+ /// move is one message long and happens once per message; the
+ /// alternative is a ring buffer, which would mean a decoded `Msg`
+ /// could straddle the wrap and no longer be one slice.
+ fn dropFrame(s: *Self) void {
+ assert(s.frame != 0);
+ assert(s.frame <= s.in_len);
+ const n = s.frame;
+ std.mem.copyForwards(u8, s.in[0 .. s.in_len - n], s.in[n..s.in_len]);
+ s.in_len -= n;
+ s.frame = 0;
+ }
+
+ // -- the messages ----------------------------------------------------
+
+ /// One T-message onto its handler. Every message either answers itself
+ /// here or becomes `job`.
+ fn dispatch(s: *Self, got: Decoded) void {
+ switch (got.msg) {
+ .tversion => |m| s.version(got.tag, m.msize, m.version),
+ // REFUSED, all three, and each for its own reason.
+ //
+ // `Tauth`: there is no authentication here and there is not
+ // going to be one in this file. The socket's permissions are
+ // the protection and a network is tunnelled (docs/9p.typ §10).
+ //
+ // `Tcreate` and `Tremove`: the shape of this tree follows the
+ // pane list, so there is nothing in it for a client to make or
+ // unmake. The one place a client DOES create something is
+ // `new/`, where walking to a name is what creates a pane
+ // (`acmefs.zig:901-919`) — so the capability is there and it
+ // is not spelled `Tcreate`. That answers the open question in
+ // `docs/registry.typ` `9P-18`, and it takes most of `ad`'s
+ // shipped-and-fixed bug list off the table with it.
+ .tauth => s.fail(got.tag, e_no_auth),
+ .tcreate => s.fail(got.tag, e_perm),
+ .tattach => |m| s.attach(got.tag, m.fid, m.uname, m.aname),
+ .tflush => |m| s.flush(got.tag, m.oldtag),
+ .twalk => |m| s.walk(got, m.fid, m.newfid, @intCast(m.nwname)),
+ .topen => |m| s.open(got.tag, m.fid, m.mode),
+ .tread => |m| s.read(got.tag, m.fid, m.offset, m.count),
+ .twrite => |m| s.write(got, m.fid, m.offset, m.data.len),
+ .tclunk => |m| s.clunk(got.tag, m.fid, .clunk),
+ // A remove clunks the fid too — see `clunk` — which is the
+ // half of `remove(5)` that is easy to miss.
+ .tremove => |m| s.clunk(got.tag, m.fid, .remove),
+ .tstat => |m| s.stat(got.tag, m.fid),
+ .twstat => |m| s.wstat(got.tag, m.fid, m.stat),
+ // The R-variants, which `startFrame` already refused by
+ // parity. Answered rather than `unreachable`, because the cost
+ // of being wrong about that is a panic in a server.
+ else => s.fail(got.tag, e_botch),
+ }
+ }
+
+ /// `Tversion`: the msize handshake, and a connection reset.
+ fn version(s: *Self, tag: u16, want: u32, ver: []const u8) void {
+ // Three ceilings and the smallest wins: what the client will
+ // accept, what one input buffer holds, and half of what the output
+ // queue holds (`Options.out`).
+ const cap: u32 = @intCast(@min(s.in.len, s.out.len / 2));
+ const m = @min(want, cap);
+ if (m < msize_min) return s.fail(tag, e_small_msize);
+ // u9fs `rversion`: any version string that STARTS with "9P" is
+ // answered "9P2000", which is how a `.u` or `.L` client is told to
+ // fall back to the base protocol. Anything else has no dialect in
+ // common with us, and that is a SUCCESSFUL `Rversion` carrying the
+ // literal "unknown" rather than an `Rerror`.
+ const known = std.mem.startsWith(u8, ver, "9P");
+ s.reset();
+ // The msize only becomes real once a version is agreed: after
+ // "unknown" the client must negotiate again, and `startFrame`
+ // serves nothing else until it does.
+ s.msize = if (known) m else 0;
+ s.emit(tag, .{ .rversion = .{ .msize = m, .version = if (known) "9P2000" else "unknown" } });
+ }
+
+ fn attach(s: *Self, tag: u16, fid: u32, uname: []const u8, aname: []const u8) void {
+ // No `aname`. There is one tree here and it has no name; a client
+ // that asked for another one is told so rather than handed this.
+ if (aname.len != 0) return s.fail(tag, e_no_tree);
+ if (fid == nofid) return s.fail(tag, e_unknown_fid);
+ if (s.findFid(fid) != null) return s.fail(tag, e_fid_in_use);
+ if (s.freeFid() == null) return s.fail(tag, e_too_many_fids);
+ s.setUname(uname);
+ // The root's attributes come from the core like every other node's.
+ // Its node id is the only thing we were told (see `root`).
+ s.job = .{ .kind = .attach, .tag = tag, .fid = fid, .node = s.root };
+ }
+
+ fn walk(s: *Self, got: Decoded, fid: u32, newfid: u32, nwname: u8) void {
+ const tag = got.tag;
+ const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid);
+ // «must not have been opened for I/O» — walk(5). The fid IS the
+ // open, so a walk would move the file out from under it.
+ if (s.fids[i].open) return s.fail(tag, e_bad_use);
+ if (newfid == nofid) return s.fail(tag, e_unknown_fid);
+ if (newfid != fid) {
+ if (s.findFid(newfid) != null) return s.fail(tag, e_fid_in_use);
+ // Checked BEFORE any lookup, because a lookup under `new/`
+ // creates a pane and a walk that then failed for want of a fid
+ // slot would leave one behind.
+ if (s.freeFid() == null) return s.fail(tag, e_too_many_fids);
+ }
+ if (nwname == 0) {
+ // THE CLONE. No names, no lookups, no qids: `Rwalk` with
+ // `nwqid == 0`, and it is a success — which is exactly why a
+ // failure on the first element may not be spelled that way.
+ if (newfid != fid) {
+ const j = s.freeFid().?;
+ s.fids[j] = s.fids[i];
+ s.fids[j].fid = newfid;
+ // A clone shares the file and NOT the directory cursor:
+ // two fids on one directory each keep their own place,
+ // which is what a duplicated descriptor means everywhere
+ // else. The open state is not shared either, and cannot
+ // be — an open fid was refused above.
+ s.fids[j].diroff = 0;
+ s.fids[j].dirindex = 0;
+ }
+ s.emit(tag, .{ .rwalk = .{ .nwqid = 0 } });
+ return;
+ }
+ if (!s.fids[i].dir) return s.fail(tag, e_not_dir);
+ s.job = .{
+ .kind = .walk,
+ .tag = tag,
+ .fid = fid,
+ .newfid = newfid,
+ .nwname = nwname,
+ .node = s.fids[i].node,
+ .dir = s.fids[i].dir,
+ .perm = s.fids[i].perm,
+ .name = s.fids[i].name,
+ .name_len = s.fids[i].name_len,
+ .msg = got.msg,
+ };
+ }
+
+ fn open(s: *Self, tag: u16, fid: u32, mode: u8) void {
+ const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid);
+ const f = &s.fids[i];
+ if (f.open) return s.fail(tag, e_already_open);
+ // Nothing in a generated tree can be removed, so nothing in it can
+ // be opened remove-on-close either.
+ if (mode & orclose != 0) return s.fail(tag, e_perm);
+ const rw = mode & 3;
+ if (rw == oexec) return s.fail(tag, e_perm);
+ // A directory is read, and only read: 9P has no other verb for one,
+ // and truncating a pane list is not a thing to mean.
+ if (f.dir and (rw != oread or mode & otrunc != 0)) return s.fail(tag, e_perm);
+ var need: u16 = 0;
+ if (rw == oread or rw == ordwr) need |= 0o400;
+ if (rw == owrite or rw == ordwr or mode & otrunc != 0) need |= 0o200;
+ // THE PERMISSION CHECK IS OURS. Under FUSE the kernel does it,
+ // against the mode a `getattr` reported, and `acmefs.open` never
+ // sees a mode at all (`acmefs.zig:1023-1031`). Over 9P there is
+ // nobody above us, so this is what stops `errors` and `wrsel` —
+ // write-only in acme's own dirtab — from being readable.
+ if (f.perm & need != need) return s.fail(tag, e_perm);
+ s.job = .{ .kind = .open, .tag = tag, .fid = fid, .omode = mode };
+ }
+
+ fn read(s: *Self, tag: u16, fid: u32, offset: u64, count: u32) void {
+ const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid);
+ const f = &s.fids[i];
+ // The two conditions `u9fs.c:755-758` refuses, and the same
+ // answer: a fid that was never opened, or one opened write-only.
+ if (!f.open or (f.omode & 3) == owrite) return s.fail(tag, e_bad_use);
+ // THE CLAMP, `min(count, msize - 11)`. An `Rread` longer than the
+ // count asked for is a hard `-EIO` in Linux rather than a
+ // truncation (`net/9p/client.c:1475-1479`, `9P-17`), and one
+ // longer than the msize is a message the client cannot read at
+ // all. Eleven is `Rread`'s header: `size[4] type[1] tag[2]
+ // count[4]`. It is applied to the request as well as to the
+ // answer, so the core is never asked to produce bytes that would
+ // have to be thrown away.
+ const want = @min(count, s.msize - header_len - 4);
+ if (!f.dir) {
+ s.job = .{ .kind = .read, .tag = tag, .fid = fid, .offset = offset, .count = want };
+ return;
+ }
+ // THE DIRECTORY RULE: offset zero, or exactly where the last read
+ // ended, and nothing else (`u9fs.c:760-769`, `lib9p/srv.c:473`). A
+ // client that seeks inside a directory is refused rather than
+ // served a listing that tears — which is the bug `ad` has, where an
+ // arbitrary offset that happens to land on an entry boundary is
+ // silently accepted (`9P-5`).
+ if (offset != f.diroff) {
+ if (offset != 0) return s.fail(tag, e_bad_offset);
+ f.diroff = 0;
+ f.dirindex = 0;
+ }
+ s.job = .{ .kind = .readdir, .tag = tag, .fid = fid, .offset = offset, .count = want };
+ }
+
+ fn write(s: *Self, got: Decoded, fid: u32, offset: u64, len: usize) void {
+ const tag = got.tag;
+ const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid);
+ const f = &s.fids[i];
+ if (!f.open or (f.omode & 3) == oread) return s.fail(tag, e_bad_use);
+ s.job = .{
+ .kind = .write,
+ .tag = tag,
+ .fid = fid,
+ .offset = offset,
+ .count = @intCast(len),
+ .msg = got.msg,
+ };
+ }
+
+ fn clunk(s: *Self, tag: u16, fid: u32, kind: Kind) void {
+ assert(kind == .clunk or kind == .remove);
+ const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid);
+ // An open fid owes the core a `release` before it goes. That is
+ // what decrements a pane's `event` reader count, and losing it
+ // leaves the editor reporting button actions to a script that has
+ // gone (`acmefs.zig:1069-1093`).
+ if (s.fids[i].open) {
+ s.job = .{ .kind = kind, .tag = tag, .fid = fid };
+ return;
+ }
+ s.fids[i] = .{};
+ if (kind == .remove) s.fail(tag, e_perm) else s.emit(tag, .rclunk);
+ }
+
+ fn stat(s: *Self, tag: u16, fid: u32) void {
+ if (s.findFid(fid) == null) return s.fail(tag, e_unknown_fid);
+ s.job = .{ .kind = .stat, .tag = tag, .fid = fid };
+ }
+
+ fn wstat(s: *Self, tag: u16, fid: u32, st: Stat) void {
+ if (s.findFid(fid) == null) return s.fail(tag, e_unknown_fid);
+ // The sentinels `stat(5)` specifies: an empty string and an
+ // all-ones integer mean "do not touch". The codec above carries
+ // them and has no opinion; deciding is this file's job.
+ if (st.name.len != 0) return s.fail(tag, e_wstat);
+ if (st.length == std.math.maxInt(u64)) {
+ // Nothing left that we honour. Mode, owner, group and the two
+ // times are ACCEPTED AND IGNORED, which is what a filesystem
+ // of live editor state has to do with them
+ // (`acmefs.zig:1096-1104`): refusing would make `touch` and
+ // `chmod` fail on a tree where they mean nothing anyway.
+ s.emit(tag, .rwstat);
+ return;
+ }
+ // A length that is neither the sentinel nor zero. The core honours
+ // exactly one value, so name it — and this string is one Linux
+ // already knows, so `truncate` gets EPERM rather than 526.
+ if (st.length != 0) return s.fail(tag, e_trunc_only);
+ // ...and zero IS the truncate, which is the same `Req.truncate`
+ // that `Topen` with `OTRUNC` produces (`FIX-1`).
+ s.job = .{ .kind = .wstat, .tag = tag, .fid = fid };
+ }
+
+ /// `Tflush`: a park-table lookup, and THE ORDER IS THE POINT.
+ ///
+ /// The original is answered first and the `Rflush` second. That is what
+ /// `lib9p/srv.c:241-266` does with its chained flush list, and what
+ /// `srv.c:810-827` does when the original finally responds: write the
+ /// original's reply, then respond to every flush waiting on it. A
+ /// client that sees `Rflush` may reuse the tag, so a reply arriving
+ /// after it would be a reply to whatever the tag names NEXT.
+ ///
+ /// `docs/registry.typ` `9P-16` says this is where we beat the prior
+ /// art, and the reason is structural rather than clever: the park table
+ /// is already keyed per outstanding request, so this is a lookup and
+ /// two replies. `ad` gets the ordering right in thirty-nine lines and
+ /// then defaults its filesystem's `flush` hook to doing nothing, so a
+ /// client flushing a blocked `event` read waits for an unrelated editor
+ /// event to arrive. There is no hook here to forget to implement.
+ fn flush(s: *Self, tag: u16, oldtag: u16) void {
+ if (s.findTag(oldtag)) |i| {
+ // EINTR and drop it, which is exactly what `fuse.zig:1334-1341`
+ // answers a `FUSE_INTERRUPT` naming a parked request.
+ s.fail(s.slots[i].tag, e_interrupted);
+ s.slots[i] = .{};
+ }
+ // A tag we do not hold was already answered or never existed.
+ // `Rflush` either way: after it the client may reuse the tag, and
+ // that is the only promise `flush(5)` makes.
+ s.emit(tag, .rflush);
+ }
+
+ // -- steps and answers -----------------------------------------------
+
+ /// Record the step being handed to the core, so that a park has
+ /// something to copy and `reply` has something to match.
+ fn ask(s: *Self, req: fs.Req) fs.Req {
+ assert(req.tag != 0);
+ s.job.req = req;
+ s.job.req_tag = req.tag;
+ return req;
+ }
+
+ /// The fid the message in flight names. Null cannot happen — nothing
+ /// else runs while a job does — and is answered rather than asserted,
+ /// because the cost of being wrong is a corrupted table.
+ fn jobFid(s: *Self) ?*Fid {
+ const i = s.findFid(s.job.fid) orelse {
+ s.fail(s.job.tag, e_unknown_fid);
+ s.finishJob();
+ return null;
+ };
+ return &s.fids[i];
+ }
+
+ /// The next core request the message in flight needs, or null when it
+ /// has just answered itself.
+ fn stepJob(s: *Self) ?fs.Req {
+ const j = &s.job;
+ assert(j.kind != .none);
+ assert(j.req_tag == 0);
+ switch (j.kind) {
+ .none => unreachable,
+ .attach => return s.ask(.{ .tag = s.tick(), .op = .getattr, .node = s.root }),
+ .walk => return s.stepWalk(),
+ .open => {
+ const f = s.jobFid() orelse return null;
+ // `OTRUNC` is a truncate and THEN an open, in that order.
+ if (j.step == 0 and j.omode & otrunc != 0) return s.ask(.{
+ .tag = s.tick(),
+ .op = .setattr,
+ .node = f.node,
+ .truncate = true,
+ });
+ return s.ask(.{ .tag = s.tick(), .op = .open, .node = f.node });
+ },
+ .read => {
+ const f = s.jobFid() orelse return null;
+ return s.ask(.{
+ .tag = s.tick(),
+ .op = .read,
+ .node = f.node,
+ .handle = f.handle,
+ .off = j.offset,
+ .size = j.count,
+ });
+ },
+ .readdir => {
+ const f = s.jobFid() orelse return null;
+ // THE COORDINATE CHANGE. 9P counts bytes and `acmefs`
+ // counts entries (`acmefs.zig:972`), so the request carries
+ // the entry index this fid's byte cursor stands at, and
+ // `emitDirRead` advances both.
+ return s.ask(.{
+ .tag = s.tick(),
+ .op = .readdir,
+ .node = f.node,
+ .handle = f.handle,
+ .off = f.dirindex,
+ .size = j.count,
+ });
+ },
+ .write => {
+ const f = s.jobFid() orelse return null;
+ return s.ask(.{
+ .tag = s.tick(),
+ .op = .write,
+ .node = f.node,
+ .handle = f.handle,
+ .off = j.offset,
+ .size = j.count,
+ .data = j.msg.twrite.data,
+ });
+ },
+ .clunk, .remove => {
+ const f = s.jobFid() orelse return null;
+ return s.ask(.{
+ .tag = s.tick(),
+ .op = .release,
+ .node = f.node,
+ .handle = f.handle,
+ });
+ },
+ .stat => {
+ const f = s.jobFid() orelse return null;
+ return s.ask(.{ .tag = s.tick(), .op = .getattr, .node = f.node });
+ },
+ .wstat => {
+ const f = s.jobFid() orelse return null;
+ return s.ask(.{ .tag = s.tick(), .op = .setattr, .node = f.node, .truncate = true });
+ },
+ }
+ }
+
+ /// One walk element at a time, and the local ones without asking.
+ fn stepWalk(s: *Self) ?fs.Req {
+ const j = &s.job;
+ while (j.step < j.nwname) {
+ const name = j.msg.twalk.wname[j.step];
+ // A name the fid could not hold cannot be a name in this tree,
+ // and refusing it on its length is what keeps `Rstat` honest.
+ if (name.len > name_max) {
+ s.stopWalk(e_illegal_name);
+ return null;
+ }
+ // `.` is the fid where it already stands, and costs nothing.
+ if (std.mem.eql(u8, name, ".")) {
+ j.wqid[j.nwqid] = qidOf(j.node, j.dir);
+ j.nwqid += 1;
+ j.step += 1;
+ continue;
+ }
+ if (std.mem.eql(u8, name, "..")) {
+ const p = parentOf(j.node, s.root, &j.name);
+ j.name_len = p.name_len;
+ j.node = p.node;
+ // WHICH node the parent is, is ours to work out; what it
+ // LOOKS like is not. A `getattr` keeps `perm`, `dir` and
+ // the qid the core's answer rather than this file's
+ // invention, and reports ENOENT if the pane closed
+ // underneath us.
+ return s.ask(.{ .tag = s.tick(), .op = .getattr, .node = p.node });
+ }
+ @memcpy(j.name[0..name.len], name);
+ j.name_len = @intCast(name.len);
+ return s.ask(.{ .tag = s.tick(), .op = .lookup, .node = j.node, .data = name });
+ }
+ // Every element resolved, so `newfid` is bound — and only now. A
+ // partial walk binds NOTHING, which is `ad`'s «new_fid is only
+ // bound when all elements were walked successfully» and the spec's.
+ const dst = pick: {
+ if (j.newfid == j.fid) break :pick s.findFid(j.fid) orelse {
+ s.fail(j.tag, e_unknown_fid);
+ s.finishJob();
+ return null;
+ };
+ break :pick s.freeFid() orelse {
+ s.fail(j.tag, e_too_many_fids);
+ s.finishJob();
+ return null;
+ };
+ };
+ s.fids[dst] = .{
+ .used = true,
+ .fid = j.newfid,
+ .node = j.node,
+ .dir = j.dir,
+ .perm = j.perm,
+ .name = j.name,
+ .name_len = j.name_len,
+ };
+ s.emit(j.tag, .{ .rwalk = .{ .nwqid = j.nwqid, .wqid = j.wqid } });
+ s.finishJob();
+ return null;
+ }
+
+ /// A walk that could not finish, and THE SCAR that says how to answer
+ /// it: «Spec: first element failure must be Rerror, not Rwalk with zero
+ /// qids» — `ad/crates/ninep/src/sansio/server.rs:335-338`, left in
+ /// their source after they shipped it the other way. Zero qids already
+ /// means the clone, so it cannot also mean a failure.
+ ///
+ /// A failure at any LATER element is a successful short `Rwalk`, and
+ /// the client is expected to notice that it got fewer qids than it
+ /// asked for. It gets no error string at all, which is the protocol's
+ /// choice and not ours.
+ fn stopWalk(s: *Self, ename: []const u8) void {
+ const j = &s.job;
+ if (j.nwqid == 0)
+ s.fail(j.tag, ename)
+ else
+ s.emit(j.tag, .{ .rwalk = .{ .nwqid = j.nwqid, .wqid = j.wqid } });
+ s.finishJob();
+ }
+
+ fn finishJob(s: *Self) void {
+ s.job = .{};
+ if (s.frame != 0) s.dropFrame();
+ }
+
+ /// The core answered a step of the message in flight.
+ fn jobReply(s: *Self, r: *const fs.Reply, bytes: []const u8) void {
+ const j = &s.job;
+ assert(j.kind != .none);
+ assert(j.req_tag == r.tag);
+ j.req_tag = 0;
+
+ // A CLUNK CANNOT FAIL. «even if the clunk fails, the fid is no
+ // longer valid» — clunk(5) — and `remove(5)` says the same of
+ // remove, so the core's answer to the release is not consulted at
+ // all. That also means a release the core tried to park is dropped
+ // rather than leaving behind a fid the client can no longer reach.
+ if (j.kind == .clunk or j.kind == .remove) {
+ s.dropFid(j.fid);
+ if (j.kind == .remove) s.fail(j.tag, e_perm) else s.emit(j.tag, .rclunk);
+ s.finishJob();
+ return;
+ }
+ if (r.status == .again) return s.parkJob();
+ if (r.status == .err) {
+ const ename = errString(r.errno);
+ if (j.kind == .walk) return s.stopWalk(ename);
+ s.fail(j.tag, ename);
+ s.finishJob();
+ return;
+ }
+
+ switch (j.kind) {
+ .none, .clunk, .remove => unreachable,
+ .attach => {
+ const i = s.freeFid() orelse {
+ s.fail(j.tag, e_too_many_fids);
+ s.finishJob();
+ return;
+ };
+ const node = if (r.attr.node != 0) r.attr.node else s.root;
+ s.fids[i] = .{
+ .used = true,
+ .fid = j.fid,
+ .node = node,
+ .dir = r.attr.dir,
+ .perm = r.attr.mode,
+ };
+ // The root's name is "/" — one of the bugs `ad` shipped
+ // and then fixed (`9P-18`, commit `64f2f4b`).
+ s.fids[i].name[0] = '/';
+ s.fids[i].name_len = 1;
+ s.emit(j.tag, .{ .rattach = .{ .qid = qidOf(node, r.attr.dir) } });
+ s.finishJob();
+ },
+ .walk => {
+ if (r.attr.node != 0) j.node = r.attr.node;
+ j.dir = r.attr.dir;
+ j.perm = r.attr.mode;
+ j.wqid[j.nwqid] = qidOf(j.node, j.dir);
+ j.nwqid += 1;
+ j.step += 1;
+ // The job STAYS: `next()` asks `stepWalk` for the next
+ // element, or lets it bind the fid and answer.
+ },
+ .open => {
+ if (j.step == 0 and j.omode & otrunc != 0) {
+ // The truncate landed; the open is the next step.
+ j.step = 1;
+ return;
+ }
+ const f = s.jobFid() orelse return;
+ f.open = true;
+ f.omode = j.omode;
+ f.handle = r.handle;
+ // A fresh open starts a directory at the beginning.
+ f.diroff = 0;
+ f.dirindex = 0;
+ // `iounit` is the largest atomic read or write: one message
+ // less the slack `fcall.h:72` has reserved for a `Twrite`
+ // header for thirty years.
+ s.emit(j.tag, .{ .ropen = .{
+ .qid = qidOf(f.node, f.dir),
+ .iounit = s.msize - iohdrsz,
+ } });
+ s.finishJob();
+ },
+ .read => {
+ // Clamped a second time, against the bytes that actually
+ // came back: the request already carried the count, and a
+ // core that answered with more would otherwise become an
+ // `-EIO` in the client rather than a bug here.
+ s.emit(j.tag, .{ .rread = .{ .data = bytes[0..@min(bytes.len, j.count)] } });
+ s.finishJob();
+ },
+ .readdir => {
+ s.emitDirRead(j.tag, j.fid, bytes, j.count);
+ s.finishJob();
+ },
+ .write => {
+ // The core's own count and not the request's: `data`
+ // refusing a partial grapheme is a real short write, and
+ // claiming the whole request would tell the writer that
+ // its trailing bytes landed when they did not.
+ s.emit(j.tag, .{ .rwrite = .{ .count = @min(r.written, j.count) } });
+ s.finishJob();
+ },
+ .stat => {
+ const f = s.jobFid() orelse return;
+ // The core is authoritative about size and mode, and the
+ // fid's cache follows: `body` grows between stats, and
+ // `Topen` is checked against `perm`.
+ f.perm = r.attr.mode;
+ f.dir = r.attr.dir;
+ s.emit(j.tag, .{ .rstat = .{ .stat = s.statOf(f, r.attr) } });
+ s.finishJob();
+ },
+ .wstat => {
+ s.emit(j.tag, .rwstat);
+ s.finishJob();
+ },
+ }
+ }
+
+ /// The core said `.again`: nothing consumed, ask me later. The request
+ /// moves into a park slot and the 9P tag goes with it, so the client
+ /// hears nothing at all until the core has something to say — which is
+ /// what makes a blocking `event` read work on a single-threaded core
+ /// with no waiter list anywhere.
+ fn parkJob(s: *Self) void {
+ const j = &s.job;
+ // Only these three can park, and only because the state a retry
+ // needs is scalars. A walk cannot: its names borrow the input
+ // buffer, which the next message overwrites. `e_again` is honest
+ // (the client may retry) and by construction unreachable — the
+ // core parks reads of `event` and nothing else.
+ switch (j.kind) {
+ .read, .readdir, .write => {},
+ else => {
+ s.fail(j.tag, e_again);
+ s.finishJob();
+ return;
+ },
+ }
+ const i = s.freeSlot() orelse {
+ // Overflow is a refusal, not a queue: thirty-two blocked
+ // readers is thirty-two scripts watching one session.
+ s.fail(j.tag, e_again);
+ s.finishJob();
+ return;
+ };
+ const sl = &s.slots[i];
+ sl.* = .{
+ .used = true,
+ .parked = true,
+ .seq = s.tick(),
+ .tag = j.tag,
+ .kind = j.kind,
+ .fid = j.fid,
+ .count = j.count,
+ .req = j.req,
+ };
+ if (j.req.data.len != 0) {
+ if (j.req.data.len > park_data_max) {
+ // A payload too large to copy would go on borrowing the
+ // input buffer, so parking it would park a dangling slice.
+ sl.* = .{};
+ s.fail(j.tag, e_again);
+ s.finishJob();
+ return;
+ }
+ @memcpy(sl.data[0..j.req.data.len], j.req.data);
+ sl.copied = true;
+ sl.req.data = sl.data[0..j.req.data.len];
+ }
+ // The frame is nobody's now: everything the retry needs has been
+ // copied, so the next message may take its place.
+ s.finishJob();
+ }
+
+ /// The core answered a request that had been parked.
+ fn slotReply(s: *Self, i: usize, r: *const fs.Reply, bytes: []const u8) void {
+ const sl = &s.slots[i];
+ if (r.status == .again) {
+ sl.parked = true;
+ return;
+ }
+ if (r.status == .err) {
+ s.fail(sl.tag, errString(r.errno));
+ sl.* = .{};
+ return;
+ }
+ switch (sl.kind) {
+ .read => s.emit(sl.tag, .{ .rread = .{ .data = bytes[0..@min(bytes.len, sl.count)] } }),
+ .readdir => s.emitDirRead(sl.tag, sl.fid, bytes, sl.count),
+ .write => s.emit(sl.tag, .{ .rwrite = .{ .count = @min(r.written, sl.count) } }),
+ // `parkJob` admits no other kind.
+ else => s.fail(sl.tag, e_botch),
+ }
+ sl.* = .{};
+ }
+
+ /// A directory read: `acmefs`'s staged entries become 9P `stat`
+ /// records, in place, and the fid's cursor advances by exactly what
+ /// was sent.
+ ///
+ /// THE ONE ENTRY THAT DID NOT FIT needs no buffer here, and that is
+ /// worth saying because every reference server has one: u9fs caches a
+ /// `dirent` per fid «for when convD2M fails» (`u9fs.c:780`) because
+ /// `readdir(3)` has already consumed it. `acmefs` re-stages the whole
+ /// listing from an entry index on every call and says why —
+ /// «re-staging from scratch on every call is what makes a partially
+ /// consumed answer safe to ask for again at a higher cookie»
+ /// (`acmefs.zig:1013-1016`) — so an entry that does not fit is simply
+ /// not counted, and the next read asks for it by index.
+ fn emitDirRead(s: *Self, tag: u16, fid: u32, staging: []const u8, count: u32) void {
+ const buf = s.out[s.out_len..];
+ assert(buf.len > header_len + 4);
+ const cap = @min(@as(usize, count), buf.len - header_len - 4);
+ const who = s.uname[0..s.uname_len];
+ var n: usize = header_len + 4;
+ var entries: u32 = 0;
+ var i: usize = 0;
+ // `node[8] dir[1] namelen[1] name[]`, repeated — `acmefs.zig:942`.
+ while (i + 10 <= staging.len) {
+ const nlen = staging[i + 9];
+ if (i + 10 + nlen > staging.len) break;
+ const dir = staging[i + 8] != 0;
+ const rec: Stat = .{
+ .type = 0,
+ .dev = 0,
+ .qid = qidOf(std.mem.readInt(u64, staging[i..][0..8], .little), dir),
+ .mode = (if (dir) dmdir else 0) | @as(u32, if (dir) dirent_dir_perm else dirent_file_perm),
+ .atime = 0,
+ .mtime = 0,
+ .length = 0,
+ .name = staging[i + 10 ..][0..nlen],
+ .uid = who,
+ .gid = who,
+ .muid = who,
+ };
+ const size = @as(usize, rec.size() catch break) + 2;
+ // WHOLE RECORDS ONLY. `read(5)`: a directory read returns an
+ // integral number of entries, so the first one that does not
+ // fit ends the reply and the cursor stops in front of it.
+ if (n - header_len - 4 + size > cap) break;
+ _ = rec.encode(buf[n..]) catch break;
+ n += size;
+ entries += 1;
+ i += 10 + nlen;
+ }
+ // A count that cannot hold the FIRST entry is refused rather than
+ // answered with zero bytes, because zero bytes is end of directory
+ // and a client that believes it stops asking. `entries == 0` with
+ // nothing staged is the real end.
+ if (entries == 0 and staging.len != 0) return s.fail(tag, e_count_small);
+ const payload: u32 = @intCast(n - header_len - 4);
+ // The header goes on LAST, over bytes reserved for it, because the
+ // payload's length is only known once the entries are encoded —
+ // `u9fs.c:775-806` builds it the same way and for the same reason.
+ // Written by hand rather than through `encode`, which would want
+ // the payload contiguous somewhere else first, and this file will
+ // not carry a third msize buffer to make that true. The test "a
+ // directory read is whole stat records" decodes the result with
+ // `decode`, which is what keeps these four lines honest.
+ comptime assert(header_len == 7);
+ std.mem.writeInt(u32, buf[0..4], @intCast(n), .little);
+ buf[4] = @intFromEnum(Type.rread);
+ std.mem.writeInt(u16, buf[5..7], tag, .little);
+ std.mem.writeInt(u32, buf[7..11], payload, .little);
+ s.out_len += n;
+ // BOTH cursors, together, or the next read is refused: bytes for
+ // the client's offset rule, entries for `acmefs`'s index.
+ if (s.findFid(fid)) |k| {
+ s.fids[k].diroff += payload;
+ s.fids[k].dirindex += entries;
+ }
+ }
+
+ /// One `stat` record for a file the core has just described.
+ ///
+ /// `type` and `dev` are Plan 9 kernel device identifiers, meaningless
+ /// off Plan 9, and zero — as u9fs sends them. `atime` and `mtime` are
+ /// zero because this tree has no times to report and the FUSE
+ /// transport already reports none (`fuse.zig:1544-1546`); an invented
+ /// time is one `make` would believe. The three name fields are whoever
+ /// attached: the tree is synthetic and has exactly one owner.
+ fn statOf(s: *const Self, f: *const Fid, a: fs.Reply.Attr) Stat {
+ const who = s.uname[0..s.uname_len];
+ return .{
+ .type = 0,
+ .dev = 0,
+ .qid = qidOf(if (a.node != 0) a.node else f.node, a.dir),
+ // The high bits are the type and the low nine are the
+ // permission: `dmdir` is `qtdir` shifted up 24, which the
+ // codec's last test asserts rather than assumes.
+ .mode = (if (a.dir) dmdir else 0) | @as(u32, a.mode),
+ .atime = 0,
+ .mtime = 0,
+ .length = a.size,
+ .name = f.name[0..f.name_len],
+ .uid = who,
+ .gid = who,
+ .muid = who,
+ };
+ }
+ };
+}
+
+// ---------------------------------------------------------------------------
+// server tests
+// ---------------------------------------------------------------------------
+//
+// Driven with BYTE ARRAYS and a STUB FILESYSTEM, so there is no `Pardes` here
+// and no transport either: `push` takes encoded messages, `pump` is
+// `fs_service.drain` written out, and `reap` decodes what came back with the
+// codec above. A test that fails is a message a real client would have been
+// sent, byte for byte.
+
+/// Everything `Server` asks of a filesystem, plus a tree small enough to check
+/// by eye. The three types are `acmefs`'s ABI verbatim — that is the whole
+/// contract, and `Server(acmefs)` is the instantiation that matters — so this
+/// is a MIRROR and not a redefinition: a field that drifts is a compile error
+/// the moment the real adapter is built.
+///
+/// THE NODE IDS ARE `acmefs.Node`'s PACKING, `{ file: u4, serial: u60 }`, and
+/// they have to be: `parentOf` reads them. So pane 1's directory is `1 << 4`,
+/// its `body` is that plus `PaneFile.body` (2), and the top-level files are
+/// `TopFile`'s own 1..4 with a zero serial.
+const StubFs = struct {
+ pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs };
+ pub const Status = enum(u8) { ok, again, err };
+
+ pub const Req = struct {
+ tag: u64,
+ op: Op,
+ node: u64,
+ handle: u32 = 0,
+ off: u64 = 0,
+ size: u32 = 0,
+ data: []const u8 = &.{},
+ truncate: bool = false,
+ };
+
+ pub const Reply = struct {
+ tag: u64,
+ status: Status = .ok,
+ errno: u16 = 0,
+ attr: Attr = .{},
+ handle: u32 = 0,
+ written: u32 = 0,
+
+ pub const Attr = struct {
+ node: u64 = 0,
+ dir: bool = false,
+ size: u64 = 0,
+ mode: u16 = 0o600,
+ };
+ };
+
+ const Entry = struct { node: u64, parent: u64, name: []const u8, dir: bool, mode: u16 };
+
+ /// `acmefs`'s tree, cut down: the root's four names, two panes, and six of
+ /// a pane's files including the two that matter most here — `event`, which
+ /// blocks, and `errors`, which is write-only.
+ const tree = [_]Entry{
+ .{ .node = 1, .parent = 1, .name = "/", .dir = true, .mode = 0o500 },
+ .{ .node = 2, .parent = 1, .name = "index", .dir = false, .mode = 0o400 },
+ .{ .node = 3, .parent = 1, .name = "cons", .dir = false, .mode = 0o200 },
+ .{ .node = 4, .parent = 1, .name = "new", .dir = true, .mode = 0o500 },
+ .{ .node = 16, .parent = 1, .name = "1", .dir = true, .mode = 0o500 },
+ .{ .node = 32, .parent = 1, .name = "2", .dir = true, .mode = 0o500 },
+ .{ .node = 17, .parent = 16, .name = "addr", .dir = false, .mode = 0o600 },
+ .{ .node = 18, .parent = 16, .name = "body", .dir = false, .mode = 0o600 },
+ .{ .node = 19, .parent = 16, .name = "ctl", .dir = false, .mode = 0o600 },
+ .{ .node = 21, .parent = 16, .name = "errors", .dir = false, .mode = 0o200 },
+ .{ .node = 22, .parent = 16, .name = "event", .dir = false, .mode = 0o600 },
+ .{ .node = 23, .parent = 16, .name = "tag", .dir = false, .mode = 0o600 },
+ };
+
+ const body_node = 18;
+ const index_node = 2;
+ const event_node = 22;
+
+ body: []const u8 = "hello, body\n",
+ /// `index`, and long enough that a read of it has to be clamped.
+ filler: [1024]u8 = @splat('x'),
+ /// One event record, or nothing — which is `Status.again`.
+ event: ?[]const u8 = null,
+ /// Set to make every write park, so the copy into a slot is exercised.
+ park_writes: bool = false,
+ releases: u32 = 0,
+ calls: u32 = 0,
+ writes: [128]u8 = undefined,
+ writes_len: usize = 0,
+ stage: [1024]u8 = undefined,
+
+ const Answer = struct { reply: Reply, bytes: []const u8 = "" };
+
+ fn find(node: u64) ?usize {
+ for (tree, 0..) |e, i| if (e.node == node) return i;
+ return null;
+ }
+
+ fn sizeOf(st: *const StubFs, node: u64) u64 {
+ return switch (node) {
+ body_node => st.body.len,
+ index_node => st.filler.len,
+ else => 0,
+ };
+ }
+
+ fn contentOf(st: *const StubFs, node: u64) []const u8 {
+ return switch (node) {
+ body_node => st.body,
+ index_node => &st.filler,
+ else => "",
+ };
+ }
+
+ fn attrOf(st: *const StubFs, e: Entry) Reply.Attr {
+ return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = st.sizeOf(e.node) };
+ }
+
+ /// `acmefs`'s staging format, which is what the 9P server decodes:
+ /// `node[8] dir[1] namelen[1] name[]`, repeated, from an ENTRY INDEX.
+ fn stageDir(st: *StubFs, node: u64, skip: u64) []const u8 {
+ var n: usize = 0;
+ var seen: u64 = 0;
+ for (tree) |e| {
+ if (e.parent != node or e.node == node) continue;
+ if (seen < skip) {
+ seen += 1;
+ continue;
+ }
+ std.mem.writeInt(u64, st.stage[n..][0..8], e.node, .little);
+ st.stage[n + 8] = @intFromBool(e.dir);
+ st.stage[n + 9] = @intCast(e.name.len);
+ @memcpy(st.stage[n + 10 ..][0..e.name.len], e.name);
+ n += 10 + e.name.len;
+ }
+ return st.stage[0..n];
+ }
+
+ fn handle(st: *StubFs, req: Req) Answer {
+ st.calls += 1;
+ const fail: Answer = .{ .reply = .{ .tag = req.tag, .status = .err, .errno = 2 } };
+ const i = find(req.node) orelse return fail;
+ switch (req.op) {
+ .lookup => {
+ for (tree) |e| {
+ if (e.parent != req.node or e.node == req.node) continue;
+ if (!std.mem.eql(u8, e.name, req.data)) continue;
+ return .{ .reply = .{ .tag = req.tag, .attr = st.attrOf(e) } };
+ }
+ return fail;
+ },
+ .getattr => return .{ .reply = .{ .tag = req.tag, .attr = st.attrOf(tree[i]) } },
+ .setattr => {
+ if (req.truncate and req.node == body_node) st.body = "";
+ return .{ .reply = .{ .tag = req.tag, .attr = st.attrOf(tree[i]) } };
+ },
+ .open => return .{ .reply = .{ .tag = req.tag, .handle = 7 } },
+ .release => {
+ st.releases += 1;
+ return .{ .reply = .{ .tag = req.tag } };
+ },
+ .readdir => {
+ if (!tree[i].dir) return .{ .reply = .{ .tag = req.tag, .status = .err, .errno = 20 } };
+ return .{ .reply = .{ .tag = req.tag }, .bytes = st.stageDir(req.node, req.off) };
+ },
+ .read => {
+ // `event`: one record per read, and `.again` when there is
+ // none — `acmefs.zig:1358-1367` exactly.
+ if (req.node == event_node) {
+ const rec = st.event orelse return .{ .reply = .{ .tag = req.tag, .status = .again } };
+ st.event = null;
+ return .{ .reply = .{ .tag = req.tag }, .bytes = rec };
+ }
+ const all = st.contentOf(req.node);
+ if (req.off >= all.len) return .{ .reply = .{ .tag = req.tag } };
+ const from = all[@intCast(req.off)..];
+ return .{ .reply = .{ .tag = req.tag }, .bytes = from[0..@min(from.len, req.size)] };
+ },
+ .write => {
+ if (st.park_writes) return .{ .reply = .{ .tag = req.tag, .status = .again } };
+ const n = @min(req.data.len, st.writes.len - st.writes_len);
+ @memcpy(st.writes[st.writes_len..][0..n], req.data[0..n]);
+ st.writes_len += n;
+ return .{ .reply = .{ .tag = req.tag, .written = @intCast(n) } };
+ },
+ .statfs => return .{ .reply = .{ .tag = req.tag } },
+ }
+ }
+};
+
+const Srv = Server(StubFs);
+
+/// One connection: two buffers, a stub filesystem and the server between them.
+const Harness = struct {
+ in: [4096]u8 = undefined,
+ out: [8192]u8 = undefined,
+ fsys: StubFs = .{},
+ srv: Srv = undefined,
+
+ /// The buffers are fields, so the server can only be built once the
+ /// harness has an address.
+ fn start(h: *Harness) void {
+ h.srv = Srv.init(.{ .in = &h.in, .out = &h.out, .root = 1 });
+ }
+
+ fn answer(h: *Harness, req: StubFs.Req) void {
+ const a = h.fsys.handle(req);
+ h.srv.reply(&a.reply, a.bytes);
+ }
+
+ /// THE TRANSPORT CONTRACT, in the shape `src/fs_service.zig:209-222`
+ /// requires it: every parked request offered once, then everything the
+ /// wire has, both loops to null. Written out rather than imported —
+ /// `fs_service` is `pardes.zig` deep — and writing it out is how these
+ /// tests document what they are testing against.
+ fn pump(h: *Harness) void {
+ while (h.srv.retry()) |req| h.answer(req);
+ while (h.srv.next()) |req| h.answer(req);
+ }
+
+ fn send(h: *Harness, tag: u16, msg: Msg) !void {
+ var buf: [1024]u8 = undefined;
+ const bytes = try encode(msg, tag, &buf);
+ try testing.expectEqual(bytes.len, h.srv.push(bytes));
+ h.pump();
+ }
+
+ /// One reply off the queue. Borrows the out buffer, so a caller checks it
+ /// before sending anything else.
+ fn reap(h: *Harness) !Decoded {
+ const out = h.srv.output();
+ const len = frameLen(out) orelse return error.NoReply;
+ if (len > out.len) return error.ShortReply;
+ const got = try decode(out[0..len]);
+ h.srv.wrote(len);
+ return got;
+ }
+
+ fn quiet(h: *Harness) !void {
+ try testing.expectEqual(@as(usize, 0), h.srv.output().len);
+ }
+
+ /// `Tversion` and `Tattach`, which every test but the handshake ones want,
+ /// leaving the root on fid 0.
+ fn handshake(h: *Harness, msize: u32) !void {
+ h.start();
+ try h.send(notag, .{ .tversion = .{ .msize = msize, .version = "9P2000" } });
+ const v = try h.reap();
+ try testing.expectEqualStrings("9P2000", v.msg.rversion.version);
+ try h.send(0, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } });
+ const a = try h.reap();
+ try testing.expectEqual(@as(u64, 1), a.msg.rattach.qid.path);
+ }
+
+ /// A walk from the root to one name, landing on `newfid`.
+ fn walkTo(h: *Harness, tag: u16, newfid: u32, list: []const []const u8) !Decoded {
+ try h.send(tag, .{ .twalk = .{
+ .fid = 0,
+ .newfid = newfid,
+ .nwname = @intCast(list.len),
+ .wname = wnames(list),
+ } });
+ return h.reap();
+ }
+};
+
+fn wnames(list: []const []const u8) [max_welem][]const u8 {
+ var out: [max_welem][]const u8 = @splat("");
+ for (list, 0..) |n, i| out[i] = n;
+ return out;
+}
+
+/// The names in a directory read, decoded as whole `stat` records. Which is
+/// also the proof that `emitDirRead`'s hand-written header is right: this goes
+/// through `Stat.decode`, which refuses anything that does not add up.
+fn dirNames(data: []const u8, out: [][]const u8) !usize {
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i < data.len) {
+ const size = std.mem.readInt(u16, data[i..][0..2], .little);
+ const st = try Stat.decode(data[i..][0 .. @as(usize, size) + 2]);
+ out[n] = st.name;
+ n += 1;
+ i += @as(usize, size) + 2;
+ }
+ return n;
+}
+
+test "9p server: the version handshake clamps, falls back, and refuses" {
+ var h: Harness = .{};
+ h.start();
+
+ // A client offering a megabyte gets what the buffers hold: one input
+ // buffer, or half the output queue, whichever is smaller.
+ try h.send(notag, .{ .tversion = .{ .msize = 1 << 20, .version = "9P2000" } });
+ var got = try h.reap();
+ try testing.expectEqual(notag, got.tag);
+ try testing.expectEqual(@as(u32, 4096), got.msg.rversion.msize);
+ try testing.expectEqualStrings("9P2000", got.msg.rversion.version);
+
+ // BELOW LINUX'S FLOOR IS FINE. 4096 is the kernel's number and nobody
+ // else's: Plan 9's devmnt, plan9port's `9p` and our own client all accept
+ // 512, and refusing it would cost the board a kilobyte for nothing.
+ try h.send(notag, .{ .tversion = .{ .msize = 512, .version = "9P2000" } });
+ got = try h.reap();
+ try testing.expectEqual(@as(u32, 512), got.msg.rversion.msize);
+
+ // A `.u` client is told to fall back rather than refused: u9fs answers
+ // "9P2000" to any version starting with "9P".
+ try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000.u" } });
+ got = try h.reap();
+ try testing.expectEqualStrings("9P2000", got.msg.rversion.version);
+
+ // Something that is not 9P at all: the literal "unknown", in a SUCCESSFUL
+ // Rversion and not an Rerror.
+ try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "TCP/IP" } });
+ got = try h.reap();
+ try testing.expectEqualStrings("unknown", got.msg.rversion.version);
+ // ...and nothing else is served until a version is agreed.
+ try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_botch, got.msg.rerror.ename);
+
+ // An msize too small to hold one `Rwalk` cannot be served at all, and
+ // `Rversion` has no field that means "too small" — so `Rerror`, which is a
+ // legal reply to any T-message.
+ try h.send(notag, .{ .tversion = .{ .msize = 64, .version = "9P2000" } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_small_msize, got.msg.rerror.ename);
+ try testing.expectEqual(@as(u32, 216), msize_min - 1);
+}
+
+test "9p server: attach names the root, and the only tree there is" {
+ var h: Harness = .{};
+ h.start();
+ try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000" } });
+ _ = try h.reap();
+
+ // An `aname` names a tree we do not have, and saying so beats handing over
+ // the one we do.
+ try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "work" } });
+ var got = try h.reap();
+ try testing.expectEqualStrings(e_no_tree, got.msg.rerror.ename);
+
+ try h.send(2, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } });
+ got = try h.reap();
+ try testing.expectEqual(@as(u64, 1), got.msg.rattach.qid.path);
+ try testing.expectEqual(qtdir, got.msg.rattach.qid.type);
+ // ALWAYS ZERO, which is what makes Linux's client skip its cache.
+ try testing.expectEqual(@as(u32, 0), got.msg.rattach.qid.version);
+
+ // The same fid twice is a client bug, refused rather than rebound.
+ try h.send(3, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_fid_in_use, got.msg.rerror.ename);
+
+ // `Tauth` is refused, which is how a Plan 9 mount learns there is no
+ // authentication here and carries on without it.
+ try h.send(4, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_no_auth, got.msg.rerror.ename);
+
+ // The attacher's name is what `Rstat` reports as owner, group and last
+ // modifier: the tree is synthetic and has exactly one owner.
+ try h.send(5, .{ .tstat = .{ .fid = 0 } });
+ got = try h.reap();
+ try testing.expectEqualStrings("/", got.msg.rstat.stat.name);
+ try testing.expectEqualStrings("goblin", got.msg.rstat.stat.uid);
+ try testing.expectEqualStrings("goblin", got.msg.rstat.stat.muid);
+ try testing.expect(got.msg.rstat.stat.mode & dmdir != 0);
+}
+
+test "9p server: a three-element walk, and `..` with no kernel to resolve it" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ // Three elements in one message, on a tree that is three deep: `..` at the
+ // root is the root, which is POSIX's rule and intro(5)'s, and NOT an error.
+ var got = try h.walkTo(5, 1, &.{ "..", "1", "body" });
+ try testing.expectEqual(@as(u16, 3), got.msg.rwalk.nwqid);
+ try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[0].path);
+ try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[1].path);
+ try testing.expectEqual(@as(u64, 18), got.msg.rwalk.wqid[2].path);
+ try testing.expectEqual(qtdir, got.msg.rwalk.wqid[1].type);
+ try testing.expectEqual(qtfile, got.msg.rwalk.wqid[2].type);
+ for (got.msg.rwalk.wqid[0..3]) |q| try testing.expectEqual(@as(u32, 0), q.version);
+
+ // Up and down and up and down. `..` from a pane's directory is the root
+ // too, and five elements land where two would have.
+ got = try h.walkTo(6, 2, &.{ "..", "1", "..", "1", "body" });
+ try testing.expectEqual(@as(u16, 5), got.msg.rwalk.nwqid);
+ try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[2].path);
+ try testing.expectEqual(@as(u64, 18), got.msg.rwalk.wqid[4].path);
+ // ...and the fid really is the body.
+ try h.send(7, .{ .tstat = .{ .fid = 2 } });
+ got = try h.reap();
+ try testing.expectEqualStrings("body", got.msg.rstat.stat.name);
+ try testing.expectEqual(@as(u64, 12), got.msg.rstat.stat.length);
+
+ // `..` after an element that landed on a FILE is that pane's directory —
+ // `parentOf`'s third case — and the name comes out of the node id rather
+ // than out of the element, because "1" is not what the client typed.
+ got = try h.walkTo(8, 3, &.{ "1", "body", ".." });
+ try testing.expectEqual(@as(u16, 3), got.msg.rwalk.nwqid);
+ try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[2].path);
+ try testing.expectEqual(qtdir, got.msg.rwalk.wqid[2].type);
+ try h.send(9, .{ .tstat = .{ .fid = 3 } });
+ got = try h.reap();
+ try testing.expectEqualStrings("1", got.msg.rstat.stat.name);
+
+ // `.` is where the fid already stands, and it costs the core NOTHING: no
+ // request is made for it at all.
+ const before = h.fsys.calls;
+ got = try h.walkTo(10, 4, &.{ ".", "." });
+ try testing.expectEqual(@as(u16, 2), got.msg.rwalk.nwqid);
+ try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[1].path);
+ try testing.expectEqual(before, h.fsys.calls);
+}
+
+test "9p server: a walk failing on the first element is Rerror, on the second a short Rwalk" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ // THE SCAR (`ad/.../sansio/server.rs:335-338`): zero qids already means
+ // the clone, so a failure on the first element cannot be spelled that way.
+ var got = try h.walkTo(5, 1, &.{ "nope", "body" });
+ try testing.expectEqualStrings("No such file or directory", got.msg.rerror.ename);
+
+ // A failure LATER is a successful short `Rwalk` with no error string at
+ // all, which is the protocol's choice and not ours.
+ got = try h.walkTo(6, 1, &.{ "1", "nope" });
+ try testing.expectEqual(@as(u16, 1), got.msg.rwalk.nwqid);
+ try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[0].path);
+
+ // ...and `newfid` is NOT bound by a partial walk.
+ try h.send(7, .{ .tstat = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename);
+
+ // The clone does bind it, with zero qids and no lookups.
+ try h.send(8, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 0 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(u16, 0), got.msg.rwalk.nwqid);
+ try h.send(9, .{ .tstat = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expectEqualStrings("/", got.msg.rstat.stat.name);
+
+ // A walk with names from something that is not a directory is not a walk.
+ got = try h.walkTo(10, 2, &.{"index"});
+ try testing.expectEqual(@as(u16, 1), got.msg.rwalk.nwqid);
+ try h.send(11, .{ .twalk = .{ .fid = 2, .newfid = 3, .nwname = 1, .wname = wnames(&.{"body"}) } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_not_dir, got.msg.rerror.ename);
+
+ // A name no fid could hold is refused on its length rather than looked up,
+ // which is what keeps `Rstat`'s name field honest.
+ got = try h.walkTo(12, 4, &.{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"});
+ try testing.expectEqualStrings(e_illegal_name, got.msg.rerror.ename);
+
+ // An in-use `newfid` is refused before anything is walked.
+ try h.send(13, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 1, .wname = wnames(&.{"1"}) } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_fid_in_use, got.msg.rerror.ename);
+}
+
+test "9p server: open then read then clunk, and the release a clunk owes the core" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+ _ = try h.walkTo(5, 1, &.{ "1", "body" });
+
+ try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } });
+ var got = try h.reap();
+ try testing.expectEqual(@as(u64, 18), got.msg.ropen.qid.path);
+ // `iounit` is one message less the slack `fcall.h:72` reserves.
+ try testing.expectEqual(@as(u32, 4096 - iohdrsz), got.msg.ropen.iounit);
+
+ // The fid IS the open, so a second one has nothing to mean.
+ try h.send(7, .{ .topen = .{ .fid = 1, .mode = oread } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_already_open, got.msg.rerror.ename);
+
+ try h.send(8, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ got = try h.reap();
+ try testing.expectEqualStrings("hello, body\n", got.msg.rread.data);
+
+ // Offsets are honoured on `body`, which is the whole reason `cat`, `wc`
+ // and `tail` work against this tree (docs/9p.typ §4).
+ try h.send(9, .{ .tread = .{ .fid = 1, .offset = 7, .count = 4096 } });
+ got = try h.reap();
+ try testing.expectEqualStrings("body\n", got.msg.rread.data);
+
+ // Past the end is zero bytes, which is end of file and not an error.
+ try h.send(10, .{ .tread = .{ .fid = 1, .offset = 99, .count = 16 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(usize, 0), got.msg.rread.data.len);
+
+ // THE RELEASE. Without it a pane's `event` reader count never comes back
+ // down and the editor answers to a script that has gone.
+ try testing.expectEqual(@as(u32, 0), h.fsys.releases);
+ try h.send(11, .{ .tclunk = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expect(got.msg == .rclunk);
+ try testing.expectEqual(@as(u32, 1), h.fsys.releases);
+
+ // A FID USED AFTER CLUNK is a fid nobody knows.
+ try h.send(12, .{ .tread = .{ .fid = 1, .offset = 0, .count = 16 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename);
+ try h.send(13, .{ .tclunk = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename);
+
+ // A clunk of a fid that was never opened needs no release at all.
+ _ = try h.walkTo(14, 2, &.{"index"});
+ try h.send(15, .{ .tclunk = .{ .fid = 2 } });
+ got = try h.reap();
+ try testing.expect(got.msg == .rclunk);
+ try testing.expectEqual(@as(u32, 1), h.fsys.releases);
+}
+
+test "9p server: every Rread is clamped to the client's count and to the msize" {
+ var h: Harness = .{};
+ // A small msize on purpose: `index` is a kilobyte and one message cannot
+ // carry it.
+ try h.handshake(512);
+ _ = try h.walkTo(5, 1, &.{"index"});
+ try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } });
+ _ = try h.reap();
+
+ // The count, when the count is the smaller.
+ try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 5 } });
+ var got = try h.reap();
+ try testing.expectEqual(@as(usize, 5), got.msg.rread.data.len);
+
+ // The MSIZE, when the client asks for more than one message can hold. An
+ // `Rread` longer than the count is a hard -EIO in Linux
+ // (`client.c:1475-1479`) and one longer than the msize is unreadable, so
+ // the answer is `msize - 11` exactly and the whole message is `msize`.
+ try h.send(8, .{ .tread = .{ .fid = 1, .offset = 0, .count = 1 << 20 } });
+ const out = h.srv.output();
+ try testing.expectEqual(@as(?u32, 512), frameLen(out));
+ got = try h.reap();
+ try testing.expectEqual(@as(usize, 512 - header_len - 4), got.msg.rread.data.len);
+
+ // And the core was never asked for bytes that would have been thrown
+ // away: the clamp is on the request too.
+ try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 1 << 20 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(usize, 501), got.msg.rread.data.len);
+}
+
+test "9p server: a directory read is whole stat records at a cursor the client cannot invent" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+ // Walked before the root is opened, because an open fid cannot be walked
+ // (walk(5)) and the mode assertion at the bottom of this test needs it.
+ _ = try h.walkTo(4, 1, &.{"index"});
+ try h.send(5, .{ .topen = .{ .fid = 0, .mode = oread } });
+ _ = try h.reap();
+
+ var found: [8][]const u8 = undefined;
+
+ // Two entries fit in 150 bytes; the third does not, so it is not counted
+ // and the next read asks for it by index. No cached entry anywhere, which
+ // is what `acmefs`'s re-staging buys (`acmefs.zig:1013-1016`).
+ try h.send(6, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } });
+ var got = try h.reap();
+ const first = got.msg.rread.data.len;
+ try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found));
+ try testing.expectEqualStrings("index", found[0]);
+ try testing.expectEqualStrings("cons", found[1]);
+ try testing.expect(first <= 150);
+
+ // THE RULE: the next read carries exactly the byte offset where the last
+ // one ended. Not the entry count, and not anything the client chose.
+ try h.send(7, .{ .tread = .{ .fid = 0, .offset = first, .count = 150 } });
+ got = try h.reap();
+ const second = got.msg.rread.data.len;
+ try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found));
+ try testing.expectEqualStrings("new", found[0]);
+ try testing.expectEqualStrings("1", found[1]);
+
+ // An arbitrary offset is refused — even one that would land on an entry
+ // boundary, which is exactly the case `ad` accepts silently (`9P-5`).
+ try h.send(8, .{ .tread = .{ .fid = 0, .offset = first + second + 1, .count = 150 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_bad_offset, got.msg.rerror.ename);
+ try h.send(9, .{ .tread = .{ .fid = 0, .offset = 3, .count = 150 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_bad_offset, got.msg.rerror.ename);
+
+ // The refusal did not move the cursor: the listing carries on.
+ try h.send(10, .{ .tread = .{ .fid = 0, .offset = first + second, .count = 150 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found));
+ try testing.expectEqualStrings("2", found[0]);
+
+ // Zero bytes is END OF DIRECTORY, and it is not an error.
+ try h.send(11, .{ .tread = .{ .fid = 0, .offset = first + second + got.msg.rread.data.len, .count = 150 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(usize, 0), got.msg.rread.data.len);
+
+ // Offset zero REWINDS, which is the only seek 9P allows in a directory.
+ try h.send(12, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found));
+ try testing.expectEqualStrings("index", found[0]);
+
+ // A count too small for ONE entry is refused rather than answered with
+ // zero bytes, because zero bytes means end of directory and a client that
+ // believes it stops asking.
+ try h.send(13, .{ .tread = .{ .fid = 0, .offset = 0, .count = 40 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_count_small, got.msg.rerror.ename);
+
+ // A directory's entries carry the tree's default mode and a zero length;
+ // the exact bits come from `Tstat`, which asks the core.
+ try h.send(14, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } });
+ got = try h.reap();
+ const one = try Stat.decode(got.msg.rread.data[0 .. std.mem.readInt(u16, got.msg.rread.data[0..2], .little) + 2]);
+ try testing.expectEqualStrings("index", one.name);
+ try testing.expectEqual(@as(u32, dirent_file_perm), one.mode);
+ try testing.expectEqual(@as(u64, 0), one.length);
+ try testing.expectEqual(@as(u32, 0), one.qid.version);
+
+ try h.send(16, .{ .tstat = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(u32, 0o400), got.msg.rstat.stat.mode);
+ try testing.expectEqual(@as(u64, 1024), got.msg.rstat.stat.length);
+}
+
+test "9p server: a blocked read parks, and the connection keeps working" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+ _ = try h.walkTo(5, 1, &.{ "1", "event" });
+ try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } });
+ _ = try h.reap();
+
+ // `Status.again`: nothing consumed, ask me later. NOT an error and NOT an
+ // empty read — the client hears nothing at all.
+ try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ try h.quiet();
+
+ // A retry round finds it, the core still has nothing, and it goes back.
+ h.pump();
+ h.pump();
+ try h.quiet();
+
+ // Meanwhile the connection is not blocked: another tag is served while the
+ // read waits, which is the entire point of parking rather than waiting.
+ _ = try h.walkTo(8, 2, &.{ "1", "body" });
+ try h.send(9, .{ .tstat = .{ .fid = 2 } });
+ var got = try h.reap();
+ try testing.expectEqualStrings("body", got.msg.rstat.stat.name);
+
+ // The core has something now, and the retry round is what delivers it —
+ // with the tag the client used seven messages ago.
+ h.fsys.event = "Kli7 7 0 0 hello\n";
+ h.pump();
+ got = try h.reap();
+ try testing.expectEqual(@as(u16, 7), got.tag);
+ try testing.expectEqualStrings("Kli7 7 0 0 hello\n", got.msg.rread.data);
+ try h.quiet();
+
+ // A write the core parks is copied out of the input buffer, so the next
+ // message may overwrite it and the retry still has its bytes.
+ _ = try h.walkTo(10, 3, &.{ "1", "ctl" });
+ try h.send(11, .{ .topen = .{ .fid = 3, .mode = owrite } });
+ _ = try h.reap();
+ h.fsys.park_writes = true;
+ try h.send(12, .{ .twrite = .{ .fid = 3, .offset = 0, .data = "clean\n" } });
+ try h.quiet();
+ try h.send(13, .{ .tstat = .{ .fid = 2 } });
+ _ = try h.reap();
+ h.fsys.park_writes = false;
+ h.pump();
+ got = try h.reap();
+ try testing.expectEqual(@as(u16, 12), got.tag);
+ try testing.expectEqual(@as(u32, 6), got.msg.rwrite.count);
+ try testing.expectEqualStrings("clean\n", h.fsys.writes[0..h.fsys.writes_len]);
+ // Overflow is a refusal and not a queue. Thirty-two blocked readers is
+ // thirty-two scripts watching one session; the thirty-third is told to
+ // retry, which is honest, rather than dropped, which would hang it.
+ for (0..max_slots) |k| {
+ try h.send(@intCast(100 + k), .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ try h.quiet();
+ }
+ try h.send(200, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_again, got.msg.rerror.ename);
+}
+
+test "9p server: the reply queue is a FIFO that survives a partial write" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ // A UART writes what it can. What is left stays, in order, and the next
+ // reply lands behind it rather than on top of it.
+ try h.send(5, .{ .tstat = .{ .fid = 0 } });
+ var saved: [256]u8 = undefined;
+ const one = h.srv.output();
+ const n = one.len;
+ @memcpy(saved[0..n], one);
+ h.srv.wrote(3);
+ try testing.expectEqual(n - 3, h.srv.output().len);
+
+ try h.send(6, .{ .tstat = .{ .fid = 0 } });
+ const rest = h.srv.output();
+ try testing.expectEqualSlices(u8, saved[3..n], rest[0 .. n - 3]);
+ const tail = rest[n - 3 ..];
+ const second = try decode(tail[0..frameLen(tail).?]);
+ try testing.expectEqual(@as(u16, 6), second.tag);
+
+ // `push` takes what there is room for and says how much, which is the only
+ // back-pressure a server with no descriptor has. (A buffer of zeros is
+ // also a `size` no encoder produced, so the connection dies on it — which
+ // is the other half of what a caller has to handle.)
+ var flood: [8192]u8 = @splat(0);
+ try testing.expectEqual(@as(usize, 4096), h.srv.push(&flood));
+ h.pump();
+ try testing.expect(h.srv.dead);
+ try testing.expectEqual(@as(usize, 0), h.srv.push(&flood));
+}
+
+test "9p server: Tflush answers the original first and the Rflush second" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+ _ = try h.walkTo(5, 1, &.{ "1", "event" });
+ try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } });
+ _ = try h.reap();
+ try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ try h.quiet();
+
+ // TWO messages, in this order and no other. A client that sees `Rflush`
+ // may reuse the tag, so a reply arriving after it would be a reply to
+ // whatever that tag names next.
+ try h.send(8, .{ .tflush = .{ .oldtag = 7 } });
+ var got = try h.reap();
+ try testing.expectEqual(@as(u16, 7), got.tag);
+ try testing.expectEqualStrings(e_interrupted, got.msg.rerror.ename);
+ got = try h.reap();
+ try testing.expectEqual(@as(u16, 8), got.tag);
+ try testing.expect(got.msg == .rflush);
+ try h.quiet();
+
+ // The park slot is gone with it: the core producing an event now sends
+ // nothing, rather than a second answer to a tag the client has reused.
+ h.fsys.event = "Kli7 7 0 0 hello\n";
+ h.pump();
+ try h.quiet();
+
+ // A flush of a tag we do not hold is an `Rflush` and nothing else, which
+ // is the only promise flush(5) makes.
+ try h.send(9, .{ .tflush = .{ .oldtag = 99 } });
+ got = try h.reap();
+ try testing.expectEqual(@as(u16, 9), got.tag);
+ try testing.expect(got.msg == .rflush);
+ try h.quiet();
+}
+
+test "9p server: the fid and permission refusals, each in a string Linux knows" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ // A fid nobody walked to.
+ for ([_]Msg{
+ .{ .tread = .{ .fid = 99, .offset = 0, .count = 16 } },
+ .{ .tstat = .{ .fid = 99 } },
+ .{ .tclunk = .{ .fid = 99 } },
+ .{ .topen = .{ .fid = 99, .mode = oread } },
+ .{ .twalk = .{ .fid = 99, .newfid = 98, .nwname = 0 } },
+ }, 20..) |msg, tag| {
+ try h.send(@intCast(tag), msg);
+ const got = try h.reap();
+ try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename);
+ }
+
+ // A fid that was never opened, and one opened the other way round. Both
+ // are `u9fs.c:755-758`'s two conditions.
+ _ = try h.walkTo(5, 1, &.{ "1", "body" });
+ try h.send(6, .{ .tread = .{ .fid = 1, .offset = 0, .count = 16 } });
+ var got = try h.reap();
+ try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename);
+ try h.send(7, .{ .topen = .{ .fid = 1, .mode = oread } });
+ _ = try h.reap();
+ try h.send(8, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "x" } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename);
+
+ // «must not have been opened for I/O» — walk(5). The fid IS the open.
+ try h.send(9, .{ .twalk = .{ .fid = 1, .newfid = 2, .nwname = 0 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename);
+
+ // THE PERMISSION CHECK IS OURS: there is no kernel above us to do it, and
+ // `errors` is write-only in acme's own dirtab.
+ _ = try h.walkTo(10, 3, &.{ "1", "errors" });
+ try h.send(11, .{ .topen = .{ .fid = 3, .mode = oread } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_perm, got.msg.rerror.ename);
+ try h.send(12, .{ .topen = .{ .fid = 3, .mode = owrite } });
+ got = try h.reap();
+ try testing.expect(got.msg == .ropen);
+
+ // A directory is read and only read; and nothing here can be removed, so
+ // nothing can be opened remove-on-close or executed either.
+ try h.send(13, .{ .topen = .{ .fid = 0, .mode = ordwr } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_perm, got.msg.rerror.ename);
+ _ = try h.walkTo(14, 4, &.{ "1", "tag" });
+ for ([_]u8{ orclose, oexec, oread | orclose }, 30..) |mode, tag| {
+ try h.send(@intCast(tag), .{ .topen = .{ .fid = 4, .mode = mode } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_perm, got.msg.rerror.ename);
+ }
+}
+
+test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+ _ = try h.walkTo(5, 1, &.{ "1", "body" });
+
+ // The sentinels stat(5) specifies: an empty string, an all-ones integer.
+ const sentinel: Stat = .{
+ .type = std.math.maxInt(u16),
+ .dev = std.math.maxInt(u32),
+ .qid = .{ .type = 0xFF, .version = std.math.maxInt(u32), .path = std.math.maxInt(u64) },
+ .mode = std.math.maxInt(u32),
+ .atime = std.math.maxInt(u32),
+ .mtime = std.math.maxInt(u32),
+ .length = std.math.maxInt(u64),
+ .name = "",
+ .uid = "",
+ .gid = "",
+ .muid = "",
+ };
+
+ // Nothing to do: accepted and ignored, which is what a filesystem of live
+ // editor state has to do with a mode, an owner and two times.
+ try h.send(6, .{ .twstat = .{ .fid = 1, .stat = sentinel } });
+ var got = try h.reap();
+ try testing.expect(got.msg == .rwstat);
+ try testing.expectEqualStrings("hello, body\n", h.fsys.body);
+
+ // A length that is neither the sentinel nor zero. The core honours exactly
+ // one value, and this string is one Linux maps to EPERM rather than 526.
+ var five = sentinel;
+ five.length = 5;
+ try h.send(7, .{ .twstat = .{ .fid = 1, .stat = five } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_trunc_only, got.msg.rerror.ename);
+
+ // A rename would change the shape of a tree that follows the pane list.
+ var renamed = sentinel;
+ renamed.name = "other";
+ try h.send(8, .{ .twstat = .{ .fid = 1, .stat = renamed } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_wstat, got.msg.rerror.ename);
+ try testing.expectEqualStrings("hello, body\n", h.fsys.body);
+
+ // ...and zero IS the truncate.
+ var zero = sentinel;
+ zero.length = 0;
+ try h.send(9, .{ .twstat = .{ .fid = 1, .stat = zero } });
+ got = try h.reap();
+ try testing.expect(got.msg == .rwstat);
+ try testing.expectEqualStrings("", h.fsys.body);
+
+ // The other spelling of the same thing, and the one a shell's `>`
+ // produces: `Topen` with `OTRUNC` is a truncate and then an open, in that
+ // order, and it is refused on a fid with no write permission.
+ h.fsys.body = "hello, body\n";
+ _ = try h.walkTo(10, 2, &.{"index"});
+ try h.send(11, .{ .topen = .{ .fid = 2, .mode = oread | otrunc } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_perm, got.msg.rerror.ename);
+ try h.send(12, .{ .topen = .{ .fid = 1, .mode = owrite | otrunc } });
+ got = try h.reap();
+ try testing.expectEqual(@as(u64, 18), got.msg.ropen.qid.path);
+ try testing.expectEqualStrings("", h.fsys.body);
+}
+
+test "9p server: create and remove are refused, and a remove clunks the fid anyway" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ // Nothing in a generated tree is a client's to make. The one place a
+ // client DOES create something is `new/`, where the WALK creates a pane —
+ // so the capability exists and is not spelled `Tcreate` (`9P-18`).
+ try h.send(5, .{ .tcreate = .{ .fid = 0, .name = "thing", .perm = 0o600, .mode = owrite } });
+ var got = try h.reap();
+ try testing.expectEqualStrings(e_perm, got.msg.rerror.ename);
+
+ // A remove is refused too — but «the fid is clunked even if the remove
+ // fails», which is the half of remove(5) that is easy to miss, and the
+ // release still goes to the core.
+ _ = try h.walkTo(6, 1, &.{ "1", "body" });
+ try h.send(7, .{ .topen = .{ .fid = 1, .mode = ordwr } });
+ _ = try h.reap();
+ try h.send(8, .{ .tremove = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_perm, got.msg.rerror.ename);
+ try testing.expectEqual(@as(u32, 1), h.fsys.releases);
+ try h.send(9, .{ .tstat = .{ .fid = 1 } });
+ got = try h.reap();
+ try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename);
+}
+
+test "9p server: a message arriving a byte at a time is served when its last byte lands" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ // The board's UART, and a socket that happened to split a write. Framing
+ // is `size[4]` and nothing may be served until all of it is in.
+ var buf: [64]u8 = undefined;
+ const bytes = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf);
+ for (bytes[0 .. bytes.len - 1]) |b| {
+ try testing.expectEqual(@as(usize, 1), h.srv.push(&.{b}));
+ h.pump();
+ try h.quiet();
+ }
+ try testing.expectEqual(@as(usize, 1), h.srv.push(bytes[bytes.len - 1 ..]));
+ h.pump();
+ const got = try h.reap();
+ try testing.expectEqualStrings("/", got.msg.rstat.stat.name);
+
+ // TWO messages in one push are two replies, in order, and the input buffer
+ // ends up empty.
+ var pair: [128]u8 = undefined;
+ const a = try encode(.{ .tstat = .{ .fid = 0 } }, 6, &pair);
+ const b = try encode(.{ .tstat = .{ .fid = 0 } }, 7, pair[a.len..]);
+ try testing.expectEqual(a.len + b.len, h.srv.push(pair[0 .. a.len + b.len]));
+ h.pump();
+ try testing.expectEqual(@as(u16, 6), (try h.reap()).tag);
+ try testing.expectEqual(@as(u16, 7), (try h.reap()).tag);
+ try h.quiet();
+}
+
+test "9p server: what is not 9P2000 on this connection is refused, not guessed" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+
+ var buf: [64]u8 = undefined;
+ const good = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf);
+
+ // An R-message: a client on the wrong end of the connection, or the
+ // double-role link docs/9p.typ §7 says not to build.
+ var raw: [64]u8 = undefined;
+ @memcpy(raw[0..good.len], good);
+ raw[4] = @intFromEnum(Type.rstat);
+ try testing.expectEqual(good.len, h.srv.push(raw[0..good.len]));
+ h.pump();
+ var got = try h.reap();
+ try testing.expectEqual(@as(u16, 5), got.tag);
+ try testing.expectEqualStrings(e_botch, got.msg.rerror.ename);
+
+ // A type byte no dialect we serve defines — 8 is 9P2000.L's `Tstatfs` —
+ // still gets an answer, because the tag is at a fixed offset and a client
+ // that gets no reply hangs.
+ @memcpy(raw[0..good.len], good);
+ raw[4] = 8;
+ _ = h.srv.push(raw[0..good.len]);
+ h.pump();
+ got = try h.reap();
+ try testing.expectEqual(@as(u16, 5), got.tag);
+ try testing.expectEqualStrings(e_botch, got.msg.rerror.ename);
+
+ // A `size` no encoder could have produced is not a message to answer: the
+ // stream is not 9P and there is no resynchronising from it.
+ @memcpy(raw[0..good.len], good);
+ std.mem.writeInt(u32, raw[0..4], 3, .little);
+ _ = h.srv.push(raw[0..good.len]);
+ h.pump();
+ try h.quiet();
+ try testing.expect(h.srv.dead);
+}
+
+test "9p server: a connection that drops still pays the core its releases" {
+ var h: Harness = .{};
+ try h.handshake(4096);
+ _ = try h.walkTo(5, 1, &.{ "1", "event" });
+ _ = try h.walkTo(6, 2, &.{ "1", "body" });
+ for ([_]u32{ 1, 2 }, 7..) |fid, tag| {
+ try h.send(@intCast(tag), .{ .topen = .{ .fid = fid, .mode = oread } });
+ _ = try h.reap();
+ }
+ // One blocked reader, so there is a parked request to abandon as well.
+ try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ try h.quiet();
+
+ // The socket died. Every open fid still owes the core a release, and that
+ // debt outlives the connection — losing it leaves the editor reporting
+ // button actions to a script that is gone.
+ h.srv.hangup();
+ h.pump();
+ try testing.expectEqual(@as(u32, 2), h.fsys.releases);
+ // ...and nothing is written to a socket that has gone.
+ try h.quiet();
+
+ // `Tversion` is the same reset on a live connection: fids clunked,
+ // outstanding I/O abandoned, releases still paid (version(5)).
+ var g: Harness = .{};
+ try g.handshake(4096);
+ _ = try g.walkTo(5, 1, &.{ "1", "event" });
+ try g.send(6, .{ .topen = .{ .fid = 1, .mode = oread } });
+ _ = try g.reap();
+ try g.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } });
+ try g.quiet();
+ try g.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000" } });
+ const v = try g.reap();
+ try testing.expectEqualStrings("9P2000", v.msg.rversion.version);
+ try testing.expectEqual(@as(u32, 1), g.fsys.releases);
+ // The abandoned read is never answered, and the fid is gone.
+ g.fsys.event = "Kli7 7 0 0 hello\n";
+ g.pump();
+ try g.quiet();
+ try g.send(8, .{ .tstat = .{ .fid = 1 } });
+ const got = try g.reap();
+ try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename);
+}
+
+test "9p server: every errno the core can answer is a string Linux knows" {
+ // The nine values `acmefs.E` defines, spelled exactly as
+ // `linux/net/9p/error.c:41-171` holds them. A typo in any of these is
+ // "Unknown error 526" on every `mount -t 9p`, which is why they are
+ // asserted as literals rather than derived from anything.
+ try testing.expectEqualStrings("Operation not permitted", errString(1));
+ try testing.expectEqualStrings("No such file or directory", errString(2));
+ try testing.expectEqualStrings("Input/output error", errString(5));
+ try testing.expectEqualStrings("Cannot allocate memory", errString(12));
+ try testing.expectEqualStrings("Not a directory", errString(20));
+ try testing.expectEqualStrings("Invalid argument", errString(22));
+ try testing.expectEqualStrings("Too many open files in system", errString(23));
+ try testing.expectEqualStrings("No space left on device", errString(28));
+ try testing.expectEqualStrings("Function not implemented", errString(38));
+ // A number this file never emits is EIO, not a table miss.
+ try testing.expectEqualStrings("Input/output error", errString(0));
+ try testing.expectEqualStrings("Input/output error", errString(999));
+
+ // The server's own strings, from the same table and the fossil/u9fs half
+ // of it. Every one of these is a line in `error.c`, which is the whole
+ // difference between an errno and 526.
+ try testing.expectEqualStrings("fid unknown or out of range", e_unknown_fid);
+ try testing.expectEqualStrings("fid already in use", e_fid_in_use);
+ try testing.expectEqualStrings("bad use of fid", e_bad_use);
+ try testing.expectEqualStrings("bad offset in directory read", e_bad_offset);
+ try testing.expectEqualStrings("permission denied", e_perm);
+ try testing.expectEqualStrings("not a directory", e_not_dir);
+ try testing.expectEqualStrings("file already open for I/O", e_already_open);
+ try testing.expectEqualStrings("illegal name", e_illegal_name);
+ try testing.expectEqualStrings("Too many open files in system", e_too_many_fids);
+ try testing.expectEqualStrings("protocol botch", e_botch);
+ try testing.expectEqualStrings("Interrupted system call", e_interrupted);
+ try testing.expectEqualStrings("only support truncation to zero length", e_trunc_only);
+ try testing.expectEqualStrings("wstat prohibited", e_wstat);
+ try testing.expectEqualStrings("Resource temporarily unavailable", e_again);
+ // Every one of them fits the buffer a Plan 9 client has for it.
+ for ([_][]const u8{
+ e_unknown_fid, e_fid_in_use, e_bad_use, e_bad_offset, e_perm,
+ e_not_dir, e_already_open, e_botch, e_interrupted, e_trunc_only,
+ e_wstat, e_again, e_no_tree, e_no_auth, e_count_small,
+ e_illegal_name, e_too_many_fids, e_small_msize,
+ }) |s| try testing.expect(s.len <= errmax);
+}
+
+test "9p server: the fid table and the park table are what the board was costed for" {
+ // `docs/registry.typ` `9P-11` costed a fid table at 32 x 16 bytes. The
+ // real entry is larger, and the difference is not a mistake in either
+ // place: it is the entry NAME, which `Rstat` carries and a node id does
+ // not, plus the open handle and the two-coordinate directory cursor. The
+ // numbers are asserted here so that a change to `Fid` shows up as a diff
+ // in the board's budget rather than as a surprise on the board.
+ const S = Server(StubFs);
+ const fids = @sizeOf(S.Fid) * max_fids;
+ const slots = @sizeOf(S.Slot) * max_slots;
+ try testing.expect(fids <= 3 * 1024);
+ try testing.expect(slots <= 8 * 1024);
+ // Two buffers at a 4,096-byte msize — `in` and `out`'s two — plus the two
+ // tables, against 336 KB of free heap on the P4.
+ try testing.expect(3 * 4096 + fids + slots <= 24 * 1024);
+}
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 8e6b92d4..2d7a5238 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -174,6 +174,10 @@ const file_watch = @import("../file_watch.zig");
/// drain, at the same point in the frame that tty.zig drains.
const fuse = @import("../fuse.zig");
const fs_service = @import("../fs_service.zig");
+/// ...and the SECOND transport onto that same tree, on a unix socket of its
+/// own. `Source.ninep_listener` and `Source.ninep` are its arms; `pollFrame`
+/// is its drain, beside the mount's, at the same point in the frame.
+const fs9_service = @import("../fs9_service.zig");
/// Host-lifetime storage for the OSC 133 rc files a forked shell sources, held
/// by `Session` because a Session is exactly one host's lifetime.
@@ -250,9 +254,11 @@ const read_chunk = 16 * 1024;
const pty_chunk = 64 * 1024;
/// Descriptors in the ONE poll this process runs: the listener, every frontend,
-/// every pane's pty master, the inotify descriptor behind every watch, and
-/// `/dev/fuse`. 51 on a full house, and one syscall covers all of them.
-const poll_slots = 1 + max_clients + pardes.MAX_PANES + 2;
+/// every pane's pty master, the inotify descriptor behind every watch,
+/// `/dev/fuse`, the 9P listener, and every 9P connection. That is
+/// 1 + 32 + 16 + 1 + 1 + 1 + 4 = 56 on a full house, and one syscall covers
+/// all of them.
+const poll_slots = 1 + max_clients + pardes.MAX_PANES + 2 + 1 + fs9_service.max_conns;
/// How many times `reloadWatched` will honour `file_watch.reloadChanged`'s
/// request for another pass within one round. See `reloadWatched`.
@@ -411,6 +417,16 @@ const Source = union(enum) {
/// one, and the `fd < 0` guard cannot see it because the fd is valid,
/// merely different. Same hazard as the client slots, same reason.
fuse,
+ /// The 9P listener, and one arm per connection on it. Same shape and same
+ /// hazard as `fuse` above, and stated separately only because the hazard
+ /// is WORSE here: a 9P `Twrite` to `ctl` reaches `core.perform` exactly as
+ /// a FUSE write does, so it can `push_spawn` and close a pane's master
+ /// under a later `.pty` entry in this same pass. So `dispatch` moves BYTES
+ /// — accept, read into `push`, drain `output` — and never serves a
+ /// request; `pollFrame` does the serving, after the snapshot is done with.
+ ninep_listener,
+ /// A connection's index in `Listener.conns`.
+ ninep: u8,
};
pub const Session = struct {
@@ -463,6 +479,31 @@ pub const Session = struct {
/// Same role as `check_files`: nothing else will wake us, because no
/// acknowledgement has gone back, so the next round must not sleep.
fs_pending: bool = false,
+ /// The same tree on a unix socket, or null when `--fs9` was not asked for
+ /// or the bind failed. Owned here, like `fs`, so that `deinit` closes the
+ /// socket and unlinks its path on every way out.
+ ninep: ?*fs9_service.Listener = null,
+ /// A 9P drain stopped with work still owed. `fs_pending`'s twin, and it
+ /// needs its own field rather than sharing that one: they are cleared by
+ /// different drains, and or'ing them into one flag would make a busy 9P
+ /// script keep the FUSE drain's `pending` set forever.
+ ninep_pending: bool = false,
+ /// WHICH TRANSPORT THE REQUEST BEING SERVED CAME FROM, for the whole of
+ /// one `fs_service.drain` and never outside one.
+ ///
+ /// A filesystem reply reaches its transport through `push_fs_reply`, which
+ /// is a HOST method: the core answers a `fs_req` and does not know, and
+ /// must not know, that this process has two filesystems on one tree. This
+ /// field is the routing origin docs/9p.typ's layering table says a second
+ /// listener costs, and it is one pointer set by the drain that already
+ /// knows the answer.
+ ///
+ /// Null outside a drain, and `fsReply` then falls back to the mount. That
+ /// fallback is for a reply the core produced with no request outstanding,
+ /// which `Fs.reply` drops on a slot lookup; it is NOT a routing guess, and
+ /// a 9P reply cannot reach it — every 9P request is answered inside the
+ /// `step` that made it, which is inside the drain that set this.
+ fs_origin: ?fs_service.Transport = null,
/// Which directory mark belongs to which pane, and the generation the core
/// has already accepted from each. file_watch.zig owns the shape and the
/// transaction; this host owns only the descriptor and the wake.
@@ -513,6 +554,15 @@ pub const Session = struct {
f.deinit();
s.fs = null;
}
+ // ...and the 9P socket, for the mount's reason above: a script blocked
+ // on `event` over 9P is woken by the EOF its own connection closing
+ // produces, while its shell is still alive to run its exit path. The
+ // orphaned fids are not pumped — the core they would report releases to
+ // is going with them.
+ if (s.ninep) |l| {
+ l.deinit(s.gpa);
+ s.ninep = null;
+ }
// Tell everyone the session is over before the socket disappears, so a
// frontend exits on a `quit` rather than on a read error whose meaning
// it has to guess. Best effort by construction: these descriptors are
@@ -960,9 +1010,17 @@ pub const Session = struct {
/// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside
/// `perform` and is borrowed only for this call, so a body read is a window
/// onto the pane's live text and copies nothing. `.again` needs no case:
- /// `Fs.reply` reads the status and re-parks the request itself.
+ /// both transports read the status and re-park the request themselves.
+ ///
+ /// WHICH transport is `fs_origin`, set by the drain that asked. This used
+ /// to be `s.fs` unconditionally, which was right while a mount was the only
+ /// answer there was and became a silent misroute the moment `--fs9` gave
+ /// the session a second one: `Fs.reply` looks the tag up in ITS park table,
+ /// finds nothing, and returns — so a 9P `Tattach` was answered into the
+ /// void and its client waited forever. Measured against plan9port's `9p`.
fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void {
const s = of(ctx);
+ if (s.fs_origin) |t| return t.reply(reply, bytes);
if (s.fs) |f| f.reply(reply, bytes);
}
@@ -985,7 +1043,29 @@ pub const Session = struct {
// in the surface this frame composes, not the next one. The flag is
// read by `waitInput`, which must not sleep while the kernel still has
// requests we have not acknowledged.
- if (s.fs) |f| s.fs_pending = fs_service.drain(f.transport(), s.core).pending;
+ if (s.fs) |f| {
+ s.fs_origin = f.transport();
+ s.fs_pending = fs_service.drain(s.fs_origin.?, s.core).pending;
+ s.fs_origin = null;
+ }
+ // ...and the 9P connections, in the same breath and for the same
+ // reason. One connection is one `Transport`, so this is
+ // `fs_service.drain` per connection per frame, with `fs_origin` naming
+ // the one being served so its replies come back to it (see `fsReply`).
+ // HERE rather than in `dispatch` for `Source.ninep`'s reason: a
+ // `Twrite` to `ctl` can `push_spawn`, and `dispatch` is mid-iteration
+ // over a descriptor snapshot when it runs.
+ if (s.ninep) |l| {
+ var pending = false;
+ for (0..fs9_service.max_conns) |i| {
+ const t = l.transport(@intCast(i)) orelse continue;
+ s.fs_origin = t;
+ const d = fs_service.drain(t, s.core);
+ s.fs_origin = null;
+ if (l.settle(@intCast(i), d)) pending = true;
+ }
+ s.ninep_pending = pending;
+ }
for (&s.ptys, 0..) |*pt, pane| {
if (pt.fd < 0) continue;
var lbuf: [1024]u8 = undefined;
@@ -1345,6 +1425,28 @@ pub const Session = struct {
src[n] = .fuse;
n += 1;
};
+ // ...and the 9P socket, when there is one: the listener, plus one
+ // descriptor per live connection. POLLOUT only while a connection owes
+ // bytes, which is the same rule and the same reason as a client's and
+ // a pty's — asking for it unconditionally makes every idle socket a
+ // ready descriptor and turns the poll into a spin.
+ if (s.ninep) |l| {
+ if (l.fd >= 0) {
+ fds[n] = .{ .fd = l.fd, .events = poll_in, .revents = 0 };
+ src[n] = .ninep_listener;
+ n += 1;
+ }
+ for (0..fs9_service.max_conns) |i| {
+ if (!l.live(@intCast(i))) continue;
+ fds[n] = .{
+ .fd = l.conns[i].fd,
+ .events = if (l.owes(@intCast(i))) poll_in | poll_out else poll_in,
+ .revents = 0,
+ };
+ src[n] = .{ .ninep = @intCast(i) };
+ n += 1;
+ }
+ }
// A session with no listener, no clients, no shells and no watches has
// no event source at all. Returning immediately would spin the outer
// `while (!core.quit)` at full speed, so sleep the interval the core
@@ -1355,7 +1457,13 @@ pub const Session = struct {
// `reconcile` posts a resize only when a client is ATTACHED, which means
// its socket is in the set; and `fs_pending` is only ever set by a drain,
// which runs only when `fs` is live, which puts `/dev/fuse` in the set.
- // So `n == 0` implies `!regridded` and `!fs_pending` too.
+ // So `n == 0` implies `!regridded` and `!fs_pending` too. `--fs9` is
+ // the ONE exception, and it is why `ninep_pending` is named on the
+ // `timeout = 0` line below rather than here: a hung-up 9P connection
+ // still owing the core its orphaned fids has no descriptor at all, so
+ // it can be the only work left with `n == 0`. It is also finite — one
+ // release per fid — so the 16 ms nap that path takes costs it a couple
+ // of frames and never a stall.
if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms);
// Zero is the core's word for "sleep until something happens" (see
// pardes.zig `pump`: it passes a frame interval only while an animation
@@ -1373,7 +1481,7 @@ pub const Session = struct {
// during `perform`, outside this function) and a regrid this round has
// already performed. Both are consumed before this function returns, so
// the round must not sleep before reaching them.
- if (s.check_files or regridded or s.fs_pending) timeout = 0;
+ if (s.check_files or regridded or s.fs_pending or s.ninep_pending) timeout = 0;
const ready = libc.poll(&fds, @intCast(n), timeout);
// A timeout is an ordinary frame boundary and EINTR is a signal we do not
// handle here. Neither skips anything below any more: what used to be an
@@ -1455,6 +1563,26 @@ pub const Session = struct {
.fuse => if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) {
if (s.fs) |f| f.dead = true;
},
+ .ninep_listener => if (pfd.revents != 0) {
+ if (s.ninep) |l| l.accept();
+ },
+ // BYTES ONLY. The requests those bytes decode into are served by
+ // `pollFrame`, after this snapshot is done with — see
+ // `Source.ninep`. POLLOUT before POLLIN so a reply the last frame
+ // could not finish writing goes before more work arrives, and
+ // POLLIN before the hangup because a script that wrote a `Tclunk`
+ // and closed has bytes still worth reading; the `live` guard is the
+ // client slots' `c.fd < 0`, for its reason.
+ .ninep => |i| if (s.ninep) |l| {
+ if (!l.live(i)) continue;
+ if (pfd.revents & poll_out != 0) l.flush(i);
+ if (!l.live(i)) continue;
+ if (pfd.revents & poll_in != 0) {
+ l.fill(i);
+ } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) {
+ l.drop(i);
+ }
+ },
};
}
@@ -1898,6 +2026,19 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void
// process polls `/dev/fuse` itself, in the same syscall as everything else.
// See `Source.fuse`.
session.fs = fs_service.start(gpa, core);
+ // ...and the same tree on a unix socket, independently: `--fs` and `--fs9`
+ // are two transports and neither is the other's prerequisite, so a daemon
+ // may serve one, both or neither. Null on every failure, for
+ // `fs_service.start`'s reason — a transport that will not bind must cost
+ // the operator their scripting, never their session — and NOT a
+ // `return error` the way the frontend socket above is, because a session
+ // whose frontend socket did not bind is one nobody can ever find, while
+ // this one is merely one nobody can script over 9P.
+ //
+ // A bare `--fs9` is named by the SESSION rather than by the pid: the
+ // operator typed that name to find the daemon again, and having to look up
+ // a pid to reach its filesystem would undo it. `--fs9=<name>` wins.
+ if (opts.fs9) |named| session.ninep = fs9_service.open(gpa, named, name);
const h = session.host();
core.host = h;
diff --git a/src/fs9_service.zig b/src/fs9_service.zig
new file mode 100644
index 00000000..f02e179e
--- /dev/null
+++ b/src/fs9_service.zig
@@ -0,0 +1,496 @@
+//! `pardes --fs9`: what a native HOST has to decide to serve acme's control
+//! filesystem over 9P2000 on a unix socket.
+//!
+//! `src/fs_service.zig`'s sibling, and deliberately a separate file: that one
+//! is three decisions about a MOUNT (where to mount, when to drain, what a
+//! pane shell is told), and this one is a listener with connections, buffers
+//! and a socket path. They share the seam and nothing else — `Transport`,
+//! `drain` and `Drained` all live there and are used verbatim here, which is
+//! the whole point of `9P-2`: a second answer to the same three functions.
+//!
+//! WHAT IS HERE: a bound listening socket, a small fixed table of connections,
+//! and the four things a non-blocking byte stream needs — accept, read into
+//! `push`, `output` out through `write` and back through `wrote`, and hangup.
+//! Everything above that is `src/9p.zig`, which is freestanding and knows
+//! about neither sockets nor `acmefs.zig`; the instantiation
+//! `ninep.Server(pardes.acmefs)` happens here and nowhere else.
+//!
+//! WHAT IS NOT HERE: the drain. One connection is one `Transport`, and the
+//! host calls `fs_service.drain` per connection per frame at the same point in
+//! the frame it drains the mount — see `drainAll`, and see
+//! `detached/server.zig`'s `Source.ninep` for why a filesystem request must
+//! not be served from inside a poll dispatch.
+//!
+//! Linux and darwin, like every other unix socket in the tree. On anything
+//! else `open` returns null and the flag is quietly off.
+const std = @import("std");
+const libc = std.c;
+const nested = @import("nested.zig");
+const ninep = @import("9p.zig");
+const pardes = @import("pardes.zig");
+const fs_service = @import("fs_service.zig");
+
+/// Diagnostics land where `fs_service`'s do and for its reason: stderr IS the
+/// screen in the tty shell, so this is the `PARDES_LOG=1` copy.
+const log = std.log.scoped(.fs9);
+
+/// Unix sockets, which is all this needs. `nested.supported` also demands a
+/// way to name an arbitrary pid's executable, which no part of this asks.
+pub const supported = builtin_unix;
+const builtin_unix = @import("builtin").os.tag == .linux or nested.darwin;
+
+/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`.
+const sun_path_len = nested.sun_path_len;
+
+/// A THIRD prefix in the one per-user directory, beside nested.zig's
+/// `pardes-<pid>.sock` and detached/server.zig's `pardes-detached-<name>.sock`,
+/// for the reason `nested.zig:39-44` gives: one directory vetted by different
+/// predicates is exactly the divergence that naming prevents. That file's
+/// sweeper unlinks any name whose digits name a dead pid, and this socket must
+/// never look like one; the detached transport's `vetted` accepts only its own
+/// prefix, so a 9P socket cannot be dialled by a frontend expecting `wire.zig`
+/// either.
+const prefix = "pardes-9p-";
+
+/// The msize this host serves, and the ONE number both buffers are sized from.
+///
+/// 8 KiB, which is `9P-17`'s clamp applied to the thing a client actually
+/// reads: the largest single answer this tree produces is one pane's `body`,
+/// and a client reading a megabyte of it does so in msize-sized `Tread`s
+/// whatever this number is. So the only thing a larger msize buys is fewer
+/// round trips on a LOCAL socket, and the only thing it costs is resident
+/// memory in a daemon nobody is talking to. 8 KiB is two `Tread`s per screen
+/// of text and comfortably above `ninep.min_msize` (4096), which is the floor
+/// below which plan9port's `9p` and Linux's `v9fs` start refusing mounts with
+/// `EINVAL` and no message.
+pub const msize: u32 = 8192;
+
+/// Connections one session serves at once.
+///
+/// FOUR, and it is not a guess about load: a 9P client here is a SCRIPT, and
+/// the thing a script does is walk, read and clunk. What holds a connection
+/// open for minutes is a blocked reader on `event` or `cons` — one per script
+/// that is watching the editor — and beyond a handful of those the honest
+/// answer is that somebody is using the wrong tool. The number is small on
+/// purpose because a connection costs its buffers whether it is busy or idle:
+/// MEASURED at 34,072 B each (a `Server` of 9,488 B plus `msize` in and twice
+/// `msize` out) for 136,408 B of table, which is the whole of what `--fs9`
+/// adds to a daemon's resident memory. A refused connect is also a diagnostic
+/// a script author sees immediately, where a silently queued one is not. The
+/// detached transport's `max_clients` is 32 because a frontend is a human's
+/// window; this is not that.
+pub const max_conns = 4;
+
+/// The 9P server, over the filesystem ABI `acmefs.zig` defines. This
+/// instantiation is the only coupling between the freestanding protocol file
+/// and the core, and it is a type parameter rather than an import for the
+/// reason `9p.zig`'s `Server` doc comment gives.
+const Srv = ninep.Server(pardes.acmefs);
+
+/// One connection: a socket, a server, and the server's two buffers.
+///
+/// THE BUFFERS ARE FIELDS HERE, which is what `Srv`'s "no allocator" means
+/// from the caller's side: `srv.in` and `srv.out` are slices INTO this struct,
+/// so a `Conn` must never be moved or copied once `srv` is initialised. That
+/// is why `Listener` is heap-allocated by `open` and held by pointer, and why
+/// nothing below takes a `Conn` by value.
+///
+/// `out` is twice `msize` because `Srv` requires it: one reply being written
+/// out and one being built, which is what lets a reply be encoded the moment
+/// the core answers with no "can I write yet" question anywhere in `9p.zig`.
+const Conn = struct {
+ /// Non-negative exactly while the peer is connected. It goes to -1 the
+ /// moment the connection ends, which is BEFORE this slot is free: see
+ /// `draining`.
+ fd: c_int = -1,
+ /// The peer has gone and the server still owes the core `release` calls
+ /// for the fids it held. A dropped `event` fid without one leaves the
+ /// pane's reader count high forever (`acmefs.zig:1053-1061`), so the slot
+ /// stays occupied, with no descriptor, until `next()` runs dry. See
+ /// `Srv.hangup` and `drainAll`.
+ draining: bool = false,
+ /// Undefined until `accept` initialises it in place, which it may only do
+ /// through a pointer to this exact storage.
+ srv: Srv = undefined,
+ in: [msize]u8 = undefined,
+ out: [2 * msize]u8 = undefined,
+
+ /// This connection's answer to `fs_service.Transport`. Thunked exactly
+ /// like `fuse.Fs.transport()`, and for its reason: a `*Conn` is not an
+ /// `*anyopaque` and a vtable cannot hold the typed function.
+ fn transport(c: *Conn) fs_service.Transport {
+ return .{ .ctx = c, .vtable = &transport_vtable };
+ }
+
+ const transport_vtable: fs_service.Transport.VTable = .{
+ .retry = transportRetry,
+ .next = transportNext,
+ .reply = transportReply,
+ };
+
+ fn transportRetry(ctx: *anyopaque) ?pardes.acmefs.Req {
+ const c: *Conn = @ptrCast(@alignCast(ctx));
+ return c.srv.retry();
+ }
+
+ fn transportNext(ctx: *anyopaque) ?pardes.acmefs.Req {
+ const c: *Conn = @ptrCast(@alignCast(ctx));
+ return c.srv.next();
+ }
+
+ fn transportReply(ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void {
+ const c: *Conn = @ptrCast(@alignCast(ctx));
+ c.srv.reply(r, bytes);
+ }
+};
+
+/// The listening socket and its connections. Heap-allocated because a `Conn`
+/// holds slices into itself (see there) and because at three buffers per
+/// connection this is ≈100 KiB, which does not belong in a host's struct.
+pub const Listener = struct {
+ fd: c_int = -1,
+ /// The bound path, kept so teardown unlinks exactly what was created —
+ /// guarded on the fd, like nested.zig's and detached/server.zig's
+ /// `unlisten`.
+ path_buf: [sun_path_len]u8 = undefined,
+ path_len: usize = 0,
+ conns: [max_conns]Conn = @splat(.{}),
+
+ /// The socket path, so a host can tell the operator where to dial.
+ pub fn path(l: *const Listener) []const u8 {
+ return l.path_buf[0..l.path_len];
+ }
+
+ /// Accept whatever is waiting, bounded.
+ ///
+ /// BOUNDED for detached/server.zig's `accept` reason: `poll` is level
+ /// triggered, so an unaccepted backlog reports ready forever and a peer
+ /// dialling in a loop would otherwise hold the core in here. And always
+ /// accepting, even with a full table, for the same reason — the surplus is
+ /// accepted and closed rather than left to spin the poll.
+ pub fn accept(l: *Listener) void {
+ if (comptime !supported) return;
+ for (0..max_conns + 1) |_| {
+ const fd = libc.accept(l.fd, null, null);
+ if (fd < 0) return; // EAGAIN is this loop's ordinary exit
+ nested.setCloexec(fd);
+ setNonblock(fd);
+ if (comptime nested.darwin) {
+ // linux says MSG_NOSIGNAL per write, darwin once per socket. A
+ // script that dies mid-reply must not take the editor down.
+ const on: c_int = 1;
+ _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int));
+ }
+ const c = for (&l.conns) |*cand| {
+ if (cand.fd < 0 and !cand.draining) break cand;
+ } else {
+ // No slot. Closing is the whole refusal: a 9P client that
+ // reads EOF instead of an `Rversion` reports a dial failure,
+ // which is the honest thing for it to say.
+ log.debug("--fs9: refusing a connection, all {d} slots busy", .{max_conns});
+ _ = libc.close(fd);
+ continue;
+ };
+ c.fd = fd;
+ c.draining = false;
+ c.srv = .init(.{
+ .in = &c.in,
+ .out = &c.out,
+ .root = @intFromEnum(pardes.acmefs.TopFile.root),
+ });
+ }
+ }
+
+ /// Read one chunk off connection `i` and hand it to the server.
+ ///
+ /// ONE read per connection per round, which is detached/server.zig's
+ /// `receive` rule: a script in a `while true` loop gets one turn and then
+ /// the loop moves on to the other connections and to the frame.
+ ///
+ /// Sized to what the server can TAKE rather than to the socket, because
+ /// `push` returns short on back-pressure and bytes read past that point
+ /// would have nowhere to go. Zero room is not an error and not a hangup:
+ /// the buffer holds a message the core has not finished with, and the next
+ /// drain frees it.
+ pub fn fill(l: *Listener, i: u8) void {
+ if (comptime !supported) return;
+ const c = &l.conns[i];
+ const room = c.srv.in.len - c.srv.in_len;
+ if (room == 0) return;
+ // A frame-local staging buffer rather than a read straight into the
+ // server's tail: advancing `in_len` is `push`'s business, and reaching
+ // past it to do it here would make this file a second author of
+ // `9p.zig`'s invariants for the sake of one memcpy per 8 KiB.
+ var buf: [msize]u8 = undefined;
+ const got = libc.read(c.fd, &buf, @min(room, buf.len));
+ if (got == 0) return l.drop(i); // clean EOF: the script left
+ if (got < 0) return switch (libc.errno(got)) {
+ .INTR, .AGAIN => {},
+ else => l.drop(i),
+ };
+ const n = c.srv.push(buf[0..@intCast(got)]);
+ // Cannot happen — the read was clamped to the room — and it is
+ // asserted rather than ignored because silently dropping wire bytes
+ // desynchronises the stream, which is the one failure 9P cannot
+ // resynchronise from.
+ std.debug.assert(n == @as(usize, @intCast(got)));
+ }
+
+ /// Push what the kernel will take of what this connection owes, and leave
+ /// the rest for a POLLOUT. detached/server.zig's `flush` on a 9P byte
+ /// FIFO instead of an `ArrayList`, and the rule it exists for is the same:
+ /// a peer that will not read must never block the editor.
+ pub fn flush(l: *Listener, i: u8) void {
+ if (comptime !supported) return;
+ const c = &l.conns[i];
+ if (c.fd < 0) return;
+ while (true) {
+ const bytes = c.srv.output();
+ if (bytes.len == 0) return;
+ const n = libc.send(c.fd, bytes.ptr, bytes.len, nosignal);
+ if (n < 0) switch (libc.errno(n)) {
+ .INTR => continue,
+ .AGAIN => return,
+ else => return l.drop(i),
+ };
+ // No progress and no error. Looping on it is a spin, and a spin in
+ // here is the whole session at 100% of a core with no syscall for
+ // a signal to interrupt — host_io.zig's `writeFd` rule.
+ if (n == 0) return;
+ c.srv.wrote(@intCast(n));
+ }
+ }
+
+ /// Whether this connection wants POLLOUT: only while it owes bytes, which
+ /// is the same rule and the same reason as a client's and a pty's — asking
+ /// for it unconditionally makes every idle socket a ready descriptor and
+ /// turns the poll into a spin.
+ pub fn owes(l: *const Listener, i: u8) bool {
+ return l.conns[i].srv.output().len != 0;
+ }
+
+ /// True when this slot has a live descriptor to poll.
+ pub fn live(l: *const Listener, i: u8) bool {
+ return l.conns[i].fd >= 0;
+ }
+
+ /// This connection is over: out of the poll set, out of the process — but
+ /// NOT out of the table, because the server still owes the core a
+ /// `release` per open fid. See `Conn.draining`.
+ pub fn drop(l: *Listener, i: u8) void {
+ const c = &l.conns[i];
+ if (c.fd >= 0) {
+ _ = libc.close(c.fd);
+ c.fd = -1;
+ }
+ if (c.draining) return;
+ c.srv.hangup();
+ c.draining = true;
+ }
+
+ /// This connection's `Transport`, or null when the slot has no work: the
+ /// peer never arrived, or it left and its fids are already released.
+ ///
+ /// THE HOST DRAINS, not this file, and that is not a style choice. A reply
+ /// reaches a transport through the host's `push_fs_reply`, so the host has
+ /// to know WHICH transport the request being served came from — the
+ /// "routing origin" docs/9p.typ's layering table (`O2 two listeners`)
+ /// names as the thing a second listener costs. Handing the transport out
+ /// here and taking the `Drained` back in `settle` is that origin made
+ /// explicit: the host sets it, calls `fs_service.drain`, clears it. A
+ /// `drainAll` that hid the loop in this file could not, and every 9P reply
+ /// went to the FUSE mount instead — measured, as a `Tattach` that never
+ /// came back.
+ pub fn transport(l: *Listener, i: u8) ?fs_service.Transport {
+ if (comptime !supported) return null;
+ const c = &l.conns[i];
+ if (c.fd < 0 and !c.draining) return null;
+ return c.transport();
+ }
+
+ /// What one connection's drain came to: write the replies, and reclaim the
+ /// slot when a hung-up peer's last fid is released. Returns whether this
+ /// connection still owes work, which the caller or's into the flag that
+ /// keeps the loop from sleeping.
+ ///
+ /// The flush is HERE rather than left to a POLLOUT, so a reply the core
+ /// produced this frame is on the wire this frame; what the kernel would not
+ /// take waits for POLLOUT as usual.
+ pub fn settle(l: *Listener, i: u8, d: fs_service.Drained) bool {
+ if (comptime !supported) return false;
+ const c = &l.conns[i];
+ if (c.draining) {
+ // A hung-up connection has no descriptor and therefore no event of
+ // its own: its orphaned fids are pumped out over successive frames,
+ // and the loop must stay hot until the pump runs dry. It does run
+ // dry — the debt is one release per fid.
+ if (d.count == 0) {
+ c.draining = false;
+ return false;
+ }
+ return true;
+ }
+ l.flush(i);
+ return d.pending;
+ }
+
+ /// Every connection down, the socket closed, and the path unlinked.
+ ///
+ /// The orphaned fids are NOT pumped here: `deinit` runs when the session is
+ /// being torn down, and the core it would report the releases to is going
+ /// with it. `Fs.deinit` makes the same choice about the mount.
+ pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void {
+ for (0..max_conns) |i| l.drop(@intCast(i));
+ if (l.fd >= 0) {
+ _ = libc.close(l.fd);
+ l.fd = -1;
+ var z: [sun_path_len:0]u8 = undefined;
+ @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]);
+ z[l.path_len] = 0;
+ _ = libc.unlink(z[0..l.path_len :0]);
+ }
+ gpa.destroy(l);
+ }
+};
+
+/// `<dir>/pardes-9p-<name>.sock`. A name is one path component and nothing
+/// clever, for detached/server.zig's `socketPath` reason: a `/` would put the
+/// socket somewhere else entirely and a NUL would truncate the address. The
+/// buffer is `sun_path`-sized, so a name that does not fit is no address at
+/// all rather than a truncated one pointing somewhere else.
+pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 {
+ if (name.len == 0) return null;
+ if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null;
+ return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null;
+}
+
+/// Bind, listen, and hand back a listener — or null, which is the same answer
+/// for "this platform has no unix sockets", "there is no runtime directory"
+/// and "the bind failed". That is `fs_service.start`'s posture and
+/// nested.zig's: a transport that will not come up must cost the operator
+/// their scripting, never their session.
+///
+/// `named` is `Options.fs9`: EMPTY means a bare `--fs9`, so `fallback` names
+/// it (the session name in a daemon, this pid anywhere else), and anything
+/// else is the name the user gave, which wins — scripts need a path they can
+/// predict.
+pub fn open(gpa: std.mem.Allocator, named: []const u8, fallback: []const u8) ?*Listener {
+ if (comptime !supported) return null;
+ var dir_buf: [sun_path_len:0]u8 = undefined;
+ const dir = nested.socketDir(&dir_buf) orelse {
+ log.warn("--fs9: no runtime directory for the socket", .{});
+ return null;
+ };
+ if (!nested.ensureSocketDir(dir)) return null;
+ const l = gpa.create(Listener) catch return null;
+ l.* = .{};
+ // No sweep of the directory, unlike detached/server.zig's `listen`. That
+ // sweeper connects to every socket of its OWN prefix to retire dead ones;
+ // this prefix has no handshake to probe with, so the only stale file worth
+ // removing is the one this bind collides with, immediately below.
+ const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse {
+ gpa.destroy(l);
+ return null;
+ };
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. p.len + 1], p[0 .. p.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) {
+ gpa.destroy(l);
+ return null;
+ }
+ nested.setCloexec(fd);
+ // `bind` IS the exclusive create, so it and nothing else decides who owns
+ // a name — detached/server.zig's rule, and its reason: unlinking
+ // unconditionally is how a second daemon takes a live one's socket away.
+ // The one case that is not a collision is a session killed rather than
+ // quit, whose file outlived it, and `alive` is the only thing allowed to
+ // say so.
+ if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ if (alive(p)) {
+ log.warn("--fs9: something is already listening on {s}", .{p});
+ _ = libc.close(fd);
+ gpa.destroy(l);
+ return null;
+ }
+ _ = libc.unlink(p);
+ if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ _ = libc.close(fd);
+ gpa.destroy(l);
+ return null;
+ }
+ }
+ // Owner-only, and BEFORE listen(2), which is the first moment anyone could
+ // connect. The directory is already 0700; this is the second wall, and
+ // this socket can write into every pane of a live editor.
+ _ = libc.chmod(p, 0o600);
+ if (libc.listen(fd, max_conns) != 0) {
+ _ = libc.close(fd);
+ gpa.destroy(l);
+ return null;
+ }
+ setNonblock(fd);
+ l.fd = fd;
+ l.path_len = p.len;
+ log.info("--fs9: serving 9P2000 on {s}", .{p});
+ return l;
+}
+
+/// Whether a socket file at `path` has a listener behind it. Only ever asked
+/// about a bind that failed, and it answers on the CONNECT: a refusal means
+/// the file outlived its process and may be unlinked, and anything else —
+/// including a success — means somebody is there.
+fn alive(path: [:0]const u8) bool {
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ if (path.len + 1 > sun_path_len) return true; // cannot ask; assume occupied
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return true;
+ defer _ = libc.close(fd);
+ return libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0;
+}
+
+/// Every descriptor here is non-blocking, for detached/server.zig's reason:
+/// the core must never park on a peer. Its `setNonblock` is not reused because
+/// importing the daemon into a module the tty and GUI shells may also serve
+/// from would make a frontend transport a dependency of a filesystem.
+fn setNonblock(fd: c_int) void {
+ const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0));
+ if (flags < 0) return;
+ var o: libc.O = @bitCast(@as(u32, @bitCast(flags)));
+ o.NONBLOCK = true;
+ _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o)))));
+}
+
+/// A dead script must never kill the editor. linux says it per write, darwin
+/// once per socket (see `accept`).
+const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL;
+
+const testing = std.testing;
+
+test "the socket name is a third prefix in the shared directory" {
+ // Asserted rather than described: nested.zig's sweeper unlinks any name
+ // whose digits name a dead pid, and the detached transport's `vetted`
+ // accepts only its own prefix. A 9P socket must be invisible to both.
+ var buf: [sun_path_len]u8 = undefined;
+ const p = socketPath(&buf, "/run/user/1000", "t9srv").?;
+ try testing.expectEqualStrings("/run/user/1000/pardes-9p-t9srv.sock", p);
+ try testing.expect(!std.mem.startsWith(u8, std.fs.path.basename(p), "pardes-detached-"));
+}
+
+test "a name that is not one path component is no address at all" {
+ var buf: [sun_path_len]u8 = undefined;
+ try testing.expect(socketPath(&buf, "/run", "") == null);
+ try testing.expect(socketPath(&buf, "/run", "a/b") == null);
+ try testing.expect(socketPath(&buf, "/run", "a\x00b") == null);
+}
+
+test "one connection's buffers are sized from the one msize constant" {
+ // The `Srv` asserts both of these at `init`, where a violation is a panic
+ // in a live daemon; here it is a build failure instead.
+ try testing.expect(msize >= ninep.min_msize);
+ const c: Conn = .{};
+ try testing.expectEqual(@as(usize, msize), c.in.len);
+ try testing.expectEqual(@as(usize, 2 * msize), c.out.len);
+}
+
diff --git a/src/main.zig b/src/main.zig
index 1104aeb8..66d25816 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -85,6 +85,15 @@ const help_text =
\\ PARDES_FS and PARDES_PANE into every pane shell
\\ --fs=<dir> ...at <dir> instead. Must be absolute; pardes
\\ unmounts it on exit but leaves the directory
+ \\ --fs9 serve that same tree over 9P2000 on a unix socket
+ \\ at $XDG_RUNTIME_DIR/pardes-9p-<name>.sock, where
+ \\ <name> is the session name or this pid. Dial it
+ \\ with `9p -a <socket>` or mount with `9pfuse`.
+ \\ Independent of --fs: either, both or neither
+ \\ --fs9=<name> ...named <name> rather than derived. One path
+ \\ component: no '/' and nothing empty. Served by
+ \\ --detach sessions today; the tty and GUI shells
+ \\ still take --fs only
\\ --detach run this session with NO terminal of its own,
\\ serving frontends over a unix socket beside the
\\ nested-instance one. The core, the panes and the
@@ -166,6 +175,8 @@ fn nativeMain(init: std.process.Init) !void {
if (!std.mem.eql(u8, a, "--nested") and
!std.mem.eql(u8, a, "--fs") and
!std.mem.startsWith(u8, a, "--fs=") and
+ !std.mem.eql(u8, a, "--fs9") and
+ !std.mem.startsWith(u8, a, "--fs9=") and
!std.mem.eql(u8, a, "--detach") and
!std.mem.startsWith(u8, a, "--detach=") and
!std.mem.eql(u8, a, "--attach") and
@@ -211,6 +222,14 @@ fn nativeMain(init: std.process.Init) !void {
// mount at ./README and open no file — the flag would silently eat
// the FILE argument. One spelling, and it carries its own value.
opts.fs = a["--fs=".len..];
+ } else if (std.mem.eql(u8, a, "--fs9")) {
+ opts.fs9 = "";
+ } else if (std.mem.startsWith(u8, a, "--fs9=")) {
+ // One spelling that carries its own value, for the reason `--fs`
+ // gives directly above: the flag is useful bare, so a two-word
+ // form would make `pardes --fs9 README` a socket called README
+ // that opens no file.
+ opts.fs9 = a["--fs9=".len..];
} else if (std.mem.eql(u8, a, "--nested")) {
opts.nested = true;
} else if (std.mem.eql(u8, a, "--detach")) {
@@ -321,6 +340,12 @@ fn nativeMain(init: std.process.Init) !void {
// that most wants a control filesystem, because it is the one whose panes
// outlive every terminal that could otherwise have scripted them.
if (opts.fs != null and attach != null) return error.BadArgs;
+ // ...and `--fs9` for exactly that reason and no other: it is the same tree
+ // over a different transport, and an `--attach` has no core to serve it
+ // from either. Checked separately rather than folded into the line above
+ // so that neither flag's refusal is a side effect of the other's — they
+ // are independent everywhere else.
+ if (opts.fs9 != null and attach != null) return error.BadArgs;
// `--detach` replaces the frontend rather than choosing among them: the
// core runs here, with no terminal, and the frontends are elsewhere on a
// socket (src/detached/). It is checked before `platform` because it is not
@@ -408,6 +433,14 @@ test {
_ = @import("detached/wire.zig");
_ = @import("detached/server.zig");
_ = @import("detached/client.zig");
+ // The 9P listener, and it needs its name here for the reason the
+ // panel_compositor line below states rather than the fuse.zig one above:
+ // detached/server.zig imports it, but naming a file does not make Zig
+ // analyse the tests of what IT imports — measured, by three tests that
+ // compiled and never ran. A standalone b.addTest is not an option either,
+ // because this file reaches pardes.zig (`Server(acmefs)`); src/9p.zig, the
+ // half that does NOT, has one in build.zig.
+ _ = @import("fs9_service.zig");
if (comptime pardes.platform == .tty) {
_ = @import("tty/tty.zig");
// tty.zig calls the compositor only from its runtime loop, so merely
diff --git a/src/pardes.zig b/src/pardes.zig
index 111e2853..4fdb6c71 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5638,6 +5638,23 @@ pub const Options = struct {
/// browser) has no filesystem at all — but the option rides here because
/// argv already reaches the shells this way, like `nested`.
fs: ?[]const u8 = null,
+ /// SERVE THAT SAME TREE OVER 9P2000 on a unix socket (`--fs9`), and under
+ /// what name. `null` is off; `""` means "derive the socket name" (the
+ /// session name in a daemon, this pid anywhere else); anything else is the
+ /// name `--fs9=<name>` gave, which scripts need because they have to
+ /// predict `$XDG_RUNTIME_DIR/pardes-9p-<name>.sock`.
+ ///
+ /// INDEPENDENT of `fs` above: either, both or neither. They are two
+ /// transports onto one tree (`src/acmefs.zig`) and neither is the other's
+ /// prerequisite — on Linux the FUSE mount needs `fusermount3` and a
+ /// kernel with FUSE, and this needs neither, which is the whole reason
+ /// docs/9p.typ §12.4 calls them complementary rather than competing.
+ ///
+ /// Read by `detached/server.zig` and nowhere else so far: a daemon polls
+ /// its own listener in the one `poll(2)` it already runs, which costs it
+ /// no thread. The tty and GUI shells would each need their own wake for
+ /// it, exactly as they need one for `/dev/fuse`, and they take `fs` only.
+ fs9: ?[]const u8 = null,
/// Native launcher's resolved per-user `pardes` directory. Relative
/// ThemeFile operands and DumpThemes are rooted here. Null for web and
/// direct core callers which did not opt into per-user configuration.