src/internal/poll/fstatat_unix.go | 22 ++++++++++++++++++++++ src/os/dir_darwin.go | 4 ++-- src/os/dir_unix.go | 4 ++-- src/os/export_test.go | 14 +++++++++++++- src/os/file.go | 3 --- src/os/file_unix.go | 14 +++++++++----- src/os/os_test.go | 7 +++---- src/os/os_unix_test.go | 8 +++----- src/os/root_test.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/os/root_unix.go | 1 + src/os/stat.go | 3 +++ src/os/statat.go | 24 ++++++++++++++++++++++++ src/os/statat_other.go | 12 ++++++++++++ src/os/statat_unix.go | 20 ++++++++++++++++++++ diff --git a/src/internal/poll/fstatat_unix.go b/src/internal/poll/fstatat_unix.go new file mode 100644 index 0000000000000000000000000000000000000000..cde8551a775de40d71ab83b14331edbc692068ff --- /dev/null +++ b/src/internal/poll/fstatat_unix.go @@ -0,0 +1,22 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build unix || wasip1 + +package poll + +import ( + "internal/syscall/unix" + "syscall" +) + +func (fd *FD) Fstatat(name string, s *syscall.Stat_t, flags int) error { + if err := fd.incref(); err != nil { + return err + } + defer fd.decref() + return ignoringEINTR(func() error { + return unix.Fstatat(fd.Sysfd, name, s, flags) + }) +} diff --git a/src/os/dir_darwin.go b/src/os/dir_darwin.go index 91b67d8d61d1fc0f23a4cf0fce2db8364401efa8..b6ebca7e9e837fef3a537613a72a9151c9e3042c 100644 --- a/src/os/dir_darwin.go +++ b/src/os/dir_darwin.go @@ -88,7 +88,7 @@ } if mode == readdirName { names = append(names, string(name)) } else if mode == readdirDirEntry { - de, err := newUnixDirent(f.name, string(name), dtToType(dirent.Type)) + de, err := newUnixDirent(f, string(name), dtToType(dirent.Type)) if IsNotExist(err) { // File disappeared between readdir and stat. // Treat as if it didn't exist. @@ -99,7 +99,7 @@ return nil, dirents, nil, err } dirents = append(dirents, de) } else { - info, err := lstat(f.name + "/" + string(name)) + info, err := f.lstatat(string(name)) if IsNotExist(err) { // File disappeared between readdir + stat. // Treat as if it didn't exist. diff --git a/src/os/dir_unix.go b/src/os/dir_unix.go index 87df3122d4eada8502b556ee422a4137e8292a7f..8f1aabec52b4c730441cff35c6742226a7071dc7 100644 --- a/src/os/dir_unix.go +++ b/src/os/dir_unix.go @@ -138,7 +138,7 @@ } if mode == readdirName { names = append(names, string(name)) } else if mode == readdirDirEntry { - de, err := newUnixDirent(f.name, string(name), direntType(rec)) + de, err := newUnixDirent(f, string(name), direntType(rec)) if IsNotExist(err) { // File disappeared between readdir and stat. // Treat as if it didn't exist. @@ -149,7 +149,7 @@ return nil, dirents, nil, err } dirents = append(dirents, de) } else { - info, err := lstat(f.name + "/" + string(name)) + info, err := f.lstatat(string(name)) if IsNotExist(err) { // File disappeared between readdir + stat. // Treat as if it didn't exist. diff --git a/src/os/export_test.go b/src/os/export_test.go index 93b10898e0b4ba45e1ddfe50a563a4fd1e8b3e31..bea38c905a673fca91e6efc4285cdbc1c9e8d74d 100644 --- a/src/os/export_test.go +++ b/src/os/export_test.go @@ -7,7 +7,6 @@ // Export for testing. var Atime = atime -var LstatP = &lstat var ErrWriteAtInAppendMode = errWriteAtInAppendMode var ErrPatternHasSeparator = errPatternHasSeparator @@ -16,3 +15,16 @@ checkWrapErr = true } var ExportReadFileContents = readFileContents + +// cleanuper stands in for *testing.T, since we can't import testing in os. +type cleanuper interface { + Cleanup(func()) +} + +func SetStatHook(t cleanuper, f func(f *File, name string) (FileInfo, error)) { + oldstathook := stathook + t.Cleanup(func() { + stathook = oldstathook + }) + stathook = f +} diff --git a/src/os/file.go b/src/os/file.go index 66269c199e7d95309c72b41f2522232ffbbb6c09..80857240f5c7bbb12ca58ca9035469da66a5360e 100644 --- a/src/os/file.go +++ b/src/os/file.go @@ -428,9 +428,6 @@ testlog.Open(name) return openDirNolog(name) } -// lstat is overridden in tests. -var lstat = Lstat - // Rename renames (moves) oldpath to newpath. // If newpath already exists and is not a directory, Rename replaces it. // If newpath already exists and is a directory, Rename returns an error. diff --git a/src/os/file_unix.go b/src/os/file_unix.go index 2074df70febc2eaad96a938990ccb065b875564d..6f9cab788b556005ff4ced5e62b9dd6ac5c5ce14 100644 --- a/src/os/file_unix.go +++ b/src/os/file_unix.go @@ -63,6 +63,7 @@ dirinfo atomic.Pointer[dirInfo] // nil unless directory being read nonblock bool // whether we set nonblocking mode stdoutOrErr bool // whether this is stdout or stderr appendMode bool // whether file is opened for appending + inRoot bool // whether file is opened in a Root } // fd is the Unix implementation of Fd. @@ -458,24 +459,27 @@ func (d *unixDirent) Info() (FileInfo, error) { if d.info != nil { return d.info, nil } - return lstat(d.parent + "/" + d.name) + return Lstat(d.parent + "/" + d.name) } func (d *unixDirent) String() string { return fs.FormatDirEntry(d) } -func newUnixDirent(parent, name string, typ FileMode) (DirEntry, error) { +func newUnixDirent(parent *File, name string, typ FileMode) (DirEntry, error) { ude := &unixDirent{ - parent: parent, + parent: parent.name, name: name, typ: typ, } - if typ != ^FileMode(0) { + // When the parent file was opened in a Root, + // we cannot use a lazy lstat to load the FileInfo. + // Use lstatat here. + if typ != ^FileMode(0) && !parent.inRoot { return ude, nil } - info, err := lstat(parent + "/" + name) + info, err := parent.lstatat(name) if err != nil { return nil, err } diff --git a/src/os/os_test.go b/src/os/os_test.go index 9f6eb13e1f96a9b62b34ec659d23132d5b3d6411..13db353352e005ed48e5f78daa837f32e292df01 100644 --- a/src/os/os_test.go +++ b/src/os/os_test.go @@ -767,13 +767,12 @@ t.Skipf("skipping test on %v", runtime.GOOS) } var xerr error // error to return for x - *LstatP = func(path string) (FileInfo, error) { + SetStatHook(t, func(f *File, path string) (FileInfo, error) { if xerr != nil && strings.HasSuffix(path, "x") { return nil, xerr } - return Lstat(path) - } - defer func() { *LstatP = Lstat }() + return nil, nil + }) dir := t.TempDir() touch(t, filepath.Join(dir, "good1")) diff --git a/src/os/os_unix_test.go b/src/os/os_unix_test.go index 41feaf77e2cea4d1b3e48221815fb5e5942cb39b..6b55e09427b100bdc43556a38583b4e9e5057fa2 100644 --- a/src/os/os_unix_test.go +++ b/src/os/os_unix_test.go @@ -196,15 +196,13 @@ } // Issue 16919: Readdir must return a non-empty slice or an error. func TestReaddirRemoveRace(t *testing.T) { - oldStat := *LstatP - defer func() { *LstatP = oldStat }() - *LstatP = func(name string) (FileInfo, error) { + SetStatHook(t, func(f *File, name string) (FileInfo, error) { if strings.HasSuffix(name, "some-file") { // Act like it's been deleted. return nil, ErrNotExist } - return oldStat(name) - } + return nil, nil + }) dir := t.TempDir() if err := WriteFile(filepath.Join(dir, "some-file"), []byte("hello"), 0644); err != nil { t.Fatal(err) diff --git a/src/os/root_test.go b/src/os/root_test.go index f9fbd11575e6a058683a49080863cef359e3ed46..71ce5e559a2dba48f4816b7eb3d919d2604f5d03 100644 --- a/src/os/root_test.go +++ b/src/os/root_test.go @@ -1952,3 +1952,108 @@ if got, want := subroot.Name(), filepath.Join(dir, "dir"); got != want { t.Errorf(`root.OpenRoot("dir").Name() = %q, want %q`, got, want) } } + +// TestRootNoLstat verifies that we do not use lstat (possibly escaping the root) +// when reading directories in a Root. +func TestRootNoLstat(t *testing.T) { + if runtime.GOARCH == "wasm" { + t.Skip("wasm lacks fstatat") + } + + dir := makefs(t, []string{ + "subdir/", + }) + const size = 42 + contents := strings.Repeat("x", size) + if err := os.WriteFile(dir+"/subdir/file", []byte(contents), 0666); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + + test := func(name string, fn func(t *testing.T, f *os.File)) { + t.Run(name, func(t *testing.T) { + os.SetStatHook(t, func(f *os.File, name string) (os.FileInfo, error) { + if f == nil { + t.Errorf("unexpected Lstat(%q)", name) + } + return nil, nil + }) + f, err := root.Open("subdir") + if err != nil { + t.Fatal(err) + } + defer f.Close() + fn(t, f) + }) + } + + checkFileInfo := func(t *testing.T, fi fs.FileInfo) { + t.Helper() + if got, want := fi.Name(), "file"; got != want { + t.Errorf("FileInfo.Name() = %q, want %q", got, want) + } + if got, want := fi.Size(), int64(size); got != want { + t.Errorf("FileInfo.Size() = %v, want %v", got, want) + } + } + checkDirEntry := func(t *testing.T, d fs.DirEntry) { + t.Helper() + if got, want := d.Name(), "file"; got != want { + t.Errorf("DirEntry.Name() = %q, want %q", got, want) + } + if got, want := d.IsDir(), false; got != want { + t.Errorf("DirEntry.IsDir() = %v, want %v", got, want) + } + fi, err := d.Info() + if err != nil { + t.Fatalf("DirEntry.Info() = _, %v", err) + } + checkFileInfo(t, fi) + } + + test("Stat", func(t *testing.T, subdir *os.File) { + fi, err := subdir.Stat() + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf(`Open("subdir").Stat().IsDir() = false, want true`) + } + }) + // File.ReadDir, returning []DirEntry + test("ReadDirEntry", func(t *testing.T, subdir *os.File) { + dirents, err := subdir.ReadDir(-1) + if err != nil { + t.Fatal(err) + } + if len(dirents) != 1 { + t.Fatalf(`Open("subdir").ReadDir(-1) = {%v}, want {file}`, dirents) + } + checkDirEntry(t, dirents[0]) + }) + // File.Readdir, returning []FileInfo + test("ReadFileInfo", func(t *testing.T, subdir *os.File) { + fileinfos, err := subdir.Readdir(-1) + if err != nil { + t.Fatal(err) + } + if len(fileinfos) != 1 { + t.Fatalf(`Open("subdir").Readdir(-1) = {%v}, want {file}`, fileinfos) + } + checkFileInfo(t, fileinfos[0]) + }) + // File.Readdirnames, returning []string + test("Readdirnames", func(t *testing.T, subdir *os.File) { + names, err := subdir.Readdirnames(-1) + if err != nil { + t.Fatal(err) + } + if got, want := names, []string{"file"}; !slices.Equal(got, want) { + t.Fatalf(`Open("subdir").Readdirnames(-1) = %q, want %q`, got, want) + } + }) +} diff --git a/src/os/root_unix.go b/src/os/root_unix.go index c891e81b793bb5c3c4853107a817fa33a816d635..885a8353ebc455beb2854810a633b04bcbcb47a1 100644 --- a/src/os/root_unix.go +++ b/src/os/root_unix.go @@ -104,6 +104,7 @@ if err != nil { return nil, &PathError{Op: "openat", Path: name, Err: err} } f := newFile(fd, joinPath(root.Name(), name), kindOpenFile, unix.HasNonblockFlag(flag)) + f.inRoot = true return f, nil } diff --git a/src/os/stat.go b/src/os/stat.go index 50acb6dbdd12bdc90e23cf659f62aab66edd1585..5ef731f4f2637186a1ad91281e92316b03a16d7b 100644 --- a/src/os/stat.go +++ b/src/os/stat.go @@ -25,3 +25,6 @@ func Lstat(name string) (FileInfo, error) { testlog.Stat(name) return lstatNolog(name) } + +// stathook is set in tests +var stathook func(f *File, name string) (FileInfo, error) diff --git a/src/os/statat.go b/src/os/statat.go new file mode 100644 index 0000000000000000000000000000000000000000..d460fe27984b468c4ab747b1f57d088c9a358d4e --- /dev/null +++ b/src/os/statat.go @@ -0,0 +1,24 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !windows + +package os + +import ( + "internal/testlog" +) + +func (f *File) lstatat(name string) (FileInfo, error) { + if stathook != nil { + fi, err := stathook(f, name) + if fi != nil || err != nil { + return fi, err + } + } + if log := testlog.Logger(); log != nil { + log.Stat(joinPath(f.Name(), name)) + } + return f.lstatatNolog(name) +} diff --git a/src/os/statat_other.go b/src/os/statat_other.go new file mode 100644 index 0000000000000000000000000000000000000000..673ae21ac1fc18d45caf670dc555c7760909a847 --- /dev/null +++ b/src/os/statat_other.go @@ -0,0 +1,12 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build (js && wasm) || plan9 + +package os + +func (f *File) lstatatNolog(name string) (FileInfo, error) { + // These platforms don't have fstatat, so use stat instead. + return Lstat(f.name + "/" + name) +} diff --git a/src/os/statat_unix.go b/src/os/statat_unix.go new file mode 100644 index 0000000000000000000000000000000000000000..80f89f95c41129d71856ac8d52faa3ef41cc8900 --- /dev/null +++ b/src/os/statat_unix.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build aix || darwin || dragonfly || freebsd || wasip1 || linux || netbsd || openbsd || solaris + +package os + +import ( + "internal/syscall/unix" +) + +func (f *File) lstatatNolog(name string) (FileInfo, error) { + var fs fileStat + if err := f.pfd.Fstatat(name, &fs.sys, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return nil, f.wrapErr("fstatat", err) + } + fillFileStatFromSys(&fs, name) + return &fs, nil +}