From 95fe8c32fa4d90063eee70d74e29092492d5c450 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 10 May 2026 23:33:13 +0000 Subject: [PATCH] Harden Go CLI subprocess behavior --- cmd/audita/main_integration_test.go | 230 ++++++++++++++++++++++++++++ internal/cli/run.go | 2 +- internal/cli/run_test.go | 2 +- internal/core/io/files.go | 4 +- 4 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 cmd/audita/main_integration_test.go diff --git a/cmd/audita/main_integration_test.go b/cmd/audita/main_integration_test.go new file mode 100644 index 0000000..ecc4ec0 --- /dev/null +++ b/cmd/audita/main_integration_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/cli" +) + +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + sep := -1 + for i, arg := range os.Args { + if arg == "--" { + sep = i + break + } + } + if sep == -1 { + os.Exit(2) + } + + code := cli.Run(os.Args[sep+1:], os.Stdout, os.Stderr) + os.Exit(code) +} + +func TestProcessHelpSubprocess(t *testing.T) { + result := runCLISubprocess(t, "process", "--help") + if result.exitCode != 0 { + t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr) + } + if !strings.Contains(result.stdout, "Usage:") || !strings.Contains(result.stdout, "--glossary") { + t.Fatalf("unexpected help stdout: %q", result.stdout) + } + if result.stderr != "" { + t.Fatalf("expected empty stderr, got %q", result.stderr) + } +} + +func TestProcessSuccessWithOutputSubprocess(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "corrected.json") + result := runCLISubprocess( + t, + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--output", + outputPath, + ) + + if result.exitCode != 0 { + t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr) + } + if result.stdout != "" { + t.Fatalf("expected empty stdout when --output is set, got %q", result.stdout) + } + if result.stderr != "" { + t.Fatalf("expected empty stderr on success, got %q", result.stderr) + } + + inputBytes := readFile(t, fixturePath("tiny_transcript.json")) + outputBytes := readFile(t, outputPath) + assertJSONSemanticallyEqual(t, inputBytes, outputBytes) +} + +func TestProcessSuccessWithoutOutputSubprocess(t *testing.T) { + result := runCLISubprocess( + t, + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + ) + + if result.exitCode != 0 { + t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr) + } + if result.stderr != "" { + t.Fatalf("expected empty stderr on success, got %q", result.stderr) + } + + inputBytes := readFile(t, fixturePath("tiny_transcript.json")) + assertJSONSemanticallyEqual(t, inputBytes, []byte(result.stdout)) +} + +func TestProcessFailureMissingTranscriptSubprocess(t *testing.T) { + result := runCLISubprocess(t, "process", "--glossary", fixturePath("tiny_glossary.yaml")) + if result.exitCode == 0 { + t.Fatalf("expected nonzero exit code") + } + if result.stdout != "" { + t.Fatalf("expected empty stdout on failure, got %q", result.stdout) + } + if !strings.Contains(result.stderr, "expected exactly 1 transcript JSON path argument") { + t.Fatalf("expected actionable missing transcript error, got %q", result.stderr) + } +} + +func TestProcessFailureMalformedJSONSubprocess(t *testing.T) { + result := runCLISubprocess( + t, + "process", + fixturePath("malformed_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + ) + if result.exitCode == 0 { + t.Fatalf("expected nonzero exit code") + } + if result.stdout != "" { + t.Fatalf("expected empty stdout on failure, got %q", result.stdout) + } + if !strings.Contains(result.stderr, "is not valid JSON") { + t.Fatalf("expected malformed JSON error, got %q", result.stderr) + } +} + +func TestProcessFailureUnwritableOutputSubprocess(t *testing.T) { + outputDir := t.TempDir() + result := runCLISubprocess( + t, + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--output", + outputDir, + ) + if result.exitCode == 0 { + t.Fatalf("expected nonzero exit code") + } + if result.stdout != "" { + t.Fatalf("expected empty stdout on failure, got %q", result.stdout) + } + if !strings.Contains(result.stderr, "failed to write output file") { + t.Fatalf("expected write failure message, got %q", result.stderr) + } +} + +type subprocessResult struct { + stdout string + stderr string + exitCode int +} + +func runCLISubprocess(t *testing.T, args ...string) subprocessResult { + t.Helper() + + cmdArgs := append([]string{"-test.run=TestHelperProcess", "--"}, args...) + cmd := exec.Command(os.Args[0], cmdArgs...) + cmd.Env = append(filterAuditaEnv(os.Environ()), "GO_WANT_HELPER_PROCESS=1") + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err := cmd.Run() + result := subprocessResult{ + stdout: stdoutBuf.String(), + stderr: stderrBuf.String(), + } + if err == nil { + return result + } + + if exitErr, ok := err.(*exec.ExitError); ok { + result.exitCode = exitErr.ExitCode() + return result + } + + t.Fatalf("subprocess execution failed: %v", err) + return subprocessResult{} +} + +func filterAuditaEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, entry := range env { + key := entry + if idx := strings.IndexByte(entry, '='); idx >= 0 { + key = entry[:idx] + } + if strings.HasPrefix(key, "AUDITA_") || key == "OPENROUTER_API_KEY" { + continue + } + filtered = append(filtered, entry) + } + return filtered +} + +func fixturePath(name string) string { + return filepath.Join("..", "..", "internal", "cli", "testdata", name) +} + +func readFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file %q: %v", path, err) + } + return data +} + +func assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) { + t.Helper() + if !json.Valid(actual) { + t.Fatalf("actual output is not valid JSON: %q", string(actual)) + } + + var expectedValue any + var actualValue any + if err := json.Unmarshal(expected, &expectedValue); err != nil { + t.Fatalf("failed to unmarshal expected JSON: %v", err) + } + if err := json.Unmarshal(actual, &actualValue); err != nil { + t.Fatalf("failed to unmarshal actual JSON: %v", err) + } + if !reflect.DeepEqual(expectedValue, actualValue) { + t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual)) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 0e710fb..a5cc104 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -26,7 +26,7 @@ var processRunner = func(inv processInvocation, stdout io.Writer) error { if err != nil { return err } - if err := coreio.ValidateWellFormedJSON(transcriptBytes); err != nil { + if err := coreio.ValidateWellFormedJSON(inv.TranscriptPath, transcriptBytes); err != nil { return err } if _, err := coreio.ReadRequiredFile(inv.GlossaryPath, "glossary"); err != nil { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 57aff57..cc67465 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -156,7 +156,7 @@ func TestRunProcessMalformedTranscriptJSON(t *testing.T) { if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } - if !strings.Contains(stderr.String(), "transcript file is not valid JSON") { + if !strings.Contains(stderr.String(), "is not valid JSON") { t.Fatalf("expected malformed transcript error, got %q", stderr.String()) } } diff --git a/internal/core/io/files.go b/internal/core/io/files.go index efc4c4c..9d438e0 100644 --- a/internal/core/io/files.go +++ b/internal/core/io/files.go @@ -14,9 +14,9 @@ func ReadRequiredFile(path string, label string) ([]byte, error) { return contents, nil } -func ValidateWellFormedJSON(raw []byte) error { +func ValidateWellFormedJSON(path string, raw []byte) error { if !json.Valid(raw) { - return fmt.Errorf("transcript file is not valid JSON") + return fmt.Errorf("transcript file %q is not valid JSON", path) } return nil }