1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
|
# 9player design
`9player` mounts a 9P2000 file tree served over a Unix or TCP stream socket
into a **fresh mount namespace** and runs a program inside it. The program
(fish, bash, `claude`, anything) sees the 9P tree as ordinary files, without
root and without touching the host's mount table.
## Why FUSE
The kernel's own `9p` filesystem is not mountable inside an unprivileged user
namespace (it lacks `FS_USERNS_MOUNT`) and loading it needs root. FUSE has been
user-namespace mountable since Linux 4.18, and `/dev/fuse` is world read/write.
So 9player is a tiny FUSE server that speaks 9P2000 to the real server:
```
program (fish/bash/claude) 9player (parent) 9P server
in new user+mount namespace │ (introspect,
/mnt/9p ─── FUSE ───▶ kernel ──▶│ fuse.zig ──▶ bridge.zig ──▶ nine.zig ──▶ ramfs, ...)
│ (framing) (translation) (cloud9 Client)
```
No libfuse: `src/fuse.zig` implements the small subset of the kernel FUSE
protocol we need directly against `/usr/include/linux/fuse.h`.
## Toolchain facts (Zig 0.16)
* Zig 0.16.0 at `/usr/bin/zig`, std at `/usr/lib/zig/std`. **Grep the std
tree before assuming an API exists**; 0.16 moved a lot of process/fs code
behind `std.Io`. Raw Linux syscalls in `std.os.linux` (`fork`, `execve`,
`mount`, `unshare`, `waitpid`, `pipe2`, `socketpair`, `poll`, `read`, `write`,
`open`, `openat`, `getdents64`, `sigaction`, `kill`, `readlinkat`, `mkdirat`,
`symlinkat`) are the intended low-level path. They return `usize`; decode
with `std.os.linux.errno(rc)` (an `E` enum, `.SUCCESS` when ok).
* `std.posix.poll`, `std.posix.sigaction`, `std.posix.read`, `std.posix.kill`
exist. `std.posix.fork/execve/waitpid/pipe2/socketpair` do **not**.
* `pub fn main() !void` and `pub fn main(init: std.process.Init) !void` are
both supported. Prefer `main(init: std.process.Init)`; `init.gpa` is a
general purpose allocator, `init.arena` an arena, `init.minimal.args` the
argv (`toSlice(allocator)`), `init.minimal.environ.block` the envp block.
* No libc is linked. Do not use `std.c.*`. Hostname lookups are therefore out
of scope: `--tcp` takes IP literals only.
* 9player lives in the cloud9 repository as `cloud9/9player/` and is built by
the root `build.zig` through the fragment `9player/build.zig` (steps
`9player`, `9player-test`, `9player-itest`, `9player-adv`; toggle
`-D9player`). cloud9 itself is imported as module `cloud9`
(`@import("cloud9")`). Read `../src/client.zig`, `Server.zig`, `wire.zig`
and `../docs/design.md`. Its core is allocation-free and caller-driven: you
push bytes in, take results out. The demo 9P server the tests mount is the
sibling program `../introspect` (`zig build introspect`).
* Standalone module tests while other files are missing (from the cloud9
root):
`zig test --dep cloud9 -Mroot=9player/src/<file>.zig -Mcloud9=src/root.zig`.
* Format everything with `zig fmt`.
## Process model
```
9player [options] -- PROGRAM [ARGS...]
```
1. Parent parses args, probes that `/dev/fuse` exists, connects to the 9P
server, negotiates `version` and `attach`es (fid 0 = root). Connection
failures are reported before anything is forked.
2. Parent forks with a `socketpair` status channel. **Child**:
1. `unshare(CLONE_NEWUSER | CLONE_NEWNS)`.
2. Writes `/proc/self/setgroups` = `deny`, `/proc/self/uid_map` =
`"<uid> <uid> 1"`, `/proc/self/gid_map` = `"<gid> <gid> 1"` (same ids
inside as outside; the child creating the namespace holds full
capabilities in it until exec).
3. `mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL)` so nothing propagates.
4. Ensures the mountpoint exists (see below).
5. Opens `/dev/fuse` (`O_RDWR|O_CLOEXEC`). The kernel refuses to mount a
fuse descriptor opened from a different user namespace than the mount
("wrong user namespace for fuse device"), so this must happen here, not
in the parent.
6. `mount("9player", mountpoint, "fuse", MS_NOSUID|MS_NODEV,
"fd=<fusefd>,rootmode=40000,user_id=<uid>,group_id=<gid>,max_read=<n>")`.
7. Sends the fuse fd to the parent over the status socket (`SCM_RIGHTS`).
8. `statx` of the mountpoint: this forces one GETATTR, which the parent
serves. Without it the kernel keeps the root inode's initial uid 0
(unmapped in the namespace) and every create in the root gets `EACCES`.
9. Sets `NINEPLAYER_MOUNT=<mountpoint>` in the environment.
10. `execve` of PROGRAM with PATH search (implemented by hand; no libc).
Exec failures are reported through the `CLOEXEC` status socket
(errno + message); the parent prints them after the serve loop ends.
3. **Parent** receives the fuse fd, then runs the FUSE loop (`bridge.serve`)
until either the child exits (SIGCHLD via self-pipe) or the FUSE fd reports
`ENODEV` (last process in the namespace gone, mount destroyed). It then
closes the fuse fd and exits with the child's status (`128+sig` if
signalled). The self-pipe is also watched by the 9P session while a reply
is outstanding (`Session.stop_fd` → `error.Stopped`), so a server that
never answers cannot keep 9player alive after the child is gone; a 3 s
watchdog armed from the SIGCHLD handler is the last resort.
4. Signals in the parent: `SIGINT`/`SIGQUIT` ignored (the child owns the tty
and gets them itself); `SIGTERM`/`SIGHUP` forwarded to the child;
`SIGPIPE` ignored; `SIGCHLD` → self-pipe.
The FUSE fd is shared with the child only until exec (CLOEXEC); the parent's
copy keeps the connection alive.
### Mountpoint policy
Default mountpoint: `/mnt/9p`. A relative `--mount` is resolved against cwd.
* If the path is a directory: use it.
* Else try `mkdir`. If that fails with `EACCES`/`EPERM`/`EROFS` (the normal
case for `/mnt/9p` as a plain user), **shadow the parent directory**:
open an fd to the parent, mount a `tmpfs` over it, then recreate every
existing entry inside the tmpfs: directories → `mkdir` + bind mount from
`/proc/self/fd/<fd>/<name>`; symlinks → `readlinkat` + `symlink`; anything
else → empty regular file + bind mount. Then `mkdir` the target inside.
Refuse (with a clear message) if the parent has more than 4096 entries or
is `/`. This only affects the new namespace.
* Else fail with the errno and a hint to pass `--mount` an existing dir.
## Module contracts
### `src/fuse.zig` — kernel FUSE protocol (no policy)
Extern structs mirroring `linux/fuse.h`, with `comptime` size asserts:
`InHeader` (40), `OutHeader` (16), `Attr` (88), `EntryOut` (128),
`AttrOut` (104), `GetattrIn` (16), `SetattrIn` (88), `OpenIn` (8),
`OpenOut` (16), `ReleaseIn` (24), `FlushIn` (24), `ReadIn` (40),
`WriteIn` (40), `WriteOut` (8), `CreateIn` (16), `MkdirIn` (8),
`RenameIn` (8), `Rename2In` (16), `ForgetIn` (8), `BatchForgetIn` (8),
`ForgetOne` (16), `FsyncIn` (16), `AccessIn` (8), `InterruptIn` (8),
`Kstatfs` (80), `StatfsOut` (80), `InitIn` (64), `InitOut` (64),
`Dirent` (24 header, name padded to 8), `LseekIn` (24).
`pub const Opcode = enum(u32) { lookup = 1, forget = 2, getattr = 3, setattr = 4,
readlink = 5, symlink = 6, mknod = 8, mkdir = 9, unlink = 10, rmdir = 11,
rename = 12, link = 13, open = 14, read = 15, write = 16, statfs = 17,
release = 18, fsync = 20, setxattr = 21, getxattr = 22, listxattr = 23,
removexattr = 24, flush = 25, init = 26, opendir = 27, readdir = 28,
releasedir = 29, fsyncdir = 30, getlk = 31, setlk = 32, setlkw = 33,
access = 34, create = 35, interrupt = 36, bmap = 37, destroy = 38,
ioctl = 39, poll = 40, notify_reply = 41, batch_forget = 42, fallocate = 43,
readdirplus = 44, rename2 = 45, lseek = 46, copy_file_range = 47,
setupmapping = 48, removemapping = 49, syncfs = 50, tmpfile = 51, statx = 52, _ }`
Constants: `kernel_version = 7`, `kernel_minor = 31` (what we answer; the
kernel adapts to the lower minor), `FOPEN_DIRECT_IO = 1`, `FOPEN_KEEP_CACHE = 2`,
`FOPEN_NONSEEKABLE = 4`, `FUSE_ASYNC_READ = 1`, `FUSE_MAX_PAGES = 1<<22`,
`FATTR_MODE=1, FATTR_UID=2, FATTR_GID=4, FATTR_SIZE=8, FATTR_ATIME=16,
FATTR_MTIME=32, FATTR_FH=64, FATTR_ATIME_NOW=128, FATTR_MTIME_NOW=256,
FATTR_LOCKOWNER=512, FATTR_CTIME=1024`. `root_id = 1`.
I/O helpers (blocking fd, no allocation beyond the caller's buffer):
```zig
pub const Request = struct { header: InHeader, body: []const u8 };
/// One kernel request. Returns null on ENODEV (unmounted). Retries EINTR/EAGAIN/ENOENT.
pub fn readRequest(fd: i32, buf: []u8) !?Request;
/// Success reply: header + concatenated payload slices, single writev.
pub fn reply(fd: i32, unique: u64, payloads: []const []const u8) !void;
/// Error reply: negative errno.
pub fn replyError(fd: i32, unique: u64, err: std.os.linux.E) !void;
/// Append a fuse_dirent (8-byte padded) to `buf`; returns false if it doesn't fit.
pub fn addDirent(buf: []u8, used: *usize, ino: u64, off: u64, dtype: u32, name: []const u8) bool;
pub fn body(comptime T: type, req: Request) !*const T; // aligned copy-free view, checks size
pub fn nameAfter(comptime T: type, req: Request) ![]const u8; // NUL-terminated name after a struct
```
The request buffer must be ≥ `max_write + 4096`; 9player uses 1 MiB + 4 KiB.
Requests with an unknown/unsupported opcode get `ENOSYS`.
### `src/nine.zig` — synchronous 9P2000 session on a blocking fd
Thin, synchronous RPC layer over `cloud9.Client` (which is push/take,
non-blocking-agnostic). One outstanding request at a time (the FUSE loop is
single-threaded). Fids are allocated from a free list.
```zig
pub const Address = union(enum) { unix: []const u8, tcp: struct { host: []const u8, port: u16 }, fd: i32 };
pub const Session = struct {
pub const Error = error{ Nine, Protocol, Io, Closed, TooLarge, OutOfMemory };
/// After error.Nine, `ename` holds the server's Rerror text (copied, bounded).
ename: [256]u8, ename_len: usize,
msize: u32,
pub fn connect(gpa: std.mem.Allocator, address: Address, msize: u32) !Session; // socket+connect, version
pub fn deinit(s: *Session) void;
pub fn attach(s: *Session, fid: u32, uname: []const u8, aname: []const u8) Error!cloud9.Qid;
pub fn allocFid(s: *Session) u32;
pub fn freeFid(s: *Session, fid: u32) void;
/// Generic RPC. Result slices borrow the input buffer until the next call.
pub fn rpc(s: *Session, req: cloud9.Client.Request) Error!cloud9.Client.Result;
// Conveniences (all built on rpc):
pub fn walk(s, fid: u32, newfid: u32, names: []const []const u8) Error!Walk; // Walk = { nwqid, wqid[16] }; partial walk → error.Nine with ename "not found"-ish
pub fn clone(s, fid: u32) Error!u32; // allocFid + walk with no names
pub fn open(s, fid: u32, mode: u8) Error!Open; // Open = { qid, iounit }
pub fn create(s, fid: u32, name: []const u8, perm: u32, mode: u8) Error!Open;
pub fn read(s, fid: u32, offset: u64, buf: []u8) Error!usize; // chunks by maxRead/iounit; stops at short read
pub fn write(s, fid: u32, offset: u64, data: []const u8) Error!usize; // chunks; stops at short write
pub fn stat(s, fid: u32) Error!cloud9.Stat; // strings borrow the input buffer
pub fn wstat(s, fid: u32, st: cloud9.Stat) Error!void;
pub fn clunk(s, fid: u32) Error!void; // frees the fid even on error
pub fn remove(s, fid: u32) Error!void; // frees the fid even on error
pub fn errno(s: *const Session) std.os.linux.E; // map ename → errno (see below)
};
pub const dontcare = cloud9.Stat{ .type = 0xFFFF, .dev = 0xFFFF_FFFF, .qid = .{ .type = 0xFF, .version = 0xFFFF_FFFF, .path = 0xFFFF_FFFF_FFFF_FFFF }, .mode = 0xFFFF_FFFF, .atime = 0xFFFF_FFFF, .mtime = 0xFFFF_FFFF, .length = 0xFFFF_FFFF_FFFF_FFFF, .name = "", .uid = "", .gid = "", .muid = "" };
```
`connect`: for `.unix` and `.tcp` create a blocking `SOCK_STREAM|SOCK_CLOEXEC`
socket and connect (`TCP_NODELAY` on TCP); for `.fd` adopt it. Buffers of
`msize` bytes for in/out are heap allocated. Then submit `.version`, drain
output to the socket, read until `take()` yields the version result. The
negotiated msize is `result.version.msize`; if the server answered
`"unknown"`, fail with `error.Protocol`.
`rpc`: submit, write all of `client.output()` (calling `wrote`), then loop:
`take()`; if null, `read` from the fd into a temp buffer and `push` (push
returns how much fit; the frame is at most msize so it always fits after a
`take`). If the fd returns 0 → `error.Closed`. If the client dies →
`error.Protocol`. A `.fail` result copies the ename and returns `error.Nine`.
Rerror text → errno mapping (case-insensitive substring, in this order):
`"not exist"`, `"not found"`, `"no such"` → `ENOENT`; `"exists"` → `EEXIST`;
`"not empty"` → `ENOTEMPTY`; `"not a dir"` → `ENOTDIR`;
`"is a dir"` → `EISDIR`; `"permission"`, `"denied"` → `EACCES`;
`"read-only"`, `"read only"`, `"readonly"` → `EROFS`; `"no space"` → `ENOSPC`;
`"not allowed"`, `"not permitted"`, `"cannot"` → `EPERM`;
`"fid"` → `EBADF`; `"bad offset"`, `"invalid"`, `"bad "` → `EINVAL`;
`"busy"`, `"in use"` → `EBUSY`; `"too long"` → `ENAMETOOLONG`;
`"not supported"`, `"unsupported"` → `ENOTSUP`; otherwise `EIO`.
### `src/bridge.zig` — FUSE ↔ 9P translation
```zig
pub const Options = struct {
uid: u32, gid: u32, // reported owner of every file
attr_timeout_ns: u64 = 1e9, // attr/entry cache validity (0 = none)
direct_io: bool = true, // FOPEN_DIRECT_IO on every regular file
debug: bool = false, // trace to stderr
};
/// Runs until the FUSE fd reports ENODEV or `stop_fd` becomes readable.
pub fn serve(gpa: std.mem.Allocator, fuse_fd: i32, nine: *nine.Session, root_fid: u32, stop_fd: i32, opts: Options) !void;
```
State:
* `inodes: AutoHashMap(u64 /*nodeid*/, Inode{ fid: u32, qid: Qid, nlookup: u64 })`.
Node 1 is the root (`root_fid`, never forgotten).
* `by_qid: AutoHashMap(u64 /*qid.path*/, u64 /*nodeid*/)` so that repeated
lookups of the same file map to the same inode (the old fid is clunked and
the fresh one kept). Dedupe only merges when the qid type (dir bit) also
matches, so a server reusing a path across a file and a directory cannot
poison an inode. `ino` in attrs is `qid.path` (root, or anything carrying
the root's path: 1).
* `handles: AutoHashMap(u64 /*fh*/, Handle{ fid: u32, dir: ?DirList })`.
`DirList` is the entire directory read at first `READDIR` offset 0:
`[]Entry{ name: []u8, ino: u64, dtype: u32 }` with synthetic `.` and `..`
first. `READDIR` offsets are indices into that list; a `READDIR` at offset 0
re-reads the directory (rewinddir).
Op mapping (9P2000 has no symlinks, links, xattrs, locks, mknod):
| FUSE | 9P |
|---|---|
| INIT | reply `InitOut{ major=7, minor=31, max_readahead=in.max_readahead, flags = FUSE_ASYNC_READ \| FUSE_ATOMIC_O_TRUNC \| FUSE_AUTO_INVAL_DATA \| FUSE_BIG_WRITES (plus FUSE_MAX_PAGES with max_pages=256 if offered), max_background=16, congestion_threshold=12, max_write=1 MiB, time_gran=1 }`. Atomic O_TRUNC matters: without it the kernel truncates via a separate SETATTR(size=0) that synthetic control files reject; with it `O_TRUNC` becomes 9P `OTRUNC` inside the open |
| LOOKUP(parent,name) | `walk(parent.fid → newfid, [name])`; `stat(newfid)`; dedupe by qid; `EntryOut` |
| FORGET / BATCH_FORGET | `nlookup -= n`; at 0 `clunk` and drop (no reply) |
| GETATTR | `stat(inode.fid)` → `AttrOut` |
| SETATTR | `stat` then `wstat` with a *dontcare* Stat: `FATTR_SIZE`→length; `FATTR_MODE`→`(old.mode & ~0o777) \| (mode & 0o777)`; `FATTR_MTIME`→mtime (`FATTR_MTIME_NOW` → now); `FATTR_ATIME` ignored; `FATTR_UID/GID` → `EPERM` unless unchanged; then `stat` again for the reply |
| OPEN | `clone(inode.fid)` then `open(newfid, mode)`; mode from `O_ACCMODE` (`oread/owrite/ordwr`), `O_TRUNC` → `otrunc`; reply `OpenOut{ fh, open_flags = FOPEN_DIRECT_IO }`; on failure clunk |
| OPENDIR | same with `oread`; `fh` with `dir = null` |
| READ | `read(fh.fid, offset, buf[0..min(size, 1 MiB)])`; reply data |
| WRITE | `write(fh.fid, offset, data)`; `WriteOut{ size = n }` |
| READDIR | fill `Dirent`s from the `DirList` starting at `offset`, up to `size` bytes |
| RELEASE / RELEASEDIR | `clunk(fh.fid)`; free DirList |
| FLUSH / FSYNC / FSYNCDIR | ok (no-op) |
| CREATE(parent,name,flags,mode) | `clone(parent)`; `create(fid, name, mode & 0o777, openmode)` → this fid is the **open** file; then `walk(parent → fid2, [name])` + `stat(fid2)` for the inode; reply `EntryOut ++ OpenOut` |
| MKDIR | `clone(parent)`; `create(fid, name, DMDIR \| (mode & 0o777), oread)`; `clunk`; then lookup as above |
| UNLINK / RMDIR | `walk(parent → tmp, [name])`; `remove(tmp)` |
| RENAME / RENAME2 | if `newdir != parent` → `EXDEV`; else `walk(parent → tmp, [oldname])`, `wstat(tmp, dontcare with .name = newname)`, `clunk`. 9P rename never replaces, POSIX does: when the target exists (and `RENAME_NOREPLACE` is not set) a directory target is removed first; a file target is parked under a temporary name, the rename retried, and the parked file removed only after success (restored on failure) |
| STATFS | constant `Kstatfs{ bsize = 4096, namelen = 255, frsize = 4096 }` |
| ACCESS | `ENOSYS` (kernel stops asking; the server enforces permissions on open) |
| READLINK, SYMLINK, LINK, MKNOD, *XATTR, *LK, IOCTL, POLL, BMAP, FALLOCATE, LSEEK, COPY_FILE_RANGE, TMPFILE, STATX | `ENOSYS` |
| INTERRUPT | ignored (reply nothing) |
| DESTROY | return from `serve` |
Attr mapping from `cloud9.Stat`: `mode = (S_IFDIR if DMDIR else S_IFREG) |
(st.mode & 0o777)`; `nlink = 1`; `size = length`; `blocks = (length+511)/512`;
`blksize = 4096`; `atime/mtime/ctime = st.atime/st.mtime/st.mtime`;
`uid/gid = opts.uid/gid`. `Dirent.type` = `DT_DIR` (4) / `DT_REG` (8).
Errors: `nine.Session.Error.Nine` → `nine.errno()`; `Closed`/`Protocol`/`Io`
→ `EIO` and, since the session is dead, `serve` returns `error.Closed` after
replying so 9player can report "9P server went away".
With `direct_io` the kernel never trusts `length` for reads: synthetic files
that report length 0 (very common in 9P) still `cat` correctly, and reads run
until the server returns a short read. With `--no-direct-io` the bridge forces
`attr_timeout_ns = 0`, because a cached stale size truncates reads (observed
data loss on a 4 MiB copy otherwise).
Hostile-server rules: directory listings are capped at 64 MiB (a server that
ignores read offsets otherwise loops forever); directory records with names
containing `/`, NUL, empty, `.`/`..` or longer than `FUSE_NAME_MAX` are dropped
rather than poisoning the whole READDIR reply; `length` near 2^64 is clamped
to `i64` max; the errno of a failing 9P call is latched before any cleanup
clunk overwrites the session's ename.
### `src/ns.zig` — namespace and process plumbing
```zig
pub const Spawn = struct {
argv: []const []const u8, // argv[0] is PATH-searched unless it contains '/'
envp: [*:null]const ?[*:0]const u8, // inherited environment
mountpoint: []const u8, // absolute
fuse_fd: i32,
uid: u32, gid: u32,
max_read: u32,
};
pub const Child = struct { pid: i32 };
/// fork; the child sets up the namespace, mounts, and execs. Returns once exec succeeded
/// (status pipe closed) or fails with the child's error (message on stderr).
pub fn spawn(gpa: std.mem.Allocator, s: Spawn) !Child;
pub fn ensureMountpoint(path: [:0]const u8) !void; // the shadowing logic, testable alone
pub fn resolveMountpoint(gpa, path: []const u8) ![:0]u8; // absolute, no trailing slash
pub fn findInPath(gpa, envp, name) ![:0]u8;
```
Also exports the signal plumbing used by `main.zig`:
`installSignals(child_pid_ptr: *i32) !i32` returning the SIGCHLD self-pipe
read end (used as `stop_fd` for `bridge.serve`), and
`waitChild(pid) !u8` → exit status (`128+sig` on signal death).
### `src/main.zig` — CLI
```
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, max 16 MiB)
--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.
```
Exit codes: child's status; 125 for 9player's own failures (usage, connect,
mount); 126/127 as usual for exec failures.
### `../introspect/demo/main.zig` — demo 9P2000 server (binary `introspect`)
The demo server is a separate program in this repository, built on the
introspect library; see `../introspect/docs/LIBRARY.md` for the library
contract (freestanding core, value renderers, Linux debug probe). The tree it serves keeps the paths the integration tests read
(`/build/*`, `/comptime/types/<T>/*`, `/comptime/decls`, `/runtime/fn/*`,
`/runtime/ctl`, `/runtime/{pid,ppid,uptime,argv,cwd,env,clients}`,
`/scratch/`) and adds `/vars`, `/threads`, `/addr`, `/mem`, `/hex`,
`/breakpoints` and `/panic`.
## Integration test plan (`test/integration.sh`)
Run by `zig build 9player-itest`; args: path to `9player`, path to `introspect`.
Everything under a temp dir. Skips (exit 0 with a notice) when
`unshare -Urm true` fails or `/dev/fuse` is missing.
1. introspect on a Unix socket; `9player --unix … -- sh -c` scripts:
`cat /mnt/9p/build/zig_version` == `zig version`; `ls` listings; `stat`
sizes; `/runtime/fn/now` is numeric; `ctl` round trip; `/scratch`: create,
append (`>>`), overwrite, truncate, `mkdir -p a/b/c`, rename within dir,
`mv` across dirs fails with `EXDEV`-ish message, `rm`, `rmdir`, 1 MiB
random file round trip compared with `sha256sum`, `dd` with odd block
sizes, many small files, `find`, exit-status propagation (`exit 7` → 7),
`$NINEPLAYER_MOUNT` set, nested `9player` inside `9player`.
2. `--spawn "<introspect> --stdio"` variant.
3. `--tcp 127.0.0.1:<port>` variant.
4. If `/usr/lib/plan9/bin/ramfs` exists: `NAMESPACE=$tmp ramfs -s ramfs`
creates `$tmp/ramfs`; run the scratch battery against it.
5. `--mount` with an existing dir, with a relative path, and the default
`/mnt/9p` (exercises parent shadowing; verify `/mnt`'s other entries are
still visible inside).
6. Kill tests: 9player exits when the child exits; server death during use
yields `EIO`, not a hang.
## Verification
`zig build 9player-test` (unit), `zig build 9player-itest` (74 end-to-end
checks against introspect over unix/tcp/socketpair and against plan9port's
`ramfs`) and `zig build 9player-adv` (adversarial suites: a scriptable
hostile 9P server with ~30 misbehaviour modes, FUSE semantics through the
bridge, process/namespace/signal edge cases with 51 checks, and stress). The
suites that attack the introspect server itself (a hostile raw-9P client with
181 checks, the core, the Linux layer) moved with it to
`../introspect/test` (`zig build introspect-adv`). All pass in Debug and
ReleaseSafe.
## Out of scope for v1 (documented, not hidden)
* One 9P request in flight at a time: a 9P read that blocks (event files)
stalls the whole mount until it returns (but not past the child's exit).
* No 9P2000.u/.L: no symlinks, ownership, or extended attributes.
* No PID namespace, no `/proc` remount. `--tcp` needs an IP literal.
* Cross-directory rename returns `EXDEV` (9P2000 cannot move files).
|