diff --git a/internal/ldso-cache/ldsocache.go b/internal/ldso-cache/ldsocache.go index ae43c1dc1..2e0eaaa44 100644 --- a/internal/ldso-cache/ldsocache.go +++ b/internal/ldso-cache/ldsocache.go @@ -29,6 +29,8 @@ import ( "slices" "strings" "unsafe" + + "chainguard.dev/apko/pkg/elfmeta" ) const debug = false @@ -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 @@ -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 @@ -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 diff --git a/internal/ldso-cache/stamped_test.go b/internal/ldso-cache/stamped_test.go new file mode 100644 index 000000000..742a6964d --- /dev/null +++ b/internal/ldso-cache/stamped_test.go @@ -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) + } + } +} diff --git a/pkg/elfmeta/elfmeta.go b/pkg/elfmeta/elfmeta.go new file mode 100644 index 000000000..e05262630 --- /dev/null +++ b/pkg/elfmeta/elfmeta.go @@ -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 " 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 +} diff --git a/pkg/elfmeta/elfmeta_test.go b/pkg/elfmeta/elfmeta_test.go new file mode 100644 index 000000000..ae81ade9d --- /dev/null +++ b/pkg/elfmeta/elfmeta_test.go @@ -0,0 +1,138 @@ +// 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 + +import ( + "bytes" + "debug/elf" + "encoding/binary" + "reflect" + "testing" +) + +// testELF synthesizes a minimal ELF64 little-endian object of the given type +// with one DT_SONAME entry, just enough for debug/elf's Type, Machine, and +// DynString to work with. +func testELF(t *testing.T, typ elf.Type, machine elf.Machine, soname string) []byte { + t.Helper() + dynstr := append([]byte{0}, append([]byte(soname), 0)...) + const ( + ehsize = 64 + shentsz = 64 + shnum = 3 + ) + shoff := int64(ehsize) + dynstrOff := shoff + shnum*shentsz + dynOff := dynstrOff + int64(len(dynstr)) + + var buf bytes.Buffer + // ELF header. + buf.Write([]byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + le := binary.LittleEndian + w := func(v any) { _ = binary.Write(&buf, le, v) } + w(uint16(typ)) // e_type + w(uint16(machine)) // e_machine + w(uint32(1)) // e_version + w(uint64(0)) // e_entry + w(uint64(0)) // e_phoff + w(uint64(shoff)) // e_shoff + w(uint32(0)) // e_flags + w(uint16(ehsize)) // e_ehsize + w(uint16(0)) // e_phentsize + w(uint16(0)) // e_phnum + w(uint16(shentsz)) // e_shentsize + w(uint16(shnum)) // e_shnum + w(uint16(0)) // e_shstrndx + + type shdr struct { + Name, Type uint32 + Flags, Addr, Off, Sz uint64 + Link, Info uint32 + Align, Entsize uint64 + } + w(shdr{}) // null section + w(shdr{Type: uint32(elf.SHT_DYNAMIC), Off: uint64(dynOff), Sz: 32, Link: 2, Align: 8, Entsize: 16}) // .dynamic + w(shdr{Type: uint32(elf.SHT_STRTAB), Off: uint64(dynstrOff), Sz: uint64(len(dynstr)), Align: 1}) // .dynstr + buf.Write(dynstr) + w(uint64(elf.DT_SONAME)) // .dynamic: DT_SONAME -> offset 1 + w(uint64(1)) + w(uint64(elf.DT_NULL)) + w(uint64(0)) + return buf.Bytes() +} + +func TestExtract(t *testing.T) { + dyn := testELF(t, elf.ET_DYN, elf.EM_AARCH64, "libfake.so.1") + info, err := Extract(bytes.NewReader(dyn)) + if err != nil { + t.Fatalf("Extract: %v", err) + } + want := Info{Dyn: true, Machine: elf.EM_AARCH64, Sonames: []string{"libfake.so.1"}} + if !reflect.DeepEqual(info, want) { + t.Errorf("Extract: got = %+v, wanted = %+v", info, want) + } + + exe := testELF(t, elf.ET_EXEC, elf.EM_X86_64, "ignored") + info, err = Extract(bytes.NewReader(exe)) + if err != nil { + t.Fatalf("Extract(ET_EXEC): %v", err) + } + if info.Dyn { + t.Errorf("Extract(ET_EXEC): got = dynamic, wanted = not") + } + + if _, err := Extract(bytes.NewReader([]byte("just some text, not an ELF"))); err == nil { + t.Error("Extract(non-ELF): got = nil, wanted an error") + } +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + for _, info := range []Info{ + {}, + {Dyn: true, Machine: elf.EM_AARCH64, Sonames: []string{"libc.so.6"}}, + {Dyn: true, Machine: elf.EM_X86_64, Sonames: []string{"liba.so.1", "libb.so.2"}}, + {Dyn: true, Machine: elf.EM_RISCV}, + } { + got, err := Decode(info.Encode()) + if err != nil { + t.Fatalf("Decode(Encode(%+v)): %v", info, err) + } + if !reflect.DeepEqual(got, info) { + t.Errorf("round trip: got = %+v, wanted = %+v", got, info) + } + } + for _, bad := range []string{"", "dyn", "dyn x", "wat 42"} { + if _, err := Decode([]byte(bad)); err == nil { + t.Errorf("Decode(%q): got = nil, wanted an error", bad) + } + } +} + +func TestEligibleName(t *testing.T) { + for name, want := range map[string]bool{ + "libc.so.6": true, + "libfoo.so": true, + "ld-linux.so.2": true, + "libbar.so.1.2.3": true, + "python3": false, + "lib": false, + "foo.so": false, + "libnodots": false, + } { + if got := EligibleName(name); got != want { + t.Errorf("EligibleName(%q): got = %v, wanted = %v", name, got, want) + } + } +}