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

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