|
| 1 | +//go:build linux |
| 2 | + |
| 3 | +// Package fdfs is like os.DirFS, but with a file descriptor and openat(2), |
| 4 | +// fchownat(2), etc, to ensure symlinks do not escape. |
| 5 | +package fdfs |
| 6 | + |
| 7 | +import ( |
| 8 | + "fmt" |
| 9 | + "io/fs" |
| 10 | + "os" |
| 11 | + |
| 12 | + "golang.org/x/sys/unix" |
| 13 | +) |
| 14 | + |
| 15 | +const resolveFlags = unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_XDEV |
| 16 | + |
| 17 | +// FS uses a file descriptor for a directory as the base of a fs.FS. |
| 18 | +type FS struct { |
| 19 | + file *os.File |
| 20 | +} |
| 21 | + |
| 22 | +// DirFS opens the directory dir, and returns an FS rooted at that directory. |
| 23 | +// It uses open(2) with O_RDONLY+O_DIRECTORY+O_CLOEXEC. Note that this will |
| 24 | +// resolve symlinks in the path, so only use this to open a trusted base path. |
| 25 | +func DirFS(dir string) (*FS, error) { |
| 26 | + f, err := os.OpenFile(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| 27 | + if err != nil { |
| 28 | + return nil, err |
| 29 | + } |
| 30 | + return &FS{file: f}, nil |
| 31 | +} |
| 32 | + |
| 33 | +// Close closes the file descriptor. |
| 34 | +func (s *FS) Close() error { |
| 35 | + return s.file.Close() |
| 36 | +} |
| 37 | + |
| 38 | +// Open wraps openat2(2) with O_RDONLY+O_NOFOLLOW+O_CLOEXEC, and prohibits |
| 39 | +// symlinks etc within the path. |
| 40 | +func (s *FS) Open(path string) (fs.File, error) { |
| 41 | + fd, err := unix.Openat2(int(s.file.Fd()), path, &unix.OpenHow{ |
| 42 | + Flags: unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC, |
| 43 | + Mode: 0, |
| 44 | + Resolve: resolveFlags, |
| 45 | + }) |
| 46 | + if err != nil { |
| 47 | + return nil, fmt.Errorf("openat2(%d, %q): %w", s.file.Fd(), path, err) |
| 48 | + } |
| 49 | + return os.NewFile(uintptr(fd), path), nil |
| 50 | +} |
| 51 | + |
| 52 | +// Lchown wraps fchownat(2) (with AT_SYMLINK_NOFOLLOW). |
| 53 | +func (s *FS) Lchown(path string, uid, gid int) error { |
| 54 | + if err := unix.Fchownat(int(s.file.Fd()), path, uid, gid, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| 55 | + return fmt.Errorf("fchownat(%d, %q, %d, %d): %w", s.file.Fd(), path, uid, gid, err) |
| 56 | + } |
| 57 | + return nil |
| 58 | +} |
| 59 | + |
| 60 | +// Sub wraps openat2(2) (with O_RDONLY+O_DIRECTORY+O_NOFOLLOW+O_CLOEXEC), and |
| 61 | +// returns an FS. |
| 62 | +func (s *FS) Sub(dir string) (*FS, error) { |
| 63 | + fd, err := unix.Openat2(int(s.file.Fd()), dir, &unix.OpenHow{ |
| 64 | + Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC, |
| 65 | + Mode: 0, |
| 66 | + Resolve: resolveFlags, |
| 67 | + }) |
| 68 | + if err != nil { |
| 69 | + return nil, fmt.Errorf("openat2(%d, %q): %w", s.file.Fd(), dir, err) |
| 70 | + } |
| 71 | + return &FS{os.NewFile(uintptr(fd), dir)}, nil |
| 72 | +} |
| 73 | + |
| 74 | +// RecursiveChown lchowns everything within the receiver. |
| 75 | +func (s *FS) RecursiveChown(uid, gid int) error { |
| 76 | + // Q: Why not fs.WalkDir(... s.Lchown(path, uid, gid) ... ) ? |
| 77 | + // A: fs.WalkDir gives the callback a subpath to each item. So although |
| 78 | + // fs.WalkDir doesn't traverse symlinks, there's a race between walking |
| 79 | + // each path (no intermediate symlinks), and passing that path to lchown |
| 80 | + // (has possibly changed). |
| 81 | + // Solution: More openat. |
| 82 | + |
| 83 | + if err := s.Lchown(".", uid, gid); err != nil { |
| 84 | + return err |
| 85 | + } |
| 86 | + |
| 87 | + // This closure exists so sd.Close happens before the next loop iteration, |
| 88 | + // rather than at the end of RecursiveChown. |
| 89 | + chownSubdir := func(name string) error { |
| 90 | + sd, err := s.Sub(name) |
| 91 | + if err != nil { |
| 92 | + return err |
| 93 | + } |
| 94 | + defer sd.Close() |
| 95 | + return sd.RecursiveChown(uid, gid) |
| 96 | + } |
| 97 | + |
| 98 | + // The "file" within an *FS should always be a directory. |
| 99 | + ds, err := s.file.ReadDir(-1) |
| 100 | + if err != nil { |
| 101 | + return err |
| 102 | + } |
| 103 | + for _, d := range ds { |
| 104 | + if !d.IsDir() { |
| 105 | + if err := s.Lchown(d.Name(), uid, gid); err != nil { |
| 106 | + return err |
| 107 | + } |
| 108 | + continue |
| 109 | + } |
| 110 | + |
| 111 | + // Defensively check we're not about to recurse on a symlink. |
| 112 | + // (The openat2 call in s.Sub will block it anyway.) |
| 113 | + if d.Type()&fs.ModeSymlink != 0 { |
| 114 | + continue |
| 115 | + } |
| 116 | + |
| 117 | + if err := chownSubdir(d.Name()); err != nil { |
| 118 | + return err |
| 119 | + } |
| 120 | + } |
| 121 | + return nil |
| 122 | +} |
0 commit comments