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