prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 3ba66b208f9d28bb221d6740c5e15a1f6751b244
parent a7e4f1c00bbc3c512cb109847023200aef42b617
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 08:23:42 +0000

fix(tui): use the ssh session user in the shell, prompts and title

The about-page shell hardcoded "guest" (/home/guest, whoami, sudo) while
the scripted prompts and window title said moncheeta, so the user name
changed mid-screen. Everything now uses the SSH session user, sanitized
to a safe path segment ([a-z0-9._-], max 32 chars, fallback "visitor"):
prompts, window title, whoami, the sudo message and /home/<user>.

Diffstat:
Minternal/tui/content.go | 10+++++-----
Minternal/tui/program.go | 10+++-------
Minternal/tui/shell.go | 30+++++++++++++++++-------------
Minternal/tui/shell_test.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Minternal/tui/types.go | 2+-
Minternal/tui/update.go | 2+-
Minternal/tui/vfs.go | 56++++++++++++++++++++++++++++++++++++++++++--------------
7 files changed, 134 insertions(+), 51 deletions(-)

diff --git a/internal/tui/content.go b/internal/tui/content.go @@ -19,19 +19,19 @@ func (m portfolioModel) aboutDetail() string { parts := []string{ m.aboutFetch(), "", - renderPrompt(m.about.Handle, "cat bio"), + renderPrompt(m.user, "cat bio"), m.about.Bio, "", - renderPrompt(m.about.Handle, "ls interests/"), + renderPrompt(m.user, "ls interests/"), renderTagList(m.about.Interests), "", - renderPrompt(m.about.Handle, "ls hobbies/"), + renderPrompt(m.user, "ls hobbies/"), renderTagList(m.about.Hobbies), "", m.shellView(shellW), } if appconfig.WakaTimeEnabled && appconfig.WakaTimeWeeklyEnabled { - parts = append(parts, "", renderPrompt(m.about.Handle, "wakatime --week"), m.aboutWeeklyStats()) + parts = append(parts, "", renderPrompt(m.user, "wakatime --week"), m.aboutWeeklyStats()) } return strings.Join(parts, "\n") @@ -275,7 +275,7 @@ func (m portfolioModel) experiencePage() string { return "No experience available." } - rows := []string{renderPrompt(m.about.Handle, "git log --oneline --graph --decorate")} + rows := []string{renderPrompt(m.user, "git log --oneline --graph --decorate")} for i, e := range m.experience { decorator := "" if e.Current { diff --git a/internal/tui/program.go b/internal/tui/program.go @@ -1,8 +1,6 @@ package tui import ( - "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ssh "github.com/charmbracelet/ssh" @@ -26,10 +24,8 @@ func (m portfolioModel) Init() tea.Cmd { ) } +// defaultUser is the SSH session user as shown in prompts, the window title, +// whoami and the shell's home directory, sanitized to a safe path segment. func defaultUser(user string) string { - user = strings.TrimSpace(user) - if user == "" { - return "visitor" - } - return user + return sanitizeUser(user) } diff --git a/internal/tui/shell.go b/internal/tui/shell.go @@ -25,6 +25,8 @@ type shellEntry struct { // shellState is the interactive prompt at the bottom of the about page. type shellState struct { + user string + home string cwd string input string history []shellEntry @@ -32,8 +34,10 @@ type shellState struct { cleared bool } -func newShellState() shellState { - return shellState{cwd: shellHome} +func newShellState(user string) shellState { + user = sanitizeUser(user) + home := homeDir(user) + return shellState{user: user, home: home, cwd: home} } type shellResult struct { @@ -61,7 +65,7 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { case "pwd": return shellResult{output: s.cwd} case "whoami": - return shellResult{output: shellUser} + return shellResult{output: s.user} case "echo": return shellResult{output: strings.Join(args, " ")} case "cd": @@ -72,7 +76,7 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { if len(args) > 1 { return errf("cd: too many arguments") } - abs := resolvePath(s.cwd, target) + abs := resolvePath(s.home, s.cwd, target) n := root.lookup(abs) switch { case n == nil: @@ -95,7 +99,7 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { var out []string failed := false for _, p := range paths { - n := root.lookup(resolvePath(s.cwd, p)) + n := root.lookup(resolvePath(s.home, s.cwd, p)) if n == nil { out = append(out, shellErrorStyle.Render("ls: cannot access '"+p+"': No such file or directory")) failed = true @@ -115,7 +119,7 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { var out []string failed := false for _, p := range args { - n := root.lookup(resolvePath(s.cwd, p)) + n := root.lookup(resolvePath(s.home, s.cwd, p)) switch { case n == nil: out = append(out, shellErrorStyle.Render("cat: "+p+": No such file or directory")) @@ -129,7 +133,7 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { } return shellResult{output: strings.Join(out, "\n"), err: failed && len(args) == 1} case "sudo": - return errf(shellUser + " is not in the sudoers file. This incident will be reported.") + return errf(s.user + " is not in the sudoers file. This incident will be reported.") default: return errf(cmd + ": command not found") } @@ -192,7 +196,7 @@ func (s *shellState) complete(root *vfsNode) { if i := strings.LastIndex(word, "/"); i >= 0 { dirPart, base = word[:i+1], word[i+1:] } - dir := root.lookup(resolvePath(s.cwd, dirPart)) + dir := root.lookup(resolvePath(s.home, s.cwd, dirPart)) if dir == nil || !dir.dir { return } @@ -354,7 +358,7 @@ func (m portfolioModel) shellView(width int) string { wrap := lipgloss.NewStyle().Width(max(width, 10)) parts := make([]string, 0, len(m.shell.history)*2+1) for _, e := range m.shell.history { - parts = append(parts, renderShellPrompt(e.cwd, e.input)) + parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, e.cwd, e.input)) if e.output == "" { continue } @@ -364,16 +368,16 @@ func (m portfolioModel) shellView(width int) string { } parts = append(parts, wrap.Render(out)) } - parts = append(parts, renderShellPrompt(m.shell.cwd, m.shell.input)+rowStyle.Render("█")) + parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+rowStyle.Render("█")) return strings.Join(parts, "\n") } -func renderShellPrompt(cwd, input string) string { - return promptUserStyle.Render(shellUser) + +func renderShellPrompt(user, home, cwd, input string) string { + return promptUserStyle.Render(user) + promptStyle.Render("@") + promptHostStyle.Render("prime8") + promptStyle.Render(":") + - promptPathStyle.Render(displayPath(cwd)) + + promptPathStyle.Render(displayPath(home, cwd)) + promptDollarStyle.Render("$") + promptStyle.Render(" ") + promptCmdStyle.Render(input) diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go @@ -36,7 +36,7 @@ func testModel(t *testing.T, w, h int) portfolioModel { keys: defaultKeyMap(), help: help.New(), viewport: viewport.New(1, 1), - shell: newShellState(), + shell: newShellState("visitor"), projectReadmes: map[string]string{projects[0].ID: "# janitor\n\nsorts audio."}, projectReadmeErrors: map[string]string{}, projectReadmeLoads: map[string]bool{}, @@ -75,7 +75,7 @@ func plainView(m portfolioModel) string { return ansi.Strip(m.View()) } func TestShellPromptRendersOnAboutPage(t *testing.T) { m := testModel(t, 120, 60) - if !strings.Contains(plainView(m), "guest@prime8:~$ █") { + if !strings.Contains(plainView(m), "visitor@prime8:~$ █") { t.Fatalf("prompt not rendered:\n%s", plainView(m)) } } @@ -89,7 +89,7 @@ func TestShellTypingDoesNotTriggerShortcuts(t *testing.T) { if m.shell.input != "qfbh" { t.Fatalf("input = %q", m.shell.input) } - if !strings.Contains(plainView(m), "guest@prime8:~$ qfbh█") { + if !strings.Contains(plainView(m), "visitor@prime8:~$ qfbh█") { t.Fatalf("typed text not shown:\n%s", plainView(m)) } m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyBackspace}) @@ -132,15 +132,15 @@ func TestShellCommands(t *testing.T) { out := plainView(m) for _, want := range []string{ "about bio contact experience/ hobbies/ interests/ languages/ projects/", - "guest@prime8:~/projects$ pwd", - "/home/guest/projects", + "visitor@prime8:~/projects$ pwd", + "/home/visitor/projects", "computer-science-club/ finger/ janitor/ summarizer/", "sorts audio.", "cat: nope: No such file or directory", "cd: ../bio: Not a directory", "rm: command not found", "cat: ../../../../etc/passwd: No such file or directory", - "guest@prime8:~/projects$ █", + "visitor@prime8:~/projects$ █", } { if !strings.Contains(out, want) { t.Errorf("missing %q in:\n%s", want, out) @@ -155,7 +155,7 @@ func TestShellClear(t *testing.T) { if strings.Contains(out, "cat bio") || strings.Contains(out, "experience/") { t.Fatalf("clear left output behind:\n%s", out) } - if !strings.Contains(out, "guest@prime8:~$ █") { + if !strings.Contains(out, "visitor@prime8:~$ █") { t.Fatalf("prompt missing after clear:\n%s", out) } } @@ -175,18 +175,73 @@ func TestShellCompletion(t *testing.T) { } func TestVFSNeverEscapesRoot(t *testing.T) { - root := buildVFS(testModel(t, 120, 60).vfsSource()) + m := testModel(t, 120, 60) + root := buildVFS(m.vfsSource()) + home := m.shell.home for _, p := range []string{"/etc/passwd", "/proc/self/environ", "../../..", "/srv"} { - n := root.lookup(resolvePath(shellHome, p)) + n := root.lookup(resolvePath(home, home, p)) if n != nil && n != root { t.Errorf("%s resolved to %q", p, n.name) } } - if n := root.lookup(resolvePath(shellHome, "../../..")); n != root { + if n := root.lookup(resolvePath(home, home, "../../..")); n != root { t.Error(".. above / should stay at /") } } +func TestSanitizeUser(t *testing.T) { + for in, want := range map[string]string{ + "": "visitor", + " ": "visitor", + "Damian": "damian", + "moncheeta": "moncheeta", + "../../etc": "etc", + "..": "visitor", + "a/b c": "abc", + "j.doe_1-x": "j.doe_1-x", + "\x1b[31mred": "31mred", + "élan": "lan", + strings.Repeat("a", 50): strings.Repeat("a", 32), + } { + if got := sanitizeUser(in); got != want { + t.Errorf("sanitizeUser(%q) = %q, want %q", in, got, want) + } + } +} + +func TestShellUsesSessionUser(t *testing.T) { + m := testModel(t, 120, 80) + m.user = defaultUser("Dam/ian") + m.shell = newShellState(m.user) + m.syncViewport() + typeString(&m, "whoami\n") + typeString(&m, "pwd\n") + typeString(&m, "sudo ls\n") + typeString(&m, "cd ~/projects\n") + out := plainView(m) + for _, want := range []string{ + "damian@prime8:~$ whoami", + "/home/damian", + "damian is not in the sudoers file.", + "damian@prime8:~/projects$ █", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "guest") { + t.Errorf("guest leaked:\n%s", out) + } + if got := m.windowTitle(); got != "damian@prime8:~" { + t.Errorf("window title = %q", got) + } + m.shell = newShellState(m.user) + m.syncViewport() + if out := plainView(m); !strings.Contains(out, "damian@prime8:~$ cat bio") { + t.Errorf("intro prompt not using session user:\n%s", out) + } +} + func TestProjectsSharedBorderIsAccent(t *testing.T) { accent := "\x1b[38;5;179m" for _, w := range []int{120, 90} { diff --git a/internal/tui/types.go b/internal/tui/types.go @@ -118,7 +118,7 @@ func newPortfolioModel(user string) portfolioModel { keys: defaultKeyMap(), help: h, viewport: vp, - shell: newShellState(), + shell: newShellState(user), projectReadmes: make(map[string]string), projectReadmeErrors: make(map[string]string), diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -38,7 +38,7 @@ func (m portfolioModel) windowTitle() string { path += "/" + p.ID } } - return m.about.Handle + "@prime8:" + path + return m.user + "@prime8:" + path } func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/vfs.go b/internal/tui/vfs.go @@ -16,10 +16,36 @@ import ( // filesystem: every node is generated from config/content values. const ( - shellUser = "guest" - shellHome = "/home/" + shellUser + fallbackUser = "visitor" + maxShellUser = 32 + homeDirPrefix = "/home/" ) +// sanitizeUser turns the SSH session user into a safe single path segment +// ([a-z0-9._-], at most 32 chars) so it can name the home directory and the +// prompt. Anything that sanitizes to nothing (or to . / ..) becomes visitor. +func sanitizeUser(user string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(user)) { + if b.Len() >= maxShellUser { + break + } + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b.WriteRune(r) + } + } + s := strings.Trim(b.String(), ".") + if s == "" { + return fallbackUser + } + return s +} + +func homeDir(user string) string { + return homeDirPrefix + user +} + type vfsNode struct { name string dir bool @@ -55,6 +81,7 @@ func (n *vfsNode) sortedChildren() []*vfsNode { // vfsSource is everything the filesystem is generated from. type vfsSource struct { + user string about appconfig.About currentJob *appconfig.Experience experience []appconfig.Experience @@ -65,6 +92,7 @@ type vfsSource struct { func (m portfolioModel) vfsSource() vfsSource { return vfsSource{ + user: m.shell.user, about: m.about, currentJob: m.currentJob, experience: m.experience, @@ -76,13 +104,13 @@ func (m portfolioModel) vfsSource() vfsSource { // buildVFS lays out: // -// /home/guest/{about,bio,contact} -// /home/guest/{interests,hobbies,languages}/<item> -// /home/guest/experience/<id> -// /home/guest/projects/<id>/{README.md,info} +// /home/<user>/{about,bio,contact} +// /home/<user>/{interests,hobbies,languages}/<item> +// /home/<user>/experience/<id> +// /home/<user>/projects/<id>/{README.md,info} func buildVFS(src vfsSource) *vfsNode { root := newVFSDir("/") - home := root.addDir("home").addDir(shellUser) + home := root.addDir("home").addDir(src.user) root.addDir("tmp") a := src.about @@ -197,12 +225,12 @@ func vfsName(s string) string { // resolvePath turns p (absolute, ~-relative, or relative to cwd) into a clean // absolute virtual path. -func resolvePath(cwd, p string) string { +func resolvePath(home, cwd, p string) string { switch { case p == "" || p == "~": - return shellHome + return home case strings.HasPrefix(p, "~/"): - p = shellHome + p[1:] + p = home + p[1:] case !strings.HasPrefix(p, "/"): p = cwd + "/" + p } @@ -228,12 +256,12 @@ func (root *vfsNode) lookup(abs string) *vfsNode { } // displayPath abbreviates the home directory to ~. -func displayPath(abs string) string { - if abs == shellHome { +func displayPath(home, abs string) string { + if abs == home { return "~" } - if strings.HasPrefix(abs, shellHome+"/") { - return "~" + abs[len(shellHome):] + if strings.HasPrefix(abs, home+"/") { + return "~" + abs[len(home):] } return abs }