diff --git a/internal/archive/resolver.go b/internal/archive/resolver.go new file mode 100644 index 000000000..a9baddd85 --- /dev/null +++ b/internal/archive/resolver.go @@ -0,0 +1,274 @@ +package archive + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "sync" + + "github.com/anchore/syft/syft/file" +) + +const ( + // ArchivePathSeparator is used to encode nested archive paths in AccessPath, + // matching the convention established by the Java cataloger. + ArchivePathSeparator = ":" +) + +// childResolver holds a resolver for an extracted archive along with metadata about the archive. +type childResolver struct { + resolver file.Resolver + archiveLocation file.Location // where the archive file lives in the parent resolver + fsID string // unique filesystem ID for this archive + depth int // nesting depth (0 = base filesystem) +} + +// CompositeResolver implements file.Resolver by compositing a parent resolver with +// child resolvers created from extracted archive contents. Files from child resolvers +// appear transparently alongside files from the parent. +type CompositeResolver struct { + parent file.Resolver + children []*childResolver + mu sync.RWMutex +} + +var _ file.Resolver = (*CompositeResolver)(nil) + +// NewCompositeResolver creates a new CompositeResolver wrapping the given parent resolver. +func NewCompositeResolver(parent file.Resolver) *CompositeResolver { + return &CompositeResolver{ + parent: parent, + } +} + +// AddChild registers a child resolver for an extracted archive. +func (r *CompositeResolver) AddChild(resolver file.Resolver, archiveLocation file.Location, depth int) string { + fsID := generateArchiveFSID(archiveLocation) + r.mu.Lock() + defer r.mu.Unlock() + r.children = append(r.children, &childResolver{ + resolver: resolver, + archiveLocation: archiveLocation, + fsID: fsID, + depth: depth, + }) + return fsID +} + +// ChildCount returns the number of child resolvers registered. +func (r *CompositeResolver) ChildCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.children) +} + +// HasPath checks if the path exists in the parent or any child resolver. +func (r *CompositeResolver) HasPath(path string) bool { + if r.parent.HasPath(path) { + return true + } + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + if child.resolver.HasPath(path) { + return true + } + } + return false +} + +// FilesByPath returns locations from the parent and all child resolvers. +func (r *CompositeResolver) FilesByPath(paths ...string) ([]file.Location, error) { + var results []file.Location + + parentLocs, err := r.parent.FilesByPath(paths...) + if err != nil { + return nil, err + } + results = append(results, parentLocs...) + + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + childLocs, err := child.resolver.FilesByPath(paths...) + if err != nil { + continue + } + for _, loc := range childLocs { + results = append(results, r.transformLocation(loc, child)) + } + } + + return results, nil +} + +// FilesByGlob returns locations from the parent and all child resolvers. +func (r *CompositeResolver) FilesByGlob(patterns ...string) ([]file.Location, error) { + var results []file.Location + + parentLocs, err := r.parent.FilesByGlob(patterns...) + if err != nil { + return nil, err + } + results = append(results, parentLocs...) + + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + childLocs, err := child.resolver.FilesByGlob(patterns...) + if err != nil { + continue + } + for _, loc := range childLocs { + results = append(results, r.transformLocation(loc, child)) + } + } + + return results, nil +} + +// FilesByMIMEType returns locations from the parent and all child resolvers. +func (r *CompositeResolver) FilesByMIMEType(types ...string) ([]file.Location, error) { + var results []file.Location + + parentLocs, err := r.parent.FilesByMIMEType(types...) + if err != nil { + return nil, err + } + results = append(results, parentLocs...) + + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + childLocs, err := child.resolver.FilesByMIMEType(types...) + if err != nil { + continue + } + for _, loc := range childLocs { + results = append(results, r.transformLocation(loc, child)) + } + } + + return results, nil +} + +// RelativeFileByPath looks up a file relative to a given location. +func (r *CompositeResolver) RelativeFileByPath(location file.Location, path string) *file.Location { + // try parent first + if loc := r.parent.RelativeFileByPath(location, path); loc != nil { + return loc + } + + // try children - match on the archive's FSID + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + if location.FileSystemID == child.fsID { + if loc := child.resolver.RelativeFileByPath(location, path); loc != nil { + transformed := r.transformLocation(*loc, child) + return &transformed + } + } + } + return nil +} + +// FileContentsByLocation routes to the appropriate resolver based on FileSystemID. +func (r *CompositeResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) { + // check if this location belongs to a child resolver + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + if location.FileSystemID == child.fsID { + // look up the file by path in the child resolver to get a location with + // a valid internal reference (needed by directory resolvers that use + // stereoscope file references for content lookup) + childLocs, err := child.resolver.FilesByPath(location.RealPath) + if err != nil || len(childLocs) == 0 { + // fallback: try with a constructed location + childLoc := file.NewLocation(location.RealPath) + return child.resolver.FileContentsByLocation(childLoc) + } + return child.resolver.FileContentsByLocation(childLocs[0]) + } + } + + // fall through to parent + return r.parent.FileContentsByLocation(location) +} + +// AllLocations returns locations from the parent and all child resolvers. +func (r *CompositeResolver) AllLocations(ctx context.Context) <-chan file.Location { + results := make(chan file.Location) + go func() { + defer close(results) + + // parent locations + for loc := range r.parent.AllLocations(ctx) { + select { + case <-ctx.Done(): + return + case results <- loc: + } + } + + // child locations + r.mu.RLock() + children := make([]*childResolver, len(r.children)) + copy(children, r.children) + r.mu.RUnlock() + + for _, child := range children { + for loc := range child.resolver.AllLocations(ctx) { + transformed := r.transformLocation(loc, child) + select { + case <-ctx.Done(): + return + case results <- transformed: + } + } + } + }() + return results +} + +// FileMetadataByLocation routes to the appropriate resolver based on FileSystemID. +func (r *CompositeResolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, child := range r.children { + if location.FileSystemID == child.fsID { + childLocs, err := child.resolver.FilesByPath(location.RealPath) + if err != nil || len(childLocs) == 0 { + childLoc := file.NewLocation(location.RealPath) + return child.resolver.FileMetadataByLocation(childLoc) + } + return child.resolver.FileMetadataByLocation(childLocs[0]) + } + } + return r.parent.FileMetadataByLocation(location) +} + +// transformLocation transforms a location from a child resolver to include the archive context. +func (r *CompositeResolver) transformLocation(loc file.Location, child *childResolver) file.Location { + archivePath := child.archiveLocation.Path() + return file.Location{ + LocationData: file.LocationData{ + Coordinates: file.Coordinates{ + RealPath: loc.RealPath, + FileSystemID: child.fsID, + }, + AccessPath: archivePath + ArchivePathSeparator + loc.Path(), + }, + LocationMetadata: loc.LocationMetadata, + } +} + +// generateArchiveFSID creates a deterministic filesystem ID for an archive based on its location. +func generateArchiveFSID(archiveLocation file.Location) string { + h := sha256.New() + h.Write([]byte(archiveLocation.RealPath)) + h.Write([]byte(archiveLocation.FileSystemID)) + return fmt.Sprintf("archive:%x", h.Sum(nil)[:12]) +} diff --git a/internal/archive/resolver_test.go b/internal/archive/resolver_test.go new file mode 100644 index 000000000..7eef566ae --- /dev/null +++ b/internal/archive/resolver_test.go @@ -0,0 +1,429 @@ +package archive + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" +) + +// mockResolver implements file.Resolver for testing. +type mockResolver struct { + locations map[string]file.Location // path -> location + contents map[string]string // realPath -> content +} + +func newMockResolver(files map[string]string) *mockResolver { + r := &mockResolver{ + locations: make(map[string]file.Location), + contents: files, + } + for path := range files { + r.locations[path] = file.NewLocation(path) + } + return r +} + +func (m *mockResolver) HasPath(path string) bool { + _, ok := m.locations[path] + return ok +} + +func (m *mockResolver) FilesByPath(paths ...string) ([]file.Location, error) { + var results []file.Location + for _, p := range paths { + if loc, ok := m.locations[p]; ok { + results = append(results, loc) + } + } + return results, nil +} + +func (m *mockResolver) FilesByGlob(patterns ...string) ([]file.Location, error) { + var results []file.Location + for _, pattern := range patterns { + for path, loc := range m.locations { + matched, _ := filepath.Match(pattern, path) + if matched { + results = append(results, loc) + } + // also try with the basename for simple glob patterns + if !matched { + matched, _ = filepath.Match(pattern, filepath.Base(path)) + if matched { + results = append(results, loc) + } + } + } + } + return results, nil +} + +func (m *mockResolver) FilesByMIMEType(_ ...string) ([]file.Location, error) { + return nil, nil +} + +func (m *mockResolver) RelativeFileByPath(_ file.Location, path string) *file.Location { + if loc, ok := m.locations[path]; ok { + return &loc + } + return nil +} + +func (m *mockResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) { + if content, ok := m.contents[location.RealPath]; ok { + return io.NopCloser(strings.NewReader(content)), nil + } + return nil, os.ErrNotExist +} + +func (m *mockResolver) AllLocations(ctx context.Context) <-chan file.Location { + ch := make(chan file.Location) + go func() { + defer close(ch) + for _, loc := range m.locations { + select { + case <-ctx.Done(): + return + case ch <- loc: + } + } + }() + return ch +} + +func (m *mockResolver) FileMetadataByLocation(_ file.Location) (file.Metadata, error) { + return file.Metadata{}, nil +} + +// strictMockResolver mimics real directory resolvers (fileresolver.Directory) which +// require that FileContentsByLocation receives a location object that was originally +// returned by FilesByPath/FilesByGlob — not a freshly constructed file.NewLocation(). +// Real directory resolvers need the internal stereoscope file.Reference for index lookup. +// We simulate this by annotating returned locations and only accepting those back. +type strictMockResolver struct { + mockResolver + resolvedAnnotation string // annotation key that marks locations as "resolved" +} + +func newStrictMockResolver(files map[string]string) *strictMockResolver { + return &strictMockResolver{ + mockResolver: *newMockResolver(files), + resolvedAnnotation: "strict-resolved", + } +} + +func (s *strictMockResolver) annotate(loc file.Location) file.Location { + return loc.WithAnnotation(s.resolvedAnnotation, "true") +} + +func (s *strictMockResolver) isResolved(loc file.Location) bool { + return loc.Annotations[s.resolvedAnnotation] == "true" +} + +func (s *strictMockResolver) FilesByPath(paths ...string) ([]file.Location, error) { + locs, err := s.mockResolver.FilesByPath(paths...) + for i := range locs { + locs[i] = s.annotate(locs[i]) + } + return locs, err +} + +func (s *strictMockResolver) FilesByGlob(patterns ...string) ([]file.Location, error) { + locs, err := s.mockResolver.FilesByGlob(patterns...) + for i := range locs { + locs[i] = s.annotate(locs[i]) + } + return locs, err +} + +func (s *strictMockResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) { + if !s.isResolved(location) { + return nil, fmt.Errorf("location %q was not resolved via FilesByPath/FilesByGlob first (simulating real directory resolver behavior)", location.RealPath) + } + return s.mockResolver.FileContentsByLocation(location) +} + +func (s *strictMockResolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) { + if !s.isResolved(location) { + return file.Metadata{}, fmt.Errorf("location %q was not resolved via FilesByPath/FilesByGlob first", location.RealPath) + } + return s.mockResolver.FileMetadataByLocation(location) +} + +// TestCompositeResolver_FileContentsByLocation_ChildRequiresLookup tests that +// FileContentsByLocation works when the child resolver requires locations to be +// resolved via FilesByPath first (as real directory resolvers do). This catches +// the bug where we constructed a bare file.NewLocation() without a valid internal +// reference, which fails on real fileresolver.Directory implementations. +func TestCompositeResolver_FileContentsByLocation_ChildRequiresLookup(t *testing.T) { + parent := newMockResolver(map[string]string{}) + child := newStrictMockResolver(map[string]string{ + "/lib/data.txt": "important data", + }) + + composite := NewCompositeResolver(parent) + archiveLoc := file.NewLocation("/archive.zip") + fsID := composite.AddChild(child, archiveLoc, 1) + + // Simulate what a cataloger does: find the file, then read its content. + // The location returned has the archive's fsID, not the child's internal ref. + locs, err := composite.FilesByPath("/lib/data.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + assert.Equal(t, fsID, locs[0].FileSystemID) + + // Now read content - this must work even though the location was transformed + reader, err := composite.FileContentsByLocation(locs[0]) + require.NoError(t, err, "FileContentsByLocation should resolve the file in the child resolver via FilesByPath before reading") + defer reader.Close() + + content, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, "important data", string(content)) +} + +// TestCompositeResolver_FileMetadataByLocation_ChildRequiresLookup is the same +// test as above but for FileMetadataByLocation. +func TestCompositeResolver_FileMetadataByLocation_ChildRequiresLookup(t *testing.T) { + parent := newMockResolver(map[string]string{}) + child := newStrictMockResolver(map[string]string{ + "/lib/data.txt": "important data", + }) + + composite := NewCompositeResolver(parent) + archiveLoc := file.NewLocation("/archive.zip") + fsID := composite.AddChild(child, archiveLoc, 1) + + locs, err := composite.FilesByPath("/lib/data.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + assert.Equal(t, fsID, locs[0].FileSystemID) + + // FileMetadataByLocation must also resolve via FilesByPath first + _, err = composite.FileMetadataByLocation(locs[0]) + require.NoError(t, err, "FileMetadataByLocation should resolve the file in the child resolver via FilesByPath before reading metadata") +} + +func TestCompositeResolver_FilesByPath_ParentOnly(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/parent.txt": "parent content", + }) + composite := NewCompositeResolver(parent) + + locs, err := composite.FilesByPath("/parent.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + assert.Equal(t, "/parent.txt", locs[0].RealPath) +} + +func TestCompositeResolver_FilesByPath_WithChild(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/parent.txt": "parent content", + }) + child := newMockResolver(map[string]string{ + "/child.txt": "child content", + }) + + composite := NewCompositeResolver(parent) + archiveLoc := file.NewLocation("/archive.tar.gz") + composite.AddChild(child, archiveLoc, 1) + + locs, err := composite.FilesByPath("/child.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + assert.Contains(t, locs[0].AccessPath, "archive.tar.gz:") + assert.Contains(t, locs[0].AccessPath, "child.txt") +} + +func TestCompositeResolver_FileContentsByLocation_Parent(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/parent.txt": "parent content", + }) + composite := NewCompositeResolver(parent) + + locs, err := composite.FilesByPath("/parent.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + + reader, err := composite.FileContentsByLocation(locs[0]) + require.NoError(t, err) + defer reader.Close() + + content, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, "parent content", string(content)) +} + +func TestCompositeResolver_FileContentsByLocation_Child(t *testing.T) { + parent := newMockResolver(map[string]string{}) + child := newMockResolver(map[string]string{ + "/child.txt": "child content", + }) + + composite := NewCompositeResolver(parent) + archiveLoc := file.NewLocation("/archive.zip") + fsID := composite.AddChild(child, archiveLoc, 1) + + // construct a location as returned by FilesByPath + loc := file.Location{ + LocationData: file.LocationData{ + Coordinates: file.Coordinates{ + RealPath: "/child.txt", + FileSystemID: fsID, + }, + AccessPath: "/archive.zip:/child.txt", + }, + } + + reader, err := composite.FileContentsByLocation(loc) + require.NoError(t, err) + defer reader.Close() + + content, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, "child content", string(content)) +} + +func TestCompositeResolver_HasPath(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/parent.txt": "p", + }) + child := newMockResolver(map[string]string{ + "/child.txt": "c", + }) + + composite := NewCompositeResolver(parent) + archiveLoc := file.NewLocation("/archive.zip") + composite.AddChild(child, archiveLoc, 1) + + assert.True(t, composite.HasPath("/parent.txt")) + assert.True(t, composite.HasPath("/child.txt")) + assert.False(t, composite.HasPath("/nonexistent.txt")) +} + +func TestCompositeResolver_AllLocations(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/parent.txt": "p", + }) + child := newMockResolver(map[string]string{ + "/child.txt": "c", + }) + + composite := NewCompositeResolver(parent) + archiveLoc := file.NewLocation("/archive.zip") + composite.AddChild(child, archiveLoc, 1) + + ctx := context.Background() + var allLocs []file.Location + for loc := range composite.AllLocations(ctx) { + allLocs = append(allLocs, loc) + } + + assert.Len(t, allLocs, 2) + + hasArchivePrefix := false + for _, loc := range allLocs { + if strings.Contains(loc.AccessPath, "archive.zip:") { + hasArchivePrefix = true + break + } + } + assert.True(t, hasArchivePrefix) +} + +func TestCompositeResolver_ChildCount(t *testing.T) { + parent := newMockResolver(map[string]string{}) + composite := NewCompositeResolver(parent) + assert.Equal(t, 0, composite.ChildCount()) + + child := newMockResolver(map[string]string{"/a.txt": "a"}) + archiveLoc := file.NewLocation("/archive.zip") + composite.AddChild(child, archiveLoc, 1) + assert.Equal(t, 1, composite.ChildCount()) +} + +func TestCompositeResolver_TransformLocation(t *testing.T) { + composite := NewCompositeResolver(nil) + + archiveLoc := file.NewLocation("/path/to/archive.tar.gz") + child := &childResolver{ + archiveLocation: archiveLoc, + fsID: "archive:abc123", + depth: 1, + } + + loc := file.NewLocation("/inner/file.txt") + transformed := composite.transformLocation(loc, child) + + assert.Equal(t, "/inner/file.txt", transformed.RealPath) + assert.Equal(t, "archive:abc123", transformed.FileSystemID) + assert.Equal(t, "/path/to/archive.tar.gz:/inner/file.txt", transformed.AccessPath) +} + +func TestGenerateArchiveFSID(t *testing.T) { + loc1 := file.NewLocation("/archive1.zip") + loc2 := file.NewLocation("/archive2.zip") + + fsID1 := generateArchiveFSID(loc1) + fsID2 := generateArchiveFSID(loc2) + + assert.NotEqual(t, fsID1, fsID2) + assert.Equal(t, fsID1, generateArchiveFSID(loc1)) + assert.True(t, strings.HasPrefix(fsID1, "archive:")) +} + +func TestCompositeResolver_MultipleChildren(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/base.txt": "base", + }) + child1 := newMockResolver(map[string]string{ + "/lib.so": "library1", + }) + child2 := newMockResolver(map[string]string{ + "/lib.so": "library2", + }) + + composite := NewCompositeResolver(parent) + composite.AddChild(child1, file.NewLocation("/archive1.zip"), 1) + composite.AddChild(child2, file.NewLocation("/archive2.zip"), 1) + + assert.Equal(t, 2, composite.ChildCount()) + + // searching for /lib.so should find it in both children + locs, err := composite.FilesByPath("/lib.so") + require.NoError(t, err) + assert.Len(t, locs, 2) + + // each should have different archive prefixes + var accessPaths []string + for _, loc := range locs { + accessPaths = append(accessPaths, loc.AccessPath) + } + assert.Contains(t, accessPaths[0], "archive1.zip:") + assert.Contains(t, accessPaths[1], "archive2.zip:") +} + +func TestCompositeResolver_NoChildren_BehavesLikeParent(t *testing.T) { + parent := newMockResolver(map[string]string{ + "/file.txt": "content", + }) + composite := NewCompositeResolver(parent) + + // FilesByPath + parentLocs, _ := parent.FilesByPath("/file.txt") + compositeLocs, _ := composite.FilesByPath("/file.txt") + assert.Equal(t, len(parentLocs), len(compositeLocs)) + + // HasPath + assert.Equal(t, parent.HasPath("/file.txt"), composite.HasPath("/file.txt")) + assert.Equal(t, parent.HasPath("/nope"), composite.HasPath("/nope")) +}