Files

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)
}
}