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
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.
309 lines
14 KiB
Go
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)
|
|
}
|
|
}
|