summaryrefslogtreecommitdiff
path: root/9player/src/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
committerGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
commitba996acfcad1698adbf4a1834fe50e73b1c6cab9 (patch)
tree282ba00ce5b10d7416aecb9f2f0f0a439340a57d /9player/src/main.zig
parentb05abcba3ea09ea106ad28364c6e40a3ec31b890 (diff)
downloadcloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.tar.gz
cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.zip
Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web)
Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9player/src/main.zig')
-rw-r--r--9player/src/main.zig444
1 files changed, 0 insertions, 444 deletions
diff --git a/9player/src/main.zig b/9player/src/main.zig
deleted file mode 100644
index 24990b9..0000000
--- a/9player/src/main.zig
+++ /dev/null
@@ -1,444 +0,0 @@
-//! 9player: mount a 9P2000 tree into a fresh user+mount namespace via FUSE
-//! and run a program inside it.
-//!
-//! Exit codes: the child's status (128+sig if signalled); 125 for 9player's
-//! own failures (usage, connect, attach, namespace/mount); 126/127 for exec
-//! failures.
-
-const std = @import("std");
-const linux = std.os.linux;
-const ns = @import("ns.zig");
-const nine = @import("nine.zig");
-const bridge = @import("bridge.zig");
-
-const version_string = "9player 0.1.0";
-
-const usage_text =
- \\Usage: 9player [options] -- PROGRAM [ARGS...]
- \\Transport (exactly one):
- \\ --unix PATH Unix stream socket
- \\ --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
- \\Options:
- \\ --mount PATH mountpoint inside the new namespace (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)
- \\ --cache SECONDS attr/entry cache validity, may be fractional (default 1)
- \\ --no-direct-io let the kernel cache file pages (trusts stat length)
- \\ --debug trace FUSE and 9P operations on stderr
- \\ --help, --version
- \\PROGRAM defaults to $SHELL (else /bin/sh). The mountpoint is exported as $NINEPLAYER_MOUNT.
- \\
-;
-
-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.
-const max_msize: u32 = 16 * 1024 * 1024;
-
-/// Write `text` to stdout (informational output such as --help); errors are
-/// ignored, there is nowhere better to report them.
-fn printStdout(text: []const u8) void {
- var off: usize = 0;
- while (off < text.len) {
- const rc = linux.write(1, text[off..].ptr, text.len - off);
- switch (linux.errno(rc)) {
- .SUCCESS => off += rc,
- .INTR => continue,
- else => return,
- }
- }
-}
-
-const Config = struct {
- address: ?nine.Address = null,
- spawn_cmd: ?[]const u8 = null,
- mount: []const u8 = "/mnt/9p",
- uname: ?[]const u8 = null,
- aname: []const u8 = "",
- msize: u32 = 131072,
- cache_ns: u64 = 1_000_000_000,
- direct_io: bool = true,
- debug: bool = false,
- /// Empty means "default program".
- program: []const []const u8 = &.{},
-};
-
-const ParseResult = union(enum) {
- run: Config,
- /// Usage error, already reported on stderr; exit with this status.
- exit: u8,
- /// --help/--version: text for stdout, then exit 0. Printing is left to
- /// `main` so that no test path writes to fd 1 (under `zig build test`
- /// that is the test runner's protocol pipe).
- info: []const u8,
-};
-
-fn usageError(comptime fmt: []const u8, args: anytype) ParseResult {
- std.debug.print("9player: " ++ fmt ++ "\n(try 9player --help)\n", args);
- return .{ .exit = own_failure };
-}
-
-fn parseArgs(arena: std.mem.Allocator, args: []const [:0]const u8) !ParseResult {
- var cfg = Config{};
- var transports: usize = 0;
- var i: usize = 1;
- var program_start: ?usize = null;
- while (i < args.len) : (i += 1) {
- const arg: []const u8 = args[i];
- if (std.mem.eql(u8, arg, "--")) {
- program_start = i + 1;
- break;
- }
- if (!std.mem.startsWith(u8, arg, "--")) {
- // A single-dash word is a typo for an option, not a program.
- if (arg.len > 1 and arg[0] == '-') return usageError("unknown option {s} (options start with --)", .{arg});
- // A bare word starts PROGRAM, as if "--" were given.
- program_start = i;
- break;
- }
- // Split "--opt=value".
- var name = arg;
- var inline_value: ?[]const u8 = null;
- if (std.mem.indexOfScalar(u8, arg, '=')) |eq| {
- 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 = 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}),
- .unknown => return usageError("unknown option {s}", .{name}),
- else => {},
- }
- const value: []const u8 = switch (opt) {
- .@"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});
- break :blk args[i];
- },
- };
- switch (opt) {
- .unix => {
- if (value.len == 0) return usageError("--unix wants a socket path", .{});
- cfg.address = .{ .unix = value };
- transports += 1;
- },
- .tcp => {
- cfg.address = parseTcp(value) orelse return usageError("--tcp wants IP:PORT (IPv6 as [ADDR]:PORT), got '{s}'", .{value});
- transports += 1;
- },
- .fd => {
- const n = std.fmt.parseInt(i32, value, 10) catch return usageError("--fd wants a number, got '{s}'", .{value});
- if (n < 0) return usageError("--fd wants a non-negative number", .{});
- cfg.address = .{ .fd = n };
- transports += 1;
- },
- .spawn => {
- if (value.len == 0) return usageError("--spawn wants a command", .{});
- cfg.spawn_cmd = value;
- transports += 1;
- },
- .mount => {
- if (value.len == 0) return usageError("--mount wants a path", .{});
- cfg.mount = value;
- },
- .uname => cfg.uname = value,
- .aname => cfg.aname = value,
- .msize => {
- cfg.msize = std.fmt.parseInt(u32, value, 10) catch return usageError("--msize wants a number, got '{s}'", .{value});
- if (cfg.msize < 4096 or cfg.msize > max_msize) return usageError("--msize must be between 4096 and {d}", .{max_msize});
- },
- .cache => {
- const secs = std.fmt.parseFloat(f64, value) catch return usageError("--cache wants seconds, got '{s}'", .{value});
- if (!(secs >= 0) or secs > 1e9) return usageError("--cache out of range", .{});
- cfg.cache_ns = @intFromFloat(secs * 1e9);
- },
- .@"no-direct-io" => cfg.direct_io = false,
- .debug => cfg.debug = true,
- .help => return .{ .info = usage_text },
- .version => return .{ .info = version_string ++ "\n" },
- .unknown => unreachable,
- }
- }
- if (transports == 0) return usageError("one transport is required (--unix, --tcp, --fd or --spawn)", .{});
- if (transports > 1) return usageError("exactly one transport is allowed", .{});
- if (program_start) |start| {
- const prog = try arena.alloc([]const u8, args.len - start);
- for (args[start..], 0..) |a, j| prog[j] = a;
- cfg.program = prog;
- }
- return .{ .run = cfg };
-}
-
-fn parseTcp(spec: []const u8) ?nine.Address {
- const colon = std.mem.lastIndexOfScalar(u8, spec, ':') orelse return null;
- var host = spec[0..colon];
- if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') host = host[1 .. host.len - 1];
- if (host.len == 0) return null;
- const port = std.fmt.parseInt(u16, spec[colon + 1 ..], 10) catch return null;
- return .{ .tcp = .{ .host = host, .port = port } };
-}
-
-/// `--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 };
-
-fn spawnServer(cmd: [:0]const u8, envp: [*:null]const ?[*:0]const u8) !Server {
- var sv: [2]i32 = undefined;
- switch (linux.errno(linux.socketpair(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0, &sv))) {
- .SUCCESS => {},
- else => |e| {
- std.debug.print("9player: socketpair: E{t}\n", .{e});
- return error.SystemResources;
- },
- }
- const rc = linux.fork();
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- else => |e| {
- _ = linux.close(sv[0]);
- _ = linux.close(sv[1]);
- std.debug.print("9player: fork: E{t}\n", .{e});
- return error.SystemResources;
- },
- }
- if (rc == 0) {
- // Child: dup2 clears CLOEXEC on 0 and 1; everything else is CLOEXEC.
- if (linux.errno(linux.dup2(sv[1], 0)) != .SUCCESS or linux.errno(linux.dup2(sv[1], 1)) != .SUCCESS) linux.exit_group(125);
- // The server shares our process group, so a Ctrl-C meant for the
- // program would kill it and take the mount down with it: ignore the
- // tty signals (inherited across exec). SIGPIPE goes back to its
- // default, we only ignore it for ourselves.
- ignoreSignal(.INT);
- ignoreSignal(.QUIT);
- defaultSignal(.PIPE);
- const argv = [_:null]?[*:0]const u8{ "sh", "-c", cmd.ptr };
- const e = linux.errno(linux.execve("/bin/sh", &argv, envp));
- std.debug.print("9player: --spawn: exec /bin/sh: E{t}\n", .{e});
- linux.exit_group(127);
- }
- _ = linux.close(sv[1]);
- return .{ .pid = @intCast(rc), .fd = sv[0] };
-}
-
-fn stopServer(server: ?Server) void {
- const s = server orelse return;
- _ = linux.kill(s.pid, .TERM);
- ns.reapAny(s.pid);
-}
-
-/// Fail early (before spawning servers or forking) if /dev/fuse is unusable.
-fn probeFuseDevice() bool {
- const rc = linux.open("/dev/fuse", .{ .ACCMODE = .RDWR, .CLOEXEC = true }, 0);
- switch (linux.errno(rc)) {
- .SUCCESS => {
- _ = linux.close(@intCast(rc));
- return true;
- },
- .NOENT => std.debug.print("9player: /dev/fuse: ENOENT (is the fuse module loaded? try: modprobe fuse)\n", .{}),
- else => |e| std.debug.print("9player: open /dev/fuse: E{t}\n", .{e}),
- }
- return false;
-}
-
-fn ignoreSignal(sig: linux.SIG) void {
- const ign = linux.Sigaction{ .handler = .{ .handler = linux.SIG.IGN }, .mask = linux.sigemptyset(), .flags = 0 };
- std.posix.sigaction(sig, &ign, null);
-}
-
-fn defaultSignal(sig: linux.SIG) void {
- const dfl = linux.Sigaction{ .handler = .{ .handler = linux.SIG.DFL }, .mask = linux.sigemptyset(), .flags = 0 };
- std.posix.sigaction(sig, &dfl, null);
-}
-
-/// `--fd N`: the descriptor is ours from now on; it must not leak into the
-/// program (which could otherwise read 9P replies meant for us). Fails on a
-/// bad descriptor, which is the earliest place to report it.
-fn adoptFd(fd: i32) bool {
- switch (linux.errno(linux.fcntl(fd, linux.F.SETFD, linux.FD_CLOEXEC))) {
- .SUCCESS => return true,
- else => |e| {
- std.debug.print("9player: --fd {d}: E{t}\n", .{ fd, e });
- return false;
- },
- }
-}
-
-fn describeAddress(a: nine.Address, buf: []u8) []const u8 {
- return switch (a) {
- .unix => |p| std.fmt.bufPrint(buf, "unix socket {s}", .{p}) catch "unix socket",
- .tcp => |t| std.fmt.bufPrint(buf, "tcp {s}:{d}", .{ t.host, t.port }) catch "tcp",
- .fd => |fd| std.fmt.bufPrint(buf, "fd {d}", .{fd}) catch "fd",
- };
-}
-
-pub fn main(init: std.process.Init) !u8 {
- const gpa = init.gpa;
- const arena = init.arena.allocator();
- const args = try init.minimal.args.toSlice(arena);
- const envp: [*:null]const ?[*:0]const u8 = init.minimal.environ.block.slice.ptr;
-
- var cfg = switch (try parseArgs(arena, args)) {
- .exit => |code| return code,
- .info => |text| {
- printStdout(text);
- return 0;
- },
- .run => |c| c,
- };
-
- // Defaults that come from the environment.
- if (cfg.program.len == 0) {
- const env_shell = ns.getenv(envp, "SHELL") orelse "";
- const shell = if (env_shell.len == 0) "/bin/sh" else env_shell;
- 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("9player: --mount {s}: {t}\n", .{ cfg.mount, err });
- return own_failure;
- };
- defer gpa.free(mountpoint);
-
- if (!probeFuseDevice()) return own_failure;
-
- // Writes to a dead server socket must not kill us.
- ignoreSignal(.PIPE);
-
- var server: ?Server = null;
- var address: nine.Address = undefined;
- if (cfg.spawn_cmd) |cmd| {
- const cmd_z = try arena.dupeZ(u8, cmd);
- server = spawnServer(cmd_z, envp) catch return own_failure;
- ns.watchServer(server.?.pid);
- address = .{ .fd = server.?.fd };
- } else {
- address = cfg.address.?;
- if (address == .fd and !adoptFd(address.fd)) return own_failure;
- }
-
- var addr_buf: [256]u8 = undefined;
- var session = nine.Session.connect(gpa, address, cfg.msize) catch |err| {
- std.debug.print("9player: connect to {s}: {t}\n", .{ describeAddress(address, &addr_buf), err });
- stopServer(server);
- return own_failure;
- };
- defer session.deinit();
- defer stopServer(server);
-
- _ = session.attach(0, uname, cfg.aname) catch |err| {
- switch (err) {
- error.Nine => std.debug.print("9player: attach (uname={s}, aname='{s}'): {s}\n", .{ uname, cfg.aname, session.ename[0..session.ename_len] }),
- else => std.debug.print("9player: attach: {t}\n", .{err}),
- }
- return own_failure;
- };
- if (cfg.debug) std.debug.print("9player: attached to {s} (msize {d}), mounting on {s}\n", .{ describeAddress(address, &addr_buf), session.msize, mountpoint });
-
- 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.serve(gpa, child.fuse_fd, &session, 0, stop_fd, .{
- .uid = uid,
- .gid = gid,
- .attr_timeout_ns = cfg.cache_ns,
- .direct_io = cfg.direct_io,
- .debug = cfg.debug,
- }) catch |err| switch (err) {
- error.Closed => std.debug.print("9player: 9P server connection closed\n", .{}),
- else => std.debug.print("9player: 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;
- // An exec failure (126/127) is already in `status`; this prints its message.
- _ = ns.reportExecFailure(child);
- return status;
-}
-
-test "parseTcp" {
- const a = parseTcp("127.0.0.1:564").?;
- try std.testing.expectEqualStrings("127.0.0.1", a.tcp.host);
- try std.testing.expectEqual(@as(u16, 564), a.tcp.port);
- const b = parseTcp("[::1]:9999").?;
- try std.testing.expectEqualStrings("::1", b.tcp.host);
- try std.testing.expectEqual(@as(u16, 9999), b.tcp.port);
- try std.testing.expect(parseTcp("nohost") == null);
- try std.testing.expect(parseTcp(":564") == null);
- try std.testing.expect(parseTcp("1.2.3.4:") == null);
- try std.testing.expect(parseTcp("1.2.3.4:70000") == null);
-}
-
-test "parseArgs" {
- const arena = std.testing.allocator;
- {
- const args = [_][:0]const u8{ "9player", "--unix", "/s", "--cache", "0.5", "--msize=8192", "--no-direct-io", "--", "sh", "-c", "x" };
- const r = try parseArgs(arena, &args);
- defer arena.free(r.run.program);
- try std.testing.expectEqualStrings("/s", r.run.address.?.unix);
- try std.testing.expectEqual(@as(u64, 500_000_000), r.run.cache_ns);
- try std.testing.expectEqual(@as(u32, 8192), r.run.msize);
- try std.testing.expect(!r.run.direct_io);
- try std.testing.expectEqual(@as(usize, 3), r.run.program.len);
- try std.testing.expectEqualStrings("x", r.run.program[2]);
- }
- {
- const args = [_][:0]const u8{ "9player", "--fd", "3" };
- 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);
- }
- {
- // Two transports, no transport, unknown option, missing value: all 125.
- const two = [_][:0]const u8{ "9player", "--fd", "3", "--unix", "/s" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &two)).exit);
- const none = [_][:0]const u8{ "9player", "--", "sh" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &none)).exit);
- const unknown = [_][:0]const u8{ "9player", "--bogus" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &unknown)).exit);
- const missing = [_][:0]const u8{ "9player", "--unix" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &missing)).exit);
- const badcache = [_][:0]const u8{ "9player", "--fd", "3", "--cache", "abc" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &badcache)).exit);
- // Empty values, a single-dash typo, and an msize that would allocate gigabytes.
- const emptyunix = [_][:0]const u8{ "9player", "--unix=", "--", "sh" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &emptyunix)).exit);
- const emptymount = [_][:0]const u8{ "9player", "--fd", "3", "--mount", "" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &emptymount)).exit);
- const singledash = [_][:0]const u8{ "9player", "--fd", "3", "-mount", "/x" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &singledash)).exit);
- const hugemsize = [_][:0]const u8{ "9player", "--fd", "3", "--msize", "4294967295" };
- try std.testing.expectEqual(@as(u8, 125), (try parseArgs(arena, &hugemsize)).exit);
- const okmsize = [_][:0]const u8{ "9player", "--fd", "3", "--msize", "16777216" };
- try std.testing.expectEqual(@as(u32, 16777216), (try parseArgs(arena, &okmsize)).run.msize);
- }
- {
- const ver = [_][:0]const u8{ "9player", "--version" };
- try std.testing.expectEqualStrings(version_string ++ "\n", (try parseArgs(arena, &ver)).info);
- const help = [_][:0]const u8{ "9player", "--help" };
- try std.testing.expect(std.mem.startsWith(u8, (try parseArgs(arena, &help)).info, "Usage: 9player"));
- }
-}
-
-test {
- _ = ns;
-}