From 65209217b5b68f56bc0bd5bc6c4dce33911ded59 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 20 Sep 2026 00:58:22 -0300 Subject: Add the file-server engine: cloud9.fs.Server(Backend, Options) The asynchronous 9P file-server engine from the Pardes editor moves into the library: fid table, walks, directory cursors, a job/slot model where backend replies arrive later by tag (status again = parked), Tflush cancellation and orphaned fids on hangup. Allocation-free, no OS calls, no std.Io; comptime Options (fid, slot, park data, name and user capacities) replace the editor's constants. Backend contract types (Req, Reply/ReplyWith, Op, Status, Attr, E, error strings) live here. 29 engine tests plus the client tests that sat beside it in Pardes. Co-Authored-By: Claude Fable 5.1 --- src/fs.zig | 2334 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2334 insertions(+) create mode 100644 src/fs.zig (limited to 'src/fs.zig') diff --git a/src/fs.zig b/src/fs.zig new file mode 100644 index 0000000..69cdf9f --- /dev/null +++ b/src/fs.zig @@ -0,0 +1,2334 @@ +//! An asynchronous 9P2000 file-server engine over `Server` (the session). +//! +//! The engine turns each request into a job of one or more backend +//! operations (`Req`), issued through `next()`/`retry()` and answered by tag +//! through `reply()`; a backend may answer at once or later, and may park a +//! read or write with `Status.again`. The engine owns fids, permissions, the +//! directory-read cursor, Tflush and the releases a dropped connection owes. +//! Like the session it is built on, it is allocation-free, makes no OS +//! calls, and is driven by `push()`/`output()`/`wrote()`. Its tables are +//! sized by comptime `Options`. See docs/design.md, "File server engine". +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const c9 = @import("root.zig"); +const wire = @import("wire.zig"); +const Protocol = @import("Server.zig"); + +const Qid = wire.Qid; +const Stat = wire.Stat; +const Msg = wire.Msg; +const Decoded = wire.Decoded; +const header_len = wire.header_len; +const qid_len = wire.qid_len; +const stat_fixed = wire.stat_fixed; +const max_welem = wire.max_welem; +const iohdrsz = wire.iohdrsz; +const notag = wire.notag; +const nofid = wire.nofid; +const qtdir = wire.qtdir; +const qtfile = wire.qtfile; +const dmdir = wire.dmdir; + +// ---- the backend contract ---- + +/// What the engine asks of a backend. Every request names a node; open, +/// read, write, readdir and release also carry the handle open returned. +pub const Op = enum(u8) { + lookup, + getattr, + setattr, + open, + read, + write, + release, + readdir, +}; + +/// `again` parks a read or write until the backend answers the same tag +/// later (or the engine retries it); any other op answering `again` fails. +pub const Status = enum(u8) { + ok, + again, + err, +}; + +/// Node attributes a lookup, getattr or setattr answers with. `node` zero +/// keeps the node the engine asked about; `mode` is the permission bits. +pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o644, + mtime: u32 = 0, +}; +const attr_type = Attr; + +/// One backend operation. `tag` is unique per connection and never zero. +/// `data` is a lookup's name or a write's bytes and is borrowed until the +/// reply, unless the write parks, in which case the engine keeps a copy. +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, +}; + +/// A reply carrying an application-defined `payload`: a locator for the +/// reply's bytes that the engine ignores and the application resolves +/// before calling `reply()`. `Payload` is `void` or a type with a `none` +/// tag, which is the default value. +pub fn ReplyWith(comptime Payload: type) type { + return struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: attr_type = .{}, + handle: u32 = 0, + payload: Payload = noPayload(Payload), + written: u32 = 0, + + pub const Attr = attr_type; + + pub fn fail(tag: u64, e: u16) @This() { + return .{ .tag = tag, .status = .err, .errno = e }; + } + }; +} + +fn noPayload(comptime Payload: type) Payload { + return if (Payload == void) {} else .none; +} + +/// The backend's answer to one `Req`, matched by tag. The bytes of a read +/// or readdir travel beside it as the `bytes` argument of `reply()`. +pub const Reply = ReplyWith(void); + +/// The errno values a backend may answer with; `errString` names each. +pub const E = struct { + pub const PERM: u16 = 1; + pub const NOENT: u16 = 2; + pub const IO: u16 = 5; + pub const NOMEM: u16 = 12; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; + pub const NFILE: u16 = 23; + pub const NOSPC: u16 = 28; + pub const NOSYS: u16 = 38; +}; + +/// Longest Rerror string the engine emits. +pub const errmax: usize = 128; + +pub const e_unknown_fid = "fid unknown or out of range"; +pub const e_fid_in_use = "fid already in use"; +pub const e_bad_use = "bad use of fid"; +pub const e_bad_offset = "bad offset in directory read"; +pub const e_perm = "permission denied"; +pub const e_not_dir = "not a directory"; +pub const e_already_open = "file already open for I/O"; +pub const e_illegal_name = "illegal name"; +pub const e_too_many_fids = "Too many open files in system"; +pub const e_botch = "protocol botch"; +pub const e_interrupted = "Interrupted system call"; +pub const e_trunc_only = "only support truncation to zero length"; +pub const e_wstat = "wstat prohibited"; +pub const e_again = "Resource temporarily unavailable"; +pub const e_count_small = "Invalid argument"; +pub const e_no_tree = "No such file or directory"; +pub const e_no_auth = "authentication not required"; +pub const e_small_msize = "Invalid argument"; + +/// The Rerror string for a backend errno, in the spelling Linux v9fs maps back. +pub fn errString(errno: u16) []const u8 { + return switch (errno) { + E.PERM => "Operation not permitted", + E.NOENT => "No such file or directory", + E.IO => "Input/output error", + E.NOMEM => "Cannot allocate memory", + E.NOTDIR => "Not a directory", + E.INVAL => "Invalid argument", + E.NFILE => "Too many open files in system", + E.NOSPC => "No space left on device", + E.NOSYS => "Function not implemented", + else => "Input/output error", + }; +} + +/// The modes directory entries report; a stat of the entry gives the real ones. +pub const dirent_dir_perm: u16 = 0o500; +pub const dirent_file_perm: u16 = 0o600; + +/// The smallest msize the engine negotiates: one full Rwalk must fit. +pub const msize_min: u32 = header_len + 2 + max_welem * qid_len; + +/// Comptime capacities of one engine instance. The defaults are the +/// editor's, except the name capacity, which is the board's. +pub const Options = struct { + /// Fids one connection may hold at once. + fid_capacity: usize = 256, + /// Parked reads and writes one connection may hold at once. + slot_capacity: usize = 32, + /// Longest write payload a parked write keeps a copy of. + park_data_max: usize = 128, + /// Longest file name a fid remembers; longer walk elements are illegal. + name_capacity: usize = 28, + /// Longest attach uname kept for the uid, gid and muid of stats. + username_capacity: usize = 28, +}; + +comptime { + const defaults: Options = .{}; + assert(msize_min == 217); + assert(header_len + 2 + stat_fixed + defaults.name_capacity + 3 * defaults.username_capacity <= msize_min); + assert(header_len + 4 + stat_fixed + defaults.name_capacity + 3 * defaults.username_capacity <= msize_min); + assert(defaults.username_capacity >= 20); + assert(c9.oread | c9.owrite | c9.ordwr | c9.oexec == 3); + assert(c9.otrunc | c9.ocexec | c9.orclose == 112); +} + +fn qidOf(node: u64, dir: bool) Qid { + return .{ .type = if (dir) qtdir else qtfile, .version = 0, .path = node }; +} + +/// The engine for a `Backend` that declares `Req` (this module's `Req`) and +/// `Reply` (this module's `Reply`, or a `ReplyWith`). Drive it like the +/// session: `push()` bytes in, answer every `retry()` then every `next()` +/// request through `reply()`, drain `output()` and report `wrote()`. On +/// `protocol.dead` call `hangup()`, answer the releases `next()` still +/// yields, and `init()` again. +pub fn Server(comptime Backend: type, comptime opts: Options) type { + if (opts.fid_capacity == 0) @compileError("9P server needs at least one fid"); + if (opts.slot_capacity == 0) @compileError("9P server needs at least one parking slot"); + if (opts.name_capacity == 0 or opts.name_capacity > 255) @compileError("9P backend name capacity must fit a directory entry"); + if (opts.username_capacity == 0) @compileError("9P server needs room for a user name"); + if (Backend.Req != Req) @compileError("9P backend requests must be cloud9.fs.Req"); + inline for (.{ "tag", "status", "errno", "attr", "handle", "written" }) |field| { + if (!@hasField(Backend.Reply, field)) @compileError("9P backend replies must be cloud9.fs.Reply or a cloud9.fs.ReplyWith"); + } + if (@FieldType(Backend.Reply, "attr") != Attr) @compileError("9P backend replies must carry cloud9.fs.Attr"); + const name_capacity = opts.name_capacity; + const username_capacity = opts.username_capacity; + const park_data_max = opts.park_data_max; + return struct { + const Self = @This(); + + protocol: Protocol, + root: u64, + + uname: [username_capacity]u8 = @splat(0), + uname_len: u8 = 0, + + fids: [opts.fid_capacity]Fid = @splat(.{}), + slots: [opts.slot_capacity]Slot = @splat(.{}), + job: Job = .{}, + seq: u64 = 0, + + pub const options = opts; + + const Kind = enum { none, attach, walk, open, read, readdir, write, clunk, remove, stat, wstat }; + + pub const Fid = struct { + used: bool = false, + fid: u32 = 0, + node: u64 = 0, + dir: bool = false, + perm: u16 = 0, + open: bool = false, + omode: u8 = 0, + handle: u32 = 0, + diroff: u64 = 0, + dirindex: u32 = 0, + orphan: bool = false, + name: [name_capacity]u8 = @splat(0), + name_len: u8 = 0, + }; + + const Slot = struct { + used: bool = false, + parked: bool = false, + retried: bool = false, + copied: bool = false, + seq: u64 = 0, + tag: u16 = 0, + kind: Kind = .none, + fid: u32 = 0, + count: u32 = 0, + req: Backend.Req = undefined, + data: [park_data_max]u8 = undefined, + }; + + const Job = struct { + kind: Kind = .none, + tag: u16 = 0, + req_tag: u64 = 0, + req: Backend.Req = undefined, + step: u8 = 0, + fid: u32 = 0, + newfid: u32 = 0, + count: u32 = 0, + offset: u64 = 0, + omode: u8 = 0, + node: u64 = 0, + dir: bool = false, + perm: u16 = 0, + name: [name_capacity]u8 = @splat(0), + name_len: u8 = 0, + nwname: u8 = 0, + nwqid: u8 = 0, + wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }), + msg: Msg = .rflush, + }; + + pub const InitOptions = struct { + in: []u8, + out: []u8, + root: u64, + }; + + pub fn init(o: InitOptions) Self { + assert(o.in.len >= msize_min); + assert(o.out.len >= 2 * msize_min); + assert(o.root != 0); + return .{ .protocol = .init(.{ .in = o.in, .out = o.out }), .root = o.root }; + } + + pub fn references(s: *const Self, node: u64) bool { + for (s.fids) |fid| if (fid.used and fid.node == node) return true; + if (s.job.kind != .none and s.job.node == node) return true; + for (s.slots) |slot| if (slot.used and slot.req.node == node) return true; + return false; + } + + pub fn hangup(s: *Self) void { + s.reset(); + s.protocol.hangup(); + } + + 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 = .{}; + } + + pub fn push(s: *Self, bytes: []const u8) usize { + return s.protocol.push(bytes); + } + + pub fn output(s: *const Self) []const u8 { + return s.protocol.output(); + } + + pub fn wrote(s: *Self, n: usize) void { + s.protocol.wrote(n); + } + + fn hasRoom(s: *Self) bool { + return s.protocol.hasRoom(); + } + + fn emit(s: *Self, tag: u16, msg: Msg) void { + s.protocol.reply(tag, msg) catch { + s.protocol.dead = true; + }; + } + + fn fail(s: *Self, tag: u16, ename: []const u8) void { + assert(ename.len <= errmax); + s.emit(tag, .{ .rerror = .{ .ename = ename } }); + } + + 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; + } + + 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, username_capacity); + @memcpy(s.uname[0..n], uname[0..n]); + s.uname_len = @intCast(n); + } + + /// The oldest parked request not yet retried this round, or null when + /// every parked request has been retried (which starts a new round). + pub fn retry(s: *Self) ?Backend.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; + }; + if (!s.hasRoom()) { + for (&s.slots) |*sl| sl.retried = false; + return null; + } + s.slots[i].retried = true; + s.slots[i].parked = false; + return s.slots[i].req; + } + + /// The next backend operation, or null while one is outstanding, the + /// output is full, or no whole request has arrived. + pub fn next(s: *Self) ?Backend.Req { + while (true) { + if (s.job.kind != .none) { + if (s.job.req_tag != 0) return null; + if (s.stepJob()) |req| return req; + assert(s.job.kind == .none); + continue; + } + if (s.orphan()) |req| return req; + if (!s.hasRoom()) return null; + if (!s.startFrame()) return null; + } + } + + /// Answer a request by tag; `bytes` are a read's or readdir's data. + /// A tag the engine no longer waits for (flushed, hung up) is ignored. + pub fn reply(s: *Self, r: *const Backend.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); + } + + fn orphan(s: *Self) ?Backend.Req { + for (&s.fids) |*f| { + if (!f.used or !f.orphan) continue; + assert(f.open); + const req: Backend.Req = .{ + .tag = s.tick(), + .op = .release, + .node = f.node, + .handle = f.handle, + }; + f.* = .{}; + return req; + } + return null; + } + + fn startFrame(s: *Self) bool { + assert(s.job.kind == .none); + const got = (s.protocol.receive() catch return false) orelse return false; + defer if (s.job.kind == .none) s.dropFrame(); + s.dispatch(got); + return true; + } + + fn dropFrame(s: *Self) void { + s.protocol.release(); + } + + fn dispatch(s: *Self, got: Decoded) void { + switch (got.msg) { + .tversion => |m| s.version(got.tag, m.msize, m.version), + .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), + .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), + else => s.fail(got.tag, e_botch), + } + } + + fn version(s: *Self, tag: u16, want: u32, ver: []const u8) void { + assert(tag == notag); + s.reset(); + // The engine's fixed directory staging requires this capacity. + if (want < msize_min) { + s.protocol.dead = true; + return; + } + s.protocol.negotiate(want, ver) catch { + s.protocol.dead = true; + }; + } + + fn attach(s: *Self, tag: u16, fid: u32, uname: []const u8, aname: []const u8) void { + 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); + 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); + 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); + if (s.freeFid() == null) return s.fail(tag, e_too_many_fids); + } + if (nwname == 0) { + if (newfid != fid) { + const j = s.freeFid().?; + s.fids[j] = s.fids[i]; + s.fids[j].fid = newfid; + 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); + if (mode & c9.orclose != 0) return s.fail(tag, e_perm); + const rw = mode & 3; + if (rw == c9.oexec) return s.fail(tag, e_perm); + if (f.dir and (rw != c9.oread or mode & c9.otrunc != 0)) return s.fail(tag, e_perm); + var need: u16 = 0; + if (rw == c9.oread or rw == c9.ordwr) need |= 0o400; + if (rw == c9.owrite or rw == c9.ordwr or mode & c9.otrunc != 0) need |= 0o200; + 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]; + if (!f.open or (f.omode & 3) == c9.owrite) return s.fail(tag, e_bad_use); + const want = @min(count, s.protocol.msize - header_len - 4); + if (!f.dir) { + s.job = .{ .kind = .read, .tag = tag, .fid = fid, .offset = offset, .count = want }; + return; + } + 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) == c9.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); + 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); + if (st.type != std.math.maxInt(u16) or st.dev != std.math.maxInt(u32) or + st.qid.type != std.math.maxInt(u8) or st.qid.version != std.math.maxInt(u32) or + st.qid.path != std.math.maxInt(u64) or st.mode != std.math.maxInt(u32) or + st.atime != std.math.maxInt(u32) or + // Linux v9fs follows O_TRUNC with Twstat(length=0, mtime=now). + // Accept that timestamp hint with truncation; a control + // filesystem does not persist caller-selected timestamps. + (st.mtime != std.math.maxInt(u32) and st.length != 0) or + st.name.len != 0 or st.uid.len != 0 or st.gid.len != 0 or st.muid.len != 0) + return s.fail(tag, e_wstat); + if (st.length == std.math.maxInt(u64)) { + s.emit(tag, .rwstat); + return; + } + if (st.length != 0) return s.fail(tag, e_trunc_only); + s.job = .{ .kind = .wstat, .tag = tag, .fid = fid }; + } + + fn flush(s: *Self, tag: u16, oldtag: u16) void { + if (s.findTag(oldtag)) |i| { + s.fail(s.slots[i].tag, e_interrupted); + s.slots[i] = .{}; + } + s.emit(tag, .rflush); + } + + fn ask(s: *Self, req: Backend.Req) Backend.Req { + assert(req.tag != 0); + s.job.req = req; + s.job.req_tag = req.tag; + return req; + } + + 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]; + } + + fn stepJob(s: *Self) ?Backend.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; + if (j.step == 0 and j.omode & c9.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; + 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 }); + }, + } + } + + fn stepWalk(s: *Self) ?Backend.Req { + const j = &s.job; + while (j.step < j.nwname) { + const name = j.msg.twalk.wname[j.step]; + if (name.len > name_capacity) { + s.stopWalk(e_illegal_name); + return null; + } + if (std.mem.eql(u8, name, ".")) { + j.wqid[j.nwqid] = qidOf(j.node, j.dir); + j.nwqid += 1; + j.step += 1; + continue; + } + @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 }); + } + 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; + } + + 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.protocol.frame != 0) s.dropFrame(); + } + + fn jobReply(s: *Self, r: *const Backend.Reply, bytes: []const u8) void { + const j = &s.job; + assert(j.kind != .none); + assert(j.req_tag == r.tag); + j.req_tag = 0; + + 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, + }; + 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; + if (r.attr.name.len != 0) { + j.name_len = @intCast(@min(r.attr.name.len, j.name.len)); + @memcpy(j.name[0..j.name_len], r.attr.name[0..j.name_len]); + } + 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; + }, + .open => { + if (j.step == 0 and j.omode & c9.otrunc != 0) { + j.step = 1; + return; + } + const f = s.jobFid() orelse return; + if (r.attr.node != 0) f.node = r.attr.node; + f.open = true; + f.omode = j.omode; + f.handle = r.handle; + f.diroff = 0; + f.dirindex = 0; + s.emit(j.tag, .{ .ropen = .{ + .qid = qidOf(f.node, f.dir), + .iounit = s.protocol.msize - iohdrsz, + } }); + s.finishJob(); + }, + .read => { + 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 => { + s.emit(j.tag, .{ .rwrite = .{ .count = @min(r.written, j.count) } }); + s.finishJob(); + }, + .stat => { + const f = s.jobFid() orelse return; + f.perm = r.attr.mode; + f.dir = r.attr.dir; + const response: Msg = .{ .rstat = .{ .stat = s.statOf(f, r.attr) } }; + if ((wire.encodedLen(response) catch unreachable) > s.protocol.msize) + s.fail(j.tag, e_small_msize) + else + s.emit(j.tag, response); + s.finishJob(); + }, + .wstat => { + s.emit(j.tag, .rwstat); + s.finishJob(); + }, + } + } + + fn parkJob(s: *Self) void { + const j = &s.job; + switch (j.kind) { + .read, .readdir, .write => {}, + else => { + s.fail(j.tag, e_again); + s.finishJob(); + return; + }, + } + const i = s.freeSlot() orelse { + 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) { + 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]; + } + s.finishJob(); + } + + fn slotReply(s: *Self, i: usize, r: *const Backend.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) } }), + else => s.fail(sl.tag, e_botch), + } + sl.* = .{}; + } + + /// A readdir answers records of `node:u64le dir:u8 len:u8 name`; the + /// engine encodes whole Stat entries into the output frame directly. + fn emitDirRead(s: *Self, tag: u16, fid: u32, staging: []const u8, count: u32) void { + const buf = s.protocol.out[s.protocol.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; + while (i + 10 <= staging.len) { + const nlen: usize = 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; + if (n - header_len - 4 + size > cap) break; + _ = rec.encode(buf[n..]) catch break; + n += size; + entries += 1; + i += 10 + nlen; + } + if (entries == 0 and staging.len != 0) return s.fail(tag, e_count_small); + const payload: u32 = @intCast(n - header_len - 4); + comptime assert(header_len == 7); + s.emit(tag, .{ .rread = .{ .data = buf[header_len + 4 .. n] } }); + if (s.findFid(fid)) |k| { + s.fids[k].diroff += payload; + s.fids[k].dirindex += entries; + } + } + + fn statOf(s: *const Self, f: *const Fid, a: 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), + .mode = (if (a.dir) dmdir else 0) | @as(u32, a.mode), + .atime = 0, + .mtime = a.mtime, + .length = a.size, + .name = f.name[0..f.name_len], + .uid = who, + .gid = who, + .muid = who, + }; + } + }; +} + +// ---- tests: a stub backend, the engine, and the client against it ---- + +const oread = c9.oread; +const owrite = c9.owrite; +const ordwr = c9.ordwr; +const oexec = c9.oexec; +const otrunc = c9.otrunc; +const orclose = c9.orclose; +const encode = wire.encode; +const decode = wire.decode; +const frameLen = wire.frameLen; +const Client = c9.Client; +const max_tags = c9.max_tags; + +const req_type = Req; +const reply_type = Reply; + +const StubFs = struct { + pub const Req = req_type; + pub const Reply = reply_type; + + const Entry = struct { node: u64, parent: u64, name: []const u8, dir: bool, mode: u16 }; + + 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", + filler: [1024]u8 = @splat('x'), + event: ?[]const u8 = null, + 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_type, 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) Attr { + return .{ .name = e.name, .node = e.node, .dir = e.dir, .mode = e.mode, .size = st.sizeOf(e.node) }; + } + + 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_type) Answer { + st.calls += 1; + const fail: Answer = .{ .reply = .{ .tag = req.tag, .status = .err, .errno = E.NOENT } }; + const i = find(req.node) orelse return fail; + switch (req.op) { + .lookup => { + if (std.mem.eql(u8, req.data, "..")) + return .{ .reply = .{ .tag = req.tag, .attr = st.attrOf(tree[find(tree[i].parent).?]) } }; + 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 = E.NOTDIR } }; + return .{ .reply = .{ .tag = req.tag }, .bytes = st.stageDir(req.node, req.off) }; + }, + .read => { + 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) } }; + }, + } + } +}; + +const Srv = Server(StubFs, .{}); + +const Harness = struct { + in: [4096]u8 = undefined, + out: [8192]u8 = undefined, + fsys: StubFs = .{}, + srv: Srv = undefined, + + fn start(h: *Harness) void { + h.srv = Srv.init(.{ .in = &h.in, .out = &h.out, .root = 1 }); + } + + fn answer(h: *Harness, req: Req) void { + const a = h.fsys.handle(req); + h.srv.reply(&a.reply, a.bytes); + } + + 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(); + } + + 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); + } + + 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); + } + + 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; +} + +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 "fs server: the version handshake clamps, falls back, and refuses" { + var h: Harness = .{}; + h.start(); + + 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); + + try h.send(notag, .{ .tversion = .{ .msize = 512, .version = "9P2000" } }); + got = try h.reap(); + try testing.expectEqual(@as(u32, 512), got.msg.rversion.msize); + + try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000.u" } }); + got = try h.reap(); + try testing.expectEqualStrings("9P2000", got.msg.rversion.version); + + try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "TCP/IP" } }); + got = try h.reap(); + try testing.expectEqualStrings("unknown", got.msg.rversion.version); + try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } }); + try testing.expect(h.srv.protocol.dead); + h.start(); + try h.send(notag, .{ .tversion = .{ .msize = 64, .version = "9P2000" } }); + try testing.expect(h.srv.protocol.dead); + try testing.expectEqual(@as(u32, 216), msize_min - 1); +} + +test "fs 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(); + + 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); + try testing.expectEqual(@as(u32, 0), got.msg.rattach.qid.version); + + 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); + + try h.send(4, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } }); + got = try h.reap(); + try testing.expectEqualStrings(e_no_auth, got.msg.rerror.ename); + + 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 "fs server: a three-element walk, and `..` with no kernel to resolve it" { + var h: Harness = .{}; + try h.handshake(4096); + + 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); + + 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); + 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); + + 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); + + 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 "fs server: a walk failing on the first element is Rerror, on the second a short Rwalk" { + var h: Harness = .{}; + try h.handshake(4096); + + var got = try h.walkTo(5, 1, &.{ "nope", "body" }); + try testing.expectEqualStrings("No such file or directory", got.msg.rerror.ename); + + 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); + + try h.send(7, .{ .tstat = .{ .fid = 1 } }); + got = try h.reap(); + try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); + + 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); + + 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); + + got = try h.walkTo(12, 4, &.{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}); + try testing.expectEqualStrings(e_illegal_name, got.msg.rerror.ename); + + 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 "fs server: backend-sized filenames survive walk and stat within negotiated msize" { + const Native = Server(StubFs, .{ .fid_capacity = 2, .name_capacity = 255 }); + try testing.expectEqual(@as(usize, 28), @sizeOf(@FieldType(Srv.Fid, "name"))); + try testing.expectEqual(@as(usize, 255), @sizeOf(@FieldType(Native.Fid, "name"))); + + const filename: [255]u8 = @splat('f'); + for ([_]struct { length: usize, msize: u32 }{ + .{ .length = 29, .msize = 512 }, + .{ .length = 128, .msize = 512 }, + .{ .length = 255, .msize = 512 }, + .{ .length = 200, .msize = 256 }, + }) |case| { + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var encoded: [1024]u8 = undefined; + var server = Native.init(.{ .in = &in, .out = &out, .root = 1 }); + server.protocol.msize = case.msize; + server.setUname("u" ** Native.options.username_capacity); + server.fids[0] = .{ .used = true, .fid = 0, .node = 1, .dir = true, .perm = 0o500 }; + const name = filename[0..case.length]; + const walk = try encode(.{ .twalk = .{ + .fid = 0, + .newfid = 1, + .nwname = 1, + .wname = wnames(&.{name}), + } }, 1, &encoded); + try testing.expectEqual(walk.len, server.push(walk)); + const lookup = server.next() orelse return error.MissingLookup; + try testing.expectEqual(.lookup, lookup.op); + try testing.expectEqualStrings(name, lookup.data); + server.reply(&.{ .tag = lookup.tag, .attr = .{ .node = 2, .name = name, .size = 12 } }, ""); + try testing.expectEqual(null, server.next()); + var response = try decode(server.output()); + try testing.expectEqual(@as(u16, 1), response.msg.rwalk.nwqid); + server.wrote(server.output().len); + + const stat = try encode(.{ .tstat = .{ .fid = 1 } }, 2, &encoded); + try testing.expectEqual(stat.len, server.push(stat)); + const getattr = server.next() orelse return error.MissingGetattr; + try testing.expectEqual(.getattr, getattr.op); + server.reply(&.{ .tag = getattr.tag, .attr = .{ .node = 2, .name = name, .size = 12 } }, ""); + try testing.expect(server.output().len <= case.msize); + response = try decode(server.output()); + if (case.msize == 256) { + try testing.expectEqualStrings(e_small_msize, response.msg.rerror.ename); + } else { + try testing.expectEqualStrings(name, response.msg.rstat.stat.name); + try testing.expectEqual(@as(u64, 12), response.msg.rstat.stat.length); + } + server.wrote(server.output().len); + const clunk = try encode(.{ .tclunk = .{ .fid = 1 } }, 3, &encoded); + try testing.expectEqual(clunk.len, server.push(clunk)); + try testing.expectEqual(null, server.next()); + response = try decode(server.output()); + try testing.expect(response.msg == .rclunk); + try testing.expect(!server.protocol.dead); + } +} + +test "fs server: open then read then clunk, and the release a clunk owes the backend" { + 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); + try testing.expectEqual(@as(u32, 4096 - iohdrsz), got.msg.ropen.iounit); + + 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); + + try h.send(9, .{ .tread = .{ .fid = 1, .offset = 7, .count = 4096 } }); + got = try h.reap(); + try testing.expectEqualStrings("body\n", got.msg.rread.data); + + 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); + + 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); + + 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); + + _ = 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 "fs server: every Rread is clamped to the client's count and to the msize" { + var h: Harness = .{}; + try h.handshake(512); + _ = try h.walkTo(5, 1, &.{"index"}); + try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); + _ = try h.reap(); + + 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); + + 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); + + 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 "fs server: a directory read is whole stat records at a cursor the client cannot invent" { + var h: Harness = .{}; + try h.handshake(4096); + _ = try h.walkTo(4, 1, &.{"index"}); + try h.send(5, .{ .topen = .{ .fid = 0, .mode = oread } }); + _ = try h.reap(); + + var found: [8][]const u8 = undefined; + + 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); + + 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]); + + 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); + + 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]); + + 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); + + 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]); + + 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); + + 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 "fs server: long directory names remain whole across pages" { + var h: Harness = .{}; + try h.handshake(4096); + var entries: [10 + 255 + 10 + 4]u8 = @splat(0); + std.mem.writeInt(u64, entries[0..8], 41, .little); + entries[9] = 255; + @memset(entries[10..265], 'f'); + std.mem.writeInt(u64, entries[265..273], 42, .little); + entries[274] = 4; + @memcpy(entries[275..], "next"); + var found: [2][]const u8 = undefined; + + { + var request: [64]u8 = undefined; + const bytes = try encode(.{ .tread = .{ .fid = 0, .offset = 0, .count = 330 } }, 5, &request); + _ = h.srv.protocol.push(bytes); + _ = (try h.srv.protocol.receive()).?; + h.srv.protocol.release(); + } + h.srv.emitDirRead(5, 0, &entries, 330); + var got = try h.reap(); + const first = got.msg.rread.data.len; + try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings(entries[10..265], found[0]); + try testing.expectEqual(@as(u64, 1), h.srv.fids[0].dirindex); + + { + var request: [64]u8 = undefined; + const bytes = try encode(.{ .tread = .{ .fid = 0, .offset = 0, .count = 330 } }, 6, &request); + _ = h.srv.protocol.push(bytes); + _ = (try h.srv.protocol.receive()).?; + h.srv.protocol.release(); + } + h.srv.emitDirRead(6, 0, entries[265..], 330); + got = try h.reap(); + try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings("next", found[0]); + try testing.expectEqual(@as(u64, 2), h.srv.fids[0].dirindex); + try testing.expectEqual(first + got.msg.rread.data.len, h.srv.fids[0].diroff); + + { + var request: [64]u8 = undefined; + const bytes = try encode(.{ .tread = .{ .fid = 0, .offset = 0, .count = 4096 } }, 7, &request); + _ = h.srv.protocol.push(bytes); + _ = (try h.srv.protocol.receive()).?; + h.srv.protocol.release(); + } + h.srv.emitDirRead(7, 0, &entries, 4096); + got = try h.reap(); + try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings(entries[10..265], found[0]); + try testing.expectEqualStrings("next", found[1]); +} + +test "fs 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(); + + try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); + try h.quiet(); + + h.pump(); + h.pump(); + try h.quiet(); + + _ = 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); + + 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(); + + _ = 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]); + for (0..Srv.options.slot_capacity) |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 "fs server: the reply queue is a FIFO that survives a partial write" { + var h: Harness = .{}; + try h.handshake(4096); + + 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); + + var flood: [8192]u8 = @splat(0); + try testing.expectEqual(@as(usize, 4096), h.srv.push(&flood)); + h.pump(); + try testing.expect(h.srv.protocol.dead); + try testing.expectEqual(@as(usize, 0), h.srv.push(&flood)); +} + +test "fs 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(); + + 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(); + + h.fsys.event = "Kli7 7 0 0 hello\n"; + h.pump(); + try h.quiet(); + + 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 "fs server: the fid and permission refusals, each in a string Linux knows" { + var h: Harness = .{}; + try h.handshake(4096); + + 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); + } + + _ = 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); + + 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); + + _ = 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); + + 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 "fs 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" }); + + 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 = "", + }; + + 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); + + 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); + + 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); + + var changes: [12]Stat = @splat(sentinel); + changes[0].type = 0; + changes[1].dev = 0; + changes[2].qid.type = 0; + changes[3].qid.version = 0; + changes[4].qid.path = 0; + changes[5].mode = 0o644; + changes[6].atime = 0; + changes[7].mtime = 0; + changes[8].name = "renamed"; + changes[9].uid = "owner"; + changes[10].gid = "group"; + changes[11].muid = "writer"; + for (changes) |change| { + for ([_]u64{ std.math.maxInt(u64), 0 }) |length| { + if (change.mtime != std.math.maxInt(u32) and length == 0) continue; + var attributes = change; + attributes.length = length; + const calls = h.fsys.calls; + try h.send(20, .{ .twstat = .{ .fid = 1, .stat = attributes } }); + got = try h.reap(); + try testing.expect(got.msg == .rerror); + try testing.expectEqualStrings(e_wstat, got.msg.rerror.ename); + try testing.expectEqual(calls, h.fsys.calls); + try testing.expectEqualStrings("hello, body\n", h.fsys.body); + } + } + + // Captured Linux v9fs O_TRUNC follow-up: length=0 plus current mtime. + var linux_truncate = sentinel; + linux_truncate.length = 0; + linux_truncate.mtime = 1789432552; + try h.send(21, .{ .twstat = .{ .fid = 1, .stat = linux_truncate } }); + got = try h.reap(); + try testing.expect(got.msg == .rwstat); + try testing.expectEqualStrings("", h.fsys.body); + h.fsys.body = "hello, body\n"; + + 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); + + 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 "fs server: create and remove are refused, and a remove clunks the fid anyway" { + var h: Harness = .{}; + try h.handshake(4096); + + 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); + + _ = 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 "fs server: a message arriving a byte at a time is served when its last byte lands" { + var h: Harness = .{}; + try h.handshake(4096); + + 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); + + 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 "fs server: invalid framing and reply types terminate the connection" { + for ([_]u8{ @intFromEnum(wire.Type.rstat), 8, 0 }) |kind| { + var h: Harness = .{}; + try h.handshake(4096); + var buf: [64]u8 = undefined; + const raw = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf); + if (kind == 0) std.mem.writeInt(u32, raw[0..4], 3, .little) else raw[4] = kind; + _ = h.srv.push(raw); + h.pump(); + try h.quiet(); + try testing.expect(h.srv.protocol.dead); + } +} + +test "fs server: a connection that drops still pays the backend 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(); + } + try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); + try h.quiet(); + + h.srv.hangup(); + h.pump(); + try testing.expectEqual(@as(u32, 2), h.fsys.releases); + try h.quiet(); + + 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); + 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 "fs server: every errno the backend can answer is a string Linux knows" { + try testing.expectEqualStrings("Operation not permitted", errString(E.PERM)); + try testing.expectEqualStrings("No such file or directory", errString(E.NOENT)); + try testing.expectEqualStrings("Input/output error", errString(E.IO)); + try testing.expectEqualStrings("Cannot allocate memory", errString(E.NOMEM)); + try testing.expectEqualStrings("Not a directory", errString(E.NOTDIR)); + try testing.expectEqualStrings("Invalid argument", errString(E.INVAL)); + try testing.expectEqualStrings("Too many open files in system", errString(E.NFILE)); + try testing.expectEqualStrings("No space left on device", errString(E.NOSPC)); + try testing.expectEqualStrings("Function not implemented", errString(E.NOSYS)); + try testing.expectEqualStrings("Input/output error", errString(0)); + try testing.expectEqualStrings("Input/output error", errString(999)); + + 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); + 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 "fs server: the fid capacity sizes the actual fid storage, and nothing else" { + const Small = Server(StubFs, .{ .fid_capacity = 32 }); + const Large = Server(StubFs, .{ .fid_capacity = 256 }); + const SmallFids = @FieldType(Small, "fids"); + const LargeFids = @FieldType(Large, "fids"); + try testing.expectEqual(32, @typeInfo(SmallFids).array.len); + try testing.expectEqual(256, @typeInfo(LargeFids).array.len); + try testing.expectEqual(32 * @sizeOf(Small.Fid), @sizeOf(SmallFids)); + try testing.expectEqual(256 * @sizeOf(Large.Fid), @sizeOf(LargeFids)); + try testing.expectEqual( + @sizeOf(LargeFids) - @sizeOf(SmallFids), + @sizeOf(Large) - @sizeOf(Small), + ); + try testing.expect(@sizeOf(SmallFids) <= 3 * 1024); + try testing.expect(@sizeOf(LargeFids) <= 24 * 1024); + try testing.expect(@sizeOf(@FieldType(Small, "slots")) <= 8 * 1024); + try testing.expect(3 * 4096 + @sizeOf(Small) <= 24 * 1024); +} + +test "fs server: a reply type with a payload locator is accepted and ignored" { + const Payload = union(enum) { none, staged: u32 }; + const Located = struct { + pub const Req = req_type; + pub const Reply = ReplyWith(Payload); + }; + const Engine = Server(Located, .{ .fid_capacity = 2 }); + const r: Located.Reply = .{ .tag = 1 }; + try testing.expect(r.payload == .none); + try testing.expect(Located.Reply.Attr == Attr); + try testing.expectEqual(E.IO, Located.Reply.fail(1, E.IO).errno); + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var encoded: [64]u8 = undefined; + var server = Engine.init(.{ .in = &in, .out = &out, .root = 1 }); + _ = server.push(try encode(.{ .tversion = .{ .msize = 1024, .version = "9P2000" } }, notag, &encoded)); + try testing.expectEqual(null, server.next()); + server.wrote(server.output().len); + _ = server.push(try encode(.{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "u", .aname = "" } }, 1, &encoded)); + const getattr = server.next() orelse return error.MissingGetattr; + server.reply(&.{ .tag = getattr.tag, .attr = .{ .dir = true, .mode = 0o500 }, .payload = .{ .staged = 0 } }, ""); + try testing.expectEqual(null, server.next()); + const got = try decode(server.output()); + try testing.expectEqual(@as(u64, 1), got.msg.rattach.qid.path); +} + +const Pair = struct { + srv_in: [4096]u8 = undefined, + srv_out: [8192]u8 = undefined, + cli_in: [4096]u8 = undefined, + cli_out: [4096]u8 = undefined, + fsys: StubFs = .{}, + srv: Srv = undefined, + cli: Client = undefined, + + fn start(p: *Pair) void { + p.srv = Srv.init(.{ .in = &p.srv_in, .out = &p.srv_out, .root = 1 }); + p.cli = Client.init(.{ .in = &p.cli_in, .out = &p.cli_out }); + } + + fn answer(p: *Pair, req: Req) void { + const a = p.fsys.handle(req); + p.srv.reply(&a.reply, a.bytes); + } + + fn wire_(p: *Pair) void { + var moved = true; + while (moved) { + moved = false; + while (p.cli.output().len != 0) { + const n = p.srv.push(p.cli.output()); + if (n == 0) break; + p.cli.wrote(n); + moved = true; + } + while (p.srv.retry()) |req| { + p.answer(req); + moved = true; + } + while (p.srv.next()) |req| { + p.answer(req); + moved = true; + } + while (p.srv.output().len != 0) { + const n = p.cli.push(p.srv.output()); + if (n == 0) break; + p.srv.wrote(n); + moved = true; + } + } + } + + fn one(p: *Pair, req: Client.Request) !Client.Done { + const tag = try p.cli.submit(req); + p.wire_(); + const done = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(tag, done.tag); + try testing.expectEqual(std.meta.activeTag(req), done.op); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + return done; + } + + fn handshake(p: *Pair) !void { + p.start(); + const v = try p.one(.{ .version = .{} }); + try testing.expectEqualStrings("9P2000", v.result.version.version); + try testing.expectEqual(@as(u16, notag), v.tag); + const a = try p.one(.{ .attach = .{ .fid = 0, .uname = "goblin" } }); + try testing.expectEqual(@as(u64, 1), a.result.attach.path); + try testing.expectEqual(qtdir, a.result.attach.type); + } +}; + +test "fs client: a whole session against the engine" { + var p: Pair = .{}; + try p.handshake(); + try testing.expectEqual(@as(u32, 4096), p.cli.msize); + try testing.expectEqual(@as(u32, 4096 - 11), p.cli.maxRead()); + try testing.expectEqual(@as(u32, 4096 - 23), p.cli.maxWrite()); + + const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); + try testing.expectEqual(@as(u16, 2), w.result.walk.nwqid); + try testing.expectEqual(@as(u64, 16), w.result.walk.wqid[0].path); + try testing.expectEqual(@as(u64, 18), w.result.walk.wqid[1].path); + try testing.expectEqual(qtfile, w.result.walk.wqid[1].type); + + const o = try p.one(.{ .open = .{ .fid = 1, .mode = ordwr } }); + try testing.expectEqual(@as(u64, 18), o.result.open.qid.path); + try testing.expectEqual(@as(u32, 4096 - iohdrsz), o.result.open.iounit); + + const r = try p.one(.{ .read = .{ .fid = 1, .offset = 0, .count = 64 } }); + try testing.expectEqualStrings("hello, body\n", r.result.read); + const eof = try p.one(.{ .read = .{ .fid = 1, .offset = 12, .count = 64 } }); + try testing.expectEqual(@as(usize, 0), eof.result.read.len); + + const wr = try p.one(.{ .write = .{ .fid = 1, .offset = 0, .data = "abc" } }); + try testing.expectEqual(@as(u32, 3), wr.result.write); + try testing.expectEqualStrings("abc", p.fsys.writes[0..p.fsys.writes_len]); + + const st = try p.one(.{ .stat = .{ .fid = 1 } }); + try testing.expectEqualStrings("body", st.result.stat.name); + try testing.expectEqual(@as(u64, 12), st.result.stat.length); + try testing.expectEqualStrings("goblin", st.result.stat.uid); + + _ = try p.one(.{ .clunk = .{ .fid = 1 } }); + try testing.expectEqual(@as(u32, 1), p.fsys.releases); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expectEqual(@as(usize, 0), p.cli.output().len); + try testing.expect(p.cli.take() == null); + try testing.expect(!p.cli.dead); +} + +fn deliverReversed(p: *Pair) !void { + var scratch: [4096]u8 = undefined; + const out = p.srv.output(); + try testing.expect(out.len <= scratch.len); + @memcpy(scratch[0..out.len], out); + const total = out.len; + p.srv.wrote(total); + + var at: [max_tags]usize = undefined; + var lens: [max_tags]u32 = undefined; + var count: usize = 0; + var i: usize = 0; + while (i < total) { + const len = frameLen(scratch[i..total]) orelse return error.ShortReply; + at[count] = i; + lens[count] = len; + count += 1; + i += len; + } + try testing.expect(count >= 2); + var k = count; + while (k > 0) { + k -= 1; + const f = scratch[at[k]..][0..lens[k]]; + try testing.expectEqual(f.len, p.cli.push(f)); + } +} + +test "fs client: replies out of order are matched by tag and not by arrival" { + var p: Pair = .{}; + try p.handshake(); + const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"index"} } }); + try testing.expectEqual(@as(u16, 1), w.result.walk.nwqid); + + const root_tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); + const index_tag = try p.cli.submit(.{ .stat = .{ .fid = 1 } }); + try testing.expectEqual(@as(u16, 0), root_tag); + try testing.expectEqual(@as(u16, 1), index_tag); + try testing.expectEqual(@as(usize, 2), p.cli.pending()); + + while (p.cli.output().len != 0) { + const n = p.srv.push(p.cli.output()); + p.cli.wrote(n); + } + while (p.srv.next()) |req| p.answer(req); + try deliverReversed(&p); + + const first = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(index_tag, first.tag); + try testing.expectEqualStrings("index", first.result.stat.name); + const second = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(root_tag, second.tag); + try testing.expectEqualStrings("/", second.result.stat.name); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expect(!p.cli.dead); +} + +test "fs client: an Rerror answers one operation and the session carries on" { + var p: Pair = .{}; + try p.handshake(); + + const bad = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"nope"} } }); + try testing.expectEqual(Client.Op.walk, bad.op); + try testing.expectEqualStrings(errString(E.NOENT), bad.result.fail); + + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expect(!p.cli.dead); + const st = try p.one(.{ .stat = .{ .fid = 0 } }); + try testing.expectEqualStrings("/", st.result.stat.name); + + const stale = try p.one(.{ .stat = .{ .fid = 9 } }); + try testing.expectEqualStrings(e_unknown_fid, stale.result.fail); + try testing.expect(!p.cli.dead); +} + +test "fs client: a reply arriving a byte at a time is taken when its last byte lands" { + var p: Pair = .{}; + try p.handshake(); + const tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); + + while (p.cli.output().len != 0) { + const n = p.srv.push(p.cli.output()); + p.cli.wrote(n); + } + while (p.srv.next()) |req| p.answer(req); + var scratch: [512]u8 = undefined; + const out = p.srv.output(); + try testing.expect(out.len > 4 and out.len <= scratch.len); + @memcpy(scratch[0..out.len], out); + const reply = scratch[0..out.len]; + p.srv.wrote(reply.len); + + for (reply[0 .. reply.len - 1]) |b| { + try testing.expectEqual(@as(usize, 1), p.cli.push(&.{b})); + try testing.expect(p.cli.take() == null); + try testing.expect(!p.cli.dead); + } + try testing.expectEqual(@as(usize, 1), p.cli.push(reply[reply.len - 1 ..])); + const done = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(tag, done.tag); + try testing.expectEqualStrings("/", done.result.stat.name); +} + +test "fs client: sixteen tags outstanding against the engine, and the seventeenth is refused" { + var p: Pair = .{}; + try p.handshake(); + + var tags: [max_tags]u16 = undefined; + for (&tags, 0..) |*t, i| { + t.* = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); + try testing.expectEqual(@as(u16, @intCast(i)), t.*); + } + try testing.expectEqual(max_tags, p.cli.pending()); + try testing.expectError(error.NoTags, p.cli.submit(.{ .stat = .{ .fid = 0 } })); + const owed = p.cli.output().len; + try testing.expectError(error.NoTags, p.cli.submit(.{ .clunk = .{ .fid = 0 } })); + try testing.expectEqual(owed, p.cli.output().len); + + p.wire_(); + var seen: [max_tags]bool = @splat(false); + for (0..max_tags) |_| { + const done = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(Client.Op.stat, done.op); + try testing.expect(!seen[done.tag]); + seen[done.tag] = true; + } + for (seen) |s| try testing.expect(s); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + _ = try p.one(.{ .stat = .{ .fid = 0 } }); +} + +test "fs client: what a caller may not ask for is refused before a tag is spent" { + var p: Pair = .{}; + p.start(); + + try testing.expectError(error.Handshake, p.cli.submit(.{ .stat = .{ .fid = 0 } })); + try p.handshake(); + + try testing.expectError(error.BadRequest, p.cli.submit(.{ .stat = .{ .fid = nofid } })); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .clunk = .{ .fid = nofid } })); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = nofid, .names = &.{} } })); + + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"1/body"} } })); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{""} } })); + const seventeen: [max_welem + 1][]const u8 = @splat("x"); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &seventeen } })); + + try testing.expectError(error.TooLarge, p.cli.submit(.{ .read = .{ + .fid = 0, + .offset = 0, + .count = p.cli.maxRead() + 1, + } })); + var big: [4096]u8 = @splat('x'); + try testing.expectError(error.TooLarge, p.cli.submit(.{ .write = .{ + .fid = 0, + .offset = 0, + .data = big[0 .. p.cli.maxWrite() + 1], + } })); + p.cli.wrote(p.cli.output().len); + _ = try p.cli.submit(.{ .read = .{ .fid = 0, .offset = 0, .count = p.cli.maxRead() } }); + p.cli.wrote(p.cli.output().len); + _ = try p.cli.submit(.{ .write = .{ .fid = 0, .offset = 0, .data = big[0..p.cli.maxWrite()] } }); + + try testing.expectError(error.Handshake, p.cli.submit(.{ .version = .{} })); + try testing.expectError(error.NoSpace, p.cli.submit(.{ .stat = .{ .fid = 0 } })); +} + +test "fs client: an Rread longer than the Tread asked for is refused" { + var p: Pair = .{}; + try p.handshake(); + _ = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); + _ = try p.one(.{ .open = .{ .fid = 1, .mode = oread } }); + + const tag = try p.cli.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 4 } }); + p.cli.wrote(p.cli.output().len); + var buf: [64]u8 = undefined; + _ = p.cli.push(try encode(.{ .rread = .{ .data = "hello, body\n" } }, tag, &buf)); + try testing.expect(p.cli.take() == null); + try testing.expect(p.cli.dead); + + var q: Pair = .{}; + try q.handshake(); + _ = try q.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); + _ = try q.one(.{ .open = .{ .fid = 1, .mode = oread } }); + const short = try q.one(.{ .read = .{ .fid = 1, .offset = 0, .count = 4 } }); + try testing.expectEqualStrings("hell", short.result.read); +} + +test "fs client: hangup and a dead connection refuse everything after" { + var p: Pair = .{}; + try p.handshake(); + p.cli.hangup(); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expectEqual(@as(usize, 0), p.cli.output().len); + try testing.expectEqual(@as(usize, 0), p.cli.push("anything")); + try testing.expect(p.cli.take() == null); + try testing.expectError(error.Dead, p.cli.submit(.{ .stat = .{ .fid = 0 } })); + try testing.expectError(error.Dead, p.cli.submit(.{ .version = .{} })); +} -- cgit v1.3