github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/internal/pkgtest/observing_resolver.go (about)

     1  // Package pkgtest provides test helpers for cataloger and parser testing,
     2  // including resolver decorators that track file access patterns.
     3  package pkgtest
     4  
     5  import (
     6  	"context"
     7  	"fmt"
     8  	"io"
     9  	"sort"
    10  
    11  	"github.com/scylladb/go-set/strset"
    12  
    13  	"github.com/anchore/syft/syft/file"
    14  )
    15  
    16  var _ file.Resolver = (*ObservingResolver)(nil)
    17  
    18  // ObservingResolver wraps a file.Resolver to observe and track all file access patterns.
    19  // it records what paths were queried, what was returned, and what file contents were read.
    20  // this is useful for validating that catalogers use appropriate glob patterns and don't over-read files.
    21  type ObservingResolver struct {
    22  	decorated          file.Resolver
    23  	pathQueries        map[string][]string // method name -> list of query patterns
    24  	pathResponses      []file.Location     // all locations successfully returned
    25  	contentQueries     []file.Location     // all locations whose content was read
    26  	emptyPathResponses map[string][]string // method name -> paths that returned empty results
    27  }
    28  
    29  // NewObservingResolver creates a new ObservingResolver that wraps the given resolver.
    30  func NewObservingResolver(resolver file.Resolver) *ObservingResolver {
    31  	return &ObservingResolver{
    32  		decorated:          resolver,
    33  		pathQueries:        make(map[string][]string),
    34  		pathResponses:      make([]file.Location, 0),
    35  		contentQueries:     make([]file.Location, 0),
    36  		emptyPathResponses: make(map[string][]string),
    37  	}
    38  }
    39  
    40  // ===== Test Assertion Helpers =====
    41  // these methods are used by tests to validate expected file access patterns.
    42  
    43  // ObservedPathQuery checks if a specific path pattern was queried.
    44  func (r *ObservingResolver) ObservedPathQuery(input string) bool {
    45  	for _, queries := range r.pathQueries {
    46  		for _, query := range queries {
    47  			if query == input {
    48  				return true
    49  			}
    50  		}
    51  	}
    52  	return false
    53  }
    54  
    55  // ObservedPathResponses checks if a specific path was returned in any response.
    56  func (r *ObservingResolver) ObservedPathResponses(path string) bool {
    57  	for _, loc := range r.pathResponses {
    58  		if loc.RealPath == path {
    59  			return true
    60  		}
    61  	}
    62  	return false
    63  }
    64  
    65  // ObservedContentQueries checks if a specific file's content was read.
    66  func (r *ObservingResolver) ObservedContentQueries(path string) bool {
    67  	for _, loc := range r.contentQueries {
    68  		if loc.RealPath == path {
    69  			return true
    70  		}
    71  	}
    72  	return false
    73  }
    74  
    75  // AllContentQueries returns a deduplicated list of all file paths whose content was read.
    76  func (r *ObservingResolver) AllContentQueries() []string {
    77  	observed := strset.New()
    78  	for _, loc := range r.contentQueries {
    79  		observed.Add(loc.RealPath)
    80  	}
    81  	return observed.List()
    82  }
    83  
    84  // AllPathQueries returns all path query patterns grouped by method name.
    85  func (r *ObservingResolver) AllPathQueries() map[string][]string {
    86  	return r.pathQueries
    87  }
    88  
    89  // PruneUnfulfilledPathResponses removes specified paths from the unfulfilled requests tracking.
    90  // ignore maps method names to paths that should be ignored for that method.
    91  // ignorePaths lists paths that should be ignored for all methods.
    92  func (r *ObservingResolver) PruneUnfulfilledPathResponses(ignore map[string][]string, ignorePaths ...string) {
    93  	// remove paths ignored for specific methods
    94  	for methodName, pathsToIgnore := range ignore {
    95  		r.emptyPathResponses[methodName] = removeStrings(r.emptyPathResponses[methodName], pathsToIgnore)
    96  		if len(r.emptyPathResponses[methodName]) == 0 {
    97  			delete(r.emptyPathResponses, methodName)
    98  		}
    99  	}
   100  
   101  	// remove paths ignored for all methods
   102  	if len(ignorePaths) > 0 {
   103  		for methodName := range r.emptyPathResponses {
   104  			r.emptyPathResponses[methodName] = removeStrings(r.emptyPathResponses[methodName], ignorePaths)
   105  			if len(r.emptyPathResponses[methodName]) == 0 {
   106  				delete(r.emptyPathResponses, methodName)
   107  			}
   108  		}
   109  	}
   110  }
   111  
   112  // HasUnfulfilledPathRequests returns true if there are any paths that were queried but returned empty.
   113  func (r *ObservingResolver) HasUnfulfilledPathRequests() bool {
   114  	return len(r.emptyPathResponses) > 0
   115  }
   116  
   117  // PrettyUnfulfilledPathRequests returns a formatted string of all unfulfilled path requests.
   118  func (r *ObservingResolver) PrettyUnfulfilledPathRequests() string {
   119  	if len(r.emptyPathResponses) == 0 {
   120  		return ""
   121  	}
   122  
   123  	var keys []string
   124  	for k := range r.emptyPathResponses {
   125  		keys = append(keys, k)
   126  	}
   127  	sort.Strings(keys)
   128  
   129  	var result string
   130  	for _, k := range keys {
   131  		result += fmt.Sprintf("   %s: %+v\n", k, r.emptyPathResponses[k])
   132  	}
   133  	return result
   134  }
   135  
   136  // removeStrings removes all occurrences of toRemove from slice.
   137  func removeStrings(slice []string, toRemove []string) []string {
   138  	if len(toRemove) == 0 {
   139  		return slice
   140  	}
   141  
   142  	// create a set for O(1) lookup
   143  	removeSet := make(map[string]bool)
   144  	for _, s := range toRemove {
   145  		removeSet[s] = true
   146  	}
   147  
   148  	// filter the slice
   149  	result := make([]string, 0, len(slice))
   150  	for _, s := range slice {
   151  		if !removeSet[s] {
   152  			result = append(result, s)
   153  		}
   154  	}
   155  	return result
   156  }
   157  
   158  // ===== Internal Tracking Helpers =====
   159  
   160  // recordQuery records a path query for a given method.
   161  func (r *ObservingResolver) recordQuery(methodName string, queries ...string) {
   162  	r.pathQueries[methodName] = append(r.pathQueries[methodName], queries...)
   163  }
   164  
   165  // recordResponses records successful path responses and tracks any unfulfilled queries.
   166  func (r *ObservingResolver) recordResponses(methodName string, locs []file.Location, queriedPaths ...string) {
   167  	r.pathResponses = append(r.pathResponses, locs...)
   168  
   169  	// track paths that returned no results
   170  	if len(locs) == 0 && len(queriedPaths) > 0 {
   171  		r.emptyPathResponses[methodName] = append(r.emptyPathResponses[methodName], queriedPaths...)
   172  	}
   173  }
   174  
   175  // ===== file.Resolver Implementation =====
   176  // these methods delegate to the wrapped resolver while recording observations.
   177  
   178  // FilesByPath returns files matching the given paths.
   179  func (r *ObservingResolver) FilesByPath(paths ...string) ([]file.Location, error) {
   180  	const methodName = "FilesByPath"
   181  	r.recordQuery(methodName, paths...)
   182  
   183  	locs, err := r.decorated.FilesByPath(paths...)
   184  	r.recordResponses(methodName, locs, paths...)
   185  
   186  	return locs, err
   187  }
   188  
   189  // FilesByGlob returns files matching the given glob patterns.
   190  func (r *ObservingResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
   191  	const methodName = "FilesByGlob"
   192  	r.recordQuery(methodName, patterns...)
   193  
   194  	locs, err := r.decorated.FilesByGlob(patterns...)
   195  	r.recordResponses(methodName, locs, patterns...)
   196  
   197  	return locs, err
   198  }
   199  
   200  // FilesByMIMEType returns files matching the given MIME types.
   201  func (r *ObservingResolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
   202  	const methodName = "FilesByMIMEType"
   203  	r.recordQuery(methodName, types...)
   204  
   205  	locs, err := r.decorated.FilesByMIMEType(types...)
   206  	r.recordResponses(methodName, locs, types...)
   207  
   208  	return locs, err
   209  }
   210  
   211  // RelativeFileByPath returns a file at a path relative to the given location.
   212  func (r *ObservingResolver) RelativeFileByPath(location file.Location, path string) *file.Location {
   213  	const methodName = "RelativeFileByPath"
   214  	r.recordQuery(methodName, path)
   215  
   216  	loc := r.decorated.RelativeFileByPath(location, path)
   217  
   218  	if loc != nil {
   219  		r.pathResponses = append(r.pathResponses, *loc)
   220  	} else {
   221  		r.emptyPathResponses[methodName] = append(r.emptyPathResponses[methodName], path)
   222  	}
   223  
   224  	return loc
   225  }
   226  
   227  // FileContentsByLocation returns a reader for the contents of the file at the given location.
   228  func (r *ObservingResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
   229  	r.contentQueries = append(r.contentQueries, location)
   230  	return r.decorated.FileContentsByLocation(location)
   231  }
   232  
   233  // AllLocations returns all file locations known to the resolver.
   234  func (r *ObservingResolver) AllLocations(ctx context.Context) <-chan file.Location {
   235  	return r.decorated.AllLocations(ctx)
   236  }
   237  
   238  // HasPath returns true if the resolver knows about the given path.
   239  func (r *ObservingResolver) HasPath(path string) bool {
   240  	return r.decorated.HasPath(path)
   241  }
   242  
   243  // FileMetadataByLocation returns metadata for the file at the given location.
   244  func (r *ObservingResolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
   245  	return r.decorated.FileMetadataByLocation(location)
   246  }