Split internal idtools functionality

Separare idtools functionality that is used internally from the
functionlality used by importers. The `pkg/idtools` package is now
much smaller and more generic.

Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Derek McGowan
2024-12-12 20:46:11 -08:00
parent 8260f985b6
commit 9c368a93b6
13 changed files with 383 additions and 254 deletions

View File

@@ -1,4 +1,4 @@
package idtools // import "github.com/docker/docker/pkg/idtools"
package usergroup
import (
"fmt"

View File

@@ -0,0 +1,73 @@
package usergroup
import (
"os"
"os/exec"
"os/user"
"syscall"
"testing"
"github.com/docker/docker/pkg/idtools"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
)
const (
tempUser = "tempuser"
)
func TestNewIDMappings(t *testing.T) {
skip.If(t, os.Getuid() != 0, "skipping test that requires root")
_, _, err := AddNamespaceRangesUser(tempUser)
assert.Check(t, err)
defer delUser(t, tempUser)
tempUser, err := user.Lookup(tempUser)
assert.Check(t, err)
idMapping, err := LoadIdentityMapping(tempUser.Username)
assert.Check(t, err)
rootUID, rootGID, err := idtools.GetRootUIDGID(idMapping.UIDMaps, idMapping.GIDMaps)
assert.Check(t, err)
dirName, err := os.MkdirTemp("", "mkdirall")
assert.Check(t, err, "Couldn't create temp directory")
defer os.RemoveAll(dirName)
err = idtools.MkdirAllAndChown(dirName, 0o700, idtools.Identity{UID: rootUID, GID: rootGID})
assert.Check(t, err, "Couldn't change ownership of file path. Got error")
cmd := exec.Command("ls", "-la", dirName)
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{Uid: uint32(rootUID), Gid: uint32(rootGID)},
}
out, err := cmd.CombinedOutput()
assert.Check(t, err, "Unable to access %s directory with user UID:%d and GID:%d:\n%s", dirName, rootUID, rootGID, string(out))
}
func TestLookupUserAndGroup(t *testing.T) {
skip.If(t, os.Getuid() != 0, "skipping test that requires root")
uid, gid, err := AddNamespaceRangesUser(tempUser)
assert.Check(t, err)
defer delUser(t, tempUser)
fetchedUser, err := LookupUser(tempUser)
assert.Check(t, err)
fetchedUserByID, err := LookupUID(uid)
assert.Check(t, err)
assert.Check(t, is.DeepEqual(fetchedUserByID, fetchedUser))
fetchedGroup, err := LookupGroup(tempUser)
assert.Check(t, err)
fetchedGroupByID, err := LookupGID(gid)
assert.Check(t, err)
assert.Check(t, is.DeepEqual(fetchedGroupByID, fetchedGroup))
}
func delUser(t *testing.T, name string) {
out, err := exec.Command("userdel", name).CombinedOutput()
assert.Check(t, err, out)
}

View File

@@ -1,6 +1,6 @@
//go:build !linux
package idtools // import "github.com/docker/docker/pkg/idtools"
package usergroup
import "fmt"

View File

@@ -0,0 +1,10 @@
package usergroup
const (
SeTakeOwnershipPrivilege = "SeTakeOwnershipPrivilege"
)
const (
ContainerAdministratorSidString = "S-1-5-93-2-1"
ContainerUserSidString = "S-1-5-93-2-2"
)

View File

@@ -0,0 +1,188 @@
//go:build !windows
package usergroup
import (
"bytes"
"fmt"
"io"
"os/exec"
"strconv"
"syscall"
"github.com/docker/docker/pkg/idtools"
"github.com/moby/sys/user"
)
// LookupUser uses traditional local system files lookup (from libcontainer/user) on a username,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
func LookupUser(name string) (user.User, error) {
// first try a local system files lookup using existing capabilities
usr, err := user.LookupUser(name)
if err == nil {
return usr, nil
}
// local files lookup failed; attempt to call `getent` to query configured passwd dbs
usr, err = getentUser(name)
if err != nil {
return user.User{}, err
}
return usr, nil
}
// LookupUID uses traditional local system files lookup (from libcontainer/user) on a uid,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
func LookupUID(uid int) (user.User, error) {
// first try a local system files lookup using existing capabilities
usr, err := user.LookupUid(uid)
if err == nil {
return usr, nil
}
// local files lookup failed; attempt to call `getent` to query configured passwd dbs
return getentUser(strconv.Itoa(uid))
}
func getentUser(name string) (user.User, error) {
reader, err := callGetent("passwd", name)
if err != nil {
return user.User{}, err
}
users, err := user.ParsePasswd(reader)
if err != nil {
return user.User{}, err
}
if len(users) == 0 {
return user.User{}, fmt.Errorf("getent failed to find passwd entry for %q", name)
}
return users[0], nil
}
// LookupGroup uses traditional local system files lookup (from libcontainer/user) on a group name,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
func LookupGroup(name string) (user.Group, error) {
// first try a local system files lookup using existing capabilities
group, err := user.LookupGroup(name)
if err == nil {
return group, nil
}
// local files lookup failed; attempt to call `getent` to query configured group dbs
return getentGroup(name)
}
// LookupGID uses traditional local system files lookup (from libcontainer/user) on a group ID,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
func LookupGID(gid int) (user.Group, error) {
// first try a local system files lookup using existing capabilities
group, err := user.LookupGid(gid)
if err == nil {
return group, nil
}
// local files lookup failed; attempt to call `getent` to query configured group dbs
return getentGroup(strconv.Itoa(gid))
}
func getentGroup(name string) (user.Group, error) {
reader, err := callGetent("group", name)
if err != nil {
return user.Group{}, err
}
groups, err := user.ParseGroup(reader)
if err != nil {
return user.Group{}, err
}
if len(groups) == 0 {
return user.Group{}, fmt.Errorf("getent failed to find groups entry for %q", name)
}
return groups[0], nil
}
func callGetent(database, key string) (io.Reader, error) {
getentCmd, err := resolveBinary("getent")
// if no `getent` command within the execution environment, can't do anything else
if err != nil {
return nil, fmt.Errorf("unable to find getent command: %w", err)
}
command := exec.Command(getentCmd, database, key)
// we run getent within container filesystem, but without /dev so /dev/null is not available for exec to mock stdin
command.Stdin = io.NopCloser(bytes.NewReader(nil))
out, err := command.CombinedOutput()
if err != nil {
exitCode, errC := getExitCode(err)
if errC != nil {
return nil, err
}
switch exitCode {
case 1:
return nil, fmt.Errorf("getent reported invalid parameters/database unknown")
case 2:
return nil, fmt.Errorf("getent unable to find entry %q in %s database", key, database)
case 3:
return nil, fmt.Errorf("getent database doesn't support enumeration")
default:
return nil, err
}
}
return bytes.NewReader(out), nil
}
// getExitCode returns the ExitStatus of the specified error if its type is
// exec.ExitError, returns 0 and an error otherwise.
func getExitCode(err error) (int, error) {
exitCode := 0
if exiterr, ok := err.(*exec.ExitError); ok {
if procExit, ok := exiterr.Sys().(syscall.WaitStatus); ok {
return procExit.ExitStatus(), nil
}
}
return exitCode, fmt.Errorf("failed to get exit code")
}
// LoadIdentityMapping takes a requested username and
// using the data from /etc/sub{uid,gid} ranges, creates the
// proper uid and gid remapping ranges for that user/group pair
func LoadIdentityMapping(name string) (idtools.IdentityMapping, error) {
usr, err := LookupUser(name)
if err != nil {
return idtools.IdentityMapping{}, fmt.Errorf("could not get user for username %s: %v", name, err)
}
subuidRanges, err := lookupSubRangesFile("/etc/subuid", usr)
if err != nil {
return idtools.IdentityMapping{}, err
}
subgidRanges, err := lookupSubRangesFile("/etc/subgid", usr)
if err != nil {
return idtools.IdentityMapping{}, err
}
return idtools.IdentityMapping{
UIDMaps: subuidRanges,
GIDMaps: subgidRanges,
}, nil
}
func lookupSubRangesFile(path string, usr user.User) ([]idtools.IDMap, error) {
uidstr := strconv.Itoa(usr.Uid)
rangeList, err := user.ParseSubIDFileFilter(path, func(sid user.SubID) bool {
return sid.Name == usr.Name || sid.Name == uidstr
})
if err != nil {
return nil, err
}
if len(rangeList) == 0 {
return nil, fmt.Errorf("no subuid ranges found for user %q", usr.Name)
}
idMap := []idtools.IDMap{}
containerID := 0
for _, idrange := range rangeList {
idMap = append(idMap, idtools.IDMap{
ContainerID: containerID,
HostID: int(idrange.SubID),
Size: int(idrange.Count),
})
containerID = containerID + int(idrange.Count)
}
return idMap, nil
}

View File

@@ -0,0 +1,26 @@
//go:build !windows
package usergroup
import (
"testing"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestLookupUserAndGroupThatDoesNotExist(t *testing.T) {
fakeUser := "fakeuser"
_, err := LookupUser(fakeUser)
assert.Check(t, is.Error(err, `getent unable to find entry "fakeuser" in passwd database`))
_, err = LookupUID(-1)
assert.Check(t, is.ErrorContains(err, ""))
fakeGroup := "fakegroup"
_, err = LookupGroup(fakeGroup)
assert.Check(t, is.Error(err, `getent unable to find entry "fakegroup" in group database`))
_, err = LookupGID(-1)
assert.Check(t, is.ErrorContains(err, ""))
}

View File

@@ -0,0 +1,22 @@
package usergroup
import (
"github.com/moby/sys/user"
)
const (
subuidFileName = "/etc/subuid"
subgidFileName = "/etc/subgid"
)
func parseSubuid(username string) ([]user.SubID, error) {
return user.ParseSubIDFileFilter(subuidFileName, func(sid user.SubID) bool {
return sid.Name == username
})
}
func parseSubgid(username string) ([]user.SubID, error) {
return user.ParseSubIDFileFilter(subgidFileName, func(sid user.SubID) bool {
return sid.Name == username
})
}

View File

@@ -0,0 +1,39 @@
package usergroup
import (
"os"
"path/filepath"
"testing"
"github.com/moby/sys/user"
)
func TestParseSubidFileWithNewlinesAndComments(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "parsesubid")
if err != nil {
t.Fatal(err)
}
fnamePath := filepath.Join(tmpDir, "testsubuid")
fcontent := `tss:100000:65536
# empty default subuid/subgid file
dockremap:231072:65536`
if err := os.WriteFile(fnamePath, []byte(fcontent), 0o644); err != nil {
t.Fatal(err)
}
ranges, err := user.ParseSubIDFileFilter(fnamePath, func(sid user.SubID) bool {
return sid.Name == "dockremap"
})
if err != nil {
t.Fatal(err)
}
if len(ranges) != 1 {
t.Fatalf("wanted 1 element in ranges, got %d instead", len(ranges))
}
if ranges[0].SubID != 231072 {
t.Fatalf("wanted 231072, got %d instead", ranges[0].SubID)
}
if ranges[0].Count != 65536 {
t.Fatalf("wanted 65536, got %d instead", ranges[0].Count)
}
}

View File

@@ -1,6 +1,6 @@
//go:build !windows
package idtools // import "github.com/docker/docker/pkg/idtools"
package usergroup
import (
"fmt"

View File

@@ -1,10 +1,8 @@
package idtools // import "github.com/docker/docker/pkg/idtools"
package idtools
import (
"fmt"
"os"
"github.com/moby/sys/user"
)
// IDMap contains a single entry for user namespace range remapping. An array
@@ -16,11 +14,6 @@ type IDMap struct {
Size int `json:"size"`
}
const (
subuidFileName = "/etc/subuid"
subgidFileName = "/etc/subgid"
)
// MkdirAllAndChown creates a directory (include any along the path) and then modifies
// ownership to the requested uid/gid. If the directory already exists, this
// function will still change ownership and permissions.
@@ -150,18 +143,6 @@ func (i IdentityMapping) Empty() bool {
return len(i.UIDMaps) == 0 && len(i.GIDMaps) == 0
}
func parseSubuid(username string) ([]user.SubID, error) {
return user.ParseSubIDFileFilter(subuidFileName, func(sid user.SubID) bool {
return sid.Name == username
})
}
func parseSubgid(username string) ([]user.SubID, error) {
return user.ParseSubIDFileFilter(subgidFileName, func(sid user.SubID) bool {
return sid.Name == username
})
}
// CurrentIdentity returns the identity of the current process
func CurrentIdentity() Identity {
return Identity{UID: os.Getuid(), GID: os.Getegid()}

View File

@@ -1,13 +1,10 @@
//go:build !windows
package idtools // import "github.com/docker/docker/pkg/idtools"
package idtools
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
@@ -72,127 +69,25 @@ func mkdirAs(path string, mode os.FileMode, owner Identity, mkAll, chownExisting
return nil
}
// LookupUser uses traditional local system files lookup (from libcontainer/user) on a username,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
// LookupUser uses traditional local system files lookup (from libcontainer/user) on a username
//
// Deprecated: use [user.LookupUser] instead
func LookupUser(name string) (user.User, error) {
// first try a local system files lookup using existing capabilities
usr, err := user.LookupUser(name)
if err == nil {
return usr, nil
}
// local files lookup failed; attempt to call `getent` to query configured passwd dbs
usr, err = getentUser(name)
if err != nil {
return user.User{}, err
}
return usr, nil
return user.LookupUser(name)
}
// LookupUID uses traditional local system files lookup (from libcontainer/user) on a uid,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
// LookupUID uses traditional local system files lookup (from libcontainer/user) on a uid
//
// Deprecated: use [user.LookupUid] instead
func LookupUID(uid int) (user.User, error) {
// first try a local system files lookup using existing capabilities
usr, err := user.LookupUid(uid)
if err == nil {
return usr, nil
}
// local files lookup failed; attempt to call `getent` to query configured passwd dbs
return getentUser(strconv.Itoa(uid))
}
func getentUser(name string) (user.User, error) {
reader, err := callGetent("passwd", name)
if err != nil {
return user.User{}, err
}
users, err := user.ParsePasswd(reader)
if err != nil {
return user.User{}, err
}
if len(users) == 0 {
return user.User{}, fmt.Errorf("getent failed to find passwd entry for %q", name)
}
return users[0], nil
return user.LookupUid(uid)
}
// LookupGroup uses traditional local system files lookup (from libcontainer/user) on a group name,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
//
// Deprecated: use [user.LookupGroup] instead
func LookupGroup(name string) (user.Group, error) {
// first try a local system files lookup using existing capabilities
group, err := user.LookupGroup(name)
if err == nil {
return group, nil
}
// local files lookup failed; attempt to call `getent` to query configured group dbs
return getentGroup(name)
}
// LookupGID uses traditional local system files lookup (from libcontainer/user) on a group ID,
// followed by a call to `getent` for supporting host configured non-files passwd and group dbs
func LookupGID(gid int) (user.Group, error) {
// first try a local system files lookup using existing capabilities
group, err := user.LookupGid(gid)
if err == nil {
return group, nil
}
// local files lookup failed; attempt to call `getent` to query configured group dbs
return getentGroup(strconv.Itoa(gid))
}
func getentGroup(name string) (user.Group, error) {
reader, err := callGetent("group", name)
if err != nil {
return user.Group{}, err
}
groups, err := user.ParseGroup(reader)
if err != nil {
return user.Group{}, err
}
if len(groups) == 0 {
return user.Group{}, fmt.Errorf("getent failed to find groups entry for %q", name)
}
return groups[0], nil
}
func callGetent(database, key string) (io.Reader, error) {
getentCmd, err := resolveBinary("getent")
// if no `getent` command within the execution environment, can't do anything else
if err != nil {
return nil, fmt.Errorf("unable to find getent command: %w", err)
}
command := exec.Command(getentCmd, database, key)
// we run getent within container filesystem, but without /dev so /dev/null is not available for exec to mock stdin
command.Stdin = io.NopCloser(bytes.NewReader(nil))
out, err := command.CombinedOutput()
if err != nil {
exitCode, errC := getExitCode(err)
if errC != nil {
return nil, err
}
switch exitCode {
case 1:
return nil, fmt.Errorf("getent reported invalid parameters/database unknown")
case 2:
return nil, fmt.Errorf("getent unable to find entry %q in %s database", key, database)
case 3:
return nil, fmt.Errorf("getent database doesn't support enumeration")
default:
return nil, err
}
}
return bytes.NewReader(out), nil
}
// getExitCode returns the ExitStatus of the specified error if its type is
// exec.ExitError, returns 0 and an error otherwise.
func getExitCode(err error) (int, error) {
exitCode := 0
if exiterr, ok := err.(*exec.ExitError); ok {
if procExit, ok := exiterr.Sys().(syscall.WaitStatus); ok {
return procExit.ExitStatus(), nil
}
}
return exitCode, fmt.Errorf("failed to get exit code")
return user.LookupGroup(name)
}
// setPermissions performs a chown/chmod only if the uid/gid don't match what's requested
@@ -223,7 +118,8 @@ func setPermissions(p string, mode os.FileMode, owner Identity, stat os.FileInfo
// using the data from /etc/sub{uid,gid} ranges, creates the
// proper uid and gid remapping ranges for that user/group pair
func LoadIdentityMapping(name string) (IdentityMapping, error) {
usr, err := LookupUser(name)
// TODO: Consider adding support for calling out to "getent"
usr, err := user.LookupUser(name)
if err != nil {
return IdentityMapping{}, fmt.Errorf("could not get user for username %s: %v", name, err)
}

View File

@@ -1,26 +1,17 @@
//go:build !windows
package idtools // import "github.com/docker/docker/pkg/idtools"
package idtools
import (
"fmt"
"os"
"os/exec"
stduser "os/user"
"path/filepath"
"syscall"
"testing"
"golang.org/x/sys/unix"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
"github.com/moby/sys/user"
)
const (
tempUser = "tempuser"
)
type node struct {
@@ -327,41 +318,6 @@ func compareTrees(left, right map[string]node) error {
return nil
}
func delUser(t *testing.T, name string) {
out, err := exec.Command("userdel", name).CombinedOutput()
assert.Check(t, err, out)
}
func TestParseSubidFileWithNewlinesAndComments(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "parsesubid")
if err != nil {
t.Fatal(err)
}
fnamePath := filepath.Join(tmpDir, "testsubuid")
fcontent := `tss:100000:65536
# empty default subuid/subgid file
dockremap:231072:65536`
if err := os.WriteFile(fnamePath, []byte(fcontent), 0o644); err != nil {
t.Fatal(err)
}
ranges, err := user.ParseSubIDFileFilter(fnamePath, func(sid user.SubID) bool {
return sid.Name == "dockremap"
})
if err != nil {
t.Fatal(err)
}
if len(ranges) != 1 {
t.Fatalf("wanted 1 element in ranges, got %d instead", len(ranges))
}
if ranges[0].SubID != 231072 {
t.Fatalf("wanted 231072, got %d instead", ranges[0].SubID)
}
if ranges[0].Count != 65536 {
t.Fatalf("wanted 65536, got %d instead", ranges[0].Count)
}
}
func TestGetRootUIDGID(t *testing.T) {
uidMap := []IDMap{
{
@@ -408,72 +364,6 @@ func TestToContainer(t *testing.T) {
assert.Check(t, is.Equal(uidMap[0].ContainerID, containerID))
}
func TestNewIDMappings(t *testing.T) {
RequiresRoot(t)
_, _, err := AddNamespaceRangesUser(tempUser)
assert.Check(t, err)
defer delUser(t, tempUser)
tempUser, err := stduser.Lookup(tempUser)
assert.Check(t, err)
idMapping, err := LoadIdentityMapping(tempUser.Username)
assert.Check(t, err)
rootUID, rootGID, err := GetRootUIDGID(idMapping.UIDMaps, idMapping.GIDMaps)
assert.Check(t, err)
dirName, err := os.MkdirTemp("", "mkdirall")
assert.Check(t, err, "Couldn't create temp directory")
defer os.RemoveAll(dirName)
err = MkdirAllAndChown(dirName, 0o700, Identity{UID: rootUID, GID: rootGID})
assert.Check(t, err, "Couldn't change ownership of file path. Got error")
cmd := exec.Command("ls", "-la", dirName)
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{Uid: uint32(rootUID), Gid: uint32(rootGID)},
}
out, err := cmd.CombinedOutput()
assert.Check(t, err, "Unable to access %s directory with user UID:%d and GID:%d:\n%s", dirName, rootUID, rootGID, string(out))
}
func TestLookupUserAndGroup(t *testing.T) {
RequiresRoot(t)
uid, gid, err := AddNamespaceRangesUser(tempUser)
assert.Check(t, err)
defer delUser(t, tempUser)
fetchedUser, err := LookupUser(tempUser)
assert.Check(t, err)
fetchedUserByID, err := LookupUID(uid)
assert.Check(t, err)
assert.Check(t, is.DeepEqual(fetchedUserByID, fetchedUser))
fetchedGroup, err := LookupGroup(tempUser)
assert.Check(t, err)
fetchedGroupByID, err := LookupGID(gid)
assert.Check(t, err)
assert.Check(t, is.DeepEqual(fetchedGroupByID, fetchedGroup))
}
func TestLookupUserAndGroupThatDoesNotExist(t *testing.T) {
fakeUser := "fakeuser"
_, err := LookupUser(fakeUser)
assert.Check(t, is.Error(err, `getent unable to find entry "fakeuser" in passwd database`))
_, err = LookupUID(-1)
assert.Check(t, is.ErrorContains(err, ""))
fakeGroup := "fakegroup"
_, err = LookupGroup(fakeGroup)
assert.Check(t, is.Error(err, `getent unable to find entry "fakegroup" in group database`))
_, err = LookupGID(-1)
assert.Check(t, is.ErrorContains(err, ""))
}
// TestMkdirIsNotDir checks that mkdirAs() function (used by MkdirAll...)
// returns a correct error in case a directory which it is about to create
// already exists but is a file (rather than a directory).

View File

@@ -1,16 +1,20 @@
package idtools // import "github.com/docker/docker/pkg/idtools"
package idtools
import (
"os"
)
const (
// Deprecated: copy value locally
SeTakeOwnershipPrivilege = "SeTakeOwnershipPrivilege"
)
const (
// Deprecated: copy value locally
ContainerAdministratorSidString = "S-1-5-93-2-1"
ContainerUserSidString = "S-1-5-93-2-2"
// Deprecated: copy value locally
ContainerUserSidString = "S-1-5-93-2-2"
)
// This is currently a wrapper around [os.MkdirAll] since currently