Files
pterodactyl-wings/server/filesystem/compress.go
T

307 lines
8.8 KiB
Go

package filesystem
import (
"archive/tar"
"context"
"fmt"
"io"
iofs "io/fs"
"math"
"path"
"path/filepath"
"strings"
"time"
"emperror.dev/errors"
"github.com/klauspost/compress/zip"
"github.com/mholt/archives"
"github.com/pterodactyl/wings/internal/ufs"
)
// CompressFiles compresses all the files matching the given paths in the
// specified directory. This function also supports passing nested paths to only
// compress certain files and folders when working in a larger directory. This
// effectively creates a local backup, but rather than ignoring specific files
// and folders, it takes an allow-list of files and folders.
//
// 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) (ufs.FileInfo, error) {
a := &Archive{Filesystem: fs, BaseDirectory: dir, Files: paths}
name := fmt.Sprintf("archive-%s", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", ""))
// Never write into an existing file, such as an archive created by another
// request within the same second.
var (
d string
f ufs.File
err error
)
for i := 0; ; i++ {
d = path.Join(dir, name+".tar.gz")
if i > 0 {
d = path.Join(dir, fmt.Sprintf("%s-%d.tar.gz", name, i))
}
f, err = fs.unixFS.OpenFile(d, ufs.O_WRONLY|ufs.O_CREATE|ufs.O_EXCL, 0o644)
if err == nil {
break
}
if !errors.Is(err, ufs.ErrExist) || i >= 100 {
return nil, err
}
}
// Count every write to the archive against the disk limit as it happens, so
// that archives being created at the same time cannot each use all the space
// that is left.
qf := newQuotaFile(fs, f, 0)
if err := a.Stream(context.Background(), qf); err != nil {
_ = qf.Close()
_ = fs.unixFS.Remove(d)
return nil, err
}
st, err := qf.Stat()
if err != nil {
_ = qf.Close()
return nil, err
}
if err := qf.Close(); err != nil {
return nil, err
}
return st, nil
}
// maxArchiveEntryNameLength is the longest name an entry in an archive can have,
// which matches the longest path that can be created on Linux.
const maxArchiveEntryNameLength = 4096
// SpaceAvailableForDecompression looks through a given archive and determines
// if decompressing it would put the server over its allocated disk space limit.
//
// The entries are read one at a time rather than opening the archive as a file
// system, which is much faster for large archives.
func (fs *Filesystem) SpaceAvailableForDecompression(ctx context.Context, dir string, file string) error {
// Don't waste time trying to determine this if we know the server will have the space for
// it since there is no limit.
if fs.MaxDisk() <= 0 {
return nil
}
f, err := fs.unixFS.Open(filepath.Join(dir, file))
if err != nil {
return err
}
defer f.Close()
format, _, err := archives.Identify(ctx, filepath.Base(file), f)
if err != nil && !errors.Is(err, archives.NoMatch) {
return err
}
// Reset the file reader.
if _, err := f.Seek(0, io.SeekStart); err != nil {
return err
}
info, err := f.Stat()
if err != nil {
return err
}
var size int64
add := func(name string, n int64) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if len(name) > maxArchiveEntryNameLength {
return errors.New("filesystem: archive contains an entry with a name that is too long")
}
if n <= 0 {
return nil
}
if n > math.MaxInt64-size {
return newFilesystemError(ErrCodeDiskSpace, nil)
}
size += n
if !fs.unixFS.CanFit(size) {
return newFilesystemError(ErrCodeDiskSpace, nil)
}
return nil
}
switch ff := format.(type) {
case archives.Zip:
// The sizes can be read straight from the central directory of a zip.
reader, err := zip.NewReader(f, info.Size())
if err != nil {
return err
}
for _, e := range reader.File {
if e.UncompressedSize64 > math.MaxInt64 {
return newFilesystemError(ErrCodeDiskSpace, nil)
}
if err := add(e.Name, int64(e.UncompressedSize64)); err != nil {
return err
}
}
return nil
case archives.Extraction:
return ff.Extract(ctx, io.NewSectionReader(f, 0, info.Size()), func(ctx context.Context, e archives.FileInfo) error {
return add(e.NameInArchive, e.Size())
})
case archives.Compression:
// A single compressed file is checked against the disk limit as it is
// decompressed, since its size is not known until then.
return add(file, info.Size())
}
return newFilesystemError(ErrCodeUnknownArchive, archives.NoMatch)
}
// DecompressFile will decompress a file in a given directory by using the
// archiver tool to infer the file type and go from there. This will walk over
// all the files within the given archive and ensure that there is not a
// 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 {
f, err := fs.unixFS.Open(filepath.Join(dir, file))
if err != nil {
return err
}
defer f.Close()
// Identify the type of archive we are dealing with.
format, input, err := archives.Identify(ctx, filepath.Base(file), f)
if err != nil {
if errors.Is(err, archives.NoMatch) {
return newFilesystemError(ErrCodeUnknownArchive, err)
}
return err
}
return fs.extractStream(ctx, extractStreamOptions{
FileName: file,
Directory: dir,
Format: format,
Reader: input,
})
}
// ExtractTransfer extracts the archive of a server that is being transferred to
// this node into the server's directory. Every file is kept, including ones the
// denylist would refuse, since they are the server's own files.
func (fs *Filesystem) ExtractTransfer(ctx context.Context, r io.Reader) error {
format, input, err := archives.Identify(ctx, "archive.tar.gz", r)
if err != nil {
if errors.Is(err, archives.NoMatch) {
return newFilesystemError(ErrCodeUnknownArchive, err)
}
return err
}
return fs.extractStream(ctx, extractStreamOptions{
Directory: "/",
Format: format,
Reader: input,
KeepDenylist: true,
})
}
type extractStreamOptions struct {
// The directory to extract the archive to.
Directory string
// File name of the archive.
FileName string
// Format of the archive.
Format archives.Format
// Reader for the archive.
Reader io.Reader
// KeepDenylist extracts files the denylist would otherwise skip.
KeepDenylist bool
}
func (fs *Filesystem) extractStream(ctx context.Context, opts extractStreamOptions) error {
// See if it's a compressed archive, such as TAR or a ZIP
ex, ok := opts.Format.(archives.Extractor)
if !ok {
// If not, check if it's a single-file compression, such as
// .log.gz, .sql.gz, and so on
de, ok := opts.Format.(archives.Decompressor)
if !ok {
return nil
}
// Strip the compression suffix
p := filepath.Join(opts.Directory, strings.TrimSuffix(opts.FileName, opts.Format.Extension()))
// Make sure it's not ignored
if err := fs.IsIgnored(p); err != nil {
return nil
}
reader, err := de.OpenReader(opts.Reader)
if err != nil {
return err
}
defer reader.Close()
// Write the file, counting each write against the disk limit. The file is
// removed if it does not fit.
if _, err := fs.WriteFrom(p, reader); err != nil {
return wrapError(err, opts.FileName)
}
return nil
}
// Decompress and extract archive
return ex.Extract(ctx, opts.Reader, func(ctx context.Context, f archives.FileInfo) error {
p := filepath.Join(opts.Directory, f.NameInArchive)
// If it is ignored, just don't do anything with the entry and skip over it.
if !opts.KeepDenylist {
if err := fs.IsIgnored(p); err != nil {
return nil
}
}
// Create directories explicitly; an empty one has no file to create it
// implicitly and would otherwise be dropped during extraction.
if f.IsDir() {
if err := fs.mkdirAll(p, 0o755); err != nil {
return wrapError(err, opts.FileName)
}
return nil
}
if f.Mode()&iofs.ModeSymlink != 0 {
if f.LinkTarget == "" {
return nil
}
if err := fs.mkdirAll(filepath.Dir(p), 0o755); err != nil {
return wrapError(err, opts.FileName)
}
if err := fs.Symlink(f.LinkTarget, p); err != nil && !errors.Is(err, ufs.ErrExist) {
return wrapError(err, opts.FileName)
}
return nil
}
// Hard links, devices and other special files are not extracted.
if !f.Mode().IsRegular() {
return nil
}
if h, ok := f.Header.(*tar.Header); ok && h.Typeflag == tar.TypeLink {
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
})
}