Files
mesh-host/internal/apply/kept_test.go
T
jschoubben 51e97a8c14 Give every module an identity and keep shared folders through ACL entries (hq ADR 0325, issue 567)
A module's containers ran as whatever their image chose and shared folders were
made writable by putting accounts in the folder's group (ADR 0312), which ADR 0325
supersedes. The engine now makes each declared module identity as a system account
with the controller's numbers, runs a container declaring `identity` as it, and keeps
each placed shared folder usable through one POSIX ACL entry per user, set through
the kernel's xattrs on descriptors it opened itself. A folder holding data is kept
only after the operator's warrant (the take act); a missing folder is made where its
data lives and waits where its disk is not; root's data is never kept. A watch and a
bounded sweep put back what an app narrows. The group grant is removed, and what it
put an account in is taken back.

Reuses the descriptor walks, the create act, the missing-path wait and the warrant-
backed act message from the paused issue 567 branch.
2026-10-12 18:43:04 +02:00

590 lines
21 KiB
Go

//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))
}
}