diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-20 01:47:28 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-20 01:47:29 -0300 |
| commit | 66f2e492c348677ab3050f5e378b9eb4c04c98ce (patch) | |
| tree | cf06b32309eace8eb573925e9cf14e3dfc27bd66 /9proc | |
| parent | 65209217b5b68f56bc0bd5bc6c4dce33911ded59 (diff) | |
| download | cloud9-66f2e492c348677ab3050f5e378b9eb4c04c98ce.tar.gz cloud9-66f2e492c348677ab3050f5e378b9eb4c04c98ce.zip | |
9proc core becomes a backend of cloud9.fs; engine gains optional features
9proc's own fid table, walk loop and dir-read engine are replaced by
cloud9.fs.Server; the tree (static, vars, providers) is served through the
engine's Req/Reply contract with node ids that keep the old qid scheme.
Providers may answer later by returning error.Again (parked in the engine,
retried each step, Tflush -> EINTR); no new files are exposed.
Engine (backward compatible, all opt-in via Backend.features / Options):
create, remove, wstat, reference accounting for backends that count
handles, a salted fid index, name_capacity 0 (names from getattr),
Reply.ename for backend-chosen error text, Attr.path/version/atime.
Engine-level error strings and the 217-byte msize floor now apply to 9proc;
tests updated accordingly.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9proc')
| -rw-r--r-- | 9proc/README.md | 9 | ||||
| -rw-r--r-- | 9proc/docs/LIBRARY.md | 85 | ||||
| -rw-r--r-- | 9proc/src/core.zig | 1181 | ||||
| -rw-r--r-- | 9proc/src/linux/probe.zig | 2 | ||||
| -rw-r--r-- | 9proc/src/scratch.zig | 30 | ||||
| -rwxr-xr-x | 9proc/test/adv_9proc_hostile.py | 125 | ||||
| -rwxr-xr-x | 9proc/test/adv_core_hostile.py | 158 |
7 files changed, 777 insertions, 813 deletions
diff --git a/9proc/README.md b/9proc/README.md index 0fd3ddb..15177d7 100644 --- a/9proc/README.md +++ b/9proc/README.md @@ -9,7 +9,12 @@ panics. The core (`core`, `vars`) is freestanding: no allocator, no OS, no threads, caller-owned static `Storage`, fixed-capacity tables sized at comptime. It -compiles for `riscv32-freestanding-none`. `scratch` (an in-memory read/write +compiles for `riscv32-freestanding-none`. The tree is a backend of +`cloud9.fs.Server`, the file-server engine every 9P server built on cloud9 +shares: the engine owns fids, walks, permissions and directory cursors; +the core answers its requests from static nodes, exposed variables and +providers, and a provider may answer a read later (`error.Again` parks it +until a later step). `scratch` (an in-memory read/write tree) takes an allocator; `linux` is the platform layer (listeners, a poll loop on one background thread, threads/stacks/registers, memory, breakpoints and panics via `std.debug`). [docs/LIBRARY.md](docs/LIBRARY.md) has the full @@ -19,7 +24,7 @@ contract. 9proc/ build.zig fragment imported by cloud9's root build.zig (steps below) src/root.zig pub const core, vars, scratch, linux; Config, Server(cfg), Provider - src/core.zig Tree/Server engine on cloud9.Server: fids, walks, dir reads, providers + src/core.zig the tree (static nodes, vars, providers) as a backend of cloud9.fs.Server src/vars.zig comptime value renderers (@typeInfo) for /vars src/scratch.zig in-memory read/write tree provider (takes an Allocator) src/freestanding_check.zig root for the riscv32-freestanding-none compile check diff --git a/9proc/docs/LIBRARY.md b/9proc/docs/LIBRARY.md index eef7dad..e0152e0 100644 --- a/9proc/docs/LIBRARY.md +++ b/9proc/docs/LIBRARY.md @@ -24,7 +24,7 @@ Design rules (non-negotiable, they mirror cloud9): ``` 9proc/src/root.zig pub const core, vars, scratch, linux (linux only), Server(cfg) - 9proc/src/core.zig Tree/Server engine on cloud9.Server: fids, walks, dir reads, providers + 9proc/src/core.zig the tree (static nodes, vars, providers) as a backend of cloud9.fs.Server 9proc/src/vars.zig comptime value renderers (@typeInfo) for /vars 9proc/src/scratch.zig in-memory read/write tree provider (takes an Allocator) 9proc/src/linux/probe.zig background thread + poll loop + unix/tcp/fd listeners @@ -51,34 +51,51 @@ pub const Config = struct { /// slots so that reads at arbitrary offsets are consistent. snapshot_slots: u8 = 8, snapshot_bytes: u32 = 16 * 1024, + max_parked: u8 = 8, // reads a provider has parked with error.Again, per connection }; pub fn Server(comptime cfg: Config) type { return struct { pub const Storage = struct { // caller places this in static memory - in: [msize]u8, out: [msize]u8, snapshots: [cfg.snapshot_slots][cfg.snapshot_bytes]u8, + in: [msize]u8, out: [msize]u8, data: [msize]u8, snapshots: [cfg.snapshot_slots][cfg.snapshot_bytes]u8, }; pub const Shared = struct { // state common to all connections (providers, vars) pub fn init(name_ctx: *anyopaque) Shared; pub fn addProvider(s: *Shared, p: Provider) error{Full}!void; pub fn expose(s: *Shared, name: []const u8, ptr: anytype) error{Full}!void; // typed value → /vars/<name> }; - pub const Conn = struct { // one 9P connection, push/step/output like cloud9 + pub const Backend = struct { ... }; // what cloud9.fs.Server knows of the tree: Req, Reply, features + pub const Engine = cloud9.fs.Server(Backend, .{ .fid_capacity = cfg.max_fids, .slot_capacity = cfg.max_parked, ... }); + pub const Conn = struct { // one 9P connection: an Engine plus the tree's per-connection state pub fn init(shared: *Shared, storage: *Storage, msize: u32) Conn; pub fn push(c: *Conn, bytes: []const u8) usize; // feed transport bytes - pub fn step(c: *Conn) error{Protocol}!bool; // handle ≤ 1 request; false = nothing to do + pub fn step(c: *Conn) error{Protocol}!bool; // serve ≤ 1 engine request; false = nothing to do pub fn output(c: *const Conn) []const u8; // bytes to send pub fn wrote(c: *Conn, n: usize) void; pub fn hangup(c: *Conn) void; // drop fids, tell providers + pub fn fidCount(c: *const Conn) usize; }; }; } ``` -`step` drives `cloud9.Server.receive/reply/negotiate` and the backend: the -static tree (comptime-generated from `cfg`: `/README`, `/build/*` via a -`build_options`-like struct passed in `cfg.build`, `/comptime/types/*`, -`/comptime/decls`, `/runtime/fn/*`, `/ctl`, `/vars/*`) plus **providers**. +The core is a **backend of `cloud9.fs.Server`**, the library's file-server +engine (cloud9's `docs/design.md`, "File server engine"): one engine for +every 9P server built on cloud9. The engine owns the fid table (indexed, +`Options.fid_index`, so thousands of fids cost O(1) per lookup), walks, +permission checks, directory cursors, Tflush, and the releases a dropped +connection owes; `Conn` wraps one engine instance and answers its +requests (`lookup`, `getattr`, `setattr`, `open`, `read`, `write`, +`release`, `readdir`) from the tree: the static part (comptime-generated +from `cfg`: `/README`, `/build/*` via a `build_options`-like struct passed +in `cfg.build`, `/comptime/types/*`, `/comptime/decls`, `/runtime/fn/*`, +`/ctl`, `/vars/*`) is served directly, **providers** through their vtable. +The backend declares every optional engine feature (`fs.Features`: +create, remove, wstat, references), which is how Tcreate/Tremove/Twstat +and the one-clunk-per-handle contract below reach providers. `step` +answers the engine's `retry()` and `next()` requests synchronously, one +`next()` per call; a provider that cannot answer yet parks instead (see +"Answering later"). A provider is a runtime vtable mounted at a top-level name. It owns a subtree with its own naming (dynamic directories such as `/threads/<tid>` or @@ -106,23 +123,53 @@ pub const Provider = struct { }; ``` -Error → Rerror text mapping lives in one place in the core, using the Plan 9 -strings 9ns's bridge already understands (`file does not exist`, -`permission denied`, `file already exists`, `directory not empty`, +Error → Rerror text: what the tree or a provider refuses is answered in +`ename`'s Plan 9 strings, which 9ns's bridge understands (`file does not +exist`, `permission denied`, `file already exists`, `directory not empty`, `not a directory`, `is a directory`, `bad offset`, `no space`, `i/o error`, -`not supported`). +`not supported`, `bad command`, `bad value`, `too many open dynamic +files`); what the engine refuses on its own carries cloud9.fs's strings, +the ones Linux v9fs maps back to errnos (`fid unknown or out of range`, +`fid already in use`, `Too many open files in system`, `bad use of fid` +for I/O on an unopened fid or a walk or clone from an open one, `file +already open for I/O`, `bad offset in directory read`, `permission denied` +for an open the walked mode forbids (a directory for writing, OEXEC, a +static file for writing), `wstat prohibited` for any of the fields the +engine owns (type, dev, qid, atime, the owner names) or a length on a +directory, `illegal name`, `Invalid argument` for an Rstat that cannot fit +the msize or a directory read whose count holds no whole record). The +smallest msize is the engine's `fs.msize_min` (217: one full Rwalk); a +Tversion below it ends the connection. Directory reads follow the 9P rule (offset 0 or previous offset+count, never -split a record). Dynamic file reads: on open the content is generated once -into a snapshot slot (`open` runs the generator; `read` serves the slot; a -read at offset 0 regenerates); no free slot → Rerror `too many open dynamic +split a record); the engine encodes the entries from the tree's records, +with uid/gid/muid the attach uname, the modes `dirent_dir_perm`/ +`dirent_file_perm` and length 0 (a stat of the entry gives the real +ones). Dynamic file reads: on open the content is generated once into a +snapshot slot (`open` runs the generator; `read` serves the slot; a read +at offset 0 regenerates); no free slot → Rerror `too many open dynamic files`. Stats of dynamic files report length 0. Qids: static nodes get comptime paths; provider nodes get -`(provider index << 56) | handle`. +`(provider index << 56) | handle` (or `NodeStat.path` in place of the +handle). The engine's node ids are the same numbers, except that +provider ids are offset by one in the top byte, since the engine reads +node 0 as "the node asked about". + +**Answering later.** `Provider.read` may return `error.Again`: the engine +parks the request (`Config.max_parked` per connection; a further one fails +with EAGAIN), the connection goes on serving, and every later `Conn.step` +asks the provider again with the same handle and offset until it answers, +whether at once or on a later step. The fid stays open; a Tflush of a +parked read answers it with `Interrupted system call` before the Rflush; +hangup and Tversion drop it and close the file as usual. Nothing wakes a +connection by itself: the platform layer steps a connection when its +transport moves, so a provider that becomes ready has to make that happen +(an event stream's job, not the core's). Writes cannot park. Static memory: `Server(cfg).Storage` per connection, `Shared` once. No heap. -The core has unit tests driven through `cloud9.Client` in memory (like today). +The core has unit tests driven through `cloud9.Client` in memory, +including a provider that parks. ## Value renderers (`vars.zig`) @@ -252,8 +299,8 @@ the demo's Storage is a global. ## Verification -* Unit tests: core (in-memory client drives every op incl. providers and - snapshots), vars (render/set for every category), scratch, debug (capture +* Unit tests: core (in-memory client drives every op incl. providers, + snapshots and a parked read), vars (render/set for every category), scratch, debug (capture own thread and a helper thread; breakpoint pause/continue on a helper thread; panic record path without holding). * `zig build 9proc-check-freestanding`: compiles `core.zig` + `vars.zig` diff --git a/9proc/src/core.zig b/9proc/src/core.zig index cead8f3..ce71809 100644 --- a/9proc/src/core.zig +++ b/9proc/src/core.zig @@ -1,12 +1,14 @@ -//! The freestanding 9P2000 introspection engine: a static tree generated at +//! The freestanding 9P2000 introspection tree: a static tree generated at //! comptime from a `Config` (README, /build, /comptime, /runtime/fn, /ctl, -//! /vars) plus runtime `Provider`s mounted at the top level, served over a -//! `cloud9.Server` connection. No allocator, no OS, no threads: every buffer is -//! caller-owned (`Storage`, `Shared`, `Conn`), every table is sized at comptime. -//! See docs/LIBRARY.md. +//! /vars) plus runtime `Provider`s mounted at the top level, served as a +//! backend of `cloud9.fs.Server` (the file-server engine, which owns fids, +//! walks, directory cursors, permissions and Tflush). No allocator, no OS, +//! no threads: every buffer is caller-owned (`Storage`, `Shared`, `Conn`), +//! every table is sized at comptime. See docs/LIBRARY.md. const std = @import("std"); const builtin = @import("builtin"); const cloud9 = @import("cloud9"); +const fs = cloud9.fs; const vars = @import("vars.zig"); const Writer = std.Io.Writer; @@ -50,6 +52,8 @@ pub const Config = struct { /// slots so that reads at arbitrary offsets are consistent. snapshot_slots: u8 = 8, snapshot_bytes: u32 = 16 * 1024, + /// Reads a provider has parked with `error.Again`, per connection. + max_parked: u8 = 8, }; /// Attributes of a provider node, filled by `VTable.stat` and `VTable.list`. @@ -85,6 +89,16 @@ pub const NodeStat = struct { /// `clunk(0)` as a no-op. `walk` must accept "." on any node, file or directory /// (a fresh reference to the same node; the core clones fids with it), and ".." /// on directories (except at the root, which the core resolves itself). +/// +/// Answering later: `read` may return `error.Again` when nothing is there +/// yet. The request then parks in the engine (up to `Config.max_parked` per +/// connection; a further one fails with EAGAIN) and every later `Conn.step` +/// asks the provider again, with the same handle and offset, until it +/// answers; the fid stays open. A Tflush of a parked read answers it with +/// "Interrupted system call"; a hangup or Tversion drops it and closes the +/// file as usual. Nothing wakes a connection by itself: the platform layer +/// steps it when its transport moves, so a provider that becomes ready has +/// to make that happen (out of the core's hands). pub const Provider = struct { name: []const u8, ctx: *anyopaque, @@ -94,7 +108,7 @@ pub const Provider = struct { pub const Handle = u64; pub const root: Handle = 0; - pub const Error = error{ NotFound, Exists, Perm, NotDir, IsDir, NotEmpty, BadOffset, NoSpace, Io, Unsupported, Excl }; + pub const Error = error{ NotFound, Exists, Perm, NotDir, IsDir, NotEmpty, BadOffset, NoSpace, Io, Unsupported, Excl, Again }; pub const VTable = struct { walk: *const fn (ctx: *anyopaque, parent: Handle, name: []const u8) Error!Handle, @@ -102,6 +116,7 @@ pub const Provider = struct { /// The `index`-th entry of `dir`; false when done. list: *const fn (ctx: *anyopaque, dir: Handle, index: usize, out: *NodeStat) Error!bool, open: *const fn (ctx: *anyopaque, h: Handle, mode: u8) Error!void, + /// `error.Again` parks the read; see `Provider`. read: *const fn (ctx: *anyopaque, h: Handle, offset: u64, buf: []u8) Error!usize, write: *const fn (ctx: *anyopaque, h: Handle, offset: u64, data: []const u8) Error!usize, /// Returns the new node, already open with `mode`. @@ -117,7 +132,8 @@ pub const Provider = struct { }; }; -/// The Plan 9 error string for any error the engine or a provider can raise. +/// The Plan 9 error string for any error the tree or a provider can raise +/// (the engine's own refusals carry cloud9.fs's strings). pub fn ename(err: anyerror) []const u8 { return switch (err) { error.NotFound, error.NoFile => "file does not exist", @@ -144,10 +160,28 @@ pub fn ename(err: anyerror) []const u8 { error.WriteFailed => "no space in buffer", error.ReplyTooLarge => "reply too large for msize", error.OutOfMemory => "out of memory", + error.Again => "would block", else => "i/o error", }; } +/// The engine errno closest to an error, beside its `ename`. +fn errno(err: anyerror) u16 { + return switch (err) { + error.NotFound, error.NoFile => fs.E.NOENT, + error.Perm, error.Excl => fs.E.PERM, + error.Exists => fs.E.EXIST, + error.NotEmpty => fs.E.NOTEMPTY, + error.NotDir => fs.E.NOTDIR, + error.IsDir => fs.E.ISDIR, + error.BadOffset, error.Invalid, error.BadValue, error.BadCommand, error.BadName => fs.E.INVAL, + error.NoSpace, error.WriteFailed, error.NoSnapshot => fs.E.NOSPC, + error.Unsupported => fs.E.NOSYS, + error.OutOfMemory => fs.E.NOMEM, + else => fs.E.IO, + }; +} + /// A "don't care" Twstat: every field left as it is. pub const stat_dontcare: cloud9.Stat = .{ .type = 0xFFFF, @@ -380,24 +414,26 @@ pub fn Server(comptime cfg: Config) type { comptime { for (flat) |f| if (f.node.kind == .dynamic and f.node.gen == null) @compileError("dynamic node without generator"); - std.debug.assert(cfg.msize >= cloud9.Server.msize_min); - std.debug.assert(cfg.snapshot_slots > 0 and cfg.max_fids > 0); + std.debug.assert(cfg.msize >= fs.msize_min); + std.debug.assert(cfg.snapshot_slots > 0 and cfg.max_fids > 0 and cfg.max_parked > 0); // Provider index 0xFE/0xFF would collide with the var/static qid tags. std.debug.assert(cfg.max_providers < 0xFE); } - // -- qid paths ------------------------------------------------------ + // -- node ids and qid paths ------------------------------------------ const static_tag: u64 = 0xFF << 56; const var_tag: u64 = 0xFE << 56; const handle_mask: u64 = (1 << 56) - 1; + /// The engine's root: static node 0. + const root_id: u64 = static_tag; // -- storage -------------------------------------------------------- /// Per-connection buffers; the caller places one in static memory. pub const Storage = struct { in: [cfg.msize]u8, - out: [cfg.msize]u8, + out: [@max(cfg.msize, 2 * fs.msize_min)]u8, /// Staging area for read replies (directory records, provider and raw reads). data: [cfg.msize]u8, snapshots: [cfg.snapshot_slots][cfg.snapshot_bytes]u8, @@ -426,9 +462,9 @@ pub fn Server(comptime cfg: Config) type { /// Length of the current ctl result (in `ctl_bufs[ctl_cur]`). ctl_len: u32 = 0, ctl_version: u32 = 0, - /// Entropy for the per-connection fid hash. The core mixes in a + /// Entropy for the per-connection fid index. The core mixes in a /// connection counter and buffer addresses; a platform layer with a - /// random source may set this once after `init` to make the seed + /// random source may set this once after `init` to make the salt /// unpredictable even where addresses are static. hash_seed: u32 = 0, conn_seq: u32 = 0, @@ -486,47 +522,44 @@ pub fn Server(comptime cfg: Config) type { return null; } - // -- connection ----------------------------------------------------- + // -- the engine and its backend --------------------------------------- + /// What the engine knows of this tree: the contract types and the + /// capabilities it may ask for. The requests themselves are served + /// by `Conn.serve`. + pub const Backend = struct { + pub const Req = fs.Req; + pub const Reply = fs.Reply; + pub const features: fs.Features = .{ .create = true, .remove = true, .wstat = true, .references = true }; + }; + + /// The file-server engine this tree is a backend of: it owns the fid + /// table, permissions, directory cursors, Tflush and the releases a + /// dropped connection owes. Names are not kept per fid (`getattr` + /// answers them), so a fid costs the same whatever the name length. + pub const Engine = fs.Server(Backend, .{ + .fid_capacity = cfg.max_fids, + .slot_capacity = cfg.max_parked, + .park_data_max = 0, + .name_capacity = 0, + .fid_index = true, + }); + + /// A reference to a node of the tree: the engine's node id decoded. const NodeRef = union(enum) { static: u32, prov: struct { idx: u8, h: Provider.Handle }, @"var": struct { idx: u8, node: u32 }, }; - const Fid = struct { - id: u32 = 0, - used: bool = false, - node: NodeRef = .{ .static = 0 }, - is_dir: bool = true, - open: bool = false, - mode: u8 = 0, - rclose: bool = false, - dir_offset: u64 = 0, - dir_index: usize = 0, - /// Snapshot slot of an open dynamic file. - snap: ?u8 = null, - /// Free-list link, meaningful while `!used`. - next_free: u16 = no_slot, - }; - - const no_slot: u16 = std.math.maxInt(u16); - /// The fid index is an open-addressing (linear probing) table from fid - /// number to a slot of `Conn.fids`, sized to stay at most half full so - /// that lookups are O(1) with any number of fids. - const index_len: usize = std.math.ceilPowerOfTwoAssert(usize, @as(usize, cfg.max_fids) * 2); - const index_mask: usize = index_len - 1; - const index_shift: u5 = @intCast(32 - @as(usize, std.math.log2_int(usize, index_len))); - - /// MurmurHash3's 32-bit finalizer: every input bit affects every output bit. - fn fmix32(x: u32) u32 { - var h = x; - h ^= h >> 16; - h *%= 0x85EB_CA6B; - h ^= h >> 13; - h *%= 0xC2B2_AE35; - h ^= h >> 16; - return h; + fn nodeId(ref: NodeRef) u64 { + return switch (ref) { + .static => |idx| static_tag | idx, + .@"var" => |v| var_tag | (@as(u64, v.idx) << 32) | v.node, + // Provider ids are offset by one: the engine reads node 0 as + // "the node asked about", and provider 0's root would be 0. + .prov => |p| (@as(u64, p.idx + 1) << 56) | (p.h & handle_mask), + }; } /// Everything the engine needs to know about a node for qid/stat. @@ -537,240 +570,166 @@ pub fn Server(comptime cfg: Config) type { atime: u32, mtime: u32, version: u32, + /// The qid path. path: u64, name: []const u8, - fn qid(i: Info) cloud9.Qid { - var t: u8 = if (i.is_dir) cloud9.qtdir else cloud9.qtfile; - if (i.mode & cloud9.dmappend != 0) t |= cloud9.qtappend; - if (i.mode & cloud9.dmexcl != 0) t |= cloud9.qtexcl; - return .{ .type = t, .version = i.version, .path = i.path }; - } - - fn stat(i: Info) cloud9.Stat { + fn attr(i: Info, ref: NodeRef) fs.Attr { + const id = nodeId(ref); return .{ - .type = 0, - .dev = 0, - .qid = i.qid(), - .mode = i.mode, - .atime = i.atime, - .mtime = i.mtime, - .length = i.length, .name = i.name, - .uid = cfg.name, - .gid = cfg.name, - .muid = cfg.name, + .node = id, + .dir = i.is_dir, + .size = i.length, + .mode = @truncate(i.mode), + .mtime = i.mtime, + .atime = i.atime, + .version = i.version, + .path = if (i.path != id) i.path else null, + .append = i.mode & cloud9.dmappend != 0, + .excl = i.mode & cloud9.dmexcl != 0, }; } }; - /// One 9P connection: a cloud9.Server plus a fid table and snapshot slots. + /// A directory entry as the engine's readdir record wants it. + const Entry = struct { path: u64, dir: bool, name: []const u8 }; + + /// The engine's handle of an open file that holds no snapshot slot. + const no_snapshot: u32 = std.math.maxInt(u32); + + /// One 9P connection: the engine plus this tree's per-connection + /// state (snapshot slots). pub const Conn = struct { shared: *Shared, storage: *Storage, - server: cloud9.Server, + engine: Engine, /// Largest msize this connection negotiates. msize_cap: u32, - fids: [cfg.max_fids]Fid = @splat(.{}), - /// XORed into every fid number before hashing so that a client - /// cannot precompute fid numbers that collide (which would turn the - /// index back into a linear scan). - hash_seed: u32, - /// fid number -> slot of `fids` (`no_slot` = empty bucket). - index: [index_len]u16 = @splat(no_slot), - /// Head of the free list threaded through `Fid.next_free`. - free_head: u16 = no_slot, - /// Slots `high_water..` have never been used (bump allocation). - high_water: u16 = 0, - nfids: u16 = 0, slot_used: [cfg.snapshot_slots]bool = @splat(false), slot_len: [cfg.snapshot_slots]u32 = @splat(0), - name_buf: [max_name]u8 = undefined, pub fn init(shared: *Shared, storage: *Storage, msize: u32) Conn { shared.conn_seq +%= 1; const addr = @intFromPtr(storage) ^ (@intFromPtr(shared) << 7); - const seed = fmix32(shared.hash_seed ^ (shared.conn_seq *% 0x9E37_79B1) ^ @as(u32, @truncate(addr)) ^ @as(u32, @truncate(addr >> 16))); + const seed = shared.hash_seed ^ (shared.conn_seq *% 0x9E37_79B1) ^ @as(u32, @truncate(addr)) ^ @as(u32, @truncate(addr >> 16)); + const cap = @max(@min(msize, cfg.msize), fs.msize_min); return .{ .shared = shared, .storage = storage, - .server = .init(.{ .in = &storage.in, .out = &storage.out }), - .msize_cap = @max(@min(msize, cfg.msize), cloud9.Server.msize_min), - .hash_seed = seed, + .engine = .init(.{ .in = storage.in[0..cap], .out = &storage.out, .root = root_id, .seed = seed }), + .msize_cap = cap, }; } - /// Fibonacci hashing of the (seeded) fid number into `index_len` buckets. - fn fidHome(c: *const Conn, id: u32) usize { - return @intCast(((id ^ c.hash_seed) *% 0x9E37_79B1) >> index_shift); - } - /// Feeds transport bytes; returns how many were taken. pub fn push(c: *Conn, bytes: []const u8) usize { - return c.server.push(bytes); + return c.engine.push(bytes); } /// Bytes to send to the client. pub fn output(c: *const Conn) []const u8 { - return c.server.output(); + return c.engine.output(); } pub fn wrote(c: *Conn, n: usize) void { - c.server.wrote(n); + c.engine.wrote(n); + } + + /// Free space in the input buffer (one msize-sized frame at most). + pub fn inputRoom(c: *const Conn) usize { + return c.engine.protocol.in.len - c.engine.protocol.in_len; } /// Drops every fid (telling providers) and kills the session. pub fn hangup(c: *Conn) void { - c.resetFids(); - c.server.hangup(); + c.engine.hangup(); + while (c.engine.next()) |req| c.serve(req); } - /// Handles at most one request. Returns false when more input (or - /// output drainage) is needed. `error.Protocol` is terminal. + /// Serves at most one request of the engine (a 9P request is one + /// or more of them), after re-asking providers about every parked + /// read. Returns false when more input (or output drainage) is + /// needed. `error.Protocol` is terminal. pub fn step(c: *Conn) error{Protocol}!bool { - const req = (c.server.receive() catch return error.Protocol) orelse return false; - defer c.server.release(); - switch (req.msg) { - .tversion => |m| { - c.resetFids(); - c.server.negotiate(@min(m.msize, c.msize_cap), m.version) catch return error.Protocol; - }, - else => { - const reply = c.dispatch(req.msg) catch |e| cloud9.Msg{ .rerror = .{ .ename = ename(e) } }; - c.server.reply(req.tag, reply) catch |e| switch (e) { - // The reply does not fit the negotiated msize (Rstat or a long - // Rwalk at a tiny msize). receive() guarantees room for one - // msize-sized message, so this is never backpressure: answer with - // an Rerror (truncated to fit by cloud9). Rwalk, the only - // variable-size reply that follows a state change, is size-checked - // in walk() before anything is mutated. - error.TooLarge => c.server.reply(req.tag, .{ .rerror = .{ .ename = ename(error.ReplyTooLarge) } }) catch return error.Protocol, - else => return error.Protocol, - }; - }, - } + if (c.engine.protocol.dead) return error.Protocol; + while (c.engine.retry()) |req| c.serve(req); + const req = c.engine.next() orelse { + if (c.engine.protocol.dead) return error.Protocol; + return false; + }; + c.serve(req); + if (c.engine.protocol.dead) return error.Protocol; return true; } /// Number of fids currently held. pub fn fidCount(c: *const Conn) usize { - return c.nfids; + return c.engine.fidCount(); } - fn dispatch(c: *Conn, msg: cloud9.Msg) anyerror!cloud9.Msg { - return switch (msg) { - .tauth => error.AuthNotRequired, - .tattach => |m| c.attach(m), - .tflush => .rflush, - .twalk => |m| c.walk(m), - .topen => |m| c.open(m), - .tcreate => |m| c.create(m), - .tread => |m| c.read(m), - .twrite => |m| c.write(m), - .tclunk => |m| c.clunk(m), - .tremove => |m| c.remove(m), - .tstat => |m| c.stat(m), - .twstat => |m| c.wstat(m), - else => error.Protocol, - }; - } - - // -- fid table -- - - /// The index bucket holding `id`, if any. - fn findBucket(c: *const Conn, id: u32) ?usize { - var pos = c.fidHome(id); - while (true) : (pos = (pos + 1) & index_mask) { - const slot = c.index[pos]; - if (slot == no_slot) return null; - if (c.fids[slot].id == id) return pos; + /// The provider handle a fid holds, if it holds a provider node. + pub fn providerHandle(c: *const Conn, fid: u32) ?Provider.Handle { + for (c.engine.fids) |f| { + if (!f.used or f.orphan or f.fid != fid) continue; + return switch (c.refOf(f.node) orelse return null) { + .prov => |p| p.h, + else => null, + }; } + return null; } - fn findFid(c: *Conn, id: u32) ?*Fid { - const pos = c.findBucket(id) orelse return null; - return &c.fids[c.index[pos]]; - } - - fn allocFid(c: *Conn, id: u32) !*Fid { - if (c.findBucket(id) != null) return error.FidInUse; - if (c.nfids >= cfg.max_fids) return error.TooManyFids; - const slot: u16 = if (c.free_head != no_slot) blk: { - const slot = c.free_head; - c.free_head = c.fids[slot].next_free; - break :blk slot; - } else blk: { - const slot = c.high_water; - c.high_water += 1; - break :blk slot; - }; - c.fids[slot] = .{ .id = id, .used = true }; - var pos = c.fidHome(id); - while (c.index[pos] != no_slot) pos = (pos + 1) & index_mask; - c.index[pos] = slot; - c.nfids += 1; - return &c.fids[slot]; - } - - /// Removes `id` from the index (backward-shift deletion: no tombstones). - fn unlinkFid(c: *Conn, id: u32) void { - var i = c.findBucket(id).?; - var j = i; - while (true) { - j = (j + 1) & index_mask; - const slot = c.index[j]; - if (slot == no_slot) break; - const k = c.fidHome(c.fids[slot].id); - // The entry at j may move into the hole at i unless its home - // lies in the cyclic interval (i, j]. - const stays = if (i <= j) (k > i and k <= j) else (k > i or k <= j); - if (!stays) { - c.index[i] = slot; - i = j; - } - } - c.index[i] = no_slot; - } + // -- serving the engine -- - /// Releases everything a fid holds; the slot stays allocated. - fn dropContents(c: *Conn, f: *Fid) void { - if (f.snap) |s| c.slot_used[s] = false; - f.snap = null; - if (f.node == .prov) { - const p = c.shared.providers[f.node.prov.idx]; - if (f.open) if (p.vtable.close) |close| close(p.ctx, f.node.prov.h); - if (f.rclose and f.open) if (p.vtable.remove) |rm| rm(p.ctx, f.node.prov.h) catch {}; - p.vtable.clunk(p.ctx, f.node.prov.h); - } - f.open = false; - f.rclose = false; + fn serve(c: *Conn, req: fs.Req) void { + var bytes: []const u8 = ""; + const reply = c.dispatch(req, &bytes) catch |e| failing(req.tag, e); + c.engine.reply(&reply, bytes); } - fn freeFid(c: *Conn, f: *Fid) void { - c.dropContents(f); - c.unlinkFid(f.id); - const slot: u16 = @intCast((@intFromPtr(f) - @intFromPtr(&c.fids)) / @sizeOf(Fid)); - f.* = .{ .next_free = c.free_head }; - c.free_head = slot; - c.nfids -= 1; + fn failing(tag: u64, e: anyerror) fs.Reply { + if (e == error.Again) return .{ .tag = tag, .status = .again }; + return .{ .tag = tag, .status = .err, .errno = errno(e), .ename = ename(e) }; } - fn resetFids(c: *Conn) void { - for (c.fids[0..c.high_water]) |*f| { - if (f.used) c.dropContents(f); - f.* = .{}; + fn dispatch(c: *Conn, req: fs.Req, bytes: *[]const u8) anyerror!fs.Reply { + const ref = c.refOf(req.node) orelse return error.NotFound; + switch (req.op) { + .lookup => { + const l = try c.lookup(ref, req.data); + return .{ .tag = req.tag, .attr = l.info.attr(l.ref) }; + }, + .getattr => return .{ .tag = req.tag, .attr = (try c.info(ref)).attr(ref) }, + .setattr => return c.setattr(req, ref), + .open => return if (req.create) c.create(req, ref) else c.open(req, ref), + .read => return c.read(req, ref, bytes), + .readdir => return c.readDir(req, ref, bytes), + .write => return c.write(req, ref), + .release => return c.release(req, ref), } - @memset(&c.index, no_slot); - c.free_head = no_slot; - c.high_water = 0; - c.nfids = 0; } - /// Releases a provider handle that is not held by any fid. - fn releaseRef(c: *Conn, ref: NodeRef) void { - if (ref == .prov) { - const p = c.shared.providers[ref.prov.idx]; - p.vtable.clunk(p.ctx, ref.prov.h); + /// Decodes an engine node id; null for one this tree never issued. + fn refOf(c: *const Conn, id: u64) ?NodeRef { + const top: u8 = @intCast(id >> 56); + switch (top) { + 0xFF => { + const idx = id & handle_mask; + if (idx >= flat_len) return null; + return .{ .static = @intCast(idx) }; + }, + 0xFE => { + const vidx: u8 = @truncate(id >> 32); + const node: u32 = @truncate(id); + if ((id >> 40) & 0xFFFF != 0 or vidx >= c.shared.nvars) return null; + if (node >= c.shared.vars[vidx].vt.nodes.len) return null; + return .{ .@"var" = .{ .idx = vidx, .node = node } }; + }, + else => { + if (top == 0 or top - 1 >= c.shared.nprov) return null; + return .{ .prov = .{ .idx = top - 1, .h = id & handle_mask } }; + }, } } @@ -835,6 +794,10 @@ pub fn Server(comptime cfg: Config) type { } } + fn provPath(idx: u8, h: Provider.Handle, st: NodeStat) u64 { + return (@as(u64, idx) << 56) | ((if (st.path != 0) st.path else h) & handle_mask); + } + fn provInfo(pr: Provider, idx: u8, h: Provider.Handle, st: NodeStat) Info { return .{ .is_dir = st.isDir(), @@ -843,24 +806,23 @@ pub fn Server(comptime cfg: Config) type { .atime = st.atime, .mtime = st.mtime, .version = st.version, - .path = (@as(u64, idx) << 56) | ((if (st.path != 0) st.path else h) & handle_mask), + .path = provPath(idx, h, st), .name = if (h == Provider.root) pr.name else st.name, }; } - /// Copies `st.name` into the connection so the reply cannot dangle. - fn pinName(c: *Conn, st: cloud9.Stat) cloud9.Stat { - var out = st; - const n = @min(st.name.len, c.name_buf.len); - @memcpy(c.name_buf[0..n], st.name[0..n]); - out.name = c.name_buf[0..n]; - return out; + /// Releases a provider handle that no fid holds. + fn releaseRef(c: *Conn, ref: NodeRef) void { + if (ref == .prov) { + const p = c.shared.providers[ref.prov.idx]; + p.vtable.clunk(p.ctx, ref.prov.h); + } } const Looked = struct { ref: NodeRef, info: Info }; /// Resolves `name` in the directory `ref`. A returned provider ref - /// is a fresh handle the caller must release or retain. + /// is a fresh handle the engine will release exactly once. fn lookup(c: *Conn, ref: NodeRef, name: []const u8) !Looked { switch (ref) { .static => |idx| { @@ -909,34 +871,39 @@ pub fn Server(comptime cfg: Config) type { } } - /// The i-th entry of directory `ref` as a Stat, or null past the end. - /// The name borrows either static memory or the provider's NodeStat. - fn entryStat(c: *Conn, ref: NodeRef, i: usize) !?cloud9.Stat { + /// The i-th entry of directory `ref`, or null past the end. The + /// name borrows static memory or the provider's NodeStat. + fn entry(c: *Conn, ref: NodeRef, i: usize) !?Entry { switch (ref) { .static => |idx| { const f = flat[idx]; if (f.node.kind == .vars) { if (i >= c.shared.nvars) return null; - return (try c.info(.{ .@"var" = .{ .idx = @intCast(i), .node = 0 } })).stat(); + return .{ .path = var_tag | (@as(u64, i) << 32), .dir = true, .name = c.shared.vars[i].name }; + } + if (i < f.count) { + const ci = f.first + @as(u32, @intCast(i)); + return .{ .path = static_tag | ci, .dir = flat[ci].node.isDir(), .name = flat[ci].node.name }; } - if (i < f.count) return (try c.info(.{ .static = f.first + @as(u32, @intCast(i)) })).stat(); if (idx == 0) { const pi = i - f.count; if (pi >= c.shared.nprov) return null; - return (try c.info(.{ .prov = .{ .idx = @intCast(pi), .h = Provider.root } })).stat(); + return .{ .path = @as(u64, pi) << 56, .dir = true, .name = c.shared.providers[pi].name }; } return null; }, .@"var" => |v| { - const n = c.shared.vars[v.idx].vt.nodes[v.node]; + const vt = c.shared.vars[v.idx].vt; + const n = vt.nodes[v.node]; if (i >= n.count) return null; - return (try c.info(.{ .@"var" = .{ .idx = v.idx, .node = n.first + @as(u32, @intCast(i)) } })).stat(); + const ci = n.first + @as(u32, @intCast(i)); + return .{ .path = var_tag | (@as(u64, v.idx) << 32) | ci, .dir = vt.nodes[ci].isDir(), .name = vt.nodes[ci].name }; }, .prov => |p| { const pr = c.provider(p.idx); var st: NodeStat = .{ .mode = 0 }; if (!try pr.vtable.list(pr.ctx, p.h, i, &st)) return null; - return provInfo(pr, p.idx, st.handle, st).stat(); + return .{ .path = provPath(p.idx, st.handle, st), .dir = st.isDir(), .name = st.name }; }, } } @@ -951,12 +918,11 @@ pub fn Server(comptime cfg: Config) type { return error.NoSnapshot; } - /// (Re)generates the content of a dynamic file into its slot. - fn generate(c: *Conn, f: *Fid) !void { - const s = f.snap.?; + /// (Re)generates the content of a dynamic file into slot `s`. + fn generate(c: *Conn, ref: NodeRef, s: u8) !void { var w: Writer = .fixed(&c.storage.snapshots[s]); c.slot_len[s] = 0; - switch (f.node) { + switch (ref) { .static => |idx| try flat[idx].node.gen.?(c.shared.ctx, &w), .@"var" => |v| { const n = c.shared.vars[v.idx].vt.nodes[v.node]; @@ -983,162 +949,85 @@ pub fn Server(comptime cfg: Config) type { }; } - // -- request handlers -- - - fn attach(c: *Conn, m: anytype) !cloud9.Msg { - const f = try c.allocFid(m.fid); - f.node = .{ .static = 0 }; - f.is_dir = true; - return .{ .rattach = .{ .qid = (try c.info(f.node)).qid() } }; + fn snapshot(c: *Conn, handle: u32) []const u8 { + return c.storage.snapshots[handle][0..c.slot_len[handle]]; } - fn walk(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - if (m.newfid != m.fid and c.findFid(m.newfid) != null) return error.FidInUse; - if (m.newfid != m.fid and c.nfids >= cfg.max_fids) return error.TooManyFids; - if (m.nwname > 0 and f.open) return error.AlreadyOpen; - // Cloning a fid onto itself changes nothing; in particular it must not - // close an open fid or discard generated content. - if (m.nwname == 0 and m.newfid == m.fid) return .{ .rwalk = .{ .nwqid = 0 } }; - // A full Rwalk must fit the negotiated msize; check before binding anything. - if (cloud9.header_len + 2 + cloud9.qid_len * @as(usize, m.nwname) > c.server.msize) return error.ReplyTooLarge; - var cur = f.node; - var cur_is_dir = f.is_dir; - var held = false; // cur is a provider handle obtained here, not the fid's - var reply: cloud9.Msg = .{ .rwalk = .{ .nwqid = 0 } }; - const names = m.wname[0..m.nwname]; - for (names, 0..) |name, i| { - if (!cur_is_dir) { - if (i == 0) return error.NotDir; - break; - } - const next = c.lookup(cur, name) catch |e| { - if (i == 0) return e; - break; - }; - if (held) c.releaseRef(cur); - cur = next.ref; - cur_is_dir = next.info.is_dir; - held = cur == .prov; - reply.rwalk.wqid[i] = next.info.qid(); - reply.rwalk.nwqid += 1; - } - if (reply.rwalk.nwqid != names.len) { - if (held) c.releaseRef(cur); - return reply; - } - if (names.len == 0 and cur == .prov) { - // A clone of a provider handle needs its own reference. - const dup = try c.lookup(cur, "."); - cur = dup.ref; - cur_is_dir = dup.info.is_dir; - held = true; - } - const target = if (m.newfid == m.fid) f else c.allocFid(m.newfid) catch |e| { - if (held) c.releaseRef(cur); - return e; - }; - if (target == f) c.dropContents(f); - target.node = cur; - target.is_dir = cur_is_dir; - return reply; - } + // -- request handlers -- - fn open(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - if (f.open) return error.AlreadyOpen; - const acc = m.mode & 3; + fn open(c: *Conn, req: fs.Req, ref: NodeRef) !fs.Reply { + const mode = req.omode; + const acc = mode & 3; const want_write = acc == cloud9.owrite or acc == cloud9.ordwr; - const trunc = m.mode & cloud9.otrunc != 0; - if (f.is_dir and (want_write or trunc)) return error.IsDir; - switch (f.node) { + const trunc = mode & cloud9.otrunc != 0; + switch (ref) { .static => |idx| switch (flat[idx].node.kind) { .dir, .vars, .ctl => {}, .static, .dynamic => if (want_write or trunc) return error.Perm, }, .@"var" => |v| { const n = c.shared.vars[v.idx].vt.nodes[v.node]; - if ((want_write or trunc) and !n.writable()) return error.Perm; + if (n.isDir()) { + if (want_write or trunc) return error.IsDir; + } else if ((want_write or trunc) and !n.writable()) return error.Perm; }, .prov => |p| { const pr = c.provider(p.idx); - try pr.vtable.open(pr.ctx, p.h, m.mode); + try pr.vtable.open(pr.ctx, p.h, mode); }, } - const qid = (c.info(f.node) catch |e| { + const i = c.info(ref) catch |e| { // The provider's open succeeded but its stat did not: undo the open. - if (f.node == .prov) { - const pr = c.provider(f.node.prov.idx); - if (pr.vtable.close) |close| close(pr.ctx, f.node.prov.h); + if (ref == .prov) { + const pr = c.provider(ref.prov.idx); + if (pr.vtable.close) |close| close(pr.ctx, ref.prov.h); } return e; - }).qid(); - if (c.isDynamic(f.node)) { - f.snap = try c.takeSlot(); - c.generate(f) catch |e| { - c.slot_used[f.snap.?] = false; - f.snap = null; - if (f.node == .prov) unreachable; + }; + var handle: u32 = no_snapshot; + if (c.isDynamic(ref)) { + const s = try c.takeSlot(); + c.generate(ref, s) catch |e| { + c.slot_used[s] = false; return e; }; + handle = s; } - f.open = true; - f.mode = m.mode; - f.rclose = m.mode & cloud9.orclose != 0; - f.dir_offset = 0; - f.dir_index = 0; - return .{ .ropen = .{ .qid = qid, .iounit = 0 } }; + return .{ .tag = req.tag, .attr = i.attr(ref), .handle = handle }; } - fn create(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - if (f.open) return error.AlreadyOpen; - const p = switch (f.node) { + fn create(c: *Conn, req: fs.Req, ref: NodeRef) !fs.Reply { + const p = switch (ref) { .prov => |p| p, else => return error.Perm, }; - if (!f.is_dir) return error.NotDir; const pr = c.provider(p.idx); const create_fn = pr.vtable.create orelse return error.Perm; - try validName(m.name); - const is_dir = m.perm & cloud9.dmdir != 0; - const acc = m.mode & 3; - if (is_dir and (acc != cloud9.oread or m.mode & cloud9.otrunc != 0)) return error.IsDir; - const h = try create_fn(pr.ctx, p.h, m.name, m.perm, m.mode); + try validName(req.data); + const h = try create_fn(pr.ctx, p.h, req.data, req.perm, req.omode); const node: NodeRef = .{ .prov = .{ .idx = p.idx, .h = h } }; - const qid = (c.info(node) catch |e| { + const i = c.info(node) catch |e| { if (pr.vtable.close) |close| close(pr.ctx, h); pr.vtable.clunk(pr.ctx, h); return e; - }).qid(); - c.dropContents(f); - f.node = node; - f.is_dir = is_dir; - f.open = true; - f.mode = m.mode; - f.rclose = m.mode & cloud9.orclose != 0; - f.dir_offset = 0; - f.dir_index = 0; - return .{ .rcreate = .{ .qid = qid, .iounit = 0 } }; + }; + return .{ .tag = req.tag, .attr = i.attr(node), .handle = no_snapshot }; } - fn read(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - if (!f.open or (f.mode & 3) == cloud9.owrite) return error.NotOpen; - const count: usize = @min(m.count, c.server.msize -| cloud9.iohdrsz, c.storage.data.len); - if (f.is_dir) return c.readDir(f, m.offset, count); + fn read(c: *Conn, req: fs.Req, ref: NodeRef, bytes: *[]const u8) !fs.Reply { const data = &c.storage.data; - const src: []const u8 = switch (f.node) { + const count: usize = @min(req.size, data.len); + const src: []const u8 = switch (ref) { .static => |idx| blk: { const n = flat[idx].node; switch (n.kind) { .static => break :blk n.content, .ctl => break :blk c.shared.ctlResult(), .dynamic => { - if (m.offset == 0) try c.generate(f); - break :blk c.storage.snapshots[f.snap.?][0..c.slot_len[f.snap.?]]; + if (req.off == 0) try c.generate(ref, @intCast(req.handle)); + break :blk c.snapshot(req.handle); }, - .dir, .vars => unreachable, + .dir, .vars => return error.IsDir, } }, .@"var" => |v| blk: { @@ -1146,72 +1035,75 @@ pub fn Server(comptime cfg: Config) type { switch (n.kind) { .type_name, .size => break :blk n.content, .value, .addr => { - if (m.offset == 0) try c.generate(f); - break :blk c.storage.snapshots[f.snap.?][0..c.slot_len[f.snap.?]]; + if (req.off == 0) try c.generate(ref, @intCast(req.handle)); + break :blk c.snapshot(req.handle); }, .raw => break :blk c.varBase(v.idx, v.node)[0..n.size], - .dir, .fields => unreachable, + .dir, .fields => return error.IsDir, } }, .prov => |p| { const pr = c.provider(p.idx); - const n = try pr.vtable.read(pr.ctx, p.h, m.offset, data[0..count]); - return .{ .rread = .{ .data = data[0..@min(n, count)] } }; + const n = try pr.vtable.read(pr.ctx, p.h, req.off, data[0..count]); + bytes.* = data[0..@min(n, count)]; + return .{ .tag = req.tag }; }, }; - if (m.offset >= src.len) return .{ .rread = .{ .data = "" } }; - const off: usize = @intCast(m.offset); + if (req.off >= src.len) return .{ .tag = req.tag }; + const off: usize = @intCast(req.off); const n = @min(count, src.len - off); - if (f.node == .@"var" and c.shared.vars[f.node.@"var".idx].vt.nodes[f.node.@"var".node].kind == .raw) { + if (ref == .@"var" and c.shared.vars[ref.@"var".idx].vt.nodes[ref.@"var".node].kind == .raw) { // Copy out of the variable so the reply does not read live memory twice. @memcpy(data[0..n], src[off..][0..n]); - return .{ .rread = .{ .data = data[0..n] } }; + bytes.* = data[0..n]; + } else { + bytes.* = src[off..][0..n]; } - return .{ .rread = .{ .data = src[off..][0..n] } }; + return .{ .tag = req.tag }; } - fn readDir(c: *Conn, f: *Fid, offset: u64, count: usize) !cloud9.Msg { - if (offset == 0) { - f.dir_offset = 0; - f.dir_index = 0; - } else if (offset != f.dir_offset) return error.BadOffset; + /// Stages `node:u64le dir:u8 len:u8 name` records from entry + /// `req.off` on, about as many as the engine can fit in `req.size`. + fn readDir(c: *Conn, req: fs.Req, ref: NodeRef, bytes: *[]const u8) !fs.Reply { const data = &c.storage.data; - var used: usize = 0; - var i = f.dir_index; - while (try c.entryStat(f.node, i)) |st| : (i += 1) { - const rec = st.encode(data[used..count]) catch |e| switch (e) { - error.NoSpace => break, - else => return error.Io, - }; - used += rec.len; + const who = c.engine.uname_len; + var n: usize = 0; + var est: usize = 0; + var i: usize = @intCast(req.off); + while (est < req.size) : (i += 1) { + const e = (try c.entry(ref, i)) orelse break; + if (e.name.len > 255 or n + 10 + e.name.len > data.len) break; + std.mem.writeInt(u64, data[n..][0..8], e.path, .little); + data[n + 8] = @intFromBool(e.dir); + data[n + 9] = @intCast(e.name.len); + @memcpy(data[n + 10 ..][0..e.name.len], e.name); + n += 10 + e.name.len; + est += cloud9.stat_fixed + 2 + e.name.len + 3 * who; } - f.dir_offset += used; - f.dir_index = i; - return .{ .rread = .{ .data = data[0..used] } }; + bytes.* = data[0..n]; + return .{ .tag = req.tag }; } - fn write(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - const acc = f.mode & 3; - if (!f.open or (acc != cloud9.owrite and acc != cloud9.ordwr)) return error.NotOpen; - if (f.is_dir) return error.IsDir; - switch (f.node) { + fn write(c: *Conn, req: fs.Req, ref: NodeRef) !fs.Reply { + switch (ref) { .static => |idx| switch (flat[idx].node.kind) { - .ctl => try c.ctlCommand(m.data), + .ctl => try c.ctlCommand(req.data), + .dir, .vars => return error.IsDir, else => return error.Perm, }, .@"var" => |v| { const n = c.shared.vars[v.idx].vt.nodes[v.node]; + if (n.isDir()) return error.IsDir; const set = n.set orelse return error.Perm; - try set(c.varBase(v.idx, v.node), m.data); + try set(c.varBase(v.idx, v.node), req.data); }, .prov => |p| { const pr = c.provider(p.idx); - const n = try pr.vtable.write(pr.ctx, p.h, m.offset, m.data); - return .{ .rwrite = .{ .count = @intCast(@min(n, m.data.len)) } }; + const n = try pr.vtable.write(pr.ctx, p.h, req.off, req.data); + return .{ .tag = req.tag, .written = @intCast(@min(n, req.data.len)) }; }, } - return .{ .rwrite = .{ .count = @intCast(m.data.len) } }; + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// Runs `cfg.ctl`; on success its output becomes the ctl result. @@ -1227,60 +1119,45 @@ pub fn Server(comptime cfg: Config) type { s.ctl_version +%= 1; } - fn clunk(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - c.freeFid(f); - return .rclunk; - } - - fn remove(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - defer c.freeFid(f); // Tremove always clunks - f.rclose = false; - switch (f.node) { - .prov => |p| { - const pr = c.provider(p.idx); - const rm = pr.vtable.remove orelse return error.Perm; - try rm(pr.ctx, p.h); - }, - else => return error.Perm, - } - return .rremove; - } - - fn stat(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - return .{ .rstat = .{ .stat = c.pinName((try c.info(f.node)).stat()) } }; - } - - fn wstat(c: *Conn, m: anytype) !cloud9.Msg { - const f = c.findFid(m.fid) orelse return error.UnknownFid; - const p = switch (f.node) { + /// A wstat, or the truncation hint of an OTRUNC open (which the + /// provider's `open` performs itself; nothing to do here). + fn setattr(c: *Conn, req: fs.Req, ref: NodeRef) !fs.Reply { + if (!req.set.any()) return .{ .tag = req.tag }; + const p = switch (ref) { .prov => |p| p, else => return error.Perm, }; const pr = c.provider(p.idx); const ws = pr.vtable.wstat orelse return error.Perm; - const cur = try c.info(f.node); - const st = m.stat; - const q = cur.qid(); - // Fields we cannot change must be "don't care" or unchanged. - if (st.type != 0xFFFF and st.type != 0) return error.Perm; - if (st.dev != 0xFFFF_FFFF and st.dev != 0) return error.Perm; - if (st.qid.type != 0xFF and st.qid.type != q.type) return error.Perm; - if (st.qid.version != 0xFFFF_FFFF and st.qid.version != q.version) return error.Perm; - if (st.qid.path != 0xFFFF_FFFF_FFFF_FFFF and st.qid.path != q.path) return error.Perm; - if (st.uid.len != 0 and !std.mem.eql(u8, st.uid, cfg.name)) return error.Perm; - if (st.gid.len != 0 and !std.mem.eql(u8, st.gid, cfg.name)) return error.Perm; - if (st.muid.len != 0 and !std.mem.eql(u8, st.muid, cfg.name)) return error.Perm; - if (st.name.len != 0 and !std.mem.eql(u8, st.name, cur.name)) { - if (p.h == Provider.root) return error.Perm; - try validName(st.name); - } - if (st.length != 0xFFFF_FFFF_FFFF_FFFF and st.length != cur.length and cur.is_dir) return error.IsDir; - if (st.mode != 0xFFFF_FFFF and (st.mode & cloud9.dmdir) != (cur.mode & cloud9.dmdir)) return error.Perm; + if (req.set.name and p.h == Provider.root) return error.Perm; + var st = stat_dontcare; + if (req.set.name) st.name = req.data; + if (req.set.mode) st.mode = req.perm; + if (req.set.mtime) st.mtime = req.mtime; + if (req.set.length) st.length = req.length; try ws(pr.ctx, p.h, &st); - return .rwstat; + return .{ .tag = req.tag, .attr = (try c.info(ref)).attr(ref) }; + } + + /// The engine lets go of a reference: closes the open handle it + /// came with, removes the node on Tremove or ORCLOSE, and clunks + /// the provider handle exactly once. + fn release(c: *Conn, req: fs.Req, ref: NodeRef) !fs.Reply { + if (req.opened and req.handle != no_snapshot) c.slot_used[req.handle] = false; + switch (ref) { + .prov => |p| { + const pr = c.provider(p.idx); + if (req.opened) if (pr.vtable.close) |close| close(pr.ctx, p.h); + var result: anyerror!void = {}; + if (req.remove) { + if (pr.vtable.remove) |rm| result = rm(pr.ctx, p.h) else result = error.Perm; + } + pr.vtable.clunk(pr.ctx, p.h); + try result; + }, + else => if (req.remove) return error.Perm, + } + return .{ .tag = req.tag }; } }; @@ -1321,29 +1198,35 @@ pub fn Server(comptime cfg: Config) type { try testing.expectEqualStrings("9P2000", v.version.version); } - /// One round trip; the result borrows the client input buffer until the next call. - pub fn rpc(h: *Harness, req: cloud9.Client.Request) !cloud9.Client.Result { - _ = try h.client.submit(req); + /// Moves bytes both ways and steps the connection until nothing + /// moves; true when anything did. + pub fn pump(h: *Harness) !bool { + var moved = false; while (true) { - var moved = false; + var again = false; while (h.client.output().len > 0) { const k = h.conn.push(h.client.output()); - h.client.wrote(k); - moved = moved or k > 0; - while (try h.conn.step()) {} - while (h.conn.output().len > 0) { - const n = h.client.push(h.conn.output()); - h.conn.wrote(n); - moved = moved or n > 0; - } if (k == 0) break; + h.client.wrote(k); + again = true; } - while (try h.conn.step()) {} + while (try h.conn.step()) again = true; while (h.conn.output().len > 0) { const n = h.client.push(h.conn.output()); + if (n == 0) break; h.conn.wrote(n); - moved = moved or n > 0; + again = true; } + if (!again) return moved; + moved = true; + } + } + + /// One round trip; the result borrows the client input buffer until the next call. + pub fn rpc(h: *Harness, req: cloud9.Client.Request) !cloud9.Client.Result { + _ = try h.client.submit(req); + while (true) { + const moved = try h.pump(); if (h.client.take()) |done| return done.result; if (!moved) return error.Stuck; } @@ -1449,6 +1332,21 @@ pub fn Server(comptime cfg: Config) type { const testing = std.testing; +// The engine's own Rerror strings, for the conditions it decides itself. +const e_unknown_fid = fs.e_unknown_fid; +const e_fid_in_use = fs.e_fid_in_use; +const e_too_many_fids = fs.e_too_many_fids; +const e_bad_use = fs.e_bad_use; +const e_already_open = fs.e_already_open; +const e_bad_offset = fs.e_bad_offset; +const e_perm = fs.e_perm; +const e_not_dir = fs.e_not_dir; +const e_wstat = fs.e_wstat; +const e_illegal_name = fs.e_illegal_name; +const e_small_msize = fs.e_small_msize; +const e_count_small = fs.e_count_small; +const e_interrupted = fs.e_interrupted; + const TestBuild = struct { pub const zig_version: []const u8 = builtin.zig_version_string; pub const target: []const u8 = "test-target"; @@ -1537,12 +1435,20 @@ const test_cfg: Config = .{ .max_vars = 4, .snapshot_slots = 2, .snapshot_bytes = 512, + .max_parked = 2, }; const TS = Server(test_cfg); +/// Whether `fid` is open, read from the engine's table. +fn isOpen(c: *const TS.Conn, fid: u32) bool { + for (c.engine.fids) |f| if (f.used and !f.orphan and f.fid == fid) return f.open; + return false; +} + /// A small in-memory provider: /prov/{hello,dir/{inner}} with create/remove/wstat, -/// counting every handle reference so tests can check clunk discipline. +/// counting every handle reference so tests can check clunk discipline. A +/// read of `hello` parks (error.Again) while `park` is set. const TestProv = struct { const max_nodes = 16; const Entry = struct { @@ -1565,8 +1471,10 @@ const TestProv = struct { nodes: [max_nodes]Entry = @splat(.{}), total_refs: u32 = 0, clunks: u32 = 0, + reads: u32 = 0, fail_io: bool = false, fail_stat: bool = false, + park: bool = false, fn init() TestProv { var p: TestProv = .{}; @@ -1664,6 +1572,8 @@ const TestProv = struct { fn read(ctx: *anyopaque, h: Provider.Handle, offset: u64, buf: []u8) Provider.Error!usize { const p = self(ctx); const e = try p.node(h); + p.reads += 1; + if (p.park and h == 1) return error.Again; if (offset >= e.len) return 0; const n = @min(buf.len, e.len - @as(usize, @intCast(offset))); @memcpy(buf[0..n], e.data[@intCast(offset)..][0..n]); @@ -1799,16 +1709,21 @@ test "README, /build and the static tree read as expected" { try testing.expectEqual(@as(usize, 7), names.len); // static files are read-only; the static tree admits no creates or removes try x.h.walkTo(1, &.{ "build", "target" }); - try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.owrite } }, "permission denied"); + try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.owrite } }, e_perm); try x.h.expectFail(.{ .remove = .{ .fid = 1 } }, "permission denied"); try x.h.walkTo(2, &.{"build"}); - try x.h.expectFail(.{ .create = .{ .fid = 2, .name = "nope", .perm = 0o644, .mode = cloud9.owrite } }, "permission denied"); - try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = stat_dontcare } }, "permission denied"); + try x.h.expectFail(.{ .create = .{ .fid = 2, .name = "nope", .perm = 0o644, .mode = cloud9.owrite } }, e_perm); + // a wstat that changes nothing is answered by the engine without asking + _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = stat_dontcare } }); + var ws = stat_dontcare; + ws.mtime = 5; + try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "permission denied"); const st = try x.h.ok(.{ .stat = .{ .fid = 2 } }); try testing.expectEqualStrings("build", st.stat.name); try testing.expectEqualStrings("tester", st.stat.uid); try testing.expect(st.stat.qid.type & cloud9.qtdir != 0); try testing.expectEqual(TS.build_secs, st.stat.mtime); + try testing.expectEqual(TS.build_secs, st.stat.atime); try testing.expectEqual(@as(u32, 0), parseIso8601("1970-01-01T00:00:00Z").?); try testing.expectEqual(@as(?u32, null), parseIso8601("unknown")); } @@ -1873,7 +1788,7 @@ test "runtime/fn calls the function at open and at each read from offset 0" { try x.h.walkTo(4, &.{ "runtime", "fn", "fib30" }); _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } }); _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } }); - _ = try x.h.ok(.{ .walk = .{ .fid = 4, .newfid = 5, .names = &.{} } }); + try x.h.walkTo(5, &.{ "runtime", "fn", "fib30" }); _ = try x.h.ok(.{ .open = .{ .fid = 5, .mode = cloud9.oread } }); } @@ -1895,14 +1810,13 @@ test "snapshot slot exhaustion is an Rerror and clunk frees the slot" { _ = try x.h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }); const r = try x.h.ok(.{ .read = .{ .fid = 3, .offset = 0, .count = 100 } }); try testing.expectEqualStrings("7", r.read); - // cloning an open fid onto itself keeps it open and its content - const w = try x.h.ok(.{ .walk = .{ .fid = 3, .newfid = 3, .names = &.{} } }); - try testing.expectEqual(@as(u16, 0), w.walk.nwqid); + // an open fid cannot be walked from, not even cloned, and stays open + try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 3, .names = &.{} } }, e_bad_use); + try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{} } }, e_bad_use); + try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{".."} } }, e_bad_use); const r2 = try x.h.ok(.{ .read = .{ .fid = 3, .offset = 0, .count = 100 } }); try testing.expectEqualStrings("7", r2.read); - try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{".."} } }, "file already open"); - _ = try x.h.ok(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{} } }); - try x.h.expectFail(.{ .read = .{ .fid = 4, .offset = 0, .count = 100 } }, "file not open"); + try x.h.expectFail(.{ .read = .{ .fid = 4, .offset = 0, .count = 100 } }, e_unknown_fid); } test "ctl round trip" { @@ -1946,7 +1860,7 @@ test "ctl round trip" { try testing.expectEqual(@as(u64, 0), (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.length); // Tversion resets the ctl fid like any other try x.h.version(4096); - try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, e_unknown_fid); } test "auth is not required and flush is answered" { @@ -1956,7 +1870,7 @@ test "auth is not required and flush is answered" { try x.h.expectFail(.{ .auth = .{ .afid = 5, .uname = "tester" } }, "authentication not required"); const f = try x.h.ok(.{ .flush = .{ .oldtag = 1 } }); try testing.expect(f == .flush); - try x.h.expectFail(.{ .attach = .{ .fid = 0, .uname = "tester" } }, "fid in use"); + try x.h.expectFail(.{ .attach = .{ .fid = 0, .uname = "tester" } }, e_fid_in_use); } test "vars: value/type/size/addr/raw, fields and writes" { @@ -2027,24 +1941,24 @@ test "vars: value/type/size/addr/raw, fields and writes" { try testing.expectEqualStrings("value", a_st.stat.name); // non-scalar values, type/size/addr/raw and directories are read-only try x.h.walkTo(3, &.{ "vars", "state", "value" }); - try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.owrite } }, "permission denied"); + try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.owrite } }, e_perm); _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } }); try x.h.walkTo(4, &.{ "vars", "state", "f", "name", "value" }); - try x.h.expectFail(.{ .open = .{ .fid = 4, .mode = cloud9.owrite } }, "permission denied"); + try x.h.expectFail(.{ .open = .{ .fid = 4, .mode = cloud9.owrite } }, e_perm); _ = try x.h.ok(.{ .clunk = .{ .fid = 4 } }); try x.h.walkTo(5, &.{ "vars", "state" }); - try x.h.expectFail(.{ .open = .{ .fid = 5, .mode = cloud9.owrite } }, "is a directory"); - try x.h.expectFail(.{ .create = .{ .fid = 5, .name = "z", .perm = 0o644, .mode = cloud9.owrite } }, "permission denied"); + try x.h.expectFail(.{ .open = .{ .fid = 5, .mode = cloud9.owrite } }, e_perm); + try x.h.expectFail(.{ .create = .{ .fid = 5, .name = "z", .perm = 0o644, .mode = cloud9.owrite } }, e_perm); // .. climbs back out of the var tree; unknown names fail const up = try x.h.ok(.{ .walk = .{ .fid = 5, .newfid = 6, .names = &.{ "f", "inner", "..", "..", "..", "..", "README" } } }); try testing.expectEqual(@as(u16, 7), up.walk.nwqid); _ = try x.h.ok(.{ .clunk = .{ .fid = 6 } }); try x.h.walkTo(7, &.{"vars"}); try x.h.expectFail(.{ .walk = .{ .fid = 7, .newfid = 8, .names = &.{"nope"} } }, "file does not exist"); - try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 8, .names = &.{"x"} } }, "file already open"); + try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 8, .names = &.{"x"} } }, e_bad_use); _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } }); try x.h.walkTo(2, &.{ "vars", "state", "f", "a", "value" }); - try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 8, .names = &.{"x"} } }, "not a directory"); + try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 8, .names = &.{"x"} } }, e_not_dir); } test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and error mapping" { @@ -2083,13 +1997,13 @@ test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and err _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } }); try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].opens); try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].refs); - // permission and kind errors come from the provider + // permission and kind errors: the engine's from the walked mode, the provider's as its own strings try x.h.walkTo(3, &.{ "prov", "locked" }); - try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "permission denied"); + try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, e_perm); try x.h.walkTo(4, &.{ "prov", "hello" }); - try x.h.expectFail(.{ .walk = .{ .fid = 4, .newfid = 5, .names = &.{"x"} } }, "not a directory"); + try x.h.expectFail(.{ .walk = .{ .fid = 4, .newfid = 5, .names = &.{"x"} } }, e_not_dir); try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 5, .names = &.{"missing"} } }, "file does not exist"); - try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.owrite } }, "is a directory"); + try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.owrite } }, e_perm); // create in a provider directory: the fid becomes the new open file const cr = try x.h.ok(.{ .create = .{ .fid = 2, .name = "new", .perm = 0o644, .mode = cloud9.ordwr } }); try testing.expectEqual(cloud9.qtfile, cr.create.qid.type); @@ -2099,11 +2013,11 @@ test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and err try x.h.walkTo(6, &.{"prov"}); try x.h.expectFail(.{ .create = .{ .fid = 6, .name = "new", .perm = 0o644, .mode = cloud9.oread } }, "file already exists"); const long_name = [_]u8{'n'} ** (max_name + 1); - try x.h.expectFail(.{ .create = .{ .fid = 6, .name = &long_name, .perm = 0o644, .mode = cloud9.oread } }, "bad file name"); - try x.h.expectFail(.{ .create = .{ .fid = 6, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.owrite } }, "is a directory"); + try x.h.expectFail(.{ .create = .{ .fid = 6, .name = &long_name, .perm = 0o644, .mode = cloud9.oread } }, e_illegal_name); + try x.h.expectFail(.{ .create = .{ .fid = 6, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.owrite } }, e_perm); const dr = try x.h.ok(.{ .create = .{ .fid = 6, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.oread } }); try testing.expectEqual(cloud9.qtdir, dr.create.qid.type); - // wstat: rename, truncate, mode, mtime; immutable fields are refused + // wstat: rename, truncate, mode, mtime; immutable fields are refused by the engine var ws = stat_dontcare; ws.name = "renamed"; ws.length = 2; @@ -2117,19 +2031,19 @@ test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and err try testing.expectEqual(@as(u32, 99), st2.stat.mtime); ws = stat_dontcare; ws.uid = "someone-else"; - try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "permission denied"); + try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, e_wstat); ws = stat_dontcare; ws.mode = cloud9.dmdir | 0o755; - try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "permission denied"); + try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, e_wstat); ws = stat_dontcare; ws.name = "bad/name"; - try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "bad file name"); + try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, e_illegal_name); ws.name = ".."; - try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "bad file name"); + try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, e_illegal_name); ws = stat_dontcare; ws.length = 5; try x.h.walkTo(7, &.{ "prov", "d" }); - try x.h.expectFail(.{ .wstat = .{ .fid = 7, .stat = ws } }, "is a directory"); + try x.h.expectFail(.{ .wstat = .{ .fid = 7, .stat = ws } }, e_wstat); ws = stat_dontcare; ws.name = "root2"; // the provider root cannot be renamed try x.h.walkTo(14, &.{"prov"}); @@ -2139,7 +2053,7 @@ test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and err _ = try x.h.ok(.{ .clunk = .{ .fid = 6 } }); try x.h.walkTo(8, &.{ "prov", "dir" }); try x.h.expectFail(.{ .remove = .{ .fid = 8 } }, "directory not empty"); - try x.h.expectFail(.{ .clunk = .{ .fid = 8 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 8 } }, e_unknown_fid); _ = try x.h.ok(.{ .remove = .{ .fid = 2 } }); try x.h.walkTo(9, &.{"prov"}); try x.h.expectFail(.{ .walk = .{ .fid = 9, .newfid = 15, .names = &.{"renamed"} } }, "file does not exist"); @@ -2168,10 +2082,10 @@ test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and err // a partial walk releases the handles it obtained const part = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 13, .names = &.{ "prov", "dir", "nope" } } }); try testing.expectEqual(@as(u16, 2), part.walk.nwqid); - try x.h.expectFail(.{ .clunk = .{ .fid = 13 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 13 } }, e_unknown_fid); _ = try x.h.ok(.{ .clunk = .{ .fid = 12 } }); - for (x.h.conn.fids) |f| { - if (f.used) _ = try x.h.ok(.{ .clunk = .{ .fid = f.id } }); + for (x.h.conn.engine.fids) |f| { + if (f.used and !f.orphan) _ = try x.h.ok(.{ .clunk = .{ .fid = f.fid } }); } try testing.expectEqual(@as(u32, 0), x.prov.total_refs); } @@ -2184,7 +2098,7 @@ test "directory reads across offsets, bad offset, and records never split" { _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }); const first = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 4096 } }); try testing.expect(first.read.len > 0); - try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 5, .count = 4096 } }, "bad offset"); + try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 5, .count = 4096 } }, e_bad_offset); // offset 0 restarts; the same bytes come back const again = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 4096 } }); try testing.expectEqual(first.read.len, again.read.len); @@ -2192,9 +2106,8 @@ test "directory reads across offsets, bad offset, and records never split" { const names = try x.h.listDir(1, 80); defer TS.Harness.freeNames(names); try testing.expectEqual(@as(usize, 7), names.len); - // a count too small for even one record returns nothing rather than splitting it - const tiny = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }); - try testing.expectEqual(@as(usize, 0), tiny.read.len); + // a count too small for even one record is refused rather than splitting it + try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, e_count_small); // the same for provider and var directories const pn = try x.h.listPath(&.{ "prov", "dir" }); defer TS.Harness.freeNames(pn); @@ -2204,7 +2117,7 @@ test "directory reads across offsets, bad offset, and records never split" { const vn = try x.h.listDir(2, 100); defer TS.Harness.freeNames(vn); try testing.expectEqual(@as(usize, 4), vn.len); - try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 1, .count = 100 } }, "bad offset"); + try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 1, .count = 100 } }, e_bad_offset); } test "Tversion mid-session resets fids and clunks every provider handle" { @@ -2226,7 +2139,7 @@ test "Tversion mid-session resets fids and clunks every provider handle" { try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].opens); try testing.expectEqual(before + 2, x.prov.clunks); try testing.expect(!x.h.conn.slot_used[0] and !x.h.conn.slot_used[1]); - try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, e_unknown_fid); _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } }); try x.h.walkTo(1, &.{ "prov", "hello" }); // hangup does the same @@ -2235,24 +2148,34 @@ test "Tversion mid-session resets fids and clunks every provider handle" { try testing.expectEqual(@as(usize, 0), x.h.conn.fidCount()); } -test "a reply that does not fit msize is an Rerror, not a dead connection" { +test "msize: below the engine's floor the connection dies; at the floor everything that fits is served" { var x: Fixture = .{}; try x.init(); defer x.deinit(); - try x.h.version(64); - _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "t" } }); - // Rstat of the root is ~70 bytes. - try x.h.expectFail(.{ .stat = .{ .fid = 0 } }, "reply too large for msize"); - // Rwalk with 5 qids is 74 bytes; the walk must not bind newfid. - try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ ".", ".", ".", ".", "." } } }, "reply too large for msize"); - try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, "unknown fid"); - try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 0, .names = &.{ ".", ".", ".", ".", "." } } }, "reply too large for msize"); - const r = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"README"} } }); - try testing.expectEqual(@as(u16, 1), r.walk.nwqid); - _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }); - const rd = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 40 } }); - try testing.expect(rd.read.len > 0 and rd.read.len <= 64 - cloud9.iohdrsz); - _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } }); + try testing.expectError(error.Protocol, x.h.version(64)); + + var y: Fixture = .{}; + try y.init(); + defer y.deinit(); + try y.h.version(fs.msize_min); + _ = try y.h.ok(.{ .attach = .{ .fid = 0, .uname = "t" } }); + const st = try y.h.ok(.{ .stat = .{ .fid = 0 } }); + try testing.expectEqualStrings("/", st.stat.name); + // A full 16-element Rwalk is exactly msize_min. + const w = try y.h.ok(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &([_][]const u8{"."} ** 16) } }); + try testing.expectEqual(@as(u16, 16), w.walk.nwqid); + _ = try y.h.ok(.{ .clunk = .{ .fid = 1 } }); + // An Rstat that cannot fit is an Rerror, not a dead connection. + try y.h.walkTo(2, &.{"prov"}); + const long_name = [_]u8{'n'} ** 180; // Tcreate fits; the Rstat (61 + 180 bytes) does not + _ = try y.h.ok(.{ .create = .{ .fid = 2, .name = &long_name, .perm = 0o644, .mode = cloud9.oread } }); + try y.h.expectFail(.{ .stat = .{ .fid = 2 } }, e_small_msize); + _ = try y.h.ok(.{ .remove = .{ .fid = 2 } }); + try y.h.walkTo(1, &.{"README"}); + _ = try y.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }); + const rd = try y.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = fs.msize_min - cloud9.iohdrsz } }); + try testing.expect(rd.read.len > 0 and rd.read.len <= fs.msize_min - cloud9.iohdrsz); + _ = try y.h.ok(.{ .clunk = .{ .fid = 1 } }); } test "fid table is bounded per connection" { @@ -2263,18 +2186,18 @@ test "fid table is bounded per connection" { while (x.h.conn.fidCount() < test_cfg.max_fids) : (i += 1) { _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } }); } - try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } }, "too many fids"); - try x.h.expectFail(.{ .attach = .{ .fid = i, .uname = "tester" } }, "too many fids"); + try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } }, e_too_many_fids); + try x.h.expectFail(.{ .attach = .{ .fid = i, .uname = "tester" } }, e_too_many_fids); // self-walks and clunks still work at the limit _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 0, .names = &.{"build"} } }); _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } }); _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } }); - try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{"README"} } }, "fid in use"); - try x.h.expectFail(.{ .walk = .{ .fid = 1234, .newfid = 2, .names = &.{} } }, "unknown fid"); + try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{"README"} } }, e_fid_in_use); + try x.h.expectFail(.{ .walk = .{ .fid = 1234, .newfid = 2, .names = &.{} } }, e_unknown_fid); // a walk into a provider at the limit must not leak the handle _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } }); _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{ "..", "prov", "hello" } } }); - try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = i + 1, .names = &.{} } }, "too many fids"); + try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = i + 1, .names = &.{} } }, e_too_many_fids); try testing.expectEqual(@as(u32, 1), x.prov.total_refs); } @@ -2295,7 +2218,7 @@ test "Shared refuses more providers or vars than configured" { try testing.expectError(error.Full, shared.expose("v4", &v[4])); } -/// A server with a large fid table for the index tests. +/// A server with a large fid table for the churn test. const big_cfg: Config = .{ .name = "big", .msize = 8192, @@ -2307,8 +2230,8 @@ const big_cfg: Config = .{ }; const BigS = Server(big_cfg); -/// Fid numbers chosen to stress the index: dense low ids, ids with only high -/// bits set, and ids counting down from 2^32-1 (all distinct for i < 2^20). +/// Fid numbers chosen to stress the engine's index: dense low ids, ids with +/// only high bits set, and ids counting down from 2^32-1 (all distinct for i < 2^20). fn adversarialId(i: u32) u32 { return switch (i % 3) { 0 => i * 8192 + 1, @@ -2317,34 +2240,15 @@ fn adversarialId(i: u32) u32 { }; } -/// Every index bucket points at a used fid that finds itself, and every used -/// fid is found: the invariant the hostile fid tests check after each phase. -fn checkFidIndex(c: *BigS.Conn) !void { - var indexed: usize = 0; - for (c.index) |slot| { - if (slot == BigS.no_slot) continue; - indexed += 1; - try testing.expect(c.fids[slot].used); - try testing.expectEqual(&c.fids[slot], c.findFid(c.fids[slot].id).?); - } - var used: usize = 0; - for (c.fids[0..c.high_water]) |*f| if (f.used) { - used += 1; - try testing.expectEqual(f, c.findFid(f.id).?); - }; - for (c.fids[c.high_water..]) |*f| try testing.expect(!f.used); - try testing.expectEqual(indexed, used); - try testing.expectEqual(used, c.nfids); -} - -test "fid index: thousands of fids, clunk in hostile orders, reuse, Tversion" { +test "fid table: thousands of fids, clunk in hostile orders, reuse, Tversion" { var ctx: TestCtx = .{}; var shared: BigS.Shared = .init(&ctx); var prov = TestProv.init(); try shared.addProvider(prov.provider()); const storage = try testing.allocator.create(BigS.Storage); defer testing.allocator.destroy(storage); - var h: BigS.Harness = undefined; + const h = try testing.allocator.create(BigS.Harness); + defer testing.allocator.destroy(h); try h.init(&shared, storage); defer h.deinit(); const n: u32 = big_cfg.max_fids - 1; // fid 0 is the attach @@ -2354,34 +2258,30 @@ test "fid index: thousands of fids, clunk in hostile orders, reuse, Tversion" { } try testing.expectEqual(@as(usize, n + 1), h.conn.fidCount()); try testing.expectEqual(n, prov.total_refs); - try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 0x7FFF_FFFF, .names = &.{} } }, "too many fids"); - try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = adversarialId(5), .names = &.{} } }, "fid in use"); - try h.expectFail(.{ .attach = .{ .fid = adversarialId(7), .uname = "t" } }, "fid in use"); - try testing.expect(h.conn.findFid(0x7FFF_FFFF) == null); - try testing.expect(h.conn.findFid(adversarialId(n)) == null); - try checkFidIndex(&h.conn); - // clunk every third fid, then the rest from the top: backward-shift deletion under churn + try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 0x7FFF_FFFF, .names = &.{} } }, e_too_many_fids); + try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = adversarialId(5), .names = &.{} } }, e_fid_in_use); + try h.expectFail(.{ .attach = .{ .fid = adversarialId(7), .uname = "t" } }, e_fid_in_use); + try h.expectFail(.{ .clunk = .{ .fid = 0x7FFF_FFFF } }, e_unknown_fid); + try h.expectFail(.{ .clunk = .{ .fid = adversarialId(n) } }, e_unknown_fid); + // clunk every third fid, then the rest from the top i = 0; while (i < n) : (i += 3) _ = try h.ok(.{ .clunk = .{ .fid = adversarialId(i) } }); - try checkFidIndex(&h.conn); i = n; while (i > 0) { i -= 1; if (i % 3 == 0) { - try h.expectFail(.{ .clunk = .{ .fid = adversarialId(i) } }, "unknown fid"); + try h.expectFail(.{ .clunk = .{ .fid = adversarialId(i) } }, e_unknown_fid); } else { _ = try h.ok(.{ .clunk = .{ .fid = adversarialId(i) } }); } } try testing.expectEqual(@as(usize, 1), h.conn.fidCount()); try testing.expectEqual(@as(u32, 0), prov.total_refs); - try checkFidIndex(&h.conn); - // the whole table is reusable after the churn, through the free list + // the whole table is reusable after the churn i = 0; while (i < n) : (i += 1) _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = n - i, .names = &.{} } }); - try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = n + 1, .names = &.{} } }, "too many fids"); - try checkFidIndex(&h.conn); - // pseudo-random alloc/free storm with verification + try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = n + 1, .names = &.{} } }, e_too_many_fids); + // pseudo-random alloc/free storm var prng = std.Random.DefaultPrng.init(0x9a11); const rnd = prng.random(); var live: [n + 1]bool = @splat(true); @@ -2395,18 +2295,17 @@ test "fid index: thousands of fids, clunk in hostile orders, reuse, Tversion" { _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = id, .names = &.{"prov"} } }); } live[id] = !live[id]; - if (round % 997 == 0) try checkFidIndex(&h.conn); } - try checkFidIndex(&h.conn); + var expected: usize = 1; + for (live) |l| expected += @intFromBool(l); + try testing.expectEqual(expected, h.conn.fidCount()); // Tversion drops everything and the table starts over, provider refs balanced try h.version(big_cfg.msize); try testing.expectEqual(@as(usize, 0), h.conn.fidCount()); try testing.expectEqual(@as(u32, 0), prov.total_refs); - try testing.expectEqual(@as(u16, 0), h.conn.high_water); - try checkFidIndex(&h.conn); _ = try h.ok(.{ .attach = .{ .fid = 0xFFFF_FFFE, .uname = "t" } }); _ = try h.ok(.{ .walk = .{ .fid = 0xFFFF_FFFE, .newfid = 0, .names = &.{} } }); - try checkFidIndex(&h.conn); + try testing.expectEqual(@as(usize, 2), h.conn.fidCount()); } test "open: a provider stat failure after a successful open closes the file again" { @@ -2418,7 +2317,7 @@ test "open: a provider stat failure after a successful open closes the file agai try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, "i/o error"); x.prov.fail_stat = false; try testing.expectEqual(@as(u32, 0), x.prov.nodes[1].opens); - try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, "file not open"); + try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, e_bad_use); _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }); try testing.expectEqual(@as(u32, 1), x.prov.nodes[1].opens); // the same for create: a stat failure after the provider created the node releases it @@ -2430,7 +2329,7 @@ test "open: a provider stat failure after a successful open closes the file agai try testing.expectEqual(@as(u32, 0), e.opens); try testing.expectEqual(@as(u32, 0), e.refs); }; - try testing.expect(!x.h.conn.findFid(2).?.open); + try testing.expect(!isOpen(&x.h.conn, 2)); try testing.expectEqual(@as(u32, 1), x.prov.total_refs); // fid 1 only } @@ -2440,11 +2339,12 @@ test "fid state machine: open twice, walk from open, remove/clunk of open provid defer x.deinit(); try x.h.walkTo(1, &.{ "prov", "dir", "inner" }); _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } }); - try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, "file already open"); - try x.h.expectFail(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{"."} } }, "file already open"); - try x.h.expectFail(.{ .create = .{ .fid = 1, .name = "z", .perm = 0o644, .mode = cloud9.oread } }, "file already open"); - // a clone of an open fid is a fresh, unopened reference - _ = try x.h.ok(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{} } }); + try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, e_already_open); + try x.h.expectFail(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{"."} } }, e_bad_use); + try x.h.expectFail(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{} } }, e_bad_use); + try x.h.expectFail(.{ .create = .{ .fid = 1, .name = "z", .perm = 0o644, .mode = cloud9.oread } }, e_already_open); + // a second, unopened reference to the same node + try x.h.walkTo(2, &.{ "prov", "dir", "inner" }); try testing.expectEqual(@as(u32, 2), x.prov.nodes[3].refs); try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].opens); // walking newfid == fid with names on an unopened provider fid swaps the handle, refs balanced @@ -2459,24 +2359,24 @@ test "fid state machine: open twice, walk from open, remove/clunk of open provid _ = try x.h.ok(.{ .remove = .{ .fid = 1 } }); try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].opens); try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].refs); - try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, "unknown fid"); + try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, e_unknown_fid); _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } }); try testing.expectEqual(@as(u32, 0), x.prov.total_refs); // walking "." on a file fid is "not a directory" at the protocol level, without a provider walk try x.h.walkTo(3, &.{ "prov", "hello" }); const before = x.prov.clunks; - try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"."} } }, "not a directory"); + try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"."} } }, e_not_dir); try testing.expectEqual(before, x.prov.clunks); try testing.expectEqual(@as(u32, 1), x.prov.total_refs); // a partial walk through a file releases the handles it took const part = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 5, .names = &.{ "prov", "hello", "x", "y" } } }); try testing.expectEqual(@as(u16, 2), part.walk.nwqid); try testing.expectEqual(@as(u32, 1), x.prov.total_refs); - try x.h.expectFail(.{ .clunk = .{ .fid = 5 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 5 } }, e_unknown_fid); // Tremove is always a clunk, even of a static node or when the provider refuses try x.h.walkTo(6, &.{"README"}); try x.h.expectFail(.{ .remove = .{ .fid = 6 } }, "permission denied"); - try x.h.expectFail(.{ .clunk = .{ .fid = 6 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 6 } }, e_unknown_fid); _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } }); try testing.expectEqual(@as(u32, 0), x.prov.total_refs); } @@ -2491,7 +2391,7 @@ test "snapshot slots: exhaust, hold, Tversion frees; reads past the end and at h _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }); _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } }); try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "too many open dynamic files"); - try testing.expect(!x.h.conn.findFid(3).?.open); + try testing.expect(!isOpen(&x.h.conn, 3)); // reads at offsets near 2^64 never trap (counts above msize are a raw-9P // case: the cloud9 client refuses to send them; test/adv_core_hostile.py covers it) const max_count = test_cfg.msize - cloud9.iohdrsz; @@ -2522,23 +2422,26 @@ test "static and var nodes refuse create, remove and wstat; directories refuse w var x: Fixture = .{}; try x.init(); defer x.deinit(); + var ws = stat_dontcare; + ws.mtime = 1; const dirs = [_][]const []const u8{ &.{}, &.{"build"}, &.{"comptime"}, &.{ "comptime", "types" }, &.{ "comptime", "types", "Layout" }, &.{"runtime"}, &.{ "runtime", "fn" }, &.{"vars"}, &.{ "vars", "state" }, &.{ "vars", "state", "f" }, &.{ "vars", "state", "f", "inner" } }; for (dirs, 0..) |d, k| { const fid: u32 = @intCast(10 + k); try x.h.walkTo(fid, d); - try x.h.expectFail(.{ .create = .{ .fid = fid, .name = "x", .perm = 0o644, .mode = cloud9.owrite } }, "permission denied"); - try x.h.expectFail(.{ .wstat = .{ .fid = fid, .stat = stat_dontcare } }, "permission denied"); - try x.h.expectFail(.{ .open = .{ .fid = fid, .mode = cloud9.owrite } }, "is a directory"); - try x.h.expectFail(.{ .open = .{ .fid = fid, .mode = cloud9.oread | cloud9.otrunc } }, "is a directory"); + try x.h.expectFail(.{ .create = .{ .fid = fid, .name = "x", .perm = 0o644, .mode = cloud9.owrite } }, e_perm); + try x.h.expectFail(.{ .wstat = .{ .fid = fid, .stat = ws } }, "permission denied"); + _ = try x.h.ok(.{ .wstat = .{ .fid = fid, .stat = stat_dontcare } }); + try x.h.expectFail(.{ .open = .{ .fid = fid, .mode = cloud9.owrite } }, e_perm); + try x.h.expectFail(.{ .open = .{ .fid = fid, .mode = cloud9.oread | cloud9.otrunc } }, e_perm); try x.h.expectFail(.{ .remove = .{ .fid = fid } }, "permission denied"); - try x.h.expectFail(.{ .clunk = .{ .fid = fid } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = fid } }, e_unknown_fid); } const files = [_][]const []const u8{ &.{"README"}, &.{ "build", "time" }, &.{ "comptime", "decls" }, &.{ "runtime", "pid" }, &.{ "runtime", "fn", "fib30" }, &.{"ctl"}, &.{ "vars", "state", "value" }, &.{ "vars", "state", "raw" }, &.{ "vars", "state", "f", "a", "value" }, &.{ "vars", "counter", "type" } }; for (files, 0..) |f, k| { const fid: u32 = @intCast(30 + k); try x.h.walkTo(fid, f); - try x.h.expectFail(.{ .wstat = .{ .fid = fid, .stat = stat_dontcare } }, "permission denied"); - try x.h.expectFail(.{ .walk = .{ .fid = fid, .newfid = 99, .names = &.{".."} } }, "not a directory"); + try x.h.expectFail(.{ .wstat = .{ .fid = fid, .stat = ws } }, "permission denied"); + try x.h.expectFail(.{ .walk = .{ .fid = fid, .newfid = 99, .names = &.{".."} } }, e_not_dir); try x.h.expectFail(.{ .remove = .{ .fid = fid } }, "permission denied"); } // writes to a var value at a non-zero offset and with an empty payload @@ -2551,35 +2454,62 @@ test "static and var nodes refuse create, remove and wstat; directories refuse w try testing.expectEqual(@as(u32, 1), x.exposed.a); _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = std.math.maxInt(u64), .data = "77\n" } }); try testing.expectEqual(@as(u32, 77), x.exposed.a); - // reads of a write-only fid are refused; OEXEC reads like OREAD - try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, "file not open"); + // reads of a write-only fid are refused; the engine refuses OEXEC + try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, e_bad_use); try x.h.walkTo(2, &.{"README"}); - _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oexec } }); + try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.oexec } }, e_perm); + _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } }); try testing.expect((try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 10 } })).read.len == 10); } -test "msize 24: every request that fits is answered, every reply that cannot fit is an Rerror" { +test "a provider read that is not ready parks and is answered on a later step" { var x: Fixture = .{}; try x.init(); defer x.deinit(); - try x.h.version(24); - _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "u" } }); // Tattach 20, Rattach 20 - try x.h.expectFail(.{ .stat = .{ .fid = 0 } }, "reply too large"); // Rerror truncated to fit 24 bytes - const w = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"ctl"} } }); // Rwalk 22 - try testing.expectEqual(@as(u16, 1), w.walk.nwqid); - try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{ ".", "." } } }, "reply too large"); - try x.h.expectFail(.{ .clunk = .{ .fid = 2 } }, "unknown fid"); - _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } }); // Ropen 24 - try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "e" } }, "bad command"); // Twrite 24 - // the largest read the client may ask for is msize - iohdrsz = 0 bytes - const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 0 } }); - try testing.expectEqual(@as(usize, 0), r.read.len); - _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } }); - try x.h.walkTo(3, &.{"build"}); - _ = try x.h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }); - const d = try x.h.ok(.{ .read = .{ .fid = 3, .offset = 0, .count = 0 } }); - try testing.expectEqual(@as(usize, 0), d.read.len); // no record fits in 0 bytes, nothing is split - try x.h.expectFail(.{ .read = .{ .fid = 3, .offset = 1, .count = 0 } }, "bad offset"); + try x.h.walkTo(1, &.{ "prov", "hello" }); + _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }); + x.prov.park = true; + const reads = x.prov.reads; + const tag = try x.h.client.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } }); + _ = try x.h.pump(); + try testing.expectEqual(@as(?cloud9.Client.Done, null), x.h.client.take()); + try testing.expect(x.prov.reads > reads); + // the connection keeps serving, and every step asks the provider again + const asked = x.prov.reads; + const st = try x.h.ok(.{ .stat = .{ .fid = 1 } }); + try testing.expectEqualStrings("hello", st.stat.name); + try testing.expect(x.prov.reads > asked); + try testing.expectEqual(@as(?cloud9.Client.Done, null), x.h.client.take()); + // a second parked read fills the slots; a third is refused at once + _ = try x.h.client.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } }); + _ = try x.h.pump(); + try testing.expectEqual(@as(?cloud9.Client.Done, null), x.h.client.take()); + try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } }, fs.e_again); + // ready: the next step answers both, oldest first, with the original tags + x.prov.park = false; + _ = try x.h.pump(); + const first = x.h.client.take() orelse return error.NoReply; + try testing.expectEqual(tag, first.tag); + try testing.expectEqualStrings("hello", first.result.read); + const second = x.h.client.take() orelse return error.NoReply; + try testing.expectEqualStrings("hello", second.result.read); + try testing.expectEqual(@as(?cloud9.Client.Done, null), x.h.client.take()); + // a flushed parked read is interrupted; a hangup with one parked pays the provider + x.prov.park = true; + const parked = try x.h.client.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } }); + _ = try x.h.pump(); + _ = try x.h.client.submit(.{ .flush = .{ .oldtag = parked } }); + _ = try x.h.pump(); + const interrupted = x.h.client.take() orelse return error.NoReply; + try testing.expectEqual(parked, interrupted.tag); + try testing.expectEqualStrings(e_interrupted, interrupted.result.fail); + try testing.expect((x.h.client.take() orelse return error.NoReply).result == .flush); + _ = try x.h.client.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } }); + _ = try x.h.pump(); + try testing.expectEqual(@as(u32, 1), x.prov.nodes[1].opens); + x.h.conn.hangup(); + try testing.expectEqual(@as(u32, 0), x.prov.nodes[1].opens); + try testing.expectEqual(@as(u32, 0), x.prov.total_refs); } test "Conn.init clamps the msize cap to [msize_min, cfg.msize]" { @@ -2587,11 +2517,13 @@ test "Conn.init clamps the msize cap to [msize_min, cfg.msize]" { var shared: TS.Shared = .init(&ctx); var storage: TS.Storage = undefined; const lo: TS.Conn = .init(&shared, &storage, 0); - try testing.expectEqual(cloud9.Server.msize_min, lo.msize_cap); + try testing.expectEqual(fs.msize_min, lo.msize_cap); const hi: TS.Conn = .init(&shared, &storage, std.math.maxInt(u32)); try testing.expectEqual(test_cfg.msize, hi.msize_cap); const mid: TS.Conn = .init(&shared, &storage, 4096); try testing.expectEqual(@as(u32, 4096), mid.msize_cap); + // two connections on the same Shared never share a fid-index salt + try testing.expect(lo.engine.hash_seed != hi.engine.hash_seed); } test "parseIso8601 rejects malformed stamps and never traps" { @@ -2611,46 +2543,3 @@ test "parseIso8601 rejects malformed stamps and never traps" { try testing.expectEqual(@as(u32, std.math.maxInt(u32)), parseIso8601("2106-02-07T06:28:15Z").?); try testing.expectEqual(@as(?u32, null), parseIso8601("2106-02-07T06:28:16Z")); } - -/// Multiplicative inverse of an odd 32-bit constant (Newton iteration). -fn inverseMod32(a: u32) u32 { - var x: u32 = a; - for (0..5) |_| x *%= 2 -% a *% x; - return x; -} - -test "fid index: fid numbers crafted to collide under the public hash do not cluster a seeded connection" { - var ctx: TestCtx = .{}; - var shared: BigS.Shared = .init(&ctx); - const storage = try testing.allocator.create(BigS.Storage); - defer testing.allocator.destroy(storage); - var h: BigS.Harness = undefined; - try h.init(&shared, storage); - defer h.deinit(); - // two connections on the same Shared never share a seed - const other: BigS.Conn = .init(&shared, storage, big_cfg.msize); - try testing.expect(other.hash_seed != h.conn.hash_seed); - // ids whose products with the golden ratio share their top bits: all one bucket when unseeded - const inv = inverseMod32(0x9E37_79B1); - try testing.expectEqual(@as(u32, 1), inv *% 0x9E37_79B1); - const n: u32 = big_cfg.max_fids - 1; - const base: u32 = 0x4242_0000; - var i: u32 = 0; - while (i < n) : (i += 1) { - const id = (base + i) *% inv; - try testing.expectEqual(@as(usize, base >> BigS.index_shift), @as(usize, @intCast((id *% 0x9E37_79B1) >> BigS.index_shift))); - _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = id, .names = &.{} } }); - } - try checkFidIndex(&h.conn); - // the longest probe sequence in the seeded table is short; unseeded it would be ~n - var worst: usize = 0; - i = 0; - while (i < n) : (i += 1) { - const id = (base + i) *% inv; - var pos = h.conn.fidHome(id); - var steps: usize = 0; - while (h.conn.fids[h.conn.index[pos]].id != id) : (pos = (pos + 1) & BigS.index_mask) steps += 1; - worst = @max(worst, steps); - } - try testing.expect(worst < 64); -} diff --git a/9proc/src/linux/probe.zig b/9proc/src/linux/probe.zig index 559d981..4db277d 100644 --- a/9proc/src/linux/probe.zig +++ b/9proc/src/linux/probe.zig @@ -427,7 +427,7 @@ pub fn Probe(comptime Srv: type) type { /// Free space in the connection's input buffer (cloud9 keeps one /// msize-sized frame; `push` copies at most this much). fn inputRoom(conn: *const Srv.Conn) usize { - return conn.server.in.len - conn.server.in_len; + return conn.inputRoom(); } fn readClient(p: *Self, i: usize, hup: bool) void { diff --git a/9proc/src/scratch.zig b/9proc/src/scratch.zig index 2163a28..f666afb 100644 --- a/9proc/src/scratch.zig +++ b/9proc/src/scratch.zig @@ -7,6 +7,7 @@ //! the root is handle 0. Not internally synchronized (like `Shared`). const std = @import("std"); const cloud9 = @import("cloud9"); +const fs = cloud9.fs; const core = @import("core.zig"); const Allocator = std.mem.Allocator; const Provider = core.Provider; @@ -374,8 +375,7 @@ const Fixture = struct { } fn nodeOf(x: *Fixture, fid: u32) *Node { - for (x.h.conn.fids) |f| if (f.used and f.id == fid) return x.scratch.node(f.node.prov.h); - unreachable; + return x.scratch.node(x.h.conn.providerHandle(fid).?); } }; @@ -429,7 +429,7 @@ test "scratch create/write/read/rename/truncate/remove" { _ = try x.h.ok(.{ .clunk = .{ .fid = 5 } }); try x.h.walkTo(6, &.{ "scratch", "d" }); try x.h.expectFail(.{ .remove = .{ .fid = 6 } }, "directory not empty"); - try x.h.expectFail(.{ .clunk = .{ .fid = 6 } }, "unknown fid"); // remove always clunks + try x.h.expectFail(.{ .clunk = .{ .fid = 6 } }, fs.e_unknown_fid); // remove always clunks try x.h.walkTo(7, &.{ "scratch", "d", "inner" }); _ = try x.h.ok(.{ .remove = .{ .fid = 7 } }); try x.h.walkTo(8, &.{ "scratch", "d" }); @@ -469,7 +469,7 @@ test "walk of a missing name and walking a file" { // a walk that fails past the first element is a partial Rwalk that leaves newfid unused const r = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 4, .names = &.{ "scratch", "nope", "x" } } }); try testing.expectEqual(@as(u16, 1), r.walk.nwqid); - try x.h.expectFail(.{ .clunk = .{ .fid = 4 } }, "unknown fid"); + try x.h.expectFail(.{ .clunk = .{ .fid = 4 } }, fs.e_unknown_fid); // .. from a file is not a directory; .. from the scratch root reaches the server root try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 5, .names = &.{".."} } }, "not a directory"); try x.h.walkTo(5, &.{"scratch"}); @@ -504,10 +504,9 @@ test "directory read across consecutive offsets returns every record exactly onc seen[idx] = true; } for (seen) |s| try testing.expect(s); - try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 7, .count = 200 } }, "bad offset"); - // a read that cannot fit even one record returns nothing rather than splitting it - const tiny = try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 30 } }); - try testing.expectEqual(@as(usize, 0), tiny.read.len); + try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 7, .count = 200 } }, fs.e_bad_offset); + // a read that cannot fit even one record is refused rather than splitting it + try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 0, .count = 30 } }, fs.e_count_small); _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } }); // Tversion resets every fid and every reference try x.h.version(8192); @@ -592,13 +591,16 @@ test "wstat with every field equal to the current stat changes nothing" { try x.h.walkTo(1, &.{"scratch"}); _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "same", .perm = 0o640, .mode = cloud9.oread } }); const before = (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat; - var copy = before; + // The engine owns type, dev, qid, atime and the owner names: those must + // be "don't care"; name, mode, mtime and length equal to the current + // stat reach the provider and change nothing. + var copy = dontcare; var name_buf: [core.max_name]u8 = undefined; @memcpy(name_buf[0..before.name.len], before.name); copy.name = name_buf[0..before.name.len]; - copy.uid = "tester"; - copy.gid = "tester"; - copy.muid = "tester"; + copy.mode = before.mode; + copy.mtime = before.mtime; + copy.length = before.length; _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = copy } }); const after = (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat; try testing.expectEqual(before.qid, after.qid); @@ -619,7 +621,7 @@ test "wstat with every field equal to the current stat changes nothing" { // the mode's directory bit is immutable, mtime is settable st = dontcare; st.mode = cloud9.dmdir | 0o640; - try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = st } }, "permission denied"); + try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = st } }, fs.e_wstat); st = dontcare; st.mtime = 12345; _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = st } }); @@ -651,7 +653,7 @@ test "ORCLOSE removes on clunk and removed files stay readable through open fids _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } }); try x.h.walkTo(6, &.{"scratch"}); try x.h.expectFail(.{ .walk = .{ .fid = 6, .newfid = 7, .names = &.{"d"} } }, "file does not exist"); - try x.h.expectFail(.{ .create = .{ .fid = 5, .name = "x", .perm = 0o644, .mode = cloud9.oread } }, "unknown fid"); // remove clunked it + try x.h.expectFail(.{ .create = .{ .fid = 5, .name = "x", .perm = 0o644, .mode = cloud9.oread } }, fs.e_unknown_fid); // remove clunked it } test "qid paths are stable identities, not addresses: remove + recreate differ" { diff --git a/9proc/test/adv_9proc_hostile.py b/9proc/test/adv_9proc_hostile.py index 934e757..dfa0d20 100755 --- a/9proc/test/adv_9proc_hostile.py +++ b/9proc/test/adv_9proc_hostile.py @@ -21,6 +21,22 @@ import time NOTAG = 0xFFFF NOFID = 0xFFFFFFFF + +# The Rerror strings of the conditions cloud9.fs (the file-server engine the +# core is a backend of) decides itself; the tree's own refusals keep the +# Plan 9 strings ("file does not exist", "bad command", ...). +MSIZE_MIN = 217 # one full Rwalk must fit +E_UNKNOWN_FID = "fid unknown or out of range" +E_FID_IN_USE = "fid already in use" +E_TOO_MANY_FIDS = "Too many open files in system" +E_BAD_USE = "bad use of fid" # I/O on an unopened fid; a walk or clone from an open one +E_ALREADY_OPEN = "file already open for I/O" # open or create on an open fid +E_BAD_OFFSET = "bad offset in directory read" +E_PERM = "permission denied" # the engine's own refusals: dirs for write, OEXEC, static trees +E_WSTAT = "wstat prohibited" # engine-owned stat fields, or a length on a directory +E_ILLEGAL_NAME = "illegal name" # names of creates and renames +E_INVAL = "Invalid argument" # a reply that cannot fit msize; a dir read count below one record +E_INTERRUPTED = "Interrupted system call" Tversion, Rversion, Tauth, Rauth, Tattach, Rattach, Rerror = 100, 101, 102, 103, 104, 105, 107 Tflush, Rflush, Twalk, Rwalk, Topen, Ropen, Tcreate, Rcreate = 108, 109, 110, 111, 112, 113, 114, 115 Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove = 116, 117, 118, 119, 120, 121, 122, 123 @@ -303,37 +319,31 @@ def attack_framing(path): c.raw(frame(Tversion, 5, struct.pack("<I", 8192) + s16(b"9P2000"))) ok("Tversion with tag 5: closed", expect_dead(c)) c.close() - # Tversion msize below the resource floor - for ms in (0, 1, 23): + # Tversion msize below the engine's floor (one full Rwalk must fit): no Rversion + for ms in (0, 1, 23, 24, 64, MSIZE_MIN - 1): c = Nine(path) rt, _, _ = c.version(ms) ok(f"Tversion msize {ms}: no Rversion (closed or Rerror)", rt in (None, Rerror) or expect_dead(c)) c.close() - # tiny msize 24 is negotiable (Rversion fits); Tattach cannot fit, so use msize 64 for the rest - c = Nine(path) - rt, ms, ver = c.version(24) - ok("Tversion msize 24 accepted", rt == Rversion and ms == 24 and ver == b"9P2000", f"{rt} {ms} {ver}") - c.close() + # the floor itself is negotiable, and everything that fits is served at it c = Nine(path) - rt, ms, ver = c.version(64) - ok("Tversion msize 64 accepted", rt == Rversion and ms == 64, f"{rt} {ms} {ver}") + rt, ms, ver = c.version(MSIZE_MIN) + ok(f"Tversion msize {MSIZE_MIN} accepted", rt == Rversion and ms == MSIZE_MIN and ver == b"9P2000", f"{rt} {ms} {ver}") rt, _, _ = c.attach(uname=b"u") - ok("attach at msize 64", rt == Rattach, rt) - # Rstat of the root is ~70 bytes and cannot fit: must be an Rerror, not a dead socket + ok("attach at the floor", rt == Rattach, rt) rt, rb = c.stat(0) - ok("stat at msize 64 answers Rerror (reply does not fit), socket stays open", rt == Rerror, f"{rt} {rb!r}") - # Twalk with 5 names is 37 bytes (fits); Rwalk with 5 qids is 74 bytes (does not) - rt, _, rb = c.walk(0, 1, [b".", b".", b".", b".", b"."]) - ok("5-element walk at msize 64 answers Rerror, socket stays open", rt == Rerror, f"{rt} {rb!r}") - ok("newfid not bound by the failed walk", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok("stat of the root at the floor fits", rt == Rstat, f"{rt} {rb!r}") + rt, _, rb = c.walk(0, 1, [b"."] * 16) + ok("16-element walk at the floor fits exactly", rt == Rwalk, f"{rt} {rb!r}") + c.clunk(1) rt, _, _ = c.walk(0, 1, [b"README"]) - ok("1-element walk at msize 64", rt == Rwalk, rt) + ok("1-element walk at the floor", rt == Rwalk, rt) rt, _, _ = c.open(1, OREAD) - ok("open at msize 64", rt == Ropen, rt) + ok("open at the floor", rt == Ropen, rt) rt, d = c.read(1, 0, 4096) - ok("read at msize 64 returns <= 40 bytes", rt == Rread and 0 < len(d) <= 40, f"{rt} {d!r}") + ok(f"read at the floor returns <= {MSIZE_MIN - 11} bytes", rt == Rread and 0 < len(d) <= MSIZE_MIN - 11, f"{rt} {d!r}") rt, _, _ = c.clunk(1) - ok("clunk at msize 64 still works", rt == Rclunk, rt) + ok("clunk at the floor still works", rt == Rclunk, rt) c.close() # huge msize is clamped to the server's max (1 MiB) c = Nine(path) @@ -413,11 +423,11 @@ def attack_walk(path): # partial walk: newfid not bound n = c.walk_ok(0, 1, [b"build", b"nope", b"x"]) ok("partial walk returns 1 qid", n == 1, n) - ok("partial walk does not bind newfid", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok("partial walk does not bind newfid", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) # walk through a file n = c.walk_ok(0, 1, [b"build", b"target", b"x"]) ok("walk through a file is partial (2)", n == 2, n) - ok("newfid unbound after partial walk through file", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok("newfid unbound after partial walk through file", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) # walk from a file with nwname>0 ok("walk to file", c.walk_ok(0, 1, [b"build", b"target"]) == 2) ok("walk from file fails 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"x")) == "not a directory") @@ -431,10 +441,10 @@ def attack_walk(path): c.clunk(1) # newfid in use c.walk_ok(0, 1, []) - ok("walk to a fid in use", c.err(Twalk, struct.pack("<IIH", 0, 1, 0)) == "fid in use") - ok("walk from unknown fid", c.err(Twalk, struct.pack("<IIH", 999, 2, 0)) == "unknown fid") + ok("walk to a fid in use", c.err(Twalk, struct.pack("<IIH", 0, 1, 0)) == E_FID_IN_USE) + ok("walk from unknown fid", c.err(Twalk, struct.pack("<IIH", 999, 2, 0)) == E_UNKNOWN_FID) # attach twice - ok("attach twice same fid", c.err(Tattach, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")) == "fid in use") + ok("attach twice same fid", c.err(Tattach, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")) == E_FID_IN_USE) ok("auth is refused", c.err(Tauth, struct.pack("<I", 5) + s16(b"u") + s16(b"")) is not None) c.close() ok("server healthy after walk attacks", healthy(path)) @@ -458,20 +468,20 @@ def attack_io(path): c.clunk(1) # read on unopened fid c.walk_ok(0, 2, [b"README"]) - ok("read on unopened fid", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == "file not open") - ok("write on unopened fid", c.err(Twrite, struct.pack("<IQI", 2, 0, 1) + b"x") == "file not open") - ok("read unknown fid", c.err(Tread, struct.pack("<IQI", 555, 0, 10)) == "unknown fid") + ok("read on unopened fid", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == E_BAD_USE) + ok("write on unopened fid", c.err(Twrite, struct.pack("<IQI", 2, 0, 1) + b"x") == E_BAD_USE) + ok("read unknown fid", c.err(Tread, struct.pack("<IQI", 555, 0, 10)) == E_UNKNOWN_FID) c.clunk(2) # directory: write/trunc/write on a dir c.walk_ok(0, 3, [b"build"]) - ok("open dir for write is 'is a directory'", c.err(Topen, struct.pack("<IB", 3, OWRITE)) == "is a directory") + ok("open dir for write is refused by the engine", c.err(Topen, struct.pack("<IB", 3, OWRITE)) == E_PERM) ok("open dir with OTRUNC is refused", c.err(Topen, struct.pack("<IB", 3, OREAD | OTRUNC)) is not None) rt, _, _ = c.open(3, OREAD) ok("write on open dir", c.err(Twrite, struct.pack("<IQI", 3, 0, 1) + b"x") is not None) rt, d = c.read(3, 0, 8192) ok("read dir", rt == Rread and len(d) > 0) - ok("read dir at bad offset", c.err(Tread, struct.pack("<IQI", 3, 3, 8192)) == "bad offset") - ok("read dir at 2^64-1 is bad offset", c.err(Tread, struct.pack("<IQI", 3, (1 << 64) - 1, 8192)) == "bad offset") + ok("read dir at bad offset", c.err(Tread, struct.pack("<IQI", 3, 3, 8192)) == E_BAD_OFFSET) + ok("read dir at 2^64-1 is bad offset", c.err(Tread, struct.pack("<IQI", 3, (1 << 64) - 1, 8192)) == E_BAD_OFFSET) rt, d2 = c.read(3, len(d), 8192) ok("read dir at end returns empty", rt == Rread and d2 == b"") # read of an open write-only file @@ -527,12 +537,12 @@ def attack_scratch(path): return c.walk_ok(0, fid, S + list(extra)) fresh(1) - ok("create name with '/'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a/b") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name") - ok("create '.'", c.err(Tcreate, struct.pack("<I", 1) + s16(b".") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name") - ok("create '..'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"..") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name") - ok("create empty name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name") - ok("create NUL name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a\x00b") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name") - ok("create 256-byte name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a" * 256) + struct.pack("<IB", 0o644, OWRITE)) == "bad file name") + ok("create name with '/'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a/b") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create '.'", c.err(Tcreate, struct.pack("<I", 1) + s16(b".") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create '..'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"..") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create empty name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create NUL name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a\x00b") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create 256-byte name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a" * 256) + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) rt, _, _ = c.create(1, b"b" * 255, 0o644, OWRITE) ok("create 255-byte name ok", rt == Rcreate, rt) rt, st = c.stat(1) @@ -566,25 +576,28 @@ def attack_scratch(path): ok("wstat length 64MiB+1 is no space", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=(64 << 20) + 1))) == "no space left on device") ok("wstat length 2^64-2 is no space", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=(1 << 64) - 2))) == "no space left on device") # read on a write-only fid - ok("read on OWRITE fid", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == "file not open") + ok("read on OWRITE fid", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == E_BAD_USE) c.clunk(1) - # wstat with everything set to the current values: no-op + # wstat with name, mode, mtime and length equal to the current values: no-op fresh(1, [b"f"]) rt, st = c.stat(1) + same = mkstat(name=st["name"], mode=st["mode"], mtime=st["mtime"], length=st["length"]) + rt, _, _ = c.wstat(1, same) + ok("wstat with name/mode/mtime/length equal to current is ok", rt == Rwstat, rt) + rt, st2 = c.stat(1) + ok("stat/wstat round trip fidelity", st2 == st, f"{st}\n{st2}") + # the engine owns type, dev, qid, atime and the owner names: naming them at all is refused, even unchanged full = mkstat(name=st["name"], uid=st["uid"], gid=st["gid"], muid=st["muid"], typ=st["type"], dev=st["dev"], qtype=st["qid"][0], qvers=st["qid"][1], qpath=st["qid"][2], mode=st["mode"], atime=st["atime"], mtime=st["mtime"], length=st["length"]) - rt, _, _ = c.wstat(1, full) - ok("wstat with everything equal to current is ok", rt == Rwstat, rt) - rt, st2 = c.stat(1) - ok("stat/wstat round trip fidelity", st2 == st, f"{st}\n{st2}") + ok("wstat naming the engine-owned fields is 'wstat prohibited' even when equal", c.err(Twstat, struct.pack("<I", 1) + s16(full)) == E_WSTAT) # wstat changing immutable fields - ok("wstat changing qid.path", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(qpath=12345))) == "permission denied") - ok("wstat changing uid", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(uid=b"root"))) == "permission denied") - ok("wstat DMDIR on a file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mode=DMDIR | 0o755))) == "permission denied") - ok("wstat rename to '.'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"."))) == "bad file name") - ok("wstat rename to '..'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b".."))) == "bad file name") - ok("wstat rename to 'a/b'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"a/b"))) == "bad file name") + ok("wstat changing qid.path", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(qpath=12345))) == E_WSTAT) + ok("wstat changing uid", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(uid=b"root"))) == E_WSTAT) + ok("wstat DMDIR on a file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mode=DMDIR | 0o755))) == E_WSTAT) + ok("wstat rename to '.'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"."))) == E_ILLEGAL_NAME) + ok("wstat rename to '..'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b".."))) == E_ILLEGAL_NAME) + ok("wstat rename to 'a/b'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"a/b"))) == E_ILLEGAL_NAME) ok("wstat rename to existing", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"b" * 255))) == "file already exists") rt, _, _ = c.wstat(1, mkstat(name=b"F")) rt2, st = c.stat(1) @@ -595,18 +608,18 @@ def attack_scratch(path): c.clunk(1) # remove of root / scratch root / static ok("remove of attach root", c.err(Tremove, struct.pack("<I", 0)) == "permission denied") - ok("fid clunked by failed remove", c.err(Tstat, struct.pack("<I", 0)) == "unknown fid") + ok("fid clunked by failed remove", c.err(Tstat, struct.pack("<I", 0)) == E_UNKNOWN_FID) c.attach() c.walk_ok(0, 1, [b"scratch"]) ok("remove of /scratch", c.err(Tremove, struct.pack("<I", 1)) == "permission denied") - ok("fid clunked by failed remove of /scratch", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok("fid clunked by failed remove of /scratch", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) c.walk_ok(0, 1, [b"build", b"target"]) ok("remove of static file", c.err(Tremove, struct.pack("<I", 1)) == "permission denied") - ok("clunk unknown fid", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok("clunk unknown fid", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) # remove non-empty dir; fid clunked fresh(1) ok("remove non-empty dir", c.err(Tremove, struct.pack("<I", 1)) == "directory not empty") - ok("fid clunked after failed remove", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok("fid clunked after failed remove", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) # a fid on a removed file: everything but stat/clunk fails cleanly fresh(1, [b"F"]) fresh(2, [b"F"]) @@ -667,7 +680,9 @@ def attack_scratch(path): # directory read across offsets while the directory changes fresh(1) c.open(1, OREAD) - rt, d = c.read(1, 0, 120) # one or two records + rt, d = c.read(1, 0, 8192) + first = struct.unpack_from("<H", d)[0] + 2 + rt, d = c.read(1, 0, first) # exactly one record (the engine never splits one) fresh(2, [b"weird", b"inner"]) c.remove(2) fresh(2, [b"weird"]) @@ -700,7 +715,7 @@ def attack_scratch(path): rt, ms, _ = c.version(65536) ok("mid-session Tversion", rt == Rversion) ok("fids gone after Tversion", c.err(Tstat, struct.pack("<I", 1)) is not None) - ok("fids gone after Tversion (0)", c.err(Tstat, struct.pack("<I", 0)) == "unknown fid") + ok("fids gone after Tversion (0)", c.err(Tstat, struct.pack("<I", 0)) == E_UNKNOWN_FID) c.attach() # cleanup: remove everything under root c.walk_ok(0, 1, S) diff --git a/9proc/test/adv_core_hostile.py b/9proc/test/adv_core_hostile.py index 464876f..febda0d 100755 --- a/9proc/test/adv_core_hostile.py +++ b/9proc/test/adv_core_hostile.py @@ -4,7 +4,7 @@ Complements adv_9proc_hostile.py in this directory (framing, tags, scratch, floods) with attacks on the freestanding engine's own paths: the /vars tree and its comptime renderers, snapshot slots, the static tree, the fid table at its -configured maximum, directory-read offsets, msize 24, the ctl staging rule, +configured maximum, directory-read offsets, the msize floor, the ctl staging rule, and the demo's debug providers driven as black boxes. Usage: @@ -30,6 +30,8 @@ from adv_9proc_hostile import ( # noqa: E402 Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove, Tstat, Rstat, Twstat, Rwstat, Rerror, OREAD, OWRITE, ORDWR, OEXEC, OTRUNC, ORCLOSE, DMDIR, Nine, frame, s16, mkstat, parse_stat, ok, healthy, expect_dead, + MSIZE_MIN, E_UNKNOWN_FID, E_FID_IN_USE, E_TOO_MANY_FIDS, E_BAD_USE, E_ALREADY_OPEN, E_BAD_OFFSET, + E_PERM, E_WSTAT, E_INVAL, ) MAX_FIDS = 32768 # demo/main.zig cfg.max_fids @@ -88,7 +90,7 @@ def attack_vars(path): for nm in (b"0", b"-1", b"0x", b"0x0", b"state\x00", b"State", b" state", b"state ", b"a" * 255, b"a" * 65535, b"\xff\xfe", b"..\x00", b"f", b"value"): n = c.walk_ok(0, 1, [b"vars", nm]) ok(f"walk /vars/{nm[:12]!r}{'...' if len(nm) > 12 else ''} is a partial walk (1)", n == 1, n) - ok(" and newfid stays unbound", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok(" and newfid stays unbound", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) for nm in (b"0", b"F", b"f\x00", b"ticks", b"value ", b"raw\x00"): n = c.walk_ok(0, 1, [b"vars", b"state", nm]) ok(f"walk /vars/state/{nm!r} is a partial walk (2)", n == 2, n) @@ -151,7 +153,7 @@ def attack_vars(path): for bad in (b"abc", b"99999999999999999999999", b"-1", b"1e3", b"", b" ", b"4\x002", b"1.5", b"0x", b"+", b"\xd9\xa1\xd9\xa2", b"12 34", b"0b102"): e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad) ok(f"write {bad!r} to u64 value is 'bad value'", e == "bad value", e) - ok("read on the write-only value fid is 'file not open'", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == "file not open") + ok("read on the write-only value fid is 'file not open'", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == E_BAD_USE) for good, want in ((b" 4200 \n", 4200), (b"0x10", 16), (b"+7", 7), (b"0b1010", 10), (b"0o17", 15), (b"1_000", 1000), (b"18446744073709551615", (1 << 64) - 1)): rt, _, rb = c.write(1, (1 << 64) - 1, good) # offset is ignored for values got = c.path_read([b"vars", b"state", b"f", b"ticks", b"value"]) @@ -200,9 +202,9 @@ def attack_vars(path): p = b"/".join(names).decode() ok(f"create in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied") ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"y"))) == "permission denied") - ok(f"open {p} for write is 'is a directory'", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == "is a directory") + ok(f"open {p} for write is refused by the engine", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == E_PERM) ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied") - ok(f" and the fid was clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok(f" and the fid was clunked", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) for names in ([b"vars", b"state", b"value"], [b"vars", b"state", b"f", b"last_job", b"value"], [b"vars", b"state", b"type"]): c.walk_ok(0, 1, names) p = b"/".join(names).decode() @@ -249,7 +251,7 @@ def attack_snapshots(path): break ok(f"exactly {SNAPSHOT_SLOTS} dynamic files open per connection", opened == SNAPSHOT_SLOTS, opened) ok("the next open is 'too many open dynamic files'", err == "too many open dynamic files", err) - ok("the refused fid is still unopened (read is 'file not open')", c.err(Tread, struct.pack("<IQI", 100 + opened, 0, 10)) == "file not open") + ok("the refused fid is still unopened (read is 'file not open')", c.err(Tread, struct.pack("<IQI", 100 + opened, 0, 10)) == E_BAD_USE) # every held snapshot is still readable and consistent at offset 1 for i in range(opened): rt, d = c.read(100 + i, 0, 8192) @@ -278,11 +280,11 @@ def attack_snapshots(path): c.walk_ok(0, 60, dyn[0]) rt, _, _ = c.open(60, OREAD) ok("the failed-remove fid's slot was released", rt == Ropen, rt) - # a clone of an open dynamic fid takes no slot and is unopened - rt, _, _ = c.walk(60, 61, []) - ok("clone of an open dynamic fid is allowed", rt == Rwalk, rt) - ok("the clone is not open", c.err(Tread, struct.pack("<IQI", 61, 0, 10)) == "file not open") - ok("the clone cannot open (slots exhausted again)", c.err(Topen, struct.pack("<IB", 61, OREAD)) == "too many open dynamic files") + # an open fid cannot be cloned; a fresh walk to the same file takes no slot and is unopened + ok("clone of an open dynamic fid is refused", c.err(Twalk, struct.pack("<IIH", 60, 61, 0)) == E_BAD_USE) + c.walk_ok(0, 61, dyn[0]) + ok("the fresh fid is not open", c.err(Tread, struct.pack("<IQI", 61, 0, 10)) == E_BAD_USE) + ok("the fresh fid cannot open (slots exhausted again)", c.err(Topen, struct.pack("<IB", 61, OREAD)) == "too many open dynamic files") # Tversion releases everything: 8 opens succeed again rt, ms, _ = c.version(65536) ok("mid-session Tversion", rt == base.Rversion) @@ -310,12 +312,13 @@ def attack_static(path): ok(f"create in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied") ok(f"mkdir in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"d") + struct.pack("<IB", DMDIR | 0o755, OREAD)) == "permission denied") ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mtime=1))) == "permission denied") - ok(f"wstat of {p} with all don't-care is denied too", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat())) == "permission denied") - ok(f"open {p} ORDWR is 'is a directory'", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == "is a directory") - ok(f"open {p} OEXEC works like OREAD", c.open(1, OEXEC)[0] == Ropen) + ok(f"wstat of {p} with all don't-care is a no-op the engine answers itself", c.wstat(1, mkstat())[0] == Rwstat) + ok(f"open {p} ORDWR is refused by the engine", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == E_PERM) + ok(f"open {p} OEXEC is refused by the engine", c.err(Topen, struct.pack("<IB", 1, OEXEC)) == E_PERM) + ok(f"open {p} OREAD", c.open(1, OREAD)[0] == Ropen) ok(f"write to open {p} is 'is a directory'", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") is not None) ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied") - ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) files = [[b"README"], [b"build", b"time"], [b"comptime", b"decls"], [b"comptime", b"types", b"Qid", b"fields"], [b"runtime", b"pid"], [b"runtime", b"fn", b"fib30"], [b"runtime", b"ctl"]] for names in files: p = "/" + b"/".join(names).decode() @@ -324,7 +327,7 @@ def attack_static(path): ok(f"'..' from {p} is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory") ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=0))) == "permission denied") ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied") - ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid") + ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) if names[-1] != b"ctl": c.walk_ok(0, 1, names) ok(f"open {p} OWRITE is denied", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == "permission denied") @@ -563,11 +566,11 @@ def attack_fid_table(path): good, bad, dt, dead = flood(c, ids, [b"scratch"]) ok(f"{n} walks with adversarial fid numbers all succeed", good == n and bad == 0 and not dead, (good, bad, dead)) print(f" {n} clones (provider handles) in {dt:.2f}s") - ok("the next fid is 'too many fids'", c.err(Twalk, struct.pack("<IIH", 0, 7, 0)) == "too many fids") - ok("attach at the limit is 'too many fids'", c.err(base.Tattach, struct.pack("<II", 7, base.NOFID) + s16(b"u") + s16(b"")) == "too many fids") - ok("an existing id is 'fid in use'", c.err(Twalk, struct.pack("<IIH", 0, ids[12345], 0)) == "fid in use") + ok("the next fid is 'too many fids'", c.err(Twalk, struct.pack("<IIH", 0, 7, 0)) == E_TOO_MANY_FIDS) + ok("attach at the limit is 'too many fids'", c.err(base.Tattach, struct.pack("<II", 7, base.NOFID) + s16(b"u") + s16(b"")) == E_TOO_MANY_FIDS) + ok("an existing id is 'fid in use'", c.err(Twalk, struct.pack("<IIH", 0, ids[12345], 0)) == E_FID_IN_USE) ok("a self-walk at the limit works", c.walk_ok(ids[5], ids[5], [b".."]) == 1) - ok("an unknown fid at the limit is 'unknown fid'", c.err(Tstat, struct.pack("<I", 7)) == "unknown fid") + ok("an unknown fid at the limit is 'unknown fid'", c.err(Tstat, struct.pack("<I", 7)) == E_UNKNOWN_FID) # clunk all, pipelined, in a hostile order (every third first, then the rest reversed) order = ids[::3] + ids[1::3][::-1] + ids[2::3][::-1] got = [0] @@ -588,12 +591,12 @@ def attack_fid_table(path): t.join(120) ok(f"{n} clunks all answered", got[0] == n and not dead[0] and not t.is_alive(), (got[0], dead[0])) print(f" {n} clunks in {time.time() - t0:.2f}s") - ok("clunk of a clunked fid is 'unknown fid'", c.err(Tclunk, struct.pack("<I", ids[100])) == "unknown fid") + ok("clunk of a clunked fid is 'unknown fid'", c.err(Tclunk, struct.pack("<I", ids[100])) == E_UNKNOWN_FID) # reuse: the whole table is available again with dense ids good, bad, dt, dead = flood(c, list(range(1, n + 1)), []) ok(f"{n} clones with dense ids after the churn all succeed", good == n and bad == 0 and not dead, (good, bad, dead)) print(f" {n} clones (reuse) in {dt:.2f}s") - ok("still 'too many fids' at the limit", c.err(Twalk, struct.pack("<IIH", 0, n + 1, 0)) == "too many fids") + ok("still 'too many fids' at the limit", c.err(Twalk, struct.pack("<IIH", 0, n + 1, 0)) == E_TOO_MANY_FIDS) rt, _, _ = c.version(65536) ok("Tversion after the fid churn", rt == base.Rversion) c.attach() @@ -635,63 +638,66 @@ def attack_flush(path): ok("server healthy after the flush storm", healthy(path)) -# --------------------------------------------------------------------------- msize 24 +# --------------------------------------------------------------------------- msize floor -def attack_msize24(path): - print("# msize 24: everything that fits is served, everything else is an Rerror that fits") +def attack_msize_floor(path): + print(f"# msize floor: below {MSIZE_MIN} the connection dies; at {MSIZE_MIN} everything that fits is served") + for ms in (24, 64, MSIZE_MIN - 1): + c = Nine(path) + rt, _, _ = c.version(ms) + ok(f"Tversion msize {ms}: closed", rt is None or expect_dead(c), rt) + c.close() c = Nine(path) - rt, ms, ver = c.version(24) - ok("Tversion 24", rt == base.Rversion and ms == 24, (rt, ms)) - rt, _, _ = c.attach(uname=b"u") # 20 bytes; Rattach is 20 - ok("Tattach at msize 24", rt == base.Rattach, rt) - e = c.err(Tstat, struct.pack("<I", 0)) - ok("Tstat: Rerror truncated to 15 bytes ('reply too large')", e == "reply too large", e) - rt, _, rb = c.walk(0, 1, [b"build"]) # Twalk 24, Rwalk 22 - ok("Twalk of one 5-byte name", rt == Rwalk, rt) - e = c.err(Twalk, struct.pack("<IIH", 0, 2, 2) + s16(b".") + s16(b".")) # 23 bytes; Rwalk would be 35 - ok("Twalk of two names cannot be answered: 'reply too large'", e == "reply too large", e) - ok("newfid unbound after the refused walk", c.err(Tclunk, struct.pack("<I", 2)) == "unknown fid") - rt, _, _ = c.open(1, OREAD) # Ropen 24 - ok("Topen at msize 24", rt == Ropen, rt) - rt, d = c.read(1, 0, 4096) # count clamped to msize - iohdrsz = 0 - ok("Tread of a directory at msize 24 answers an empty Rread (no split record)", rt == Rread and d == b"", (rt, d)) - e = c.err(Tread, struct.pack("<IQI", 1, 1, 4096)) - ok("dir read at offset 1 is 'bad offset'", e == "bad offset", e) - rt, _, _ = c.clunk(1) - ok("Tclunk at msize 24", rt == Rclunk, rt) - rt, _, _ = c.walk(0, 1, [b"vars"]) # Twalk 23 - rt, _, _ = c.walk(1, 1, [b"state"]) # 23 - rt, _, _ = c.walk(1, 1, [b"value"]) # 23 - ok("walk to /vars/state/value in 3 self-walks", rt == Rwalk, rt) + rt, ms, ver = c.version(MSIZE_MIN) + ok(f"Tversion {MSIZE_MIN}", rt == base.Rversion and ms == MSIZE_MIN, (rt, ms)) + rt, _, _ = c.attach(uname=b"u") + ok("Tattach at the floor", rt == base.Rattach, rt) + rt, st = c.stat(0) + ok("Tstat of the root fits", rt == Rstat and st["name"] == b"/", (rt, st)) + rt, _, rb = c.walk(0, 1, [b"."] * 16) + ok("a full 16-element Rwalk is exactly the floor", rt == Rwalk and struct.unpack_from("<H", rb)[0] == 16, rt) + c.clunk(1) + # an Rstat that cannot fit is an Rerror, not a dead connection: a long name in /scratch + long_name = b"m" * 180 # Tcreate (18 + 180 bytes) fits; the Rstat (61 + 180) does not + c.walk_ok(0, 1, [b"scratch"]) + rt, _, _ = c.create(1, long_name, 0o644, OREAD) + ok("create a long name at the floor", rt == Rcreate, rt) + e = c.err(Tstat, struct.pack("<I", 1)) + ok("Tstat that does not fit is 'Invalid argument'", e == E_INVAL, e) + ok("remove it", c.remove(1)[0] == Rremove) + rt, _, _ = c.walk(0, 1, [b"build"]) rt, _, _ = c.open(1, OREAD) - ok("open a dynamic file at msize 24", rt == Ropen, rt) - rt, d = c.read(1, 0, 4096) - ok("read of a dynamic file at msize 24 is an empty Rread", rt == Rread and d == b"", (rt, d)) + ok("Topen at the floor", rt == Ropen, rt) + rt, d = c.read(1, 0, 4096) # count clamped to msize - 11 + ok("a directory read at the floor yields whole records", rt == Rread and 0 < len(d) <= MSIZE_MIN - 11 and records(d), (rt, len(d) if d else d)) + e = c.err(Tread, struct.pack("<IQI", 1, 1, 4096)) + ok("dir read at offset 1 is a bad offset", e == E_BAD_OFFSET, e) rt, _, _ = c.clunk(1) - for nm in (b"vars", b"state", b"f", b"ticks", b"value"): # each Twalk <= 24 bytes + ok("Tclunk at the floor", rt == Rclunk, rt) + for nm in (b"vars", b"state", b"f", b"ticks", b"value"): rt, _, _ = c.walk(0 if nm == b"vars" else 1, 1, [nm]) ok("walk to /vars/state/f/ticks/value in 5 self-walks", rt == Rwalk, rt) rt, _, _ = c.open(1, OWRITE) - ok("open a writable value at msize 24", rt == Ropen, rt) - rt, _, _ = c.write(1, 0, b"5") # Twrite 24, Rwrite 11 - ok("Twrite of one byte at msize 24", rt == Rwrite, rt) + ok("open a writable value at the floor", rt == Ropen, rt) + rt, _, _ = c.write(1, 0, b"5") + ok("Twrite of one byte at the floor", rt == Rwrite, rt) e = c.err(Twrite, struct.pack("<IQI", 1, 0, 0) + b"") - ok("empty write to a value at msize 24 is 'bad value'", e == "bad value", e) + ok("empty write to a value at the floor is 'bad value'", e == "bad value", e) rt, _, _ = c.clunk(1) - ok("clunk at msize 24", rt == Rclunk, rt) + ok("clunk at the floor", rt == Rclunk, rt) rt, ms, _ = c.version(65536) ok("renegotiate a big msize on the same connection", rt == base.Rversion and ms == 65536, (rt, ms)) c.attach() ok("normal service resumes", c.path_read([b"build", b"zig_version"]) not in (None, b"")) c.close() - # frames larger than 24 after negotiating 24 kill the connection + # frames larger than the negotiated msize kill the connection c = Nine(path) - c.version(24) - c.raw(frame(base.Tattach, 1, struct.pack("<II", 0, base.NOFID) + s16(b"longer-name") + s16(b""))) - ok("a 30-byte Tattach at msize 24: connection closed", expect_dead(c)) + c.version(MSIZE_MIN) + c.raw(frame(base.Tattach, 1, struct.pack("<II", 0, base.NOFID) + s16(b"u" * 210) + s16(b""))) + ok("an over-long Tattach at the floor: connection closed", expect_dead(c)) c.close() - ok("server healthy after msize-24 attacks", healthy(path)) + ok("server healthy after msize-floor attacks", healthy(path)) # --------------------------------------------------------------------------- directory offsets @@ -727,15 +733,15 @@ def attack_dir_offsets(path): rt, d1 = c.read(1, r0, r1) ok(f"{p}: read at the record boundary returns the next record", rt == Rread and d1 == recs[1][1], (rt, len(d1) if d1 else d1)) e = c.err(Tread, struct.pack("<IQI", 1, r0 + r1 + 1, 65000)) - ok(f"{p}: offset boundary+1 is 'bad offset'", e == "bad offset", e) + ok(f"{p}: offset boundary+1 is 'bad offset'", e == E_BAD_OFFSET, e) e = c.err(Tread, struct.pack("<IQI", 1, r0 + r1 - 1, 65000)) - ok(f"{p}: offset boundary-1 is 'bad offset'", e == "bad offset", e) + ok(f"{p}: offset boundary-1 is 'bad offset'", e == E_BAD_OFFSET, e) e = c.err(Tread, struct.pack("<IQI", 1, r0, 65000)) - ok(f"{p}: re-reading an earlier boundary is 'bad offset'", e == "bad offset", e) + ok(f"{p}: re-reading an earlier boundary is 'bad offset'", e == E_BAD_OFFSET, e) rt, rest = c.read(1, r0 + r1, 65000) ok(f"{p}: after a bad offset the good boundary still continues", rt == Rread and rest == d[r0 + r1:], rt) - rt, dd = c.read(1, 0, r0 - 1) - ok(f"{p}: count one short of a record returns nothing (no split)", rt == Rread and dd == b"", (rt, dd)) + e = c.err(Tread, struct.pack("<IQI", 1, 0, r0 - 1)) + ok(f"{p}: count one short of a record is refused (no split)", e == E_INVAL, e) rt, dd = c.read(1, 0, 65000) ok(f"{p}: offset 0 restarts and yields the same bytes", rt == Rread and dd == d) rt, dd = c.read(1, len(d), 65000) @@ -817,17 +823,17 @@ def attack_fid_states(path): c.walk_ok(0, 1, S) rt, _, _ = c.create(1, b"f", 0o644, ORDWR) ok("create f", rt == Rcreate) - ok("open of an open fid is 'file already open'", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "file already open") - ok("walk with names from an open fid is 'file already open'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "file already open") - ok("create on an open fid is 'file already open'", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == "file already open") - rt, _, _ = c.walk(1, 2, []) - ok("clone of an open fid is allowed", rt == Rwalk) - ok("the clone is not open", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == "file not open") + ok("open of an open fid is 'file already open for I/O'", c.err(Topen, struct.pack("<IB", 1, OREAD)) == E_ALREADY_OPEN) + ok("walk with names from an open fid is 'bad use of fid'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == E_BAD_USE) + ok("create on an open fid is 'file already open for I/O'", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == E_ALREADY_OPEN) + ok("clone of an open fid is refused", c.err(Twalk, struct.pack("<IIH", 1, 2, 0)) == E_BAD_USE) + ok("a fresh walk reaches the open file", c.walk_ok(0, 2, S + [b"f"]) == 3) + ok("the fresh fid is not open", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == E_BAD_USE) rt, _, _ = c.open(2, OREAD) - ok("the clone opens independently", rt == Ropen) + ok("the fresh fid opens independently", rt == Ropen) c.write(1, 0, b"data") rt, d = c.read(2, 0, 10) - ok("the clone sees the write", rt == Rread and d == b"data", d) + ok("the second fid sees the write", rt == Rread and d == b"data", d) rt, _, _ = c.wstat(2, mkstat(name=b"renamed")) ok("wstat through an open fid works", rt == Rwstat) rt, _, _ = c.remove(1) @@ -982,7 +988,7 @@ def main(): attack_dir_offsets(path) attack_ctl(path) attack_fid_states(path) - attack_msize24(path) + attack_msize_floor(path) attack_flush(path) attack_fid_table(path) if not args.fast: |
