From b05abcba3ea09ea106ad28364c6e40a3ec31b890 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 21:26:05 -0300 Subject: 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 --- introspect/src/scratch.zig | 689 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 introspect/src/scratch.zig (limited to 'introspect/src/scratch.zig') 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 `/`. + 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 } }); +} -- cgit v1.3