diff --git a/go.mod b/go.mod index c01e26d99..e29fc2d49 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/anchore/go-sync v0.1.1 github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b github.com/anchore/packageurl-go v0.2.0 - github.com/anchore/stereoscope v0.3.0 + github.com/anchore/stereoscope v0.3.1-0.20260820190420-3cd14ecb9fbf github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/aquasecurity/go-pep440-version v0.0.1 github.com/bitnami/go-version v0.0.0-20250131085805-b1f57a8634ef @@ -183,9 +183,9 @@ require ( github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v29.6.2+incompatible // indirect + github.com/docker/cli v29.7.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect - github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emirpasic/gods v1.18.1 // indirect @@ -196,7 +196,7 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/gkampitakis/ciinfo v0.3.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/go.sum b/go.sum index 696231d2b..98738ec8a 100644 --- a/go.sum +++ b/go.sum @@ -146,8 +146,8 @@ github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZV github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.2.0 h1:CkrM4RMUwrEGAiE1OVlxaZNzWj0TuHRey7o4T/EAErk= github.com/anchore/packageurl-go v0.2.0/go.mod h1:2JCgOQMIsqZ7TmliXG4PnUthPJAKE3mWQbsW2XHjAOE= -github.com/anchore/stereoscope v0.3.0 h1:6mGGRxm4zRKj/jNiSM7g6XGwWihrnkQRJj11hcULDqY= -github.com/anchore/stereoscope v0.3.0/go.mod h1:QIBWxa5WCrjtBqXH0+RIuECATrffcbaNdMWquZ83+OI= +github.com/anchore/stereoscope v0.3.1-0.20260820190420-3cd14ecb9fbf h1:eTQTL06tadOz+V4ubhXOlc5oKQ11cXbyo/clnxlcsnU= +github.com/anchore/stereoscope v0.3.1-0.20260820190420-3cd14ecb9fbf/go.mod h1:cWQ8fLFblnkJ0c3BSFY+BwXe4DcJDdysP/gnoWHVb7s= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -339,12 +339,12 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= -github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= -github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= @@ -403,8 +403,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= diff --git a/syft/file/location.go b/syft/file/location.go index f5e1aa51c..76678c40f 100644 --- a/syft/file/location.go +++ b/syft/file/location.go @@ -153,14 +153,9 @@ func NewLocationFromImage(accessPath string, ref file.Reference, img *image.Imag } // NewVirtualLocationFromImage creates a new Location surfaced at realPath but whose contents and metadata are -// described by the given reference. This is used to present a hardlink as the underlying type it points to (at the -// hardlink's own path) so that image results are in parity with directory results, which cannot distinguish a -// hardlink from a regular file. Note that RealPath (the hardlink's own path) intentionally differs from -// ref.RealPath (the target's path); callers must not assume the two agree for these locations. +// described by the given reference. Note that RealPath may differ from ref.RealPath; callers must not assume the two +// agree for these locations. The FileSystemID comes from the given ref's layer. func NewVirtualLocationFromImage(realPath, accessPath string, ref file.Reference, img *image.Image) Location { - // the FileSystemID comes from the target ref's layer. a hardlink and its target are always materialized in the - // same layer tar (a tar hardlink entry can only reference a file within the same archive), so the target's layer - // digest is also the hardlink path's layer. layer := img.FileCatalog.Layer(ref) return Location{ LocationData: LocationData{ diff --git a/syft/format/syftjson/to_syft_model.go b/syft/format/syftjson/to_syft_model.go index ee227b00c..651f8bba9 100644 --- a/syft/format/syftjson/to_syft_model.go +++ b/syft/format/syftjson/to_syft_model.go @@ -175,6 +175,8 @@ func toSyftFileType(ty string) stereoscopeFile.Type { case "SymbolicLink": return stereoscopeFile.TypeSymLink case "HardLink": + // kept for reading SBOMs written before stereoscope adopted hardlinks at index time (they are emitted as + // regular files now, except for malformed link entries) return stereoscopeFile.TypeHardLink case "Directory": return stereoscopeFile.TypeDirectory diff --git a/syft/internal/fileresolver/container_image_all_layers.go b/syft/internal/fileresolver/container_image_all_layers.go index 20e8136bc..1a3151e8c 100644 --- a/syft/internal/fileresolver/container_image_all_layers.go +++ b/syft/internal/fileresolver/container_image_all_layers.go @@ -56,18 +56,6 @@ func (r *ContainerImageAllLayers) HasPath(path string) bool { func (r *ContainerImageAllLayers) locationsByRef(ref stereoscopeFile.Reference, accessPath string, uniqueFileIDs stereoscopeFile.ReferenceSet, layerPos int) ([]file.Location, error) { uniqueLocations := make([]file.Location, 0) - // if the access path is itself a hardlink, surface it as the underlying type it points to, at its own path - // (bound to the target's content), so that image results are in parity with directory results (which cannot - // tell a hardlink from a regular file). the path-based lookup is required because the search that produced ref - // already followed the basename link, collapsing the hardlink onto its target. - if ownRef, targetRef, ok := r.hardLinkAtPath(accessPath, r.layers[layerPos]); ok { - if !uniqueFileIDs.Contains(ownRef) { - uniqueFileIDs.Add(ownRef) - uniqueLocations = append(uniqueLocations, file.NewVirtualLocationFromImage(string(ownRef.RealPath), accessPath, targetRef, r.img)) - } - return uniqueLocations, nil - } - // since there is potentially considerable work for each symlink/hardlink that needs to be resolved, let's check to see if this is a symlink/hardlink first entry, err := r.img.FileCatalog.Get(ref) if err != nil { @@ -95,25 +83,6 @@ func (r *ContainerImageAllLayers) locationsByRef(ref stereoscopeFile.Reference, return uniqueLocations, nil } -// hardLinkAtPath returns the hardlink's own reference and its resolved target reference when the basename of path is a -// hardlink within the given layer. ok is false when path does not exist there or is not a hardlink. The lookup does -// not follow the basename link so that the hardlink's own path is preserved. This adds a tree walk per -// matched ref (path x layer); if it shows up in profiles, fold the hardlink check into the existing search resolution. -func (r *ContainerImageAllLayers) hardLinkAtPath(path string, layerIdx int) (stereoscopeFile.Reference, stereoscopeFile.Reference, bool) { - var own stereoscopeFile.Reference - // use the squashed-to-layer view (not just this layer's additions) so a hardlink added in a lower layer is still - // detected when the path is searched at a higher layer; otherwise it would collapse onto its target there. - exists, resolution, err := r.img.Layers[layerIdx].SquashedTree.File(stereoscopeFile.Path(path)) - if err != nil || !exists || !resolution.HasReference() { - return own, own, false - } - target, ok := r.resolveHardLinkTarget(*resolution.Reference, layerIdx) - if !ok { - return own, own, false - } - return *resolution.Reference, target, true -} - // FilesByPath returns all file.References that match the given paths from any layer in the image. func (r *ContainerImageAllLayers) FilesByPath(paths ...string) ([]file.Location, error) { uniqueFileIDs := stereoscopeFile.NewFileReferenceSet() @@ -284,11 +253,6 @@ func (r *ContainerImageAllLayers) AllLocations(ctx context.Context) <-chan file. tree := r.img.Layers[layerIdx].Tree for _, ref := range tree.AllFiles(stereoscopeFile.AllTypes()...) { l := file.NewLocationFromImage(string(ref.RealPath), ref, r.img) - // surface a hardlink as the underlying type it points to (at its own path) so image results match - // directory results, which cannot distinguish a hardlink from a regular file. - if targetRef, ok := r.resolveHardLinkTarget(ref, layerIdx); ok { - l = file.NewVirtualLocationFromImage(string(ref.RealPath), string(ref.RealPath), targetRef, r.img) - } r.annotateLocation(&l) select { case <-ctx.Done(): @@ -306,21 +270,6 @@ func (r *ContainerImageAllLayers) FileMetadataByLocation(location file.Location) return fileMetadataByLocation(r.img, location) } -// resolveHardLinkTarget returns the reference of a hardlink's underlying target (resolved relative to the given layer) -// when ref is a hardlink; ok is false otherwise. No resolution is performed for non-hardlinks (symlinks keep their -// existing resolution semantics). -func (r *ContainerImageAllLayers) resolveHardLinkTarget(ref stereoscopeFile.Reference, layerIdx int) (stereoscopeFile.Reference, bool) { - metadata, err := r.img.FileCatalog.Get(ref) - if err != nil || metadata.Type != stereoscopeFile.TypeHardLink { - return ref, false - } - resolved, err := r.img.ResolveLinkByLayerSquash(ref, layerIdx) - if err != nil || !resolved.HasReference() { - return ref, false - } - return *resolved.Reference, true -} - func (r *ContainerImageAllLayers) annotateLocation(l *file.Location) { if !r.markVisibility || l == nil { return @@ -345,8 +294,6 @@ func (r *ContainerImageAllLayers) annotateLocation(l *file.Location) { } // pathResolvesToRef reports whether the given path in the squashed tree resolves to the given reference. -// SearchByPath always follows basename links, so a hardlink surfaced at its own path (whose reference is its -// target) still resolves to the target here and is correctly considered visible. func (r *ContainerImageAllLayers) pathResolvesToRef(path string, target stereoscopeFile.Reference) bool { ref, err := r.img.SquashedSearchContext.SearchByPath(path, filetree.DoNotFollowDeadBasenameLinks) return err == nil && ref.HasReference() && ref.ID() == target.ID() diff --git a/syft/internal/fileresolver/container_image_squash.go b/syft/internal/fileresolver/container_image_squash.go index ef7122af6..efcd2e8e4 100644 --- a/syft/internal/fileresolver/container_image_squash.go +++ b/syft/internal/fileresolver/container_image_squash.go @@ -40,17 +40,6 @@ func (r *ContainerImageSquash) FilesByPath(paths ...string) ([]file.Location, er uniqueLocations := make([]file.Location, 0) for _, path := range paths { - // if the requested path is itself a hardlink, surface it at its own path (bound to its target's content) - // rather than collapsing it onto the target's path, so that image results are in parity with directory - // results (which cannot tell a hardlink from a regular file). - if ownRef, targetRef, ok := r.hardLinkAtPath(path); ok { - if !uniqueFileIDs.Contains(ownRef) { - uniqueFileIDs.Add(ownRef) - uniqueLocations = append(uniqueLocations, file.NewVirtualLocationFromImage(string(ownRef.RealPath), path, targetRef, r.img)) - } - continue - } - ref, err := r.img.SquashedSearchContext.SearchByPath(path, filetree.FollowBasenameLinks) if err != nil { return nil, err @@ -93,7 +82,7 @@ func (r *ContainerImageSquash) FilesByPath(paths ...string) ([]file.Location, er // //nolint:gocognit func (r *ContainerImageSquash) FilesByGlob(patterns ...string) ([]file.Location, error) { - uniqueCoordinates := file.NewCoordinateSet() + uniqueFileIDs := stereoscopeFile.NewFileReferenceSet() uniqueLocations := make([]file.Location, 0) for _, pattern := range patterns { @@ -127,14 +116,10 @@ func (r *ContainerImageSquash) FilesByGlob(patterns ...string) ([]file.Location, return nil, fmt.Errorf("failed to find files by path (result=%+v): %w", result, err) } for _, resolvedLocation := range resolvedLocations { - // dedup on the surfaced coordinate rather than the underlying reference: distinct hardlinks share a - // single target reference but each has its own real path, so a reference-based dedup would collapse - // them back onto one entry (the exact behavior this parity fix removes). symlink resolutions keep - // their target's real path, so they still collapse as before. - if uniqueCoordinates.Contains(resolvedLocation.Coordinates) { + if uniqueFileIDs.Contains(resolvedLocation.Reference()) { continue } - uniqueCoordinates.Add(resolvedLocation.Coordinates) + uniqueFileIDs.Add(resolvedLocation.Reference()) uniqueLocations = append(uniqueLocations, resolvedLocation) } } @@ -194,16 +179,10 @@ func (r *ContainerImageSquash) AllLocations(ctx context.Context) <-chan file.Loc go func() { defer close(results) for _, ref := range r.img.SquashedTree().AllFiles(stereoscopeFile.AllTypes()...) { - loc := file.NewLocationFromImage(string(ref.RealPath), ref, r.img) - // surface a hardlink as the underlying type it points to (at its own path) so image results match - // directory results, which cannot distinguish a hardlink from a regular file. - if targetRef, ok := r.resolveHardLinkTarget(ref); ok { - loc = file.NewVirtualLocationFromImage(string(ref.RealPath), string(ref.RealPath), targetRef, r.img) - } select { case <-ctx.Done(): return - case results <- loc: + case results <- file.NewLocationFromImage(string(ref.RealPath), ref, r.img): continue } } @@ -211,37 +190,6 @@ func (r *ContainerImageSquash) AllLocations(ctx context.Context) <-chan file.Loc return results } -// hardLinkAtPath returns the hardlink's own reference and its resolved target reference when the basename of path is a -// hardlink. ok is false when path does not exist or is not a hardlink. The lookup does not follow the basename link so -// that the hardlink's own path is preserved. This adds a tree walk per FilesByPath path; if it shows up in -// profiles, fold the hardlink check into the existing SearchByPath resolution. -func (r *ContainerImageSquash) hardLinkAtPath(path string) (stereoscopeFile.Reference, stereoscopeFile.Reference, bool) { - var own stereoscopeFile.Reference - exists, resolution, err := r.img.SquashedTree().File(stereoscopeFile.Path(path)) - if err != nil || !exists || !resolution.HasReference() { - return own, own, false - } - target, ok := r.resolveHardLinkTarget(*resolution.Reference) - if !ok { - return own, own, false - } - return *resolution.Reference, target, true -} - -// resolveHardLinkTarget returns the reference of a hardlink's underlying target when ref is a hardlink; ok is false -// otherwise. No resolution is performed for non-hardlinks (symlinks keep their existing resolution semantics). -func (r *ContainerImageSquash) resolveHardLinkTarget(ref stereoscopeFile.Reference) (stereoscopeFile.Reference, bool) { - metadata, err := r.img.FileCatalog.Get(ref) - if err != nil || metadata.Type != stereoscopeFile.TypeHardLink { - return ref, false - } - resolved, err := r.img.ResolveLinkByImageSquash(ref) - if err != nil || !resolved.HasReference() { - return ref, false - } - return *resolved.Reference, true -} - func (r *ContainerImageSquash) FilesByMIMEType(types ...string) ([]file.Location, error) { refs, err := r.img.SquashedSearchContext.SearchByMIMEType(types...) if err != nil { diff --git a/syft/internal/fileresolver/container_image_squash_test.go b/syft/internal/fileresolver/container_image_squash_test.go index 82b0b87fb..b1ceaca0d 100644 --- a/syft/internal/fileresolver/container_image_squash_test.go +++ b/syft/internal/fileresolver/container_image_squash_test.go @@ -642,6 +642,24 @@ func TestImageResolvers_Hardlinks(t *testing.T) { } }) + t.Run("FilesByMIMEType surfaces every hardlink name", func(t *testing.T) { + // this is the surface #5029 could not fix and stereoscope's index-time adoption does: MIME-driven + // catalogers see each name. it also pins that the names are separately addressable (distinct + // references), since this resolver dedups on reference and would return one entry if they shared it. + locs, err := resolver.FilesByMIMEType("text/plain") + require.NoError(t, err) + + byPath := map[string]file.Location{} + for _, loc := range locs { + byPath[loc.RealPath] = loc + } + for _, path := range []string{"/file.txt", "/hardlink-a", "/hardlink-b"} { + loc, ok := byPath[path] + require.Truef(t, ok, "expected MIME search to surface %s (got %v)", path, byPath) + assertHardlink(t, resolver, path, loc) + } + }) + t.Run("AllLocations includes every hardlink path as a regular file exactly once", func(t *testing.T) { counts := map[string]int{} locsByPath := map[string]file.Location{}