From d5c3d03a218def0ff3d642d37ba3062887d3200e Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 16:51:55 +0200 Subject: [PATCH] fixing bugs, update README,Help; adding .gitignore --- .gitignore | 1 + README.md | 24 +++- main.go | 305 ++++++++++++++++++++++++++++++++++++++++++++------ main_test.go | 310 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 598 insertions(+), 42 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e547483 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gosh diff --git a/README.md b/README.md index 6111a53..fd09689 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,29 @@ Built-in commands: help - Show this help Features: - - Argument parsing with quotes and escapes - - Ctrl+D to exit, Ctrl+C to cancel - - Command history (up/down arrows) + - Argument parsing with quotes and escapes ('...' is literal, + "..." expands variables, \ escapes the next character) + - Ctrl+D to exit, Ctrl+C to cancel the current line or command + - Command history (up/down arrows), stored in + $XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history, + override with $GOSH_HISTFILE); lines starting with a space are not saved - Tab completion for commands and file paths - - Tilde expansion (~, ~/path) - - Environment variable expansion ($VAR, ${VAR}) + - Tilde expansion (~, ~/path) at the start of unquoted words + - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); + using an unset variable is an error, use ${VAR:-} for an empty default ``` +### Sicherheitshinweise + +- Die History liegt in `$XDG_STATE_HOME/gosh/history` (Standard: `~/.local/state/gosh/history`) und ist nur für den eigenen Benutzer lesbar (Verzeichnis `0700`, Datei `0600`). Mit `GOSH_HISTFILE` lässt sich ein anderer Pfad setzen, mit `GOSH_HISTFILE=` wird die History abgeschaltet. +- Zeilen, die mit einem Leerzeichen beginnen, werden nicht in der History gespeichert – praktisch für Befehle mit Passwörtern oder Tokens. +- Innerhalb von `'...'` wird nichts expandiert, `\$` ergibt ein wörtliches `$`. +- Eine nicht gesetzte Variable bricht den Befehl ab (wie `set -u`), damit z. B. `rm -rf $DIR/out` nie mit leerem `$DIR` ausgeführt wird. Für einen Standardwert `${VAR:-default}` bzw. `${VAR:-}` verwenden. +- Variablenwerte werden nicht in mehrere Wörter aufgeteilt. +- Steuerzeichen in Verzeichnisnamen werden im Prompt escaped angezeigt (z. B. `\x1b`), Dateien mit Steuerzeichen im Namen werden nicht vervollständigt. +- Ctrl+C beendet nur den laufenden Befehl bzw. verwirft die aktuelle Zeile, nicht die Shell. + _gosh is not meant for production use, it is just a proof of concept._ ## building diff --git a/main.go b/main.go index 8eb03df..5906db7 100644 --- a/main.go +++ b/main.go @@ -6,12 +6,14 @@ import ( "io" "os" "os/exec" + "os/signal" "os/user" "path/filepath" "strings" + "unicode" + "unicode/utf8" "github.com/chzyer/readline" - "github.com/google/shlex" ) // ErrNoPath is returned when 'cd' was called without a second argument. @@ -58,11 +60,223 @@ func expandTilde(path string) string { return path } -// expandEnv expands environment variables in a string -// Supports $VAR, ${VAR}, and ${VAR:-default} syntax -func expandEnv(s string) string { - // Use os.ExpandEnv for simple ${VAR} and $VAR expansion - return os.ExpandEnv(s) +// splitWords splits input into words like a (very) simple POSIX shell: +// - words are separated by unquoted blanks, an unquoted # starts a comment +// - '...' is taken literally, nothing is expanded inside +// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and ` +// - outside quotes \ escapes any character +// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes; +// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs +// with an empty $DIR +// - ~ and ~/... are expanded at the start of an unquoted word +// +// Expanded values are never split into several words. +func splitWords(input string) ([]string, error) { + var ( + words []string + b strings.Builder + inWord bool // a word has been started (even if it is still empty) + quote byte + escaped bool + ) + + endWord := func() { + if inWord { + words = append(words, b.String()) + } + b.Reset() + inWord = false + } + + for i := 0; i < len(input); i++ { + ch := input[i] + switch { + case escaped: + if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) { + b.WriteByte('\\') + } + b.WriteByte(ch) + inWord = true + escaped = false + case quote == '\'': + if ch == '\'' { + quote = 0 + } else { + b.WriteByte(ch) + } + case ch == '\\': + escaped = true + case ch == '$': + value, n, err := expandVar(input[i:]) + if err != nil { + return nil, err + } + b.WriteString(value) + // A literal "$" counts as content, an expansion that is empty does + // not (unless quoted), like in sh + if n == 1 || value != "" { + inWord = true + } + i += n - 1 + case quote == '"': + if ch == '"' { + quote = 0 + } else { + b.WriteByte(ch) + } + case ch == '"' || ch == '\'': + quote = ch + inWord = true + case ch == ' ' || ch == '\t' || ch == '\n': + endWord() + case ch == '#' && !inWord && b.Len() == 0: + // Comment until the end of the line + i = len(input) + case ch == '~' && !inWord && b.Len() == 0 && + (i+1 == len(input) || strings.IndexByte("/ \t\n", input[i+1]) >= 0): + home := getHomeDir() + if home == "" { + home = "~" + } + b.WriteString(home) + inWord = true + default: + b.WriteByte(ch) + inWord = true + } + } + + switch { + case escaped: + return nil, errors.New("unexpected end of input after \\") + case quote != 0: + return nil, fmt.Errorf("unterminated %c quote", quote) + } + endWord() + return words, nil +} + +// expandVar expands the variable reference at the start of s (which starts +// with "$"). It returns the value and the number of bytes consumed. A "$" that +// is not followed by a variable name is returned literally. +func expandVar(s string) (string, int, error) { + if len(s) > 1 && s[1] == '{' { + end := strings.IndexByte(s, '}') + if end < 0 { + return "", 0, errors.New("missing } in variable reference") + } + name, def, hasDef := strings.Cut(s[2:end], ":-") + if !isVarName(name) { + return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1]) + } + value, ok := os.LookupEnv(name) + if hasDef && value == "" { + return def, end + 1, nil + } + if !ok { + return "", 0, fmt.Errorf("%s: unbound variable", name) + } + return value, end + 1, nil + } + + n := 1 + for n < len(s) && isVarNameByte(s[n], n == 1) { + n++ + } + if n == 1 { + return "$", 1, nil + } + value, ok := os.LookupEnv(s[1:n]) + if !ok { + return "", 0, fmt.Errorf("%s: unbound variable", s[1:n]) + } + return value, n, nil +} + +func isVarName(name string) bool { + if name == "" { + return false + } + for i := 0; i < len(name); i++ { + if !isVarNameByte(name[i], i == 0) { + return false + } + } + return true +} + +func isVarNameByte(c byte, first bool) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || + !first && c >= '0' && c <= '9' +} + +// sanitize replaces non-printable characters (e.g. terminal escape sequences +// in directory names) with a visible escape so they cannot manipulate the +// terminal. +func sanitize(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == utf8.RuneError && size == 1: + fmt.Fprintf(&b, "\\x%02x", s[i]) + case !unicode.IsPrint(r): + if r < 0x100 { + fmt.Fprintf(&b, "\\x%02x", r) + } else { + fmt.Fprintf(&b, "\\u%04x", r) + } + default: + b.WriteRune(r) + } + i += size + } + return b.String() +} + +// isPrintable reports whether s only consists of printable characters +func isPrintable(s string) bool { + return sanitize(s) == s +} + +// historyFile returns the path of the history file: $GOSH_HISTFILE if set, +// otherwise $XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history). +// An empty string disables the history. +func historyFile() string { + if path, ok := os.LookupEnv("GOSH_HISTFILE"); ok { + return path + } + state := os.Getenv("XDG_STATE_HOME") + if state == "" { + home := getHomeDir() + if home == "" { + return "" + } + state = filepath.Join(home, ".local", "state") + } + return filepath.Join(state, "gosh", "history") +} + +// prepareHistoryFile makes sure that the history file and its directory are +// only accessible by the current user. readline itself creates the file (and +// a temporary file next to it) with mode 0666 & ~umask. +func prepareHistoryFile(path string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + // Only restrict the directory if it is ours (and not e.g. $HOME itself) + if filepath.Base(dir) == "gosh" { + if err := os.Chmod(dir, 0700); err != nil { + return err + } + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + return f.Chmod(0600) } // getPrompt returns a dynamic prompt string like "user@host:dir $" @@ -92,10 +306,10 @@ func getPrompt() string { if home != "" && strings.HasPrefix(wd, home) { // Replace home with ~ displayPath := "~" + strings.TrimPrefix(wd, home) - return fmt.Sprintf("%s@%s:%s $ ", username, hostname, displayPath) + return sanitize(fmt.Sprintf("%s@%s:%s $ ", username, hostname, displayPath)) } - return fmt.Sprintf("%s@%s:%s $ ", username, hostname, wd) + return sanitize(fmt.Sprintf("%s@%s:%s $ ", username, hostname, wd)) } // shellCompleter implements readline.AutoCompleter for contextual completion @@ -163,7 +377,7 @@ func pathExecutables(prefix string) []string { } for _, entry := range entries { name := entry.Name() - if !strings.HasPrefix(name, prefix) { + if !strings.HasPrefix(name, prefix) || !isPrintable(name) { continue } // Follow symlinks, only regular files with an executable bit count @@ -254,6 +468,10 @@ func completeFilePath(line, prefix string) []string { if strings.HasPrefix(name, ".") && !strings.HasPrefix(pattern, ".") { continue } + // Never insert control characters (e.g. escape sequences) into the line + if !isPrintable(name) { + continue + } // Follow symlinks to decide whether this is a directory if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() { name += "/" @@ -265,13 +483,27 @@ func completeFilePath(line, prefix string) []string { } func main() { + // Ctrl+C sends SIGINT to the whole foreground process group. Catch it so + // that it only stops the running command and not the shell. (signal.Ignore + // would be inherited by the child processes.) + signal.Notify(make(chan os.Signal, 1), os.Interrupt) + + histFile := historyFile() + if histFile != "" { + if err := prepareHistoryFile(histFile); err != nil { + fmt.Fprintln(os.Stderr, "History disabled:", err) + histFile = "" + } + } + // Create readline instance l, err := readline.NewEx(&readline.Config{ - Prompt: getPrompt(), - HistoryFile: "./gosh_history", - AutoComplete: newShellCompleter(), - InterruptPrompt: "^C", - EOFPrompt: "exit", + Prompt: getPrompt(), + HistoryFile: histFile, + DisableAutoSaveHistory: true, + AutoComplete: newShellCompleter(), + InterruptPrompt: "^C", + EOFPrompt: "exit", }) if err != nil { fmt.Fprintln(os.Stderr, "Error initializing readline:", err) @@ -286,8 +518,12 @@ func main() { line, err := l.Readline() if err != nil { + // Ctrl+C discards the current line + if err == readline.ErrInterrupt { + continue + } // Handle EOF (Ctrl+D) - if err == io.EOF || err == readline.ErrInterrupt { + if err == io.EOF { fmt.Println() break } @@ -300,8 +536,11 @@ func main() { continue } - // Add to history - l.SaveHistory(line) + // Add to history, unless the line starts with a space (like bash's + // HISTCONTROL=ignorespace) so that secrets can be kept out of it + if !strings.HasPrefix(line, " ") { + l.SaveHistory(line) + } // Execute the input if err := execInput(line); err != nil { @@ -311,8 +550,9 @@ func main() { } func execInput(input string) error { - // Split the input separate the command and the arguments. - args, err := shlex.Split(input) + // Split the input into the command and its arguments, expanding + // variables and ~ on the way. + args, err := splitWords(input) if err != nil { return fmt.Errorf("invalid input: %w", err) } @@ -320,16 +560,6 @@ func execInput(input string) error { return nil } - // Expand environment variables in all arguments - for i, arg := range args { - args[i] = expandEnv(arg) - } - - // Expand tilde in all arguments - for i, arg := range args { - args[i] = expandTilde(arg) - } - // Check for built-in commands. switch args[0] { case "cd": @@ -357,9 +587,6 @@ func execInput(input string) error { return os.Chdir(prevDir) } - // Expand tilde in the path - path = expandTilde(path) - // Save current directory as previous before changing previousDir = currentDir @@ -423,12 +650,16 @@ Built-in commands: help - Show this help Features: - - Argument parsing with quotes and escapes - - Ctrl+D to exit, Ctrl+C to cancel - - Command history (up/down arrows) + - Argument parsing with quotes and escapes ('...' is literal, + "..." expands variables, \ escapes the next character) + - Ctrl+D to exit, Ctrl+C to cancel the current line or command + - Command history (up/down arrows), stored in + $XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history, + override with $GOSH_HISTFILE); lines starting with a space are not saved - Tab completion for commands and file paths - - Tilde expansion (~, ~/path) - - Environment variable expansion ($VAR, ${VAR})`) + - Tilde expansion (~, ~/path) at the start of unquoted words + - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); + using an unset variable is an error, use ${VAR:-} for an empty default`) return nil } diff --git a/main_test.go b/main_test.go index 46beea6..3c9ddd1 100644 --- a/main_test.go +++ b/main_test.go @@ -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) + } +}