Skip to content

Commit 75a5ad9

Browse files
committed
user: add filesystem-aware mkdir/chown helpers
Add FS-backed MkdirAllAndChownFS and MkdirAndChownFS variants for better security via os.Root implementation. Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
1 parent 469a746 commit 75a5ad9

6 files changed

Lines changed: 143 additions & 35 deletions

File tree

user/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/moby/sys/user
22

3-
go 1.18
3+
go 1.24
44

55
require golang.org/x/sys v0.1.0
66

user/idtools.go

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ package user
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
7+
)
8+
9+
// FS is the filesystem contract used by the Mkdir*AndChownFS helpers.
10+
type FS interface {
11+
Stat(name string) (os.FileInfo, error)
12+
Mkdir(name string, perm os.FileMode) error
13+
MkdirAll(name string, perm os.FileMode) error
14+
Chmod(name string, mode os.FileMode) error
15+
Chown(name string, uid, gid int) error
16+
}
17+
18+
var (
19+
_ FS = &os.Root{}
20+
_ FS = &hostFS{}
621
)
722

823
// MkdirOpt is a type for options to pass to Mkdir calls
@@ -23,12 +38,24 @@ func WithOnlyNew(o *mkdirOptions) {
2338
// function will still change ownership and permissions. If WithOnlyNew is passed as an
2439
// option, then only the newly created directories will have ownership and permissions changed.
2540
func MkdirAllAndChown(path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error {
26-
var options mkdirOptions
27-
for _, opt := range opts {
28-
opt(&options)
41+
return MkdirAllAndChownFS(nil, path, mode, uid, gid, opts...)
42+
}
43+
44+
// MkdirAllAndChownFS creates a directory (including any along the path) on the
45+
// provided filesystem and then modifies ownership to the requested uid/gid. If
46+
// fsys is nil, the host filesystem is used.
47+
func MkdirAllAndChownFS(fsys FS, path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error {
48+
if fsys == nil {
49+
absPath, err := filepath.Abs(path)
50+
if err != nil {
51+
return err
52+
}
53+
fsys = &hostFS{}
54+
path = absPath
2955
}
3056

31-
return mkdirAs(path, mode, uid, gid, true, options.onlyNew)
57+
options := mkdirOpts(opts)
58+
return mkdirAs(fsys, path, mode, uid, gid, true, options.onlyNew)
3259
}
3360

3461
// 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
3865
// Note that unlike os.Mkdir(), this function does not return IsExist error
3966
// in case path already exists.
4067
func MkdirAndChown(path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error {
68+
return MkdirAndChownFS(nil, path, mode, uid, gid, opts...)
69+
}
70+
71+
// MkdirAndChownFS creates a directory on the provided filesystem and then
72+
// modifies ownership to the requested uid/gid. If fsys is nil, the host
73+
// filesystem is used.
74+
func MkdirAndChownFS(fsys FS, path string, mode os.FileMode, uid, gid int, opts ...MkdirOpt) error {
75+
if fsys == nil {
76+
absPath, err := filepath.Abs(path)
77+
if err != nil {
78+
return err
79+
}
80+
fsys = &hostFS{}
81+
path = absPath
82+
}
83+
84+
options := mkdirOpts(opts)
85+
return mkdirAs(fsys, path, mode, uid, gid, false, options.onlyNew)
86+
}
87+
88+
func mkdirOpts(opts []MkdirOpt) mkdirOptions {
4189
var options mkdirOptions
4290
for _, opt := range opts {
4391
opt(&options)
4492
}
45-
return mkdirAs(path, mode, uid, gid, false, options.onlyNew)
93+
return options
4694
}
4795

4896
// 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) {
139187
func (i IdentityMapping) Empty() bool {
140188
return len(i.UIDMaps) == 0 && len(i.GIDMaps) == 0
141189
}
190+
191+
type hostFS struct{}
192+
193+
func (*hostFS) Stat(name string) (os.FileInfo, error) {
194+
return os.Stat(name)
195+
}
196+
197+
func (*hostFS) Mkdir(name string, perm os.FileMode) error {
198+
return os.Mkdir(name, perm)
199+
}
200+
201+
func (*hostFS) MkdirAll(name string, perm os.FileMode) error {
202+
return os.MkdirAll(name, perm)
203+
}
204+
205+
func (*hostFS) Chmod(name string, mode os.FileMode) error {
206+
return os.Chmod(name, mode)
207+
}
208+
209+
func (*hostFS) Chown(name string, uid, gid int) error {
210+
return os.Chown(name, uid, gid)
211+
}

user/idtools_unix.go

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,15 @@ package user
44

55
import (
66
"fmt"
7+
"io/fs"
78
"os"
89
"path/filepath"
910
"strconv"
1011
"syscall"
1112
)
1213

13-
func mkdirAs(path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) error {
14-
path, err := filepath.Abs(path)
15-
if err != nil {
16-
return err
17-
}
18-
19-
stat, err := os.Stat(path)
14+
func mkdirAs(fsys FS, path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) error {
15+
stat, err := fsys.Stat(path)
2016
if err == nil {
2117
if !stat.IsDir() {
2218
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
2622
}
2723

2824
// short-circuit -- we were called with an existing directory and chown was requested
29-
return setPermissions(path, mode, uid, gid, stat)
25+
return setPermissions(fsys, path, mode, uid, gid, stat)
3026
}
3127

3228
// 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
4440
dirPath := path
4541
for {
4642
dirPath = filepath.Dir(dirPath)
47-
if dirPath == "/" {
43+
if dirPath == string(filepath.Separator) || dirPath == "." {
4844
break
4945
}
50-
if _, err = os.Stat(dirPath); os.IsNotExist(err) {
46+
if _, err = fsys.Stat(dirPath); os.IsNotExist(err) {
5147
paths = append(paths, dirPath)
5248
}
5349
}
54-
if err = os.MkdirAll(path, mode); err != nil {
50+
if err = fsys.MkdirAll(path, mode); err != nil {
5551
return err
5652
}
57-
} else if err = os.Mkdir(path, mode); err != nil {
53+
} else if err = fsys.Mkdir(path, mode); err != nil {
5854
return err
5955
}
6056
// even if it existed, we will chown the requested path + any subpaths that
6157
// didn't exist when we called MkdirAll
6258
for _, pathComponent := range paths {
63-
if err = setPermissions(pathComponent, mode, uid, gid, nil); err != nil {
59+
if err = setPermissions(fsys, pathComponent, mode, uid, gid, nil); err != nil {
6460
return err
6561
}
6662
}
@@ -71,24 +67,24 @@ func mkdirAs(path string, mode os.FileMode, uid, gid int, mkAll, onlyNew bool) e
7167
// 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
7268
// dir is on an NFS share, so don't call chown unless we absolutely must.
7369
// Likewise for setting permissions.
74-
func setPermissions(p string, mode os.FileMode, uid, gid int, stat os.FileInfo) error {
70+
func setPermissions(fsys FS, p string, mode os.FileMode, uid, gid int, stat fs.FileInfo) error {
7571
if stat == nil {
7672
var err error
77-
stat, err = os.Stat(p)
73+
stat, err = fsys.Stat(p)
7874
if err != nil {
7975
return err
8076
}
8177
}
8278
if stat.Mode().Perm() != mode.Perm() {
83-
if err := os.Chmod(p, mode.Perm()); err != nil {
79+
if err := fsys.Chmod(p, mode.Perm()); err != nil {
8480
return err
8581
}
8682
}
8783
ssi := stat.Sys().(*syscall.Stat_t)
8884
if ssi.Uid == uint32(uid) && ssi.Gid == uint32(gid) {
8985
return nil
9086
}
91-
return os.Chown(p, uid, gid)
87+
return fsys.Chown(p, uid, gid)
9288
}
9389

9490
// LoadIdentityMapping takes a requested username and

user/idtools_unix_test.go

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package user
55
import (
66
"errors"
77
"fmt"
8+
"maps"
89
"os"
910
"path/filepath"
1011
"testing"
@@ -292,9 +293,7 @@ func readTree(base, root string) (map[string]node, error) {
292293
if err != nil {
293294
return nil, err
294295
}
295-
for path, nodeinfo := range subtree {
296-
tree[path] = nodeinfo
297-
}
296+
maps.Copy(tree, subtree)
298297
}
299298
}
300299
return tree, nil
@@ -385,12 +384,59 @@ func TestMkdirIsNotDir(t *testing.T) {
385384
t.Fatalf("Couldn't create temp dir: %v", err)
386385
}
387386

388-
err = mkdirAs(file.Name(), 0o755, 0, 0, false, false)
387+
fsys := FS(&hostFS{})
388+
389+
err = mkdirAs(fsys, file.Name(), 0o755, 0, 0, false, false)
389390
if expected := "mkdir " + file.Name() + ": not a directory"; err.Error() != expected {
390391
t.Fatalf("expected error: %v, got: %v", expected, err)
391392
}
392393
}
393394

395+
func TestMkdirAllAndChownFSNilUsesHostFS(t *testing.T) {
396+
requiresRoot(t)
397+
398+
baseDir := t.TempDir()
399+
path := filepath.Join(baseDir, "usr", "share")
400+
401+
if err := MkdirAllAndChownFS(nil, path, 0o755, 99, 99); err != nil {
402+
t.Fatal(err)
403+
}
404+
405+
s := &unix.Stat_t{}
406+
if err := unix.Stat(path, s); err != nil {
407+
t.Fatal(err)
408+
}
409+
if s.Uid != 99 || s.Gid != 99 {
410+
t.Fatalf("expected ownership 99:99, got %d:%d", s.Uid, s.Gid)
411+
}
412+
}
413+
414+
func TestMkdirAllAndChownFSWithRoot(t *testing.T) {
415+
requiresRoot(t)
416+
417+
baseDir := t.TempDir()
418+
root, err := os.OpenRoot(baseDir)
419+
if err != nil {
420+
t.Fatal(err)
421+
}
422+
defer root.Close()
423+
424+
if err := MkdirAllAndChownFS(root, "usr/share", 0o755, 123, 124); err != nil {
425+
t.Fatal(err)
426+
}
427+
428+
verifyTree, err := readTree(baseDir, "")
429+
if err != nil {
430+
t.Fatal(err)
431+
}
432+
433+
expected := map[string]node{
434+
"usr": {123, 124},
435+
"usr/share": {123, 124},
436+
}
437+
compareTrees(t, expected, verifyTree)
438+
}
439+
394440
func requiresRoot(t *testing.T) {
395441
if os.Getuid() != 0 {
396442
t.Skip("skipping test that requires root")

user/idtools_windows.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ import (
88
// permissions aren't set through this path, the identity isn't utilized.
99
// Ownership is handled elsewhere, but in the future could be support here
1010
// too.
11-
func mkdirAs(path string, _ os.FileMode, _, _ int, _, _ bool) error {
12-
return os.MkdirAll(path, 0)
11+
func mkdirAs(fsys FS, path string, _ os.FileMode, _, _ int, _, _ bool) error {
12+
return fsys.MkdirAll(path, 0)
1313
}

user/user.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"os"
10+
"slices"
1011
"strconv"
1112
"strings"
1213
)
@@ -373,12 +374,7 @@ func GetExecUser(userSpec string, defaults *ExecUser, passwd, group io.Reader) (
373374
// If the group argument isn't explicit, we'll just search for it.
374375
if groupArg == "" {
375376
// Check if user is a member of this group.
376-
for _, u := range g.List {
377-
if u == matchedUserName {
378-
return true
379-
}
380-
}
381-
return false
377+
return slices.Contains(g.List, matchedUserName)
382378
}
383379

384380
if gidErr == nil {

0 commit comments

Comments
 (0)