Skip to content

Commit 9853d53

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 9853d53

5 files changed

Lines changed: 139 additions & 26 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: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,12 +385,59 @@ func TestMkdirIsNotDir(t *testing.T) {
385385
t.Fatalf("Couldn't create temp dir: %v", err)
386386
}
387387

388-
err = mkdirAs(file.Name(), 0o755, 0, 0, false, false)
388+
fsys := FS(&hostFS{})
389+
390+
err = mkdirAs(fsys, file.Name(), 0o755, 0, 0, false, false)
389391
if expected := "mkdir " + file.Name() + ": not a directory"; err.Error() != expected {
390392
t.Fatalf("expected error: %v, got: %v", expected, err)
391393
}
392394
}
393395

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

0 commit comments

Comments
 (0)