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.
448 lines
15 KiB
Go
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")
|
|
}
|
|
}
|