package reportbuf import ( "errors" "io/fs" "log/slog" "os" "os/user" "path/filepath" "strconv" "syscall" "golang.org/x/sys/unix" ) // ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR // that is not the volume or a path below it, written in full. var ErrDataDirOutsideVolume = errors.New( "must be /data or a path below it, with no '.', '..' or extra '/'") // PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the // user the server runs as, so that a host directory mounted at volume, // /data in the image, needs no preparing: it creates dir, gives volume // and the directories, regular files and symbolic links in it to // owner, and gives volume and dir the mode the server gives a directory // it creates. It leaves as they are a regular file or symbolic link // with a second name (hard link), as that name could be outside volume, // and any other file, such as a device (see chownAll). dir must be // volume or a path below it, with no '.', '..', empty part or '/' at // the end. // // bin/entrypoint.sh runs this as root, which would follow a symbolic // link anywhere, so every step goes through an os.Root opened on // volume: it follows a link only when it is written as a relative // path that stays inside volume, and refuses any other. A process on // the host can swap a link onto a path in volume at any moment while // this runs. Even then, the os.Root checks each link as it reaches // it. MkdirAll creates each directory inside a parent it already has // open, never following a link at the name it creates, and follows a // link on the path only as the os.Root allows, so a relative link // inside volume can lead it to create directories elsewhere inside // volume. The owners and modes are set on files already opened (see // chownAll and chmodDir), so the most that process can do is make a // step fail or wait, or act on something else inside volume. func PrepareDataDir(volume, dir string, owner *user.User) error { // rel is dir as a path from volume; IsLocal is false for one that // leads out of it. rel, err := filepath.Rel(volume, dir) if err != nil || dir != filepath.Clean(dir) || !filepath.IsLocal(rel) { return ErrDataDirOutsideVolume } uid, err := strconv.Atoi(owner.Uid) if err != nil { return err } gid, err := strconv.Atoi(owner.Gid) if err != nil { return err } root, err := os.OpenRoot(volume) if err != nil { return err } defer func() { _ = root.Close() }() err = root.MkdirAll(rel, dirPerms) if err != nil { return err } err = chownAll(root, ".", uid, gid) if err != nil { return err } err = chmodDir(root, ".") if err != nil { return err } return chmodDir(root, rel) } // chownAll gives name, a directory inside root, and the directories, // regular files and symbolic links in it to uid and gid. It opens each // through root and gives the open file to them, as chmodDir sets a // mode on the open directory. Root.Lchown would not hold: it acts on // whatever is at the name by then, and a hard link swapped in there // shares its owner with a file that could be outside root. A symbolic // link is given to them itself, never what it points to, because in a // directory that anyone can write to and that has the sticky bit set, // as /tmp has, the kernel follows a link only for the link's owner or // when the link and the directory have the same owner. chownAll goes // into an entry only when the read found a directory there, so it // follows no link it finds; one swapped in afterwards for a directory // is followed only as the os.Root allows, and one swapped in for a // regular file or symbolic link is not followed at all (see // chownFile). It leaves any other file, such as a device: the server // needs to own none of them. func chownAll(root *os.Root, name string, uid, gid int) error { entries, err := chownDir(root, name, uid, gid) if err != nil { return err } for _, entry := range entries { entryName := filepath.Join(name, entry.Name()) switch { case entry.IsDir(): err = chownAll(root, entryName, uid, gid) case entry.Type().IsRegular(), entry.Type() == fs.ModeSymlink: err = chownFile(root, entryName, uid, gid) } if err != nil { return err } } return nil } // chownDir gives name, a directory inside root, to uid and gid, and // returns what it holds. Like chmodDir, it opens name through root and // refuses anything but a directory, which has no second name (hard // link). It reads the open directory, not root.FS(), which refuses a // name that is not valid UTF-8. func chownDir(root *os.Root, name string, uid, gid int) ([]fs.DirEntry, error) { dir, err := root.Open(name) if err != nil { return nil, err } defer func() { _ = dir.Close() }() info, err := dir.Stat() if err != nil { return nil, err } if !info.IsDir() { return nil, &fs.PathError{Op: "chown", Path: name, Err: syscall.ENOTDIR} } err = dir.Chown(uid, gid) if err != nil { return nil, err } return dir.ReadDir(-1) } // chownFile gives name, a regular file or symbolic link inside root, to // uid and gid, unless it has a second name (hard link): all the names // of a file share its owner, and the other could be outside root. It // opens name through root with O_PATH, which reads nothing from the // file; os.Root adds O_NOFOLLOW, and the two together open a symbolic // link itself, never what it points to. It reads the number of names // from the open file and gives that file to them, so a hard link // swapped in at name is caught too. It logs a file it leaves. func chownFile(root *os.Root, name string, uid, gid int) error { file, err := root.OpenFile(name, unix.O_PATH, 0) if err != nil { return err } defer func() { _ = file.Close() }() info, err := file.Stat() if err != nil { return err } // File.Chown would fail: fchown refuses a file opened with O_PATH. // Fchownat with an empty name and AT_EMPTY_PATH acts on the open // file instead. stat, ok := info.Sys().(*syscall.Stat_t) if ok && stat.Nlink == 1 { return unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH) } slog.Warn("owner not changed: file has a second name (hard link)", "path", file.Name()) return nil } // chmodDir gives name, a directory inside root, the mode the server // gives a directory it creates. Root.Chmod would not hold: on Linux it // checks that name is not a symbolic link, then sets the mode by name, // following a link swapped in between. So chmodDir opens name through // root and sets the mode on the open directory. It refuses anything // but a directory: a directory has no second name (hard link), so the // one opened is inside root, where any other file could be a hard link // to one outside. func chmodDir(root *os.Root, name string) error { dir, err := root.Open(name) if err != nil { return err } defer func() { _ = dir.Close() }() info, err := dir.Stat() if err != nil { return err } if !info.IsDir() { return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR} } return dir.Chmod(dirPerms) }