package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "time" ) // fake is a machine under a temporary root, with recorded command answers. type fake struct { t *testing.T root string answers map[string]Ran ran []string now time.Time uid int owners map[string]Owner // onExportfs, when set, writes the kernel's table as exportfs -ra would. onExportfs func() } func newFake(t *testing.T) *fake { f := &fake{t: t, root: t.TempDir(), answers: map[string]Ran{}, now: time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC), uid: 0} f.answers["systemctl is-active nfs-server.service"] = Ran{Out: "active\n"} f.answers["zfs list -H -o name,mountpoint,sharenfs -t filesystem"] = Ran{Code: -1, Err: `exec: "zfs": executable file not found in $PATH`} return f } func (f *fake) machine() *Machine { return &Machine{Root: f.root, UID: f.uid, owners: f.owners, ownerOf: asTheLibrarysOwner, Now: func() time.Time { return f.now }, Run: func(_ context.Context, name string, args ...string) Ran { line := strings.Join(append([]string{name}, args...), " ") f.ran = append(f.ran, line) if (line == "exportfs -ra" || line == "sudo -n exportfs -ra") && f.onExportfs != nil { f.onExportfs() } if r, ok := f.answers[line]; ok { return r } return Ran{} }} } func (f *fake) dir(p string) { if err := os.MkdirAll(filepath.Join(f.root, p), 0o755); err != nil { f.t.Fatal(err) } } func (f *fake) file(p, content string) { f.dir(filepath.Dir(p)) if err := os.WriteFile(filepath.Join(f.root, p), []byte(content), 0o644); err != nil { f.t.Fatal(err) } } func (f *fake) read(p string) string { b, _ := os.ReadFile(filepath.Join(f.root, p)) return string(b) } func (f *fake) restic(p string) { f.file(p+"/config", "x") for _, d := range []string{"keys", "snapshots", "data"} { f.dir(p + "/" + d) } } // kernelReads makes exportfs -ra copy the mesh's export file into the kernel's table, as it would. func (f *fake) kernelReads() { f.onExportfs = func() { var b strings.Builder for _, e := range ParseExports(f.read(ExportFile), ExportFile) { fmt.Fprintf(&b, "%s\t%s(%s)\n", e.Path, e.Client, e.Options) } f.file(Etab, b.String()) } } const meshRange = "10.0.0.0/24" // ws is the node every older test serves: granted every share and wanting it. const wsAddress = "10.0.0.3" // config is a config granting every share to ws; serve writes the roster and ws's wants for those shares. func config(shares string) string { parsed, _ := ParseShares(shares) var grants []string for _, s := range parsed { grants = append(grants, s.Name+"=ws:rw") } g := strings.Join(grants, " ") if g == "" { g = "none" } return "# test\nshares=" + shares + "\ngrants=" + g + "\nrange=" + meshRange + "\n" } // serve has ws want these shares of this machine, at the mesh's address in the roster. func (f *fake) serve(names ...string) { f.roster("ws " + wsAddress) var w []Want for _, n := range names { w = append(w, Want{Share: n, Access: "rw"}) } f.wants(map[string][]Want{"ws": w}) } func TestAnExportLineSquashesEveryClientToTheFoldersOwnerToTheMeshRangeOnly(t *testing.T) { f := newFake(t) f.dir("/srv/library") f.dir("/srv/downloads") f.file(ConfigFile, config("library=/srv/library downloads=/srv/downloads:ro")) f.serve("library", "downloads") f.kernelReads() s := f.machine().Pass(context.Background(), nil) if len(s.Shares) != 2 || len(s.Refused) != 0 { t.Fatalf("status: %+v", s) } uid, gid := 1001, 2000 // the owner the tests' folders stand for (asTheLibrarysOwner) text := f.read(ExportFile) for _, want := range []string{ fmt.Sprintf("/srv/library %s(rw,sync,secure,no_subtree_check,all_squash,anonuid=%d,anongid=%d)", wsAddress, uid, gid), fmt.Sprintf("/srv/downloads %s(ro,sync,secure,no_subtree_check,all_squash,anonuid=%d,anongid=%d)", wsAddress, uid, gid), } { if !strings.Contains(text, want+"\n") { t.Errorf("the export file lacks %q:\n%s", want, text) } } for _, e := range ParseExports(text, ExportFile) { if e.Client != wsAddress { t.Errorf("a client other than the granted and wanting node: %+v", e) } if !strings.Contains(e.Options, "all_squash") || strings.Contains(e.Options, "no_root_squash") { t.Errorf("every client is squashed: %+v", e) } } if !s.Shares[0].Live || !s.Shares[1].Live { t.Errorf("both shares are live after exportfs -ra: %+v", s.Shares) } if s.Reloaded == nil || s.ReloadWhy != "the export file changed" { t.Errorf("the kernel read the file again because it changed: %+v", s) } // Rendering is stable: a second pass changes nothing and asks the kernel nothing. f.ran = nil f.machine().Pass(context.Background(), &s) for _, r := range f.ran { if strings.Contains(r, "exportfs") { t.Errorf("a pass with nothing changed reloaded: %v", f.ran) } } } func TestTheRootABackupRepositoryAndAMissingFolderAreRefusedAndNothingIsCreated(t *testing.T) { f := newFake(t) f.restic("/srv/pool/restic") f.dir("/srv/pool/media") f.restic("/srv/backups") f.file("/srv/a-file", "x") f.file(ConfigFile, config("root=/ pool=/srv/pool backups=/srv/backups missing=/srv/nowhere file=/srv/a-file rel=srv/x media=/srv/pool/media")) f.serve("root", "pool", "backups", "missing", "file", "rel", "media") f.kernelReads() s := f.machine().Pass(context.Background(), nil) refused := map[string]string{} for _, r := range s.Refused { refused[r.Name] = r.Why } for name, word := range map[string]string{ "root": "root", "pool": "holds a backup repository", "backups": "is a backup repository", "missing": "not there", "file": "not a folder", "rel": "not an absolute path", } { if !strings.Contains(refused[name], word) { t.Errorf("%s refused with %q, want %q", name, refused[name], word) } } if len(s.Shares) != 1 || s.Shares[0].Name != "media" { t.Errorf("only the folder beside the backups is exported: %+v", s.Shares) } if strings.Contains(f.read(ExportFile), "/srv/pool ") || strings.Contains(f.read(ExportFile), "/srv/backups") { t.Errorf("a refused folder is in the export file:\n%s", f.read(ExportFile)) } if _, err := os.Stat(filepath.Join(f.root, "/srv/nowhere")); err == nil { t.Errorf("the module created a missing folder") } h := f.machine().Health() if h.Healthy || !strings.Contains(h.Why, "backups are never exported") { t.Errorf("a refused share makes the module unhealthy, said: %+v", h) } } func TestTheModuleWritesOnlyItsTwoFilesAndHasNoWayToChangeAnOwnerOrMode(t *testing.T) { m := newFake(t).machine() for _, p := range []string{"/srv/library/x", "/etc/exports", "/etc/exports.d/zfs.exports", ConfigFile} { if err := m.Write(p, "x"); err == nil { t.Errorf("Write accepted %s", p) } } // No code path changes an owner or a mode, or removes anything: the words are not in the bundle. files, _ := filepath.Glob("*.go") for _, file := range files { if strings.HasSuffix(file, "_test.go") { continue } raw, _ := os.ReadFile(file) text := string(raw) // The one removal: unexport takes the module's own export file away on unassignment. if file == "machine.go" { start := strings.Index(text, "func (m *Machine) unexport() error {") end := strings.Index(text[start:], "\n}\n") body := text[start : start+end] if strings.Count(body, "os.Remove(") != 1 || !strings.Contains(body, "os.Remove(m.at(ExportFile))") { t.Errorf("unexport removes something other than the export file:\n%s", body) } text = text[:start] + text[start+end:] } for _, word := range []string{"Chown", "Lchown", "Chmod", "os.Remove", "RemoveAll", `"chown"`, `"chmod"`, `"rm"`, "Mkdir(", "MkdirAll(filepath.Join(m.Root"} { if strings.Contains(text, word) { t.Errorf("%s says %s", file, word) } } } } func TestParseSharesAndConfig(t *testing.T) { shares, problems := ParseShares("library=/srv/library, downloads=/srv/downloads:ro Bad=/x noequals library=/again") if len(shares) != 2 || shares[0].Name != "downloads" || !shares[0].ReadOnly || shares[1].Access != "rw" { t.Errorf("shares: %+v", shares) } if len(problems) != 3 { t.Errorf("problems: %v", problems) } c, problems := ParseConfig("shares=\nrange=not-a-range\n") if c.Range != "" || len(problems) != 1 { t.Errorf("a range that is not one is said: %+v %v", c, problems) } _, problems = ParseConfig("shares=a=/x\n") if len(problems) != 1 || !strings.Contains(problems[0], "no private network range") { t.Errorf("no range is said: %v", problems) } } func TestParseExportsReadsTheKernelsTableAndZFSFile(t *testing.T) { etab := "/srv/library\t192.0.2.0/24(rw,sync,wdelay,root_squash,sec=sys)\n/srv/x\n# comment\n/srv/y a(ro) b(rw)\n" got := ParseExports(etab, Etab) if len(got) != 4 || got[0].Client != "192.0.2.0/24" || got[1].Client != "*" || got[3].Client != "b" || got[3].Options != "rw" { t.Errorf("%+v", got) } } func zfsWith(f *fake, sharenfs string) { f.answers["zfs list -H -o name,mountpoint,sharenfs -t filesystem"] = Ran{Out: "pool\t/srv/pool\toff\npool/library\t/srv/library\t" + sharenfs + "\n"} } func TestAdoptIsADryRunWithoutConfirmAndRefusesWhileTheMeshExportIsNotLive(t *testing.T) { f := newFake(t) f.uid = 1000 f.dir("/srv/library") zfsWith(f, "rw=@192.0.2.0/24") f.file(ConfigFile, config("library=/srv/library")) f.serve("library") // The kernel has not read the mesh's file: no exportfs effect. f.machine().Pass(context.Background(), nil) if _, err := f.machine().Adopt("/srv/library", true, "take it over"); err == nil || !strings.Contains(err.Error(), "not live") { t.Fatalf("adopt while not live: %v", err) } f.kernelReads() f.machine().Pass(context.Background(), nil) f.ran = nil dry, err := f.machine().Adopt("/srv/library", false, "") if err != nil || dry.Done || !dry.DryRun || !strings.Contains(dry.Said, "would set sharenfs=off on the dataset pool/library") { t.Fatalf("dry run: %+v %v", dry, err) } for _, r := range f.ran { if strings.Contains(r, "zfs set") { t.Fatalf("a dry run changed something: %v", f.ran) } } if _, err := f.machine().Adopt("/srv/library", true, ""); err == nil { t.Errorf("adopt with confirm and no why was done") } done, err := f.machine().Adopt("/srv/library", true, "the mesh exports it now") if err != nil || !done.Done { t.Fatalf("adopt: %+v %v", done, err) } var set []string for _, r := range f.ran { if strings.Contains(r, "zfs set") { set = append(set, r) } } if len(set) != 1 || set[0] != "sudo -n zfs set sharenfs=off pool/library" { t.Errorf("exactly one property of exactly one dataset, through sudo: %v", set) } zfsWith(f, "off") if _, err := f.machine().Adopt("/srv/library", true, "again"); err == nil || !strings.Contains(err.Error(), "already has sharenfs off") { t.Errorf("adopt of a dataset already off: %v", err) } if _, err := f.machine().Adopt("/srv/elsewhere", false, ""); err == nil { t.Errorf("adopt of a folder no dataset is mounted at") } } func TestAdoptRefusesAFolderTheMeshDoesNotExport(t *testing.T) { f := newFake(t) f.dir("/srv/library") zfsWith(f, "on") f.file(ConfigFile, config("")) f.kernelReads() f.machine().Pass(context.Background(), nil) if _, err := f.machine().Adopt("/srv/library", false, ""); err == nil || !strings.Contains(err.Error(), "does not export") { t.Errorf("%v", err) } } func TestHealthSaysAStoppedServerAStaleProcessAndAShareNotInTheTable(t *testing.T) { f := newFake(t) f.dir("/srv/library") f.file(ConfigFile, config("library=/srv/library")) f.serve("library") f.kernelReads() f.machine().Pass(context.Background(), nil) if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "exports library to ws (10.0.0.3, rw)") { t.Errorf("healthy: %+v", h) } f.file(Etab, "") f.answers["systemctl is-active nfs-server.service"] = Ran{Out: "failed\n", Code: 3} f.now = f.now.Add(10 * time.Minute) h := f.machine().Health() for _, want := range []string{"is failed", "last looked 10m0s ago", "The share library (/srv/library) is not in the kernel's export table."} { if h.Healthy || !strings.Contains(h.Why, want) { t.Errorf("health lacks %q: %+v", want, h) } } if h := newFake(t).machine().Health(); h.Healthy || !strings.Contains(h.Why, "written no status") { t.Errorf("no status is unhealthy: %+v", h) } } func TestExportsAnswersTheSharesAndWhatIsNotTheMeshs(t *testing.T) { f := newFake(t) f.dir("/srv/library") f.file(ConfigFile, config("library=/srv/library")) f.file("/etc/exports.d/zfs.exports", "# !!! DO NOT EDIT\n/srv/library 192.0.2.0/24(sec=sys,rw,no_subtree_check)\n") f.file("/etc/exports", "# only comments\n") f.serve("library") zfsWith(f, "rw=@192.0.2.0/24") f.kernelReads() f.machine().Pass(context.Background(), nil) a := f.machine().Exports() raw, _ := json.Marshal(a) var shape struct { Shares []struct { Name, Path, Access, Clients string UID, GID int Live bool } Foreign []map[string]any Service string } if err := json.Unmarshal(raw, &shape); err != nil { t.Fatal(err) } if len(shape.Shares) != 1 || shape.Shares[0].Name != "library" || !shape.Shares[0].Live || shape.Shares[0].Clients != wsAddress || shape.Service != "active" { t.Errorf("%s", raw) } if len(shape.Foreign) != 2 { t.Errorf("the ZFS file's line and the dataset's property are not the mesh's: %s", raw) } for _, key := range []string{`"shares"`, `"foreign"`, `"service"`, `"uid"`, `"gid"`, `"live"`, `"clients"`, `"access"`} { if !strings.Contains(string(raw), key) { t.Errorf("the answer lacks %s, which the mounts module reads", key) } } } func TestTestSaysWhetherAShareIsServedAndWhetherAnAddressIsInRange(t *testing.T) { f := newFake(t) f.dir("/srv/library") f.file(ConfigFile, config("library=/srv/library")) f.serve("library") f.kernelReads() f.machine().Pass(context.Background(), nil) ok, _ := f.machine().Test("library", "10.0.0.3") if !ok.OK || ok.InRange == nil || !*ok.InRange { t.Errorf("%+v", ok) } out, _ := f.machine().Test("library", "192.0.2.7") if out.OK || !strings.Contains(out.Why, "not inside the private network's range") { t.Errorf("%+v", out) } other, _ := f.machine().Test("library", "10.0.0.9") if other.OK || !strings.Contains(other.Why, "is not exported to 10.0.0.9") { t.Errorf("an address in the range the share is not exported to: %+v", other) } none, _ := f.machine().Test("photos", "") if none.OK || !strings.Contains(none.Why, "no share named photos") { t.Errorf("%+v", none) } } func TestClientsReadTheKernelsListOrFallBackToConnections(t *testing.T) { f := newFake(t) f.file("/proc/fs/nfsd/clients/7/info", "clientid: 0xabc\naddress: \"10.0.0.3:785\"\nname: \"Linux NFSv4.2 workstation\"\nminor version: 2\n") a := f.machine().Clients() if len(a.Clients) != 1 || a.Clients[0].Address != "10.0.0.3:785" || a.Clients[0].Minor != "2" { t.Errorf("%+v", a) } g := newFake(t) g.answers["ss -tnH state established ( sport = :2049 )"] = Ran{Out: "0 0 10.0.0.2:2049 10.0.0.3:785\n"} b := g.machine().Clients() if len(b.Clients) != 1 || b.Clients[0].Address != "10.0.0.3:785" || b.From != "connections to port 2049" { t.Errorf("%+v", b) } } func TestAnActNeedingRootGoesThroughSudoOnlyWhenNotRoot(t *testing.T) { f := newFake(t) f.uid = 1000 f.machine().AsRoot("exportfs", "-ra") f.uid = 0 f.machine().AsRoot("exportfs", "-ra") if len(f.ran) != 2 || f.ran[0] != "sudo -n exportfs -ra" || f.ran[1] != "exportfs -ra" { t.Errorf("%v", f.ran) } f.answers["sudo -n zfs set sharenfs=off x"] = Ran{Code: 1, Err: "sudo: a password is required"} f.uid = 1000 if _, err := f.machine().AsRoot("zfs", "set", "sharenfs=off", "x"); err == nil || !strings.Contains(err.Error(), "sudo refused") { t.Errorf("%v", err) } } // asTheLibrarysOwner is how the tests see the folders they make: owned by the library's owner (1001:2000), // not by whoever runs them. The build seat runs the suite as root, where every temporary folder is root's // and the refusal of a root-owned share would fire first; on a person's machine they are that person's. // A folder the test runner does not own keeps its real owner, so a real root-owned folder is still root's. func asTheLibrarysOwner(info os.FileInfo) (uid, gid int) { uid, gid = inodeOwner(info) if uid == os.Getuid() { uid = 1001 } if gid == os.Getgid() { gid = 2000 } return uid, gid }