summaryrefslogtreecommitdiff
path: root/9ns/src/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 14:13:43 -0300
committerGabriel Schneider <[email protected]>2026-09-21 14:13:43 -0300
commit3a23f6a29e47ace901bd4d82b9db4055fcc12bb9 (patch)
treeb82d6e7c3ebe108434ce00ca75db59cf037917e0 /9ns/src/main.zig
parentf1b53c1533539aecbf16ad19fd9156deae091f92 (diff)
downloadcloud9-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.zig116
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, &reg_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" };