diff --git a/api/next/49580.txt b/api/next/49580.txt new file mode 100644 index 0000000000..ce213cc9ca --- /dev/null +++ b/api/next/49580.txt @@ -0,0 +1,8 @@ +pkg io/fs, func Lstat(FS, string) (FileInfo, error) #49580 +pkg io/fs, func ReadLink(FS, string) (string, error) #49580 +pkg io/fs, type ReadLinkFS interface { Lstat, Open, ReadLink } #49580 +pkg io/fs, type ReadLinkFS interface, Lstat(string) (FileInfo, error) #49580 +pkg io/fs, type ReadLinkFS interface, Open(string) (File, error) #49580 +pkg io/fs, type ReadLinkFS interface, ReadLink(string) (string, error) #49580 +pkg testing/fstest, method (MapFS) Lstat(string) (fs.FileInfo, error) #49580 +pkg testing/fstest, method (MapFS) ReadLink(string) (string, error) #49580 diff --git a/doc/next/6-stdlib/99-minor/archive/tar/49580.md b/doc/next/6-stdlib/99-minor/archive/tar/49580.md new file mode 100644 index 0000000000..8fa43681fa --- /dev/null +++ b/doc/next/6-stdlib/99-minor/archive/tar/49580.md @@ -0,0 +1,2 @@ +The [*Writer.AddFS] implementation now supports symbolic links +for filesystems that implement [io/fs.ReadLinkFS]. diff --git a/doc/next/6-stdlib/99-minor/io/fs/49580.md b/doc/next/6-stdlib/99-minor/io/fs/49580.md new file mode 100644 index 0000000000..c1cba5a395 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/io/fs/49580.md @@ -0,0 +1 @@ +A new [ReadLinkFS] interface provides the ability to read symbolic links in a filesystem. diff --git a/doc/next/6-stdlib/99-minor/os/49580.md b/doc/next/6-stdlib/99-minor/os/49580.md new file mode 100644 index 0000000000..18d8831e7b --- /dev/null +++ b/doc/next/6-stdlib/99-minor/os/49580.md @@ -0,0 +1,2 @@ +The filesystem returned by [DirFS] implements the new [io/fs.ReadLinkFS] interface. +[CopyFS] supports symlinks when copying filesystems that implement [io/fs.ReadLinkFS]. diff --git a/doc/next/6-stdlib/99-minor/testing/fstest/49580.md b/doc/next/6-stdlib/99-minor/testing/fstest/49580.md new file mode 100644 index 0000000000..5b3c0d6a84 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/testing/fstest/49580.md @@ -0,0 +1,3 @@ +[MapFS] implements the new [io/fs.ReadLinkFS] interface. +[TestFS] will verify the functionality of the [io/fs.ReadLinkFS] interface if implemented. +[TestFS] will no longer follow symlinks to avoid unbounded recursion. diff --git a/src/archive/tar/writer.go b/src/archive/tar/writer.go index f966c5b4c6..336c9fd758 100644 --- a/src/archive/tar/writer.go +++ b/src/archive/tar/writer.go @@ -415,11 +415,17 @@ func (tw *Writer) AddFS(fsys fs.FS) error { if err != nil { return err } - // TODO(#49580): Handle symlinks when fs.ReadLinkFS is available. - if !d.IsDir() && !info.Mode().IsRegular() { + linkTarget := "" + if typ := d.Type(); typ == fs.ModeSymlink { + var err error + linkTarget, err = fs.ReadLink(fsys, name) + if err != nil { + return err + } + } else if !typ.IsRegular() && typ != fs.ModeDir { return errors.New("tar: cannot add non-regular file") } - h, err := FileInfoHeader(info, "") + h, err := FileInfoHeader(info, linkTarget) if err != nil { return err } @@ -430,7 +436,7 @@ func (tw *Writer) AddFS(fsys fs.FS) error { if err := tw.WriteHeader(h); err != nil { return err } - if d.IsDir() { + if !d.Type().IsRegular() { return nil } f, err := fsys.Open(name) diff --git a/src/archive/tar/writer_test.go b/src/archive/tar/writer_test.go index 7b10bf6a70..9e484432ea 100644 --- a/src/archive/tar/writer_test.go +++ b/src/archive/tar/writer_test.go @@ -1342,6 +1342,7 @@ func TestWriterAddFS(t *testing.T) { "emptyfolder": {Mode: 0o755 | os.ModeDir}, "file.go": {Data: []byte("hello")}, "subfolder/another.go": {Data: []byte("world")}, + "symlink.go": {Mode: 0o777 | os.ModeSymlink, Data: []byte("file.go")}, // Notably missing here is the "subfolder" directory. This makes sure even // if we don't have a subfolder directory listed. } @@ -1370,7 +1371,7 @@ func TestWriterAddFS(t *testing.T) { for _, name := range names { entriesLeft-- - entryInfo, err := fsys.Stat(name) + entryInfo, err := fsys.Lstat(name) if err != nil { t.Fatalf("getting entry info error: %v", err) } @@ -1396,18 +1397,23 @@ func TestWriterAddFS(t *testing.T) { name, entryInfo.Mode(), hdr.FileInfo().Mode()) } - if entryInfo.IsDir() { - continue - } - - data, err := io.ReadAll(tr) - if err != nil { - t.Fatal(err) - } - origdata := fsys[name].Data - if string(data) != string(origdata) { - t.Fatalf("test fs has file content %v; archive header has %v", - data, origdata) + switch entryInfo.Mode().Type() { + case fs.ModeDir: + // No additional checks necessary. + case fs.ModeSymlink: + origtarget := string(fsys[name].Data) + if hdr.Linkname != origtarget { + t.Fatalf("test fs has link content %s; archive header %v", origtarget, hdr.Linkname) + } + default: + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + origdata := fsys[name].Data + if string(data) != string(origdata) { + t.Fatalf("test fs has file content %v; archive header has %v", origdata, data) + } } } if entriesLeft > 0 { diff --git a/src/io/fs/readlink.go b/src/io/fs/readlink.go new file mode 100644 index 0000000000..64340b9fb4 --- /dev/null +++ b/src/io/fs/readlink.go @@ -0,0 +1,45 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fs + +// ReadLinkFS is the interface implemented by a file system +// that supports reading symbolic links. +type ReadLinkFS interface { + FS + + // ReadLink returns the destination of the named symbolic link. + // If there is an error, it should be of type [*PathError]. + ReadLink(name string) (string, error) + + // Lstat returns a [FileInfo] describing the named file. + // If the file is a symbolic link, the returned [FileInfo] describes the symbolic link. + // Lstat makes no attempt to follow the link. + // If there is an error, it should be of type [*PathError]. + Lstat(name string) (FileInfo, error) +} + +// ReadLink returns the destination of the named symbolic link. +// +// If fsys does not implement [ReadLinkFS], then ReadLink returns an error. +func ReadLink(fsys FS, name string) (string, error) { + sym, ok := fsys.(ReadLinkFS) + if !ok { + return "", &PathError{Op: "readlink", Path: name, Err: ErrInvalid} + } + return sym.ReadLink(name) +} + +// Lstat returns a [FileInfo] describing the named file. +// If the file is a symbolic link, the returned [FileInfo] describes the symbolic link. +// Lstat makes no attempt to follow the link. +// +// If fsys does not implement [ReadLinkFS], then Lstat is identical to [Stat]. +func Lstat(fsys FS, name string) (FileInfo, error) { + sym, ok := fsys.(ReadLinkFS) + if !ok { + return Stat(fsys, name) + } + return sym.Lstat(name) +} diff --git a/src/io/fs/readlink_test.go b/src/io/fs/readlink_test.go new file mode 100644 index 0000000000..3932c7b778 --- /dev/null +++ b/src/io/fs/readlink_test.go @@ -0,0 +1,106 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fs_test + +import ( + . "io/fs" + "testing" + "testing/fstest" +) + +func TestReadLink(t *testing.T) { + testFS := fstest.MapFS{ + "foo": { + Data: []byte("bar"), + Mode: ModeSymlink | 0o777, + }, + "bar": { + Data: []byte("Hello, World!\n"), + Mode: 0o644, + }, + + "dir/parentlink": { + Data: []byte("../bar"), + Mode: ModeSymlink | 0o777, + }, + "dir/link": { + Data: []byte("file"), + Mode: ModeSymlink | 0o777, + }, + "dir/file": { + Data: []byte("Hello, World!\n"), + Mode: 0o644, + }, + } + + check := func(fsys FS, name string, want string) { + t.Helper() + got, err := ReadLink(fsys, name) + if got != want || err != nil { + t.Errorf("ReadLink(%q) = %q, %v; want %q, ", name, got, err, want) + } + } + + check(testFS, "foo", "bar") + check(testFS, "dir/parentlink", "../bar") + check(testFS, "dir/link", "file") + + // Test that ReadLink on Sub works. + sub, err := Sub(testFS, "dir") + if err != nil { + t.Fatal(err) + } + + check(sub, "link", "file") + check(sub, "parentlink", "../bar") +} + +func TestLstat(t *testing.T) { + testFS := fstest.MapFS{ + "foo": { + Data: []byte("bar"), + Mode: ModeSymlink | 0o777, + }, + "bar": { + Data: []byte("Hello, World!\n"), + Mode: 0o644, + }, + + "dir/parentlink": { + Data: []byte("../bar"), + Mode: ModeSymlink | 0o777, + }, + "dir/link": { + Data: []byte("file"), + Mode: ModeSymlink | 0o777, + }, + "dir/file": { + Data: []byte("Hello, World!\n"), + Mode: 0o644, + }, + } + + check := func(fsys FS, name string, want FileMode) { + t.Helper() + info, err := Lstat(fsys, name) + var got FileMode + if err == nil { + got = info.Mode() + } + if got != want || err != nil { + t.Errorf("Lstat(%q) = %v, %v; want %v, ", name, got, err, want) + } + } + + check(testFS, "foo", ModeSymlink|0o777) + check(testFS, "bar", 0o644) + + // Test that Lstat on Sub works. + sub, err := Sub(testFS, "dir") + if err != nil { + t.Fatal(err) + } + check(sub, "link", ModeSymlink|0o777) +} diff --git a/src/io/fs/sub.go b/src/io/fs/sub.go index 70ac623077..376d561bad 100644 --- a/src/io/fs/sub.go +++ b/src/io/fs/sub.go @@ -23,7 +23,8 @@ type SubFS interface { // Otherwise, if fs implements [SubFS], Sub returns fsys.Sub(dir). // Otherwise, Sub returns a new [FS] implementation sub that, // in effect, implements sub.Open(name) as fsys.Open(path.Join(dir, name)). -// The implementation also translates calls to ReadDir, ReadFile, and Glob appropriately. +// The implementation also translates calls to ReadDir, ReadFile, +// ReadLink, Lstat, and Glob appropriately. // // Note that Sub(os.DirFS("/"), "prefix") is equivalent to os.DirFS("/prefix") // and that neither of them guarantees to avoid operating system @@ -44,6 +45,12 @@ func Sub(fsys FS, dir string) (FS, error) { return &subFS{fsys, dir}, nil } +var _ FS = (*subFS)(nil) +var _ ReadDirFS = (*subFS)(nil) +var _ ReadFileFS = (*subFS)(nil) +var _ ReadLinkFS = (*subFS)(nil) +var _ GlobFS = (*subFS)(nil) + type subFS struct { fsys FS dir string @@ -105,6 +112,30 @@ func (f *subFS) ReadFile(name string) ([]byte, error) { return data, f.fixErr(err) } +func (f *subFS) ReadLink(name string) (string, error) { + full, err := f.fullName("readlink", name) + if err != nil { + return "", err + } + target, err := ReadLink(f.fsys, full) + if err != nil { + return "", f.fixErr(err) + } + return target, nil +} + +func (f *subFS) Lstat(name string) (FileInfo, error) { + full, err := f.fullName("lstat", name) + if err != nil { + return nil, err + } + info, err := Lstat(f.fsys, full) + if err != nil { + return nil, f.fixErr(err) + } + return info, nil +} + func (f *subFS) Glob(pattern string) ([]string, error) { // Check pattern is well-formed. if _, err := path.Match(pattern, ""); err != nil { diff --git a/src/io/fs/walk_test.go b/src/io/fs/walk_test.go index a5fc715e15..91f195f7be 100644 --- a/src/io/fs/walk_test.go +++ b/src/io/fs/walk_test.go @@ -110,6 +110,47 @@ func TestWalkDir(t *testing.T) { }) } +func TestWalkDirSymlink(t *testing.T) { + fsys := fstest.MapFS{ + "link": {Data: []byte("dir"), Mode: ModeSymlink}, + "dir/a": {}, + "dir/b/c": {}, + "dir/d": {Data: []byte("b"), Mode: ModeSymlink}, + } + + wantTypes := map[string]FileMode{ + "link": ModeDir, + "link/a": 0, + "link/b": ModeDir, + "link/b/c": 0, + "link/d": ModeSymlink, + } + marks := make(map[string]int) + walkFn := func(path string, entry DirEntry, err error) error { + marks[path]++ + if want, ok := wantTypes[path]; !ok { + t.Errorf("Unexpected path %q in walk", path) + } else if got := entry.Type(); got != want { + t.Errorf("%s entry type = %v; want %v", path, got, want) + } + if err != nil { + t.Errorf("Walking %s: %v", path, err) + } + return nil + } + + // Expect no errors. + err := WalkDir(fsys, "link", walkFn) + if err != nil { + t.Fatalf("no error expected, found: %s", err) + } + for path := range wantTypes { + if got := marks[path]; got != 1 { + t.Errorf("%s visited %d times; expected 1", path, got) + } + } +} + func TestIssue51617(t *testing.T) { dir := t.TempDir() for _, sub := range []string{"a", filepath.Join("a", "bad"), filepath.Join("a", "next")} { diff --git a/src/os/dir.go b/src/os/dir.go index 939b208d8c..cc3fd602af 100644 --- a/src/os/dir.go +++ b/src/os/dir.go @@ -140,9 +140,6 @@ func ReadDir(name string) ([]DirEntry, error) { // already exists in the destination, CopyFS will return an error // such that errors.Is(err, fs.ErrExist) will be true. // -// Symbolic links in fsys are not supported. A *PathError with Err set -// to ErrInvalid is returned when copying from a symbolic link. -// // Symbolic links in dir are followed. // // New files added to fsys (including if dir is a subdirectory of fsys) @@ -160,35 +157,38 @@ func CopyFS(dir string, fsys fs.FS) error { return err } newPath := joinPath(dir, fpath) - if d.IsDir() { - return MkdirAll(newPath, 0777) - } - // TODO(panjf2000): handle symlinks with the help of fs.ReadLinkFS - // once https://go.dev/issue/49580 is done. - // we also need filepathlite.IsLocal from https://go.dev/cl/564295. - if !d.Type().IsRegular() { + switch d.Type() { + case ModeDir: + return MkdirAll(newPath, 0777) + case ModeSymlink: + target, err := fs.ReadLink(fsys, path) + if err != nil { + return err + } + return Symlink(target, newPath) + case 0: + r, err := fsys.Open(path) + if err != nil { + return err + } + defer r.Close() + info, err := r.Stat() + if err != nil { + return err + } + w, err := OpenFile(newPath, O_CREATE|O_EXCL|O_WRONLY, 0666|info.Mode()&0777) + if err != nil { + return err + } + + if _, err := io.Copy(w, r); err != nil { + w.Close() + return &PathError{Op: "Copy", Path: newPath, Err: err} + } + return w.Close() + default: return &PathError{Op: "CopyFS", Path: path, Err: ErrInvalid} } - - r, err := fsys.Open(path) - if err != nil { - return err - } - defer r.Close() - info, err := r.Stat() - if err != nil { - return err - } - w, err := OpenFile(newPath, O_CREATE|O_EXCL|O_WRONLY, 0666|info.Mode()&0777) - if err != nil { - return err - } - - if _, err := io.Copy(w, r); err != nil { - w.Close() - return &PathError{Op: "Copy", Path: newPath, Err: err} - } - return w.Close() }) } diff --git a/src/os/file.go b/src/os/file.go index a5063680f9..1d4382e486 100644 --- a/src/os/file.go +++ b/src/os/file.go @@ -700,12 +700,17 @@ func (f *File) SyscallConn() (syscall.RawConn, error) { // // The directory dir must not be "". // -// The result implements [io/fs.StatFS], [io/fs.ReadFileFS] and -// [io/fs.ReadDirFS]. +// The result implements [io/fs.StatFS], [io/fs.ReadFileFS], [io/fs.ReadDirFS], and +// [io/fs.ReadLinkFS]. func DirFS(dir string) fs.FS { return dirFS(dir) } +var _ fs.StatFS = dirFS("") +var _ fs.ReadFileFS = dirFS("") +var _ fs.ReadDirFS = dirFS("") +var _ fs.ReadLinkFS = dirFS("") + type dirFS string func (dir dirFS) Open(name string) (fs.File, error) { @@ -777,6 +782,28 @@ func (dir dirFS) Stat(name string) (fs.FileInfo, error) { return f, nil } +func (dir dirFS) Lstat(name string) (fs.FileInfo, error) { + fullname, err := dir.join(name) + if err != nil { + return nil, &PathError{Op: "lstat", Path: name, Err: err} + } + f, err := Lstat(fullname) + if err != nil { + // See comment in dirFS.Open. + err.(*PathError).Path = name + return nil, err + } + return f, nil +} + +func (dir dirFS) ReadLink(name string) (string, error) { + fullname, err := dir.join(name) + if err != nil { + return "", &PathError{Op: "readlink", Path: name, Err: err} + } + return Readlink(fullname) +} + // join returns the path for name in dir. func (dir dirFS) join(name string) (string, error) { if dir == "" { diff --git a/src/os/file_test.go b/src/os/file_test.go new file mode 100644 index 0000000000..f56a34da3e --- /dev/null +++ b/src/os/file_test.go @@ -0,0 +1,156 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package os_test + +import ( + "internal/testenv" + "io/fs" + . "os" + "path/filepath" + "testing" +) + +func TestDirFSReadLink(t *testing.T) { + testenv.MustHaveSymlink(t) + + root := t.TempDir() + subdir := filepath.Join(root, "dir") + if err := Mkdir(subdir, 0o777); err != nil { + t.Fatal(err) + } + links := map[string]string{ + filepath.Join(root, "parent-link"): filepath.Join("..", "foo"), + filepath.Join(root, "sneaky-parent-link"): filepath.Join("dir", "..", "..", "foo"), + filepath.Join(root, "abs-link"): filepath.Join(root, "foo"), + filepath.Join(root, "rel-link"): "foo", + filepath.Join(root, "rel-sub-link"): filepath.Join("dir", "foo"), + filepath.Join(subdir, "parent-link"): filepath.Join("..", "foo"), + } + for newname, oldname := range links { + if err := Symlink(oldname, newname); err != nil { + t.Fatal(err) + } + } + + fsys := DirFS(root) + want := map[string]string{ + "rel-link": "foo", + "rel-sub-link": filepath.Join("dir", "foo"), + "dir/parent-link": filepath.Join("..", "foo"), + "parent-link": filepath.Join("..", "foo"), + "sneaky-parent-link": filepath.Join("dir", "..", "..", "foo"), + "abs-link": filepath.Join(root, "foo"), + } + for name, want := range want { + got, err := fs.ReadLink(fsys, name) + if got != want || err != nil { + t.Errorf("fs.ReadLink(fsys, %q) = %q, %v; want %q, ", name, got, err, want) + } + } +} + +func TestDirFSLstat(t *testing.T) { + testenv.MustHaveSymlink(t) + + root := t.TempDir() + subdir := filepath.Join(root, "dir") + if err := Mkdir(subdir, 0o777); err != nil { + t.Fatal(err) + } + if err := Symlink("dir", filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + + fsys := DirFS(root) + want := map[string]fs.FileMode{ + "link": fs.ModeSymlink, + "dir": fs.ModeDir, + } + for name, want := range want { + info, err := fs.Lstat(fsys, name) + var got fs.FileMode + if info != nil { + got = info.Mode().Type() + } + if got != want || err != nil { + t.Errorf("fs.Lstat(fsys, %q).Mode().Type() = %v, %v; want %v, ", name, got, err, want) + } + } +} + +func TestDirFSWalkDir(t *testing.T) { + testenv.MustHaveSymlink(t) + + root := t.TempDir() + subdir := filepath.Join(root, "dir") + if err := Mkdir(subdir, 0o777); err != nil { + t.Fatal(err) + } + if err := Symlink("dir", filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + if err := WriteFile(filepath.Join(root, "dir", "a"), nil, 0o666); err != nil { + t.Fatal(err) + } + fsys := DirFS(root) + + t.Run("SymlinkRoot", func(t *testing.T) { + wantTypes := map[string]fs.FileMode{ + "link": fs.ModeDir, + "link/a": 0, + } + marks := make(map[string]int) + err := fs.WalkDir(fsys, "link", func(path string, entry fs.DirEntry, err error) error { + marks[path]++ + if want, ok := wantTypes[path]; !ok { + t.Errorf("Unexpected path %q in walk", path) + } else if got := entry.Type(); got != want { + t.Errorf("%s entry type = %v; want %v", path, got, want) + } + if err != nil { + t.Errorf("%s: %v", path, err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + for path := range wantTypes { + if got := marks[path]; got != 1 { + t.Errorf("%s visited %d times; expected 1", path, got) + } + } + }) + + t.Run("SymlinkPresent", func(t *testing.T) { + wantTypes := map[string]fs.FileMode{ + ".": fs.ModeDir, + "dir": fs.ModeDir, + "dir/a": 0, + "link": fs.ModeSymlink, + } + marks := make(map[string]int) + err := fs.WalkDir(fsys, ".", func(path string, entry fs.DirEntry, err error) error { + marks[path]++ + if want, ok := wantTypes[path]; !ok { + t.Errorf("Unexpected path %q in walk", path) + } else if got := entry.Type(); got != want { + t.Errorf("%s entry type = %v; want %v", path, got, want) + } + if err != nil { + t.Errorf("%s: %v", path, err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + for path := range wantTypes { + if got := marks[path]; got != 1 { + t.Errorf("%s visited %d times; expected 1", path, got) + } + } + }) +} diff --git a/src/os/os_test.go b/src/os/os_test.go index 1e2db94dea..6bb89f5870 100644 --- a/src/os/os_test.go +++ b/src/os/os_test.go @@ -3725,13 +3725,9 @@ func TestCopyFSWithSymlinks(t *testing.T) { t.Fatalf("Mkdir: %v", err) } - // TODO(panjf2000): symlinks are currently not supported, and a specific error - // will be returned. Verify that error and skip the subsequent test, - // revisit this once #49580 is closed. - if err := CopyFS(tmpDupDir, fsys); !errors.Is(err, ErrInvalid) { - t.Fatalf("got %v, want ErrInvalid", err) + if err := CopyFS(tmpDupDir, fsys); err != nil { + t.Fatalf("CopyFS: %v", err) } - t.Skip("skip the subsequent test and wait for #49580") forceMFTUpdateOnWindows(t, tmpDupDir) tmpFsys := DirFS(tmpDupDir) diff --git a/src/testing/fstest/mapfs.go b/src/testing/fstest/mapfs.go index 5e3720b0ed..5ce03985e1 100644 --- a/src/testing/fstest/mapfs.go +++ b/src/testing/fstest/mapfs.go @@ -15,7 +15,7 @@ import ( // A MapFS is a simple in-memory file system for use in tests, // represented as a map from path names (arguments to Open) -// to information about the files or directories they represent. +// to information about the files, directories, or symbolic links they represent. // // The map need not include parent directories for files contained // in the map; those will be synthesized if needed. @@ -34,21 +34,27 @@ type MapFS map[string]*MapFile // A MapFile describes a single file in a [MapFS]. type MapFile struct { - Data []byte // file content + Data []byte // file content or symlink destination Mode fs.FileMode // fs.FileInfo.Mode ModTime time.Time // fs.FileInfo.ModTime Sys any // fs.FileInfo.Sys } var _ fs.FS = MapFS(nil) +var _ fs.ReadLinkFS = MapFS(nil) var _ fs.File = (*openMapFile)(nil) -// Open opens the named file. +// Open opens the named file after following any symbolic links. func (fsys MapFS) Open(name string) (fs.File, error) { if !fs.ValidPath(name) { return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} } - file := fsys[name] + realName, ok := fsys.resolveSymlinks(name) + if !ok { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + + file := fsys[realName] if file != nil && file.Mode&fs.ModeDir == 0 { // Ordinary file return &openMapFile{name, mapFileInfo{path.Base(name), file}, 0}, nil @@ -59,10 +65,8 @@ func (fsys MapFS) Open(name string) (fs.File, error) { // But file can also be non-nil, in case the user wants to set metadata for the directory explicitly. // Either way, we need to construct the list of children of this directory. var list []mapFileInfo - var elem string var need = make(map[string]bool) - if name == "." { - elem = "." + if realName == "." { for fname, f := range fsys { i := strings.Index(fname, "/") if i < 0 { @@ -74,8 +78,7 @@ func (fsys MapFS) Open(name string) (fs.File, error) { } } } else { - elem = name[strings.LastIndex(name, "/")+1:] - prefix := name + "/" + prefix := realName + "/" for fname, f := range fsys { if strings.HasPrefix(fname, prefix) { felem := fname[len(prefix):] @@ -107,9 +110,103 @@ func (fsys MapFS) Open(name string) (fs.File, error) { if file == nil { file = &MapFile{Mode: fs.ModeDir | 0555} } + var elem string + if name == "." { + elem = "." + } else { + elem = name[strings.LastIndex(name, "/")+1:] + } return &mapDir{name, mapFileInfo{elem, file}, list, 0}, nil } +func (fsys MapFS) resolveSymlinks(name string) (_ string, ok bool) { + // Fast path: if a symlink is in the map, resolve it. + if file := fsys[name]; file != nil && file.Mode.Type() == fs.ModeSymlink { + target := string(file.Data) + if path.IsAbs(target) { + return "", false + } + return fsys.resolveSymlinks(path.Join(path.Dir(name), target)) + } + + // Check if each parent directory (starting at root) is a symlink. + for i := 0; i < len(name); { + j := strings.Index(name[i:], "/") + var dir string + if j < 0 { + dir = name + i = len(name) + } else { + dir = name[:i+j] + i += j + } + if file := fsys[dir]; file != nil && file.Mode.Type() == fs.ModeSymlink { + target := string(file.Data) + if path.IsAbs(target) { + return "", false + } + return fsys.resolveSymlinks(path.Join(path.Dir(dir), target) + name[i:]) + } + i += len("/") + } + return name, fs.ValidPath(name) +} + +// ReadLink returns the destination of the named symbolic link. +func (fsys MapFS) ReadLink(name string) (string, error) { + info, err := fsys.lstat(name) + if err != nil { + return "", &fs.PathError{Op: "readlink", Path: name, Err: err} + } + if info.f.Mode.Type() != fs.ModeSymlink { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid} + } + return string(info.f.Data), nil +} + +// Lstat returns a FileInfo describing the named file. +// If the file is a symbolic link, the returned FileInfo describes the symbolic link. +// Lstat makes no attempt to follow the link. +func (fsys MapFS) Lstat(name string) (fs.FileInfo, error) { + info, err := fsys.lstat(name) + if err != nil { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: err} + } + return info, nil +} + +func (fsys MapFS) lstat(name string) (*mapFileInfo, error) { + if !fs.ValidPath(name) { + return nil, fs.ErrNotExist + } + realDir, ok := fsys.resolveSymlinks(path.Dir(name)) + if !ok { + return nil, fs.ErrNotExist + } + elem := path.Base(name) + realName := path.Join(realDir, elem) + + file := fsys[realName] + if file != nil { + return &mapFileInfo{elem, file}, nil + } + + if realName == "." { + return &mapFileInfo{elem, &MapFile{Mode: fs.ModeDir | 0555}}, nil + } + // Maybe a directory. + prefix := realName + "/" + for fname := range fsys { + if strings.HasPrefix(fname, prefix) { + return &mapFileInfo{elem, &MapFile{Mode: fs.ModeDir | 0555}}, nil + } + } + // If the directory name is not in the map, + // and there are no children of the name in the map, + // then the directory is treated as not existing. + return nil, fs.ErrNotExist +} + // fsOnly is a wrapper that hides all but the fs.FS methods, // to avoid an infinite recursion when implementing special // methods in terms of helpers that would use them. diff --git a/src/testing/fstest/mapfs_test.go b/src/testing/fstest/mapfs_test.go index 6381a2e56c..e7ff4180ec 100644 --- a/src/testing/fstest/mapfs_test.go +++ b/src/testing/fstest/mapfs_test.go @@ -57,3 +57,69 @@ func TestMapFSFileInfoName(t *testing.T) { t.Errorf("MapFS FileInfo.Name want:\n%s\ngot:\n%s\n", want, got) } } + +func TestMapFSSymlink(t *testing.T) { + const fileContent = "If a program is too slow, it must have a loop.\n" + m := MapFS{ + "fortune/k/ken.txt": {Data: []byte(fileContent)}, + "dirlink": {Data: []byte("fortune/k"), Mode: fs.ModeSymlink}, + "linklink": {Data: []byte("dirlink"), Mode: fs.ModeSymlink}, + "ken.txt": {Data: []byte("dirlink/ken.txt"), Mode: fs.ModeSymlink}, + } + if err := TestFS(m, "fortune/k/ken.txt", "dirlink", "ken.txt", "linklink"); err != nil { + t.Error(err) + } + + gotData, err := fs.ReadFile(m, "ken.txt") + if string(gotData) != fileContent || err != nil { + t.Errorf("fs.ReadFile(m, \"ken.txt\") = %q, %v; want %q, ", gotData, err, fileContent) + } + gotLink, err := fs.ReadLink(m, "dirlink") + if want := "fortune/k"; gotLink != want || err != nil { + t.Errorf("fs.ReadLink(m, \"dirlink\") = %q, %v; want %q, ", gotLink, err, fileContent) + } + gotInfo, err := fs.Lstat(m, "dirlink") + if err != nil { + t.Errorf("fs.Lstat(m, \"dirlink\") = _, %v; want _, ", err) + } else { + if got, want := gotInfo.Name(), "dirlink"; got != want { + t.Errorf("fs.Lstat(m, \"dirlink\").Name() = %q; want %q", got, want) + } + if got, want := gotInfo.Mode(), fs.ModeSymlink; got != want { + t.Errorf("fs.Lstat(m, \"dirlink\").Mode() = %v; want %v", got, want) + } + } + gotInfo, err = fs.Stat(m, "dirlink") + if err != nil { + t.Errorf("fs.Stat(m, \"dirlink\") = _, %v; want _, ", err) + } else { + if got, want := gotInfo.Name(), "dirlink"; got != want { + t.Errorf("fs.Stat(m, \"dirlink\").Name() = %q; want %q", got, want) + } + if got, want := gotInfo.Mode(), fs.ModeDir|0555; got != want { + t.Errorf("fs.Stat(m, \"dirlink\").Mode() = %v; want %v", got, want) + } + } + gotInfo, err = fs.Lstat(m, "linklink") + if err != nil { + t.Errorf("fs.Lstat(m, \"linklink\") = _, %v; want _, ", err) + } else { + if got, want := gotInfo.Name(), "linklink"; got != want { + t.Errorf("fs.Lstat(m, \"linklink\").Name() = %q; want %q", got, want) + } + if got, want := gotInfo.Mode(), fs.ModeSymlink; got != want { + t.Errorf("fs.Lstat(m, \"linklink\").Mode() = %v; want %v", got, want) + } + } + gotInfo, err = fs.Stat(m, "linklink") + if err != nil { + t.Errorf("fs.Stat(m, \"linklink\") = _, %v; want _, ", err) + } else { + if got, want := gotInfo.Name(), "linklink"; got != want { + t.Errorf("fs.Stat(m, \"linklink\").Name() = %q; want %q", got, want) + } + if got, want := gotInfo.Mode(), fs.ModeDir|0555; got != want { + t.Errorf("fs.Stat(m, \"linklink\").Mode() = %v; want %v", got, want) + } + } +} diff --git a/src/testing/fstest/testfs.go b/src/testing/fstest/testfs.go index affdfa6429..1fb84b8928 100644 --- a/src/testing/fstest/testfs.go +++ b/src/testing/fstest/testfs.go @@ -20,6 +20,9 @@ import ( // TestFS tests a file system implementation. // It walks the entire tree of files in fsys, // opening and checking that each file behaves correctly. +// Symbolic links are not followed, +// but their Lstat values are checked +// if the file system implements [fs.ReadLinkFS]. // It also checks that the file system contains at least the expected files. // As a special case, if no expected files are listed, fsys must be empty. // Otherwise, fsys must contain at least the listed files; it can also contain others. @@ -156,9 +159,14 @@ func (t *fsTester) checkDir(dir string) { path := prefix + name t.checkStat(path, info) t.checkOpen(path) - if info.IsDir() { + switch info.Type() { + case fs.ModeDir: t.checkDir(path) - } else { + case fs.ModeSymlink: + // No further processing. + // Avoid following symlinks to avoid potentially unbounded recursion. + t.files = append(t.files, path) + default: t.checkFile(path) } } @@ -440,6 +448,23 @@ func (t *fsTester) checkStat(path string, entry fs.DirEntry) { t.errorf("%s: fsys.Stat(...) = %s\n\twant %s", path, finfo2, finfo) } } + + if fsys, ok := t.fsys.(fs.ReadLinkFS); ok { + info2, err := fsys.Lstat(path) + if err != nil { + t.errorf("%s: fsys.Lstat: %v", path, err) + return + } + fientry2 := formatInfoEntry(info2) + if fentry != fientry2 { + t.errorf("%s: mismatch:\n\tentry = %s\n\tfsys.Lstat(...) = %s", path, fentry, fientry2) + } + feinfo := formatInfo(einfo) + finfo2 := formatInfo(info2) + if feinfo != finfo2 { + t.errorf("%s: mismatch:\n\tentry.Info() = %s\n\tfsys.Lstat(...) = %s\n", path, feinfo, finfo2) + } + } } // checkDirList checks that two directory lists contain the same files and file info. diff --git a/src/testing/fstest/testfs_test.go b/src/testing/fstest/testfs_test.go index 2ef1053a01..d6d6d89b89 100644 --- a/src/testing/fstest/testfs_test.go +++ b/src/testing/fstest/testfs_test.go @@ -28,6 +28,9 @@ func TestSymlink(t *testing.T) { if err := os.Symlink(filepath.Join(tmp, "hello"), filepath.Join(tmp, "hello.link")); err != nil { t.Fatal(err) } + if err := os.Symlink("hello", filepath.Join(tmp, "hello_rel.link")); err != nil { + t.Fatal(err) + } if err := TestFS(tmpfs, "hello", "hello.link"); err != nil { t.Fatal(err)