Description
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'