435 lines
17 KiB
Go
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")
|
|
}
|
|
}
|