Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/sca/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ func TestAnalyze(t *testing.T) {
"so:libwebpdemux-df9b36c7.so.2.0.14=2.0.14",
"so:libwebpmux-9fe05867.so.3.0.13=3.0.13",
"so:libxcb-f0538cc0.so.1.1.0=1.1.0",
"static:libnpymath",
"static:libnpyrandom",
},
},
}, {
Expand Down
165 changes: 165 additions & 0 deletions pkg/sca/sca.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,31 @@ func generateSharedObjectNameDeps(ctx context.Context, hdl SCAHandle, generated
// wolfi, however package install tests will catch that in presubmit
var generateRuntimePkgConfigDeps = true

// generateStaticLibRunDeps controls whether static:lib<name> runtime dependencies
// derived from pkg-config Libs.private fields are added to packages. When false,
// candidate dependencies are only logged. Set MELANGE_GENERATE_STATIC_DEPS to
// "true" or "false" to control this behavior.
var generateStaticLibRunDeps = parseEnvBool("MELANGE_GENERATE_STATIC_DEPS", false)

// parseEnvBool reads an environment variable and returns its boolean value.
// Accepted values are "true" and "false"; any other non-empty value is fatal.
// Returns defaultVal when the variable is unset or empty.
func parseEnvBool(envVar string, defaultVal bool) bool {
v := os.Getenv(envVar)
switch v {
case "":
return defaultVal
case "true":
return true
case "false":
return false
default:
fmt.Fprintf(os.Stderr, "fatal: %s must be set to 'true' or 'false', got %q\n", envVar, v)
os.Exit(1)
return false // unreachable
}
}

// generatePkgConfigDeps generates a list of provided pkg-config package names and versions,
// as well as dependency relationships.
func generatePkgConfigDeps(ctx context.Context, hdl SCAHandle, generated *config.Dependencies, extraLibDirs []string) error {
Expand Down Expand Up @@ -828,6 +853,145 @@ func generatePkgConfigDeps(ctx context.Context, hdl SCAHandle, generated *config
return nil
}

// parsePCFile parses a pkg-config file from fsys at path, returning the
// Package or nil if the file should be skipped (symlink) or cannot be parsed.
func parsePCFile(log *clog.Logger, fsys SCAFS, path string) *pkgconfig.Package {
fi, err := fsys.Stat(path)
if err != nil {
return nil
}
if fi.Mode().Type()&fs.ModeSymlink == fs.ModeSymlink {
return nil
}
f, err := fsys.Open(path)
if err != nil {
return nil
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil
}
pkg, err := pkgconfig.Parse(string(data))
if err != nil {
log.Warnf("unable to parse .pc file (%s) for static dep scanning: %v", path, err)
return nil
}
return pkg
}

// generateStaticLibDeps scans for static libraries (.a files) in standard
// library directories and emits static:lib<name> provides for each one found.
// For each static library found it also searches sibling packages for a
// matching pkg-config file and derives static:lib<name> runtime dependencies
// from its Libs.private field, so that the depends land on the same package
// that owns the .a file. The runtime dependency generation is gated behind
// the MELANGE_GENERATE_STATIC_DEPS environment variable; when disabled,
// candidate dependencies are only logged.
func generateStaticLibDeps(ctx context.Context, hdl SCAHandle, generated *config.Dependencies, extraLibDirs []string) error {
log := clog.FromContext(ctx)
log.Infof("scanning for static libraries...")

fsys, err := hdl.Filesystem()
if err != nil {
return err
}

staticLibDirs := []string{"lib/", "usr/lib/", "lib64/", "usr/lib64/"}

pcDirs := []string{
"usr/local/lib/pkgconfig/",
"usr/local/share/pkgconfig/",
"usr/lib/pkgconfig/",
"usr/lib64/pkgconfig/",
"usr/share/pkgconfig/",
}

// findPC searches for <libName>.pc across all sibling package filesystems.
findPC := func(libName string) *pkgconfig.Package {
pcFile := libName + ".pc"
for _, pkgName := range hdl.RelativeNames() {
sibFS, err := hdl.FilesystemForRelative(pkgName)
if err != nil {
continue
}
for _, dir := range pcDirs {
path := filepath.Join(dir, pcFile)
if pkg := parsePCFile(log, sibFS, path); pkg != nil {
return pkg
}
}
}
return nil
}

// addStaticDeps emits static:lib<name> deps derived from a pkg-config
// Libs.private field, either adding them to runtime or only logging them
// depending on the feature flag.
addStaticDeps := func(libsPrivate, sourceDesc string) {
for field := range strings.FieldsSeq(libsPrivate) {
name, ok := strings.CutPrefix(field, "-l")
if !ok {
continue
}
dep := fmt.Sprintf("static:lib%s", name)
if generateStaticLibRunDeps {
log.Infof(" found static library dependency (Libs.private) %s for %s", dep, sourceDesc)
generated.Runtime = append(generated.Runtime, dep)
} else {
log.Infof(" would add static library dependency (Libs.private) %s for %s (set MELANGE_GENERATE_STATIC_DEPS=1 to enable)", dep, sourceDesc)
}
}
}

// Scan for .a files: emit provides and derive deps from matching .pc files
// in sibling packages.
if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

if !strings.HasSuffix(path, ".a") {
return nil
}

fi, err := d.Info()
if err != nil {
return err
}

if !fi.Mode().IsRegular() {
return nil
}

base := filepath.Base(path)
libName, ok := strings.CutSuffix(base, ".a")
if !ok || !strings.HasPrefix(libName, "lib") {
return nil
}

staticProv := fmt.Sprintf("static:%s", libName)
if isInDir(path, staticLibDirs) {
log.Infof(" found static library %s for %s", libName, path)
generated.Provides = append(generated.Provides, staticProv)

// Look for a matching .pc file in sibling packages to derive deps.
if pkg := findPC(libName); pkg != nil {
addStaticDeps(pkg.LibsPrivate, path)
}
} else {
log.Infof(" found vendored static library %s for %s", libName, path)
generated.Vendored = append(generated.Vendored, staticProv)
}

return nil
}); err != nil {
return err
}

return nil
}

// generatePerlDeps generates a perl dependency for packages which ship
// Perl modules.
func generatePerlDeps(ctx context.Context, hdl SCAHandle, generated *config.Dependencies, extraLibDirs []string) error {
Expand Down Expand Up @@ -1185,6 +1349,7 @@ func Analyze(ctx context.Context, hdl SCAHandle, generated *config.Dependencies)
generateCmdProviders,
generateDocDeps,
generatePkgConfigDeps,
generateStaticLibDeps,
generatePerlDeps,
generatePythonDeps,
generateRubyDeps,
Expand Down
162 changes: 162 additions & 0 deletions pkg/sca/sca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"testing/fstest"
"time"

"chainguard.dev/apko/pkg/apk/apk"
Expand Down Expand Up @@ -459,3 +461,163 @@ func TestIsInDir(t *testing.T) {
}
}
}

// mapSCAFS wraps fstest.MapFS to satisfy the SCAFS interface for unit tests.
// Readlink always returns an error since test fixtures don't use symlinks.
type mapSCAFS struct {
fstest.MapFS
}

func (m mapSCAFS) Readlink(name string) (string, error) {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid}
}

// staticTestHandle is a minimal SCAHandle for testing generateStaticLibDeps.
type staticTestHandle struct {
name string
fsys mapSCAFS
siblings map[string]mapSCAFS
}

func (h *staticTestHandle) PackageName() string { return h.name }
func (h *staticTestHandle) Version() string { return "1.0-r0" }
func (h *staticTestHandle) RelativeNames() []string {
names := make([]string, 1, 1+len(h.siblings))
names[0] = h.name
for k := range h.siblings {
names = append(names, k)
}
return names
}

func (h *staticTestHandle) FilesystemForRelative(pkgName string) (SCAFS, error) {
if pkgName == h.name {
return h.fsys, nil
}
if fsys, ok := h.siblings[pkgName]; ok {
return fsys, nil
}
return nil, fmt.Errorf("unknown package %q", pkgName)
}
func (h *staticTestHandle) Filesystem() (SCAFS, error) { return h.fsys, nil }
func (h *staticTestHandle) Options() config.PackageOption { return config.PackageOption{} }
func (h *staticTestHandle) BaseDependencies() config.Dependencies { return config.Dependencies{} }
func (h *staticTestHandle) InstalledPackages() map[string]string { return nil }
func (h *staticTestHandle) PkgResolver() *apk.PkgResolver { return nil }

func TestStaticLibProvides(t *testing.T) {
ctx := slogtest.Context(t)
hdl := &staticTestHandle{
name: "libfoo-static",
fsys: mapSCAFS{fstest.MapFS{
"usr/lib/libfoo.a": {},
}},
}
got := config.Dependencies{}
if err := generateStaticLibDeps(ctx, hdl, &got, nil); err != nil {
t.Fatal(err)
}
want := config.Dependencies{Provides: []string{"static:libfoo"}}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("(-want, +got):\n%s", diff)
}
}

func TestStaticLibVendored(t *testing.T) {
ctx := slogtest.Context(t)
hdl := &staticTestHandle{
name: "libfoo-static",
fsys: mapSCAFS{fstest.MapFS{
"opt/lib/libfoo.a": {},
}},
}
got := config.Dependencies{}
if err := generateStaticLibDeps(ctx, hdl, &got, nil); err != nil {
t.Fatal(err)
}
want := config.Dependencies{Vendored: []string{"static:libfoo"}}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("(-want, +got):\n%s", diff)
}
}

func TestStaticLibDepsFromSiblingPC(t *testing.T) {
orig := generateStaticLibRunDeps
generateStaticLibRunDeps = true
defer func() { generateStaticLibRunDeps = orig }()

ctx := slogtest.Context(t)
hdl := &staticTestHandle{
name: "libfoo-static",
fsys: mapSCAFS{fstest.MapFS{
"usr/lib/libfoo.a": {},
}},
siblings: map[string]mapSCAFS{
"libfoo-dev": {fstest.MapFS{
"usr/lib/pkgconfig/libfoo.pc": {Data: []byte(
"Name: libfoo\nVersion: 1.0\nDescription: foo\nLibs: -lfoo\nLibs.private: -lbar\n",
)},
}},
},
}
got := config.Dependencies{}
if err := generateStaticLibDeps(ctx, hdl, &got, nil); err != nil {
t.Fatal(err)
}
want := config.Dependencies{
Provides: []string{"static:libfoo"},
Runtime: []string{"static:libbar"},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("(-want, +got):\n%s", diff)
}
}

func TestStaticLibDepsLoggedWhenFlagDisabled(t *testing.T) {
orig := generateStaticLibRunDeps
generateStaticLibRunDeps = false
defer func() { generateStaticLibRunDeps = orig }()

ctx := slogtest.Context(t)
hdl := &staticTestHandle{
name: "libfoo-static",
fsys: mapSCAFS{fstest.MapFS{
"usr/lib/libfoo.a": {},
}},
siblings: map[string]mapSCAFS{
"libfoo-dev": {fstest.MapFS{
"usr/lib/pkgconfig/libfoo.pc": {Data: []byte(
"Name: libfoo\nVersion: 1.0\nDescription: foo\nLibs: -lfoo\nLibs.private: -lbar\n",
)},
}},
},
}
got := config.Dependencies{}
if err := generateStaticLibDeps(ctx, hdl, &got, nil); err != nil {
t.Fatal(err)
}
// Runtime deps should not be added when the flag is disabled.
want := config.Dependencies{Provides: []string{"static:libfoo"}}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("(-want, +got):\n%s", diff)
}
}

func TestParseEnvBool(t *testing.T) {
for _, tc := range []struct {
val string
def bool
want bool
}{
{"", false, false},
{"", true, true},
{"true", false, true},
{"false", true, false},
} {
t.Setenv("TEST_PARSE_ENV_BOOL", tc.val)
got := parseEnvBool("TEST_PARSE_ENV_BOOL", tc.def)
if got != tc.want {
t.Errorf("parseEnvBool(env=%q, default=%v) = %v, want %v", tc.val, tc.def, got, tc.want)
}
}
}
Loading