mirror of
https://github.com/anchore/syft.git
synced 2026-11-05 17:06:32 +01:00
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:
@@ -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])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user