Calling OpenRoot
on a *os.Root
sets the resulting root's name to the relative component only, rather than preserving the full path including the original root. This leads to incorrect behavior in downstream operations that rely on the Name()
of the root, particularly when constructing paths for syscalls.
Minimal example
The following program fails despite the directory structure existing as expected:
package main
import (
"fmt"
"os"
)
func main() {
rootDirA, err := os.OpenRoot("a")
if err != nil {
panic(err)
}
rootDirB, err := rootDirA.OpenRoot("b")
if err != nil {
panic(err)
}
dirC, err := rootDirB.Open("c")
if err != nil {
panic(err)
}
entries, err := dirC.ReadDir(-1)
if err != nil {
panic(err)
}
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
panic(err)
}
fmt.Printf("%s\n", info)
}
}
Directory structure:
a/
└── b/
└── c/
└── test
Runtime panic:
panic: lstat b/c/test: no such file or directory
goroutine 1 [running]:
main.main()
/mnt/main.go:32 +0x108
exit status 2
Root cause analysis
The function (d *unixDirent) Info()
at os/file_unix.go:480
performs:
lstat(d.parent + "/" + d.name)
At this point, d.parent == "b/c"
, which is incorrect. It should be "a/b/c"
.
Tracing this back:
rootOpenFileNolog
(at os/root_unix.go:92
) sets the correct name by doing:
newFile(fd, joinPath(root.Name(), name), kindOpenFile, unix.HasNonblockFlag(flag))
But openRootInRoot
(at os/root_unix.go:74
) does:
newRoot(fd, name)
Where name
is the name of the directory inside the root, so in this case name == "b"
.
The prefix "a"
from the original root is dropped when opening "b"
under it, and all nested paths become relative to a truncated root.
For most operations this issue remains unnoticed, since they operate on directory file descriptors instead of paths and are thus unaffected. Only once the unixDirent.Info()
uses this path for the lstat
syscall does this bug result in unexpected behaviour.
Impact
In this minimal example the potential impact is obviously relatively small since it's doing an lstat
only, but depending on where else the name property of an os.Root
is used this may even lead to unintended root path escapes.
Proposed Fix
In openRootInRoot
, replace:
return newRoot(fd, name)
with:
return newRoot(fd, joinPath(r.Name(), name))
This ensures that Root.Name()
always reflects the full path, and any derived File
or DirEntry
will operate on the correct base.
go version
and go env
:
go version go1.24.3 linux/arm64
AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/root/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/root/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build2623320160=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='arm64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/dev/null'
GOMODCACHE='/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/usr/local/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/root/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/usr/local/go/pkg/tool/linux_arm64'
GOVCS=''
GOVERSION='go1.24.3'
GOWORK=''
PKG_CONFIG='pkg-config'
Comment From: gabyhelp
Related Issues
- os: doInRoot does not resolve relative paths that land on root #73780
- os: OpenRoot follows symlinks #71806 (closed)
- os: `Lstat` on `darwin` sometimes fails to expand symlinks in paths with trailing slashes #59586
- os: Root.Mkdir creates directories with zero permissions on OpenBSD #73559 (closed)
- os: os.DirFS doesn't work with Windows UNC paths #54694 (closed)
- os: document that OpenFile Create do not create file if directory do not exist #69836 (closed)
- os: TestFileChdir fail when GOROOT is a symbolic link #64281 (closed)
- os: DirFS has interaction with Chdir #47214 (closed)
Related Code Changes
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Comment From: mknyszek
CC @neild
Comment From: ngicks
This is not fixed in Go 1.25 release.
For same reason, the Info
method on os.DirEntry
returned from the *os.Root.ReadDir
method also fails.
go env
AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/root/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/root/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build2554795501=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/home/watage/tmp/openroot_readdir/go.mod'
GOMODCACHE='/root/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/root/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/root/.local/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/root/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/root/.local/go/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.25.0'
GOWORK=''
PKG_CONFIG='pkg-config'
test code
package openrootreaddir
import (
"io/fs"
"os"
"path/filepath"
"slices"
"testing"
)
func TestOsRoot_SubRoot_Readdir(t *testing.T) {
tempDir := t.TempDir()
err := os.MkdirAll(filepath.Join(tempDir, filepath.FromSlash("a/b/c")), fs.ModePerm)
if err != nil {
t.Fatalf("Mkdir failed: %v", err)
}
d, err := os.Create(filepath.Join(tempDir, filepath.FromSlash("a/b/c/d")))
if err != nil {
t.Fatalf("Create testdata/a/b/c/d failed: %v", err)
}
_ = d.Close()
a, err := os.OpenRoot(filepath.Join(tempDir, "a"))
if err != nil {
t.Fatalf("open a: %v", err)
}
defer a.Close()
b, err := a.OpenRoot("b")
if err != nil {
t.Fatalf("open a: %v", err)
}
defer b.Close()
c, err := b.OpenRoot("c")
if err != nil {
t.Fatalf("open a: %v", err)
}
defer c.Close()
assertReadDir := func(t *testing.T, r *os.Root, expected []string) {
t.Helper()
{
f, err := r.Open(".")
if err != nil {
t.Fatalf("open .: %v", err)
}
defer f.Close()
dirents, err := f.ReadDir(-1)
if err != nil {
t.Errorf("ReadDir: %v", err)
} else {
names := make([]string, len(dirents))
var errs []error
for i, dirent := range dirents {
_, err := dirent.Info()
if err != nil {
errs = append(errs, err)
}
names[i] = dirent.Name()
}
if len(errs) > 0 {
t.Errorf("Info on fs.DirEntry failed: %v", errs)
}
slices.Sort(names)
if !slices.Equal(expected, names) {
t.Errorf("ReadDir not expected:\nexpected = %#v\nactual = %#v", expected, names)
}
}
}
{
f, err := r.Open(".")
if err != nil {
t.Fatalf("open .: %v", err)
}
defer f.Close()
info, err := f.Readdir(-1)
if err != nil {
t.Errorf("Readdir: %v", err)
} else {
names := make([]string, len(info))
for i, info := range info {
names[i] = info.Name()
}
slices.Sort(names)
if !slices.Equal(expected, names) {
t.Errorf("Readdir not expected:\nexpected = %#v\nactual = %#v", expected, names)
}
}
}
{
f, err := r.Open(".")
if err != nil {
t.Fatalf("open .: %v", err)
}
defer f.Close()
names, err := f.Readdirnames(-1)
if err != nil {
t.Errorf("Readdirnames: %v", err)
} else {
slices.Sort(names)
if !slices.Equal(expected, names) {
t.Errorf("Readdirnames not expected:\nexpected = %#v\nactual = %#v", expected, names)
}
}
}
}
assertReadDir(t, a, []string{"b"})
assertReadDir(t, b, []string{"c"})
assertReadDir(t, c, []string{"d"})
}
test result
root@e85d3cef659d:/home/watage/tmp/openroot_readdir# go test .
--- FAIL: TestOsRoot_SubRoot_Readdir (0.00s)
openroot_readdir_test.go:110: Info on fs.DirEntry failed: [lstat b/./c: no such file or directory]
openroot_readdir_test.go:110: Readdir not expected:
expected = []string{"c"}
actual = []string{}
openroot_readdir_test.go:111: Info on fs.DirEntry failed: [lstat c/./d: no such file or directory]
openroot_readdir_test.go:111: Readdir not expected:
expected = []string{"d"}
actual = []string{}
FAIL
FAIL openroot_readdir 0.006s
FAIL
Comment From: ngicks
~~Is anyone working on this? If no one, I'm going to make a patch for this.~~
~~Unlike the fix proposed above, I'm going to add joinedPrefix string
or anything to *os.root
which will be used with Open, etc.~~
~~Because *root.name
is directly retuned by *os.Root.Name
method. And Name
method's doc comment explains it as Name returns the name of the directory presented to OpenRoot.
~~
~~If I correctly understand the code, joining the prefix to the name changes this behavior which should be considered breaking.~~
Took some time to think about it and it turned out the problem is difficult to solve.
- You can't prefix the
*os.root.name
because it will be directly returned from*os.Root.Name
. - You can't save a prefixed path since its parent directory could be moved before the time
OpenDir
is called.
At my current guess, a true solution is to fix *os.File.readdir
to use fstatat
which is generally available where openat is supported, thanks to efforts made along with *os.Root
.
Only windows and plan9 do not support it. But on windows, the readdir
already reads file info using relative paths to file handles if I'm reading the code correctly.
The suggestion to use fstatat
for readdir is kinda rejected once in https://github.com/golang/go/issues/62028#issuecomment-1678111723
Is *os.Root
the "compelling use case" to "complicating this code"?
On plan9, it still could return ENOENT
or return info about wrong files, but it should be acceptable since *os.Root
on the platform behaves like that.
Comment From: ngicks
As demostrated below, it may return stat information for wrong and outside-of-the-root files.
func TestOsRoot_SubRoot_Readdir_InfoWrongFile(t *testing.T) {
switch runtime.GOOS {
case "js", "windows", "plan9":
t.Skip("unix only")
}
tempDir := t.TempDir()
t.Chdir(tempDir)
for _, fileName := range []string{
"a/b/c/d",
"b/c",
"c/d",
} {
fullPath := filepath.Join(tempDir, filepath.FromSlash(fileName))
err := os.MkdirAll(filepath.Dir(fullPath), fs.ModePerm)
if err != nil {
t.Fatalf("Mkdir %q failed: %v", filepath.Dir(fullPath), err)
}
d, err := os.Create(fullPath)
if err != nil {
t.Fatalf("Create testdata/a/b/c/d failed: %v", err)
}
_ = d.Close()
}
a, err := os.OpenRoot(filepath.Join(tempDir, "a"))
if err != nil {
t.Fatalf("open a: %v", err)
}
defer a.Close()
b, err := a.OpenRoot("b")
if err != nil {
t.Fatalf("open a: %v", err)
}
defer b.Close()
c, err := b.OpenRoot("c")
if err != nil {
t.Fatalf("open a: %v", err)
}
defer c.Close()
assertSys := func(t *testing.T, direct, throughReadDir fs.FileInfo) {
t.Helper()
lsys := direct.Sys().(*syscall.Stat_t)
rsys := throughReadDir.Sys().(*syscall.Stat_t)
if *lsys != *rsys {
t.Errorf(`stating different file:
direct : %#v
through ReadDir and Info: %#v`,
*lsys, *rsys,
)
}
}
csDirect, err := b.Stat("c")
if err != nil {
t.Fatalf("b.Stat(\"c\") failed: %v", err)
}
csThroughReadDir := statByReadDir(t, b, "c")
assertSys(t, csDirect, csThroughReadDir)
dsDirect, err := c.Stat("d")
if err != nil {
t.Fatalf("c.Stat(\"d\") failed: %v", err)
}
dsThroughReadDir := statByReadDir(t, c, "d")
assertSys(t, dsDirect, dsThroughReadDir)
}
--- FAIL: TestOsRoot_SubRoot_Readdir_InfoWrongFile (0.00s)
openroot_readdir_test.go:75: stating different file:
direct : syscall.Stat_t{Dev:0x820, Ino:0x1460, Nlink:0x2, Mode:0x41ed, Uid:0x3e8, Gid:0x3e8, X__pad0:0, Rdev:0x0, Size:4096, Blksize:4096, Blocks:8, Atim:s
yscall.Timespec{Sec:1755877945, Nsec:539331633}, Mtim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, Ctim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, X__unused:[3]int64{0,
0, 0}}
through ReadDir and Info: syscall.Stat_t{Dev:0x820, Ino:0x1463, Nlink:0x1, Mode:0x81a4, Uid:0x3e8, Gid:0x3e8, X__pad0:0, Rdev:0x0, Size:0, Blksize:4096, Blocks:0, Atim:sysc
all.Timespec{Sec:1755877945, Nsec:539331633}, Mtim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, Ctim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, X__unused:[3]int64{0, 0,
0}}
openroot_readdir_test.go:83: stating different file:
direct : syscall.Stat_t{Dev:0x820, Ino:0x1461, Nlink:0x1, Mode:0x81a4, Uid:0x3e8, Gid:0x3e8, X__pad0:0, Rdev:0x0, Size:0, Blksize:4096, Blocks:0, Atim:sysc
all.Timespec{Sec:1755877945, Nsec:539331633}, Mtim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, Ctim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, X__unused:[3]int64{0, 0,
0}}
through ReadDir and Info: syscall.Stat_t{Dev:0x820, Ino:0x1465, Nlink:0x1, Mode:0x81a4, Uid:0x3e8, Gid:0x3e8, X__pad0:0, Rdev:0x0, Size:0, Blksize:4096, Blocks:0, Atim:sysc
all.Timespec{Sec:1755877945, Nsec:539331633}, Mtim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, Ctim:syscall.Timespec{Sec:1755877945, Nsec:539331633}, X__unused:[3]int64{0, 0,
0}}
FAIL
FAIL openroot_readdir 0.003s
FAIL
Comment From: gopherbot
Change https://go.dev/cl/698376 mentions this issue: os: set full name for Roots created with Root.OpenRoot
Comment From: neild
Sorry, I missed this when it was first opened.
os.File.Name
is, in general, a bit problematic because it references the name a file had when we opened it, not necessarily the name it has now.
os.Root
creates files using the name used to open the Root
: r, _ := OpenRoot("dir"); f, _ := r.Open("file")
will create a file with f.Name() == "dir/file"
, even though the Root
's directory may have been moved. We should do the same for os.Root.OpenRoot
.
os.DirEntry
doesn't cope well with events that invalidate an os.File
's name: chdir, renaming a directory after opening it, etc. Let's use #62028 for that issue, since it doesn't just affect Root
.
Comment From: ngicks
I was just thinking that "falling back to name-based stat if the directory fd has been closed" ( described in https://github.com/golang/go/issues/62028#issuecomment-1678111723 ) should not be done if the os.DirEntry
is coming from *os.Root
because it may still return infomation of wrong files, even from outside of the *os.Root
. This behavior, I think, was ok for non-root os functions but the *os.Root
is about restriction. Therefore I was thinking it was related to this issue.
After CL698376 lands, an *os.Root
may not return FileInfo from outside of the original(parent?) *os.Root
unless the original directory is not renamed after the root is opened. I beleive this would not be a real world problem since the original directory is normally controlled by the user.
However it may still return outside-of-subroot file info.
- 1) os.OpenRoot("somewhere")
- 2) Mkdir
"a"
and"b"
under "somewhere" - 3) Open sub root for "b" using the root we've opened by
1)
- 4) rename or delete "a" and rename "b" to "a"
I think this would be a problem as I expect functions which receive *os.Root
normally can not tell if their argument is an original or sub root. Also I expect *os.Root
is kept in struct's field or somewhere and not be reopened after rename.
I hope https://github.com/golang/go/issues/62028 gets some treatments soon.
Comment From: neild
@gopherbot please open backport issues. This breaks Stat.Info
with no good workaround.
Comment From: gopherbot
Backport issue(s) opened: #75138 (for 1.24), #75139 (for 1.25).
Remember to create the cherry-pick CL(s) as soon as the patch is submitted to master, according to https://go.dev/wiki/MinorReleases.
Comment From: gopherbot
Change https://go.dev/cl/704277 mentions this issue: [release-branch.go1.25] os: set full name for Roots created with Root.OpenRoot
Comment From: gopherbot
Change https://go.dev/cl/704278 mentions this issue: [release-branch.go1.24] os: set full name for Roots created with Root.OpenRoot