Let the setuid search run to its 15-minute bound, ask pacman once, and call a walk with errors incomplete
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 fail: its merge-check.sh failed: --- FAIL: TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots (0.00s)
mesh/delivery superseded: a newer head of the same pull request

The search kept a two-minute limit of its own inside its 15-minute bound, so
a long walk always ended incomplete; pacman was asked once per path; and find
exiting with an error but some output was read as a complete walk. The bound
and the not-judged reasons move to the public rootsearch package so the
controller reads the same values (the review of 2026-10-09).
This commit is contained in:
jochen
2026-10-09 12:11:57 +02:00
parent b28d7bbcbf
commit 2673e7a2c9
4 changed files with 195 additions and 40 deletions
+17 -5
View File
@@ -38,6 +38,8 @@ type agentMachine struct {
// proc is /proc's files, aSafeProc unless a test changes them; findArgs sees find's arguments.
proc map[string]string
findArgs func([]string)
// pacmanCalls counts the package manager's calls: one for every path found.
pacmanCalls int
}
func (m *agentMachine) readFile(path string) ([]byte, error) {
@@ -107,11 +109,21 @@ func (m *agentMachine) run(_ context.Context, name string, args ...string) (stri
m.findArgs(args)
}
return m.setuid, m.findErr
case name == "pacman" && len(args) == 2 && args[0] == "-Qqo":
if m.packaged[args[1]] {
return "somepackage\n", nil
case name == "pacman" && len(args) >= 2 && args[0] == "-Qo":
m.pacmanCalls++
var out, errs []string
for _, p := range args[1:] {
if m.packaged[p] {
out = append(out, p+" is owned by somepackage 1.0-1")
} else {
errs = append(errs, "error: No package owns "+p)
}
}
return "", errors.New("pacman exited 1: error: No package owns " + args[1])
said := strings.Join(out, "\n")
if len(errs) > 0 {
return said, errors.New("pacman exited 1: " + strings.Join(errs, "\n"))
}
return said, nil
}
return "", errors.New("not a command the judge may run: " + line)
}
@@ -144,7 +156,7 @@ func lookAgent(t *testing.T, m *agentMachine) Verdict {
}
for _, a := range m.asked {
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "find / ") &&
!strings.HasPrefix(a, "pacman -Qqo ") {
!strings.HasPrefix(a, "pacman -Qo ") {
t.Errorf("the judge asked something that is not a read: %q", a)
}
}
+89 -35
View File
@@ -10,6 +10,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"github.com/novox/mesh-host/rootsearch"
"io/fs"
"os"
"os/exec"
@@ -398,9 +399,11 @@ type SetuidCache struct {
backoff time.Duration
}
// SearchBound is how long one search may run in the background; MaxBackoff the longest wait after failures.
// SearchBound is how long one search may run in the background — the whole search, the walk and the package
// manager's answers together (rootsearch.Bound, which the controller reads too); MaxBackoff the longest wait
// after failures.
const (
SearchBound = 15 * time.Minute
SearchBound = rootsearch.Bound
MaxBackoff = 6 * time.Hour
)
@@ -424,8 +427,8 @@ func (e *SetuidNotJudged) Error() string {
// The reasons of a root verdict the setuid search could not answer.
const (
ReasonPending = "not judged yet (search running)"
ReasonIncomplete = "not judged (search incomplete)"
ReasonPending = rootsearch.ReasonPending
ReasonIncomplete = rootsearch.ReasonIncomplete
)
// Look answers the last result and when its search finished, starting a search in the background when one is
@@ -475,11 +478,10 @@ func (c *SetuidCache) Look(now time.Time, search func(ctx context.Context) ([]st
func (c *SetuidCache) Wait() { c.wg.Wait() }
// setuidSearch is every setuid- or setgid-root regular file on the root filesystem that no installed package
// owns, found with find and asked of the package manager. Bounded: a search that does not finish is an
// unanswered question, never "none".
// owns, found with find and asked of the package manager. Bounded by its caller (SearchBound, the whole search):
// a search that does not finish is an unanswered question, never "none". A walk find could not complete — it
// exited with an error — is incomplete too, unless every error was a file that vanished while it walked.
func (e Exec) setuidSearch(ctx context.Context, mounts []string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
// Each mount point a starting point of its own, -xdev keeping each to its own filesystem: every local
// filesystem mounted without nosuid is searched once (the re-review of 2026-10-08), and a program setgid to
// root's group counts whoever owns it.
@@ -489,10 +491,10 @@ func (e Exec) setuidSearch(ctx context.Context, mounts []string) ([]string, erro
"-2000", ")", ")", "-print")
out, err := e.Run(ctx, "find", args...)
if ctx.Err() != nil {
return nil, fmt.Errorf("the search for setuid programs did not finish within two minutes")
return nil, fmt.Errorf("the search for setuid programs did not finish within %s", SearchBound)
}
if err != nil && strings.TrimSpace(out) == "" {
return nil, fmt.Errorf("the search for setuid programs did not finish: %w", err)
if err != nil && (strings.TrimSpace(out) == "" || !onlyVanished(err)) {
return nil, fmt.Errorf("the search for setuid programs did not complete its walk: %w", err)
}
var paths []string
for _, l := range strings.Split(out, "\n") {
@@ -501,36 +503,88 @@ func (e Exec) setuidSearch(ctx context.Context, mounts []string) ([]string, erro
}
}
sort.Strings(paths)
var unowned []string
for _, p := range paths {
owned, err := e.packaged(ctx, p)
if err != nil {
return nil, err
}
if !owned {
unowned = append(unowned, p)
}
}
return unowned, nil
return e.unpackaged(ctx, paths)
}
// packaged says whether an installed package owns a path: pacman's or apk's answer, never a guess.
func (e Exec) packaged(ctx context.Context, path string) (bool, error) {
out, err := e.Run(ctx, "pacman", "-Qqo", path)
if err == nil {
return strings.TrimSpace(out) != "", nil
// onlyVanished says every error find reported was a file that went while it walked — the one error that leaves
// the walk complete for what still exists. find's own words come after its exit, one per line.
func onlyVanished(err error) bool {
said := false
for _, l := range errorLines(err, "find") {
said = true
if !strings.Contains(l, "No such file or directory") {
return false
}
}
if strings.Contains(out+err.Error(), "No package owns") || strings.Contains(err.Error(), "exited 1") {
return false, nil
return said
}
// errorLines are the lines a command said on its error stream, as the runner words a failure: "<name> exited N:
// <its words>", the prefix taken off the first line. Empty lines are dropped.
func errorLines(err error, name string) []string {
words := err.Error()
if strings.HasPrefix(words, name+" exited") {
if i := strings.Index(words, ": "); i >= 0 {
words = words[i+2:]
}
}
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("pacman could not say who owns %s: %w", path, err)
var out []string
for _, l := range strings.Split(words, "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
out, err = e.Run(ctx, "apk", "info", "-W", path)
if err != nil {
return false, fmt.Errorf("no package manager could say who owns %s: %w", path, err)
return out
}
// unpackaged is every path no installed package owns: pacman asked about all of them in one call, else apk
// about each. Never a guess: a path neither said owned nor said unowned is an unanswered question.
func (e Exec) unpackaged(ctx context.Context, paths []string) ([]string, error) {
if len(paths) == 0 {
return nil, nil
}
return strings.Contains(out, " is owned by "), nil
out, err := e.Run(ctx, "pacman", append([]string{"-Qo"}, paths...)...)
if err == nil || !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
owned := map[string]bool{}
for _, l := range strings.Split(out, "\n") {
if p, _, ok := strings.Cut(strings.TrimSpace(l), " is owned by "); ok {
owned[p] = true
}
}
unowned := map[string]bool{}
if err != nil {
// pacman exits 1 when a path is owned by nothing, and says which on its error stream.
for _, l := range errorLines(err, "pacman") {
if _, p, ok := strings.Cut(l, "No package owns "); ok {
unowned[strings.TrimSpace(p)] = true
} else {
return nil, fmt.Errorf("pacman could not say who owns the setuid programs found: %s", l)
}
}
}
var out []string
for _, p := range paths {
switch {
case owned[p]:
case unowned[p]:
out = append(out, p)
default:
return nil, fmt.Errorf("pacman said nothing of who owns %s", p)
}
}
return out, nil
}
var none []string
for _, p := range paths {
said, err := e.Run(ctx, "apk", "info", "-W", p)
if err != nil {
return nil, fmt.Errorf("no package manager could say who owns %s: %w", p, err)
}
if !strings.Contains(said, " is owned by ") {
none = append(none, p)
}
}
return none, nil
}
// moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none.
+70
View File
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"github.com/novox/mesh-host/rootsearch"
"os/exec"
"strings"
"testing"
@@ -318,3 +319,72 @@ polkit.addRule(function(a, s) { /* isInGroup("wheel") */ return polkit.Result.YE
t.Error("each .pkla section on its own")
}
}
// The search's bound is the one the controller reads (rootsearch.Bound): a search may run its whole fifteen
// minutes, with no shorter limit inside it that would end every long walk incomplete (the review of 2026-10-09).
func TestTheSearchRunsToItsBoundWithNoShorterLimitInside(t *testing.T) {
if rootsearch.Bound != 15*time.Minute {
t.Fatalf("the shared bound is %s", rootsearch.Bound)
}
m := clean()
var left time.Duration
run := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "find" {
if d, ok := ctx.Deadline(); ok {
left = time.Until(d)
}
}
return m.run(ctx, name, args...)
}
ctx, cancel := context.WithTimeout(t.Context(), SearchBound)
defer cancel()
if _, err := (Exec{Run: run}).setuidSearch(ctx, []string{"/"}); err != nil {
t.Fatal(err)
}
if left < SearchBound-time.Minute {
t.Fatalf("find was given %s, less than the search's bound of %s", left, SearchBound)
}
}
// The package manager is asked about every path found in one call, and answers each: owned, or owned by nothing.
func TestThePackageManagerIsAskedOnceForEveryPathFound(t *testing.T) {
m := clean()
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n/usr/local/bin/x\n/opt/y\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
found, err := (Exec{Run: m.run}).setuidSearch(t.Context(), []string{"/"})
if err != nil {
t.Fatal(err)
}
if m.pacmanCalls != 1 {
t.Errorf("pacman was asked %d times for four paths", m.pacmanCalls)
}
if strings.Join(found, ",") != "/opt/y,/usr/local/bin/x" {
t.Errorf("unowned: %v", found)
}
// An error pacman says that is not "owned by nothing" is no answer.
odd := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "pacman" {
return "", errors.New("pacman exited 1: error: could not open database")
}
return m.run(ctx, name, args...)
}
if _, err := (Exec{Run: odd}).setuidSearch(t.Context(), []string{"/"}); err == nil {
t.Error("a package manager that could not answer was read as an answer")
}
}
// find that exits with an error has not walked everything: the search is incomplete, unless every error was a
// file that vanished while it walked.
func TestAWalkFindDidNotCompleteIsIncomplete(t *testing.T) {
m := clean()
m.setuid = "/usr/local/bin/x\n"
m.findErr = errors.New("find exited 1: find: '/mnt/data/private': Permission denied")
if _, err := (Exec{Run: m.run}).setuidSearch(t.Context(), []string{"/"}); err == nil {
t.Fatal("a walk that could not enter a directory was read as complete")
}
m.findErr = errors.New("find exited 1: find: '/tmp/a': No such file or directory\nfind: '/tmp/b': No such file or directory")
found, err := (Exec{Run: m.run}).setuidSearch(t.Context(), []string{"/"})
if err != nil || len(found) != 1 {
t.Fatalf("files that vanished while find walked: %v %v", found, err)
}
}