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