fixing bugs, update README,Help; adding .gitignore

This commit is contained in:
Florian Walther committed 2026-10-03 16:51:55 +02:00
1 parent 847b6cd5c6
commit d5c3d03a21
4 files changed
+598 -42

No files matched your search

+310
View File
@@ -2,13 +2,16 @@ package main
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"strings"
"syscall"
"testing"
"time"
"github.com/google/shlex"
)
@@ -848,3 +851,310 @@ func TestShellCompleter_Do(t *testing.T) {
}
}
}
// =============================================================================
// 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) {
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 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)
}
}