diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 14:13:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-21 14:13:43 -0300 |
| commit | 3a23f6a29e47ace901bd4d82b9db4055fcc12bb9 (patch) | |
| tree | b82d6e7c3ebe108434ce00ca75db59cf037917e0 /9ns/src/main.zig | |
| parent | f1b53c1533539aecbf16ad19fd9156deae091f92 (diff) | |
| download | cloud9-3a23f6a29e47ace901bd4d82b9db4055fcc12bb9.tar.gz cloud9-3a23f6a29e47ace901bd4d82b9db4055fcc12bb9.zip | |
post registry + 9ns --mntgen: the /srv translation
cloud9.post: servers post their socket under a name in
$XDG_RUNTIME_DIR/9p (post/unpost, posted, dial, Watch) and
serve.Runner.listenPosted posts a server by name, unposting on stop.
Names are budget-checked against the 108-byte socket path; a claim
binds+listens at a private temp path and takes the name with atomic
renames under flock (RENAME_NOREPLACE for free names, RENAME_EXCHANGE
grab-verify-commit for stale ones): the registry path is never unlinked
by a claim, live names refuse with AlreadyPosted, foreign files with
NotSocket, and unpost removes only the caller's inode-matched entry.
Watch surfaces inotify overflow and a replaced registry dir.
9ns --mntgen [--mount DIR] -- PROGRAM: one FUSE mount at /mnt/9p whose
synthetic root lists the posted registry (no connection made); a walk
into an unmounted name dials it and runs the existing bridge dispatch
in a per-server worker thread, routed by mount index in the node id's
top bits (ordinals never reused, cap 4096); a dead server answers EIO
on its subtree and is re-dialed on the next walk. The dial watches
stop_fd through Tversion (connectWatched). All existing 9ns forms are
unchanged.
9proc's unix listener no longer blind-unlinks its path: a foreign
non-socket is refused (Occupied), a live server is refused
(AlreadyListening), only a refused socket is cleared, and stop()
unlinks only the listener's own inode-matched socket.
Hardened by adversarial review (GLM 5.3 x2 + DeepSeek V4.1 Flash, all
high-thinking): double-bind races on one name (0 in 180k rounds),
foreign-file TOCTOU deletions (0 in 4M flips), a 255-byte-name listing
panic, inotify queue overflow silently dropped, listenPosted silently
overwriting, dial-time Tversion hangs wedging the dispatcher, --debug
silently ignored in mntgen, and xattr/statx probes answering EPERM on
the synthetic root (broke `ls -l /mnt/9p`).
Tests: root 80/80, 9ns 47/47, 9proc 60/60, integration 88/88 +
mntgen 37/37, adversarial 213/0, freestanding riscv32 gate green.
Diffstat (limited to '9ns/src/main.zig')
| -rw-r--r-- | 9ns/src/main.zig | 116 |
1 files changed, 108 insertions, 8 deletions
diff --git a/9ns/src/main.zig b/9ns/src/main.zig index 076aa42..386552f 100644 --- a/9ns/src/main.zig +++ b/9ns/src/main.zig @@ -7,6 +7,7 @@ const std = @import("std"); const linux = std.os.linux; +const cloud9 = @import("cloud9"); const ns = @import("ns.zig"); const nine = @import("nine.zig"); const bridge = @import("bridge.zig"); @@ -20,10 +21,16 @@ const usage_text = \\ --tcp IP:PORT TCP (IPv4/IPv6 literal) \\ --fd N already-connected inherited descriptor \\ --spawn CMD run CMD (via /bin/sh -c) with a socketpair on its stdin/stdout + \\ --mntgen mount the posted-9P registry ($XDG_RUNTIME_DIR/9p): one + \\ mount whose root lists the posted names; walking into a + \\ name dials that server (mutually exclusive with the rest) \\Options: \\ --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) + \\ component; default derived from the transport, see below; + \\ not with --mntgen) + \\ --mount PATH mountpoint inside the new namespace (overrides --name; + \\ with --mntgen the mount is the registry view itself, + \\ default /mnt/9p) \\ --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) @@ -35,7 +42,7 @@ const usage_text = \\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. - \\ + \\--mntgen: no per-server name; the registry mount goes to --mount (default /mnt/9p). ; /// Where `--name NAME` mounts: `mount_root/NAME`. @@ -61,10 +68,12 @@ fn printStdout(text: []const u8) void { } } } - const Config = struct { address: ?nine.Address = null, spawn_cmd: ?[]const u8 = null, + /// `--mntgen`: the mount lists the posted-9P registry and dials servers + /// lazily (see `runMntgen`); mutually exclusive with the transports. + mntgen: bool = false, /// `--mount`: wins over `name` when set. mount: ?[]const u8 = null, /// `--name`: null means "derive from the transport" (see `defaultName`). @@ -119,15 +128,15 @@ 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, name, mount, uname, aname, msize, cache, @"no-direct-io", debug, help, version, unknown }; + const Opt = enum { unix, tcp, fd, spawn, mntgen, 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}), + .mntgen, .@"no-direct-io", .debug, .help, .version => if (inline_value != null) return usageError("{s} takes no value", .{name}), .unknown => return usageError("unknown option {s}", .{name}), else => {}, } const value: []const u8 = switch (opt) { - .@"no-direct-io", .debug, .help, .version, .unknown => "", + .mntgen, .@"no-direct-io", .debug, .help, .version, .unknown => "", else => inline_value orelse blk: { i += 1; if (i >= args.len) return usageError("{s} needs a value", .{name}); @@ -155,6 +164,10 @@ fn parseArgs(arena: std.mem.Allocator, args: []const [:0]const u8) !ParseResult cfg.spawn_cmd = value; transports += 1; }, + .mntgen => { + cfg.mntgen = true; + 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; @@ -181,7 +194,8 @@ fn parseArgs(arena: std.mem.Allocator, args: []const [:0]const u8) !ParseResult .unknown => unreachable, } } - if (transports == 0) return usageError("one transport is required (--unix, --tcp, --fd or --spawn)", .{}); + if (cfg.mntgen and cfg.name != null) return usageError("--name is not meaningful with --mntgen (the registry mount is --mount, default {s})", .{mount_root}); + if (transports == 0) return usageError("one transport is required (--unix, --tcp, --fd, --spawn or --mntgen)", .{}); if (transports > 1) return usageError("exactly one transport is allowed", .{}); if (program_start) |start| { const prog = try arena.alloc([]const u8, args.len - start); @@ -338,6 +352,71 @@ fn describeAddress(a: nine.Address, buf: []u8) []const u8 { .fd => |fd| std.fmt.bufPrint(buf, "fd {d}", .{fd}) catch "fd", }; } +/// `--mntgen`: one FUSE mount whose root lists the posted-9P registry +/// (`$XDG_RUNTIME_DIR/9p`, the /srv translation of cloud9.post). Servers +/// are dialed lazily when the program walks into their name; see +/// `bridge.serveMntgen` for the process model. Fails before anything is +/// forked when XDG_RUNTIME_DIR is unset or /dev/fuse is unusable. +fn runMntgen(init: std.process.Init, envp: [*:null]const ?[*:0]const u8, cfg: Config, uname: []const u8) !u8 { + const gpa = init.gpa; + const mount_arg = cfg.mount orelse mount_root; + 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); + + // The registry must be nameable before anything is forked; there is no + // fallback directory (post.registryDir errors rather than guess /tmp). + var reg_buf: [128]u8 = undefined; + _ = cloud9.post.registryDir(envp, ®_buf) catch |err| { + std.debug.print("9ns: --mntgen: {t} (the posted-9P registry is $XDG_RUNTIME_DIR/9p)\n", .{err}); + return own_failure; + }; + + if (!probeFuseDevice()) return own_failure; + + // Writes to a dead server socket must not kill us. + ignoreSignal(.PIPE); + + var child_pid: i32 = 0; + const stop_fd = ns.installSignals(&child_pid) catch return own_failure; + + const uid = linux.getuid(); + const gid = linux.getgid(); + const child = ns.spawn(gpa, .{ + .argv = cfg.program, + .envp = envp, + .mountpoint = mountpoint, + .uid = uid, + .gid = gid, + .max_read = bridge.max_write, + }) catch return own_failure; + + bridge.serveMntgen(gpa, child.fuse_fd, stop_fd, .{ + .io = init.io, + .env = envp, + .uname = uname, + .aname = cfg.aname, + .msize = cfg.msize, + }, .{ + .uid = uid, + .gid = gid, + .attr_timeout_ns = cfg.cache_ns, + .direct_io = cfg.direct_io, + .debug = cfg.debug, + }) catch |err| { + std.debug.print("9ns: fuse: {t}\n", .{err}); + }; + + // Closing the device aborts the FUSE connection: anything still using + // the mount gets ENOTCONN instead of hanging on an unserved request. + _ = linux.close(child.fuse_fd); + + const status = ns.reapIfExited(child.pid) orelse ns.waitChild(child.pid) catch own_failure; + _ = ns.reportExecFailure(child); + return status; +} pub fn main(init: std.process.Init) !u8 { const gpa = init.gpa; @@ -361,6 +440,7 @@ 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"; + if (cfg.mntgen) return runMntgen(init, envp, cfg, uname); // `--mount PATH` wins; otherwise `/mnt/9p/<name>` with `--name` or a // name derived from the transport. var name_buf: [512]u8 = undefined; @@ -516,6 +596,26 @@ test "parseArgs" { try std.testing.expectEqual(@as(u32, 16777216), (try parseArgs(arena, &okmsize)).run.msize); } { + // --mntgen is a transport: exclusive with the others, no value, + // --name rejected, options still apply to the per-server dials. + const ok = [_][:0]const u8{ "9ns", "--mntgen", "--mount", "/m", "--msize=8192", "--", "sh" }; + const r = try parseArgs(arena, &ok); + defer arena.free(r.run.program); + try std.testing.expect(r.run.mntgen); + try std.testing.expect(r.run.address == null); + try std.testing.expectEqualStrings("/m", r.run.mount.?); + try std.testing.expectEqual(@as(u32, 8192), r.run.msize); + try std.testing.expectEqual(@as(usize, 1), r.run.program.len); + const withunix = [_][:0]const u8{ "9ns", "--mntgen", "--unix", "/s", "--", "sh" }; + try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &withunix)).exit); + const withspawn = [_][:0]const u8{ "9ns", "--spawn", "x", "--mntgen", "--", "sh" }; + try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &withspawn)).exit); + const withname = [_][:0]const u8{ "9ns", "--mntgen", "--name", "foo", "--", "sh" }; + try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &withname)).exit); + const withvalue = [_][:0]const u8{ "9ns", "--mntgen=x", "--", "sh" }; + try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &withvalue)).exit); + } + { const ver = [_][:0]const u8{ "9ns", "--version" }; try std.testing.expectEqualStrings(version_string ++ "\n", (try parseArgs(arena, &ver)).info); const help = [_][:0]const u8{ "9ns", "--help" }; |
