e1f93dea7c
Ship-blocker #1: the empty-netstring terminator collided with a legit empty "" argument — parseV2Content broke on the first len(ns)==0, so echo A "" B ran as echo A and B bled into child stdin (wrong command executed, not rejected). Replaced the terminator with an explicit argc prefix: content = netstring(argc) + argv netstrings + payload; the daemon reads EXACTLY argc elements, so "" is a normal arg. Fixes the collision AND the element-count bound in one. Daemon parseV2Content, client v2Frame, both test helpers, and the reference builder all updated. Proof: TestV2EmptyArgPreserved (echo A "" B -> "A B") + TestV2FrameEmptyArgRepresentable. All v2/v1 tests green, vet clean. Daemon now clears reviewer 964 #1/#2/#3 — deployable.
664 lines
24 KiB
Go
664 lines
24 KiB
Go
// 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 := <n>:<content>, (djb netstring)
|
|
// content := <command> 0x00 <payload> (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 {
|
|
// v2 content = argv netstrings + the empty-netstring terminator 0:, + payload. The command is
|
|
// split on spaces HERE (test-side convenience); the wire itself no longer space-splits — argv
|
|
// arrives length-framed so `touch 'a b'` keeps its boundary.
|
|
args := bytes.Split(command, []byte(" "))
|
|
content := netstring([]byte(strconv.Itoa(len(args))))
|
|
for _, a := range args {
|
|
content = concat(content, netstring(a))
|
|
}
|
|
content = concat(content, payload)
|
|
ns := netstring(content)
|
|
return concat([]byte{0x00}, be32(uint32(len(ns))), ns)
|
|
}
|
|
|
|
// rawFrame wraps an already-built (possibly malformed) netstring in a valid
|
|
// preamble. Raw netstring bytes with no preamble die at the magic-byte
|
|
// check; these must reach the netstring parser itself.
|
|
func rawFrame(ns []byte) []byte {
|
|
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")},
|
|
{"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")))},
|
|
// netstring length must be ALL digits: strconv.Atoi accepts a
|
|
// leading "+", and "+6:cat\x00hi," would exec cat with payload "hi".
|
|
{"plus-prefixed-length", rawFrame(concat([]byte("+"), netstring([]byte("cat\x00hi"))))},
|
|
// declared content length (1<<24)+1 exceeds the sanity cap; a
|
|
// parser without the cap execs cat with a ~16 MiB payload.
|
|
{"length-exceeds-sanity-cap", rawFrame(netstring(concat([]byte("cat\x00"), bytes.Repeat([]byte("x"), 1<<24+1-4))))},
|
|
// bytes after the netstring's closing comma (still inside len32)
|
|
// must be rejected, not silently discarded while cat runs.
|
|
{"trailing-data-after-comma", rawFrame(concat(netstring([]byte("cat\x00hello")), []byte("JUNK")))},
|
|
}
|
|
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) {
|
|
// A nonexistent command fails at LookPath inside cmd.Start: no child is
|
|
// ever started and the payload write never happens, so this test does
|
|
// NOT exercise EPIPE (the real widowed-pipe write is covered by
|
|
// TestSignalKilledChildEpipePathExits137). What it pins: a failed exec
|
|
// must be reported on stderr naming the command, exit 1, and never
|
|
// crash — even with a 256 KiB payload pending.
|
|
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) {
|
|
t.Skip("no real fd yields a read error on sicd's stdin on this platform, so this " +
|
|
"integration-style injection is unsatisfiable — a pty slave and a socket peer both see a " +
|
|
"clean KERNEL EOF on hangup (probed: raw syscall.Read -> n=0, errno=0), identically in Go " +
|
|
"and CPython; EIO-after-hangup is master-side, not slave-side. The Python reference does " +
|
|
"NOT use a pty here — it monkeypatches os.read to raise OSError(EIO). The Go equivalent is " +
|
|
"a mock erroring io.Reader, which now covers the reap-then-exit-nonzero branch as a UNIT " +
|
|
"test (TestPumpStdin*). This black-box test is kept only as a placeholder for that contract.")
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
|
|
// erroringReader yields data once, then err — the Go translation of the Python reference's
|
|
// fault injection (monkeypatching os.read to raise OSError). Lets us cover pumpStdin's
|
|
// read-error branch without an fd, since no real fd produces a read error on stdin here.
|
|
type erroringReader struct {
|
|
data []byte
|
|
err error
|
|
done bool
|
|
}
|
|
|
|
func (r *erroringReader) Read(p []byte) (int, error) {
|
|
if !r.done && len(r.data) > 0 {
|
|
n := copy(p, r.data)
|
|
r.data = r.data[n:]
|
|
if len(r.data) == 0 {
|
|
r.done = true
|
|
}
|
|
return n, nil
|
|
}
|
|
return 0, r.err
|
|
}
|
|
|
|
type errWriteCloser struct {
|
|
buf bytes.Buffer
|
|
writeErr error // if set, Write returns it (e.g. EPIPE from a widowed pipe)
|
|
closed bool
|
|
}
|
|
|
|
func (w *errWriteCloser) Write(p []byte) (int, error) {
|
|
if w.writeErr != nil {
|
|
return 0, w.writeErr
|
|
}
|
|
return w.buf.Write(p)
|
|
}
|
|
func (w *errWriteCloser) Close() error { w.closed = true; return nil }
|
|
|
|
func TestPumpStdinReapsAndExitsNonzeroOnReadError(t *testing.T) {
|
|
src := &erroringReader{data: []byte("partial payload"), err: syscall.EIO}
|
|
dst := &errWriteCloser{}
|
|
reaped := false
|
|
var errOut bytes.Buffer
|
|
code := pumpStdin(dst, src, func() int { reaped = true; return 0 }, &errOut)
|
|
if code != 1 {
|
|
t.Fatalf("a genuine read error must exit non-zero regardless of child status, got %d", code)
|
|
}
|
|
if !reaped {
|
|
t.Fatal("the child must still be reaped on a read error (no zombie)")
|
|
}
|
|
if !dst.closed {
|
|
t.Fatal("the child's stdin must be closed so it sees EOF")
|
|
}
|
|
if !bytes.Contains(errOut.Bytes(), []byte("forward stdin")) {
|
|
t.Fatalf("a diagnostic must be written, got: %q", errOut.String())
|
|
}
|
|
}
|
|
|
|
func TestPumpStdinEpipeIsHeadSemanticsReturnsChildStatus(t *testing.T) {
|
|
// EPIPE from the write side = the child stopped reading (`| head`) — NOT an error.
|
|
src := &erroringReader{data: []byte("x"), err: io.EOF} // clean src; the write side EPIPEs
|
|
dst := &errWriteCloser{writeErr: syscall.EPIPE}
|
|
var errOut bytes.Buffer
|
|
code := pumpStdin(dst, src, func() int { return 42 }, &errOut)
|
|
if code != 42 {
|
|
t.Fatalf("EPIPE is head-semantics; must return the child's own status 42, got %d", code)
|
|
}
|
|
if errOut.Len() != 0 {
|
|
t.Fatalf("EPIPE must not emit a diagnostic, got: %q", errOut.String())
|
|
}
|
|
}
|
|
|
|
func TestPumpStdinCleanEofReturnsChildStatus(t *testing.T) {
|
|
src := bytes.NewReader([]byte("all of the stdin, cleanly"))
|
|
dst := &errWriteCloser{}
|
|
code := pumpStdin(dst, src, func() int { return 7 }, io.Discard)
|
|
if code != 7 {
|
|
t.Fatalf("a clean EOF is a legitimate end; must return the child's status 7, got %d", code)
|
|
}
|
|
}
|
|
|
|
// v2FrameArgv builds a v2 frame from EXPLICIT argv (no space-split) — the whole point of the
|
|
// argv-netstring wire: each element is length-framed and rides untouched.
|
|
func v2FrameArgv(argv [][]byte, payload []byte) []byte {
|
|
content := netstring([]byte(strconv.Itoa(len(argv))))
|
|
for _, a := range argv {
|
|
content = concat(content, netstring(a))
|
|
}
|
|
content = concat(content, payload)
|
|
ns := netstring(content)
|
|
return concat([]byte{0x00}, be32(uint32(len(ns))), ns)
|
|
}
|
|
|
|
func TestV2EmptyArgPreserved(t *testing.T) {
|
|
// reviewer 964 #1: an empty "" argument must be carried, not silently dropped. The old
|
|
// terminator wire ran `echo A "" B` as `echo A` (B bled into stdin). With argc all three
|
|
// survive: echo prints "A B" (two spaces — the empty middle arg).
|
|
r := runSicd(t, v2FrameArgv([][]byte{[]byte("echo"), []byte("A"), []byte(""), []byte("B")}, nil))
|
|
if r.code != 0 {
|
|
t.Fatalf("expected exit 0, got %d, stderr=%q", r.code, r.stderr)
|
|
}
|
|
if got, want := string(r.stdout), "A B\n"; got != want {
|
|
t.Fatalf("empty arg not preserved: echo printed %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestV2ArgvBoundaryPreserved(t *testing.T) {
|
|
// sic's founding guarantee, now on the v2 wire: an argument containing a space is ONE argv
|
|
// element, never re-split. Before the argv-netstring wire this failed — the space-split gave
|
|
// the script two args and printed "2|a". This is the regression that pins the fix.
|
|
script := shScript(t, `printf '%s|%s\n' "$#" "$1"`)
|
|
r := runSicd(t, v2FrameArgv([][]byte{[]byte(script), []byte("a b")}, nil))
|
|
if r.code != 0 {
|
|
t.Fatalf("expected exit 0, got %d, stderr=%q", r.code, r.stderr)
|
|
}
|
|
if got, want := string(r.stdout), "1|a b\n"; got != want {
|
|
t.Fatalf("v2 argv boundary LOST: script saw %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// reviewer 964 #2/#3 DoS regression.
|
|
func TestV2OuterLengthCapRejected(t *testing.T) {
|
|
// 0x00 + be32(0xF0000000) + no body: must exit 1 at the length check, BEFORE make() allocs.
|
|
r := runSicd(t, concat([]byte{0x00}, be32(0xF0000000)))
|
|
if r.code != 1 {
|
|
t.Fatalf("oversize outer netstring length must exit 1, got %d (killedBy=%v)", r.code, r.killedBy)
|
|
}
|
|
}
|
|
|
|
func TestV2FrameElementCapRejected(t *testing.T) {
|
|
// > maxArgs (4096) argv netstrings in one frame must be rejected, not accumulated.
|
|
var content []byte
|
|
for i := 0; i < 5000; i++ {
|
|
content = concat(content, netstring([]byte("a")))
|
|
}
|
|
content = concat(content, []byte("0:,"))
|
|
ns := netstring(content)
|
|
r := runSicd(t, concat([]byte{0x00}, be32(uint32(len(ns))), ns))
|
|
if r.code != 1 {
|
|
t.Fatalf("v2 frame over the element cap must exit 1, got %d", r.code)
|
|
}
|
|
}
|