#!/usr/bin/env bash # Adversarial regression tests for 9ns/src/ns.zig and 9ns/src/main.zig: process, # namespace, signal and CLI handling. Real namespaces, real FUSE. # Usage: bash 9ns/test/adv_ns_process.sh <9ns> <9proc-demo> (part of zig build 9ns-adv) # Exit 0 on success (or when the machine cannot run the tests), 1 on failure. set -u NS=$(realpath "${1:?path to 9ns}") PROC=$(realpath "${2:?path to 9proc-demo}") # 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 "$PROC" --unix "$SOCK" & PIDS+=($!) for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.05; done [ -S "$SOCK" ] || { echo "9proc-demo did not create $SOCK"; exit 1; } MI_BEFORE=$(grep -v " $TMP" /proc/self/mountinfo | sort) TIMEOUT=$(command -v timeout) run() { "$TIMEOUT" 60 "$NS" --unix "$SOCK" "$@"; } echo "# CLI" expect_eq "--help goes to stdout, exit 0" "Usage: 9ns" "$(run --help 2>/dev/null | head -1 | cut -c1-10; )" expect_eq "--help exit code" "0" "$("$NS" --help >/dev/null 2>&1; echo $?)" expect_eq "--version on stdout" "9ns" "$("$NS" --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" "$("$NS" --unix= -- true 2>/dev/null; echo $?)" expect_contains "--unix= message" "socket path" "$("$NS" --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 2>&1; echo $?)" expect_eq "--fd with a closed descriptor fails early" "125" "$("$NS" --fd 987 -- true 2>/dev/null; echo $?)" expect_contains "--fd bad descriptor message" "--fd 987: EBADF" "$("$NS" --fd 987 -- true 2>&1)" echo "# exec failures" expect_eq "not found is 127" "127" "$(run -- no-such-program-9ns 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 "$NS" --unix "$SOCK" -- sh -c 'echo ok')" echo "# fd hygiene" # 9ns 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 "$NS" --spawn "$PROC --stdio" -- sh -c "$FD_LIST")" expect_eq "--fd transport does not leak into the program" "0 1 2" "$(python3 - "$NS" "$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 9ns 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 - "$NS" "$PROC" <<'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['NINE_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 "$NS" --spawn "$PROC --stdio" -- sh -c 'srv=$(cat $NINE_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 9ns. 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("/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 9ns 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 "$NS" --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 "$NS" --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 "$NS" --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 "$NS" --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 "$NS" --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('$NS', ['$NS', '--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 "$NINE_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 $NINE_MOUNT/build/optimize >/dev/null' 2>/dev/null; done BG=(); for i in $(seq 1 10); do ( run -- sh -c 'cat $NINE_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 9ns processes" "" "$(pgrep -f "^$NS " | tr '\n' ' ')" expect_eq "no stray --stdio servers" "" "$(pgrep -f "$PROC --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 ]