src/os/export_test.go | 1 + src/os/root.go | 18 ++++++++++-------- src/os/root_js.go | 17 +++++------------ src/os/root_noopenat.go | 5 +++++ src/os/root_openat.go | 163 ++++++++++++++++++++++++++++++++++++++++++----------- src/os/root_test.go | 1443 +++++++++++++++++++++++++++++++++++++++++++++++++++-- src/os/root_unix.go | 39 +++++++++++++++++++++++++-------------- src/os/root_unix_test.go | 5 ++--- src/os/root_windows.go | 56 +++++++++++++++++++++++++++++++++++++++-------------- diff --git a/src/os/export_test.go b/src/os/export_test.go index bea38c905a673fca91e6efc4285cdbc1c9e8d74d..01bdf8cea0538f9b9655533693656c875ea8dc37 100644 --- a/src/os/export_test.go +++ b/src/os/export_test.go @@ -9,6 +9,7 @@ var Atime = atime var ErrWriteAtInAppendMode = errWriteAtInAppendMode var ErrPatternHasSeparator = errPatternHasSeparator +var ErrPathEscapes = errPathEscapes func init() { checkWrapErr = true diff --git a/src/os/root.go b/src/os/root.go index d759727ce7a89bce79d67979a51e46c2c7dc8bf3..80e655e4fbdac22c4cc2df31044cd9e4e594a4cb 100644 --- a/src/os/root.go +++ b/src/os/root.go @@ -297,20 +297,20 @@ // absolute, volume-relative, or "". // // "." components are removed, except in the last component. // -// Path separators following the last component are returned in suffixSep. -func splitPathInRoot(s string, prefix, suffix []string) (_ []string, suffixSep string, err error) { +// endsInSlash reports whether the path ends in one or more slashes. +func splitPathInRoot(s string, prefix, suffix []string) (_ []string, endsInSlash bool, err error) { if len(s) == 0 { - return nil, "", errors.New("empty path") + return nil, false, errors.New("empty path") } if IsPathSeparator(s[0]) { - return nil, "", errPathEscapes + return nil, false, errPathEscapes } if runtime.GOOS == "windows" { // Windows cleans paths before opening them. s, err = rootCleanPath(s, prefix, suffix) if err != nil { - return nil, "", err + return nil, false, err } prefix = nil suffix = nil @@ -332,8 +332,10 @@ j++ } if j == len(s) { // If this is the last path component, - // preserve any trailing path separators. - suffixSep = s[partEnd:] + // check for any trailing path separators. + if len(s[partEnd:]) > 0 { + endsInSlash = true + } break } if parts[len(parts)-1] == "." { @@ -347,7 +349,7 @@ // Remove a trailing "." component if we're joining to a suffix. parts = parts[:len(parts)-1] } parts = append(parts, suffix...) - return parts, suffixSep, nil + return parts, endsInSlash, nil } // FS returns a file system (an fs.FS) for the tree of files in the root. diff --git a/src/os/root_js.go b/src/os/root_js.go index 56a37dafe1626e5125c69f4d8c7bf9310bd8ffbe..b201f1c406c5688154a236a516777a470f1d0768 100644 --- a/src/os/root_js.go +++ b/src/os/root_js.go @@ -33,7 +33,7 @@ func checkPathEscapesInternal(r *Root, name string, lstat bool) error { if r.root.closed.Load() { return ErrClosed } - parts, suffixSep, err := splitPathInRoot(name, nil, nil) + parts, endsInSlash, err := splitPathInRoot(name, nil, nil) if err != nil { return err } @@ -63,10 +63,9 @@ } part := parts[i] if i == len(parts)-1 { - if lstat { + if lstat && !endsInSlash { break } - part += suffixSep } next := joinPath(base, part) @@ -86,18 +85,12 @@ symlinks++ if symlinks > rootMaxSymlinks { return errors.New("too many symlinks") } - newparts, newSuffixSep, err := splitPathInRoot(link, parts[:i], parts[i+1:]) + newparts, newEndsInSlash, err := splitPathInRoot(link, parts[:i], parts[i+1:]) if err != nil { return err } - if i == len(parts) { - // suffixSep contains any trailing path separator characters - // in the link target. - // If we are replacing the remainder of the path, retain these. - // If we're replacing some intermediate component of the path, - // ignore them, since intermediate components must always be - // directories. - suffixSep = newSuffixSep + if i == len(parts)-1 && newEndsInSlash { + endsInSlash = true } parts = newparts continue diff --git a/src/os/root_noopenat.go b/src/os/root_noopenat.go index 59f1abe91b0b2e3dfa4fedab55d355a3ad34506b..228b580db41be9b4d73be9f2845a1cb4bb5941dc 100644 --- a/src/os/root_noopenat.go +++ b/src/os/root_noopenat.go @@ -187,6 +187,11 @@ return nil } func rootRemoveAll(r *Root, name string) error { + // Consistency with os.RemoveAll: Strip trailing /s from the name, + // so RemoveAll("not_a_directory/") succeeds. + for len(name) > 0 && IsPathSeparator(name[len(name)-1]) { + name = name[:len(name)-1] + } if endsWithDot(name) { // Consistency with os.RemoveAll: Return EINVAL when trying to remove . return &PathError{Op: "RemoveAll", Path: name, Err: syscall.EINVAL} diff --git a/src/os/root_openat.go b/src/os/root_openat.go index 83bde5ef14160d08763b017283351251e355b5f1..d7c9334ac1919c9b0df045565ac72dbf0f390bd8 100644 --- a/src/os/root_openat.go +++ b/src/os/root_openat.go @@ -7,6 +7,7 @@ package os import ( + "io/fs" "runtime" "slices" "sync" @@ -66,7 +67,7 @@ return r.name } func rootChmod(r *Root, name string, mode FileMode) error { - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, chmodat(parent, name, mode) }) if err != nil { @@ -76,7 +77,7 @@ return nil } func rootChown(r *Root, name string, uid, gid int) error { - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, chownat(parent, name, uid, gid) }) if err != nil { @@ -86,27 +87,36 @@ return nil } func rootLchown(r *Root, name string, uid, gid int) error { - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, lchownat(parent, name, uid, gid) }) if err != nil { return &PathError{Op: "lchownat", Path: name, Err: err} } - return err + return nil } func rootChtimes(r *Root, name string, atime time.Time, mtime time.Time) error { - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, chtimesat(parent, name, atime, mtime) }) if err != nil { return &PathError{Op: "chtimesat", Path: name, Err: err} } - return err + return nil } func rootMkdir(r *Root, name string, perm FileMode) error { - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + flags := uint(doInRootCreatingDirectory) + switch runtime.GOOS { + case "linux", "windows": + // These platforms do not follow "symlink" on "mkdir symlink/". + // (POSIX.1-2024 4.16 says that the trailing slash should cause + // resolution to follow the symlink, but we're trying to match + // platform semantics, not implement POSIX.) + flags = doInRootNoHandleTerminalSlash + } + _, err := doInRoot(r, name, flags, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, mkdirat(parent, name, perm) }) if err != nil { @@ -140,7 +150,7 @@ } panic("unreachable") } // openLastComponentFunc opens the last path component. - openLastComponentFunc := func(parent sysfdType, name string) (struct{}, error) { + openLastComponentFunc := func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { err := mkdirat(parent, name, perm) if err == syscall.EEXIST { mode, e := modeAt(parent, name) @@ -167,7 +177,7 @@ return struct{}{}, err } return struct{}{}, &PathError{Op: "mkdirat", Err: err} } - _, err := doInRoot(r, fullname, openDirFunc, openLastComponentFunc) + _, err := doInRoot(r, fullname, 0, openDirFunc, openLastComponentFunc) if err != nil { if _, ok := err.(*PathError); !ok { err = &PathError{Op: "mkdirat", Path: fullname, Err: err} @@ -177,7 +187,7 @@ return err } func rootReadlink(r *Root, name string) (string, error) { - target, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (string, error) { + target, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (string, error) { return readlinkat(parent, name) }) if err != nil { @@ -187,7 +197,7 @@ return target, nil } func rootRemove(r *Root, name string) error { - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, removeat(parent, name) }) if err != nil { @@ -206,7 +216,7 @@ if endsWithDot(name) { // Consistency with os.RemoveAll: Return EINVAL when trying to remove . return &PathError{Op: "RemoveAll", Path: name, Err: syscall.EINVAL} } - _, err := doInRoot(r, name, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, name, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, removeAllFrom(parent, name) }) if IsNotExist(err) { @@ -219,8 +229,30 @@ return err } func rootRename(r *Root, oldname, newname string) error { - _, err := doInRoot(r, oldname, nil, func(oldparent sysfdType, oldname string) (struct{}, error) { - _, err := doInRoot(r, newname, nil, func(newparent sysfdType, newname string) (struct{}, error) { + _, err := doInRoot(r, oldname, 0, nil, func(oldparent sysfdType, oldname string, oldEndsInSlash bool) (struct{}, error) { + flags := uint(doInRootCreatingDirectory) + if runtime.GOOS == "windows" { + flags = doInRootNoHandleTerminalSlash + } + _, err := doInRoot(r, newname, flags, nil, func(newparent sysfdType, newname string, newEndsInSlash bool) (struct{}, error) { + if runtime.GOOS != "windows" && newEndsInSlash { + oldMode, err := modeAt(oldparent, oldname) + if err != nil { + return struct{}{}, err + } + if oldMode.Type() != fs.ModeDir { + return struct{}{}, syscall.ENOTDIR + } + } + // Same checks as applied by rename (in file_unix.go): + fi, err := lstatat(newparent, newname) + if err == nil && fi.IsDir() { + if ofi, err := lstatat(oldparent, oldname); err != nil { + return struct{}{}, err + } else if newname == oldname || !SameFile(fi, ofi) { + return struct{}{}, syscall.EEXIST + } + } return struct{}{}, renameat(oldparent, oldname, newparent, newname) }) return struct{}{}, err @@ -232,8 +264,13 @@ return err } func rootLink(r *Root, oldname, newname string) error { - _, err := doInRoot(r, oldname, nil, func(oldparent sysfdType, oldname string) (struct{}, error) { - _, err := doInRoot(r, newname, nil, func(newparent sysfdType, newname string) (struct{}, error) { + _, err := doInRoot(r, oldname, 0, nil, func(oldparent sysfdType, oldname string, oldEndsInSlash bool) (struct{}, error) { + flags := uint(0) + if runtime.GOOS == "windows" { + // Windows doesn't pay attention to trailing slashes in the link target. + flags = doInRootNoHandleTerminalSlash + } + _, err := doInRoot(r, newname, flags, nil, func(newparent sysfdType, newname string, newEndsInSlash bool) (struct{}, error) { return struct{}{}, linkat(oldparent, oldname, newparent, newname) }) return struct{}{}, err @@ -244,31 +281,58 @@ } return err } +// Flags for doInRoot. +const ( + // doInRootNoHandleTerminalSlash prevents doInRoot from applying special handling + // for paths which end in one or more slashes. + doInRootNoHandleTerminalSlash = 1 << iota + + // doInRootCreatingDirectory indicates that the operation is creating a directory. + // When a path ends in /, the last path component may name a file which does not exist. + doInRootCreatingDirectory + + // doInRootAlwaysResolveTerminalSlash causes doInRoot to resolve symlinks in the last + // path component when a path ends in /, even on Windows. For example, this causes + // doInRoot to resolve "symlink/" as the link target of "symlink". + // + // POSIX path operations resolve symlinks in this case. + // Most Windows operations do not. + // This flag enforces the POSIX behavior. + doInRootAlwaysResolveTerminalSlash +) + // doInRoot performs an operation on a path in a Root. // // It calls f with the FD or handle for the directory containing the last -// path element, and the name of the last path element. +// path element, the name of the last path element (not including slashes), +// and a boolean indicating whether the original path ended in one or more slashes. // // For example, given the path a/b/c it calls f with the FD for a/b and the name "c". // +// It applies special handling for paths ending in a slash: When a path ends in a slash +// (for example "a/b/"), doInRoot will check the final component ("b") before calling f. +// If the final component is a symlink, doInRoot will resolve it. +// If the final component is neither a symlink nor a directory, doInRoot will return ENOTDIR. +// This behavior may be disabled by passing the doInRootNoHandleTerminalSlash flag. +// // If openDirFunc is non-nil, it is called to open intermediate path elements. // For example, given the path a/b/c openDirFunc will be called to open a and a/b in turn. // // f or openDirFunc may return errSymlink to indicate that the path element is a symlink // which should be followed. Note that this can result in f being called multiple times -// with different names. For example, give the path "link" which is a symlink to "target", +// with different names. For example, given the path "link" which is a symlink to "target", // f is called with the path "link", returns errSymlink("target"), and is called again with // the path "target". // // If f or openDirFunc return a *PathError, doInRoot will set PathError.Path to the // full path which caused the error. -func doInRoot[T any](r *Root, name string, openDirFunc func(parent sysfdType, name string) (sysfdType, error), f func(parent sysfdType, name string) (T, error)) (ret T, err error) { +func doInRoot[T any](r *Root, name string, flags uint, openDirFunc func(parent sysfdType, name string) (sysfdType, error), f func(parent sysfdType, name string, endsInSlash bool) (T, error)) (ret T, err error) { if err := r.root.incref(); err != nil { return ret, err } defer r.root.decref() - parts, suffixSep, err := splitPathInRoot(name, nil, nil) + parts, endsInSlash, err := splitPathInRoot(name, nil, nil) if err != nil { return ret, err } @@ -332,15 +396,43 @@ continue } if i == len(parts)-1 { + err = nil + if endsInSlash && flags&doInRootNoHandleTerminalSlash == 0 { + var fi FileInfo + fi, err = lstatat(dirfd, parts[i]) + switch { + case IsNotExist(err) && flags&doInRootCreatingDirectory != 0: + // The path ends in a slash, the last path component + // does not exist, and we creating a directory. + // This is fine. + err = nil + case err != nil: + return + case fi.Mode().Type() == fs.ModeDir: + case fi.Mode().Type() == fs.ModeSymlink: + if runtime.GOOS != "windows" || flags&doInRootAlwaysResolveTerminalSlash != 0 { + err = checkSymlink(dirfd, parts[i], syscall.ENOTDIR) + } else { + if !isDirectoryLink(fi) { + err = syscall.ENOTDIR + } + } + default: + err = syscall.ENOTDIR + return + } + } + // This is the last path element. // Call f to decide what to do with it. // If f returns errSymlink, this element is a symlink // which should be followed. - // suffixSep contains any trailing separator characters - // which we rejoin to the final part at this time. - ret, err = f(dirfd, parts[i]+suffixSep) + // suffixSep contains any trailing separator characters. if err == nil { - return + ret, err = f(dirfd, parts[i], endsInSlash) + if err == nil { + return + } } } else { var fd sysfdType @@ -360,18 +452,15 @@ symlinks++ if symlinks > rootMaxSymlinks { return ret, syscall.ELOOP } - newparts, newSuffixSep, err := splitPathInRoot(string(e), parts[:i], parts[i+1:]) + lastPart := i == len(parts)-1 + newparts, newEndsInSlash, err := splitPathInRoot(string(e), parts[:i], parts[i+1:]) if err != nil { return ret, err } - if i == len(parts)-1 { - // suffixSep contains any trailing path separator characters - // in the link target. - // If we are replacing the remainder of the path, retain these. - // If we're replacing some intermediate component of the path, - // ignore them, since intermediate components must always be - // directories. - suffixSep = newSuffixSep + if lastPart && newEndsInSlash { + // If a link target in the final path component ends in a slash, + // then the path now ends in a slash. + endsInSlash = true } if len(newparts) < i || !slices.Equal(parts[:i], newparts[:i]) { // Some component in the path which we have already traversed @@ -397,6 +486,14 @@ } i++ } +} + +func modeAt(parent sysfdType, name string) (FileMode, error) { + fi, err := lstatat(parent, name) + if err != nil { + return 0, err + } + return fi.Mode(), nil } // errSymlink reports that a file being operated on is actually a symlink, diff --git a/src/os/root_test.go b/src/os/root_test.go index 71ce5e559a2dba48f4816b7eb3d919d2604f5d03..1678d200eb2dd9b9b695b2f3fdaafce4d3632ec9 100644 --- a/src/os/root_test.go +++ b/src/os/root_test.go @@ -7,10 +7,12 @@ import ( "bytes" "errors" + "flag" "fmt" "internal/testenv" "io" "io/fs" + "iter" "net" "os" "path" @@ -55,7 +57,7 @@ // Parent directories are automatically created as needed. // // makefs calls t.Skip if the layout contains features not supported by the current GOOS. func makefs(t *testing.T, fs []string) string { - root := path.Join(t.TempDir(), "ROOT") + root := filepath.Join(t.TempDir(), "ROOT") if err := os.Mkdir(root, 0o777); err != nil { t.Fatal(err) } @@ -204,6 +206,22 @@ }, open: "link/target", target: "dir/target", }, { + name: "slash after symlink to file", + fs: []string{ + "link => ../ROOT/target", + }, + open: "link/", + target: "target", + wantError: true, +}, { + name: "slash after symlink to dir", + fs: []string{ + "link => ../ROOT/target", + "target/", + }, + open: "link/", + wantError: true, +}, { name: "symlink dotdot dotdot slash", fs: []string{ "dir/link => ../../", @@ -244,8 +262,10 @@ fs: []string{}, open: "../", wantError: true, }, { - name: "path with dotdot dotdot slash", - fs: []string{}, + name: "path with dotdot dotdot slash", + fs: []string{ + "a/", + }, open: "a/../../", wantError: true, }, { @@ -521,13 +541,10 @@ func TestRootMkdir(t *testing.T) { for _, test := range rootTestCases { test.run(t, func(t *testing.T, target string, root *os.Root) { wantError := test.wantError - if !wantError { - fi, err := os.Lstat(filepath.Join(root.Name(), test.open)) - if err == nil && fi.Mode().Type() == fs.ModeSymlink { - // This case is trying to mkdir("some symlink"), - // which is an error. - wantError = true - } + if test.ltarget != "" { + // This case is trying to mkdir("some symlink"), + // which is an error (but not an escape). + wantError = true } err := root.Mkdir(test.open, 0o777) @@ -555,13 +572,10 @@ func TestRootMkdirAll(t *testing.T) { for _, test := range rootTestCases { test.run(t, func(t *testing.T, target string, root *os.Root) { wantError := test.wantError - if !wantError { - fi, err := os.Lstat(filepath.Join(root.Name(), test.open)) - if err == nil && fi.Mode().Type() == fs.ModeSymlink { - // This case is trying to mkdir("some symlink"), - // which is an error. - wantError = true - } + if test.ltarget != "" { + // This case is trying to mkdir("some symlink"), + // which is an error (but not an escape). + wantError = true } err := root.Mkdir(test.open, 0o777) @@ -667,6 +681,15 @@ func TestRootRemoveAll(t *testing.T) { for _, test := range rootTestCases { test.run(t, func(t *testing.T, target string, root *os.Root) { + if strings.HasSuffix(test.open, "/") { + // The test is removing a file with a trailing /. + // RemoveAll ignores trailing /s + // If the file is a symlink, it will remove the symlink. + fullname := filepath.Join(root.Name(), test.open) + if st, err := os.Lstat(fullname); err == nil && st.Mode().Type() == fs.ModeSymlink { + test.ltarget = test.open + } + } wantError := test.wantError if test.ltarget != "" { // Remove doesn't follow symlinks in the final path component, @@ -945,6 +968,15 @@ if err := os.WriteFile(filepath.Join(root.Name(), srcPath), want, 0o666); err != nil { t.Fatal(err) } + if runtime.GOOS == "windows" && strings.HasSuffix(test.open, "/") { + // Windows will ignore trailing slashes in the rename/link target. + p := strings.TrimSuffix(test.open, "/") + st, err := root.Lstat(p) + if err == nil && st.Mode().Type() == fs.ModeSymlink { + test.ltarget = p + } + } + target = test.target wantError := test.wantError if test.ltarget != "" { @@ -1106,6 +1138,16 @@ "target/file", "link => target", }, open: "link/", + check: func(t *testing.T) { + if runtime.GOOS == "linux" && strings.HasPrefix(t.Name(), "TestRootConsistencyRename/") { + // Linux does not resolve "symlink" in rename("symlink/", "target"). + t.Skip("known inconsistency on linux") + } + if strings.HasPrefix(t.Name(), "TestRootConsistencyRemoveAll/") { + // Root.RemoveAll and os.RemoveAll are not always consistent here. + t.Skip("known inconsistency in RemoveAll") + } + }, }, { name: "symlink slash dot", fs: []string{ @@ -1113,17 +1155,6 @@ "target/file", "link => target", }, open: "link/.", -}, { - name: "file symlink slash", - fs: []string{ - "target", - "link => target", - }, - open: "link/", - detailedErrorMismatch: func(t *testing.T) bool { - // os.Create returns ENOTDIR or EISDIR depending on the platform. - return runtime.GOOS == "js" - }, }, { name: "unresolved symlink", fs: []string{ @@ -1193,13 +1224,19 @@ // and os.Open returns "The file cannot be accessed by the system.". return runtime.GOOS == "windows" }, check: func(t *testing.T) { - if runtime.GOOS == "windows" && strings.HasPrefix(t.Name(), "TestRootConsistencyRemoveAll/") { - // Root.RemoveAll notices that a/ is not a directory, - // and returns success. - // os.RemoveAll tries to open a/ and fails because - // it is not a regular file. - // The inconsistency here isn't worth fixing, so just skip this test. - t.Skip("known inconsistency on windows") + if strings.HasPrefix(t.Name(), "TestRootConsistencyRemoveAll/") { + switch runtime.GOOS { + case "windows": + // Root.RemoveAll notices that a/ is not a directory, + // and returns success. + // os.RemoveAll tries to open a/ and fails because + // it is not a regular file. + // The inconsistency here isn't worth fixing, so just skip this test. + t.Skip("known inconsistency on windows") + case "js": + // GOOS=js behavior varies with what the underlying OS is. + t.Skip("known inconsistency with GOOS=js") + } } }, }, { @@ -2057,3 +2094,1339 @@ t.Fatalf(`Open("subdir").Readdirnames(-1) = %q, want %q`, got, want) } }) } + +// A rootMultiTest is state for testing an os.Root operation in one configuration among many. +// Each execution of a rootMultiTest varies in several ways: +// +// - With or without an *os.Root, to check consistency between root/non-root operations. +// - With a target that may be a file, directory, symlink, or entirely absent. +// - With various paths referencing the target: "target", "DIR/../target", etc. +// - When the target is a symlink, with various link target paths. +// +// For example, a single test execution might be: +// In an *os.Root, copy "source" to "DIR/../target". +// "source" is a file, and "target" is a symlink to "../ROOT/s_target". "s_target" is a directory. +// (In this case, we expect the test to fail due to the path escape in the symlink.) +type rootMultiTest struct { + // dir is the directory containing the test. + // dir will always contain a directory named "ROOT" + // and a subdir named "ROOT/DIR". + dir string + + // root is the *Root for the test. May be nil. + root *os.Root + + // source and target are files acted on by the test. + // target is always set; source is only set for tests which request two files. + source testFileDesc + target testFileDesc + + // sourcePath and targetPath are the paths which should be used to acceess + // the source/target. + sourcePath string + targetPath string + + sourceInfo os.FileInfo + targetInfo os.FileInfo + + // op is the operation being performed, used for reporting errors. + op string +} + +var testVerbose = flag.Bool("verbose", false, "verbose") + +// A rootMultiTest function may return this error to disable +// the check that in-root and out-of-root functions have the same outcome. +var errSkipRootConsistencyCheck = errors.New("skip root consistency check") + +// runRootMultiTest runs f in a variety of configurations. +// See above. +func runRootMultiTest(t *testing.T, f func(*testing.T, *rootMultiTest) (string, error)) { + for target := range allTestFileDescs() { + t.Run(target.String(), func(t *testing.T) { + var source testFileDesc // unused + runRootMultiTestDescs(t, source, target, f) + }) + } +} + +// runRootMultiTest2 runs f in a variety of configurations, +// with both source and target files. +// See above. +func runRootMultiTest2(t *testing.T, f func(*testing.T, *rootMultiTest) (string, error)) { + // A "simple" desc is one which contains only direct references. + // When not running the comprehensive (but slow) set of test variations, + // we only test variations where at least one of source and target is simple. + isSimple := func(desc testFileDesc) bool { + if desc.ref.template != "BASE" { + return false + } + if desc.kind == testFileSymlink && desc.target.ref.template != "BASE" { + return false + } + return true + } + for source := range allTestFileDescs() { + for target := range allTestFileDescs() { + if !*rootComprehensive && !isSimple(source) && !isSimple(target) { + continue + } + name := fmt.Sprintf("%s_to_%s", source, target) + t.Run(name, func(t *testing.T) { + runRootMultiTestDescs(t, source, target, f) + }) + } + } +} + +// setOp sets the operation performed by the test (logged in errors). +// +// This currently assumes the operation will be a method of os.Root and a function in os +// (e.g., root.Open/os.Open). +func (test *rootMultiTest) setOp(format string, a ...any) { + if test.root != nil { + test.op = "root." + } else { + test.op = "os." + } + test.op += fmt.Sprintf(format, a...) +} + +var errAny = errors.New("any error") + +func (test *rootMultiTest) errorf(t *testing.T, format string, args ...any) { + t.Errorf("%v:", test.op) + t.Fatalf(" "+format, args...) +} + +// wantError tests whether got matches want. +// If want is errAny, got may be any non-nil error. +func (test *rootMultiTest) wantError(t *testing.T, got, want error) { + t.Helper() + if errors.Is(got, want) || (got != nil && want == errAny) { + return + } + t.Fatalf("%v:\ngot error: %v\nwant error: %v", test.op, got, want) +} + +func runRootMultiTestDescs(t *testing.T, source, target testFileDesc, f func(*testing.T, *rootMultiTest) (string, error)) { + rootTest := newRootTest(t, source, target, true) + osTest := newRootTest(t, source, target, false) + + initialContent := dirTreeContents(t, rootTest.dir) + t.Cleanup(func() { + if t.Failed() { + t.Log("Initial directory contents:") + for _, line := range initialContent { + t.Logf(" %v", line) + } + } + }) + + rootResult, rootErr := f(t, rootTest) + + if runtime.GOOS == "darwin" { + // Darwin appears to have a kernel bug which causes restrictions on paths + // with a trailing / to not be applied during uncached path lookups. + // These restrictions are applied during cached lookups, so the results + // of operating on /-suffixed paths are inconsistent. + // + // An example of this Darwin behavior (as of 25.4.0) is: + // $ mkdir -p test/dir + // $ echo hello > test/file + // $ ln -s dir/../file test/link + // $ cat test/link/ + // hello + // $ cat test/link/ + // cat: test/link/: Not a directory + // + // Since Darwin isn't consistent with itself, we can't verify that we're + // consistent with it. + if rootTest.source.anySlashSuffix() || rootTest.target.anySlashSuffix() { + return + } + } + + if runtime.GOOS == "wasip1" || runtime.GOOS == "js" { + // WASI runtimes don't have any consistent behavior for handling paths with + // a trailing /, so skip consistency tests for these paths. + if rootTest.source.anySlashSuffix() || rootTest.target.anySlashSuffix() { + return + } + } + + osResult, osErr := f(t, osTest) + + t.Cleanup(func() { + if t.Failed() || !*testVerbose { + return + } + rootContent := dirTreeContents(t, rootTest.dir) + osContent := dirTreeContents(t, osTest.dir) + t.Log("Initial directory contents:") + for _, line := range initialContent { + t.Logf(" %v", line) + } + t.Logf("%v:", rootTest.op) + t.Logf(" result: %v", rootResult) + t.Logf(" error: %v", rootErr) + for _, line := range rootContent { + t.Logf(" %v", line) + } + t.Logf("%v:", osTest.op) + t.Logf(" result: %v", osResult) + t.Logf(" error: %v", osErr) + for _, line := range osContent { + t.Logf(" %v", line) + } + }) + + if errors.Is(rootErr, os.ErrPathEscapes) { + // os.Root forbids this operation (and is therefore not consistent with + // the non-root version). + return + } + + if rootErr == errSkipRootConsistencyCheck || osErr == errSkipRootConsistencyCheck { + return + } + + // Consistency check: Performing the same operation in and out of a root + // should produce the same results. + if rootResult != osResult { + t.Errorf("inconsistent results in/out of root") + t.Errorf("%v:", rootTest.op) + t.Errorf(" result: %v", rootResult) + t.Errorf("%v:", osTest.op) + t.Errorf(" result: %v", osResult) + } + if (rootErr == nil) != (osErr == nil) { + t.Errorf("inconsistent errors in/out of root") + t.Errorf("%v:", rootTest.op) + t.Errorf(" error: %v", rootErr) + t.Errorf("%v:", osTest.op) + t.Errorf(" error: %v", osErr) + } + + // Filesystem consistency check: Same files in the same places. + rootContent := dirTreeContents(t, rootTest.dir) + osContent := dirTreeContents(t, osTest.dir) + if !slices.Equal(rootContent, osContent) { + t.Errorf("inconsistent filesystem after running in/out of root") + t.Errorf("%v:", rootTest.op) + for _, line := range rootContent { + t.Errorf(" %v", line) + } + t.Errorf("%v:", osTest.op) + for _, line := range osContent { + t.Errorf(" %v", line) + } + } +} + +func newRootTest(t *testing.T, source, target testFileDesc, inRoot bool) *rootMultiTest { + dir := makefs(t, []string{ + "DIR/", + }) + var root *os.Root + if inRoot { + var err error + root, err = os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + root.Close() + }) + } + test := &rootMultiTest{ + dir: dir, + root: root, + source: source, + target: target, + } + createFile := func(name string, desc testFileDesc) (path string, fi os.FileInfo) { + if desc.kind == testFileUnused { + return "", nil + } + fi = desc.create(t, dir, name, name) + path = desc.ref.path(dir, name) + if !inRoot && !filepath.IsAbs(path) { + path = dir + "/" + path + } + return path, fi + } + test.sourcePath, test.sourceInfo = createFile("source", source) + test.targetPath, test.targetInfo = createFile("target", target) + return test +} + +// testFileKind is a kind of file. +type testFileKind int + +const ( + testFileUnused = testFileKind(iota) + testFileAbsent // file does not exist + testFileFile // regular file + testFileDir // directory + testFileSymlink // symlink + testFileMax + + // testFileError represents a path which fails during resolution, + // such as "a/b" where "a" does not exist. + testFileError +) + +func (kind testFileKind) String() string { + switch kind { + case testFileUnused: + return "unused" + case testFileAbsent: + return "absent" + case testFileFile: + return "file" + case testFileDir: + return "dir" + case testFileSymlink: + return "symlink" + case testFileError: + return "error" + default: + return fmt.Sprintf("testFileKind(%d)", kind) + } +} + +// testFileRef is a kind of reference to a file. +// +// Many path names can refer to the same file: f, ./f, /abs/path/to/f, somedir/../f, etc. +// A testFileRef describes some form of reference. +type testFileRef struct { + // name is the name of the reference (not the file name). + // These are a bit cryptic to keep test names short: + // s (/ slash), p (.. parent), b (base), d (directory), r (root) + name string + + // template is a template path. + // + // templates assume that the file is contained in a directory named "ROOT", + // and that "ROOT/DIR" exists and is a directory. + // + // The string BASE in the template may be replaced with the file's basename. + // + // Absolute path templates start with /ROOT. + template string + + // escapes indicates whether the path escapes the current directory. + escapes bool +} + +var testFileRefs = []testFileRef{ + {escapes: false, name: "b", template: "BASE"}, + {escapes: false, name: "bs", template: "BASE/"}, + {escapes: false, name: "dpb", template: "DIR/../BASE"}, + {escapes: false, name: "dpbs", template: "DIR/../BASE/"}, + {escapes: true, name: "prb", template: "../ROOT/BASE"}, + {escapes: true, name: "prbs", template: "../ROOT/BASE/"}, + {escapes: true, name: "srb", template: "/ROOT/BASE"}, + {escapes: true, name: "srbs", template: "/ROOT/BASE/"}, +} + +// testFileLimitedRefs is a smaller set of references which do not exercise path escapes +// (see allTestFileDescs). +var testFileLimitedRefs = testFileRefs[0:2] + +// path creates a path using the template. +// +// dir is the absolute path to the root directory (which must be named "ROOT"). +// base is the name of the target file within the root directory. +func (ref testFileRef) path(dir, base string) string { + p := ref.template + p = strings.ReplaceAll(p, "BASE", base) + if trim, ok := strings.CutPrefix(p, "/ROOT"); ok { + p = dir + trim + } + return p +} + +// hasSlashSuffix reports whether the file reference ends in a /. +func (ref testFileRef) hasSlashSuffix() bool { + return strings.HasSuffix(ref.template, "/") +} + +// testFileDesc is a description of a type of file, combining the kind and reference type. +// +// Some sample testFileDescs: +// - "name", a plain file. +// - "DIR/../name", a directory +// - "name/", where name is a symlink to "DIR/../target/", where target is a plain file. +type testFileDesc struct { + kind testFileKind + ref testFileRef + target *testFileDesc // symlink target, nil when kind is not testFileSymlink +} + +var rootComprehensive = flag.Bool("root_comprehensive", false, + "run many more os.Root test variations (slow, uncertain value)") + +// allTestFileDescs returns an iterator over all the testFileDescs we use in tests. +func allTestFileDescs() iter.Seq[testFileDesc] { + // A testFileDesc contains a reference type ("name", "d/../name", "../r/name", etc.) and + // a file kind (file, directory, symlink, etc.). + // + // When the kind is symlink, the desc contains a reference type and file kind for + // the link target as well. We only exercise one level of symlink (although we + // could do more), so this means a testFileDesc effectively contains four axes of + // variation: ref, kind, symlink ref, symlink kind. + // + // For example: + // + // - "name" is a file + // - "d/../name" is a directory + // - "name" is a symlink to "name2" which is a file + // - "d/../name" is a symlink to "d/../name2" which is a directory + // - etc. + // + // It is feasible to test every possible variation of these four axes, + // but this is quite a few tests and gets quite slow. So by default we exclude + // some variations. We test: + // + // - every reference to every kind, except symlink + // - direct and direct/ references to a symlink to every reference to a file + // - a direct reference to a symlink to a direct reference to every kind (except file) + // + // The full set of variations may be enabled with the -comprehensive_root_tests flag. + + return func(yield func(testFileDesc) bool) { + // Every type of reference to every type of file, except symlink. + for _, ref := range testFileRefs { + for kind := range testFileMax { + if kind == testFileUnused || kind == testFileSymlink { + continue + } + desc := testFileDesc{ + kind: kind, + ref: ref, + } + if !yield(desc) { + return + } + } + } + + // Unless we're being comprehensive, only direct references to symlinks. + refs := testFileRefs + if !*rootComprehensive { + refs = testFileLimitedRefs + } + for _, ref := range refs { + for linkKind := range testFileMax { + if linkKind == testFileUnused || linkKind == testFileSymlink { + continue + } + + linkRefs := testFileRefs + if !*rootComprehensive && linkKind != testFileFile && linkKind != testFileDir { + linkRefs = testFileLimitedRefs + } + for _, linkRef := range linkRefs { + desc := testFileDesc{ + kind: testFileSymlink, + ref: ref, + target: &testFileDesc{ + kind: linkKind, + ref: linkRef, + }, + } + if !yield(desc) { + return + } + } + } + } + } +} + +// String returns the target name. +// +// These are somewhat cryptic to keep test names short. +// For example, "bsSdpbD" is: +// +// bs - "BASE/" +// S - symlink +// dpb - "DIR/../BASE" +// D - directory +// +// So, open "file1/", where file1 is a symlink to "DIR/../file2", where file2 is a directory. +func (desc testFileDesc) String() string { + s := desc.ref.name + strings.ToUpper(desc.kind.String()[:1]) + if desc.kind == testFileSymlink { + s += desc.target.String() + } + return s +} + +// escapes reports whether accessing this file escapes the root, +// either because the file name escapes or because some element of a symlink chain escapes. +func (desc testFileDesc) escapes() bool { + if desc.ref.escapes { + return true + } + if desc.kind == testFileSymlink { + return desc.target.escapes() + } + return false +} + +func (desc testFileDesc) lescapes() bool { + if desc.ref.escapes { + return true + } + if runtime.GOOS == "windows" { + // On POSIX filesystems, a trailing slash at the end of a path causes + // symlinks in the last path component to be resolved. + // On Windows, a trailing slash does not cause symlink resolution. + return false + } + if desc.ref.hasSlashSuffix() && desc.kind == testFileSymlink { + return desc.target.escapes() + } + return false +} + +// finalKind reports the kind of the file after following all symlinks. +func (desc testFileDesc) finalKind() testFileKind { + if desc.kind == testFileSymlink { + return desc.target.finalKind() + } + return desc.kind +} + +func (desc testFileDesc) lfinalKind() testFileKind { + switch runtime.GOOS { + case "windows": + if desc.ref.hasSlashSuffix() && desc.kind == testFileSymlink && desc.target.kind != testFileDir { + return testFileError + } + default: + if desc.ref.hasSlashSuffix() && desc.kind == testFileSymlink { + return desc.target.finalKind() + } + } + return desc.kind +} + +func (desc testFileDesc) isError() bool { + if runtime.GOOS == "js" { + return false + } + var isError func(desc testFileDesc, hasSuffix bool) bool + isError = func(desc testFileDesc, hasSuffix bool) bool { + if desc.ref.escapes { + return false + } + if desc.ref.hasSlashSuffix() { + hasSuffix = true + } + switch desc.kind { + case testFileDir: + return false + case testFileSymlink: + if runtime.GOOS == "windows" && hasSuffix && desc.target.kind != testFileDir { + return true + } + return isError(*desc.target, hasSuffix) + default: + return hasSuffix + } + } + return isError(desc, false) +} + +func (desc testFileDesc) isSymlinkToDir() bool { + if desc.kind != testFileSymlink { + return false + } + if desc.ref.escapes { + return false + } + if desc.finalKind() == testFileDir { + return true + } + return false +} + +// anySlashSuffix reports whether any of the names in the file +// (either the initial name, or a symlink target) +// include a trailing /. +func (desc testFileDesc) anySlashSuffix() bool { + name := desc.ref.template + if len(name) > 0 && os.IsPathSeparator(name[len(name)-1]) { + return true + } + if desc.kind == testFileSymlink { + return desc.target.anySlashSuffix() + } + return false +} + +// anySlashSuffix reports whether the name of the file includes a trailing /. +func (desc testFileDesc) slashSuffix() bool { + name := desc.ref.template + if len(name) > 0 && os.IsPathSeparator(name[len(name)-1]) { + return true + } + return false +} + +// create creates the file(s) for this descriptor. +// +// dir is the test root directory. +// base is the base name of the file we will open within the root. +// (If there are symlinks, base is the start of the symlink chain.) +// +// Tests may create, delete, or move files, which makes it useful to have a way to identify +// and track the files that existed at the start of the test. The token parameter identifies +// which file we're creating. When symlinks are involved, the token is used in creating the +// final, non-symlink file. +func (desc testFileDesc) create(t *testing.T, dir, base, token string) (fi os.FileInfo) { + path := filepath.Join(dir, base) + switch desc.kind { + case testFileAbsent: + // File does not exist. + case testFileFile: + // Regular file. We use the token as the file contents. + if err := os.WriteFile(path, []byte(token), 0o666); err != nil { + t.Fatal(err) + } + case testFileDir: + // Directory. We create a subdir within the directory named "c_"+token. + // (The "c_" prefix is to distinguish this subdir from any files that may + // have the same name as the token.) + if err := os.Mkdir(path, 0o777); err != nil { + t.Fatal(err) + } + case testFileSymlink: + // Symlink. We create a symlink target named "s_"+base. + if runtime.GOOS == "plan9" { + t.Skip("symlinks not supported on " + runtime.GOOS) + } + linktarget := desc.target.ref.path(dir, "s_"+base) + if runtime.GOOS == "wasip1" && filepath.IsAbs(linktarget) { + t.Skip("absolute link targets not supported on " + runtime.GOOS) + } + fi = desc.target.create(t, dir, "s_"+base, token) + if err := os.Symlink(linktarget, path); err != nil { + t.Fatal(err) + } + default: + t.Fatalf("can't create file of kind: %v", desc.kind) + } + if desc.kind == testFileFile || desc.kind == testFileDir { + var err error + fi, err = os.Lstat(path) + if err != nil { + t.Fatal(err) + } + } + return fi +} + +// testRootDescribeFile returns a string identifying a file. +// +// It returns "" if f is nil. +// It returns "source" or "target" if f is the source or target file in the test. +// Otherwise, it returns "unknown file". +func (test *rootMultiTest) describeFile(t *testing.T, f *os.File) string { + if f == nil { + return "" + } + fi, err := f.Stat() + if err != nil { + t.Fatal(err) + } + switch { + case os.SameFile(fi, test.sourceInfo): + return "source" + case os.SameFile(fi, test.targetInfo): + return "target" + default: + return "unknown file" + } +} + +// dirTreeContents returns a description of the contents of directory. +// For example: +// +// drwxrwxrwx dir/ +// -rw-rw-rw- dir/file "file contents" +// Lrw-rw-rw- symlink => dir/file +func dirTreeContents(t *testing.T, dir string) (contents []string) { + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + fs.WalkDir(root.FS(), ".", func(path string, d fs.DirEntry, err error) error { + if path == "." { + return nil + } + info, err := d.Info() + if err != nil { + t.Fatal(err) + } + ent := info.Mode().String() + " " + path + switch d.Type() { + case fs.ModeDir: + ent += "/" + case fs.ModeSymlink: + target, err := root.Readlink(path) + if err != nil { + t.Fatal(err) + } + if filepath.IsAbs(target) { + relPath, err := filepath.Rel(dir, target) + if err == nil && filepath.IsLocal(relPath) { + target = "/.../" + relPath + } + } + ent += " => " + target + default: + f, err := root.Open(path) + if err != nil { + ent += " (unreadable)" + } else { + content, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + ent += fmt.Sprintf(" %q", content) + } + } + contents = append(contents, ent) + return nil + }) + return contents +} + +// TestRootMultiOpen tests os.Root.Open. +// +// This also serves as a prototypical example of using rootMultiTest +// (see also the doc comment on rootMultiTest above). +func TestRootMultiOpen(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + // This function will be run many times, with different inputs: + // - in and out of a Root + // - opening a file, directory, symlink, or nothing at all + // - opening various names: target, DIR/../target, /abs/path/to/target, etc. + // + // The test function should perform the requested operation + // (for example: open "target" in a Root), + // verify that the result is consistent with expectations, + // and then return a description of the result. + // + // The returned description is used to validate consistent behavior + // between operations in and out of a Root. + var open = os.Open + if test.root != nil { + open = test.root.Open + } + + test.setOp("Open(%q)", test.targetPath) // test's operation, for errors + f, gotErr := open(test.targetPath) + if gotErr == nil { + defer f.Close() + } + + // testRootDescribeFile returns a string identifying a file. + // + // This is always "source" or "target" for the source/target files in a test, + // or "" if f is nil. + // (Note that most tests use only a target file, no source.) + got := test.describeFile(t, f) + + switch { + case test.root != nil && test.target.escapes(): + // The operation escapes the root. + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.finalKind() == testFileAbsent: + // The file does not exist ("absent"). + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + // The file name or a symlink target contain a trailing slash. + // Trailing slashes are handled differently on different platforms, + // so we won't try to assert an outcome when they are present. + // runRootMultiTest will verify that root.Open and os.Open + // produce consistent results. + default: + // We should have successfully opened the file. + test.wantError(t, gotErr, nil) + if want := "target"; got != want { + t.Fatalf("opened file %q, want %q", got, want) + } + } + + // Return the name of the file opened (possibly "" for nothing) and the error. + // runRootMultiTest will compare the results for in-a-root and out-of-a-root + // to validate that they are the same. + return got, gotErr + }) +} + +func TestRootMultiChmod(t *testing.T) { + if runtime.GOOS == "wasip1" { + t.Skip("Chmod not supported on " + runtime.GOOS) + } + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var ( + chmod = os.Chmod + stat = os.Stat + lstat = os.Lstat + ) + if test.root != nil { + chmod = test.root.Chmod + stat = test.root.Stat + lstat = test.root.Lstat + } + + // Using the wrong mode here can cause problems during test cleanup, + // if we leave a temp dir with a mode that prevents listing or removing + // its contents. + // + // read+execute permissions let us list directory contents, + // and we restore writability before deleting the temp dir. + wantMode := os.FileMode(0o500) // readable, executable + if runtime.GOOS == "windows" { + // On Windows, the only modes we support are the default (777/rwx) + // or read-only (444/r-x). Making a directory read-only doesn't prevent + // listing its contents, so we can use 444 here. + wantMode = 0o444 // readable + } + t.Cleanup(func() { + chmod(test.targetPath, 0o700) + }) + + test.setOp("Chmod(%q, %o)", test.targetPath, wantMode) + gotErr := chmod(test.targetPath, wantMode) + + escapes := test.target.escapes() + targetKind := test.target.finalKind() + if runtime.GOOS == "windows" { + // On Windows, Chmod("symlink") affects the link, not its target. + // See issue #71492. + stat = lstat + escapes = test.target.ref.escapes + targetKind = test.target.kind + } + + var gotMode fs.FileMode + switch { + case test.root != nil && escapes: + test.wantError(t, gotErr, os.ErrPathEscapes) + case targetKind == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + // Don't expect anything, just be consistent with the OS. + default: + test.wantError(t, gotErr, nil) + + fi, err := stat(test.targetPath) + if err != nil { + t.Fatalf("could not stat target: %v", err) + } + if runtime.GOOS == "windows" && !fi.Mode().IsRegular() { + // See issue #71492. + break + } + + gotMode = fi.Mode() & fs.ModePerm + if gotMode != wantMode { + t.Fatalf("file %q:\ngot mode: %v\nwant mode: %v", test.targetPath, gotMode, wantMode) + } + } + + if runtime.GOOS == "windows" && test.root == nil && gotErr != nil { + // On Windows, os.Chmod calls GetFileAttributes on the target. + // This seems to fail in a number of situations where the os.Root + // chmod path works. For now, just skip the consistency check + // when os.Chmod fails. + return "", errSkipRootConsistencyCheck + } + + return gotMode.String(), gotErr + }) +} + +func TestRootMultiCreate(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var create = os.Create + if test.root != nil { + create = test.root.Create + } + + test.setOp("Create(%q)", test.targetPath) // test's operation, for errors + f, gotErr := create(test.targetPath) + if gotErr == nil { + defer f.Close() + } + + switch { + case test.target.isError(): + test.wantError(t, gotErr, errAny) + case runtime.GOOS == "windows" && test.target.isSymlinkToDir(): + // The error here is because the link is a Windows directory link, + // not because the link target is a directory. + test.wantError(t, gotErr, errAny) + case test.root != nil && test.target.escapes(): + // The operation escapes the root. + test.wantError(t, gotErr, os.ErrPathEscapes) + default: + } + + return "", gotErr + }) +} + +func TestRootMultiLink(t *testing.T) { + if runtime.GOOS == "wasip1" { + switch os.Getenv("GOWASIRUNTIME") { + case "", "wasmtime": + // This test fails when run with wasmtime, because os.RemoveAll fails + // to remove the test tempdir. + t.Skip("test seems to tickle a wasmtime bug") + } + } + testenv.MustHaveLink(t) + runRootMultiTest2(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var ( + rename = os.Link + ) + if test.root != nil { + rename = test.root.Link + } + + test.setOp("Link(%q, %q)", test.sourcePath, test.targetPath) + gotErr := rename(test.sourcePath, test.targetPath) + + switch { + case test.root != nil && test.source.lescapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.source.lfinalKind() == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.source.kind == testFileSymlink: + // os.Link(old, new) may or may not deference old when it is a symlink. + // POSIX says that link(2) should deference the source, but implementations + // are inconsistent. + return "", errSkipRootConsistencyCheck + case test.source.slashSuffix() && test.source.lfinalKind() != testFileDir: + test.wantError(t, gotErr, errAny) + } + return "", gotErr + }) +} + +func TestRootMultiLstat(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var ( + lstat = os.Lstat + ) + if test.root != nil { + lstat = test.root.Lstat + } + + test.setOp("Lstat(%q)", test.targetPath) + gotStat, gotErr := lstat(test.targetPath) + + result := "" + if gotStat != nil { + result = gotStat.Mode().String() + } + + escapes := test.target.lescapes() + finalKind := test.target.lfinalKind() + if runtime.GOOS == "windows" && test.target.ref.hasSlashSuffix() { + // When the target of lstat has a trailing slash, + // Windows follows it. + escapes = test.target.escapes() + finalKind = test.target.finalKind() + } + + switch { + case test.root != nil && escapes: + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.kind == testFileAbsent: + // Target does not exist. + test.wantError(t, gotErr, errAny) + case finalKind == testFileSymlink: + test.wantError(t, gotErr, nil) + if got, want := gotStat.Mode().Type(), fs.ModeSymlink; got != want { + test.errorf(t, "got mode %v, want %v", got, want) + } + case gotErr != nil: + default: + if !os.SameFile(gotStat, test.targetInfo) { + test.errorf(t, "stat result is not for target file; want it to be") + } + } + + return result, gotErr + }) +} + +func TestRootMultiMkdir(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var ( + mkdir = os.Mkdir + stat = os.Stat + ) + if test.root != nil { + mkdir = test.root.Mkdir + stat = test.root.Stat + } + + test.setOp("Mkdir(%q, 0o777)", test.targetPath) + gotErr := mkdir(test.targetPath, 0o777) + + switch { + case test.root != nil && test.target.ref.escapes: + // "mkdir ../target", or equivalent escaping path. + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.slashSuffix() && test.target.kind == testFileSymlink: + // "mkdir symlink/", inconsistent behavior across platforms + // as to whether this follows the symlink or not. + // + // If the symlink escapes, this needs to be some kind of error though. + if test.root != nil && test.target.escapes() { + test.wantError(t, gotErr, errAny) + } + if runtime.GOOS == "openbsd" { + // Known inconsistency: OpenBSD doesn't resolve the final + // symlink when creating a directory. + return "", errSkipRootConsistencyCheck + } + case test.target.kind != testFileAbsent: + // "mkdir target", where target exists. + test.wantError(t, gotErr, errAny) + default: + test.wantError(t, gotErr, nil) + fi, err := stat(test.targetPath) + if err != nil { + t.Fatalf("could not stat target: %v", err) + } + if !fi.IsDir() { + t.Fatalf("%q: not a directory, expected it to be", test.targetPath) + } + } + return "", gotErr + }) +} + +func TestRootMultiRename(t *testing.T) { + if runtime.GOOS == "wasip1" { + switch os.Getenv("GOWASIRUNTIME") { + case "", "wasmtime": + // This test fails when run with wasmtime, because os.RemoveAll fails + // to remove the test tempdir. + t.Skip("test seems to tickle a wasmtime bug") + } + } + runRootMultiTest2(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var ( + rename = os.Rename + ) + if test.root != nil { + rename = test.root.Rename + } + + // TODO: target directory (if any) should be empty + + test.setOp("Rename(%q, %q)", test.sourcePath, test.targetPath) + gotErr := rename(test.sourcePath, test.targetPath) + + if runtime.GOOS == "windows" && + (test.source.finalKind() != test.target.finalKind() || test.source.kind == testFileSymlink || test.target.kind == testFileSymlink) { + // os.Rename on Windows is implemented using MoveFileEx, + // while Root.Rename is implemented using NtSetInformationFileEx + // with an explicit request for POSIX semantics. + // + // This means the two do not behave the same when renaming + // a file onto a directory or vice-versa. + // + // We should make this consistent, but for now just skip + // the consistency checks in this case. + return "", errSkipRootConsistencyCheck + } + + switch { + case test.root != nil && test.source.lescapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.source.lfinalKind() == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.source.slashSuffix() && test.source.lfinalKind() != testFileDir && runtime.GOOS != "js": + test.wantError(t, gotErr, errAny) + case test.root != nil && test.target.lescapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case runtime.GOOS == "plan9": + // Plan9 rename behaves differently. + // Just rely on consistency checks. + case test.target.lfinalKind() == testFileDir: + // POSIX rename() will replace an empty target directory, + // but os.Rename will not. + test.wantError(t, gotErr, errAny) + case test.source.lfinalKind() == testFileDir && test.target.lfinalKind() != testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.source.anySlashSuffix() || test.target.anySlashSuffix(): + if runtime.GOOS == "openbsd" { + // Known inconsistency: OpenBSD doesn't resolve the final + // symlink when creating a directory. + return "", errSkipRootConsistencyCheck + } + default: + test.wantError(t, gotErr, nil) + // TODO: check that the file is in its new location + } + + if runtime.GOOS == "linux" && (test.source.slashSuffix() || test.target.slashSuffix()) { + return "", errSkipRootConsistencyCheck + } + + return "", gotErr + }) +} + +func TestRootMultiReadFile(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var readFile = os.ReadFile + if test.root != nil { + readFile = test.root.ReadFile + } + + test.setOp("ReadFile(%q)", test.targetPath) + data, gotErr := readFile(test.targetPath) + var got string + if gotErr == nil { + got = string(data) + } + + switch { + case test.root != nil && test.target.escapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.finalKind() == testFileAbsent: + test.wantError(t, gotErr, errAny) + case runtime.GOOS == "plan9": + // Plan9 lets you read from directories. + // Just rely on consistency checks. + case test.target.finalKind() == testFileDir: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + // Trailing slashes are handled differently on different platforms, + // so we won't try to assert an outcome when they are present. + // runRootMultiTest will verify that root.ReadFile and os.ReadFile + // produce consistent results. + default: + test.wantError(t, gotErr, nil) + if want := "target"; got != want { + t.Fatalf("read file content %q, want %q", got, want) + } + } + + return got, gotErr + }) +} + +func TestRootMultiStat(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var stat = os.Stat + if test.root != nil { + stat = test.root.Stat + } + + test.setOp("Stat(%q)", test.targetPath) + gotStat, gotErr := stat(test.targetPath) + + switch { + case test.target.isError(): + test.wantError(t, gotErr, errAny) + case test.root != nil && test.target.escapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.finalKind() == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + default: + test.wantError(t, gotErr, nil) + if !os.SameFile(gotStat, test.targetInfo) { + test.errorf(t, "stat result is not for target file; want it to be") + } + } + return "", gotErr + }) +} + +func TestRootMultiRemove(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var remove = os.Remove + if test.root != nil { + remove = test.root.Remove + } + + test.setOp("Remove(%q)", test.targetPath) + gotErr := remove(test.targetPath) + + switch { + case test.root != nil && test.target.lescapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.kind == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + if runtime.GOOS == "linux" { + // Linux treats rmdir("symlink/") as an error when + // "symlink" is a symlink to a directory. + // Root.Remove prefers the POSIX interpretation + // of resolving the symlink. + return "", errSkipRootConsistencyCheck + } + default: + test.wantError(t, gotErr, nil) + } + return "", gotErr + }) +} + +func TestRootMultiRemoveAll(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var removeAll = os.RemoveAll + if test.root != nil { + removeAll = test.root.RemoveAll + } + + test.setOp("RemoveAll(%q)", test.targetPath) + gotErr := removeAll(test.targetPath) + + switch { + case test.root != nil && test.target.ref.escapes: + // This is only checking target.ref.escapes, + // not target.lescapes(), because RemoveAll strips + // terminal slashes. + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.anySlashSuffix(): + // We are inconsistent on some platforms on whether + // RemoveAll("symlink/") removes the link or the link target. + // Something worth addressing, but for now skip the check. + return "", errSkipRootConsistencyCheck + default: + test.wantError(t, gotErr, nil) + } + return "", gotErr + }) +} + +func TestRootMultiChtimes(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var chtimes = os.Chtimes + if test.root != nil { + chtimes = test.root.Chtimes + } + + now := time.Now() + test.setOp("Chtimes(%q, %v, %v)", test.targetPath, now, now) + gotErr := chtimes(test.targetPath, now, now) + + switch { + case test.target.isError(): + test.wantError(t, gotErr, errAny) + case test.root != nil && test.target.escapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.finalKind() == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + default: + test.wantError(t, gotErr, nil) + } + return "", gotErr + }) +} + +func TestRootMultiReadlink(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var readlink = os.Readlink + if test.root != nil { + readlink = test.root.Readlink + } + + test.setOp("Readlink(%q)", test.targetPath) + got, gotErr := readlink(test.targetPath) + if suffix, ok := strings.CutPrefix(got, test.dir); ok { + // Replace absolute path prefix with /.../ + got = "/..." + suffix + } + + switch { + case test.root != nil && test.target.lescapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.kind != testFileSymlink: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + default: + test.wantError(t, gotErr, nil) + } + return got, gotErr + }) +} + +func TestRootMultiWriteFile(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var writeFile = os.WriteFile + if test.root != nil { + writeFile = test.root.WriteFile + } + + test.setOp("WriteFile(%q, ...)", test.targetPath) + gotErr := writeFile(test.targetPath, []byte("data"), 0o666) + + switch { + case test.target.isError(): + test.wantError(t, gotErr, errAny) + case runtime.GOOS == "windows" && test.target.isSymlinkToDir(): + test.wantError(t, gotErr, errAny) + case test.root != nil && test.target.escapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.finalKind() == testFileDir: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + default: + test.wantError(t, gotErr, nil) + } + return "", gotErr + }) +} + +func TestRootMultiOpenFile(t *testing.T) { + runRootMultiTest(t, func(t *testing.T, test *rootMultiTest) (string, error) { + var openFile = os.OpenFile + if test.root != nil { + openFile = test.root.OpenFile + } + + test.setOp("OpenFile(%q, O_RDONLY, 0)", test.targetPath) + f, gotErr := openFile(test.targetPath, os.O_RDONLY, 0) + if gotErr == nil { + defer f.Close() + } + + got := test.describeFile(t, f) + + switch { + case test.root != nil && test.target.escapes(): + test.wantError(t, gotErr, os.ErrPathEscapes) + case test.target.finalKind() == testFileAbsent: + test.wantError(t, gotErr, errAny) + case test.target.anySlashSuffix(): + default: + test.wantError(t, gotErr, nil) + if want := "target"; got != want { + t.Fatalf("opened file %q, want %q", got, want) + } + } + + return got, gotErr + }) +} diff --git a/src/os/root_unix.go b/src/os/root_unix.go index 885a8353ebc455beb2854810a633b04bcbcb47a1..e88058715387b3984c9024f90a81ac7380b02e32 100644 --- a/src/os/root_unix.go +++ b/src/os/root_unix.go @@ -62,7 +62,7 @@ } // openRootInRoot is Root.OpenRoot. func openRootInRoot(r *Root, name string) (*Root, error) { - fd, err := doInRoot(r, name, nil, func(parent int, name string) (fd int, err error) { + fd, err := doInRoot(r, name, 0, nil, func(parent int, name string, endsInSlash bool) (fd int, err error) { ignoringEINTR(func() error { fd, err = unix.Openat(parent, name, syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) if isNoFollowErr(err) { @@ -80,9 +80,10 @@ } // rootOpenFileNolog is Root.OpenFile. func rootOpenFileNolog(root *Root, name string, flag int, perm FileMode) (*File, error) { - fd, err := doInRoot(root, name, nil, func(parent int, name string) (fd int, err error) { + fd, err := doInRoot(root, name, 0, nil, func(parent int, name string, endsInSlash bool) (fd int, err error) { ignoringEINTR(func() error { - fd, err = unix.Openat(parent, name, syscall.O_NOFOLLOW|syscall.O_CLOEXEC|flag, uint32(perm)) + openFlag := syscall.O_NOFOLLOW | syscall.O_CLOEXEC | flag + fd, err = unix.Openat(parent, name, openFlag, uint32(perm)) if err != nil { // Never follow symlinks when O_CREATE|O_EXCL, no matter // what error the OS returns. @@ -129,16 +130,15 @@ return fd, err } func rootStat(r *Root, name string, lstat bool) (FileInfo, error) { - fi, err := doInRoot(r, name, nil, func(parent sysfdType, n string) (FileInfo, error) { - var fs fileStat - if err := unix.Fstatat(parent, n, &fs.sys, unix.AT_SYMLINK_NOFOLLOW); err != nil { + fi, err := doInRoot(r, name, 0, nil, func(parent sysfdType, n string, endsInSlash bool) (FileInfo, error) { + fi, err := lstatatWithName(parent, name, n) + if err != nil { return nil, err } - fillFileStatFromSys(&fs, name) - if !lstat && fs.Mode()&ModeSymlink != 0 { + if !lstat && fi.Mode()&ModeSymlink != 0 { return nil, checkSymlink(parent, n, syscall.ELOOP) } - return &fs, nil + return fi, nil }) if err != nil { return nil, &PathError{Op: "statat", Path: name, Err: err} @@ -147,7 +147,7 @@ return fi, nil } func rootSymlink(r *Root, oldname, newname string) error { - _, err := doInRoot(r, newname, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, newname, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, symlinkat(oldname, parent, name) }) if err != nil { @@ -257,13 +257,17 @@ func symlinkat(oldname string, newfd int, newname string) error { return unix.Symlinkat(oldname, newfd, newname) } -func modeAt(parent int, name string) (FileMode, error) { +func lstatat(parent int, name string) (FileInfo, error) { + return lstatatWithName(parent, name, name) +} + +func lstatatWithName(parent int, origName, name string) (FileInfo, error) { var fs fileStat if err := unix.Fstatat(parent, name, &fs.sys, unix.AT_SYMLINK_NOFOLLOW); err != nil { - return 0, err + return nil, err } - fillFileStatFromSys(&fs, name) - return fs.mode, nil + fillFileStatFromSys(&fs, origName) + return &fs, nil } // checkSymlink resolves the symlink name in parent, @@ -301,3 +305,10 @@ return string(b[0:n]), nil } } } + +// isDirectoryLink always returns false, because Unix systems don't have separate +// symlink types for files and directories. +// (See the Windows version of this function for more details.) +func isDirectoryLink(fi FileInfo) bool { + return false +} diff --git a/src/os/root_unix_test.go b/src/os/root_unix_test.go index b4b37c2be9dea20c2db9bb60f063d09b7c625561..9790a13164caf7486f817431223e54aae23aaf1c 100644 --- a/src/os/root_unix_test.go +++ b/src/os/root_unix_test.go @@ -71,9 +71,8 @@ } groups = append(groups, os.Getgid()) for _, test := range rootTestCases { test.run(t, func(t *testing.T, target string, root *os.Root) { - wantError := test.wantError if test.ltarget != "" { - wantError = false + test.wantError = false target = filepath.Join(root.Name(), test.ltarget) } else if target != "" { if err := os.WriteFile(target, nil, 0o666); err != nil { @@ -82,7 +81,7 @@ } } for _, gid := range groups { err := root.Lchown(test.open, -1, gid) - if errEndsTest(t, err, wantError, "root.Lchown(%q, -1, %v)", test.open, gid) { + if errEndsTest(t, err, test.wantError, "root.Lchown(%q, -1, %v)", test.open, gid) { return } checkUidGid(t, target, int(sys.Uid), gid) diff --git a/src/os/root_windows.go b/src/os/root_windows.go index 72fe17050020ed552600be195defccd8f35d10fc..f45f8162e6d654a7202d84bfb5e38acbfff4c0d6 100644 --- a/src/os/root_windows.go +++ b/src/os/root_windows.go @@ -119,7 +119,9 @@ } // openRootInRoot is Root.OpenRoot. func openRootInRoot(r *Root, name string) (*Root, error) { - fd, err := doInRoot(r, name, nil, rootOpenDir) + fd, err := doInRoot(r, name, 0, nil, func(parent syscall.Handle, name string, endsInSlash bool) (syscall.Handle, error) { + return rootOpenDir(parent, name) + }) if err != nil { return nil, &PathError{Op: "openat", Path: name, Err: err} } @@ -128,7 +130,10 @@ } // rootOpenFileNolog is Root.OpenFile. func rootOpenFileNolog(root *Root, name string, flag int, perm FileMode) (*File, error) { - fd, err := doInRoot(root, name, nil, func(parent syscall.Handle, name string) (syscall.Handle, error) { + fd, err := doInRoot(root, name, doInRootNoHandleTerminalSlash, nil, func(parent syscall.Handle, name string, endsInSlash bool) (syscall.Handle, error) { + if endsInSlash { + flag |= windows.O_DIRECTORY + } return openat(parent, name, flag, perm) }) if err != nil { @@ -204,15 +209,16 @@ return h, err } func rootStat(r *Root, name string, lstat bool) (FileInfo, error) { - if len(name) > 0 && IsPathSeparator(name[len(name)-1]) { - // When a filename ends with a path separator, - // Lstat behaves like Stat. + var flags uint + if lstat { + // Follow symlinks in the last path component when the path + // ends with a path separator. // - // This behavior is not based on a principled decision here, - // merely the empirical evidence that Lstat behaves this way. - lstat = false + // This is not the usual behavior for Windows path resolution, + // but empirically os.Lstat behaves this way. + flags = doInRootAlwaysResolveTerminalSlash } - fi, err := doInRoot(r, name, nil, func(parent syscall.Handle, n string) (FileInfo, error) { + fi, err := doInRoot(r, name, flags, nil, func(parent syscall.Handle, n string, endsInSlash bool) (FileInfo, error) { fd, err := openat(parent, n, windows.O_OPEN_REPARSE, 0) if err != nil { return nil, err @@ -274,7 +280,7 @@ if filepathlite.VolumeNameLen(oldname) == 0 { flags |= windows.SYMLINKAT_RELATIVE } - _, err := doInRoot(r, newname, nil, func(parent sysfdType, name string) (struct{}, error) { + _, err := doInRoot(r, newname, 0, nil, func(parent sysfdType, name string, endsInSlash bool) (struct{}, error) { return struct{}{}, windows.Symlinkat(oldname, parent, name, flags) }) if err != nil { @@ -381,6 +387,18 @@ func linkat(oldfd syscall.Handle, oldname string, newfd syscall.Handle, newname string) error { return windows.Linkat(oldfd, oldname, newfd, newname) } +// checkSymlink resolves the symlink name in parent, +// and returns errSymlink with the link contents. +// +// If name is not a symlink, return origError. +func checkSymlink(parent syscall.Handle, name string, origError error) error { + link, err := readlinkat(parent, name) + if err != nil { + return origError + } + return errSymlink(link) +} + func readlinkat(dirfd syscall.Handle, name string) (string, error) { fd, err := openat(dirfd, name, windows.O_OPEN_REPARSE, 0) if err != nil { @@ -390,15 +408,23 @@ defer syscall.CloseHandle(fd) return readReparseLinkHandle(fd) } -func modeAt(parent syscall.Handle, name string) (FileMode, error) { - fd, err := openat(parent, name, windows.O_OPEN_REPARSE|windows.O_DIRECTORY, 0) +func lstatat(parent syscall.Handle, name string) (FileInfo, error) { + fd, err := openat(parent, name, windows.O_OPEN_REPARSE, 0) if err != nil { - return 0, err + return nil, err } defer syscall.CloseHandle(fd) fi, err := statHandle(name, fd) if err != nil { - return 0, err + return nil, err } - return fi.Mode(), nil + return fi, nil +} + +// isDirectoryLink reports whether fi (assumed to be a symlink) is a directory link. +// Windows symlinks come in two flavors: file and directory. This function distinguishes +// between the two. +func isDirectoryLink(fi FileInfo) bool { + fs, ok := fi.(*fileStat) + return ok && fs.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 }