Report each hardlink as its own file when scanning images (#5029)

* report each hardlink as its own file when scanning images

image scans previously collapsed a set of hardlinks onto a single file, so only
one path per inode showed up in results. dir scans report every hardlink path,
which made image vs dir SBOMs of the same filesystem diverge (and produce
different SPDX `packageVerificationCode` values for packages that own hardlinked
files).

now both image resolvers (squash and all-layers) surface each hardlink at its
own path as a regular file bound to the target's content, matching dir scans.

user-facing impact:
- SBOMs for images containing hardlinks will list more `file` entries
- SPDX `packageVerificationCode` values change for affected packages, now
  matching the equivalent `dir:` scan
- adds `file.NewVirtualLocationFromImage` to the public API

fixes #5019

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

* fix busybox test assertion

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-07-29 13:50:43 -04:00
committed by GitHub
parent 5ef5b1badc
commit 08e913a2ce
6 changed files with 260 additions and 40 deletions
@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/file"
)
@@ -574,3 +575,87 @@ func TestSquashResolver_AllLocations(t *testing.T) {
assert.ElementsMatchf(t, expected, pathsList, "expected all paths to be indexed, but found different paths: \n%s", cmp.Diff(expected, paths.List()))
}
// a hardlink should be surfaced at its own path as the underlying type it points to (a regular file with the target's
// content and metadata), so that image results are in parity with directory results (which cannot distinguish a
// hardlink from a regular file). this must hold for both the squashed and all-layers resolvers.
func TestImageResolvers_Hardlinks(t *testing.T) {
img := imagetest.GetFixtureImage(t, "docker-archive", "image-hardlinks")
resolvers := map[string]file.Resolver{}
squash, err := NewFromContainerImageSquash(img)
require.NoError(t, err)
resolvers["squashed"] = squash
allLayers, err := NewFromContainerImageAllLayers(img)
require.NoError(t, err)
resolvers["all-layers"] = allLayers
const wantContents = "hardlinked contents\n"
// asserts a location surfaces the hardlink at its own path as a regular file with the target's content, and is
// not marked hidden (all-layers annotates visibility; squash leaves the annotation unset, which must not read as
// hidden either).
assertHardlink := func(t *testing.T, resolver file.Resolver, wantPath string, loc file.Location) {
t.Helper()
assert.Equal(t, wantPath, loc.RealPath, "expected the hardlink's own path, not the target's")
assert.NotEqual(t, file.HiddenAnnotation, loc.Annotations[file.VisibleAnnotationKey], "path=%s should not be hidden", wantPath)
meta, err := resolver.FileMetadataByLocation(loc)
require.NoError(t, err)
assert.Equal(t, stereoscopeFile.TypeRegular, meta.Type, "path=%s", wantPath)
reader, err := resolver.FileContentsByLocation(loc)
require.NoError(t, err)
actual, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, wantContents, string(actual), "path=%s", wantPath)
}
for name, resolver := range resolvers {
t.Run(name, func(t *testing.T) {
t.Run("FilesByPath surfaces a hardlink at its own path with the target's content", func(t *testing.T) {
for _, path := range []string{"/hardlink-a", "/hardlink-b", "/file.txt"} {
locs, err := resolver.FilesByPath(path)
require.NoError(t, err)
require.Len(t, locs, 1, "path=%s", path)
assertHardlink(t, resolver, path, locs[0])
}
})
t.Run("FilesByGlob surfaces every hardlink in a matched set, not just one", func(t *testing.T) {
// the whole point of #5019: a glob that matches multiple hardlinks to the same target must return
// all of them (each at its own path), not collapse them onto a single entry.
locs, err := resolver.FilesByGlob("**/hardlink-*")
require.NoError(t, err)
byPath := map[string]file.Location{}
for _, loc := range locs {
_, dup := byPath[loc.RealPath]
assert.Falsef(t, dup, "path %s emitted more than once", loc.RealPath)
byPath[loc.RealPath] = loc
}
for _, path := range []string{"/hardlink-a", "/hardlink-b"} {
loc, ok := byPath[path]
require.Truef(t, ok, "expected glob to surface %s", path)
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{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for loc := range resolver.AllLocations(ctx) {
counts[loc.RealPath]++
locsByPath[loc.RealPath] = loc
}
for _, path := range []string{"/file.txt", "/hardlink-a", "/hardlink-b"} {
require.Equalf(t, 1, counts[path], "expected %s to be reported exactly once by AllLocations", path)
assertHardlink(t, resolver, path, locsByPath[path])
}
})
})
}
}