sicd: implement wire protocol (preamble + nested netstring peeling)

- Read preamble: magic byte \x00 + 4-byte BE length + netstring
- Split netstring at first NUL: command\0<payload>
- Exec command with payload + trailing stdin forwarded
- Nested payloads are netstrings for the next hop (one layer peeled per sicd)
- Bare -- survives as opaque data
- Malformed input: exit 1 with stderr diagnostic
- 14/14 pytest green
This commit is contained in:
Markus Fritsche
2026-07-22 23:44:00 +02:00
parent 4a6c1bae5c
commit b5e7e2b632
2 changed files with 226 additions and 54 deletions
+84 -54
View File
@@ -1,68 +1,98 @@
#!/usr/bin/env python3
"""sicd — read netstring-framed argv from stdin and execvp it. Python reference.
"""sicd — sic wire protocol daemon, reference implementation.
Wire format and exec modes: docs/design.md.
Usage: ssh <host> sicd < <netstring-frames>
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 struct
import sys
import subprocess
def read_netstring(buf):
"""Read one netstring from buffer. Returns (payload, rest) or None."""
i = buf.find(b":")
if i < 0:
return None
def read_exact(n: int) -> bytes:
"""Read exactly n bytes from stdin, or raise EOFError."""
buf = b""
while len(buf) < n:
chunk = sys.stdin.buffer.read(n - len(buf))
if not chunk:
raise EOFError("unexpected EOF")
buf += chunk
return buf
def parse_netstring(data: bytes) -> tuple[bytes, bytes]:
"""Parse a DJB netstring from data, return (content, rest)."""
# Find the colon separating length from content
colon = data.find(b":")
if colon == -1:
raise ValueError("netstring: missing colon")
try:
n = int(buf[:i])
length = int(data[:colon])
except ValueError:
return None
start = i + 1
end = start + n
if len(buf) < end + 1 or buf[end : end + 1] != b",":
return None
return buf[start:end], buf[end + 1 :]
raise ValueError("netstring: invalid length")
content_start = colon + 1
content_end = content_start + length
if content_end + 1 > len(data):
raise ValueError("netstring: truncated")
if data[content_end:content_end+1] != b",":
raise ValueError("netstring: missing trailing comma")
return data[content_start:content_end], data[content_end+1:]
def run_frame(mode, args):
"""Execute a single command frame."""
if mode == b"exec":
decoded = [a.decode("utf-8", errors="replace") for a in args]
result = subprocess.run(decoded)
return result.returncode
elif mode == b"sh":
shell_cmd = args[0].decode("utf-8", errors="replace")
result = subprocess.run(shell_cmd, shell=True)
return result.returncode
else:
print(f"sicd: unknown mode {mode!r}", file=sys.stderr)
return 1
def main():
buf = sys.stdin.buffer.read()
mode = None
fields = []
frames_processed = 0
while True:
item = read_netstring(buf)
if item is None:
break
field, buf = item
if mode is None:
mode = field # first field = mode selector
elif field == b"":
if mode is not None:
run_frame(mode, fields)
frames_processed += 1
mode = None
fields = []
def main() -> int:
try:
# Read preamble: magic byte + 4-byte big-endian length
preamble = read_exact(5)
if preamble[0:1] != b"\x00":
sys.stderr.write("sicd: missing magic byte\n")
return 1
ns_len = struct.unpack(">I", preamble[1:5])[0]
# Read the netstring
ns_data = read_exact(ns_len)
# Parse netstring
content, rest = parse_netstring(ns_data)
# Split content at first NUL
nul_pos = content.find(b"\x00")
if nul_pos == -1:
sys.stderr.write("sicd: missing NUL separator\n")
return 1
command_bytes = content[:nul_pos]
payload = content[nul_pos+1:]
# Read any remaining stdin (trailing bytes)
trailing = sys.stdin.buffer.read()
# Build child's stdin: payload + trailing
child_stdin = payload + trailing
# 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:
fields.append(field)
return 0 if frames_processed > 0 else 1
# 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")
return 1
if __name__ == "__main__":
+142
View File
@@ -0,0 +1,142 @@
"""Executable spec for the sic wire protocol v2 — Python reference daemon gateway/sicd.
Wire format (one hop):
frame := MAGIC len32 netstring
MAGIC := 0x00 (1 byte)
len32 := big-endian uint32 (byte length of the netstring that follows)
netstring := <n>:<content>, (djb netstring)
content := <command> 0x00 <payload> (split at the FIRST NUL; NUL is required)
Semantics each sicd MUST implement:
* Read exactly the frame (5 bytes preamble + len32 bytes) from stdin.
* Split content at the first NUL: command / payload.
* exec the command (argv = command split on ASCII spaces, shell=False).
* The exec'd command's stdin = payload ++ every remaining byte of sicd's
own stdin, forwarded verbatim. (This is the fix for the silent
zero-byte-file bug: trailing stream bytes must reach the child.)
* Nesting: a payload may itself be a complete frame for the next hop; each
sicd peels exactly ONE layer and never looks inside the payload.
* Payload bytes are opaque: NULs, 0x00 magic bytes, and a bare `--` inside
the payload are data, never flags or separators.
* Malformed input (missing magic, truncated frame, invalid netstring,
missing NUL separator): exit status 1, a diagnostic on stderr, nothing
exec'd.
sicd inherits the child's exit status on success paths.
"""
import struct
import subprocess
import sys
from pathlib import Path
import pytest
SICD = str((Path(__file__).resolve().parent.parent / "gateway" / "sicd"))
TIMEOUT = 10
def netstring(data: bytes) -> bytes:
return str(len(data)).encode() + b":" + data + b","
def frame(command: bytes, payload: bytes) -> bytes:
"""Build one wire frame: magic, 4-byte BE length, netstring(command NUL payload)."""
ns = netstring(command + b"\x00" + payload)
return b"\x00" + struct.pack(">I", len(ns)) + ns
def run_sicd(wire: bytes) -> subprocess.CompletedProcess:
try:
return subprocess.run(
[sys.executable, SICD],
input=wire,
capture_output=True,
timeout=TIMEOUT,
)
except subprocess.TimeoutExpired:
pytest.fail("sicd hung instead of completing (stdin not consumed/forwarded correctly)")
def test_single_hop_peel_payload_reaches_command_stdin():
"""One frame, command=cat: sicd execs cat and cat's stdin is exactly the payload."""
payload = b"hello from hop zero\n"
r = run_sicd(frame(b"cat", payload))
assert r.returncode == 0, f"expected exit 0, got {r.returncode}, stderr={r.stderr!r}"
assert r.stdout == payload
def test_two_hop_nested_peel():
"""Payload of the outer frame is itself a frame; each sicd peels exactly one layer.
The inner frame starts with the 0x00 magic byte, so this also pins
'split at the FIRST NUL' — a split at any later NUL corrupts the hop.
"""
inner = frame(b"cat", b"nested payload survived two hops\n")
outer = frame(f"{sys.executable} {SICD}".encode(), inner)
r = run_sicd(outer)
assert r.returncode == 0, f"expected exit 0, got {r.returncode}, stderr={r.stderr!r}"
assert r.stdout == b"nested payload survived two hops\n"
def test_trailing_stdin_forwarded_after_payload():
"""Bytes after the frame reach the child's stdin, after the payload, verbatim.
Trailing bytes are raw binary (NULs, 0xFF, a colon and comma that must not
be re-parsed as netstring material).
"""
payload = b"payload-first:"
trailing = b"then \x00 raw \xff bytes, 7:not,a netstring\n"
r = run_sicd(frame(b"cat", payload) + trailing)
assert r.returncode == 0, f"expected exit 0, got {r.returncode}, stderr={r.stderr!r}"
assert r.stdout == payload + trailing
def test_zero_byte_file_regression_large_body_not_swallowed():
"""The historical bug: sicd read ALL of stdin for framing, so a streamed
file body after the frame silently became a zero-byte file. A 64 KiB body
after an empty-payload frame must come out of cat intact."""
body = bytes(range(256)) * 256 # 64 KiB, every byte value
r = run_sicd(frame(b"cat", b"") + body)
assert r.returncode == 0, f"expected exit 0, got {r.returncode}, stderr={r.stderr!r}"
assert r.stdout == body
@pytest.mark.parametrize("payload", [b"--", b"-- --help --version\n", b"a -- b\n"])
def test_bare_double_dash_payload_is_opaque(payload):
"""A bare -- inside the payload is data, never an option/separator: it must
reach the command's stdin byte-for-byte and never abort the run."""
r = run_sicd(frame(b"cat", payload))
assert r.returncode == 0, f"expected exit 0, got {r.returncode}, stderr={r.stderr!r}"
assert r.stdout == payload
@pytest.mark.parametrize(
"wire",
[
pytest.param(b"", id="empty-input"),
pytest.param(b"garbage with no magic byte", id="missing-magic"),
pytest.param(b"4:exec,3:cat,0:,", id="old-v1-frame-without-preamble"),
pytest.param(b"\x00" + struct.pack(">I", 14) + b"not&a netstrng", id="body-not-a-netstring"),
pytest.param(b"\x00" + struct.pack(">I", 100) + netstring(b"cat\x00hi"), id="truncated-declared-length"),
pytest.param(b"\x00" + struct.pack(">I", 6) + netstring(b"ca"), id="content-missing-nul-separator"),
],
)
def test_malformed_input_exits_1_with_diagnostic(wire):
"""Malformed frames: exit status exactly 1, a clear message on stderr,
and nothing exec'd (no stdout)."""
r = run_sicd(wire)
assert r.returncode == 1, f"expected exit 1, got {r.returncode}"
assert r.stdout == b"", "malformed input must not silently exec anything"
assert r.stderr.strip() != b"", "malformed input must produce a diagnostic on stderr"
def test_empty_payload_command_gets_immediate_eof():
"""Empty payload, no trailing bytes: the command runs, sees EOF at once
(no hang), produces nothing, exits 0."""
r = run_sicd(frame(b"cat", b""))
assert r.returncode == 0, f"expected exit 0, got {r.returncode}, stderr={r.stderr!r}"
assert r.stdout == b""