From 4df9a0ce101c188d88edb30159bcfb274b55700a Mon Sep 17 00:00:00 2001 From: Thomas Bechtold Date: Wed, 25 Mar 2026 20:16:55 +0100 Subject: [PATCH] feat(archive): add extractor interface and zip/tar implementations Introduce the internal/archive package with a generic Extractor interface for extracting archive contents to a destination directory. Includes two implementations: - ZipExtractor: handles zip-based archives (.zip, .jar, .war, etc.) - TarExtractor: handles tar-based archives (.tar, .tar.gz, .tar.bz2, etc.) Both extractors enforce configurable safety limits and protect against common malicious-archive vectors: - Per-archive extraction size limit - Per-archive file count limit - Zip-slip path traversal prevention via existing SafeJoin - Symlink entries are written as real symlinks only when their target resolves inside destDir; symlinks pointing outside are dropped to avoid escape via symlink-then-write - Non-regular non-symlink entries (devices, fifos, sockets) are skipped because their contents can't safely round-trip to disk Also provides FindExtractor for auto-detecting archive format, and IsExcludedExtension for filtering by extension. Signed-off-by: Thomas Bechtold --- internal/archive/extractor.go | 285 +++++++++++++++++++ internal/archive/extractor_test.go | 432 +++++++++++++++++++++++++++++ 2 files changed, 717 insertions(+) create mode 100644 internal/archive/extractor.go create mode 100644 internal/archive/extractor_test.go diff --git a/internal/archive/extractor.go b/internal/archive/extractor.go new file mode 100644 index 000000000..96525570d --- /dev/null +++ b/internal/archive/extractor.go @@ -0,0 +1,285 @@ +package archive + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/mholt/archives" + + intFile "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/cataloging" +) + +// Extractor extracts archive contents to a destination directory. +type Extractor interface { + // CanExtract returns true if this extractor can handle the given file. + CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool + + // Extract extracts the archive contents to destDir, respecting the given limits. + // Returns the number of files extracted and total bytes written. + Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) +} + +// ExtractionLimits defines safety limits for archive extraction. +type ExtractionLimits struct { + MaxExtractionSizeBytes int64 + MaxFileCount int +} + +// ExtractionResult holds the result of an extraction operation. +type ExtractionResult struct { + FilesExtracted int + BytesWritten int64 +} + +// DefaultExtractionLimits returns limits from the given config. +func DefaultExtractionLimits(cfg cataloging.ArchiveSearchConfig) ExtractionLimits { + return ExtractionLimits{ + MaxExtractionSizeBytes: cfg.MaxExtractionSizeBytes, + MaxFileCount: cfg.MaxFileCount, + } +} + +// DefaultExtractors returns the set of built-in archive extractors. +func DefaultExtractors() []Extractor { + return []Extractor{ + &ZipExtractor{}, + &TarExtractor{}, + } +} + +// FindExtractor finds an extractor that can handle the given file, or nil if none can. +func FindExtractor(ctx context.Context, extractors []Extractor, path string) Extractor { + f, err := os.Open(path) + if err != nil { + log.Tracef("unable to open file for archive detection: %v", err) + return nil + } + defer f.Close() + + for _, ext := range extractors { + if ext.CanExtract(ctx, path, f) { + return ext + } + // reset for next extractor + if _, err := f.Seek(0, io.SeekStart); err != nil { + log.Tracef("unable to seek file for archive detection: %v", err) + return nil + } + } + return nil +} + +// IsExcludedExtension checks if a file path has an excluded extension. +func IsExcludedExtension(path string, excludeExtensions []string) bool { + for _, ext := range excludeExtensions { + if strings.HasSuffix(strings.ToLower(path), strings.ToLower(ext)) { + return true + } + } + return false +} + +// ZipExtractor extracts zip-based archives (zip, jar, war, ear, etc.). +type ZipExtractor struct{} + +func (z *ZipExtractor) CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool { + format, _, err := intFile.IdentifyArchive(ctx, path, reader) + if err != nil { + return false + } + _, ok := format.(archives.Zip) + return ok +} + +func (z *ZipExtractor) Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) { + var result ExtractionResult + + f, err := os.Open(path) + if err != nil { + return result, fmt.Errorf("unable to open zip archive %q: %w", path, err) + } + defer f.Close() + + return result, archives.Zip{}.Extract(ctx, f, func(_ context.Context, file archives.FileInfo) error { + if file.IsDir() { + joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) + if err != nil { + return err + } + return os.MkdirAll(joinedPath, file.Mode()) + } + + if limits.MaxFileCount > 0 && result.FilesExtracted >= limits.MaxFileCount { + return fmt.Errorf("archive file count limit reached (%d files)", limits.MaxFileCount) + } + + joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(joinedPath), 0o755); err != nil { + return fmt.Errorf("unable to create parent dir: %w", err) + } + + return extractFileWithLimits(file, joinedPath, destDir, &result, limits) + }) +} + +// TarExtractor extracts tar-based archives (tar, tar.gz, tar.bz2, tar.xz, tar.zst). +type TarExtractor struct{} + +func (t *TarExtractor) CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool { + format, _, err := intFile.IdentifyArchive(ctx, path, reader) + if err != nil { + return false + } + // mholt/archives returns a compound type for tar+compression, check if it's an extractor but not a zip + if _, isZip := format.(archives.Zip); isZip { + return false + } + _, ok := format.(archives.Extractor) + return ok +} + +func (t *TarExtractor) Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) { + var result ExtractionResult + + f, err := os.Open(path) + if err != nil { + return result, fmt.Errorf("unable to open tar archive %q: %w", path, err) + } + defer f.Close() + + format, readerAfterIdentify, err := intFile.IdentifyArchive(ctx, path, f) + if err != nil { + return result, fmt.Errorf("unable to identify archive format for %q: %w", path, err) + } + + extractor, ok := format.(archives.Extractor) + if !ok { + return result, fmt.Errorf("file format does not support extraction: %s", path) + } + + // use the reader returned by IdentifyArchive since it may have consumed some bytes + extractReader := readerAfterIdentify + if extractReader == nil { + extractReader = f + } + + return result, extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error { + if file.IsDir() { + joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) + if err != nil { + return err + } + return os.MkdirAll(joinedPath, file.Mode()) + } + + if limits.MaxFileCount > 0 && result.FilesExtracted >= limits.MaxFileCount { + return fmt.Errorf("archive file count limit reached (%d files)", limits.MaxFileCount) + } + + joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(joinedPath), 0o755); err != nil { + return fmt.Errorf("unable to create parent dir: %w", err) + } + + return extractFileWithLimits(file, joinedPath, destDir, &result, limits) + }) +} + +// extractFileWithLimits extracts a single file from an archive, respecting size limits. +// Symlinks are written as real symlinks (only when the target stays inside destDir); +// non-regular file types other than symlinks are skipped to avoid writing bogus content. +func extractFileWithLimits(file archives.FileInfo, destPath, destDir string, result *ExtractionResult, limits ExtractionLimits) error { + mode := file.Mode() + if mode.Type()&fs.ModeSymlink != 0 { + if err := writeSafeSymlink(file.LinkTarget, destPath, destDir); err != nil { + log.WithFields("entry", file.NameInArchive, "target", file.LinkTarget, "error", err).Debug("skipping unsafe symlink in archive") + return nil + } + result.FilesExtracted++ + return nil + } + if !mode.IsRegular() { + // skip device nodes, fifos, sockets, etc. — they can't safely round-trip + log.WithFields("entry", file.NameInArchive, "mode", mode).Debug("skipping non-regular archive entry") + return nil + } + + src, err := file.Open() + if err != nil { + return fmt.Errorf("unable to open archive entry %q: %w", file.NameInArchive, err) + } + defer src.Close() + + dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("unable to create dest file %q: %w", destPath, err) + } + defer dst.Close() + + var reader io.Reader = src + + // enforce per-archive total size limit + if limits.MaxExtractionSizeBytes > 0 { + remaining := limits.MaxExtractionSizeBytes - result.BytesWritten + if remaining <= 0 { + return fmt.Errorf("archive extraction size limit reached (%d bytes)", limits.MaxExtractionSizeBytes) + } + reader = io.LimitReader(src, remaining+1) // +1 to detect exceeding the limit + } + + n, err := io.Copy(dst, reader) + result.BytesWritten += n + result.FilesExtracted++ + + if limits.MaxExtractionSizeBytes > 0 && result.BytesWritten > limits.MaxExtractionSizeBytes { + return fmt.Errorf("archive extraction size limit reached (%d bytes)", limits.MaxExtractionSizeBytes) + } + + return err +} + +// writeSafeSymlink creates a symlink at destPath only if target is relative and +// resolves — relative to destPath's directory — to a location inside destDir. +// +// Absolute targets are rejected outright: os.Symlink writes the literal target +// string, so an absolute target like "/etc/passwd" would resolve on the host +// filesystem when read, regardless of any safety check we performed against +// destDir at extraction time. +// +// NOTE: this guards against the single-symlink-escape pattern, but a malicious +// archive can still attempt a multi-step chain where two cooperating link +// entries together resolve outside destDir (e.g., one entry creates a benign +// link, a later entry writes through it). Fully preventing that requires +// either openat/O_NOFOLLOW per path component during extraction or +// transitively resolving every newly-created symlink against all prior ones +// — neither is implemented here. +func writeSafeSymlink(target, destPath, destDir string) error { + if target == "" { + return fmt.Errorf("empty link target") + } + if filepath.IsAbs(target) { + return fmt.Errorf("absolute symlink target not allowed") + } + resolved := filepath.Join(filepath.Dir(destPath), target) + cleanRoot := filepath.Clean(destDir) + cleanResolved := filepath.Clean(resolved) + if cleanResolved != cleanRoot && !strings.HasPrefix(cleanResolved, cleanRoot+string(filepath.Separator)) { + return fmt.Errorf("symlink target escapes extraction root") + } + return os.Symlink(target, destPath) +} diff --git a/internal/archive/extractor_test.go b/internal/archive/extractor_test.go new file mode 100644 index 000000000..b244f3dcd --- /dev/null +++ b/internal/archive/extractor_test.go @@ -0,0 +1,432 @@ +package archive + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/cataloging" +) + +func createTestZip(t *testing.T, dir string, files map[string]string) string { + t.Helper() + zipPath := filepath.Join(dir, "test.zip") + f, err := os.Create(zipPath) + require.NoError(t, err) + defer f.Close() + + w := zip.NewWriter(f) + for name, content := range files { + fw, err := w.Create(name) + require.NoError(t, err) + _, err = fw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return zipPath +} + +func createTestTarGz(t *testing.T, dir string, files map[string]string) string { + t.Helper() + tarPath := filepath.Join(dir, "test.tar.gz") + f, err := os.Create(tarPath) + require.NoError(t, err) + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err = tw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return tarPath +} + +func TestZipExtractor_CanExtract(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"}) + + ext := &ZipExtractor{} + + f, err := os.Open(zipPath) + require.NoError(t, err) + defer f.Close() + + assert.True(t, ext.CanExtract(ctx, zipPath, f)) + + // non-zip file + nonZipPath := filepath.Join(dir, "notazip.txt") + require.NoError(t, os.WriteFile(nonZipPath, []byte("just text"), 0o644)) + + f2, err := os.Open(nonZipPath) + require.NoError(t, err) + defer f2.Close() + + assert.False(t, ext.CanExtract(ctx, nonZipPath, f2)) +} + +func TestZipExtractor_Extract(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + files := map[string]string{ + "file1.txt": "content1", + "dir/file2.txt": "content2", + } + zipPath := createTestZip(t, dir, files) + + ext := &ZipExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + result, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{}) + require.NoError(t, err) + assert.Equal(t, 2, result.FilesExtracted) + + // verify extracted files + content, err := os.ReadFile(filepath.Join(destDir, "file1.txt")) + require.NoError(t, err) + assert.Equal(t, "content1", string(content)) + + content, err = os.ReadFile(filepath.Join(destDir, "dir", "file2.txt")) + require.NoError(t, err) + assert.Equal(t, "content2", string(content)) +} + +func TestZipExtractor_Extract_FileLimitReached(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + files := map[string]string{ + "file1.txt": "a", + "file2.txt": "b", + "file3.txt": "c", + } + zipPath := createTestZip(t, dir, files) + + ext := &ZipExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + _, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{MaxFileCount: 2}) + require.Error(t, err) + assert.Contains(t, err.Error(), "file count limit") +} + +func TestZipExtractor_Extract_SizeLimitReached(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + files := map[string]string{ + "file1.txt": strings.Repeat("x", 100), + } + zipPath := createTestZip(t, dir, files) + + ext := &ZipExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + _, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{MaxExtractionSizeBytes: 50}) + require.Error(t, err) + assert.Contains(t, err.Error(), "size limit") +} + +func TestTarExtractor_CanExtract(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + tarPath := createTestTarGz(t, dir, map[string]string{"hello.txt": "world"}) + + ext := &TarExtractor{} + + f, err := os.Open(tarPath) + require.NoError(t, err) + defer f.Close() + + assert.True(t, ext.CanExtract(ctx, tarPath, f)) +} + +func TestTarExtractor_Extract(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + files := map[string]string{ + "file1.txt": "content1", + "dir/file2.txt": "content2", + } + tarPath := createTestTarGz(t, dir, files) + + ext := &TarExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + result, err := ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + require.NoError(t, err) + assert.Equal(t, 2, result.FilesExtracted) + + // verify extracted files + content, err := os.ReadFile(filepath.Join(destDir, "file1.txt")) + require.NoError(t, err) + assert.Equal(t, "content1", string(content)) + + content, err = os.ReadFile(filepath.Join(destDir, "dir", "file2.txt")) + require.NoError(t, err) + assert.Equal(t, "content2", string(content)) +} + +func TestFindExtractor(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + extractors := DefaultExtractors() + + // zip file should match + zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"}) + ext := FindExtractor(ctx, extractors, zipPath) + require.NotNil(t, ext) + assert.IsType(t, &ZipExtractor{}, ext) + + // tar.gz should match + tarPath := createTestTarGz(t, dir, map[string]string{"hello.txt": "world"}) + ext = FindExtractor(ctx, extractors, tarPath) + require.NotNil(t, ext) + assert.IsType(t, &TarExtractor{}, ext) + + // non-archive should return nil + textPath := filepath.Join(dir, "plain.txt") + require.NoError(t, os.WriteFile(textPath, []byte("just text"), 0o644)) + ext = FindExtractor(ctx, extractors, textPath) + assert.Nil(t, ext) +} + +func TestIsExcludedExtension(t *testing.T) { + assert.True(t, IsExcludedExtension("/path/to/file.rpm", []string{".rpm", ".deb"})) + assert.True(t, IsExcludedExtension("/path/to/file.deb", []string{".rpm", ".deb"})) + assert.False(t, IsExcludedExtension("/path/to/file.zip", []string{".rpm", ".deb"})) + assert.False(t, IsExcludedExtension("/path/to/file.zip", nil)) +} + +func TestFindExtractor_NoReaders(t *testing.T) { + ctx := context.Background() + ext := FindExtractor(ctx, DefaultExtractors(), "/nonexistent/path") + assert.Nil(t, ext) +} + +func TestTarExtractor_CanExtract_NonTarFile(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + // create a plain text file + path := filepath.Join(dir, "plain.txt") + require.NoError(t, os.WriteFile(path, []byte("not a tar"), 0o644)) + + ext := &TarExtractor{} + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + + assert.False(t, ext.CanExtract(ctx, path, f)) +} + +func TestTarExtractor_CanExtract_ZipFileReturnsFalse(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"}) + + ext := &TarExtractor{} + f, err := os.Open(zipPath) + require.NoError(t, err) + defer f.Close() + + // tar extractor should NOT claim to handle zip files + assert.False(t, ext.CanExtract(ctx, zipPath, f)) +} + +func TestDefaultExtractionLimits(t *testing.T) { + cfg := cataloging.ArchiveSearchConfig{ + MaxExtractionSizeBytes: 1024, + MaxFileCount: 50, + } + limits := DefaultExtractionLimits(cfg) + assert.Equal(t, int64(1024), limits.MaxExtractionSizeBytes) + assert.Equal(t, 50, limits.MaxFileCount) +} + +func TestZipExtractor_Extract_ZipSlipPrevented(t *testing.T) { + // Create a zip with a path traversal entry + dir := t.TempDir() + ctx := context.Background() + + zipPath := filepath.Join(dir, "evil.zip") + f, err := os.Create(zipPath) + require.NoError(t, err) + + w := zip.NewWriter(f) + fw, err := w.Create("../../etc/passwd") + require.NoError(t, err) + _, err = fw.Write([]byte("evil")) + require.NoError(t, err) + require.NoError(t, w.Close()) + require.NoError(t, f.Close()) + + ext := &ZipExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + _, err = ext.Extract(ctx, zipPath, destDir, ExtractionLimits{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") + + // verify evil file was not created + _, statErr := os.Stat(filepath.Join(dir, "etc", "passwd")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestTarExtractor_Extract_SymlinkInsideRootIsCreated(t *testing.T) { + // A tar archive with a symlink whose target stays inside the extraction + // directory should be written as a real symlink, not as a regular file + // containing the link target as bytes. + dir := t.TempDir() + ctx := context.Background() + + tarPath := filepath.Join(dir, "with-link.tar.gz") + f, err := os.Create(tarPath) + require.NoError(t, err) + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + target := []byte("real content") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "real.txt", + Mode: 0o644, + Size: int64(len(target)), + })) + _, err = tw.Write(target) + require.NoError(t, err) + + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "link.txt", + Linkname: "real.txt", + Typeflag: tar.TypeSymlink, + Mode: 0o777, + })) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + require.NoError(t, f.Close()) + + ext := &TarExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + _, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + require.NoError(t, err) + + // the link should be a real symlink + info, err := os.Lstat(filepath.Join(destDir, "link.txt")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&fs.ModeSymlink, "expected link.txt to be a symlink, got mode %v", info.Mode()) + + // reading through the link should return the target's content + readBack, err := os.ReadFile(filepath.Join(destDir, "link.txt")) + require.NoError(t, err) + assert.Equal(t, "real content", string(readBack)) +} + +func TestTarExtractor_Extract_SymlinkEscapingRootIsSkipped(t *testing.T) { + // A symlink whose target resolves outside the extraction directory must not + // be created — neither as a symlink nor as a regular file. Extraction itself + // should succeed (the unsafe entry is skipped, not fatal). + dir := t.TempDir() + ctx := context.Background() + + tarPath := filepath.Join(dir, "evil-link.tar.gz") + f, err := os.Create(tarPath) + require.NoError(t, err) + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "passwd", + Linkname: "../../../../etc/passwd", + Typeflag: tar.TypeSymlink, + Mode: 0o777, + })) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + require.NoError(t, f.Close()) + + ext := &TarExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + _, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + require.NoError(t, err) + + // nothing should have been created at the destination + _, statErr := os.Lstat(filepath.Join(destDir, "passwd")) + assert.True(t, os.IsNotExist(statErr), "expected no entry to be created for escaping symlink") +} + +func TestTarExtractor_Extract_AbsoluteSymlinkTargetIsSkipped(t *testing.T) { + // Absolute symlink targets must be rejected: os.Symlink writes the literal + // target, so once the symlink is read it resolves on the host filesystem + // regardless of any safety check at extraction time. Verifying both that + // the symlink itself is not created and (importantly) that nothing was + // written under destDir along the absolute path. + dir := t.TempDir() + ctx := context.Background() + + tarPath := filepath.Join(dir, "absolute-link.tar.gz") + f, err := os.Create(tarPath) + require.NoError(t, err) + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "shadow", + Linkname: "/etc/shadow", + Typeflag: tar.TypeSymlink, + Mode: 0o777, + })) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + require.NoError(t, f.Close()) + + ext := &TarExtractor{} + destDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + _, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + require.NoError(t, err) + + // the symlink itself must not exist + _, statErr := os.Lstat(filepath.Join(destDir, "shadow")) + assert.True(t, os.IsNotExist(statErr), "expected absolute-target symlink to be skipped") +}