package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" ) // fake is a machine for a test: a tree standing in for /etc, /proc and /var/lib, and a runner // answering from a table. type fake struct { t *testing.T root string answers map[string]string zfs bool } func newFake(t *testing.T) *fake { t.Helper() return &fake{t: t, root: t.TempDir(), answers: map[string]string{}} } func (f *fake) file(path, text string) { f.t.Helper() p := filepath.Join(f.root, path) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { f.t.Fatal(err) } if err := os.WriteFile(p, []byte(text), 0o644); err != nil { f.t.Fatal(err) } } func (f *fake) machine() *Machine { return &Machine{ Root: f.root, Run: func(_ context.Context, name string, args ...string) (string, error) { line := strings.TrimSpace(name + " " + strings.Join(args, " ")) if name == "systemctl" { if err := systemctlRefuses(args); err != nil { return "", err } } if out, ok := f.answers[line]; ok { return out, nil } return "", fmt.Errorf("%s: not found", line) }, LookPath: func(n string) bool { return n == "zfs" && f.zfs }, } } // systemctlRefuses is what systemctl itself does with an argument that starts with a dash before // "--": it reads it as an option, so the root mount's unit -.mount is refused. The message is the // one systemctl gave on the desktop (novox/hq issue 374); a fake that took -.mount as a unit name // agreed with the mistake. func systemctlRefuses(args []string) error { for i := 0; i < len(args); i++ { a := args[i] switch { case a == "--": return nil case a == "-p": i++ // its value is the property list case strings.HasPrefix(a, "--"): case strings.HasPrefix(a, "-") && len(a) > 1: return fmt.Errorf("exit status 1: systemctl: invalid option -- '%c'\nHint: to specify units starting with a dash, use \"--\":\n systemctl [OPTIONS…] COMMAND -- %s …", a[1], a) } } return nil } func TestRedactOptions(t *testing.T) { cases := []struct{ in, want string }{ {"", ""}, {"defaults,_netdev", "defaults,_netdev"}, {"username=root,password=hunter2,iocharset=utf8", "username=root,password=(redacted),iocharset=utf8"}, {"user=a,pass=x,PASSWD=y", "user=a,pass=(redacted),PASSWD=(redacted)"}, {"credentials=/etc/samba/creds,sec=ntlmssp", "credentials=/etc/samba/creds,sec=ntlmssp"}, {"password=", "password=(redacted)"}, {"password2=z,passwordless", "password2=(redacted),passwordless"}, } for _, c := range cases { if got := RedactOptions(c.in); got != c.want { t.Errorf("RedactOptions(%q) = %q, want %q", c.in, got, c.want) } } } const sampleFstab = `# /etc/fstab UUID=aaaa / ext4 rw,relatime 0 1 UUID=bbbb none swap defaults 0 0 192.0.2.10:/storage/media /storage/media nfs defaults,_netdev 0 0 //192.0.2.20/Share /mnt/my\040share cifs username=root,password=s3cret,iocharset=utf8 0 0 192.0.2.10:/backup /mnt/backup nfs4 nofail,x-systemd.automount 0 0 ` func TestParseFstab(t *testing.T) { got := ParseFstab(sampleFstab) if len(got) != 5 { t.Fatalf("got %d entries, want 5: %+v", len(got), got) } cases := []struct { i int point string network bool line int }{ {0, "/", false, 2}, {2, "/storage/media", true, 4}, {3, "/mnt/my share", true, 5}, {4, "/mnt/backup", true, 6}, } for _, c := range cases { e := got[c.i] if e.Mountpoint != c.point || e.Network != c.network || e.Line != c.line { t.Errorf("entry %d = %+v, want point %q network %v line %d", c.i, e, c.point, c.network, c.line) } } if strings.Contains(got[3].Options, "s3cret") { t.Errorf("a password was answered: %q", got[3].Options) } } func TestIsNetwork(t *testing.T) { cases := []struct { typ, src string want bool }{ {"nfs", "h:/x", true}, {"cifs", "//h/s", true}, {"fuse.sshfs", "u@h:/x", true}, {"ext4", "/dev/sda1", false}, {"fuseblk", "/dev/sda2", false}, {"fuse.rclone", "remote:", true}, {"fuse.portal", "portal", false}, } for _, c := range cases { if got := IsNetwork(c.typ, c.src); got != c.want { t.Errorf("IsNetwork(%q, %q) = %v, want %v", c.typ, c.src, got, c.want) } } } const sampleMountinfo = `22 1 259:3 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p3 rw 40 22 0:50 / /mnt/backup rw,relatime shared:30 - autofs systemd-1 rw,fd=50 61 40 0:60 / /mnt/backup rw,relatime shared:31 - nfs4 192.0.2.10:/backup rw,vers=4.2 70 22 0:70 / /mnt/my\040share rw - cifs //192.0.2.20/Share rw,username=root,password=leaked ` func TestParseMountinfo(t *testing.T) { got := ParseMountinfo(sampleMountinfo) if got["/mnt/backup"].Type != "nfs4" { t.Errorf("the last mount at a point must win: %+v", got["/mnt/backup"]) } share, ok := got["/mnt/my share"] if !ok { t.Fatalf("an escaped mount point was not decoded: %+v", got) } if strings.Contains(share.Options, "leaked") { t.Errorf("a password in mountinfo was answered: %q", share.Options) } } const sampleListUnits = `-.mount loaded active mounted Root Mount mnt-backup.automount loaded active waiting mnt-backup.automount mnt-backup.mount loaded active mounted /mnt/backup mnt-my\x20share.mount loaded failed failed /mnt/my share storage-media.mount loaded failed failed /storage/media dev-mqueue.mount loaded active mounted POSIX Message Queue File System ` var sampleShow = strings.Join([]string{ "Id=-.mount\nWhere=/\nWhat=/dev/nvme0n1p3\nType=ext4\nOptions=rw\nResult=success\nActiveState=active\nSubState=mounted\nFragmentPath=/run/systemd/generator/-.mount\nSourcePath=/etc/fstab\nTimeoutUSec=1min 30s\nUnitFileState=generated", "Id=mnt-backup.automount\nWhere=/mnt/backup\nResult=success\nActiveState=active\nSubState=waiting\nFragmentPath=/run/systemd/generator/mnt-backup.automount\nSourcePath=/etc/fstab\nUnitFileState=generated", "Id=mnt-backup.mount\nWhere=/mnt/backup\nWhat=192.0.2.10:/backup\nType=nfs4\nOptions=rw,vers=4.2\nResult=success\nActiveState=active\nSubState=mounted\nFragmentPath=/run/systemd/generator/mnt-backup.mount\nSourcePath=/etc/fstab\nTimeoutUSec=1min 30s\nUnitFileState=generated", "Id=mnt-my\\x20share.mount\nWhere=/mnt/my share\nWhat=//192.0.2.20/Share\nType=cifs\nOptions=username=root,password=s3cret\nResult=exit-code\nActiveState=failed\nSubState=failed\nFragmentPath=/run/systemd/generator/mnt-my\\x20share.mount\nSourcePath=/etc/fstab\nUnitFileState=generated", "Id=storage-media.mount\nWhere=/storage/media\nWhat=192.0.2.10:/storage/media\nType=nfs\nOptions=defaults,_netdev\nResult=timeout\nActiveState=failed\nSubState=failed\nFragmentPath=/run/systemd/generator/storage-media.mount\nSourcePath=/etc/fstab\nTimeoutUSec=1min 30s\nUnitFileState=generated", "Id=dev-mqueue.mount\nWhere=/dev/mqueue\nWhat=mqueue\nType=mqueue\nActiveState=active\nSubState=mounted\nFragmentPath=/usr/lib/systemd/system/dev-mqueue.mount\nUnitFileState=static", }, "\n\n") + "\n" func listFake(t *testing.T) *fake { f := newFake(t) f.file("/etc/fstab", sampleFstab) f.file("/proc/self/mountinfo", sampleMountinfo) f.answers["systemctl list-units --type=mount,automount --all --no-legend --plain --no-pager"] = sampleListUnits names := strings.Join(ParseListUnits(sampleListUnits), " ") f.answers["systemctl show -p "+ShowProperties+" --no-pager -- "+names] = sampleShow return f } func TestList(t *testing.T) { r := listFake(t).machine().List(false, "") said := strings.Join(r.Said, "\n") for _, want := range []string{ "/storage/media (nfs from 192.0.2.10:/storage/media): failed, timeout; from /etc/fstab line 4, no automount, no nofail", "/mnt/my share (cifs from //192.0.2.20/Share): failed, exit-code; from /etc/fstab line 5, no automount, no nofail", "/mnt/backup (nfs4 from 192.0.2.10:/backup): mounted; from /etc/fstab line 6", "/ (ext4 from UUID=aaaa): mounted; from /etc/fstab line 2", } { if !strings.Contains(said, want) { t.Errorf("said lacks %q:\n%s", want, said) } } if strings.Contains(said, "mqueue") { t.Errorf("a kernel filesystem was said:\n%s", said) } for _, mt := range r.Mounts { if mt.Mountpoint == "/mnt/backup" && (mt.Automount == nil || mt.MountUnit == nil) { t.Errorf("/mnt/backup must carry both units: %+v", mt) } if mt.Mountpoint == "/storage/media" && mt.MountUnit.From != "generated from /etc/fstab" { t.Errorf("from = %q", mt.MountUnit.From) } } all, _ := json.Marshal(r) if strings.Contains(string(all), "s3cret") || strings.Contains(string(all), "leaked") { t.Errorf("a credential was answered: %s", all) } } // The desktop's own mount units, as its service manager listed them on 2026-10-11 (novox/hq issue // 374): the root mount's unit is -.mount, and systemctl reads a name that starts with a dash as an // option unless it comes after "--". Every mount then lost its unit state, not only the root's. const desktopListUnits = `-.mount loaded active mounted Root Mount boot.mount loaded active mounted /boot home.mount loaded active mounted /home mnt-hdd_2tb.mount loaded active mounted /mnt/hdd_2tb mnt-recalbox.automount loaded active waiting recalbox, mounted by the mesh at first use mnt-recalbox.mount loaded inactive dead recalbox (//192.0.2.176/Share), mounted by the mesh storage-media.automount loaded active waiting media, mounted by the mesh at first use storage-media.mount loaded inactive dead media (192.0.2.10:/storage/media), mounted by the mesh ` func TestListReadsTheRootMountsUnit(t *testing.T) { f := newFake(t) f.file("/etc/fstab", "UUID=aaaa / ext4 rw,relatime 0 1\n") f.file("/proc/self/mountinfo", "22 1 259:3 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p3 rw\n") f.answers["systemctl list-units --type=mount,automount --all --no-legend --plain --no-pager"] = desktopListUnits names := strings.Join(ParseListUnits(desktopListUnits), " ") if !strings.HasPrefix(names, "-.mount ") { t.Fatalf("the root mount's unit was not listed: %q", names) } f.answers["systemctl show -p "+ShowProperties+" --no-pager -- "+names] = "Id=-.mount\nWhere=/\nWhat=/dev/nvme0n1p3\nType=ext4\nOptions=rw\nResult=success\nActiveState=active\nSubState=mounted\n" + "FragmentPath=/run/systemd/generator/-.mount\nSourcePath=/etc/fstab\nUnitFileState=generated\n\n" + "Id=storage-media.automount\nWhere=/storage/media\nResult=success\nActiveState=active\nSubState=waiting\n" + "FragmentPath=/etc/systemd/system/storage-media.automount\nUnitFileState=enabled\n" r := f.machine().List(false, "") if len(r.Notes) != 0 { t.Fatalf("the units' properties must be read, the root mount's included: %q", r.Notes) } got := map[string]Mount{} for _, mt := range r.Mounts { got[mt.Mountpoint] = mt } if root := got["/"]; root.MountUnit == nil || root.MountUnit.Unit != "-.mount" { t.Errorf("/ must carry its unit -.mount: %+v", root.MountUnit) } if media := got["/storage/media"]; media.Automount == nil { t.Errorf("/storage/media must carry its automount: %+v", media) } } // The desktop's recalbox share as it stood on 2026-10-11 (novox/hq issue 437): its automount armed // (active, waiting), its mount unit inactive, and in mountinfo only the autofs line the armed automount // leaves at the point, with the superblock options the desktop showed. Nothing is mounted there, so it // must be said as waiting for its first use, never as mounted. /mnt/backup in TestList keeps the other // half: an nfs4 line over the autofs line is mounted. func TestListSaysAnArmedAutomountIsNotMounted(t *testing.T) { f := newFake(t) f.file("/etc/fstab", "//192.0.2.176/Share /mnt/recalbox cifs username=root,password=s3cret,iocharset=utf8,sec=ntlmssp 0 0\n") f.file("/proc/self/mountinfo", "22 1 259:3 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p3 rw\n"+ "40 22 0:50 / /mnt/recalbox rw,relatime shared:30 - autofs systemd-1 rw,fd=116,pgrp=1,timeout=600,minproto=5,maxproto=5,direct,pipe_ino=68285996\n") f.answers["systemctl list-units --type=mount,automount --all --no-legend --plain --no-pager"] = desktopListUnits names := strings.Join(ParseListUnits(desktopListUnits), " ") f.answers["systemctl show -p "+ShowProperties+" --no-pager -- "+names] = "Id=mnt-recalbox.automount\nWhere=/mnt/recalbox\nResult=success\nActiveState=active\nSubState=waiting\n" + "FragmentPath=/etc/systemd/system/mnt-recalbox.automount\nUnitFileState=enabled\n\n" + "Id=mnt-recalbox.mount\nWhere=/mnt/recalbox\nWhat=//192.0.2.176/Share\nType=cifs\n" + "Options=credentials=/var/lib/mounts/smb-recalbox.credentials,iocharset=utf8,_netdev,nofail,uid=1000,gid=1000\n" + "Result=success\nActiveState=inactive\nSubState=dead\nFragmentPath=/etc/systemd/system/mnt-recalbox.mount\n" + "TimeoutUSec=10s\nUnitFileState=enabled-runtime\n" r := f.machine().List(false, "recalbox") if len(r.Said) != 1 { t.Fatalf("said %q, want one line for /mnt/recalbox", r.Said) } want := "/mnt/recalbox (cifs from //192.0.2.176/Share): not mounted, waits for its first use (automount waiting); from /etc/fstab line 1, no nofail" if r.Said[0] != want { t.Errorf("said %q,\nwant %q", r.Said[0], want) } } func TestListNarrowed(t *testing.T) { m := listFake(t).machine() if r := m.List(true, ""); len(r.Mounts) != 3 { t.Errorf("network only: got %d mounts, want 3: %v", len(r.Mounts), r.Said) } if r := m.List(false, "192.0.2.20"); len(r.Mounts) != 1 || r.Mounts[0].Mountpoint != "/mnt/my share" { t.Errorf("match on source: %v", r.Said) } } func TestListWithoutFstabSaysSo(t *testing.T) { f := newFake(t) f.file("/proc/self/mountinfo", sampleMountinfo) r := f.machine().List(false, "") notes := strings.Join(r.Notes, "\n") if !strings.Contains(notes, "/etc/fstab: not present") || !strings.Contains(notes, "units were not read") { t.Errorf("a file or command that could not be read must be said: %q", notes) } }