823 lines
25 KiB
Go
823 lines
25 KiB
Go
package subprocess
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestRunSuccessCapturesStdoutStderr(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
stdoutPath := filepath.Join(dir, "stdout.log")
|
|
stderrPath := filepath.Join(dir, "stderr.log")
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "success"},
|
|
EnvOverrides: map[string]string{"GO_WANT_SUBPROCESS_HELPER": "1", "SUBPROCESS_HELPER_STDOUT": "hello-out", "SUBPROCESS_HELPER_STDERR": "hello-err"},
|
|
StdoutLogPath: stdoutPath,
|
|
StderrLogPath: stderrPath,
|
|
}
|
|
|
|
res, err := Run(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
|
}
|
|
|
|
stdoutBytes, err := os.ReadFile(stdoutPath)
|
|
if err != nil {
|
|
t.Fatalf("read stdout log: %v", err)
|
|
}
|
|
if !strings.Contains(string(stdoutBytes), "hello-out") {
|
|
t.Fatalf("stdout log = %q, want hello-out", string(stdoutBytes))
|
|
}
|
|
|
|
stderrBytes, err := os.ReadFile(stderrPath)
|
|
if err != nil {
|
|
t.Fatalf("read stderr log: %v", err)
|
|
}
|
|
if !strings.Contains(string(stderrBytes), "hello-err") {
|
|
t.Fatalf("stderr log = %q, want hello-err", string(stderrBytes))
|
|
}
|
|
}
|
|
|
|
func TestRunFailureReturnsUsefulError(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
stdoutPath := filepath.Join(dir, "stdout.log")
|
|
stderrPath := filepath.Join(dir, "stderr.log")
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "fail"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
},
|
|
StdoutLogPath: stdoutPath,
|
|
StderrLogPath: stderrPath,
|
|
}
|
|
|
|
res, err := Run(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want non-nil")
|
|
}
|
|
if res.ExitCode == 0 {
|
|
t.Fatalf("ExitCode = %d, want non-zero", res.ExitCode)
|
|
}
|
|
if !strings.Contains(err.Error(), "exit code") {
|
|
t.Fatalf("error = %q, want exit code context", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), exe) {
|
|
t.Fatalf("error = %q, want executable context", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), stdoutPath) || !strings.Contains(err.Error(), stderrPath) {
|
|
t.Fatalf("error = %q, want stdout/stderr log paths", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRunFailureRedactsSensitiveTail(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
secretValue := "super-secret-value"
|
|
dir := t.TempDir()
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "failsecret"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"API_KEY": secretValue,
|
|
"SUBPROCESS_HELPER_ENV_KEY": "API_KEY",
|
|
},
|
|
StdoutLogPath: filepath.Join(dir, "stdout.log"),
|
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
|
}
|
|
|
|
_, err = Run(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want non-nil")
|
|
}
|
|
if strings.Contains(err.Error(), secretValue) {
|
|
t.Fatalf("error leaked secret value: %q", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "<redacted>") {
|
|
t.Fatalf("error = %q, want redacted stderr tail marker", err.Error())
|
|
}
|
|
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath} {
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
t.Fatalf("read diagnostic %q: %v", path, readErr)
|
|
}
|
|
if strings.Contains(string(data), secretValue) {
|
|
t.Fatalf("diagnostic %q leaked secret: %q", path, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
targetPath := filepath.Join(dir, "outside.log")
|
|
const original = "must remain unchanged"
|
|
if err := os.WriteFile(targetPath, []byte(original), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(target) error = %v", err)
|
|
}
|
|
stdoutPath := filepath.Join(dir, "stdout.log")
|
|
if err := os.Symlink(targetPath, stdoutPath); err != nil {
|
|
t.Fatalf("Symlink() error = %v", err)
|
|
}
|
|
|
|
_, err = Run(context.Background(), RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "success"},
|
|
EnvOverrides: map[string]string{"GO_WANT_SUBPROCESS_HELPER": "1"},
|
|
StdoutLogPath: stdoutPath,
|
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
|
t.Fatalf("Run() error = %v, want symbolic-link rejection", err)
|
|
}
|
|
data, readErr := os.ReadFile(targetPath)
|
|
if readErr != nil {
|
|
t.Fatalf("ReadFile(target) error = %v", readErr)
|
|
}
|
|
if string(data) != original {
|
|
t.Fatalf("target content = %q, want %q", data, original)
|
|
}
|
|
}
|
|
|
|
func TestRunFailureUsesOpenedDiagnosticAfterPathReplacement(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
readyPath := filepath.Join(dir, "ready")
|
|
releasePath := filepath.Join(dir, "release")
|
|
stderrPath := filepath.Join(dir, "stderr.log")
|
|
openedPath := filepath.Join(dir, "opened-stderr.log")
|
|
const secretValue = "replacement-api-key-value"
|
|
const commandContent = "trusted command failure"
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "delayed-fail"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"API_KEY": secretValue,
|
|
"SUBPROCESS_HELPER_READY_PATH": readyPath,
|
|
"SUBPROCESS_HELPER_RELEASE_PATH": releasePath,
|
|
"SUBPROCESS_HELPER_STDERR": commandContent,
|
|
},
|
|
StdoutLogPath: filepath.Join(dir, "stdout.log"),
|
|
StderrLogPath: stderrPath,
|
|
}
|
|
|
|
resultCh := make(chan error, 1)
|
|
go func() {
|
|
_, runErr := Run(context.Background(), req)
|
|
resultCh <- runErr
|
|
}()
|
|
waitForHelperFile(t, readyPath)
|
|
if err := os.Rename(stderrPath, openedPath); err != nil {
|
|
t.Fatalf("Rename(stderr log) error = %v", err)
|
|
}
|
|
if err := os.WriteFile(stderrPath, []byte(secretValue), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(replacement) error = %v", err)
|
|
}
|
|
if err := os.WriteFile(releasePath, []byte("continue"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(release) error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case runErr := <-resultCh:
|
|
if runErr == nil {
|
|
t.Fatal("Run() error = nil, want command failure")
|
|
}
|
|
if strings.Contains(runErr.Error(), secretValue) {
|
|
t.Fatalf("error read replacement-path content: %q", runErr)
|
|
}
|
|
if !strings.Contains(runErr.Error(), commandContent) {
|
|
t.Fatalf("error = %q, want retained command diagnostic", runErr)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Run() did not return after helper release")
|
|
}
|
|
|
|
openedData, err := os.ReadFile(openedPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(opened diagnostic) error = %v", err)
|
|
}
|
|
if !strings.Contains(string(openedData), commandContent) {
|
|
t.Fatalf("opened diagnostic = %q, want command content", openedData)
|
|
}
|
|
replacementData, err := os.ReadFile(stderrPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(replacement diagnostic) error = %v", err)
|
|
}
|
|
if string(replacementData) != secretValue {
|
|
t.Fatalf("replacement diagnostic = %q, want %q", replacementData, secretValue)
|
|
}
|
|
}
|
|
|
|
func TestRunRedactsSplitCredentialInSeparateAndSharedDiagnostics(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
const secretValue = "split-super-secret-value"
|
|
|
|
for _, shared := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "separate", true: "shared"}[shared], func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
stdoutPath := filepath.Join(dir, "stdout.log")
|
|
stderrPath := filepath.Join(dir, "stderr.log")
|
|
if shared {
|
|
stderrPath = stdoutPath
|
|
}
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "splitsecret"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"API_KEY": secretValue,
|
|
},
|
|
StdoutLogPath: stdoutPath,
|
|
StderrLogPath: stderrPath,
|
|
}
|
|
|
|
_, runErr := Run(context.Background(), req)
|
|
if runErr == nil {
|
|
t.Fatal("Run() error = nil, want command failure")
|
|
}
|
|
if strings.Contains(runErr.Error(), secretValue) || !strings.Contains(runErr.Error(), "<redacted>") {
|
|
t.Fatalf("error = %q, want redacted credential", runErr)
|
|
}
|
|
paths := map[string]struct{}{stdoutPath: {}, stderrPath: {}}
|
|
for path := range paths {
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
t.Fatalf("ReadFile(%q) error = %v", path, readErr)
|
|
}
|
|
if strings.Contains(string(data), secretValue) || !strings.Contains(string(data), "<redacted>") {
|
|
t.Fatalf("diagnostic %q = %q, want redacted credential", path, data)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunRedactsInheritedSensitiveEnvironment(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
secretValue := "inherited-secret-value"
|
|
t.Setenv("OPENROUTER_API_KEY", secretValue)
|
|
dir := t.TempDir()
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "echoenv"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"SUBPROCESS_HELPER_ENV_KEY": "OPENROUTER_API_KEY",
|
|
},
|
|
StdoutLogPath: filepath.Join(dir, "stdout.log"),
|
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
|
}
|
|
_, err = Run(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want non-nil")
|
|
}
|
|
if strings.Contains(err.Error(), secretValue) {
|
|
t.Fatalf("error leaked inherited secret: %q", err)
|
|
}
|
|
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath} {
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
t.Fatalf("read diagnostic %q: %v", path, readErr)
|
|
}
|
|
if strings.Contains(string(data), secretValue) {
|
|
t.Fatalf("diagnostic %q leaked inherited secret: %q", path, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRedactsSensitiveOutputAndErrorTail(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
secretValue := "override-secret-value"
|
|
dir := t.TempDir()
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "echoenv"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"SUBPROCESS_HELPER_ENV_KEY": "OPENROUTER_API_KEY",
|
|
"OPENROUTER_API_KEY": secretValue,
|
|
},
|
|
StdoutLogPath: filepath.Join(dir, "stdout.log"),
|
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
|
}
|
|
_, err = Run(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want non-nil")
|
|
}
|
|
if strings.Contains(err.Error(), secretValue) || !strings.Contains(err.Error(), "<redacted>") {
|
|
t.Fatalf("error = %q, want redacted secret", err)
|
|
}
|
|
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath} {
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
t.Fatalf("read diagnostic %q: %v", path, readErr)
|
|
}
|
|
if strings.Contains(string(data), secretValue) || !strings.Contains(string(data), "<redacted>") {
|
|
t.Fatalf("diagnostic %q = %q, want redacted secret", path, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStreamRedactorHandlesSplitAndOverlappingSecrets(t *testing.T) {
|
|
redactor := newStreamRedactor([]string{"abc", "abcde", "cde", ""})
|
|
var output bytes.Buffer
|
|
output.Write(redactor.Write([]byte("start-ab")))
|
|
output.Write(redactor.Write([]byte("cde-end")))
|
|
output.Write(redactor.Flush())
|
|
if got := output.String(); got != "start-<redacted>-end" {
|
|
t.Fatalf("redacted output = %q, want one redacted marker", got)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
|
|
exactLogs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
|
var exactOutput bytes.Buffer
|
|
exact := newDiagnosticWriter(exactLogs, "stdout", "test", &exactOutput, 5, nil)
|
|
if _, err := exact.Write([]byte("abcde")); err != nil {
|
|
t.Fatalf("exact Write() error = %v", err)
|
|
}
|
|
if err := exact.Flush(); err != nil {
|
|
t.Fatalf("exact Flush() error = %v", err)
|
|
}
|
|
if got := exactOutput.String(); got != "abcde" {
|
|
t.Fatalf("exact output = %q, want abcde", got)
|
|
}
|
|
if exactLogs.Limit() != nil {
|
|
t.Fatal("exact write recorded a capture limit")
|
|
}
|
|
|
|
cappedLogs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
|
var cappedOutput bytes.Buffer
|
|
capped := newDiagnosticWriter(cappedLogs, "stderr", "test", &cappedOutput, 5, nil)
|
|
if _, err := capped.Write([]byte("abcdef")); err == nil {
|
|
t.Fatal("cap-plus-one Write() error = nil, want capture limit")
|
|
}
|
|
if err := capped.Flush(); err != nil {
|
|
t.Fatalf("cap-plus-one Flush() error = %v", err)
|
|
}
|
|
if got := cappedOutput.String(); got != "abcde" {
|
|
t.Fatalf("capped output = %q, want abcde", got)
|
|
}
|
|
if limit := cappedLogs.Limit(); limit == nil || limit.stream != "stderr" || limit.limit != 5 {
|
|
t.Fatalf("capture limit = %#v, want stderr limit 5", limit)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticWriterRetainsBoundedRedactedTail(t *testing.T) {
|
|
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
|
var output bytes.Buffer
|
|
secret := "credential-value"
|
|
writer := newDiagnosticWriter(logs, "stderr", "test", &output, 16*1024, []string{secret})
|
|
prefix := strings.Repeat("x", diagnosticTailBytes+512)
|
|
if _, err := writer.Write([]byte(prefix + secret[:7])); err != nil {
|
|
t.Fatalf("first Write() error = %v", err)
|
|
}
|
|
if _, err := writer.Write([]byte(secret[7:] + "-failure")); err != nil {
|
|
t.Fatalf("second Write() error = %v", err)
|
|
}
|
|
if err := writer.Flush(); err != nil {
|
|
t.Fatalf("Flush() error = %v", err)
|
|
}
|
|
tail := writer.Tail()
|
|
if len(tail) > diagnosticTailBytes {
|
|
t.Fatalf("retained tail length = %d, want at most %d", len(tail), diagnosticTailBytes)
|
|
}
|
|
if strings.Contains(tail, secret) || !strings.Contains(tail, "<redacted>-failure") {
|
|
t.Fatalf("retained tail = %q, want bounded redacted content", tail)
|
|
}
|
|
}
|
|
|
|
func TestRunFailureAddsBadDescriptorHint(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "failbadfd"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
},
|
|
StdoutLogPath: filepath.Join(dir, "stdout.log"),
|
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
|
}
|
|
|
|
_, err = Run(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want non-nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Bad file descriptor") {
|
|
t.Fatalf("error = %q, want stderr tail content", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "stderr stream appears invalid in child process") {
|
|
t.Fatalf("error = %q, want bad-descriptor hint", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRunTimeout(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "sleep"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
},
|
|
Timeout: 50 * time.Millisecond,
|
|
}
|
|
|
|
res, err := Run(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want timeout error")
|
|
}
|
|
if !res.TimedOut {
|
|
t.Fatalf("TimedOut = %v, want true", res.TimedOut)
|
|
}
|
|
if !strings.Contains(err.Error(), "timed out") {
|
|
t.Fatalf("error = %q, want timeout context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRunInheritsParentEnvironmentByDefault(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
t.Setenv("PATH", "inherited-value")
|
|
|
|
dir := t.TempDir()
|
|
stdoutPath := filepath.Join(dir, "stdout.log")
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "printenv"},
|
|
EnvOverrides: map[string]string{"GO_WANT_SUBPROCESS_HELPER": "1", "SUBPROCESS_HELPER_ENV_KEY": "PATH"},
|
|
StdoutLogPath: stdoutPath,
|
|
}
|
|
|
|
if _, err := Run(context.Background(), req); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(stdoutPath)
|
|
if err != nil {
|
|
t.Fatalf("read stdout log: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(data)) != "inherited-value" {
|
|
t.Fatalf("stdout = %q, want inherited-value", strings.TrimSpace(string(data)))
|
|
}
|
|
}
|
|
|
|
func TestRunEnvOverridesWinOverInheritedValues(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
stdoutPath := filepath.Join(dir, "stdout.log")
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "printenv"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"SUBPROCESS_HELPER_ENV_KEY": "SUBPROCESS_PARENT_VALUE",
|
|
"SUBPROCESS_PARENT_VALUE": "override-value",
|
|
},
|
|
StdoutLogPath: stdoutPath,
|
|
}
|
|
|
|
if _, err := Run(context.Background(), req); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(stdoutPath)
|
|
if err != nil {
|
|
t.Fatalf("read stdout log: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(data)) != "override-value" {
|
|
t.Fatalf("stdout = %q, want override-value", strings.TrimSpace(string(data)))
|
|
}
|
|
}
|
|
|
|
func TestRunSharedStdoutStderrLogPath(t *testing.T) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable() error = %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
sharedLogPath := filepath.Join(dir, "shared.log")
|
|
req := RunRequest{
|
|
Executable: exe,
|
|
Args: []string{"-test.run=TestSubprocessHelper", "--", "success"},
|
|
EnvOverrides: map[string]string{
|
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
|
"SUBPROCESS_HELPER_STDOUT": "shared-out",
|
|
"SUBPROCESS_HELPER_STDERR": "shared-err",
|
|
},
|
|
StdoutLogPath: sharedLogPath,
|
|
StderrLogPath: sharedLogPath,
|
|
}
|
|
|
|
res, err := Run(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
|
}
|
|
data, err := os.ReadFile(sharedLogPath)
|
|
if err != nil {
|
|
t.Fatalf("read shared log: %v", err)
|
|
}
|
|
text := string(data)
|
|
if !strings.Contains(text, "shared-out") || !strings.Contains(text, "shared-err") {
|
|
t.Fatalf("shared log = %q, want both stdout and stderr content", text)
|
|
}
|
|
}
|
|
|
|
func TestWriteYAMLAtomic(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.generated.yml")
|
|
|
|
if err := WriteYAMLAtomic(path, map[string]any{"name": "narratio", "stage": "merge"}, 0o644); err != nil {
|
|
t.Fatalf("WriteYAMLAtomic() error = %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read yaml: %v", err)
|
|
}
|
|
|
|
var got map[string]any
|
|
if err := yaml.Unmarshal(data, &got); err != nil {
|
|
t.Fatalf("yaml unmarshal: %v", err)
|
|
}
|
|
if got["name"] != "narratio" {
|
|
t.Fatalf("name = %#v, want narratio", got["name"])
|
|
}
|
|
}
|
|
|
|
func TestWriteYAMLAtomicOverwriteNoTempResidue(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.generated.yml")
|
|
|
|
if err := WriteYAMLAtomic(path, map[string]any{"value": "one"}, 0o644); err != nil {
|
|
t.Fatalf("first write: %v", err)
|
|
}
|
|
if err := WriteYAMLAtomic(path, map[string]any{"value": "two"}, 0o644); err != nil {
|
|
t.Fatalf("second write: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read yaml: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "two") {
|
|
t.Fatalf("yaml = %q, want overwritten value", string(data))
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir() error = %v", err)
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.Contains(name, ".tmp-") {
|
|
t.Fatalf("temp file residue found: %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSubprocessHelper(t *testing.T) {
|
|
if os.Getenv("GO_WANT_SUBPROCESS_HELPER") != "1" {
|
|
return
|
|
}
|
|
|
|
args := os.Args
|
|
mode := ""
|
|
for i := range args {
|
|
if args[i] == "--" && i+1 < len(args) {
|
|
mode = args[i+1]
|
|
break
|
|
}
|
|
}
|
|
if mode == "" {
|
|
os.Exit(2)
|
|
}
|
|
|
|
switch mode {
|
|
case "success":
|
|
_, _ = os.Stdout.WriteString(os.Getenv("SUBPROCESS_HELPER_STDOUT") + "\n")
|
|
_, _ = os.Stderr.WriteString(os.Getenv("SUBPROCESS_HELPER_STDERR") + "\n")
|
|
os.Exit(0)
|
|
case "fail":
|
|
_, _ = os.Stderr.WriteString("intentional failure\n")
|
|
os.Exit(3)
|
|
case "failsecret":
|
|
key := os.Getenv("SUBPROCESS_HELPER_ENV_KEY")
|
|
_, _ = os.Stderr.WriteString("secret:" + os.Getenv(key) + "\n")
|
|
os.Exit(4)
|
|
case "failbadfd":
|
|
_, _ = os.Stderr.WriteString("OSError: [Errno 9] Bad file descriptor\n")
|
|
os.Exit(120)
|
|
case "delayed-fail":
|
|
if err := os.WriteFile(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), []byte("ready"), 0o600); err != nil {
|
|
os.Exit(4)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(os.Getenv("SUBPROCESS_HELPER_RELEASE_PATH")); err == nil {
|
|
break
|
|
} else if !errors.Is(err, os.ErrNotExist) || time.Now().After(deadline) {
|
|
os.Exit(5)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
_, _ = os.Stderr.WriteString(os.Getenv("SUBPROCESS_HELPER_STDERR"))
|
|
os.Exit(6)
|
|
case "splitsecret":
|
|
secret := os.Getenv("API_KEY")
|
|
split := len(secret) / 2
|
|
for _, stream := range []*os.File{os.Stdout, os.Stderr} {
|
|
_, _ = stream.WriteString(secret[:split])
|
|
time.Sleep(20 * time.Millisecond)
|
|
_, _ = stream.WriteString(secret[split:] + "\n")
|
|
}
|
|
os.Exit(7)
|
|
case "sleep":
|
|
time.Sleep(500 * time.Millisecond)
|
|
os.Exit(0)
|
|
case "printenv":
|
|
key := os.Getenv("SUBPROCESS_HELPER_ENV_KEY")
|
|
_, _ = os.Stdout.WriteString(os.Getenv(key) + "\n")
|
|
os.Exit(0)
|
|
case "echoenv":
|
|
key := os.Getenv("SUBPROCESS_HELPER_ENV_KEY")
|
|
value := os.Getenv(key)
|
|
_, _ = os.Stdout.WriteString(value)
|
|
_, _ = os.Stderr.WriteString(value)
|
|
os.Exit(5)
|
|
case "spam":
|
|
chunk := strings.Repeat("x", 64*1024)
|
|
count, _ := strconv.Atoi(os.Getenv("SUBPROCESS_HELPER_CHUNKS"))
|
|
for range count {
|
|
_, _ = os.Stdout.WriteString(chunk)
|
|
}
|
|
os.Exit(0)
|
|
case "tree-spam":
|
|
descendant := exec.Command(os.Args[0], "-test.run=^TestSubprocessHelper$", "--", "descendant")
|
|
descendant.Env = append(os.Environ(), "GO_WANT_SUBPROCESS_HELPER=1")
|
|
descendant.Stdout = os.Stdout
|
|
descendant.Stderr = os.Stderr
|
|
if err := descendant.Start(); err != nil {
|
|
os.Exit(3)
|
|
}
|
|
if err := os.WriteFile(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), []byte("ready"), 0o600); err != nil {
|
|
os.Exit(4)
|
|
}
|
|
chunk := strings.Repeat("x", 64*1024)
|
|
for {
|
|
_, _ = os.Stdout.WriteString(chunk)
|
|
}
|
|
case "tree":
|
|
descendant := exec.Command(os.Args[0], "-test.run=^TestSubprocessHelper$", "--", "descendant")
|
|
descendant.Env = append(os.Environ(), "GO_WANT_SUBPROCESS_HELPER=1")
|
|
descendant.Stdout = os.Stdout
|
|
descendant.Stderr = os.Stderr
|
|
if err := descendant.Start(); err != nil {
|
|
os.Exit(3)
|
|
}
|
|
if err := os.WriteFile(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), []byte("ready"), 0o600); err != nil {
|
|
os.Exit(4)
|
|
}
|
|
time.Sleep(10 * time.Second)
|
|
os.Exit(0)
|
|
case "leader-exit-retained", "leader-exit-redirected", "leader-fail-redirected":
|
|
descendant := exec.Command(os.Args[0], "-test.run=^TestSubprocessHelper$", "--", "descendant-after-release")
|
|
descendant.Env = append(os.Environ(), "GO_WANT_SUBPROCESS_HELPER=1")
|
|
if mode == "leader-exit-retained" {
|
|
descendant.Stdout = os.Stdout
|
|
descendant.Stderr = os.Stderr
|
|
}
|
|
if err := descendant.Start(); err != nil {
|
|
os.Exit(3)
|
|
}
|
|
if !helperFileAppeared(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), 2*time.Second) {
|
|
os.Exit(4)
|
|
}
|
|
if mode == "leader-fail-redirected" {
|
|
os.Exit(9)
|
|
}
|
|
os.Exit(0)
|
|
case "descendant-after-release":
|
|
if os.Getenv("SUBPROCESS_HELPER_IGNORE_TERM") == "1" {
|
|
signal.Ignore(syscall.SIGTERM)
|
|
}
|
|
if err := os.WriteFile(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), []byte("ready"), 0o600); err != nil {
|
|
os.Exit(4)
|
|
}
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(os.Getenv("SUBPROCESS_HELPER_RELEASE_PATH")); err == nil {
|
|
_ = os.WriteFile(os.Getenv("SUBPROCESS_HELPER_SENTINEL_PATH"), []byte("survived"), 0o600)
|
|
os.Exit(0)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
os.Exit(5)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
os.Exit(0)
|
|
case "descendant":
|
|
time.Sleep(500 * time.Millisecond)
|
|
_ = os.WriteFile(os.Getenv("SUBPROCESS_HELPER_SENTINEL_PATH"), []byte("survived"), 0o600)
|
|
time.Sleep(10 * time.Second)
|
|
os.Exit(0)
|
|
default:
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func waitForHelperFile(t *testing.T, path string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("Stat(%q) error = %v", path, err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("helper file %q was not created", path)
|
|
}
|
|
|
|
func helperFileAppeared(path string, timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return true
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return false
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|