Files
mesh-host/internal/accounts/setuid_cache_test.go
T
jochen a7270f00cb
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
Keep the walk's paths as bytes, forget a walk root superseded, and leave other mounts unread (hq issue 361 review)
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.
2026-10-10 01:10:56 +02:00

448 lines
15 KiB
Go

package accounts
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// The search for setuid programs runs to completion and judges from its last complete result while it is fresh
// (novox/hq issue 361): never a bound that ends it, never a verdict from a search that did not finish or is too
// old, a new search after root acted, and the result kept beside the engine's state, believed only as root's.
var at = time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
var root = []string{"/"}
// gate is a search that waits until released; each search started hands its context on started. found and
// err are read when it is released.
type gate struct {
release chan struct{}
started chan context.Context
found []string
err error
}
func newGate() *gate {
return &gate{release: make(chan struct{}, 16), started: make(chan context.Context, 16)}
}
func (g *gate) search(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
g.started <- ctx
<-g.release
return g.found, g.err
}
func (g *gate) finish(c *SetuidCache) { g.release <- struct{}{}; c.Wait() }
// starts is how many searches c started.
func starts(c *SetuidCache) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.starts
}
func notJudged(t *testing.T, err error, pending bool) *SetuidNotJudged {
t.Helper()
var nj *SetuidNotJudged
if !errors.As(err, &nj) || nj.Pending != pending {
t.Fatalf("want not judged (pending %v), got %v", pending, err)
}
want := ReasonIncomplete
if pending {
want = ReasonPending
}
if !strings.HasPrefix(err.Error(), want) {
t.Fatalf("the words start %q: %v", want, err)
}
return nj
}
func TestTheSearchRunsToCompletionWithNoBound(t *testing.T) {
c := &SetuidCache{}
g := newGate()
if _, _, err := c.Look(at, root, g.search); err == nil {
t.Fatal("a look before the first search finished judged")
}
ctx := <-g.started
if _, ok := ctx.Deadline(); ok {
t.Fatal("the search was given a deadline: it must run until it has walked everything")
}
// Hours later, still running: still pending, never killed, never a second walk beside it.
_, _, err := c.Look(at.Add(5*time.Hour), root, g.search)
notJudged(t, err, true)
if starts(c) != 1 || ctx.Err() != nil {
t.Fatalf("a running search was started again or stopped: %d searches, %v", starts(c), ctx.Err())
}
g.found = []string{"/opt/x"}
g.finish(c)
found, when, err := c.Look(at.Add(5*time.Hour), root, g.search)
if err != nil || len(found) != 1 || !when.Equal(at) {
t.Fatalf("the complete search judges, from its start: %v %v %v", found, when, err)
}
}
func TestAFreshCompleteSearchJudgesUntilItIsDue(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.Every-time.Minute), root, g.search); err != nil {
t.Fatalf("a complete search within a day: %v", err)
}
if starts(c) != 1 {
t.Fatalf("searched again before it was due: %d", starts(c))
}
// Due: a new search starts, and the last complete one judges meanwhile.
if _, _, err := c.Look(at.Add(rootsearch.Every), root, g.search); err != nil {
t.Fatalf("while the daily search runs, the last complete one still judges: %v", err)
}
if starts(c) != 2 {
t.Fatalf("the daily search did not start: %d", starts(c))
}
g.finish(c)
}
func TestAStaleSearchIsNotJudgedNeverFree(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
c.Look(at.Add(rootsearch.Every), root, g.search) // the daily one, which does not finish
_, _, err := c.Look(at.Add(rootsearch.FreshFor), root, g.search)
nj := notJudged(t, err, true)
if !nj.Last.Equal(at) || !strings.Contains(err.Error(), "too old to judge") {
t.Fatalf("a stale search says when the last complete one started: %v", err)
}
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.FreshFor), root, g.search); err != nil {
t.Fatalf("once the new search completed it judges again: %v", err)
}
}
func TestAnIncompleteSearchIsNeverFree(t *testing.T) {
c := &SetuidCache{}
g := newGate()
g.err = errors.New("the search for setuid programs did not complete its walk")
c.Look(at, root, g.search)
g.finish(c)
_, _, err := c.Look(at.Add(time.Minute), root, g.search)
notJudged(t, err, false)
if starts(c) != 1 {
t.Fatalf("tried again within its back-off: %d", starts(c))
}
c.Look(at.Add(FailedBackoff+time.Second), root, g.search)
if starts(c) != 2 {
t.Fatalf("not tried again after its back-off: %d", starts(c))
}
g.finish(c)
// A stale complete search and a failed new one: still not judged, never the stale one.
c = &SetuidCache{}
g = newGate()
c.Look(at, root, g.search)
g.finish(c)
g.err = errors.New("pacman could not say who owns the setuid programs found")
c.Look(at.Add(rootsearch.Every), root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.Every+time.Hour), root, g.search); err != nil {
t.Fatalf("a failed search leaves the fresh complete one judging: %v", err)
}
_, _, err = c.Look(at.Add(rootsearch.FreshFor), root, g.search)
if err == nil {
t.Fatal("a stale search judged after the newer one failed")
}
g.finish(c)
}
func TestAnApplyThatMovedRootSearchesAgain(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
c.SearchAgain()
if _, _, err := c.Look(at.Add(time.Minute), root, g.search); err != nil || starts(c) != 1 {
t.Fatalf("asked again within AgainAfter of the last search's end: %v, %d searches", err, starts(c))
}
if _, _, err := c.Look(at.Add(AgainAfter+time.Second), root, g.search); err != nil {
t.Fatalf("the last complete search judges while the new one runs: %v", err)
}
if starts(c) != 2 {
t.Fatalf("an apply that moved root did not search again: %d", starts(c))
}
// Asked again while one runs: one more after it, never two at once.
c.SearchAgain()
c.Look(at.Add(AgainAfter+time.Minute), root, g.search)
if starts(c) != 2 {
t.Fatalf("a second search started beside the running one: %d", starts(c))
}
g.finish(c) // ends, on the cache's clock, at about AgainAfter+1s
c.Look(at.Add(2*AgainAfter), root, g.search)
if starts(c) != 2 {
t.Fatal("a search started again less than AgainAfter after the last one ended")
}
c.Look(at.Add(2*AgainAfter+2*time.Second), root, g.search)
if starts(c) != 3 {
t.Fatalf("the ask made while a search ran was dropped: %d", starts(c))
}
g.finish(c)
var nilCache *SetuidCache
nilCache.SearchAgain()
}
// A filesystem the last complete search did not walk is not judged by it: not judged yet, and a search starts.
func TestAFilesystemTheSearchDidNotWalkIsNotJudgedByIt(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
_, _, err := c.Look(at.Add(time.Hour), []string{"/", "/srv"}, g.search)
notJudged(t, err, true)
if starts(c) != 2 {
t.Fatalf("a new filesystem was not searched: %d", starts(c))
}
g.finish(c)
if _, _, err := c.Look(at.Add(time.Hour), []string{"/", "/srv"}, g.search); err != nil {
t.Fatalf("once walked it judges: %v", err)
}
}
// An ask to search again forgets the walk under way, so an engine restarted before the next search does not
// carry on a walk that started before root acted.
func TestAnAskToSearchAgainIsNotLostToARestart(t *testing.T) {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
first := paths()
first.Look(at, root, interrupted(seen))
<-seen
first.SearchAgain() // an apply moved root's things; then the engine restarts
restarted := paths()
var with SetuidProgress
restarted.Look(at.Add(time.Hour), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
with = *p
return nil, nil
})
restarted.Wait()
if !with.Started.Equal(at.Add(time.Hour)) || len(with.Found) != 0 {
t.Fatalf("the walk from before the apply was carried on: %+v", with)
}
}
// A cursor whose name is not UTF-8 is kept as its bytes: JSON strings would change it, and the resumed walk
// would skip what it stood before.
func TestACursorOfAnyBytesIsKeptExactly(t *testing.T) {
p := &SetuidProgress{Started: at, Mounts: Paths{"/"}, Cursor: Cursors{"/": "/home/agent/\x80/x"},
Found: Paths{"/opt/\xff"}}
var back SetuidProgress
fromJSON(t, mustJSON(t, p), &back)
if back.Cursor["/"] != "/home/agent/\x80/x" || back.Found[0] != "/opt/\xff" {
t.Fatalf("kept as %q, %q", back.Cursor["/"], back.Found[0])
}
}
func TestTheCompleteSearchIsKeptAndReadAfterARestart(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, SetuidSearchFile)
c := &SetuidCache{Path: path}
g := newGate()
g.found = []string{"/opt/x"}
c.Look(at, root, g.search)
g.finish(c)
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("kept readable by its owner alone: %v %v", info, err)
}
restarted := &SetuidCache{Path: path}
found, when, err := restarted.Look(at.Add(time.Hour), root, newGate().search)
if err != nil || len(found) != 1 || !when.Equal(at) {
t.Fatalf("a restarted engine judges from the kept search: %v %v %v", found, when, err)
}
restarted.Wait()
// And not once it is too old.
again := &SetuidCache{Path: path}
g2 := newGate()
if _, _, err := again.Look(at.Add(rootsearch.FreshFor), root, g2.search); err == nil {
t.Fatal("a kept search too old to judge judged")
}
g2.finish(again)
}
func TestAKeptSearchNotRootsAloneIsNotRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, SetuidSearchFile)
c := &SetuidCache{Path: path}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
for name, spoil := range map[string]func() error{
"writable by others": func() error { return os.Chmod(path, 0o666) },
"a link": func() error {
other := filepath.Join(dir, "elsewhere.json")
if err := os.Rename(path, other); err != nil {
return err
}
return os.Symlink(other, path)
},
"its directory writable by others": func() error { return os.Chmod(dir, 0o777) },
} {
c = &SetuidCache{Path: path}
g = newGate()
c.Look(at, root, g.search)
g.finish(c) // a fresh, good one kept again
os.Chmod(dir, 0o700)
if err := spoil(); err != nil {
t.Fatal(err)
}
var said []string
r := &SetuidCache{Path: path, Say: func(s string) { said = append(said, s) }}
gr := newGate()
if _, _, err := r.Look(at.Add(time.Minute), root, gr.search); err == nil {
t.Fatalf("%s: a kept search that is not root's alone judged", name)
}
if len(said) == 0 {
t.Fatalf("%s: refusing the kept search said nothing", name)
}
gr.finish(r)
os.Chmod(dir, 0o700)
os.Remove(path)
}
}
func TestAKeptSearchFromTheFutureIsNotFresh(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(-time.Hour), root, g.search); err == nil {
t.Fatal("a clock set back an hour read a search as fresh")
}
g.finish(c)
}
func TestTheIdleRunnerLowersItsCommand(t *testing.T) {
if _, err := exec.LookPath("nice"); err != nil {
t.Skip("no nice here")
}
out, err := Idle(t.Context(), "sh", "-c", "sleep 0.3; nice")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "19" {
t.Fatalf("the search's commands run at nice %q, want 19", strings.TrimSpace(out))
}
if _, err := Idle(t.Context(), "sh", "-c", "echo gone >&2; exit 1"); err == nil ||
!strings.HasPrefix(err.Error(), "sh exited 1: gone") {
t.Fatalf("its failure is worded as CLocale's: %v", err)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return raw
}
func fromJSON(t *testing.T, raw []byte, v any) {
t.Helper()
if err := json.Unmarshal(raw, v); err != nil {
t.Fatal(err)
}
}
// interrupted is a search that walks to a cursor, keeps it, and never returns: the engine stopped there.
func interrupted(seen chan<- *SetuidProgress) SetuidSearch {
return func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
p.Found = append(p.Found, "/opt/a")
p.Cursor["/"] = "/opt/a"
checkpoint()
seen <- p
select {} // the engine stops here
}
}
func TestASearchInterruptedByARestartCarriesOnAndIsDatedByItsStart(t *testing.T) {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
first := paths()
first.Look(at, root, interrupted(seen))
<-seen
// The engine restarts two hours later.
restarted := paths()
var resumedWith SetuidProgress
done := make(chan struct{})
_, _, err := restarted.Look(at.Add(2*time.Hour), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
resumedWith = *p
close(done)
return p.Found, nil
})
notJudged(t, err, true)
<-done
restarted.Wait()
if !resumedWith.Started.Equal(at) || resumedWith.Cursor["/"] != "/opt/a" || len(resumedWith.Found) != 1 {
t.Fatalf("the restarted search did not carry on the walk: %+v", resumedWith)
}
found, when, err := restarted.Look(at.Add(2*time.Hour), root, newGate().search)
if err != nil || !when.Equal(at) || len(found) != 1 {
t.Fatalf("the complete search is dated by its start, not its resumption: %v %v %v", found, when, err)
}
if _, err := os.Stat(filepath.Join(dir, SetuidProgressFile)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the finished walk's progress is still kept: %v", err)
}
}
func TestAWalkTooOldOrAskedAgainStartsOver(t *testing.T) {
for name, c := range map[string]struct {
after time.Duration
again bool
}{"older than it may judge": {rootsearch.FreshFor, false}, "an apply asked for a new one": {time.Hour, true}} {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
paths().Look(at, root, interrupted(seen))
<-seen
restarted := paths()
if c.again {
restarted.SearchAgain()
}
var with SetuidProgress
restarted.Look(at.Add(c.after), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
with = *p
return nil, nil
})
restarted.Wait()
if !with.Started.Equal(at.Add(c.after)) || len(with.Cursor) != 0 || len(with.Found) != 0 {
t.Fatalf("%s: the old walk was carried on: %+v", name, with)
}
}
}
func TestASearchWhosePriorityCannotBeLoweredDoesNotWalk(t *testing.T) {
c := &SetuidCache{Lower: func() error { return errors.New("not permitted") }}
walked := false
c.Look(at, root, func(context.Context, *SetuidProgress, func()) ([]string, error) { walked = true; return nil, nil })
c.Wait()
_, _, err := c.Look(at.Add(time.Minute), root, newGate().search)
notJudged(t, err, false)
if walked {
t.Fatal("it walked at full priority")
}
}