package reportbuf import ( "errors" "io/fs" "log/slog" "os" "os/user" "path/filepath" "strconv" "syscall" ) // 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 and regular files 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 with a second name (hard link), as // that name could be outside volume, and any file that is neither a // directory nor a regular file, such as a symbolic link (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 // and regular files 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. 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 or a regular file // is followed only as the os.Root allows. It leaves any file that is // neither a directory nor a regular file, such as a symbolic link: // opening one would follow the link or open the device, Root.Lchown // would not hold, and 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(): 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 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, 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.Open(name) if err != nil { return err } defer func() { _ = file.Close() }() info, err := file.Stat() if err != nil { return err } stat, ok := info.Sys().(*syscall.Stat_t) if ok && stat.Nlink == 1 { return file.Chown(uid, gid) } 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) }