feat: Support scanning license files in golang packages over the network (#1630)

Signed-off-by: Avi Deitcher <avi@deitcher.net>
Signed-off-by: Keith Zantow <kzantow@gmail.com>
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
Co-authored-by: Keith Zantow <kzantow@gmail.com>
Co-authored-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Avi Deitcher
2023-04-14 15:13:29 -04:00
committed by GitHub
co-authored by Keith Zantow Alex Goodman
parent 44422853be
commit b69259534d
29 changed files with 2475 additions and 102 deletions
+52
View File
@@ -0,0 +1,52 @@
package event
import (
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/syft/internal/bus"
)
type CatalogerTask struct {
prog *progress.Manual
// Title
Title string
// TitleOnCompletion a string to use as title when completed
TitleOnCompletion string
// SubStatus indicates this progress should be rendered as a sub-item
SubStatus bool
// RemoveOnCompletion indicates this progress line will be removed when completed
RemoveOnCompletion bool
// value is the value to display -- not public as SetValue needs to be called to initialize this progress
value string
}
func (e *CatalogerTask) init() {
e.prog = progress.NewManual(-1)
bus.Publish(partybus.Event{
Type: CatalogerTaskStarted,
Source: e,
})
}
func (e *CatalogerTask) SetCompleted() {
if e.prog != nil {
e.prog.SetCompleted()
}
}
func (e *CatalogerTask) SetValue(value string) {
if e.prog == nil {
e.init()
}
e.value = value
}
func (e *CatalogerTask) GetValue() string {
return e.value
}
func (e *CatalogerTask) GetMonitor() *progress.Manual {
return e.prog
}
+3
View File
@@ -34,4 +34,7 @@ const (
// AttestationStarted is a partybus event that occurs when starting an SBOM attestation process
AttestationStarted partybus.EventType = "syft-attestation-started-event"
// CatalogerTaskStarted is a partybus event that occurs when starting a task within a cataloger
CatalogerTaskStarted partybus.EventType = "syft-cataloger-task-started"
)
+13
View File
@@ -111,6 +111,19 @@ func ParseFileIndexingStarted(e partybus.Event) (string, progress.StagedProgress
return path, prog, nil
}
func ParseCatalogerTaskStarted(e partybus.Event) (*event.CatalogerTask, error) {
if err := checkEventType(e.Type, event.CatalogerTaskStarted); err != nil {
return nil, err
}
source, ok := e.Source.(*event.CatalogerTask)
if !ok {
return nil, newPayloadErr(e.Type, "Source", e.Source)
}
return source, nil
}
func ParseExit(e partybus.Event) (func() error, error) {
if err := checkEventType(e.Type, event.Exit); err != nil {
return nil, err
@@ -0,0 +1,87 @@
package golang
import (
"io/fs"
"os"
"github.com/go-git/go-billy/v5"
)
// billyFSAdapter is a fs.FS, fs.ReadDirFS, and fs.StatFS wrapping what billyfs returns
type billyFSAdapter struct {
fs billy.Filesystem
}
func (b billyFSAdapter) Stat(name string) (fs.FileInfo, error) {
return b.fs.Stat(name)
}
func (b billyFSAdapter) ReadDir(name string) (out []fs.DirEntry, _ error) {
entries, err := b.fs.ReadDir(name)
if err != nil {
return nil, err
}
for _, e := range entries {
out = append(out, billyDirEntry{fi: e})
}
return
}
func (b billyFSAdapter) Open(name string) (fs.File, error) {
f, err := b.fs.Open(name)
if err != nil {
return nil, err
}
fi, err := b.Stat(name)
if err != nil {
return nil, err
}
return billyFile{f: f, fi: fi}, nil
}
var _ fs.FS = (*billyFSAdapter)(nil)
var _ fs.ReadDirFS = (*billyFSAdapter)(nil)
var _ fs.StatFS = (*billyFSAdapter)(nil)
// billyFile is a fs.File wrapping what billyfs returns
type billyFile struct {
f billy.File
fi fs.FileInfo
}
func (b billyFile) Stat() (fs.FileInfo, error) {
return b.fi, nil
}
func (b billyFile) Read(i []byte) (int, error) {
return b.f.Read(i)
}
func (b billyFile) Close() error {
return b.f.Close()
}
var _ fs.File = (*billyFile)(nil)
// billyDirEntry is a fs.DirEntry wrapping what billyfs returns
type billyDirEntry struct {
fi os.FileInfo
}
func (b billyDirEntry) Name() string {
return b.fi.Name()
}
func (b billyDirEntry) IsDir() bool {
return b.fi.IsDir()
}
func (b billyDirEntry) Type() fs.FileMode {
return b.fi.Mode()
}
func (b billyDirEntry) Info() (fs.FileInfo, error) {
return b.fi, nil
}
var _ fs.DirEntry = (*billyDirEntry)(nil)
@@ -0,0 +1,33 @@
package golang
import (
"io/fs"
"os"
"testing"
"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/require"
)
func Test_billyFSAdapter(t *testing.T) {
r, err := git.PlainInit("test-fixtures/repo", false)
t.Cleanup(func() {
_ = os.RemoveAll("test-fixtures/repo/.git")
})
wt, err := r.Worktree()
require.NoError(t, err)
f := billyFSAdapter{
fs: wt.Filesystem,
}
found := ""
err = fs.WalkDir(f, ".", func(path string, d fs.DirEntry, err error) error {
found = path
return nil
})
require.NoError(t, err)
require.Equal(t, "LICENSE", found)
}
+30 -11
View File
@@ -5,28 +5,47 @@ package golang
import (
"github.com/anchore/syft/internal"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
"github.com/anchore/syft/syft/source"
)
type GoCatalogerOpts struct {
SearchLocalModCacheLicenses bool
LocalModCacheDir string
}
// NewGoModFileCataloger returns a new Go module cataloger object.
func NewGoModFileCataloger(opts GoCatalogerOpts) *generic.Cataloger {
func NewGoModFileCataloger(opts GoCatalogerOpts) pkg.Cataloger {
c := goModCataloger{
licenses: newGoLicenses(opts),
}
return generic.NewCataloger("go-mod-file-cataloger").
WithParserByGlobs(c.parseGoModFile, "**/go.mod")
return &progressingCataloger{
progress: c.licenses.progress,
cataloger: generic.NewCataloger("go-mod-file-cataloger").
WithParserByGlobs(c.parseGoModFile, "**/go.mod"),
}
}
// NewGoModuleBinaryCataloger returns a new Golang cataloger object.
func NewGoModuleBinaryCataloger(opts GoCatalogerOpts) *generic.Cataloger {
func NewGoModuleBinaryCataloger(opts GoCatalogerOpts) pkg.Cataloger {
c := goBinaryCataloger{
licenses: newGoLicenses(opts),
}
return generic.NewCataloger("go-module-binary-cataloger").
WithParserByMimeTypes(c.parseGoBinary, internal.ExecutableMIMETypeSet.List()...)
return &progressingCataloger{
progress: c.licenses.progress,
cataloger: generic.NewCataloger("go-module-binary-cataloger").
WithParserByMimeTypes(c.parseGoBinary, internal.ExecutableMIMETypeSet.List()...),
}
}
type progressingCataloger struct {
progress *event.CatalogerTask
cataloger *generic.Cataloger
}
func (p *progressingCataloger) Name() string {
return p.cataloger.Name()
}
func (p *progressingCataloger) Catalog(resolver source.FileResolver) ([]pkg.Package, []artifact.Relationship, error) {
defer p.progress.SetCompleted()
return p.cataloger.Catalog(resolver)
}
+198 -52
View File
@@ -1,105 +1,158 @@
package golang
import (
"archive/zip"
"bytes"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/anchore/syft/internal/licenses"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/source"
)
type goLicenses struct {
searchLocalModCacheLicenses bool
localModCacheResolver source.FileResolver
opts GoCatalogerOpts
localModCacheResolver source.WritableFileResolver
progress *event.CatalogerTask
}
func newGoLicenses(opts GoCatalogerOpts) goLicenses {
return goLicenses{
searchLocalModCacheLicenses: opts.SearchLocalModCacheLicenses,
localModCacheResolver: modCacheResolver(opts.LocalModCacheDir),
opts: opts,
localModCacheResolver: modCacheResolver(opts.localModCacheDir),
progress: &event.CatalogerTask{
SubStatus: true,
RemoveOnCompletion: true,
Title: "Downloading go mod",
},
}
}
func defaultGoPath() string {
goPath := os.Getenv("GOPATH")
if goPath == "" {
homeDir, err := homedir.Dir()
if err != nil {
log.Debug("unable to determine user home dir: %v", err)
} else {
goPath = path.Join(homeDir, "go")
func remotesForModule(proxies []string, noProxy []string, module string) []string {
for _, pattern := range noProxy {
if matched, err := path.Match(pattern, module); err == nil && matched {
// matched to be direct for this module
return directProxiesOnly
}
}
return goPath
return proxies
}
// resolver needs to be shared between mod file & binary scanners so it's only scanned once
var modCacheResolvers = map[string]source.FileResolver{}
func modCacheResolver(modCacheDir string) source.FileResolver {
if modCacheDir == "" {
goPath := defaultGoPath()
if goPath != "" {
modCacheDir = path.Join(goPath, "pkg", "mod")
}
}
if r, ok := modCacheResolvers[modCacheDir]; ok {
return r
}
var r source.FileResolver
func modCacheResolver(modCacheDir string) source.WritableFileResolver {
var r source.WritableFileResolver
if modCacheDir == "" {
log.Trace("unable to determine mod cache directory, skipping mod cache resolver")
r = source.NewMockResolverForPaths()
r = source.EmptyResolver{}
} else {
stat, err := os.Stat(modCacheDir)
if os.IsNotExist(err) || stat == nil || !stat.IsDir() {
log.Tracef("unable to open mod cache directory: %s, skipping mod cache resolver", modCacheDir)
r = source.NewMockResolverForPaths()
r = source.EmptyResolver{}
} else {
r = source.NewDeferredResolverFromSource(func() (source.Source, error) {
return source.NewFromDirectory(modCacheDir)
})
r = source.NewUnindexedDirectoryResolver(modCacheDir)
}
}
modCacheResolvers[modCacheDir] = r
return r
}
func (c *goLicenses) getLicenses(resolver source.FileResolver, moduleName, moduleVersion string) (licenses []string, err error) {
moduleName = processCaps(moduleName)
licenses, err = findLicenses(resolver,
fmt.Sprintf(`**/go/pkg/mod/%s@%s/*`, moduleName, moduleVersion),
fmt.Sprintf(`**/go/pkg/mod/%s@%s/*`, processCaps(moduleName), moduleVersion),
)
if c.searchLocalModCacheLicenses && err == nil && len(licenses) == 0 {
// if we're running against a directory on the filesystem, it may not include the
// user's homedir / GOPATH, so we defer to using the localModCacheResolver
licenses, err = findLicenses(c.localModCacheResolver,
fmt.Sprintf(`**/%s@%s/*`, moduleName, moduleVersion),
)
if err != nil || len(licenses) > 0 {
return requireCollection(licenses), err
}
// always return a non-nil slice
// look in the local host mod cache...
licenses, err = c.getLicensesFromLocal(moduleName, moduleVersion)
if err != nil || len(licenses) > 0 {
return requireCollection(licenses), err
}
// we did not find it yet and remote searching was enabled
licenses, err = c.getLicensesFromRemote(moduleName, moduleVersion)
return requireCollection(licenses), err
}
func (c *goLicenses) getLicensesFromLocal(moduleName, moduleVersion string) ([]string, error) {
if !c.opts.searchLocalModCacheLicenses {
return nil, nil
}
// if we're running against a directory on the filesystem, it may not include the
// user's homedir / GOPATH, so we defer to using the localModCacheResolver
return findLicenses(c.localModCacheResolver, moduleSearchGlob(moduleName, moduleVersion))
}
func (c *goLicenses) getLicensesFromRemote(moduleName, moduleVersion string) ([]string, error) {
if !c.opts.searchRemoteLicenses {
return nil, nil
}
proxies := remotesForModule(c.opts.proxies, c.opts.noProxy, moduleName)
fsys, err := getModule(c.progress, proxies, moduleName, moduleVersion)
if err != nil {
return nil, err
}
dir := moduleDir(moduleName, moduleVersion)
// populate the mod cache with the results
err = fs.WalkDir(fsys, ".", func(filePath string, d fs.DirEntry, err error) error {
if err != nil {
log.Debug(err)
return nil
}
if d.IsDir() {
return nil
}
f, err := fsys.Open(filePath)
if err != nil {
return err
}
return c.localModCacheResolver.Write(source.NewLocation(path.Join(dir, filePath)), f)
})
if err != nil {
log.Tracef("remote proxy walk failed for: %s", moduleName)
}
return findLicenses(c.localModCacheResolver, moduleSearchGlob(moduleName, moduleVersion))
}
func moduleDir(moduleName, moduleVersion string) string {
return fmt.Sprintf("%s@%s", processCaps(moduleName), moduleVersion)
}
func moduleSearchGlob(moduleName, moduleVersion string) string {
return fmt.Sprintf("%s/*", moduleDir(moduleName, moduleVersion))
}
func requireCollection(licenses []string) []string {
if licenses == nil {
licenses = []string{}
return []string{}
}
return
return licenses
}
func findLicenses(resolver source.FileResolver, globMatch string) (out []string, err error) {
@@ -138,3 +191,96 @@ func processCaps(s string) string {
return "!" + strings.ToLower(s)
})
}
func getModule(progress *event.CatalogerTask, proxies []string, moduleName, moduleVersion string) (fsys fs.FS, err error) {
for _, proxy := range proxies {
u, _ := url.Parse(proxy)
if proxy == "direct" {
fsys, err = getModuleRepository(progress, moduleName, moduleVersion)
continue
}
switch u.Scheme {
case "https", "http":
fsys, err = getModuleProxy(progress, proxy, moduleName, moduleVersion)
case "file":
p := filepath.Join(u.Path, moduleName, "@v", moduleVersion)
progress.SetValue(fmt.Sprintf("file: %s", p))
fsys = os.DirFS(p)
}
if fsys != nil {
break
}
}
return
}
func getModuleProxy(progress *event.CatalogerTask, proxy string, moduleName string, moduleVersion string) (out fs.FS, _ error) {
u := fmt.Sprintf("%s/%s/@v/%s.zip", proxy, moduleName, moduleVersion)
progress.SetValue(u)
// get the module zip
resp, err := http.Get(u) //nolint:gosec
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
u = fmt.Sprintf("%s/%s/@v/%s.zip", proxy, strings.ToLower(moduleName), moduleVersion)
progress.SetValue(u)
// try lowercasing it; some packages have mixed casing that really messes up the proxy
resp, err = http.Get(u) //nolint:gosec
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get module zip: %s", resp.Status)
}
}
// read the zip
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
out, err = zip.NewReader(bytes.NewReader(b), resp.ContentLength)
versionPath := findVersionPath(out, ".")
out = getSubFS(out, versionPath)
return out, err
}
func findVersionPath(f fs.FS, dir string) string {
list, _ := fs.ReadDir(f, dir)
for _, entry := range list {
name := entry.Name()
if strings.Contains(name, "@") {
return name
}
found := findVersionPath(f, path.Join(dir, name))
if found != "" {
return path.Join(name, found)
}
}
return ""
}
func getModuleRepository(progress *event.CatalogerTask, moduleName string, moduleVersion string) (fs.FS, error) {
repoName := moduleName
parts := strings.Split(moduleName, "/")
if len(parts) > 2 {
repoName = fmt.Sprintf("%s/%s/%s", parts[0], parts[1], parts[2])
}
progress.SetValue(fmt.Sprintf("git: %s", repoName))
f := memfs.New()
buf := &bytes.Buffer{}
_, err := git.Clone(memory.NewStorage(), f, &git.CloneOptions{
URL: fmt.Sprintf("https://%s", repoName),
ReferenceName: plumbing.NewTagReferenceName(moduleVersion), // FIXME version might be a SHA
SingleBranch: true,
Depth: 1,
Progress: buf,
})
if err != nil {
return nil, fmt.Errorf("%w -- %s", err, buf.String())
}
return billyFSAdapter{fs: f}, nil
}
+124 -4
View File
@@ -1,8 +1,14 @@
package golang
import (
"archive/zip"
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -10,7 +16,7 @@ import (
"github.com/anchore/syft/syft/source"
)
func Test_LicenseSearch(t *testing.T) {
func Test_LocalLicenseSearch(t *testing.T) {
tests := []struct {
name string
version string
@@ -34,10 +40,85 @@ func Test_LicenseSearch(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
l := newGoLicenses(GoCatalogerOpts{
SearchLocalModCacheLicenses: true,
LocalModCacheDir: path.Join(wd, "test-fixtures", "licenses"),
searchLocalModCacheLicenses: true,
localModCacheDir: path.Join(wd, "test-fixtures", "licenses", "pkg", "mod"),
})
licenses, err := l.getLicenses(source.MockResolver{}, test.name, test.version)
licenses, err := l.getLicenses(source.EmptyResolver{}, test.name, test.version)
require.NoError(t, err)
require.Len(t, licenses, 1)
require.Equal(t, test.expected, licenses[0])
})
}
}
func Test_RemoteProxyLicenseSearch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := &bytes.Buffer{}
uri := strings.TrimPrefix(strings.TrimSuffix(r.RequestURI, ".zip"), "/")
parts := strings.Split(uri, "/@v/")
modPath := parts[0]
modVersion := parts[1]
wd, err := os.Getwd()
require.NoError(t, err)
testDir := path.Join(wd, "test-fixtures", "licenses", "pkg", "mod", processCaps(modPath)+"@"+modVersion)
archive := zip.NewWriter(buf)
entries, err := os.ReadDir(testDir)
require.NoError(t, err)
for _, f := range entries {
// the zip files downloaded contain a path to the repo that somewhat matches where it ends up on disk,
// so prefix entries with something similar
writer, err := archive.Create(path.Join("github.com/something/some@version", f.Name()))
require.NoError(t, err)
contents, err := os.ReadFile(path.Join(testDir, f.Name()))
require.NoError(t, err)
_, err = writer.Write(contents)
require.NoError(t, err)
}
err = archive.Close()
require.NoError(t, err)
w.Header().Add("Content-Length", fmt.Sprintf("%d", buf.Len()))
_, err = w.Write(buf.Bytes())
require.NoError(t, err)
}))
defer server.Close()
tests := []struct {
name string
version string
expected string
}{
{
name: "github.com/someorg/somename",
version: "v0.3.2",
expected: "Apache-2.0",
},
{
name: "github.com/CapORG/CapProject",
version: "v4.111.5",
expected: "MIT",
},
}
modDir := path.Join(t.TempDir())
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
l := newGoLicenses(GoCatalogerOpts{
searchRemoteLicenses: true,
proxies: []string{server.URL},
localModCacheDir: modDir,
})
licenses, err := l.getLicenses(source.EmptyResolver{}, test.name, test.version)
require.NoError(t, err)
require.Len(t, licenses, 1)
@@ -74,3 +155,42 @@ func Test_processCaps(t *testing.T) {
})
}
}
func Test_remotesForModule(t *testing.T) {
allProxies := []string{"https://somewhere.org", "direct"}
directProxy := []string{"direct"}
tests := []struct {
module string
noProxy string
expected []string
}{
{
module: "github.com/anchore/syft",
expected: allProxies,
},
{
module: "github.com/anchore/sbom-action",
noProxy: "*/anchore/*",
expected: directProxy,
},
{
module: "github.com/anchore/sbom-action",
noProxy: "*/user/mod,*/anchore/sbom-action",
expected: directProxy,
},
}
for _, test := range tests {
t.Run(test.module, func(t *testing.T) {
got := remotesForModule(allProxies, strings.Split(test.noProxy, ","), test.module)
require.Equal(t, test.expected, got)
})
}
}
func Test_findVersionPath(t *testing.T) {
f := os.DirFS("test-fixtures/zip-fs")
vp := findVersionPath(f, ".")
require.Equal(t, "github.com/someorg/somepkg@version", vp)
}
+114
View File
@@ -0,0 +1,114 @@
package golang
import (
"os"
"path"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/anchore/syft/internal/log"
)
const (
defaultProxies = "https://proxy.golang.org,direct"
directProxyOnly = "direct"
)
var (
directProxiesOnly = []string{directProxyOnly}
)
type GoCatalogerOpts struct {
searchLocalModCacheLicenses bool
localModCacheDir string
searchRemoteLicenses bool
proxies []string
noProxy []string
}
func (g GoCatalogerOpts) WithSearchLocalModCacheLicenses(input bool) GoCatalogerOpts {
g.searchLocalModCacheLicenses = input
return g
}
func (g GoCatalogerOpts) WithLocalModCacheDir(input string) GoCatalogerOpts {
if input == "" {
return g
}
g.localModCacheDir = input
return g
}
func (g GoCatalogerOpts) WithSearchRemoteLicenses(input bool) GoCatalogerOpts {
g.searchRemoteLicenses = input
return g
}
func (g GoCatalogerOpts) WithProxy(input string) GoCatalogerOpts {
if input == "" {
return g
}
if input == "off" {
input = directProxyOnly
}
g.proxies = strings.Split(input, ",")
return g
}
func (g GoCatalogerOpts) WithNoProxy(input string) GoCatalogerOpts {
if input == "" {
return g
}
g.noProxy = strings.Split(input, ",")
return g
}
// NewGoCatalogerOpts create a GoCatalogerOpts with default options, which includes:
// - setting the default remote proxy if none is provided
// - setting the default no proxy if none is provided
// - setting the default local module cache dir if none is provided
func NewGoCatalogerOpts() GoCatalogerOpts {
g := GoCatalogerOpts{}
// first process the proxy settings
if len(g.proxies) == 0 {
goProxy := os.Getenv("GOPROXY")
if goProxy == "" {
goProxy = defaultProxies
}
g = g.WithProxy(goProxy)
}
// next process the gonoproxy settings
if len(g.noProxy) == 0 {
goPrivate := os.Getenv("GOPRIVATE")
goNoProxy := os.Getenv("GONOPROXY")
// we only use the env var if it was not set explicitly
if goPrivate != "" {
g.noProxy = append(g.noProxy, strings.Split(goPrivate, ",")...)
}
// next process the goprivate settings; we always add those
if goNoProxy != "" {
g.noProxy = append(g.noProxy, strings.Split(goNoProxy, ",")...)
}
}
if g.localModCacheDir == "" {
goPath := os.Getenv("GOPATH")
if goPath == "" {
homeDir, err := homedir.Dir()
if err != nil {
log.Debug("unable to determine user home dir: %v", err)
} else {
goPath = path.Join(homeDir, "go")
}
}
if goPath != "" {
g.localModCacheDir = path.Join(goPath, "pkg", "mod")
}
}
return g
}
+99
View File
@@ -0,0 +1,99 @@
package golang
import (
"testing"
"github.com/mitchellh/go-homedir"
"github.com/stretchr/testify/assert"
)
func Test_Options(t *testing.T) {
type opts struct {
local bool
cacheDir string
remote bool
proxy string
noProxy string
}
homedirCacheDisabled := homedir.DisableCache
homedir.DisableCache = true
t.Cleanup(func() {
homedir.DisableCache = homedirCacheDisabled
})
allEnv := map[string]string{
"HOME": "/usr/home",
"GOPATH": "",
"GOPROXY": "",
"GOPRIVATE": "",
"GONOPROXY": "",
}
tests := []struct {
name string
env map[string]string
opts opts
expected GoCatalogerOpts
}{
{
name: "set via env defaults",
env: map[string]string{
"GOPATH": "/go",
"GOPROXY": "https://my.proxy",
"GOPRIVATE": "my.private",
"GONOPROXY": "no.proxy",
},
opts: opts{},
expected: GoCatalogerOpts{
searchLocalModCacheLicenses: false,
localModCacheDir: "/go/pkg/mod",
searchRemoteLicenses: false,
proxies: []string{"https://my.proxy"},
noProxy: []string{"my.private", "no.proxy"},
},
},
{
name: "set via configuration",
env: map[string]string{
"GOPATH": "/go",
"GOPROXY": "https://my.proxy",
"GOPRIVATE": "my.private",
"GONOPROXY": "no.proxy",
},
opts: opts{
local: true,
cacheDir: "/go-cache",
remote: true,
proxy: "https://alt.proxy,direct",
noProxy: "alt.no.proxy",
},
expected: GoCatalogerOpts{
searchLocalModCacheLicenses: true,
localModCacheDir: "/go-cache",
searchRemoteLicenses: true,
proxies: []string{"https://alt.proxy", "direct"},
noProxy: []string{"alt.no.proxy"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for k, v := range allEnv {
t.Setenv(k, v)
}
for k, v := range test.env {
t.Setenv(k, v)
}
got := NewGoCatalogerOpts().
WithSearchLocalModCacheLicenses(test.opts.local).
WithLocalModCacheDir(test.opts.cacheDir).
WithSearchRemoteLicenses(test.opts.remote).
WithProxy(test.opts.proxy).
WithNoProxy(test.opts.noProxy)
assert.Equal(t, test.expected, got)
})
}
}
@@ -510,7 +510,7 @@ func TestBuildGoPkgInfo(t *testing.T) {
)
c := goBinaryCataloger{}
pkgs := c.buildGoPkgInfo(source.NewMockResolverForPaths(), location, test.mod, test.arch)
pkgs := c.buildGoPkgInfo(source.EmptyResolver{}, location, test.mod, test.arch)
assert.Equal(t, test.expected, pkgs)
})
}
+117
View File
@@ -0,0 +1,117 @@
package golang
import (
"io/fs"
"path"
"time"
)
type subFS struct {
root string
f fs.FS
}
func getSubFS(f fs.FS, root string) fs.FS {
if s, ok := f.(fs.SubFS); ok {
s, err := s.Sub(root)
if err != nil {
return s
}
}
return newSubFS(f, root)
}
func newSubFS(f fs.FS, root string) fs.FS {
return subFS{
root: root,
f: f,
}
}
func (s subFS) Open(name string) (fs.File, error) {
if name == "." {
return rootFile{
s: s,
}, nil
}
return s.f.Open(path.Join(s.root, name))
}
type rootFile struct {
s subFS
}
func (r rootFile) Name() string {
return "."
}
func (r rootFile) Size() int64 {
return 0
}
func (r rootFile) Mode() fs.FileMode {
return fs.ModePerm
}
func (r rootFile) ModTime() time.Time {
return time.Now()
}
func (r rootFile) IsDir() bool {
return true
}
func (r rootFile) Sys() any {
return nil
}
func (r rootFile) ReadDir(_ int) ([]fs.DirEntry, error) {
return fs.ReadDir(r.s.f, r.s.root)
}
func (r rootFile) Stat() (fs.FileInfo, error) {
return r, nil
}
func (r rootFile) Read(_ []byte) (int, error) {
panic("not implemented")
}
func (r rootFile) Close() error {
return nil
}
var _ fs.File = (*rootFile)(nil)
var _ fs.ReadDirFile = (*rootFile)(nil)
var _ fs.FileInfo = (*rootFile)(nil)
type subFsFileInfo struct {
fi fs.FileInfo
}
func (s subFsFileInfo) Name() string {
return s.fi.Name()
}
func (s subFsFileInfo) Size() int64 {
return s.fi.Size()
}
func (s subFsFileInfo) Mode() fs.FileMode {
return s.fi.Mode()
}
func (s subFsFileInfo) ModTime() time.Time {
return s.fi.ModTime()
}
func (s subFsFileInfo) IsDir() bool {
return s.fi.IsDir()
}
func (s subFsFileInfo) Sys() any {
return s.fi.Sys()
}
var _ fs.FileInfo = (*subFsFileInfo)(nil)
+27
View File
@@ -0,0 +1,27 @@
package golang
import (
"io/fs"
"os"
"testing"
"github.com/stretchr/testify/require"
)
func Test_NewSubFS(t *testing.T) {
f := os.DirFS("test-fixtures/zip-fs")
f = newSubFS(f, "github.com/someorg/somepkg@version")
var names []string
err := fs.WalkDir(f, ".", func(path string, d fs.DirEntry, err error) error {
names = append(names, path)
return nil
})
require.NoError(t, err)
expected := []string{
".",
"a-file",
"subdir",
"subdir/subfile",
}
require.Equal(t, expected, names)
}
@@ -0,0 +1,7 @@
Copyright (c) 2022
Acme, inc.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
THIS SOFTWARE IS PROVIDED BY [Name of Organization] “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL [Name of Organisation] BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+6 -3
View File
@@ -923,16 +923,19 @@ func TestDirectoryResolver_DoNotAddVirtualPathsToTree(t *testing.T) {
resolver, err := newDirectoryResolver("./test-fixtures/symlinks-prune-indexing", "")
require.NoError(t, err)
allRealPaths := resolver.tree.AllRealPaths()
var allRealPaths []file.Path
for l := range resolver.AllLocations() {
allRealPaths = append(allRealPaths, file.Path(l.RealPath))
}
pathSet := file.NewPathSet(allRealPaths...)
assert.False(t,
pathSet.Contains("/before-path/file.txt"),
pathSet.Contains("before-path/file.txt"),
"symlink destinations should only be indexed at their real path, not through their virtual (symlinked) path",
)
assert.False(t,
pathSet.Contains("/a-path/file.txt"),
pathSet.Contains("a-path/file.txt"),
"symlink destinations should only be indexed at their real path, not through their virtual (symlinked) path",
)
+45
View File
@@ -0,0 +1,45 @@
package source
import (
"io"
)
type EmptyResolver struct{}
func (e EmptyResolver) FileContentsByLocation(_ Location) (io.ReadCloser, error) {
return nil, nil
}
func (e EmptyResolver) HasPath(_ string) bool {
return false
}
func (e EmptyResolver) FilesByPath(_ ...string) ([]Location, error) {
return nil, nil
}
func (e EmptyResolver) FilesByGlob(_ ...string) ([]Location, error) {
return nil, nil
}
func (e EmptyResolver) FilesByMIMEType(_ ...string) ([]Location, error) {
return nil, nil
}
func (e EmptyResolver) RelativeFileByPath(_ Location, _ string) *Location {
return nil
}
func (e EmptyResolver) AllLocations() <-chan Location {
return nil
}
func (e EmptyResolver) FileMetadataByLocation(_ Location) (FileMetadata, error) {
return FileMetadata{}, nil
}
func (e EmptyResolver) Write(_ Location, _ io.Reader) error {
return nil
}
var _ WritableFileResolver = (*EmptyResolver)(nil)
+6
View File
@@ -57,3 +57,9 @@ type FileLocationResolver interface {
// - returns locations for any file or directory
AllLocations() <-chan Location
}
type WritableFileResolver interface {
FileResolver
Write(location Location, reader io.Reader) error
}
+4
View File
@@ -204,3 +204,7 @@ func (r MockResolver) FilesByBasenameGlob(_ ...string) ([]Location, error) {
// TODO implement me
panic("implement me")
}
func (r MockResolver) Write(_ Location, _ io.Reader) error {
return nil
}
+553
View File
@@ -0,0 +1,553 @@
package source
import (
"fmt"
"io"
"io/fs"
"os"
"path"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/mitchellh/go-homedir"
"github.com/spf13/afero"
"golang.org/x/exp/slices"
"github.com/anchore/syft/internal/log"
)
type UnindexedDirectoryResolver struct {
ls afero.Lstater
lr afero.LinkReader
base string
dir string
fs afero.Fs
}
func NewUnindexedDirectoryResolver(dir string) WritableFileResolver {
return NewUnindexedDirectoryResolverFS(afero.NewOsFs(), dir, "")
}
func NewUnindexedDirectoryResolverRooted(dir string, base string) WritableFileResolver {
return NewUnindexedDirectoryResolverFS(afero.NewOsFs(), dir, base)
}
func NewUnindexedDirectoryResolverFS(fs afero.Fs, dir string, base string) WritableFileResolver {
ls, ok := fs.(afero.Lstater)
if !ok {
panic(fmt.Sprintf("unable to get afero.Lstater interface from: %+v", fs))
}
lr, ok := fs.(afero.LinkReader)
if !ok {
panic(fmt.Sprintf("unable to get afero.Lstater interface from: %+v", fs))
}
expanded, err := homedir.Expand(dir)
if err == nil {
dir = expanded
}
if base != "" {
expanded, err = homedir.Expand(base)
if err == nil {
base = expanded
}
}
wd, err := os.Getwd()
if err == nil {
if !path.IsAbs(dir) {
dir = path.Clean(path.Join(wd, dir))
}
if base != "" && !path.IsAbs(base) {
base = path.Clean(path.Join(wd, base))
}
}
return UnindexedDirectoryResolver{
base: base,
dir: dir,
fs: fs,
ls: ls,
lr: lr,
}
}
func (u UnindexedDirectoryResolver) FileContentsByLocation(location Location) (io.ReadCloser, error) {
p := u.absPath(u.scrubInputPath(location.RealPath))
f, err := u.fs.Open(p)
if err != nil {
return nil, err
}
fi, err := f.Stat()
if err != nil {
return nil, err
}
if fi.IsDir() {
return nil, fmt.Errorf("unable to get contents of directory: %s", location.RealPath)
}
return f, nil
}
// - full symlink resolution should be performed on all requests
// - returns locations for any file or directory
func (u UnindexedDirectoryResolver) HasPath(p string) bool {
locs, err := u.filesByPath(true, true, p)
return err == nil && len(locs) > 0
}
func (u UnindexedDirectoryResolver) canLstat(p string) bool {
_, _, err := u.ls.LstatIfPossible(u.absPath(p))
return err == nil
}
func (u UnindexedDirectoryResolver) isRegularFile(p string) bool {
fi, _, err := u.ls.LstatIfPossible(u.absPath(p))
return err == nil && !fi.IsDir()
}
func (u UnindexedDirectoryResolver) scrubInputPath(p string) string {
if path.IsAbs(p) {
p = p[1:]
}
return path.Clean(p)
}
func (u UnindexedDirectoryResolver) scrubResolutionPath(p string) string {
if u.base != "" {
if path.IsAbs(p) {
p = p[1:]
}
for strings.HasPrefix(p, "../") {
p = p[3:]
}
}
return path.Clean(p)
}
func (u UnindexedDirectoryResolver) absPath(p string) string {
if u.base != "" {
if path.IsAbs(p) {
p = p[1:]
}
for strings.HasPrefix(p, "../") {
p = p[3:]
}
p = path.Join(u.base, p)
return path.Clean(p)
}
if path.IsAbs(p) {
return p
}
return path.Clean(path.Join(u.dir, p))
}
// - full symlink resolution should be performed on all requests
// - only returns locations to files (NOT directories)
func (u UnindexedDirectoryResolver) FilesByPath(paths ...string) (out []Location, _ error) {
return u.filesByPath(true, false, paths...)
}
func (u UnindexedDirectoryResolver) filesByPath(resolveLinks bool, includeDirs bool, paths ...string) (out []Location, _ error) {
// sort here for stable output
sort.Strings(paths)
nextPath:
for _, p := range paths {
p = u.scrubInputPath(p)
if u.canLstat(p) && (includeDirs || u.isRegularFile(p)) {
l := u.newLocation(p, resolveLinks)
if l == nil {
continue
}
// only include the first entry we find
for i := range out {
existing := &out[i]
if existing.RealPath == l.RealPath {
if l.VirtualPath == "" {
existing.VirtualPath = ""
}
continue nextPath
}
}
out = append(out, *l)
}
}
return
}
// - full symlink resolution should be performed on all requests
// - if multiple paths to the same file are found, the best single match should be returned
// - only returns locations to files (NOT directories)
func (u UnindexedDirectoryResolver) FilesByGlob(patterns ...string) (out []Location, _ error) {
return u.filesByGlob(true, false, patterns...)
}
func (u UnindexedDirectoryResolver) filesByGlob(resolveLinks bool, includeDirs bool, patterns ...string) (out []Location, _ error) {
f := unindexedDirectoryResolverFS{
u: u,
}
var paths []string
for _, p := range patterns {
opts := []doublestar.GlobOption{doublestar.WithNoFollow()}
if !includeDirs {
opts = append(opts, doublestar.WithFilesOnly())
}
found, err := doublestar.Glob(f, p, opts...)
if err != nil {
return nil, err
}
paths = append(paths, found...)
}
return u.filesByPath(resolveLinks, includeDirs, paths...)
}
func (u UnindexedDirectoryResolver) FilesByMIMEType(_ ...string) ([]Location, error) {
panic("FilesByMIMEType unsupported")
}
// RelativeFileByPath fetches a single file at the given path relative to the layer squash of the given reference.
// This is helpful when attempting to find a file that is in the same layer or lower as another file.
func (u UnindexedDirectoryResolver) RelativeFileByPath(l Location, p string) *Location {
p = path.Clean(path.Join(l.RealPath, p))
locs, err := u.filesByPath(true, false, p)
if err != nil || len(locs) == 0 {
return nil
}
l = locs[0]
p = l.RealPath
if u.isRegularFile(p) {
return u.newLocation(p, true)
}
return nil
}
// - NO symlink resolution should be performed on results
// - returns locations for any file or directory
func (u UnindexedDirectoryResolver) AllLocations() <-chan Location {
out := make(chan Location)
go func() {
defer close(out)
err := afero.Walk(u.fs, u.absPath("."), func(p string, info fs.FileInfo, err error) error {
p = strings.TrimPrefix(p, u.dir)
if p == "" {
return nil
}
p = strings.TrimPrefix(p, "/")
out <- Location{
LocationData: LocationData{
Coordinates: Coordinates{
RealPath: p,
},
},
}
return nil
})
if err != nil {
log.Debug(err)
}
}()
return out
}
func (u UnindexedDirectoryResolver) FileMetadataByLocation(_ Location) (FileMetadata, error) {
panic("FileMetadataByLocation unsupported")
}
func (u UnindexedDirectoryResolver) Write(location Location, reader io.Reader) error {
filePath := location.RealPath
if path.IsAbs(filePath) {
filePath = filePath[1:]
}
absPath := u.absPath(filePath)
return afero.WriteReader(u.fs, absPath, reader)
}
var _ FileResolver = (*UnindexedDirectoryResolver)(nil)
var _ WritableFileResolver = (*UnindexedDirectoryResolver)(nil)
func (u UnindexedDirectoryResolver) newLocation(filePath string, resolveLinks bool) *Location {
filePath = path.Clean(filePath)
virtualPath := ""
realPath := filePath
if resolveLinks {
paths := u.resolveLinks(filePath)
if len(paths) > 1 {
realPath = paths[len(paths)-1]
if realPath != path.Clean(filePath) {
virtualPath = paths[0]
}
}
if len(paths) == 0 {
// this file does not exist, don't return a location
return nil
}
}
return &Location{
LocationData: LocationData{
Coordinates: Coordinates{
RealPath: realPath,
},
VirtualPath: virtualPath,
},
}
}
//nolint:gocognit
func (u UnindexedDirectoryResolver) resolveLinks(filePath string) []string {
var visited []string
out := []string{}
resolvedPath := ""
parts := strings.Split(filePath, "/")
for i := 0; i < len(parts); i++ {
part := parts[i]
if resolvedPath == "" {
resolvedPath = part
} else {
resolvedPath = path.Clean(path.Join(resolvedPath, part))
}
resolvedPath = u.scrubResolutionPath(resolvedPath)
if resolvedPath == ".." {
resolvedPath = ""
continue
}
absPath := u.absPath(resolvedPath)
if slices.Contains(visited, absPath) {
return nil // circular links can't resolve
}
visited = append(visited, absPath)
fi, wasLstat, err := u.ls.LstatIfPossible(absPath)
if fi == nil || err != nil {
// this file does not exist
return nil
}
for wasLstat && u.isSymlink(fi) {
next, err := u.lr.ReadlinkIfPossible(absPath)
if err == nil {
if !path.IsAbs(next) {
next = path.Clean(path.Join(path.Dir(resolvedPath), next))
}
next = u.scrubResolutionPath(next)
absPath = u.absPath(next)
if slices.Contains(visited, absPath) {
return nil // circular links can't resolve
}
visited = append(visited, absPath)
fi, wasLstat, err = u.ls.LstatIfPossible(absPath)
if fi == nil || err != nil {
// this file does not exist
return nil
}
if i < len(parts) {
out = append(out, path.Join(resolvedPath, path.Join(parts[i+1:]...)))
}
if u.base != "" && path.IsAbs(next) {
next = next[1:]
}
resolvedPath = next
}
}
}
out = append(out, resolvedPath)
return out
}
func (u UnindexedDirectoryResolver) isSymlink(fi os.FileInfo) bool {
return fi.Mode().Type()&fs.ModeSymlink == fs.ModeSymlink
}
// ------------------------- fs.FS ------------------------------
// unindexedDirectoryResolverFS wraps the UnindexedDirectoryResolver as a fs.FS, fs.ReadDirFS, and fs.StatFS
type unindexedDirectoryResolverFS struct {
u UnindexedDirectoryResolver
}
// resolve takes a virtual path and returns the resolved absolute or relative path and file info
func (f unindexedDirectoryResolverFS) resolve(filePath string) (resolved string, fi fs.FileInfo, err error) {
parts := strings.Split(filePath, "/")
var visited []string
for i, part := range parts {
if i > 0 {
resolved = path.Clean(path.Join(resolved, part))
} else {
resolved = part
}
abs := f.u.absPath(resolved)
fi, _, err = f.u.ls.LstatIfPossible(abs)
if err != nil {
return
}
for f.u.isSymlink(fi) {
if slices.Contains(visited, resolved) {
return resolved, fi, fmt.Errorf("link cycle detected at: %s", f.u.absPath(resolved))
}
visited = append(visited, resolved)
link, err := f.u.lr.ReadlinkIfPossible(abs)
if err != nil {
return resolved, fi, err
}
if !path.IsAbs(link) {
link = path.Clean(path.Join(path.Dir(abs), link))
link = strings.TrimPrefix(link, abs)
} else if f.u.base != "" {
link = path.Clean(path.Join(f.u.base, link[1:]))
}
resolved = link
abs = f.u.absPath(resolved)
fi, _, err = f.u.ls.LstatIfPossible(abs)
if err != nil {
return resolved, fi, err
}
}
}
return resolved, fi, err
}
func (f unindexedDirectoryResolverFS) ReadDir(name string) (out []fs.DirEntry, _ error) {
p, _, err := f.resolve(name)
if err != nil {
return nil, err
}
entries, err := afero.ReadDir(f.u.fs, f.u.absPath(p))
if err != nil {
return nil, err
}
for _, e := range entries {
isDir := e.IsDir()
_, fi, _ := f.resolve(path.Join(name, e.Name()))
if fi != nil && fi.IsDir() {
isDir = true
}
out = append(out, unindexedDirectoryResolverDirEntry{
unindexedDirectoryResolverFileInfo: newFsFileInfo(f.u, e.Name(), isDir, e),
})
}
return out, nil
}
func (f unindexedDirectoryResolverFS) Stat(name string) (fs.FileInfo, error) {
fi, err := f.u.fs.Stat(f.u.absPath(name))
if err != nil {
return nil, err
}
return newFsFileInfo(f.u, name, fi.IsDir(), fi), nil
}
func (f unindexedDirectoryResolverFS) Open(name string) (fs.File, error) {
_, err := f.u.fs.Open(f.u.absPath(name))
if err != nil {
return nil, err
}
return unindexedDirectoryResolverFile{
u: f.u,
path: name,
}, nil
}
var _ fs.FS = (*unindexedDirectoryResolverFS)(nil)
var _ fs.StatFS = (*unindexedDirectoryResolverFS)(nil)
var _ fs.ReadDirFS = (*unindexedDirectoryResolverFS)(nil)
type unindexedDirectoryResolverDirEntry struct {
unindexedDirectoryResolverFileInfo
}
func (f unindexedDirectoryResolverDirEntry) Name() string {
return f.name
}
func (f unindexedDirectoryResolverDirEntry) IsDir() bool {
return f.isDir
}
func (f unindexedDirectoryResolverDirEntry) Type() fs.FileMode {
return f.mode
}
func (f unindexedDirectoryResolverDirEntry) Info() (fs.FileInfo, error) {
return f, nil
}
var _ fs.DirEntry = (*unindexedDirectoryResolverDirEntry)(nil)
type unindexedDirectoryResolverFile struct {
u UnindexedDirectoryResolver
path string
}
func (f unindexedDirectoryResolverFile) Stat() (fs.FileInfo, error) {
fi, err := f.u.fs.Stat(f.u.absPath(f.path))
if err != nil {
return nil, err
}
return newFsFileInfo(f.u, fi.Name(), fi.IsDir(), fi), nil
}
func (f unindexedDirectoryResolverFile) Read(_ []byte) (int, error) {
panic("Read not implemented")
}
func (f unindexedDirectoryResolverFile) Close() error {
panic("Close not implemented")
}
var _ fs.File = (*unindexedDirectoryResolverFile)(nil)
type unindexedDirectoryResolverFileInfo struct {
u UnindexedDirectoryResolver
name string
size int64
mode fs.FileMode
modTime time.Time
isDir bool
sys any
}
func newFsFileInfo(u UnindexedDirectoryResolver, name string, isDir bool, fi os.FileInfo) unindexedDirectoryResolverFileInfo {
return unindexedDirectoryResolverFileInfo{
u: u,
name: name,
size: fi.Size(),
mode: fi.Mode() & ^fs.ModeSymlink, // pretend nothing is a symlink
modTime: fi.ModTime(),
isDir: isDir,
// sys: fi.Sys(), // what values does this hold?
}
}
func (f unindexedDirectoryResolverFileInfo) Name() string {
return f.name
}
func (f unindexedDirectoryResolverFileInfo) Size() int64 {
return f.size
}
func (f unindexedDirectoryResolverFileInfo) Mode() fs.FileMode {
return f.mode
}
func (f unindexedDirectoryResolverFileInfo) ModTime() time.Time {
return f.modTime
}
func (f unindexedDirectoryResolverFileInfo) IsDir() bool {
return f.isDir
}
func (f unindexedDirectoryResolverFileInfo) Sys() any {
return f.sys
}
var _ fs.FileInfo = (*unindexedDirectoryResolverFileInfo)(nil)
@@ -0,0 +1,744 @@
//go:build !windows
// +build !windows
package source
import (
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/scylladb/go-set/strset"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/stereoscope/pkg/file"
)
func Test_UnindexedDirectoryResolver_Basic(t *testing.T) {
wd, err := os.Getwd()
require.NoError(t, err)
r := NewUnindexedDirectoryResolver(path.Join(wd, "test-fixtures"))
locations, err := r.FilesByGlob("image-symlinks/*")
require.NoError(t, err)
require.Len(t, locations, 5)
}
func Test_UnindexedDirectoryResolver_FilesByPath_relativeRoot(t *testing.T) {
cases := []struct {
name string
relativeRoot string
input string
expected []string
}{
{
name: "should find a file from an absolute input",
relativeRoot: "./test-fixtures/",
input: "/image-symlinks/file-1.txt",
expected: []string{
"image-symlinks/file-1.txt",
},
},
{
name: "should find a file from a relative path",
relativeRoot: "./test-fixtures/",
input: "image-symlinks/file-1.txt",
expected: []string{
"image-symlinks/file-1.txt",
},
},
{
name: "should find a file from a relative path (root above cwd)",
relativeRoot: "../",
input: "sbom/sbom.go",
expected: []string{
"sbom/sbom.go",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resolver := NewUnindexedDirectoryResolver(c.relativeRoot)
refs, err := resolver.FilesByPath(c.input)
require.NoError(t, err)
assert.Len(t, refs, len(c.expected))
s := strset.New()
for _, actual := range refs {
s.Add(actual.RealPath)
}
assert.ElementsMatch(t, c.expected, s.List())
})
}
}
func Test_UnindexedDirectoryResolver_FilesByPath_absoluteRoot(t *testing.T) {
cases := []struct {
name string
relativeRoot string
input string
expected []string
}{
{
name: "should find a file from an absolute input",
relativeRoot: "./test-fixtures/",
input: "/image-symlinks/file-1.txt",
expected: []string{
"image-symlinks/file-1.txt",
},
},
{
name: "should find a file from a relative path",
relativeRoot: "./test-fixtures/",
input: "image-symlinks/file-1.txt",
expected: []string{
"image-symlinks/file-1.txt",
},
},
{
name: "should find a file from a relative path (root above cwd)",
relativeRoot: "../",
input: "sbom/sbom.go",
expected: []string{
"sbom/sbom.go",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// note: this test is all about asserting correct functionality when the given analysis path
// is an absolute path
absRoot, err := filepath.Abs(c.relativeRoot)
require.NoError(t, err)
resolver := NewUnindexedDirectoryResolver(absRoot)
assert.NoError(t, err)
refs, err := resolver.FilesByPath(c.input)
require.NoError(t, err)
assert.Len(t, refs, len(c.expected))
s := strset.New()
for _, actual := range refs {
s.Add(actual.RealPath)
}
assert.ElementsMatch(t, c.expected, s.List())
})
}
}
func Test_UnindexedDirectoryResolver_FilesByPath(t *testing.T) {
cases := []struct {
name string
root string
input string
expected string
refCount int
forcePositiveHasPath bool
}{
{
name: "finds a file (relative)",
root: "./test-fixtures/",
input: "image-symlinks/file-1.txt",
expected: "image-symlinks/file-1.txt",
refCount: 1,
},
{
name: "finds a file with relative indirection",
root: "./test-fixtures/../test-fixtures",
input: "image-symlinks/file-1.txt",
expected: "image-symlinks/file-1.txt",
refCount: 1,
},
{
name: "managed non-existing files (relative)",
root: "./test-fixtures/",
input: "test-fixtures/image-symlinks/bogus.txt",
refCount: 0,
},
{
name: "finds a file (absolute)",
root: "./test-fixtures/",
input: "/image-symlinks/file-1.txt",
expected: "image-symlinks/file-1.txt",
refCount: 1,
},
{
name: "directories ignored",
root: "./test-fixtures/",
input: "/image-symlinks",
refCount: 0,
forcePositiveHasPath: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resolver := NewUnindexedDirectoryResolver(c.root)
hasPath := resolver.HasPath(c.input)
if !c.forcePositiveHasPath {
if c.refCount != 0 && !hasPath {
t.Errorf("expected HasPath() to indicate existence, but did not")
} else if c.refCount == 0 && hasPath {
t.Errorf("expected HasPath() to NOT indicate existence, but does")
}
} else if !hasPath {
t.Errorf("expected HasPath() to indicate existence, but did not (force path)")
}
refs, err := resolver.FilesByPath(c.input)
require.NoError(t, err)
assert.Len(t, refs, c.refCount)
for _, actual := range refs {
assert.Equal(t, c.expected, actual.RealPath)
}
})
}
}
func Test_UnindexedDirectoryResolver_MultipleFilesByPath(t *testing.T) {
cases := []struct {
name string
input []string
refCount int
}{
{
name: "finds multiple files",
input: []string{"image-symlinks/file-1.txt", "image-symlinks/file-2.txt"},
refCount: 2,
},
{
name: "skips non-existing files",
input: []string{"image-symlinks/bogus.txt", "image-symlinks/file-1.txt"},
refCount: 1,
},
{
name: "does not return anything for non-existing directories",
input: []string{"non-existing/bogus.txt", "non-existing/file-1.txt"},
refCount: 0,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures")
refs, err := resolver.FilesByPath(c.input...)
assert.NoError(t, err)
if len(refs) != c.refCount {
t.Errorf("unexpected number of refs: %d != %d", len(refs), c.refCount)
}
})
}
}
func Test_UnindexedDirectoryResolver_FilesByGlobMultiple(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures")
refs, err := resolver.FilesByGlob("**/image-symlinks/file*")
assert.NoError(t, err)
assert.Len(t, refs, 2)
}
func Test_UnindexedDirectoryResolver_FilesByGlobRecursive(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/image-symlinks")
refs, err := resolver.FilesByGlob("**/*.txt")
assert.NoError(t, err)
assert.Len(t, refs, 6)
}
func Test_UnindexedDirectoryResolver_FilesByGlobSingle(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures")
refs, err := resolver.FilesByGlob("**/image-symlinks/*1.txt")
assert.NoError(t, err)
assert.Len(t, refs, 1)
assert.Equal(t, "image-symlinks/file-1.txt", refs[0].RealPath)
}
func Test_UnindexedDirectoryResolver_FilesByPath_ResolvesSymlinks(t *testing.T) {
tests := []struct {
name string
fixture string
}{
{
name: "one degree",
fixture: "link_to_new_readme",
},
{
name: "two degrees",
fixture: "link_to_link_to_new_readme",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-simple")
refs, err := resolver.FilesByPath(test.fixture)
require.NoError(t, err)
require.Len(t, refs, 1)
reader, err := resolver.FileContentsByLocation(refs[0])
require.NoError(t, err)
actual, err := io.ReadAll(reader)
require.NoError(t, err)
expected, err := os.ReadFile("test-fixtures/symlinks-simple/readme")
require.NoError(t, err)
require.Equal(t, string(expected), string(actual))
})
}
}
func Test_UnindexedDirectoryResolverDoesNotIgnoreRelativeSystemPaths(t *testing.T) {
// let's make certain that "dev/place" is not ignored, since it is not "/dev/place"
resolver := NewUnindexedDirectoryResolver("test-fixtures/system_paths/target")
// all paths should be found (non filtering matches a path)
locations, err := resolver.FilesByGlob("**/place")
assert.NoError(t, err)
// 4: within target/
// 1: target/link --> relative path to "place" // NOTE: this is filtered out since it not unique relative to outside_root/link_target/place
// 1: outside_root/link_target/place
assert.Len(t, locations, 5)
// ensure that symlink indexing outside of root worked
testLocation := "../outside_root/link_target/place"
ok := false
for _, location := range locations {
if strings.HasSuffix(location.RealPath, testLocation) {
ok = true
}
}
if !ok {
t.Fatalf("could not find test location=%q", testLocation)
}
}
func Test_UnindexedDirectoryResover_IndexingNestedSymLinks(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-simple")
// check that we can get the real path
locations, err := resolver.FilesByPath("./readme")
require.NoError(t, err)
assert.Len(t, locations, 1)
// check that we can access the same file via 1 symlink
locations, err = resolver.FilesByPath("./link_to_new_readme")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "readme", locations[0].RealPath)
assert.Equal(t, "link_to_new_readme", locations[0].VirtualPath)
// check that we can access the same file via 2 symlinks
locations, err = resolver.FilesByPath("./link_to_link_to_new_readme")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "readme", locations[0].RealPath)
assert.Equal(t, "link_to_link_to_new_readme", locations[0].VirtualPath)
// check that we can access the same file via 2 symlinks
locations, err = resolver.FilesByGlob("**/link_*")
require.NoError(t, err)
require.Len(t, locations, 1) // you would think this is 2, however, they point to the same file, and glob only returns unique files
// returned locations can be in any order
expectedVirtualPaths := []string{
"link_to_link_to_new_readme",
//"link_to_new_readme", // we filter out this one because the first symlink resolves to the same file
}
expectedRealPaths := []string{
"readme",
}
actualRealPaths := strset.New()
actualVirtualPaths := strset.New()
for _, a := range locations {
actualVirtualPaths.Add(a.VirtualPath)
actualRealPaths.Add(a.RealPath)
}
assert.ElementsMatch(t, expectedVirtualPaths, actualVirtualPaths.List())
assert.ElementsMatch(t, expectedRealPaths, actualRealPaths.List())
}
func Test_UnindexedDirectoryResover_IndexingNestedSymLinksOutsideOfRoot(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-multiple-roots/root")
// check that we can get the real path
locations, err := resolver.FilesByPath("./readme")
require.NoError(t, err)
assert.Len(t, locations, 1)
// check that we can access the same file via 2 symlinks (link_to_link_to_readme -> link_to_readme -> readme)
locations, err = resolver.FilesByPath("./link_to_link_to_readme")
require.NoError(t, err)
assert.Len(t, locations, 1)
// something looks wrong here
t.Failed()
}
func Test_UnindexedDirectoryResover_RootViaSymlink(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinked-root/nested/link-root")
locations, err := resolver.FilesByPath("./file1.txt")
require.NoError(t, err)
assert.Len(t, locations, 1)
locations, err = resolver.FilesByPath("./nested/file2.txt")
require.NoError(t, err)
assert.Len(t, locations, 1)
locations, err = resolver.FilesByPath("./nested/linked-file1.txt")
require.NoError(t, err)
assert.Len(t, locations, 1)
}
func Test_UnindexedDirectoryResolver_FileContentsByLocation(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
r := NewUnindexedDirectoryResolver(path.Join(cwd, "test-fixtures/image-simple"))
require.NoError(t, err)
tests := []struct {
name string
location Location
expects string
err bool
}{
{
name: "use file reference for content requests",
location: NewLocation("file-1.txt"),
expects: "this file has contents",
},
{
name: "error on empty file reference",
location: NewLocationFromDirectory("doesn't matter", file.Reference{}),
err: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := r.FileContentsByLocation(test.location)
if test.err {
require.Error(t, err)
return
}
require.NoError(t, err)
if test.expects != "" {
b, err := io.ReadAll(actual)
require.NoError(t, err)
assert.Equal(t, test.expects, string(b))
}
})
}
}
func Test_UnindexedDirectoryResover_SymlinkLoopWithGlobsShouldResolve(t *testing.T) {
test := func(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-loop")
locations, err := resolver.FilesByGlob("**/file.target")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "devices/loop0/file.target", locations[0].RealPath)
}
testWithTimeout(t, 5*time.Second, test)
}
func Test_UnindexedDirectoryResolver_FilesByPath_baseRoot(t *testing.T) {
cases := []struct {
name string
root string
input string
expected []string
}{
{
name: "should find the base file",
root: "./test-fixtures/symlinks-base/",
input: "./base",
expected: []string{
"base",
},
},
{
name: "should follow a link with a pivoted root",
root: "./test-fixtures/symlinks-base/",
input: "./foo",
expected: []string{
"base",
},
},
{
name: "should follow a relative link with extra parents",
root: "./test-fixtures/symlinks-base/",
input: "./bar",
expected: []string{
"base",
},
},
{
name: "should follow an absolute link with extra parents",
root: "./test-fixtures/symlinks-base/",
input: "./baz",
expected: []string{
"base",
},
},
{
name: "should follow an absolute link with extra parents",
root: "./test-fixtures/symlinks-base/",
input: "./sub/link",
expected: []string{
"sub/item",
},
},
{
name: "should follow chained pivoted link",
root: "./test-fixtures/symlinks-base/",
input: "./chain",
expected: []string{
"base",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resolver := NewUnindexedDirectoryResolverRooted(c.root, c.root)
refs, err := resolver.FilesByPath(c.input)
require.NoError(t, err)
assert.Len(t, refs, len(c.expected))
s := strset.New()
for _, actual := range refs {
s.Add(actual.RealPath)
}
assert.ElementsMatch(t, c.expected, s.List())
})
}
}
func Test_UnindexedDirectoryResolver_resolvesLinks(t *testing.T) {
tests := []struct {
name string
runner func(FileResolver) []Location
expected []Location
}{
{
name: "by glob to links",
runner: func(resolver FileResolver) []Location {
// links are searched, but resolve to the real files
// for that reason we need to place **/ in front (which is not the same for other resolvers)
actualLocations, err := resolver.FilesByGlob("**/*ink-*")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
NewVirtualLocation("file-1.txt", "link-1"),
NewVirtualLocation("file-2.txt", "link-2"),
// we already have this real file path via another link, so only one is returned
// NewVirtualLocation("file-2.txt", "link-indirect"),
NewVirtualLocation("file-3.txt", "link-within"),
},
},
{
name: "by basename",
runner: func(resolver FileResolver) []Location {
// links are searched, but resolve to the real files
actualLocations, err := resolver.FilesByGlob("**/file-2.txt")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
// this has two copies in the base image, which overwrites the same location
NewLocation("file-2.txt"),
},
},
{
name: "by basename glob",
runner: func(resolver FileResolver) []Location {
// links are searched, but resolve to the real files
actualLocations, err := resolver.FilesByGlob("**/file-?.txt")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
NewLocation("file-1.txt"),
NewLocation("file-2.txt"),
NewLocation("file-3.txt"),
NewLocation("parent/file-4.txt"),
},
},
{
name: "by basename glob to links",
runner: func(resolver FileResolver) []Location {
actualLocations, err := resolver.FilesByGlob("**/link-*")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
NewVirtualLocationFromDirectory("file-1.txt", "link-1", file.Reference{RealPath: "file-1.txt"}),
NewVirtualLocationFromDirectory("file-2.txt", "link-2", file.Reference{RealPath: "file-2.txt"}),
// we already have this real file path via another link, so only one is returned
//NewVirtualLocationFromDirectory("file-2.txt", "link-indirect", file.Reference{RealPath: "file-2.txt"}),
NewVirtualLocationFromDirectory("file-3.txt", "link-within", file.Reference{RealPath: "file-3.txt"}),
},
},
{
name: "by extension",
runner: func(resolver FileResolver) []Location {
// links are searched, but resolve to the real files
actualLocations, err := resolver.FilesByGlob("**/*.txt")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
NewLocation("file-1.txt"),
NewLocation("file-2.txt"),
NewLocation("file-3.txt"),
NewLocation("parent/file-4.txt"),
},
},
{
name: "by path to degree 1 link",
runner: func(resolver FileResolver) []Location {
// links resolve to the final file
actualLocations, err := resolver.FilesByPath("/link-2")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
// we have multiple copies across layers
NewVirtualLocation("file-2.txt", "link-2"),
},
},
{
name: "by path to degree 2 link",
runner: func(resolver FileResolver) []Location {
// multiple links resolves to the final file
actualLocations, err := resolver.FilesByPath("/link-indirect")
assert.NoError(t, err)
return actualLocations
},
expected: []Location{
// we have multiple copies across layers
NewVirtualLocation("file-2.txt", "link-indirect"),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-from-image-symlinks-fixture")
actual := test.runner(resolver)
compareLocations(t, test.expected, actual)
})
}
}
func Test_UnindexedDirectoryResolver_DoNotAddVirtualPathsToTree(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-prune-indexing")
allLocations := resolver.AllLocations()
var allRealPaths []file.Path
for l := range allLocations {
allRealPaths = append(allRealPaths, file.Path(l.RealPath))
}
pathSet := file.NewPathSet(allRealPaths...)
assert.False(t,
pathSet.Contains("before-path/file.txt"),
"symlink destinations should only be indexed at their real path, not through their virtual (symlinked) path",
)
assert.False(t,
pathSet.Contains("a-path/file.txt"),
"symlink destinations should only be indexed at their real path, not through their virtual (symlinked) path",
)
}
func Test_UnindexedDirectoryResolver_FilesContents_errorOnDirRequest(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/system_paths")
dirLoc := NewLocation("arg/foo")
reader, err := resolver.FileContentsByLocation(dirLoc)
require.Error(t, err)
require.Nil(t, reader)
}
func Test_UnindexedDirectoryResolver_AllLocations(t *testing.T) {
resolver := NewUnindexedDirectoryResolver("./test-fixtures/symlinks-from-image-symlinks-fixture")
paths := strset.New()
for loc := range resolver.AllLocations() {
if strings.HasPrefix(loc.RealPath, "/") {
// ignore outside of the fixture root for now
continue
}
paths.Add(loc.RealPath)
}
expected := []string{
"file-1.txt",
"file-2.txt",
"file-3.txt",
"link-1",
"link-2",
"link-dead",
"link-indirect",
"link-within",
"parent",
"parent-link",
"parent/file-4.txt",
}
pathsList := paths.List()
sort.Strings(pathsList)
assert.ElementsMatchf(t, expected, pathsList, "expected all paths to be indexed, but found different paths: \n%s", cmp.Diff(expected, paths.List()))
}
func Test_WritableUnindexedDirectoryResolver(t *testing.T) {
tmpdir := t.TempDir()
p := "some/path/file"
c := "some contents"
dr := NewUnindexedDirectoryResolver(tmpdir)
locations, err := dr.FilesByPath(p)
require.NoError(t, err)
require.Len(t, locations, 0)
err = dr.Write(NewLocation(p), strings.NewReader(c))
require.NoError(t, err)
locations, err = dr.FilesByPath(p)
require.NoError(t, err)
require.Len(t, locations, 1)
reader, err := dr.FileContentsByLocation(locations[0])
require.NoError(t, err)
bytes, err := io.ReadAll(reader)
require.Equal(t, c, string(bytes))
}