diff --git a/api/next/67002.txt b/api/next/67002.txt index fc839e95e40b7..67c47969f4f3f 100644 --- a/api/next/67002.txt +++ b/api/next/67002.txt @@ -1,6 +1,7 @@ pkg os, func OpenRoot(string) (*Root, error) #67002 pkg os, method (*Root) Close() error #67002 pkg os, method (*Root) Create(string) (*File, error) #67002 +pkg os, method (*Root) FS() fs.FS #67002 pkg os, method (*Root) Lstat(string) (fs.FileInfo, error) #67002 pkg os, method (*Root) Mkdir(string, fs.FileMode) error #67002 pkg os, method (*Root) Name() string #67002 diff --git a/src/os/file.go b/src/os/file.go index 0e2948867c841..a5063680f9058 100644 --- a/src/os/file.go +++ b/src/os/file.go @@ -696,6 +696,8 @@ func (f *File) SyscallConn() (syscall.RawConn, error) { // a general substitute for a chroot-style security mechanism when the directory tree // contains arbitrary content. // +// Use [Root.FS] to obtain a fs.FS that prevents escapes from the tree via symbolic links. +// // The directory dir must not be "". // // The result implements [io/fs.StatFS], [io/fs.ReadFileFS] and @@ -800,7 +802,10 @@ func ReadFile(name string) ([]byte, error) { return nil, err } defer f.Close() + return readFileContents(f) +} +func readFileContents(f *File) ([]byte, error) { var size int if info, err := f.Stat(); err == nil { size64 := info.Size() diff --git a/src/os/os_test.go b/src/os/os_test.go index c646ca82463f2..dbf77db99042a 100644 --- a/src/os/os_test.go +++ b/src/os/os_test.go @@ -3188,10 +3188,21 @@ func forceMFTUpdateOnWindows(t *testing.T, path string) { func TestDirFS(t *testing.T) { t.Parallel() + testDirFS(t, DirFS("./testdata/dirfs")) +} + +func TestRootDirFS(t *testing.T) { + t.Parallel() + r, err := OpenRoot("./testdata/dirfs") + if err != nil { + t.Fatal(err) + } + testDirFS(t, r.FS()) +} +func testDirFS(t *testing.T, fsys fs.FS) { forceMFTUpdateOnWindows(t, "./testdata/dirfs") - fsys := DirFS("./testdata/dirfs") if err := fstest.TestFS(fsys, "a", "b", "dir/x"); err != nil { t.Fatal(err) } diff --git a/src/os/root.go b/src/os/root.go index 1070698f4d678..c7d9b5b071215 100644 --- a/src/os/root.go +++ b/src/os/root.go @@ -6,8 +6,12 @@ package os import ( "errors" + "internal/bytealg" + "internal/stringslite" "internal/testlog" + "io/fs" "runtime" + "slices" ) // Root may be used to only access files within a single directory tree. @@ -213,3 +217,87 @@ func splitPathInRoot(s string, prefix, suffix []string) (_ []string, err error) parts = append(parts, suffix...) return parts, nil } + +// FS returns a file system (an fs.FS) for the tree of files in the root. +// +// The result implements [io/fs.StatFS], [io/fs.ReadFileFS] and +// [io/fs.ReadDirFS]. +func (r *Root) FS() fs.FS { + return (*rootFS)(r) +} + +type rootFS Root + +func (rfs *rootFS) Open(name string) (fs.File, error) { + r := (*Root)(rfs) + if !isValidRootFSPath(name) { + return nil, &PathError{Op: "open", Path: name, Err: ErrInvalid} + } + f, err := r.Open(name) + if err != nil { + return nil, err + } + return f, nil +} + +func (rfs *rootFS) ReadDir(name string) ([]DirEntry, error) { + r := (*Root)(rfs) + if !isValidRootFSPath(name) { + return nil, &PathError{Op: "readdir", Path: name, Err: ErrInvalid} + } + + // This isn't efficient: We just open a regular file and ReadDir it. + // Ideally, we would skip creating a *File entirely and operate directly + // on the file descriptor, but that will require some extensive reworking + // of directory reading in general. + // + // This suffices for the moment. + f, err := r.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + dirs, err := f.ReadDir(-1) + slices.SortFunc(dirs, func(a, b DirEntry) int { + return bytealg.CompareString(a.Name(), b.Name()) + }) + return dirs, err +} + +func (rfs *rootFS) ReadFile(name string) ([]byte, error) { + r := (*Root)(rfs) + if !isValidRootFSPath(name) { + return nil, &PathError{Op: "readfile", Path: name, Err: ErrInvalid} + } + f, err := r.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return readFileContents(f) +} + +func (rfs *rootFS) Stat(name string) (FileInfo, error) { + r := (*Root)(rfs) + if !isValidRootFSPath(name) { + return nil, &PathError{Op: "stat", Path: name, Err: ErrInvalid} + } + return r.Stat(name) +} + +// isValidRootFSPath reprots whether name is a valid filename to pass a Root.FS method. +func isValidRootFSPath(name string) bool { + if !fs.ValidPath(name) { + return false + } + if runtime.GOOS == "windows" { + // fs.FS paths are /-separated. + // On Windows, reject the path if it contains any \ separators. + // Other forms of invalid path (for example, "NUL") are handled by + // Root's usual file lookup mechanisms. + if stringslite.IndexByte(name, '\\') >= 0 { + return false + } + } + return true +} diff --git a/src/os/stat_wasip1.go b/src/os/stat_wasip1.go index 8561e44680df3..dcf2e38ddec2d 100644 --- a/src/os/stat_wasip1.go +++ b/src/os/stat_wasip1.go @@ -15,7 +15,6 @@ import ( func fillFileStatFromSys(fs *fileStat, name string) { fs.name = filepathlite.Base(name) fs.size = int64(fs.sys.Size) - fs.mode = FileMode(fs.sys.Mode) fs.modTime = time.Unix(0, int64(fs.sys.Mtime)) switch fs.sys.Filetype {