summaryrefslogtreecommitdiff
path: root/introspect/src/scratch.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 21:26:05 -0300
committerGabriel Schneider <[email protected]>2026-09-19 21:26:05 -0300
commitb05abcba3ea09ea106ad28364c6e40a3ec31b890 (patch)
tree9170fac5e7e5d8bde108de34a182aaa9d6844117 /introspect/src/scratch.zig
parentae310a207534b33b7321dd2b9f423a73b1969159 (diff)
downloadcloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.tar.gz
cloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.zip
Add 9player and introspect as programs beside the library
9player/: FUSE mount CLI that mounts a 9P2000 tree into a fresh user+mount namespace and runs a program in it (no root, no libfuse, no libc). introspect/: the 9P debug/introspection library (freestanding core, value renderers, Linux probe with threads/stacks/memory/breakpoints/panics) and its demo server. Each has its own build fragment; the root build.zig wires them behind -D9player/-Dintrospect with namespaced steps (9player-itest, introspect-check-freestanding, programs-test, ...) and exports the introspect module for dependents. This is the layout for related programs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'introspect/src/scratch.zig')
-rw-r--r--introspect/src/scratch.zig689
1 files changed, 689 insertions, 0 deletions
diff --git a/introspect/src/scratch.zig b/introspect/src/scratch.zig
new file mode 100644
index 0000000..2163a28
--- /dev/null
+++ b/introspect/src/scratch.zig
@@ -0,0 +1,689 @@
+//! An in-memory read/write tree as a `Provider`: create, write, truncate,
+//! rename, remove, mkdir, DMAPPEND, DMEXCL. The one core-level component that
+//! takes an `Allocator` (nodes and file contents live on it); it is optional.
+//!
+//! Nodes are kept alive by `refs` (fids holding a handle) after removal, so a
+//! handle stays valid until the core clunks it. Handles are node addresses;
+//! the root is handle 0. Not internally synchronized (like `Shared`).
+const std = @import("std");
+const cloud9 = @import("cloud9");
+const core = @import("core.zig");
+const Allocator = std.mem.Allocator;
+const Provider = core.Provider;
+const Handle = Provider.Handle;
+const Error = Provider.Error;
+const NodeStat = core.NodeStat;
+
+/// A node of the tree.
+pub const Node = struct {
+ name: []u8,
+ path: u64,
+ version: u32 = 0,
+ mode: u32,
+ atime: u32,
+ mtime: u32,
+ data: std.ArrayList(u8) = .empty,
+ children: std.ArrayList(*Node) = .empty,
+ parent: ?*Node,
+ /// Handles held by the core.
+ refs: u32 = 0,
+ /// Fids currently open on this node (DMEXCL admits at most one).
+ opens: u32 = 0,
+ removed: bool = false,
+
+ pub fn isDir(n: *const Node) bool {
+ return n.mode & cloud9.dmdir != 0;
+ }
+
+ fn find(n: *const Node, name: []const u8) ?*Node {
+ for (n.children.items) |ch| if (std.mem.eql(u8, ch.name, name)) return ch;
+ return null;
+ }
+};
+
+/// Seconds since the epoch, for atime/mtime; the default clock reports 0.
+pub const Clock = *const fn () u32;
+
+fn zeroClock() u32 {
+ return 0;
+}
+
+pub const Scratch = struct {
+ gpa: Allocator,
+ root: *Node,
+ /// Qid paths are a counter, never reused: the root is 0 (the provider
+ /// root handle), so a removed-and-recreated file gets a fresh identity
+ /// even when the allocator hands back the same address.
+ next_path: u64 = 0,
+ /// Sum of all file lengths, bounded by `budget`.
+ bytes: usize = 0,
+ /// Largest total of file contents across all files.
+ budget: usize,
+ /// Largest single file; defaults to the budget.
+ max_file: usize,
+ /// The time source for atime/mtime (a platform layer sets it).
+ now: Clock = &zeroClock,
+
+ /// The tree's only allocation policy: every node and every file's
+ /// contents come from `gpa`, and no file content ever exceeds `budget_bytes`
+ /// in total.
+ pub fn init(gpa: Allocator, budget_bytes: usize) Allocator.Error!Scratch {
+ var s: Scratch = .{ .gpa = gpa, .root = undefined, .budget = budget_bytes, .max_file = budget_bytes };
+ s.root = try s.newNode("", cloud9.dmdir | 0o777, null);
+ return s;
+ }
+
+ pub fn deinit(s: *Scratch) void {
+ s.destroyTree(s.root);
+ s.* = undefined;
+ }
+
+ /// The provider to mount, at `/<name>`.
+ pub fn provider(s: *Scratch, name: []const u8) Provider {
+ return .{ .name = name, .ctx = s, .vtable = &vtable };
+ }
+
+ pub const vtable: Provider.VTable = .{
+ .walk = &walk,
+ .stat = &stat,
+ .list = &list,
+ .open = &open,
+ .read = &read,
+ .write = &write,
+ .create = &create,
+ .remove = &remove,
+ .wstat = &wstat,
+ .close = &close,
+ .clunk = &clunk,
+ };
+
+ // -- node management --
+
+ fn destroyTree(s: *Scratch, n: *Node) void {
+ for (n.children.items) |ch| s.destroyTree(ch);
+ n.children.clearRetainingCapacity();
+ n.removed = true;
+ if (n.refs == 0 or n == s.root) s.destroyNode(n);
+ }
+
+ fn destroyNode(s: *Scratch, n: *Node) void {
+ s.bytes -= n.data.items.len;
+ s.gpa.free(n.name);
+ n.data.deinit(s.gpa);
+ n.children.deinit(s.gpa);
+ s.gpa.destroy(n);
+ }
+
+ fn newNode(s: *Scratch, name: []const u8, mode: u32, parent: ?*Node) Allocator.Error!*Node {
+ const n = try s.gpa.create(Node);
+ errdefer s.gpa.destroy(n);
+ const t = s.now();
+ n.* = .{
+ .name = try s.gpa.dupe(u8, name),
+ .path = s.next_path,
+ .mode = mode,
+ .atime = t,
+ .mtime = t,
+ .parent = parent,
+ };
+ errdefer s.gpa.free(n.name);
+ if (parent) |p| try p.children.append(s.gpa, n);
+ s.next_path += 1;
+ return n;
+ }
+
+ /// Sets a file's length, zero-filling growth and charging the budget.
+ /// Shrinking releases the memory so a truncated file costs nothing.
+ fn resizeData(s: *Scratch, n: *Node, new_len: usize) Error!void {
+ const old = n.data.items.len;
+ if (new_len > old) {
+ if (new_len > s.max_file) return error.NoSpace;
+ if (s.bytes + (new_len - old) > s.budget) return error.NoSpace;
+ n.data.resize(s.gpa, new_len) catch return error.NoSpace;
+ @memset(n.data.items[old..new_len], 0);
+ s.bytes += new_len - old;
+ } else if (new_len < old) {
+ n.data.shrinkAndFree(s.gpa, new_len);
+ s.bytes -= old - new_len;
+ }
+ }
+
+ fn touch(s: *Scratch, n: *Node) void {
+ n.version +%= 1;
+ n.mtime = s.now();
+ }
+
+ fn self(ctx: *anyopaque) *Scratch {
+ return @ptrCast(@alignCast(ctx));
+ }
+
+ fn handle(s: *Scratch, n: *Node) Handle {
+ return if (n == s.root) Provider.root else @intFromPtr(n);
+ }
+
+ fn node(s: *Scratch, h: Handle) *Node {
+ return if (h == Provider.root) s.root else @ptrFromInt(@as(usize, @intCast(h)));
+ }
+
+ /// A handle the core will clunk exactly once.
+ fn retain(s: *Scratch, n: *Node) Handle {
+ if (n != s.root) n.refs += 1;
+ return s.handle(n);
+ }
+
+ fn release(s: *Scratch, n: *Node) void {
+ if (n == s.root) return;
+ n.refs -= 1;
+ if (n.refs == 0 and n.removed) s.destroyNode(n);
+ }
+
+ fn fillStat(n: *const Node, h: Handle, out: *NodeStat) void {
+ out.* = .{
+ .mode = n.mode,
+ .length = if (n.isDir()) 0 else n.data.items.len,
+ .atime = n.atime,
+ .mtime = n.mtime,
+ .version = n.version,
+ .name = n.name,
+ .handle = h,
+ .path = n.path,
+ };
+ }
+
+ // -- the vtable --
+
+ fn walk(ctx: *anyopaque, parent: Handle, name: []const u8) Error!Handle {
+ const s = self(ctx);
+ const p = s.node(parent);
+ if (std.mem.eql(u8, name, ".")) return s.retain(p);
+ if (p.removed) return error.NotFound;
+ if (!p.isDir()) return error.NotDir;
+ if (std.mem.eql(u8, name, "..")) return s.retain(p.parent orelse s.root);
+ return s.retain(p.find(name) orelse return error.NotFound);
+ }
+
+ fn stat(ctx: *anyopaque, h: Handle, out: *NodeStat) Error!void {
+ const s = self(ctx);
+ fillStat(s.node(h), h, out);
+ }
+
+ fn list(ctx: *anyopaque, dir: Handle, index: usize, out: *NodeStat) Error!bool {
+ const s = self(ctx);
+ const d = s.node(dir);
+ if (!d.isDir()) return error.NotDir;
+ if (index >= d.children.items.len) return false;
+ const ch = d.children.items[index];
+ fillStat(ch, s.handle(ch), out);
+ return true;
+ }
+
+ fn open(ctx: *anyopaque, h: Handle, mode: u8) Error!void {
+ const s = self(ctx);
+ const n = s.node(h);
+ if (n.removed) return error.NotFound;
+ const acc = mode & 3;
+ const want_write = acc == cloud9.owrite or acc == cloud9.ordwr;
+ const want_read = !want_write or acc == cloud9.ordwr;
+ const trunc = mode & cloud9.otrunc != 0;
+ if (n.isDir()) {
+ if (want_write or trunc) return error.IsDir;
+ if (n.mode & 0o400 == 0) return error.Perm;
+ } else {
+ if (want_read and n.mode & 0o400 == 0) return error.Perm;
+ if ((want_write or trunc) and n.mode & 0o200 == 0) return error.Perm;
+ if (n.mode & cloud9.dmexcl != 0 and n.opens != 0) return error.Excl;
+ if (trunc and n.mode & cloud9.dmappend == 0) {
+ s.resizeData(n, 0) catch unreachable; // shrinking cannot fail
+ s.touch(n);
+ }
+ }
+ n.opens += 1;
+ }
+
+ fn close(ctx: *anyopaque, h: Handle) void {
+ const s = self(ctx);
+ s.node(h).opens -= 1;
+ }
+
+ fn read(ctx: *anyopaque, h: Handle, offset: u64, buf: []u8) Error!usize {
+ const s = self(ctx);
+ const n = s.node(h);
+ if (n.isDir()) return error.IsDir;
+ const src = n.data.items;
+ if (offset >= src.len) return 0;
+ const off: usize = @intCast(offset);
+ const len = @min(buf.len, src.len - off);
+ @memcpy(buf[0..len], src[off..][0..len]);
+ return len;
+ }
+
+ fn write(ctx: *anyopaque, h: Handle, offset: u64, data: []const u8) Error!usize {
+ const s = self(ctx);
+ const n = s.node(h);
+ if (n.isDir()) return error.IsDir;
+ // A zero-length write changes nothing (and must not extend the file).
+ if (data.len == 0) return 0;
+ const off: usize = if (n.mode & cloud9.dmappend != 0) n.data.items.len else @intCast(@min(offset, s.max_file));
+ const end = off + data.len;
+ if (end > s.max_file) return error.NoSpace;
+ if (end > n.data.items.len) try s.resizeData(n, end);
+ @memcpy(n.data.items[off..end], data);
+ s.touch(n);
+ return data.len;
+ }
+
+ fn create(ctx: *anyopaque, dir: Handle, name: []const u8, perm: u32, mode: u8) Error!Handle {
+ const s = self(ctx);
+ const d = s.node(dir);
+ if (d.removed) return error.NotFound;
+ if (!d.isDir()) return error.NotDir;
+ if (d.mode & 0o200 == 0) return error.Perm;
+ if (d.find(name) != null) return error.Exists;
+ const is_dir = perm & cloud9.dmdir != 0;
+ const inherit: u32 = if (is_dir) d.mode & 0o777 else d.mode & 0o666;
+ const n = s.newNode(name, perm & (~@as(u32, 0o777) | inherit), d) catch return error.NoSpace;
+ s.touch(d);
+ n.opens += 1;
+ _ = mode;
+ return s.retain(n);
+ }
+
+ fn remove(ctx: *anyopaque, h: Handle) Error!void {
+ const s = self(ctx);
+ const n = s.node(h);
+ if (n.removed) return error.NotFound;
+ const parent = n.parent orelse return error.Perm;
+ if (parent.mode & 0o200 == 0) return error.Perm;
+ if (n.isDir() and n.children.items.len != 0) return error.NotEmpty;
+ const i = std.mem.indexOfScalar(*Node, parent.children.items, n) orelse return error.NotFound;
+ _ = parent.children.orderedRemove(i);
+ s.touch(parent);
+ n.removed = true;
+ if (n.refs == 0) s.destroyNode(n);
+ }
+
+ fn wstat(ctx: *anyopaque, h: Handle, st: *const cloud9.Stat) Error!void {
+ const s = self(ctx);
+ const n = s.node(h);
+ if (n.removed) return error.NotFound;
+ // Validate everything before changing anything.
+ const rename = st.name.len != 0 and !std.mem.eql(u8, st.name, n.name);
+ if (rename) {
+ const parent = n.parent orelse return error.Perm;
+ if (parent.find(st.name) != null) return error.Exists;
+ }
+ const cur_len: u64 = if (n.isDir()) 0 else n.data.items.len;
+ const set_len = st.length != 0xFFFF_FFFF_FFFF_FFFF and st.length != cur_len;
+ if (set_len) {
+ if (n.isDir()) return error.IsDir;
+ if (st.length > s.max_file) return error.NoSpace;
+ }
+ const set_mode = st.mode != 0xFFFF_FFFF and st.mode != n.mode;
+ if (set_mode and (st.mode & cloud9.dmdir) != (n.mode & cloud9.dmdir)) return error.Perm;
+ const set_mtime = st.mtime != 0xFFFF_FFFF and st.mtime != n.mtime;
+ if (!(rename or set_len or set_mode or set_mtime)) return;
+ const new_name: ?[]u8 = if (rename) s.gpa.dupe(u8, st.name) catch return error.NoSpace else null;
+ errdefer if (new_name) |nn| s.gpa.free(nn);
+ if (set_len) try s.resizeData(n, @intCast(st.length));
+ // Nothing below can fail.
+ if (new_name) |nn| {
+ s.gpa.free(n.name);
+ n.name = nn;
+ s.touch(n.parent.?);
+ }
+ if (set_mode) n.mode = (n.mode & cloud9.dmdir) | (st.mode & ~cloud9.dmdir);
+ s.touch(n);
+ if (set_mtime) n.mtime = st.mtime;
+ }
+
+ fn clunk(ctx: *anyopaque, h: Handle) void {
+ const s = self(ctx);
+ s.release(s.node(h));
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Tests: the scratch tree mounted at /scratch of a core server.
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+const test_cfg: core.Config = .{ .name = "tester", .msize = 8192, .max_fids = 32 };
+const TS = core.Server(test_cfg);
+
+const budget: usize = 1 << 20;
+
+const Fixture = struct {
+ ctx: u8 = 0,
+ shared: TS.Shared = undefined,
+ storage: TS.Storage = undefined,
+ scratch: Scratch = undefined,
+ h: TS.Harness = undefined,
+
+ fn init(x: *Fixture) !void {
+ x.shared = .init(&x.ctx);
+ x.scratch = try Scratch.init(testing.allocator, budget);
+ errdefer x.scratch.deinit();
+ try x.shared.addProvider(x.scratch.provider("scratch"));
+ try x.h.init(&x.shared, &x.storage);
+ }
+
+ fn deinit(x: *Fixture) void {
+ x.h.deinit();
+ x.scratch.deinit();
+ }
+
+ 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;
+ }
+};
+
+const dontcare = core.stat_dontcare;
+
+test "scratch create/write/read/rename/truncate/remove" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ try x.h.walkTo(1, &.{"scratch"});
+ // create + write
+ const cr = try x.h.ok(.{ .create = .{ .fid = 1, .name = "x", .perm = 0o644, .mode = cloud9.ordwr } });
+ try testing.expectEqual(cloud9.qtfile, cr.create.qid.type);
+ _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "hello" } });
+ _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 5, .data = " world" } });
+ const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
+ try testing.expectEqualStrings("hello world", r.read);
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ // rename x -> y
+ try x.h.walkTo(2, &.{ "scratch", "x" });
+ var st = dontcare;
+ st.name = "y";
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = st } });
+ try x.h.walkTo(3, &.{"scratch"});
+ try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 30, .names = &.{"x"} } }, "file does not exist");
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
+ try x.h.walkTo(3, &.{ "scratch", "y" });
+ // truncate then extend with zero fill
+ st = dontcare;
+ st.length = 2;
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 3, .stat = st } });
+ st.length = 4;
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 3, .stat = st } });
+ const text = try x.h.readAll(3);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings("he\x00\x00", text);
+ const s3 = try x.h.ok(.{ .stat = .{ .fid = 3 } });
+ try testing.expectEqualStrings("y", s3.stat.name);
+ try testing.expectEqual(@as(u64, 4), s3.stat.length);
+ try testing.expectEqualStrings("tester", s3.stat.uid);
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
+ // mkdir, nested create, remove rules
+ try x.h.walkTo(4, &.{"scratch"});
+ const dr = try x.h.ok(.{ .create = .{ .fid = 4, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.oread } });
+ try testing.expectEqual(cloud9.qtdir, dr.create.qid.type);
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 4 } });
+ try x.h.walkTo(5, &.{ "scratch", "d" });
+ _ = try x.h.ok(.{ .create = .{ .fid = 5, .name = "inner", .perm = 0o600, .mode = cloud9.owrite } });
+ _ = try x.h.ok(.{ .write = .{ .fid = 5, .offset = 0, .data = "z" } });
+ _ = 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.walkTo(7, &.{ "scratch", "d", "inner" });
+ _ = try x.h.ok(.{ .remove = .{ .fid = 7 } });
+ try x.h.walkTo(8, &.{ "scratch", "d" });
+ _ = try x.h.ok(.{ .remove = .{ .fid = 8 } });
+ try x.h.walkTo(9, &.{ "scratch", "y" });
+ _ = try x.h.ok(.{ .remove = .{ .fid = 9 } });
+ try x.h.walkTo(10, &.{"scratch"});
+ _ = try x.h.ok(.{ .open = .{ .fid = 10, .mode = cloud9.oread } });
+ const names = try x.h.listDir(10, 1024);
+ defer testing.allocator.free(names);
+ try testing.expectEqual(@as(usize, 0), names.len);
+ // append-only files ignore the offset
+ try x.h.walkTo(11, &.{"scratch"});
+ _ = try x.h.ok(.{ .create = .{ .fid = 11, .name = "log", .perm = cloud9.dmappend | 0o644, .mode = cloud9.ordwr } });
+ _ = try x.h.ok(.{ .write = .{ .fid = 11, .offset = 100, .data = "a" } });
+ _ = try x.h.ok(.{ .write = .{ .fid = 11, .offset = 0, .data = "b" } });
+ const lr = try x.h.ok(.{ .read = .{ .fid = 11, .offset = 0, .count = 10 } });
+ try testing.expectEqualStrings("ab", lr.read);
+ try testing.expect(lr.read.len == 2);
+ const ls = try x.h.ok(.{ .stat = .{ .fid = 11 } });
+ try testing.expect(ls.stat.qid.type & cloud9.qtappend != 0);
+ // the scratch root cannot be removed
+ try x.h.walkTo(12, &.{"scratch"});
+ try x.h.expectFail(.{ .remove = .{ .fid = 12 } }, "permission denied");
+}
+
+test "walk of a missing name and walking a file" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ try x.h.walkTo(1, &.{"scratch"});
+ try x.h.expectFail(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{"nope"} } }, "file does not exist");
+ _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "f", .perm = 0o644, .mode = cloud9.oread } });
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ try x.h.walkTo(3, &.{ "scratch", "f" });
+ try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"x"} } }, "not a directory");
+ // 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");
+ // .. 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"});
+ const up = try x.h.ok(.{ .walk = .{ .fid = 5, .newfid = 6, .names = &.{ "..", "scratch", "..", "README" } } });
+ try testing.expectEqual(@as(u16, 4), up.walk.nwqid);
+ try testing.expectEqual(@as(u64, 0), up.walk.wqid[1].path); // provider 0, root
+ try testing.expect(up.walk.wqid[0].type & cloud9.qtdir != 0);
+}
+
+test "directory read across consecutive offsets returns every record exactly once" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ const n = 40;
+ for (0..n) |i| {
+ try x.h.walkTo(1, &.{"scratch"});
+ var name_buf: [64]u8 = undefined;
+ const name = try std.fmt.bufPrint(&name_buf, "file-with-a-long-name-{d:0>3}", .{i});
+ _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = name, .perm = 0o644, .mode = cloud9.oread } });
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ }
+ try x.h.walkTo(2, &.{"scratch"});
+ _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
+ // 200 bytes fits two records, so this takes many reads.
+ const names = try x.h.listDir(2, 200);
+ defer TS.Harness.freeNames(names);
+ try testing.expectEqual(@as(usize, n), names.len);
+ var seen: [n]bool = @splat(false);
+ for (names) |nm| {
+ const idx = try std.fmt.parseInt(usize, nm[nm.len - 3 ..], 10);
+ try testing.expect(!seen[idx]);
+ 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.ok(.{ .clunk = .{ .fid = 2 } });
+ // Tversion resets every fid and every reference
+ try x.h.version(8192);
+ try testing.expectEqual(@as(usize, 0), x.h.conn.fidCount());
+ for (x.scratch.root.children.items) |ch| try testing.expectEqual(@as(u32, 0), ch.refs);
+ _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
+}
+
+test "DMEXCL admits one open fid at a time" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ try x.h.walkTo(1, &.{"scratch"});
+ const cr = try x.h.ok(.{ .create = .{ .fid = 1, .name = "lock", .perm = cloud9.dmexcl | 0o644, .mode = cloud9.owrite } });
+ try testing.expect(cr.create.qid.type & cloud9.qtexcl != 0);
+ try x.h.walkTo(2, &.{ "scratch", "lock" });
+ try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.oread } }, "exclusive use file already open");
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
+ try x.h.walkTo(3, &.{ "scratch", "lock" });
+ try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "exclusive use file already open");
+ // a Tversion reset drops the open and frees the file for the next session
+ try x.h.version(8192);
+ _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
+ try x.h.walkTo(4, &.{ "scratch", "lock" });
+ _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } });
+ try testing.expectEqual(@as(u32, 1), x.nodeOf(4).opens);
+ _ = try x.h.ok(.{ .remove = .{ .fid = 4 } });
+}
+
+test "scratch memory: zero-length writes, truncation frees, global budget" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ x.scratch.max_file = 4096;
+ try x.h.walkTo(1, &.{"scratch"});
+ _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "f", .perm = 0o644, .mode = cloud9.ordwr } });
+ // a zero-length write at a huge offset must not extend the file
+ const w0 = try x.h.ok(.{ .write = .{ .fid = 1, .offset = std.math.maxInt(u64), .data = "" } });
+ try testing.expectEqual(@as(u32, 0), w0.write);
+ var st = try x.h.ok(.{ .stat = .{ .fid = 1 } });
+ try testing.expectEqual(@as(u64, 0), st.stat.length);
+ // growth is charged to the budget; truncation releases it (memory too)
+ _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 1000, .data = "x" } });
+ try testing.expectEqual(@as(usize, 1001), x.scratch.bytes);
+ var ws = dontcare;
+ ws.length = 10;
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = ws } });
+ try testing.expectEqual(@as(usize, 10), x.scratch.bytes);
+ try testing.expectEqual(@as(usize, 10), x.nodeOf(1).data.capacity);
+ // per-file cap and the global budget both answer "no space"
+ try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 4096, .data = "x" } }, "no space left on device");
+ try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = std.math.maxInt(u64), .data = "x" } }, "no space left on device");
+ x.scratch.bytes = budget - 10; // pretend other files hold the rest
+ try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 10, .data = "0123456789A" } }, "no space left on device");
+ _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 10, .data = "0123456789" } });
+ try testing.expectEqual(budget, x.scratch.bytes);
+ ws.length = 4096;
+ try x.h.expectFail(.{ .wstat = .{ .fid = 1, .stat = ws } }, "no space left on device");
+ x.scratch.bytes -= budget - 20;
+ // OTRUNC releases too
+ try x.h.walkTo(2, &.{ "scratch", "f" });
+ _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.owrite | cloud9.otrunc } });
+ try testing.expectEqual(@as(usize, 0), x.scratch.bytes);
+ st = try x.h.ok(.{ .stat = .{ .fid = 2 } });
+ try testing.expectEqual(@as(u64, 0), st.stat.length);
+ // removing a file with content returns its bytes once the last fid lets go
+ _ = try x.h.ok(.{ .write = .{ .fid = 2, .offset = 0, .data = "abc" } });
+ try testing.expectEqual(@as(usize, 3), x.scratch.bytes);
+ _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
+ try testing.expectEqual(@as(usize, 3), x.scratch.bytes); // fid 1 still holds it
+ const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } });
+ try testing.expectEqualStrings("abc", r.read);
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ try testing.expectEqual(@as(usize, 0), x.scratch.bytes);
+}
+
+test "wstat with every field equal to the current stat changes nothing" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ 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;
+ 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";
+ _ = 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);
+ try testing.expectEqual(before.mtime, after.mtime);
+ try testing.expectEqual(before.mode, after.mode);
+ try testing.expectEqualStrings("same", after.name);
+ // and a rename to the very same name is also a no-op
+ var st = dontcare;
+ st.name = "same";
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = st } });
+ try testing.expectEqual(before.qid, (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.qid);
+ // renaming onto an existing sibling is refused
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ try x.h.walkTo(2, &.{"scratch"});
+ _ = try x.h.ok(.{ .create = .{ .fid = 2, .name = "other", .perm = 0o640, .mode = cloud9.oread } });
+ st.name = "same";
+ try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = st } }, "file already exists");
+ // 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");
+ st = dontcare;
+ st.mtime = 12345;
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = st } });
+ try testing.expectEqual(@as(u32, 12345), (try x.h.ok(.{ .stat = .{ .fid = 2 } })).stat.mtime);
+ _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
+}
+
+test "ORCLOSE removes on clunk and removed files stay readable through open fids" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ try x.h.walkTo(1, &.{"scratch"});
+ _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "tmp", .perm = 0o644, .mode = cloud9.ordwr | cloud9.orclose } });
+ _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "gone" } });
+ try x.h.walkTo(2, &.{ "scratch", "tmp" });
+ _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
+ try x.h.walkTo(3, &.{"scratch"});
+ try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"tmp"} } }, "file does not exist");
+ const r = try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 10 } });
+ try testing.expectEqualStrings("gone", r.read);
+ try testing.expectEqual(@as(usize, 4), x.scratch.bytes);
+ _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
+ try testing.expectEqual(@as(usize, 0), x.scratch.bytes);
+ // create inside a removed directory fails
+ _ = try x.h.ok(.{ .create = .{ .fid = 3, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.oread } });
+ try x.h.walkTo(5, &.{ "scratch", "d" });
+ _ = try x.h.ok(.{ .remove = .{ .fid = 5 } });
+ _ = 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
+}
+
+test "qid paths are stable identities, not addresses: remove + recreate differ" {
+ var x: Fixture = .{};
+ try x.init();
+ defer x.deinit();
+ try x.h.walkTo(1, &.{"scratch"});
+ const a = try x.h.ok(.{ .create = .{ .fid = 1, .name = "f", .perm = 0o644, .mode = cloud9.oread } });
+ const path_a = a.create.qid.path;
+ try testing.expectEqual(x.nodeOf(1).path, path_a & ((1 << 56) - 1));
+ try testing.expect(path_a != 0);
+ // a rename keeps the identity
+ var st = dontcare;
+ st.name = "g";
+ _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = st } });
+ try testing.expectEqual(path_a, (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.qid.path);
+ _ = try x.h.ok(.{ .remove = .{ .fid = 1 } });
+ // the allocator very likely reuses the freed node's address here
+ try x.h.walkTo(2, &.{"scratch"});
+ const b = try x.h.ok(.{ .create = .{ .fid = 2, .name = "f", .perm = 0o644, .mode = cloud9.oread } });
+ try testing.expect(b.create.qid.path != path_a);
+ _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
+ // the scratch root keeps path 0 (its handle), like every provider root
+ try x.h.walkTo(3, &.{"scratch"});
+ try testing.expectEqual(@as(u64, 0), (try x.h.ok(.{ .stat = .{ .fid = 3 } })).stat.qid.path);
+ // directory listing reports the same identities as walking
+ _ = try x.h.ok(.{ .create = .{ .fid = 3, .name = "listed", .perm = 0o644, .mode = cloud9.oread } });
+ const via_create = (try x.h.ok(.{ .stat = .{ .fid = 3 } })).stat.qid;
+ try x.h.walkTo(4, &.{"scratch"});
+ _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } });
+ const r = try x.h.ok(.{ .read = .{ .fid = 4, .offset = 0, .count = 1024 } });
+ const listed = try cloud9.Stat.decode(r.read[0 .. std.mem.readInt(u16, r.read[0..2], .little) + 2]);
+ try testing.expectEqual(via_create, listed.qid);
+ _ = try x.h.ok(.{ .remove = .{ .fid = 3 } });
+}