Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2193 lines
66 KiB
Go
2193 lines
66 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"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", ".")
|
|
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", ".")
|
|
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", ".")
|
|
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", ".")
|
|
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) {
|
|
unsetPS1(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) {
|
|
unsetPS1(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", ".")
|
|
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", ".")
|
|
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", ".")
|
|
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", ".")
|
|
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", ".")
|
|
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", ".")
|
|
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) {
|
|
// set without arguments shows the variables, quoted for the shell
|
|
t.Setenv("GOSH_SHOWN", "a b")
|
|
got := runIn(t, "set > out")
|
|
if !strings.Contains(got, "GOSH_SHOWN='a b'\n") {
|
|
t.Errorf("set: GOSH_SHOWN missing in %q", got)
|
|
}
|
|
}
|
|
|
|
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 TestVersionInfo(t *testing.T) {
|
|
origVersion, origCommit, origBranch := version, commit, branch
|
|
defer func() { version, commit, branch = origVersion, origCommit, origBranch }()
|
|
|
|
version, commit, branch = "1.2.3", "abc1234", "main"
|
|
if got := versionInfo(); got != "1.2.3 (branch: main, commit: abc1234)" {
|
|
t.Errorf("versionInfo() = %q", got)
|
|
}
|
|
|
|
branch = ""
|
|
if got := versionInfo(); got != "1.2.3 (commit: abc1234)" {
|
|
t.Errorf("versionInfo() without branch = %q", got)
|
|
}
|
|
}
|
|
|
|
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 ", "ontinue "}, 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},
|
|
// Commands are completed after |, ;, && and ||, files after redirections
|
|
{"ls | gi", []string{"tty "}, 2},
|
|
{"ls|gi", []string{"tty "}, 2},
|
|
{"true; gi", []string{"tty "}, 2},
|
|
{"true && gi", []string{"tty "}, 2},
|
|
{"false || gi", []string{"tty "}, 2},
|
|
{"ls > ma", []string{"in.go"}, 2},
|
|
{"ls 2>ma", []string{"in.go"}, 2},
|
|
{"cat <ma", []string{"in.go"}, 2},
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEscapeWord(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"plain.txt", "plain.txt"},
|
|
{"my file (1).txt", `my\ file\ \(1\).txt`},
|
|
{"a`b`$c", "a\\`b\\`\\$c"},
|
|
{"a;b|c&d<e>f", `a\;b\|c\&d\<e\>f`},
|
|
{"*?[x]{y}!", `\*\?\[x\]\{y\}\!`},
|
|
{`it's "q"\`, `it\'s\ \"q\"\\`},
|
|
{"#a#b", `\#a#b`},
|
|
{"~a~b", `\~a~b`},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := escapeWord(tt.in); got != tt.want {
|
|
t.Errorf("escapeWord(%q) = %q; want %q", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellCompleter_SpecialCharacters(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
os.Chdir(t.TempDir())
|
|
os.Create("song (live) & more.mp3")
|
|
|
|
got, length := completeLine("ls so")
|
|
want := []string{`ng\ \(live\)\ \&\ more.mp3`}
|
|
if !reflect.DeepEqual(got, want) || length != 2 {
|
|
t.Errorf("Do(%q) = %q, %d; want %q, 2", "ls so", got, length, want)
|
|
}
|
|
// Inside quotes nothing is escaped
|
|
got, _ = completeLine(`ls "so`)
|
|
want = []string{"ng (live) & more.mp3"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("Do(%q) = %q; want %q", `ls "so`, got, want)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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"))
|
|
// Never read the system's startup files; tests that read the user's
|
|
// files set $HOME themselves
|
|
sysConfDir = filepath.Join(dir, "etc")
|
|
// Command substitution runs gosh, not the test binary
|
|
subshellPath = filepath.Join(dir, "gosh")
|
|
if out, err := exec.Command("go", "build", "-o", subshellPath, ".").CombinedOutput(); err != nil {
|
|
panic(fmt.Sprintf("building gosh: %v\n%s", err, out))
|
|
}
|
|
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$ $%`, []string{"echo", "$", "5$", "$%"}},
|
|
// 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) {
|
|
unsetPS1(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, 0, &defaultColors)
|
|
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ "
|
|
if got != want {
|
|
t.Errorf("buildPrompt() = %q, want %q", got, want)
|
|
}
|
|
|
|
got = buildPrompt("root", "host", "/", true, 0, &defaultColors)
|
|
if !strings.HasPrefix(got, "\x1b[1;31mroot@host") {
|
|
t.Errorf("expected red user for root, got %q", got)
|
|
}
|
|
|
|
got = buildPrompt("flow", "host", "~/src", false, 0, nil)
|
|
if got != "flow@host:~/src $ " {
|
|
t.Errorf("buildPrompt() without color = %q", got)
|
|
}
|
|
|
|
colors := promptColors{user: "38;5;208", path: ""}
|
|
got = buildPrompt("flow", "host", "~/src", false, 0, &colors)
|
|
if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " {
|
|
t.Errorf("buildPrompt() with custom colors = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestParseColors(t *testing.T) {
|
|
tests := []struct {
|
|
spec string
|
|
want promptColors
|
|
}{
|
|
{"", defaultColors},
|
|
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34", error: "1;31"}},
|
|
{"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}},
|
|
{"error=33", promptColors{user: "1;32", root: "1;31", path: "1;34", error: "33"}},
|
|
{"path=", promptColors{user: "1;32", root: "1;31", path: "", error: "1;31"}},
|
|
{"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected
|
|
{"path=red", defaultColors}, // only numeric codes
|
|
{"foo=1:user", defaultColors}, // unknown key, missing value
|
|
{"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36", error: "1;31"}},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := parseColors(tc.spec); got != tc.want {
|
|
t.Errorf("parseColors(%q) = %+v, want %+v", tc.spec, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildPrompt_Status(t *testing.T) {
|
|
if got := buildPrompt("flow", "host", "~", false, 0, nil); got != "flow@host:~ $ " {
|
|
t.Errorf("status 0: %q", got)
|
|
}
|
|
if got := buildPrompt("flow", "host", "~", false, 127, nil); got != "flow@host:~ [127] $ " {
|
|
t.Errorf("status 127 without color: %q", got)
|
|
}
|
|
got := buildPrompt("flow", "host", "~", false, 1, &defaultColors)
|
|
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~\x1b[0m \x1b[1;31m[1]\x1b[0m $ "
|
|
if got != want {
|
|
t.Errorf("status 1 with color = %q, want %q", got, want)
|
|
}
|
|
colors := defaultColors
|
|
colors.error = ""
|
|
if got := buildPrompt("flow", "host", "~", false, 2, &colors); !strings.HasSuffix(got, " [2] $ ") {
|
|
t.Errorf("status without error color = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
|
|
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, 0, &defaultColors)
|
|
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 TestOSC7Sequence(t *testing.T) {
|
|
tests := []struct{ host, dir, want string }{
|
|
{"host", "/home/flow", "\x1b]7;file://host/home/flow\a"},
|
|
{"host", "/tmp/a b/ä", "\x1b]7;file://host/tmp/a%20b/%C3%A4\a"},
|
|
{"host", "/x%y#z?", "\x1b]7;file://host/x%25y%23z%3F\a"},
|
|
{"", "/", "\x1b]7;file:///\a"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := osc7Sequence(tt.host, tt.dir); got != tt.want {
|
|
t.Errorf("osc7Sequence(%q, %q) = %q, want %q", tt.host, tt.dir, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOSC7Sequence_EscapesControlCharacters(t *testing.T) {
|
|
got := osc7Sequence("ho/st\x07", "/evil\x07\x1b]0;title\x1b\\")
|
|
body := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]7;"), "\a")
|
|
if strings.ContainsAny(body, "\x07\x1b") {
|
|
t.Errorf("sequence contains control characters: %q", got)
|
|
}
|
|
if !strings.HasPrefix(body, "file://ho%2Fst%07/evil%07%1B") {
|
|
t.Errorf("unexpected encoding: %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, "-i", "--norc")
|
|
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, "-i", "--norc")
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func TestExecInput_PassesStdin(t *testing.T) {
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
origStdin := os.Stdin
|
|
os.Stdin = r
|
|
defer func() {
|
|
os.Stdin = origStdin
|
|
r.Close()
|
|
}()
|
|
|
|
if _, err := w.WriteString("hello\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.Close()
|
|
|
|
out := filepath.Join(t.TempDir(), "out")
|
|
t.Setenv("GOSH_TEST_OUT", out)
|
|
if err := execInput(`sh -c 'read x; printf %s "$x" > "$GOSH_TEST_OUT"'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "hello" {
|
|
t.Errorf("command read %q from stdin, want %q", got, "hello")
|
|
}
|
|
}
|
|
|
|
func TestExitStatus(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want int
|
|
}{
|
|
{"true", 0},
|
|
{"false", 1},
|
|
{`sh -c "exit 42"`, 42},
|
|
{`sh -c "kill -TERM \$\$"`, 128 + 15},
|
|
{"gosh-no-such-command", 127},
|
|
{"cd /gosh-no-such-dir", 127},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := exitStatus(execInput(tc.input)); got != tc.want {
|
|
t.Errorf("exitStatus(execInput(%q)) = %d, want %d", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
if got := exitStatus(errors.New("other")); got != 1 {
|
|
t.Errorf("exitStatus(other error) = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestRunScript(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
|
|
dir := t.TempDir()
|
|
script := "#!/usr/bin/gosh\n" +
|
|
"# comment\n" +
|
|
"\n" +
|
|
"cd " + dir + "\n" +
|
|
" # indented comment\n" +
|
|
"touch first\n" +
|
|
"false\n" +
|
|
"touch second" // no trailing newline
|
|
if got := runScript(strings.NewReader(script), ""); got != 0 {
|
|
t.Errorf("runScript() = %d, want 0", got)
|
|
}
|
|
for _, name := range []string{"first", "second"} {
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
|
t.Errorf("expected %s to be created: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// The status of the last command is returned
|
|
if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n"), ""); got != 3 {
|
|
t.Errorf("runScript() = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func TestRun_Options(t *testing.T) {
|
|
tests := []struct {
|
|
args []string
|
|
want int
|
|
}{
|
|
{[]string{"-c", "true"}, 0},
|
|
{[]string{"-c", "false"}, 1},
|
|
{[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5},
|
|
{[]string{"-c"}, 2},
|
|
{[]string{"-z"}, 2},
|
|
{[]string{"/gosh/no/such/script"}, 127},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := run(tc.args); got != tc.want {
|
|
t.Errorf("run(%q) = %d, want %d", tc.args, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadLine_DoesNotReadAhead(t *testing.T) {
|
|
r := strings.NewReader("first\nsecond\n")
|
|
line, err := readLine(r)
|
|
if line != "first" || err != nil {
|
|
t.Fatalf("readLine() = %q, %v", line, err)
|
|
}
|
|
if r.Len() != len("second\n") {
|
|
t.Errorf("readLine() consumed more than one line, %d bytes left", r.Len())
|
|
}
|
|
}
|
|
|
|
// treeString formats a syntax tree for the tests: simple commands in
|
|
// brackets, background commands with a trailing &.
|
|
func treeString(l *listNode) string {
|
|
var parts []string
|
|
for _, e := range l.items {
|
|
s := pipelineString(e.andOr.first)
|
|
for _, r := range e.andOr.rest {
|
|
s += " " + r.op + " " + pipelineString(r.pipeline)
|
|
}
|
|
if e.background {
|
|
s += " &"
|
|
}
|
|
parts = append(parts, s)
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
func pipelineString(pl *pipelineNode) string {
|
|
s := ""
|
|
if pl.negate {
|
|
s = "! "
|
|
}
|
|
switch n := pl.cmd.(type) {
|
|
case *simpleNode:
|
|
s += "[" + n.text + "]"
|
|
case *ifNode:
|
|
s += "if"
|
|
for i := range n.conds {
|
|
if i > 0 {
|
|
s += " elif"
|
|
}
|
|
s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i])
|
|
}
|
|
if n.elseBody != nil {
|
|
s += " else " + treeString(n.elseBody)
|
|
}
|
|
s += " fi"
|
|
case *forNode:
|
|
s += "for " + n.name
|
|
if n.hasIn {
|
|
s += " in (" + n.words + ")"
|
|
}
|
|
s += " do " + treeString(n.body) + " done"
|
|
case *whileNode:
|
|
if n.until {
|
|
s += "until "
|
|
} else {
|
|
s += "while "
|
|
}
|
|
s += treeString(n.cond) + " do " + treeString(n.body) + " done"
|
|
case *caseNode:
|
|
s += "case " + n.word + " in"
|
|
for _, c := range n.clauses {
|
|
s += " (" + strings.Join(c.patterns, "|") + ") " + treeString(c.body) + " ;;"
|
|
}
|
|
s += " esac"
|
|
case *groupNode:
|
|
s += "{ " + treeString(n.body) + " }"
|
|
case *subshellNode:
|
|
s += "(" + n.text + ")"
|
|
case *redirNode:
|
|
s += pipelineString(&pipelineNode{cmd: n.cmd}) + " <" + n.redirs + ">"
|
|
case *pipeNode:
|
|
var elems []string
|
|
for _, e := range n.elems {
|
|
elems = append(elems, "<"+e.text+">")
|
|
}
|
|
s += strings.Join(elems, " | ")
|
|
case *funcDefNode:
|
|
s += n.def.name + "() " + pipelineString(&pipelineNode{cmd: n.def.body})
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestParse_Lists(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"", ""},
|
|
{" ", ""},
|
|
{"# only a comment", ""},
|
|
{"ls", "[ls]"},
|
|
{"a; b", "[a]; [b]"},
|
|
{"a;", "[a]"},
|
|
{"a\nb\n", "[a]; [b]"},
|
|
{"a && b || c", "[a] && [b] || [c]"},
|
|
{"a &&\n b", "[a] && [b]"},
|
|
{"a | b|c", "[a | b|c]"},
|
|
{"a |\n b", "[a |\n b]"},
|
|
{`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`},
|
|
{`echo "x\"; y"`, `[echo "x\"; y"]`},
|
|
{"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"},
|
|
{"echo a # b; c", "[echo a]"},
|
|
{"echo a#b; c", "[echo a#b]; [c]"},
|
|
{"a & b", "[a] &; [b]"},
|
|
{"a&", "[a] &"},
|
|
{"a 2>&1 &", "[a 2>&1] &"},
|
|
{"a &> f &", "[a &> f] &"},
|
|
{"a >&2 && b", "[a >&2] && [b]"},
|
|
{`echo "a & b" \&`, `[echo "a & b" \&]`},
|
|
{"echo $(a; b && c | d) \"$(e || f)\" & g", `[echo $(a; b && c | d) "$(e || f)"] &; [g]`},
|
|
{"! false", "! [false]"},
|
|
{"echo a \\\n b", "[echo a b]"},
|
|
// Reserved words are only special as the first word of a command
|
|
{"echo if then fi do done", "[echo if then fi do done]"},
|
|
}
|
|
for _, tc := range tests {
|
|
tree, err := parse(tc.input)
|
|
if err != nil {
|
|
t.Errorf("parse(%q) returned error: %v", tc.input, err)
|
|
continue
|
|
}
|
|
if got := treeString(tree); got != tc.want {
|
|
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParse_ControlStructures(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"if a; then b; fi", "if [a] then [b] fi"},
|
|
{"if a\nthen\n b\n c\nfi", "if [a] then [b]; [c] fi"},
|
|
{"if a; then b; elif c; then d; else e; fi", "if [a] then [b] elif [c] then [d] else [e] fi"},
|
|
{"if a && ! b; then c; fi; echo x", "if [a] && ! [b] then [c] fi; [echo x]"},
|
|
{"for x in a b c; do echo $x; done", "for x in (a b c) do [echo $x] done"},
|
|
{"for x in *.go {1..3}\ndo\n echo $x\ndone", "for x in (*.go {1..3}) do [echo $x] done"},
|
|
{"for x; do echo; done", "for x do [echo] done"},
|
|
{"for x in; do echo; done", "for x in () do [echo] done"},
|
|
{"while a; do b; done", "while [a] do [b] done"},
|
|
{"until a; do b; done", "until [a] do [b] done"},
|
|
{"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi done"},
|
|
{"for i in 1 2; do for j in 3 4; do echo $i$j; done; done", "for i in (1 2) do for j in (3 4) do [echo $i$j] done done"},
|
|
{"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"},
|
|
// Compound commands in pipelines, with redirections, in the background
|
|
{"for x in a; do b; done | cat", "<for x in a; do b; done> | <cat>"},
|
|
{"a | while b; do c; done | d", "<a> | <while b; do c; done> | <d>"},
|
|
{"if a; then b; fi > out 2>&1", "if [a] then [b] fi <> out 2>&1>"},
|
|
{"while read l; do b; done < in", "while [read l] do [b] done << in>"},
|
|
{"{ a; b; } 2> err", "{ [a]; [b] } <2> err>"},
|
|
{"for x in a; do b; done &", "for x in (a) do [b] done &"},
|
|
{"a && b &", "[a] && [b] &"},
|
|
// Subshells
|
|
{"(a; b)", "(a; b)"},
|
|
{"( a ) | b", "<( a )> | <b>"},
|
|
{"(a) > f", "(a) <> f>"},
|
|
{"(a; (b)) && c", "(a; (b)) && [c]"},
|
|
// case, groups and functions
|
|
{"case $x in a|b) c;; (d) e; f;; esac", "case $x in (a|b) [c] ;; (d) [e]; [f] ;; esac"},
|
|
{"case x in\n a)\n b\n ;;\nesac", "case x in (a) [b] ;; esac"},
|
|
{"{ a; }", "{ [a] }"},
|
|
{"f() { a; }", "f() { [a] }"},
|
|
{"f ( ) { a; }", "f() { [a] }"},
|
|
{"function f { a; }", "f() { [a] }"},
|
|
{"f() (a)", "f() (a)"},
|
|
}
|
|
for _, tc := range tests {
|
|
tree, err := parse(tc.input)
|
|
if err != nil {
|
|
t.Errorf("parse(%q) returned error: %v", tc.input, err)
|
|
continue
|
|
}
|
|
if got := treeString(tree); got != tc.want {
|
|
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParse_Incomplete(t *testing.T) {
|
|
for _, input := range []string{
|
|
"if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c",
|
|
"for x", "for x in a b", "for x in a; do", "for x in a; do b",
|
|
"while a", "while a; do b", "until a; do",
|
|
"(a", "(a; b", "{ a;", "case x in", "case x in a) b;;", "f() {", "f()",
|
|
"cat <<EOF", "cat <<EOF\nbody", "cat <<'EOF'\nbody\nEO",
|
|
"a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\",
|
|
} {
|
|
if _, err := parse(input); !errors.Is(err, errIncomplete) {
|
|
t.Errorf("parse(%q) = %v, want incomplete", input, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParse_SyntaxErrors(t *testing.T) {
|
|
for _, input := range []string{
|
|
"; a", "a ;; b", "&& a", "a && ; b", "a || && b", "& a", "a & && b", "a & & b",
|
|
"fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done",
|
|
"if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x",
|
|
"a | | b", "| a",
|
|
"()", "a (", "a )", "echo (a)", ")", "(a) b", "if a; then b; fi c", "{ a; } b",
|
|
"case x in a b) c;; esac", "case x in ) a;; esac", "cat <<", "cat << ;",
|
|
"f() { a; } | b",
|
|
} {
|
|
_, err := parse(input)
|
|
if err == nil || errors.Is(err, errIncomplete) {
|
|
t.Errorf("parse(%q) = %v, want syntax error", input, err)
|
|
}
|
|
}
|
|
for input, want := range map[string]string{
|
|
"f() { a; } | b": "function definitions in pipelines are not supported",
|
|
"f() { a; } > out": "redirections of function definitions are not supported",
|
|
"if a; then b; fi c": "syntax error near unexpected token `c'",
|
|
} {
|
|
if _, err := parse(input); err == nil || err.Error() != want {
|
|
t.Errorf("parse(%q) = %v, want %q", input, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_Lists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("D", dir)
|
|
tests := []struct {
|
|
input string
|
|
created []string
|
|
missing []string
|
|
status int
|
|
}{
|
|
{"touch $D/a; touch $D/b", []string{"a", "b"}, nil, 0},
|
|
{"false; touch $D/c", []string{"c"}, nil, 0},
|
|
{"true && touch $D/d", []string{"d"}, nil, 0},
|
|
{"false && touch $D/e", nil, []string{"e"}, 1},
|
|
{"false || touch $D/f", []string{"f"}, nil, 0},
|
|
{"true || touch $D/g", nil, []string{"g"}, 0},
|
|
{"false && touch $D/h || touch $D/i", []string{"i"}, []string{"h"}, 0},
|
|
{"true && false || touch $D/j", []string{"j"}, nil, 0},
|
|
{"gosh-no-such-command || touch $D/k", []string{"k"}, nil, 0},
|
|
{"true && sh -c 'exit 4'", nil, nil, 4},
|
|
}
|
|
for _, tc := range tests {
|
|
execInput(tc.input)
|
|
if lastStatus != tc.status {
|
|
t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status)
|
|
}
|
|
for _, name := range tc.created {
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
|
t.Errorf("%q: expected %s to be created", tc.input, name)
|
|
}
|
|
}
|
|
for _, name := range tc.missing {
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
|
t.Errorf("%q: %s must not be created", tc.input, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_ExpandsPerCommand(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
dir := t.TempDir()
|
|
out := filepath.Join(dir, "out")
|
|
t.Setenv("GOSH_TEST_OUT", out)
|
|
os.Unsetenv("GOSH_TEST_LIST")
|
|
defer os.Unsetenv("GOSH_TEST_LIST")
|
|
|
|
// The variable is set by the first command and used by the second one
|
|
err := execInput(`set GOSH_TEST_LIST=hello && sh -c 'printf %s "$GOSH_TEST_LIST" > "$GOSH_TEST_OUT"'`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := os.ReadFile(out); string(got) != "hello" {
|
|
t.Errorf("got %q, want %q", got, "hello")
|
|
}
|
|
}
|
|
|
|
func TestExpandVar_LastStatus(t *testing.T) {
|
|
execInput("sh -c 'exit 3'")
|
|
for _, input := range []string{"$?", "${?}"} {
|
|
words, err := splitWords("echo " + input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if words[1] != "3" {
|
|
t.Errorf("%s = %q, want %q", input, words[1], "3")
|
|
}
|
|
}
|
|
execInput("true")
|
|
if words, _ := splitWords("echo $?"); words[1] != "0" {
|
|
t.Errorf("$? = %q after true, want 0", words[1])
|
|
}
|
|
}
|
|
|
|
func TestExecInput_CommandNotFound(t *testing.T) {
|
|
err := execInput("gosh-no-such-command")
|
|
if err == nil || err.Error() != "gosh-no-such-command: command not found" {
|
|
t.Errorf("got %v, want command not found", err)
|
|
}
|
|
if lastStatus != 127 {
|
|
t.Errorf("$? = %d, want 127", lastStatus)
|
|
}
|
|
}
|
|
|
|
func TestTokenize_Operators(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want []token
|
|
}{
|
|
{"a | b", []token{{word: "a"}, {op: "|"}, {word: "b"}}},
|
|
{"a|b", []token{{word: "a"}, {op: "|"}, {word: "b"}}},
|
|
{"a > f", []token{{word: "a"}, {op: ">", fd: 1}, {word: "f"}}},
|
|
{"a >>f", []token{{word: "a"}, {op: ">>", fd: 1}, {word: "f"}}},
|
|
{"a < f", []token{{word: "a"}, {op: "<", fd: 0}, {word: "f"}}},
|
|
{"a 2>f", []token{{word: "a"}, {op: ">", fd: 2}, {word: "f"}}},
|
|
{"a 2>>f", []token{{word: "a"}, {op: ">>", fd: 2}, {word: "f"}}},
|
|
{"a 2>&1", []token{{word: "a"}, {op: ">&", fd: 2, dup: 1}}},
|
|
{"a >&2", []token{{word: "a"}, {op: ">&", fd: 1, dup: 2}}},
|
|
{"a 0<&2", []token{{word: "a"}, {op: "<&", fd: 0, dup: 2}}},
|
|
{"a &>f", []token{{word: "a"}, {op: "&>", fd: 1}, {word: "f"}}},
|
|
{"a &>>f", []token{{word: "a"}, {op: "&>>", fd: 1}, {word: "f"}}},
|
|
// Only a word of unquoted digits is a file descriptor
|
|
{"a x2>f", []token{{word: "a"}, {word: "x2"}, {op: ">", fd: 1}, {word: "f"}}},
|
|
{`a "2">f`, []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}},
|
|
{"a 2 >f", []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}},
|
|
// Quoted and escaped operators are words
|
|
{`echo "a|b" 'c>d' e\<f`, []token{{word: "echo"}, {word: "a|b"}, {word: "c>d"}, {word: "e<f"}}},
|
|
// A lone & is a word
|
|
{"a & b", []token{{word: "a"}, {word: "&"}, {word: "b"}}},
|
|
{"cat ~|x", []token{{word: "cat"}, {word: getHomeDir()}, {op: "|"}, {word: "x"}}},
|
|
}
|
|
for _, tc := range tests {
|
|
got, err := tokenize(tc.input)
|
|
if err != nil {
|
|
t.Errorf("tokenize(%q) returned error: %v", tc.input, err)
|
|
continue
|
|
}
|
|
// Patterns are tested in TestTokenize_Patterns
|
|
for i := range got {
|
|
got[i].pattern, got[i].glob, got[i].assign = "", false, false
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_SyntaxErrors(t *testing.T) {
|
|
for _, input := range []string{"echo a |", "| echo a", "echo a | | b", "echo a >", "echo a > | b", "echo a >&", "echo a 2>&x", "; a"} {
|
|
err := execInput(input)
|
|
if err == nil || !strings.Contains(err.Error(), "syntax error") {
|
|
t.Errorf("execInput(%q) = %v, want syntax error", input, err)
|
|
}
|
|
if lastStatus != 2 {
|
|
t.Errorf("execInput(%q): $? = %d, want 2", input, lastStatus)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_PipesAndRedirections(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
dir := t.TempDir()
|
|
os.Chdir(dir)
|
|
|
|
read := func(name string) string {
|
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
t.Errorf("reading %s: %v", name, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
tests := []struct {
|
|
input string
|
|
file string
|
|
want string
|
|
status int
|
|
}{
|
|
{`printf 'b\na\nc\n' | sort | head -n 2 > out`, "out", "a\nb\n", 0},
|
|
{"echo one > f1; echo two >> f1", "f1", "one\ntwo\n", 0},
|
|
{"echo one > f2; echo two > f2", "f2", "two\n", 0},
|
|
{"tr a-z A-Z < f1 > f3", "f3", "ONE\nTWO\n", 0},
|
|
{"sh -c 'echo out; echo err >&2' > f4 2>&1", "f4", "out\nerr\n", 0},
|
|
{"sh -c 'echo out; echo err >&2' &> f5", "f5", "out\nerr\n", 0},
|
|
{"sh -c 'echo err >&2' 2> f6", "f6", "err\n", 0},
|
|
// 2>&1 before >file sends stderr to the old stdout (the pipe)
|
|
{"sh -c 'echo out; echo err >&2' 2>&1 > /dev/null | cat > f7", "f7", "err\n", 0},
|
|
{"sh -c 'echo err >&2' 2>&1 | tr a-z A-Z > f8", "f8", "ERR\n", 0},
|
|
// Builtins write to redirections and pipes
|
|
{"pwd > f9", "f9", dir + "\n", 0},
|
|
{"pwd | cat > f10", "f10", dir + "\n", 0},
|
|
{"help | head -n 1 > f11", "f11", "gosh - Go Shell\n", 0},
|
|
// Errors of builtins go to the redirected stderr
|
|
{"cd /gosh/no/such/dir 2> f12", "f12", "gosh: chdir /gosh/no/such/dir: no such file or directory\n", 1},
|
|
// The status of a pipeline is the one of the last command
|
|
{"false | true > f13", "f13", "", 0},
|
|
{"true | false > f14", "f14", "", 1},
|
|
// A redirection without a command creates the file
|
|
{"> f15", "f15", "", 0},
|
|
// SIGPIPE ends the writer when the reader exits
|
|
{"yes | head -n 1 > f16", "f16", "y\n", 0},
|
|
// A command that is not found does not stop the pipeline
|
|
{"gosh-no-such-command | echo after > f17", "f17", "after\n", 0},
|
|
{"echo before | gosh-no-such-command 2> f18", "f18", "", 127},
|
|
}
|
|
for _, tc := range tests {
|
|
execInput(tc.input)
|
|
if lastStatus != tc.status {
|
|
t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status)
|
|
}
|
|
if got := read(tc.file); got != tc.want {
|
|
t.Errorf("%q: %s = %q, want %q", tc.input, tc.file, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_RedirectionErrors(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
os.Chdir(t.TempDir())
|
|
|
|
tests := []struct {
|
|
input string
|
|
wantErr string
|
|
}{
|
|
{"cat < gosh-no-such-file", "gosh-no-such-file: no such file or directory"},
|
|
{"echo a > gosh-no-such-dir/f", "gosh-no-such-dir/f: no such file or directory"},
|
|
{"echo a 3> f", "3: bad file descriptor"},
|
|
{"echo a 2>&5", "5: bad file descriptor"},
|
|
{"echo a | cd /", "cd: cannot be used in a pipeline"},
|
|
{"echo a | exit 3", "exit: cannot be used in a pipeline"},
|
|
}
|
|
for _, tc := range tests {
|
|
err := execInput(tc.input)
|
|
if err == nil || err.Error() != tc.wantErr {
|
|
t.Errorf("execInput(%q) = %v, want %q", tc.input, err, tc.wantErr)
|
|
}
|
|
if lastStatus != 1 {
|
|
t.Errorf("execInput(%q): $? = %d, want 1", tc.input, lastStatus)
|
|
}
|
|
}
|
|
// The command is not run if a redirection fails
|
|
if _, err := os.Stat("f"); err == nil {
|
|
t.Error("file f must not be created for a bad file descriptor")
|
|
}
|
|
}
|
|
|
|
func TestTokenize_Patterns(t *testing.T) {
|
|
t.Setenv("GOSH_GLOB", "*.go")
|
|
tests := []struct {
|
|
input string
|
|
pattern string
|
|
glob bool
|
|
}{
|
|
{"*.go", "*.go", true},
|
|
{"a?c", "a?c", true},
|
|
{"[ab].go", "[ab].go", true},
|
|
{"[!a].go", "[^a].go", true},
|
|
{"plain", "plain", false},
|
|
// Quoted, escaped and expanded characters are literal
|
|
{`"*.go"`, `\*.go`, false},
|
|
{`'*'.go`, `\*.go`, false},
|
|
{`\*.go`, `\*.go`, false},
|
|
{`"x"*`, `x*`, true},
|
|
{"$GOSH_GLOB", `\*.go`, false},
|
|
{`'['!a].go`, `\[!a].go`, false},
|
|
}
|
|
for _, tc := range tests {
|
|
tokens, err := tokenize(tc.input)
|
|
if err != nil || len(tokens) != 1 {
|
|
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
|
|
continue
|
|
}
|
|
if tokens[0].pattern != tc.pattern || tokens[0].glob != tc.glob {
|
|
t.Errorf("tokenize(%q): pattern %q, glob %v; want %q, %v",
|
|
tc.input, tokens[0].pattern, tokens[0].glob, tc.pattern, tc.glob)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExpandGlob(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
dir := t.TempDir()
|
|
os.Chdir(dir)
|
|
for _, d := range []string{"sub/deep", "other"} {
|
|
os.MkdirAll(d, 0755)
|
|
}
|
|
for _, f := range []string{"a.go", "b.go", "c.txt", ".hidden.go", "with space.go", "[x].go", "sub/s1.go", "sub/deep/d.go", "other/o.go"} {
|
|
os.WriteFile(f, nil, 0644)
|
|
}
|
|
|
|
tests := []struct {
|
|
pattern string
|
|
want []string
|
|
}{
|
|
{"*.go", []string{"[x].go", "a.go", "b.go", "with space.go"}},
|
|
{".*.go", []string{".hidden.go"}},
|
|
{"?.go", []string{"a.go", "b.go"}},
|
|
{"[ab].go", []string{"a.go", "b.go"}},
|
|
{"[^a].go", []string{"b.go"}},
|
|
{"*/*.go", []string{"other/o.go", "sub/s1.go"}},
|
|
{"*/", []string{"other/", "sub/"}},
|
|
{"sub/*/*.go", []string{"sub/deep/d.go"}},
|
|
{"./c*", []string{"./c.txt"}},
|
|
{dir + "/c*", []string{dir + "/c.txt"}},
|
|
{`\[x\].go`, []string{"[x].go"}},
|
|
{"*.xyz", nil},
|
|
{"[", nil},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := expandGlob(tc.pattern); !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("expandGlob(%q) = %q, want %q", tc.pattern, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_Wildcards(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
dir := t.TempDir()
|
|
os.Chdir(dir)
|
|
for _, f := range []string{"a.go", "b.go", "c.txt"} {
|
|
os.WriteFile(f, []byte(f+"\n"), 0644)
|
|
}
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"echo *.go > out", "a.go b.go\n"},
|
|
{"cat *.go > out", "a.go\nb.go\n"},
|
|
// No match: the word is passed on unchanged, like in sh
|
|
{"echo *.xyz > out", "*.xyz\n"},
|
|
{`echo "*.go" '?.go' \*.go > out`, "*.go ?.go *.go\n"},
|
|
// A redirection to a pattern with a single match uses that file
|
|
{"echo hi > *.txt; cat c.txt > out", "hi\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
if err := execInput(tc.input); err != nil {
|
|
t.Errorf("execInput(%q) returned error: %v", tc.input, err)
|
|
}
|
|
if got, _ := os.ReadFile("out"); string(got) != tc.want {
|
|
t.Errorf("%q: got %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
|
|
err := execInput("echo hi > *.go")
|
|
if err == nil || err.Error() != "*.go: ambiguous redirect" {
|
|
t.Errorf("expected ambiguous redirect error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartupFiles(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
sys := sysConfDir
|
|
|
|
tests := []struct {
|
|
login, interactive bool
|
|
want []string
|
|
}{
|
|
{false, false, nil},
|
|
{true, false, []string{sys + "/profile", home + "/.gosh_profile"}},
|
|
{false, true, []string{sys + "/goshrc", home + "/.goshrc"}},
|
|
{true, true, []string{sys + "/profile", home + "/.gosh_profile", sys + "/goshrc", home + "/.goshrc"}},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := startupFiles(tc.login, tc.interactive); !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("startupFiles(%v, %v) = %q, want %q", tc.login, tc.interactive, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRun_LoginReadsProfile(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
os.Unsetenv("GOSH_TEST_PROFILE")
|
|
defer os.Unsetenv("GOSH_TEST_PROFILE")
|
|
os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=yes\n"), 0644)
|
|
// .goshrc is only read by interactive shells
|
|
os.WriteFile(filepath.Join(home, ".goshrc"), []byte("exit 99\n"), 0644)
|
|
|
|
check := `sh -c 'test "$GOSH_TEST_PROFILE" = yes'`
|
|
if got := run([]string{"-c", check}); got != 1 {
|
|
t.Errorf("non-login shell: status %d, want 1 (profile must not be read)", got)
|
|
}
|
|
if got := run([]string{"-l", "--noprofile", "-c", check}); got != 1 {
|
|
t.Errorf("--noprofile: status %d, want 1 (profile must not be read)", got)
|
|
}
|
|
if got := run([]string{"-l", "-c", check}); got != 0 {
|
|
t.Errorf("login shell: status %d, want 0 (profile must be read)", got)
|
|
}
|
|
}
|
|
|
|
func TestShell_InteractiveReadsRC(t *testing.T) {
|
|
bin := buildShell(t)
|
|
home := t.TempDir()
|
|
os.WriteFile(filepath.Join(home, ".goshrc"), []byte("export GOSH_TEST_RC=from-rc\n"), 0644)
|
|
os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=from-profile\n"), 0644)
|
|
|
|
tests := []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"-i"}, "from-rc -\n"},
|
|
{[]string{"-i", "--norc"}, "- -\n"},
|
|
{[]string{"-i", "-l"}, "from-rc from-profile\n"},
|
|
{[]string{"-i", "-l", "--norc", "--noprofile"}, "- -\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
cmd := exec.Command(bin, tc.args...)
|
|
cmd.Env = append(os.Environ(), "HOME="+home)
|
|
cmd.Stdin = strings.NewReader("echo ${GOSH_TEST_RC:--} ${GOSH_TEST_PROFILE:--}\nexit\n")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("%v: %v", tc.args, err)
|
|
}
|
|
if !strings.HasSuffix(string(out), tc.want) {
|
|
t.Errorf("%v: output %q, want suffix %q", tc.args, out, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecInput_Source(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.Unsetenv("GOSH_TEST_SOURCE")
|
|
defer os.Unsetenv("GOSH_TEST_SOURCE")
|
|
|
|
file := filepath.Join(dir, "vars")
|
|
os.WriteFile(file, []byte("# comment\nexport GOSH_TEST_SOURCE=hello\nsh -c 'exit 4'\n"), 0644)
|
|
|
|
for _, cmd := range []string{"source", "."} {
|
|
os.Unsetenv("GOSH_TEST_SOURCE")
|
|
execInput(cmd + " " + file)
|
|
if os.Getenv("GOSH_TEST_SOURCE") != "hello" {
|
|
t.Errorf("%s: variable was not set in the current shell", cmd)
|
|
}
|
|
if lastStatus != 4 {
|
|
t.Errorf("%s: $? = %d, want 4 (status of the last command)", cmd, lastStatus)
|
|
}
|
|
}
|
|
|
|
err := execInput("source " + filepath.Join(dir, "missing"))
|
|
if err == nil || !strings.Contains(err.Error(), "source: "+dir+"/missing: no such file or directory") {
|
|
t.Errorf("unexpected error for missing file: %v", err)
|
|
}
|
|
if lastStatus != 1 {
|
|
t.Errorf("$? = %d for missing file, want 1", lastStatus)
|
|
}
|
|
|
|
// A file that sources itself is stopped
|
|
loop := filepath.Join(dir, "loop")
|
|
os.WriteFile(loop, []byte("source "+loop+"\n"), 0644)
|
|
if err := execInput("source " + loop); err == nil {
|
|
t.Error("expected an error for a file that sources itself")
|
|
}
|
|
}
|
|
|
|
func TestGetHomeDir_UsesHOME(t *testing.T) {
|
|
t.Setenv("HOME", "/gosh/test/home")
|
|
if got := getHomeDir(); got != "/gosh/test/home" {
|
|
t.Errorf("getHomeDir() = %q, want $HOME", got)
|
|
}
|
|
if words, _ := splitWords("echo ~/x"); words[1] != "/gosh/test/home/x" {
|
|
t.Errorf("~/x = %q, want it based on $HOME", words[1])
|
|
}
|
|
|
|
// Without $HOME the user database is used
|
|
t.Setenv("HOME", "")
|
|
if got := getHomeDir(); got == "" {
|
|
t.Error("getHomeDir() is empty without $HOME")
|
|
}
|
|
}
|
|
|
|
func TestExecInput_Background(t *testing.T) {
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
dir := t.TempDir()
|
|
os.Chdir(dir)
|
|
|
|
// The shell does not wait for a background job, wait does
|
|
start := time.Now()
|
|
if err := execInput("sh -c 'sleep 0.3; echo done > out' &"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if lastStatus != 0 {
|
|
t.Errorf("$? = %d after starting a background job, want 0", lastStatus)
|
|
}
|
|
if time.Since(start) > 200*time.Millisecond {
|
|
t.Error("the shell waited for the background job")
|
|
}
|
|
if _, err := os.Stat("out"); err == nil {
|
|
t.Error("background job finished too early")
|
|
}
|
|
if err := execInput("wait"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := os.ReadFile("out"); string(got) != "done\n" {
|
|
t.Errorf("out = %q after wait", got)
|
|
}
|
|
if len(jobTable) != 0 {
|
|
t.Errorf("job table not empty after wait: %d jobs", len(jobTable))
|
|
}
|
|
|
|
// wait %n returns the status of the job
|
|
execInput("sh -c 'exit 3' &")
|
|
execInput("wait %1")
|
|
if lastStatus != 3 {
|
|
t.Errorf("$? = %d after wait %%1, want 3", lastStatus)
|
|
}
|
|
|
|
// Without job control, background jobs read from /dev/null
|
|
execInput("cat > stdin.txt &")
|
|
execInput("wait")
|
|
if got, err := os.ReadFile("stdin.txt"); err != nil || len(got) != 0 {
|
|
t.Errorf("background job read %q, %v from stdin", got, err)
|
|
}
|
|
|
|
// jobs lists running jobs, also into a redirection
|
|
execInput("sleep 0.3 &")
|
|
execInput("jobs > jobs.txt")
|
|
if got, _ := os.ReadFile("jobs.txt"); !strings.Contains(string(got), "Running sleep 0.3 &") {
|
|
t.Errorf("jobs output = %q", got)
|
|
}
|
|
execInput("wait")
|
|
|
|
// Builtins that change the shell cannot run in the background
|
|
if err := execInput("cd / &"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
execInput("wait %1")
|
|
if wd, _ := os.Getwd(); wd != dir {
|
|
t.Errorf("cd in the background changed the directory to %s", wd)
|
|
}
|
|
|
|
// fg and bg need job control
|
|
for _, cmd := range []string{"fg", "bg"} {
|
|
if err := execInput(cmd); err == nil || err.Error() != cmd+": no job control" {
|
|
t.Errorf("%s without job control: %v", cmd, err)
|
|
}
|
|
}
|
|
if err := execInput("wait %9"); err == nil || err.Error() != "wait: %9: no such job" {
|
|
t.Errorf("wait %%9: %v", err)
|
|
}
|
|
}
|