mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 22:12:28 +01:00
Add tests for important orchestrator edge cases: - Nested archives (zip-inside-zip): verifies multi-round extraction discovers archives within previously-extracted archives - Context cancellation: graceful handling without hanging - Multiple archives at same level: both extracted in one round - Corrupt archive: skipped without error, doesn't block other work - Empty archive: not counted as extracted - Per-archive file limit: partial extraction handled correctly - Relationship structure: contains relationships emit the archive's coordinates as the source side Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
538 lines
15 KiB
Go
538 lines
15 KiB
Go
package archive
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/anchore/syft/syft/cataloging"
|
|
"github.com/anchore/syft/syft/file"
|
|
)
|
|
|
|
// testResolverFactory creates a simple mock resolver factory for testing.
|
|
func testResolverFactory(root string) (file.Resolver, error) {
|
|
// Walk the directory and create a mock resolver from its contents
|
|
files := make(map[string]string)
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return err
|
|
}
|
|
relPath, _ := filepath.Rel(root, path)
|
|
content, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
files["/"+relPath] = string(content)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newMockResolver(files), nil
|
|
}
|
|
|
|
func TestOrchestrator_DiscoverAndExtract_ZipArchive(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
// Create a zip file in the "source" directory
|
|
zipFiles := map[string]string{
|
|
"inner.txt": "hello from inside the zip",
|
|
"lib/a.so": "library content",
|
|
}
|
|
zipPath := createTestZip(t, dir, zipFiles)
|
|
|
|
// Create a parent resolver that sees the zip file
|
|
parentFiles := map[string]string{
|
|
"/test.zip": "placeholder", // the resolver sees the file
|
|
}
|
|
parent := newMockResolver(parentFiles)
|
|
|
|
// Override FileContentsByLocation to return actual zip content
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
parent.contents["/test.zip"] = string(zipContent)
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 1, newCount)
|
|
|
|
// the composite resolver should now have the child files
|
|
resolver := orch.Resolver()
|
|
assert.Equal(t, 1, resolver.ChildCount())
|
|
}
|
|
|
|
func TestOrchestrator_DiscoverAndExtract_TarGzArchive(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
tarFiles := map[string]string{
|
|
"inner.txt": "hello from tar",
|
|
}
|
|
tarPath := createTestTarGz(t, dir, tarFiles)
|
|
|
|
tarContent, err := os.ReadFile(tarPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.tar.gz": string(tarContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeUnindexedArchives = true // tar needs this enabled
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 1, newCount)
|
|
assert.Equal(t, 1, orch.Resolver().ChildCount())
|
|
}
|
|
|
|
func TestOrchestrator_DiscoverAndExtract_DepthLimit(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/file.txt": "just a file",
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
// at depth 1 (== MaxDepth), should extract nothing
|
|
newCount := orch.DiscoverAndExtract(ctx, 1)
|
|
assert.Equal(t, 0, newCount)
|
|
}
|
|
|
|
func TestOrchestrator_DiscoverAndExtract_NoArchives(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/file.txt": "plain text",
|
|
"/data.csv": "a,b,c",
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 3
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, newCount)
|
|
assert.Equal(t, 0, orch.Resolver().ChildCount())
|
|
}
|
|
|
|
func TestOrchestrator_DiscoverAndExtract_ExcludeExtension(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
cfg.ExcludeExtensions = []string{".zip"}
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, newCount)
|
|
}
|
|
|
|
func TestOrchestrator_DiscoverAndExtract_TotalSizeLimit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
cfg.MaxTotalExtractionBytes = 1 // 1 byte total limit - will be exceeded
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
// should still extract but hit the limit
|
|
_ = orch.DiscoverAndExtract(ctx, 0)
|
|
// second call should be blocked by total limit
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, newCount)
|
|
}
|
|
|
|
func TestOrchestrator_Relationships(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
orch.DiscoverAndExtract(ctx, 0)
|
|
|
|
rels := orch.Relationships()
|
|
assert.NotEmpty(t, rels)
|
|
|
|
// should have a contains relationship for the archive
|
|
for _, rel := range rels {
|
|
assert.Equal(t, "contains", string(rel.Type))
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_Cleanup(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
|
|
orch.DiscoverAndExtract(ctx, 0)
|
|
|
|
// verify temp dirs exist
|
|
entries, err := os.ReadDir(tmpDir)
|
|
require.NoError(t, err)
|
|
initialCount := len(entries)
|
|
assert.True(t, initialCount > 0, "expected temp dirs to be created")
|
|
|
|
orch.Cleanup()
|
|
|
|
// verify temp dirs are cleaned up
|
|
entries, err = os.ReadDir(tmpDir)
|
|
require.NoError(t, err)
|
|
assert.True(t, len(entries) < initialCount, "expected temp dirs to be cleaned up")
|
|
}
|
|
|
|
func TestOrchestrator_DoesNotReprocess(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 2
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
// first call extracts the archive
|
|
first := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 1, first)
|
|
|
|
// second call should not re-extract the same archive
|
|
second := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, second)
|
|
assert.Equal(t, 1, orch.Resolver().ChildCount())
|
|
}
|
|
|
|
func TestOrchestrator_NestedArchives_ZipInsideZip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
// Create inner zip
|
|
innerZipPath := createTestZip(t, dir, map[string]string{
|
|
"deep.txt": "deeply nested content",
|
|
})
|
|
innerZipContent, err := os.ReadFile(innerZipPath)
|
|
require.NoError(t, err)
|
|
|
|
// Create outer zip containing the inner zip
|
|
outerDir := filepath.Join(dir, "outer")
|
|
require.NoError(t, os.MkdirAll(outerDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(outerDir, "inner.zip"), innerZipContent, 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(outerDir, "readme.txt"), []byte("readme"), 0o644))
|
|
outerZipPath := createTestZip(t, outerDir, map[string]string{
|
|
"inner.zip": string(innerZipContent),
|
|
"readme.txt": "readme",
|
|
})
|
|
outerZipContent, err := os.ReadFile(outerZipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/outer.zip": string(outerZipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 3
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
// Round 1: extract outer.zip
|
|
count1 := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 1, count1, "should extract outer.zip")
|
|
assert.Equal(t, 1, orch.Resolver().ChildCount())
|
|
|
|
// Round 2: extract inner.zip found inside outer.zip
|
|
count2 := orch.DiscoverAndExtract(ctx, 1)
|
|
assert.Equal(t, 1, count2, "should extract inner.zip from within outer.zip")
|
|
assert.Equal(t, 2, orch.Resolver().ChildCount())
|
|
|
|
// Round 3: no more archives to extract
|
|
count3 := orch.DiscoverAndExtract(ctx, 2)
|
|
assert.Equal(t, 0, count3, "no more archives to extract")
|
|
}
|
|
|
|
func TestOrchestrator_ContextCancellation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
// should handle gracefully without hanging
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.True(t, newCount >= 0) // may be 0 or 1 depending on timing
|
|
}
|
|
|
|
func TestOrchestrator_MultipleArchivesSameLevel(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zip1Path := createTestZip(t, dir, map[string]string{"file1.txt": "from zip1"})
|
|
zip1Content, err := os.ReadFile(zip1Path)
|
|
require.NoError(t, err)
|
|
|
|
// Need a different dir to avoid overwriting test.zip
|
|
dir2 := t.TempDir()
|
|
zip2Path := createTestZip(t, dir2, map[string]string{"file2.txt": "from zip2"})
|
|
zip2Content, err := os.ReadFile(zip2Path)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/archive1.zip": string(zip1Content),
|
|
"/archive2.zip": string(zip2Content),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 2, newCount, "should extract both archives")
|
|
assert.Equal(t, 2, orch.Resolver().ChildCount())
|
|
}
|
|
|
|
func TestOrchestrator_CorruptArchiveSkipped(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
// A file with .zip extension but invalid content
|
|
parent := newMockResolver(map[string]string{
|
|
"/corrupt.zip": "this is not a valid zip file",
|
|
"/normal.txt": "just text",
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
// Should not panic or error out; corrupt archives should be skipped
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, newCount, "corrupt archive should be skipped")
|
|
}
|
|
|
|
func TestOrchestrator_EmptyArchive(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
// Create an empty zip
|
|
emptyZipPath := createTestZip(t, dir, map[string]string{})
|
|
emptyZipContent, err := os.ReadFile(emptyZipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/empty.zip": string(emptyZipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, newCount, "empty archive should not count as extracted")
|
|
}
|
|
|
|
func TestOrchestrator_PerArchiveFileLimitApplied(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
// Create a zip with many files
|
|
files := map[string]string{}
|
|
for i := 0; i < 10; i++ {
|
|
files[filepath.Join("dir", string(rune('a'+i))+".txt")] = "content"
|
|
}
|
|
zipPath := createTestZip(t, dir, files)
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/big.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
cfg.MaxFileCount = 3 // only allow 3 files to be extracted
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
// Should partially extract but still register the archive
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
// Extraction will fail after 3 files, but partial extraction counts
|
|
assert.True(t, newCount >= 0)
|
|
}
|
|
|
|
func TestOrchestrator_RelationshipsHaveArchiveCoordinates(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"file.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/archive.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = true
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
orch.DiscoverAndExtract(ctx, 0)
|
|
|
|
rels := orch.Relationships()
|
|
require.Len(t, rels, 1)
|
|
|
|
// verify the relationship structure
|
|
rel := rels[0]
|
|
assert.Equal(t, "contains", string(rel.Type))
|
|
|
|
// From should be the archive's coordinates
|
|
fromCoords, ok := rel.From.(file.Coordinates)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "/archive.zip", fromCoords.RealPath)
|
|
}
|
|
|
|
func TestOrchestrator_ZipDisabledByConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tmpDir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
zipPath := createTestZip(t, dir, map[string]string{"inner.txt": "data"})
|
|
zipContent, err := os.ReadFile(zipPath)
|
|
require.NoError(t, err)
|
|
|
|
parent := newMockResolver(map[string]string{
|
|
"/test.zip": string(zipContent),
|
|
})
|
|
|
|
cfg := cataloging.DefaultArchiveSearchConfig()
|
|
cfg.MaxDepth = 1
|
|
cfg.IncludeIndexedArchives = false // disable zip extraction
|
|
|
|
orch := NewOrchestrator(parent, cfg, tmpDir, testResolverFactory)
|
|
defer orch.Cleanup()
|
|
|
|
newCount := orch.DiscoverAndExtract(ctx, 0)
|
|
assert.Equal(t, 0, newCount)
|
|
}
|