Files
gosh/main_test.go
T
Florian WaltherandClaude Opus 5.5 7afcecd053 add colored prompt
- user@host in bold green (bold red for root), directory in bold blue
- colors are disabled with NO_COLOR, TERM=dumb or when stdout is not a terminal
- parts are sanitized before adding the color codes
- only replace the home directory with ~ on a path boundary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:31:09 +02:00

1197 lines
34 KiB
Go

package main
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"strings"
"syscall"
"testing"
"time"
"github.com/google/shlex"
)
// =============================================================================
// Direct tests for shlex parsing (the Phase 1.1 improvement)
// =============================================================================
func TestShlexParsing_SingleQuotes(t *testing.T) {
args, err := shlex.Split("echo 'hello world'")
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
if args[0] != "echo" {
t.Errorf("expected 'echo', got '%s'", args[0])
}
if args[1] != "hello world" {
t.Errorf("expected 'hello world', got '%s'", args[1])
}
}
func TestShlexParsing_DoubleQuotes(t *testing.T) {
args, err := shlex.Split(`echo "hello world"`)
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
if args[0] != "echo" {
t.Errorf("expected 'echo', got '%s'", args[0])
}
if args[1] != "hello world" {
t.Errorf("expected 'hello world', got '%s'", args[1])
}
}
func TestShlexParsing_MixedQuotes(t *testing.T) {
args, err := shlex.Split(`echo 'single' "double" unquoted`)
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 4 {
t.Fatalf("expected 4 args, got %d: %v", len(args), args)
}
if args[0] != "echo" {
t.Errorf("expected 'echo', got '%s'", args[0])
}
if args[1] != "single" {
t.Errorf("expected 'single', got '%s'", args[1])
}
if args[2] != "double" {
t.Errorf("expected 'double', got '%s'", args[2])
}
if args[3] != "unquoted" {
t.Errorf("expected 'unquoted', got '%s'", args[3])
}
}
func TestShlexParsing_EscapedSpaces(t *testing.T) {
args, err := shlex.Split("echo file\\ with\\ spaces")
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
if args[0] != "echo" {
t.Errorf("expected 'echo', got '%s'", args[0])
}
if args[1] != "file with spaces" {
t.Errorf("expected 'file with spaces', got '%s'", args[1])
}
}
func TestShlexParsing_NestedQuotes(t *testing.T) {
args, err := shlex.Split(`echo "hello \"world\""`)
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
if args[0] != "echo" {
t.Errorf("expected 'echo', got '%s'", args[0])
}
if args[1] != `hello "world"` {
t.Errorf("expected 'hello \"world\"', got '%s'", args[1])
}
}
func TestShlexParsing_MultipleSpaces(t *testing.T) {
args, err := shlex.Split("echo arg1 arg2")
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 3 {
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
}
if args[0] != "echo" {
t.Errorf("expected 'echo', got '%s'", args[0])
}
if args[1] != "arg1" {
t.Errorf("expected 'arg1', got '%s'", args[1])
}
if args[2] != "arg2" {
t.Errorf("expected 'arg2', got '%s'", args[2])
}
}
func TestShlexParsing_Empty(t *testing.T) {
args, err := shlex.Split("")
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 0 {
t.Fatalf("expected 0 args for empty string, got %d: %v", len(args), args)
}
}
func TestShlexParsing_WhitespaceOnly(t *testing.T) {
args, err := shlex.Split(" ")
if err != nil {
t.Fatalf("shlex.Split failed: %v", err)
}
if len(args) != 0 {
t.Fatalf("expected 0 args for whitespace, got %d: %v", len(args), args)
}
}
func TestShlexParsing_InvalidUnclosedQuote(t *testing.T) {
_, err := shlex.Split("echo 'unclosed")
if err == nil {
t.Error("expected error for unclosed quote, got nil")
}
}
func TestShlexParsing_InvalidUnclosedDoubleQuote(t *testing.T) {
_, err := shlex.Split(`echo "unclosed`)
if err == nil {
t.Error("expected error for unclosed double quote, got nil")
}
}
// =============================================================================
// Tests for execInput with controlled environment
// =============================================================================
func TestExecInput_CdNoArgs(t *testing.T) {
// cd with no args now goes to home directory
// Save current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
err := execInput("cd")
if err != nil {
t.Errorf("cd with no args should go to home, got error: %v", err)
}
// Verify we changed to home directory
home := getHomeDir()
newDir, _ := os.Getwd()
if newDir != home {
t.Errorf("expected to be in home dir %s, but in %s", home, newDir)
}
}
func TestExecInput_EmptyInput(t *testing.T) {
err := execInput("")
if err != nil {
t.Errorf("expected nil for empty input, got %v", err)
}
}
func TestExecInput_WhitespaceInput(t *testing.T) {
err := execInput(" ")
if err != nil {
t.Errorf("expected nil for whitespace input, got %v", err)
}
}
func TestErrNoPath(t *testing.T) {
if ErrNoPath.Error() != "path required" {
t.Errorf("expected 'path required', got '%s'", ErrNoPath.Error())
}
}
// =============================================================================
// Integration tests using subprocess (minimal, run sequentially)
// =============================================================================
func TestShell_EndToEnd(t *testing.T) {
// Test basic shell interaction
tests := []struct {
name string
input string
expectOutput string
expectError bool
}{
{"echo simple", "echo hello\nquit\n", "hello", false},
{"echo quoted", "echo 'hello world'\nquit\n", "hello world", false},
{"pwd", "pwd\nquit\n", "", false}, // pwd output depends on current dir
{"cd to home", "cd\npwd\nquit\n", "", false},
{"cd with tilde", "cd ~\npwd\nquit\n", "", false},
{"quit", "quit\n", "", false},
{"exit", "exit\n", "", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader(tc.input)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
_ = cmd.Run()
output := stdout.String()
if tc.expectOutput != "" && !strings.Contains(output, tc.expectOutput) {
t.Errorf("%s: expected output to contain '%s', got: %s", tc.name, tc.expectOutput, output)
}
if tc.expectError {
combined := output + stderr.String()
if !strings.Contains(combined, "path required") &&
!strings.Contains(combined, "error") {
t.Errorf("%s: expected error, got stdout: %s, stderr: %s", tc.name, output, stderr.String())
}
}
})
}
}
// =============================================================================
// Tests for Phase 1.2: Graceful Ctrl+D and Ctrl+C handling
// =============================================================================
func TestShell_EOF_ExitsGracefully(t *testing.T) {
// Simulate EOF by sending empty input
// Note: When using piped input, readline doesn't print prompts
// So we just verify the shell exits cleanly
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// Shell should exit cleanly on EOF
if err != nil {
t.Logf("Shell exited with: %v", err)
}
// When using piped input, readline doesn't print prompts
// So we just check that it exited cleanly
_ = stdout.String()
}
func TestShell_EOF_AfterCommand(t *testing.T) {
// Test that EOF after a command works
// Note: When using piped input, readline doesn't print prompts
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("echo test\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("Shell exited with: %v", err)
}
output := stdout.String()
// Should contain the echo output
if !strings.Contains(output, "test") {
t.Errorf("expected 'test' in output, got: %s", output)
}
// Note: With piped input, readline doesn't print prompts
// In a real TTY, the prompt would be shown
}
func TestShell_SIGINT_HandlerSetup(t *testing.T) {
// This test verifies that the shell doesn't panic when receiving signals
// We can't easily test SIGINT handling from a test, but we can verify
// the shell runs without crashing
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("echo test\nquit\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "test") {
t.Errorf("expected 'test' in output, got: %s", output)
}
}
// =============================================================================
// Tests for Phase 1.3: cd to home, ~ expansion, cd -
// =============================================================================
func TestExecInput_CdToHomeWithTilde(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Test cd ~
err := execInput("cd ~")
if err != nil {
t.Errorf("cd ~ failed: %v", err)
}
home := getHomeDir()
newDir, _ := os.Getwd()
if newDir != home {
t.Errorf("expected to be in home dir %s, but in %s", home, newDir)
}
}
func TestExecInput_CdWithTildePath(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Test cd ~/ (this should go to home)
// But we can't easily test this without knowing the home structure
// So let's just test that tilde expansion doesn't cause errors
err := execInput("cd ~")
if err != nil {
t.Errorf("cd ~ failed: %v", err)
}
}
func TestExecInput_CdDash(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// First, cd to a temp dir
tmpDir := t.TempDir()
err := execInput("cd " + tmpDir)
if err != nil {
t.Fatalf("cd to tmpDir failed: %v", err)
}
// Now cd - should go back
err = execInput("cd -")
if err != nil {
t.Fatalf("cd - failed: %v", err)
}
// Verify we're back in origDir
newDir, _ := os.Getwd()
if newDir != origDir {
t.Errorf("cd -: expected to be in %s, but in %s", origDir, newDir)
}
}
func TestExecInput_CdDashNoPrevious(t *testing.T) {
// Reset previousDir for this test
origPrevDir := previousDir
defer func() { previousDir = origPrevDir }()
previousDir = ""
// cd - with no previous directory should return an error
err := execInput("cd -")
if err == nil {
t.Error("cd - with no previous directory: expected error, got nil")
}
if !strings.Contains(err.Error(), "no previous directory") {
t.Errorf("expected 'no previous directory' error, got: %v", err)
}
}
// =============================================================================
// Tests for Phase 2.1: Dynamic Prompt
// =============================================================================
func TestGetPrompt_Format(t *testing.T) {
prompt := getPrompt()
// Should contain username@hostname:path $ pattern
if !strings.Contains(prompt, "@") {
t.Errorf("expected @ in prompt, got: %s", prompt)
}
if !strings.Contains(prompt, ":") {
t.Errorf("expected : in prompt, got: %s", prompt)
}
if !strings.Contains(prompt, "$") {
t.Errorf("expected $ in prompt, got: %s", prompt)
}
}
func TestGetPrompt_IncludesHome(t *testing.T) {
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Change to home directory
os.Chdir(getHomeDir())
prompt := getPrompt()
// When in home, should show ~
if !strings.Contains(prompt, "~") {
t.Errorf("expected ~ in prompt when in home dir, got: %s", prompt)
}
}
// =============================================================================
// Tests for Phase 2.2: Exit Status Code
// =============================================================================
func TestExecInput_ExitWithStatusCode(t *testing.T) {
// We can't easily test os.Exit from a unit test
// So we just verify the function doesn't panic with a status code
// The actual exit behavior is tested in integration tests
// This test just documents the expected behavior
// Actual testing requires subprocess
_ = true // Placeholder - integration tests handle this
}
func TestShell_ExitWithStatusCode(t *testing.T) {
// Test exit with custom status code
// Note: We build the binary first because 'go run' doesn't preserve exit codes properly
tests := []struct {
input string
expected int
}{
{"exit\n", 0},
{"exit 0\n", 0},
{"exit 1\n", 1},
{"exit 42\n", 42},
{"quit\n", 0},
{"quit 0\n", 0},
{"quit 5\n", 5},
}
// Build the binary once
build := exec.Command("go", "build", "-o", "gosh_test", "main.go")
if err := build.Run(); err != nil {
t.Fatalf("failed to build: %v", err)
}
defer os.Remove("gosh_test")
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
cmd := exec.Command("./gosh_test")
cmd.Stdin = strings.NewReader(tc.input)
err := cmd.Run()
if err == nil && tc.expected != 0 {
t.Errorf("expected non-zero exit code %d, got 0", tc.expected)
}
if err != nil {
// Check the exit code
if exitErr, ok := err.(*exec.ExitError); ok {
actualCode := exitErr.ExitCode()
if actualCode != tc.expected {
t.Errorf("expected exit code %d, got %d", tc.expected, actualCode)
}
} else {
// Not an ExitError, might be another error
t.Logf("got error: %v", err)
}
}
})
}
}
// =============================================================================
// Tests for Phase 2.3: Environment Variable Expansion
// =============================================================================
func TestShell_EnvExpansion_Home(t *testing.T) {
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("echo $HOME\nexit\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
home := getHomeDir()
if !strings.Contains(output, home) {
t.Errorf("expected home dir %s in output, got: %s", home, output)
}
}
func TestShell_EnvExpansion_Path(t *testing.T) {
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("echo $PATH\nexit\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
// PATH should contain : (path separator)
if !strings.Contains(output, ":") {
t.Errorf("expected PATH with : separator in output, got: %s", output)
}
}
func TestShell_EnvExpansion_Braces(t *testing.T) {
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("echo ${HOME}\nexit\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
home := getHomeDir()
if !strings.Contains(output, home) {
t.Errorf("expected home dir %s in output with ${HOME}, got: %s", home, output)
}
}
func TestShell_EnvExpansion_CdWithVar(t *testing.T) {
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("cd $HOME\npwd\nexit\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
home := getHomeDir()
if !strings.Contains(output, home) {
t.Errorf("expected home dir %s in pwd output, got: %s", home, output)
}
}
func TestShell_TildeExpansion_Ls(t *testing.T) {
cmd := exec.Command("go", "run", "main.go")
cmd.Stdin = strings.NewReader("ls ~\nexit\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Logf("shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
// Should contain files from home directory
// We can't predict exact files, but it should have some output
if strings.TrimSpace(output) == "" {
t.Errorf("expected output from ls ~, got empty")
}
}
// =============================================================================
// Tests for Phase 2.5, 2.6, 2.7: set/export commands, help
// =============================================================================
func TestExecInput_SetEnvVar(t *testing.T) {
// Save original value
orig := os.Getenv("GOSH_TEST_VAR")
defer os.Setenv("GOSH_TEST_VAR", orig)
// Set a new variable
err := execInput("set GOSH_TEST_VAR=test_value")
if err != nil {
t.Errorf("set failed: %v", err)
}
// Verify it was set
val := os.Getenv("GOSH_TEST_VAR")
if val != "test_value" {
t.Errorf("expected 'test_value', got '%s'", val)
}
}
func TestExecInput_ExportEnvVar(t *testing.T) {
// Save original value
orig := os.Getenv("GOSH_TEST_VAR2")
defer os.Setenv("GOSH_TEST_VAR2", orig)
// Export a new variable
err := execInput("export GOSH_TEST_VAR2=exported_value")
if err != nil {
t.Errorf("export failed: %v", err)
}
// Verify it was set
val := os.Getenv("GOSH_TEST_VAR2")
if val != "exported_value" {
t.Errorf("expected 'exported_value', got '%s'", val)
}
}
func TestExecInput_SetNoArgs(t *testing.T) {
err := execInput("set")
if err == nil {
t.Error("expected error for set with no args, got nil")
}
if !strings.Contains(err.Error(), "usage: set VAR=value") {
t.Errorf("expected usage error, got: %v", err)
}
}
func TestExecInput_ExportNoArgs(t *testing.T) {
err := execInput("export")
if err == nil {
t.Error("expected error for export with no args, got nil")
}
if !strings.Contains(err.Error(), "usage: export VAR=value") {
t.Errorf("expected usage error, got: %v", err)
}
}
func TestExecInput_Help(t *testing.T) {
// Just verify help doesn't return an error
// We can't easily capture stdout in a unit test without a pipe
// but we can verify it doesn't crash
err := execInput("help")
if err != nil {
t.Errorf("help failed: %v", err)
}
// If we get here without panicking, help command works
}
// =============================================================================
// Tests for Command Completion (Phase 3: Tab Completion)
// =============================================================================
func TestFilePathCompleter_CurrentDirectory(t *testing.T) {
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Create a temp directory with known files
tmpDir := t.TempDir()
os.Chdir(tmpDir)
// Create test files and directories
os.Create("file_a.txt")
os.Create("file_b.txt")
os.Create("file_c.txt")
os.Mkdir("dir_a", 0755)
os.Mkdir("dir_b", 0755)
// Test completion for "ls file_"
// Extract the prefix (last word after space)
line := "ls file_"
prefix := "file_"
matches := completeFilePath(line, prefix)
if len(matches) == 0 {
t.Error("expected matches for 'ls file_', got none")
}
// All matches should contain "file_"
for _, m := range matches {
if !strings.Contains(m, "file_") {
t.Errorf("match %s doesn't contain 'file_'", m)
}
}
}
func TestFilePathCompleter_AbsolutePath(t *testing.T) {
// Create a temp directory with known structure
tmpDir := t.TempDir()
os.Mkdir(filepath.Join(tmpDir, "testdir"), 0755)
os.Create(filepath.Join(tmpDir, "testdir", "file1.txt"))
os.Create(filepath.Join(tmpDir, "testdir", "file2.txt"))
// Test completion for absolute path
line := "ls " + tmpDir + "/te"
prefix := tmpDir + "/te"
matches := completeFilePath(line, prefix)
if len(matches) == 0 {
t.Error("expected matches for absolute path, got none")
}
// All matches should contain "testdir"
found := false
for _, m := range matches {
if strings.Contains(m, "testdir") {
found = true
break
}
}
if !found {
t.Errorf("expected match containing 'testdir', got: %v", matches)
}
}
func TestFilePathCompleter_TildeExpansion(t *testing.T) {
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
home := getHomeDir()
if home == "" {
t.Skip("skipping: HOME directory not available")
}
// Create a test file in home if possible
// (we can't always write to home, so we'll just test the completer logic)
matches := completeFilePath("ls ~/", "~/")
// Just verify it doesn't panic - actual matches depend on home directory contents
_ = matches
}
func TestFilePathCompleter_DirectoriesWithTrailingSlash(t *testing.T) {
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
tmpDir := t.TempDir()
os.Chdir(tmpDir)
// Create a subdirectory
os.Mkdir("subdir", 0755)
os.Create("subdir/file.txt")
// Test completion when the prefix already has a trailing slash
matches := completeFilePath("ls subdir/", "subdir/")
if len(matches) == 0 {
t.Error("expected matches for 'ls subdir/', got none")
}
// Matches should include the file in the subdirectory
found := false
for _, m := range matches {
if strings.Contains(m, "file.txt") {
found = true
break
}
}
if !found {
t.Errorf("expected match containing 'file.txt', got: %v", matches)
}
}
func TestFilePathCompleter_NoMatches(t *testing.T) {
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
tmpDir := t.TempDir()
os.Chdir(tmpDir)
// Test completion for a prefix that won't match anything
matches := completeFilePath("ls xyz_nonexistent_", "xyz_nonexistent_")
if len(matches) != 0 {
t.Errorf("expected no matches for non-existent prefix, got: %v", matches)
}
}
// completeLine runs the completer on line (cursor at the end) and returns the
// candidates as strings together with the reported length.
func completeLine(line string) ([]string, int) {
cands, length := newShellCompleter().Do([]rune(line), len([]rune(line)))
var out []string
for _, c := range cands {
out = append(out, string(c))
}
sort.Strings(out)
return out, length
}
func TestShellCompleter_Do(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
tmpDir := t.TempDir()
os.Chdir(tmpDir)
os.Create("main.go")
os.Create("my file.txt")
os.Create(".hidden")
os.Mkdir("subdir", 0755)
os.Create("subdir/inner.txt")
// Controlled PATH with one executable, one non-executable and a
// duplicate of a builtin
binDir := t.TempDir()
os.WriteFile(filepath.Join(binDir, "gitty"), nil, 0755)
os.WriteFile(filepath.Join(binDir, "girl"), nil, 0644)
os.WriteFile(filepath.Join(binDir, "echo"), nil, 0755)
os.Mkdir(filepath.Join(binDir, "gidir"), 0755)
t.Setenv("PATH", binDir)
tests := []struct {
line string
want []string
length int
}{
{"c", []string{"d "}, 1},
{"e", []string{"cho ", "xit ", "xport "}, 1},
{"gi", []string{"tty "}, 2},
{" gi", []string{"tty "}, 2},
{"ls gi", nil, 2},
{"ls ma", []string{"in.go"}, 2},
{"ls su", []string{"bdir/"}, 2},
{"ls subdir/", []string{"inner.txt"}, 0},
{"ls subdir/in", []string{"ner.txt"}, 2},
{"ls my", []string{`\ file.txt`}, 2},
{`ls my\ f`, []string{"ile.txt"}, 4},
{`ls "my f`, []string{"ile.txt"}, 4},
{"ls ./ma", []string{"in.go"}, 2},
{"ls .h", []string{"idden"}, 2},
{"ls ", []string{"main.go", `my\ file.txt`, "subdir/"}, 0},
{"ls " + tmpDir + "/ma", []string{"in.go"}, 2},
{"ls xyz", nil, 3},
{"ls ~", []string{"/"}, 1},
}
for _, tt := range tests {
got, length := completeLine(tt.line)
if !reflect.DeepEqual(got, tt.want) || length != tt.length {
t.Errorf("Do(%q) = %q, %d; want %q, %d", tt.line, got, length, tt.want, tt.length)
}
}
}
// =============================================================================
// Security fixes: history file, quoting/expansion, escape sequences, SIGINT
// =============================================================================
// TestMain keeps the integration tests (which run the shell as a subprocess)
// from writing into the user's real history file.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "gosh-test-")
if err != nil {
panic(err)
}
os.Setenv("GOSH_HISTFILE", filepath.Join(dir, "history"))
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
// buildShell builds the shell into a temporary directory and returns its path
func buildShell(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "gosh")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("failed to build: %v\n%s", err, out)
}
return bin
}
func TestSplitWords(t *testing.T) {
t.Setenv("GOSH_A", "a b")
t.Setenv("GOSH_EMPTY", "")
t.Setenv("GOSH_TILDE", "~/x")
os.Unsetenv("GOSH_UNSET")
home := getHomeDir()
tests := []struct {
input string
want []string
}{
{`echo hello world`, []string{"echo", "hello", "world"}},
{`echo 'hello world'`, []string{"echo", "hello world"}},
{`echo "hello \"world\""`, []string{"echo", `hello "world"`}},
{`echo file\ with\ spaces`, []string{"echo", "file with spaces"}},
{`echo '' ""`, []string{"echo", "", ""}},
{`echo a # comment`, []string{"echo", "a"}},
{`echo a#b`, []string{"echo", "a#b"}},
// No expansion inside single quotes
{`echo '$GOSH_A'`, []string{"echo", "$GOSH_A"}},
{`grep 'foo$'`, []string{"grep", "foo$"}},
{`echo '$GOSH_UNSET/out'`, []string{"echo", "$GOSH_UNSET/out"}},
// Expansion outside of and inside double quotes, never word splitting
{`echo $GOSH_A`, []string{"echo", "a b"}},
{`echo "${GOSH_A}c"`, []string{"echo", "a bc"}},
{`echo x${GOSH_A}y`, []string{"echo", "xa by"}},
// Escaped and lone dollar signs stay literal
{`echo \$GOSH_A "\$GOSH_A"`, []string{"echo", "$GOSH_A", "$GOSH_A"}},
{`echo $ 5$ $1`, []string{"echo", "$", "5$", "$1"}},
// Backslash in double quotes only escapes $ " \ and `
{`echo "a\nb\\c"`, []string{"echo", `a\nb\c`}},
// Defaults
{`echo ${GOSH_UNSET:-def} ${GOSH_EMPTY:-def} ${GOSH_A:-def}`, []string{"echo", "def", "def", "a b"}},
{`echo ${GOSH_UNSET:-}x`, []string{"echo", "x"}},
// Unquoted empty expansions vanish, quoted ones are kept
{`echo $GOSH_EMPTY x`, []string{"echo", "x"}},
{`echo "$GOSH_EMPTY" x`, []string{"echo", "", "x"}},
// Tilde only at the start of an unquoted word, not from variables
{`ls ~ ~/x a~ '~' "~/x" \~`, []string{"ls", home, filepath.Join(home) + "/x", "a~", "~", "~/x", "~"}},
{`ls ~root`, []string{"ls", "~root"}},
{`ls $GOSH_TILDE`, []string{"ls", "~/x"}},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := splitWords(tc.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestSplitWords_Errors(t *testing.T) {
os.Unsetenv("GOSH_UNSET")
tests := []struct {
input string
wantErr string
}{
{`rm -rf $GOSH_UNSET/out`, "GOSH_UNSET: unbound variable"},
{`rm -rf "${GOSH_UNSET}/out"`, "GOSH_UNSET: unbound variable"},
{`echo 'unclosed`, "unterminated ' quote"},
{`echo "unclosed`, `unterminated " quote`},
{`echo foo\`, "unexpected end of input"},
{`echo ${GOSH_UNSET`, "missing }"},
{`echo ${}`, "bad substitution"},
{`echo ${1x}`, "bad substitution"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := splitWords(tc.input)
if err == nil {
t.Fatalf("expected error, got %q", got)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("expected error containing %q, got %q", tc.wantErr, err)
}
})
}
}
func TestExecInput_UnsetVariableDoesNotRun(t *testing.T) {
os.Unsetenv("GOSH_UNSET")
marker := filepath.Join(t.TempDir(), "marker")
// Without the fix this would have run "touch /marker"-like commands
err := execInput("touch $GOSH_UNSET" + marker)
if err == nil || !strings.Contains(err.Error(), "unbound variable") {
t.Fatalf("expected unbound variable error, got %v", err)
}
if _, err := os.Stat(marker); err == nil {
t.Errorf("command was executed despite unset variable")
}
}
func TestExecInput_SingleQuotedDollarIsLiteral(t *testing.T) {
dir := t.TempDir()
t.Setenv("GOSH_X", "expanded")
if err := execInput("touch '" + dir + "/$GOSH_X'"); err != nil {
t.Fatalf("touch failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "$GOSH_X")); err != nil {
t.Errorf("expected literal file name $GOSH_X: %v", err)
}
}
func TestSanitize(t *testing.T) {
tests := []struct{ in, want string }{
{"plain/path äöü", "plain/path äöü"},
{"evil\x1b]0;title\x07", `evil\x1b]0;title\x07`},
{"a\nb\tc", `a\x0ab\x09c`},
{"bad\xffutf8", `bad\xffutf8`},
{"bidi\u202eoverride", `bidi\u202eoverride`},
}
for _, tc := range tests {
if got := sanitize(tc.in); got != tc.want {
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
t.Setenv("NO_COLOR", "1")
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := filepath.Join(t.TempDir(), "evil\x1b[2J\x1b]52;c;cHduZWQ=\x07")
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
prompt := getPrompt()
if strings.ContainsAny(prompt, "\x1b\x07") {
t.Errorf("prompt contains control characters: %q", prompt)
}
if !strings.Contains(prompt, `evil\x1b[2J`) {
t.Errorf("expected escaped directory name in prompt, got %q", prompt)
}
}
func TestBuildPrompt_Color(t *testing.T) {
got := buildPrompt("flow", "host", "~/src", false, true)
want := colorUser + "flow@host" + colorReset + ":" + colorPath + "~/src" + colorReset + " $ "
if got != want {
t.Errorf("buildPrompt() = %q, want %q", got, want)
}
got = buildPrompt("root", "host", "/", true, true)
if !strings.HasPrefix(got, colorRoot+"root@host") {
t.Errorf("expected red user for root, got %q", got)
}
got = buildPrompt("flow", "host", "~/src", false, false)
if got != "flow@host:~/src $ " {
t.Errorf("buildPrompt() without color = %q", got)
}
}
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, true)
if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
t.Errorf("prompt contains control characters from path: %q", got)
}
if !strings.Contains(got, `evil\x1b[2J\x07`) {
t.Errorf("expected escaped path in prompt, got %q", got)
}
}
func TestUseColor_NoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1")
if useColor() {
t.Error("expected no color when NO_COLOR is set")
}
}
func TestFilePathCompleter_SkipsControlCharacters(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"good", "go\x1b[31mred"} {
if err := os.WriteFile(filepath.Join(dir, name), nil, 0644); err != nil {
t.Fatal(err)
}
}
matches := completeFilePath("", dir+"/go")
if !reflect.DeepEqual(matches, []string{"good"}) {
t.Errorf("expected only [good], got %q", matches)
}
}
func TestHistoryFile(t *testing.T) {
t.Setenv("GOSH_HISTFILE", "/custom/history")
if got := historyFile(); got != "/custom/history" {
t.Errorf("expected $GOSH_HISTFILE, got %q", got)
}
os.Unsetenv("GOSH_HISTFILE")
t.Setenv("XDG_STATE_HOME", "/state")
if got := historyFile(); got != "/state/gosh/history" {
t.Errorf("expected /state/gosh/history, got %q", got)
}
t.Setenv("XDG_STATE_HOME", "")
want := filepath.Join(getHomeDir(), ".local", "state", "gosh", "history")
if got := historyFile(); got != want {
t.Errorf("expected %q, got %q", want, got)
}
// Never relative to the current directory
if !filepath.IsAbs(historyFile()) {
t.Errorf("history file must be an absolute path, got %q", historyFile())
}
}
func TestPrepareHistoryFile_Permissions(t *testing.T) {
dir := filepath.Join(t.TempDir(), "state", "gosh")
path := filepath.Join(dir, "history")
if err := prepareHistoryFile(path); err != nil {
t.Fatalf("prepareHistoryFile failed: %v", err)
}
if info, _ := os.Stat(dir); info.Mode().Perm() != 0700 {
t.Errorf("expected directory mode 0700, got %o", info.Mode().Perm())
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0600 {
t.Errorf("expected file mode 0600, got %o", info.Mode().Perm())
}
// Existing files and directories with too open permissions are fixed
os.Chmod(dir, 0755)
os.Chmod(path, 0644)
if err := prepareHistoryFile(path); err != nil {
t.Fatalf("prepareHistoryFile failed: %v", err)
}
if info, _ := os.Stat(dir); info.Mode().Perm() != 0700 {
t.Errorf("expected directory mode 0700, got %o", info.Mode().Perm())
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0600 {
t.Errorf("expected file mode 0600, got %o", info.Mode().Perm())
}
}
func TestShell_History(t *testing.T) {
bin := buildShell(t)
histFile := filepath.Join(t.TempDir(), "gosh", "history")
workDir := t.TempDir()
cmd := exec.Command(bin)
cmd.Dir = workDir
cmd.Env = append(os.Environ(), "GOSH_HISTFILE="+histFile)
cmd.Stdin = strings.NewReader("echo first\n set SECRET=hunter2\nexit\n")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("shell failed: %v\n%s", err, out)
}
data, err := os.ReadFile(histFile)
if err != nil {
t.Fatalf("history file not written: %v", err)
}
history := string(data)
if strings.Count(history, "echo first") != 1 {
t.Errorf("expected 'echo first' exactly once in history, got %q", history)
}
if strings.Contains(history, "hunter2") {
t.Errorf("line starting with a space must not be saved, got %q", history)
}
if info, _ := os.Stat(histFile); info.Mode().Perm() != 0600 {
t.Errorf("expected history mode 0600, got %o", info.Mode().Perm())
}
// Nothing may be written into the working directory
if entries, _ := os.ReadDir(workDir); len(entries) != 0 {
t.Errorf("shell wrote files into the working directory: %v", entries)
}
}
func TestShell_SIGINTKillsOnlyChild(t *testing.T) {
bin := buildShell(t)
cmd := exec.Command(bin)
// Own process group, so that we can signal it like the terminal does
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
start := time.Now()
io.WriteString(stdin, "sleep 10\n")
time.Sleep(500 * time.Millisecond)
// Ctrl+C: SIGINT to the whole foreground process group
syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
io.WriteString(stdin, "echo still alive\nexit\n")
stdin.Close()
if err := cmd.Wait(); err != nil {
t.Errorf("shell did not exit cleanly: %v", err)
}
if !strings.Contains(stdout.String(), "still alive") {
t.Errorf("shell was killed by SIGINT, output: %q", stdout.String())
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Errorf("child was not interrupted (took %v)", elapsed)
}
}