summaryrefslogtreecommitdiff
path: root/9ns/src/fuse.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
committerGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
commitba996acfcad1698adbf4a1834fe50e73b1c6cab9 (patch)
tree282ba00ce5b10d7416aecb9f2f0f0a439340a57d /9ns/src/fuse.zig
parentb05abcba3ea09ea106ad28364c6e40a3ec31b890 (diff)
downloadcloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.tar.gz
cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.zip
Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web)
Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9ns/src/fuse.zig')
-rw-r--r--9ns/src/fuse.zig653
1 files changed, 653 insertions, 0 deletions
diff --git a/9ns/src/fuse.zig b/9ns/src/fuse.zig
new file mode 100644
index 0000000..682b3d5
--- /dev/null
+++ b/9ns/src/fuse.zig
@@ -0,0 +1,653 @@
+//! Kernel FUSE protocol subset (no libfuse, no libc, no policy).
+//!
+//! Extern structs mirror `/usr/include/linux/fuse.h` (kernel header 7.45);
+//! every layout is checked against the header's size at comptime. Only the
+//! opcodes and structs 9ns needs are here. The I/O helpers are blocking
+//! and allocation-free: the caller owns a single request buffer.
+//!
+//! Wire rules worth remembering:
+//! * The kernel delivers exactly one request per `read(2)` on `/dev/fuse`,
+//! and a reply must be exactly one `write(2)`/`writev(2)`.
+//! * Request bodies start right after the 40-byte `InHeader`; since every
+//! in-struct is 8-byte aligned in the header, `body()` requires the caller's
+//! buffer to be 8-byte aligned (`std.heap` page allocations and
+//! `align(8)` arrays both qualify).
+//! * A write that fails with `ENOENT` means the request was interrupted and
+//! the kernel already forgot it: the reply is silently dropped.
+//! * `ENODEV` on read means the filesystem was unmounted.
+
+const std = @import("std");
+const linux = std.os.linux;
+
+pub const kernel_version: u32 = 7;
+/// The minor we answer; the kernel adapts to the lower of the two.
+pub const kernel_minor: u32 = 31;
+pub const root_id: u64 = 1;
+
+pub const FOPEN_DIRECT_IO: u32 = 1 << 0;
+pub const FOPEN_KEEP_CACHE: u32 = 1 << 1;
+pub const FOPEN_NONSEEKABLE: u32 = 1 << 2;
+
+pub const FUSE_ASYNC_READ: u32 = 1 << 0;
+/// The kernel passes O_TRUNC in OPEN instead of a separate SETATTR(size=0); 9P has OTRUNC for exactly this.
+pub const FUSE_ATOMIC_O_TRUNC: u32 = 1 << 3;
+/// Without this the kernel's cached-write path (`--no-direct-io`) sends one 4 KiB WRITE per page.
+pub const FUSE_BIG_WRITES: u32 = 1 << 5;
+/// Re-fetch a cached inode's size/mtime and drop stale pages when they change.
+/// Required for `--no-direct-io` correctness: 9P sizes change under us, and
+/// without this the kernel trusts a stale cached size and truncates reads.
+pub const FUSE_AUTO_INVAL_DATA: u32 = 1 << 12;
+pub const FUSE_MAX_PAGES: u32 = 1 << 22;
+
+pub const FATTR_MODE: u32 = 1 << 0;
+pub const FATTR_UID: u32 = 1 << 1;
+pub const FATTR_GID: u32 = 1 << 2;
+pub const FATTR_SIZE: u32 = 1 << 3;
+pub const FATTR_ATIME: u32 = 1 << 4;
+pub const FATTR_MTIME: u32 = 1 << 5;
+pub const FATTR_FH: u32 = 1 << 6;
+pub const FATTR_ATIME_NOW: u32 = 1 << 7;
+pub const FATTR_MTIME_NOW: u32 = 1 << 8;
+pub const FATTR_LOCKOWNER: u32 = 1 << 9;
+pub const FATTR_CTIME: u32 = 1 << 10;
+
+/// File type bits for `Attr.mode` and `Dirent.type` (used by the bridge).
+pub const S_IFDIR: u32 = linux.S.IFDIR;
+pub const S_IFREG: u32 = linux.S.IFREG;
+pub const DT_DIR: u32 = linux.DT.DIR;
+pub const DT_REG: u32 = linux.DT.REG;
+
+pub const Opcode = enum(u32) {
+ lookup = 1,
+ forget = 2,
+ getattr = 3,
+ setattr = 4,
+ readlink = 5,
+ symlink = 6,
+ mknod = 8,
+ mkdir = 9,
+ unlink = 10,
+ rmdir = 11,
+ rename = 12,
+ link = 13,
+ open = 14,
+ read = 15,
+ write = 16,
+ statfs = 17,
+ release = 18,
+ fsync = 20,
+ setxattr = 21,
+ getxattr = 22,
+ listxattr = 23,
+ removexattr = 24,
+ flush = 25,
+ init = 26,
+ opendir = 27,
+ readdir = 28,
+ releasedir = 29,
+ fsyncdir = 30,
+ getlk = 31,
+ setlk = 32,
+ setlkw = 33,
+ access = 34,
+ create = 35,
+ interrupt = 36,
+ bmap = 37,
+ destroy = 38,
+ ioctl = 39,
+ poll = 40,
+ notify_reply = 41,
+ batch_forget = 42,
+ fallocate = 43,
+ readdirplus = 44,
+ rename2 = 45,
+ lseek = 46,
+ copy_file_range = 47,
+ setupmapping = 48,
+ removemapping = 49,
+ syncfs = 50,
+ tmpfile = 51,
+ statx = 52,
+ _,
+};
+
+// ---------------------------------------------------------------------------
+// Structs (field order and widths follow linux/fuse.h exactly)
+// ---------------------------------------------------------------------------
+
+pub const InHeader = extern struct {
+ len: u32,
+ opcode: u32,
+ unique: u64,
+ nodeid: u64,
+ uid: u32,
+ gid: u32,
+ pid: u32,
+ total_extlen: u16,
+ padding: u16,
+
+ pub fn op(h: InHeader) Opcode {
+ return @enumFromInt(h.opcode);
+ }
+};
+
+pub const OutHeader = extern struct {
+ len: u32,
+ @"error": i32,
+ unique: u64,
+};
+
+pub const Attr = extern struct {
+ ino: u64 = 0,
+ size: u64 = 0,
+ blocks: u64 = 0,
+ atime: u64 = 0,
+ mtime: u64 = 0,
+ ctime: u64 = 0,
+ atimensec: u32 = 0,
+ mtimensec: u32 = 0,
+ ctimensec: u32 = 0,
+ mode: u32 = 0,
+ nlink: u32 = 0,
+ uid: u32 = 0,
+ gid: u32 = 0,
+ rdev: u32 = 0,
+ blksize: u32 = 0,
+ flags: u32 = 0,
+};
+
+pub const EntryOut = extern struct {
+ nodeid: u64 = 0,
+ generation: u64 = 0,
+ entry_valid: u64 = 0,
+ attr_valid: u64 = 0,
+ entry_valid_nsec: u32 = 0,
+ attr_valid_nsec: u32 = 0,
+ attr: Attr = .{},
+};
+
+pub const AttrOut = extern struct {
+ attr_valid: u64 = 0,
+ attr_valid_nsec: u32 = 0,
+ dummy: u32 = 0,
+ attr: Attr = .{},
+};
+
+pub const GetattrIn = extern struct { getattr_flags: u32, dummy: u32, fh: u64 };
+
+pub const SetattrIn = extern struct {
+ valid: u32,
+ padding: u32,
+ fh: u64,
+ size: u64,
+ lock_owner: u64,
+ atime: u64,
+ mtime: u64,
+ ctime: u64,
+ atimensec: u32,
+ mtimensec: u32,
+ ctimensec: u32,
+ mode: u32,
+ unused4: u32,
+ uid: u32,
+ gid: u32,
+ unused5: u32,
+};
+
+pub const OpenIn = extern struct { flags: u32, open_flags: u32 };
+pub const OpenOut = extern struct { fh: u64 = 0, open_flags: u32 = 0, backing_id: i32 = 0 };
+pub const ReleaseIn = extern struct { fh: u64, flags: u32, release_flags: u32, lock_owner: u64 };
+pub const FlushIn = extern struct { fh: u64, unused: u32, padding: u32, lock_owner: u64 };
+
+pub const ReadIn = extern struct {
+ fh: u64,
+ offset: u64,
+ size: u32,
+ read_flags: u32,
+ lock_owner: u64,
+ flags: u32,
+ padding: u32,
+};
+
+pub const WriteIn = extern struct {
+ fh: u64,
+ offset: u64,
+ size: u32,
+ write_flags: u32,
+ lock_owner: u64,
+ flags: u32,
+ padding: u32,
+};
+
+pub const WriteOut = extern struct { size: u32, padding: u32 = 0 };
+pub const CreateIn = extern struct { flags: u32, mode: u32, umask: u32, open_flags: u32 };
+pub const MkdirIn = extern struct { mode: u32, umask: u32 };
+pub const RenameIn = extern struct { newdir: u64 };
+pub const Rename2In = extern struct { newdir: u64, flags: u32, padding: u32 };
+pub const ForgetIn = extern struct { nlookup: u64 };
+pub const BatchForgetIn = extern struct { count: u32, dummy: u32 };
+pub const ForgetOne = extern struct { nodeid: u64, nlookup: u64 };
+pub const FsyncIn = extern struct { fh: u64, fsync_flags: u32, padding: u32 };
+pub const AccessIn = extern struct { mask: u32, padding: u32 };
+pub const InterruptIn = extern struct { unique: u64 };
+pub const LseekIn = extern struct { fh: u64, offset: u64, whence: u32, padding: u32 };
+
+pub const Kstatfs = extern struct {
+ blocks: u64 = 0,
+ bfree: u64 = 0,
+ bavail: u64 = 0,
+ files: u64 = 0,
+ ffree: u64 = 0,
+ bsize: u32 = 0,
+ namelen: u32 = 0,
+ frsize: u32 = 0,
+ padding: u32 = 0,
+ spare: [6]u32 = [_]u32{0} ** 6,
+};
+
+pub const StatfsOut = extern struct { st: Kstatfs = .{} };
+
+pub const InitIn = extern struct {
+ major: u32,
+ minor: u32,
+ max_readahead: u32,
+ flags: u32,
+ flags2: u32,
+ unused: [11]u32,
+};
+
+/// 64 bytes; the kernel accepts this size whenever the answered minor >= 23.
+pub const InitOut = extern struct {
+ major: u32 = kernel_version,
+ minor: u32 = kernel_minor,
+ max_readahead: u32 = 0,
+ flags: u32 = 0,
+ max_background: u16 = 0,
+ congestion_threshold: u16 = 0,
+ max_write: u32 = 0,
+ time_gran: u32 = 0,
+ max_pages: u16 = 0,
+ map_alignment: u16 = 0,
+ flags2: u32 = 0,
+ max_stack_depth: u32 = 0,
+ request_timeout: u16 = 0,
+ unused: [11]u16 = [_]u16{0} ** 11,
+};
+
+/// Fixed 24-byte head of `fuse_dirent`; the name follows, padded to 8 bytes.
+pub const Dirent = extern struct { ino: u64, off: u64, namelen: u32, type: u32 };
+
+comptime {
+ std.debug.assert(@sizeOf(InHeader) == 40);
+ std.debug.assert(@sizeOf(OutHeader) == 16);
+ std.debug.assert(@sizeOf(Attr) == 88);
+ std.debug.assert(@sizeOf(EntryOut) == 128);
+ std.debug.assert(@sizeOf(AttrOut) == 104);
+ std.debug.assert(@sizeOf(GetattrIn) == 16);
+ std.debug.assert(@sizeOf(SetattrIn) == 88);
+ std.debug.assert(@sizeOf(OpenIn) == 8);
+ std.debug.assert(@sizeOf(OpenOut) == 16);
+ std.debug.assert(@sizeOf(ReleaseIn) == 24);
+ std.debug.assert(@sizeOf(FlushIn) == 24);
+ std.debug.assert(@sizeOf(ReadIn) == 40);
+ std.debug.assert(@sizeOf(WriteIn) == 40);
+ std.debug.assert(@sizeOf(WriteOut) == 8);
+ std.debug.assert(@sizeOf(CreateIn) == 16);
+ std.debug.assert(@sizeOf(MkdirIn) == 8);
+ std.debug.assert(@sizeOf(RenameIn) == 8);
+ std.debug.assert(@sizeOf(Rename2In) == 16);
+ std.debug.assert(@sizeOf(ForgetIn) == 8);
+ std.debug.assert(@sizeOf(BatchForgetIn) == 8);
+ std.debug.assert(@sizeOf(ForgetOne) == 16);
+ std.debug.assert(@sizeOf(FsyncIn) == 16);
+ std.debug.assert(@sizeOf(AccessIn) == 8);
+ std.debug.assert(@sizeOf(InterruptIn) == 8);
+ std.debug.assert(@sizeOf(Kstatfs) == 80);
+ std.debug.assert(@sizeOf(StatfsOut) == 80);
+ std.debug.assert(@sizeOf(InitIn) == 64);
+ std.debug.assert(@sizeOf(InitOut) == 64);
+ std.debug.assert(@sizeOf(Dirent) == 24);
+ std.debug.assert(@sizeOf(LseekIn) == 24);
+}
+
+// ---------------------------------------------------------------------------
+// Request / reply helpers
+// ---------------------------------------------------------------------------
+
+pub const Error = error{ Protocol, Io, TooManyPayloads };
+
+pub const Request = struct {
+ header: InHeader,
+ /// Bytes after the header; a slice into the caller's buffer.
+ body: []const u8,
+};
+
+/// Reads one kernel request with a single `read(2)`. Returns null on ENODEV
+/// (unmounted). Retries EINTR/EAGAIN/ENOENT. `buf` should be at least
+/// `max_write + 4096` bytes and 8-byte aligned so `body()` can view it.
+pub fn readRequest(fd: i32, buf: []u8) Error!?Request {
+ while (true) {
+ const rc = linux.read(fd, buf.ptr, buf.len);
+ switch (linux.errno(rc)) {
+ .SUCCESS => {
+ const n: usize = rc;
+ if (n < @sizeOf(InHeader)) return error.Protocol;
+ const header = std.mem.bytesToValue(InHeader, buf[0..@sizeOf(InHeader)]);
+ if (header.len != n) return error.Protocol;
+ return .{ .header = header, .body = buf[@sizeOf(InHeader)..n] };
+ },
+ .INTR, .AGAIN, .NOENT => continue,
+ .NODEV => return null,
+ else => return error.Io,
+ }
+ }
+}
+
+/// Maximum number of payload slices a single `reply` can carry.
+pub const max_payloads = 7;
+
+/// Success reply: `OutHeader` followed by the concatenated `payloads`, sent in
+/// one `writev(2)`. An ENOENT from the kernel means the request was
+/// interrupted; the reply is dropped and this returns normally.
+pub fn reply(fd: i32, unique: u64, payloads: []const []const u8) Error!void {
+ if (payloads.len > max_payloads) return error.TooManyPayloads;
+ var total: usize = @sizeOf(OutHeader);
+ for (payloads) |p| total += p.len;
+ if (total > std.math.maxInt(u32)) return error.Protocol;
+ const header = OutHeader{ .len = @intCast(total), .@"error" = 0, .unique = unique };
+ var iov: [max_payloads + 1]std.posix.iovec_const = undefined;
+ iov[0] = .{ .base = @ptrCast(&header), .len = @sizeOf(OutHeader) };
+ for (payloads, 1..) |p, i| iov[i] = .{ .base = p.ptr, .len = p.len };
+ return writeAll(fd, &iov, payloads.len + 1, total);
+}
+
+/// Error reply: an `OutHeader` carrying `-errno` and no payload.
+pub fn replyError(fd: i32, unique: u64, err: linux.E) Error!void {
+ const code: i32 = @intCast(@intFromEnum(err));
+ const header = OutHeader{ .len = @sizeOf(OutHeader), .@"error" = -code, .unique = unique };
+ var iov = [_]std.posix.iovec_const{.{ .base = @ptrCast(&header), .len = @sizeOf(OutHeader) }};
+ return writeAll(fd, &iov, 1, @sizeOf(OutHeader));
+}
+
+fn writeAll(fd: i32, iov: [*]const std.posix.iovec_const, count: usize, total: usize) Error!void {
+ while (true) {
+ const rc = linux.writev(fd, iov, count);
+ switch (linux.errno(rc)) {
+ .SUCCESS => return if (rc == total) {} else error.Protocol,
+ .INTR => continue,
+ .NOENT => return, // request was interrupted; reply dropped
+ else => return error.Io,
+ }
+ }
+}
+
+/// Appends a `fuse_dirent` (head + name, padded to a multiple of 8) at
+/// `buf[used.*..]`. Returns false and leaves `buf`/`used` unchanged if the
+/// record does not fit.
+pub fn addDirent(buf: []u8, used: *usize, ino: u64, off: u64, dtype: u32, name: []const u8) bool {
+ const raw = @sizeOf(Dirent) + name.len;
+ const rec = (raw + 7) & ~@as(usize, 7);
+ if (used.* > buf.len or buf.len - used.* < rec) return false;
+ const dst = buf[used.*..][0..rec];
+ const head = Dirent{ .ino = ino, .off = off, .namelen = @intCast(name.len), .type = dtype };
+ @memcpy(dst[0..@sizeOf(Dirent)], std.mem.asBytes(&head));
+ @memcpy(dst[@sizeOf(Dirent)..raw], name);
+ @memset(dst[raw..rec], 0);
+ used.* += rec;
+ return true;
+}
+
+/// Views the first `@sizeOf(T)` bytes of `req.body` as `T` (copy-free).
+/// Fails with `error.Protocol` if the body is too short or misaligned.
+pub fn body(comptime T: type, req: Request) Error!*const T {
+ if (req.body.len < @sizeOf(T)) return error.Protocol;
+ if (@intFromPtr(req.body.ptr) % @alignOf(T) != 0) return error.Protocol;
+ return @ptrCast(@alignCast(req.body.ptr));
+}
+
+/// The NUL-terminated string at `req.body[offset..]`, without the NUL.
+pub fn nameAt(req: Request, offset: usize) Error![]const u8 {
+ if (offset > req.body.len) return error.Protocol;
+ const rest = req.body[offset..];
+ const end = std.mem.indexOfScalar(u8, rest, 0) orelse return error.Protocol;
+ return rest[0..end];
+}
+
+/// The NUL-terminated string following a `T` body (or at offset 0 when
+/// `T == void`), e.g. LOOKUP's name (`void`) or MKDIR's name (`MkdirIn`).
+pub fn nameAfter(comptime T: type, req: Request) Error![]const u8 {
+ const offset = if (T == void) 0 else @sizeOf(T);
+ return nameAt(req, offset);
+}
+
+/// The string that follows `first` (obtained via `nameAt(req, offset)`),
+/// for "old\0new\0" pairs such as RENAME's.
+pub fn secondName(req: Request, first: []const u8, offset: usize) Error![]const u8 {
+ return nameAt(req, offset + first.len + 1);
+}
+
+/// Builds the INIT reply per docs/DESIGN.md.
+pub fn initReply(in: *const InitIn, max_write: u32) InitOut {
+ var out = InitOut{
+ .major = kernel_version,
+ .minor = @min(kernel_minor, in.minor),
+ .max_readahead = in.max_readahead,
+ .flags = FUSE_ASYNC_READ | FUSE_ATOMIC_O_TRUNC | FUSE_BIG_WRITES | FUSE_AUTO_INVAL_DATA,
+ .max_background = 16,
+ .congestion_threshold = 12,
+ .max_write = max_write,
+ .time_gran = 1,
+ };
+ if (in.flags & FUSE_MAX_PAGES != 0) {
+ out.flags |= FUSE_MAX_PAGES;
+ out.max_pages = 256;
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "struct sizes match linux/fuse.h" {
+ // The comptime block above is the real check; this makes it run under
+ // `zig test` even if the module is otherwise unreferenced.
+ try testing.expectEqual(@as(usize, 40), @sizeOf(InHeader));
+ try testing.expectEqual(@as(usize, 64), @sizeOf(InitOut));
+ try testing.expectEqual(@as(usize, 24), @sizeOf(Dirent));
+ try testing.expectEqual(@as(u32, 26), @intFromEnum(Opcode.init));
+ try testing.expectEqual(Opcode.statx, @as(Opcode, @enumFromInt(52)));
+}
+
+test "addDirent pads records to 8 bytes and refuses when full" {
+ var buf: [1024]u8 = undefined;
+ var used: usize = 0;
+ const name = "abcdefghijklmnopq"; // 17 chars
+ var expect_total: usize = 0;
+ var n: usize = 1;
+ while (n <= 17) : (n += 1) {
+ const before = used;
+ try testing.expect(addDirent(&buf, &used, n, n, DT_REG, name[0..n]));
+ const rec = used - before;
+ try testing.expectEqual(@as(usize, 0), rec % 8);
+ try testing.expectEqual((24 + n + 7) & ~@as(usize, 7), rec);
+ // check head fields and NUL padding
+ const head = std.mem.bytesToValue(Dirent, buf[before..][0..24]);
+ try testing.expectEqual(n, head.ino);
+ try testing.expectEqual(@as(u32, @intCast(n)), head.namelen);
+ try testing.expectEqualStrings(name[0..n], buf[before + 24 ..][0..n]);
+ for (buf[before + 24 + n .. used]) |b| try testing.expectEqual(@as(u8, 0), b);
+ expect_total += rec;
+ }
+ try testing.expectEqual(expect_total, used);
+
+ // A record that does not fit leaves everything untouched.
+ var small: [40]u8 = undefined;
+ var used2: usize = 0;
+ try testing.expect(addDirent(&small, &used2, 1, 1, DT_DIR, "0123456789abcdef")); // 24+16 = 40
+ try testing.expectEqual(@as(usize, 40), used2);
+ try testing.expect(!addDirent(&small, &used2, 2, 2, DT_DIR, "x"));
+ try testing.expectEqual(@as(usize, 40), used2);
+ var tight: [31]u8 = undefined;
+ var used3: usize = 0;
+ try testing.expect(!addDirent(&tight, &used3, 1, 1, DT_REG, "a")); // needs 32
+ try testing.expectEqual(@as(usize, 0), used3);
+}
+
+test "body/nameAfter/secondName on hand-built requests" {
+ var buf: [128]u8 align(8) = undefined;
+ // LOOKUP(parent=1, "hello")
+ const name = "hello";
+ const hdr = InHeader{
+ .len = @intCast(@sizeOf(InHeader) + name.len + 1),
+ .opcode = @intFromEnum(Opcode.lookup),
+ .unique = 7,
+ .nodeid = root_id,
+ .uid = 1000,
+ .gid = 1000,
+ .pid = 42,
+ .total_extlen = 0,
+ .padding = 0,
+ };
+ @memcpy(buf[0..40], std.mem.asBytes(&hdr));
+ @memcpy(buf[40..45], name);
+ buf[45] = 0;
+ const req = Request{ .header = hdr, .body = buf[40..hdr.len] };
+ try testing.expectEqual(Opcode.lookup, req.header.op());
+ try testing.expectEqualStrings("hello", try nameAfter(void, req));
+ try testing.expectError(error.Protocol, body(MkdirIn, Request{ .header = hdr, .body = buf[40..44] }));
+
+ // MKDIR(mode=0o755) + "dir"
+ const mk = MkdirIn{ .mode = 0o755, .umask = 0o22 };
+ @memcpy(buf[40..48], std.mem.asBytes(&mk));
+ @memcpy(buf[48..51], "dir");
+ buf[51] = 0;
+ const mreq = Request{ .header = hdr, .body = buf[40..52] };
+ const got = try body(MkdirIn, mreq);
+ try testing.expectEqual(@as(u32, 0o755), got.mode);
+ try testing.expectEqualStrings("dir", try nameAfter(MkdirIn, mreq));
+
+ // RENAME(newdir) + "old\0new\0"
+ const rn = RenameIn{ .newdir = 9 };
+ @memcpy(buf[40..48], std.mem.asBytes(&rn));
+ @memcpy(buf[48..56], "old\x00new\x00");
+ const rreq = Request{ .header = hdr, .body = buf[40..56] };
+ try testing.expectEqual(@as(u64, 9), (try body(RenameIn, rreq)).newdir);
+ const old = try nameAfter(RenameIn, rreq);
+ try testing.expectEqualStrings("old", old);
+ try testing.expectEqualStrings("new", try secondName(rreq, old, @sizeOf(RenameIn)));
+ try testing.expectError(error.Protocol, secondName(rreq, "new", @sizeOf(RenameIn) + 4));
+
+ // Missing NUL and misalignment are protocol errors.
+ try testing.expectError(error.Protocol, nameAt(Request{ .header = hdr, .body = buf[48..51] }, 0));
+ try testing.expectError(error.Protocol, body(MkdirIn, Request{ .header = hdr, .body = buf[41..57] }));
+}
+
+test "initReply fields" {
+ var in = InitIn{ .major = 7, .minor = 45, .max_readahead = 131072, .flags = 0, .flags2 = 0, .unused = [_]u32{0} ** 11 };
+ const a = initReply(&in, 1 << 20);
+ try testing.expectEqual(@as(u32, 7), a.major);
+ try testing.expectEqual(@as(u32, 31), a.minor);
+ try testing.expectEqual(@as(u32, 131072), a.max_readahead);
+ try testing.expectEqual(FUSE_ASYNC_READ | FUSE_ATOMIC_O_TRUNC | FUSE_BIG_WRITES | FUSE_AUTO_INVAL_DATA, a.flags);
+ try testing.expectEqual(@as(u16, 0), a.max_pages);
+ try testing.expectEqual(@as(u16, 16), a.max_background);
+ try testing.expectEqual(@as(u16, 12), a.congestion_threshold);
+ try testing.expectEqual(@as(u32, 1 << 20), a.max_write);
+ try testing.expectEqual(@as(u32, 1), a.time_gran);
+
+ in.flags = FUSE_MAX_PAGES | FUSE_ASYNC_READ;
+ in.minor = 27;
+ const b = initReply(&in, 4096);
+ try testing.expectEqual(@as(u32, 27), b.minor);
+ try testing.expectEqual(FUSE_ASYNC_READ | FUSE_ATOMIC_O_TRUNC | FUSE_BIG_WRITES | FUSE_AUTO_INVAL_DATA | FUSE_MAX_PAGES, b.flags);
+ try testing.expectEqual(@as(u16, 256), b.max_pages);
+ try testing.expectEqual(@as(u32, 4096), b.max_write);
+}
+
+fn makePipe() ![2]i32 {
+ var fds: [2]i32 = undefined;
+ if (linux.errno(linux.pipe2(&fds, .{ .CLOEXEC = true })) != .SUCCESS) return error.Io;
+ return fds;
+}
+
+fn readExact(fd: i32, out: []u8) !void {
+ var got: usize = 0;
+ while (got < out.len) {
+ const rc = linux.read(fd, out[got..].ptr, out.len - got);
+ if (linux.errno(rc) != .SUCCESS or rc == 0) return error.Io;
+ got += rc;
+ }
+}
+
+test "reply writes header + payloads through a pipe" {
+ const fds = try makePipe();
+ defer _ = linux.close(fds[0]);
+ defer _ = linux.close(fds[1]);
+
+ const oo = OpenOut{ .fh = 0x1234, .open_flags = FOPEN_DIRECT_IO };
+ try reply(fds[1], 99, &.{ std.mem.asBytes(&oo), "tail" });
+
+ var out: [16 + 16 + 4]u8 = undefined;
+ try readExact(fds[0], &out);
+ const h = std.mem.bytesToValue(OutHeader, out[0..16]);
+ try testing.expectEqual(@as(u32, 36), h.len);
+ try testing.expectEqual(@as(i32, 0), h.@"error");
+ try testing.expectEqual(@as(u64, 99), h.unique);
+ try testing.expectEqualSlices(u8, std.mem.asBytes(&oo), out[16..32]);
+ try testing.expectEqualStrings("tail", out[32..36]);
+
+ // Empty payload list: header only.
+ try reply(fds[1], 5, &.{});
+ var only: [16]u8 = undefined;
+ try readExact(fds[0], &only);
+ try testing.expectEqual(@as(u32, 16), std.mem.bytesToValue(OutHeader, &only).len);
+
+ var too_many: [max_payloads + 1][]const u8 = undefined;
+ for (&too_many) |*p| p.* = "x";
+ try testing.expectError(error.TooManyPayloads, reply(fds[1], 1, &too_many));
+}
+
+test "replyError writes a negative errno" {
+ const fds = try makePipe();
+ defer _ = linux.close(fds[0]);
+ defer _ = linux.close(fds[1]);
+
+ try replyError(fds[1], 0xdead_beef, .NOENT);
+ var out: [16]u8 = undefined;
+ try readExact(fds[0], &out);
+ const h = std.mem.bytesToValue(OutHeader, &out);
+ try testing.expectEqual(@as(u32, 16), h.len);
+ try testing.expectEqual(@as(i32, -2), h.@"error");
+ try testing.expectEqual(@as(u64, 0xdead_beef), h.unique);
+
+ try replyError(fds[1], 1, .NOSYS);
+ try readExact(fds[0], &out);
+ try testing.expectEqual(-@as(i32, @intCast(@intFromEnum(linux.E.NOSYS))), std.mem.bytesToValue(OutHeader, &out).@"error");
+}
+
+test "readRequest parses one request from a pipe and rejects bad lengths" {
+ const fds = try makePipe();
+ defer _ = linux.close(fds[0]);
+ defer _ = linux.close(fds[1]);
+
+ var wire: [48]u8 align(8) = undefined;
+ const hdr = InHeader{ .len = 48, .opcode = @intFromEnum(Opcode.forget), .unique = 3, .nodeid = 2, .uid = 0, .gid = 0, .pid = 0, .total_extlen = 0, .padding = 0 };
+ @memcpy(wire[0..40], std.mem.asBytes(&hdr));
+ @memcpy(wire[40..48], std.mem.asBytes(&ForgetIn{ .nlookup = 11 }));
+ try testing.expectEqual(@as(usize, 48), linux.write(fds[1], &wire, wire.len));
+
+ var buf: [4096]u8 align(8) = undefined;
+ const req = (try readRequest(fds[0], &buf)) orelse return error.Io;
+ try testing.expectEqual(Opcode.forget, req.header.op());
+ try testing.expectEqual(@as(u64, 2), req.header.nodeid);
+ try testing.expectEqual(@as(u64, 11), (try body(ForgetIn, req)).nlookup);
+
+ // Header length disagreeing with what was read is a protocol error.
+ var bad = wire;
+ std.mem.bytesAsValue(InHeader, bad[0..40]).len = 40;
+ try testing.expectEqual(@as(usize, 48), linux.write(fds[1], &bad, bad.len));
+ try testing.expectError(error.Protocol, readRequest(fds[0], &buf));
+}