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 --- 9player/src/main.zig | 444 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 9player/src/main.zig (limited to '9player/src/main.zig') diff --git a/9player/src/main.zig b/9player/src/main.zig new file mode 100644 index 0000000..24990b9 --- /dev/null +++ b/9player/src/main.zig @@ -0,0 +1,444 @@ +//! 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; +} -- cgit v1.3