summaryrefslogtreecommitdiff
path: root/9player/src/main.zig
diff options
context:
space:
mode:
Diffstat (limited to '9player/src/main.zig')
-rw-r--r--9player/src/main.zig444
1 files changed, 444 insertions, 0 deletions
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;
+}