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) } }