Skip to content
Merged
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
75 changes: 48 additions & 27 deletions internal/ldso-cache/ldsocache.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import (
"slices"
"strings"
"unsafe"

"chainguard.dev/apko/pkg/elfmeta"
)

const debug = false
Expand Down Expand Up @@ -187,6 +189,16 @@ func getLibInfo(fsys fs.FS, dir string, dirent fs.DirEntry) (libInfo, error) {
}
}

// Pre-computed metadata (see pkg/elfmeta) short-circuits the parse: a
// package that stamped its ELF facts at build time hands them over here
// and the scan never opens the library. Any miss or malformation falls
// through to the parse below.
if ei, err := stampedElfInfo(fsys, fullpath); err == nil {
return finishLibInfo(fullpath, realname, ei), nil
} else if errors.Is(err, errNotDyn) {
return li, fmt.Errorf("not a dynamic object (pre-computed): %s", fullpath)
}

libf, err := fsys.Open(fullpath)
if err != nil {
return li, err
Expand All @@ -207,23 +219,52 @@ func getLibInfo(fsys fs.FS, dir string, dirent fs.DirEntry) (libInfo, error) {
if err != nil {
return li, err
}
return finishLibInfo(fullpath, realname, ei), nil
}

// finishLibInfo applies the no-SONAME fallback and assembles the result,
// identically for parsed and pre-computed metadata.
func finishLibInfo(fullpath, realname string, ei elfInfo) libInfo {
// ldconfig will add an entry for a .so file even if it has
// no SONAME. Observed with libR.so on Ubuntu.
if len(ei.Sonames) == 0 && strings.HasSuffix(realname, ".so") {
debugf("DEBUG: %s has no SONAME, using filename as an SONAME\n", realname)
ei.Sonames = append(ei.Sonames, realname)
}
if len(ei.Sonames) == 0 && strings.HasSuffix(realname, ".so") {
debugf("DEBUG: %s has no DT_SONAME, using %s as an SONAME\n", realname, realname)
ei.Sonames = append(ei.Sonames, realname)
}

li = libInfo{
return libInfo{
path: fullpath,
elf: ei,
}
}

return li, nil
// errNotDyn reports pre-computed metadata that says "checked, not a dynamic
// object" — a skip, distinct from "no metadata" (which means parse).
var errNotDyn = errors.New("pre-computed metadata: not a dynamic object")

// stampedElfInfo reads pre-computed ELF metadata for the file, when the
// filesystem supports xattrs and the file carries one. GetXattr resolves
// symlinks like every other path operation, so a dirent that is a link
// hands back its target's stamp — the facts live on the regular file.
func stampedElfInfo(fsys fs.FS, fullpath string) (elfInfo, error) {
xfs, ok := fsys.(interface {
GetXattr(path string, attr string) ([]byte, error)
})
if !ok {
return elfInfo{}, fmt.Errorf("filesystem carries no xattrs")
}
b, err := xfs.GetXattr(fullpath, elfmeta.Xattr)
if err != nil {
return elfInfo{}, err
}
info, err := elfmeta.Decode(b)
if err != nil {
return elfInfo{}, err
}
if !info.Dyn {
return elfInfo{}, errNotDyn
}
return elfInfo{Machine: info.Machine, Sonames: info.Sonames}, nil
}

// accepts a library name and returns its name and a version
Expand All @@ -232,27 +273,7 @@ func getLibInfo(fsys fs.FS, dir string, dirent fs.DirEntry) (libInfo, error) {
//
// returns an error if realname doesn't comply w/ the name scheme
func ParseLibFilename(realname string) (string, string, error) {
var name string
var ver string
// ldconfig(8) says it "will look only at files that are named lib*.so*
// (for regular shared objects) or ld-*.so* (for the dynamic loader itself).
// Other files will be ignored.
if !strings.HasPrefix(realname, "lib") && !strings.HasPrefix(realname, "ld-") {
return "", "", fmt.Errorf("filename does not start with 'lib' or 'ld-': %s", realname)
}
if before, ok := strings.CutSuffix(realname, ".so"); ok {
name = before
ver = ""
return name, ver, nil
}
idx := strings.LastIndex(realname, ".so.")
if idx < 1 {
return "", "", fmt.Errorf("invalid library name: %s", realname)
}
name = realname[:idx]
ver = realname[idx+len(".so."):]

return name, ver, nil
return elfmeta.ParseLibFilename(realname)
}

// Scan `libdir` for shared libraries. Adds a new entry into `entryMap` for
Expand Down
84 changes: 84 additions & 0 deletions internal/ldso-cache/stamped_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ldsocache

import (
"debug/elf"
"testing"

apkfs "chainguard.dev/apko/pkg/apk/fs"
"chainguard.dev/apko/pkg/elfmeta"
)

// TestStampedMetadataSkipsParsing proves the pre-computed path: files whose
// content is deliberately not ELF at all still produce cache entries when
// they carry stamped metadata — the scan never opened them — and a stamp
// that says "not a dynamic object" excludes a file without a parse.
func TestStampedMetadataSkipsParsing(t *testing.T) {
// Sibling tests swap the package-global parser for a mock; this test is
// about the real one being bypassed, so pin it.
orig := getElfInfo
getElfInfo = doGetElfInfo
t.Cleanup(func() { getElfInfo = orig })

fsys := apkfs.NewMemFS()
if err := fsys.MkdirAll("usr/lib", 0o755); err != nil {
t.Fatal(err)
}

// Not an ELF; only the stamp makes it legible.
if err := fsys.WriteFile("usr/lib/libstamped.so.1", []byte("not an ELF"), 0o644); err != nil {
t.Fatal(err)
}
stamp := elfmeta.Info{Dyn: true, Machine: elf.EM_AARCH64, Sonames: []string{"libstamped.so.1"}}
if err := fsys.SetXattr("usr/lib/libstamped.so.1", elfmeta.Xattr, stamp.Encode()); err != nil {
t.Fatal(err)
}

// Stamped "checked, not a dynamic object": excluded, also without a parse.
if err := fsys.WriteFile("usr/lib/libnotdyn.so.1", []byte("also not an ELF"), 0o644); err != nil {
t.Fatal(err)
}
if err := fsys.SetXattr("usr/lib/libnotdyn.so.1", elfmeta.Xattr, elfmeta.Info{}.Encode()); err != nil {
t.Fatal(err)
}

// A symlink to the stamped file: facts resolve through the link.
if err := fsys.Symlink("libstamped.so.1", "usr/lib/libstamped.so"); err != nil {
t.Fatal(err)
}

// Unstamped garbage: parses, fails, skipped — the fallback path.
if err := fsys.WriteFile("usr/lib/libplain.so.1", []byte("garbage"), 0o644); err != nil {
t.Fatal(err)
}

entries, err := LDSOCacheEntriesForDirs(fsys, []string{"/usr/lib"})
if err != nil {
t.Fatalf("LDSOCacheEntriesForDirs: %v", err)
}
names := map[string]bool{}
for _, e := range entries {
names[e.Name] = true
}
if !names["/usr/lib/libstamped.so.1"] {
t.Errorf("stamped library missing from cache entries: %v", entries)
}
for _, absent := range []string{"/usr/lib/libnotdyn.so.1", "/usr/lib/libplain.so.1"} {
if names[absent] {
t.Errorf("%s: got = a cache entry, wanted = none", absent)
}
}
}
152 changes: 152 additions & 0 deletions pkg/elfmeta/elfmeta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package elfmeta lets a package build pre-compute the ELF facts that
// filesystem-level consumers otherwise re-derive by opening and parsing
// every library — most prominently ld.so.cache generation, which needs only
// each library's machine and SONAMEs but costs a full ELF parse per file to
// learn them.
//
// A producer (melange, or any packaging tool) runs [Extract] over a built
// file and stamps [Xattr] with the encoded [Info]. Consumers read the xattr
// instead of the file; a miss or a malformed value falls back to parsing.
// The stamp is ordinary package metadata: it rides the data section's PAX
// records like any other xattr, is covered by the package signature, and
// persists wherever the package's files do — installed systems and built
// images alike. No consumer removes it.
//
// Stamps live on regular files only: Linux permits user.* xattrs on
// neither symlinks nor special files, so a library's facts are stamped on
// the real file and consumers resolve links before reading.
//
// A stamp is exactly as authoritative as the package carrying it: a
// producer that stamps wrong facts mis-describes its own contents, just as
// it could ship a wrong library outright. Consumers trust it accordingly
// and do not re-derive stamped facts.
package elfmeta

import (
"debug/elf"
"fmt"
"io"
"strconv"
"strings"
)

// Xattr is the extended attribute carrying an encoded [Info]. It rides
// wherever file metadata rides — tar PAX records, filesystem xattrs — and
// stays with the file for its lifetime. The name is vendored: user.* is a
// namespace shared with every tool on the system.
const Xattr = "user.dev.chainguard.elfmeta"

// Info is the pre-computed metadata: whether the file is a dynamic object at
// all, and if so its machine and SONAMEs. A stamped Info with Dyn=false is
// meaningful — it records "checked, not a dynamic object", sparing consumers
// the parse that would rediscover that.
type Info struct {
Dyn bool
Machine elf.Machine
Sonames []string
}

// Extract computes Info from an ELF file. A parseable non-dynamic object
// (an executable, say) yields Dyn=false and no error; content that does not
// parse as ELF at all is an error, and producers stamping by filename
// convention typically encode that as Dyn=false too.
func Extract(r io.ReaderAt) (Info, error) {
f, err := elf.NewFile(r)
if err != nil {
return Info{}, fmt.Errorf("parsing ELF: %w", err)
}
if f.Type != elf.ET_DYN {
return Info{}, nil
}
sonames, err := f.DynString(elf.DT_SONAME)
if err != nil {
return Info{}, fmt.Errorf("reading DT_SONAME: %w", err)
}
return Info{Dyn: true, Machine: f.Machine, Sonames: sonames}, nil
}

// Encode renders Info as the Xattr value: "none" for a non-dynamic object,
// else "dyn <machine>" followed by one SONAME per line.
func (i Info) Encode() []byte {
if !i.Dyn {
return []byte("none")
}
var b strings.Builder
fmt.Fprintf(&b, "dyn %d", uint16(i.Machine))
for _, s := range i.Sonames {
b.WriteByte('\n')
b.WriteString(s)
}
return []byte(b.String())
}

// Decode parses an Xattr value.
func Decode(b []byte) (Info, error) {
s := string(b)
if s == "none" {
return Info{}, nil
}
lines := strings.Split(s, "\n")
rest, ok := strings.CutPrefix(lines[0], "dyn ")
if !ok {
return Info{}, fmt.Errorf("malformed %s value %q", Xattr, s)
}
machine, err := strconv.ParseUint(rest, 10, 16)
if err != nil {
return Info{}, fmt.Errorf("malformed machine in %s value %q: %w", Xattr, s, err)
}
info := Info{Dyn: true, Machine: elf.Machine(machine)}
for _, l := range lines[1:] {
if l != "" {
info.Sonames = append(info.Sonames, l)
}
}
return info, nil
}

// ParseLibFilename splits a shared-library filename into its name and
// version — "libfoo.so.1" into "libfoo" and "1", "libbar.so" into "libbar"
// and "" — and reports an error for names outside the scheme ldconfig
// considers: per ldconfig(8), files named lib*.so* (regular shared objects)
// or ld-*.so* (the dynamic loader itself).
func ParseLibFilename(realname string) (string, string, error) {
var name string
var ver string
if !strings.HasPrefix(realname, "lib") && !strings.HasPrefix(realname, "ld-") {
return "", "", fmt.Errorf("filename does not start with 'lib' or 'ld-': %s", realname)
}
if before, ok := strings.CutSuffix(realname, ".so"); ok {
name = before
ver = ""
return name, ver, nil
}
idx := strings.LastIndex(realname, ".so.")
if idx < 1 {
return "", "", fmt.Errorf("invalid library name: %s", realname)
}
name = realname[:idx]
ver = realname[idx+len(".so."):]

return name, ver, nil
}

// EligibleName reports whether a filename is one an ldconfig-style scan
// would consider — the shape producers stamp by.
func EligibleName(realname string) bool {
_, _, err := ParseLibFilename(realname)
return err == nil
}
Loading