mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-25 09:01:29 +02:00
472 lines
12 KiB
Go
472 lines
12 KiB
Go
//go:build !windows
|
|
|
|
package getent
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestParsePasswd(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantUser *user.User
|
|
wantShell string
|
|
wantErr bool
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "standard entry",
|
|
input: "alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash\n",
|
|
wantUser: &user.User{
|
|
Username: "alice",
|
|
Uid: "1001",
|
|
Gid: "1001",
|
|
Name: "Alice Smith",
|
|
HomeDir: "/home/alice",
|
|
},
|
|
wantShell: "/bin/bash",
|
|
},
|
|
{
|
|
name: "root entry",
|
|
input: "root:x:0:0:root:/root:/bin/bash",
|
|
wantUser: &user.User{
|
|
Username: "root",
|
|
Uid: "0",
|
|
Gid: "0",
|
|
Name: "root",
|
|
HomeDir: "/root",
|
|
},
|
|
wantShell: "/bin/bash",
|
|
},
|
|
{
|
|
name: "empty gecos field",
|
|
input: "svc:x:999:999::/var/lib/svc:/usr/sbin/nologin",
|
|
wantUser: &user.User{
|
|
Username: "svc",
|
|
Uid: "999",
|
|
Gid: "999",
|
|
Name: "",
|
|
HomeDir: "/var/lib/svc",
|
|
},
|
|
wantShell: "/usr/sbin/nologin",
|
|
},
|
|
{
|
|
name: "gecos with commas",
|
|
input: "john:x:1002:1002:John Doe,Room 101,555-1234,555-4321:/home/john:/bin/zsh",
|
|
wantUser: &user.User{
|
|
Username: "john",
|
|
Uid: "1002",
|
|
Gid: "1002",
|
|
Name: "John Doe,Room 101,555-1234,555-4321",
|
|
HomeDir: "/home/john",
|
|
},
|
|
wantShell: "/bin/zsh",
|
|
},
|
|
{
|
|
name: "remote user with large UID",
|
|
input: "remoteuser:*:50001:50001:Remote User:/home/remoteuser:/bin/bash\n",
|
|
wantUser: &user.User{
|
|
Username: "remoteuser",
|
|
Uid: "50001",
|
|
Gid: "50001",
|
|
Name: "Remote User",
|
|
HomeDir: "/home/remoteuser",
|
|
},
|
|
wantShell: "/bin/bash",
|
|
},
|
|
{
|
|
name: "no shell field (only 6 fields)",
|
|
input: "minimal:x:1000:1000::/home/minimal",
|
|
wantUser: &user.User{
|
|
Username: "minimal",
|
|
Uid: "1000",
|
|
Gid: "1000",
|
|
Name: "",
|
|
HomeDir: "/home/minimal",
|
|
},
|
|
wantShell: "",
|
|
},
|
|
{
|
|
name: "too few fields",
|
|
input: "bad:x:1000",
|
|
wantErr: true,
|
|
errContains: "need 6+ fields",
|
|
},
|
|
{
|
|
name: "empty username",
|
|
input: ":x:1000:1000::/home/test:/bin/bash",
|
|
wantErr: true,
|
|
errContains: "missing required fields",
|
|
},
|
|
{
|
|
name: "empty UID",
|
|
input: "test:x::1000::/home/test:/bin/bash",
|
|
wantErr: true,
|
|
errContains: "missing required fields",
|
|
},
|
|
{
|
|
name: "empty GID",
|
|
input: "test:x:1000:::/home/test:/bin/bash",
|
|
wantErr: true,
|
|
errContains: "missing required fields",
|
|
},
|
|
{
|
|
name: "empty input",
|
|
input: "",
|
|
wantErr: true,
|
|
errContains: "need 6+ fields",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
u, shell, err := parsePasswd(tt.input)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
if tt.errContains != "" {
|
|
assert.Contains(t, err.Error(), tt.errContains)
|
|
}
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantUser.Username, u.Username, "username")
|
|
assert.Equal(t, tt.wantUser.Uid, u.Uid, "UID")
|
|
assert.Equal(t, tt.wantUser.Gid, u.Gid, "GID")
|
|
assert.Equal(t, tt.wantUser.Name, u.Name, "name/gecos")
|
|
assert.Equal(t, tt.wantUser.HomeDir, u.HomeDir, "home directory")
|
|
assert.Equal(t, tt.wantShell, shell, "shell")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseGroup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantGroup *user.Group
|
|
wantMembers []string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "no members",
|
|
input: "vma:x:1000:\n",
|
|
wantGroup: &user.Group{Name: "vma", Gid: "1000"},
|
|
},
|
|
{
|
|
name: "one member",
|
|
input: "sudo:x:27:alice",
|
|
wantGroup: &user.Group{Name: "sudo", Gid: "27"},
|
|
wantMembers: []string{"alice"},
|
|
},
|
|
{
|
|
name: "several members",
|
|
input: "docker:x:998:alice,bob\n",
|
|
wantGroup: &user.Group{Name: "docker", Gid: "998"},
|
|
wantMembers: []string{"alice", "bob"},
|
|
},
|
|
{
|
|
name: "too few fields",
|
|
input: "bad:x",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty group name",
|
|
input: ":x:1000:alice",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty GID",
|
|
input: "vma:x::alice",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty input",
|
|
input: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g, members, err := parseGroup(tt.input)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantGroup.Name, g.Name, "group name")
|
|
assert.Equal(t, tt.wantGroup.Gid, g.Gid, "GID")
|
|
assert.Equal(t, tt.wantMembers, members, "members")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGroupMembersFromFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
entry string
|
|
want []string
|
|
}{
|
|
{name: "no members", entry: "vma:x:1000:"},
|
|
{name: "only the owner", entry: "vma:x:1000:vma", want: []string{"vma"}},
|
|
{name: "two members", entry: "vma:x:1000:vma,bob", want: []string{"vma", "bob"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "group")
|
|
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
|
|
|
|
members, err := groupMembersFromFile(path, "vma")
|
|
require.NoError(t, err, "entry %q", tt.entry)
|
|
assert.Equal(t, tt.want, members, "entry %q", tt.entry)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A group the file does not describe, because it comes from LDAP or another
|
|
// NSS source, is an error rather than an empty member list: the caller must
|
|
// be able to tell "no members" from "no answer".
|
|
func TestGroupMembersFromFileUnknownGroup(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "group")
|
|
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
|
|
|
|
_, err := groupMembersFromFile(path, "vma")
|
|
assert.Error(t, err, "a group the file does not describe")
|
|
|
|
_, err = groupMembersFromFile(filepath.Join(t.TempDir(), "absent"), "vma")
|
|
assert.Error(t, err, "no group file at all")
|
|
}
|
|
|
|
// GroupMembers on the root group, which every Unix has, whichever source
|
|
// answers for it.
|
|
func TestGroupMembers_RootGroup(t *testing.T) {
|
|
rootGroup := "root"
|
|
switch runtime.GOOS {
|
|
case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd":
|
|
rootGroup = "wheel"
|
|
}
|
|
|
|
_, err := GroupMembers(rootGroup)
|
|
assert.NoError(t, err, "the %s group must be describable", rootGroup)
|
|
}
|
|
|
|
func TestValidateInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want bool
|
|
}{
|
|
{"normal username", "alice", true},
|
|
{"numeric UID", "1001", true},
|
|
{"dots and underscores", "alice.bob_test", true},
|
|
{"hyphen", "alice-bob", true},
|
|
{"leading hyphen rejected", "-i", false},
|
|
{"leading double hyphen rejected", "--no-idn", false},
|
|
{"lone hyphen rejected", "-", false},
|
|
{"kerberos principal", "user@REALM", true},
|
|
{"samba machine account", "MACHINE$", true},
|
|
{"NIS compat", "+user", true},
|
|
{"empty", "", false},
|
|
{"null byte", "alice\x00bob", false},
|
|
{"newline", "alice\nbob", false},
|
|
{"tab", "alice\tbob", false},
|
|
{"control char", "alice\x01bob", false},
|
|
{"DEL char", "alice\x7fbob", false},
|
|
{"space rejected", "alice bob", false},
|
|
{"semicolon rejected", "alice;bob", false},
|
|
{"backtick rejected", "alice`bob", false},
|
|
{"pipe rejected", "alice|bob", false},
|
|
{"33 chars exceeds non-linux max", makeLongString(33), runtime.GOOS == "linux"},
|
|
{"256 chars at linux max", makeLongString(256), runtime.GOOS == "linux"},
|
|
{"257 chars exceeds all limits", makeLongString(257), false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, validateInput(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeLongString(n int) string {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = 'a'
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func TestPasswdLookup_RootUser(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err != nil {
|
|
t.Skip("getent not available on this system")
|
|
}
|
|
|
|
u, shell, err := passwdLookup("root")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "root", u.Username)
|
|
assert.Equal(t, "0", u.Uid)
|
|
assert.Equal(t, "0", u.Gid)
|
|
assert.NotEmpty(t, shell, "root should have a shell")
|
|
}
|
|
|
|
func TestPasswdLookup_ByUID(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err != nil {
|
|
t.Skip("getent not available on this system")
|
|
}
|
|
|
|
u, _, err := passwdLookup("0")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "root", u.Username)
|
|
assert.Equal(t, "0", u.Uid)
|
|
}
|
|
|
|
func TestPasswdLookup_NonexistentUser(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err != nil {
|
|
t.Skip("getent not available on this system")
|
|
}
|
|
|
|
_, _, err := passwdLookup("nonexistent_user_xyzzy_12345")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestPasswdLookup_InvalidInput(t *testing.T) {
|
|
_, _, err := passwdLookup("")
|
|
assert.Error(t, err)
|
|
|
|
_, _, err = passwdLookup("user\x00name")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestPasswdLookup_NotAvailable(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err == nil {
|
|
t.Skip("getent is available, can't test missing case")
|
|
}
|
|
|
|
_, _, err := passwdLookup("root")
|
|
assert.Error(t, err, "should fail when getent is not installed")
|
|
}
|
|
|
|
func TestGroupLookup_RootGroup(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err != nil {
|
|
t.Skip("getent not available on this system")
|
|
}
|
|
|
|
g, _, err := groupLookup("0")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "0", g.Gid, "GID 0 resolves to the root group")
|
|
assert.NotEmpty(t, g.Name, "the root group has a name")
|
|
}
|
|
|
|
func TestIdGroups_CurrentUser(t *testing.T) {
|
|
if _, err := exec.LookPath("id"); err != nil {
|
|
t.Skip("id not available on this system")
|
|
}
|
|
|
|
current, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
groups, err := idGroups(current.Username)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, groups, "current user should have at least one group")
|
|
|
|
for _, gid := range groups {
|
|
_, err := strconv.ParseUint(gid, 10, 32)
|
|
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
|
|
}
|
|
}
|
|
|
|
func TestIdGroups_NonexistentUser(t *testing.T) {
|
|
if _, err := exec.LookPath("id"); err != nil {
|
|
t.Skip("id not available on this system")
|
|
}
|
|
|
|
_, err := idGroups("nonexistent_user_xyzzy_12345")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestIdGroups_InvalidInput(t *testing.T) {
|
|
_, err := idGroups("")
|
|
assert.Error(t, err)
|
|
|
|
_, err = idGroups("user\x00name")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestGetentResultsMatchStdlib(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err != nil {
|
|
t.Skip("getent not available on this system")
|
|
}
|
|
|
|
current, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
getentUser, _, err := passwdLookup(current.Username)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, current.Username, getentUser.Username, "username should match")
|
|
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
|
|
assert.Equal(t, current.Gid, getentUser.Gid, "GID should match")
|
|
assert.Equal(t, current.HomeDir, getentUser.HomeDir, "home directory should match")
|
|
}
|
|
|
|
func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
|
|
if _, err := exec.LookPath("getent"); err != nil {
|
|
t.Skip("getent not available on this system")
|
|
}
|
|
|
|
current, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
getentUser, _, err := passwdLookup(current.Uid)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
|
|
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
|
|
}
|
|
|
|
func TestIdGroupsMatchStdlib(t *testing.T) {
|
|
if _, err := exec.LookPath("id"); err != nil {
|
|
t.Skip("id not available on this system")
|
|
}
|
|
|
|
current, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
stdGroups, err := current.GroupIds()
|
|
if err != nil {
|
|
t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0")
|
|
}
|
|
|
|
idGroupIDs, err := idGroups(current.Username)
|
|
require.NoError(t, err)
|
|
|
|
// Deduplicate both lists: id -G can return duplicates (e.g., root in Docker)
|
|
// and ElementsMatch treats duplicates as distinct.
|
|
assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroupIDs), "id -G should return same groups as os/user")
|
|
}
|
|
|
|
func uniqueStrings(ss []string) []string {
|
|
seen := make(map[string]struct{}, len(ss))
|
|
out := make([]string, 0, len(ss))
|
|
for _, s := range ss {
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|