Files
mesh-catalog/modules/mounts/cmd/mounts/mounts_test.go
T
jschoubben 520763e98d
mesh/merge-gate pass: builds mounts → shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mounts: list says an armed automount waits, not mounted, as the look already judges it (issue 437)
The autofs line an armed automount leaves in mountinfo made List say
"mounted" while nothing was mounted. One judgement, Mounted.IsAutomount,
now serves List, the look and the checks before a mount.
2026-10-11 02:17:47 +02:00

309 lines
14 KiB
Go

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