Files
syft/internal/archive/index_links_test.go
T
2026-09-22 00:23:36 -04:00

299 lines
9.6 KiB
Go

package archive
import (
"archive/tar"
"bytes"
"context"
"io"
"math/rand"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// linkEntry is a regular file when linkname is empty, otherwise a symlink or, with hard set, a hard link.
type linkEntry struct {
name string
body string
linkname string
hard bool
}
func indexWithLinks(t testing.TB, entries ...linkEntry) *Index {
t.Helper()
store := storeFor(t, memCharge(1<<20))
for _, e := range entries {
hdr := regularHeader(e.name, int64(len(e.body)))
switch {
case e.linkname != "" && e.hard:
hdr = tar.Header{Name: e.name, Mode: 0o644, Typeflag: tar.TypeLink, Linkname: e.linkname}
case e.linkname != "":
hdr = tar.Header{Name: e.name, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: e.linkname}
}
require.NoError(t, store.Add(hdr, bytes.NewReader([]byte(e.body))))
}
return NewIndex(store, "", "outer.tar")
}
func globPaths(t *testing.T, r *Index, pattern string) []string {
t.Helper()
locations, err := r.FilesByGlob(pattern)
require.NoError(t, err)
var out []string
for _, l := range locations {
out = append(out, l.RealPath)
}
return out
}
// several paths to one file answer as one location: the file's own path when it matched, otherwise
// the lowest-sorting link
func TestIndex_linksCollapseToOnePath(t *testing.T) {
tests := []struct {
name string
entries []linkEntry
pattern string
want []string
reason string
}{
{
name: "the file itself beats every link to it",
entries: []linkEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/a-sym.jar", linkname: "real.jar"},
{name: "opt/z-sym.jar", linkname: "real.jar"},
{name: "opt/hard.jar", linkname: "opt/real.jar", hard: true},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
reason: "one file, so one location - and it is named by the path that holds it",
},
{
name: "with no candidate naming the file, the lowest-sorting link wins",
entries: []linkEntry{
{name: "opt/real.bin", body: "PK\x03\x04"},
{name: "opt/z-sym.jar", linkname: "real.bin"},
{name: "opt/a-sym.jar", linkname: "real.bin"},
{name: "opt/m-sym.jar", linkname: "real.bin"},
},
pattern: "**/*.jar",
want: []string{"opt/real.bin"},
reason: "the content is the target's, so that is the real path the answer carries",
},
{
name: "distinct files stay distinct",
entries: []linkEntry{
{name: "opt/one.jar", body: "one"},
{name: "opt/two.jar", body: "two"},
{name: "opt/one-sym.jar", linkname: "one.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/one.jar", "opt/two.jar"},
reason: "collapsing is per file, not per pattern",
},
{
name: "a dangling link keeps its own path",
entries: []linkEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/gone.jar", linkname: "missing.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/gone.jar", "opt/real.jar"},
reason: "it names no content, so it belongs to no other file's group",
},
{
name: "two dangling links are two paths, not one",
entries: []linkEntry{
{name: "opt/a.jar", linkname: "missing.jar"},
{name: "opt/b.jar", linkname: "missing.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/a.jar", "opt/b.jar"},
reason: "nothing resolves them to a common file, so neither may absorb the other",
},
{
name: "a chain of links collapses onto the file at its end",
entries: []linkEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/mid.jar", linkname: "real.jar"},
{name: "opt/outer.jar", linkname: "mid.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
reason: "following one hop at a time reaches the same file from every path",
},
{
name: "a cycle resolves to nothing rather than looping",
entries: []linkEntry{
{name: "opt/a.jar", linkname: "b.jar"},
{name: "opt/b.jar", linkname: "a.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/a.jar", "opt/b.jar"},
reason: "neither reaches content, so each stands under its own path",
},
{
name: "a hard link resolves from the archive root, not from its own directory",
entries: []linkEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/nested/hard.jar", linkname: "opt/real.jar", hard: true},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
reason: "read as relative it would resolve to opt/nested/opt/real.jar and dangle",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := indexWithLinks(t, tt.entries...)
assert.Equal(t, tt.want, globPaths(t, r, tt.pattern), tt.reason)
})
}
}
// the choice must not depend on the order the search found the candidates in
func TestIndex_collapseIgnoresTheOrderItFinds(t *testing.T) {
r := indexWithLinks(t,
linkEntry{name: "opt/real.jar", body: "PK\x03\x04"},
linkEntry{name: "opt/a-sym.jar", linkname: "real.jar"},
linkEntry{name: "opt/m-sym.jar", linkname: "real.jar"},
linkEntry{name: "opt/z-sym.jar", linkname: "real.jar"},
)
candidates := []*node{
r.byPath["/opt/real.jar"], r.byPath["/opt/a-sym.jar"],
r.byPath["/opt/m-sym.jar"], r.byPath["/opt/z-sym.jar"],
}
for _, n := range candidates {
require.NotNil(t, n)
}
rng := rand.New(rand.NewSource(1))
for range 64 {
rng.Shuffle(len(candidates), func(i, j int) {
candidates[i], candidates[j] = candidates[j], candidates[i]
})
found := map[*node]struct{}{}
for _, n := range candidates {
found[n] = struct{}{}
}
best := onePathPerFile(found)
require.Len(t, best, 1, "four paths, one file")
assert.Equal(t, "/opt/real.jar", best[0].path, "the file itself must win from any order")
}
// and with the file itself not among the candidates, the lowest-sorting link wins from any order
links := candidates[:0:0]
for _, n := range candidates {
if n.path != "/opt/real.jar" {
links = append(links, n)
}
}
for range 64 {
rng.Shuffle(len(links), func(i, j int) { links[i], links[j] = links[j], links[i] })
found := map[*node]struct{}{}
for _, n := range links {
found[n] = struct{}{}
}
best := onePathPerFile(found)
require.Len(t, best, 1)
assert.Equal(t, "/opt/a-sym.jar", best[0].path, "the lowest-sorting link must win from any order")
}
}
func TestIndex_collapsedLocationReadsTheTarget(t *testing.T) {
r := indexWithLinks(t,
linkEntry{name: "opt/real.bin", body: "PK\x03\x04 real contents"},
linkEntry{name: "opt/a-sym.jar", linkname: "real.bin"},
linkEntry{name: "opt/z-sym.jar", linkname: "real.bin"},
)
locations, err := r.FilesByGlob("**/*.jar")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "opt/real.bin", locations[0].RealPath, "content comes from the file")
assert.Equal(t, "opt/a-sym.jar", locations[0].AccessPath, "the path searched by is the one matched")
reader, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, reader.Close()) })
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "PK\x03\x04 real contents", string(body),
"a location standing for a link must still read the file's bytes, not the link's none")
}
func TestIndex_danglingLinkHasNoContent(t *testing.T) {
r := indexWithLinks(t, linkEntry{name: "opt/gone.jar", linkname: "missing.jar"})
locations, err := r.FilesByGlob("**/*.jar")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "opt/gone.jar", locations[0].RealPath)
reader, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err)
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Empty(t, string(body), "a link to nothing holds nothing")
metadata, err := r.FileMetadataByLocation(locations[0])
require.NoError(t, err)
assert.Equal(t, "opt/missing.jar", metadata.LinkDestination,
"where it pointed is still reported, resolved against the link's own directory")
}
// a link has no content to sniff a type from; the file it points at answers under its own path
func TestIndex_mimeTypeAnswersOnlyWithFiles(t *testing.T) {
r := indexWithLinks(t,
linkEntry{name: "opt/real.jar", body: "PK\x03\x04 zip contents here"},
linkEntry{name: "opt/sym.jar", linkname: "real.jar"},
linkEntry{name: "opt/hard.jar", linkname: "opt/real.jar", hard: true},
linkEntry{name: "opt/gone.jar", linkname: "missing.jar"},
linkEntry{name: "opt/notes.txt", body: "hello world"},
)
byMIME := func(types ...string) []string {
t.Helper()
locations, err := r.FilesByMIMEType(types...)
require.NoError(t, err)
var out []string
for _, l := range locations {
out = append(out, l.RealPath)
}
return out
}
assert.Equal(t, []string{"opt/real.jar"}, byMIME("application/zip"),
"the file answers, not the two links to it")
assert.Equal(t, []string{"opt/notes.txt"}, byMIME("text/plain"))
assert.Empty(t, byMIME(""),
"a link sniffs to no type, and must not be reachable by asking for that")
assert.Equal(t, []string{"opt/notes.txt", "opt/real.jar"}, byMIME("application/zip", "text/plain"))
}
// AllLocations enumerates what the archive holds rather than searching for files: the file metadata
// cataloger records a row per path, links included
func TestIndex_allLocationsKeepsEveryPath(t *testing.T) {
r := indexWithLinks(t,
linkEntry{name: "opt/real.jar", body: "PK\x03\x04"},
linkEntry{name: "opt/sym.jar", linkname: "real.jar"},
linkEntry{name: "opt/gone.jar", linkname: "missing.jar"},
)
var got []string
for l := range r.AllLocations(context.Background()) {
got = append(got, l.RealPath)
}
assert.Equal(t, []string{"opt/gone.jar", "opt/real.jar", "opt/sym.jar"}, got,
"every path the archive names, links included")
}