1157 lines
29 KiB
Go
1157 lines
29 KiB
Go
//go:build linux || freebsd
|
|
|
|
package archive
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// --- DetectArchiveFormat ---
|
|
|
|
func TestDetectArchiveFormat(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
expected string
|
|
}{
|
|
// Standard formats
|
|
{"file.tar", "tar"},
|
|
{"file.tar.gz", "tar.gz"},
|
|
{"file.tgz", "tar.gz"},
|
|
{"file.tar.bz2", "tar.bz2"},
|
|
{"file.tbz2", "tar.bz2"},
|
|
{"file.zip", "zip"},
|
|
// Non-archive files
|
|
{"file.txt", ""},
|
|
{"file", ""},
|
|
{"", ""},
|
|
// Case insensitivity
|
|
{"FILE.TAR", "tar"},
|
|
{"ARCHIVE.TAR.GZ", "tar.gz"},
|
|
{"ARCHIVE.TGZ", "tar.gz"},
|
|
{"ARCHIVE.TAR.BZ2", "tar.bz2"},
|
|
{"ARCHIVE.TBZ2", "tar.bz2"},
|
|
{"ARCHIVE.ZIP", "zip"},
|
|
// Mixed case
|
|
{"File.Tar", "tar"},
|
|
{"File.Tar.Gz", "tar.gz"},
|
|
// Paths with directories
|
|
{"path/to/file.tar", "tar"},
|
|
{"/absolute/path/file.tar.gz", "tar.gz"},
|
|
{"./relative/file.zip", "zip"},
|
|
// Edge cases: dotfiles, partial extensions
|
|
{".tar", "tar"},
|
|
{"file.tar.txt", ""},
|
|
{"file.tgz.txt", ""},
|
|
{"file.gzip", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := DetectArchiveFormat(tt.filename)
|
|
if got != tt.expected {
|
|
t.Errorf("DetectArchiveFormat(%q) = %q; want %q", tt.filename, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- IsArchiveFormat ---
|
|
|
|
func TestIsArchiveFormat(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
expected bool
|
|
}{
|
|
{"file.tar", true},
|
|
{"file.tar.gz", true},
|
|
{"file.tgz", true},
|
|
{"file.tar.bz2", true},
|
|
{"file.tbz2", true},
|
|
{"file.zip", true},
|
|
{"file.txt", false},
|
|
{"file", false},
|
|
{"", false},
|
|
{".tar", true},
|
|
{"file.tar.txt", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := IsArchiveFormat(tt.filename)
|
|
if got != tt.expected {
|
|
t.Errorf("IsArchiveFormat(%q) = %v; want %v", tt.filename, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GetArchiveAndCompression ---
|
|
|
|
func TestGetArchiveAndCompression(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
wantArchive string
|
|
wantCompression string
|
|
}{
|
|
{"file.tar", "tar", ""},
|
|
{"file.tar.gz", "tar", "gzip"},
|
|
{"file.tgz", "tar", "gzip"},
|
|
{"file.tar.bz2", "tar", "bzip2"},
|
|
{"file.tbz2", "tar", "bzip2"},
|
|
{"file.zip", "zip", ""},
|
|
{"file.gz", "", "gzip"},
|
|
{"file.bz2", "", "bzip2"},
|
|
{"file.txt", "", ""},
|
|
{"file", "", ""},
|
|
{"", "", ""},
|
|
// Case insensitivity
|
|
{"FILE.TAR.GZ", "tar", "gzip"},
|
|
{"File.Tar.Bz2", "tar", "bzip2"},
|
|
// Paths
|
|
{"dir/file.tar.bz2", "tar", "bzip2"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
archive, compression := GetArchiveAndCompression(tt.filename)
|
|
if archive != tt.wantArchive || compression != tt.wantCompression {
|
|
t.Errorf("GetArchiveAndCompression(%q) = (%q, %q); want (%q, %q)",
|
|
tt.filename, archive, compression, tt.wantArchive, tt.wantCompression)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- BaseExtractor ---
|
|
|
|
func TestBaseExtractorName(t *testing.T) {
|
|
e := NewBaseExtractor("test-format", []string{".ext1", ".ext2"})
|
|
if got := e.Name(); got != "test-format" {
|
|
t.Errorf("Name() = %q; want %q", got, "test-format")
|
|
}
|
|
}
|
|
|
|
func TestBaseExtractorExtensions(t *testing.T) {
|
|
exts := []string{".ext1", ".ext2"}
|
|
e := NewBaseExtractor("test", exts)
|
|
got := e.Extensions()
|
|
if len(got) != len(exts) {
|
|
t.Fatalf("Extensions() length = %d; want %d", len(got), len(exts))
|
|
}
|
|
for i, ext := range exts {
|
|
if got[i] != ext {
|
|
t.Errorf("Extensions()[%d] = %q; want %q", i, got[i], ext)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBaseExtractorExtensionsConsistency(t *testing.T) {
|
|
originalExts := []string{".ext1", ".ext2"}
|
|
e := NewBaseExtractor("test", originalExts)
|
|
got := e.Extensions()
|
|
if len(got) != len(originalExts) {
|
|
t.Fatalf("Extensions() length = %d; want %d", len(got), len(originalExts))
|
|
}
|
|
for i := range originalExts {
|
|
if got[i] != originalExts[i] {
|
|
t.Errorf("Extensions()[%d] = %q; want %q", i, got[i], originalExts[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBaseExtractorCanHandle(t *testing.T) {
|
|
e := NewBaseExtractor("test", []string{".tar.gz", ".tgz"})
|
|
|
|
tests := []struct {
|
|
filename string
|
|
expected bool
|
|
}{
|
|
{"file.tar.gz", true},
|
|
{"file.tgz", true},
|
|
{"file.tar", false},
|
|
{"file.zip", false},
|
|
{"file.TAR.GZ", true}, // case insensitive
|
|
{"file.TGZ", true},
|
|
{"archive.Tar.Gz", true},
|
|
{"", false},
|
|
{"file", false},
|
|
// Paths
|
|
{"path/to/file.tar.gz", true},
|
|
{"/absolute/path/file.tgz", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := e.CanHandle(tt.filename)
|
|
if got != tt.expected {
|
|
t.Errorf("CanHandle(%q) = %v; want %v", tt.filename, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBaseExtractorCanHandlePartialSuffix(t *testing.T) {
|
|
// Ensure that ".tar" doesn't match ".tar.gz"
|
|
e := NewBaseExtractor("tar", []string{".tar"})
|
|
if e.CanHandle("file.tar.gz") {
|
|
t.Error("CanHandle('file.tar.gz') should be false for extractor with '.tar' extension")
|
|
}
|
|
if !e.CanHandle("file.tar") {
|
|
t.Error("CanHandle('file.tar') should be true for extractor with '.tar' extension")
|
|
}
|
|
}
|
|
|
|
// --- Registry: Get ---
|
|
|
|
func TestRegistryGetSuccess(t *testing.T) {
|
|
formats := []string{"tar", "tar.gz", "tar.bz2", "zip"}
|
|
for _, format := range formats {
|
|
e, err := Get(format)
|
|
if err != nil {
|
|
t.Errorf("Get(%q) returned error: %v", format, err)
|
|
}
|
|
if e == nil {
|
|
t.Errorf("Get(%q) returned nil extractor", format)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetUnknown(t *testing.T) {
|
|
_, err := Get("unknown_format")
|
|
if err == nil {
|
|
t.Error("Get('unknown_format') should return an error")
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetEmptyFormat(t *testing.T) {
|
|
_, err := Get("")
|
|
if err == nil {
|
|
t.Error("Get('') should return an error")
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetCaseInsensitive(t *testing.T) {
|
|
// Registry should handle case-insensitive lookups
|
|
variants := []string{"TAR", "Tar", "tAr"}
|
|
for _, v := range variants {
|
|
e, err := Get(v)
|
|
if err != nil {
|
|
t.Errorf("Get(%q) returned error (should be case-insensitive): %v", v, err)
|
|
}
|
|
if e == nil || e.Name() != "tar" {
|
|
t.Errorf("Get(%q) returned extractor with name %q; want 'tar'", v, e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Registry: GetByFilename ---
|
|
|
|
func TestRegistryGetByFilename(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
wantName string
|
|
}{
|
|
{"file.tar", "tar"},
|
|
{"file.tar.gz", "tar.gz"},
|
|
{"file.tgz", "tar.gz"},
|
|
{"file.tar.bz2", "tar.bz2"},
|
|
{"file.tbz2", "tar.bz2"},
|
|
{"file.zip", "zip"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
e, err := GetByFilename(tt.filename)
|
|
if err != nil {
|
|
t.Errorf("GetByFilename(%q) returned error: %v", tt.filename, err)
|
|
continue
|
|
}
|
|
if e.Name() != tt.wantName {
|
|
t.Errorf("GetByFilename(%q).Name() = %q; want %q", tt.filename, e.Name(), tt.wantName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetByFilenameUnknown(t *testing.T) {
|
|
_, err := GetByFilename("file.txt")
|
|
if err == nil {
|
|
t.Error("GetByFilename('file.txt') should return an error")
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetByFilenameEmpty(t *testing.T) {
|
|
_, err := GetByFilename("")
|
|
if err == nil {
|
|
t.Error("GetByFilename('') should return an error")
|
|
}
|
|
}
|
|
|
|
// --- Registry: Supports ---
|
|
|
|
func TestRegistrySupports(t *testing.T) {
|
|
if !Supports("tar") {
|
|
t.Error("Supports('tar') should be true")
|
|
}
|
|
if !Supports("zip") {
|
|
t.Error("Supports('zip') should be true")
|
|
}
|
|
if Supports("rar") {
|
|
t.Error("Supports('rar') should be false")
|
|
}
|
|
if Supports("7z") {
|
|
t.Error("Supports('7z') should be false")
|
|
}
|
|
if Supports("unknown") {
|
|
t.Error("Supports('unknown') should be false")
|
|
}
|
|
}
|
|
|
|
func TestRegistrySupportsCaseInsensitive(t *testing.T) {
|
|
if !Supports("TAR") {
|
|
t.Error("Supports('TAR') should be true (case-insensitive)")
|
|
}
|
|
if !Supports("Zip") {
|
|
t.Error("Supports('Zip') should be true (case-insensitive)")
|
|
}
|
|
}
|
|
|
|
func TestRegistrySupportsEmpty(t *testing.T) {
|
|
if Supports("") {
|
|
t.Error("Supports('') should be false")
|
|
}
|
|
}
|
|
|
|
// --- Registry: ListSupported ---
|
|
|
|
func TestRegistryListSupported(t *testing.T) {
|
|
formats := ListSupported()
|
|
|
|
// Should contain at least our 4 standard formats
|
|
formatSet := make(map[string]bool)
|
|
for _, f := range formats {
|
|
formatSet[f] = true
|
|
}
|
|
|
|
expected := []string{"tar", "tar.gz", "tar.bz2", "zip"}
|
|
for _, exp := range expected {
|
|
if !formatSet[exp] {
|
|
t.Errorf("ListSupported() missing format: %s", exp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistryListSupportedNoDuplicates(t *testing.T) {
|
|
formats := ListSupported()
|
|
seen := make(map[string]bool)
|
|
for _, f := range formats {
|
|
if seen[f] {
|
|
t.Errorf("ListSupported() contains duplicate: %s", f)
|
|
}
|
|
seen[f] = true
|
|
}
|
|
}
|
|
|
|
// --- Registration ---
|
|
|
|
// testExtractor is a simple extractor used for registration tests
|
|
type testExtractor struct {
|
|
*BaseExtractor
|
|
}
|
|
|
|
func (e *testExtractor) Extract(_ io.Reader, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func TestRegisterDuplicate(t *testing.T) {
|
|
// All standard extractors are already registered via init()
|
|
names := []string{"tar", "tar.gz", "tar.bz2", "zip"}
|
|
for _, name := range names {
|
|
e := &testExtractor{NewBaseExtractor(name, []string{".test"})}
|
|
err := Register(e)
|
|
if err == nil {
|
|
t.Errorf("Register(%q) should return error for duplicate registration", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegisterSuccess(t *testing.T) {
|
|
// Use a unique name that won't conflict
|
|
e := &testExtractor{NewBaseExtractor("test_uniquereg", []string{".unique"})}
|
|
err := Register(e)
|
|
if err != nil {
|
|
t.Fatalf("Register() returned error: %v", err)
|
|
}
|
|
|
|
// Verify it was registered
|
|
if !Supports("test_uniquereg") {
|
|
t.Error("Supports('test_uniquereg') should be true after registration")
|
|
}
|
|
|
|
// Verify it can be retrieved
|
|
got, err := Get("test_uniquereg")
|
|
if err != nil {
|
|
t.Errorf("Get('test_uniquereg') returned error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Error("Get('test_uniquereg') returned nil")
|
|
}
|
|
}
|
|
|
|
func TestRegisterEmptyName(t *testing.T) {
|
|
e := &testExtractor{NewBaseExtractor("", []string{".ext"})}
|
|
err := Register(e)
|
|
if err == nil {
|
|
t.Error("Register() with empty name should return error")
|
|
}
|
|
}
|
|
|
|
func TestRegisterNilExtractor(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Log("Register(nil) did not panic (acceptable behavior)")
|
|
}
|
|
}()
|
|
_ = Register(nil)
|
|
}
|
|
|
|
// --- Extraction: Invalid Data ---
|
|
|
|
func TestExtractInvalidFormat(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
err := Extract("nonexistent", strings.NewReader("data"), destDir)
|
|
if err == nil {
|
|
t.Error("Extract with unknown format should return error")
|
|
}
|
|
}
|
|
|
|
func TestExtractInvalidTar(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
err := Extract("tar", strings.NewReader("this is not a tar file"), destDir)
|
|
if err == nil {
|
|
t.Error("Extract with invalid tar data should return error")
|
|
}
|
|
}
|
|
|
|
func TestExtractInvalidTarGz(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
err := Extract("tar.gz", strings.NewReader("this is not a gzip file"), destDir)
|
|
if err == nil {
|
|
t.Error("Extract with invalid tar.gz data should return error")
|
|
}
|
|
}
|
|
|
|
func TestExtractInvalidZip(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
err := Extract("zip", strings.NewReader("this is not a zip file"), destDir)
|
|
if err == nil {
|
|
t.Error("Extract with invalid zip data should return error")
|
|
}
|
|
}
|
|
|
|
// --- Extraction: Valid Tar ---
|
|
|
|
func TestExtractTar(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "testdir/", mode: 0755, typ: tar.TypeDir},
|
|
{name: "testdir/hello.txt", mode: 0644, content: []byte("hello world")},
|
|
})
|
|
|
|
err := Extract("tar", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract tar returned error: %v", err)
|
|
}
|
|
|
|
// Verify directory exists
|
|
dirInfo, err := os.Stat(filepath.Join(destDir, "testdir"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to stat extracted directory: %v", err)
|
|
}
|
|
if !dirInfo.IsDir() {
|
|
t.Error("Extracted path is not a directory")
|
|
}
|
|
|
|
// Verify file exists and has correct content
|
|
data, err = os.ReadFile(filepath.Join(destDir, "testdir", "hello.txt"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read extracted file: %v", err)
|
|
}
|
|
if string(data) != "hello world" {
|
|
t.Errorf("Extracted file content = %q; want %q", string(data), "hello world")
|
|
}
|
|
}
|
|
|
|
func TestExtractTarMultipleFiles(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "file1.txt", mode: 0644, content: []byte("content1")},
|
|
{name: "file2.txt", mode: 0644, content: []byte("content2")},
|
|
{name: "subdir/nested.txt", mode: 0644, content: []byte("nested content")},
|
|
})
|
|
|
|
err := Extract("tar", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract tar returned error: %v", err)
|
|
}
|
|
|
|
// Verify all files
|
|
checkFileContent(t, destDir, "file1.txt", "content1")
|
|
checkFileContent(t, destDir, "file2.txt", "content2")
|
|
checkFileContent(t, destDir, "subdir/nested.txt", "nested content")
|
|
}
|
|
|
|
func TestExtractTarEmptyArchive(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := Extract("tar", &buf, destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract tar (empty) returned error: %v", err)
|
|
}
|
|
|
|
// Directory should exist and be empty
|
|
entries, err := os.ReadDir(destDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read destDir: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Errorf("Expected empty directory, got %d entries", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestExtractTarSymlink(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "target.txt", mode: 0644, content: []byte("symlink target")},
|
|
{name: "link.txt", mode: 0777, typ: tar.TypeSymlink, linkname: "target.txt"},
|
|
})
|
|
|
|
err := Extract("tar", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract tar with symlink returned error: %v", err)
|
|
}
|
|
|
|
// Read through symlink
|
|
content, err := os.ReadFile(filepath.Join(destDir, "link.txt"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read symlink target: %v", err)
|
|
}
|
|
if string(content) != "symlink target" {
|
|
t.Errorf("Symlink content = %q; want %q", string(content), "symlink target")
|
|
}
|
|
}
|
|
|
|
// --- Extraction: Valid Tar.gz ---
|
|
|
|
func TestExtractTarGz(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
|
|
// Create tar data
|
|
tarData := createTar(t, []tarEntry{
|
|
{name: "compressed.txt", mode: 0644, content: []byte("compressed data")},
|
|
})
|
|
|
|
// Compress with gzip
|
|
gzipData := compressGzip(t, tarData)
|
|
|
|
// Extract
|
|
err := Extract("tar.gz", bytes.NewReader(gzipData), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract tar.gz returned error: %v", err)
|
|
}
|
|
|
|
// Verify
|
|
checkFileContent(t, destDir, "compressed.txt", "compressed data")
|
|
}
|
|
|
|
func TestExtractTarGzEmptyGzip(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
|
|
// Create tar data and gzip it, but the tar is empty
|
|
tarData := createTar(t, []tarEntry{})
|
|
gzipData := compressGzip(t, tarData)
|
|
|
|
err := Extract("tar.gz", bytes.NewReader(gzipData), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract tar.gz (empty) returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Extraction: Valid Zip ---
|
|
|
|
func TestExtractZip(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createZip(t, []zipEntry{
|
|
{name: "hello.txt", content: []byte("zip data")},
|
|
})
|
|
|
|
err := Extract("zip", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract zip returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "hello.txt", "zip data")
|
|
}
|
|
|
|
func TestExtractZipMultipleFiles(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createZip(t, []zipEntry{
|
|
{name: "f1.txt", content: []byte("file1")},
|
|
{name: "sub/f2.txt", content: []byte("file2")},
|
|
{name: "f3.txt", content: []byte("file3")},
|
|
})
|
|
|
|
err := Extract("zip", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract zip returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "f1.txt", "file1")
|
|
checkFileContent(t, destDir, "sub/f2.txt", "file2")
|
|
checkFileContent(t, destDir, "f3.txt", "file3")
|
|
}
|
|
|
|
func TestExtractZipDirectory(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createZip(t, []zipEntry{
|
|
{name: "adir/", content: nil, isDir: true},
|
|
{name: "adir/afile.txt", content: []byte("nested")},
|
|
})
|
|
|
|
err := Extract("zip", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract zip with directory returned error: %v", err)
|
|
}
|
|
|
|
dirInfo, err := os.Stat(filepath.Join(destDir, "adir"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to stat directory: %v", err)
|
|
}
|
|
if !dirInfo.IsDir() {
|
|
t.Error("Expected a directory")
|
|
}
|
|
|
|
checkFileContent(t, destDir, "adir/afile.txt", "nested")
|
|
}
|
|
|
|
func TestExtractZipEmpty(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := Extract("zip", &buf, destDir)
|
|
if err != nil {
|
|
t.Fatalf("Extract zip (empty) returned error: %v", err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(destDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read destDir: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Errorf("Expected empty directory, got %d entries", len(entries))
|
|
}
|
|
}
|
|
|
|
// --- AutoExtract ---
|
|
|
|
func TestAutoExtractTar(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "autotest.txt", mode: 0644, content: []byte("auto extract test")},
|
|
})
|
|
|
|
err := AutoExtract("test.tar", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("AutoExtract returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "autotest.txt", "auto extract test")
|
|
}
|
|
|
|
func TestAutoExtractTarGz(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
tarData := createTar(t, []tarEntry{
|
|
{name: "autogz.txt", mode: 0644, content: []byte("auto gz test")},
|
|
})
|
|
gzipData := compressGzip(t, tarData)
|
|
|
|
err := AutoExtract("test.tar.gz", bytes.NewReader(gzipData), destDir)
|
|
if err != nil {
|
|
t.Fatalf("AutoExtract tar.gz returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "autogz.txt", "auto gz test")
|
|
}
|
|
|
|
func TestAutoExtractZip(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createZip(t, []zipEntry{
|
|
{name: "autozip.txt", content: []byte("auto zip test")},
|
|
})
|
|
|
|
err := AutoExtract("test.zip", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("AutoExtract zip returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "autozip.txt", "auto zip test")
|
|
}
|
|
|
|
func TestAutoExtractUnknownFormat(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
err := AutoExtract("test.unknown", strings.NewReader("data"), destDir)
|
|
if err == nil {
|
|
t.Error("AutoExtract with unknown format should return error")
|
|
}
|
|
}
|
|
|
|
// --- ExtractByFilename ---
|
|
|
|
func TestExtractByFilenameTar(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "byfilename.txt", mode: 0644, content: []byte("extract by filename")},
|
|
})
|
|
|
|
err := ExtractByFilename("archive.tar", bytes.NewReader(data), destDir)
|
|
if err != nil {
|
|
t.Fatalf("ExtractByFilename returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "byfilename.txt", "extract by filename")
|
|
}
|
|
|
|
func TestExtractByFilenameTarGz(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
tarData := createTar(t, []tarEntry{
|
|
{name: "byfilename_gz.txt", mode: 0644, content: []byte("extract by filename gz")},
|
|
})
|
|
gzipData := compressGzip(t, tarData)
|
|
|
|
err := ExtractByFilename("archive.tar.gz", bytes.NewReader(gzipData), destDir)
|
|
if err != nil {
|
|
t.Fatalf("ExtractByFilename returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "byfilename_gz.txt", "extract by filename gz")
|
|
}
|
|
|
|
func TestExtractByFilenameUnknown(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
err := ExtractByFilename("unknown.xyz", strings.NewReader("data"), destDir)
|
|
if err == nil {
|
|
t.Error("ExtractByFilename with unknown format should return error")
|
|
}
|
|
}
|
|
|
|
// --- Extract helper (Extract / ExtractByFilename with matching/mismatched formats) ---
|
|
|
|
func TestExtractMismatchedFormat(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
// Create tar data, but try to extract it as zip - should fail
|
|
data := createTar(t, []tarEntry{
|
|
{name: "test.txt", mode: 0644, content: []byte("test")},
|
|
})
|
|
|
|
err := Extract("zip", bytes.NewReader(data), destDir)
|
|
if err == nil {
|
|
t.Error("Extract with format 'zip' on tar data should return error")
|
|
}
|
|
}
|
|
|
|
// --- ExtractFile (requires real file on disk) ---
|
|
|
|
func TestExtractFile(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
|
|
// Create a real tar file on disk
|
|
tarData := createTar(t, []tarEntry{
|
|
{name: "extractfile.txt", mode: 0644, content: []byte("extract file test")},
|
|
})
|
|
srcPath := filepath.Join(t.TempDir(), "test.tar")
|
|
if err := os.WriteFile(srcPath, tarData, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := ExtractFile(srcPath, destDir, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExtractFile returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "extractfile.txt", "extract file test")
|
|
}
|
|
|
|
func TestExtractFileWithConfig(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
|
|
tarData := createTar(t, []tarEntry{
|
|
{name: "withconfig.txt", mode: 0644, content: []byte("with config")},
|
|
})
|
|
srcPath := filepath.Join(t.TempDir(), "test.tar")
|
|
if err := os.WriteFile(srcPath, tarData, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg := DefaultExtractConfig()
|
|
cfg.DestDir = destDir
|
|
err := ExtractFile(srcPath, destDir, cfg)
|
|
if err != nil {
|
|
t.Fatalf("ExtractFile with config returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "withconfig.txt", "with config")
|
|
}
|
|
|
|
func TestExtractFileNonExistent(t *testing.T) {
|
|
err := ExtractFile("/nonexistent/path/file.tar", t.TempDir(), nil)
|
|
if err == nil {
|
|
t.Error("ExtractFile with nonexistent path should return error")
|
|
}
|
|
}
|
|
|
|
func TestExtractFileUnknownFormat(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
srcPath := filepath.Join(t.TempDir(), "file.unknown")
|
|
if err := os.WriteFile(srcPath, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := ExtractFile(srcPath, destDir, nil)
|
|
if err == nil {
|
|
t.Error("ExtractFile with unknown format should return error")
|
|
}
|
|
}
|
|
|
|
// --- ShouldExtractFile ---
|
|
|
|
func TestShouldExtractFile(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
expected bool
|
|
}{
|
|
{"file.tar", true},
|
|
{"file.tar.gz", true},
|
|
{"file.zip", true},
|
|
{"file.txt", false},
|
|
{"file", false},
|
|
}
|
|
for _, tt := range tests {
|
|
got := ShouldExtractFile(tt.filename)
|
|
if got != tt.expected {
|
|
t.Errorf("ShouldExtractFile(%q) = %v; want %v", tt.filename, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- ListSupportedFormats / SupportsFormat helpers ---
|
|
|
|
func TestListSupportedFormats(t *testing.T) {
|
|
formats := ListSupportedFormats()
|
|
formatSet := make(map[string]bool)
|
|
for _, f := range formats {
|
|
formatSet[f] = true
|
|
}
|
|
|
|
for _, expected := range []string{"tar", "tar.gz", "tar.bz2", "zip"} {
|
|
if !formatSet[expected] {
|
|
t.Errorf("ListSupportedFormats() missing: %s", expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSupportsFormat(t *testing.T) {
|
|
if !SupportsFormat("tar") {
|
|
t.Error("SupportsFormat('tar') should be true")
|
|
}
|
|
if SupportsFormat("rar") {
|
|
t.Error("SupportsFormat('rar') should be false")
|
|
}
|
|
}
|
|
|
|
// --- DefaultExtractConfig ---
|
|
|
|
func TestDefaultExtractConfig(t *testing.T) {
|
|
cfg := DefaultExtractConfig()
|
|
if cfg == nil {
|
|
t.Fatal("DefaultExtractConfig() returned nil")
|
|
}
|
|
if cfg.DestDir != "." {
|
|
t.Errorf("Default DestDir = %q; want '.'", cfg.DestDir)
|
|
}
|
|
if cfg.StripComponents != 0 {
|
|
t.Errorf("Default StripComponents = %d; want 0", cfg.StripComponents)
|
|
}
|
|
if cfg.IncludePatterns == nil {
|
|
t.Error("Default IncludePatterns is nil")
|
|
}
|
|
if cfg.ExcludePatterns == nil {
|
|
t.Error("Default ExcludePatterns is nil")
|
|
}
|
|
if !cfg.PreservePermissions {
|
|
t.Error("Default PreservePermissions should be true")
|
|
}
|
|
if !cfg.Overwrite {
|
|
t.Error("Default Overwrite should be true")
|
|
}
|
|
if cfg.Verbose {
|
|
t.Error("Default Verbose should be false")
|
|
}
|
|
}
|
|
|
|
func TestExtractWithNilConfig(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "nilconfig.txt", mode: 0644, content: []byte("nil config test")},
|
|
})
|
|
|
|
// Create a real file for ExtractFile
|
|
srcPath := filepath.Join(t.TempDir(), "test.tar")
|
|
if err := os.WriteFile(srcPath, data, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Passing nil config should use defaults (no panic)
|
|
err := ExtractFile(srcPath, destDir, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExtractFile with nil config returned error: %v", err)
|
|
}
|
|
|
|
checkFileContent(t, destDir, "nilconfig.txt", "nil config test")
|
|
}
|
|
|
|
// --- Tar-slip protection ---
|
|
|
|
func TestExtractTarSlipProtection(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createTar(t, []tarEntry{
|
|
{name: "../../../etc/passwd", mode: 0644, content: []byte("evil")},
|
|
})
|
|
|
|
err := Extract("tar", bytes.NewReader(data), destDir)
|
|
if err == nil {
|
|
t.Error("Extract tar with path traversal should return error")
|
|
}
|
|
}
|
|
|
|
// TestExtractTarHardlinkProtection is a regression guard for the BACKLOG
|
|
// entry "Tar hardlink path traversal via prefix collision". The
|
|
// concern was that a naive strings.HasPrefix(linkTarget, destDir+"/")
|
|
// would let a sibling directory such as /tmp/output_extra/secret through
|
|
// when destDir is /tmp/out (because "/tmp/output_extra/secret" does not
|
|
// start with "/tmp/out/" but the substring "out" does appear). Verify
|
|
// the existing protection handles the realistic attack vectors.
|
|
func TestExtractTarHardlinkProtection(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
linkname string
|
|
wantErr bool
|
|
}{
|
|
{"absolute path", "/etc/passwd", true},
|
|
{"parent escape via ..", "../etc/passwd", true},
|
|
{"nested parent escape", "subdir/../../etc/passwd", true},
|
|
{"legitimate relative link", "target.txt", false},
|
|
{"legitimate subdir link", "subdir/other.txt", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
// Create a target file inside destDir so legitimate links
|
|
// can resolve. For malicious cases the rejection happens
|
|
// before the link is created.
|
|
targetPath := filepath.Join(destDir, "target.txt")
|
|
if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subdir := filepath.Join(destDir, "subdir")
|
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otherPath := filepath.Join(subdir, "other.txt")
|
|
if err := os.WriteFile(otherPath, []byte("other"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data := createTar(t, []tarEntry{
|
|
{name: "link.txt", mode: 0644, typ: tar.TypeLink, linkname: tc.linkname},
|
|
})
|
|
|
|
err := Extract("tar", bytes.NewReader(data), destDir)
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("linkname=%q: expected error, got nil", tc.linkname)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("linkname=%q: unexpected error: %v", tc.linkname, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractZipSlipProtection(t *testing.T) {
|
|
destDir := t.TempDir()
|
|
data := createZip(t, []zipEntry{
|
|
{name: "../../../etc/passwd", content: []byte("evil")},
|
|
})
|
|
|
|
err := Extract("zip", bytes.NewReader(data), destDir)
|
|
if err == nil {
|
|
t.Error("Extract zip with path traversal should return error")
|
|
}
|
|
}
|
|
|
|
// TestZipBufferLimitExceeded verifies that archives larger than
|
|
// MaxZipBufferSize are rejected at the buffering stage, before they ever
|
|
// reach the zip parser.
|
|
func TestZipBufferLimitExceeded(t *testing.T) {
|
|
orig := MaxZipBufferSize
|
|
MaxZipBufferSize = 50
|
|
t.Cleanup(func() { MaxZipBufferSize = orig })
|
|
|
|
destDir := t.TempDir()
|
|
|
|
// Build a 200-byte archive (a tiny valid zip with a tiny payload).
|
|
data := createZip(t, []zipEntry{
|
|
{name: "small.txt", content: bytes.Repeat([]byte("y"), 200)},
|
|
})
|
|
|
|
err := Extract("zip", bytes.NewReader(data), destDir)
|
|
if err == nil {
|
|
t.Fatal("Extract should reject archive larger than MaxZipBufferSize")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
|
t.Errorf("error %q should mention size limit", err)
|
|
}
|
|
}
|
|
|
|
// Note: the UncompressedSize64 check in extractFile is the primary defence
|
|
// against zip-bomb archives that declare a huge uncompressed size in
|
|
// their central directory. We do not have a unit test for it because
|
|
// archive/zip.Writer always sets UncompressedSize64 to the true payload
|
|
// size, so producing a "lying" zip requires hand-crafting the binary
|
|
// header — fragile across Go stdlib versions and not worth the maintenance
|
|
// burden. The io.LimitReader safety net below the check is similarly
|
|
// untested; it covers archives whose central directory is unset (size 0)
|
|
// or forged, which is a vanishingly rare case in practice.
|
|
|
|
// --- Helpers ---
|
|
|
|
type tarEntry struct {
|
|
name string
|
|
mode int64
|
|
typ byte
|
|
content []byte
|
|
linkname string
|
|
}
|
|
|
|
func createTar(t *testing.T, entries []tarEntry) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
for _, e := range entries {
|
|
typ := e.typ
|
|
if typ == 0 {
|
|
if e.content == nil && strings.HasSuffix(e.name, "/") {
|
|
typ = tar.TypeDir
|
|
} else {
|
|
typ = tar.TypeReg
|
|
}
|
|
}
|
|
hdr := &tar.Header{
|
|
Name: e.name,
|
|
Mode: e.mode,
|
|
Size: int64(len(e.content)),
|
|
Typeflag: typ,
|
|
Linkname: e.linkname,
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(e.content) > 0 {
|
|
if _, err := tw.Write(e.content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
type zipEntry struct {
|
|
name string
|
|
content []byte
|
|
isDir bool
|
|
}
|
|
|
|
func createZip(t *testing.T, entries []zipEntry) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
|
|
for _, e := range entries {
|
|
var header *zip.FileHeader
|
|
if e.isDir || strings.HasSuffix(e.name, "/") {
|
|
header = &zip.FileHeader{
|
|
Name: e.name,
|
|
Method: zip.Store,
|
|
}
|
|
header.SetMode(0755 | os.ModeDir)
|
|
} else {
|
|
header = &zip.FileHeader{
|
|
Name: e.name,
|
|
Method: zip.Deflate,
|
|
}
|
|
header.SetMode(0644)
|
|
}
|
|
|
|
fw, err := zw.CreateHeader(header)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(e.content) > 0 {
|
|
if _, err := fw.Write(e.content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func compressGzip(t *testing.T, data []byte) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
gw := gzip.NewWriter(&buf)
|
|
if _, err := gw.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func checkFileContent(t *testing.T, destDir, relPath, wantContent string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(destDir, relPath))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read %s: %v", relPath, err)
|
|
}
|
|
if string(data) != wantContent {
|
|
t.Errorf("Content of %s = %q; want %q", relPath, string(data), wantContent)
|
|
}
|
|
}
|