diff --git a/user/idtools.go b/user/idtools.go new file mode 100644 index 0000000..d2fbd94 --- /dev/null +++ b/user/idtools.go @@ -0,0 +1,149 @@ +package idtools + +import ( + "fmt" + "os" +) + +// IDMap contains a single entry for user namespace range remapping. An array +// of IDMap entries represents the structure that will be provided to the Linux +// kernel for creating a user namespace. +type IDMap struct { + ContainerID int `json:"container_id"` + HostID int `json:"host_id"` + Size int `json:"size"` +} + +// 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. +func MkdirAllAndChown(path string, mode os.FileMode, owner Identity) error { + return mkdirAs(path, mode, owner, true, true) +} + +// MkdirAndChown creates a directory and then modifies ownership to the requested uid/gid. +// If the directory already exists, this function still changes ownership and permissions. +// Note that unlike os.Mkdir(), this function does not return IsExist error +// in case path already exists. +func MkdirAndChown(path string, mode os.FileMode, owner Identity) error { + return mkdirAs(path, mode, owner, false, true) +} + +// MkdirAllAndChownNew creates a directory (include any along the path) and then modifies +// ownership ONLY of newly created directories to the requested uid/gid. If the +// directories along the path exist, no change of ownership or permissions will be performed +func MkdirAllAndChownNew(path string, mode os.FileMode, owner Identity) error { + return mkdirAs(path, mode, owner, true, false) +} + +// GetRootUIDGID retrieves the remapped root uid/gid pair from the set of maps. +// If the maps are empty, then the root uid/gid will default to "real" 0/0 +func GetRootUIDGID(uidMap, gidMap []IDMap) (int, int, error) { + uid, err := toHost(0, uidMap) + if err != nil { + return -1, -1, err + } + gid, err := toHost(0, gidMap) + if err != nil { + return -1, -1, err + } + return uid, gid, nil +} + +// toContainer takes an id mapping, and uses it to translate a +// host ID to the remapped ID. If no map is provided, then the translation +// assumes a 1-to-1 mapping and returns the passed in id +func toContainer(hostID int, idMap []IDMap) (int, error) { + if idMap == nil { + return hostID, nil + } + for _, m := range idMap { + if (hostID >= m.HostID) && (hostID <= (m.HostID + m.Size - 1)) { + contID := m.ContainerID + (hostID - m.HostID) + return contID, nil + } + } + return -1, fmt.Errorf("Host ID %d cannot be mapped to a container ID", hostID) +} + +// toHost takes an id mapping and a remapped ID, and translates the +// ID to the mapped host ID. If no map is provided, then the translation +// assumes a 1-to-1 mapping and returns the passed in id # +func toHost(contID int, idMap []IDMap) (int, error) { + if idMap == nil { + return contID, nil + } + for _, m := range idMap { + if (contID >= m.ContainerID) && (contID <= (m.ContainerID + m.Size - 1)) { + hostID := m.HostID + (contID - m.ContainerID) + return hostID, nil + } + } + return -1, fmt.Errorf("Container ID %d cannot be mapped to a host ID", contID) +} + +// Identity is either a UID and GID pair or a SID (but not both) +type Identity struct { + UID int + GID int + SID string +} + +// Chown changes the numeric uid and gid of the named file to id.UID and id.GID. +func (id Identity) Chown(name string) error { + return os.Chown(name, id.UID, id.GID) +} + +// IdentityMapping contains a mappings of UIDs and GIDs. +// The zero value represents an empty mapping. +type IdentityMapping struct { + UIDMaps []IDMap `json:"UIDMaps"` + GIDMaps []IDMap `json:"GIDMaps"` +} + +// RootPair returns a uid and gid pair for the root user. The error is ignored +// because a root user always exists, and the defaults are correct when the uid +// and gid maps are empty. +func (i IdentityMapping) RootPair() Identity { + uid, gid, _ := GetRootUIDGID(i.UIDMaps, i.GIDMaps) + return Identity{UID: uid, GID: gid} +} + +// ToHost returns the host UID and GID for the container uid, gid. +// Remapping is only performed if the ids aren't already the remapped root ids +func (i IdentityMapping) ToHost(pair Identity) (Identity, error) { + var err error + target := i.RootPair() + + if pair.UID != target.UID { + target.UID, err = toHost(pair.UID, i.UIDMaps) + if err != nil { + return target, err + } + } + + if pair.GID != target.GID { + target.GID, err = toHost(pair.GID, i.GIDMaps) + } + return target, err +} + +// ToContainer returns the container UID and GID for the host uid and gid +func (i IdentityMapping) ToContainer(pair Identity) (int, int, error) { + uid, err := toContainer(pair.UID, i.UIDMaps) + if err != nil { + return -1, -1, err + } + gid, err := toContainer(pair.GID, i.GIDMaps) + return uid, gid, err +} + +// Empty returns true if there are no id mappings +func (i IdentityMapping) Empty() bool { + return len(i.UIDMaps) == 0 && len(i.GIDMaps) == 0 +} + +// CurrentIdentity returns the identity of the current process +func CurrentIdentity() Identity { + return Identity{UID: os.Getuid(), GID: os.Getegid()} +} diff --git a/user/idtools_unix.go b/user/idtools_unix.go new file mode 100644 index 0000000..1f11fe4 --- /dev/null +++ b/user/idtools_unix.go @@ -0,0 +1,166 @@ +//go:build !windows + +package idtools + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "syscall" + + "github.com/moby/sys/user" +) + +func mkdirAs(path string, mode os.FileMode, owner Identity, mkAll, chownExisting bool) error { + path, err := filepath.Abs(path) + if err != nil { + return err + } + + stat, err := os.Stat(path) + if err == nil { + if !stat.IsDir() { + return &os.PathError{Op: "mkdir", Path: path, Err: syscall.ENOTDIR} + } + if !chownExisting { + return nil + } + + // short-circuit -- we were called with an existing directory and chown was requested + return setPermissions(path, mode, owner, stat) + } + + // make an array containing the original path asked for, plus (for mkAll == true) + // all path components leading up to the complete path that don't exist before we MkdirAll + // so that we can chown all of them properly at the end. If chownExisting is false, we won't + // chown the full directory path if it exists + var paths []string + if os.IsNotExist(err) { + paths = []string{path} + } + + if mkAll { + // walk back to "/" looking for directories which do not exist + // and add them to the paths array for chown after creation + dirPath := path + for { + dirPath = filepath.Dir(dirPath) + if dirPath == "/" { + break + } + if _, err = os.Stat(dirPath); err != nil && os.IsNotExist(err) { + paths = append(paths, dirPath) + } + } + if err = os.MkdirAll(path, mode); err != nil { + return err + } + } else if err = os.Mkdir(path, mode); err != nil { + return err + } + // even if it existed, we will chown the requested path + any subpaths that + // didn't exist when we called MkdirAll + for _, pathComponent := range paths { + if err = setPermissions(pathComponent, mode, owner, nil); err != nil { + return err + } + } + return nil +} + +// 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) { + return user.LookupUser(name) +} + +// 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) { + return user.LookupUid(uid) +} + +// LookupGroup uses traditional local system files lookup (from libcontainer/user) on a group name, +// +// Deprecated: use [user.LookupGroup] instead +func LookupGroup(name string) (user.Group, error) { + return user.LookupGroup(name) +} + +// setPermissions performs a chown/chmod only if the uid/gid don't match what's requested +// Normally a Chown is a no-op if uid/gid match, but in some cases this can still cause an error, e.g. if the +// dir is on an NFS share, so don't call chown unless we absolutely must. +// Likewise for setting permissions. +func setPermissions(p string, mode os.FileMode, owner Identity, stat os.FileInfo) error { + if stat == nil { + var err error + stat, err = os.Stat(p) + if err != nil { + return err + } + } + if stat.Mode().Perm() != mode.Perm() { + if err := os.Chmod(p, mode.Perm()); err != nil { + return err + } + } + ssi := stat.Sys().(*syscall.Stat_t) + if ssi.Uid == uint32(owner.UID) && ssi.Gid == uint32(owner.GID) { + return nil + } + return os.Chown(p, owner.UID, owner.GID) +} + +// 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) (IdentityMapping, error) { + // 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) + } + + subuidRanges, err := lookupSubRangesFile("/etc/subuid", usr) + if err != nil { + return IdentityMapping{}, err + } + subgidRanges, err := lookupSubRangesFile("/etc/subgid", usr) + if err != nil { + return IdentityMapping{}, err + } + + return IdentityMapping{ + UIDMaps: subuidRanges, + GIDMaps: subgidRanges, + }, nil +} + +func lookupSubRangesFile(path string, usr user.User) ([]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 := []IDMap{} + + containerID := 0 + for _, idrange := range rangeList { + idMap = append(idMap, IDMap{ + ContainerID: containerID, + HostID: int(idrange.SubID), + Size: int(idrange.Count), + }) + containerID = containerID + int(idrange.Count) + } + return idMap, nil +} diff --git a/user/idtools_unix_test.go b/user/idtools_unix_test.go new file mode 100644 index 0000000..381a1d7 --- /dev/null +++ b/user/idtools_unix_test.go @@ -0,0 +1,383 @@ +//go:build !windows + +package idtools + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" + "gotest.tools/v3/skip" +) + +type node struct { + uid int + gid int +} + +func TestMkdirAllAndChown(t *testing.T) { + RequiresRoot(t) + dirName, err := os.MkdirTemp("", "mkdirall") + if err != nil { + t.Fatalf("Couldn't create temp dir: %v", err) + } + defer os.RemoveAll(dirName) + + testTree := map[string]node{ + "usr": {0, 0}, + "usr/bin": {0, 0}, + "lib": {33, 33}, + "lib/x86_64": {45, 45}, + "lib/x86_64/share": {1, 1}, + } + + if err := buildTree(dirName, testTree); err != nil { + t.Fatal(err) + } + + // test adding a directory to a pre-existing dir; only the new dir is owned by the uid/gid + if err := MkdirAllAndChown(filepath.Join(dirName, "usr", "share"), 0o755, Identity{UID: 99, GID: 99}); err != nil { + t.Fatal(err) + } + testTree["usr/share"] = node{99, 99} + verifyTree, err := readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + if err := compareTrees(testTree, verifyTree); err != nil { + t.Fatal(err) + } + + // test 2-deep new directories--both should be owned by the uid/gid pair + if err := MkdirAllAndChown(filepath.Join(dirName, "lib", "some", "other"), 0o755, Identity{UID: 101, GID: 101}); err != nil { + t.Fatal(err) + } + testTree["lib/some"] = node{101, 101} + testTree["lib/some/other"] = node{101, 101} + verifyTree, err = readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + if err := compareTrees(testTree, verifyTree); err != nil { + t.Fatal(err) + } + + // test a directory that already exists; should be chowned, but nothing else + if err := MkdirAllAndChown(filepath.Join(dirName, "usr"), 0o755, Identity{UID: 102, GID: 102}); err != nil { + t.Fatal(err) + } + testTree["usr"] = node{102, 102} + verifyTree, err = readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + if err := compareTrees(testTree, verifyTree); err != nil { + t.Fatal(err) + } +} + +func TestMkdirAllAndChownNew(t *testing.T) { + RequiresRoot(t) + dirName, err := os.MkdirTemp("", "mkdirnew") + assert.NilError(t, err) + defer os.RemoveAll(dirName) + + testTree := map[string]node{ + "usr": {0, 0}, + "usr/bin": {0, 0}, + "lib": {33, 33}, + "lib/x86_64": {45, 45}, + "lib/x86_64/share": {1, 1}, + } + assert.NilError(t, buildTree(dirName, testTree)) + + // test adding a directory to a pre-existing dir; only the new dir is owned by the uid/gid + err = MkdirAllAndChownNew(filepath.Join(dirName, "usr", "share"), 0o755, Identity{UID: 99, GID: 99}) + assert.NilError(t, err) + + testTree["usr/share"] = node{99, 99} + verifyTree, err := readTree(dirName, "") + assert.NilError(t, err) + assert.NilError(t, compareTrees(testTree, verifyTree)) + + // test 2-deep new directories--both should be owned by the uid/gid pair + err = MkdirAllAndChownNew(filepath.Join(dirName, "lib", "some", "other"), 0o755, Identity{UID: 101, GID: 101}) + assert.NilError(t, err) + testTree["lib/some"] = node{101, 101} + testTree["lib/some/other"] = node{101, 101} + verifyTree, err = readTree(dirName, "") + assert.NilError(t, err) + assert.NilError(t, compareTrees(testTree, verifyTree)) + + // test a directory that already exists; should NOT be chowned + err = MkdirAllAndChownNew(filepath.Join(dirName, "usr"), 0o755, Identity{UID: 102, GID: 102}) + assert.NilError(t, err) + verifyTree, err = readTree(dirName, "") + assert.NilError(t, err) + assert.NilError(t, compareTrees(testTree, verifyTree)) +} + +func TestMkdirAllAndChownNewRelative(t *testing.T) { + RequiresRoot(t) + + tests := []struct { + in string + out []string + }{ + { + in: "dir1", + out: []string{"dir1"}, + }, + { + in: "dir2/subdir2", + out: []string{"dir2", "dir2/subdir2"}, + }, + { + in: "dir3/subdir3/", + out: []string{"dir3", "dir3/subdir3"}, + }, + { + in: "dir4/subdir4/.", + out: []string{"dir4", "dir4/subdir4"}, + }, + { + in: "dir5/././subdir5/", + out: []string{"dir5", "dir5/subdir5"}, + }, + { + in: "./dir6", + out: []string{"dir6"}, + }, + { + in: "./dir7/subdir7", + out: []string{"dir7", "dir7/subdir7"}, + }, + { + in: "./dir8/subdir8/", + out: []string{"dir8", "dir8/subdir8"}, + }, + { + in: "./dir9/subdir9/.", + out: []string{"dir9", "dir9/subdir9"}, + }, + { + in: "./dir10/././subdir10/", + out: []string{"dir10", "dir10/subdir10"}, + }, + } + + // Set the current working directory to the temp-dir, as we're + // testing relative paths. + tmpDir := t.TempDir() + setWorkingDirectory(t, tmpDir) + + const expectedUIDGID = 101 + + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + for _, p := range tc.out { + _, err := os.Stat(p) + assert.ErrorIs(t, err, os.ErrNotExist) + } + + err := MkdirAllAndChownNew(tc.in, 0o755, Identity{UID: expectedUIDGID, GID: expectedUIDGID}) + assert.Check(t, err) + + for _, p := range tc.out { + s := &unix.Stat_t{} + err = unix.Stat(p, s) + if assert.Check(t, err) { + assert.Check(t, is.Equal(uint64(s.Uid), uint64(expectedUIDGID))) + assert.Check(t, is.Equal(uint64(s.Gid), uint64(expectedUIDGID))) + } + } + }) + } +} + +// Change the current working directory for the duration of the test. This may +// break if tests are run in parallel. +func setWorkingDirectory(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + assert.NilError(t, err) + t.Cleanup(func() { + assert.NilError(t, os.Chdir(cwd)) + }) + err = os.Chdir(dir) + assert.NilError(t, err) +} + +func TestMkdirAndChown(t *testing.T) { + RequiresRoot(t) + dirName, err := os.MkdirTemp("", "mkdir") + if err != nil { + t.Fatalf("Couldn't create temp dir: %v", err) + } + defer os.RemoveAll(dirName) + + testTree := map[string]node{ + "usr": {0, 0}, + } + if err := buildTree(dirName, testTree); err != nil { + t.Fatal(err) + } + + // test a directory that already exists; should just chown to the requested uid/gid + if err := MkdirAndChown(filepath.Join(dirName, "usr"), 0o755, Identity{UID: 99, GID: 99}); err != nil { + t.Fatal(err) + } + testTree["usr"] = node{99, 99} + verifyTree, err := readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + if err := compareTrees(testTree, verifyTree); err != nil { + t.Fatal(err) + } + + // create a subdir under a dir which doesn't exist--should fail + if err := MkdirAndChown(filepath.Join(dirName, "usr", "bin", "subdir"), 0o755, Identity{UID: 102, GID: 102}); err == nil { + t.Fatalf("Trying to create a directory with Mkdir where the parent doesn't exist should have failed") + } + + // create a subdir under an existing dir; should only change the ownership of the new subdir + if err := MkdirAndChown(filepath.Join(dirName, "usr", "bin"), 0o755, Identity{UID: 102, GID: 102}); err != nil { + t.Fatal(err) + } + testTree["usr/bin"] = node{102, 102} + verifyTree, err = readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + if err := compareTrees(testTree, verifyTree); err != nil { + t.Fatal(err) + } +} + +func buildTree(base string, tree map[string]node) error { + for path, node := range tree { + fullPath := filepath.Join(base, path) + if err := os.MkdirAll(fullPath, 0o755); err != nil { + return fmt.Errorf("couldn't create path: %s; error: %v", fullPath, err) + } + if err := os.Chown(fullPath, node.uid, node.gid); err != nil { + return fmt.Errorf("couldn't chown path: %s; error: %v", fullPath, err) + } + } + return nil +} + +func readTree(base, root string) (map[string]node, error) { + tree := make(map[string]node) + + dirInfos, err := os.ReadDir(base) + if err != nil { + return nil, fmt.Errorf("couldn't read directory entries for %q: %v", base, err) + } + + for _, info := range dirInfos { + s := &unix.Stat_t{} + if err := unix.Stat(filepath.Join(base, info.Name()), s); err != nil { + return nil, fmt.Errorf("can't stat file %q: %v", filepath.Join(base, info.Name()), err) + } + tree[filepath.Join(root, info.Name())] = node{int(s.Uid), int(s.Gid)} + if info.IsDir() { + // read the subdirectory + subtree, err := readTree(filepath.Join(base, info.Name()), filepath.Join(root, info.Name())) + if err != nil { + return nil, err + } + for path, nodeinfo := range subtree { + tree[path] = nodeinfo + } + } + } + return tree, nil +} + +func compareTrees(left, right map[string]node) error { + if len(left) != len(right) { + return fmt.Errorf("trees aren't the same size") + } + for path, nodeLeft := range left { + if nodeRight, ok := right[path]; ok { + if nodeRight.uid != nodeLeft.uid || nodeRight.gid != nodeLeft.gid { + // mismatch + return fmt.Errorf("mismatched ownership for %q: expected: %d:%d, got: %d:%d", path, + nodeLeft.uid, nodeLeft.gid, nodeRight.uid, nodeRight.gid) + } + continue + } + return fmt.Errorf("right tree didn't contain path %q", path) + } + return nil +} + +func TestGetRootUIDGID(t *testing.T) { + uidMap := []IDMap{ + { + ContainerID: 0, + HostID: os.Getuid(), + Size: 1, + }, + } + gidMap := []IDMap{ + { + ContainerID: 0, + HostID: os.Getgid(), + Size: 1, + }, + } + + uid, gid, err := GetRootUIDGID(uidMap, gidMap) + assert.Check(t, err) + assert.Check(t, is.Equal(os.Geteuid(), uid)) + assert.Check(t, is.Equal(os.Getegid(), gid)) + + uidMapError := []IDMap{ + { + ContainerID: 1, + HostID: os.Getuid(), + Size: 1, + }, + } + _, _, err = GetRootUIDGID(uidMapError, gidMap) + assert.Check(t, is.Error(err, "Container ID 0 cannot be mapped to a host ID")) +} + +func TestToContainer(t *testing.T) { + uidMap := []IDMap{ + { + ContainerID: 2, + HostID: 2, + Size: 1, + }, + } + + containerID, err := toContainer(2, uidMap) + assert.Check(t, err) + assert.Check(t, is.Equal(uidMap[0].ContainerID, containerID)) +} + +// 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). +func TestMkdirIsNotDir(t *testing.T) { + file, err := os.CreateTemp("", t.Name()) + if err != nil { + t.Fatalf("Couldn't create temp dir: %v", err) + } + defer os.Remove(file.Name()) + + err = mkdirAs(file.Name(), 0o755, Identity{UID: 0, GID: 0}, false, false) + assert.Check(t, is.Error(err, "mkdir "+file.Name()+": not a directory")) +} + +func RequiresRoot(t *testing.T) { + skip.If(t, os.Getuid() != 0, "skipping test that requires root") +} diff --git a/user/idtools_windows.go b/user/idtools_windows.go new file mode 100644 index 0000000..43702f7 --- /dev/null +++ b/user/idtools_windows.go @@ -0,0 +1,26 @@ +package idtools + +import ( + "os" +) + +const ( + // Deprecated: copy value locally + SeTakeOwnershipPrivilege = "SeTakeOwnershipPrivilege" +) + +const ( + // Deprecated: copy value locally + ContainerAdministratorSidString = "S-1-5-93-2-1" + + // Deprecated: copy value locally + ContainerUserSidString = "S-1-5-93-2-2" +) + +// This is currently a wrapper around [os.MkdirAll] since currently +// permissions aren't set through this path, the identity isn't utilized. +// Ownership is handled elsewhere, but in the future could be support here +// too. +func mkdirAs(path string, _ os.FileMode, _ Identity, _, _ bool) error { + return os.MkdirAll(path, 0) +}