package accounts import ( "context" "errors" "os" "path/filepath" "slices" "strings" "sync" "testing" "time" ) // The engine's own walk for setuid programs (novox/hq issue 361): what it finds, what it never follows, and that // a walk interrupted carries on from its cursor. // tree makes files under a directory of its own: a name ending in "/" a directory, "!" setuid, "%" setgid. func tree(t *testing.T, names ...string) string { t.Helper() top := t.TempDir() for _, n := range names { mode := os.FileMode(0o755) switch { case strings.HasSuffix(n, "/"): if err := os.MkdirAll(filepath.Join(top, n), 0o755); err != nil { t.Fatal(err) } continue case strings.HasSuffix(n, "!"): n, mode = strings.TrimSuffix(n, "!"), mode|os.ModeSetuid case strings.HasSuffix(n, "%"): n, mode = strings.TrimSuffix(n, "%"), mode|os.ModeSetgid } p := filepath.Join(top, n) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, nil, 0o644); err != nil { t.Fatal(err) } if err := os.Chmod(p, mode); err != nil { t.Fatal(err) } } return top } // mine is a walker for which this test's own account is root. func mine() walker { return walker{rootUID: uint32(os.Getuid()), rootGID: uint32(os.Getgid()), every: time.Hour} } func walkAll(t *testing.T, w walker, top string) []string { t.Helper() p := &SetuidProgress{Mounts: []string{top}} if err := w.walk(t.Context(), p, func() {}); err != nil { t.Fatal(err) } return rel(top, p.Found) } func rel(top string, paths []string) []string { var out []string for _, p := range paths { out = append(out, strings.TrimPrefix(p, top+"/")) } return out } func TestTheWalkFindsSetuidAndSetgidRootFilesAndFollowsNoLink(t *testing.T) { top := tree(t, "bin/plain", "bin/rootshell!", "sbin/group%", "deep/a/b/c/x!") if err := os.Symlink(filepath.Join(top, "bin/rootshell"), filepath.Join(top, "link")); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(top, "bin"), filepath.Join(top, "linkdir")); err != nil { t.Fatal(err) } found := walkAll(t, mine(), top) if strings.Join(found, ",") != "bin/rootshell,deep/a/b/c/x,sbin/group" { t.Fatalf("found %v", found) } if got := walkAll(t, walker{rootUID: 4242424, rootGID: 4242424, every: time.Hour}, top); len(got) != 0 { t.Fatalf("files nobody's root owns were found: %v", got) } } func TestAWalkThatCannotReadADirectoryIsIncomplete(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root reads every directory") } top := tree(t, "open/x!", "closed/y!") if err := os.Chmod(filepath.Join(top, "closed"), 0); err != nil { t.Fatal(err) } defer os.Chmod(filepath.Join(top, "closed"), 0o755) p := &SetuidProgress{Mounts: []string{top}} if err := mine().walk(t.Context(), p, func() {}); err == nil { t.Fatal("a walk that could not read a directory was complete") } } func TestWalkOrderIsNameByName(t *testing.T) { // "/a/b" is walked before "/a-c" although '-' sorts before '/': the walk goes name by name. if walkOrder(components("/a/b"), components("/a-c")) >= 0 { t.Fatal("/a/b is walked before /a-c") } if walkOrder(components("/a"), components("/a/b")) >= 0 || !isPrefix(components("/a"), components("/a/b")) { t.Fatal("a directory is walked before what is in it") } if len(components("/")) != 0 { t.Fatal("/ has no names") } } // A walk stopped midway carries on from its cursor: what was before the cursor is not walked again, what is after // it is, and every file of the first walk is found. func TestAnInterruptedWalkCarriesOnFromItsCursor(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!") w := mine() w.every = 0 // a checkpoint at every path ctx, stop := context.WithCancel(t.Context()) p := &SetuidProgress{Mounts: []string{top}} var kept []byte visits := 0 err := w.walk(ctx, p, func() { visits++ kept = mustJSON(t, p) if strings.HasSuffix(p.Cursor[top], "/b/c/4") { stop() // the engine stops here } }) if !errors.Is(err, context.Canceled) { t.Fatalf("the walk was stopped: %v", err) } var resumed SetuidProgress fromJSON(t, kept, &resumed) if !strings.HasSuffix(resumed.Cursor[top], "/b/c/4") { t.Fatalf("the kept cursor is %q", resumed.Cursor[top]) } // Meanwhile, a file appears before the cursor and one after it. for _, n := range []string{"a/late!", "e/late!"} { tree2(t, top, n) } w.every = time.Hour if err := w.walk(t.Context(), &resumed, func() {}); err != nil { t.Fatal(err) } got := rel(top, resumed.Found) want := []string{"a-b/3", "a/1", "b/c/4", "b/d", "c/6", "d/7", "e/late"} if !slices.Equal(got, want) { t.Fatalf("the resumed walk found %v, want %v: the first walk's finds kept, nothing before the cursor "+ "walked again, everything after it walked", got, want) } } func tree2(t *testing.T, top, n string) { t.Helper() p := filepath.Join(top, strings.TrimSuffix(n, "!")) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, nil, 0o644); err != nil { t.Fatal(err) } if err := os.Chmod(p, 0o755|os.ModeSetuid); err != nil { t.Fatal(err) } } // A search's commands and walk run on a lowered thread while the rest of the engine keeps its priority. func TestTheSearchRunsOnALoweredThreadOfItsOwn(t *testing.T) { var wg sync.WaitGroup wg.Add(1) var err error go func() { defer wg.Done() err = LowerThisThread() }() wg.Wait() if err != nil { t.Fatalf("the thread could not be lowered: %v", err) } } // Another filesystem's mount point is left without being read, and a file found that is gone, or no longer // setuid-root, when the walk ends is not asked about. func TestTheWalkLeavesMountPointsAndDropsWhatWentMeanwhile(t *testing.T) { top := tree(t, "fuse/secret!", "bin/x!", "bin/y!") w := mine() w.mountPoints = map[string]bool{filepath.Join(top, "fuse"): true} w.every = 0 p := &SetuidProgress{Mounts: []string{top}} removed := false if err := w.walk(t.Context(), p, func() { if !removed && strings.HasSuffix(p.Cursor[top], "/bin/y") { removed = true os.Remove(filepath.Join(top, "bin/x")) } }); err != nil { t.Fatal(err) } if got := rel(top, p.Found); strings.Join(got, ",") != "bin/y" { t.Fatalf("found %v", got) } } func TestAMountPointIsReadAsTheKernelEscapesIt(t *testing.T) { mp := MountPoints("/dev/a /mnt/a\\040b ext4 rw 0 0\n/dev/b /mnt/back\\134slash ext4 rw 0 0\n") if !mp["/mnt/a b"] || !mp[`/mnt/back\slash`] { t.Fatalf("read %v", mp) } }