mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion delivered: every member is delivered
The review found a cursor of a name that is not UTF-8 came back changed from JSON, so a resumed walk skipped what it stood before; an ask to search again was lost to a restart, which carried the older walk on; a fresh search judged a filesystem it never walked; a mount point root cannot read failed or hung the walk; a found file gone before the package manager was asked failed every resume; and skipped or kept outcomes started a search on every reconcile, back to back with a long walk.
214 lines
6.4 KiB
Go
214 lines
6.4 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The engine's own walk for setuid programs (novox/hq issue 361): what it finds, what it never follows, and that
|
|
// a walk interrupted carries on from its cursor.
|
|
|
|
// tree makes files under a directory of its own: a name ending in "/" a directory, "!" setuid, "%" setgid.
|
|
func tree(t *testing.T, names ...string) string {
|
|
t.Helper()
|
|
top := t.TempDir()
|
|
for _, n := range names {
|
|
mode := os.FileMode(0o755)
|
|
switch {
|
|
case strings.HasSuffix(n, "/"):
|
|
if err := os.MkdirAll(filepath.Join(top, n), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
continue
|
|
case strings.HasSuffix(n, "!"):
|
|
n, mode = strings.TrimSuffix(n, "!"), mode|os.ModeSetuid
|
|
case strings.HasSuffix(n, "%"):
|
|
n, mode = strings.TrimSuffix(n, "%"), mode|os.ModeSetgid
|
|
}
|
|
p := filepath.Join(top, n)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(p, mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return top
|
|
}
|
|
|
|
// mine is a walker for which this test's own account is root.
|
|
func mine() walker {
|
|
return walker{rootUID: uint32(os.Getuid()), rootGID: uint32(os.Getgid()), every: time.Hour}
|
|
}
|
|
|
|
func walkAll(t *testing.T, w walker, top string) []string {
|
|
t.Helper()
|
|
p := &SetuidProgress{Mounts: []string{top}}
|
|
if err := w.walk(t.Context(), p, func() {}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rel(top, p.Found)
|
|
}
|
|
|
|
func rel(top string, paths []string) []string {
|
|
var out []string
|
|
for _, p := range paths {
|
|
out = append(out, strings.TrimPrefix(p, top+"/"))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestTheWalkFindsSetuidAndSetgidRootFilesAndFollowsNoLink(t *testing.T) {
|
|
top := tree(t, "bin/plain", "bin/rootshell!", "sbin/group%", "deep/a/b/c/x!")
|
|
if err := os.Symlink(filepath.Join(top, "bin/rootshell"), filepath.Join(top, "link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(filepath.Join(top, "bin"), filepath.Join(top, "linkdir")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := walkAll(t, mine(), top)
|
|
if strings.Join(found, ",") != "bin/rootshell,deep/a/b/c/x,sbin/group" {
|
|
t.Fatalf("found %v", found)
|
|
}
|
|
if got := walkAll(t, walker{rootUID: 4242424, rootGID: 4242424, every: time.Hour}, top); len(got) != 0 {
|
|
t.Fatalf("files nobody's root owns were found: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAWalkThatCannotReadADirectoryIsIncomplete(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root reads every directory")
|
|
}
|
|
top := tree(t, "open/x!", "closed/y!")
|
|
if err := os.Chmod(filepath.Join(top, "closed"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Chmod(filepath.Join(top, "closed"), 0o755)
|
|
p := &SetuidProgress{Mounts: []string{top}}
|
|
if err := mine().walk(t.Context(), p, func() {}); err == nil {
|
|
t.Fatal("a walk that could not read a directory was complete")
|
|
}
|
|
}
|
|
|
|
func TestWalkOrderIsNameByName(t *testing.T) {
|
|
// "/a/b" is walked before "/a-c" although '-' sorts before '/': the walk goes name by name.
|
|
if walkOrder(components("/a/b"), components("/a-c")) >= 0 {
|
|
t.Fatal("/a/b is walked before /a-c")
|
|
}
|
|
if walkOrder(components("/a"), components("/a/b")) >= 0 || !isPrefix(components("/a"), components("/a/b")) {
|
|
t.Fatal("a directory is walked before what is in it")
|
|
}
|
|
if len(components("/")) != 0 {
|
|
t.Fatal("/ has no names")
|
|
}
|
|
}
|
|
|
|
// A walk stopped midway carries on from its cursor: what was before the cursor is not walked again, what is after
|
|
// it is, and every file of the first walk is found.
|
|
func TestAnInterruptedWalkCarriesOnFromItsCursor(t *testing.T) {
|
|
top := tree(t, "a/1!", "a/2", "a-b/3!", "b/c/4!", "b/c/5", "b/d!", "c/6!", "d/7!")
|
|
w := mine()
|
|
w.every = 0 // a checkpoint at every path
|
|
ctx, stop := context.WithCancel(t.Context())
|
|
p := &SetuidProgress{Mounts: []string{top}}
|
|
var kept []byte
|
|
visits := 0
|
|
err := w.walk(ctx, p, func() {
|
|
visits++
|
|
kept = mustJSON(t, p)
|
|
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
|
|
stop() // the engine stops here
|
|
}
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("the walk was stopped: %v", err)
|
|
}
|
|
var resumed SetuidProgress
|
|
fromJSON(t, kept, &resumed)
|
|
if !strings.HasSuffix(resumed.Cursor[top], "/b/c/4") {
|
|
t.Fatalf("the kept cursor is %q", resumed.Cursor[top])
|
|
}
|
|
// Meanwhile, a file appears before the cursor and one after it.
|
|
for _, n := range []string{"a/late!", "e/late!"} {
|
|
tree2(t, top, n)
|
|
}
|
|
w.every = time.Hour
|
|
if err := w.walk(t.Context(), &resumed, func() {}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := rel(top, resumed.Found)
|
|
want := []string{"a-b/3", "a/1", "b/c/4", "b/d", "c/6", "d/7", "e/late"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("the resumed walk found %v, want %v: the first walk's finds kept, nothing before the cursor "+
|
|
"walked again, everything after it walked", got, want)
|
|
}
|
|
}
|
|
|
|
func tree2(t *testing.T, top, n string) {
|
|
t.Helper()
|
|
p := filepath.Join(top, strings.TrimSuffix(n, "!"))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(p, 0o755|os.ModeSetuid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// A search's commands and walk run on a lowered thread while the rest of the engine keeps its priority.
|
|
func TestTheSearchRunsOnALoweredThreadOfItsOwn(t *testing.T) {
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
var err error
|
|
go func() {
|
|
defer wg.Done()
|
|
err = LowerThisThread()
|
|
}()
|
|
wg.Wait()
|
|
if err != nil {
|
|
t.Fatalf("the thread could not be lowered: %v", err)
|
|
}
|
|
}
|
|
|
|
// Another filesystem's mount point is left without being read, and a file found that is gone, or no longer
|
|
// setuid-root, when the walk ends is not asked about.
|
|
func TestTheWalkLeavesMountPointsAndDropsWhatWentMeanwhile(t *testing.T) {
|
|
top := tree(t, "fuse/secret!", "bin/x!", "bin/y!")
|
|
w := mine()
|
|
w.mountPoints = map[string]bool{filepath.Join(top, "fuse"): true}
|
|
w.every = 0
|
|
p := &SetuidProgress{Mounts: []string{top}}
|
|
removed := false
|
|
if err := w.walk(t.Context(), p, func() {
|
|
if !removed && strings.HasSuffix(p.Cursor[top], "/bin/y") {
|
|
removed = true
|
|
os.Remove(filepath.Join(top, "bin/x"))
|
|
}
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := rel(top, p.Found); strings.Join(got, ",") != "bin/y" {
|
|
t.Fatalf("found %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAMountPointIsReadAsTheKernelEscapesIt(t *testing.T) {
|
|
mp := MountPoints("/dev/a /mnt/a\\040b ext4 rw 0 0\n/dev/b /mnt/back\\134slash ext4 rw 0 0\n")
|
|
if !mp["/mnt/a b"] || !mp[`/mnt/back\slash`] {
|
|
t.Fatalf("read %v", mp)
|
|
}
|
|
}
|