fixing bugs, update README,Help; adding .gitignore
This commit is contained in:
1 parent
847b6cd5c6
commit
d5c3d03a21
4 files changed
+598
-42
No files matched your search
@@ -0,0 +1 @@
|
||||
gosh
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+310
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user