diff --git a/user/go.mod b/user/go.mod index ad6231c..c494b18 100644 --- a/user/go.mod +++ b/user/go.mod @@ -1,6 +1,6 @@ module github.com/moby/sys/user -go 1.18 +go 1.24 require golang.org/x/sys v0.1.0 diff --git a/user/idtools.go b/user/idtools.go index 595b7a9..829e504 100644 --- a/user/idtools.go +++ b/user/idtools.go @@ -3,6 +3,21 @@ package user import ( "fmt" "os" + "path/filepath" +) + +// FS is the filesystem contract used by the Mkdir*AndChownFS helpers. +type FS interface { + Stat(name string) (os.FileInfo, error) + Mkdir(name string, perm os.FileMode) error + MkdirAll(name string, perm os.FileMode) error + Chmod(name string, mode os.FileMode) error + Chown(name string, uid, gid int) error +} + +var ( + _ FS = &os.Root{} + _ FS = &hostFS{} ) // MkdirOpt is a type for options to pass to Mkdir calls @@ -23,12 +38,24 @@ func WithOnlyNew(o *mkdirOptions) { // 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 MkdirAllAndChownFS(nil, path, mode, uid, gid, opts...) +} + +// MkdirAllAndChownFS creates a directory (including any along the path) on the +// provided filesystem and then modifies ownership to the requested uid/gid. If +// fsys is nil, the host filesystem is used. +func MkdirAllAndChownFS(fsys FS, path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error { + if fsys == nil { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + fsys = &hostFS{} + path = absPath } - return mkdirAs(path, mode, uid, gid, true, options.onlyNew) + options := mkdirOpts(opts) + return mkdirAs(fsys, path, mode, uid, gid, true, options.onlyNew) } // MkdirAndChown creates a directory and then modifies ownership to the requested uid/gid. @@ -38,11 +65,32 @@ func MkdirAllAndChown(path string, mode os.FileMode, uid, gid int, opts ...Mkdir // 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 { + return MkdirAndChownFS(nil, path, mode, uid, gid, opts...) +} + +// MkdirAndChownFS creates a directory on the provided filesystem and then +// modifies ownership to the requested uid/gid. If fsys is nil, the host +// filesystem is used. +func MkdirAndChownFS(fsys FS, path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error { + if fsys == nil { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + fsys = &hostFS{} + path = absPath + } + + options := mkdirOpts(opts) + return mkdirAs(fsys, path, mode, uid, gid, false, options.onlyNew) +} + +func mkdirOpts(opts []MkdirOpt) mkdirOptions { var options mkdirOptions for _, opt := range opts { opt(&options) } - return mkdirAs(path, mode, uid, gid, false, options.onlyNew) + return options } // getRootUIDGID retrieves the remapped root uid/gid pair from the set of maps. @@ -139,3 +187,25 @@ func (i IdentityMapping) ToContainer(uid, gid int) (int, int, error) { func (i IdentityMapping) Empty() bool { return len(i.UIDMaps) == 0 && len(i.GIDMaps) == 0 } + +type hostFS struct{} + +func (*hostFS) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} + +func (*hostFS) Mkdir(name string, perm os.FileMode) error { + return os.Mkdir(name, perm) +} + +func (*hostFS) MkdirAll(name string, perm os.FileMode) error { + return os.MkdirAll(name, perm) +} + +func (*hostFS) Chmod(name string, mode os.FileMode) error { + return os.Chmod(name, mode) +} + +func (*hostFS) Chown(name string, uid, gid int) error { + return os.Chown(name, uid, gid) +} diff --git a/user/idtools_unix.go b/user/idtools_unix.go index 4e39d24..ea1dcbd 100644 --- a/user/idtools_unix.go +++ b/user/idtools_unix.go @@ -4,19 +4,15 @@ package user import ( "fmt" + "io/fs" "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) +func mkdirAs(fsys FS, path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) error { + stat, err := fsys.Stat(path) if err == nil { if !stat.IsDir() { return &os.PathError{Op: "mkdir", Path: path, Err: syscall.ENOTDIR} @@ -26,7 +22,7 @@ func mkdirAs(path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) e } // short-circuit -- we were called with an existing directory and chown was requested - return setPermissions(path, mode, uid, gid, stat) + return setPermissions(fsys, path, mode, uid, gid, stat) } // make an array containing the original path asked for, plus (for mkAll == true) @@ -44,23 +40,23 @@ func mkdirAs(path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) e dirPath := path for { dirPath = filepath.Dir(dirPath) - if dirPath == "/" { + if dirPath == string(filepath.Separator) || dirPath == "." { break } - if _, err = os.Stat(dirPath); os.IsNotExist(err) { + if _, err = fsys.Stat(dirPath); os.IsNotExist(err) { paths = append(paths, dirPath) } } - if err = os.MkdirAll(path, mode); err != nil { + if err = fsys.MkdirAll(path, mode); err != nil { return err } - } else if err = os.Mkdir(path, mode); err != nil { + } else if err = fsys.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 { + if err = setPermissions(fsys, pathComponent, mode, uid, gid, nil); err != nil { return err } } @@ -71,16 +67,16 @@ func mkdirAs(path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) e // 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 { +func setPermissions(fsys FS, p string, mode os.FileMode, uid, gid int, stat fs.FileInfo) error { if stat == nil { var err error - stat, err = os.Stat(p) + stat, err = fsys.Stat(p) if err != nil { return err } } if stat.Mode().Perm() != mode.Perm() { - if err := os.Chmod(p, mode.Perm()); err != nil { + if err := fsys.Chmod(p, mode.Perm()); err != nil { return err } } @@ -88,7 +84,7 @@ func setPermissions(p string, mode os.FileMode, uid, gid int, stat os.FileInfo) if ssi.Uid == uint32(uid) && ssi.Gid == uint32(gid) { return nil } - return os.Chown(p, uid, gid) + return fsys.Chown(p, uid, gid) } // LoadIdentityMapping takes a requested username and diff --git a/user/idtools_unix_test.go b/user/idtools_unix_test.go index db7fc42..603ed42 100644 --- a/user/idtools_unix_test.go +++ b/user/idtools_unix_test.go @@ -5,6 +5,7 @@ package user import ( "errors" "fmt" + "maps" "os" "path/filepath" "testing" @@ -292,9 +293,7 @@ func readTree(base, root string) (map[string]node, error) { if err != nil { return nil, err } - for path, nodeinfo := range subtree { - tree[path] = nodeinfo - } + maps.Copy(tree, subtree) } } return tree, nil @@ -385,12 +384,59 @@ func TestMkdirIsNotDir(t *testing.T) { t.Fatalf("Couldn't create temp dir: %v", err) } - err = mkdirAs(file.Name(), 0o755, 0, 0, false, false) + fsys := FS(&hostFS{}) + + err = mkdirAs(fsys, 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 TestMkdirAllAndChownFSNilUsesHostFS(t *testing.T) { + requiresRoot(t) + + baseDir := t.TempDir() + path := filepath.Join(baseDir, "usr", "share") + + if err := MkdirAllAndChownFS(nil, path, 0o755, 99, 99); err != nil { + t.Fatal(err) + } + + s := &unix.Stat_t{} + if err := unix.Stat(path, s); err != nil { + t.Fatal(err) + } + if s.Uid != 99 || s.Gid != 99 { + t.Fatalf("expected ownership 99:99, got %d:%d", s.Uid, s.Gid) + } +} + +func TestMkdirAllAndChownFSWithRoot(t *testing.T) { + requiresRoot(t) + + baseDir := t.TempDir() + root, err := os.OpenRoot(baseDir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + + if err := MkdirAllAndChownFS(root, "usr/share", 0o755, 123, 124); err != nil { + t.Fatal(err) + } + + verifyTree, err := readTree(baseDir, "") + if err != nil { + t.Fatal(err) + } + + expected := map[string]node{ + "usr": {123, 124}, + "usr/share": {123, 124}, + } + compareTrees(t, expected, verifyTree) +} + 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 index 9de730c..c8a7cca 100644 --- a/user/idtools_windows.go +++ b/user/idtools_windows.go @@ -8,6 +8,6 @@ import ( // 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) +func mkdirAs(fsys FS, path string, _ os.FileMode, _, _ int, _, _ bool) error { + return fsys.MkdirAll(path, 0) } diff --git a/user/user.go b/user/user.go index e2788a1..1b7e999 100644 --- a/user/user.go +++ b/user/user.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" ) @@ -373,12 +374,7 @@ func GetExecUser(userSpec string, defaults *ExecUser, passwd, group io.Reader) ( // If the group argument isn't explicit, we'll just search for it. if groupArg == "" { // Check if user is a member of this group. - for _, u := range g.List { - if u == matchedUserName { - return true - } - } - return false + return slices.Contains(g.List, matchedUserName) } if gidErr == nil {