diff --git a/internal/sandbox/dirfs_unix.go b/internal/sandbox/dirfs_unix.go index 1f02da9e..d8d2e2eb 100644 --- a/internal/sandbox/dirfs_unix.go +++ b/internal/sandbox/dirfs_unix.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" + "github.com/stealthrocket/timecraft/internal/sandbox/fspath" "golang.org/x/sys/unix" ) @@ -14,11 +15,11 @@ func (root dirFS) Open(name string, flags int, mode fs.FileMode) (File, error) { if err != nil { return nil, &fs.PathError{Op: "open", Path: string(root), Err: err} } - if name = cleanPath(name); name == "/" || name == "." { // root? + if name = fspath.Clean(name); name == "/" || name == "." { // root? return &dirFile{fd: dirfd, name: "/"}, nil } defer closeTraceError(dirfd) - relPath := "/" + trimLeadingSlash(name) + relPath := "/" + fspath.TrimLeadingSlash(name) fd, err := openat(dirfd, name, flags, uint32(mode.Perm())) if err != nil { return nil, &fs.PathError{Op: "open", Path: relPath, Err: err} @@ -49,7 +50,7 @@ func (f *dirFile) Close() error { } func (f *dirFile) Open(name string, flags int, mode fs.FileMode) (File, error) { - name = cleanPath(name) + name = fspath.Clean(name) relPath := f.join(name) fd, err := openat(f.fd, name, flags, uint32(mode.Perm())) if err != nil { @@ -236,7 +237,7 @@ func (f *dirFile) Rename(oldName string, newDir File, newName string) error { fd2 := int(newDir.Fd()) if err := renameat(fd1, oldName, fd2, newName); err != nil { path1 := f.join(oldName) - path2 := joinPath(newDir.Name(), newName) + path2 := fspath.Join(newDir.Name(), newName) return &os.LinkError{Op: "rename", Old: path1, New: path2, Err: err} } return nil @@ -251,7 +252,7 @@ func (f *dirFile) Link(oldName string, newDir File, newName string, flags int) e fd2 := int(newDir.Fd()) if err := linkat(fd1, oldName, fd2, newName, linkFlags); err != nil { path1 := f.join(oldName) - path2 := joinPath(newDir.Name(), newName) + path2 := fspath.Join(newDir.Name(), newName) return &os.LinkError{Op: "link", Old: path1, New: path2, Err: err} } return nil @@ -272,5 +273,5 @@ func (f *dirFile) Unlink(name string) error { } func (f *dirFile) join(name string) string { - return joinPath(f.name, name) + return fspath.Join(f.name, name) } diff --git a/internal/sandbox/fs.go b/internal/sandbox/fs.go index 40b6eb6c..f81f75da 100644 --- a/internal/sandbox/fs.go +++ b/internal/sandbox/fs.go @@ -8,8 +8,9 @@ import ( "os/user" "path" "strconv" - "strings" "time" + + "github.com/stealthrocket/timecraft/internal/sandbox/fspath" ) const ( @@ -123,11 +124,11 @@ func MkdirAll(fsys FileSystem, name string, mode fs.FileMode) error { } func mkdirAll(fsys FileSystem, name string, mode fs.FileMode) error { - path := cleanPath(name) + path := fspath.Clean(name) if path == "/" || path == "." { return nil } - path = strings.TrimPrefix(path, "/") + path = fspath.TrimLeadingSlash(path) d, err := OpenRoot(fsys) if err != nil { @@ -137,10 +138,7 @@ func mkdirAll(fsys FileSystem, name string, mode fs.FileMode) error { for path != "" { var dir string - dir, path = walkPath(path) - if dir == "." { - dir, path = path, "" - } + dir, path = fspath.Walk(path) if err := d.Mkdir(dir, mode); err != nil { if !errors.Is(err, EEXIST) { diff --git a/internal/sandbox/fspath/fspath.go b/internal/sandbox/fspath/fspath.go new file mode 100644 index 00000000..6f2c69aa --- /dev/null +++ b/internal/sandbox/fspath/fspath.go @@ -0,0 +1,150 @@ +// Package fspath is similar to the standard path package but provides functions +// that are more useful for path manipulation in the presence of symbolic links. +package fspath + +// Depth returns the depth of a path. The root "/" has depth zero. +func Depth(path string) (depth int) { + for { + path = TrimLeadingSlash(path) + if path == "" { + return depth + } + i := IndexSlash(path) + if i < 0 { + i = len(path) + } + switch path[:i] { + case ".": + case "..": + if depth > 0 { + depth-- + } + default: + depth++ + } + path = path[i:] + } +} + +// Join is similar to path.Join but is simplified to only join two paths and +// avoid cleaning parent directory references in the paths. +func Join(dir, name string) string { + buf := make([]byte, 0, 256) + buf = AppendClean(buf, dir) + if name = TrimLeadingSlash(name); name != "" { + if !HasTrailingSlash(string(buf)) { + buf = append(buf, '/') + } + buf = AppendClean(buf, name) + } + return string(buf) +} + +// Clean is like path.Clean but it preserves parent directory references; +// this is necessary to ensure that symbolic links aren't erased from walking +// the path. +func Clean(path string) string { + buf := make([]byte, 0, 256) + buf = AppendClean(buf, path) + return string(buf) +} + +// AppendClean is like cleanPath but it appends the result to the byte slice +// passed as first argument. +func AppendClean(buf []byte, path string) []byte { + if len(path) == 0 { + return buf + } + + type region struct { + off, end int + } + + elems := make([]region, 0, 16) + if IsAbs(path) { + elems = append(elems, region{}) + } + + i := 0 + for { + for i < len(path) && path[i] == '/' { + i++ + } + if i == len(path) { + break + } + j := i + for j < len(path) && path[j] != '/' { + j++ + } + if path[i:j] != "." { + elems = append(elems, region{off: i, end: j}) + } + i = j + } + + if len(elems) == 0 { + return append(buf, '.') + } + if len(elems) == 1 && elems[0] == (region{}) { + return append(buf, '/') + } + for i, elem := range elems { + if i != 0 { + buf = append(buf, '/') + } + buf = append(buf, path[elem.off:elem.end]...) + } + if HasTrailingSlash(path) { + buf = append(buf, '/') + } + return buf +} + +// IndexSlash is like strings.IndexByte(path, '/') but the function is simple +// enough to be inlined, which is a measurable improvement since it gets called +// very often by the other routines in this file. +func IndexSlash(path string) int { + for i := 0; i < len(path); i++ { + if path[i] == '/' { + return i + } + } + return -1 +} + +// Walk separates the next path element from the rest of the path. +func Walk(path string) (elem, name string) { + i := IndexSlash(path) + if i < 0 { + return path, "" + } + if i == 0 { + i = 1 + } + return path[:i], TrimLeadingSlash(path[i:]) +} + +func HasTrailingSlash(s string) bool { + return len(s) > 0 && s[len(s)-1] == '/' +} + +func TrimLeadingSlash(s string) string { + i := 0 + for i < len(s) && s[i] == '/' { + i++ + } + return s[i:] +} + +func TrimTrailingSlash(s string) string { + i := len(s) + for i > 0 && s[i-1] == '/' { + i-- + } + return s[:i] +} + +func IsAbs(path string) bool { + return len(path) > 0 && path[0] == '/' +} diff --git a/internal/sandbox/fspath/fspath_test.go b/internal/sandbox/fspath/fspath_test.go new file mode 100644 index 00000000..dcd22a9a --- /dev/null +++ b/internal/sandbox/fspath/fspath_test.go @@ -0,0 +1,88 @@ +package fspath_test + +import ( + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" + "github.com/stealthrocket/timecraft/internal/sandbox/fspath" +) + +func TestDepth(t *testing.T) { + tests := []struct { + path string + depth int + }{ + {"", 0}, + {".", 0}, + {"/", 0}, + {"..", 0}, + {"/..", 0}, + {"a/b/c", 3}, + {"//hello//world/", 2}, + {"/../path/././to///file/..", 2}, + } + + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + assert.Equal(t, fspath.Depth(test.path), test.depth) + }) + } +} + +func TestJoin(t *testing.T) { + tests := []struct { + dir string + name string + path string + }{ + {"", "", ""}, + {".", ".", "./."}, + {".", "hello", "./hello"}, + {"hello", ".", "hello/."}, + {"/", "/", "/"}, + {"..//", ".", "../."}, + {"hello/world", "!", "hello/world/!"}, + {"/hello", "/world", "/hello/world"}, + {"/hello", "/world/", "/hello/world/"}, + {"//hello", "//world", "/hello/world"}, + {"//hello/", "//world//", "/hello/world/"}, + {"hello/../", "../world/./", "hello/../../world/"}, + {"hello", "/.", "hello/."}, + } + + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + path := fspath.Join(test.dir, test.name) + assert.Equal(t, path, test.path) + }) + } +} + +func TestClean(t *testing.T) { + tests := []struct { + input string + output string + }{ + {"", ""}, + {".", "."}, + {"..", ".."}, + {"./", "."}, + {"/././././", "/"}, + {"hello/world", "hello/world"}, + {"/hello/world", "/hello/world"}, + {"/tmp/.././//test/", "/tmp/../test/"}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + path := fspath.Clean(test.input) + assert.Equal(t, path, test.output) + }) + } +} + +func BenchmarkClean(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = fspath.Clean("/tmp/.././//test/") + } +} diff --git a/internal/sandbox/path.go b/internal/sandbox/path.go deleted file mode 100644 index eba9f491..00000000 --- a/internal/sandbox/path.go +++ /dev/null @@ -1,146 +0,0 @@ -package sandbox - -// filePathDepth returns the depth of a path. The root "/" has depth zero. -func filePathDepth(path string) (depth int) { - for { - path = trimLeadingSlash(path) - if path == "" { - return depth - } - i := indexSlash(path) - if i < 0 { - i = len(path) - } - switch path[:i] { - case ".": - case "..": - if depth > 0 { - depth-- - } - default: - depth++ - } - path = path[i:] - } -} - -// joinPath is similar to path.Join but is simplified to assumed that the -// paths passed as arguments hare already clean. -func joinPath(dir, name string) string { - if dir == "" { - return name - } - name = trimLeadingSlash(name) - if name == "" { - return dir - } - return trimTrailingSlash(dir) + "/" + name -} - -// cleanPath is like path.Clean but it preserves parent directory references; -// this is necessary to ensure that symbolic links aren't erased from walking -// the path. -func cleanPath(path string) string { - buf := make([]byte, 0, 256) - buf = appendCleanPath(buf, path) - return string(buf) -} - -// appendCleanPath is like cleanPath but it appends the result to the byte -// slice passed as first argument. -func appendCleanPath(buf []byte, path string) []byte { - if len(path) == 0 { - return buf - } - - type region struct { - off, end int - } - - elems := make([]region, 0, 16) - if isAbs(path) { - elems = append(elems, region{}) - } - - i := 0 - for { - for i < len(path) && path[i] == '/' { - i++ - } - if i == len(path) { - break - } - j := i - for j < len(path) && path[j] != '/' { - j++ - } - if path[i:j] != "." { - elems = append(elems, region{off: i, end: j}) - } - i = j - } - - if len(elems) == 0 { - return append(buf, '.') - } - if len(elems) == 1 && elems[0] == (region{}) { - return append(buf, '/') - } - for i, elem := range elems { - if i != 0 { - buf = append(buf, '/') - } - buf = append(buf, path[elem.off:elem.end]...) - } - if hasTrailingSlash(path) { - buf = append(buf, '/') - } - return buf -} - -// indexSlash is like strings.IndexByte(path, '/') but the function is simple -// enough to be inlined, which is a measurable improvement since it gets called -// very often by the other routines in this file. -func indexSlash(path string) int { - for i := 0; i < len(path); i++ { - if path[i] == '/' { - return i - } - } - return -1 -} - -func walkPath(path string) (elem, name string) { - path = trimLeadingSlash(path) - path = trimTrailingSlash(path) - i := indexSlash(path) - if i < 0 { - return ".", path - } else { - return path[:i], trimLeadingSlash(path[i:]) - } -} - -func hasTrailingSlash(s string) bool { - return len(s) > 0 && s[len(s)-1] == '/' -} - -func trimLeadingSlash(s string) string { - i := 0 - for i < len(s) && s[i] == '/' { - i++ - } - return s[i:] -} - -func trimTrailingSlash(s string) string { - i := len(s) - for i > 0 && s[i-1] == '/' { - i-- - } - return s[:i] -} - -func isAbs(path string) bool { - return len(path) > 0 && path[0] == '/' -} diff --git a/internal/sandbox/path_test.go b/internal/sandbox/path_test.go deleted file mode 100644 index c1bfb2d8..00000000 --- a/internal/sandbox/path_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package sandbox - -import ( - "testing" - - "github.com/stealthrocket/timecraft/internal/assert" -) - -func TestFilePathDepth(t *testing.T) { - tests := []struct { - path string - depth int - }{ - {"", 0}, - {".", 0}, - {"/", 0}, - {"..", 0}, - {"/..", 0}, - {"a/b/c", 3}, - {"//hello//world/", 2}, - {"/../path/././to///file/..", 2}, - } - - for _, test := range tests { - t.Run(test.path, func(t *testing.T) { - assert.Equal(t, filePathDepth(test.path), test.depth) - }) - } -} - -func TestCleanPath(t *testing.T) { - tests := []struct { - input string - output string - }{ - {"", ""}, - {".", "."}, - {"..", ".."}, - {"./", "."}, - {"/././././", "/"}, - {"hello/world", "hello/world"}, - {"/hello/world", "/hello/world"}, - {"/tmp/.././//test/", "/tmp/../test/"}, - } - - for _, test := range tests { - t.Run(test.input, func(t *testing.T) { - path := cleanPath(test.input) - assert.Equal(t, path, test.output) - }) - } -} - -func BenchmarkCleanPath(b *testing.B) { - for i := 0; i < b.N; i++ { - _ = cleanPath("/tmp/.././//test/") - } -} diff --git a/internal/sandbox/rootfs.go b/internal/sandbox/rootfs.go index 2814b6c6..c58e23af 100644 --- a/internal/sandbox/rootfs.go +++ b/internal/sandbox/rootfs.go @@ -4,6 +4,8 @@ import ( "errors" "io/fs" "os" + + "github.com/stealthrocket/timecraft/internal/sandbox/fspath" ) // RootFS wraps the given FileSystem to prevent path resolution from escaping @@ -77,8 +79,8 @@ func (f *rootFile) Rmdir(name string) error { func (f *rootFile) Rename(oldName string, newDir File, newName string) error { f2, ok := newDir.(*rootFile) if !ok { - path1 := joinPath(f.Name(), oldName) - path2 := joinPath(newDir.Name(), newName) + path1 := fspath.Join(f.Name(), oldName) + path2 := fspath.Join(newDir.Name(), newName) return &os.LinkError{Op: "rename", Old: path1, New: path2, Err: EXDEV} } return withPath3("rename", f, oldName, f2, newName, AT_SYMLINK_NOFOLLOW, File.Rename) @@ -87,8 +89,8 @@ func (f *rootFile) Rename(oldName string, newDir File, newName string) error { func (f *rootFile) Link(oldName string, newDir File, newName string, flags int) error { f2, ok := newDir.(*rootFile) if !ok { - path1 := joinPath(f.Name(), oldName) - path2 := joinPath(newDir.Name(), newName) + path1 := fspath.Join(f.Name(), oldName) + path2 := fspath.Join(newDir.Name(), newName) return &os.LinkError{Op: "link", Old: path1, New: path2, Err: EXDEV} } return withPath3("link", f, oldName, f2, newName, flags, func(oldDir File, oldName string, newDir File, newName string) error { @@ -132,8 +134,8 @@ func withPath3(op string, f1 *rootFile, path1 string, f2 *rootFile, path2 string }) }) if err != nil { - path1 = joinPath(f1.Name(), path2) - path2 = joinPath(f2.Name(), path2) + path1 = fspath.Join(f1.Name(), path2) + path2 = fspath.Join(f2.Name(), path2) return &os.LinkError{Op: "link", Old: path1, New: path2, Err: unwrap(err)} } return nil @@ -151,7 +153,7 @@ func resolvePath[R any](dir File, name string, flags int, do func(File, string) if name == "" { return do(dir, "") } - if hasTrailingSlash(name) { + if fspath.HasTrailingSlash(name) { flags |= O_DIRECTORY } @@ -189,10 +191,10 @@ func resolvePath[R any](dir File, name string, flags int, do func(File, string) return nil } - depth := filePathDepth(dir.Name()) + depth := fspath.Depth(dir.Name()) for { - if isAbs(name) { - if name = trimLeadingSlash(name); name == "" { + if fspath.IsAbs(name) { + if name = fspath.TrimLeadingSlash(name); name == "" { name = "." } d, err := openRoot(dir) @@ -205,17 +207,16 @@ func resolvePath[R any](dir File, name string, flags int, do func(File, string) var delta int var elem string - elem, name = walkPath(name) + elem, name = fspath.Walk(name) - switch elem { - case ".": + if name == "" { doFile: - ret, err = do(dir, name) + ret, err = do(dir, elem) if err != nil { if !errors.Is(err, ELOOP) || ((flags & O_NOFOLLOW) != 0) { return ret, err } - switch err := followSymlink(name, ""); { + switch err := followSymlink(elem, ""); { case errors.Is(err, nil): continue case errors.Is(err, EINVAL): @@ -225,15 +226,20 @@ func resolvePath[R any](dir File, name string, flags int, do func(File, string) } } return ret, nil + } - case "..": + if elem == "." { + continue + } + + if elem == ".." { // This check ensures that we cannot escape the root of the file // system when accessing a parent directory. if depth == 0 { continue } delta = -1 - default: + } else { delta = +1 } @@ -258,7 +264,7 @@ func resolvePath[R any](dir File, name string, flags int, do func(File, string) } func openRoot(dir File) (File, error) { - depth := filePathDepth(dir.Name()) + depth := fspath.Depth(dir.Name()) if depth == 0 { return dir.Open(".", openPathFlags, 0) } diff --git a/internal/sandbox/tarfs/dir.go b/internal/sandbox/tarfs/dir.go index 0162d0d2..3f016784 100644 --- a/internal/sandbox/tarfs/dir.go +++ b/internal/sandbox/tarfs/dir.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "github.com/stealthrocket/timecraft/internal/sandbox" + "github.com/stealthrocket/timecraft/internal/sandbox/fspath" ) type dir struct { @@ -54,7 +55,7 @@ func resolve[R any](fsys *fileSystem, cwd *dir, name string, flags int, do func( } var elem string - elem, name = splitPath(name) + elem, name = fspath.Walk(name) if elem == "/" { cwd = &fsys.root @@ -132,6 +133,11 @@ func (d *openDir) Open(name string, flags int, mode fs.FileMode) (sandbox.File, if dir == nil { return nil, sandbox.EBADF } + + if fspath.HasTrailingSlash(name) { + flags |= sandbox.O_DIRECTORY + } + return resolve(d.fsys, dir, name, flags, func(f fileEntry) (sandbox.File, error) { if _, ok := f.(*symlink); ok { return nil, sandbox.ELOOP diff --git a/internal/sandbox/tarfs/tarfs.go b/internal/sandbox/tarfs/tarfs.go index 661401cd..10edec23 100644 --- a/internal/sandbox/tarfs/tarfs.go +++ b/internal/sandbox/tarfs/tarfs.go @@ -7,7 +7,6 @@ import ( "io/fs" "os" "path" - "strings" "time" "github.com/stealthrocket/timecraft/internal/sandbox" @@ -154,25 +153,6 @@ func absPath(p string) string { return path.Join("/", p) } -func splitPath(path string) (string, string) { - i := strings.IndexByte(path, '/') - if i < 0 { - return path, "" - } else if i == 0 { - return path[:1], trimLeadingSlash(path[1:]) - } else { - return path[:i], trimLeadingSlash(path[i+1:]) - } -} - -func trimLeadingSlash(path string) string { - i := 0 - for i < len(path) && path[i] == '/' { - i++ - } - return path[i:] -} - func makeFileInfo(header *tar.Header) sandbox.FileInfo { info := header.FileInfo() mode := info.Mode()