From 9f78513f6312847c1ea129e64bb78656eea0fa67 Mon Sep 17 00:00:00 2001 From: upstream Date: Wed, 2 Sep 2026 11:39:06 +0200 Subject: [PATCH] Upstream spf13/afero state of the touched files before aa3bf060dc --- basepath.go | 242 +++++++++++++++++++++++++++++++++++++ basepath_test.go | 309 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 551 insertions(+) create mode 100644 basepath.go create mode 100644 basepath_test.go diff --git a/basepath.go b/basepath.go new file mode 100644 index 0000000..47438dd --- /dev/null +++ b/basepath.go @@ -0,0 +1,242 @@ +package afero + +import ( + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +var ( + _ Lstater = (*BasePathFs)(nil) + _ fs.ReadDirFile = (*BasePathFile)(nil) + _ io.WriterTo = (*BasePathFile)(nil) + _ io.ReaderFrom = (*BasePathFile)(nil) +) + +// The BasePathFs restricts all operations to a given path within an Fs. +// The given file name to the operations on this Fs will be prepended with +// the base path before calling the base Fs. +// Any file name (after filepath.Clean()) outside this base path will be +// treated as non existing file. +// +// Note that it does not clean the error messages on return, so you may +// reveal the real path on errors. +type BasePathFs struct { + source Fs + path string +} + +type BasePathFile struct { + File + path string +} + +func (f *BasePathFile) Name() string { + sourcename := f.File.Name() + return strings.TrimPrefix(sourcename, filepath.Clean(f.path)) +} + +func (f *BasePathFile) ReadDir(n int) ([]fs.DirEntry, error) { + if rdf, ok := f.File.(fs.ReadDirFile); ok { + return rdf.ReadDir(n) + } + return readDirFile{f.File}.ReadDir(n) +} + +func (f *BasePathFile) WriteTo(w io.Writer) (int64, error) { + if wt, ok := f.File.(io.WriterTo); ok { + return wt.WriteTo(w) + } + return io.Copy(w, f.File) +} + +func (f *BasePathFile) ReadFrom(r io.Reader) (int64, error) { + if rf, ok := f.File.(io.ReaderFrom); ok { + return rf.ReadFrom(r) + } + return io.Copy(f.File, r) +} + +func NewBasePathFs(source Fs, path string) Fs { + return &BasePathFs{source: source, path: path} +} + +// on a file outside the base path it returns the given file name and an error, +// else the given file with the base path prepended +func (b *BasePathFs) RealPath(name string) (path string, err error) { + if err := validateBasePathName(name); err != nil { + return name, err + } + + path = filepath.Join(b.path, name) + rel, err := filepath.Rel(b.path, path) + if err != nil { + // Usually occurs when files are on different drives. + return name, os.ErrNotExist + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return name, os.ErrNotExist + } + return path, nil +} + +func validateBasePathName(name string) error { + if runtime.GOOS != "windows" { + // Not much to do here; + // the virtual file paths all look absolute on *nix. + return nil + } + + // On Windows a common mistake would be to provide an absolute OS path + // We could strip out the base part, but that would not be very portable. + if filepath.IsAbs(name) { + return os.ErrNotExist + } + + return nil +} + +func (b *BasePathFs) Chtimes(name string, atime, mtime time.Time) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "chtimes", Path: name, Err: err} + } + return b.source.Chtimes(name, atime, mtime) +} + +func (b *BasePathFs) Chmod(name string, mode os.FileMode) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "chmod", Path: name, Err: err} + } + return b.source.Chmod(name, mode) +} + +func (b *BasePathFs) Chown(name string, uid, gid int) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "chown", Path: name, Err: err} + } + return b.source.Chown(name, uid, gid) +} + +func (b *BasePathFs) Name() string { + return "BasePathFs" +} + +func (b *BasePathFs) Stat(name string) (fi os.FileInfo, err error) { + if name, err = b.RealPath(name); err != nil { + return nil, &os.PathError{Op: "stat", Path: name, Err: err} + } + return b.source.Stat(name) +} + +func (b *BasePathFs) Rename(oldname, newname string) (err error) { + if oldname, err = b.RealPath(oldname); err != nil { + return &os.PathError{Op: "rename", Path: oldname, Err: err} + } + if newname, err = b.RealPath(newname); err != nil { + return &os.PathError{Op: "rename", Path: newname, Err: err} + } + return b.source.Rename(oldname, newname) +} + +func (b *BasePathFs) RemoveAll(name string) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "remove_all", Path: name, Err: err} + } + return b.source.RemoveAll(name) +} + +func (b *BasePathFs) Remove(name string) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "remove", Path: name, Err: err} + } + return b.source.Remove(name) +} + +func (b *BasePathFs) OpenFile(name string, flag int, mode os.FileMode) (f File, err error) { + if name, err = b.RealPath(name); err != nil { + return nil, &os.PathError{Op: "openfile", Path: name, Err: err} + } + sourcef, err := b.source.OpenFile(name, flag, mode) + if err != nil { + return nil, err + } + return &BasePathFile{sourcef, b.path}, nil +} + +func (b *BasePathFs) Open(name string) (f File, err error) { + if name, err = b.RealPath(name); err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + sourcef, err := b.source.Open(name) + if err != nil { + return nil, err + } + return &BasePathFile{File: sourcef, path: b.path}, nil +} + +func (b *BasePathFs) Mkdir(name string, mode os.FileMode) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "mkdir", Path: name, Err: err} + } + return b.source.Mkdir(name, mode) +} + +func (b *BasePathFs) MkdirAll(name string, mode os.FileMode) (err error) { + if name, err = b.RealPath(name); err != nil { + return &os.PathError{Op: "mkdir", Path: name, Err: err} + } + return b.source.MkdirAll(name, mode) +} + +func (b *BasePathFs) Create(name string) (f File, err error) { + if name, err = b.RealPath(name); err != nil { + return nil, &os.PathError{Op: "create", Path: name, Err: err} + } + sourcef, err := b.source.Create(name) + if err != nil { + return nil, err + } + return &BasePathFile{File: sourcef, path: b.path}, nil +} + +func (b *BasePathFs) LstatIfPossible(name string) (os.FileInfo, bool, error) { + name, err := b.RealPath(name) + if err != nil { + return nil, false, &os.PathError{Op: "lstat", Path: name, Err: err} + } + if lstater, ok := b.source.(Lstater); ok { + return lstater.LstatIfPossible(name) + } + fi, err := b.source.Stat(name) + return fi, false, err +} + +func (b *BasePathFs) SymlinkIfPossible(oldname, newname string) error { + oldname, err := b.RealPath(oldname) + if err != nil { + return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: err} + } + newname, err = b.RealPath(newname) + if err != nil { + return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: err} + } + if linker, ok := b.source.(Linker); ok { + return linker.SymlinkIfPossible(oldname, newname) + } + return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: ErrNoSymlink} +} + +func (b *BasePathFs) ReadlinkIfPossible(name string) (string, error) { + name, err := b.RealPath(name) + if err != nil { + return "", &os.PathError{Op: "readlink", Path: name, Err: err} + } + if reader, ok := b.source.(LinkReader); ok { + return reader.ReadlinkIfPossible(name) + } + return "", &os.PathError{Op: "readlink", Path: name, Err: ErrNoReadlink} +} diff --git a/basepath_test.go b/basepath_test.go new file mode 100644 index 0000000..2ecc440 --- /dev/null +++ b/basepath_test.go @@ -0,0 +1,309 @@ +package afero + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestBasePath(t *testing.T) { + baseFs := &MemMapFs{} + baseFs.MkdirAll("/base/path/tmp", 0o777) + bp := NewBasePathFs(baseFs, "/base/path") + + if _, err := bp.Create("/tmp/foo"); err != nil { + t.Errorf("Failed to set real path") + } + + if fh, err := bp.Create("../tmp/bar"); err == nil { + t.Errorf("succeeded in creating %s ...", fh.Name()) + } +} + +func TestBasePathRoot(t *testing.T) { + baseFs := &MemMapFs{} + baseFs.MkdirAll("/base/path/foo/baz", 0o777) + baseFs.MkdirAll("/base/path/boo/", 0o777) + bp := NewBasePathFs(baseFs, "/base/path") + + rd, err := ReadDir(bp, string(os.PathSeparator)) + + if len(rd) != 2 { + t.Errorf("base path doesn't respect root") + } + + if err != nil { + t.Error(err) + } +} + +func TestRealPath(t *testing.T) { + tests := []struct { + name string + base string + input string + want string + expectErr bool + os string + }{ + // 1. Happy Paths + { + name: "Simple subpath", + base: "/var/data", + input: "file.txt", + want: "/var/data/file.txt", + }, + { + name: "Deeply nested subpath", + base: "/var/data", + input: "subdir/images/logo.png", + want: "/var/data/subdir/images/logo.png", + }, + + // 2. Cleaning Behavior + { + name: "Cleans double slashes", + base: "/var/data", + input: "subdir//file.txt", + want: "/var/data/subdir/file.txt", + }, + { + name: "Cleans current directory dots", + base: "/var/data", + input: "./subdir/./file.txt", + want: "/var/data/subdir/file.txt", + }, + { + name: "Cleans subpath starts with /", + base: "/var/data", + input: "/file.txt", + want: "/var/data/file.txt", + }, + { + name: "Resolves internal dot-dot (safe)", + base: "/var/data", + input: "subdir/../file.txt", + want: "/var/data/file.txt", + }, + { + name: "Resolves base path dot-dot", + base: "/var/data/../data", + input: "file.txt", + want: "/var/data/file.txt", + }, + + // 3. Base Path is "." + { + name: "Base is dot, simple file", + base: ".", + input: "file.txt", + want: "file.txt", + }, + { + name: "Base is dot, input has dot prefix", + base: ".", + input: "./file.txt", + want: "file.txt", + }, + { + name: "Base is dot, safe traversal", + base: ".", + input: "foo/../bar", + want: "bar", + }, + + // 4. paths starting with .. + { + name: "Valid file starting with .. (..X)", + base: "/var/data", + input: "..foo", + want: "/var/data/..foo", + }, + { + name: "Valid file named ...", + base: "/var/data", + input: "...", + want: "/var/data/...", + }, + { + name: "Hidden file", + base: "/var/data", + input: ".config", + want: "/var/data/.config", + }, + { + name: "Base is dot, input is ..foo", + base: ".", + input: "..foo", + want: "..foo", + }, + + // 5. Failure Cases + { + name: "Traversal out (parent)", + base: "/var/data", + input: "../etc/passwd", + expectErr: true, + }, + { + name: "Traversal out (root)", + base: "/var/data", + input: "../../../../etc/passwd", + expectErr: true, + }, + { + name: "Base is dot, traversal out", + base: ".", + input: "../file.txt", + expectErr: true, + }, + { + name: "Partial suffix match (e.g. /var/dataset vs /var/data)", + base: "/var/data", + input: "../dataset/file.txt", + expectErr: true, + }, + { + name: "Windows: Absolute path", + base: `C:\base`, + input: `C:\Windows\System32`, + expectErr: true, + os: "windows", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.os != "" && tt.os != runtime.GOOS { + t.Skipf("Skipping test for OS %q", tt.os) + } + + baseFs := &MemMapFs{} + bpInterface := NewBasePathFs(baseFs, tt.base) + bp := bpInterface.(*BasePathFs) + + got, err := bp.RealPath(tt.input) + + if tt.expectErr { + if err == nil { + t.Errorf("expected error for input %q, got nil. Result was: %q", tt.input, got) + } + return + } + + if err != nil { + t.Fatalf("unexpected error for input %q: %v", tt.input, err) + } + + if runtime.GOOS == "windows" { + tt.want = filepath.FromSlash(tt.want) + } + + if got != tt.want { + t.Errorf("RealPath() mismatch.\nBase: %q\nInput: %q\nGot: %q\nWant: %q", + tt.base, tt.input, got, tt.want) + } + }) + } +} + +func TestNestedBasePaths(t *testing.T) { + type dirSpec struct { + Dir1, Dir2, Dir3 string + } + dirSpecs := []dirSpec{ + {Dir1: "/", Dir2: "/", Dir3: "/"}, + {Dir1: "/", Dir2: "/path2", Dir3: "/"}, + {Dir1: "/path1/dir", Dir2: "/path2/dir/", Dir3: "/path3/dir"}, + {Dir1: "C:/path1", Dir2: "path2/dir", Dir3: "/path3/dir/"}, + } + + for _, ds := range dirSpecs { + memFs := NewMemMapFs() + level1Fs := NewBasePathFs(memFs, ds.Dir1) + level2Fs := NewBasePathFs(level1Fs, ds.Dir2) + level3Fs := NewBasePathFs(level2Fs, ds.Dir3) + + type spec struct { + BaseFs Fs + FileName string + } + specs := []spec{ + {BaseFs: level3Fs, FileName: "f.txt"}, + {BaseFs: level2Fs, FileName: "f.txt"}, + {BaseFs: level1Fs, FileName: "f.txt"}, + } + + for _, s := range specs { + if err := s.BaseFs.MkdirAll(s.FileName, 0o755); err != nil { + t.Errorf("Got error %s", err.Error()) + } + if _, err := s.BaseFs.Stat(s.FileName); err != nil { + t.Errorf("Got error %s", err.Error()) + } + + switch s.BaseFs { + case level3Fs: + pathToExist := filepath.Join(ds.Dir3, s.FileName) + if _, err := level2Fs.Stat(pathToExist); err != nil { + t.Errorf("Got error %s (path %s)", err.Error(), pathToExist) + } + + case level2Fs: + pathToExist := filepath.Join(ds.Dir2, ds.Dir3, s.FileName) + if _, err := level1Fs.Stat(pathToExist); err != nil { + t.Errorf("Got error %s (path %s)", err.Error(), pathToExist) + } + } + } + } +} + +func TestBasePathOpenFile(t *testing.T) { + baseFs := &MemMapFs{} + baseFs.MkdirAll("/base/path/tmp", 0o777) + bp := NewBasePathFs(baseFs, "/base/path") + f, err := bp.OpenFile("/tmp/file.txt", os.O_CREATE, 0o600) + if err != nil { + t.Fatalf("failed to open file: %v", err) + } + if filepath.Dir(f.Name()) != filepath.Clean("/tmp") { + t.Fatalf("realpath leaked: %s", f.Name()) + } +} + +func TestBasePathCreate(t *testing.T) { + baseFs := &MemMapFs{} + baseFs.MkdirAll("/base/path/tmp", 0o777) + bp := NewBasePathFs(baseFs, "/base/path") + f, err := bp.Create("/tmp/file.txt") + if err != nil { + t.Fatalf("failed to create file: %v", err) + } + if filepath.Dir(f.Name()) != filepath.Clean("/tmp") { + t.Fatalf("realpath leaked: %s", f.Name()) + } +} + +func TestBasePathTempFile(t *testing.T) { + baseFs := &MemMapFs{} + baseFs.MkdirAll("/base/path/tmp", 0o777) + bp := NewBasePathFs(baseFs, "/base/path") + + tDir, err := TempDir(bp, "/tmp", "") + if err != nil { + t.Fatalf("Failed to TempDir: %v", err) + } + if filepath.Dir(tDir) != filepath.Clean("/tmp") { + t.Fatalf("Tempdir realpath leaked: %s", tDir) + } + tempFile, err := TempFile(bp, tDir, "") + if err != nil { + t.Fatalf("Failed to TempFile: %v", err) + } + defer tempFile.Close() + if expected, actual := tDir, filepath.Dir(tempFile.Name()); expected != actual { + t.Fatalf("TempFile realpath leaked: expected %s, got %s", expected, actual) + } +}