mirror of
https://github.com/anchore/syft.git
synced 2026-11-04 00:16:38 +01:00
363 lines
12 KiB
Go
363 lines
12 KiB
Go
package golang
|
|
|
|
import (
|
|
"debug/gosym"
|
|
"os"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func Test_moduleSymbols(t *testing.T) {
|
|
mainModule := &debug.Module{Path: "github.com/someorg/somecli"}
|
|
deps := []*debug.Module{
|
|
{Path: "github.com/foo/bar"},
|
|
{Path: "github.com/foo/bar/v2"},
|
|
{Path: "vendor/github.com/vendored/mod"},
|
|
{Path: "github.com/vendored/mod"},
|
|
nil,
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
symbols []binarySymbol
|
|
expected map[string]map[string][]string
|
|
expectedStdlib map[string][]string
|
|
}{
|
|
{
|
|
name: "no symbols",
|
|
symbols: nil,
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "attribute symbols by longest module path prefix, grouped by import path",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
|
{packagePath: "github.com/foo/bar/internal/util", name: "github.com/foo/bar/internal/util.(*Helper).Do"},
|
|
{packagePath: "github.com/foo/bar/v2", name: "github.com/foo/bar/v2.Parse"},
|
|
},
|
|
expected: map[string]map[string][]string{
|
|
"github.com/foo/bar": {
|
|
"github.com/foo/bar": {"Parse"},
|
|
"github.com/foo/bar/internal/util": {"(*Helper).Do"},
|
|
},
|
|
"github.com/foo/bar/v2": {
|
|
"github.com/foo/bar/v2": {"Parse"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "main package symbols are attributed to the main module and keyed by the main import path",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "main", name: "main.main"},
|
|
{packagePath: "github.com/someorg/somecli/cmd", name: "github.com/someorg/somecli/cmd.Execute"},
|
|
},
|
|
expected: map[string]map[string][]string{
|
|
"github.com/someorg/somecli": {
|
|
"github.com/someorg/somecli/cmd": {"Execute"},
|
|
"main": {"main"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "stdlib and runtime symbols are collected separately, grouped by import path",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "runtime", name: "runtime.main"},
|
|
{packagePath: "net/http", name: "net/http.(*Client).Do"},
|
|
{packagePath: "internal/abi", name: "internal/abi.(*Type).Kind"},
|
|
},
|
|
expected: map[string]map[string][]string{},
|
|
expectedStdlib: map[string][]string{
|
|
"internal/abi": {"(*Type).Kind"},
|
|
"net/http": {"(*Client).Do"},
|
|
"runtime": {"main"},
|
|
},
|
|
},
|
|
{
|
|
name: "vendored packages match non-vendored modules and are recorded under the canonical import path",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "vendor/github.com/foo/bar", name: "vendor/github.com/foo/bar.Parse"},
|
|
{packagePath: "vendor/github.com/foo/bar/internal/util", name: "vendor/github.com/foo/bar/internal/util.(*Helper).Do"},
|
|
},
|
|
expected: map[string]map[string][]string{
|
|
"github.com/foo/bar": {
|
|
"github.com/foo/bar": {"Parse"},
|
|
"github.com/foo/bar/internal/util": {"(*Helper).Do"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "stdlib-vendored packages are dropped",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "vendor/golang.org/x/net/http2", name: "vendor/golang.org/x/net/http2.(*Framer).ReadFrame"},
|
|
},
|
|
expected: map[string]map[string][]string{},
|
|
},
|
|
{
|
|
name: "modules whose own path carries the vendor/ prefix match exactly and win over the trimmed match",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "vendor/github.com/vendored/mod", name: "vendor/github.com/vendored/mod.Run"},
|
|
{packagePath: "github.com/vendored/mod", name: "github.com/vendored/mod.Run"},
|
|
},
|
|
expected: map[string]map[string][]string{
|
|
"vendor/github.com/vendored/mod": {
|
|
"vendor/github.com/vendored/mod": {"Run"},
|
|
},
|
|
"github.com/vendored/mod": {
|
|
"github.com/vendored/mod": {"Run"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "vendored and unvendored symbols for the same package are merged and deduplicated under the canonical import path",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
|
{packagePath: "vendor/github.com/foo/bar", name: "vendor/github.com/foo/bar.Parse"},
|
|
},
|
|
expected: map[string]map[string][]string{
|
|
"github.com/foo/bar": {
|
|
"github.com/foo/bar": {"Parse"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// "github.com/foo/barbaz" shares a string prefix with the "github.com/foo/bar" module but not a
|
|
// path-segment boundary, so it must not be attributed to it (and, having a dotted first element,
|
|
// it is not a stdlib path either — it is dropped)
|
|
name: "module paths only match the package path at a path-segment boundary",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "github.com/foo/barbaz", name: "github.com/foo/barbaz.Parse"},
|
|
},
|
|
expected: map[string]map[string][]string{},
|
|
},
|
|
{
|
|
name: "duplicate symbols are deduplicated",
|
|
symbols: []binarySymbol{
|
|
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
|
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
|
},
|
|
expected: map[string]map[string][]string{
|
|
"github.com/foo/bar": {
|
|
"github.com/foo/bar": {"Parse"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotByModule, gotStdlib := moduleSymbols(tt.symbols, mainModule, deps)
|
|
assert.Equal(t, tt.expected, gotByModule)
|
|
assert.Equal(t, tt.expectedStdlib, gotStdlib)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_escapedImportPathAttribution(t *testing.T) {
|
|
// the go linker escapes the '.' in the last path element, so "gopkg.in/yaml.v2" appears in the symbol
|
|
// table as "gopkg.in/yaml%2ev2". attribution must still land these under the unescaped module path.
|
|
mainModule := &debug.Module{Path: "github.com/someorg/somecli"}
|
|
deps := []*debug.Module{{Path: "gopkg.in/yaml.v2"}}
|
|
|
|
symbols := []binarySymbol{
|
|
makeBinarySymbol("gopkg.in/yaml%2ev2.(*decoder).alias", "gopkg.in/yaml%2ev2"),
|
|
makeBinarySymbol("gopkg.in/yaml%2ev2.(*TypeError).Error", "gopkg.in/yaml%2ev2"),
|
|
}
|
|
|
|
gotByModule, gotStdlib := moduleSymbols(symbols, mainModule, deps)
|
|
assert.Nil(t, gotStdlib)
|
|
assert.Equal(t, map[string]map[string][]string{
|
|
"gopkg.in/yaml.v2": {
|
|
"gopkg.in/yaml.v2": {"(*TypeError).Error", "(*decoder).alias"},
|
|
},
|
|
}, gotByModule)
|
|
}
|
|
|
|
func Test_unescapePackagePath(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"gopkg.in/yaml%2ev2", "gopkg.in/yaml.v2"},
|
|
{"foo%25bar", "foo%bar"},
|
|
{"foo%22bar", "foo\"bar"},
|
|
// no escapes: unchanged
|
|
{"github.com/foo/bar", "github.com/foo/bar"},
|
|
{"", ""},
|
|
// malformed sequences are left as-is
|
|
{"foo%2", "foo%2"},
|
|
{"foo%zz", "foo%zz"},
|
|
{"foo%", "foo%"},
|
|
// uppercase hex is accepted too
|
|
{"foo%2Ebar", "foo.bar"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, unescapePackagePath(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_makeBinarySymbol(t *testing.T) {
|
|
// only the import-path prefix is unescaped; the local-symbol suffix is preserved verbatim
|
|
sym := makeBinarySymbol("gopkg.in/yaml%2ev2.(*decoder).alias", "gopkg.in/yaml%2ev2")
|
|
assert.Equal(t, "gopkg.in/yaml.v2", sym.packagePath)
|
|
assert.Equal(t, "gopkg.in/yaml.v2.(*decoder).alias", sym.name)
|
|
}
|
|
|
|
func Test_localSymbolName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
symbol string
|
|
importPath string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "function",
|
|
symbol: "github.com/foo/bar.Parse",
|
|
importPath: "github.com/foo/bar",
|
|
expected: "Parse",
|
|
},
|
|
{
|
|
name: "pointer-receiver method",
|
|
symbol: "github.com/foo/bar.(*T).M",
|
|
importPath: "github.com/foo/bar",
|
|
expected: "(*T).M",
|
|
},
|
|
{
|
|
// type-argument brackets sit to the right of the stripped prefix and must be preserved
|
|
name: "generic instantiation",
|
|
symbol: "github.com/foo/bar.Do[net/url.Values]",
|
|
importPath: "github.com/foo/bar",
|
|
expected: "Do[net/url.Values]",
|
|
},
|
|
{
|
|
// a sibling package must not be stripped by a shorter import path (the "." boundary guards this)
|
|
name: "prefix mismatch is returned unchanged",
|
|
symbol: "github.com/foo/bar/sub.Func",
|
|
importPath: "github.com/foo/bar",
|
|
expected: "github.com/foo/bar/sub.Func",
|
|
},
|
|
{
|
|
name: "empty import path is returned unchanged",
|
|
symbol: "runtime.main",
|
|
importPath: "",
|
|
expected: "runtime.main",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, localSymbolName(tt.symbol, tt.importPath))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_getSymbols(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PE binaries are not supported for symbol extraction")
|
|
}
|
|
|
|
// the test executable is itself a go binary with a pclntab, which makes for a hermetic fixture
|
|
exe, err := os.Executable()
|
|
require.NoError(t, err)
|
|
|
|
f, err := os.Open(exe)
|
|
require.NoError(t, err)
|
|
defer f.Close()
|
|
|
|
symbols, err := getSymbols(f)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, symbols)
|
|
|
|
var foundRuntime, foundTesting bool
|
|
for _, sym := range symbols {
|
|
switch {
|
|
case sym.packagePath == "runtime" && sym.name == "runtime.main":
|
|
foundRuntime = true
|
|
case sym.packagePath == "testing" && sym.name == "testing.tRunner":
|
|
foundTesting = true
|
|
}
|
|
}
|
|
assert.True(t, foundRuntime, "expected to find runtime.main symbol")
|
|
assert.True(t, foundTesting, "expected to find testing.tRunner symbol")
|
|
|
|
// the recovery loop relies on packagePathFromSymbolName, so confirm at least one recovered name is
|
|
// present that debug/gosym's table.Funcs does not surface directly (i.e. an inlined function).
|
|
require.True(t, hasInlinedOnlySymbol(t, exe, symbols), "expected to recover at least one inlined-only symbol")
|
|
}
|
|
|
|
// hasInlinedOnlySymbol reports whether syms contains a function name that is absent from the raw
|
|
// debug/gosym function table for the same binary — i.e. a name that could only have come from the
|
|
// funcname-table recovery path.
|
|
func hasInlinedOnlySymbol(t *testing.T, exe string, syms []binarySymbol) bool {
|
|
t.Helper()
|
|
|
|
f, err := os.Open(exe)
|
|
require.NoError(t, err)
|
|
defer f.Close()
|
|
|
|
pclntab, textStart, err := readPclntab(f)
|
|
require.NoError(t, err)
|
|
|
|
table, err := gosym.NewTable(nil, gosym.NewLineTable(pclntab, textStart))
|
|
require.NoError(t, err)
|
|
|
|
gosymNames := make(map[string]struct{}, len(table.Funcs))
|
|
for _, fn := range table.Funcs {
|
|
gosymNames[fn.Name] = struct{}{}
|
|
}
|
|
|
|
for _, sym := range syms {
|
|
if _, ok := gosymNames[sym.name]; !ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func Test_packagePathFromSymbolName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expected string
|
|
}{
|
|
{"path/filepath.IsLocal", "path/filepath"},
|
|
// pointer-receiver method
|
|
{"golang.org/x/net/html.(*Tokenizer).Next", "golang.org/x/net/html"},
|
|
// versioned (major-version-suffixed) module path
|
|
{"github.com/foo/bar/v2.Parse", "github.com/foo/bar/v2"},
|
|
{"github.com/foo/bar/v2.(*Client).Do", "github.com/foo/bar/v2"},
|
|
{"github.com/foo/bar.Parse.func1", "github.com/foo/bar"},
|
|
{"main.main", "main"},
|
|
{"runtime.gcBgMarkWorker", "runtime"},
|
|
// generic instantiations: type arguments must not corrupt the package path
|
|
{"foo/bar.Do[net/url.Values]", "foo/bar"},
|
|
{"github.com/foo/bar.Map[go.shape.int,go.shape.string]", "github.com/foo/bar"},
|
|
{"main.Do[go.shape.int]", "main"},
|
|
// no package-qualifying dot
|
|
{"runtime", ""},
|
|
// compiler/linker-generated symbols belong to no package
|
|
{"type:.eq.[]string", ""},
|
|
{"type..hash.runtime._type", ""},
|
|
{"go:string.\"foo\"", ""},
|
|
// pre-go1.20 toolchains generated symbols with "." where newer ones use ":"
|
|
{"go.buildid", ""},
|
|
{"go.type.*runtime._type", ""},
|
|
{"go.itab.*os.File,io.Reader", ""},
|
|
{"go.string.\"foo\"", ""},
|
|
// module paths that begin with "go." are not compiler-generated
|
|
{"go.uber.org/zap.(*Logger).Info", "go.uber.org/zap"},
|
|
{"go.opentelemetry.io/otel.Tracer", "go.opentelemetry.io/otel"},
|
|
// vendored packages retain their "vendor/" import-path prefix in symbol names
|
|
{"vendor/golang.org/x/net/http2.(*Framer).ReadFrame", "vendor/golang.org/x/net/http2"},
|
|
{"vendor/github.com/foo/bar.Parse", "vendor/github.com/foo/bar"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
assert.Equal(t, test.expected, packagePathFromSymbolName(test.name))
|
|
})
|
|
}
|
|
}
|