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 }