diff --git a/user/idtools.go b/user/idtools.go new file mode 100644 index 00000000..595b7a92 --- /dev/null +++ b/user/idtools.go @@ -0,0 +1,141 @@ +package user + +import ( + "fmt" + "os" +) + +// MkdirOpt is a type for options to pass to Mkdir calls +type MkdirOpt func(*mkdirOptions) + +type mkdirOptions struct { + onlyNew bool +} + +// WithOnlyNew is an option for MkdirAllAndChown that will only change ownership and permissions +// on newly created directories. If the directory already exists, it will not be modified +func WithOnlyNew(o *mkdirOptions) { + o.onlyNew = true +} + +// MkdirAllAndChown creates a directory (include any along the path) and then modifies +// ownership to the requested uid/gid. By default, if the directory already exists, this +// function will still change ownership and permissions. If WithOnlyNew is passed as an +// option, then only the newly created directories will have ownership and permissions changed. +func MkdirAllAndChown(path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error { + var options mkdirOptions + for _, opt := range opts { + opt(&options) + } + + return mkdirAs(path, mode, uid, gid, true, options.onlyNew) +} + +// MkdirAndChown creates a directory and then modifies ownership to the requested uid/gid. +// By default, if the directory already exists, this function still changes ownership and permissions. +// If WithOnlyNew is passed as an option, then only the newly created directory will have ownership +// and permissions changed. +// Note that unlike os.Mkdir(), this function does not return IsExist error +// in case path already exists. +func MkdirAndChown(path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error { + var options mkdirOptions + for _, opt := range opts { + opt(&options) + } + return mkdirAs(path, mode, uid, gid, false, options.onlyNew) +} + +// 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 (int64(hostID) >= m.ParentID) && (int64(hostID) <= (m.ParentID + m.Count - 1)) { + contID := int(m.ID + (int64(hostID) - m.ParentID)) + 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 (int64(contID) >= m.ID) && (int64(contID) <= (m.ID + m.Count - 1)) { + hostID := int(m.ParentID + (int64(contID) - m.ID)) + return hostID, nil + } + } + return -1, fmt.Errorf("container ID %d cannot be mapped to a host ID", contID) +} + +// 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() (int, int) { + uid, gid, _ := getRootUIDGID(i.UIDMaps, i.GIDMaps) + return uid, 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(uid, gid int) (int, int, error) { + var err error + ruid, rgid := i.RootPair() + + if uid != ruid { + ruid, err = toHost(uid, i.UIDMaps) + if err != nil { + return ruid, rgid, err + } + } + + if gid != rgid { + rgid, err = toHost(gid, i.GIDMaps) + } + return ruid, rgid, err +} + +// ToContainer returns the container UID and GID for the host uid and gid +func (i IdentityMapping) ToContainer(uid, gid int) (int, int, error) { + ruid, err := toContainer(uid, i.UIDMaps) + if err != nil { + return -1, -1, err + } + rgid, err := toContainer(gid, i.GIDMaps) + return ruid, rgid, err +} + +// Empty returns true if there are no id mappings +func (i IdentityMapping) Empty() bool { + return len(i.UIDMaps) == 0 && len(i.GIDMaps) == 0 +} diff --git a/user/idtools_unix.go b/user/idtools_unix.go new file mode 100644 index 00000000..4e39d244 --- /dev/null +++ b/user/idtools_unix.go @@ -0,0 +1,143 @@ +//go:build !windows + +package user + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "syscall" +) + +func mkdirAs(path string, mode os.FileMode, uid, gid int, mkAll, onlyNew 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 onlyNew { + return nil + } + + // short-circuit -- we were called with an existing directory and chown was requested + return setPermissions(path, mode, uid, gid, 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 onlyNew is true, we won't + // chown the full directory path if it exists + var paths []string + if os.IsNotExist(err) { + paths = append(paths, 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); 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, uid, gid, nil); err != nil { + return err + } + } + return nil +} + +// 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, uid, gid int, 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(uid) && ssi.Gid == uint32(gid) { + return nil + } + return os.Chown(p, uid, 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 := LookupUser(name) + if err != nil { + return IdentityMapping{}, fmt.Errorf("could not get user for username %s: %w", 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) ([]IDMap, error) { + uidstr := strconv.Itoa(usr.Uid) + rangeList, err := ParseSubIDFileFilter(path, func(sid 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{} + + var containerID int64 + for _, idrange := range rangeList { + idMap = append(idMap, IDMap{ + ID: containerID, + ParentID: idrange.SubID, + Count: idrange.Count, + }) + containerID = containerID + idrange.Count + } + return idMap, nil +} diff --git a/user/idtools_unix_test.go b/user/idtools_unix_test.go new file mode 100644 index 00000000..db7fc42e --- /dev/null +++ b/user/idtools_unix_test.go @@ -0,0 +1,398 @@ +//go:build !windows + +package user + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +type node struct { + uid int + gid int +} + +func TestMkdirAllAndChown(t *testing.T) { + requiresRoot(t) + dirName := t.TempDir() + + 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, 99, 99); err != nil { + t.Fatal(err) + } + testTree["usr/share"] = node{99, 99} + verifyTree, err := readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + compareTrees(t, testTree, verifyTree) + + // test 2-deep new directories--both should be owned by the uid/gid pair + if err := MkdirAllAndChown(filepath.Join(dirName, "lib", "some", "other"), 0o755, 101, 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) + } + compareTrees(t, testTree, verifyTree) + + // test a directory that already exists; should be chowned, but nothing else + if err := MkdirAllAndChown(filepath.Join(dirName, "usr"), 0o755, 102, 102); err != nil { + t.Fatal(err) + } + testTree["usr"] = node{102, 102} + verifyTree, err = readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + compareTrees(t, testTree, verifyTree) +} + +func TestMkdirAllAndChownNew(t *testing.T) { + requiresRoot(t) + dirName := t.TempDir() + + 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, 99, 99, WithOnlyNew); err != nil { + t.Fatal(err) + } + + testTree["usr/share"] = node{99, 99} + verifyTree, err := readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + compareTrees(t, testTree, verifyTree) + + // test 2-deep new directories--both should be owned by the uid/gid pair + if err = MkdirAllAndChown(filepath.Join(dirName, "lib", "some", "other"), 0o755, 101, 101, WithOnlyNew); err != nil { + t.Fatal(err) + } + testTree["lib/some"] = node{101, 101} + testTree["lib/some/other"] = node{101, 101} + if verifyTree, err = readTree(dirName, ""); err != nil { + t.Fatal(err) + } + compareTrees(t, testTree, verifyTree) + + // test a directory that already exists; should NOT be chowned + if err = MkdirAllAndChown(filepath.Join(dirName, "usr"), 0o755, 102, 102, WithOnlyNew); err != nil { + t.Fatal(err) + } + if verifyTree, err = readTree(dirName, ""); err != nil { + t.Fatal(err) + } + compareTrees(t, 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) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected file not exists for %v, got %v", p, err) + } + } + + if err := MkdirAllAndChown(tc.in, 0o755, expectedUIDGID, expectedUIDGID, WithOnlyNew); err != nil { + t.Fatal(err) + } + + for _, p := range tc.out { + s := &unix.Stat_t{} + if err := unix.Stat(p, s); err != nil { + t.Errorf("stat %v: %v", p, err) + continue + } + if s.Uid != expectedUIDGID { + t.Errorf("expected UID: %d, got: %d", expectedUIDGID, s.Uid) + } + if s.Gid != expectedUIDGID { + t.Errorf("expected GID: %d, got: %d", expectedUIDGID, s.Gid) + } + } + }) + } +} + +// 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() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Error(err) + } + }) + if err = os.Chdir(dir); err != nil { + t.Fatal(err) + } +} + +func TestMkdirAndChown(t *testing.T) { + requiresRoot(t) + dirName := t.TempDir() + + 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, 99, 99); err != nil { + t.Fatal(err) + } + testTree["usr"] = node{99, 99} + verifyTree, err := readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + compareTrees(t, testTree, verifyTree) + + // create a subdir under a dir which doesn't exist--should fail + if err := MkdirAndChown(filepath.Join(dirName, "usr", "bin", "subdir"), 0o755, 102, 102); err == nil { + t.Fatal("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, 102, 102); err != nil { + t.Fatal(err) + } + testTree["usr/bin"] = node{102, 102} + verifyTree, err = readTree(dirName, "") + if err != nil { + t.Fatal(err) + } + compareTrees(t, testTree, verifyTree) +} + +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 err + } + if err := os.Chown(fullPath, node.uid, node.gid); err != nil { + return 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, 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: %w", 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(t testing.TB, left, right map[string]node) { + t.Helper() + if len(left) != len(right) { + t.Fatal("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 + t.Fatalf("mismatched ownership for %q: expected: %d:%d, got: %d:%d", path, + nodeLeft.uid, nodeLeft.gid, nodeRight.uid, nodeRight.gid) + } + continue + } + t.Fatalf("right tree didn't contain path %q", path) + } +} + +func TestGetRootUIDGID(t *testing.T) { + uidMap := []IDMap{ + { + ID: 0, + ParentID: int64(os.Getuid()), + Count: 1, + }, + } + gidMap := []IDMap{ + { + ID: 0, + ParentID: int64(os.Getgid()), + Count: 1, + }, + } + + uid, gid, err := getRootUIDGID(uidMap, gidMap) + if err != nil { + t.Fatal(err) + } + if uid != os.Getuid() { + t.Fatalf("expected %d, got %d", os.Getuid(), uid) + } + if gid != os.Getgid() { + t.Fatalf("expected %d, got %d", os.Getgid(), gid) + } + + uidMapError := []IDMap{ + { + ID: 1, + ParentID: int64(os.Getuid()), + Count: 1, + }, + } + _, _, err = getRootUIDGID(uidMapError, gidMap) + if expected := "container ID 0 cannot be mapped to a host ID"; err.Error() != expected { + t.Fatalf("expected error: %v, got: %v", expected, err) + } +} + +func TestToContainer(t *testing.T) { + uidMap := []IDMap{ + { + ID: 2, + ParentID: 2, + Count: 1, + }, + } + + containerID, err := toContainer(2, uidMap) + if err != nil { + t.Fatal(err) + } + if uidMap[0].ID != int64(containerID) { + t.Fatalf("expected %d, got %d", uidMap[0].ID, 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.TempDir(), t.Name()) + if err != nil { + t.Fatalf("Couldn't create temp dir: %v", err) + } + + err = mkdirAs(file.Name(), 0o755, 0, 0, false, false) + if expected := "mkdir " + file.Name() + ": not a directory"; err.Error() != expected { + t.Fatalf("expected error: %v, got: %v", expected, err) + } +} + +func requiresRoot(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("skipping test that requires root") + } +} diff --git a/user/idtools_windows.go b/user/idtools_windows.go new file mode 100644 index 00000000..9de730ca --- /dev/null +++ b/user/idtools_windows.go @@ -0,0 +1,13 @@ +package user + +import ( + "os" +) + +// 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, _, _ int, _, _ bool) error { + return os.MkdirAll(path, 0) +}