Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8653c9f5 | ||
|
|
82f9c90a39 | ||
|
|
af5281eed2 |
No files matched your search
@@ -59,6 +59,13 @@ Features:
|
||||
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
|
||||
- Custom prompt with $PS1 (and $PS2 for continuation lines, default
|
||||
"> ") using the escapes of bash: \u user, \h/\H host, \w/\W directory,
|
||||
\$ (# for root), \t \T \@ \A \d time/date, \j jobs, \n newline,
|
||||
\e escape, \[ \]; $VAR, $(...) and $((...)) are expanded, e.g.
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\w\$ '
|
||||
- Reports the working directory to the terminal (OSC 7) before each
|
||||
prompt, so new tabs/windows open in the same directory
|
||||
- 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
|
||||
@@ -723,7 +730,7 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
|
||||
|
||||
### Prompt
|
||||
|
||||
Der Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
|
||||
Der Standard-Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
|
||||
|
||||
```
|
||||
flow@host:~/src $ false
|
||||
@@ -739,7 +746,7 @@ Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführba
|
||||
|
||||
### Farben
|
||||
|
||||
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
|
||||
Der Standard-Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt (für einen eigenen Prompt mit `PS1` gilt sie nicht, siehe unten). Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
|
||||
|
||||
| Schlüssel | Teil des Prompts | Standard |
|
||||
|-----------|--------------------------------|---------------------|
|
||||
@@ -794,6 +801,42 @@ GOSH_COLORS='root=1;97;41'
|
||||
GOSH_COLORS='user=:path=4'
|
||||
```
|
||||
|
||||
### Eigener Prompt: PS1 und PS2
|
||||
|
||||
Wie in bash lässt sich der Prompt mit der Variablen `PS1` frei gestalten, am besten in `~/.goshrc`. Ist `PS1` nicht gesetzt, gilt der Standard-Prompt von oben; `unset PS1` holt ihn zurück. `PS2` ist der Prompt für Folgezeilen unvollständiger Befehle (Standard `> `).
|
||||
|
||||
```bash
|
||||
PS1='\u@\h:\w\$ ' # wie der Standard, ohne Farben
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ ' # mit Farben
|
||||
PS1='\[\e[2m\]\t\[\e[0m\] \W \$ ' # Uhrzeit und nur der Verzeichnisname
|
||||
PS1='[$?] \w\n\$ ' # Exit-Status, Eingabe in eigener Zeile
|
||||
PS1='\w$(git branch --show-current 2>/dev/null | sed "s/.*/ (&)/") \$ ' # git-Branch
|
||||
PS2='… '
|
||||
```
|
||||
|
||||
| Escape | Bedeutung |
|
||||
|---------------|-------------------------------------------------------------|
|
||||
| `\u` | Benutzername |
|
||||
| `\h` / `\H` | Rechnername bis zum ersten `.` / vollständig |
|
||||
| `\w` / `\W` | aktuelles Verzeichnis (`~` für das Home-Verzeichnis) / nur der letzte Teil |
|
||||
| `\$` | `#` für root, sonst `$` |
|
||||
| `\t` `\T` `\@` `\A` | Uhrzeit: `14:07:09`, `02:07:09`, `02:07 PM`, `14:07` |
|
||||
| `\d` | Datum, z. B. `Mon Oct 05` |
|
||||
| `\j` | Anzahl der Jobs |
|
||||
| `\l` | Terminal (z. B. `pts/3`) |
|
||||
| `\s` / `\v` / `\V` | `gosh` / Version `0.19` / `0.19.0` |
|
||||
| `\n` `\r` `\a` `\e` | Zeilenumbruch, Wagenrücklauf, Klingel, Escape |
|
||||
| `\nnn` | Zeichen mit dem Oktalcode `nnn`, z. B. `\033` |
|
||||
| `\\` | Backslash |
|
||||
| `\[` `\]` | umschließen Escape-Sequenzen (wie in bash, siehe unten) |
|
||||
|
||||
- Danach werden `$VAR`, `${...}`, `$(...)` und `$((...))` vor jedem Prompt neu ausgewertet. Deshalb `PS1` in einfachen Anführungszeichen setzen, sonst wird z. B. `$(…)` nur einmal beim Zuweisen ausgeführt. `$?` ist der Exit-Status des letzten Befehls; Befehle im Prompt ändern ihn nicht.
|
||||
- Eine nicht gesetzte Variable im Prompt ist ein Fehler (`gosh: PS1: X: unbound variable`), dann erscheint der Standard-Prompt. `${X:-}` vermeidet das.
|
||||
- Andere Escapes (z. B. `\D{…}`, `\!`, `\#`) kennt gosh nicht, sie bleiben unverändert stehen.
|
||||
- `\[` und `\]` braucht gosh nicht, sie werden ignoriert, damit Prompts aus der `.bashrc` unverändert funktionieren. gosh erkennt Farbsequenzen (`\e[…m`) selbst; andere Steuersequenzen wie der Fenstertitel `\e]0;titel\a` werden vor dem Prompt ausgegeben.
|
||||
- Die Werte von `\w`, `\u`, `\h` usw. und aller Expansionen werden wie der Standard-Prompt bereinigt: Steuerzeichen erscheinen escaped (`\x1b`). Ein Verzeichnis- oder Branch-Name kann so keine Escape-Sequenzen ins Terminal schreiben – Farben müssen deshalb direkt in `PS1` stehen, nicht in der Ausgabe von `$(...)`.
|
||||
- Wie alle Variablen in gosh ist `PS1` eine Umgebungsvariable und wird an gestartete Programme vererbt (eine bash darin versteht dieselben Escapes).
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -668,8 +668,13 @@ func prepareHistoryFile(path string) error {
|
||||
return f.Chmod(0600)
|
||||
}
|
||||
|
||||
// getPrompt returns a dynamic prompt string like "user@host:dir $"
|
||||
// getPrompt returns the expanded $PS1, or if it is not set the default
|
||||
// prompt "user@host:dir $".
|
||||
func getPrompt() string {
|
||||
if prompt, ok := promptString("PS1"); ok {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Get current working directory
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -690,10 +695,7 @@ func getPrompt() string {
|
||||
}
|
||||
|
||||
// Replace the home directory with ~
|
||||
home := getHomeDir()
|
||||
if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) {
|
||||
wd = "~" + strings.TrimPrefix(wd, home)
|
||||
}
|
||||
wd = tildePath(wd, getHomeDir())
|
||||
|
||||
var colors *promptColors
|
||||
if useColor() {
|
||||
@@ -795,6 +797,49 @@ func useColor() bool {
|
||||
return readline.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// reportCwd tells the terminal the current working directory with the OSC 7
|
||||
// escape sequence, so that new tabs or windows open in the same directory
|
||||
// (VTE terminals like GNOME Terminal, kitty, WezTerm, foot, iTerm2, ...).
|
||||
// Terminals without support ignore it. Nothing is written if stdout is not a
|
||||
// terminal or TERM is "dumb".
|
||||
func reportCwd() {
|
||||
if os.Getenv("TERM") == "dumb" || !readline.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hostname, _ := os.Hostname()
|
||||
fmt.Fprint(os.Stdout, osc7Sequence(hostname, wd))
|
||||
}
|
||||
|
||||
// osc7Sequence returns ESC ] 7 ; file://host/path BEL. Host and path are
|
||||
// percent-encoded, so control characters in a directory name cannot end the
|
||||
// sequence early or inject other escape sequences.
|
||||
func osc7Sequence(hostname, dir string) string {
|
||||
return "\x1b]7;file://" + percentEncode(hostname, false) + percentEncode(dir, true) + "\a"
|
||||
}
|
||||
|
||||
// percentEncode encodes every byte except unreserved URI characters (and '/'
|
||||
// if keepSlash) as %XX.
|
||||
func percentEncode(s string, keepSlash bool) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~' || c == '/' && keepSlash {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hex[c>>4])
|
||||
b.WriteByte(hex[c&15])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// shellCompleter implements readline.AutoCompleter for contextual completion
|
||||
type shellCompleter struct {
|
||||
commands []string
|
||||
@@ -918,11 +963,13 @@ func currentWord(s string) (word string, start int, quote byte) {
|
||||
return b.String(), start, quote
|
||||
}
|
||||
|
||||
// escapeWord escapes characters that would otherwise be interpreted by shlex
|
||||
// escapeWord escapes characters that would otherwise be interpreted by the
|
||||
// shell. # and ~ are only special at the start of a word, so they are only
|
||||
// escaped at the start of s.
|
||||
func escapeWord(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) {
|
||||
for i, r := range s {
|
||||
if strings.ContainsRune(" \t\\'\"`;|&<>()*?[]{}$!", r) || (i == 0 && (r == '#' || r == '~')) {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
@@ -1326,18 +1373,25 @@ func interactive() {
|
||||
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 "> ".
|
||||
// is continued on the next line with the prompt $PS2 (default "> ").
|
||||
var buf strings.Builder
|
||||
for {
|
||||
var prompt string
|
||||
if buf.Len() == 0 {
|
||||
runPendingTraps()
|
||||
// Report finished and stopped background jobs
|
||||
reapJobs(true)
|
||||
// Update prompt dynamically (in case directory changed)
|
||||
l.SetPrompt(getPrompt())
|
||||
reportCwd()
|
||||
prompt = getPrompt()
|
||||
} else if ps2, ok := promptString("PS2"); ok {
|
||||
prompt = ps2
|
||||
} else {
|
||||
l.SetPrompt("> ")
|
||||
prompt = "> "
|
||||
}
|
||||
prefix, prompt := splitPrompt(prompt)
|
||||
fmt.Print(prefix)
|
||||
l.SetPrompt(prompt)
|
||||
|
||||
line, err := l.Readline()
|
||||
if err != nil {
|
||||
@@ -2137,6 +2191,13 @@ Features:
|
||||
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
|
||||
- Custom prompt with $PS1 (and $PS2 for continuation lines, default
|
||||
"> ") using the escapes of bash: \u user, \h/\H host, \w/\W directory,
|
||||
\$ (# for root), \t \T \@ \A \d time/date, \j jobs, \n newline,
|
||||
\e escape, \[ \]; $VAR, $(...) and $((...)) are expanded, e.g.
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\w\$ '
|
||||
- Reports the working directory to the terminal (OSC 7) before each
|
||||
prompt, so new tabs/windows open in the same directory
|
||||
- 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
|
||||
|
||||
@@ -399,6 +399,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
|
||||
// =============================================================================
|
||||
|
||||
func TestGetPrompt_Format(t *testing.T) {
|
||||
unsetPS1(t)
|
||||
prompt := getPrompt()
|
||||
|
||||
// Should contain username@hostname:path $ pattern
|
||||
@@ -414,6 +415,7 @@ func TestGetPrompt_Format(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetPrompt_IncludesHome(t *testing.T) {
|
||||
unsetPS1(t)
|
||||
// Save and restore current directory
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
@@ -877,6 +879,43 @@ func TestShellCompleter_Do(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeWord(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"plain.txt", "plain.txt"},
|
||||
{"my file (1).txt", `my\ file\ \(1\).txt`},
|
||||
{"a`b`$c", "a\\`b\\`\\$c"},
|
||||
{"a;b|c&d<e>f", `a\;b\|c\&d\<e\>f`},
|
||||
{"*?[x]{y}!", `\*\?\[x\]\{y\}\!`},
|
||||
{`it's "q"\`, `it\'s\ \"q\"\\`},
|
||||
{"#a#b", `\#a#b`},
|
||||
{"~a~b", `\~a~b`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := escapeWord(tt.in); got != tt.want {
|
||||
t.Errorf("escapeWord(%q) = %q; want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCompleter_SpecialCharacters(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(t.TempDir())
|
||||
os.Create("song (live) & more.mp3")
|
||||
|
||||
got, length := completeLine("ls so")
|
||||
want := []string{`ng\ \(live\)\ \&\ more.mp3`}
|
||||
if !reflect.DeepEqual(got, want) || length != 2 {
|
||||
t.Errorf("Do(%q) = %q, %d; want %q, 2", "ls so", got, length, want)
|
||||
}
|
||||
// Inside quotes nothing is escaped
|
||||
got, _ = completeLine(`ls "so`)
|
||||
want = []string{"ng (live) & more.mp3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Do(%q) = %q; want %q", `ls "so`, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Security fixes: history file, quoting/expansion, escape sequences, SIGINT
|
||||
// =============================================================================
|
||||
@@ -1037,6 +1076,7 @@ func TestSanitize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
|
||||
unsetPS1(t)
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
@@ -1133,6 +1173,31 @@ func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSC7Sequence(t *testing.T) {
|
||||
tests := []struct{ host, dir, want string }{
|
||||
{"host", "/home/flow", "\x1b]7;file://host/home/flow\a"},
|
||||
{"host", "/tmp/a b/ä", "\x1b]7;file://host/tmp/a%20b/%C3%A4\a"},
|
||||
{"host", "/x%y#z?", "\x1b]7;file://host/x%25y%23z%3F\a"},
|
||||
{"", "/", "\x1b]7;file:///\a"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := osc7Sequence(tt.host, tt.dir); got != tt.want {
|
||||
t.Errorf("osc7Sequence(%q, %q) = %q, want %q", tt.host, tt.dir, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSC7Sequence_EscapesControlCharacters(t *testing.T) {
|
||||
got := osc7Sequence("ho/st\x07", "/evil\x07\x1b]0;title\x1b\\")
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]7;"), "\a")
|
||||
if strings.ContainsAny(body, "\x07\x1b") {
|
||||
t.Errorf("sequence contains control characters: %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(body, "file://ho%2Fst%07/evil%07%1B") {
|
||||
t.Errorf("unexpected encoding: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseColor_NoColor(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
if useColor() {
|
||||
|
||||
+73
-3
@@ -445,7 +445,9 @@ and 2 are supported.
|
||||
.PP
|
||||
Control structures may span several lines; an incomplete command shows
|
||||
the continuation prompt
|
||||
.BR "> " .
|
||||
.B $PS2
|
||||
(default
|
||||
.BR "> " ).
|
||||
.B !
|
||||
negates the exit status of a command.
|
||||
.B for
|
||||
@@ -731,7 +733,9 @@ Exit the shell.
|
||||
.PP
|
||||
Lines starting with a space are not saved in the history.
|
||||
.PP
|
||||
The prompt shows
|
||||
If
|
||||
.B PS1
|
||||
is not set, the prompt shows
|
||||
.IR user @ host : dir
|
||||
followed by
|
||||
.BR $ .
|
||||
@@ -739,6 +743,62 @@ If the last command failed, its exit status is shown as
|
||||
.BI [ N ]
|
||||
before the
|
||||
.BR $ .
|
||||
.PP
|
||||
Before each prompt, gosh reports the working directory to the terminal with
|
||||
the OSC 7 escape sequence
|
||||
.RB ( "ESC ] 7 ; file://" \fIhost\fP/\fIpath\fP " BEL" ),
|
||||
so terminals like GNOME Terminal open new tabs and windows in the same
|
||||
directory. Nothing is sent if the output is not a terminal or
|
||||
.BR TERM=dumb .
|
||||
.SS Custom prompts
|
||||
.B PS1
|
||||
and
|
||||
.B PS2
|
||||
are expanded before each prompt. They may contain the backslash escapes
|
||||
of
|
||||
.BR bash (1):
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
\fB\e\&u\fP user name
|
||||
\fB\e\&h\fP, \fB\e\&H\fP host name up to the first \(oq.\(cq, full host name
|
||||
\fB\e\&w\fP, \fB\e\&W\fP working directory (\fB~\fP for $HOME), its last component
|
||||
\fB\e\&$\fP \fB#\fP for root, otherwise \fB$\fP
|
||||
\fB\e\&t\fP, \fB\e\&T\fP time as HH:MM:SS (24 and 12 hours)
|
||||
\fB\e\&@\fP, \fB\e\&A\fP time as HH:MM AM/PM and HH:MM (24 hours)
|
||||
\fB\e\&d\fP date, e.g. Mon Oct 05
|
||||
\fB\e\&j\fP number of jobs
|
||||
\fB\e\&l\fP terminal name
|
||||
\fB\e\&s\fP, \fB\e\&v\fP, \fB\e\&V\fP shell name, version (major.minor), full version
|
||||
\fB\e\&n\fP, \fB\e\&r\fP newline, carriage return
|
||||
\fB\e\&a\fP, \fB\e\&e\fP bell, escape
|
||||
\fB\e\&\fP\fInnn\fP character with octal code \fInnn\fP
|
||||
\fB\e\e\fP backslash
|
||||
\fB\e[\fP, \fB\e]\fP ignored (needed by bash around escape sequences)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Unknown escapes are kept as they are. Then
|
||||
.BI $ VAR\fR,\fP
|
||||
.BR ${...} ,
|
||||
.B $(...)
|
||||
and
|
||||
.B $((...))
|
||||
are expanded;
|
||||
.B $?
|
||||
is the status of the last command. Control characters in the values of
|
||||
escapes and expansions are shown escaped, so colors must be written in
|
||||
the prompt itself, e.g.
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
PS1=\(aq\e[\ee[1;32m\e]\eu@\eh\e[\ee[0m\e]:\ew\e$ \(aq
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
If the prompt cannot be expanded (e.g. because of an unset variable), an
|
||||
error is printed and the default prompt is used. Escape sequences other
|
||||
than colors (e.g. the window title) are written before the prompt.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B GOSH_HISTFILE
|
||||
@@ -748,8 +808,18 @@ Path of the history file. If set to an empty value, no history is saved.
|
||||
Base directory of the default history file
|
||||
.IR $XDG_STATE_HOME/gosh/history .
|
||||
.TP
|
||||
.B PS1
|
||||
Prompt of the interactive shell, see
|
||||
.BR "Custom prompts" .
|
||||
.TP
|
||||
.B PS2
|
||||
Prompt for continuation lines (default
|
||||
.BR "> " ).
|
||||
.TP
|
||||
.B GOSH_COLORS
|
||||
Prompt colors as colon-separated
|
||||
Colors of the default prompt (if
|
||||
.B PS1
|
||||
is not set) as colon-separated
|
||||
.IB key = code
|
||||
entries. Keys are
|
||||
.B user
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
// Custom prompts with $PS1 and $PS2, using the backslash escapes of bash:
|
||||
//
|
||||
// \u user name \h host name up to the first '.' \H host name
|
||||
// \w working directory \W its last component (~ for home) \$ # for root, else $
|
||||
// \t time 24h HH:MM:SS \T 12h HH:MM:SS \@ 12h am/pm \A 24h HH:MM \d date
|
||||
// \j number of jobs \l terminal \s shell name \v \V version
|
||||
// \n newline \r carriage return \a bell \e escape \nnn octal \\ backslash
|
||||
// \[ ... \] begin/end of characters that take no space (escape sequences)
|
||||
//
|
||||
// After the escapes, $VAR, ${...}, $(...) and $((...)) are expanded (in the
|
||||
// same pass, so the values of escapes are not expanded again).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// promptInfo holds the values for the escapes of a prompt string.
|
||||
type promptInfo struct {
|
||||
user, host, wd, home string
|
||||
root bool
|
||||
jobs int
|
||||
tty string
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// currentPromptInfo collects the promptInfo of the running shell.
|
||||
func currentPromptInfo() promptInfo {
|
||||
info := promptInfo{user: "?", host: "unknown", wd: "?", home: getHomeDir(),
|
||||
root: os.Geteuid() == 0, jobs: len(jobTable), now: time.Now()}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
info.wd = wd
|
||||
}
|
||||
if usr, err := user.Current(); err == nil && usr.Username != "" {
|
||||
info.user = usr.Username
|
||||
}
|
||||
if host, err := os.Hostname(); err == nil {
|
||||
info.host = host
|
||||
}
|
||||
if tty, err := os.Readlink("/proc/self/fd/0"); err == nil && strings.HasPrefix(tty, "/dev/") {
|
||||
info.tty = filepath.Base(tty)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// expandPrompt expands the escapes and $ expressions of a PS1 or PS2 value.
|
||||
// The values of escapes like \w and of expansions are sanitized, so a
|
||||
// directory or git branch name cannot write escape sequences to the
|
||||
// terminal; colors have to be written as \e[...m in the prompt itself.
|
||||
func expandPrompt(ps string, info promptInfo) (string, error) {
|
||||
// $(...) in the prompt must not change $? or the status of the next
|
||||
// command line
|
||||
defer func(status int) { substStatus = status }(substStatus)
|
||||
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(ps); i++ {
|
||||
ch := ps[i]
|
||||
if ch == '$' {
|
||||
value, n, err := expandDollar(ps[i:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(sanitize(value))
|
||||
i += n - 1
|
||||
continue
|
||||
}
|
||||
if ch != '\\' || i+1 == len(ps) {
|
||||
b.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch esc := ps[i]; esc {
|
||||
case 'u':
|
||||
b.WriteString(sanitize(info.user))
|
||||
case 'h':
|
||||
host, _, _ := strings.Cut(info.host, ".")
|
||||
b.WriteString(sanitize(host))
|
||||
case 'H':
|
||||
b.WriteString(sanitize(info.host))
|
||||
case 'w':
|
||||
b.WriteString(sanitize(tildePath(info.wd, info.home)))
|
||||
case 'W':
|
||||
b.WriteString(sanitize(baseDir(info.wd, info.home)))
|
||||
case '$':
|
||||
if info.root {
|
||||
b.WriteByte('#')
|
||||
} else {
|
||||
b.WriteByte('$')
|
||||
}
|
||||
case 't':
|
||||
b.WriteString(info.now.Format("15:04:05"))
|
||||
case 'T':
|
||||
b.WriteString(info.now.Format("03:04:05"))
|
||||
case '@':
|
||||
b.WriteString(info.now.Format("03:04 PM"))
|
||||
case 'A':
|
||||
b.WriteString(info.now.Format("15:04"))
|
||||
case 'd':
|
||||
b.WriteString(info.now.Format("Mon Jan 02"))
|
||||
case 'j':
|
||||
b.WriteString(strconv.Itoa(info.jobs))
|
||||
case 'l':
|
||||
b.WriteString(sanitize(info.tty))
|
||||
case 's':
|
||||
b.WriteString("gosh")
|
||||
case 'v':
|
||||
b.WriteString(shortVersion(version))
|
||||
case 'V':
|
||||
b.WriteString(sanitize(version))
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case 'a':
|
||||
b.WriteByte('\a')
|
||||
case 'e':
|
||||
b.WriteByte('\x1b')
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case '[', ']':
|
||||
// Markers for bash's readline. Our readline finds the width of
|
||||
// the prompt itself (see splitPrompt).
|
||||
default:
|
||||
if n := octalLen(ps[i:]); n > 0 {
|
||||
code, _ := strconv.ParseUint(ps[i:i+n], 8, 8)
|
||||
b.WriteByte(byte(code))
|
||||
i += n - 1
|
||||
} else {
|
||||
// Unknown escapes are kept, like in bash
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(esc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// tildePath replaces the home directory at the start of path with ~.
|
||||
func tildePath(path, home string) string {
|
||||
if home != "" && home != "/" && (path == home || strings.HasPrefix(path, home+"/")) {
|
||||
return "~" + strings.TrimPrefix(path, home)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// baseDir returns the last component of path for \W: ~ for the home
|
||||
// directory, / for the root directory.
|
||||
func baseDir(path, home string) string {
|
||||
if home != "" && path == home {
|
||||
return "~"
|
||||
}
|
||||
if path == "/" {
|
||||
return path
|
||||
}
|
||||
return filepath.Base(path)
|
||||
}
|
||||
|
||||
// shortVersion returns major.minor of a version like v0.19.0 for \v.
|
||||
func shortVersion(v string) string {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) >= 2 {
|
||||
return sanitize(parts[0] + "." + parts[1])
|
||||
}
|
||||
return sanitize(v)
|
||||
}
|
||||
|
||||
// octalLen returns the length of the octal number (up to 3 digits, at most
|
||||
// 0377) at the start of s.
|
||||
func octalLen(s string) int {
|
||||
n := 0
|
||||
for n < len(s) && n < 3 && s[n] >= '0' && s[n] <= '7' {
|
||||
n++
|
||||
}
|
||||
if n == 3 && s[0] > '3' {
|
||||
n = 2
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// splitPrompt splits an expanded prompt into what is written to the terminal
|
||||
// before readline starts and the prompt that readline shows. readline only
|
||||
// handles a single line and only knows the width of SGR sequences
|
||||
// (\e[...m), so all lines but the last and every other control sequence of
|
||||
// the last line (e.g. \e]0;title\a for the window title) go into prefix.
|
||||
func splitPrompt(prompt string) (prefix, line string) {
|
||||
var pre strings.Builder
|
||||
if i := strings.LastIndexByte(prompt, '\n'); i >= 0 {
|
||||
pre.WriteString(prompt[:i+1])
|
||||
prompt = prompt[i+1:]
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(prompt); {
|
||||
ch := prompt[i]
|
||||
switch {
|
||||
case ch == '\x1b':
|
||||
n := escapeLen(prompt[i:])
|
||||
if seq := prompt[i : i+n]; isSGRSequence(seq) {
|
||||
b.WriteString(seq)
|
||||
} else {
|
||||
pre.WriteString(seq)
|
||||
}
|
||||
i += n
|
||||
case ch < ' ' || ch == 0x7f:
|
||||
pre.WriteByte(ch)
|
||||
i++
|
||||
default:
|
||||
b.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return pre.String(), b.String()
|
||||
}
|
||||
|
||||
// escapeLen returns the length of the escape sequence at the start of s
|
||||
// (which starts with ESC): a CSI sequence ESC [ ... final byte, an OSC
|
||||
// sequence ESC ] ... BEL or ESC \, or ESC followed by one character.
|
||||
func escapeLen(s string) int {
|
||||
if len(s) < 2 {
|
||||
return len(s)
|
||||
}
|
||||
switch s[1] {
|
||||
case '[':
|
||||
for i := 2; i < len(s); i++ {
|
||||
if s[i] >= 0x40 && s[i] <= 0x7e {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
case ']', 'P', '_', '^':
|
||||
for i := 2; i < len(s); i++ {
|
||||
if s[i] == '\a' {
|
||||
return i + 1
|
||||
}
|
||||
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// isSGRSequence reports whether seq is a complete ESC [ ... m sequence.
|
||||
func isSGRSequence(seq string) bool {
|
||||
return len(seq) >= 3 && strings.HasPrefix(seq, "\x1b[") && strings.HasSuffix(seq, "m") && isSGR(seq[2:len(seq)-1])
|
||||
}
|
||||
|
||||
// promptString returns the expanded value of the prompt variable name (PS1
|
||||
// or PS2), or ok == false if it is not set. An error in the prompt is reported and
|
||||
// the default prompt is used.
|
||||
func promptString(name string) (string, bool) {
|
||||
ps, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
prompt, err := expandPrompt(ps, currentPromptInfo())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gosh: %s: %s\n", name, sanitize(err.Error()))
|
||||
return "", false
|
||||
}
|
||||
return prompt, true
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// unsetPS1 removes $PS1 for the test, so that the default prompt is used.
|
||||
func unsetPS1(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("PS1", "")
|
||||
os.Unsetenv("PS1")
|
||||
}
|
||||
|
||||
var testInfo = promptInfo{
|
||||
user: "flow", host: "box.example.com", wd: "/home/flow/src/gosh", home: "/home/flow",
|
||||
jobs: 2, tty: "pts/3", now: time.Date(2026, 10, 5, 14, 7, 9, 0, time.UTC),
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Escapes(t *testing.T) {
|
||||
t.Setenv("GOSH_PS_VAR", "val")
|
||||
rootInfo := testInfo
|
||||
rootInfo.root, rootInfo.wd = true, "/"
|
||||
homeInfo := testInfo
|
||||
homeInfo.wd = "/home/flow"
|
||||
tests := []struct {
|
||||
ps string
|
||||
info promptInfo
|
||||
want string
|
||||
}{
|
||||
{`\u@\h:\w\$ `, testInfo, "flow@box:~/src/gosh$ "},
|
||||
{`\u@\H \W \$ `, rootInfo, "flow@box.example.com / # "},
|
||||
{`\W`, homeInfo, "~"},
|
||||
{`\W`, testInfo, "gosh"},
|
||||
{`\t \T \@ \A \d`, testInfo, "14:07:09 02:07:09 02:07 PM 14:07 Mon Oct 05"},
|
||||
{`\j \l \s`, testInfo, "2 pts/3 gosh"},
|
||||
{`a\nb\\c`, testInfo, "a\nb\\c"},
|
||||
{`\[\e[1;32m\]x\[\033[0m\]`, testInfo, "\x1b[1;32mx\x1b[0m"},
|
||||
{`\101\400`, testInfo, "A 0"},
|
||||
{`\q\`, testInfo, `\q\`},
|
||||
{`$GOSH_PS_VAR ${GOSH_PS_VAR}-$((1+2)) $(echo sub) $?`, testInfo, "val val-3 sub 0"},
|
||||
{`'$GOSH_PS_VAR' "x"`, testInfo, `'val' "x"`},
|
||||
{`cost: 5$ \$`, testInfo, "cost: 5$ $"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := expandPrompt(tt.ps, tt.info)
|
||||
if err != nil {
|
||||
t.Errorf("expandPrompt(%q): %v", tt.ps, err)
|
||||
} else if got != tt.want {
|
||||
t.Errorf("expandPrompt(%q) = %q, want %q", tt.ps, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Errors(t *testing.T) {
|
||||
os.Unsetenv("GOSH_PS_UNSET")
|
||||
if _, err := expandPrompt(`$GOSH_PS_UNSET`, testInfo); err == nil {
|
||||
t.Error("expected an error for an unset variable")
|
||||
}
|
||||
if got, err := expandPrompt(`${GOSH_PS_UNSET:-none}`, testInfo); err != nil || got != "none" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_KeepsStatus(t *testing.T) {
|
||||
lastStatus, substStatus = 3, 0
|
||||
defer func() { lastStatus = 0 }()
|
||||
got, err := expandPrompt(`$(exit 7)$?`, testInfo)
|
||||
if err != nil || got != "3" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
if substStatus != 0 {
|
||||
t.Errorf("substStatus = %d, want 0", substStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Sanitizes(t *testing.T) {
|
||||
t.Setenv("GOSH_PS_EVIL", "a\x1b[2Jb")
|
||||
info := testInfo
|
||||
info.wd = "/tmp/evil\x1b]0;x\x07"
|
||||
got, err := expandPrompt(`\w $GOSH_PS_EVIL $(printf 'c\033[2Jd')`, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(got, "\x1b\x07") {
|
||||
t.Errorf("prompt contains control characters: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPrompt(t *testing.T) {
|
||||
tests := []struct{ prompt, prefix, line string }{
|
||||
{"a $ ", "", "a $ "},
|
||||
{"top\n\x1b[1mbold\x1b[0m $ ", "top\n", "\x1b[1mbold\x1b[0m $ "},
|
||||
{"l1\nl2\n$ ", "l1\nl2\n", "$ "},
|
||||
{"\x1b]0;title\x07$ ", "\x1b]0;title\x07", "$ "},
|
||||
{"\x1b]0;t\x1b\\\x1b[2K$ \a", "\x1b]0;t\x1b\\\x1b[2K\a", "$ "},
|
||||
{"x\x1b", "\x1b", "x"},
|
||||
{"x\x1b[", "\x1b[", "x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
prefix, line := splitPrompt(tt.prompt)
|
||||
if prefix != tt.prefix || line != tt.line {
|
||||
t.Errorf("splitPrompt(%q) = %q, %q, want %q, %q", tt.prompt, prefix, line, tt.prefix, tt.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrompt_PS1(t *testing.T) {
|
||||
t.Setenv("PS1", `[\s] \$ `)
|
||||
want := "[gosh] $ "
|
||||
if os.Geteuid() == 0 {
|
||||
want = "[gosh] # "
|
||||
}
|
||||
if got := getPrompt(); got != want {
|
||||
t.Errorf("getPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
// An invalid PS1 falls back to the default prompt
|
||||
t.Setenv("PS1", `$GOSH_PS_UNSET`)
|
||||
os.Unsetenv("GOSH_PS_UNSET")
|
||||
if got := getPrompt(); !strings.Contains(got, "@") {
|
||||
t.Errorf("expected default prompt, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user