perf: group golang symbols by package path to reduce the sbom size (#5064)

---------
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Christopher Angelo Phillips 2026-07-13 16:40:16 -04:00 committed by GitHub
parent 4312699dd6
commit f6b5d3e736
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 235 additions and 85 deletions

View File

@ -15,5 +15,5 @@ const (
// 16.1.6 - add Dependencies to ElixirMixLockEntry metadata // 16.1.6 - add Dependencies to ElixirMixLockEntry metadata
// 16.1.7 - add AppleAppBundleEntry metadata type for the apple app bundle cataloger // 16.1.7 - add AppleAppBundleEntry metadata type for the apple app bundle cataloger
// 16.1.8 - add VcpkgManifest metadata type for vcpkg manifest support // 16.1.8 - add VcpkgManifest metadata type for vcpkg manifest support
// 16.1.9 - add Symbols to GolangBinaryBuildinfoEntry metadata // 16.1.9 - add Symbols (grouped by owning package import path) to GolangBinaryBuildinfoEntry metadata
) )

View File

@ -1655,11 +1655,14 @@
"description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")." "description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")."
}, },
"symbols": { "symbols": {
"items": { "additionalProperties": {
"type": "string" "items": {
"type": "string"
},
"type": "array"
}, },
"type": "array", "type": "object",
"description": "Symbols are the fully qualified function symbols from this module that are compiled into the binary\n(e.g., \"github.com/foo/bar.(*Type).Method\"), extracted from the binary symbol table (pclntab).\nPopulated only when the golang cataloger's capture-symbols scope covers this package: the \"all\" scope\npopulates every module package plus the synthetic stdlib package, while the \"stdlib\" scope populates\nonly the stdlib package." "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, while\nthe \"stdlib\" scope populates only the stdlib package."
} }
}, },
"type": "object", "type": "object",

View File

@ -1655,11 +1655,14 @@
"description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")." "description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")."
}, },
"symbols": { "symbols": {
"items": { "additionalProperties": {
"type": "string" "items": {
"type": "string"
},
"type": "array"
}, },
"type": "array", "type": "object",
"description": "Symbols are the fully qualified function symbols from this module that are compiled into the binary\n(e.g., \"github.com/foo/bar.(*Type).Method\"), extracted from the binary symbol table (pclntab).\nPopulated only when the golang cataloger's capture-symbols scope covers this package: the \"all\" scope\npopulates every module package plus the synthetic stdlib package, while the \"stdlib\" scope populates\nonly the stdlib package." "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, while\nthe \"stdlib\" scope populates only the stdlib package."
} }
}, },
"type": "object", "type": "object",

View File

@ -45,7 +45,7 @@ func (c *goBinaryCataloger) newGoBinaryPackage(dep *debug.Module, m pkg.GolangBi
return p return p
} }
func newBinaryMetadata(dep *debug.Module, mainModule, goVersion, architecture string, buildSettings pkg.KeyValues, cryptoSettings, experiments, symbols []string) pkg.GolangBinaryBuildinfoEntry { func newBinaryMetadata(dep *debug.Module, mainModule, goVersion, architecture string, buildSettings pkg.KeyValues, cryptoSettings, experiments []string, symbols map[string][]string) pkg.GolangBinaryBuildinfoEntry {
if dep.Replace != nil { if dep.Replace != nil {
dep = dep.Replace dep = dep.Replace
} }

View File

@ -54,9 +54,10 @@ type goBinaryCataloger struct {
symbolScope cataloging.SymbolScope symbolScope cataloging.SymbolScope
// stdlibSymbols holds the standard-library function symbols discovered per binary (keyed by the // stdlibSymbols holds the standard-library function symbols discovered per binary (keyed by the
// binary's location), populated during parsing and consumed by stdlibProcessor when it builds the // binary's location), grouped by import path, populated during parsing and consumed by stdlibProcessor
// synthetic "stdlib" package. Guarded by stdlibSymbolsMu because parsers run concurrently. // when it builds the synthetic "stdlib" package. Guarded by stdlibSymbolsMu because parsers run
stdlibSymbols map[file.Coordinates][]string // concurrently.
stdlibSymbols map[file.Coordinates]map[string][]string
stdlibSymbolsMu sync.Mutex stdlibSymbolsMu sync.Mutex
} }
@ -65,29 +66,49 @@ func newGoBinaryCataloger(opts CatalogerConfig) *goBinaryCataloger {
licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts), licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts),
mainModuleVersion: opts.MainModuleVersion, mainModuleVersion: opts.MainModuleVersion,
symbolScope: opts.CaptureSymbols, symbolScope: opts.CaptureSymbols,
stdlibSymbols: make(map[file.Coordinates][]string), stdlibSymbols: make(map[file.Coordinates]map[string][]string),
} }
} }
// recordStdlibSymbols merges the standard-library symbols discovered for a binary location so the // recordStdlibSymbols merges the standard-library symbols discovered for a binary location (grouped by
// stdlib processor can attach them to the synthetic stdlib package. // import path) so the stdlib processor can attach them to the synthetic stdlib package.
func (c *goBinaryCataloger) recordStdlibSymbols(coord file.Coordinates, symbols []string) { func (c *goBinaryCataloger) recordStdlibSymbols(coord file.Coordinates, symbols map[string][]string) {
if len(symbols) == 0 { if len(symbols) == 0 {
return return
} }
c.stdlibSymbolsMu.Lock() c.stdlibSymbolsMu.Lock()
defer c.stdlibSymbolsMu.Unlock() defer c.stdlibSymbolsMu.Unlock()
merged := slices.Concat(c.stdlibSymbols[coord], symbols) existing := c.stdlibSymbols[coord]
slices.Sort(merged) if existing == nil {
c.stdlibSymbols[coord] = slices.Compact(merged) existing = make(map[string][]string)
c.stdlibSymbols[coord] = existing
}
for path, names := range symbols {
merged := slices.Concat(existing[path], names)
slices.Sort(merged)
existing[path] = slices.Compact(merged)
}
} }
// stdlibSymbolsFor returns the standard-library symbols recorded for a binary location. It returns a copy // stdlibSymbolsFor returns the standard-library symbols recorded for a binary location. It returns a deep
// so callers cannot alias (and later mutate or race on) the map's internal slice. // copy so callers cannot alias (and later mutate or race on) the map's internal state.
func (c *goBinaryCataloger) stdlibSymbolsFor(coord file.Coordinates) []string { func (c *goBinaryCataloger) stdlibSymbolsFor(coord file.Coordinates) map[string][]string {
c.stdlibSymbolsMu.Lock() c.stdlibSymbolsMu.Lock()
defer c.stdlibSymbolsMu.Unlock() defer c.stdlibSymbolsMu.Unlock()
return slices.Clone(c.stdlibSymbols[coord]) return cloneSymbolGroups(c.stdlibSymbols[coord])
}
// cloneSymbolGroups returns a deep copy of a symbol group map (import path -> local symbol names), or nil
// when the input is empty.
func cloneSymbolGroups(groups map[string][]string) map[string][]string {
if len(groups) == 0 {
return nil
}
out := make(map[string][]string, len(groups))
for path, names := range groups {
out[path] = slices.Clone(names)
}
return out
} }
// parseGoBinary catalogs packages found in the "buildinfo" section of a binary built by the go compiler. // parseGoBinary catalogs packages found in the "buildinfo" section of a binary built by the go compiler.
@ -221,7 +242,7 @@ func missingMainModule(mod *extendedBuildInfo) bool {
return mod.Main == moduleFromPartialPackageBuild return mod.Main == moduleFromPartialPackageBuild
} }
func (c *goBinaryCataloger) makeGoMainPackage(ctx context.Context, resolver file.Resolver, mod *extendedBuildInfo, arch string, location file.Location, reader io.ReadSeekCloser, symbols []string) pkg.Package { func (c *goBinaryCataloger) makeGoMainPackage(ctx context.Context, resolver file.Resolver, mod *extendedBuildInfo, arch string, location file.Location, reader io.ReadSeekCloser, symbols map[string][]string) pkg.Package {
gbs := getBuildSettings(mod.Settings) gbs := getBuildSettings(mod.Settings)
lics := c.licenseResolver.getLicenses(ctx, resolver, mod.Main.Path, mod.Main.Version) lics := c.licenseResolver.getLicenses(ctx, resolver, mod.Main.Path, mod.Main.Version)
gover, experiments := getExperimentsFromVersion(mod.GoVersion) gover, experiments := getExperimentsFromVersion(mod.GoVersion)

View File

@ -1427,12 +1427,14 @@ func (alwaysErrorReader) Read(_ []byte) (int, error) {
func Test_buildGoPkgInfo_symbolScope(t *testing.T) { func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
location := file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/a-path", FileSystemID: "layer-id"}) location := file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/a-path", FileSystemID: "layer-id"})
// the symbols a binary would carry once scanFile has extracted them: one main-package symbol, one // the symbols a binary would carry once scanFile has extracted them: one main-package symbol, two
// dependency symbol, and one standard-library symbol. For the "none" scope scanFile never runs, so the // dependency symbols from distinct packages within the same module (so the per-package keying of the
// build info carries no symbols at all. // emitted map is visible), and one standard-library symbol. For the "none" scope scanFile never runs,
// so the build info carries no symbols at all.
populatedSymbols := []binarySymbol{ populatedSymbols := []binarySymbol{
{packagePath: "main", name: "main.main"}, {packagePath: "main", name: "main.main"},
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"}, {packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
{packagePath: "github.com/foo/bar/baz", name: "github.com/foo/bar/baz.Helper"},
{packagePath: "net/http", name: "net/http.(*Client).Do"}, {packagePath: "net/http", name: "net/http.(*Client).Do"},
} }
@ -1440,9 +1442,9 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
name string name string
scope cataloging.SymbolScope scope cataloging.SymbolScope
symbols []binarySymbol symbols []binarySymbol
wantMainSyms []string wantMainSyms map[string][]string
wantDepSyms []string wantDepSyms map[string][]string
wantStdlibSyms []string wantStdlibSyms map[string][]string
}{ }{
{ {
name: "none captures nothing", name: "none captures nothing",
@ -1453,15 +1455,18 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
name: "stdlib captures only the stdlib package", name: "stdlib captures only the stdlib package",
scope: cataloging.SymbolScopeStdlib, scope: cataloging.SymbolScopeStdlib,
symbols: populatedSymbols, symbols: populatedSymbols,
wantStdlibSyms: []string{"net/http.(*Client).Do"}, wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}},
}, },
{ {
name: "all captures module and stdlib packages", name: "all captures module and stdlib packages",
scope: cataloging.SymbolScopeAll, scope: cataloging.SymbolScopeAll,
symbols: populatedSymbols, symbols: populatedSymbols,
wantMainSyms: []string{"main.main"}, wantMainSyms: map[string][]string{"main": {"main"}},
wantDepSyms: []string{"github.com/foo/bar.Parse"}, wantDepSyms: map[string][]string{
wantStdlibSyms: []string{"net/http.(*Client).Do"}, "github.com/foo/bar": {"Parse"},
"github.com/foo/bar/baz": {"Helper"},
},
wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}},
}, },
} }
@ -1491,3 +1496,41 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
}) })
} }
} }
// Test_recordStdlibSymbols_merge covers the merge path where the same binary location records stdlib
// symbols more than once. This happens in production for universal/fat Mach-O binaries: scanFile yields
// one build info per architecture and each is recorded under the same location coordinates.
func Test_recordStdlibSymbols_merge(t *testing.T) {
coord := file.Coordinates{RealPath: "/a-path", FileSystemID: "layer-id"}
c := newGoBinaryCataloger(CatalogerConfig{})
c.recordStdlibSymbols(coord, map[string][]string{
"net/http": {"(*Client).Do", "Get"},
"net/url": {"Parse"},
})
// a second architecture: overlapping names (must dedup), a new name for an existing package, and a new package
c.recordStdlibSymbols(coord, map[string][]string{
"net/http": {"Get", "Post"},
"os": {"Open"},
})
assert.Equal(t, map[string][]string{
"net/http": {"(*Client).Do", "Get", "Post"},
"net/url": {"Parse"},
"os": {"Open"},
}, c.stdlibSymbolsFor(coord))
}
// Test_stdlibSymbolsFor_isolation asserts the returned map is a deep copy: mutating it (or its slices)
// must not corrupt the cataloger's guarded internal state.
func Test_stdlibSymbolsFor_isolation(t *testing.T) {
coord := file.Coordinates{RealPath: "/a-path", FileSystemID: "layer-id"}
c := newGoBinaryCataloger(CatalogerConfig{})
c.recordStdlibSymbols(coord, map[string][]string{"net/http": {"Get"}})
got := c.stdlibSymbolsFor(coord)
got["net/http"] = append(got["net/http"], "MUTATED")
got["net/url"] = []string{"Parse"}
assert.Equal(t, map[string][]string{"net/http": {"Get"}}, c.stdlibSymbolsFor(coord), "internal state must be unaffected by caller mutation")
}

View File

@ -50,7 +50,7 @@ func (c *goBinaryCataloger) stdlibPackageAndRelationships(ctx context.Context, p
return goCompilerPkgs, relationships return goCompilerPkgs, relationships
} }
func newGoStdLib(ctx context.Context, version string, location file.LocationSet, symbols []string) *pkg.Package { func newGoStdLib(ctx context.Context, version string, location file.LocationSet, symbols map[string][]string) *pkg.Package {
stdlibCpe, err := generateStdlibCpe(version) stdlibCpe, err := generateStdlibCpe(version)
if err != nil { if err != nil {
return nil return nil

View File

@ -88,7 +88,7 @@ func Test_stdlibPackageAndRelationships(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates][]string)} c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates]map[string][]string)}
gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, tt.pkgs) gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, tt.pkgs)
assert.Len(t, gotPkgs, tt.wantPkgs) assert.Len(t, gotPkgs, tt.wantPkgs)
assert.Len(t, gotRels, tt.wantRels) assert.Len(t, gotRels, tt.wantRels)
@ -138,7 +138,7 @@ func Test_stdlibPackageAndRelationships_values(t *testing.T) {
Type: artifact.DependencyOfRelationship, Type: artifact.DependencyOfRelationship,
} }
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates][]string)} c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates]map[string][]string)}
gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, []pkg.Package{p}) gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, []pkg.Package{p})
require.Len(t, gotPkgs, 1) require.Len(t, gotPkgs, 1)

View File

@ -267,12 +267,15 @@ func readPclntab(r io.ReaderAt) (pclntab []byte, textStart uint64, err error) {
} }
// moduleSymbols attributes each extracted symbol to the module that owns it (by longest module path prefix // moduleSymbols attributes each extracted symbol to the module that owns it (by longest module path prefix
// of the symbol's package path) and returns a sorted, deduplicated list of symbol names per module path. // of the symbol's package path) and returns, per module path, the symbols grouped by the import path of the
// Symbols from the "main" package are attributed to the main module. Standard-library symbols (which belong // owning package. Each inner value is a sorted, deduplicated list of symbol names local to that package
// to no module) are collected separately and returned as the second value so they can be attached to the // (the import path prefix stripped, e.g. "github.com/foo/bar.(*T).M" under key "github.com/foo/bar" becomes
// "(*T).M"). Symbols from the "main" package are attributed to the main module and keyed by the "main"
// import path the linker assigns. Standard-library symbols (which belong to no module) are collected
// separately and returned as the second value, grouped by import path, so they can be attached to the
// synthetic "stdlib" package. Compiler/runtime-internal symbols that are neither module-owned nor a // synthetic "stdlib" package. Compiler/runtime-internal symbols that are neither module-owned nor a
// recognizable stdlib import path are dropped. // recognizable stdlib import path are dropped.
func moduleSymbols(symbols []binarySymbol, main *debug.Module, deps []*debug.Module) (byModule map[string][]string, stdlib []string) { func moduleSymbols(symbols []binarySymbol, main *debug.Module, deps []*debug.Module) (byModule map[string]map[string][]string, stdlib map[string][]string) {
if len(symbols) == 0 { if len(symbols) == 0 {
return nil, nil return nil, nil
} }
@ -287,41 +290,67 @@ func moduleSymbols(symbols []binarySymbol, main *debug.Module, deps []*debug.Mod
} }
} }
results := make(map[string][]string) results := make(map[string]map[string][]string)
stdlib = make(map[string][]string)
for _, sym := range symbols { for _, sym := range symbols {
pkgPath := sym.packagePath importPath := sym.packagePath
if pkgPath == mainPackage && main != nil {
// the linker renames the main package's import path to "main" // the linker renames the main package's import path to "main"; attribute it to the main module,
pkgPath = main.Path // but keep "main" as the group key since the original import path is not recoverable.
attrPath := importPath
if importPath == mainPackage && main != nil {
attrPath = main.Path
} }
var best string var best string
for _, modPath := range modulePaths { for _, modPath := range modulePaths {
if len(modPath) > len(best) && (pkgPath == modPath || strings.HasPrefix(pkgPath, modPath+"/")) { if len(modPath) > len(best) && (attrPath == modPath || strings.HasPrefix(attrPath, modPath+"/")) {
best = modPath best = modPath
} }
} }
local := localSymbolName(sym.name, importPath)
if best == "" { if best == "" {
if pkgPath != mainPackage && isStandardImportPath(pkgPath) { if importPath != mainPackage && isStandardImportPath(importPath) {
stdlib = append(stdlib, sym.name) stdlib[importPath] = append(stdlib[importPath], local)
} }
continue continue
} }
results[best] = append(results[best], sym.name) if results[best] == nil {
results[best] = make(map[string][]string)
}
results[best][importPath] = append(results[best][importPath], local)
} }
for modPath, names := range results { for _, byImport := range results {
slices.Sort(names) sortCompactGroups(byImport)
results[modPath] = slices.Compact(names)
} }
if len(stdlib) > 0 { sortCompactGroups(stdlib)
slices.Sort(stdlib) if len(stdlib) == 0 {
stdlib = slices.Compact(stdlib) stdlib = nil
} }
return results, stdlib return results, stdlib
} }
// localSymbolName strips the owning package's import path prefix from a fully qualified symbol name, e.g.
// "github.com/foo/bar.(*T).M" with import path "github.com/foo/bar" becomes "(*T).M". The name is returned
// unchanged when it does not carry the expected prefix.
func localSymbolName(name, importPath string) string {
if importPath != "" && strings.HasPrefix(name, importPath+".") {
return name[len(importPath)+1:]
}
return name
}
// sortCompactGroups sorts and deduplicates each symbol list in a group keyed by import path, in place.
func sortCompactGroups(groups map[string][]string) {
for path, names := range groups {
slices.Sort(names)
groups[path] = slices.Compact(names)
}
}
// isStandardImportPath reports whether path is a Go standard-library import path. This mirrors the rule // isStandardImportPath reports whether path is a Go standard-library import path. This mirrors the rule
// the Go toolchain uses: a path is standard if the element before its first slash contains no dot (e.g. // the Go toolchain uses: a path is standard if the element before its first slash contains no dot (e.g.
// "net/http", "runtime", "internal/abi"), which distinguishes it from module paths like // "net/http", "runtime", "internal/abi"), which distinguishes it from module paths like

View File

@ -22,8 +22,8 @@ func Test_moduleSymbols(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
symbols []binarySymbol symbols []binarySymbol
expected map[string][]string expected map[string]map[string][]string
expectedStdlib []string expectedStdlib map[string][]string
}{ }{
{ {
name: "no symbols", name: "no symbols",
@ -31,47 +31,47 @@ func Test_moduleSymbols(t *testing.T) {
expected: nil, expected: nil,
}, },
{ {
name: "attribute symbols by longest module path prefix", name: "attribute symbols by longest module path prefix, grouped by import path",
symbols: []binarySymbol{ symbols: []binarySymbol{
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"}, {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/internal/util", name: "github.com/foo/bar/internal/util.(*Helper).Do"},
{packagePath: "github.com/foo/bar/v2", name: "github.com/foo/bar/v2.Parse"}, {packagePath: "github.com/foo/bar/v2", name: "github.com/foo/bar/v2.Parse"},
}, },
expected: map[string][]string{ expected: map[string]map[string][]string{
"github.com/foo/bar": { "github.com/foo/bar": {
"github.com/foo/bar.Parse", "github.com/foo/bar": {"Parse"},
"github.com/foo/bar/internal/util.(*Helper).Do", "github.com/foo/bar/internal/util": {"(*Helper).Do"},
}, },
"github.com/foo/bar/v2": { "github.com/foo/bar/v2": {
"github.com/foo/bar/v2.Parse", "github.com/foo/bar/v2": {"Parse"},
}, },
}, },
}, },
{ {
name: "main package symbols are attributed to the main module", name: "main package symbols are attributed to the main module and keyed by the main import path",
symbols: []binarySymbol{ symbols: []binarySymbol{
{packagePath: "main", name: "main.main"}, {packagePath: "main", name: "main.main"},
{packagePath: "github.com/someorg/somecli/cmd", name: "github.com/someorg/somecli/cmd.Execute"}, {packagePath: "github.com/someorg/somecli/cmd", name: "github.com/someorg/somecli/cmd.Execute"},
}, },
expected: map[string][]string{ expected: map[string]map[string][]string{
"github.com/someorg/somecli": { "github.com/someorg/somecli": {
"github.com/someorg/somecli/cmd.Execute", "github.com/someorg/somecli/cmd": {"Execute"},
"main.main", "main": {"main"},
}, },
}, },
}, },
{ {
name: "stdlib and runtime symbols are collected separately", name: "stdlib and runtime symbols are collected separately, grouped by import path",
symbols: []binarySymbol{ symbols: []binarySymbol{
{packagePath: "runtime", name: "runtime.main"}, {packagePath: "runtime", name: "runtime.main"},
{packagePath: "net/http", name: "net/http.(*Client).Do"}, {packagePath: "net/http", name: "net/http.(*Client).Do"},
{packagePath: "internal/abi", name: "internal/abi.(*Type).Kind"}, {packagePath: "internal/abi", name: "internal/abi.(*Type).Kind"},
}, },
expected: map[string][]string{}, expected: map[string]map[string][]string{},
expectedStdlib: []string{ expectedStdlib: map[string][]string{
"internal/abi.(*Type).Kind", "internal/abi": {"(*Type).Kind"},
"net/http.(*Client).Do", "net/http": {"(*Client).Do"},
"runtime.main", "runtime": {"main"},
}, },
}, },
{ {
@ -80,9 +80,9 @@ func Test_moduleSymbols(t *testing.T) {
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"}, {packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
{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][]string{ expected: map[string]map[string][]string{
"github.com/foo/bar": { "github.com/foo/bar": {
"github.com/foo/bar.Parse", "github.com/foo/bar": {"Parse"},
}, },
}, },
}, },
@ -97,6 +97,53 @@ func Test_moduleSymbols(t *testing.T) {
} }
} }
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) { func Test_getSymbols(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("PE binaries are not supported for symbol extraction") t.Skip("PE binaries are not supported for symbol extraction")

View File

@ -23,12 +23,16 @@ type GolangBinaryBuildinfoEntry struct {
// GoExperiments lists experimental Go features enabled during compilation (e.g., "arenas", "cgocheck2"). // GoExperiments lists experimental Go features enabled during compilation (e.g., "arenas", "cgocheck2").
GoExperiments []string `json:"goExperiments,omitempty" cyclonedx:"goExperiments"` GoExperiments []string `json:"goExperiments,omitempty" cyclonedx:"goExperiments"`
// Symbols are the fully qualified function symbols from this module that are compiled into the binary // Symbols are the function symbols from this module that are compiled into the binary, extracted from
// (e.g., "github.com/foo/bar.(*Type).Method"), extracted from the binary symbol table (pclntab). // the binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each
// Populated only when the golang cataloger's capture-symbols scope covers this package: the "all" scope // value is the sorted, deduplicated list of symbol names local to that package, i.e. with the import
// populates every module package plus the synthetic stdlib package, while the "stdlib" scope populates // path prefix stripped (e.g. import path "github.com/foo/bar" -> "(*Type).Method"). The fully qualified
// only the stdlib package. // name is the import path, a ".", and the local name. One exception: the binary's main package appears
Symbols []string `json:"symbols,omitempty"` // under the key "main" (the name the linker assigns), not its original source import path, which is not
// recoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers
// this package: the "all" scope populates every module package plus the synthetic stdlib package, while
// the "stdlib" scope populates only the stdlib package.
Symbols map[string][]string `json:"symbols,omitempty"`
} }
// GolangModuleEntry represents all captured data for a Golang source scan with go.mod/go.sum // GolangModuleEntry represents all captured data for a Golang source scan with go.mod/go.sum