Files
pterodactyl-wings/server/filesystem/decompress_test.go
T
dualfroz e7bb19d447 Identify archives by content before file name
An archive renamed to another extension, such as a .tar.gz renamed to
.zip, was read as a zip inside a gzip stream and failed with "input type
must be an io.ReaderAt and io.Seeker". The format is now taken from the
contents first, and the file name is only used when they are not
recognized.

Fixes pterodactyl/panel#5728
2026-10-13 22:47:20 +02:00

173 lines
5.8 KiB
Go

package filesystem
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
iofs "io/fs"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pterodactyl/wings/config"
)
func gzipBytes(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
_, err := zw.Write(data)
require.NoError(t, err)
require.NoError(t, zw.Close())
return buf.Bytes()
}
// A single compressed file decompressed over an existing, larger file leaves
// exactly the decompressed content behind.
func TestDecompressSingleFileReplacesExistingContent(t *testing.T) {
fs, rfs := NewFs()
t.Cleanup(func() { _ = os.RemoveAll(rfs.root) })
require.NoError(t, rfs.CreateServerFileFromString("in.txt", strings.Repeat("A", 100)))
require.NoError(t, rfs.CreateServerFile("in.txt.gz", gzipBytes(t, []byte("hello"))))
require.NoError(t, fs.DecompressFile(context.Background(), "/", "in.txt.gz"))
got, err := os.ReadFile(filepath.Join(rfs.root, "server", "in.txt"))
require.NoError(t, err)
assert.Equal(t, "hello", string(got))
}
// A single file decompressed from a compressed archive is owned by the server
// user, like every other extracted file.
func TestDecompressSingleFileOwnedByServerUser(t *testing.T) {
fs, rfs := NewFs()
t.Cleanup(func() { _ = os.RemoveAll(rfs.root) })
const uid, gid = 1234, 1234
cfg := config.Get()
cfg.System.User.Uid = uid
cfg.System.User.Gid = gid
config.Set(cfg)
fs.isTest = false
require.NoError(t, rfs.CreateServerFile("in.txt.gz", gzipBytes(t, []byte("hello"))))
require.NoError(t, fs.DecompressFile(context.Background(), "/", "in.txt.gz"))
info, err := os.Stat(filepath.Join(rfs.root, "server", "in.txt"))
require.NoError(t, err)
st, ok := info.Sys().(*syscall.Stat_t)
require.True(t, ok)
assert.Equal(t, uint32(uid), st.Uid)
assert.Equal(t, uint32(gid), st.Gid)
}
// A single compressed file that does not fit the disk limit is not left
// partly written.
func TestDecompressSingleFileStopsAtDiskLimit(t *testing.T) {
fs, rfs := NewFs()
t.Cleanup(func() { _ = os.RemoveAll(rfs.root) })
const limit = 1 << 20
fs.SetDiskLimit(limit)
require.NoError(t, rfs.CreateServerFile("large.gz", gzipBytes(t, make([]byte, 8<<20))))
err := fs.DecompressFile(context.Background(), "/", "large.gz")
require.Error(t, err)
assert.True(t, IsErrorCode(err, ErrCodeDiskSpace), "unexpected error: %v", err)
_, err = os.Stat(filepath.Join(rfs.root, "server", "large"))
assert.ErrorIs(t, err, os.ErrNotExist)
assert.LessOrEqual(t, fs.CachedUsage(), int64(limit))
}
// Symlinks in an archive are extracted as symlinks, and special files are not
// extracted.
func TestDecompressExtractsEntriesByType(t *testing.T) {
fs, rfs := NewFs()
t.Cleanup(func() { _ = os.RemoveAll(rfs.root) })
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "file.txt", Mode: 0o644, Size: 5, Typeflag: tar.TypeReg}))
_, err := tw.Write([]byte("hello"))
require.NoError(t, err)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "link", Linkname: "file.txt", Mode: 0o777, Typeflag: tar.TypeSymlink}))
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "hard", Linkname: "file.txt", Mode: 0o644, Typeflag: tar.TypeLink}))
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "device", Mode: 0o644, Typeflag: tar.TypeChar, Devmajor: 1, Devminor: 3}))
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "pipe", Mode: 0o644, Typeflag: tar.TypeFifo}))
require.NoError(t, tw.Close())
require.NoError(t, gz.Close())
require.NoError(t, rfs.CreateServerFile("bundle.tar.gz", buf.Bytes()))
require.NoError(t, fs.DecompressFile(context.Background(), "/", "bundle.tar.gz"))
root := filepath.Join(rfs.root, "server")
target, err := os.Readlink(filepath.Join(root, "link"))
require.NoError(t, err)
assert.Equal(t, "file.txt", target)
for _, name := range []string{"hard", "device", "pipe"} {
_, err := os.Lstat(filepath.Join(root, name))
assert.ErrorIs(t, err, os.ErrNotExist, name)
}
}
// A symlink in a zip archive is extracted as a symlink.
func TestDecompressZipSymlinkEntry(t *testing.T) {
fs, rfs := NewFs()
t.Cleanup(func() { _ = os.RemoveAll(rfs.root) })
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
hdr := &zip.FileHeader{Name: "link"}
hdr.SetMode(iofs.ModeSymlink | 0o777)
w, err := zw.CreateHeader(hdr)
require.NoError(t, err)
_, err = w.Write([]byte("target.txt"))
require.NoError(t, err)
require.NoError(t, zw.Close())
require.NoError(t, rfs.CreateServerFile("bundle.zip", buf.Bytes()))
require.NoError(t, fs.DecompressFile(context.Background(), "/", "bundle.zip"))
target, err := os.Readlink(filepath.Join(rfs.root, "server", "link"))
require.NoError(t, err)
assert.Equal(t, "target.txt", target)
}
// An archive created by the compress endpoint and renamed to a different
// extension is still read as the format it actually is.
func TestDecompressRenamedArchive(t *testing.T) {
fs, rfs := NewFs()
t.Cleanup(func() { _ = os.RemoveAll(rfs.root) })
fs.SetDiskLimit(1 << 20)
require.NoError(t, fs.CreateDirectory("source", "/"))
require.NoError(t, rfs.CreateServerFileFromString("source/file.txt", "hello"))
st, err := fs.CompressFiles("/", []string{"source"})
require.NoError(t, err)
require.NoError(t, fs.Rename(st.Name(), "renamed.zip"))
require.NoError(t, os.RemoveAll(filepath.Join(rfs.root, "server", "source")))
// The formats matching an archive are tried in random order, so a single
// attempt can pass by chance.
for i := 0; i < 10; i++ {
require.NoError(t, fs.SpaceAvailableForDecompression(context.Background(), "/", "renamed.zip"))
require.NoError(t, fs.DecompressFile(context.Background(), "/", "renamed.zip"))
}
got, err := os.ReadFile(filepath.Join(rfs.root, "server", "source", "file.txt"))
require.NoError(t, err)
assert.Equal(t, "hello", string(got))
}