mirror of
https://github.com/anchore/syft.git
synced 2026-10-31 14:36:33 +01:00
Add tests for important edge cases: - Nested children (child-in-child): verifies that archives within archives produce correct multi-level AccessPath chains like "/archive1.tar.gz:/inner-archive.zip:/deep-file.txt" - Empty child resolver: no locations leak from empty archives - Same file path in parent and child: both versions accessible with distinct FileSystemID routing - Context cancellation in AllLocations: no hanging Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
585 lines
18 KiB
Go
585 lines
18 KiB
Go
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_NestedChildren_ChildInChild(t *testing.T) {
|
|
// Simulates: base filesystem -> archive1.tar.gz -> archive2.zip -> inner file
|
|
// In practice the orchestrator adds children at different depths, but the resolver
|
|
// itself just has a flat list of children. The nesting is encoded in the AccessPath.
|
|
parent := newMockResolver(map[string]string{
|
|
"/base.txt": "base",
|
|
})
|
|
|
|
// First level: contents of archive1.tar.gz
|
|
child1 := newMockResolver(map[string]string{
|
|
"/inner-archive.zip": "zipdata", // this is an archive inside archive1
|
|
"/readme.txt": "readme from archive1",
|
|
})
|
|
|
|
// Second level: contents of inner-archive.zip (which was inside archive1.tar.gz)
|
|
child2 := newMockResolver(map[string]string{
|
|
"/deep-file.txt": "deeply nested content",
|
|
})
|
|
|
|
composite := NewCompositeResolver(parent)
|
|
|
|
// Add child1 as extracted from archive1.tar.gz (depth 1)
|
|
archive1Loc := file.NewLocation("/archive1.tar.gz")
|
|
fsID1 := composite.AddChild(child1, archive1Loc, 1)
|
|
|
|
// Add child2 as extracted from inner-archive.zip which was in archive1 (depth 2)
|
|
// The archive location for child2 refers to the inner-archive.zip as it appears in child1
|
|
innerArchiveLoc := file.Location{
|
|
LocationData: file.LocationData{
|
|
Coordinates: file.Coordinates{
|
|
RealPath: "/inner-archive.zip",
|
|
FileSystemID: fsID1,
|
|
},
|
|
AccessPath: "/archive1.tar.gz:/inner-archive.zip",
|
|
},
|
|
}
|
|
fsID2 := composite.AddChild(child2, innerArchiveLoc, 2)
|
|
|
|
assert.Equal(t, 2, composite.ChildCount())
|
|
assert.NotEqual(t, fsID1, fsID2)
|
|
|
|
// Should find base file
|
|
locs, err := composite.FilesByPath("/base.txt")
|
|
require.NoError(t, err)
|
|
assert.Len(t, locs, 1)
|
|
assert.Equal(t, "/base.txt", locs[0].AccessPath)
|
|
|
|
// Should find readme from archive1
|
|
locs, err = composite.FilesByPath("/readme.txt")
|
|
require.NoError(t, err)
|
|
require.Len(t, locs, 1)
|
|
assert.Equal(t, "/archive1.tar.gz:/readme.txt", locs[0].AccessPath)
|
|
assert.Equal(t, fsID1, locs[0].FileSystemID)
|
|
|
|
// Should find deeply nested file
|
|
locs, err = composite.FilesByPath("/deep-file.txt")
|
|
require.NoError(t, err)
|
|
require.Len(t, locs, 1)
|
|
// The access path shows the full chain: outer-archive:inner-archive:file
|
|
assert.Equal(t, "/archive1.tar.gz:/inner-archive.zip:/deep-file.txt", locs[0].AccessPath)
|
|
assert.Equal(t, fsID2, locs[0].FileSystemID)
|
|
|
|
// Should be able to read the deeply nested content
|
|
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, "deeply nested content", string(content))
|
|
}
|
|
|
|
func TestCompositeResolver_EmptyChild(t *testing.T) {
|
|
parent := newMockResolver(map[string]string{
|
|
"/file.txt": "content",
|
|
})
|
|
child := newMockResolver(map[string]string{})
|
|
|
|
composite := NewCompositeResolver(parent)
|
|
composite.AddChild(child, file.NewLocation("/empty.zip"), 1)
|
|
|
|
// should still find parent files
|
|
locs, err := composite.FilesByPath("/file.txt")
|
|
require.NoError(t, err)
|
|
assert.Len(t, locs, 1)
|
|
|
|
// no extra locations from empty child
|
|
ctx := context.Background()
|
|
var all []file.Location
|
|
for loc := range composite.AllLocations(ctx) {
|
|
all = append(all, loc)
|
|
}
|
|
assert.Len(t, all, 1)
|
|
}
|
|
|
|
func TestCompositeResolver_SameFileInParentAndChild(t *testing.T) {
|
|
// File with the same path exists in both parent and child
|
|
parent := newMockResolver(map[string]string{
|
|
"/config.yaml": "parent version",
|
|
})
|
|
child := newMockResolver(map[string]string{
|
|
"/config.yaml": "child version",
|
|
})
|
|
|
|
composite := NewCompositeResolver(parent)
|
|
fsID := composite.AddChild(child, file.NewLocation("/archive.zip"), 1)
|
|
|
|
locs, err := composite.FilesByPath("/config.yaml")
|
|
require.NoError(t, err)
|
|
// Should find both versions
|
|
assert.Len(t, locs, 2)
|
|
|
|
// parent version has no archive prefix
|
|
parentLoc := locs[0]
|
|
assert.Equal(t, "/config.yaml", parentLoc.AccessPath)
|
|
assert.Empty(t, parentLoc.FileSystemID)
|
|
|
|
// child version has archive prefix
|
|
childLoc := locs[1]
|
|
assert.Equal(t, "/archive.zip:/config.yaml", childLoc.AccessPath)
|
|
assert.Equal(t, fsID, childLoc.FileSystemID)
|
|
|
|
// can read both
|
|
r1, err := composite.FileContentsByLocation(parentLoc)
|
|
require.NoError(t, err)
|
|
c1, _ := io.ReadAll(r1)
|
|
r1.Close()
|
|
assert.Equal(t, "parent version", string(c1))
|
|
|
|
r2, err := composite.FileContentsByLocation(childLoc)
|
|
require.NoError(t, err)
|
|
c2, _ := io.ReadAll(r2)
|
|
r2.Close()
|
|
assert.Equal(t, "child version", string(c2))
|
|
}
|
|
|
|
func TestCompositeResolver_AllLocations_ContextCancellation(t *testing.T) {
|
|
parent := newMockResolver(map[string]string{
|
|
"/a.txt": "a",
|
|
"/b.txt": "b",
|
|
"/c.txt": "c",
|
|
})
|
|
|
|
composite := NewCompositeResolver(parent)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
var count int
|
|
for range composite.AllLocations(ctx) {
|
|
count++
|
|
}
|
|
// with cancelled context, we may get 0 or some locations, but shouldn't hang
|
|
assert.True(t, count <= 3)
|
|
}
|
|
|
|
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"))
|
|
}
|