//go:build linux package apply import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" "golang.org/x/sys/unix" "github.com/novox/mesh-host/internal/store" ) // The engine's half of ADR 0325's checks (novox/hq ADR 0325, "How it is checked", issue 567): a shared folder kept // through access entries, on a filesystem of the test's own. Nothing here needs root: a file's owner may set its // ACL, and the identities are numbers no account here has. const ( radarr = 61001 // a container's identity, read-write plex = 61002 // a container's identity, read runner = 61003 // the tool runner's account ) // aclFolder is a folder of the test's own on a filesystem that carries ACLs, or the test is skipped saying why. func aclFolder(t *testing.T) string { t.Helper() dir := t.TempDir() fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY, 0) if err != nil { t.Fatal(err) } defer unix.Close(fd) probe := keptACL(minimalACL(0o755), map[uint32]uint16{radarr: 5}) if err := setACL(fd, aclAccessXattr, probe); errors.Is(err, ErrNoACL) { t.Skipf("the test's filesystem (%s) carries no POSIX ACLs (ENOTSUP), and every check here reads them", dir) } else if err != nil { t.Fatal(err) } if err := unix.Fremovexattr(fd, aclAccessXattr); err != nil { t.Fatal(err) } if err := os.Chmod(dir, 0o755); err != nil { t.Fatal(err) } return dir } // keeping has the test keep its own record of kept folders, and judge mounts as the test says. func keeping(t *testing.T, points []string, targets []string) { t.Helper() was, wasPoints, wasTargets := KeptFolders, MountPoints, MountTargets KeptFolders = NewKeptMemory() MountPoints = func() []string { return append([]string{"/"}, points...) } MountTargets = func(context.Context) []string { return targets } t.Cleanup(func() { KeptFolders, MountPoints, MountTargets = was, wasPoints, wasTargets }) } // accessJSON is an access of module to path with users, each "identity:uid:use:from". func accessJSON(id, path, mode string, taken bool, users ...string) string { var us []string for _, u := range users { f := strings.Split(u, ":") us = append(us, fmt.Sprintf(`{"identity":%q,"uid":%s,"use":%q,"from":%q}`, f[0], f[1], f[2], f[3])) } return fmt.Sprintf(`{"id":%q,"type":"access","path":%q,"mode":%q,"taken":%v,"users":[%s]}`, id, path, mode, taken, strings.Join(us, ",")) } // readACLs is a path's access ACL and default ACL (nil when it has none), and its stat. func readACLs(t *testing.T, path string) (acl, acl, unix.Stat_t) { t.Helper() fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW, 0) if err != nil { t.Fatal(err) } defer unix.Close(fd) var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { t.Fatal(err) } access, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777) if err != nil { t.Fatal(err) } def, err := getACL(fd, aclDefaultXattr, uint32(st.Mode)&0o7777) if err != nil { t.Fatal(err) } return access, def, st } // effectiveOn is what uid may do on path, by entry and mask together. func effectiveOn(t *testing.T, path string, uid uint32) uint16 { t.Helper() a, _, st := readACLs(t, path) return effective(a, uid, st.Uid, uint32(st.Mode)&0o7777) } func applyAccesses(t *testing.T, known store.State, resources ...string) (Report, store.State, error) { t.Helper() d := declare(t, strings.Join(resources, ",")) return Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil) } // Row 5: entries and default entries per identity and use; owner, owning group, owner's and others' bits unchanged; // the owning group's entry equals its former group bits; the mask equals what the entries need; an entry the mesh // did not add is untouched. func TestAKeptFolderCarriesAnEntryPerUserAndNothingElseOfItChanges(t *testing.T) { dir := aclFolder(t) keeping(t, nil, nil) lib := filepath.Join(dir, "movies") if err := os.Mkdir(lib, 0o750); err != nil { t.Fatal(err) } if err := os.Chmod(lib, 0o750); err != nil { t.Fatal(err) } // The operator's own entry, there before: never the mesh's to change. fd, _ := unix.Open(lib, unix.O_RDONLY|unix.O_DIRECTORY, 0) operator := keptACL(minimalACL(0o750), map[uint32]uint16{60099: permRead}) operator[len(operator)-1].Perm = permRead | permExec // its mask: r-x, as the operator set it if err := setACL(fd, aclAccessXattr, operator); err != nil { t.Fatal(err) } unix.Close(fd) _, _, before := readACLs(t, lib) report, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.movies", lib, "read-write", false, "mesh-radarr:61001:read-write:container"), accessJSON("plex.movies", lib, "read", false, "mesh-plex:61002:read:container")) if err != nil { t.Fatalf("an empty folder was not kept: %v", err) } access, def, after := readACLs(t, lib) if bits, _ := access.perm(aclUser, radarr); bits != 7 { t.Errorf("the read-write identity's entry is %s, and it needs rwx", permWords(bits)) } if bits, _ := access.perm(aclUser, plex); bits != 5 { t.Errorf("the read identity's entry is %s, and it needs r-x", permWords(bits)) } for _, uid := range []uint32{radarr, plex} { if _, ok := def.perm(aclUser, uid); !ok { t.Errorf("uid %d has no default entry: what is made under the folder would not be its", uid) } } if bits, _ := access.perm(aclUser, 60099); bits != permRead { t.Errorf("the operator's entry was changed to %s", permWords(bits)) } if g, _ := access.perm(aclGroupObj, 0); g != 5 { t.Errorf("the owning group's entry is %s, and its group bits gave r-x", permWords(g)) } if m, _ := access.perm(aclMask, 0); m != 7 { t.Errorf("the mask is %s, and the entries need rwx", permWords(m)) } if after.Uid != before.Uid || after.Gid != before.Gid { t.Error("the folder's owner or owning group changed") } if after.Mode&0o707 != before.Mode&0o707 { t.Errorf("the owner's or others' bits changed: %o to %o", before.Mode&0o777, after.Mode&0o777) } if after.Mode&0o070 != 0o070 { t.Errorf("the group bits show %o, and they show the mask the entries need (rwx)", (after.Mode>>3)&7) } rec, _ := keptRecord(lib) if !rec.Kept || rec.How != store.KeptEmpty { t.Errorf("the folder was not recorded kept as one that held nothing: %+v", rec) } for _, o := range report.Outcomes { if o.ID == "radarr.movies" && o.Action == "waiting" { t.Errorf("an empty folder waited for a warrant: %s", o.Detail) } } } // Row 4: the runner's entry is the widest use among the modules whose tools use the folder. func TestTheToolRunnersEntryIsTheWidestUseAnyModuleGivesIt(t *testing.T) { dir := aclFolder(t) keeping(t, nil, nil) lib := filepath.Join(dir, "books") if err := os.Mkdir(lib, 0o755); err != nil { t.Fatal(err) } _, _, err := applyAccesses(t, store.State{}, accessJSON("reader.books", lib, "read", false, "nox:61003:read:tool-runner"), accessJSON("importer.books", lib, "read-write", false, "nox:61003:read-write:tool-runner")) if err != nil { t.Fatal(err) } if got := effectiveOn(t, lib, runner); got != 7 { t.Fatalf("the tool runner may %s, and one module whose tools use it declared read-write", permWords(got)) } } // Row 6: a file made at 0666 by one identity is writable by another read-write identity at once; one made at 0644 // is writable by it after the watch's repair, and before it the judgement says so; with watches exhausted, the // sweep repairs it; the judgement reads the effective right (entry and mask). func TestWhatIsMadeInAKeptFolderIsGivenItsUsersRights(t *testing.T) { dir := aclFolder(t) keeping(t, nil, nil) lib := filepath.Join(dir, "downloads") if err := os.Mkdir(lib, 0o755); err != nil { t.Fatal(err) } users := []string{"mesh-radarr:61001:read-write:container", "mesh-sab:61004:read-write:container"} if _, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.downloads", lib, "read-write", false, users...)); err != nil { t.Fatal(err) } rec, _ := keptRecord(lib) usual := filepath.Join(lib, "usual") if err := os.WriteFile(usual, nil, 0o666); err != nil { t.Fatal(err) } if got := effectiveOn(t, usual, radarr); got&6 != 6 { t.Fatalf("a file made at 0666 gives another read-write identity %s at once", permWords(got)) } w, err := NewWatcher() if err != nil { t.Skipf("no inotify here: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go w.Run(ctx) w.Set(map[string][]store.KeptUser{lib: rec.Want}) narrow := filepath.Join(lib, "narrow") f, err := os.OpenFile(narrow, os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } f.Close() waitFor(t, "the watch widened the mask of a file made at 0644", func() bool { return effectiveOn(t, narrow, radarr)&6 == 6 }) // Before a repair the judgement says so: a mask narrowed by hand where nothing watches. w.Set(nil) if err := os.Chmod(usual, 0o644); err != nil { t.Fatal(err) } c, err := JudgeKept(lib, rec.Want, ActLimits) if err != nil { t.Fatal(err) } if c.Narrowed == 0 { t.Fatal("a file whose mask gives a read-write user only read is not said by the judgement") } // Watches run out: the record says so, and the sweep puts it back. was := MaxWatchedPerFolder MaxWatchedPerFolder = 0 defer func() { MaxWatchedPerFolder = was }() w.Set(map[string][]store.KeptUser{lib: rec.Want}) if r, _ := keptRecord(lib); !r.OutOfWatches { t.Error("watches that ran out are not recorded") } look := LookKept(lib, rec.Want, SweepLimits) if look.Repaired == 0 { t.Errorf("the sweep put nothing back: %+v", look) } if got := effectiveOn(t, usual, radarr); got&6 != 6 { t.Fatalf("after the sweep a read-write identity may %s", permWords(got)) } if c, _ := JudgeKept(lib, rec.Want, ActLimits); c.Narrowed != 0 { t.Fatalf("after the sweep %d entries still give a user less than its right", c.Narrowed) } } func waitFor(t *testing.T, what string, ok func() bool) { t.Helper() deadline := time.Now().Add(3 * time.Second) for !ok() { if time.Now().After(deadline) { t.Fatalf("not within 3s: %s", what) } time.Sleep(20 * time.Millisecond) } } // Row 8, the engine's part: a folder that holds data waits, and nothing is changed without a warrant; the act adds // the entries under the folder, follows no link, enters no other filesystem; a dropped access removes exactly its // own entries. func TestAFolderThatHoldsDataIsTakenOnlyOnAWarrantAndLetGoEntryByEntry(t *testing.T) { dir := aclFolder(t) keeping(t, nil, nil) lib := filepath.Join(dir, "music") outside := filepath.Join(dir, "outside") for _, d := range []string{lib, filepath.Join(lib, "album"), outside} { if err := os.Mkdir(d, 0o755); err != nil { t.Fatal(err) } } song := filepath.Join(lib, "album", "song") if err := os.WriteFile(song, []byte("data"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(lib, "link")); err != nil { t.Fatal(err) } a := accessJSON("lidarr.music", lib, "read-write", false, "mesh-lidarr:61001:read-write:container", "mesh-plex:61002:read:container") report, _, err := applyAccesses(t, store.State{}, a) if err != nil { t.Fatal(err) } if o := outcomeOf(report, "lidarr.music"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("music")) { t.Fatalf("a folder that holds data did not wait for the operator: %+v", o) } if has := effectiveOn(t, lib, radarr); has != 0 { t.Fatal("the folder was given an entry without a warrant") } if strings.Contains(KeepWords("music"), "/") || strings.Contains(DiskWords("music"), "/") { t.Fatal("a wait's words name a path") } d := declare(t, a) done, said, err := TakeFolder(context.Background(), d, "lidarr.music", lib, "warrant-7", "the operator", noServices) if err != nil { t.Fatalf("the take act was refused: %v", err) } <-done if !strings.Contains(said, "warrant-7") { t.Errorf("the act does not say its warrant: %s", said) } for _, p := range []string{lib, filepath.Join(lib, "album"), song} { if got := effectiveOn(t, p, radarr); got&6 != 6 { t.Errorf("after the act the read-write identity may %s", permWords(got)) } } if has := effectiveOn(t, outside, radarr); has != 0 { t.Error("the act followed a link out of the folder") } rec, _ := keptRecord(lib) if !rec.Kept || rec.How != store.KeptByWarrant || rec.Warrant != "warrant-7" || rec.Act == nil || rec.Act.State != "done" || rec.Act.Links != 1 { t.Fatalf("the act is not recorded as done under its warrant, with the link passed by: %+v %+v", rec, rec.Act) } // Kept now: the next apply keeps it without asking, and plex dropping it has exactly plex's entries removed. if _, _, err := applyAccesses(t, store.State{}, accessJSON("lidarr.music", lib, "read-write", false, "mesh-lidarr:61001:read-write:container")); err != nil { t.Fatal(err) } beforeMask, _ := func() (uint16, bool) { a, _, _ := readACLs(t, song); return a.perm(aclMask, 0) }() ReconcileKept(context.Background()) for _, p := range []string{lib, song} { a, def, _ := readACLs(t, p) if _, still := a.perm(aclUser, plex); still { t.Errorf("a dropped user's entry stayed") } if _, still := def.perm(aclUser, plex); still && def != nil { t.Errorf("a dropped user's default entry stayed") } if _, kept := a.perm(aclUser, radarr); !kept { t.Errorf("the user that stayed lost its entry") } } if m, _ := func() (uint16, bool) { a, _, _ := readACLs(t, song); return a.perm(aclMask, 0) }(); m != beforeMask { t.Errorf("the mask moved on a drop: %s to %s", permWords(beforeMask), permWords(m)) } } // Row 8: the act enters no other filesystem — a folder whose device is not the kept folder's is passed by. func TestAWalkEntersNoOtherFilesystem(t *testing.T) { dir := aclFolder(t) mount := filepath.Join(dir, "elsewhere") if err := os.Mkdir(mount, 0o755); err != nil { t.Fatal(err) } inside := filepath.Join(mount, "file") if err := os.WriteFile(inside, nil, 0o644); err != nil { t.Fatal(err) } // A stand-in for a device the test cannot mount: the folder named elsewhere reads as another filesystem. stats := walkStats{ at: func(dirfd int, name string, st *unix.Stat_t) error { err := unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW) if name == "elsewhere" { st.Dev++ } return err }, fd: unix.Fstat, } w, err := walkKept(dir, ActLimits, stats, nil, keepVisit(map[uint32]bool{radarr: true})) if err != nil { t.Fatal(err) } if w.counts.Mounts != 1 { t.Errorf("the walk counted %d folders on another filesystem, and there is one", w.counts.Mounts) } if got := effectiveOn(t, inside, radarr); got != 0 { t.Error("the walk entered another filesystem") } } // Row 9: a missing folder under a mounted dataset is made as its parent; with the dataset not mounted (its empty // mount point on the root filesystem) nothing is made and the access waits. func TestAMissingFolderIsMadeWhereItsDataLivesAndWaitsWhereItsDiskIsNot(t *testing.T) { dir := aclFolder(t) dataset := filepath.Join(dir, "tank") if err := os.Mkdir(dataset, 0o750); err != nil { t.Fatal(err) } if err := os.Chmod(dataset, 0o750); err != nil { t.Fatal(err) } keeping(t, []string{dataset}, []string{dataset}) movies := filepath.Join(dataset, "media", "movies") report, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.movies", movies, "read-write", false, "mesh-radarr:61001:read-write:container")) if err != nil { t.Fatalf("a missing folder under a mounted dataset was not made: %v", err) } info, err := os.Stat(movies) if err != nil { t.Fatalf("the folder was not made: %v", err) } if info.Mode().Perm()&0o707 != 0o700 { t.Errorf("the folder was made at %o, and its parent is 0750", info.Mode().Perm()) } if got := effectiveOn(t, movies, radarr); got != 7 { t.Errorf("the folder made is not kept: the identity may %s", permWords(got)) } if rec, _ := keptRecord(movies); rec.How != store.KeptMade || rec.Mount != dataset { t.Errorf("not recorded as made on its dataset: %+v", rec) } _ = report // The dataset not mounted: its mount point an empty folder on the filesystem below. MountPoints = func() []string { return []string{"/"} } books := filepath.Join(dataset, "books") report, _, err = applyAccesses(t, store.State{}, accessJSON("readarr.books", books, "read-write", false, "mesh-readarr:61001:read-write:container")) if err != nil { t.Fatalf("a missing disk failed the apply: %v", err) } if o := outcomeOf(report, "readarr.books"); o.Action != "waiting" || !strings.Contains(o.Detail, DiskWords("books")) { t.Fatalf("a folder whose disk is not there did not wait for it: %+v", o) } if _, err := os.Stat(books); !errors.Is(err, os.ErrNotExist) { t.Fatal("a folder was made where its disk is not") } if v := AccessVerdict(context.Background(), books, false, true); v.Kind != VerdictWaitDisk { t.Errorf("the look judges it %s, not waiting for its disk", v.Kind) } // And the folder made before, its dataset gone: its disk is not there, whatever is at its path now. if v := AccessVerdict(context.Background(), movies, false, true); v.Kind != VerdictWaitDisk { t.Errorf("a kept folder whose dataset is not mounted is judged %s", v.Kind) } } // Row 10: a narrowed entry or mask is reported and put back; successive looks cover a folder whole; a look over // its bound says how far it got. func TestTheSweepPutsBackWhatAnAppNarrowedAndCoversTheFolderOverLooks(t *testing.T) { dir := aclFolder(t) keeping(t, nil, nil) lib := filepath.Join(dir, "shows") if err := os.Mkdir(lib, 0o755); err != nil { t.Fatal(err) } a := accessJSON("sonarr.shows", lib, "read-write", false, "mesh-sonarr:61001:read-write:container") if _, _, err := applyAccesses(t, store.State{}, a); err != nil { t.Fatal(err) } rec, _ := keptRecord(lib) for i := 0; i < 25; i++ { p := filepath.Join(lib, fmt.Sprintf("episode-%02d", i)) if err := os.WriteFile(p, nil, 0o666); err != nil { t.Fatal(err) } if err := os.Chmod(p, 0o644); err != nil { // an app's chmod narrows the mask t.Fatal(err) } } // The folder itself narrowed too: put back on the next apply, said by the look until then. if err := os.Chmod(lib, 0o755); err != nil { t.Fatal(err) } if short, _ := judgeTop(lib, rec.Want); len(short) == 0 { t.Fatal("a folder whose mask an app narrowed is not said short") } if _, _, err := applyAccesses(t, store.State{}, a); err != nil { t.Fatal(err) } if short, _ := judgeTop(lib, rec.Want); len(short) != 0 { t.Fatalf("the apply did not put the folder back: %v", short) } limits := WalkLimits{Entries: 10, Time: time.Minute} looks, repaired := 0, 0 for { looks++ look := LookKept(lib, rec.Want, limits) repaired += look.Repaired if look.Failed != "" { t.Fatal(look.Failed) } if !look.Sweep.Bound || look.Sweep.Cursor == nil { break } if len(look.Sweep.Cursor) == 0 || look.Sweep.Read == 0 { t.Fatalf("a look over its bound does not say how far it got: %+v", look.Sweep) } if looks > 10 { t.Fatal("the sweep never covered the folder") } } if looks < 3 { t.Errorf("26 entries at 10 a look were covered in %d looks", looks) } if repaired != 25 { t.Errorf("the sweep put back %d of 25 narrowed entries", repaired) } if c, _ := JudgeKept(lib, rec.Want, ActLimits); c.Narrowed != 0 { t.Errorf("after the looks %d entries still give the user less than its right", c.Narrowed) } if r, _ := keptRecord(lib); r.Sweep.Covered.IsZero() { t.Error("a sweep that covered the folder is not recorded as having done so") } } // Row 12: a root-owned folder on its placed filesystem, and an absent one under a root-owned parent on it, are // unhealthy, nothing is made, and no ask is raised. Read from the machine's own root-owned folders, never changed. func TestRootsDataOnItsPlacedFilesystemIsNeverKept(t *testing.T) { keeping(t, nil, nil) // A root-owned folder the placement guard lets an access name: below a protected root, never one. root := "" for _, c := range []string{"/srv/http", "/srv/ftp", "/var/empty", "/var/opt"} { if info, err := os.Lstat(c); err == nil && info.IsDir() { if uid, _, _ := statOwner(info); uid == 0 { root = c break } } } if root == "" { t.Skip("this machine has no root-owned folder below /srv or /var to read") } absent := filepath.Join(root, "mesh-test-never-made") for _, path := range []string{root, absent} { report, _, err := applyAccesses(t, store.State{}, accessJSON("m.data", path, "read-write", false, "mesh-m:61001:read-write:container")) if err == nil || !strings.Contains(err.Error(), "root's data is never kept") { t.Errorf("root's data at %s was not refused for what it is: %v", path, err) } if o := outcomeOf(report, "m.data"); o.Action == "waiting" { t.Errorf("root's data waits, and is never asked about: %+v", o) } if v := AccessVerdict(context.Background(), path, false, true); v.Kind != VerdictRoot { t.Errorf("the look judges root's data at %s %s", path, v.Kind) } } if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) { t.Fatal("a folder was made under root's data") } if _, has := keptRecord(root); has { t.Error("root's data was recorded as a kept folder") } } // The codec reads back what it writes, in the kernel's order. func TestAnACLReadsBackAsWritten(t *testing.T) { a := keptACL(minimalACL(0o640), map[uint32]uint16{radarr: 6, plex: 4}) b, err := decodeACL(a.encode()) if err != nil { t.Fatal(err) } if !sameACL(a, b) || b[0].Tag != aclUserObj || b[len(b)-1].Tag != aclOther { t.Fatalf("read back %+v", b) } if err := b.check(); err != nil { t.Fatal(err) } if _, err := decodeACL([]byte{1, 0, 0, 0}); err == nil { t.Error("a version 1 attribute was read") } if m, _ := b.perm(aclMask, 0); m != 6 { t.Errorf("mask %s, and the entries need rw- and the group had r--", permWords(m)) } }