Files
mesh-host/internal/accounts/setuid_progress_test.go
T
jochen 0b4c86e3be
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
Count a setuid find only where the account can execute it, read at every judgement (hq issue 361)
The two finds on the control-node sit in the operator's home of mode 0700, which the agent account cannot
search. The search keeps every find; each judgement reads the modes on the way again, since an account can
open its own home without root. A find in a home is said by count, its path in the journal alone. Also
from review: a checkpoint racing SearchNow forgets the walk it just kept, and a walk kept by an older engine
says its counts as lower bounds.
2026-10-10 02:00:10 +02:00

219 lines
8.4 KiB
Go

package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// How far a running search got is readable through the verdict (novox/hq issue 361): counts, times and mount
// points, counted once across a resume, and never a path the walk visited or found.
// A walk stopped midway and resumed counts every entry once, as the walk that was never stopped does, and says
// how long each filesystem took.
func TestTheProgressCountsEachEntryOnceAcrossAResume(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!")
other := tree(t, "x/1", "y!")
whole := &SetuidProgress{Mounts: []string{top, other}}
if err := mine().walk(t.Context(), whole, func() {}); err != nil {
t.Fatal(err)
}
// top and the 14 entries below it, and other and its 3.
if whole.Visited != 15+4 || len(whole.Took) != 2 || whole.Took[0] < 0 || whole.Walking != 0 {
t.Fatalf("the whole walk: visited %d, took %v, walking %v", whole.Visited, whole.Took, whole.Walking)
}
w := mine()
w.every = 0
ctx, stop := context.WithCancel(t.Context())
p := &SetuidProgress{Mounts: []string{top, other}, Counted: true}
var kept []byte
err := w.walk(ctx, p, func() {
kept = mustJSON(t, p)
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
stop()
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("the walk was stopped: %v", err)
}
var resumed SetuidProgress
fromJSON(t, kept, &resumed)
if resumed.Visited == 0 || resumed.Visited >= whole.Visited || resumed.Walking < 0 {
t.Fatalf("the kept walk counted %d of %d", resumed.Visited, whole.Visited)
}
if err := mine().walk(t.Context(), &resumed, func() {}); err != nil {
t.Fatal(err)
}
if resumed.Visited != whole.Visited || len(resumed.Took) != 2 || resumed.Took[0] < 0 {
t.Fatalf("the resumed walk counted %d entries (the whole one %d), took %v", resumed.Visited,
whole.Visited, resumed.Took)
}
}
// A walk kept before the times were counted has them not known, never shifted onto the next filesystem, and
// its entries visited are a lower bound; the filesystem it was part-way through took a time not known.
func TestAWalkKeptWithoutTimesSaysThemNotKnown(t *testing.T) {
top, other := tree(t, "a"), tree(t, "b")
p := &SetuidProgress{Mounts: []string{top, other}, Done: Paths{top}}
if err := mine().walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
if len(p.Took) != 2 || p.Took[0] >= 0 || p.Took[1] < 0 {
t.Fatalf("took %v", p.Took)
}
if w := progressOf(p, time.Time{}).Words(); !strings.Contains(w, top+" in a time not known") ||
!strings.Contains(w, "at least ") {
t.Fatalf("the words: %s", w)
}
}
// A look while the search runs says how far it got at its last checkpoint, with no path it visited or found.
func TestALookSaysHowFarTheRunningSearchGotAndNoPath(t *testing.T) {
dir := t.TempDir()
c := &SetuidCache{ProgressPath: filepath.Join(dir, SetuidProgressFile)}
secret := "/home/someone/private/rootshell"
checkpointed, release := make(chan struct{}), make(chan struct{})
search := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
p.Mounts = Paths{"/", "/home", "/boot"}
p.Done, p.Took = Paths{"/"}, []time.Duration{90 * time.Minute}
p.Cursor["/home"] = "/home/someone/private"
p.Found = Paths{"/usr/bin/odd", secret}
p.Visited = 1234567
checkpoint()
close(checkpointed)
<-release
return nil, nil
}
_, _, err := c.Look(at, root, search)
nj := notJudged(t, err, true)
if nj.Progress == nil || nj.Progress.Visited != 0 || nj.Progress.Walking != "/" {
t.Fatalf("before the first checkpoint: %+v", nj.Progress)
}
<-checkpointed
_, _, err = c.Look(at.Add(time.Minute), root, search)
nj = notJudged(t, err, true)
sp := nj.Progress
if sp == nil || sp.Filesystems != 3 || sp.Done != 1 || sp.Walking != "/home" || sp.Visited != 1234567 ||
sp.Found != 2 || sp.Kept.IsZero() || len(sp.Finished) != 1 || sp.Finished[0].Took != 90*time.Minute {
t.Fatalf("the progress: %+v", sp)
}
words := sp.Words()
for _, want := range []string{"1 of 3 filesystems walked", "walking /home", "1234567 entries visited",
"2 setuid- or setgid-root files found so far", "kept " + sp.Kept.Local().Format("2006-01-02 15:04:05"),
"walked / in 1h30m0s"} {
if !strings.Contains(words, want) {
t.Errorf("the words lack %q: %s", want, words)
}
}
for _, leak := range []string{"someone", "private", "odd", "rootshell"} {
if strings.Contains(words, leak) || strings.Contains(err.Error(), leak) {
t.Errorf("a path leaks (%q): %s / %v", leak, words, err)
}
}
close(release)
c.Wait()
if _, _, err := c.Look(at.Add(2*time.Minute), root, search); err != nil {
t.Fatalf("the complete search judges: %v", err)
}
}
// The progress moves every minute; the verdict does not change for it, so the journal says it once.
type waiting struct{ progress *SearchProgress }
func (w *waiting) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
func (w *waiting) Session(context.Context, string, []string) (Session, error) {
return Session{}, nil
}
func (w *waiting) Escalation(context.Context, string, []string) ([]string, error) {
return nil, &SetuidNotJudged{Pending: true, Since: at, Progress: w.progress}
}
func TestTheProgressMovesWithoutChangingTheVerdict(t *testing.T) {
r := &waiting{progress: &SearchProgress{Filesystems: 2, Visited: 10}}
j := New(r)
j.Set([]Account{{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}})
st, changed := j.Look(t.Context())
if !changed || st.Accounts[0].State != Unknown || st.Accounts[0].Search.Visited != 10 {
t.Fatalf("first look: changed %v, %+v", changed, st.Accounts[0])
}
r.progress = &SearchProgress{Filesystems: 2, Visited: 20}
st, changed = j.Look(t.Context())
if changed || st.Accounts[0].Search.Visited != 20 || strings.Contains(st.Accounts[0].Reason, "progress") {
t.Fatalf("second look: changed %v, %+v", changed, st.Accounts[0])
}
}
// Asked at the terminal, a search starts afresh at the next look: the last complete one is thrown away, kept or
// in memory, and a running one is ended and neither judges nor is carried on.
func TestSearchNowThrowsAwayTheLastSearchAndStartsAFullOne(t *testing.T) {
dir := t.TempDir()
c := &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
g := newGate()
g.found = []string{"/home/someone/rootshell"}
c.Look(at, root, g.search)
<-g.started
g.finish(c)
if found, _, err := c.Look(at.Add(time.Hour), root, g.search); err != nil || len(found) != 1 {
t.Fatalf("the complete search judges: %v %v", found, err)
}
said := c.SearchNow("jo at the controller's terminal")
if !strings.Contains(said, "thrown away") || strings.Contains(said, "someone") {
t.Fatalf("said: %s", said)
}
if _, err := os.Stat(c.Path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the kept search is still there: %v", err)
}
// The next look starts a full search at once, though the last ended only an hour ago.
ended := make(chan error, 1)
running := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
if len(p.Done) != 0 || len(p.Cursor) != 0 {
t.Errorf("not a full search: %+v", p)
}
checkpoint()
<-ctx.Done()
ended <- ctx.Err()
return nil, ctx.Err()
}
_, _, err := c.Look(at.Add(time.Hour+time.Minute), root, running)
notJudged(t, err, true)
if starts(c) != 2 {
t.Fatalf("searches started: %d", starts(c))
}
// Asked again while it runs: it is ended, judges nothing, fails nothing, and is not carried on.
c.SearchNow("jo")
if err := <-ended; !errors.Is(err, context.Canceled) {
t.Fatalf("the running search was not ended: %v", err)
}
c.Wait()
if _, err := os.Stat(c.ProgressPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the ended walk is still kept: %v", err)
}
g2 := newGate()
_, _, err = c.Look(at.Add(time.Hour+2*time.Minute), root, g2.search)
notJudged(t, err, true)
<-g2.started
g2.finish(c)
if starts(c) != 3 {
t.Fatalf("searches started: %d", starts(c))
}
if _, _, err := c.Look(at.Add(time.Hour+3*time.Minute), root, g2.search); err != nil {
t.Fatalf("the new search judges: %v", err)
}
}
func TestAFilesystemPartWalkedByAnEngineThatDidNotCountTookATimeNotKnown(t *testing.T) {
top := tree(t, "a", "b")
p := &SetuidProgress{Mounts: []string{top}, Cursor: Cursors{top: filepath.Join(top, "a")}}
if err := mine().walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
if len(p.Took) != 1 || p.Took[0] >= 0 {
t.Fatalf("took %v", p.Took)
}
}