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
co-authored by Alex Goodman
parent 4312699dd6
commit f6b5d3e736
11 changed files with 235 additions and 85 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ func (c *goBinaryCataloger) newGoBinaryPackage(dep *debug.Module, m pkg.GolangBi
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 {
dep = dep.Replace
}
+36 -15
View File
@@ -54,9 +54,10 @@ type goBinaryCataloger struct {
symbolScope cataloging.SymbolScope
// 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
// synthetic "stdlib" package. Guarded by stdlibSymbolsMu because parsers run concurrently.
stdlibSymbols map[file.Coordinates][]string
// binary's location), grouped by import path, populated during parsing and consumed by stdlibProcessor
// when it builds the synthetic "stdlib" package. Guarded by stdlibSymbolsMu because parsers run
// concurrently.
stdlibSymbols map[file.Coordinates]map[string][]string
stdlibSymbolsMu sync.Mutex
}
@@ -65,29 +66,49 @@ func newGoBinaryCataloger(opts CatalogerConfig) *goBinaryCataloger {
licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts),
mainModuleVersion: opts.MainModuleVersion,
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
// stdlib processor can attach them to the synthetic stdlib package.
func (c *goBinaryCataloger) recordStdlibSymbols(coord file.Coordinates, symbols []string) {
// recordStdlibSymbols merges the standard-library symbols discovered for a binary location (grouped by
// import path) so the stdlib processor can attach them to the synthetic stdlib package.
func (c *goBinaryCataloger) recordStdlibSymbols(coord file.Coordinates, symbols map[string][]string) {
if len(symbols) == 0 {
return
}
c.stdlibSymbolsMu.Lock()
defer c.stdlibSymbolsMu.Unlock()
merged := slices.Concat(c.stdlibSymbols[coord], symbols)
slices.Sort(merged)
c.stdlibSymbols[coord] = slices.Compact(merged)
existing := c.stdlibSymbols[coord]
if existing == nil {
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
// so callers cannot alias (and later mutate or race on) the map's internal slice.
func (c *goBinaryCataloger) stdlibSymbolsFor(coord file.Coordinates) []string {
// stdlibSymbolsFor returns the standard-library symbols recorded for a binary location. It returns a deep
// copy so callers cannot alias (and later mutate or race on) the map's internal state.
func (c *goBinaryCataloger) stdlibSymbolsFor(coord file.Coordinates) map[string][]string {
c.stdlibSymbolsMu.Lock()
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.
@@ -221,7 +242,7 @@ func missingMainModule(mod *extendedBuildInfo) bool {
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)
lics := c.licenseResolver.getLicenses(ctx, resolver, mod.Main.Path, mod.Main.Version)
gover, experiments := getExperimentsFromVersion(mod.GoVersion)
@@ -1427,12 +1427,14 @@ func (alwaysErrorReader) Read(_ []byte) (int, error) {
func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
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
// dependency symbol, and one standard-library symbol. For the "none" scope scanFile never runs, so the
// build info carries no symbols at all.
// the symbols a binary would carry once scanFile has extracted them: one main-package symbol, two
// dependency symbols from distinct packages within the same module (so the per-package keying of the
// 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{
{packagePath: "main", name: "main.main"},
{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"},
}
@@ -1440,9 +1442,9 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
name string
scope cataloging.SymbolScope
symbols []binarySymbol
wantMainSyms []string
wantDepSyms []string
wantStdlibSyms []string
wantMainSyms map[string][]string
wantDepSyms map[string][]string
wantStdlibSyms map[string][]string
}{
{
name: "none captures nothing",
@@ -1453,15 +1455,18 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
name: "stdlib captures only the stdlib package",
scope: cataloging.SymbolScopeStdlib,
symbols: populatedSymbols,
wantStdlibSyms: []string{"net/http.(*Client).Do"},
wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}},
},
{
name: "all captures module and stdlib packages",
scope: cataloging.SymbolScopeAll,
symbols: populatedSymbols,
wantMainSyms: []string{"main.main"},
wantDepSyms: []string{"github.com/foo/bar.Parse"},
wantStdlibSyms: []string{"net/http.(*Client).Do"},
name: "all captures module and stdlib packages",
scope: cataloging.SymbolScopeAll,
symbols: populatedSymbols,
wantMainSyms: map[string][]string{"main": {"main"}},
wantDepSyms: map[string][]string{
"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")
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (c *goBinaryCataloger) stdlibPackageAndRelationships(ctx context.Context, p
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)
if err != nil {
return nil
@@ -88,7 +88,7 @@ func Test_stdlibPackageAndRelationships(t *testing.T) {
}
for _, tt := range tests {
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)
assert.Len(t, gotPkgs, tt.wantPkgs)
assert.Len(t, gotRels, tt.wantRels)
@@ -138,7 +138,7 @@ func Test_stdlibPackageAndRelationships_values(t *testing.T) {
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})
require.Len(t, gotPkgs, 1)
+48 -19
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
// of the symbol's package path) and returns a sorted, deduplicated list of symbol names per module path.
// Symbols from the "main" package are attributed to the main module. Standard-library symbols (which belong
// to no module) are collected separately and returned as the second value so they can be attached to the
// of the symbol's package path) and returns, per module path, the symbols grouped by the import path of the
// owning package. Each inner value is a sorted, deduplicated list of symbol names local to that package
// (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
// 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 {
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 {
pkgPath := sym.packagePath
if pkgPath == mainPackage && main != nil {
// the linker renames the main package's import path to "main"
pkgPath = main.Path
importPath := sym.packagePath
// the linker renames the main package's import path to "main"; attribute it to the main module,
// 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
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
}
}
local := localSymbolName(sym.name, importPath)
if best == "" {
if pkgPath != mainPackage && isStandardImportPath(pkgPath) {
stdlib = append(stdlib, sym.name)
if importPath != mainPackage && isStandardImportPath(importPath) {
stdlib[importPath] = append(stdlib[importPath], local)
}
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 {
slices.Sort(names)
results[modPath] = slices.Compact(names)
for _, byImport := range results {
sortCompactGroups(byImport)
}
if len(stdlib) > 0 {
slices.Sort(stdlib)
stdlib = slices.Compact(stdlib)
sortCompactGroups(stdlib)
if len(stdlib) == 0 {
stdlib = nil
}
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
// 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
+66 -19
View File
@@ -22,8 +22,8 @@ func Test_moduleSymbols(t *testing.T) {
tests := []struct {
name string
symbols []binarySymbol
expected map[string][]string
expectedStdlib []string
expected map[string]map[string][]string
expectedStdlib map[string][]string
}{
{
name: "no symbols",
@@ -31,47 +31,47 @@ func Test_moduleSymbols(t *testing.T) {
expected: nil,
},
{
name: "attribute symbols by longest module path prefix",
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][]string{
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": {"Parse"},
"github.com/foo/bar/internal/util": {"(*Helper).Do"},
},
"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{
{packagePath: "main", name: "main.main"},
{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/cmd.Execute",
"main.main",
"github.com/someorg/somecli/cmd": {"Execute"},
"main": {"main"},
},
},
},
{
name: "stdlib and runtime symbols are collected separately",
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][]string{},
expectedStdlib: []string{
"internal/abi.(*Type).Kind",
"net/http.(*Client).Do",
"runtime.main",
expected: map[string]map[string][]string{},
expectedStdlib: map[string][]string{
"internal/abi": {"(*Type).Kind"},
"net/http": {"(*Client).Do"},
"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"},
},
expected: map[string][]string{
expected: map[string]map[string][]string{
"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) {
if runtime.GOOS == "windows" {
t.Skip("PE binaries are not supported for symbol extraction")
+10 -6
View File
@@ -23,12 +23,16 @@ type GolangBinaryBuildinfoEntry struct {
// GoExperiments lists experimental Go features enabled during compilation (e.g., "arenas", "cgocheck2").
GoExperiments []string `json:"goExperiments,omitempty" cyclonedx:"goExperiments"`
// Symbols are the fully qualified function symbols from this module that are compiled into the binary
// (e.g., "github.com/foo/bar.(*Type).Method"), extracted from the binary symbol table (pclntab).
// 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 []string `json:"symbols,omitempty"`
// Symbols are the function symbols from this module that are compiled into the binary, extracted from
// the binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each
// value is the sorted, deduplicated list of symbol names local to that package, i.e. with the import
// path prefix stripped (e.g. import path "github.com/foo/bar" -> "(*Type).Method"). The fully qualified
// name is the import path, a ".", and the local name. One exception: the binary's main package appears
// 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