prime8dev

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

commit 38f61e09119c06a07cafd15fa6b637f163af19c8
parent 0adf0d708025fc64defd7be0ffcc98abec0f9bca
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 08:33:11 +0000

refactor(vfs): move the shell's virtual filesystem into a shared package

Diffstat:
Minternal/tui/program.go | 3++-
Minternal/tui/shell.go | 62++++++++++++++++++++++++++++++++------------------------------
Minternal/tui/shell_test.go | 35-----------------------------------
Minternal/tui/vfs.go | 273++++---------------------------------------------------------------------------
Ainternal/vfs/vfs.go | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/vfs/vfs_test.go | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 436 insertions(+), 327 deletions(-)

diff --git a/internal/tui/program.go b/internal/tui/program.go @@ -1,6 +1,7 @@ package tui import ( + "git.prime8.dev/moncheeta/prime8dev/internal/vfs" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ssh "github.com/charmbracelet/ssh" @@ -27,5 +28,5 @@ 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 { - return sanitizeUser(user) + return vfs.SanitizeUser(user) } diff --git a/internal/tui/shell.go b/internal/tui/shell.go @@ -7,6 +7,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "git.prime8.dev/moncheeta/prime8dev/internal/vfs" ) const shellMaxHistory = 200 @@ -35,8 +37,8 @@ type shellState struct { } func newShellState(user string) shellState { - user = sanitizeUser(user) - home := homeDir(user) + user = vfs.SanitizeUser(user) + home := vfs.HomeDir(user) return shellState{user: user, home: home, cwd: home} } @@ -47,7 +49,7 @@ type shellResult struct { quit bool } -func (s *shellState) exec(root *vfsNode, line string) shellResult { +func (s *shellState) exec(root *vfs.Node, line string) shellResult { fields := strings.Fields(line) if len(fields) == 0 { return shellResult{} @@ -76,12 +78,12 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { if len(args) > 1 { return errf("cd: too many arguments") } - abs := resolvePath(s.home, s.cwd, target) - n := root.lookup(abs) + abs := vfs.Resolve(s.home, s.cwd, target) + n := root.Lookup(abs) switch { case n == nil: return errf("cd: " + target + ": No such file or directory") - case !n.dir: + case !n.Dir: return errf("cd: " + target + ": Not a directory") } s.cwd = abs @@ -99,14 +101,14 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { var out []string failed := false for _, p := range paths { - n := root.lookup(resolvePath(s.home, s.cwd, p)) + n := root.Lookup(vfs.Resolve(s.home, s.cwd, p)) if n == nil { out = append(out, shellErrorStyle.Render("ls: cannot access '"+p+"': No such file or directory")) failed = true continue } listing := renderLs(n) - if len(paths) > 1 && n.dir { + if len(paths) > 1 && n.Dir { listing = p + ":\n" + listing } out = append(out, listing) @@ -119,16 +121,16 @@ func (s *shellState) exec(root *vfsNode, line string) shellResult { var out []string failed := false for _, p := range args { - n := root.lookup(resolvePath(s.home, s.cwd, p)) + n := root.Lookup(vfs.Resolve(s.home, s.cwd, p)) switch { case n == nil: out = append(out, shellErrorStyle.Render("cat: "+p+": No such file or directory")) failed = true - case n.dir: + case n.Dir: out = append(out, shellErrorStyle.Render("cat: "+p+": Is a directory")) failed = true default: - out = append(out, rowStyle.Render(n.content)) + out = append(out, rowStyle.Render(n.Content)) } } return shellResult{output: strings.Join(out, "\n"), err: failed && len(args) == 1} @@ -150,27 +152,27 @@ const shellHelpText = `commands: try: cat about, ls projects, cat projects/janitor/README.md` -func renderLs(n *vfsNode) string { - if !n.dir { - return rowStyle.Render(n.name) +func renderLs(n *vfs.Node) string { + if !n.Dir { + return rowStyle.Render(n.Name) } - children := n.sortedChildren() + children := n.SortedChildren() if len(children) == 0 { return "" } parts := make([]string, 0, len(children)) for _, c := range children { - if c.dir { - parts = append(parts, shellDirStyle.Render(c.name+"/")) + if c.Dir { + parts = append(parts, shellDirStyle.Render(c.Name+"/")) } else { - parts = append(parts, rowStyle.Render(c.name)) + parts = append(parts, rowStyle.Render(c.Name)) } } return strings.Join(parts, " ") } // complete expands the last word of the input against the vfs. -func (s *shellState) complete(root *vfsNode) { +func (s *shellState) complete(root *vfs.Node) { if s.input == "" { return } @@ -196,16 +198,16 @@ 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.home, s.cwd, dirPart)) - if dir == nil || !dir.dir { + dir := root.Lookup(vfs.Resolve(s.home, s.cwd, dirPart)) + if dir == nil || !dir.Dir { return } var matches []string - nodes := map[string]*vfsNode{} - for _, c := range dir.sortedChildren() { - if strings.HasPrefix(c.name, base) { - matches = append(matches, c.name) - nodes[c.name] = c + nodes := map[string]*vfs.Node{} + for _, c := range dir.SortedChildren() { + if strings.HasPrefix(c.Name, base) { + matches = append(matches, c.Name) + nodes[c.Name] = c } } if len(matches) == 0 { @@ -213,7 +215,7 @@ func (s *shellState) complete(root *vfsNode) { } completed := commonPrefix(matches) if len(matches) == 1 { - if nodes[completed].dir { + if nodes[completed].Dir { completed += "/" } else { completed += " " @@ -296,7 +298,7 @@ func (m *portfolioModel) handleShellKey(msg tea.KeyMsg) (tea.Cmd, bool) { if s.input == "" { return nil, false } - s.complete(buildVFS(m.vfsSource())) + s.complete(vfs.Build(m.vfsSource())) case tea.KeyEnter: return m.runShellLine(), true default: @@ -311,7 +313,7 @@ func (m *portfolioModel) runShellLine() tea.Cmd { line := s.input s.input = "" cwd := s.cwd - res := s.exec(buildVFS(m.vfsSource()), line) + res := s.exec(vfs.Build(m.vfsSource()), line) if res.quit { return tea.Quit } @@ -377,7 +379,7 @@ func renderShellPrompt(user, home, cwd, input string) string { promptStyle.Render("@") + promptHostStyle.Render("prime8") + promptStyle.Render(":") + - promptPathStyle.Render(displayPath(home, cwd)) + + promptPathStyle.Render(vfs.DisplayPath(home, cwd)) + promptDollarStyle.Render("$") + promptStyle.Render(" ") + promptCmdStyle.Render(input) diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go @@ -174,41 +174,6 @@ func TestShellCompletion(t *testing.T) { } } -func TestVFSNeverEscapesRoot(t *testing.T) { - 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(home, home, p)) - if n != nil && n != root { - t.Errorf("%s resolved to %q", p, n.name) - } - } - 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") diff --git a/internal/tui/vfs.go b/internal/tui/vfs.go @@ -1,267 +1,18 @@ package tui -import ( - "fmt" - "path" - "sort" - "strings" - "time" +import "git.prime8.dev/moncheeta/prime8dev/internal/vfs" - appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" - "git.prime8.dev/moncheeta/prime8dev/internal/utils" -) +// The shell on the about page runs against the shared read-only virtual fs +// (internal/vfs), the same one the web shell gets from /api/fs. -// The shell on the about page runs against a read-only, in-memory filesystem -// built from the site's own content. Nothing here ever touches the host -// filesystem: every node is generated from config/content values. - -const ( - 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 - content string - children map[string]*vfsNode -} - -func newVFSDir(name string) *vfsNode { - return &vfsNode{name: name, dir: true, children: map[string]*vfsNode{}} -} - -func (n *vfsNode) addDir(name string) *vfsNode { - if c, ok := n.children[name]; ok && c.dir { - return c - } - c := newVFSDir(name) - n.children[name] = c - return c -} - -func (n *vfsNode) addFile(name, content string) { - n.children[name] = &vfsNode{name: name, content: content} -} - -func (n *vfsNode) sortedChildren() []*vfsNode { - out := make([]*vfsNode, 0, len(n.children)) - for _, c := range n.children { - out = append(out, c) - } - sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) - return out -} - -// vfsSource is everything the filesystem is generated from. -type vfsSource struct { - user string - about appconfig.About - currentJob *appconfig.Experience - experience []appconfig.Experience - projects []appconfig.Project - readmes map[string]string - now time.Time -} - -func (m portfolioModel) vfsSource() vfsSource { - return vfsSource{ - user: m.shell.user, - about: m.about, - currentJob: m.currentJob, - experience: m.experience, - projects: m.projects, - readmes: m.projectReadmes, - now: m.now, - } -} - -// buildVFS lays out: -// -// /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(src.user) - root.addDir("tmp") - - a := src.about - home.addFile("bio", a.Bio) - home.addFile("about", vfsAboutFile(src)) - home.addFile("contact", strings.Join([]string{ - "email: damian@prime8.dev", - "web: " + appconfig.SiteURL, - "git: " + appconfig.SiteURL + "/projects", - "linkedin: https://linkedin.com/in/damian-myrda", - "ssh: ssh prime8.dev", - }, "\n")) - - for dir, items := range map[string][]string{ - "interests": a.Interests, - "hobbies": a.Hobbies, - "languages": a.Languages, - } { - d := home.addDir(dir) - for _, item := range items { - if name := vfsName(item); name != "" { - d.addFile(name, item) - } - } - } - - expDir := home.addDir("experience") - for _, e := range src.experience { - if name := vfsName(e.ID); name != "" { - expDir.addFile(name, vfsExperienceFile(e)) - } - } - - projDir := home.addDir("projects") - for _, p := range src.projects { - name := vfsName(p.ID) - if name == "" { - continue - } - d := projDir.addDir(name) - readme := strings.TrimSpace(src.readmes[p.ID]) - if readme == "" { - readme = strings.TrimSpace(p.Markdown) - } - if readme != "" { - d.addFile("README.md", readme) - } - info := []string{"name: " + p.Name, "about: " + p.Subtitle} - if len(p.Tech) > 0 { - info = append(info, "tech: "+strings.Join(p.Tech, ", ")) - } - if p.Repo != "" { - info = append(info, - "clone: git clone "+appconfig.GitCloneURL(p.Repo), - " git clone "+appconfig.GitSSHCloneURL(p.Repo), - ) - } - d.addFile("info", strings.Join(info, "\n")) - } - - return root -} - -func vfsAboutFile(src vfsSource) string { - a := src.about - role := a.Profession - if src.currentJob != nil { - role = src.currentJob.Role + " @ " + src.currentJob.Company - } - now := src.now - if now.IsZero() { - now = time.Now() - } - lines := []string{ - "name: " + a.Name, - "role: " + role, - "location: " + a.Location, - "uptime: " + utils.Uptime(appconfig.Birthday, now), - } - for _, kv := range a.Setup { - lines = append(lines, fmt.Sprintf("%-9s %s", kv.Key+":", kv.Value)) - } - lines = append(lines, "langs: "+strings.Join(a.Languages, ", ")) - return strings.Join(lines, "\n") -} - -func vfsExperienceFile(e appconfig.Experience) string { - lines := []string{ - e.Role + " @ " + e.Company, - dateRangeLabel(e), - "", - } - lines = append(lines, e.Description...) - if len(e.Tech) > 0 { - lines = append(lines, "", "tech: "+strings.Join(e.Tech, ", ")) - } - if e.URL != "" { - lines = append(lines, "url: "+e.URL) - } - return strings.Join(lines, "\n") -} - -// vfsName turns a label into a single path component. -func vfsName(s string) string { - s = strings.TrimSpace(strings.ToLower(s)) - s = strings.NewReplacer("/", "-", " ", "-").Replace(s) - if s == "." || s == ".." { - return "" - } - return s -} - -// resolvePath turns p (absolute, ~-relative, or relative to cwd) into a clean -// absolute virtual path. -func resolvePath(home, cwd, p string) string { - switch { - case p == "" || p == "~": - return home - case strings.HasPrefix(p, "~/"): - p = home + p[1:] - case !strings.HasPrefix(p, "/"): - p = cwd + "/" + p - } - return path.Clean(p) -} - -func (root *vfsNode) lookup(abs string) *vfsNode { - n := root - for _, part := range strings.Split(strings.Trim(abs, "/"), "/") { - if part == "" { - continue - } - if !n.dir { - return nil - } - c, ok := n.children[part] - if !ok { - return nil - } - n = c - } - return n -} - -// displayPath abbreviates the home directory to ~. -func displayPath(home, abs string) string { - if abs == home { - return "~" - } - if strings.HasPrefix(abs, home+"/") { - return "~" + abs[len(home):] +func (m portfolioModel) vfsSource() vfs.Source { + return vfs.Source{ + User: m.shell.user, + About: m.about, + CurrentJob: m.currentJob, + Experience: m.experience, + Projects: m.projects, + Readmes: m.projectReadmes, + Now: m.now, } - return abs } diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go @@ -0,0 +1,264 @@ +// Package vfs builds the read-only, in-memory filesystem behind the shell on +// the about page (SSH TUI and web). Nothing here ever touches the host +// filesystem: every node is generated from config/content values. +package vfs + +import ( + "fmt" + "path" + "sort" + "strings" + "time" + + appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" + "git.prime8.dev/moncheeta/prime8dev/internal/utils" +) + +const ( + // FallbackUser is the shell user for anonymous sessions (and the web). + FallbackUser = "visitor" + maxUser = 32 + homeDirPrefix = "/home/" +) + +// SanitizeUser turns a 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() >= maxUser { + 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 +} + +// HomeDir is the user's home directory in the virtual fs. +func HomeDir(user string) string { + return homeDirPrefix + user +} + +// Node is a file or directory. Directories have Children; files have Content. +type Node struct { + Name string `json:"name"` + Dir bool `json:"dir,omitempty"` + Content string `json:"content,omitempty"` + Children map[string]*Node `json:"children,omitempty"` +} + +// NewDir returns an empty directory node. +func NewDir(name string) *Node { + return &Node{Name: name, Dir: true, Children: map[string]*Node{}} +} + +// AddDir returns the child directory name, creating it if needed. +func (n *Node) AddDir(name string) *Node { + if c, ok := n.Children[name]; ok && c.Dir { + return c + } + c := NewDir(name) + n.Children[name] = c + return c +} + +// AddFile adds (or replaces) a file. +func (n *Node) AddFile(name, content string) { + n.Children[name] = &Node{Name: name, Content: content} +} + +// SortedChildren lists a directory by name. +func (n *Node) SortedChildren() []*Node { + out := make([]*Node, 0, len(n.Children)) + for _, c := range n.Children { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Lookup walks an absolute virtual path; nil when it does not exist. +func (n *Node) Lookup(abs string) *Node { + cur := n + for _, part := range strings.Split(strings.Trim(abs, "/"), "/") { + if part == "" { + continue + } + if !cur.Dir { + return nil + } + c, ok := cur.Children[part] + if !ok { + return nil + } + cur = c + } + return cur +} + +// Source is everything the filesystem is generated from. +type Source struct { + User string + About appconfig.About + CurrentJob *appconfig.Experience + Experience []appconfig.Experience + Projects []appconfig.Project + // Readmes overrides project READMEs by project id (falls back to + // Project.Markdown). + Readmes map[string]string + Now time.Time +} + +// Build lays out: +// +// /home/<user>/{about,bio,contact} +// /home/<user>/{interests,hobbies,languages}/<item> +// /home/<user>/experience/<id> +// /home/<user>/projects/<id>/{README.md,info} +func Build(src Source) *Node { + root := NewDir("/") + home := root.AddDir("home").AddDir(src.User) + root.AddDir("tmp") + + a := src.About + home.AddFile("bio", a.Bio) + home.AddFile("about", aboutFile(src)) + home.AddFile("contact", strings.Join([]string{ + "email: damian@prime8.dev", + "web: " + appconfig.SiteURL, + "git: " + appconfig.SiteURL + "/projects", + "linkedin: https://linkedin.com/in/damian-myrda", + "ssh: ssh prime8.dev", + }, "\n")) + + for dir, items := range map[string][]string{ + "interests": a.Interests, + "hobbies": a.Hobbies, + "languages": a.Languages, + } { + d := home.AddDir(dir) + for _, item := range items { + if name := Name(item); name != "" { + d.AddFile(name, item) + } + } + } + + expDir := home.AddDir("experience") + for _, e := range src.Experience { + if name := Name(e.ID); name != "" { + expDir.AddFile(name, experienceFile(e)) + } + } + + projDir := home.AddDir("projects") + for _, p := range src.Projects { + name := Name(p.ID) + if name == "" { + continue + } + d := projDir.AddDir(name) + readme := strings.TrimSpace(src.Readmes[p.ID]) + if readme == "" { + readme = strings.TrimSpace(p.Markdown) + } + if readme != "" { + d.AddFile("README.md", readme) + } + info := []string{"name: " + p.Name, "about: " + p.Subtitle} + if len(p.Tech) > 0 { + info = append(info, "tech: "+strings.Join(p.Tech, ", ")) + } + if p.Repo != "" { + info = append(info, + "clone: git clone "+appconfig.GitCloneURL(p.Repo), + " git clone "+appconfig.GitSSHCloneURL(p.Repo), + ) + } + d.AddFile("info", strings.Join(info, "\n")) + } + + return root +} + +func aboutFile(src Source) string { + a := src.About + role := a.Profession + if src.CurrentJob != nil { + role = src.CurrentJob.Role + " @ " + src.CurrentJob.Company + } + now := src.Now + if now.IsZero() { + now = time.Now() + } + lines := []string{ + "name: " + a.Name, + "role: " + role, + "location: " + a.Location, + "uptime: " + utils.Uptime(appconfig.Birthday, now), + } + for _, kv := range a.Setup { + lines = append(lines, fmt.Sprintf("%-9s %s", kv.Key+":", kv.Value)) + } + lines = append(lines, "langs: "+strings.Join(a.Languages, ", ")) + return strings.Join(lines, "\n") +} + +func experienceFile(e appconfig.Experience) string { + lines := []string{ + e.Role + " @ " + e.Company, + utils.FormatMonthYear(e.StartDate) + " -> " + utils.FormatEndDate(e.EndDate, e.Current), + "", + } + lines = append(lines, e.Description...) + if len(e.Tech) > 0 { + lines = append(lines, "", "tech: "+strings.Join(e.Tech, ", ")) + } + if e.URL != "" { + lines = append(lines, "url: "+e.URL) + } + return strings.Join(lines, "\n") +} + +// Name turns a label into a single path component. +func Name(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.NewReplacer("/", "-", " ", "-").Replace(s) + if s == "." || s == ".." { + return "" + } + return s +} + +// Resolve turns p (absolute, ~-relative, or relative to cwd) into a clean +// absolute virtual path. +func Resolve(home, cwd, p string) string { + switch { + case p == "" || p == "~": + return home + case strings.HasPrefix(p, "~/"): + p = home + p[1:] + case !strings.HasPrefix(p, "/"): + p = cwd + "/" + p + } + return path.Clean(p) +} + +// DisplayPath abbreviates the home directory to ~. +func DisplayPath(home, abs string) string { + if abs == home { + return "~" + } + if strings.HasPrefix(abs, home+"/") { + return "~" + abs[len(home):] + } + return abs +} diff --git a/internal/vfs/vfs_test.go b/internal/vfs/vfs_test.go @@ -0,0 +1,126 @@ +package vfs + +import ( + "encoding/json" + "strings" + "testing" + "time" + + appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" +) + +func testSource(user string) Source { + exp := appconfig.GetExperience() + projects := appconfig.GetProjects() + return Source{ + User: user, + About: appconfig.GetAbout(), + CurrentJob: appconfig.CurrentJob(exp), + Experience: exp, + Projects: projects, + Readmes: map[string]string{projects[0].ID: "# readme"}, + Now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC), + } +} + +func TestBuildLayout(t *testing.T) { + src := testSource("visitor") + root := Build(src) + home := HomeDir("visitor") + for _, p := range []string{"bio", "about", "contact", "interests", "hobbies", "languages", "experience", "projects"} { + if root.Lookup(home+"/"+p) == nil { + t.Errorf("missing %s/%s", home, p) + } + } + first := src.Projects[0].ID + if n := root.Lookup(home + "/projects/" + first + "/README.md"); n == nil || n.Content != "# readme" { + t.Fatalf("readme override not used: %+v", n) + } + if n := root.Lookup(home + "/projects/" + first + "/info"); n == nil || !strings.Contains(n.Content, src.Projects[0].Name) { + t.Fatalf("info file: %+v", n) + } + for _, e := range src.Experience { + if root.Lookup(home+"/experience/"+Name(e.ID)) == nil { + t.Errorf("missing experience %s", e.ID) + } + } + if n := root.Lookup("/tmp"); n == nil || !n.Dir { + t.Error("missing /tmp") + } +} + +func TestBuildDeterministicJSON(t *testing.T) { + a, _ := json.Marshal(Build(testSource("visitor"))) + b, _ := json.Marshal(Build(testSource("visitor"))) + if string(a) != string(b) { + t.Fatal("json output differs between builds") + } + if !strings.Contains(string(a), `"name":"visitor"`) { + t.Fatal("home dir not in json") + } +} + +func TestNeverEscapesRoot(t *testing.T) { + root := Build(testSource("visitor")) + home := HomeDir("visitor") + for _, p := range []string{"/etc/passwd", "/root/.ssh/id_ed25519", "../../..", "/srv", "~/../../etc"} { + n := root.Lookup(Resolve(home, home, p)) + if n != nil && n != root { + t.Errorf("%s resolved to %q", p, n.Name) + } + } + if n := root.Lookup(Resolve(home, home, "../../..")); n != root { + t.Error(".. above / should stay at /") + } +} + +func TestResolveAndDisplay(t *testing.T) { + home := "/home/visitor" + cases := map[string]string{ + "": home, + "~": home, + "~/projects": home + "/projects", + "projects/../": home, + "/tmp": "/tmp", + "..": "/home", + } + for in, want := range cases { + if got := Resolve(home, home, in); got != want { + t.Errorf("Resolve(%q) = %q, want %q", in, got, want) + } + } + if got := DisplayPath(home, home+"/projects/mos"); got != "~/projects/mos" { + t.Errorf("DisplayPath = %q", got) + } + if got := DisplayPath(home, "/tmp"); got != "/tmp" { + t.Errorf("DisplayPath = %q", got) + } +} + +func TestName(t *testing.T) { + for in, want := range map[string]string{"Rally Cars": "rally-cars", "a/b": "a-b", ".": "", "..": ""} { + if got := Name(in); got != want { + t.Errorf("Name(%q) = %q, want %q", in, got, want) + } + } +} + +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) + } + } +}