Pin the invocation gate against every kind of stdout
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
//go:build integration && linux
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Under generate() the exit code is the only failure signal and every byte of
|
||||
// stdout is fed to parsejson(). A bare invocation may therefore print help and
|
||||
// exit zero on a terminal and nowhere else. /dev/null and a closed fd 1 are the
|
||||
// traps: both leave the process holding a character device, so a stat of the
|
||||
// file mode calls them terminals.
|
||||
func TestBareInvocationHelpsOnlyATerminal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := buildBinary(t, dir, filepath.Join(dir, "absent.yaml"))
|
||||
|
||||
t.Run("pipe", func(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
code := runBare(t, bin, w)
|
||||
_ = w.Close()
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
assertMachineCaller(t, code, out)
|
||||
})
|
||||
|
||||
t.Run("regular file", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stdout")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
code := runBare(t, bin, f)
|
||||
out, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
assertMachineCaller(t, code, out)
|
||||
})
|
||||
|
||||
t.Run("unix socket", func(t *testing.T) {
|
||||
sock, peer := socketPair(t)
|
||||
code := runBare(t, bin, sock)
|
||||
// The child's copy went with it; dropping ours ends the peer's read.
|
||||
_ = sock.Close()
|
||||
out, err := io.ReadAll(peer)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
assertMachineCaller(t, code, out)
|
||||
})
|
||||
|
||||
// Nothing written to /dev/null is observable, so the exit code carries the
|
||||
// whole contract here -- which is exactly the situation under generate().
|
||||
t.Run("dev null", func(t *testing.T) {
|
||||
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", os.DevNull, err)
|
||||
}
|
||||
defer func() { _ = devNull.Close() }()
|
||||
|
||||
if code := runBare(t, bin, devNull); code == 0 {
|
||||
t.Errorf("exit code = 0, want non-zero")
|
||||
}
|
||||
})
|
||||
|
||||
// A nil file closes fd 1 across the exec. The Go runtime reopens it onto
|
||||
// /dev/null before main() runs, so the process sees a character device it
|
||||
// never chose.
|
||||
t.Run("closed fd 1", func(t *testing.T) {
|
||||
if code := runBare(t, bin, nil); code == 0 {
|
||||
t.Errorf("exit code = 0, want non-zero")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pty", func(t *testing.T) {
|
||||
master, slave := openPTY(t)
|
||||
drained := make(chan []byte, 1)
|
||||
go func() {
|
||||
out, _ := io.ReadAll(master)
|
||||
drained <- out
|
||||
}()
|
||||
|
||||
code := runBare(t, bin, slave)
|
||||
// The child's copy is gone; dropping ours makes the master read EIO.
|
||||
_ = slave.Close()
|
||||
out := <-drained
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("exit code = %d, want 0", code)
|
||||
}
|
||||
if !strings.Contains(string(out), "Usage:") {
|
||||
t.Errorf("stdout = %q, want the help text", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertMachineCaller(t *testing.T, code int, stdout []byte) {
|
||||
t.Helper()
|
||||
if code == 0 {
|
||||
t.Errorf("exit code = 0, want non-zero (stdout %q)", stdout)
|
||||
}
|
||||
if len(stdout) != 0 {
|
||||
t.Errorf("stdout = %q, want empty", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// runBare runs the binary with no arguments and stdout as fd 1. A nil stdout
|
||||
// leaves fd 1 closed at exec.
|
||||
func runBare(t *testing.T, bin string, stdout *os.File) int {
|
||||
t.Helper()
|
||||
proc, err := os.StartProcess(bin, []string{bin}, &os.ProcAttr{
|
||||
Env: hermeticEnv(t),
|
||||
Files: []*os.File{nil, stdout, nil},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start %s: %v", bin, err)
|
||||
}
|
||||
state, err := proc.Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("wait: %v", err)
|
||||
}
|
||||
return state.ExitCode()
|
||||
}
|
||||
|
||||
// socketPair returns a connected unix socket to hand the child as stdout, and
|
||||
// the peer end to read back whatever reached it.
|
||||
func socketPair(t *testing.T) (sock, peer *os.File) {
|
||||
t.Helper()
|
||||
fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("socketpair: %v", err)
|
||||
}
|
||||
sock = os.NewFile(uintptr(fds[0]), "stdout-socket")
|
||||
peer = os.NewFile(uintptr(fds[1]), "stdout-socket-peer")
|
||||
t.Cleanup(func() { _ = sock.Close(); _ = peer.Close() })
|
||||
return sock, peer
|
||||
}
|
||||
|
||||
// openPTY allocates a pty pair. CI containers have no controlling terminal, so
|
||||
// this is the only way to exercise the branch that prints help and exits zero.
|
||||
func openPTY(t *testing.T) (master, slave *os.File) {
|
||||
t.Helper()
|
||||
m, err := os.OpenFile("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open /dev/ptmx: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.Close() })
|
||||
|
||||
if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil {
|
||||
t.Fatalf("unlock pty: %v", err)
|
||||
}
|
||||
n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN)
|
||||
if err != nil {
|
||||
t.Fatalf("pty number: %v", err)
|
||||
}
|
||||
s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open pty slave: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return m, s
|
||||
}
|
||||
Reference in New Issue
Block a user