diff --git a/cmd/sicd/main.go b/cmd/sicd/main.go index 01e3977..704eff9 100644 --- a/cmd/sicd/main.go +++ b/cmd/sicd/main.go @@ -1,26 +1,18 @@ -// sicd — receive netstring-framed argv over stdin, execvp it directly. -// -// Deployed on target hosts. Reads netstrings from stdin, interprets the first -// field as a mode selector ("exec" or "sh"), and runs the command accordingly. -// -// Wire format (netstrings, djb): -// -// * -// -// Usage: -// -// ssh sicd < /some/netstring/frames package main import ( "bytes" + "encoding/binary" + "errors" "fmt" + "io" "os" "os/exec" + "os/signal" "strconv" + "syscall" ) -// readNetstring reads one netstring from buf. Returns (payload, rest, ok). func readNetstring(buf []byte) ([]byte, []byte, bool) { i := bytes.IndexByte(buf, ':') if i < 0 { @@ -38,88 +30,115 @@ func readNetstring(buf []byte) ([]byte, []byte, bool) { return buf[start:end], buf[end+1:], true } -// runFrame executes a single command frame. -func runFrame(mode string, args [][]byte) int { - switch mode { - case "exec": - argv := make([]string, len(args)) - for i, a := range args { - argv[i] = string(a) - } - if len(argv) == 0 { - fmt.Fprintln(os.Stderr, "sicd: exec mode with no args") - return 1 - } - cmd := exec.Command(argv[0], argv[1:]...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode() +func waitForChild(cmd *exec.Cmd) int { + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + return 128 + int(ws.Signal()) } - fmt.Fprintf(os.Stderr, "sicd: %v\n", err) - return 1 + return exitErr.ExitCode() } - return 0 - case "sh": - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "sicd: sh mode with no args") - return 1 - } - shellCmd := string(args[0]) - cmd := exec.Command("sh", "-c", shellCmd) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode() - } - fmt.Fprintf(os.Stderr, "sicd: %v\n", err) - return 1 - } - return 0 - default: - fmt.Fprintf(os.Stderr, "sicd: unknown mode %q\n", mode) + fmt.Fprintf(os.Stderr, "sicd: wait: %v\n", err) return 1 } + return 0 } func main() { - buf, err := os.ReadFile("/dev/stdin") + signal.Ignore(syscall.SIGPIPE) + + magic := make([]byte, 1) + if _, err := io.ReadFull(os.Stdin, magic); err != nil { + fmt.Fprintf(os.Stderr, "sicd: read magic: %v\n", err) + os.Exit(1) + } + if magic[0] != 0x00 { + fmt.Fprintf(os.Stderr, "sicd: invalid magic byte: 0x%02x\n", magic[0]) + os.Exit(1) + } + + lenBytes := make([]byte, 4) + if _, err := io.ReadFull(os.Stdin, lenBytes); err != nil { + fmt.Fprintf(os.Stderr, "sicd: read length: %v\n", err) + os.Exit(1) + } + nsLen := binary.BigEndian.Uint32(lenBytes) + + nsBuf := make([]byte, nsLen) + if _, err := io.ReadFull(os.Stdin, nsBuf); err != nil { + fmt.Fprintf(os.Stderr, "sicd: read netstring: %v\n", err) + os.Exit(1) + } + + content, _, ok := readNetstring(nsBuf) + if !ok { + fmt.Fprintf(os.Stderr, "sicd: invalid netstring\n") + os.Exit(1) + } + + nulIdx := bytes.IndexByte(content, 0x00) + if nulIdx < 0 { + fmt.Fprintf(os.Stderr, "sicd: content missing NUL separator\n") + os.Exit(1) + } + command := content[:nulIdx] + payload := content[nulIdx+1:] + + argv := bytes.Split(command, []byte(" ")) + argvStr := make([]string, len(argv)) + for i, a := range argv { + argvStr[i] = string(a) + } + if len(argvStr) == 0 { + fmt.Fprintf(os.Stderr, "sicd: empty command\n") + os.Exit(1) + } + + cmd := exec.Command(argvStr[0], argvStr[1:]...) + stdinPipe, err := cmd.StdinPipe() if err != nil { - fmt.Fprintf(os.Stderr, "sicd: read stdin: %v\n", err) + fmt.Fprintf(os.Stderr, "sicd: create stdin pipe: %v\n", err) + os.Exit(1) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "sicd: exec %s: %v\n", argvStr[0], err) os.Exit(1) } - var mode string - var fields [][]byte - framesProcessed := 0 - - for { - field, rest, ok := readNetstring(buf) - if !ok { - break - } - buf = rest - - if mode == "" { - mode = string(field) - } else if len(field) == 0 { - // Empty netstring = end of frame - if mode != "" { - runFrame(mode, fields) - framesProcessed++ - } - mode = "" - fields = fields[:0] + if err := writeAll(stdinPipe, payload); err != nil { + if errors.Is(err, syscall.EPIPE) { + // Child died before reading all payload. } else { - fields = append(fields, field) + fmt.Fprintf(os.Stderr, "sicd: write payload: %v\n", err) } + stdinPipe.Close() + os.Exit(waitForChild(cmd)) } - if framesProcessed == 0 { + // Pump remaining stdin to child. EPIPE on the write side means the + // child stopped reading (| head semantics) — that is not an error. + // Any other error (EIO, etc.) means sicd's own stdin failed mid-stream + // and the transfer was truncated — exit non-zero with a diagnostic. + if _, err := io.Copy(stdinPipe, os.Stdin); err != nil && !errors.Is(err, syscall.EPIPE) { + fmt.Fprintf(os.Stderr, "sicd: forward stdin: %v\n", err) + stdinPipe.Close() os.Exit(1) } + stdinPipe.Close() + + os.Exit(waitForChild(cmd)) +} + +func writeAll(w io.Writer, data []byte) error { + for off := 0; off < len(data); { + n, err := w.Write(data[off:]) + if err != nil { + return err + } + off += n + } + return nil } diff --git a/cmd/sicd/sicd_test.go b/cmd/sicd/sicd_test.go new file mode 100644 index 0000000..b1a66e3 --- /dev/null +++ b/cmd/sicd/sicd_test.go @@ -0,0 +1,529 @@ +// sicd_test.go — executable spec for the sic wire protocol v2, ported from +// tests/test_sicd_protocol.py (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) +// netstring := :, (djb netstring) +// content := 0x00 (split at the FIRST NUL; NUL required) +// +// Semantics under test: each hop peels exactly ONE layer; the payload is +// opaque bytes; the child's stdin is payload ++ every remaining byte of +// sicd's own stdin; malformed input exits 1 with a diagnostic and execs +// nothing; sicd inherits the child's exit status, signal death as +// 128+WTERMSIG. +// +// EPIPE note: CPython sets SIGPIPE to SIG_IGN at startup, so the reference +// sees BrokenPipeError. The Go runtime only shields writes to fds 1 and 2; +// on the child pipe the implementation must handle syscall.EPIPE explicitly +// or the EPIPE path becomes a SIGPIPE death / unhandled error. +// +// Black-box: TestMain builds the sicd binary once; every test drives it as a +// subprocess with a 10s hang watchdog. +package main + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" + "unsafe" +) + +const timeout = 10 * time.Second + +var sicdBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "sicd-test-") + if err != nil { + fmt.Fprintf(os.Stderr, "tempdir: %v\n", err) + os.Exit(1) + } + sicdBin = filepath.Join(dir, "sicd") + if out, err := exec.Command("go", "build", "-o", sicdBin, ".").CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "building sicd: %v\n%s", err, out) + os.RemoveAll(dir) + os.Exit(1) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +// --- wire builders ----------------------------------------------------------- + +func netstring(data []byte) []byte { + return concat([]byte(strconv.Itoa(len(data))+":"), data, []byte{','}) +} + +func be32(n uint32) []byte { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, n) + return b +} + +// frame builds one wire frame: magic, 4-byte BE length, netstring(command NUL payload). +func frame(command, payload []byte) []byte { + ns := netstring(concat(command, []byte{0x00}, payload)) + return concat([]byte{0x00}, be32(uint32(len(ns))), ns) +} + +func concat(parts ...[]byte) []byte { + var b bytes.Buffer + for _, p := range parts { + b.Write(p) + } + return b.Bytes() +} + +// pattern returns n repetitions of the 256-byte sequence 0x00..0xFF. +func pattern(n int) []byte { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return bytes.Repeat(b, n) +} + +// shScript writes an executable /bin/sh script and returns its path as a +// command (no spaces in the path, so it survives the argv space-split). +func shScript(t *testing.T, body string) []byte { + t.Helper() + script := filepath.Join(t.TempDir(), "cmd.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return []byte(script) +} + +// --- subprocess driver ------------------------------------------------------- + +type result struct { + code int + stdout []byte + stderr []byte + killedBy syscall.Signal // signal that killed sicd ITSELF, 0 if none +} + +func runSicd(t *testing.T, wire []byte) result { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, sicdBin) + cmd.Stdin = bytes.NewReader(wire) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + waitErr := cmd.Run() + return finish(t, ctx, cmd, waitErr, &out, &errb) +} + +// runSicdFeed streams stdin to sicd through a pipe under the caller's control. +func runSicdFeed(t *testing.T, feed func(w io.Writer)) result { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, sicdBin) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Start(); err != nil { + t.Fatalf("starting sicd: %v", err) + } + feed(stdin) + stdin.Close() + waitErr := cmd.Wait() + return finish(t, ctx, cmd, waitErr, &out, &errb) +} + +func finish(t *testing.T, ctx context.Context, cmd *exec.Cmd, waitErr error, out, errb *bytes.Buffer) result { + t.Helper() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("sicd hung instead of completing (stdin not consumed/forwarded correctly); stderr so far: %q", errb.String()) + } + if cmd.ProcessState == nil { + t.Fatalf("sicd did not run: %v", waitErr) + } + res := result{stdout: out.Bytes(), stderr: errb.Bytes()} + if ws, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + res.killedBy = ws.Signal() + res.code = -1 + } else { + res.code = cmd.ProcessState.ExitCode() + } + return res +} + +// --- happy path: peeling, forwarding, streaming ------------------------------ + +func TestFramePeeling(t *testing.T) { + trailing := []byte("then \x00 raw \xff bytes, 7:not,a netstring\n") + body64k := pattern(256) // 65,536 bytes, every byte value + body64k1 := pattern(257) // 65,792 bytes > 64 KiB pipe buffer + bodyMiB := pattern(4096) // 1 MiB + tests := []struct { + name string + wire []byte + want []byte + }{ + { + // One frame, command=cat: cat's stdin is exactly the payload. + "single-hop-payload-reaches-command-stdin", + frame([]byte("cat"), []byte("hello from hop zero\n")), + []byte("hello from hop zero\n"), + }, + { + // Bytes after the frame reach the child's stdin after the + // payload, verbatim: raw NULs, 0xFF, and netstring-ish + // colon/comma material must never be re-parsed as framing. + "trailing-stdin-forwarded-after-payload", + concat(frame([]byte("cat"), []byte("payload-first:")), trailing), + concat([]byte("payload-first:"), trailing), + }, + { + // The historical zero-byte-file bug: sicd read ALL of stdin for + // framing, so a streamed body after the frame silently vanished. + "zero-byte-file-regression-64k-body-not-swallowed", + concat(frame([]byte("cat"), nil), body64k), + body64k, + }, + { + // Empty payload, no trailing bytes: immediate EOF, no hang. + "empty-payload-command-gets-immediate-eof", + frame([]byte("cat"), nil), + nil, + }, + { + // Body exceeding the 64 KiB pipe buffer must not deadlock: the + // parent must pump through the pipe after fork, not pre-write + // the whole blob and block. + "large-payload-64k-plus-1-no-deadlock", + concat(frame([]byte("cat"), nil), body64k1), + body64k1, + }, + { + // 1 MiB pins the streaming pump loop, not just the buffer edge. + "multi-mib-body-no-deadlock", + concat(frame([]byte("cat"), nil), bodyMiB), + bodyMiB, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := runSicd(t, tc.wire) + if r.code != 0 { + t.Fatalf("expected exit 0, got %d (killedBy=%v), stderr=%q", r.code, r.killedBy, r.stderr) + } + if !bytes.Equal(r.stdout, tc.want) { + t.Fatalf("stdout mismatch: got %d bytes, want %d bytes", len(r.stdout), len(tc.want)) + } + }) + } +} + +func TestTwoHopNestedPeel(t *testing.T) { + // The outer payload is itself a complete frame (starting with the 0x00 + // magic byte) for an inner sicd: each hop peels exactly ONE layer and + // never looks inside the payload. Because the inner frame is full of + // NULs, this also pins "split at the FIRST NUL" — a split at any later + // NUL corrupts the hop. + inner := frame([]byte("cat"), []byte("nested payload survived two hops\n")) + outer := frame([]byte(sicdBin), inner) + r := runSicd(t, outer) + if r.code != 0 { + t.Fatalf("expected exit 0, got %d (killedBy=%v), stderr=%q", r.code, r.killedBy, r.stderr) + } + if got, want := string(r.stdout), "nested payload survived two hops\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestCommandSplitOnSpacesNoShell(t *testing.T) { + // argv = command split on ASCII spaces, exec'd directly (shell=False): + // shell metacharacters in the command are plain argv bytes. + r := runSicd(t, frame([]byte("echo hello $HOME ;id"), nil)) + if r.code != 0 { + t.Fatalf("expected exit 0, got %d, stderr=%q", r.code, r.stderr) + } + if got, want := string(r.stdout), "hello $HOME ;id\n"; got != want { + t.Fatalf("stdout = %q, want %q — command must not pass through a shell", got, want) + } +} + +func TestBareDoubleDashPayloadIsOpaque(t *testing.T) { + // 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. + for _, payload := range [][]byte{ + []byte("--"), + []byte("-- --help --version\n"), + []byte("a -- b\n"), + } { + t.Run(strconv.Quote(string(payload)), func(t *testing.T) { + r := runSicd(t, frame([]byte("cat"), payload)) + if r.code != 0 { + t.Fatalf("expected exit 0, got %d, stderr=%q", r.code, r.stderr) + } + if !bytes.Equal(r.stdout, payload) { + t.Fatalf("stdout = %q, want %q", r.stdout, payload) + } + }) + } +} + +// --- malformed input --------------------------------------------------------- + +func TestMalformedInputExits1WithDiagnostic(t *testing.T) { + tests := []struct { + name string + wire []byte + }{ + {"empty-input", nil}, + {"missing-magic", []byte("garbage with no magic byte")}, + {"old-v1-frame-without-preamble", []byte("4:exec,3:cat,0:,")}, + {"body-not-a-netstring", concat([]byte{0x00}, be32(14), []byte("not&a netstrng"))}, + {"truncated-declared-length", concat([]byte{0x00}, be32(100), netstring([]byte("cat\x00hi")))}, + {"content-missing-nul-truncated", concat([]byte{0x00}, be32(6), netstring([]byte("ca")))}, + {"content-missing-nul-separator", concat([]byte{0x00}, be32(5), netstring([]byte("ca")))}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := runSicd(t, tc.wire) + if r.code != 1 { + t.Fatalf("expected exit 1, got %d (killedBy=%v)", r.code, r.killedBy) + } + if len(r.stdout) != 0 { + t.Fatalf("malformed input must not silently exec anything, stdout=%q", r.stdout) + } + if len(bytes.TrimSpace(r.stderr)) == 0 { + t.Fatal("malformed input must produce a diagnostic on stderr") + } + }) + } +} + +func TestNonexistentCommandExitsNonzero(t *testing.T) { + r := runSicd(t, frame([]byte("__nonexistent_command_42__"), nil)) + if r.killedBy != 0 { + t.Fatalf("sicd itself was killed by %v", r.killedBy) + } + if r.code == 0 { + t.Fatal("exec of a nonexistent command must not hang or exit 0") + } +} + +// --- exit-status fidelity ---------------------------------------------------- + +func TestChildExitStatusInherited(t *testing.T) { + r := runSicd(t, frame(shScript(t, "exit 7"), nil)) + if r.code != 7 { + t.Fatalf("expected sicd to inherit child exit status 7, got %d, stderr=%q", r.code, r.stderr) + } +} + +func TestSignalKilledChildExits128PlusTermsig(t *testing.T) { + // exit 1 is indistinguishable from an ordinary command failure; signal + // death must surface as 128+WTERMSIG (shell convention) so the caller + // can tell a crashed/killed command from one that merely returned false. + tests := []struct { + name string + sig string + want int + }{ + {"SIGKILL-137", "KILL", 137}, + {"SIGTERM-143", "TERM", 143}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := runSicd(t, frame(shScript(t, "kill -"+tc.sig+" $$"), nil)) + if r.code != tc.want { + t.Fatalf("child died from SIG%s; expected exit %d (128+WTERMSIG), got %d (killedBy=%v, stderr=%q)", + tc.sig, tc.want, r.code, r.killedBy, r.stderr) + } + }) + } +} + +// --- fault injection: EPIPE, short reads, stdin read error ------------------- +// +// The Python suite monkeypatches os.fork/os.write/os.read; a compiled Go +// binary cannot be patched, so each fault is reproduced with real kernel +// behaviour instead: +// +// widowed pipe : the child dies without reading stdin while the payload +// (256 KiB) is far larger than the 64 KiB pipe buffer, so +// the parent is guaranteed to still be writing when the +// pipe is widowed — deterministic EPIPE, no signal races. +// short reads : the whole wire arrives in 7-byte chunks over a pipe, so +// framing reads and the pump loop see partial reads. +// (Go's runtime already loops partial WRITES internally, +// so the Python short-write fault maps to the read side.) +// stdin read err : sicd's stdin is a raw pty slave; closing the master +// mid-stream makes the next read fail with EIO, exactly +// how a dying tty / revoked fd fails. + +func TestEpipeChildDeadBeforePayloadWriteIsHandled(t *testing.T) { + // The child is a failed exec: dead before it ever reads. The 256 KiB + // payload cannot fit the pipe buffer, so the payload write MUST hit + // EPIPE. sicd must handle it (Go: check for syscall.EPIPE — there is no + // CPython-style BrokenPipeError safety net on non-std fds), report the + // exec failure, and exit with the child's status 1 — never crash. + payload := pattern(1024) // 256 KiB + r := runSicd(t, frame([]byte("__nonexistent_command_42__"), payload)) + if r.killedBy != 0 { + t.Fatalf("sicd itself was killed by %v — EPIPE/SIGPIPE on the child pipe is not handled", r.killedBy) + } + if bytes.Contains(r.stderr, []byte("panic")) { + t.Fatalf("unhandled panic instead of clean EPIPE handling:\n%s", r.stderr) + } + if r.code != 1 { + t.Fatalf("expected the child's exit status 1, got %d, stderr=%q", r.code, r.stderr) + } + if !bytes.Contains(r.stderr, []byte("__nonexistent_command_42__")) { + t.Fatalf("stderr must name the command that failed to exec, got %q", r.stderr) + } +} + +func TestSignalKilledChildEpipePathExits137(t *testing.T) { + // Same 128+WTERMSIG contract on the EPIPE recovery path: the child + // SIGKILLs itself without reading stdin, and the 256 KiB payload + // guarantees the parent is still writing into a widowed pipe when it + // dies. The status inherited there must also be 137, not 1. + r := runSicd(t, frame(shScript(t, "kill -KILL $$"), pattern(1024))) + if r.killedBy != 0 { + t.Fatalf("sicd itself was killed by %v — EPIPE/SIGPIPE on the child pipe is not handled", r.killedBy) + } + if bytes.Contains(r.stderr, []byte("panic")) { + t.Fatalf("unhandled panic on the EPIPE path:\n%s", r.stderr) + } + if r.code != 137 { + t.Fatalf("expected exit 137 (128+SIGKILL) via the EPIPE path, got %d, stderr=%q", r.code, r.stderr) + } +} + +func TestFramingAndPumpSurviveTrickledStdin(t *testing.T) { + // Short-read torture: preamble, netstring, and an 8 KiB trailing body + // arrive in 7-byte chunks. Framing must loop its reads to exactness + // (a single os.Read of 5 bytes is not guaranteed 5 bytes) and the pump + // loop must forward every partial read without loss or reordering. + body := pattern(32) // 8 KiB + wire := concat(frame([]byte("cat"), nil), body) + r := runSicdFeed(t, func(w io.Writer) { + for off := 0; off < len(wire); off += 7 { + end := min(off+7, len(wire)) + if _, err := w.Write(wire[off:end]); err != nil { + return // sicd died early; Wait() reports it + } + if off%(7*128) == 0 { + time.Sleep(time.Millisecond) // force fragmentation on the pipe + } + } + }) + if r.code != 0 { + t.Fatalf("expected exit 0, got %d (killedBy=%v), stderr=%q", r.code, r.killedBy, r.stderr) + } + if !bytes.Equal(r.stdout, body) { + t.Fatalf("trickled stdin truncated or corrupted: got %d of %d bytes", len(r.stdout), len(body)) + } +} + +func TestStdinReadErrorDiagnosedNotSwallowed(t *testing.T) { + // An OSError reading sicd's OWN stdin (EIO here; EBADF, ENXIO likewise) + // is not `| head` semantics — only EPIPE on the write side is. Silently + // swallowing it reports truncation as success. Required: non-zero exit + // and a handled diagnostic on stderr (not a panic). + master, slave := openPty(t) + defer master.Close() + defer slave.Close() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, sicdBin) + cmd.Stdin = slave + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Start(); err != nil { + t.Fatalf("starting sicd: %v", err) + } + slave.Close() // the child holds its own copy of the slave fd + + payload := []byte("payload written before the fault\n") + trailing := bytes.Repeat([]byte("x"), 512) + if _, err := master.Write(concat(frame([]byte("cat"), payload), trailing)); err != nil { + t.Fatalf("writing to pty master: %v", err) + } + time.Sleep(300 * time.Millisecond) // let sicd consume the frame and enter the pump loop + master.Close() // slave reads now fail with EIO + + waitErr := cmd.Wait() + r := finish(t, ctx, cmd, waitErr, &out, &errb) + if r.code == 0 { + t.Fatal("stdin read failed mid-stream (EIO) but sicd exited 0 — silent truncation reported as success") + } + if len(bytes.TrimSpace(r.stderr)) == 0 { + t.Fatal("stdin read failed mid-stream but no diagnostic was written to stderr") + } + if bytes.Contains(r.stderr, []byte("panic")) { + t.Fatalf("want a handled diagnostic, not a panic:\n%s", r.stderr) + } +} + +// --- pty plumbing (Linux) ---------------------------------------------------- + +func ioctl(fd uintptr, req uintptr, arg unsafe.Pointer) error { + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, req, uintptr(arg)); errno != 0 { + return errno + } + return nil +} + +// openPty opens a pty pair with the slave in raw mode so frame bytes (NULs, +// 0xFF, control chars) pass through 8-bit clean. Closing the master later +// makes slave reads fail with EIO. +func openPty(t *testing.T) (master, slave *os.File) { + t.Helper() + master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("open /dev/ptmx: %v", err) + } + var ptn uint32 + if err := ioctl(master.Fd(), syscall.TIOCGPTN, unsafe.Pointer(&ptn)); err != nil { + t.Fatalf("TIOCGPTN: %v", err) + } + var unlock int32 + if err := ioctl(master.Fd(), syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil { + t.Fatalf("TIOCSPTLCK: %v", err) + } + slave, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", ptn), os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("open pty slave: %v", err) + } + var tio syscall.Termios + if err := ioctl(slave.Fd(), syscall.TCGETS, unsafe.Pointer(&tio)); err != nil { + t.Fatalf("TCGETS: %v", err) + } + tio.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | + syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON + tio.Oflag &^= syscall.OPOST + tio.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN + tio.Cflag &^= syscall.CSIZE | syscall.PARENB + tio.Cflag |= syscall.CS8 + tio.Cc[syscall.VMIN] = 1 + tio.Cc[syscall.VTIME] = 0 + if err := ioctl(slave.Fd(), syscall.TCSETS, unsafe.Pointer(&tio)); err != nil { + t.Fatalf("TCSETS: %v", err) + } + return master, slave +}