diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-19 21:26:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-19 21:26:05 -0300 |
| commit | b05abcba3ea09ea106ad28364c6e40a3ec31b890 (patch) | |
| tree | 9170fac5e7e5d8bde108de34a182aaa9d6844117 /9player/test/adv_ns_process.sh | |
| parent | ae310a207534b33b7321dd2b9f423a73b1969159 (diff) | |
| download | cloud9-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-x | 9player/test/adv_ns_process.sh | 202 |
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 ] |
