From 4b44cc6e7258116c1d181d4cc4c4d9c873772b32 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 23:08:23 +0200 Subject: [PATCH] add startup files and the source builtin - login shells (-l or argv[0] starting with -) read /etc/gosh/profile and ~/.gosh_profile, interactive shells /etc/gosh/goshrc and ~/.goshrc; --noprofile and --norc skip them - source file / . file runs a file in the current shell, nesting is limited to stop files that source themselves - errors in startup files and scripts are reported with file and line - ~ and the startup files use $HOME (like sh), the user database is only the fallback Co-Authored-By: Claude Opus 5.5 --- README.md | 48 +++++++++++++++- main.go | 151 +++++++++++++++++++++++++++++++++++++++++++-------- main_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 304 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c105b9c..25559ba 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Built-in commands: quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable export VAR=val - Export environment variable + source file - Run the commands of file in this shell (also: . file) help - Show this help Features: @@ -38,14 +39,17 @@ Features: 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - 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) ``` ### Aufruf ``` -gosh [-l] [-i] [script] -gosh [-l] -c command +gosh [-l] [-i] [--norc] [--noprofile] [script] +gosh [-l] [--noprofile] -c command gosh -version ``` @@ -56,12 +60,50 @@ gosh -version | `gosh skript.gosh` | führt das Skript Zeile für Zeile aus | | `befehle \| gosh` | liest die Befehle von stdin | | `-i` | erzwingt den interaktiven Modus, auch ohne Terminal | -| `-l`, `--login` | Login-Shell (wird akzeptiert, eine Startdatei gibt es noch nicht) | +| `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) | +| `--norc` | `goshrc`-Dateien nicht lesen | +| `--noprofile` | `profile`-Dateien nicht lesen | Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen. Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Befehle lassen sich mit `;`, `&&` und `||` verketten und mit Pipes verbinden, z. B. `ssh host 'cd /srv && git log | head'`. +### Startdateien + +Beim Start liest gosh Startdateien und führt sie wie ein Skript in der laufenden Shell aus (Variablen, die dort gesetzt werden, bleiben also erhalten): + +| Shell | Dateien (in dieser Reihenfolge) | abschalten mit | +|-------------------------------|----------------------------------------------|-----------------| +| Login-Shell | `/etc/gosh/profile`, `~/.gosh_profile` | `--noprofile` | +| interaktive Shell | `/etc/gosh/goshrc`, `~/.goshrc` | `--norc` | + +- Eine Login-Shell ist gosh mit `-l` oder wenn `argv[0]` mit `-` beginnt (so starten `login`, `sshd` und `su -` die Shell). Eine interaktive Login-Shell liest erst die Profil-, dann die `goshrc`-Dateien. +- `gosh -c '…'` und Skripte lesen keine Startdateien, außer als Login-Shell (`gosh -l -c '…'`). +- Fehlende Dateien werden übersprungen. Fehler werden mit Datei und Zeile gemeldet (`gosh: ~/.goshrc:4: foo: command not found`), die übrigen Zeilen laufen trotzdem. +- Bash-Dateien wie `/etc/profile` oder `~/.bashrc` liest gosh bewusst nicht, weil es deren Syntax (`if`, Funktionen, …) nicht versteht. +- Mit `source datei` (oder `. datei`) lässt sich eine Datei auch später einlesen, z. B. nach einer Änderung: `source ~/.goshrc`. + +Beispiel für `~/.goshrc`: + +```bash +# Eigene Programme zuerst +export PATH=$HOME/bin:$HOME/go/bin:$PATH +export EDITOR=vim + +# Prompt-Farben (Anführungszeichen wegen des ;) +export GOSH_COLORS='user=1;38;5;208:path=38;5;205' + +# Weitere Einstellungen nur, wenn die Datei existiert +test -f ~/.goshrc.local && source ~/.goshrc.local +``` + +Beispiel für `~/.gosh_profile` (wird nur von Login-Shells gelesen, z. B. beim Anmelden an der Konsole oder per `ssh host` ohne Befehl): + +```bash +export LANG=de_DE.UTF-8 +export PATH=$HOME/bin:$PATH +``` + ### Befehlslisten und Exit-Status Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft: diff --git a/main.go b/main.go index 5a3892e..6ec590a 100644 --- a/main.go +++ b/main.go @@ -74,12 +74,15 @@ func versionInfo() string { // previousDir tracks the previous working directory for 'cd -' support var previousDir string -// getHomeDir returns the user's home directory +// 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 { - // Fallback to HOME environment variable - return os.Getenv("HOME") + return "" } return usr.HomeDir } @@ -699,7 +702,7 @@ type shellCompleter struct { func newShellCompleter() *shellCompleter { return &shellCompleter{ - commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "help"}, + commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "help"}, } } @@ -866,33 +869,48 @@ func completeFilePath(line, prefix string) []string { return matches } -const usage = `usage: gosh [-l] [-i] [script] - gosh [-l] -c command +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() { - os.Exit(run(os.Args[1:])) + 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 { - // -l/--login is accepted for compatibility (e.g. "su -l", login(1)), but - // gosh does not read a startup file yet. -i forces interactive mode even - // if stdin is not a terminal. - forceInteractive := false + 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": @@ -909,13 +927,13 @@ flags: fmt.Fprintln(os.Stderr, usage) return 2 } - return runScript(strings.NewReader(args[1])) + 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 == "-": - return runScript(os.Stdin) + script = func() int { return runScript(os.Stdin, "") } default: f, err := os.Open(arg) if err != nil { @@ -923,30 +941,107 @@ flags: return 127 } defer f.Close() - return runScript(bufio.NewReader(f)) + 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, "") } } - // Without a terminal (e.g. "echo ls | gosh") read the commands from stdin. - if !forceInteractive && !readline.IsTerminal(int(os.Stdin.Fd())) { - return runScript(os.Stdin) + 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 line by line. Like sh, it does not // stop at a failing command and returns the exit status of the last one. // Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are -// skipped. -func runScript(r io.Reader) int { +// skipped. If name is set, errors are reported with file name and line. +func runScript(r io.Reader, name string) int { status := 0 - for { + for lineNo := 1; ; lineNo++ { line, err := readLine(r) if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { - printError(execInput(line)) + cmdErr := execInput(line) status = lastStatus + if name != "" && cmdErr != nil { + cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr) + } + printError(cmdErr) } if err != nil { if err != io.EOF { @@ -1476,6 +1571,8 @@ var builtins = map[string]struct{ changesState bool }{ "set": {changesState: true}, "export": {changesState: true}, "help": {}, + "source": {changesState: true}, + ".": {changesState: true}, } // runBuiltin executes a built-in command, writing its output to stdout. @@ -1555,6 +1652,12 @@ func runBuiltin(args []string, stdout io.Writer) error { } os.Setenv(parts[0], parts[1]) return nil + 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() + ` @@ -1566,6 +1669,7 @@ Built-in commands: quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable export VAR=val - Export environment variable + source file - Run the commands of file in this shell (also: . file) help - Show this help Features: @@ -1591,7 +1695,10 @@ Features: - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Non-interactive use: gosh -c 'command', gosh script, command | gosh; - the exit status is the one of the last command`) + 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]) diff --git a/main_test.go b/main_test.go index 7a89fb4..a832100 100644 --- a/main_test.go +++ b/main_test.go @@ -889,6 +889,9 @@ func TestMain(m *testing.M) { panic(err) } os.Setenv("GOSH_HISTFILE", filepath.Join(dir, "history")) + // Never read the system's startup files; tests that read the user's + // files set $HOME themselves + sysConfDir = filepath.Join(dir, "etc") code := m.Run() os.RemoveAll(dir) os.Exit(code) @@ -1183,7 +1186,7 @@ func TestShell_History(t *testing.T) { histFile := filepath.Join(t.TempDir(), "gosh", "history") workDir := t.TempDir() - cmd := exec.Command(bin, "-i") + cmd := exec.Command(bin, "-i", "--norc") cmd.Dir = workDir cmd.Env = append(os.Environ(), "GOSH_HISTFILE="+histFile) cmd.Stdin = strings.NewReader("echo first\n set SECRET=hunter2\nexit\n") @@ -1215,7 +1218,7 @@ func TestShell_History(t *testing.T) { func TestShell_SIGINTKillsOnlyChild(t *testing.T) { bin := buildShell(t) - cmd := exec.Command(bin, "-i") + cmd := exec.Command(bin, "-i", "--norc") // Own process group, so that we can signal it like the terminal does cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} stdin, err := cmd.StdinPipe() @@ -1313,7 +1316,7 @@ func TestRunScript(t *testing.T) { "touch first\n" + "false\n" + "touch second" // no trailing newline - if got := runScript(strings.NewReader(script)); got != 0 { + if got := runScript(strings.NewReader(script), ""); got != 0 { t.Errorf("runScript() = %d, want 0", got) } for _, name := range []string{"first", "second"} { @@ -1323,7 +1326,7 @@ func TestRunScript(t *testing.T) { } // The status of the last command is returned - if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n")); got != 3 { + if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n"), ""); got != 3 { t.Errorf("runScript() = %d, want 3", got) } } @@ -1335,7 +1338,7 @@ func TestRun_Options(t *testing.T) { }{ {[]string{"-c", "true"}, 0}, {[]string{"-c", "false"}, 1}, - {[]string{"-l", "-c", "sh -c 'exit 5'"}, 5}, + {[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5}, {[]string{"-c"}, 2}, {[]string{"-x"}, 2}, {[]string{"/gosh/no/such/script"}, 127}, @@ -1730,3 +1733,125 @@ func TestExecInput_Wildcards(t *testing.T) { t.Errorf("expected ambiguous redirect error, got %v", err) } } + +func TestStartupFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sys := sysConfDir + + tests := []struct { + login, interactive bool + want []string + }{ + {false, false, nil}, + {true, false, []string{sys + "/profile", home + "/.gosh_profile"}}, + {false, true, []string{sys + "/goshrc", home + "/.goshrc"}}, + {true, true, []string{sys + "/profile", home + "/.gosh_profile", sys + "/goshrc", home + "/.goshrc"}}, + } + for _, tc := range tests { + if got := startupFiles(tc.login, tc.interactive); !reflect.DeepEqual(got, tc.want) { + t.Errorf("startupFiles(%v, %v) = %q, want %q", tc.login, tc.interactive, got, tc.want) + } + } +} + +func TestRun_LoginReadsProfile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.Unsetenv("GOSH_TEST_PROFILE") + defer os.Unsetenv("GOSH_TEST_PROFILE") + os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=yes\n"), 0644) + // .goshrc is only read by interactive shells + os.WriteFile(filepath.Join(home, ".goshrc"), []byte("exit 99\n"), 0644) + + check := `sh -c 'test "$GOSH_TEST_PROFILE" = yes'` + if got := run([]string{"-c", check}); got != 1 { + t.Errorf("non-login shell: status %d, want 1 (profile must not be read)", got) + } + if got := run([]string{"-l", "--noprofile", "-c", check}); got != 1 { + t.Errorf("--noprofile: status %d, want 1 (profile must not be read)", got) + } + if got := run([]string{"-l", "-c", check}); got != 0 { + t.Errorf("login shell: status %d, want 0 (profile must be read)", got) + } +} + +func TestShell_InteractiveReadsRC(t *testing.T) { + bin := buildShell(t) + home := t.TempDir() + os.WriteFile(filepath.Join(home, ".goshrc"), []byte("export GOSH_TEST_RC=from-rc\n"), 0644) + os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=from-profile\n"), 0644) + + tests := []struct { + args []string + want string + }{ + {[]string{"-i"}, "from-rc -\n"}, + {[]string{"-i", "--norc"}, "- -\n"}, + {[]string{"-i", "-l"}, "from-rc from-profile\n"}, + {[]string{"-i", "-l", "--norc", "--noprofile"}, "- -\n"}, + } + for _, tc := range tests { + cmd := exec.Command(bin, tc.args...) + cmd.Env = append(os.Environ(), "HOME="+home) + cmd.Stdin = strings.NewReader("echo ${GOSH_TEST_RC:--} ${GOSH_TEST_PROFILE:--}\nexit\n") + out, err := cmd.Output() + if err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + if !strings.HasSuffix(string(out), tc.want) { + t.Errorf("%v: output %q, want suffix %q", tc.args, out, tc.want) + } + } +} + +func TestExecInput_Source(t *testing.T) { + dir := t.TempDir() + os.Unsetenv("GOSH_TEST_SOURCE") + defer os.Unsetenv("GOSH_TEST_SOURCE") + + file := filepath.Join(dir, "vars") + os.WriteFile(file, []byte("# comment\nexport GOSH_TEST_SOURCE=hello\nsh -c 'exit 4'\n"), 0644) + + for _, cmd := range []string{"source", "."} { + os.Unsetenv("GOSH_TEST_SOURCE") + execInput(cmd + " " + file) + if os.Getenv("GOSH_TEST_SOURCE") != "hello" { + t.Errorf("%s: variable was not set in the current shell", cmd) + } + if lastStatus != 4 { + t.Errorf("%s: $? = %d, want 4 (status of the last command)", cmd, lastStatus) + } + } + + err := execInput("source " + filepath.Join(dir, "missing")) + if err == nil || !strings.Contains(err.Error(), "source: "+dir+"/missing: no such file or directory") { + t.Errorf("unexpected error for missing file: %v", err) + } + if lastStatus != 1 { + t.Errorf("$? = %d for missing file, want 1", lastStatus) + } + + // A file that sources itself is stopped + loop := filepath.Join(dir, "loop") + os.WriteFile(loop, []byte("source "+loop+"\n"), 0644) + if err := execInput("source " + loop); err == nil { + t.Error("expected an error for a file that sources itself") + } +} + +func TestGetHomeDir_UsesHOME(t *testing.T) { + t.Setenv("HOME", "/gosh/test/home") + if got := getHomeDir(); got != "/gosh/test/home" { + t.Errorf("getHomeDir() = %q, want $HOME", got) + } + if words, _ := splitWords("echo ~/x"); words[1] != "/gosh/test/home/x" { + t.Errorf("~/x = %q, want it based on $HOME", words[1]) + } + + // Without $HOME the user database is used + t.Setenv("HOME", "") + if got := getHomeDir(); got == "" { + t.Error("getHomeDir() is empty without $HOME") + } +}