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), 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(AgainAfter), 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) c.Look(at.Add(2*AgainAfter+time.Minute), root, g.search) if starts(c) != 3 { t.Fatalf("the ask made while a search ran was dropped: %d", starts(c)) } g.finish(c) // A filesystem the last search did not walk asks for one too. c.Look(at.Add(4*AgainAfter), []string{"/", "/srv"}, g.search) if starts(c) != 4 { t.Fatalf("a new filesystem was not searched: %d", starts(c)) } g.finish(c) var nilCache *SetuidCache nilCache.SearchAgain() } 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") } }