Files
mesh-host/internal/profile/userns_test.go
T
mesh-tools 64dfa90c3a Report whether an unprivileged user namespace can be made (issue 1308)
A build module's sandbox needs one, and a module can require only a capability the node-engine detects (ADR 0448 section 5). The probe drops to an unprivileged uid first when run as root, since root always can.
2026-10-20 01:12:24 +00:00

156 lines
5.2 KiB
Go

package profile
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
)
// recorded is a runner that remembers every command it was given and answers the user-namespace
// probe with the given output and error. Other detectors get "not here".
type recorded struct {
mu sync.Mutex
calls [][]string
out string
err error
}
func (r *recorded) run(_ context.Context, name string, args ...string) (string, error) {
if name != "setpriv" && name != "unshare" {
return "", errors.New("not here")
}
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, append([]string{name}, args...))
return r.out, r.err
}
// asUID makes the detector believe it runs as uid, and reads no files from the machine.
func asUID(t *testing.T, uid int, procs map[string]string) {
t.Helper()
oldUID, oldRead := effectiveUID, readProcFile
effectiveUID = func() int { return uid }
readProcFile = func(path string) (string, error) {
if v, ok := procs[path]; ok {
return v, nil
}
return "", errors.New("no such file")
}
t.Cleanup(func() { effectiveUID, readProcFile = oldUID, oldRead })
}
func verdictOf(t *testing.T, p Profile, name string) Verdict {
t.Helper()
for _, v := range p.Capabilities {
if v.Name == name {
return v
}
}
t.Fatalf("no verdict named %s in the profile", name)
return Verdict{}
}
// unshare's own words when user namespaces are off, from util-linux's unshare.c:
// err(EXIT_FAILURE, _("unshare failed")), which prints "unshare: unshare failed: " and strerror(EPERM).
// ExecRunner wraps stderr as "<name> exited <code>: <stderr>".
const unshareRefused = "unshare exited 1: unshare: unshare failed: Operation not permitted"
func TestUserNamespacesAsRootDropsToAnUnprivilegedUIDFirst(t *testing.T) {
// 1308/AC1. Root can always make a user namespace, so a probe run as root proves nothing: the
// command must drop to an unprivileged uid before unshare runs.
asUID(t, 0, nil)
r := &recorded{}
got := Detect(context.Background(), Default(r.run), time.Second)
v := verdictOf(t, got, CapUserNamespaces)
if !v.Present {
t.Fatalf("a successful probe was reported absent: %+v", v)
}
want := "setpriv --reuid=65534 --regid=65534 --clear-groups unshare --user true"
if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != want {
t.Fatalf("the probe ran %v, want one call: %s", r.calls, want)
}
}
func TestUserNamespacesAbsentCarriesTheCommandsWords(t *testing.T) {
// 1308/AC2. The failing probe gives absent, and the detail is what unshare said.
asUID(t, 0, nil)
r := &recorded{err: errors.New(unshareRefused)}
got := Detect(context.Background(), Default(r.run), time.Second)
v := verdictOf(t, got, CapUserNamespaces)
if v.Present {
t.Fatal("a probe that failed was reported present")
}
if !strings.Contains(v.Detail, "unshare failed: Operation not permitted") {
t.Fatalf("the command's words were lost; detail was %q", v.Detail)
}
if strings.TrimSpace(v.How) == "" {
t.Fatal("an absent verdict with no stated method")
}
}
func TestUserNamespacesAbsentSaysWhyFromTheKernelSettings(t *testing.T) {
// 1308/AC2, the reasons: the two kernel settings that switch user namespaces off are read
// through a replaceable function and named in the detail.
asUID(t, 0, map[string]string{
"/proc/sys/user/max_user_namespaces": "0\n",
"/proc/sys/kernel/unprivileged_userns_clone": "0\n",
})
r := &recorded{err: errors.New(unshareRefused)}
v := verdictOf(t, Detect(context.Background(), Default(r.run), time.Second), CapUserNamespaces)
if v.Present {
t.Fatal("reported present")
}
// The detail is one line (firstLine), so the settings must be on that line.
if !strings.Contains(v.Detail, "max_user_namespaces=0") || !strings.Contains(v.Detail, "unprivileged_userns_clone=0") {
t.Fatalf("the kernel settings are not in the detail: %q", v.Detail)
}
}
func TestUserNamespacesNotAsRootUnsharesDirectly(t *testing.T) {
// 1308/AC3. An unprivileged node-engine is already the unprivileged user: no setpriv.
asUID(t, 1000, nil)
r := &recorded{}
v := verdictOf(t, Detect(context.Background(), Default(r.run), time.Second), CapUserNamespaces)
if !v.Present {
t.Fatalf("a successful probe was reported absent: %+v", v)
}
if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != "unshare --user true" {
t.Fatalf("the probe ran %v, want: unshare --user true", r.calls)
}
if strings.TrimSpace(v.Detail) == "" || strings.TrimSpace(v.How) == "" {
t.Fatalf("present verdict lacks detail or how: %+v", v)
}
}
func TestUserNamespacesProbeThatHangsIsCutAndTheRestStillReport(t *testing.T) {
// 1308/AC4. A wedged unshare is cut by Detect's timeout; the other detectors still answer.
asUID(t, 0, nil)
blocking := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "setpriv" {
<-ctx.Done()
return "", ctx.Err()
}
return "ok", nil
}
done := make(chan Profile, 1)
go func() { done <- Detect(context.Background(), Default(blocking), 50*time.Millisecond) }()
select {
case got := <-done:
if verdictOf(t, got, CapUserNamespaces).Present {
t.Error("a probe that never answered was reported present")
}
if !got.Has(CapPackageManager) {
t.Error("the other capabilities were lost behind the wedged probe")
}
case <-time.After(5 * time.Second):
t.Fatal("detection did not return")
}
}