fix: move image hardlink handling upstream during image indexing (#5196)

* chore(deps): pin stereoscope to the hardlink-inode-adoption branch

temporary pin to anchore/stereoscope#670 so the hardlink changes can be
exercised end to end. needs re-pinning to a release tag before merge.

pulls transitive bumps along with it: docker/cli, docker/go-connections and
gabriel-vasile/mimetype.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: drop the image resolver hardlink workaround

stereoscope now adopts a hardlink's target at index time, so a hardlinked name
arrives already described as a regular file with the target's size, mime type
and content. the resolver-side fix from #5029 sat on top of that doing the same
job a second time, gated on a file type that no longer shows up, so both
`resolveHardLinkTarget` implementations and the extra tree walk they cost on
every `FilesByPath` are gone.

`file.NewVirtualLocationFromImage` stays as-is. it is exported and syft is v1,
so it keeps working for anyone using it, it just has no callers in syft now.

this picks up `FilesByMIMEType`, which #5029 explicitly could not fix, so
hardlinked names now reach mime-driven catalogers. on images built around
multi-call binaries (busybox and friends) that is a lot more file and executable
entries than before; distro and toolchain images move by about one entry.

one shape regresses: a hardlink stereoscope could not adopt (a link name that is
absent, empty, names a directory, names another un-adopted link, or points into
a lower layer) keeps `TypeHardLink`, and syft goes back to collapsing it onto
its target's path. no mainstream builder emits the cross-layer case.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* bump stereoscope to main

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2026-08-20 16:17:41 -04:00 committed by GitHub
parent c4597a08a2
commit 39c9c690df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 38 additions and 128 deletions

8
go.mod
View File

@ -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

16
go.sum
View File

@ -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=

View File

@ -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{

View File

@ -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

View File

@ -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()

View File

@ -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 {

View File

@ -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{}