summaryrefslogtreecommitdiff
path: root/9player/test/adv_ns_process.sh
blob: 4ce0ae81f3c49fbaa1ac960b64b6b711ceb17b20 (plain) (blame)
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
#!/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 ]