mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 22:12:28 +01:00
351 lines
9.8 KiB
Go
351 lines
9.8 KiB
Go
package archive
|
|
|
|
import (
|
|
"archive/tar"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
|
|
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
|
|
syftindex "github.com/anchore/syft/internal/index"
|
|
"github.com/anchore/syft/syft/file"
|
|
)
|
|
|
|
// Index is a file.Resolver over one extracted archive's entries.
|
|
//
|
|
// Paths are archive-relative. Nearly every cataloger glob is `**/<name>` or `**/*<suffix>`, so files
|
|
// are indexed by base name and a pattern whose last segment fixes a name, prefix or suffix is
|
|
// narrowed to those files before doublestar decides; any other pattern is matched against every file.
|
|
type Index struct {
|
|
fileSystemID string
|
|
archivePath string
|
|
store *EntryStore
|
|
|
|
// byPath holds every file and directory by rooted path, including directories the archive never
|
|
// listed but that entry paths imply
|
|
byPath map[string]*node
|
|
|
|
// files holds every file the archive listed, in path order
|
|
files []*node
|
|
|
|
// names indexes files by base name
|
|
names syftindex.PrefixSuffix[[]*node]
|
|
}
|
|
|
|
type node struct {
|
|
path string // rooted, e.g. "/META-INF/MANIFEST.MF"
|
|
isDir bool
|
|
entry *Entry // nil for a directory the archive never listed
|
|
metadata file.Metadata
|
|
|
|
// target is the node whose entry holds this node's content: itself for a regular file, the
|
|
// resolved node for a link, and nil for a link that leads nowhere inside this archive
|
|
target *node
|
|
}
|
|
|
|
var _ file.Resolver = (*Index)(nil)
|
|
|
|
// NewIndex builds a resolver over the store's entries whose locations carry the given fileSystemID
|
|
// and archivePath.
|
|
func NewIndex(store *EntryStore, fileSystemID, archivePath string) *Index {
|
|
r := &Index{
|
|
fileSystemID: fileSystemID,
|
|
archivePath: archivePath,
|
|
store: store,
|
|
byPath: map[string]*node{"/": {path: "/", isDir: true}},
|
|
}
|
|
for _, entry := range store.Entries() {
|
|
r.add(entry)
|
|
}
|
|
sort.Slice(r.files, func(i, j int) bool { return r.files[i].path < r.files[j].path })
|
|
for _, n := range r.byPath {
|
|
n.target = r.linkTarget(n)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// add places an entry at its path, creating the directories the path implies. The first entry at a
|
|
// path wins.
|
|
func (r *Index) add(entry *Entry) {
|
|
entryPath := path.Clean("/" + entry.Header.Name)
|
|
if entryPath == "/" {
|
|
return
|
|
}
|
|
for i := 1; i < len(entryPath); i++ {
|
|
if entryPath[i] == '/' {
|
|
r.nodeAt(entryPath[:i], true)
|
|
}
|
|
}
|
|
|
|
n := r.nodeAt(entryPath, entry.Header.FileInfo().IsDir())
|
|
if n.entry != nil {
|
|
return
|
|
}
|
|
n.entry = entry
|
|
n.metadata = r.metadataOf(entry, entryPath)
|
|
if n.isDir {
|
|
return
|
|
}
|
|
r.files = append(r.files, n)
|
|
r.names.Update(path.Base(entryPath), func(current *syftindex.Node[[]*node]) []*node {
|
|
return append(current.Value(), n)
|
|
})
|
|
}
|
|
|
|
func (r *Index) nodeAt(p string, isDir bool) *node {
|
|
if n, ok := r.byPath[p]; ok {
|
|
return n
|
|
}
|
|
n := &node{path: p, isDir: isDir}
|
|
r.byPath[p] = n
|
|
return n
|
|
}
|
|
|
|
// maxLinkHops bounds a chain of links pointing at links, which is also what ends a cycle.
|
|
const maxLinkHops = 8
|
|
|
|
func (r *Index) linkTarget(n *node) *node {
|
|
for hops := 0; hops <= maxLinkHops; hops++ {
|
|
if n.entry == nil || n.isDir {
|
|
return nil
|
|
}
|
|
if n.entry.Header.Linkname == "" {
|
|
return n
|
|
}
|
|
next, ok := r.byPath[linkDestination(n.path, n.entry.Header)]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
n = next
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// linkDestination is the rooted path a link points at inside the archive. A hard link names its
|
|
// target from the archive root, as does an absolute symlink, so "/etc/passwd" is an entry of this
|
|
// archive and never the host's. A relative symlink is joined onto the link's own directory, and
|
|
// cleaning against the root keeps "../../x" inside the archive.
|
|
func linkDestination(linkPath string, hdr tar.Header) string {
|
|
if hdr.Typeflag == tar.TypeLink || path.IsAbs(hdr.Linkname) {
|
|
return path.Join("/", hdr.Linkname)
|
|
}
|
|
return path.Join(path.Dir(linkPath), hdr.Linkname)
|
|
}
|
|
|
|
func (r *Index) metadataOf(entry *Entry, entryPath string) file.Metadata {
|
|
metadata := stereoscopeFile.NewMetadata(entry.Header, r.store.Open(entry))
|
|
// NewMetadata copies the header and points FileInfo into the copy; share the one the store holds
|
|
metadata.FileInfo = entry.Header.FileInfo()
|
|
metadata.Path = reportedPath(entryPath)
|
|
if entry.Header.Linkname != "" {
|
|
metadata.LinkDestination = reportedPath(linkDestination(entryPath, entry.Header))
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
// reportedPath is how paths leave the resolver: archive-relative with no leading slash.
|
|
func reportedPath(rooted string) string {
|
|
return strings.TrimPrefix(rooted, "/")
|
|
}
|
|
|
|
func rootedPath(userPath string) string {
|
|
return path.Clean("/" + userPath)
|
|
}
|
|
|
|
func (r *Index) coordinates(n *node) file.Coordinates {
|
|
return file.Coordinates{
|
|
RealPath: reportedPath(n.path),
|
|
FileSystemID: r.fileSystemID,
|
|
ArchivePath: r.archivePath,
|
|
}
|
|
}
|
|
|
|
// locationOf names a node by its own path.
|
|
func (r *Index) locationOf(n *node) file.Location {
|
|
return file.NewLocationFromCoordinates(r.coordinates(n))
|
|
}
|
|
|
|
// resolvedLocation names the file behind a node: a link is reported at its target's path with the
|
|
// link's own path as the access path, and a link leading nowhere is reported as itself.
|
|
func (r *Index) resolvedLocation(n *node) file.Location {
|
|
if n.target == nil || n.target == n {
|
|
return r.locationOf(n)
|
|
}
|
|
return file.NewVirtualLocationFromCoordinates(r.coordinates(n.target), reportedPath(n.path))
|
|
}
|
|
|
|
func (r *Index) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
|
|
n, ok := r.byPath[rootedPath(location.RealPath)]
|
|
if !ok || n.entry == nil {
|
|
return nil, fmt.Errorf("no archive entry for path=%q", location.RealPath)
|
|
}
|
|
return entryReader{r.store.Open(n.entry)}, nil
|
|
}
|
|
|
|
func (r *Index) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
|
|
n, ok := r.byPath[rootedPath(location.RealPath)]
|
|
if !ok {
|
|
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.RealPath)
|
|
}
|
|
return n.metadata, nil
|
|
}
|
|
|
|
func (r *Index) HasPath(userPath string) bool {
|
|
_, ok := r.byPath[rootedPath(userPath)]
|
|
return ok
|
|
}
|
|
|
|
func (r *Index) FilesByPath(paths ...string) ([]file.Location, error) {
|
|
var out []file.Location
|
|
seen := map[*node]struct{}{}
|
|
for _, userPath := range paths {
|
|
n, ok := r.byPath[rootedPath(userPath)]
|
|
if !ok || n.isDir || n.entry == nil {
|
|
continue
|
|
}
|
|
if _, dup := seen[n]; dup {
|
|
continue
|
|
}
|
|
seen[n] = struct{}{}
|
|
out = append(out, r.resolvedLocation(n))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Index) FilesByGlob(patterns ...string) ([]file.Location, error) {
|
|
matched := map[*node]struct{}{}
|
|
for _, pattern := range patterns {
|
|
pattern = strings.TrimLeft(pattern, "/")
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
for _, n := range r.globCandidates(pattern) {
|
|
ok, err := doublestar.Match(pattern, reportedPath(n.path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
|
|
}
|
|
if ok {
|
|
matched[n] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
var out []file.Location
|
|
for _, n := range onePathPerFile(matched) {
|
|
out = append(out, r.resolvedLocation(n))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].RealPath != out[j].RealPath {
|
|
return out[i].RealPath < out[j].RealPath
|
|
}
|
|
return out[i].AccessPath < out[j].AccessPath
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// globMetacharacters are the glob metacharacters other than `*` itself.
|
|
const globMetacharacters = `?[]{}\`
|
|
|
|
// globCandidates narrows the files a pattern can match by its last segment: an exact name, `*suffix`
|
|
// or `prefix*` is looked up in the name index. Any other shape has every file as a candidate.
|
|
func (r *Index) globCandidates(pattern string) []*node {
|
|
last := pattern[strings.LastIndexByte(pattern, '/')+1:]
|
|
if strings.ContainsAny(last, globMetacharacters) || strings.Count(last, "*") > 1 {
|
|
return r.files
|
|
}
|
|
switch star := strings.IndexByte(last, '*'); {
|
|
case star < 0:
|
|
return r.names.Get(last)
|
|
case star == 0:
|
|
return flatten(r.names.BySuffix(last[1:]))
|
|
case star == len(last)-1:
|
|
return flatten(r.names.ByPrefix(last[:star]))
|
|
}
|
|
return r.files
|
|
}
|
|
|
|
func flatten(groups [][]*node) []*node {
|
|
var out []*node
|
|
for _, group := range groups {
|
|
out = append(out, group...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// onePathPerFile keeps one matched path per file. Several paths can name the same content, such as
|
|
// a link and its target, and a resolver answers with one: the file's own path when it matched,
|
|
// otherwise the lowest-sorting link. A link leading nowhere stands as its own file.
|
|
func onePathPerFile(matched map[*node]struct{}) []*node {
|
|
best := map[*node]*node{}
|
|
for n := range matched {
|
|
key := n.target
|
|
if key == nil {
|
|
key = n
|
|
}
|
|
current, seen := best[key]
|
|
if !seen || n == key || (current != key && n.path < current.path) {
|
|
best[key] = n
|
|
}
|
|
}
|
|
out := make([]*node, 0, len(best))
|
|
for _, n := range best {
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *Index) FilesByMIMEType(types ...string) ([]file.Location, error) {
|
|
wanted := map[string]struct{}{}
|
|
for _, t := range types {
|
|
wanted[t] = struct{}{}
|
|
}
|
|
var out []file.Location
|
|
for _, n := range r.files {
|
|
// only a file holding bytes has a type of its own; a link's target answers for itself
|
|
if n.target != n {
|
|
continue
|
|
}
|
|
if _, ok := wanted[n.metadata.MIMEType]; ok {
|
|
out = append(out, r.locationOf(n))
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// RelativeFileByPath resolves against the archive root, the only frame an archive has.
|
|
func (r *Index) RelativeFileByPath(_ file.Location, userPath string) *file.Location {
|
|
locations, _ := r.FilesByPath(userPath)
|
|
if len(locations) == 0 {
|
|
return nil
|
|
}
|
|
return &locations[0]
|
|
}
|
|
|
|
func (r *Index) AllLocations(ctx context.Context) <-chan file.Location {
|
|
out := make(chan file.Location)
|
|
go func() {
|
|
defer close(out)
|
|
for _, n := range r.files {
|
|
select {
|
|
case out <- r.locationOf(n):
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return out
|
|
}
|
|
|
|
// entryReader gives an entry's reader the Close the resolver interface requires. The store owns the
|
|
// bytes, so there is nothing to close.
|
|
type entryReader struct {
|
|
ReaderAtSeeker
|
|
}
|
|
|
|
func (entryReader) Close() error { return nil }
|