181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
//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
|
|
}
|