Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/review_test.go
T

435 lines
17 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The review of 2026-10-08 (mesh-catalog #136): each finding, reproduced.
// roster writes the machines the mesh renders, name and address.
func (f *fake) roster(lines ...string) {
f.file(RosterFile, "# test\n"+strings.Join(lines, "\n")+"\n")
}
// wants writes what the nodes' mounts modules want of this machine, as the tools take it from the bus.
func (f *fake) wants(w map[string][]Want) {
raw, _ := json.Marshal(WantsFromBus{Wants: w})
f.file(WantsFile, string(raw))
}
// mountinfo writes the kernel's mount table with these mount points.
func (f *fake) mountinfo(points ...string) {
var b strings.Builder
for i, p := range points {
fmt.Fprintf(&b, "%d 1 0:%d / %s rw,relatime shared:1 - zfs pool%d rw\n", 20+i, 40+i, p, i)
}
f.file(MountInfo, b.String())
}
func (f *fake) symlink(target, at string) {
f.dir(filepath.Dir(at))
if err := os.Symlink(target, filepath.Join(f.root, at)); err != nil {
f.t.Fatal(err)
}
}
// grantsConfig is a config with grants.
func grantsConfig(shares, grants string) string {
return "# test\nshares=" + shares + "\ngrants=" + grants + "\nrange=" + meshRange + "\n"
}
func refusedWhy(s Status, name string) string {
for _, r := range s.Refused {
if r.Name == name {
return r.Why
}
}
return ""
}
// Finding 1: a share owned by root would squash every client to root.
func TestAShareWhoseOwnerIsRootIsRefused(t *testing.T) {
f := newFake(t)
f.dir("/srv/rootowned")
f.dir("/srv/rootgroup")
f.mountinfo("/")
f.owners = map[string]Owner{"/srv/rootowned": {UID: 0, GID: 2000, Dir: true}, "/srv/rootgroup": {UID: 1001, GID: 0, Dir: true}}
f.file(ConfigFile, grantsConfig("a=/srv/rootowned b=/srv/rootgroup", "a=ws:rw b=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "a", Access: "rw"}, {Share: "b", Access: "rw"}}})
s := f.machine().Pass(context.Background(), nil)
for _, name := range []string{"a", "b"} {
if !strings.Contains(refusedWhy(s, name), "root") {
t.Errorf("%s is not refused for being root's: %+v", name, s)
}
}
if strings.Contains(f.read(ExportFile), "anonuid=0") || strings.Contains(f.read(ExportFile), "anongid=0") {
t.Errorf("an export squashes to root:\n%s", f.read(ExportFile))
}
}
// Finding 1, on a real inode: a folder root owns, read as the kernel says, is refused. Run as root (the build
// seat, or `unshare -r`), the test's own temporary folder is root's; run as anyone else, a system folder is.
func TestARealRootOwnedFolderIsRefused(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
share := "/srv/mine"
f.dir(share)
root := f.root
if os.Getuid() != 0 {
info, err := os.Stat("/usr")
if err != nil {
t.Skip("no /usr to stand for a root-owned folder:", err)
}
if uid, _ := inodeOwner(info); uid != 0 {
t.Skipf("/usr is not root's here (uid %d); run as root or under unshare -r", uid)
}
root, share = "/", "/usr"
}
f.file(ConfigFile, grantsConfig("system="+share, "system=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "system", Access: "rw"}}})
m := f.machine()
m.ownerOf = nil // the inode as the kernel says it
if root == "/" {
// Judge only reads: the folder's real path, its inode and the mount table. Nothing is written.
sys := &Machine{Root: "/", Run: func(context.Context, string, ...string) Ran { return Ran{} }}
_, refused, _ := sys.Judge(Config{Shares: []Share{{Name: "system", Path: share, Access: "rw"}},
Grants: map[string]map[string]string{"system": {"ws": "rw"}}, Range: "10.0.0.0/24"},
map[string]string{"ws": "10.0.0.3"}, map[string][]Want{"ws": {{Share: "system", Access: "rw"}}})
if len(refused) != 1 || !strings.Contains(refused[0].Why, "owned by root") {
t.Fatalf("the system folder %s, root's on its inode, is not refused for being root's: %+v", share, refused)
}
return
}
s := m.Pass(context.Background(), nil)
if !strings.Contains(refusedWhy(s, "system"), "root") {
t.Errorf("a folder root owns on its inode is not refused: %+v", s)
}
}
// Finding 2: a symlink is judged where it points, and the export names the real folder.
func TestASymlinkIsJudgedAndExportedAtItsRealPath(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.restic("/srv/backups")
f.dir("/srv/real-library")
f.symlink("backups", "/srv/innocent")
f.symlink("real-library", "/srv/library")
f.file(ConfigFile, grantsConfig("innocent=/srv/innocent library=/srv/library", "innocent=ws:rw library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "innocent", Access: "rw"}, {Share: "library", Access: "rw"}}})
s := f.machine().Pass(context.Background(), nil)
if !strings.Contains(refusedWhy(s, "innocent"), "backup") {
t.Errorf("a symlink to the backups is not refused: %+v", s)
}
text := f.read(ExportFile)
if strings.Contains(text, "/srv/innocent") || strings.Contains(text, "/srv/backups") {
t.Errorf("the backups are exported through a symlink:\n%s", text)
}
if !strings.Contains(text, "\n/srv/real-library ") || strings.Contains(text, "\n/srv/library ") {
t.Errorf("the export does not name the real folder:\n%s", text)
}
}
// Finding 3: a folder inside a backup repository, or one holding a repository deeper than one level,
// is refused; a walk cut short refuses rather than exports.
func TestABackupRepositoryAboveOrDeepBelowIsRefusedAndAnUnfinishedLookRefuses(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.restic("/srv/repo")
f.dir("/srv/repo/data/ab")
f.restic("/srv/media/films/old/backup")
f.dir("/srv/wide/a")
f.dir("/srv/wide/b")
f.dir("/srv/wide/c")
f.dir("/srv/clean/films/one")
f.file(ConfigFile, grantsConfig("inside=/srv/repo/data/ab deep=/srv/media wide=/srv/wide clean=/srv/clean", "inside=ws:rw deep=ws:rw wide=ws:rw clean=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "inside", Access: "rw"}, {Share: "deep", Access: "rw"}, {Share: "wide", Access: "rw"}, {Share: "clean", Access: "rw"}}})
m := f.machine()
m.WalkLimit = 4
s := m.Pass(context.Background(), nil)
if !strings.Contains(refusedWhy(s, "inside"), "inside the backup repository /srv/repo") {
t.Errorf("a folder inside a backup repository: %q", refusedWhy(s, "inside"))
}
if !strings.Contains(refusedWhy(s, "deep"), "/srv/media/films/old/backup") {
t.Errorf("a repository three levels down: %q", refusedWhy(s, "deep"))
}
m2 := f.machine()
m2.WalkLimit = 2
s = m2.Pass(context.Background(), nil)
if !strings.Contains(refusedWhy(s, "wide"), "could not make sure") {
t.Errorf("a look cut short by its bound did not refuse: %q", refusedWhy(s, "wide"))
}
m3 := f.machine()
s = m3.Pass(context.Background(), nil)
if refusedWhy(s, "clean") != "" || refusedWhy(s, "wide") != "" {
t.Errorf("a clean folder is refused: %+v", s.Refused)
}
}
// Finding 5: a share is exported to the nodes the server grants it to and whose mounts want it, at the
// lower of the two accesses, at the roster's address only; the control node and strangers get nothing.
func TestAShareGoesOnlyToTheNodesGrantedAndWantingItAtTheLowerAccess(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library", "/srv/pool")
f.dir("/srv/library")
f.dir("/srv/pool/downloads")
f.dir("/srv/pool/unwanted")
f.file(ConfigFile, grantsConfig("library=/srv/library downloads=/srv/pool/downloads unwanted=/srv/pool/unwanted",
"library=ws:rw,laptop:ro downloads=ws:rw unwanted=ws:rw"))
f.roster("ws 10.0.0.3", "laptop 10.0.0.4", "control 10.0.0.1", "outside 192.0.2.9")
f.wants(map[string][]Want{
"ws": {{Share: "library", Access: "ro"}, {Share: "downloads", Access: "rw"}},
"laptop": {{Share: "library", Access: "rw"}},
"control": {{Share: "library", Access: "rw"}},
"stranger": {{Share: "library", Access: "rw"}},
"outside": {{Share: "downloads", Access: "rw"}},
})
f.kernelReads()
s := f.machine().Pass(context.Background(), nil)
text := f.read(ExportFile)
var lib, dl string
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "/srv/library ") {
lib = line
}
if strings.HasPrefix(line, "/srv/pool/downloads ") {
dl = line
}
}
if !strings.Contains(lib, " 10.0.0.3(ro,") || !strings.Contains(lib, " 10.0.0.4(ro,") {
t.Errorf("the library goes ro to the workstation (it wants ro) and ro to the laptop (granted ro): %q", lib)
}
if strings.Contains(text, "10.0.0.1") || strings.Contains(text, meshRange) || strings.Contains(text, "192.0.2.9") {
t.Errorf("the control node, the whole range or an address outside the range is exported to:\n%s", text)
}
if !strings.Contains(dl, " 10.0.0.3(rw,") || strings.Count(dl, "(") != 1 {
t.Errorf("downloads goes rw to the workstation only: %q", dl)
}
if strings.Contains(text, "/srv/pool/unwanted") {
t.Errorf("a share no node wants is exported:\n%s", text)
}
var said = strings.Join(s.Said, "\n")
for _, want := range []string{"control wants library", "stranger", "unwanted is granted to ws, which does not want it"} {
if !strings.Contains(said, want) {
t.Errorf("the status does not say %q:\n%s", want, said)
}
}
for _, e := range s.Shares {
if e.Name == "library" && (len(e.To) != 2 || !e.Live) {
t.Errorf("the library's clients: %+v", e)
}
}
if !strings.Contains(f.read(StatusFile), `"to"`) {
t.Errorf("the status lacks each share's clients")
}
}
// With no wants file (the bus never answered), nothing is exported: closed, not open.
func TestWithNoWantsNothingIsExported(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.machine().Pass(context.Background(), nil)
if strings.Contains(f.read(ExportFile), "/srv/library") {
t.Errorf("exported with no node asking:\n%s", f.read(ExportFile))
}
}
// Finding 6: `mp` keeps a share unexported while its filesystem is not mounted.
func TestTheExportCarriesMpForItsFilesystem(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library", "/srv/pool")
f.dir("/srv/library")
f.dir("/srv/pool/downloads")
f.dir("/srv/plain")
f.file(ConfigFile, grantsConfig("library=/srv/library downloads=/srv/pool/downloads plain=/srv/plain", "library=ws:rw downloads=ws:rw plain=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}, {Share: "downloads", Access: "rw"}, {Share: "plain", Access: "rw"}}})
f.machine().Pass(context.Background(), nil)
text := f.read(ExportFile)
if !strings.Contains(text, "/srv/library 10.0.0.3(rw,mp,") {
t.Errorf("a dataset share lacks mp:\n%s", text)
}
if !strings.Contains(text, "/srv/pool/downloads 10.0.0.3(rw,mp=/srv/pool,") {
t.Errorf("a folder in a dataset lacks mp=<its dataset>:\n%s", text)
}
if strings.Contains(text, "/srv/plain 10.0.0.3(rw,mp") {
t.Errorf("a folder of the root filesystem names mp:\n%s", text)
}
}
// Finding 7: the module's export file goes when the module is unassigned, and stays on a restart.
func TestTheExportFileGoesOnUnassignmentAndStaysOnARestart(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
m.Pass(context.Background(), nil)
f.ran = nil
m.Stopped()
if !strings.Contains(f.read(ExportFile), "/srv/library") {
t.Errorf("a restart (config still there) removed the exports")
}
for _, r := range f.ran {
if strings.Contains(r, "exportfs") {
t.Errorf("a restart reloaded the exports: %v", f.ran)
}
}
if err := os.Remove(filepath.Join(f.root, ConfigFile)); err != nil {
t.Fatal(err)
}
m.Stopped()
if _, err := os.Stat(filepath.Join(f.root, ExportFile)); err == nil {
t.Errorf("the export file stays after the module's config was removed")
}
if strings.Contains(f.read(Etab), "/srv/library") {
t.Errorf("the kernel still exports the folder after unassignment: %q", f.read(Etab))
}
}
// Finding 9: the seat's schema passes `confirm` as a string.
func TestConfirmIsTrueAsABooleanOrTheStringTrue(t *testing.T) {
for _, c := range []struct {
v any
want bool
}{{true, true}, {"true", true}, {"TRUE", true}, {"false", false}, {"yes", false}, {nil, false}, {false, false}} {
if got := confirmed(map[string]any{"confirm": c.v}); got != c.want {
t.Errorf("confirm %#v read as %v", c.v, got)
}
}
}
// Grants are read plainly, and "none" grants nothing.
func TestParseGrants(t *testing.T) {
g, problems := ParseGrants("library=ws:rw,laptop:ro downloads=ws:rw")
if len(problems) != 0 || g["library"]["laptop"] != "ro" || g["downloads"]["ws"] != "rw" {
t.Errorf("%v %v", g, problems)
}
g, problems = ParseGrants("none")
if len(g) != 0 || len(problems) != 0 {
t.Errorf("none: %v %v", g, problems)
}
_, problems = ParseGrants("library=ws library=x:rw bad=ws:rx")
if len(problems) != 3 {
t.Errorf("bad grants are said: %v", problems)
}
}
// The wants are taken from each node's `mounts` state: only what is asked of this machine, keyed by the
// node that asked.
func TestWantsFromTheMountsStateKeepOnlyWhatIsAskedOfThisMachine(t *testing.T) {
states := map[string][]byte{
"ws": []byte(`{"wants":[{"server":"home","share":"library","access":"rw"},{"server":"other","share":"x","access":"rw"}],"shares":[]}`),
"laptop": []byte(`{"wants":[{"server":"home","share":"library","access":"ro"}]}`),
"broken": []byte(`not json`),
"none": []byte(`{"shares":[]}`),
}
w, notes := WantsFromStates("home", states)
if len(w) != 2 || len(w["ws"]) != 1 || w["ws"][0].Share != "library" || w["laptop"][0].Access != "ro" {
t.Errorf("%+v", w)
}
if len(notes) != 1 || !strings.Contains(notes[0], "broken") {
t.Errorf("a state that cannot be read is said: %v", notes)
}
m := newFake(t).machine()
if err := m.Write(WantsFile, "{}"); err != nil {
t.Errorf("the tools may write the wants file: %v", err)
}
}
// The look below a share for a backup repository walks up to WalkLimit folders; on a library of tens of
// thousands of folders that must not run on every 30 s pass. A clean result is kept for LookKept, and a
// repository put there later is found once that has passed.
func TestTheLookBelowIsKeptForAWhileAndThenTakenAgain(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/clean/films/one")
f.file(ConfigFile, grantsConfig("clean=/srv/clean", "clean=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "clean", Access: "rw"}}})
m := f.machine()
now := time.Date(2026, 10, 8, 19, 0, 0, 0, time.UTC)
m.Now = func() time.Time { return now }
if s := m.Pass(context.Background(), nil); refusedWhy(s, "clean") != "" {
t.Fatalf("a clean folder is refused: %+v", s.Refused)
}
f.restic("/srv/clean/films/one/backup")
now = now.Add(time.Minute)
if s := m.Pass(context.Background(), nil); refusedWhy(s, "clean") != "" {
t.Errorf("the look was taken again within a minute: %+v", s.Refused)
}
now = now.Add(LookKept)
if s := m.Pass(context.Background(), nil); !strings.Contains(refusedWhy(s, "clean"), "/srv/clean/films/one/backup") {
t.Errorf("a repository put there later was not found once the kept look expired: %+v", s.Refused)
}
}
// mesh-host #62 found that a stop the service manager ends with SIGKILL after 90 s leaves the export file
// behind. A pass that hangs in a command (sudo, exportfs: 20 s each) must not hold the cleanup: SIGTERM
// cancels the running pass, and Stopped has its own short bound, so the file is gone well inside the stop
// timeout even when every command hangs.
func TestAHangingCommandStillLetsStoppedRemoveTheFileInTime(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
m := f.machine()
inner := m.Run
hung := make(chan struct{}, 8)
m.Run = func(ctx context.Context, name string, args ...string) Ran {
line := strings.Join(append([]string{name}, args...), " ")
if strings.Contains(line, "exportfs") {
hung <- struct{}{}
<-ctx.Done() // hangs until its context ends, as a stuck sudo or exportfs would
return Ran{Code: -1, Err: ctx.Err().Error()}
}
return inner(ctx, name, args...)
}
saved := StopTimeout
StopTimeout = 300 * time.Millisecond
defer func() { StopTimeout = saved }()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- m.RunExports(ctx) }()
select {
case <-hung: // the first pass is stuck in exportfs
case <-time.After(5 * time.Second):
t.Fatal("the first pass never reached exportfs")
}
if err := os.Remove(filepath.Join(f.root, ConfigFile)); err != nil { // unassigned: config removed first
t.Fatal(err)
}
start := time.Now()
cancel() // SIGTERM
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the process did not stop within 3 s of SIGTERM while a command hung")
}
if took := time.Since(start); took > 2*time.Second {
t.Errorf("stopping took %s with a hanging command; the bound is StopTimeout (%s)", took, StopTimeout)
}
if _, err := os.Stat(filepath.Join(f.root, ExportFile)); err == nil {
t.Errorf("the export file stays after an unassignment whose commands hung")
}
}