diff --git a/internal/system/util.go b/internal/system/util.go index d1676f2..7a946b9 100644 --- a/internal/system/util.go +++ b/internal/system/util.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "os/user" + "strings" "github.com/fatih/color" ) @@ -58,5 +59,55 @@ func realUser() (*user.User, error) { return user.Lookup("root") } - return user.Lookup(realUser) + u, err := user.Lookup(realUser) + if err == nil { + return u, nil + } + + var unknownUserErr user.UnknownUserError + if errors.As(err, &unknownUserErr) { + return lookupUserGetent(realUser) + } + + return nil, err +} + +// getentBinary is the name of the `getent` binary to invoke. It is a variable +// so that tests can replace it to exercise failure modes (e.g. binary missing). +var getentBinary = "getent" + +// lookupUserGetent looks up a user via `getent passwd`, which queries NSS and +// therefore works for users provided by SSSD, LDAP, and similar sources. This +// is needed because Go's [user.Lookup] only reads /etc/passwd when the binary +// is built with CGO_ENABLED=0. +// +// This uses exec.Command directly rather than the System worker because it runs +// during System construction, before the worker pipeline is available. +func lookupUserGetent(username string) (*user.User, error) { + out, err := exec.Command(getentBinary, "passwd", username).Output() + if err != nil { + // `getent passwd ` exits 2 when the key is not found; treat that + // as "unknown user" to match the stdlib error. Anything else (binary + // missing, NSS misconfiguration, ...) is wrapped so the underlying + // cause is not lost. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 { + return nil, user.UnknownUserError(username) + } + return nil, fmt.Errorf("getent passwd %s: %w", username, err) + } + + // getent passwd format: username:password:uid:gid:gecos:home:shell + parts := strings.SplitN(strings.TrimSpace(string(out)), ":", 7) + if len(parts) < 6 { + return nil, fmt.Errorf("getent passwd %s: unexpected output %q", username, string(out)) + } + + return &user.User{ + Username: parts[0], + Uid: parts[2], + Gid: parts[3], + Name: parts[4], + HomeDir: parts[5], + }, nil } diff --git a/internal/system/util_test.go b/internal/system/util_test.go new file mode 100644 index 0000000..6403b81 --- /dev/null +++ b/internal/system/util_test.go @@ -0,0 +1,70 @@ +package system + +import ( + "errors" + "os/exec" + "os/user" + "testing" +) + +func TestLookupUserGetent(t *testing.T) { + // Verify getent is available on this system. + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + // user.Current works even for SSSD/LDAP users not in /etc/passwd, because + // it reads the UID from /proc/self rather than searching /etc/passwd by + // name. Use it as the reference to verify lookupUserGetent returns the + // same information. + current, err := user.Current() + if err != nil { + t.Fatalf("user.Current() failed: %v", err) + } + + got, err := lookupUserGetent(current.Username) + if err != nil { + t.Fatalf("lookupUserGetent(%q) failed: %v", current.Username, err) + } + + if got.Username != current.Username { + t.Errorf("Username: got %q, want %q", got.Username, current.Username) + } + if got.Uid != current.Uid { + t.Errorf("Uid: got %q, want %q", got.Uid, current.Uid) + } + if got.Gid != current.Gid { + t.Errorf("Gid: got %q, want %q", got.Gid, current.Gid) + } + if got.HomeDir != current.HomeDir { + t.Errorf("HomeDir: got %q, want %q", got.HomeDir, current.HomeDir) + } +} + +func TestLookupUserGetentUnknownUser(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + _, err := lookupUserGetent("nonexistent-user-that-should-not-exist") + var unknownUserErr user.UnknownUserError + if !errors.As(err, &unknownUserErr) { + t.Fatalf("expected user.UnknownUserError, got %v", err) + } +} + +func TestLookupUserGetentBinaryMissing(t *testing.T) { + t.Cleanup(func() { getentBinary = "getent" }) + getentBinary = "/nonexistent/path/to/getent" + + _, err := lookupUserGetent("anyone") + if err == nil { + t.Fatal("expected error when getent binary is missing, got nil") + } + // A missing binary must not be reported as "unknown user" — that would + // hide the real failure from the operator. + var unknownUserErr user.UnknownUserError + if errors.As(err, &unknownUserErr) { + t.Fatalf("missing binary should not surface as UnknownUserError, got %v", err) + } +}