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:
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
}