mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 14:02:26 +01:00
feat(archive): add CompositeResolver for transparent archive content access
Implement a file.Resolver decorator that composites a parent resolver with child resolvers created from extracted archive contents. Files from child resolvers appear transparently alongside parent files. Key behaviors: - FilesByPath/FilesByGlob/FilesByMIMEType delegate to parent AND children - FileContentsByLocation routes to the correct resolver via FileSystemID - Locations from children get archive-prefixed AccessPath (using ":" separator matching Java cataloger convention) and unique FileSystemID - Thread-safe via RWMutex for concurrent access Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
This commit is contained in:
@@ -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])
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user