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 " exited : ". 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") } }