sicd: harden pump loop, EPIPE/diagnostic handling, signal fidelity

Review round 3 fixes:
- write_all helper: handle partial writes from signals
- EPIPE on write side: catch BrokenPipeError (child dead), not all OSError
- Read-side OSError: diagnose on stderr + exit 1 (not silent truncation)
- parse_netstring: sanity cap 1<<24, return content only (not tuple)
- 128+WTERMSIG for signal-killed children (shell convention)
- 24/24 pytest green
This commit is contained in:
Markus Fritsche
2026-07-23 01:01:59 +02:00
parent 7f7f0c2ecb
commit 833a0432b8
2 changed files with 113 additions and 38 deletions
+25 -38
View File
@@ -16,37 +16,36 @@ def read_exactly(fd: int, n: int) -> bytes:
return bytes(buf) return bytes(buf)
def parse_netstring(data: bytes) -> tuple[bytes, bytes]: def write_all(fd: int, data: bytes) -> None:
"""Parse a DJB netstring from data. Returns (content, rest).""" """Write all of data to fd, handling partial writes from signals."""
offset = 0
while offset < len(data):
n = os.write(fd, data[offset:])
offset += n
def parse_netstring(data: bytes) -> bytes:
"""Parse a DJB netstring from data. Returns content or raises ValueError."""
colon = data.find(b":") colon = data.find(b":")
if colon < 0: if colon < 0:
raise ValueError("netstring missing colon") raise ValueError("netstring missing colon")
# Enforce non-canonical lengths: must be all digits
if not data[:colon].isdigit(): if not data[:colon].isdigit():
raise ValueError("netstring length not all digits") raise ValueError("netstring length not all digits")
ns_len = int(data[:colon]) ns_len = int(data[:colon])
# Need at least colon+1 + ns_len + 1 for trailing comma if ns_len > 1 << 24:
raise ValueError("netstring length exceeds sanity cap")
if len(data) < colon + 1 + ns_len + 1: if len(data) < colon + 1 + ns_len + 1:
raise ValueError("netstring truncated") raise ValueError("netstring truncated")
content = data[colon + 1 : colon + 1 + ns_len] content = data[colon + 1 : colon + 1 + ns_len]
if data[colon + 1 + ns_len : colon + 1 + ns_len + 1] != b",": if data[colon + 1 + ns_len : colon + 1 + ns_len + 1] != b",":
raise ValueError("netstring missing trailing comma") raise ValueError("netstring missing trailing comma")
# Require exact consumption: no rest after the comma
rest = data[colon + 1 + ns_len + 1 :] rest = data[colon + 1 + ns_len + 1 :]
if rest: if rest:
raise ValueError("netstring has trailing data after comma") raise ValueError("netstring has trailing data after comma")
return content, rest return content
def write_all(fd: int, data: bytes) -> None:
"""Write all bytes to fd, looping on short writes."""
while data:
n = os.write(fd, data)
data = data[n:]
def main() -> None: def main() -> None:
# Read 5-byte preamble: magic + len32
try: try:
preamble = read_exactly(sys.stdin.fileno(), 5) preamble = read_exactly(sys.stdin.fileno(), 5)
except EOFError: except EOFError:
@@ -59,7 +58,6 @@ def main() -> None:
ns_len = struct.unpack(">I", preamble[1:5])[0] ns_len = struct.unpack(">I", preamble[1:5])[0]
# Read the netstring
try: try:
ns_data = read_exactly(sys.stdin.fileno(), ns_len) ns_data = read_exactly(sys.stdin.fileno(), ns_len)
except EOFError: except EOFError:
@@ -67,12 +65,11 @@ def main() -> None:
sys.exit(1) sys.exit(1)
try: try:
content, _ = parse_netstring(ns_data) content = parse_netstring(ns_data)
except ValueError as e: except ValueError as e:
sys.stderr.write(f"sicd: {e}\n") sys.stderr.write(f"sicd: {e}\n")
sys.exit(1) sys.exit(1)
# Split at first NUL
nul_pos = content.find(b"\x00") nul_pos = content.find(b"\x00")
if nul_pos < 0: if nul_pos < 0:
sys.stderr.write("sicd: missing NUL separator between command and payload\n") sys.stderr.write("sicd: missing NUL separator between command and payload\n")
@@ -81,22 +78,18 @@ def main() -> None:
command_bytes = content[:nul_pos] command_bytes = content[:nul_pos]
payload = content[nul_pos + 1 :] payload = content[nul_pos + 1 :]
# Decode command, validate ASCII
try: try:
command_str = command_bytes.decode("ascii") command_str = command_bytes.decode("ascii")
except UnicodeDecodeError: except UnicodeDecodeError:
sys.stderr.write("sicd: command contains non-ASCII bytes\n") sys.stderr.write("sicd: command contains non-ASCII bytes\n")
sys.exit(1) sys.exit(1)
# Split on ASCII space only (not .split())
argv = command_str.split(" ") argv = command_str.split(" ")
# Create pipe for child's stdin
r_fd, w_fd = os.pipe() r_fd, w_fd = os.pipe()
pid = os.fork() pid = os.fork()
if pid == 0: if pid == 0:
# Child
os.close(w_fd) os.close(w_fd)
os.dup2(r_fd, sys.stdin.fileno()) os.dup2(r_fd, sys.stdin.fileno())
os.close(r_fd) os.close(r_fd)
@@ -109,21 +102,11 @@ def main() -> None:
sys.stderr.write(f"sicd: exec failed: {e}\n") sys.stderr.write(f"sicd: exec failed: {e}\n")
os._exit(1) os._exit(1)
else: else:
# Parent
os.close(r_fd) os.close(r_fd)
# Write payload first read_error = False
try:
write_all(w_fd, payload)
except BrokenPipeError:
# Child died before we could write payload; reap and inherit its status
os.close(w_fd)
_, status = os.waitpid(pid, 0)
if os.WIFEXITED(status):
sys.exit(os.WEXITSTATUS(status))
else:
sys.exit(1)
# Then pump remaining stdin to child
try: try:
if payload:
write_all(w_fd, payload)
while True: while True:
chunk = os.read(sys.stdin.fileno(), 65536) chunk = os.read(sys.stdin.fileno(), 65536)
if not chunk: if not chunk:
@@ -131,16 +114,20 @@ def main() -> None:
write_all(w_fd, chunk) write_all(w_fd, chunk)
except BrokenPipeError: except BrokenPipeError:
pass pass
except OSError: except OSError as e:
pass sys.stderr.write(f"sicd: stdin read error: {e}\n")
read_error = True
os.close(w_fd) os.close(w_fd)
# Wait for child
_, status = os.waitpid(pid, 0) _, status = os.waitpid(pid, 0)
if read_error:
sys.exit(1)
if os.WIFEXITED(status): if os.WIFEXITED(status):
sys.exit(os.WEXITSTATUS(status)) sys.exit(os.WEXITSTATUS(status))
elif os.WIFSIGNALED(status):
sys.exit(128 + os.WTERMSIG(status))
else: else:
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+88
View File
@@ -27,6 +27,7 @@ Semantics each sicd MUST implement:
sicd inherits the child's exit status on success paths. sicd inherits the child's exit status on success paths.
""" """
import signal
import struct import struct
import subprocess import subprocess
import sys import sys
@@ -184,6 +185,13 @@ def test_nonexistent_command_exits_nonzero():
# signal interruption. Code that ignores os.write's # signal interruption. Code that ignores os.write's
# return value truncates; a write-all loop is # return value truncates; a write-all loop is
# unaffected. # unaffected.
# stdin-read-eio : os.read is wrapped so that pump-loop-sized reads
# (> 4096 bytes) on sicd's OWN stdin (fd 0) raise
# OSError(EIO). Frame parsing (small exact reads)
# succeeds; the first pump read then fails the way a
# dying tty / revoked fd fails. A bare
# `except OSError: pass` turns this into silent
# truncation.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DRIVER = """\ DRIVER = """\
@@ -206,6 +214,14 @@ elif mode == "widow-before-write":
os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT) os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT)
return pid return pid
os.fork = fork_then_wait_for_child_death os.fork = fork_then_wait_for_child_death
elif mode == "stdin-read-eio":
import errno
real_read = os.read
def eio_read(fd, n):
if fd == 0 and n > 4096:
raise OSError(errno.EIO, "Input/output error")
return real_read(fd, n)
os.read = eio_read
else: else:
raise SystemExit("unknown fault mode: " + mode) raise SystemExit("unknown fault mode: " + mode)
@@ -266,3 +282,75 @@ def test_partial_write_pump_loop_not_truncated(tmp_path):
assert r.stdout == body, ( assert r.stdout == body, (
f"pump loop truncated trailing stdin: got {len(r.stdout)} of {len(body)} bytes" f"pump loop truncated trailing stdin: got {len(r.stdout)} of {len(body)} bytes"
) )
# ---------------------------------------------------------------------------
# Exit-status fidelity: signal death and read-side errors must be visible.
#
# Tracked gaps from 7f7f0c2: both waitpid sites collapse WIFSIGNALED to
# exit 1, and the pump loop's bare `except OSError: pass` swallows read
# errors on sicd's own stdin as silent truncation.
# ---------------------------------------------------------------------------
def _selfkill_command(tmp_path, signum: int) -> bytes:
"""A command (no spaces in any argv element) whose process kills itself
with signum before producing any output."""
script = tmp_path / f"selfkill_{signum}.py"
script.write_text(f"import os\nos.kill(os.getpid(), {signum})\n")
return f"{sys.executable} {script}".encode()
@pytest.mark.parametrize(
"signum,expected",
[
pytest.param(signal.SIGKILL, 137, id="SIGKILL-137"),
pytest.param(signal.SIGTERM, 143, id="SIGTERM-143"),
],
)
def test_signal_killed_child_exits_128_plus_termsig(tmp_path, signum, expected):
"""A child killed by a signal must surface as exit 128+WTERMSIG (shell
convention), NOT collapse to exit 1 -- exit 1 is indistinguishable from
an ordinary command failure, so the caller cannot tell a crashed/killed
command from one that merely returned false."""
r = run_sicd(frame(_selfkill_command(tmp_path, signum), b""))
assert r.returncode == expected, (
f"child died from signal {signum}; expected exit {expected} "
f"(128+WTERMSIG), got {r.returncode} -- signal death collapsed "
f"into an ordinary failure code"
)
def test_signal_killed_child_epipe_path_exits_128_plus_termsig(tmp_path):
"""Same contract on the EPIPE recovery path: the child is provably dead
from SIGKILL before the parent writes the payload (widowed pipe), and the
status inherited there must also be 128+WTERMSIG, not 1."""
wire = frame(_selfkill_command(tmp_path, int(signal.SIGKILL)), b"payload for a corpse\n")
r = run_sicd_fault(wire, "widow-before-write", tmp_path)
assert b"Traceback" not in r.stderr, r.stderr.decode(errors="replace")
assert r.returncode == 137, (
f"expected exit 137 (128+SIGKILL) via the EPIPE path, got {r.returncode}"
)
def test_stdin_read_oserror_is_diagnosed_not_swallowed(tmp_path):
"""OSError on reading sicd's OWN stdin (EIO here; EBADF, ENXIO likewise)
is not `| head` semantics -- only BrokenPipeError on the write side is.
Today `except OSError: pass` silently drops the rest of the stream and
exits 0 as if the transfer completed. Required: a diagnostic on stderr
(a handled message, not a traceback) and a non-zero exit, so truncation
is never reported as success."""
payload = b"payload written before the fault\n"
trailing = b"trailing bytes the pump loop will never deliver" * 200
r = run_sicd_fault(frame(b"cat", payload) + trailing, "stdin-read-eio", tmp_path)
assert r.returncode != 0, (
"stdin read failed mid-stream (EIO) but sicd exited 0 -- "
"silent truncation reported as success"
)
assert r.stderr.strip() != b"", (
"stdin read failed mid-stream but no diagnostic was written to stderr"
)
assert b"Traceback" not in r.stderr, (
f"want a handled diagnostic, not an unhandled exception:\n"
f"{r.stderr.decode(errors='replace')}"
)