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-group group fix/361-the-setuid-search-runs-to-completion ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The control-node's walk takes far longer than the engine runs between updates, so a search that starts over at every restart never completes. The walk is the engine's own, in lexical order, so its progress is one cursor per filesystem, kept every minute beside the engine's state and carried on after a restart while younger than rootsearch.FreshFor. It runs on a thread of its own at nice 19 and the idle I/O class.
185 lines
5.4 KiB
Go
185 lines
5.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)
|
|
}
|
|
}
|