Files
sic/gateway/sicd
T
Markus Fritsche 833a0432b8 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
2026-07-23 01:01:59 +02:00

133 lines
3.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""sic wire protocol v2 daemon — read one frame, exec command, forward stdin."""
import os
import struct
import sys
def read_exactly(fd: int, n: int) -> bytes:
"""Read exactly n bytes from fd, or raise EOFError."""
buf = bytearray()
while len(buf) < n:
chunk = os.read(fd, n - len(buf))
if not chunk:
raise EOFError(f"expected {n} bytes, got {len(buf)}")
buf.extend(chunk)
return bytes(buf)
def write_all(fd: int, data: bytes) -> None:
"""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":")
if colon < 0:
raise ValueError("netstring missing colon")
if not data[:colon].isdigit():
raise ValueError("netstring length not all digits")
ns_len = int(data[:colon])
if ns_len > 1 << 24:
raise ValueError("netstring length exceeds sanity cap")
if len(data) < colon + 1 + ns_len + 1:
raise ValueError("netstring truncated")
content = data[colon + 1 : colon + 1 + ns_len]
if data[colon + 1 + ns_len : colon + 1 + ns_len + 1] != b",":
raise ValueError("netstring missing trailing comma")
rest = data[colon + 1 + ns_len + 1 :]
if rest:
raise ValueError("netstring has trailing data after comma")
return content
def main() -> None:
try:
preamble = read_exactly(sys.stdin.fileno(), 5)
except EOFError:
sys.stderr.write("sicd: EOF reading preamble\n")
sys.exit(1)
if preamble[0:1] != b"\x00":
sys.stderr.write("sicd: missing magic byte 0x00\n")
sys.exit(1)
ns_len = struct.unpack(">I", preamble[1:5])[0]
try:
ns_data = read_exactly(sys.stdin.fileno(), ns_len)
except EOFError:
sys.stderr.write("sicd: EOF reading netstring body\n")
sys.exit(1)
try:
content = parse_netstring(ns_data)
except ValueError as e:
sys.stderr.write(f"sicd: {e}\n")
sys.exit(1)
nul_pos = content.find(b"\x00")
if nul_pos < 0:
sys.stderr.write("sicd: missing NUL separator between command and payload\n")
sys.exit(1)
command_bytes = content[:nul_pos]
payload = content[nul_pos + 1 :]
try:
command_str = command_bytes.decode("ascii")
except UnicodeDecodeError:
sys.stderr.write("sicd: command contains non-ASCII bytes\n")
sys.exit(1)
argv = command_str.split(" ")
r_fd, w_fd = os.pipe()
pid = os.fork()
if pid == 0:
os.close(w_fd)
os.dup2(r_fd, sys.stdin.fileno())
os.close(r_fd)
try:
os.execvp(argv[0], argv)
except FileNotFoundError:
sys.stderr.write(f"sicd: command not found: {argv[0]}\n")
os._exit(1)
except Exception as e:
sys.stderr.write(f"sicd: exec failed: {e}\n")
os._exit(1)
else:
os.close(r_fd)
read_error = False
try:
if payload:
write_all(w_fd, payload)
while True:
chunk = os.read(sys.stdin.fileno(), 65536)
if not chunk:
break
write_all(w_fd, chunk)
except BrokenPipeError:
pass
except OSError as e:
sys.stderr.write(f"sicd: stdin read error: {e}\n")
read_error = True
os.close(w_fd)
_, status = os.waitpid(pid, 0)
if read_error:
sys.exit(1)
if os.WIFEXITED(status):
sys.exit(os.WEXITSTATUS(status))
elif os.WIFSIGNALED(status):
sys.exit(128 + os.WTERMSIG(status))
else:
sys.exit(1)
if __name__ == "__main__":
main()