summaryrefslogtreecommitdiff
path: root/9player/test/adv_ns_process.sh
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 21:26:05 -0300
committerGabriel Schneider <[email protected]>2026-09-19 21:26:05 -0300
commitb05abcba3ea09ea106ad28364c6e40a3ec31b890 (patch)
tree9170fac5e7e5d8bde108de34a182aaa9d6844117 /9player/test/adv_ns_process.sh
parentae310a207534b33b7321dd2b9f423a73b1969159 (diff)
downloadcloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.tar.gz
cloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.zip
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 <[email protected]>
Diffstat (limited to '9player/test/adv_ns_process.sh')
-rwxr-xr-x9player/test/adv_ns_process.sh202
1 files changed, 202 insertions, 0 deletions
diff --git a/9player/test/adv_ns_process.sh b/9player/test/adv_ns_process.sh
new file mode 100755
index 0000000..4ce0ae8
--- /dev/null
+++ b/9player/test/adv_ns_process.sh
@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+# Adversarial regression tests for 9player/src/ns.zig and 9player/src/main.zig: process,
+# namespace, signal and CLI handling. Real namespaces, real FUSE.
+# Usage: bash 9player/test/adv_ns_process.sh <9player> <introspect> (part of zig build 9player-adv)
+# Exit 0 on success (or when the machine cannot run the tests), 1 on failure.
+set -u
+
+PLAYER=$(realpath "${1:?path to 9player}")
+INTROSPECT=$(realpath "${2:?path to introspect}")
+# Unix socket paths are limited to ~107 bytes; keep the temp dir short.
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9padv.XXXXXX")
+PIDS=()
+FAILED=0
+PASSED=0
+
+cleanup() {
+ for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done
+ rm -rf "$TMP"
+}
+trap cleanup EXIT
+
+if ! unshare -Urm true 2>/dev/null; then echo "SKIP: unprivileged user namespaces unavailable"; exit 0; fi
+if [ ! -c /dev/fuse ]; then echo "SKIP: /dev/fuse missing"; exit 0; fi
+
+pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
+fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
+expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; }
+expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; }
+
+SOCK=$TMP/s
+"$INTROSPECT" --unix "$SOCK" &
+PIDS+=($!)
+for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.05; done
+[ -S "$SOCK" ] || { echo "introspect did not create $SOCK"; exit 1; }
+MI_BEFORE=$(grep -v " $TMP" /proc/self/mountinfo | sort)
+
+TIMEOUT=$(command -v timeout)
+run() { "$TIMEOUT" 60 "$PLAYER" --unix "$SOCK" "$@"; }
+
+echo "# CLI"
+expect_eq "--help goes to stdout, exit 0" "Usage: 9player" "$(run --help 2>/dev/null | head -1 | cut -c1-14; )"
+expect_eq "--help exit code" "0" "$("$PLAYER" --help >/dev/null 2>&1; echo $?)"
+expect_eq "--version on stdout" "9player" "$("$PLAYER" --version 2>/dev/null | cut -d' ' -f1)"
+expect_eq "single-dash typo is a usage error, not a program" "125" "$(run -mount /x -- true 2>/dev/null; echo $?)"
+expect_contains "single-dash typo message" "unknown option -mount" "$(run -mount /x -- true 2>&1)"
+expect_eq "--unix= empty is a usage error" "125" "$("$PLAYER" --unix= -- true 2>/dev/null; echo $?)"
+expect_contains "--unix= message" "socket path" "$("$PLAYER" --unix= -- true 2>&1)"
+expect_eq "--mount '' is a usage error" "125" "$(run --mount '' -- true 2>/dev/null; echo $?)"
+expect_eq "--msize huge rejected" "125" "$(run --msize 4294967295 -- true 2>/dev/null; echo $?)"
+expect_eq "--msize 16 MiB accepted" "ok" "$(run --msize 16777216 -- sh -c 'echo ok')"
+expect_contains "empty program name is reported" "empty program name" "$(run -- '' 2>&1)"
+expect_eq "empty program name exit" "125" "$(run -- '' 2>/dev/null; echo $?)"
+expect_eq "empty \$SHELL falls back to /bin/sh" "0" "$(SHELL= run -- </dev/null >/dev/null 2>&1; echo $?)"
+expect_eq "--fd with a closed descriptor fails early" "125" "$("$PLAYER" --fd 987 -- true 2>/dev/null; echo $?)"
+expect_contains "--fd bad descriptor message" "--fd 987: EBADF" "$("$PLAYER" --fd 987 -- true 2>&1)"
+
+echo "# exec failures"
+expect_eq "not found is 127" "127" "$(run -- no-such-program-9player 2>/dev/null; echo $?)"
+expect_eq "PATH element that is a file: still 127" "127" "$(PATH=/etc/passwd run -- true 2>/dev/null; echo $?)"
+expect_contains "PATH element that is a file: message" "exec true: E" "$(PATH=/etc/passwd run -- true 2>&1)"
+printf '#!/bin/sh\necho no\n' >"$TMP/nx"; chmod 644 "$TMP/nx"
+expect_eq "non-executable is 126" "126" "$(run -- "$TMP/nx" 2>/dev/null; echo $?)"
+mkdir -p "$TMP/p1" "$TMP/p2"; cp "$TMP/nx" "$TMP/p1/prog"; printf '#!/bin/sh\necho right\n' >"$TMP/p2/prog"; chmod 755 "$TMP/p2/prog"
+expect_eq "non-executable first in PATH, executable later" "right" "$(PATH=$TMP/p1:$TMP/p2 run -- prog)"
+expect_eq "non-executable only in PATH is 126" "126" "$(PATH=$TMP/p1 run -- prog 2>/dev/null; echo $?)"
+expect_eq "argv[0] preserved" "sh" "$(run -- sh -c 'echo $0')"
+expect_eq "PATH unset uses default" "ok" "$(env -u PATH "$PLAYER" --unix "$SOCK" -- sh -c 'echo ok')"
+
+echo "# fd hygiene"
+# 9player passes inherited descriptors through untouched, so compare with what a
+# plain child of this script sees (the runner may itself hold extra fds).
+FD_LIST='ls /proc/self/fd | grep -v "^3$" | sort -n | tr "\n" " " | sed "s/ $//"'
+FD_BASE=$(sh -c "$FD_LIST")
+expect_eq "no extra fds in the program (unix)" "$FD_BASE" "$(run -- sh -c "$FD_LIST")"
+expect_eq "no extra fds in the program (spawn)" "$FD_BASE" "$("$TIMEOUT" 60 "$PLAYER" --spawn "$INTROSPECT --stdio" -- sh -c "$FD_LIST")"
+expect_eq "--fd transport does not leak into the program" "0 1 2" "$(python3 - "$PLAYER" "$SOCK" <<'EOF'
+import socket, subprocess, sys, os
+s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(sys.argv[2])
+r = subprocess.run([sys.argv[1], "--fd", str(s.fileno()), "--", "sh", "-c",
+ 'ls /proc/self/fd | grep -v "^3$" | sort -n | tr "\n" " " | sed "s/ $//"'],
+ pass_fds=[s.fileno()], capture_output=True, text=True)
+print(r.stdout.strip())
+EOF
+)"
+
+echo "# signals"
+expect_eq "SIGTERM forwarded" "143" "$(run -- sh -c 'kill -TERM $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)"
+expect_eq "SIGHUP forwarded" "129" "$(run -- sh -c 'kill -HUP $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)"
+expect_eq "SIGINT to 9player is ignored while the child lives" "still-here" "$(run -- sh -c 'kill -INT $PPID; sleep 0.3; echo still-here')"
+# Ctrl-C from the tty must not kill the --spawn server (same process group).
+expect_eq "Ctrl-C on the tty leaves the --spawn server alive" "ok" "$(timeout 30 python3 - "$PLAYER" "$INTROSPECT" <<'EOF'
+import os, pty, sys, time, select
+P, I = sys.argv[1], sys.argv[2]
+prog = ["python3", "-c", """
+import os, signal, sys, time
+signal.signal(signal.SIGINT, lambda *a: None)
+m = os.environ['NINEPLAYER_MOUNT']
+open(m + '/build/optimize').read()
+sys.stdin.readline()
+try:
+ open(m + '/build/optimize').read(); print('ok', flush=True)
+except Exception as e:
+ print('mount dead:', e, flush=True)
+"""]
+pid, fd = pty.fork()
+if pid == 0:
+ os.execv(P, [P, "--spawn", I + " --stdio", "--"] + prog)
+out = b""
+def rd(t):
+ global out
+ end = time.time() + t
+ while time.time() < end:
+ r, _, _ = select.select([fd], [], [], 0.1)
+ if r:
+ try: d = os.read(fd, 4096)
+ except OSError: return
+ if not d: return
+ out += d
+rd(1.5); os.write(fd, b"\x03"); rd(0.7); os.write(fd, b"\n"); rd(3)
+os.waitpid(pid, 0)
+print(out.decode(errors="replace").replace("^C", "").strip().splitlines()[-1] if out.strip() else "no output")
+EOF
+)"
+# The --spawn server dying mid-session is reaped (no zombie) and does not end the session.
+OUT=$("$TIMEOUT" 60 "$PLAYER" --spawn "$INTROSPECT --stdio" -- sh -c 'srv=$(cat $NINEPLAYER_MOUNT/runtime/pid); kill -TERM $srv; sleep 0.5; st=$(ps -o stat= -p $srv 2>/dev/null); echo "${st:-gone}"; exit 5' 2>/dev/null); RC=$?
+expect_eq "server death mid-session: exit status still the child's, server reaped (no zombie)" "5 gone" "$RC $OUT"
+# A server that never answers: once the child is dead, SIGTERM must end 9player.
+cat >"$TMP/hang.py" <<'EOF'
+import struct, os, sys, time
+def rd(n):
+ b = b""
+ while len(b) < n:
+ c = os.read(0, n - len(b))
+ if not c: sys.exit(0)
+ b += c
+ return b
+while True:
+ size, = struct.unpack("<I", rd(4)); body = rd(size - 4)
+ typ, tag = struct.unpack("<BH", body[:3])
+ if typ == 100:
+ msize, = struct.unpack("<I", body[3:7]); v = b"9P2000"
+ r = struct.pack("<BHI", 101, tag, msize) + struct.pack("<H", len(v)) + v
+ elif typ == 104:
+ r = struct.pack("<BH", 105, tag) + bytes([0x80]) + struct.pack("<IQ", 0, 0)
+ else:
+ time.sleep(3600)
+ os.write(1, struct.pack("<I", 4 + len(r)) + r)
+EOF
+"$PLAYER" --spawn "python3 $TMP/hang.py" -- true 2>/dev/null &
+HP=$!
+sleep 1; kill -TERM $HP
+START=$(date +%s)
+for _ in $(seq 1 100); do kill -0 $HP 2>/dev/null || break; sleep 0.1; done
+if kill -0 $HP 2>/dev/null; then kill -KILL $HP; RC=hung; else wait $HP; RC=$?; fi
+expect_eq "hung server: one SIGTERM ends 9player once the child is dead (watchdog)" "143" "$RC"
+expect_eq "hung server: exit was prompt" "yes" "$([ $(( $(date +%s) - START )) -lt 8 ] && echo yes)"
+pkill -f "$TMP/hang.py" 2>/dev/null
+
+echo "# child/parent protocol"
+if command -v strace >/dev/null 2>&1 && strace -qq -e trace=none true 2>/dev/null; then
+ expect_eq "child killed before handoff" "125" "$(strace -f -qq -e trace=unshare -e inject=unshare:signal=KILL -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>/dev/null; echo $?)"
+ expect_contains "child killed before handoff: message" "child exited before reporting" "$(strace -f -qq -e trace=unshare -e inject=unshare:signal=KILL -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>&1)"
+ expect_eq "status handoff fails" "125" "$(strace -f -qq -e trace=sendmsg -e inject=sendmsg:error=EPIPE -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>/dev/null; echo $?)"
+ # recvmsg skipped (returns 1 without the fd): the child must be killed, not exec'd onto a dead mount.
+ OUT=$(strace -f -qq -e trace=recvmsg -e inject=recvmsg:retval=1:when=1 -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- sh -c 'echo child-ran' 2>&1; echo "rc=$?")
+ expect_contains "truncated fd handoff: child not exec'd" "rc=125" "$OUT"
+ expect_eq "truncated fd handoff: program never ran" "no" "$(case "$OUT" in *child-ran*) echo yes;; *) echo no;; esac)"
+ expect_contains "fuse mount failure is reported" "mount fuse: EPERM" "$(strace -f -qq -e trace=mount -e inject=mount:error=EPERM:when=2 -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" --mount "$TMP/mp" -- true 2>&1)"
+else
+ echo "skip - strace unavailable (child failure injection)"
+fi
+expect_contains "fork failure is reported" "fork: E" "$(python3 -c "
+import resource, os
+resource.setrlimit(resource.RLIMIT_NPROC, (1, 1))
+os.execv('$PLAYER', ['$PLAYER', '--unix', '$SOCK', '--', 'true'])" 2>&1)"
+
+echo "# mountpoint policy"
+ln -s /nonexistent "$TMP/dangling"
+expect_contains "dangling symlink mountpoint" "dangling symlink" "$(run --mount "$TMP/dangling" -- true 2>&1)"
+expect_eq "refuse to shadow / via /proc/self/root" "125" "$(run --mount /proc/self/root/x9p -- true 2>/dev/null; echo $?)"
+expect_contains "refuse to shadow / via /proc/self/root: message" "refusing to shadow /" "$(run --mount /proc/self/root/x9p -- true 2>&1)"
+expect_eq "refuse to shadow under /proc" "125" "$(run --mount /proc/self/fd/x9p -- true 2>/dev/null; echo $?)"
+if [ "$(ls -A /usr/lib | wc -l)" -gt 4096 ]; then
+ expect_contains "parent with >4096 entries refused" "more than 4096 entries" "$(run --mount /usr/lib/x9p -- true 2>&1)"
+else
+ echo "skip - no root-owned directory with >4096 entries"
+fi
+expect_eq "shadowed /run keeps its entries" "$(ls -A /run | sort | tr '\n' ' ')" "$(run --mount /run/x9p -- sh -c 'ls -A /run | grep -v "^x9p$" | sort | tr "\n" " "')"
+expect_eq "mountpoint with spaces" "ok" "$(mkdir -p "$TMP/with space" && run --mount "$TMP/with space" -- sh -c '[ -f "$NINEPLAYER_MOUNT/README" ] && echo ok')"
+expect_eq "mountpoint is a file" "125" "$(run --mount "$TMP/nx" -- true 2>/dev/null; echo $?)"
+
+echo "# leaks"
+for i in $(seq 1 30); do run -- sh -c 'cat $NINEPLAYER_MOUNT/build/optimize >/dev/null' 2>/dev/null; done
+BG=(); for i in $(seq 1 10); do ( run -- sh -c 'cat $NINEPLAYER_MOUNT/build/optimize >/dev/null' 2>/dev/null ) & BG+=($!); done; wait "${BG[@]}" # not a bare wait: that would also wait for the server
+sleep 0.3
+expect_eq "no stray 9player processes" "" "$(pgrep -f "^$PLAYER " | tr '\n' ' ')"
+expect_eq "no stray --stdio servers" "" "$(pgrep -f "$INTROSPECT --stdio" | tr '\n' ' ')"
+expect_eq "host mount table untouched" "same" "$([ "$MI_BEFORE" = "$(grep -v " $TMP" /proc/self/mountinfo | sort)" ] && echo same || echo changed)"
+
+echo
+echo "passed=$PASSED failed=$FAILED"
+[ "$FAILED" -eq 0 ]