package main import ( "bufio" "errors" "fmt" "io" "io/fs" "os" "os/exec" "os/signal" "os/user" "path/filepath" "runtime/debug" "sort" "strconv" "strings" "syscall" "unicode" "unicode/utf8" "github.com/chzyer/readline" ) // ErrNoPath is returned when 'cd' was called without a second argument. var ErrNoPath = errors.New("path required") // Build information, set at build time via // -ldflags "-X main.version=... -X main.commit=... -X main.branch=...". var ( version = "dev" commit = "" branch = "" ) // versionInfo returns the version including branch and commit, e.g. // "0.2.1 (branch: main, commit: 03e791c)". Without ldflags (plain go build) // the commit is taken from the VCS information that Go embeds in the binary. func versionInfo() string { c := commit if c == "" { if info, ok := debug.ReadBuildInfo(); ok { var dirty bool for _, s := range info.Settings { switch s.Key { case "vcs.revision": c = s.Value case "vcs.modified": dirty = s.Value == "true" } } if len(c) > 7 { c = c[:7] } if c != "" && dirty { c += "-dirty" } } } var details []string if branch != "" { details = append(details, "branch: "+branch) } if c != "" { details = append(details, "commit: "+c) } if len(details) == 0 { return version } return version + " (" + strings.Join(details, ", ") + ")" } // previousDir tracks the previous working directory for 'cd -' support var previousDir string // getHomeDir returns the user's home directory: $HOME like in sh, or the // home directory from the user database if $HOME is not set func getHomeDir() string { if home := os.Getenv("HOME"); home != "" { return home } usr, err := user.Current() if err != nil { return "" } return usr.HomeDir } // expandTilde expands ~ to the home directory and ~user to user's home func expandTilde(path string) string { if len(path) == 0 { return path } // Handle ~ at the start if strings.HasPrefix(path, "~") { home := getHomeDir() if home == "" { return path // Can't expand, return as-is } // Handle ~ (just home) if len(path) == 1 { return home } // Handle ~/path or ~user/path // For now, just handle ~/path if path[1] == '/' { return filepath.Join(home, path[2:]) } } return path } // token is a word or an operator of a command line. type token struct { op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>> word string // the word, if op == "" pattern string // the word as glob pattern (quoted characters escaped) glob bool // the word contains unquoted * ? or [ and is expanded assign bool // the word is an assignment name=value (unquoted name and =) fd int // the redirected file descriptor (for redirections) dup int // the target file descriptor of >& and <& } // tokenize splits input into words and operators 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 // - $(command) is replaced by the output of command (see commandSubst); // unquoted, the output is split into words at blanks and newlines // - $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, and after // the = and : of an assignment (PATH=~/bin:~/go/bin) // - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators // // Expanded values are never split into several words. func tokenize(input string) ([]token, error) { var ( tokens []token b strings.Builder pat strings.Builder // the word as glob pattern, quoted parts escaped glob bool // the word contains unquoted * ? or [ inWord bool // a word has been started (even if it is still empty) plain = true // the word so far has no quotes, escapes or expansions assign bool // the word is an assignment name=value tildeOK bool // ~ may be expanded here (after = or : of an assignment) quote byte escaped bool rawStart = -1 // start of the current word in input ) endWord := func() { if inWord { tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign}) } b.Reset() pat.Reset() glob, assign, plain = false, false, true inWord = false rawStart = -1 } // literal adds quoted, escaped or expanded text: it is never a pattern literal := func(s string) { plain = false b.WriteString(s) pat.WriteString(escapeGlob(s)) } startRaw := func(i int) { if rawStart < 0 { rawStart = i } } for i := 0; i < len(input); i++ { ch := input[i] afterAssignSep := tildeOK tildeOK = false switch { case escaped: if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) { literal("\\") } literal(string(ch)) inWord = true escaped = false case quote == '\'': if ch == '\'' { quote = 0 } else { literal(string(ch)) } case ch == '\\': startRaw(i) escaped = true case isSubstStart(input, i): startRaw(i) end := substEnd(input, i) if end < 0 { return nil, errors.New("missing ) in command substitution") } out, err := commandSubst(input[i+2 : end]) if err != nil { return nil, err } if quote == '"' || assign { // Quoted or in an assignment, the output is one word literal(out) inWord = true } else { // Unquoted, the output is split into words at blanks and // newlines (but, unlike in sh, wildcards in it are not // expanded) for k, field := range strings.Fields(out) { if k > 0 { endWord() } literal(field) inWord = true } } i = end case ch == '$': startRaw(i) value, n, err := expandVar(input[i:]) if err != nil { return nil, err } // Expanded values are never patterns (and never split), so that // e.g. a * in a variable does not match files literal(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 { literal(string(ch)) } case ch == '"' || ch == '\'': startRaw(i) plain = false 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 == '|': endWord() tokens = append(tokens, token{op: "|"}) case ch == '&' && i+1 < len(input) && input[i+1] == '>': // &> and &>> redirect stdout and stderr endWord() op := "&>" if i+2 < len(input) && input[i+2] == '>' { op = "&>>" } tokens = append(tokens, token{op: op, fd: 1}) i += len(op) - 1 case ch == '<' || ch == '>': // A word consisting only of unquoted digits right before the // operator is the file descriptor, e.g. 2>file fd := 0 if ch == '>' { fd = 1 } if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) { fd, _ = strconv.Atoi(input[rawStart:i]) b.Reset() pat.Reset() glob = false inWord = false rawStart = -1 } else { endWord() } t := token{op: string(ch), fd: fd} switch { case ch == '>' && i+1 < len(input) && input[i+1] == '>': t.op = ">>" i++ case i+1 < len(input) && input[i+1] == '&': // N>&M and N<&M duplicate file descriptor M j := i + 2 for j < len(input) && input[j] >= '0' && input[j] <= '9' { j++ } if j == i+2 { return nil, syntaxError(input[i : i+2]) } t.op = string(ch) + "&" t.dup, _ = strconv.Atoi(input[i+2 : j]) i = j - 1 } tokens = append(tokens, t) case ch == '~' && (!inWord && b.Len() == 0 || afterAssignSep) && (i+1 == len(input) || strings.IndexByte("/ \t\n|<>:", input[i+1]) >= 0): startRaw(i) home := getHomeDir() if home == "" { home = "~" } literal(home) inWord = true default: startRaw(i) switch { case ch == '=' && plain && !assign && isVarName(b.String()): // name=value; ~ may follow assign, tildeOK = true, true case ch == ':' && assign: // PATH=~/bin:~/go/bin tildeOK = true } b.WriteByte(ch) switch { case ch == '*' || ch == '?' || ch == '[': glob = true pat.WriteByte(ch) case ch == '!' && strings.HasSuffix(pat.String(), "[") && !strings.HasSuffix(pat.String(), "\\["): // POSIX [!...] is [^...] in Go pat.WriteByte('^') default: pat.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 tokens, nil } // escapeGlob escapes the characters that are special in a glob pattern. func escapeGlob(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { if strings.IndexByte("*?[]\\", s[i]) >= 0 { b.WriteByte('\\') } b.WriteByte(s[i]) } return b.String() } // hasGlobMeta reports whether pattern contains an unescaped * ? or [. func hasGlobMeta(pattern string) bool { for i := 0; i < len(pattern); i++ { switch pattern[i] { case '\\': i++ case '*', '?', '[': return true } } return false } // unescapeGlob removes the escapes from a pattern without wildcards. func unescapeGlob(pattern string) string { var b strings.Builder for i := 0; i < len(pattern); i++ { if pattern[i] == '\\' && i+1 < len(pattern) { i++ } b.WriteByte(pattern[i]) } return b.String() } // expandGlob returns the paths matching pattern, sorted. Like sh, * and ? // do not match a leading dot (hidden files), unless the pattern component // starts with a dot, and a trailing / only matches directories. An invalid // pattern matches nothing. func expandGlob(pattern string) []string { paths := []string{""} if strings.HasPrefix(pattern, "/") { paths = []string{"/"} } components := strings.Split(strings.Trim(pattern, "/"), "/") join := func(dir, name string) string { if dir == "" || strings.HasSuffix(dir, "/") { return dir + name } return dir + "/" + name } for _, comp := range components { if comp == "" { continue // a//b } var next []string for _, dir := range paths { if !hasGlobMeta(comp) { p := join(dir, unescapeGlob(comp)) if _, err := os.Lstat(p); err == nil { next = append(next, p) } continue } readDir := dir if readDir == "" { readDir = "." } entries, err := os.ReadDir(readDir) if err != nil { continue } for _, e := range entries { name := e.Name() if strings.HasPrefix(name, ".") && !strings.HasPrefix(comp, ".") && !strings.HasPrefix(comp, "\\.") { continue } ok, err := filepath.Match(comp, name) if err != nil { return nil } if ok { next = append(next, join(dir, name)) } } } paths = next } if strings.HasSuffix(pattern, "/") { var dirs []string for _, p := range paths { if info, err := os.Stat(p); err == nil && info.IsDir() { dirs = append(dirs, p+"/") } } paths = dirs } sort.Strings(paths) return paths } // expandWord returns the words a token expands to: the matching paths for // a pattern, or the word itself if it is no pattern or nothing matches (like // in sh). func expandWord(t token) []string { if t.glob { if matches := expandGlob(t.pattern); len(matches) > 0 { return matches } } return []string{t.word} } // isDigits reports whether s is a non-empty string of ASCII digits. func isDigits(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true } // splitWords splits input into words (see tokenize). Operators are an error. func splitWords(input string) ([]string, error) { tokens, err := tokenize(input) if err != nil { return nil, err } var words []string for _, t := range tokens { if t.op != "" { return nil, fmt.Errorf("unexpected operator %s", t.op) } words = append(words, t.word) } 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 name == "?" { return strconv.Itoa(lastStatus), end + 1, nil } 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 } if len(s) > 1 && s[1] == '?' { return strconv.Itoa(lastStatus), 2, 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 $" func getPrompt() string { // Get current working directory wd, err := os.Getwd() if err != nil { wd = "?" } // Get username usr, err := user.Current() username := "?" if err == nil && usr.Username != "" { username = usr.Username } // Get hostname hostname, err := os.Hostname() if err != nil { hostname = "unknown" } // Replace the home directory with ~ home := getHomeDir() if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) { wd = "~" + strings.TrimPrefix(wd, home) } var colors *promptColors if useColor() { c := parseColors(os.Getenv("GOSH_COLORS")) colors = &c } return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors) } // promptColors holds the SGR parameters (e.g. "1;32") for the parts of the // prompt. An empty value means no color for that part. type promptColors struct { user string // user@host root string // user@host when running as root path string // working directory error string // exit status of the last command, if it failed } // defaultColors: bold green user, bold red root, bold blue directory, bold // red exit status var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"} // parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31" // and overrides the corresponding defaults. Unknown keys and values that are // not plain SGR parameters (digits and ';') are ignored, so the variable // cannot be used to write arbitrary escape sequences to the terminal. func parseColors(spec string) promptColors { colors := defaultColors for _, entry := range strings.Split(spec, ":") { key, value, ok := strings.Cut(entry, "=") if !ok || !isSGR(value) { continue } switch key { case "user": colors.user = value case "root": colors.root = value case "path": colors.path = value case "error": colors.error = value } } return colors } // isSGR reports whether s consists only of digits and ';'. The empty string // is allowed and disables the color. func isSGR(s string) bool { for _, r := range s { if (r < '0' || r > '9') && r != ';' { return false } } return true } // colorize wraps text in the SGR sequence for sgr, or returns it unchanged if // sgr is empty. func colorize(text, sgr string) string { if sgr == "" { return text } return "\x1b[" + sgr + "m" + text + "\x1b[0m" } // buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $ // if the last command failed with exit status N. The parts are sanitized // before the color codes are added, so only our own escape sequences end up // in the prompt. colors == nil disables colors. func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string { userHost := sanitize(username + "@" + hostname) path = sanitize(path) var failed string if status != 0 { failed = " [" + strconv.Itoa(status) + "]" } if colors == nil { return userHost + ":" + path + failed + " $ " } userColor := colors.user if root { userColor = colors.root } if failed != "" { failed = " " + colorize(failed[1:], colors.error) } return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ " } // useColor reports whether the prompt should be colored: stdout must be a // terminal, TERM must not be "dumb" and NO_COLOR (https://no-color.org) must // not be set. func useColor() bool { if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { return false } return readline.IsTerminal(int(os.Stdout.Fd())) } // shellCompleter implements readline.AutoCompleter for contextual completion type shellCompleter struct { commands []string } func newShellCompleter() *shellCompleter { return &shellCompleter{ commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help"}, } } // Do implements readline.AutoCompleter interface. // readline expects the candidates to contain only the text that still has to // be inserted after the cursor, and the returned length to be the number of // runes of the current word that are already typed. func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { word, start, quote := currentWord(string(line[:pos])) // A command is expected at the start and after |, ;, && and || before := strings.TrimSpace(string(line[:start])) isFirstWord := before == "" || strings.HasSuffix(before, "|") || strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&") // ... and after if, then, do etc. if fields := strings.Fields(before); len(fields) > 0 { switch fields[len(fields)-1] { case "if", "then", "elif", "else", "do", "while", "until", "!": isFirstWord = true } } // Plain "~" -> "~/" if word == "~" { return [][]rune{[]rune("/")}, 1 } if isFirstWord && !strings.Contains(word, "/") && !strings.HasPrefix(word, "~") { // Complete commands var candidates [][]rune seen := make(map[string]bool) names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...) for _, cmd := range names { if strings.HasPrefix(cmd, word) && !seen[cmd] { seen[cmd] = true candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" ")) } } return candidates, len([]rune(word)) } // Complete file paths; only the part after the last slash is completed pattern := word[strings.LastIndex(word, "/")+1:] matches := completeFilePath(string(line[:pos]), word) var candidates [][]rune for _, m := range matches { rest := m[len(pattern):] if quote == 0 { rest = escapeWord(rest) } candidates = append(candidates, []rune(rest)) } return candidates, len([]rune(pattern)) } // pathExecutables returns the names of all executables in $PATH starting // with prefix func pathExecutables(prefix string) []string { var names []string for _, dir := range filepath.SplitList(os.Getenv("PATH")) { if dir == "" { dir = "." } entries, err := os.ReadDir(dir) if err != nil { continue } for _, entry := range entries { name := entry.Name() if !strings.HasPrefix(name, prefix) || !isPrintable(name) { continue } // Follow symlinks, only regular files with an executable bit count info, err := os.Stat(filepath.Join(dir, name)) if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { continue } names = append(names, name) } } return names } // currentWord returns the (unescaped, unquoted) word the cursor is in, the // byte offset where it starts in s and the quote character that is still open // (0 if none). func currentWord(s string) (word string, start int, quote byte) { var b strings.Builder escaped := false for i := 0; i < len(s); i++ { ch := s[i] switch { case escaped: b.WriteByte(ch) escaped = false case ch == '\\' && quote != '\'': escaped = true case quote != 0: if ch == quote { quote = 0 } else { b.WriteByte(ch) } case ch == '"' || ch == '\'': quote = ch case ch == ' ' || ch == '\t' || strings.IndexByte(";|&<>", ch) >= 0: // Blanks and operators end a word b.Reset() start = i + 1 default: b.WriteByte(ch) } } return b.String(), start, quote } // escapeWord escapes characters that would otherwise be interpreted by shlex func escapeWord(s string) string { var b strings.Builder for _, r := range s { if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) { b.WriteByte('\\') } b.WriteRune(r) } return b.String() } // completeFilePath handles file path completion. // prefix is the unquoted path typed so far. It returns the names (with a // trailing "/" for directories) of all entries in the directory part of // prefix that start with the part after the last slash. func completeFilePath(line, prefix string) []string { // Split textually so that "." or "~" are not normalized away slash := strings.LastIndex(prefix, "/") dir, pattern := prefix[:slash+1], prefix[slash+1:] switch { case dir == "": dir = "." case strings.HasPrefix(dir, "~"): dir = expandTilde(dir) } // Read the directory contents (relative paths resolve against the cwd) entries, err := os.ReadDir(dir) if err != nil { return nil } // Collect matching entries var matches []string for _, entry := range entries { name := entry.Name() if !strings.HasPrefix(name, pattern) { continue } // Hide dotfiles unless explicitly asked for 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 += "/" } matches = append(matches, name) } return matches } const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script] gosh [-l] [--noprofile] -c command gosh -version` // sysConfDir holds the system-wide startup files. var sysConfDir = "/etc/gosh" func main() { args := os.Args[1:] // login(1), sshd and "su -" start a login shell with a leading - in // argv[0], e.g. "-gosh" if strings.HasPrefix(os.Args[0], "-") { args = append([]string{"-l"}, args...) } os.Exit(run(args)) } // run parses the command line and runs gosh in the matching mode. It returns // the exit status of the shell. func run(args []string) int { var login, forceInteractive, noRC, noProfile bool flags: for len(args) > 0 { switch args[0] { case "-l", "--login": login = true case "-i": // Interactive mode even if stdin is not a terminal forceInteractive = true case "--norc": noRC = true case "--noprofile": noProfile = true default: break flags } args = args[1:] } // Determine what to run, before the startup files are read var script func() int interactiveMode := false if len(args) > 0 { switch arg := args[0]; { case arg == "-version" || arg == "--version" || arg == "-V": fmt.Println("gosh", versionInfo()) return 0 case arg == "-h" || arg == "--help": fmt.Println(usage) return 0 case arg == "-c": // Like sh, further arguments are allowed (they would become $0, // $1, ...), but gosh has no positional parameters yet. if len(args) < 2 { fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument") fmt.Fprintln(os.Stderr, usage) return 2 } script = func() int { return runScript(strings.NewReader(args[1]), "") } case strings.HasPrefix(arg, "-") && arg != "-": fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg) fmt.Fprintln(os.Stderr, usage) return 2 case arg == "-": script = func() int { return runScript(os.Stdin, "") } default: f, err := os.Open(arg) if err != nil { fmt.Fprintln(os.Stderr, "gosh:", err) return 127 } defer f.Close() script = func() int { return runScript(bufio.NewReader(f), arg) } } } else if forceInteractive || readline.IsTerminal(int(os.Stdin.Fd())) { interactiveMode = true } else { // Without a terminal (e.g. "echo ls | gosh") read the commands from // stdin script = func() int { return runScript(os.Stdin, "") } } // Aliases are expanded in interactive shells, also in their startup files interactiveShell = interactiveMode for _, file := range startupFiles(login && !noProfile, interactiveMode && !noRC) { sourceStartupFile(file) } if !interactiveMode { return script() } interactive() return 0 } // startupFiles returns the startup files to read, in order: for login // shells /etc/gosh/profile and ~/.gosh_profile, for interactive shells // /etc/gosh/goshrc and ~/.goshrc. Non-interactive shells (gosh -c, scripts) // read no files unless they are login shells. func startupFiles(login, interactive bool) []string { home := getHomeDir() var files []string if login { files = append(files, filepath.Join(sysConfDir, "profile")) if home != "" { files = append(files, filepath.Join(home, ".gosh_profile")) } } if interactive { files = append(files, filepath.Join(sysConfDir, "goshrc")) if home != "" { files = append(files, filepath.Join(home, ".goshrc")) } } return files } // sourceStartupFile runs a startup file in the current shell. A missing file // is skipped silently, other errors are reported. func sourceStartupFile(path string) { f, err := os.Open(path) if errors.Is(err, fs.ErrNotExist) { return } if err != nil { fmt.Fprintln(os.Stderr, "gosh:", err) return } defer f.Close() runScript(bufio.NewReader(f), path) } // sourceDepth counts nested source commands, to stop a file that sources // itself. var sourceDepth int // sourceFile runs the commands of a file in the current shell (the source and // . builtins) and returns an error carrying the status of the last command. func sourceFile(cmd, path string) error { if sourceDepth >= 100 { return fmt.Errorf("%s: %s: maximum nesting depth exceeded", cmd, sanitize(path)) } f, err := os.Open(path) if err != nil { var pathErr *fs.PathError if errors.As(err, &pathErr) { err = pathErr.Err } return fmt.Errorf("%s: %s: %v", cmd, sanitize(path), err) } defer f.Close() sourceDepth++ defer func() { sourceDepth-- }() if status := runScript(bufio.NewReader(f), path); status != 0 { return statusError(status) } return nil } // runScript executes the commands from r. Each command is executed as soon // as it is complete (a control structure can span several lines). Like sh, // it does not stop at a failing command and returns the exit status of the // last one. If name is set, errors are reported with file name and line. func runScript(r io.Reader, name string) int { var ( buf strings.Builder startLine int ) report := func(err error, line int) { if name != "" && err != nil { err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err) } printError(err) } for lineNo := 1; ; lineNo++ { line, readErr := readLine(r) if readErr == nil || line != "" { if buf.Len() == 0 { if strings.TrimSpace(line) == "" { goto next } startLine = lineNo } buf.WriteString(line) buf.WriteByte('\n') tree, err := parse(buf.String()) if errors.Is(err, errIncomplete) { goto next // read the rest of the command } buf.Reset() if err != nil { lastStatus = exitStatus(err) report(err, startLine) } else { report(tree.exec(), startLine) } } next: if readErr != nil { if readErr != io.EOF { fmt.Fprintln(os.Stderr, "gosh:", readErr) return 1 } if buf.Len() > 0 { lastStatus = 2 report(errors.New("syntax error: unexpected end of file"), startLine) } return lastStatus } } } // readLine reads a single line from r without the trailing newline. It reads // byte by byte so that nothing after the line is consumed: when the script // comes from stdin, the commands it starts read the rest of stdin (like in // sh, "printf 'cat\nhello\n' | gosh" prints hello). Pass a bufio.Reader if // that does not matter. func readLine(r io.Reader) (string, error) { var b strings.Builder buf := make([]byte, 1) for { n, err := r.Read(buf) if n > 0 { if buf[0] == '\n' { return b.String(), nil } b.WriteByte(buf[0]) } if err != nil { return b.String(), err } } } // exitStatus converts the result of execInput into a shell exit status: the // exit code of the command, 128+n if it was killed by signal n, 127 if it was // not found, 126 if it could not be executed and 1 for other errors. func exitStatus(err error) int { if err == nil { return 0 } var statusErr statusError if errors.As(err, &statusErr) { return int(statusErr) } var ( synErr syntaxError lc loopControl ) if errors.As(err, &synErr) || errors.Is(err, errIncomplete) { return 2 } if errors.As(err, &lc) { return 0 } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { return 128 + int(ws.Signal()) } return exitErr.ExitCode() } if errors.Is(err, errCommandNotFound) || errors.Is(err, fs.ErrNotExist) { return 127 } if errors.Is(err, fs.ErrPermission) { return 126 } return 1 } // interactive runs the interactive shell with line editing, history and // completion until the user exits. func interactive() { interactiveShell = true initJobControl() defer releaseTerminal() // 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: histFile, DisableAutoSaveHistory: true, AutoComplete: newShellCompleter(), InterruptPrompt: "^C", EOFPrompt: "exit", }) if err != nil { fmt.Fprintln(os.Stderr, "Error initializing readline:", err) os.Exit(1) } defer l.Close() // Main loop. A command that is not complete yet (e.g. if without fi) // is continued on the next line with the prompt "> ". var buf strings.Builder for { if buf.Len() == 0 { // Report finished and stopped background jobs reapJobs(true) // Update prompt dynamically (in case directory changed) l.SetPrompt(getPrompt()) } else { l.SetPrompt("> ") } line, err := l.Readline() if err != nil { // Ctrl+C discards the current line (and an incomplete command) if err == readline.ErrInterrupt { buf.Reset() continue } // Handle EOF (Ctrl+D) if err == io.EOF { fmt.Println() if buf.Len() > 0 { buf.Reset() lastStatus = 2 fmt.Fprintln(os.Stderr, "gosh: syntax error: unexpected end of file") continue } if hasStoppedJobs() && !exitWarned { exitWarned = true fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs") continue } break } fmt.Fprintln(os.Stderr, "Error reading input:", err) continue } // Skip empty input if buf.Len() == 0 && strings.TrimSpace(line) == "" { continue } // 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) } buf.WriteString(line) buf.WriteByte('\n') tree, err := parse(buf.String()) if errors.Is(err, errIncomplete) { continue } buf.Reset() if err != nil { lastStatus = exitStatus(err) printError(err) continue } printError(tree.exec()) } } // lastStatus is the exit status of the last command ($?). var lastStatus int // statusError is an error that only carries an exit status; the problem has // already been reported. type statusError int func (e statusError) Error() string { return "exit status " + strconv.Itoa(int(e)) } // syntaxError is a syntax error in the input (exit status 2, like sh). type syntaxError string func (e syntaxError) Error() string { return "syntax error near unexpected token `" + string(e) + "'" } // errCommandNotFound is returned when an external command does not exist. var errCommandNotFound = errors.New("command not found") // printError prints an error of a command to stderr. Commands that ran and // failed have already reported the problem themselves, only their exit // status is kept. func printError(err error) { var ( exitErr *exec.ExitError statusErr statusError lc loopControl ) if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) { return } fmt.Fprintln(os.Stderr, "gosh:", err) } // execInput parses and executes input: a command line or a whole script. // It returns the error of the last command that was run; errors of earlier // commands are printed. $? is updated after each command. func execInput(input string) error { tree, err := parse(input) if err != nil { lastStatus = exitStatus(err) return err } return tree.exec() } // execCommand executes a pipeline of simple commands with redirections, in // the background if background is set. func execCommand(input string, background bool) error { // Brace expansion comes first, so that its results are expanded further substStatus = 0 input, err := expandBraceWords(input) if err != nil { return err } // Split the input into words and operators, expanding variables and ~ on // the way. tokens, err := tokenize(input) var ( synErr syntaxError statusErr statusError ) if errors.As(err, &synErr) || errors.As(err, &statusErr) { return err } if err != nil { return fmt.Errorf("invalid input: %w", err) } stages, err := parsePipeline(tokens) if err != nil { return err } if len(stages) == 0 { // A command that consists only of $(...) has its status if substStatus != 0 { return statusError(substStatus) } return nil } // "exit" with stopped jobs only warns, unless it is repeated right away if name := stages[0].args; len(name) == 0 || (name[0] != "exit" && name[0] != "quit") { exitWarned = false } return runPipeline(stages, strings.TrimSpace(input), background) } // redirect is a redirection of a simple command, e.g. 2>>file or 2>&1. type redirect struct { op string // <, >, >>, &>, &>>, >& or <& fd int // the redirected file descriptor dup int // the target file descriptor of >& and <& file string // the file name for all other operators } // stage is a simple command of a pipeline. type stage struct { assigns []string // name=value before the command name args []string redirects []redirect } // parsePipeline groups tokens into the stages of a pipeline. func parsePipeline(tokens []token) ([]stage, error) { var ( stages []stage cur stage ) isEmpty := func(s stage) bool { return len(s.assigns) == 0 && len(s.args) == 0 && len(s.redirects) == 0 } for i := 0; i < len(tokens); i++ { t := tokens[i] switch t.op { case "": if t.assign && len(cur.args) == 0 { // The value is not split and not expanded as a pattern cur.assigns = append(cur.assigns, t.word) } else { cur.args = append(cur.args, expandWord(t)...) } case "|": if isEmpty(cur) { return nil, syntaxError("|") } stages = append(stages, cur) cur = stage{} case ">&", "<&": cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, dup: t.dup}) default: if i+1 == len(tokens) { return nil, syntaxError("newline") } if next := tokens[i+1]; next.op != "" { return nil, syntaxError(next.op) } i++ files := expandWord(tokens[i]) if len(files) > 1 { return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word)) } cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: files[0]}) } } if isEmpty(cur) { if len(stages) > 0 { return nil, syntaxError("|") } return nil, nil } return append(stages, cur), nil } // runPipeline runs the stages of a pipeline concurrently as a job, // connecting the stdout of each stage to the stdin of the next one. In the // foreground it waits for the job and returns the error of the last stage (so // $? is its exit status, like in sh); errors of the other stages are printed. // In the background it adds the job to the job table and returns nil. func runPipeline(stages []stage, text string, background bool) error { j := &job{text: text} if jobControl && !background { shellTTY, _ = readline.GetState(ttyFd) } stdin := os.Stdin if background && !jobControl { // Without job control background jobs must not read from the // terminal, like in sh if devNull, err := os.Open(os.DevNull); err == nil { stdin = devNull } } var startErr error for i, st := range stages { stdout := os.Stdout var next *os.File if i < len(stages)-1 { r, w, err := os.Pipe() if err != nil { if stdin != os.Stdin { stdin.Close() } startErr = err break } stdout, next = w, r } j.procs = append(j.procs, startStage(st, stdin, stdout, j, len(stages) > 1, background)) stdin = next } if startErr != nil { // Let the stages that already run finish if len(j.procs) > 0 { waitForeground(j) } return startErr } if background { addJob(j) if interactiveShell { pid := 0 for _, p := range j.procs { if p.cmd != nil { pid = p.cmd.Process.Pid } } fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid) } return nil } return waitForeground(j) } // startStage starts a stage of the pipeline of job j with the given stdin and // stdout. Pipe ends passed in are closed by startStage once the stage no // longer needs them, so that the neighbors see EOF or get SIGPIPE. func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc { var owned []*os.File if stdin != os.Stdin { owned = append(owned, stdin) } if stdout != os.Stdout { owned = append(owned, stdout) } closeOwned := func() { for _, f := range owned { f.Close() } } // Connect the command to the terminal unless redirected. Without Stdin // the command would read from /dev/null, so interactive programs (su, // vim, ssh, ...) would get EOF right away. readline only reads from stdin // while a line is being edited, so it does not steal input. fds := [3]*os.File{stdin, stdout, os.Stderr} opened, err := applyRedirects(&fds, st.redirects) owned = append(owned, opened...) if err == nil && len(st.args) == 0 && len(st.assigns) > 0 { // Assignments without a command set variables of the shell (not in // a pipeline or in the background, where sh runs them in a // subshell). The status is the one of the last $(...) in them. if !inPipeline && !background { for _, a := range st.assigns { name, value, _ := strings.Cut(a, "=") os.Setenv(name, value) } } if substStatus != 0 { err = statusError(substStatus) } } if err != nil || len(st.args) == 0 { // A command without arguments only creates or truncates the files closeOwned() return finishedProc(err) } if builtin, ok := builtins[st.args[0]]; ok { switch { case inPipeline && builtin.changesState: closeOwned() return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0])) case background && builtin.changesState: closeOwned() return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0])) } run := func() error { defer closeOwned() err := runBuiltin(st.args, fds[1]) if err != nil && fds[2] != os.Stderr { // Report the error where stderr is redirected to fmt.Fprintln(fds[2], "gosh:", err) return statusError(1) } return err } if !inPipeline && !background { // name=value before a builtin applies only while it runs defer setTemporarily(st.assigns)() return finishedProc(run()) } p := &proc{done: make(chan error, 1)} go func() { p.done <- run() }() return p } cmd := exec.Command(st.args[0], st.args[1:]...) cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2] if len(st.assigns) > 0 { // name=value before a command is only in its environment (the // last value of a name wins) cmd.Env = append(os.Environ(), st.assigns...) } if jobControl { // All processes of the job share a process group: the one of the // first process. In the foreground, the first process makes its // group the foreground group of the terminal before it starts the // program, so that it can use the terminal right away. attr := &syscall.SysProcAttr{Setpgid: true, Pgid: j.pgid} if j.pgid == 0 && !background { if ctty := ttyIndex(fds); ctty >= 0 { attr.Foreground, attr.Ctty = true, ctty } } cmd.SysProcAttr = attr } err = cmd.Start() // The child has its own copies of the files now closeOwned() if errors.Is(err, exec.ErrNotFound) { err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound) } if err != nil { return finishedProc(err) } if jobControl && j.pgid == 0 { j.pgid = cmd.Process.Pid if !background { // In case the process could not do it (no terminal in fds) setForeground(j.pgid) } } return &proc{cmd: cmd} } // setTemporarily sets the variables of the assignments and returns a // function that restores the previous values. func setTemporarily(assigns []string) func() { type saved struct { name, value string ok bool } var restore []saved for _, a := range assigns { name, value, _ := strings.Cut(a, "=") old, ok := os.LookupEnv(name) restore = append(restore, saved{name, old, ok}) os.Setenv(name, value) } return func() { for i := len(restore) - 1; i >= 0; i-- { if r := restore[i]; r.ok { os.Setenv(r.name, r.value) } else { os.Unsetenv(r.name) } } } } // applyRedirects applies the redirections in order to fds (stdin, stdout, // stderr) and returns the files it opened. func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) { var opened []*os.File fail := func(err error) ([]*os.File, error) { for _, f := range opened { f.Close() } return nil, err } for _, r := range redirects { if r.fd > 2 { return fail(fmt.Errorf("%d: bad file descriptor", r.fd)) } if r.op == ">&" || r.op == "<&" { if r.dup > 2 { return fail(fmt.Errorf("%d: bad file descriptor", r.dup)) } fds[r.fd] = fds[r.dup] continue } flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC switch r.op { case "<": flags = os.O_RDONLY case ">>", "&>>": flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND } f, err := os.OpenFile(r.file, flags, 0666) if err != nil { // "file: No such file or directory" with exit status 1, like sh var pathErr *fs.PathError if errors.As(err, &pathErr) { err = fmt.Errorf("%s: %v", sanitize(r.file), pathErr.Err) } return fail(err) } opened = append(opened, f) fds[r.fd] = f if r.op == "&>" || r.op == "&>>" { fds[2] = f } } return opened, nil } // builtins lists the built-in commands. Commands that change the state of // the shell cannot be used in a pipeline, since there they would run // concurrently (sh runs them in a subshell where they have no effect). var builtins = map[string]struct{ changesState bool }{ "cd": {changesState: true}, "pwd": {}, "exit": {changesState: true}, "quit": {changesState: true}, "set": {changesState: true}, "export": {changesState: true}, "help": {}, "source": {changesState: true}, ".": {changesState: true}, "jobs": {}, "fg": {changesState: true}, "bg": {changesState: true}, "wait": {changesState: true}, "alias": {}, "unalias": {changesState: true}, ":": {}, "unset": {changesState: true}, "break": {changesState: true}, "continue": {changesState: true}, } // runBuiltin executes a built-in command, writing its output to stdout. func runBuiltin(args []string, stdout io.Writer) error { switch args[0] { case "cd": // Save current directory before changing (for 'cd -') currentDir, _ := os.Getwd() // Handle different cd cases if len(args) < 2 { // cd with no args: go to home directory previousDir = currentDir return os.Chdir(getHomeDir()) } path := args[1] // Handle 'cd -' : go to previous directory if path == "-" { if previousDir == "" { return fmt.Errorf("no previous directory") } // Save current directory as the new previous // so that next 'cd -' will come back here prevDir := previousDir previousDir = currentDir return os.Chdir(prevDir) } // Save current directory as previous before changing previousDir = currentDir // Change the directory and return the error. return os.Chdir(path) case "pwd": wd, err := os.Getwd() fmt.Fprintln(stdout, wd) return err case "exit": // exit with optional status code if len(args) > 1 { var exitCode int fmt.Sscanf(args[1], "%d", &exitCode) return exitShell(exitCode) } return exitShell(lastStatus) case "quit": // quit with optional status code if len(args) > 1 { var exitCode int fmt.Sscanf(args[1], "%d", &exitCode) return exitShell(exitCode) } return exitShell(lastStatus) case "set": // set VAR=value - set environment variable if len(args) < 2 { return fmt.Errorf("usage: set VAR=value") } // Parse VAR=value parts := strings.SplitN(args[1], "=", 2) if len(parts) != 2 { return fmt.Errorf("usage: set VAR=value") } os.Setenv(parts[0], parts[1]) return nil case "export": // export VAR=value - same as set but also marks for child processes if len(args) < 2 { return fmt.Errorf("usage: export VAR=value") } // Parse VAR=value parts := strings.SplitN(args[1], "=", 2) if len(parts) != 2 { return fmt.Errorf("usage: export VAR=value") } os.Setenv(parts[0], parts[1]) return nil case ":": // Does nothing, successfully (e.g. while :; do ...) return nil case "unset": // unset NAME ... - remove variables if len(args) < 2 { return fmt.Errorf("usage: unset NAME ...") } var err error for _, name := range args[1:] { if !isVarName(name) { err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name)) continue } os.Unsetenv(name) } return err case "break", "continue": return builtinLoopControl(args) case "alias": return builtinAlias(args, stdout) case "unalias": return builtinUnalias(args) case "jobs": return builtinJobs(stdout) case "fg": return builtinFg(args, stdout) case "bg": return builtinBg(args, stdout) case "wait": return builtinWait(args) case "source", ".": // source file - run the commands of file in the current shell if len(args) < 2 { return fmt.Errorf("usage: %s file", args[0]) } return sourceFile(args[0], args[1]) case "help": fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + ` Built-in commands: cd [dir] - Change directory (cd with no args goes to home) pwd - Print working directory echo [args] - Echo arguments exit [code] - Exit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable export VAR=val - Export environment variable unset VAR - Remove a variable source file - Run the commands of file in this shell (also: . file) jobs - List background and stopped jobs fg [%n] - Continue job n (default: current job) in the foreground bg [%n] - Continue stopped job n in the background wait [%n] - Wait for job n or all background jobs alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') unalias name - Remove an alias (unalias -a: all aliases) : - Do nothing (exit status 0) break [n] - Leave the innermost (or n-th) for/while/until loop continue [n] - Continue with the next iteration of a loop help - Show this help Features: - 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) at the start of unquoted words - Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e} - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files unless the pattern starts with a dot); quoted wildcards and values of variables are not expanded, a pattern without match is kept as is - Prompt user@host:dir $, with [N] before the $ if the last command failed with exit status N - Colored prompt (user@host green, red for root; directory blue; exit status red), configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, TERM=dumb or when output is not a terminal - Variables: name=value sets a variable (like set), name=value command sets it only for that command; the value is not split or expanded as a pattern - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default - Command substitution: $(command) is replaced by the output of command (run in a subshell); unquoted it is split into words, "$(command)" stays one word - Command lists: a ; b (run both), a && b (b only if a succeeded), a || b (b only if a failed); $? is the exit status of the last command - Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi, for x in words; do ...; done, while cmd; do ...; done, until ..., ! cmd negates the status; they can span several lines (prompt "> ") - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Job control: command & runs in the background, Ctrl+Z stops the foreground job; jobs, fg, bg and wait manage the jobs - Aliases replace the first word of a command in interactive shells (define them in ~/.goshrc); \name or 'name' skips the alias - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command - Startup files: login shells (-l or argv[0] starting with -) read /etc/gosh/profile and ~/.gosh_profile, interactive shells read /etc/gosh/goshrc and ~/.goshrc (skip with --noprofile, --norc)`) return nil } return fmt.Errorf("%s: not a builtin", args[0]) }