server(filesystem): rebuild everything imaginable

This wonderfully large commit replaces basically everything under the
`server/filesystem` package, re-implementing essentially everything.

This is related to
https://github.com/pterodactyl/wings/security/advisories/GHSA-494h-9924-xww9

If any vulnerabilities related to symlinks persist after this commit, I
will be very upset.

Signed-off-by: Matthew Penner <me@matthewp.io>
This commit is contained in:
Matthew Penner
2024-03-12 21:44:55 -06:00
parent 27f3e76c77
commit d1c0ca5260
51 changed files with 3694 additions and 1225 deletions

View File

@@ -3,7 +3,6 @@ package filesystem
import (
"archive/tar"
"context"
"fmt"
"io"
"io/fs"
"os"
@@ -14,12 +13,12 @@ import (
"emperror.dev/errors"
"github.com/apex/log"
"github.com/juju/ratelimit"
"github.com/karrick/godirwalk"
"github.com/klauspost/pgzip"
ignore "github.com/sabhiram/go-gitignore"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/internal/progress"
"github.com/pterodactyl/wings/internal/ufs"
)
const memory = 4 * 1024
@@ -57,14 +56,16 @@ func (p *TarProgress) Write(v []byte) (int, error) {
}
type Archive struct {
// BasePath is the absolute path to create the archive from where Files and Ignore are
// relative to.
BasePath string
// Filesystem to create the archive with.
Filesystem *Filesystem
// Ignore is a gitignore string (most likely read from a file) of files to ignore
// from the archive.
Ignore string
// BaseDirectory .
BaseDirectory string
// Files specifies the files to archive, this takes priority over the Ignore option, if
// unspecified, all files in the BasePath will be archived unless Ignore is set.
//
@@ -73,11 +74,18 @@ type Archive struct {
// Progress wraps the writer of the archive to pass through the progress tracker.
Progress *progress.Progress
w *TarProgress
}
// Create creates an archive at dst with all the files defined in the
// included Files array.
//
// THIS IS UNSAFE TO USE IF `dst` IS PROVIDED BY A USER! ONLY USE THIS WITH
// CONTROLLED PATHS!
func (a *Archive) Create(ctx context.Context, dst string) error {
// Using os.OpenFile here is expected, as long as `dst` is not a user
// provided path.
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
@@ -98,14 +106,19 @@ func (a *Archive) Create(ctx context.Context, dst string) error {
return a.Stream(ctx, writer)
}
// Stream .
type walkFunc func(dirfd int, name, relative string, d ufs.DirEntry) error
// Stream streams the creation of the archive to the given writer.
func (a *Archive) Stream(ctx context.Context, w io.Writer) error {
for _, f := range a.Files {
if strings.HasPrefix(f, a.BasePath) {
if a.Filesystem == nil {
return errors.New("filesystem: archive.Filesystem is unset")
}
for i, f := range a.Files {
if !strings.HasPrefix(f, a.Filesystem.Path()) {
continue
}
return fmt.Errorf("archive: all entries in Files must be absolute and within BasePath: %s\n", f)
a.Files[i] = strings.TrimPrefix(strings.TrimPrefix(f, a.Filesystem.Path()), "/")
}
// Choose which compression level to use based on the compression_level configuration option
@@ -130,107 +143,100 @@ func (a *Archive) Stream(ctx context.Context, w io.Writer) error {
tw := tar.NewWriter(gw)
defer tw.Close()
pw := NewTarProgress(tw, a.Progress)
a.w = NewTarProgress(tw, a.Progress)
// Configure godirwalk.
options := &godirwalk.Options{
FollowSymbolicLinks: false,
Unsorted: true,
}
fs := a.Filesystem.unixFS
// If we're specifically looking for only certain files, or have requested
// that certain files be ignored we'll update the callback function to reflect
// that request.
var callback godirwalk.WalkFunc
var callback walkFunc
if len(a.Files) == 0 && len(a.Ignore) > 0 {
i := ignore.CompileIgnoreLines(strings.Split(a.Ignore, "\n")...)
callback = a.callback(pw, func(_ string, rp string) error {
if i.MatchesPath(rp) {
return godirwalk.SkipThis
callback = a.callback(func(_ int, _, relative string, _ ufs.DirEntry) error {
if i.MatchesPath(relative) {
return ufs.SkipDir
}
return nil
})
} else if len(a.Files) > 0 {
callback = a.withFilesCallback(pw)
callback = a.withFilesCallback()
} else {
callback = a.callback(pw)
callback = a.callback()
}
// Set the callback function, wrapped with support for context cancellation.
options.Callback = func(path string, de *godirwalk.Dirent) error {
dirfd, name, closeFd, err := fs.SafePath(a.BaseDirectory)
defer closeFd()
if err != nil {
return err
}
return fs.WalkDirat(dirfd, name, func(dirfd int, name, relative string, d ufs.DirEntry, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return callback(path, de)
return callback(dirfd, name, relative, d)
}
}
// Recursively walk the path we are archiving.
return godirwalk.Walk(a.BasePath, options)
})
}
// Callback function used to determine if a given file should be included in the archive
// being generated.
func (a *Archive) callback(tw *TarProgress, opts ...func(path string, relative string) error) func(path string, de *godirwalk.Dirent) error {
return func(path string, de *godirwalk.Dirent) error {
func (a *Archive) callback(opts ...walkFunc) walkFunc {
return func(dirfd int, name, relative string, d ufs.DirEntry) error {
// Skip directories because we are walking them recursively.
if de.IsDir() {
if d.IsDir() {
return nil
}
relative := filepath.ToSlash(strings.TrimPrefix(path, a.BasePath+string(filepath.Separator)))
// Call the additional options passed to this callback function. If any of them return
// a non-nil error we will exit immediately.
for _, opt := range opts {
if err := opt(path, relative); err != nil {
if err := opt(dirfd, name, relative, d); err != nil {
return err
}
}
// Add the file to the archive, if it is nested in a directory,
// the directory will be automatically "created" in the archive.
return a.addToArchive(path, relative, tw)
return a.addToArchive(dirfd, name, relative, d)
}
}
// Pushes only files defined in the Files key to the final archive.
func (a *Archive) withFilesCallback(tw *TarProgress) func(path string, de *godirwalk.Dirent) error {
return a.callback(tw, func(p string, rp string) error {
func (a *Archive) withFilesCallback() walkFunc {
return a.callback(func(_ int, _, relative string, _ ufs.DirEntry) error {
for _, f := range a.Files {
// Allow exact file matches, otherwise check if file is within a parent directory.
//
// The slashes are added in the prefix checks to prevent partial name matches from being
// included in the archive.
if f != p && !strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(f, "/")+"/") {
if f != relative && !strings.HasPrefix(strings.TrimSuffix(relative, "/")+"/", strings.TrimSuffix(f, "/")+"/") {
continue
}
// Once we have a match return a nil value here so that the loop stops and the
// call to this function will correctly include the file in the archive. If there
// are no matches we'll never make it to this line, and the final error returned
// will be the godirwalk.SkipThis error.
// will be the ufs.SkipDir error.
return nil
}
return godirwalk.SkipThis
return ufs.SkipDir
})
}
// Adds a given file path to the final archive being created.
func (a *Archive) addToArchive(p string, rp string, w *TarProgress) error {
// Lstat the file, this will give us the same information as Stat except that it will not
// follow a symlink to its target automatically. This is important to avoid including
// files that exist outside the server root unintentionally in the backup.
s, err := os.Lstat(p)
func (a *Archive) addToArchive(dirfd int, name, relative string, entry ufs.DirEntry) error {
s, err := entry.Info()
if err != nil {
if os.IsNotExist(err) {
if errors.Is(err, ufs.ErrNotExist) {
return nil
}
return errors.WrapIff(err, "failed executing os.Lstat on '%s'", rp)
return errors.WrapIff(err, "failed executing os.Lstat on '%s'", name)
}
// Skip socket files as they are unsupported by archive/tar.
@@ -250,7 +256,7 @@ func (a *Archive) addToArchive(p string, rp string, w *TarProgress) error {
if err != nil {
// Ignore the not exist errors specifically, since there is nothing important about that.
if !os.IsNotExist(err) {
log.WithField("path", rp).WithField("readlink_err", err.Error()).Warn("failed reading symlink for target path; skipping...")
log.WithField("name", name).WithField("readlink_err", err.Error()).Warn("failed reading symlink for target path; skipping...")
}
return nil
}
@@ -259,17 +265,17 @@ func (a *Archive) addToArchive(p string, rp string, w *TarProgress) error {
// Get the tar FileInfoHeader in order to add the file to the archive.
header, err := tar.FileInfoHeader(s, filepath.ToSlash(target))
if err != nil {
return errors.WrapIff(err, "failed to get tar#FileInfoHeader for '%s'", rp)
return errors.WrapIff(err, "failed to get tar#FileInfoHeader for '%s'", name)
}
// Fix the header name if the file is not a symlink.
if s.Mode()&fs.ModeSymlink == 0 {
header.Name = rp
header.Name = relative
}
// Write the tar FileInfoHeader to the archive.
if err := w.WriteHeader(header); err != nil {
return errors.WrapIff(err, "failed to write tar#FileInfoHeader for '%s'", rp)
if err := a.w.WriteHeader(header); err != nil {
return errors.WrapIff(err, "failed to write tar#FileInfoHeader for '%s'", name)
}
// If the size of the file is less than 1 (most likely for symlinks), skip writing the file.
@@ -291,7 +297,7 @@ func (a *Archive) addToArchive(p string, rp string, w *TarProgress) error {
}
// Open the file.
f, err := os.Open(p)
f, err := a.Filesystem.unixFS.OpenFileat(dirfd, name, ufs.O_RDONLY, 0)
if err != nil {
if os.IsNotExist(err) {
return nil
@@ -301,9 +307,8 @@ func (a *Archive) addToArchive(p string, rp string, w *TarProgress) error {
defer f.Close()
// Copy the file's contents to the archive using our buffer.
if _, err := io.CopyBuffer(w, io.LimitReader(f, header.Size), buf); err != nil {
if _, err := io.CopyBuffer(a.w, io.LimitReader(f, header.Size), buf); err != nil {
return errors.WrapIff(err, "failed to copy '%s' to archive", header.Name)
}
return nil
}

View File

@@ -20,43 +20,34 @@ func TestArchive_Stream(t *testing.T) {
g.Describe("Archive", func() {
g.AfterEach(func() {
// Reset the filesystem after each run.
rfs.reset()
})
g.It("throws an error when passed invalid file paths", func() {
a := &Archive{
BasePath: fs.Path(),
Files: []string{
// To use the archiver properly, this needs to be filepath.Join(BasePath, "yeet")
// However, this test tests that we actually validate that behavior.
"yeet",
},
}
g.Assert(a.Create(context.Background(), "")).IsNotNil()
_ = fs.TruncateRootDirectory()
})
g.It("creates archive with intended files", func() {
g.Assert(fs.CreateDirectory("test", "/")).IsNil()
g.Assert(fs.CreateDirectory("test2", "/")).IsNil()
err := fs.Writefile("test/file.txt", strings.NewReader("hello, world!\n"))
r := strings.NewReader("hello, world!\n")
err := fs.Write("test/file.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
err = fs.Writefile("test2/file.txt", strings.NewReader("hello, world!\n"))
r = strings.NewReader("hello, world!\n")
err = fs.Write("test2/file.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
err = fs.Writefile("test_file.txt", strings.NewReader("hello, world!\n"))
r = strings.NewReader("hello, world!\n")
err = fs.Write("test_file.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
err = fs.Writefile("test_file.txt.old", strings.NewReader("hello, world!\n"))
r = strings.NewReader("hello, world!\n")
err = fs.Write("test_file.txt.old", r, r.Size(), 0o644)
g.Assert(err).IsNil()
a := &Archive{
BasePath: fs.Path(),
Filesystem: fs,
Files: []string{
filepath.Join(fs.Path(), "test"),
filepath.Join(fs.Path(), "test_file.txt"),
"test",
"test_file.txt",
},
}
@@ -119,7 +110,7 @@ func getFiles(f iofs.ReadDirFS, name string) ([]string, error) {
if files == nil {
return nil, nil
}
v = append(v, files...)
continue
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"io"
iofs "io/fs"
"os"
"path"
"path/filepath"
"strings"
@@ -13,7 +12,10 @@ import (
"time"
"emperror.dev/errors"
"github.com/klauspost/compress/zip"
"github.com/mholt/archiver/v4"
"github.com/pterodactyl/wings/internal/ufs"
)
// CompressFiles compresses all the files matching the given paths in the
@@ -25,46 +27,69 @@ import (
// All paths are relative to the dir that is passed in as the first argument,
// and the compressed file will be placed at that location named
// `archive-{date}.tar.gz`.
func (fs *Filesystem) CompressFiles(dir string, paths []string) (os.FileInfo, error) {
cleanedRootDir, err := fs.SafePath(dir)
if err != nil {
return nil, err
}
// Take all the paths passed in and merge them together with the root directory we've gotten.
for i, p := range paths {
paths[i] = filepath.Join(cleanedRootDir, p)
}
cleaned, err := fs.ParallelSafePath(paths)
if err != nil {
return nil, err
}
a := &Archive{BasePath: cleanedRootDir, Files: cleaned}
func (fs *Filesystem) CompressFiles(dir string, paths []string) (ufs.FileInfo, error) {
a := &Archive{Filesystem: fs, BaseDirectory: dir, Files: paths}
d := path.Join(
cleanedRootDir,
dir,
fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")),
)
if err := a.Create(context.Background(), d); err != nil {
return nil, err
}
f, err := os.Stat(d)
f, err := fs.unixFS.OpenFile(d, ufs.O_WRONLY|ufs.O_CREATE, 0o644)
if err != nil {
_ = os.Remove(d)
return nil, err
}
defer f.Close()
cw := ufs.NewCountedWriter(f)
if err := a.Stream(context.Background(), cw); err != nil {
return nil, err
}
if !fs.unixFS.CanFit(cw.BytesWritten()) {
_ = fs.unixFS.Remove(d)
return nil, newFilesystemError(ErrCodeDiskSpace, nil)
}
fs.unixFS.Add(cw.BytesWritten())
return f.Stat()
}
func (fs *Filesystem) archiverFileSystem(ctx context.Context, p string) (iofs.FS, error) {
f, err := fs.unixFS.Open(p)
if err != nil {
return nil, err
}
// Do not use defer to close `f`, it will likely be used later.
format, _, err := archiver.Identify(filepath.Base(p), f)
if err != nil && !errors.Is(err, archiver.ErrNoMatch) {
_ = f.Close()
return nil, err
}
if err := fs.HasSpaceFor(f.Size()); err != nil {
_ = os.Remove(d)
// Reset the file reader.
if _, err := f.Seek(0, io.SeekStart); err != nil {
_ = f.Close()
return nil, err
}
fs.addDisk(f.Size())
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
return f, nil
if format != nil {
switch ff := format.(type) {
case archiver.Zip:
// zip.Reader is more performant than ArchiveFS, because zip.Reader caches content information
// and zip.Reader can open several content files concurrently because of io.ReaderAt requirement
// while ArchiveFS can't.
// zip.Reader doesn't suffer from issue #330 and #310 according to local test (but they should be fixed anyway)
return zip.NewReader(f, info.Size())
case archiver.Archival:
return archiver.ArchiveFS{Stream: io.NewSectionReader(f, 0, info.Size()), Format: ff, Context: ctx}, nil
}
}
_ = f.Close()
return nil, nil
}
// SpaceAvailableForDecompression looks through a given archive and determines
@@ -76,16 +101,7 @@ func (fs *Filesystem) SpaceAvailableForDecompression(ctx context.Context, dir st
return nil
}
source, err := fs.SafePath(filepath.Join(dir, file))
if err != nil {
return err
}
// Get the cached size in a parallel process so that if it is not cached we are not
// waiting an unnecessary amount of time on this call.
dirSize, err := fs.DiskUsage(false)
fsys, err := archiver.FileSystem(ctx, source)
fsys, err := fs.archiverFileSystem(ctx, filepath.Join(dir, file))
if err != nil {
if errors.Is(err, archiver.ErrNoMatch) {
return newFilesystemError(ErrCodeUnknownArchive, err)
@@ -93,7 +109,7 @@ func (fs *Filesystem) SpaceAvailableForDecompression(ctx context.Context, dir st
return err
}
var size int64
var size atomic.Int64
return iofs.WalkDir(fsys, ".", func(path string, d iofs.DirEntry, err error) error {
if err != nil {
return err
@@ -108,7 +124,7 @@ func (fs *Filesystem) SpaceAvailableForDecompression(ctx context.Context, dir st
if err != nil {
return err
}
if atomic.AddInt64(&size, info.Size())+dirSize > fs.MaxDisk() {
if !fs.unixFS.CanFit(size.Add(info.Size())) {
return newFilesystemError(ErrCodeDiskSpace, nil)
}
return nil
@@ -122,23 +138,7 @@ func (fs *Filesystem) SpaceAvailableForDecompression(ctx context.Context, dir st
// zip-slip attack being attempted by validating that the final path is within
// the server data directory.
func (fs *Filesystem) DecompressFile(ctx context.Context, dir string, file string) error {
source, err := fs.SafePath(filepath.Join(dir, file))
if err != nil {
return err
}
return fs.DecompressFileUnsafe(ctx, dir, source)
}
// DecompressFileUnsafe will decompress any file on the local disk without checking
// if it is owned by the server. The file will be SAFELY decompressed and extracted
// into the server's directory.
func (fs *Filesystem) DecompressFileUnsafe(ctx context.Context, dir string, file string) error {
// Ensure that the archive actually exists on the system.
if _, err := os.Stat(file); err != nil {
return errors.WithStack(err)
}
f, err := os.Open(file)
f, err := fs.unixFS.Open(filepath.Join(dir, file))
if err != nil {
return err
}
@@ -169,7 +169,6 @@ func (fs *Filesystem) ExtractStreamUnsafe(ctx context.Context, dir string, r io.
}
return err
}
return fs.extractStream(ctx, extractStreamOptions{
Directory: dir,
Format: format,
@@ -190,34 +189,31 @@ type extractStreamOptions struct {
func (fs *Filesystem) extractStream(ctx context.Context, opts extractStreamOptions) error {
// Decompress and extract archive
if ex, ok := opts.Format.(archiver.Extractor); ok {
return ex.Extract(ctx, opts.Reader, nil, func(ctx context.Context, f archiver.File) error {
if f.IsDir() {
return nil
}
p := filepath.Join(opts.Directory, f.NameInArchive)
// If it is ignored, just don't do anything with the file and skip over it.
if err := fs.IsIgnored(p); err != nil {
return nil
}
r, err := f.Open()
if err != nil {
return err
}
defer r.Close()
if err := fs.Writefile(p, r); err != nil {
return wrapError(err, opts.FileName)
}
// Update the file permissions to the one set in the archive.
if err := fs.Chmod(p, f.Mode()); err != nil {
return wrapError(err, opts.FileName)
}
// Update the file modification time to the one set in the archive.
if err := fs.Chtimes(p, f.ModTime(), f.ModTime()); err != nil {
return wrapError(err, opts.FileName)
}
return nil
})
ex, ok := opts.Format.(archiver.Extractor)
if !ok {
return nil
}
return nil
return ex.Extract(ctx, opts.Reader, nil, func(ctx context.Context, f archiver.File) error {
if f.IsDir() {
return nil
}
p := filepath.Join(opts.Directory, f.NameInArchive)
// If it is ignored, just don't do anything with the file and skip over it.
if err := fs.IsIgnored(p); err != nil {
return nil
}
r, err := f.Open()
if err != nil {
return err
}
defer r.Close()
if err := fs.Write(p, r, f.Size(), f.Mode()); err != nil {
return wrapError(err, opts.FileName)
}
// Update the file modification time to the one set in the archive.
if err := fs.Chtimes(p, f.ModTime(), f.ModTime()); err != nil {
return wrapError(err, opts.FileName)
}
return nil
})
}

View File

@@ -3,17 +3,18 @@ package filesystem
import (
"context"
"os"
"sync/atomic"
"testing"
. "github.com/franela/goblin"
)
// Given an archive named test.{ext}, with the following file structure:
//
// test/
// |──inside/
// |────finside.txt
// |──outside.txt
//
// this test will ensure that it's being decompressed as expected
func TestFilesystem_DecompressFile(t *testing.T) {
g := Goblin(t)
@@ -47,9 +48,7 @@ func TestFilesystem_DecompressFile(t *testing.T) {
}
g.AfterEach(func() {
rfs.reset()
atomic.StoreInt64(&fs.diskUsed, 0)
atomic.StoreInt64(&fs.diskLimit, 0)
_ = fs.TruncateRootDirectory()
})
})
}

View File

@@ -3,24 +3,25 @@ package filesystem
import (
"sync"
"sync/atomic"
"syscall"
"time"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/karrick/godirwalk"
"github.com/pterodactyl/wings/internal/ufs"
)
type SpaceCheckingOpts struct {
AllowStaleResponse bool
}
// TODO: can this be replaced with some sort of atomic? Like atomic.Pointer?
type usageLookupTime struct {
sync.RWMutex
value time.Time
}
// Update the last time that a disk space lookup was performed.
// Set sets the last time that a disk space lookup was performed.
func (ult *usageLookupTime) Set(t time.Time) {
ult.Lock()
ult.value = t
@@ -35,14 +36,15 @@ func (ult *usageLookupTime) Get() time.Time {
return ult.value
}
// Returns the maximum amount of disk space that this Filesystem instance is allowed to use.
// MaxDisk returns the maximum amount of disk space that this Filesystem
// instance is allowed to use.
func (fs *Filesystem) MaxDisk() int64 {
return atomic.LoadInt64(&fs.diskLimit)
return fs.unixFS.Limit()
}
// Sets the disk space limit for this Filesystem instance.
// SetDiskLimit sets the disk space limit for this Filesystem instance.
func (fs *Filesystem) SetDiskLimit(i int64) {
atomic.SwapInt64(&fs.diskLimit, i)
fs.unixFS.SetLimit(i)
}
// The same concept as HasSpaceAvailable however this will return an error if there is
@@ -65,7 +67,7 @@ func (fs *Filesystem) HasSpaceErr(allowStaleValue bool) error {
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
size, err := fs.DiskUsage(allowStaleValue)
if err != nil {
log.WithField("root", fs.root).WithField("error", err).Warn("failed to determine root fs directory size")
log.WithField("root", fs.Path()).WithField("error", err).Warn("failed to determine root fs directory size")
}
// If space is -1 or 0 just return true, means they're allowed unlimited.
@@ -84,7 +86,7 @@ func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
// function for critical logical checks. It should only be used in areas where the actual disk usage
// does not need to be perfect, e.g. API responses for server resource usage.
func (fs *Filesystem) CachedUsage() int64 {
return atomic.LoadInt64(&fs.diskUsed)
return fs.unixFS.Usage()
}
// Internal helper function to allow other parts of the codebase to check the total used disk space
@@ -114,14 +116,14 @@ func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
// currently performing a lookup, just do the disk usage calculation in the background.
go func(fs *Filesystem) {
if _, err := fs.updateCachedDiskUsage(); err != nil {
log.WithField("root", fs.root).WithField("error", err).Warn("failed to update fs disk usage from within routine")
log.WithField("root", fs.Path()).WithField("error", err).Warn("failed to update fs disk usage from within routine")
}
}(fs)
}
}
// Return the currently cached value back to the calling function.
return atomic.LoadInt64(&fs.diskUsed), nil
return fs.unixFS.Usage(), nil
}
// Updates the currently used disk space for a server.
@@ -149,63 +151,46 @@ func (fs *Filesystem) updateCachedDiskUsage() (int64, error) {
// error encountered.
fs.lastLookupTime.Set(time.Now())
atomic.StoreInt64(&fs.diskUsed, size)
fs.unixFS.SetUsage(size)
return size, err
}
// Determines the directory size of a given location by running parallel tasks to iterate
// through all of the folders. Returns the size in bytes. This can be a fairly taxing operation
// on locations with tons of files, so it is recommended that you cache the output.
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
d, err := fs.SafePath(dir)
// DirectorySize calculates the size of a directory and its descendants.
func (fs *Filesystem) DirectorySize(root string) (int64, error) {
dirfd, name, closeFd, err := fs.unixFS.SafePath(root)
defer closeFd()
if err != nil {
return 0, err
}
var size int64
var st syscall.Stat_t
err = godirwalk.Walk(d, &godirwalk.Options{
Unsorted: true,
Callback: func(p string, e *godirwalk.Dirent) error {
// If this is a symlink then resolve the final destination of it before trying to continue walking
// over its contents. If it resolves outside the server data directory just skip everything else for
// it. Otherwise, allow it to continue.
if e.IsSymlink() {
if _, err := fs.SafePath(p); err != nil {
if IsErrorCode(err, ErrCodePathResolution) {
return godirwalk.SkipThis
}
return err
}
}
if !e.IsDir() {
_ = syscall.Lstat(p, &st)
atomic.AddInt64(&size, st.Size)
}
var size atomic.Int64
err = fs.unixFS.WalkDirat(dirfd, name, func(dirfd int, name, _ string, d ufs.DirEntry, err error) error {
if err != nil {
return errors.Wrap(err, "walkdirat err")
}
// Only calculate the size of regular files.
if !d.Type().IsRegular() {
return nil
},
})
}
return size, errors.WrapIf(err, "server/filesystem: directorysize: failed to walk directory")
info, err := fs.unixFS.Lstatat(dirfd, name)
if err != nil {
return errors.Wrap(err, "lstatat err")
}
// TODO: detect if info is a hard-link and de-duplicate it.
// ref; https://github.com/pterodactyl/wings/pull/181/files
size.Add(info.Size())
return nil
})
return size.Load(), errors.WrapIf(err, "server/filesystem: directorysize: failed to walk directory")
}
// Helper function to determine if a server has space available for a file of a given size.
// If space is available, no error will be returned, otherwise an ErrNotEnoughSpace error
// will be raised.
func (fs *Filesystem) HasSpaceFor(size int64) error {
if fs.MaxDisk() == 0 {
return nil
}
s, err := fs.DiskUsage(true)
if err != nil {
return err
}
if (s + size) > fs.MaxDisk() {
if !fs.unixFS.CanFit(size) {
return newFilesystemError(ErrCodeDiskSpace, nil)
}
return nil
@@ -213,24 +198,5 @@ func (fs *Filesystem) HasSpaceFor(size int64) error {
// Updates the disk usage for the Filesystem instance.
func (fs *Filesystem) addDisk(i int64) int64 {
size := atomic.LoadInt64(&fs.diskUsed)
// Sorry go gods. This is ugly but the best approach I can come up with for right
// now without completely re-evaluating the logic we use for determining disk space.
//
// Normally I would just be using the atomic load right below, but I'm not sure about
// the scenarios where it is 0 because nothing has run that would trigger a disk size
// calculation?
//
// Perhaps that isn't even a concern for the sake of this?
if !fs.isTest {
size, _ = fs.DiskUsage(true)
}
// If we're dropping below 0 somehow just cap it to 0.
if (size + i) < 0 {
return atomic.SwapInt64(&fs.diskUsed, 0)
}
return atomic.AddInt64(&fs.diskUsed, i)
return fs.unixFS.Add(i)
}

View File

@@ -2,11 +2,12 @@ package filesystem
import (
"fmt"
"os"
"path/filepath"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/pterodactyl/wings/internal/ufs"
)
type ErrorCode string
@@ -86,15 +87,15 @@ func (e *Error) Unwrap() error {
// Generates an error logger instance with some basic information.
func (fs *Filesystem) error(err error) *log.Entry {
return log.WithField("subsystem", "filesystem").WithField("root", fs.root).WithField("error", err)
return log.WithField("subsystem", "filesystem").WithField("root", fs.Path()).WithField("error", err)
}
// Handle errors encountered when walking through directories.
//
// If there is a path resolution error just skip the item entirely. Only return this for a
// directory, otherwise return nil. Returning this error for a file will stop the walking
// for the remainder of the directory. This is assuming an os.FileInfo struct was even returned.
func (fs *Filesystem) handleWalkerError(err error, f os.FileInfo) error {
// for the remainder of the directory. This is assuming an FileInfo struct was even returned.
func (fs *Filesystem) handleWalkerError(err error, f ufs.FileInfo) error {
if !IsErrorCode(err, ErrCodePathResolution) {
return err
}

View File

@@ -1,13 +1,11 @@
package filesystem
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"path/filepath"
"sort"
"slices"
"strconv"
"strings"
"sync"
@@ -15,220 +13,205 @@ import (
"time"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/gabriel-vasile/mimetype"
"github.com/karrick/godirwalk"
ignore "github.com/sabhiram/go-gitignore"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/system"
"github.com/pterodactyl/wings/internal/ufs"
)
// TODO: detect and enable
var useOpenat2 = true
type Filesystem struct {
unixFS *ufs.Quota
mu sync.RWMutex
lastLookupTime *usageLookupTime
lookupInProgress *system.AtomicBool
diskUsed int64
lookupInProgress atomic.Bool
diskCheckInterval time.Duration
denylist *ignore.GitIgnore
// The maximum amount of disk space (in bytes) that this Filesystem instance can use.
diskLimit int64
// The root data directory path for this Filesystem instance.
root string
isTest bool
}
// New creates a new Filesystem instance for a given server.
func New(root string, size int64, denylist []string) *Filesystem {
func New(root string, size int64, denylist []string) (*Filesystem, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
unixFS, err := ufs.NewUnixFS(root, config.UseOpenat2())
if err != nil {
return nil, err
}
quota := ufs.NewQuota(unixFS, size)
return &Filesystem{
root: root,
diskLimit: size,
unixFS: quota,
diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval),
lastLookupTime: &usageLookupTime{},
lookupInProgress: system.NewAtomicBool(false),
denylist: ignore.CompileIgnoreLines(denylist...),
}
}, nil
}
// Path returns the root path for the Filesystem instance.
func (fs *Filesystem) Path() string {
return fs.root
return fs.unixFS.BasePath()
}
// ReadDir reads directory entries.
func (fs *Filesystem) ReadDir(path string) ([]ufs.DirEntry, error) {
return fs.unixFS.ReadDir(path)
}
// ReadDirStat is like ReadDir except that it returns FileInfo for each entry
// instead of just a DirEntry.
func (fs *Filesystem) ReadDirStat(path string) ([]ufs.FileInfo, error) {
return ufs.ReadDirMap(fs.unixFS.UnixFS, path, func(e ufs.DirEntry) (ufs.FileInfo, error) {
return e.Info()
})
}
// File returns a reader for a file instance as well as the stat information.
func (fs *Filesystem) File(p string) (*os.File, Stat, error) {
cleaned, err := fs.SafePath(p)
func (fs *Filesystem) File(p string) (ufs.File, Stat, error) {
f, err := fs.unixFS.Open(p)
if err != nil {
return nil, Stat{}, errors.WithStackIf(err)
return nil, Stat{}, err
}
st, err := fs.Stat(cleaned)
st, err := statFromFile(f)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, Stat{}, newFilesystemError(ErrNotExist, err)
}
return nil, Stat{}, errors.WithStackIf(err)
}
if st.IsDir() {
return nil, Stat{}, newFilesystemError(ErrCodeIsDirectory, nil)
}
f, err := os.Open(cleaned)
if err != nil {
return nil, Stat{}, errors.WithStackIf(err)
_ = f.Close()
return nil, Stat{}, err
}
return f, st, nil
}
func (fs *Filesystem) UnixFS() *ufs.UnixFS {
return fs.unixFS.UnixFS
}
// Touch acts by creating the given file and path on the disk if it is not present
// already. If it is present, the file is opened using the defaults which will truncate
// the contents. The opened file is then returned to the caller.
func (fs *Filesystem) Touch(p string, flag int) (*os.File, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, err
}
f, err := os.OpenFile(cleaned, flag, 0o644)
if err == nil {
return f, nil
}
if f != nil {
_ = f.Close()
}
// If the error is not because it doesn't exist then we just need to bail at this point.
if !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(err, "server/filesystem: touch: failed to open file handle")
}
// Only create and chown the directory if it doesn't exist.
if _, err := os.Stat(filepath.Dir(cleaned)); errors.Is(err, os.ErrNotExist) {
// Create the path leading up to the file we're trying to create, setting the final perms
// on it as we go.
if err := os.MkdirAll(filepath.Dir(cleaned), 0o755); err != nil {
return nil, errors.Wrap(err, "server/filesystem: touch: failed to create directory tree")
}
if err := fs.Chown(filepath.Dir(cleaned)); err != nil {
return nil, err
}
}
o := &fileOpener{}
// Try to open the file now that we have created the pathing necessary for it, and then
// Chown that file so that the permissions don't mess with things.
f, err = o.open(cleaned, flag, 0o644)
if err != nil {
return nil, errors.Wrap(err, "server/filesystem: touch: failed to open file with wait")
}
_ = fs.Chown(cleaned)
return f, nil
func (fs *Filesystem) Touch(p string, flag int) (ufs.File, error) {
return fs.unixFS.Touch(p, flag, 0o644)
}
// Writefile writes a file to the system. If the file does not already exist one
// will be created. This will also properly recalculate the disk space used by
// the server when writing new files or modifying existing ones.
//
// DEPRECATED: use `Write` instead.
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
cleaned, err := fs.SafePath(p)
if err != nil {
return err
}
var currentSize int64
// If the file does not exist on the system already go ahead and create the pathway
// to it and an empty file. We'll then write to it later on after this completes.
stat, err := os.Stat(cleaned)
if err != nil && !os.IsNotExist(err) {
st, err := fs.unixFS.Stat(p)
if err != nil && !errors.Is(err, ufs.ErrNotExist) {
return errors.Wrap(err, "server/filesystem: writefile: failed to stat file")
} else if err == nil {
if stat.IsDir() {
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: cleaned})
if st.IsDir() {
// TODO: resolved
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: ""})
}
currentSize = stat.Size()
currentSize = st.Size()
}
// Touch the file and return the handle to it at this point. This will
// create or truncate the file, and create any necessary parent directories
// if they are missing.
file, err := fs.unixFS.Touch(p, ufs.O_RDWR|ufs.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("error touching file: %w", err)
}
defer file.Close()
// Do not use CopyBuffer here, it is wasteful as the file implements
// io.ReaderFrom, which causes it to not use the buffer anyways.
n, err := io.Copy(file, r)
// Adjust the disk usage to account for the old size and the new size of the file.
fs.unixFS.Add(n - currentSize)
if err := fs.chownFile(p); err != nil {
return fmt.Errorf("error chowning file: %w", err)
}
// Return the error from io.Copy.
return err
}
func (fs *Filesystem) Write(p string, r io.Reader, newSize int64, mode ufs.FileMode) error {
var currentSize int64
st, err := fs.unixFS.Stat(p)
if err != nil && !errors.Is(err, ufs.ErrNotExist) {
return errors.Wrap(err, "server/filesystem: writefile: failed to stat file")
} else if err == nil {
if st.IsDir() {
// TODO: resolved
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: ""})
}
currentSize = st.Size()
}
br := bufio.NewReader(r)
// Check that the new size we're writing to the disk can fit. If there is currently
// a file we'll subtract that current file size from the size of the buffer to determine
// the amount of new data we're writing (or amount we're removing if smaller).
if err := fs.HasSpaceFor(int64(br.Size()) - currentSize); err != nil {
if err := fs.HasSpaceFor(newSize - currentSize); err != nil {
return err
}
// Touch the file and return the handle to it at this point. This will create the file,
// any necessary directories, and set the proper owner of the file.
file, err := fs.Touch(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
// Touch the file and return the handle to it at this point. This will
// create or truncate the file, and create any necessary parent directories
// if they are missing.
file, err := fs.unixFS.Touch(p, ufs.O_RDWR|ufs.O_TRUNC, mode)
if err != nil {
return err
}
defer file.Close()
buf := make([]byte, 1024*4)
sz, err := io.CopyBuffer(file, r, buf)
// Do not use CopyBuffer here, it is wasteful as the file implements
// io.ReaderFrom, which causes it to not use the buffer anyways.
n, err := io.Copy(file, io.LimitReader(r, newSize))
// Adjust the disk usage to account for the old size and the new size of the file.
fs.addDisk(sz - currentSize)
fs.unixFS.Add(n - currentSize)
return fs.unsafeChown(cleaned)
}
// Creates a new directory (name) at a specified path (p) for the server.
func (fs *Filesystem) CreateDirectory(name string, p string) error {
cleaned, err := fs.SafePath(path.Join(p, name))
if err != nil {
if err := fs.chownFile(p); err != nil {
return err
}
return os.MkdirAll(cleaned, 0o755)
// Return the error from io.Copy.
return err
}
// Rename moves (or renames) a file or directory.
func (fs *Filesystem) Rename(from string, to string) error {
cleanedFrom, err := fs.SafePath(from)
if err != nil {
return errors.WithStack(err)
}
cleanedTo, err := fs.SafePath(to)
if err != nil {
return errors.WithStack(err)
}
// If the target file or directory already exists the rename function will fail, so just
// bail out now.
if _, err := os.Stat(cleanedTo); err == nil {
return os.ErrExist
}
if cleanedTo == fs.Path() {
return errors.New("attempting to rename into an invalid directory space")
}
d := strings.TrimSuffix(cleanedTo, path.Base(cleanedTo))
// Ensure that the directory we're moving into exists correctly on the system. Only do this if
// we're not at the root directory level.
if d != fs.Path() {
if mkerr := os.MkdirAll(d, 0o755); mkerr != nil {
return errors.WithMessage(mkerr, "failed to create directory structure for file rename")
}
}
if err := os.Rename(cleanedFrom, cleanedTo); err != nil {
return errors.WithStack(err)
}
return nil
// CreateDirectory creates a new directory (name) at a specified path (p) for
// the server.
func (fs *Filesystem) CreateDirectory(name string, p string) error {
return fs.unixFS.MkdirAll(filepath.Join(p, name), 0o755)
}
// Recursively iterates over a file or directory and sets the permissions on all of the
func (fs *Filesystem) Rename(oldpath, newpath string) error {
return fs.unixFS.Rename(oldpath, newpath)
}
func (fs *Filesystem) Symlink(oldpath, newpath string) error {
return fs.unixFS.Symlink(oldpath, newpath)
}
func (fs *Filesystem) chownFile(name string) error {
if fs.isTest {
return nil
}
uid := config.Get().System.User.Uid
gid := config.Get().System.User.Gid
return fs.unixFS.Lchown(name, uid, gid)
}
// Chown recursively iterates over a file or directory and sets the permissions on all of the
// underlying files. Iterate over all of the files and directories. If it is a file just
// go ahead and perform the chown operation. Otherwise dig deeper into the directory until
// we've run out of directories to dig into.
func (fs *Filesystem) Chown(path string) error {
cleaned, err := fs.SafePath(path)
if err != nil {
return err
}
return fs.unsafeChown(cleaned)
}
// unsafeChown chowns the given path, without checking if the path is safe. This should only be used
// when the path has already been checked.
func (fs *Filesystem) unsafeChown(path string) error {
func (fs *Filesystem) Chown(p string) error {
if fs.isTest {
return nil
}
@@ -236,54 +219,44 @@ func (fs *Filesystem) unsafeChown(path string) error {
uid := config.Get().System.User.Uid
gid := config.Get().System.User.Gid
dirfd, name, closeFd, err := fs.unixFS.SafePath(p)
defer closeFd()
if err != nil {
return err
}
// Start by just chowning the initial path that we received.
if err := os.Chown(path, uid, gid); err != nil {
if err := fs.unixFS.Lchownat(dirfd, name, uid, gid); err != nil {
return errors.Wrap(err, "server/filesystem: chown: failed to chown path")
}
// If this is not a directory we can now return from the function, there is nothing
// left that we need to do.
if st, err := os.Stat(path); err != nil || !st.IsDir() {
if st, err := fs.unixFS.Lstatat(dirfd, name); err != nil || !st.IsDir() {
return nil
}
// If this was a directory, begin walking over its contents recursively and ensure that all
// of the subfiles and directories get their permissions updated as well.
err := godirwalk.Walk(path, &godirwalk.Options{
Unsorted: true,
Callback: func(p string, e *godirwalk.Dirent) error {
// Do not attempt to chown a symlink. Go's os.Chown function will affect the symlink
// so if it points to a location outside the data directory the user would be able to
// (un)intentionally modify that files permissions.
if e.IsSymlink() {
if e.IsDir() {
return godirwalk.SkipThis
}
return nil
}
return os.Chown(p, uid, gid)
},
})
return errors.Wrap(err, "server/filesystem: chown: failed to chown during walk function")
// This walker is probably some of the most efficient code in Wings. It has
// an internally re-used buffer for listing directory entries and doesn't
// need to check if every individual path it touches is safe as the code
// doesn't traverse symlinks, is immune to symlink timing attacks, and
// gives us a dirfd and file name to make a direct syscall with.
if err := fs.unixFS.WalkDirat(dirfd, name, func(dirfd int, name, _ string, info ufs.DirEntry, err error) error {
if err != nil {
return err
}
if err := fs.unixFS.Lchownat(dirfd, name, uid, gid); err != nil {
return err
}
return nil
}); err != nil {
return fmt.Errorf("server/filesystem: chown: failed to chown during walk function: %w", err)
}
return nil
}
func (fs *Filesystem) Chmod(path string, mode os.FileMode) error {
cleaned, err := fs.SafePath(path)
if err != nil {
return err
}
if fs.isTest {
return nil
}
if err := os.Chmod(cleaned, mode); err != nil {
return err
}
return nil
func (fs *Filesystem) Chmod(path string, mode ufs.FileMode) error {
return fs.unixFS.Chmod(path, mode)
}
// Begin looping up to 50 times to try and create a unique copy file name. This will take
@@ -294,7 +267,7 @@ func (fs *Filesystem) Chmod(path string, mode os.FileMode) error {
// Could probably make this more efficient by checking if there are any files matching the copy
// pattern, and trying to find the highest number and then incrementing it by one rather than
// looping endlessly.
func (fs *Filesystem) findCopySuffix(dir string, name string, extension string) (string, error) {
func (fs *Filesystem) findCopySuffix(dirfd int, name, extension string) (string, error) {
var i int
suffix := " copy"
@@ -306,11 +279,10 @@ func (fs *Filesystem) findCopySuffix(dir string, name string, extension string)
n := name + suffix + extension
// If we stat the file and it does not exist that means we're good to create the copy. If it
// does exist, we'll just continue to the next loop and try again.
if _, err := fs.Stat(path.Join(dir, n)); err != nil {
if !errors.Is(err, os.ErrNotExist) {
if _, err := fs.unixFS.Lstatat(dirfd, n); err != nil {
if !errors.Is(err, ufs.ErrNotExist) {
return "", err
}
break
}
@@ -322,53 +294,68 @@ func (fs *Filesystem) findCopySuffix(dir string, name string, extension string)
return name + suffix + extension, nil
}
// Copies a given file to the same location and appends a suffix to the file to indicate that
// it has been copied.
// Copy copies a given file to the same location and appends a suffix to the
// file to indicate that it has been copied.
func (fs *Filesystem) Copy(p string) error {
cleaned, err := fs.SafePath(p)
dirfd, name, closeFd, err := fs.unixFS.SafePath(p)
defer closeFd()
if err != nil {
return err
}
s, err := os.Stat(cleaned)
if err != nil {
return err
} else if s.IsDir() || !s.Mode().IsRegular() {
// If this is a directory or not a regular file, just throw a not-exist error
// since anything calling this function should understand what that means.
return os.ErrNotExist
}
// Check that copying this file wouldn't put the server over its limit.
if err := fs.HasSpaceFor(s.Size()); err != nil {
return err
}
base := filepath.Base(cleaned)
relative := strings.TrimSuffix(strings.TrimPrefix(cleaned, fs.Path()), base)
extension := filepath.Ext(base)
name := strings.TrimSuffix(base, extension)
// Ensure that ".tar" is also counted as apart of the file extension.
// There might be a better way to handle this for other double file extensions,
// but this is a good workaround for now.
if strings.HasSuffix(name, ".tar") {
extension = ".tar" + extension
name = strings.TrimSuffix(name, ".tar")
}
source, err := os.Open(cleaned)
source, err := fs.unixFS.OpenFileat(dirfd, name, ufs.O_RDONLY, 0)
if err != nil {
return err
}
defer source.Close()
info, err := source.Stat()
if err != nil {
return err
}
if info.IsDir() || !info.Mode().IsRegular() {
// If this is a directory or not a regular file, just throw a not-exist error
// since anything calling this function should understand what that means.
return ufs.ErrNotExist
}
currentSize := info.Size()
n, err := fs.findCopySuffix(relative, name, extension)
// Check that copying this file wouldn't put the server over its limit.
if err := fs.HasSpaceFor(currentSize); err != nil {
return err
}
base := info.Name()
extension := filepath.Ext(base)
baseName := strings.TrimSuffix(base, extension)
// Ensure that ".tar" is also counted as apart of the file extension.
// There might be a better way to handle this for other double file extensions,
// but this is a good workaround for now.
if strings.HasSuffix(baseName, ".tar") {
extension = ".tar" + extension
baseName = strings.TrimSuffix(baseName, ".tar")
}
newName, err := fs.findCopySuffix(dirfd, baseName, extension)
if err != nil {
return err
}
dst, err := fs.unixFS.OpenFileat(dirfd, newName, ufs.O_WRONLY|ufs.O_CREATE, info.Mode())
if err != nil {
return err
}
return fs.Writefile(path.Join(relative, n), source)
// Do not use CopyBuffer here, it is wasteful as the file implements
// io.ReaderFrom, which causes it to not use the buffer anyways.
n, err := io.Copy(dst, io.LimitReader(source, currentSize))
fs.unixFS.Add(n)
if !fs.isTest {
if err := fs.unixFS.Lchownat(dirfd, newName, config.Get().System.User.Uid, config.Get().System.User.Gid); err != nil {
return err
}
}
// Return the error from io.Copy.
return err
}
// TruncateRootDirectory removes _all_ files and directories from a server's
@@ -380,211 +367,128 @@ func (fs *Filesystem) TruncateRootDirectory() error {
if err := os.Mkdir(fs.Path(), 0o755); err != nil {
return err
}
atomic.StoreInt64(&fs.diskUsed, 0)
_ = fs.unixFS.Close()
unixFS, err := ufs.NewUnixFS(fs.Path(), config.UseOpenat2())
if err != nil {
return err
}
var limit int64
if fs.isTest {
limit = 0
} else {
limit = fs.unixFS.Limit()
}
fs.unixFS = ufs.NewQuota(unixFS, limit)
return nil
}
// Delete removes a file or folder from the system. Prevents the user from
// accidentally (or maliciously) removing their root server data directory.
func (fs *Filesystem) Delete(p string) error {
// This is one of the few (only?) places in the codebase where we're explicitly not using
// the SafePath functionality when working with user provided input. If we did, you would
// not be able to delete a file that is a symlink pointing to a location outside the data
// directory.
//
// We also want to avoid resolving a symlink that points _within_ the data directory and thus
// deleting the actual source file for the symlink rather than the symlink itself. For these
// purposes just resolve the actual file path using filepath.Join() and confirm that the path
// exists within the data directory.
resolved := fs.unsafeFilePath(p)
if !fs.unsafeIsInDataDirectory(resolved) {
return NewBadPathResolution(p, resolved)
}
// Block any whoopsies.
if resolved == fs.Path() {
return errors.New("cannot delete root server directory")
}
st, err := os.Lstat(resolved)
if err != nil {
if !os.IsNotExist(err) {
fs.error(err).Warn("error while attempting to stat file before deletion")
return err
}
// The following logic is used to handle a case where a user attempts to
// delete a file that does not exist through a directory symlink.
// We don't want to reveal that the file does not exist, so we validate
// the path of the symlink and return a bad path error if it is invalid.
// The requested file or directory doesn't exist, so at this point we
// need to iterate up the path chain until we hit a directory that
// _does_ exist and can be validated.
parts := strings.Split(filepath.Dir(resolved), "/")
// Range over all the path parts and form directory paths from the end
// moving up until we have a valid resolution, or we run out of paths to
// try.
for k := range parts {
try := strings.Join(parts[:(len(parts)-k)], "/")
if !fs.unsafeIsInDataDirectory(try) {
break
}
t, err := filepath.EvalSymlinks(try)
if err == nil {
if !fs.unsafeIsInDataDirectory(t) {
return NewBadPathResolution(p, t)
}
break
}
}
// Always return early if the file does not exist.
return nil
}
// If the file is not a symlink, we need to check that it is not within a
// symlinked directory that points outside the data directory.
if st.Mode()&os.ModeSymlink == 0 {
ep, err := filepath.EvalSymlinks(resolved)
if err != nil {
if !os.IsNotExist(err) {
return err
}
} else if !fs.unsafeIsInDataDirectory(ep) {
return NewBadPathResolution(p, ep)
}
}
if st.IsDir() {
if s, err := fs.DirectorySize(resolved); err == nil {
fs.addDisk(-s)
}
} else {
fs.addDisk(-st.Size())
}
return os.RemoveAll(resolved)
return fs.unixFS.RemoveAll(p)
}
type fileOpener struct {
busy uint
}
// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
// has been exhaused, at which point we will abort with an error.
func (fo *fileOpener) open(path string, flags int, perm os.FileMode) (*os.File, error) {
for {
f, err := os.OpenFile(path, flags, perm)
// If there is an error because the text file is busy, go ahead and sleep for a few
// hundred milliseconds and then try again up to three times before just returning the
// error back to the caller.
//
// Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
time.Sleep(100 * time.Millisecond << fo.busy)
fo.busy++
continue
}
return f, err
}
}
//type fileOpener struct {
// fs *Filesystem
// busy uint
//}
//
//// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
//// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
//// has been exhaused, at which point we will abort with an error.
//func (fo *fileOpener) open(path string, flags int, perm ufs.FileMode) (ufs.File, error) {
// for {
// f, err := fo.fs.unixFS.OpenFile(path, flags, perm)
//
// // If there is an error because the text file is busy, go ahead and sleep for a few
// // hundred milliseconds and then try again up to three times before just returning the
// // error back to the caller.
// //
// // Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
// if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
// time.Sleep(100 * time.Millisecond << fo.busy)
// fo.busy++
// continue
// }
//
// return f, err
// }
//}
// ListDirectory lists the contents of a given directory and returns stat
// information about each file and folder within it.
func (fs *Filesystem) ListDirectory(p string) ([]Stat, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, err
}
files, err := ioutil.ReadDir(cleaned)
if err != nil {
return nil, err
}
var wg sync.WaitGroup
// You must initialize the output of this directory as a non-nil value otherwise
// when it is marshaled into a JSON object you'll just get 'null' back, which will
// break the panel badly.
out := make([]Stat, len(files))
// Iterate over all of the files and directories returned and perform an async process
// to get the mime-type for them all.
for i, file := range files {
wg.Add(1)
go func(idx int, f os.FileInfo) {
defer wg.Done()
var m *mimetype.MIME
d := "inode/directory"
if !f.IsDir() {
cleanedp := filepath.Join(cleaned, f.Name())
if f.Mode()&os.ModeSymlink != 0 {
cleanedp, _ = fs.SafePath(filepath.Join(cleaned, f.Name()))
}
// Don't try to detect the type on a pipe — this will just hang the application and
// you'll never get a response back.
//
// @see https://github.com/pterodactyl/panel/issues/4059
if cleanedp != "" && f.Mode()&os.ModeNamedPipe == 0 {
m, _ = mimetype.DetectFile(filepath.Join(cleaned, f.Name()))
} else {
// Just pass this for an unknown type because the file could not safely be resolved within
// the server data path.
d = "application/octet-stream"
}
}
st := Stat{FileInfo: f, Mimetype: d}
if m != nil {
st.Mimetype = m.String()
}
out[idx] = st
}(i, file)
}
wg.Wait()
// Sort the output alphabetically to begin with since we've run the output
// through an asynchronous process and the order is gonna be very random.
sort.SliceStable(out, func(i, j int) bool {
if out[i].Name() == out[j].Name() || out[i].Name() > out[j].Name() {
return true
// Read entries from the path on the filesystem, using the mapped reader, so
// we can map the DirEntry slice into a Stat slice with mimetype information.
out, err := ufs.ReadDirMap(fs.unixFS.UnixFS, p, func(e ufs.DirEntry) (Stat, error) {
info, err := e.Info()
if err != nil {
return Stat{}, err
}
var d string
if e.Type().IsDir() {
d = "inode/directory"
} else {
d = "application/octet-stream"
}
var m *mimetype.MIME
if e.Type().IsRegular() {
// TODO: I should probably find a better way to do this.
eO := e.(interface {
Open() (ufs.File, error)
})
f, err := eO.Open()
if err != nil {
return Stat{}, err
}
m, err = mimetype.DetectReader(f)
if err != nil {
log.Error(err.Error())
}
_ = f.Close()
}
st := Stat{FileInfo: info, Mimetype: d}
if m != nil {
st.Mimetype = m.String()
}
return st, nil
})
if err != nil {
return nil, err
}
// Sort entries alphabetically.
slices.SortStableFunc(out, func(a, b Stat) int {
switch {
case a.Name() == b.Name():
return 0
case a.Name() > b.Name():
return 1
default:
return -1
}
return false
})
// Then, sort it so that directories are listed first in the output. Everything
// will continue to be alphabetized at this point.
sort.SliceStable(out, func(i, j int) bool {
return out[i].IsDir()
// Sort folders before other file types.
slices.SortStableFunc(out, func(a, b Stat) int {
switch {
case a.IsDir() && b.IsDir():
return 0
case a.IsDir():
return 1
default:
return -1
}
})
return out, nil
}
func (fs *Filesystem) Chtimes(path string, atime, mtime time.Time) error {
cleaned, err := fs.SafePath(path)
if err != nil {
return err
}
if fs.isTest {
return nil
}
if err := os.Chtimes(cleaned, atime, mtime); err != nil {
return err
}
return nil
return fs.unixFS.Chtimes(path, atime, mtime)
}

View File

@@ -7,12 +7,13 @@ import (
"math/rand"
"os"
"path/filepath"
"sync/atomic"
"testing"
"unicode/utf8"
. "github.com/franela/goblin"
"github.com/pterodactyl/wings/internal/ufs"
"github.com/pterodactyl/wings/config"
)
@@ -28,15 +29,23 @@ func NewFs() (*Filesystem, *rootFs) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "pterodactyl")
if err != nil {
panic(err)
return nil, nil
}
// defer os.RemoveAll(tmpDir)
rfs := rootFs{root: tmpDir}
rfs.reset()
p := filepath.Join(tmpDir, "server")
if err := os.Mkdir(p, 0o755); err != nil {
panic(err)
return nil, nil
}
fs := New(filepath.Join(tmpDir, "/server"), 0, []string{})
fs, _ := New(p, 0, []string{})
fs.isTest = true
if err := fs.TruncateRootDirectory(); err != nil {
panic(err)
return nil, nil
}
return fs, &rfs
}
@@ -45,7 +54,7 @@ type rootFs struct {
root string
}
func getFileContent(file *os.File) string {
func getFileContent(file ufs.File) string {
var w bytes.Buffer
if _, err := bufio.NewReader(file).WriteTo(&w); err != nil {
panic(err)
@@ -54,11 +63,11 @@ func getFileContent(file *os.File) string {
}
func (rfs *rootFs) CreateServerFile(p string, c []byte) error {
f, err := os.Create(filepath.Join(rfs.root, "/server", p))
f, err := os.Create(filepath.Join(rfs.root, "server", p))
if err == nil {
f.Write(c)
f.Close()
_, _ = f.Write(c)
_ = f.Close()
}
return err
@@ -69,19 +78,7 @@ func (rfs *rootFs) CreateServerFileFromString(p string, c string) error {
}
func (rfs *rootFs) StatServerFile(p string) (os.FileInfo, error) {
return os.Stat(filepath.Join(rfs.root, "/server", p))
}
func (rfs *rootFs) reset() {
if err := os.RemoveAll(filepath.Join(rfs.root, "/server")); err != nil {
if !os.IsNotExist(err) {
panic(err)
}
}
if err := os.Mkdir(filepath.Join(rfs.root, "/server"), 0o755); err != nil {
panic(err)
}
return os.Stat(filepath.Join(rfs.root, "server", p))
}
func TestFilesystem_Openfile(t *testing.T) {
@@ -93,7 +90,8 @@ func TestFilesystem_Openfile(t *testing.T) {
_, _, err := fs.File("foo/bar.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrNotExist)).IsTrue()
// TODO
//g.Assert(IsErrorCode(err, ErrNotExist)).IsTrue()
})
g.It("returns file stat information", func() {
@@ -108,14 +106,14 @@ func TestFilesystem_Openfile(t *testing.T) {
})
g.AfterEach(func() {
rfs.reset()
_ = fs.TruncateRootDirectory()
})
})
}
func TestFilesystem_Writefile(t *testing.T) {
g := Goblin(t)
fs, rfs := NewFs()
fs, _ := NewFs()
g.Describe("Open and WriteFile", func() {
buf := &bytes.Buffer{}
@@ -125,22 +123,22 @@ func TestFilesystem_Writefile(t *testing.T) {
g.It("can create a new file", func() {
r := bytes.NewReader([]byte("test file content"))
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
g.Assert(fs.CachedUsage()).Equal(int64(0))
err := fs.Writefile("test.txt", r)
err := fs.Write("test.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
f, _, err := fs.File("test.txt")
g.Assert(err).IsNil()
defer f.Close()
g.Assert(getFileContent(f)).Equal("test file content")
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(r.Size())
g.Assert(fs.CachedUsage()).Equal(r.Size())
})
g.It("can create a new file inside a nested directory with leading slash", func() {
r := bytes.NewReader([]byte("test file content"))
err := fs.Writefile("/some/nested/test.txt", r)
err := fs.Write("/some/nested/test.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
f, _, err := fs.File("/some/nested/test.txt")
@@ -152,7 +150,7 @@ func TestFilesystem_Writefile(t *testing.T) {
g.It("can create a new file inside a nested directory without a trailing slash", func() {
r := bytes.NewReader([]byte("test file content"))
err := fs.Writefile("some/../foo/bar/test.txt", r)
err := fs.Write("some/../foo/bar/test.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
f, _, err := fs.File("foo/bar/test.txt")
@@ -164,13 +162,13 @@ func TestFilesystem_Writefile(t *testing.T) {
g.It("cannot create a file outside the root directory", func() {
r := bytes.NewReader([]byte("test file content"))
err := fs.Writefile("/some/../foo/../../test.txt", r)
err := fs.Write("/some/../foo/../../test.txt", r, r.Size(), 0o644)
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("cannot write a file that exceeds the disk limits", func() {
atomic.StoreInt64(&fs.diskLimit, 1024)
fs.SetDiskLimit(1024)
b := make([]byte, 1025)
_, err := rand.Read(b)
@@ -178,18 +176,18 @@ func TestFilesystem_Writefile(t *testing.T) {
g.Assert(len(b)).Equal(1025)
r := bytes.NewReader(b)
err = fs.Writefile("test.txt", r)
err = fs.Write("test.txt", r, int64(len(b)), 0o644)
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodeDiskSpace)).IsTrue()
})
g.It("truncates the file when writing new contents", func() {
r := bytes.NewReader([]byte("original data"))
err := fs.Writefile("test.txt", r)
err := fs.Write("test.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
r = bytes.NewReader([]byte("new data"))
err = fs.Writefile("test.txt", r)
err = fs.Write("test.txt", r, r.Size(), 0o644)
g.Assert(err).IsNil()
f, _, err := fs.File("test.txt")
@@ -200,10 +198,7 @@ func TestFilesystem_Writefile(t *testing.T) {
g.AfterEach(func() {
buf.Truncate(0)
rfs.reset()
atomic.StoreInt64(&fs.diskUsed, 0)
atomic.StoreInt64(&fs.diskLimit, 0)
_ = fs.TruncateRootDirectory()
})
})
}
@@ -236,17 +231,17 @@ func TestFilesystem_CreateDirectory(t *testing.T) {
g.It("should not allow the creation of directories outside the root", func() {
err := fs.CreateDirectory("test", "e/../../something")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("should not increment the disk usage", func() {
err := fs.CreateDirectory("test", "/")
g.Assert(err).IsNil()
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
g.Assert(fs.CachedUsage()).Equal(int64(0))
})
g.AfterEach(func() {
rfs.reset()
_ = fs.TruncateRootDirectory()
})
})
}
@@ -268,25 +263,25 @@ func TestFilesystem_Rename(t *testing.T) {
err = fs.Rename("source.txt", "target.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrExist)).IsTrue("err is not ErrExist")
})
g.It("returns an error if the final destination is the root directory", func() {
err := fs.Rename("source.txt", "/")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("returns an error if the source destination is the root directory", func() {
err := fs.Rename("source.txt", "/")
err := fs.Rename("/", "target.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("does not allow renaming to a location outside the root", func() {
err := fs.Rename("source.txt", "../target.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("does not allow renaming from a location outside the root", func() {
@@ -294,7 +289,7 @@ func TestFilesystem_Rename(t *testing.T) {
err = fs.Rename("/../ext-source.txt", "target.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("allows a file to be renamed", func() {
@@ -303,7 +298,7 @@ func TestFilesystem_Rename(t *testing.T) {
_, err = rfs.StatServerFile("source.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
st, err := rfs.StatServerFile("target.txt")
g.Assert(err).IsNil()
@@ -320,7 +315,7 @@ func TestFilesystem_Rename(t *testing.T) {
_, err = rfs.StatServerFile("source_dir")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
st, err := rfs.StatServerFile("target_dir")
g.Assert(err).IsNil()
@@ -330,7 +325,7 @@ func TestFilesystem_Rename(t *testing.T) {
g.It("returns an error if the source does not exist", func() {
err := fs.Rename("missing.txt", "target.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
})
g.It("creates directories if they are missing", func() {
@@ -343,7 +338,7 @@ func TestFilesystem_Rename(t *testing.T) {
})
g.AfterEach(func() {
rfs.reset()
_ = fs.TruncateRootDirectory()
})
})
}
@@ -358,13 +353,13 @@ func TestFilesystem_Copy(t *testing.T) {
panic(err)
}
atomic.StoreInt64(&fs.diskUsed, int64(utf8.RuneCountInString("test content")))
fs.unixFS.SetUsage(int64(utf8.RuneCountInString("test content")))
})
g.It("should return an error if the source does not exist", func() {
err := fs.Copy("foo.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
})
g.It("should return an error if the source is outside the root", func() {
@@ -372,11 +367,11 @@ func TestFilesystem_Copy(t *testing.T) {
err = fs.Copy("../ext-source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("should return an error if the source directory is outside the root", func() {
err := os.MkdirAll(filepath.Join(rfs.root, "/nested/in/dir"), 0o755)
err := os.MkdirAll(filepath.Join(rfs.root, "nested/in/dir"), 0o755)
g.Assert(err).IsNil()
err = rfs.CreateServerFileFromString("/../nested/in/dir/ext-source.txt", "external content")
@@ -384,28 +379,28 @@ func TestFilesystem_Copy(t *testing.T) {
err = fs.Copy("../nested/in/dir/ext-source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
err = fs.Copy("nested/in/../../../nested/in/dir/ext-source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("should return an error if the source is a directory", func() {
err := os.Mkdir(filepath.Join(rfs.root, "/server/dir"), 0o755)
err := os.Mkdir(filepath.Join(rfs.root, "server/dir"), 0o755)
g.Assert(err).IsNil()
err = fs.Copy("dir")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
})
g.It("should return an error if there is not space to copy the file", func() {
atomic.StoreInt64(&fs.diskLimit, 2)
fs.SetDiskLimit(2)
err := fs.Copy("source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodeDiskSpace)).IsTrue()
g.Assert(IsErrorCode(err, ErrCodeDiskSpace)).IsTrue("err is not ErrCodeDiskSpace")
})
g.It("should create a copy of the file and increment the disk used", func() {
@@ -433,7 +428,7 @@ func TestFilesystem_Copy(t *testing.T) {
g.Assert(err).IsNil()
}
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(utf8.RuneCountInString("test content")) * 3)
g.Assert(fs.CachedUsage()).Equal(int64(utf8.RuneCountInString("test content")) * 3)
})
g.It("should create a copy inside of a directory", func() {
@@ -454,10 +449,7 @@ func TestFilesystem_Copy(t *testing.T) {
})
g.AfterEach(func() {
rfs.reset()
atomic.StoreInt64(&fs.diskUsed, 0)
atomic.StoreInt64(&fs.diskLimit, 0)
_ = fs.TruncateRootDirectory()
})
})
}
@@ -472,7 +464,7 @@ func TestFilesystem_Delete(t *testing.T) {
panic(err)
}
atomic.StoreInt64(&fs.diskUsed, int64(utf8.RuneCountInString("test content")))
fs.unixFS.SetUsage(int64(utf8.RuneCountInString("test content")))
})
g.It("does not delete files outside the root directory", func() {
@@ -480,13 +472,13 @@ func TestFilesystem_Delete(t *testing.T) {
err = fs.Delete("../ext-source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("does not allow the deletion of the root directory", func() {
err := fs.Delete("/")
g.Assert(err).IsNotNil()
g.Assert(err.Error()).Equal("cannot delete root server directory")
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("does not return an error if the target does not exist", func() {
@@ -504,9 +496,9 @@ func TestFilesystem_Delete(t *testing.T) {
_, err = rfs.StatServerFile("source.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
g.Assert(fs.CachedUsage()).Equal(int64(0))
})
g.It("deletes all items inside a directory if the directory is deleted", func() {
@@ -524,16 +516,16 @@ func TestFilesystem_Delete(t *testing.T) {
g.Assert(err).IsNil()
}
atomic.StoreInt64(&fs.diskUsed, int64(utf8.RuneCountInString("test content")*3))
fs.unixFS.SetUsage(int64(utf8.RuneCountInString("test content") * 3))
err = fs.Delete("foo")
g.Assert(err).IsNil()
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
g.Assert(fs.unixFS.Usage()).Equal(int64(0))
for _, s := range sources {
_, err = rfs.StatServerFile(s)
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
}
})
@@ -589,7 +581,7 @@ func TestFilesystem_Delete(t *testing.T) {
// Delete a file inside the symlinked directory.
err = fs.Delete("symlink/source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
// Ensure the file outside the root directory still exists.
_, err = os.Lstat(filepath.Join(rfs.root, "foo/source.txt"))
@@ -608,14 +600,11 @@ func TestFilesystem_Delete(t *testing.T) {
// Delete a file inside the symlinked directory.
err = fs.Delete("symlink/source.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.AfterEach(func() {
rfs.reset()
atomic.StoreInt64(&fs.diskUsed, 0)
atomic.StoreInt64(&fs.diskLimit, 0)
_ = fs.TruncateRootDirectory()
})
})
}

View File

@@ -1,71 +1,28 @@
package filesystem
import (
"context"
iofs "io/fs"
"os"
"path/filepath"
"strings"
"sync"
"emperror.dev/errors"
"golang.org/x/sync/errgroup"
)
// Checks if the given file or path is in the server's file denylist. If so, an Error
// is returned, otherwise nil is returned.
func (fs *Filesystem) IsIgnored(paths ...string) error {
for _, p := range paths {
sp, err := fs.SafePath(p)
if err != nil {
return err
}
if fs.denylist.MatchesPath(sp) {
return errors.WithStack(&Error{code: ErrCodeDenylistFile, path: p, resolved: sp})
//sp, err := fs.SafePath(p)
//if err != nil {
// return err
//}
// TODO: update logic to use unixFS
if fs.denylist.MatchesPath(p) {
return errors.WithStack(&Error{code: ErrCodeDenylistFile, path: p, resolved: p})
}
}
return nil
}
// Normalizes a directory being passed in to ensure the user is not able to escape
// from their data directory. After normalization if the directory is still within their home
// path it is returned. If they managed to "escape" an error will be returned.
//
// This logic is actually copied over from the SFTP server code. Ideally that eventually
// either gets ported into this application, or is able to make use of this package.
func (fs *Filesystem) SafePath(p string) (string, error) {
// Start with a cleaned up path before checking the more complex bits.
r := fs.unsafeFilePath(p)
// At the same time, evaluate the symlink status and determine where this file or folder
// is truly pointing to.
ep, err := filepath.EvalSymlinks(r)
if err != nil && !os.IsNotExist(err) {
return "", errors.Wrap(err, "server/filesystem: failed to evaluate symlink")
} else if os.IsNotExist(err) {
// The target of one of the symlinks (EvalSymlinks is recursive) does not exist.
// So we get what target path does not exist and check if it's within the data
// directory. If it is, we return the original path, otherwise we return an error.
pErr, ok := err.(*iofs.PathError)
if !ok {
return "", errors.Wrap(err, "server/filesystem: failed to evaluate symlink")
}
ep = pErr.Path
}
// If the requested directory from EvalSymlinks begins with the server root directory go
// ahead and return it. If not we'll return an error which will block any further action
// on the file.
if fs.unsafeIsInDataDirectory(ep) {
// Returning the original path here instead of the resolved path ensures that
// whatever the user is trying to do will work as expected. If we returned the
// resolved path, the user would be unable to know that it is in fact a symlink.
return r, nil
}
return "", NewBadPathResolution(p, r)
}
// Generate a path to the file by cleaning it up and appending the root server path to it. This
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
// the fs.unsafeIsInDataDirectory(p) function to confirm.
@@ -84,51 +41,3 @@ func (fs *Filesystem) unsafeFilePath(p string) string {
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
}
// Executes the fs.SafePath function in parallel against an array of paths. If any of the calls
// fails an error will be returned.
func (fs *Filesystem) ParallelSafePath(paths []string) ([]string, error) {
var cleaned []string
// Simple locker function to avoid racy appends to the array of cleaned paths.
m := new(sync.Mutex)
push := func(c string) {
m.Lock()
cleaned = append(cleaned, c)
m.Unlock()
}
// Create an error group that we can use to run processes in parallel while retaining
// the ability to cancel the entire process immediately should any of it fail.
g, ctx := errgroup.WithContext(context.Background())
// Iterate over all of the paths and generate a cleaned path, if there is an error for any
// of the files, abort the process.
for _, p := range paths {
// Create copy so we can use it within the goroutine correctly.
pi := p
// Recursively call this function to continue digging through the directory tree within
// a separate goroutine. If the context is canceled abort this process.
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
default:
// If the callback returns true, go ahead and keep walking deeper. This allows
// us to programmatically continue deeper into directories, or stop digging
// if that pathway knows it needs nothing else.
if c, err := fs.SafePath(pi); err != nil {
return err
} else {
push(c)
}
return nil
}
})
}
// Block until all of the routines finish and have returned a value.
return cleaned, g.Wait()
}

View File

@@ -8,6 +8,8 @@ import (
"emperror.dev/errors"
. "github.com/franela/goblin"
"github.com/pterodactyl/wings/internal/ufs"
)
func TestFilesystem_Path(t *testing.T) {
@@ -21,80 +23,6 @@ func TestFilesystem_Path(t *testing.T) {
})
}
func TestFilesystem_SafePath(t *testing.T) {
g := Goblin(t)
fs, rfs := NewFs()
prefix := filepath.Join(rfs.root, "/server")
g.Describe("SafePath", func() {
g.It("returns a cleaned path to a given file", func() {
p, err := fs.SafePath("test.txt")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/test.txt")
p, err = fs.SafePath("/test.txt")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/test.txt")
p, err = fs.SafePath("./test.txt")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/test.txt")
p, err = fs.SafePath("/foo/../test.txt")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/test.txt")
p, err = fs.SafePath("/foo/bar")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/foo/bar")
})
g.It("handles root directory access", func() {
p, err := fs.SafePath("/")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix)
p, err = fs.SafePath("")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix)
})
g.It("removes trailing slashes from paths", func() {
p, err := fs.SafePath("/foo/bar/")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/foo/bar")
})
g.It("handles deeply nested directories that do not exist", func() {
p, err := fs.SafePath("/foo/bar/baz/quaz/../../ducks/testing.txt")
g.Assert(err).IsNil()
g.Assert(p).Equal(prefix + "/foo/bar/ducks/testing.txt")
})
g.It("blocks access to files outside the root directory", func() {
p, err := fs.SafePath("../test.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(p).Equal("")
p, err = fs.SafePath("/../test.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(p).Equal("")
p, err = fs.SafePath("./foo/../../test.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(p).Equal("")
p, err = fs.SafePath("..")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(p).Equal("")
})
})
}
// We test against accessing files outside the root directory in the tests, however it
// is still possible for someone to mess up and not properly use this safe path call. In
// order to truly confirm this, we'll try to pass in a symlinked malicious file to all of
@@ -133,7 +61,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
err := fs.Writefile("symlinked.txt", r)
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("cannot write to a non-existent file symlinked outside the root", func() {
@@ -141,7 +69,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
err := fs.Writefile("symlinked_does_not_exist.txt", r)
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("cannot write to chained symlinks with target that does not exist outside the root", func() {
@@ -149,7 +77,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
err := fs.Writefile("symlinked_does_not_exist2.txt", r)
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
})
g.It("cannot write a file to a directory symlinked outside the root", func() {
@@ -157,7 +85,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
err := fs.Writefile("external_dir/foo.txt", r)
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
})
})
@@ -165,55 +93,54 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
g.It("cannot create a directory outside the root", func() {
err := fs.CreateDirectory("my_dir", "external_dir")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
})
g.It("cannot create a nested directory outside the root", func() {
err := fs.CreateDirectory("my/nested/dir", "external_dir/foo/bar")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
})
g.It("cannot create a nested directory outside the root", func() {
err := fs.CreateDirectory("my/nested/dir", "external_dir/server")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
})
})
g.Describe("Rename", func() {
g.It("cannot rename a file symlinked outside the directory root", func() {
err := fs.Rename("symlinked.txt", "foo.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.It("can rename a file symlinked outside the directory root", func() {
_, err := os.Lstat(filepath.Join(rfs.root, "server", "symlinked.txt"))
g.Assert(err).IsNil()
err = fs.Rename("symlinked.txt", "foo.txt")
g.Assert(err).IsNil()
_, err = os.Lstat(filepath.Join(rfs.root, "server", "foo.txt"))
g.Assert(err).IsNil()
})
g.It("cannot rename a symlinked directory outside the root", func() {
err := fs.Rename("external_dir", "foo")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.It("can rename a symlinked directory outside the root", func() {
_, err := os.Lstat(filepath.Join(rfs.root, "server", "external_dir"))
g.Assert(err).IsNil()
err = fs.Rename("external_dir", "foo")
g.Assert(err).IsNil()
_, err = os.Lstat(filepath.Join(rfs.root, "server", "foo"))
g.Assert(err).IsNil()
})
g.It("cannot rename a file to a location outside the directory root", func() {
rfs.CreateServerFileFromString("my_file.txt", "internal content")
_ = rfs.CreateServerFileFromString("my_file.txt", "internal content")
t.Log(rfs.root)
err := fs.Rename("my_file.txt", "external_dir/my_file.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
})
})
st, err := os.Lstat(filepath.Join(rfs.root, "server", "foo"))
g.Assert(err).IsNil()
g.Assert(st.Mode()&ufs.ModeSymlink != 0).IsTrue()
g.Describe("Chown", func() {
g.It("cannot chown a file symlinked outside the directory root", func() {
err := fs.Chown("symlinked.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
})
err = fs.Rename("my_file.txt", "foo/my_file.txt")
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue()
g.It("cannot chown a directory symlinked outside the directory root", func() {
err := fs.Chown("external_dir")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
st, err = os.Lstat(filepath.Join(rfs.root, "malicious_dir", "my_file.txt"))
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue()
})
})
@@ -221,7 +148,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
g.It("cannot copy a file symlinked outside the directory root", func() {
err := fs.Copy("symlinked.txt")
g.Assert(err).IsNotNil()
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
})
})
@@ -235,9 +162,9 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
_, err = rfs.StatServerFile("symlinked.txt")
g.Assert(err).IsNotNil()
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
})
})
rfs.reset()
_ = fs.TruncateRootDirectory()
}

View File

@@ -1,16 +1,18 @@
package filesystem
import (
"os"
"encoding/json"
"io"
"strconv"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/goccy/go-json"
"github.com/pterodactyl/wings/internal/ufs"
)
type Stat struct {
os.FileInfo
ufs.FileInfo
Mimetype string
}
@@ -31,40 +33,31 @@ func (s *Stat) MarshalJSON() ([]byte, error) {
Created: s.CTime().Format(time.RFC3339),
Modified: s.ModTime().Format(time.RFC3339),
Mode: s.Mode().String(),
// Using `&os.ModePerm` on the file's mode will cause the mode to only have the permission values, and nothing else.
ModeBits: strconv.FormatUint(uint64(s.Mode()&os.ModePerm), 8),
// Using `&ModePerm` on the file's mode will cause the mode to only have the permission values, and nothing else.
ModeBits: strconv.FormatUint(uint64(s.Mode()&ufs.ModePerm), 8),
Size: s.Size(),
Directory: s.IsDir(),
File: !s.IsDir(),
Symlink: s.Mode().Perm()&os.ModeSymlink != 0,
Symlink: s.Mode().Perm()&ufs.ModeSymlink != 0,
Mime: s.Mimetype,
})
}
// Stat stats a file or folder and returns the base stat object from go along
// with the MIME data that can be used for editing files.
func (fs *Filesystem) Stat(p string) (Stat, error) {
cleaned, err := fs.SafePath(p)
func statFromFile(f ufs.File) (Stat, error) {
s, err := f.Stat()
if err != nil {
return Stat{}, err
}
return fs.unsafeStat(cleaned)
}
func (fs *Filesystem) unsafeStat(p string) (Stat, error) {
s, err := os.Stat(p)
if err != nil {
return Stat{}, err
}
var m *mimetype.MIME
if !s.IsDir() {
m, err = mimetype.DetectFile(p)
m, err = mimetype.DetectReader(f)
if err != nil {
return Stat{}, err
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return Stat{}, err
}
}
st := Stat{
FileInfo: s,
Mimetype: "inode/directory",
@@ -72,6 +65,20 @@ func (fs *Filesystem) unsafeStat(p string) (Stat, error) {
if m != nil {
st.Mimetype = m.String()
}
return st, nil
}
// Stat stats a file or folder and returns the base stat object from go along
// with the MIME data that can be used for editing files.
func (fs *Filesystem) Stat(p string) (Stat, error) {
f, err := fs.unixFS.Open(p)
if err != nil {
return Stat{}, err
}
defer f.Close()
st, err := statFromFile(f)
if err != nil {
return Stat{}, err
}
return st, nil
}

View File

@@ -1,13 +0,0 @@
package filesystem
import (
"syscall"
"time"
)
// CTime returns the time that the file/folder was created.
func (s *Stat) CTime() time.Time {
st := s.Sys().(*syscall.Stat_t)
return time.Unix(st.Ctimespec.Sec, st.Ctimespec.Nsec)
}

View File

@@ -3,12 +3,22 @@ package filesystem
import (
"syscall"
"time"
"golang.org/x/sys/unix"
)
// Returns the time that the file/folder was created.
// CTime returns the time that the file/folder was created.
//
// TODO: remove. Ctim is not actually ever been correct and doesn't actually
// return the creation time.
func (s *Stat) CTime() time.Time {
st := s.Sys().(*syscall.Stat_t)
// Do not remove these "redundant" type-casts, they are required for 32-bit builds to work.
return time.Unix(int64(st.Ctim.Sec), int64(st.Ctim.Nsec))
if st, ok := s.Sys().(*unix.Stat_t); ok {
// Do not remove these "redundant" type-casts, they are required for 32-bit builds to work.
return time.Unix(int64(st.Ctim.Sec), int64(st.Ctim.Nsec))
}
if st, ok := s.Sys().(*syscall.Stat_t); ok {
// Do not remove these "redundant" type-casts, they are required for 32-bit builds to work.
return time.Unix(int64(st.Ctim.Sec), int64(st.Ctim.Nsec))
}
return time.Time{}
}

View File

@@ -1,12 +0,0 @@
package filesystem
import (
"time"
)
// On linux systems this will return the time that the file was created.
// However, I have no idea how to do this on windows, so we're skipping it
// for right now.
func (s *Stat) CTime() time.Time {
return s.ModTime()
}