From dafe8bc831802eb8c115a5b4aeab7f57d3e4d0bf Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Mon, 21 Aug 2023 01:23:07 +0100 Subject: [PATCH 1/2] build: Bump Go to 1.19 Signed-off-by: Paulo Gomes --- .github/workflows/test.yml | 2 +- .github/workflows/test_js.yml | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3098fee..4bdf33e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ jobs: test: strategy: matrix: - go-version: [1.19.x,1.20.x] + go-version: [1.20.x,1.21.x] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/.github/workflows/test_js.yml b/.github/workflows/test_js.yml index 038080a..ae9fef3 100644 --- a/.github/workflows/test_js.yml +++ b/.github/workflows/test_js.yml @@ -5,7 +5,7 @@ jobs: test: strategy: matrix: - go-version: [1.19.x,1.20.x] + go-version: [1.20.x,1.21.x] runs-on: ubuntu-latest steps: - name: Install Go diff --git a/go.mod b/go.mod index 3f130af..28eb1ca 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,7 @@ module github.com/go-git/go-billy/v5 // go-git supports the last 3 stable Go versions. -go 1.18 +go 1.19 require ( golang.org/x/sys v0.8.0 From 3c59de8dc969175c9164e56fe768111cc5c74a0e Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 24 Aug 2023 15:56:05 +0100 Subject: [PATCH 2/2] osfs: Add new BoundOS type Create the new BoundOS osfs type, which works as a pass-through filesystem for files descending from base dir. For backwards compatibility the previous behaviour is still the default and is now represented by the ChrootOS type. Signed-off-by: Paulo Gomes --- go.mod | 10 +- go.sum | 22 +- osfs/os.go | 151 ++- osfs/os_bound.go | 249 +++++ osfs/os_bound_test.go | 1179 ++++++++++++++++++++++++ osfs/os_chroot.go | 112 +++ osfs/{os_test.go => os_chroot_test.go} | 15 +- osfs/os_js.go | 6 +- osfs/os_js_test.go | 1 + osfs/os_options.go | 3 + osfs/os_plan9.go | 6 + osfs/os_posix.go | 11 + osfs/os_windows.go | 15 +- 13 files changed, 1665 insertions(+), 115 deletions(-) create mode 100644 osfs/os_bound.go create mode 100644 osfs/os_bound_test.go create mode 100644 osfs/os_chroot.go rename osfs/{os_test.go => os_chroot_test.go} (76%) create mode 100644 osfs/os_options.go diff --git a/go.mod b/go.mod index 28eb1ca..cef2802 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,20 @@ module github.com/go-git/go-billy/v5 // go-git supports the last 3 stable Go versions. go 1.19 +replace github.com/cyphar/filepath-securejoin => github.com/pjbgf/filepath-securejoin v0.0.0-20230821001828-0ca74e6d4bf8 + require ( - golang.org/x/sys v0.8.0 + github.com/cyphar/filepath-securejoin v0.2.3 + github.com/onsi/gomega v1.27.10 + golang.org/x/sys v0.11.0 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c ) require ( + github.com/google/go-cmp v0.5.9 // indirect github.com/kr/pretty v0.2.1 // indirect github.com/kr/text v0.2.0 // indirect + golang.org/x/net v0.12.0 // indirect + golang.org/x/text v0.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 58c31c4..418c52e 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,29 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pjbgf/filepath-securejoin v0.0.0-20230821001828-0ca74e6d4bf8 h1:nqjCeQ2TVnccihhBoVBd0p+70hCFT4yqJKhfc8l1D50= +github.com/pjbgf/filepath-securejoin v0.0.0-20230821001828-0ca74e6d4bf8/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/osfs/os.go b/osfs/os.go index 9665d27..32ea0e5 100644 --- a/osfs/os.go +++ b/osfs/os.go @@ -1,140 +1,101 @@ +//go:build !js // +build !js // Package osfs provides a billy filesystem for the OS. -package osfs // import "github.com/go-git/go-billy/v5/osfs" +package osfs import ( - "io/ioutil" + "fmt" + "io/fs" "os" - "path/filepath" "sync" "github.com/go-git/go-billy/v5" - "github.com/go-git/go-billy/v5/helper/chroot" ) const ( - defaultDirectoryMode = 0755 - defaultCreateMode = 0666 + defaultDirectoryMode = 0o755 + defaultCreateMode = 0o666 ) -// Default Filesystem representing the root of the os filesystem. -var Default = &OS{} - -// OS is a filesystem based on the os filesystem. -type OS struct{} - // New returns a new OS filesystem. -func New(baseDir string) billy.Filesystem { - return chroot.New(Default, baseDir) -} - -func (fs *OS) Create(filename string) (billy.File, error) { - return fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultCreateMode) -} - -func (fs *OS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { - if flag&os.O_CREATE != 0 { - if err := fs.createDir(filename); err != nil { - return nil, err - } - } - - f, err := os.OpenFile(filename, flag, perm) - if err != nil { - return nil, err +func New(baseDir string, opts ...Option) billy.Filesystem { + o := &options{} + for _, opt := range opts { + opt(o) } - return &file{File: f}, err -} -func (fs *OS) createDir(fullpath string) error { - dir := filepath.Dir(fullpath) - if dir != "." { - if err := os.MkdirAll(dir, defaultDirectoryMode); err != nil { - return err - } + if o.Type == BoundOSFS { + return newBoundOS(baseDir) } - return nil + return newChrootOS(baseDir) } -func (fs *OS) ReadDir(path string) ([]os.FileInfo, error) { - l, err := ioutil.ReadDir(path) - if err != nil { - return nil, err - } - - var s = make([]os.FileInfo, len(l)) - for i, f := range l { - s[i] = f +// WithBoundOS returns the option of using a Bound filesystem OS. +func WithBoundOS() Option { + return func(o *options) { + o.Type = BoundOSFS } - - return s, nil } -func (fs *OS) Rename(from, to string) error { - if err := fs.createDir(to); err != nil { - return err +// WithChrootOS returns the option of using a Chroot filesystem OS. +func WithChrootOS() Option { + return func(o *options) { + o.Type = ChrootOSFS } - - return rename(from, to) -} - -func (fs *OS) MkdirAll(path string, perm os.FileMode) error { - return os.MkdirAll(path, defaultDirectoryMode) } -func (fs *OS) Open(filename string) (billy.File, error) { - return fs.OpenFile(filename, os.O_RDONLY, 0) +type options struct { + Type } -func (fs *OS) Stat(filename string) (os.FileInfo, error) { - return os.Stat(filename) -} +type Type int -func (fs *OS) Remove(filename string) error { - return os.Remove(filename) -} +const ( + ChrootOSFS Type = iota + BoundOSFS +) -func (fs *OS) TempFile(dir, prefix string) (billy.File, error) { - if err := fs.createDir(dir + string(os.PathSeparator)); err != nil { +func readDir(dir string) ([]os.FileInfo, error) { + entries, err := os.ReadDir(dir) + if err != nil { return nil, err } + infos := make([]fs.FileInfo, 0, len(entries)) + for _, entry := range entries { + fi, err := entry.Info() + if err != nil { + return nil, err + } + infos = append(infos, fi) + } + return infos, nil +} - f, err := ioutil.TempFile(dir, prefix) +func tempFile(dir, prefix string) (billy.File, error) { + f, err := os.CreateTemp(dir, prefix) if err != nil { return nil, err } return &file{File: f}, nil } -func (fs *OS) Join(elem ...string) string { - return filepath.Join(elem...) -} - -func (fs *OS) RemoveAll(path string) error { - return os.RemoveAll(filepath.Clean(path)) -} - -func (fs *OS) Lstat(filename string) (os.FileInfo, error) { - return os.Lstat(filepath.Clean(filename)) -} - -func (fs *OS) Symlink(target, link string) error { - if err := fs.createDir(link); err != nil { - return err +func openFile(fn string, flag int, perm os.FileMode, createDir func(string) error) (billy.File, error) { + if flag&os.O_CREATE != 0 { + if createDir == nil { + return nil, fmt.Errorf("createDir func cannot be nil if file needs to be opened in create mode") + } + if err := createDir(fn); err != nil { + return nil, err + } } - return os.Symlink(target, link) -} - -func (fs *OS) Readlink(link string) (string, error) { - return os.Readlink(link) -} - -// Capabilities implements the Capable interface. -func (fs *OS) Capabilities() billy.Capability { - return billy.DefaultCapabilities + f, err := os.OpenFile(fn, flag, perm) + if err != nil { + return nil, err + } + return &file{File: f}, err } // file is a wrapper for an os.File which adds support for file locking. diff --git a/osfs/os_bound.go b/osfs/os_bound.go new file mode 100644 index 0000000..5e64ebd --- /dev/null +++ b/osfs/os_bound.go @@ -0,0 +1,249 @@ +//go:build !js +// +build !js + +/* + Copyright 2022 The Flux authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package osfs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/go-git/go-billy/v5" +) + +// BoundOS is a fs implementation based on the OS filesystem which is bound to +// a base dir. +// Prefer this fs implementation over ChrootOS. +// +// Behaviours of note: +// 1. Read and write operations can only be directed to files which descends +// from the base dir. +// 2. Symlinks don't have their targets modified, and therefore can point +// to locations outside the base dir or to non-existent paths. +// 3. Readlink and Lstat ensures that the link file is located within the base +// dir, evaluating any symlinks that file or base dir may contain. +type BoundOS struct { + baseDir string +} + +func newBoundOS(d string) billy.Filesystem { + return &BoundOS{baseDir: d} +} + +func (fs *BoundOS) Create(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultCreateMode) +} + +func (fs *BoundOS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { + fn, err := fs.abs(filename) + if err != nil { + return nil, err + } + return openFile(fn, flag, perm, fs.createDir) +} + +func (fs *BoundOS) ReadDir(path string) ([]os.FileInfo, error) { + dir, err := fs.abs(path) + if err != nil { + return nil, err + } + + return readDir(dir) +} + +func (fs *BoundOS) Rename(from, to string) error { + f, err := fs.abs(from) + if err != nil { + return err + } + t, err := fs.abs(to) + if err != nil { + return err + } + + // MkdirAll for target name. + if err := fs.createDir(t); err != nil { + return err + } + + return os.Rename(f, t) +} + +func (fs *BoundOS) MkdirAll(path string, perm os.FileMode) error { + dir, err := fs.abs(path) + if err != nil { + return err + } + return os.MkdirAll(dir, perm) +} + +func (fs *BoundOS) Open(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDONLY, 0) +} + +func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) { + filename, err := fs.abs(filename) + if err != nil { + return nil, err + } + return os.Stat(filename) +} + +func (fs *BoundOS) Remove(filename string) error { + fn, err := fs.abs(filename) + if err != nil { + return err + } + return os.Remove(fn) +} + +// TempFile creates a temporary file. If dir is empty, the file +// will be created within the OS Temporary dir. If dir is provided +// it must descend from the current base dir. +func (fs *BoundOS) TempFile(dir, prefix string) (billy.File, error) { + if dir != "" { + var err error + dir, err = fs.abs(dir) + if err != nil { + return nil, err + } + } + + return tempFile(dir, prefix) +} + +func (fs *BoundOS) Join(elem ...string) string { + return filepath.Join(elem...) +} + +func (fs *BoundOS) RemoveAll(path string) error { + dir, err := fs.abs(path) + if err != nil { + return err + } + return os.RemoveAll(dir) +} + +func (fs *BoundOS) Symlink(target, link string) error { + ln, err := fs.abs(link) + if err != nil { + return err + } + // MkdirAll for containing dir. + if err := fs.createDir(ln); err != nil { + return err + } + return os.Symlink(target, ln) +} + +func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) { + filename = filepath.Clean(filename) + if !filepath.IsAbs(filename) { + filename = filepath.Join(fs.baseDir, filename) + } + if ok, err := fs.insideBaseDirEval(filename); !ok { + return nil, err + } + return os.Lstat(filename) +} + +func (fs *BoundOS) Readlink(link string) (string, error) { + if !filepath.IsAbs(link) { + link = filepath.Clean(filepath.Join(fs.baseDir, link)) + } + if ok, err := fs.insideBaseDirEval(link); !ok { + return "", err + } + return os.Readlink(link) +} + +// Chroot returns a new OS filesystem, with the base dir set to the +// result of joining the provided path with the underlying base dir. +func (fs *BoundOS) Chroot(path string) (billy.Filesystem, error) { + joined, err := securejoin.SecureJoin(fs.baseDir, path) + if err != nil { + return nil, err + } + return New(joined), nil +} + +// Root returns the current base dir of the billy.Filesystem. +// This is required in order for this implementation to be a drop-in +// replacement for other upstream implementations (e.g. memory and osfs). +func (fs *BoundOS) Root() string { + return fs.baseDir +} + +func (fs *BoundOS) createDir(fullpath string) error { + dir := filepath.Dir(fullpath) + if dir != "." { + if err := os.MkdirAll(dir, defaultDirectoryMode); err != nil { + return err + } + } + + return nil +} + +// abs transforms filename to an absolute path, taking into account the base dir. +// Relative paths won't be allowed to ascend the base dir, so `../file` will become +// `/working-dir/file`. +// +// Note that if filename is a symlink, the returned address will be the target of the +// symlink. +func (fs *BoundOS) abs(filename string) (string, error) { + if filename == fs.baseDir { + filename = string(filepath.Separator) + } else if cw := fs.baseDir + string(filepath.Separator); strings.HasPrefix(filename, cw) { + filename = strings.TrimPrefix(filename, cw) + } + return securejoin.SecureJoin(fs.baseDir, filename) +} + +// insideBaseDir checks whether filename is located within +// the fs.baseDir. +func (fs *BoundOS) insideBaseDir(filename string) (bool, error) { + if filename == fs.baseDir { + return true, nil + } + if !strings.HasPrefix(filename, fs.baseDir+string(filepath.Separator)) { + return false, fmt.Errorf("path outside base dir") + } + return true, nil +} + +// insideBaseDirEval checks whether filename is contained within +// a dir that is within the fs.baseDir, by first evaluating any symlinks +// that either filename or fs.baseDir may contain. +func (fs *BoundOS) insideBaseDirEval(filename string) (bool, error) { + dir, err := filepath.EvalSymlinks(filepath.Dir(filename)) + if dir == "" || os.IsNotExist(err) { + dir = filepath.Dir(filename) + } + wd, err := filepath.EvalSymlinks(fs.baseDir) + if wd == "" || os.IsNotExist(err) { + wd = fs.baseDir + } + if filename != wd && dir != wd && !strings.HasPrefix(dir, wd+string(filepath.Separator)) { + return false, fmt.Errorf("path outside base dir") + } + return true, nil +} diff --git a/osfs/os_bound_test.go b/osfs/os_bound_test.go new file mode 100644 index 0000000..8307028 --- /dev/null +++ b/osfs/os_bound_test.go @@ -0,0 +1,1179 @@ +//go:build !js +// +build !js + +/* + Copyright 2022 The Flux authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package osfs + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/onsi/gomega" +) + +func TestOpen(t *testing.T) { + tests := []struct { + name string + filename string + makeAbs bool + before func(dir string) billy.Filesystem + wantErr string + }{ + { + name: "file: rel same dir", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + }, + { + name: "file: rel path to above cwd", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "rel-above-cwd"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "../../rel-above-cwd", + }, + { + name: "file: rel path to below cwd", + before: func(dir string) billy.Filesystem { + os.Mkdir(filepath.Join(dir, "sub"), 0o700) + os.WriteFile(filepath.Join(dir, "sub/rel-below-cwd"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "sub/rel-below-cwd", + }, + { + name: "file: abs inside cwd", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "abs-test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "abs-test-file", + makeAbs: true, + }, + { + name: "file: abs outside cwd", + before: func(dir string) billy.Filesystem { + return newBoundOS(dir) + }, + filename: "/some/path/outside/cwd", + wantErr: notFoundError(), + }, + { + name: "symlink: same dir", + before: func(dir string) billy.Filesystem { + target := filepath.Join(dir, "target-file") + os.WriteFile(target, []byte("anything"), 0o600) + os.Symlink(target, filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + }, + { + name: "symlink: rel outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("../../../../../../outside/cwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: true, + wantErr: notFoundError(), + }, + { + name: "symlink: abs outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/some/path/outside/cwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: true, + wantErr: notFoundError(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + if tt.before != nil { + fs = tt.before(dir) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(dir, filename) + } + + fi, err := fs.Open(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + g.Expect(fi).To(gomega.BeNil()) + } else { + g.Expect(err).To(gomega.BeNil()) + g.Expect(fi).ToNot(gomega.BeNil()) + g.Expect(fi.Close()).To(gomega.Succeed()) + } + }) + } +} + +func Test_Symlink(t *testing.T) { + // The umask value set at OS level can impact this test, so + // it is set to 0 during the duration of this test and then + // reverted back to the original value. + // Outside of linux this is a no-op. + defer umask(0)() + + tests := []struct { + name string + link string + target string + before func(dir string) billy.Filesystem + wantStatErr string + }{ + { + name: "link to abs valid target", + link: "symlink", + target: filepath.FromSlash("/etc/passwd"), + }, + { + name: "link to abs inexistent target", + link: "symlink", + target: filepath.FromSlash("/some/random/path"), + }, + { + name: "link to rel valid target", + link: "symlink", + target: filepath.FromSlash("../../../../../../../../../etc/passwd"), + }, + { + name: "link to rel inexistent target", + link: "symlink", + target: filepath.FromSlash("../../../some/random/path"), + }, + { + name: "auto create dir", + link: "new-dir/symlink", + target: filepath.FromSlash("../../../some/random/path"), + }, + { + name: "keep dir filemode if exists", + link: "new-dir/symlink", + before: func(dir string) billy.Filesystem { + os.Mkdir(filepath.Join(dir, "new-dir"), 0o701) + return newBoundOS(dir) + }, + target: filepath.FromSlash("../../../some/random/path"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + if tt.before != nil { + fs = tt.before(dir) + } + + // Even if CWD is changed outside of the fs instance, + // the base dir must still be observed. + err := os.Chdir(os.TempDir()) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + link := filepath.Join(dir, tt.link) + + diBefore, _ := os.Lstat(filepath.Dir(link)) + + err = fs.Symlink(tt.target, tt.link) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + fi, err := os.Lstat(link) + if tt.wantStatErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantStatErr)) + } else { + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(fi).ToNot(gomega.BeNil()) + } + + got, err := os.Readlink(link) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(got).To(gomega.Equal(tt.target)) + + diAfter, err := os.Lstat(filepath.Dir(link)) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + if diBefore != nil { + g.Expect(diAfter.Mode()).To(gomega.Equal(diBefore.Mode())) + } + }) + } +} + +func TestTempFile(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + f, err := fs.TempFile("", "prefix") + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(f).ToNot(gomega.BeNil()) + g.Expect(f.Name()).To(gomega.ContainSubstring(os.TempDir())) + g.Expect(f.Close()).ToNot(gomega.HaveOccurred()) + + f, err = fs.TempFile("/above/cwd", "prefix") + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(fmt.Sprint(dir, filepath.FromSlash("/above/cwd/prefix")))) + g.Expect(f).To(gomega.BeNil()) + + tempDir := os.TempDir() + // For windows, volume name must be removed. + if v := filepath.VolumeName(tempDir); v != "" { + tempDir = strings.TrimPrefix(tempDir, v) + } + + f, err = fs.TempFile(tempDir, "prefix") + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(filepath.Join(dir, tempDir, "prefix"))) + g.Expect(f).To(gomega.BeNil()) +} + +func TestChroot(t *testing.T) { + g := gomega.NewWithT(t) + tmp := t.TempDir() + fs := newBoundOS(tmp) + + f, err := fs.Chroot("test") + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(f).ToNot(gomega.BeNil()) + g.Expect(f.Root()).To(gomega.Equal(filepath.Join(tmp, "test"))) +} + +func TestRoot(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + root := fs.Root() + g.Expect(root).To(gomega.Equal(dir)) +} + +func TestReadLink(t *testing.T) { + tests := []struct { + name string + filename string + makeAbs bool + expected string + makeExpectedAbs bool + before func(dir string) billy.Filesystem + wantErr string + }{ + { + name: "symlink: pointing to abs outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + expected: filepath.FromSlash("/etc/passwd"), + }, + { + name: "file: rel pointing to abs above cwd", + filename: "../../file", + wantErr: "path outside base dir", + }, + { + name: "symlink: abs symlink pointing outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: true, + expected: filepath.FromSlash("/etc/passwd"), + }, + { + name: "symlink: dir pointing outside cwd", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "current-dir") + outside := filepath.Join(dir, "outside-cwd") + + os.Mkdir(cwd, 0o700) + os.Mkdir(outside, 0o700) + + os.Symlink(outside, filepath.Join(cwd, "symlink")) + os.WriteFile(filepath.Join(outside, "file"), []byte("anything"), 0o600) + + return newBoundOS(cwd) + }, + filename: "current-dir/symlink/file", + makeAbs: true, + wantErr: "path outside base dir", + }, + { + name: "symlink: within cwd + baseDir symlink", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "symlink-dir") + cwdAlt := filepath.Join(dir, "symlink-altdir") + cwdTarget := filepath.Join(dir, "cwd-target") + + os.MkdirAll(cwdTarget, 0o700) + + os.WriteFile(filepath.Join(cwdTarget, "file"), []byte{}, 0o600) + os.Symlink(cwdTarget, cwd) + os.Symlink(cwdTarget, cwdAlt) + os.Symlink(filepath.Join(cwdTarget, "file"), filepath.Join(cwdAlt, "symlink-file")) + return newBoundOS(cwd) + }, + filename: "symlink-file", + expected: filepath.Join("cwd-target/file"), + makeExpectedAbs: true, + }, + { + name: "symlink: outside cwd + baseDir symlink", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "symlink-dir") + outside := filepath.Join(cwd, "symlink-outside") + cwdTarget := filepath.Join(dir, "cwd-target") + outsideDir := filepath.Join(dir, "outside") + + os.Mkdir(cwdTarget, 0o700) + os.Mkdir(outsideDir, 0o700) + + os.WriteFile(filepath.Join(cwdTarget, "file"), []byte{}, 0o600) + os.Symlink(cwdTarget, cwd) + os.Symlink(outsideDir, outside) + os.Symlink(filepath.Join(cwdTarget, "file"), filepath.Join(outside, "symlink-file")) + return newBoundOS(cwd) + }, + filename: "symlink-outside/symlink-file", + wantErr: "path outside base dir", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + if tt.before != nil { + fs = tt.before(dir) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(dir, filename) + } + + expected := tt.expected + if tt.makeExpectedAbs { + expected = filepath.Join(dir, expected) + } + + got, err := fs.Readlink(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + g.Expect(got).To(gomega.BeEmpty()) + } else { + g.Expect(err).To(gomega.BeNil()) + g.Expect(got).To(gomega.Equal(expected)) + } + }) + } +} + +func TestLstat(t *testing.T) { + tests := []struct { + name string + filename string + makeAbs bool + before func(dir string) billy.Filesystem + wantErr string + }{ + { + name: "rel symlink: pointing to abs outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + }, + { + name: "rel symlink: pointing to rel path above cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("../../../../../../../../etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + }, + { + name: "abs symlink: pointing to abs outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: true, + }, + { + name: "abs symlink: pointing to rel outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("../../../../../../../../etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: false, + }, + { + name: "symlink: within cwd + baseDir symlink", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "symlink-dir") + cwdAlt := filepath.Join(dir, "symlink-altdir") + cwdTarget := filepath.Join(dir, "cwd-target") + + os.MkdirAll(cwdTarget, 0o700) + + os.WriteFile(filepath.Join(cwdTarget, "file"), []byte{}, 0o600) + os.Symlink(cwdTarget, cwd) + os.Symlink(cwdTarget, cwdAlt) + os.Symlink(filepath.Join(cwdTarget, "file"), filepath.Join(cwdAlt, "symlink-file")) + return newBoundOS(cwd) + }, + filename: "symlink-file", + makeAbs: false, + }, + { + name: "symlink: outside cwd + baseDir symlink", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "symlink-dir") + outside := filepath.Join(cwd, "symlink-outside") + cwdTarget := filepath.Join(dir, "cwd-target") + outsideDir := filepath.Join(dir, "outside") + + os.Mkdir(cwdTarget, 0o700) + os.Mkdir(outsideDir, 0o700) + + os.WriteFile(filepath.Join(cwdTarget, "file"), []byte{}, 0o600) + os.Symlink(cwdTarget, cwd) + os.Symlink(outsideDir, outside) + os.Symlink(filepath.Join(cwdTarget, "file"), filepath.Join(outside, "symlink-file")) + return newBoundOS(cwd) + }, + filename: "symlink-outside/symlink-file", + makeAbs: false, + wantErr: "path outside base dir", + }, + { + name: "path: rel pointing to abs above cwd", + filename: "../../file", + wantErr: "path outside base dir", + }, + { + name: "path: abs pointing outside cwd", + filename: "/etc/passwd", + wantErr: "path outside base dir", + }, + { + name: "file: rel", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + }, + { + name: "file: abs", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + makeAbs: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + if tt.before != nil { + fs = tt.before(dir) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(dir, filename) + } + fi, err := fs.Lstat(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + g.Expect(fi).To(gomega.BeNil()) + } else { + g.Expect(err).To(gomega.BeNil()) + g.Expect(fi).ToNot(gomega.BeNil()) + g.Expect(fi.Name()).To(gomega.Equal(filepath.Base(tt.filename))) + } + }) + } +} + +func TestStat(t *testing.T) { + tests := []struct { + name string + filename string + makeAbs bool + before func(dir string) billy.Filesystem + wantErr string + }{ + { + name: "rel symlink: pointing to abs outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + wantErr: notFoundError(), + }, + { + name: "rel symlink: pointing to rel path above cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("../../../../../../../../etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + wantErr: notFoundError(), + }, + + { + name: "abs symlink: pointing to abs outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: true, + wantErr: notFoundError(), + }, + { + name: "abs symlink: pointing to rel outside cwd", + before: func(dir string) billy.Filesystem { + os.Symlink("../../../../../../../../etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: false, + wantErr: notFoundError(), + }, + { + name: "path: rel pointing to abs above cwd", + filename: "../../file", + wantErr: notFoundError(), + }, + { + name: "path: abs pointing outside cwd", + filename: "/etc/passwd", + wantErr: notFoundError(), + }, + { + name: "rel file", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + }, + { + name: "abs file", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + makeAbs: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + if tt.before != nil { + fs = tt.before(dir) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(dir, filename) + } + + fi, err := fs.Stat(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + g.Expect(fi).To(gomega.BeNil()) + } else { + g.Expect(err).To(gomega.BeNil()) + g.Expect(fi).ToNot(gomega.BeNil()) + } + }) + } +} + +func TestRemove(t *testing.T) { + tests := []struct { + name string + filename string + makeAbs bool + before func(dir string) billy.Filesystem + wantErr string + }{ + { + name: "path: rel pointing outside cwd w forward slash", + filename: "/some/path/outside/cwd", + wantErr: notFoundError(), + }, + { + name: "path: rel pointing outside cwd", + filename: "../../../../path/outside/cwd", + wantErr: notFoundError(), + }, + { + name: "inexistent dir", + before: func(dir string) billy.Filesystem { + return newBoundOS(dir) + }, + filename: "inexistent", + wantErr: notFoundError(), + }, + { + name: "same dir file", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + }, + { + name: "symlink: same dir", + before: func(dir string) billy.Filesystem { + target := filepath.Join(dir, "target-file") + os.WriteFile(target, []byte("anything"), 0o600) + os.Symlink(target, filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + }, + { + name: "rel path to file above cwd", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "rel-above-cwd"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "../../rel-above-cwd", + }, + { + name: "abs file", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "abs-test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "abs-test-file", + makeAbs: true, + }, + { + name: "abs symlink: pointing outside is forced to descend", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "current-dir") + outsideFile := filepath.Join(dir, "outside-cwd/file") + + os.Mkdir(cwd, 0o700) + os.MkdirAll(filepath.Dir(outsideFile), 0o700) + os.WriteFile(outsideFile, []byte("anything"), 0o600) + os.Symlink(outsideFile, filepath.Join(cwd, "remove-abs-symlink")) + return newBoundOS(cwd) + }, + filename: "remove-abs-symlink", + wantErr: notFoundError(), + }, + { + name: "rel symlink: pointing outside is forced to descend", + before: func(dir string) billy.Filesystem { + cwd := filepath.Join(dir, "current-dir") + outsideFile := filepath.Join(dir, "outside-cwd", "file2") + + os.Mkdir(cwd, 0o700) + os.MkdirAll(filepath.Dir(outsideFile), 0o700) + os.WriteFile(outsideFile, []byte("anything"), 0o600) + os.Symlink(filepath.Join("..", "outside-cwd", "file2"), filepath.Join(cwd, "remove-abs-symlink2")) + return newBoundOS(cwd) + }, + filename: "remove-rel-symlink", + wantErr: notFoundError(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + if tt.before != nil { + fs = tt.before(dir) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(dir, filename) + } + + err := fs.Remove(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + } else { + g.Expect(err).To(gomega.BeNil()) + } + }) + } +} + +func TestRemoveAll(t *testing.T) { + tests := []struct { + name string + filename string + makeAbs bool + before func(dir string) billy.Filesystem + wantErr string + }{ + { + name: "parent with children", + before: func(dir string) billy.Filesystem { + os.MkdirAll(filepath.Join(dir, "parent/children"), 0o600) + return newBoundOS(dir) + }, + filename: "parent", + }, + { + name: "inexistent dir", + filename: "inexistent", + }, + { + name: "same dir file", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "test-file", + }, + { + name: "same dir symlink", + before: func(dir string) billy.Filesystem { + target := filepath.Join(dir, "target-file") + os.WriteFile(target, []byte("anything"), 0o600) + os.Symlink(target, filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + }, + { + name: "rel path to file above cwd", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "rel-above-cwd"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "../../rel-above-cwd", + }, + { + name: "abs file", + before: func(dir string) billy.Filesystem { + os.WriteFile(filepath.Join(dir, "abs-test-file"), []byte("anything"), 0o600) + return newBoundOS(dir) + }, + filename: "abs-test-file", + makeAbs: true, + }, + { + name: "abs symlink", + before: func(dir string) billy.Filesystem { + os.Symlink("/etc/passwd", filepath.Join(dir, "symlink")) + return newBoundOS(dir) + }, + filename: "symlink", + makeAbs: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir).(*BoundOS) + + if tt.before != nil { + fs = tt.before(dir).(*BoundOS) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(dir, filename) + } + + err := fs.RemoveAll(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + } else { + g.Expect(err).To(gomega.BeNil()) + } + }) + } +} + +func TestJoin(t *testing.T) { + tests := []struct { + elems []string + wanted string + }{ + { + elems: []string{}, + wanted: "", + }, + { + elems: []string{"/a", "b", "c"}, + wanted: filepath.FromSlash("/a/b/c"), + }, + { + elems: []string{"/a", "b/c"}, + wanted: filepath.FromSlash("/a/b/c"), + }, + { + elems: []string{"/a", ""}, + wanted: filepath.FromSlash("/a"), + }, + { + elems: []string{"/a", "/", "b"}, + wanted: filepath.FromSlash("/a/b"), + }, + } + for _, tt := range tests { + t.Run(tt.wanted, func(t *testing.T) { + g := gomega.NewWithT(t) + fs := newBoundOS(t.TempDir()) + + got := fs.Join(tt.elems...) + g.Expect(got).To(gomega.Equal(tt.wanted)) + }) + } +} + +func TestAbs(t *testing.T) { + tests := []struct { + name string + cwd string + filename string + makeAbs bool + expected string + makeExpectedAbs bool + wantErr string + before func(dir string) + }{ + { + name: "path: same dir rel file", + cwd: "/working/dir", + filename: "./file", + expected: filepath.FromSlash("/working/dir/file"), + }, + { + name: "path: descending rel file", + cwd: "/working/dir", + filename: "file", + expected: filepath.FromSlash("/working/dir/file"), + }, + { + name: "path: ascending rel file 1", + cwd: "/working/dir", + filename: "../file", + expected: filepath.FromSlash("/working/dir/file"), + }, + { + name: "path: ascending rel file 2", + cwd: "/working/dir", + filename: "../../file", + expected: filepath.FromSlash("/working/dir/file"), + }, + { + name: "path: ascending rel file 3", + cwd: "/working/dir", + filename: "/../../file", + expected: filepath.FromSlash("/working/dir/file"), + }, + { + name: "path: abs file within cwd", + cwd: filepath.FromSlash("/working/dir"), + filename: filepath.FromSlash("/working/dir/abs-file"), + expected: filepath.FromSlash("/working/dir/abs-file"), + }, + { + name: "path: abs file within cwd", + cwd: "/working/dir", + filename: "/outside/dir/abs-file", + expected: filepath.FromSlash("/working/dir/outside/dir/abs-file"), + }, + { + name: "abs symlink: within cwd w abs descending target", + filename: "ln-cwd-cwd", + makeAbs: true, + expected: "within-cwd", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink(filepath.Join(dir, "within-cwd"), filepath.Join(dir, "ln-cwd-cwd")) + }, + }, + { + name: "abs symlink: within cwd w rel descending target", + filename: "ln-rel-cwd-cwd", + makeAbs: true, + expected: "within-cwd", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink("within-cwd", filepath.Join(dir, "ln-rel-cwd-cwd")) + }, + }, + { + name: "abs symlink: within cwd w abs ascending target", + filename: "ln-cwd-up", + makeAbs: true, + expected: "/some/outside/dir", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink("/some/outside/dir", filepath.Join(dir, "ln-cwd-up")) + }, + }, + { + name: "abs symlink: within cwd w rel ascending target", + filename: "ln-rel-cwd-up", + makeAbs: true, + expected: "outside-cwd", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink("../../outside-cwd", filepath.Join(dir, "ln-rel-cwd-up")) + }, + }, + { + name: "rel symlink: within cwd w abs descending target", + filename: "ln-cwd-cwd", + expected: "within-cwd", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink(filepath.Join(dir, "within-cwd"), filepath.Join(dir, "ln-cwd-cwd")) + }, + }, + { + name: "rel symlink: within cwd w rel descending target", + filename: "ln-rel-cwd-cwd2", + expected: "within-cwd", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink("within-cwd", filepath.Join(dir, "ln-rel-cwd-cwd2")) + }, + }, + { + name: "rel symlink: within cwd w abs ascending target", + filename: "ln-cwd-up2", + expected: "/outside/path/up", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink("/outside/path/up", filepath.Join(dir, "ln-cwd-up2")) + }, + }, + { + name: "rel symlink: within cwd w rel ascending target", + filename: "ln-rel-cwd-up2", + expected: "outside", + makeExpectedAbs: true, + before: func(dir string) { + os.Symlink("../../../../outside", filepath.Join(dir, "ln-rel-cwd-up2")) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + cwd := tt.cwd + if cwd == "" { + cwd = t.TempDir() + } + + fs := newBoundOS(cwd).(*BoundOS) + if tt.before != nil { + tt.before(cwd) + } + + filename := tt.filename + if tt.makeAbs { + filename = filepath.Join(cwd, filename) + } + + expected := tt.expected + if tt.makeExpectedAbs { + expected = filepath.Join(cwd, expected) + } + + got, err := fs.abs(filename) + if tt.wantErr != "" { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.wantErr)) + } else { + g.Expect(err).ToNot(gomega.HaveOccurred()) + } + + g.Expect(got).To(gomega.Equal(expected)) + }) + } +} + +func TestReadDir(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + f, err := os.Create(filepath.Join(dir, "file1")) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(f).ToNot(gomega.BeNil()) + g.Expect(f.Close()).To(gomega.Succeed()) + + f, err = os.Create(filepath.Join(dir, "file2")) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(f).ToNot(gomega.BeNil()) + g.Expect(f.Close()).To(gomega.Succeed()) + + dirs, err := fs.ReadDir(dir) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(dirs).ToNot(gomega.BeNil()) + g.Expect(dirs).To(gomega.HaveLen(2)) + + dirs, err = fs.ReadDir(".") + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(dirs).ToNot(gomega.BeNil()) + g.Expect(dirs).To(gomega.HaveLen(2)) + + os.Symlink("/some/path/outside/cwd", filepath.Join(dir, "symlink")) + dirs, err = fs.ReadDir("symlink") + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(notFoundError())) + g.Expect(dirs).To(gomega.BeNil()) +} + +func TestMkdirAll(t *testing.T) { + g := gomega.NewWithT(t) + root := t.TempDir() + cwd := filepath.Join(root, "cwd") + target := "abc" + targetAbs := filepath.Join(cwd, target) + fs := newBoundOS(cwd) + + // Even if CWD is changed outside of the fs instance, + // the base dir must still be observed. + err := os.Chdir(os.TempDir()) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + err = fs.MkdirAll(target, 0o700) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + fi, err := os.Stat(targetAbs) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(fi).ToNot(gomega.BeNil()) + + err = os.Mkdir(filepath.Join(root, "outside"), 0o700) + g.Expect(err).ToNot(gomega.HaveOccurred()) + err = os.Symlink(filepath.Join(root, "outside"), filepath.Join(cwd, "symlink")) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + err = fs.MkdirAll(filepath.Join(cwd, "symlink", "new-dir"), 0o700) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + // For windows, the volume name must be removed from the path or + // it will lead to an invalid path. + if vol := filepath.VolumeName(root); vol != "" { + root = root[len(vol):] + } + + mustExist(filepath.Join(cwd, root, "outside", "new-dir")) +} + +func TestRename(t *testing.T) { + g := gomega.NewWithT(t) + dir := t.TempDir() + fs := newBoundOS(dir) + + oldFile := "old-file" + newFile := filepath.Join("newdir", "newfile") + + // Even if CWD is changed outside of the fs instance, + // the base dir must still be observed. + err := os.Chdir(os.TempDir()) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + f, err := fs.Create(oldFile) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(f.Close()).To(gomega.Succeed()) + + err = fs.Rename(oldFile, newFile) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + fi, err := os.Stat(filepath.Join(dir, newFile)) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(fi).ToNot(gomega.BeNil()) + + err = fs.Rename(filepath.FromSlash("/tmp/outside/cwd/file1"), newFile) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(notFoundError())) + + err = fs.Rename(oldFile, filepath.FromSlash("/tmp/outside/cwd/file2")) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring(notFoundError())) +} + +func mustExist(filename string) { + fi, err := os.Stat(filename) + if err != nil || fi == nil { + panic(fmt.Sprintf("file %s should exist", filename)) + } +} + +func notFoundError() string { + switch runtime.GOOS { + case "windows": + return "The system cannot find the " // {path,file} specified + default: + return "no such file or directory" + } +} diff --git a/osfs/os_chroot.go b/osfs/os_chroot.go new file mode 100644 index 0000000..fd65e77 --- /dev/null +++ b/osfs/os_chroot.go @@ -0,0 +1,112 @@ +//go:build !js +// +build !js + +package osfs + +import ( + "os" + "path/filepath" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/helper/chroot" +) + +// ChrootOS is a legacy filesystem based on a "soft chroot" of the os filesystem. +// Although this is still the default os filesystem, consider using BoundOS instead. +// +// Behaviours of note: +// 1. A "soft chroot" translates the base dir to "/" for the purposes of the +// fs abstraction. +// 2. Symlinks targets may be modified to be kept within the chroot bounds. +// 3. Some file modes does not pass-through the fs abstraction. +// 4. The combination of 1 and 2 may cause go-git to think that a Git repository +// is dirty, when in fact it isn't. +type ChrootOS struct{} + +func newChrootOS(baseDir string) billy.Filesystem { + return chroot.New(&ChrootOS{}, baseDir) +} + +func (fs *ChrootOS) Create(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultCreateMode) +} + +func (fs *ChrootOS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { + return openFile(filename, flag, perm, fs.createDir) +} + +func (fs *ChrootOS) createDir(fullpath string) error { + dir := filepath.Dir(fullpath) + if dir != "." { + if err := os.MkdirAll(dir, defaultDirectoryMode); err != nil { + return err + } + } + + return nil +} + +func (fs *ChrootOS) ReadDir(dir string) ([]os.FileInfo, error) { + return readDir(dir) +} + +func (fs *ChrootOS) Rename(from, to string) error { + if err := fs.createDir(to); err != nil { + return err + } + + return rename(from, to) +} + +func (fs *ChrootOS) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, defaultDirectoryMode) +} + +func (fs *ChrootOS) Open(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDONLY, 0) +} + +func (fs *ChrootOS) Stat(filename string) (os.FileInfo, error) { + return os.Stat(filename) +} + +func (fs *ChrootOS) Remove(filename string) error { + return os.Remove(filename) +} + +func (fs *ChrootOS) TempFile(dir, prefix string) (billy.File, error) { + if err := fs.createDir(dir + string(os.PathSeparator)); err != nil { + return nil, err + } + + return tempFile(dir, prefix) +} + +func (fs *ChrootOS) Join(elem ...string) string { + return filepath.Join(elem...) +} + +func (fs *ChrootOS) RemoveAll(path string) error { + return os.RemoveAll(filepath.Clean(path)) +} + +func (fs *ChrootOS) Lstat(filename string) (os.FileInfo, error) { + return os.Lstat(filepath.Clean(filename)) +} + +func (fs *ChrootOS) Symlink(target, link string) error { + if err := fs.createDir(link); err != nil { + return err + } + + return os.Symlink(target, link) +} + +func (fs *ChrootOS) Readlink(link string) (string, error) { + return os.Readlink(link) +} + +// Capabilities implements the Capable interface. +func (fs *ChrootOS) Capabilities() billy.Capability { + return billy.DefaultCapabilities +} diff --git a/osfs/os_test.go b/osfs/os_chroot_test.go similarity index 76% rename from osfs/os_test.go rename to osfs/os_chroot_test.go index b6f3dd9..2d3ddb4 100644 --- a/osfs/os_test.go +++ b/osfs/os_chroot_test.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package osfs @@ -17,14 +18,14 @@ import ( func Test(t *testing.T) { TestingT(t) } -type OSSuite struct { +type ChrootOSSuite struct { test.FilesystemSuite path string } -var _ = Suite(&OSSuite{}) +var _ = Suite(&ChrootOSSuite{}) -func (s *OSSuite) SetUpTest(c *C) { +func (s *ChrootOSSuite) SetUpTest(c *C) { s.path, _ = ioutil.TempDir(os.TempDir(), "go-billy-osfs-test") if runtime.GOOS == "plan9" { // On Plan 9, permission mode of newly created files @@ -34,15 +35,15 @@ func (s *OSSuite) SetUpTest(c *C) { // in the temporary directory, we need to make it more permissive. c.Assert(os.Chmod(s.path, 0777), IsNil) } - s.FilesystemSuite = test.NewFilesystemSuite(New(s.path)) + s.FilesystemSuite = test.NewFilesystemSuite(newChrootOS(s.path)) } -func (s *OSSuite) TearDownTest(c *C) { +func (s *ChrootOSSuite) TearDownTest(c *C) { err := os.RemoveAll(s.path) c.Assert(err, IsNil) } -func (s *OSSuite) TestOpenDoesNotCreateDir(c *C) { +func (s *ChrootOSSuite) TestOpenDoesNotCreateDir(c *C) { _, err := s.FS.Open("dir/non-existent") c.Assert(err, NotNil) @@ -50,7 +51,7 @@ func (s *OSSuite) TestOpenDoesNotCreateDir(c *C) { c.Assert(os.IsNotExist(err), Equals, true) } -func (s *OSSuite) TestCapabilities(c *C) { +func (s *ChrootOSSuite) TestCapabilities(c *C) { _, ok := s.FS.(billy.Capable) c.Assert(ok, Equals, true) diff --git a/osfs/os_js.go b/osfs/os_js.go index 8ae68fe..2e58aa5 100644 --- a/osfs/os_js.go +++ b/osfs/os_js.go @@ -1,3 +1,4 @@ +//go:build js // +build js package osfs @@ -16,6 +17,9 @@ var globalMemFs = memfs.New() var Default = memfs.New() // New returns a new OS filesystem. -func New(baseDir string) billy.Filesystem { +func New(baseDir string, _ ...Option) billy.Filesystem { return chroot.New(Default, Default.Join("/", baseDir)) } + +type options struct { +} diff --git a/osfs/os_js_test.go b/osfs/os_js_test.go index 84a8190..a62d103 100644 --- a/osfs/os_js_test.go +++ b/osfs/os_js_test.go @@ -1,3 +1,4 @@ +//go:build js // +build js package osfs diff --git a/osfs/os_options.go b/osfs/os_options.go new file mode 100644 index 0000000..2f235c6 --- /dev/null +++ b/osfs/os_options.go @@ -0,0 +1,3 @@ +package osfs + +type Option func(*options) diff --git a/osfs/os_plan9.go b/osfs/os_plan9.go index e8f519f..84020b5 100644 --- a/osfs/os_plan9.go +++ b/osfs/os_plan9.go @@ -1,3 +1,4 @@ +//go:build plan9 // +build plan9 package osfs @@ -83,3 +84,8 @@ func dirwstat(name string, d *syscall.Dir) error { } return nil } + +func umask(new int) func() { + return func() { + } +} diff --git a/osfs/os_posix.go b/osfs/os_posix.go index c74d60e..d834a11 100644 --- a/osfs/os_posix.go +++ b/osfs/os_posix.go @@ -1,9 +1,11 @@ +//go:build !plan9 && !windows && !js // +build !plan9,!windows,!js package osfs import ( "os" + "syscall" "golang.org/x/sys/unix" ) @@ -25,3 +27,12 @@ func (f *file) Unlock() error { func rename(from, to string) error { return os.Rename(from, to) } + +// umask sets umask to a new value, and returns a func which allows the +// caller to reset it back to what it was originally. +func umask(new int) func() { + old := syscall.Umask(new) + return func() { + syscall.Umask(old) + } +} diff --git a/osfs/os_windows.go b/osfs/os_windows.go index 8f5caeb..e54df74 100644 --- a/osfs/os_windows.go +++ b/osfs/os_windows.go @@ -1,3 +1,4 @@ +//go:build windows // +build windows package osfs @@ -10,15 +11,6 @@ import ( "golang.org/x/sys/windows" ) -type fileInfo struct { - os.FileInfo - name string -} - -func (fi *fileInfo) Name() string { - return fi.name -} - var ( kernel32DLL = windows.NewLazySystemDLL("kernel32.dll") lockFileExProc = kernel32DLL.NewProc("LockFileEx") @@ -59,3 +51,8 @@ func (f *file) Unlock() error { func rename(from, to string) error { return os.Rename(from, to) } + +func umask(new int) func() { + return func() { + } +}