Files
syft/syft/pkg/cataloger/golang/symbols_test.go
T

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))
})
}
}