From 3e9f8805f293f622bb885cf849b5ce47dc062ad1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 23:55:47 -0300 Subject: 9ns: --name and /mnt/9p/ mounts, qid.path as inode number, interrupts as Tflush - --name NAME (default derived from the transport: socket basename, tcp-IP-PORT, spawned command, fdN) mounts at /mnt/9p/; --mount still overrides. ensureMountpoint walks down and creates missing components, shadowing the deepest unwritable ancestor. NINE_MOUNT is the only exported variable. - The inode number reported to the kernel is the 9P qid.path for every node, root included; a server handing qid.path 1 to a file (Pardes /self) no longer collides with the root. - FUSE_INTERRUPT for the request in flight becomes Tflush; a blocked read returns EINTR when the server answers the flush, chunked transfers return short counts, other requests arriving meanwhile are stashed and served next. Servers ignoring Tflush still block until they answer. - 9ns-test now covers nine/bridge/fuse; new adv_bridge_interrupt suite (28); 9ns-itest grows to 88 checks. Co-Authored-By: Claude Fable 5.1 --- 9ns/src/main.zig | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 6 deletions(-) (limited to '9ns/src/main.zig') diff --git a/9ns/src/main.zig b/9ns/src/main.zig index 26bd699..076aa42 100644 --- a/9ns/src/main.zig +++ b/9ns/src/main.zig @@ -21,7 +21,9 @@ const usage_text = \\ --fd N already-connected inherited descriptor \\ --spawn CMD run CMD (via /bin/sh -c) with a socketpair on its stdin/stdout \\Options: - \\ --mount PATH mountpoint inside the new namespace (default /mnt/9p) + \\ --name NAME mount name: the tree appears at /mnt/9p/NAME (one path + \\ component; default derived from the transport, see below) + \\ --mount PATH mountpoint inside the new namespace (overrides --name) \\ --uname NAME 9P user name (default $USER, else "none") \\ --aname NAME 9P tree to attach (default "") \\ --msize BYTES maximum 9P message size to request (default 131072) @@ -30,9 +32,17 @@ const usage_text = \\ --debug trace FUSE and 9P operations on stderr \\ --help, --version \\PROGRAM defaults to $SHELL (else /bin/sh). The mountpoint is exported as $NINE_MOUNT. + \\Default name: --unix PATH -> basename of PATH without .sock/.9p/.socket; + \\--tcp IP:PORT -> tcp-IP-PORT (':' becomes '-'); --spawn CMD -> basename of its + \\first word; --fd N -> fdN; 9p when nothing usable comes out of that. \\ ; +/// Where `--name NAME` mounts: `mount_root/NAME`. +const mount_root = "/mnt/9p"; +/// Name used when nothing usable can be derived from the transport. +const fallback_name = "9p"; + const own_failure: u8 = 125; /// Largest 9P message size we agree to request: the session allocates two /// buffers of this size up front, before the server negotiates it down. @@ -55,7 +65,10 @@ fn printStdout(text: []const u8) void { const Config = struct { address: ?nine.Address = null, spawn_cmd: ?[]const u8 = null, - mount: []const u8 = "/mnt/9p", + /// `--mount`: wins over `name` when set. + mount: ?[]const u8 = null, + /// `--name`: null means "derive from the transport" (see `defaultName`). + name: ?[]const u8 = null, uname: ?[]const u8 = null, aname: []const u8 = "", msize: u32 = 131072, @@ -106,7 +119,7 @@ fn parseArgs(arena: std.mem.Allocator, args: []const [:0]const u8) !ParseResult name = arg[0..eq]; inline_value = arg[eq + 1 ..]; } - const Opt = enum { unix, tcp, fd, spawn, mount, uname, aname, msize, cache, @"no-direct-io", debug, help, version, unknown }; + const Opt = enum { unix, tcp, fd, spawn, name, mount, uname, aname, msize, cache, @"no-direct-io", debug, help, version, unknown }; const opt = std.meta.stringToEnum(Opt, name[2..]) orelse .unknown; switch (opt) { .@"no-direct-io", .debug, .help, .version => if (inline_value != null) return usageError("{s} takes no value", .{name}), @@ -142,6 +155,10 @@ fn parseArgs(arena: std.mem.Allocator, args: []const [:0]const u8) !ParseResult cfg.spawn_cmd = value; transports += 1; }, + .name => { + if (!validName(value)) return usageError("--name wants a single path component (not empty, no '/', not . or ..), got '{s}'", .{value}); + cfg.name = value; + }, .mount => { if (value.len == 0) return usageError("--mount wants a path", .{}); cfg.mount = value; @@ -183,6 +200,52 @@ fn parseTcp(spec: []const u8) ?nine.Address { return .{ .tcp = .{ .host = host, .port = port } }; } +/// A mount name is one path component: non-empty, no '/', no NUL, not `.` +/// or `..`. +fn validName(name: []const u8) bool { + if (name.len == 0) return false; + if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + for (name) |c| if (c == '/' or c == 0) return false; + return true; +} + +/// The mount name derived from the transport when `--name` is absent: +/// `--unix PATH` → basename of PATH without a trailing `.sock`/`.9p`/ +/// `.socket`; `--tcp IP:PORT` → `tcp-IP-PORT` with every ':' turned into +/// '-' (so an IPv6 literal stays one component); `--spawn CMD` → basename +/// of CMD's first word; `--fd N` → `fdN`. Anything that does not come out +/// as a valid name (empty basename, `..`, ...) becomes `9p`. The result is +/// written into `buf` (at most `buf.len` bytes; longer inputs fall back). +fn defaultName(buf: []u8, cfg: Config) []const u8 { + const raw: []const u8 = blk: { + if (cfg.spawn_cmd) |cmd| { + var words = std.mem.tokenizeAny(u8, cmd, " \t\r\n"); + break :blk std.fs.path.basename(words.next() orelse ""); + } + switch (cfg.address orelse return fallback_name) { + .unix => |path| { + const base = std.fs.path.basename(path); + inline for (.{ ".sock", ".socket", ".9p" }) |ext| { + if (base.len > ext.len and std.mem.endsWith(u8, base, ext)) break :blk base[0 .. base.len - ext.len]; + } + break :blk base; + }, + .tcp => |t| { + const text = std.fmt.bufPrint(buf, "tcp-{s}-{d}", .{ t.host, t.port }) catch return fallback_name; + std.mem.replaceScalar(u8, text, ':', '-'); + return if (validName(text)) text else fallback_name; + }, + .fd => |fd| { + const text = std.fmt.bufPrint(buf, "fd{d}", .{fd}) catch return fallback_name; + return text; + }, + } + }; + if (!validName(raw) or raw.len > buf.len) return fallback_name; + @memcpy(buf[0..raw.len], raw); + return buf[0..raw.len]; +} + /// `--spawn`: run CMD under /bin/sh with one end of a socketpair as its /// stdin/stdout; the other end is the 9P transport. const Server = struct { pid: i32, fd: i32 }; @@ -298,8 +361,15 @@ pub fn main(init: std.process.Init) !u8 { cfg.program = try arena.dupe([]const u8, &.{shell}); } const uname = cfg.uname orelse ns.getenv(envp, "USER") orelse "none"; - const mountpoint = ns.resolveMountpoint(gpa, cfg.mount) catch |err| { - std.debug.print("9ns: --mount {s}: {t}\n", .{ cfg.mount, err }); + // `--mount PATH` wins; otherwise `/mnt/9p/` with `--name` or a + // name derived from the transport. + var name_buf: [512]u8 = undefined; + const mount_arg: []const u8 = cfg.mount orelse blk: { + const name = cfg.name orelse defaultName(&name_buf, cfg); + break :blk try std.fmt.allocPrint(arena, mount_root ++ "/{s}", .{name}); + }; + const mountpoint = ns.resolveMountpoint(gpa, mount_arg) catch |err| { + std.debug.print("9ns: --mount {s}: {t}\n", .{ mount_arg, err }); return own_failure; }; defer gpa.free(mountpoint); @@ -405,7 +475,21 @@ test "parseArgs" { const r = try parseArgs(arena, &args); try std.testing.expectEqual(@as(i32, 3), r.run.address.?.fd); try std.testing.expectEqual(@as(usize, 0), r.run.program.len); - try std.testing.expectEqualStrings("/mnt/9p", r.run.mount); + try std.testing.expect(r.run.mount == null); + try std.testing.expect(r.run.name == null); + } + { + const named = [_][:0]const u8{ "9ns", "--fd", "3", "--name", "bar", "--mount=/x" }; + const r = try parseArgs(arena, &named); + try std.testing.expectEqualStrings("bar", r.run.name.?); + try std.testing.expectEqualStrings("/x", r.run.mount.?); + const eq = [_][:0]const u8{ "9ns", "--fd", "3", "--name=baz" }; + try std.testing.expectEqualStrings("baz", (try parseArgs(arena, &eq)).run.name.?); + // Invalid names: a path, empty, . and .. + for ([_][:0]const u8{ "a/b", "", ".", "..", "/" }) |bad| { + const args = [_][:0]const u8{ "9ns", "--fd", "3", "--name", bad }; + try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &args)).exit); + } } { // Two transports, no transport, unknown option, missing value: all 125. @@ -439,6 +523,43 @@ test "parseArgs" { } } +test "defaultName" { + var buf: [512]u8 = undefined; + const Case = struct { cfg: Config, want: []const u8 }; + const cases = [_]Case{ + .{ .cfg = .{ .address = .{ .unix = "/tmp/9debug.sock" } }, .want = "9debug" }, + .{ .cfg = .{ .address = .{ .unix = "/run/user/1000/acme" } }, .want = "acme" }, + .{ .cfg = .{ .address = .{ .unix = "ramfs.9p" } }, .want = "ramfs" }, + .{ .cfg = .{ .address = .{ .unix = "/x/y.socket" } }, .want = "y" }, + .{ .cfg = .{ .address = .{ .unix = "/x/.sock" } }, .want = ".sock" }, // the whole name, not empty + .{ .cfg = .{ .address = .{ .unix = "/x/y/" } }, .want = "y" }, + .{ .cfg = .{ .address = .{ .unix = "/" } }, .want = "9p" }, + .{ .cfg = .{ .address = .{ .unix = "/x/.." } }, .want = "9p" }, + .{ .cfg = .{ .address = .{ .tcp = .{ .host = "127.0.0.1", .port = 564 } } }, .want = "tcp-127.0.0.1-564" }, + .{ .cfg = .{ .address = .{ .tcp = .{ .host = "::1", .port = 9999 } } }, .want = "tcp---1-9999" }, + .{ .cfg = .{ .address = .{ .fd = 3 } }, .want = "fd3" }, + .{ .cfg = .{ .spawn_cmd = "/x/9proc-demo --stdio" }, .want = "9proc-demo" }, + .{ .cfg = .{ .spawn_cmd = " ramfs\t-s" }, .want = "ramfs" }, + .{ .cfg = .{ .spawn_cmd = " " }, .want = "9p" }, + .{ .cfg = .{}, .want = "9p" }, + }; + for (cases) |c| try std.testing.expectEqualStrings(c.want, defaultName(&buf, c.cfg)); +} + +test "validName" { + try std.testing.expect(validName("a")); + try std.testing.expect(validName("tcp-127.0.0.1-564")); + try std.testing.expect(validName("...")); + try std.testing.expect(!validName("")); + try std.testing.expect(!validName(".")); + try std.testing.expect(!validName("..")); + try std.testing.expect(!validName("a/b")); + try std.testing.expect(!validName("a\x00b")); +} + test { _ = ns; + _ = @import("nine.zig"); + _ = @import("bridge.zig"); + _ = @import("fuse.zig"); } -- cgit v1.3