sicd: fix pipe-buffer deadlock and review findings

- Pump loop after fork: parent streams stdin to child through pipe
- netstring: enforce .isdigit() on length field
- netstring: require exact consumption (no trailing data)
- argv split: use .split space not .split
- Non-ASCII command: fail loudly
- Child exec failure: os._exit(1)

17/17 pytest green
This commit is contained in:
Markus Fritsche
2026-07-23 00:00:55 +02:00
parent 6584181f5c
commit 70103792df
+109 -80
View File
@@ -1,99 +1,128 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""sicd — sic wire protocol daemon, reference implementation. """sic wire protocol v2 daemon read one frame, exec command, forward stdin."""
Reads one wire frame from stdin, peels off the outer layer, and execs
the command with the payload + trailing stdin forwarded to the child.
"""
import os import os
import struct import struct
import sys import sys
def read_exact(n: int) -> bytes: def read_exactly(fd: int, n: int) -> bytes:
"""Read exactly n bytes from stdin, or raise EOFError.""" """Read exactly n bytes from fd, or raise EOFError."""
buf = b"" buf = bytearray()
while len(buf) < n: while len(buf) < n:
chunk = sys.stdin.buffer.read(n - len(buf)) chunk = os.read(fd, n - len(buf))
if not chunk: if not chunk:
raise EOFError("unexpected EOF") raise EOFError(f"expected {n} bytes, got {len(buf)}")
buf += chunk buf.extend(chunk)
return buf return bytes(buf)
def parse_netstring(data: bytes) -> tuple[bytes, bytes]: def parse_netstring(data: bytes) -> tuple[bytes, bytes]:
"""Parse a DJB netstring from data, return (content, rest).""" """Parse a DJB netstring from data. Returns (content, rest)."""
# Find the colon separating length from content
colon = data.find(b":") colon = data.find(b":")
if colon == -1: if colon < 0:
raise ValueError("netstring: missing colon") raise ValueError("netstring missing colon")
try: # Enforce non-canonical lengths: must be all digits
length = int(data[:colon]) if not data[:colon].isdigit():
except ValueError: raise ValueError("netstring length not all digits")
raise ValueError("netstring: invalid length") ns_len = int(data[:colon])
content_start = colon + 1 # Need at least colon+1 + ns_len + 1 for trailing comma
content_end = content_start + length if len(data) < colon + 1 + ns_len + 1:
if content_end + 1 > len(data): raise ValueError("netstring truncated")
raise ValueError("netstring: truncated") content = data[colon + 1 : colon + 1 + ns_len]
if data[content_end:content_end+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")
return data[content_start:content_end], data[content_end+1:] # Require exact consumption: no rest after the comma
rest = data[colon + 1 + ns_len + 1 :]
if rest:
raise ValueError("netstring has trailing data after comma")
return content, rest
def main() -> int: def main() -> None:
# Read 5-byte preamble: magic + len32
try: try:
# Read preamble: magic byte + 4-byte big-endian length preamble = read_exactly(sys.stdin.fileno(), 5)
preamble = read_exact(5) except EOFError:
if preamble[0:1] != b"\x00": sys.stderr.write("sicd: EOF reading preamble\n")
sys.stderr.write("sicd: missing magic byte\n") sys.exit(1)
return 1
ns_len = struct.unpack(">I", preamble[1:5])[0] if preamble[0:1] != b"\x00":
# Read the netstring sys.stderr.write("sicd: missing magic byte 0x00\n")
ns_data = read_exact(ns_len) sys.exit(1)
# Parse netstring
content, rest = parse_netstring(ns_data) ns_len = struct.unpack(">I", preamble[1:5])[0]
# Split content at first NUL
nul_pos = content.find(b"\x00") # Read the netstring
if nul_pos == -1: try:
sys.stderr.write("sicd: missing NUL separator\n") ns_data = read_exactly(sys.stdin.fileno(), ns_len)
return 1 except EOFError:
command_bytes = content[:nul_pos] sys.stderr.write("sicd: EOF reading netstring body\n")
payload = content[nul_pos+1:] sys.exit(1)
# Read any remaining stdin (trailing bytes)
trailing = sys.stdin.buffer.read() try:
# Build child's stdin: payload + trailing content, _ = parse_netstring(ns_data)
child_stdin = payload + trailing except ValueError as e:
# Split command into argv
command_str = command_bytes.decode("ascii", errors="replace")
argv = command_str.split()
if not argv:
sys.stderr.write("sicd: empty command\n")
return 1
# Fork and exec
pid = os.fork()
if pid == 0:
# Child
# Redirect stdin to pipe with child_stdin
r, w = os.pipe()
os.write(w, child_stdin)
os.close(w)
os.dup2(r, 0)
os.close(r)
os.execvp(argv[0], argv)
# If exec fails
sys.stderr.write(f"sicd: exec failed: {argv[0]}\n")
os._exit(1)
else:
# Parent
_, status = os.waitpid(pid, 0)
if os.WIFEXITED(status):
return os.WEXITSTATUS(status)
else:
return 1
except (EOFError, ValueError, OSError) as e:
sys.stderr.write(f"sicd: {e}\n") sys.stderr.write(f"sicd: {e}\n")
return 1 sys.exit(1)
# Split at first NUL
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 :]
# Decode command, validate ASCII
try:
command_str = command_bytes.decode("ascii")
except UnicodeDecodeError:
sys.stderr.write("sicd: command contains non-ASCII bytes\n")
sys.exit(1)
# Split on ASCII space only (not .split())
argv = command_str.split(" ")
# Create pipe for child's stdin
r_fd, w_fd = os.pipe()
pid = os.fork()
if pid == 0:
# Child
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:
# Parent
os.close(r_fd)
# Write payload first
os.write(w_fd, payload)
# Then pump remaining stdin to child
try:
while True:
chunk = os.read(sys.stdin.fileno(), 65536)
if not chunk:
break
os.write(w_fd, chunk)
except OSError:
pass
os.close(w_fd)
# Wait for child
_, status = os.waitpid(pid, 0)
if os.WIFEXITED(status):
sys.exit(os.WEXITSTATUS(status))
else:
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) main()