SCA: Fix so-ver: depends losing their constraint, and allow packages to opt in - #2634
Draft
xnox wants to merge 7 commits into
Draft
SCA: Fix so-ver: depends losing their constraint, and allow packages to opt in#2634xnox wants to merge 7 commits into
xnox wants to merge 7 commits into
Conversation
xnox
marked this pull request as draft
August 31, 2026 13:43
xnox
force-pushed
the
so-ver-depends-opt-in
branch
from
September 1, 2026 15:08
d7e4c0f to
ec679f9
Compare
a-crate
reviewed
Sep 3, 2026
Member
There was a problem hiding this comment.
Mostly LGTM
with ld-linux-x86-64.so.2 staying unversioned by design
I'm not sure about this.
- We generate so-ver provides for this today. 🔗 This contradicts this comment.
- I think that comment is wrong, if we generate
>=so-ver dependencies there's no reason we can't do the same for ld-linux that we're doing for libc. It's roughly the same blast radius. - Tests should assert this, and I think they will catch the bug from point 1.
Member
Author
agree! |
removeSelfProvidedDeps() strips the ">=version" from so-ver: dependencies so that it can look the dependency name up in the map of dependencies the package provides itself. It reassigns the loop variable to do so, though, and then appends that truncated value, so the constraint never reaches .PKGINFO: so-ver:libc.so.6>=2.42-r0 -> so-ver:libc.so.6 An unversioned so-ver: depend is satisfied by any package providing the shared library, which makes the generated dependency a no-op. None of the existing tests use so-ver: dependencies, so nothing caught this. This test fails; the fix follows in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
Use a separate variable for the dependency name we look up in the map of self-provided dependencies, so that the dependency we emit keeps its ">=version" constraint: depend = so-ver:libc.so.6>=2.42-r0 The filtering behaviour is unchanged: a so-ver: dependency that the package provides itself is still recognised and dropped, because the lookup key is still the bare dependency name. This makes the test added in the previous commit pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
Versioned shared library "depends:" are gated behind the
MELANGE_VERSIONED_SHLIB_DEPENDS environment variable, which is
per-build rather than per-package, and the existing
"no-versioned-shlib-deps" option can only turn them off. There was no
way for an individual package to switch them on.
Add a "versioned-shlib-deps" option that is evaluated before the
environment variable and has the final say:
options:
versioned-shlib-deps: true
It is a *bool so that unset, true and false are distinguishable: unset
falls back to the environment variable, and then to
"no-versioned-shlib-deps"; true opts a package in while the feature
flag is still off; false opts one out while the flag is on. An
explicit value also wins over a stale "no-versioned-shlib-deps: true"
in the same file, rather than the two silently cancelling out.
The gate now lives in versionedShlibDepsEnabled(), which is covered by
a table test over the whole precedence matrix. The option's tri-state
YAML round-trip is covered in pkg/config.
While documenting the new option, correct two errors in the
"no-versioned-shlib-deps" documentation: versioned depends are off by
default, not on, and the option governs the shared libraries a package
links against, not the ones it ships -- the versioned so-ver:
"provides:" are emitted regardless.
Note that subpackages do not inherit the origin package's options, so
this option has to be repeated on each subpackage that needs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
determineShlibVersion() only emits a versioned "depends:" if the providing package publishes a matching versioned "provides:", which it checks by parsing the provided version. The predicate returns the result of "err != nil", so it accepted a provided version only when parsing *failed*, which is the opposite of the comment right above it and of what the check is for. Every version in the wolfi index parses cleanly, so the check never matched and determineShlibVersion() fell through to returning an empty version. In other words, no versioned shared library "depends:" was generated for any library provided by another package, even with the feature enabled -- only libraries resolved through the "@current@" path could get one, as that returns before this check. Add a test that runs determineShlibVersion() against a synthetic APKINDEX shaped like the real one: oniguruma 6.9.10-r4 providing both "so:libonig.so.5=5" and "so-ver:libonig.so.5=6.9.10-r4". It expects "6.9.10-r4" and fails without this fix. The companion case, a provider without a versioned "provides:", still correctly yields no versioned depend. To support that, testHandle grows optional "installed" and "resolver" fields, so tests can supply a package resolver and a build environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
Add jq to the wolfi presubmit matrix and build it with MELANGE_VERSIONED_SHLIB_DEPENDS=1, so that so-ver: dependency generation is exercised on a real package. jq is a good demonstration: it links libonig, libc and libm, and the packages providing those all publish versioned so-ver: "provides:" in the wolfi index, so the build should turn depend = so:libonig.so.5 into depend = so:libonig.so.5 depend = so-ver:libonig.so.5>=6.9.10-r4 ld-linux-x86-64.so.2 is deliberately excluded from versioned depends and should stay unversioned. The variable is only set for jq; every other package in the matrix is built exactly as before, since melange reads an empty value as unset. The bubblewrap runner invokes melange through sudo, which resets the environment, so there the variable is handed to env(1) instead of being set in the step environment. The QEMU runner does not use sudo and takes it directly. A new step prints the generated depends for the built jq packages and fails the job if no so-ver: depend was produced, so that the demonstration cannot silently turn into a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
determineShlibVersion() returned early for ld-linux-x86-64.so and ld-linux-aarch64.so, on the grounds that versioning them would require rebuilding every package that links against glibc whenever glibc is updated. That is exactly what a versioned depend is for: the linker is part of glibc's ABI surface like the rest of the libraries it ships, and singling it out means a package can be built against a newer linker than its depends admit. Drop the exclusion, so the linker resolves through the same path as every other library and picks up "so-ver:ld-linux-*.so>=<glibc version>" whenever glibc publishes a matching versioned "provides:". Removing the exclusion alone would cover almost nothing, though: the linker reaches a binary through PT_INTERP, not DT_NEEDED, so the interpreter branch of generateSharedObjectNameDeps() is where the depend is actually generated for ordinary executables. Give that branch the same versioned depend. While there, do the musl rewrite (ld-musl-* => libc.musl-*) on the bare basename with CutPrefix, so the resolved name -- the one the provider publishes -- is what gets version-resolved, and the prefix stays anchored as the "so:"-prefixed ReplaceAll had it. The "provides:" side needed no change: it never excluded the linker, so glibc has been publishing "so-ver:ld-linux-*.so=<version>" all along. The comment claiming otherwise goes with the block. TestDetermineShlibVersion is parameterized over the shared library and its provider, and gains a case per architecture for the linker provided by glibc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
Melange already stamps every pkg-config "provides:" with the version of the package that ships the .pc file -- jq-dev publishes "pc:libjq=1.8.1-r4" -- but the matching "depends:" is unversioned. A -dev package that was built against "pc:openssl=4.0.2-r1" only records "pc:openssl", so it installs happily against an older openssl-dev than the headers and .pc file it was compiled with. This is the same build-time coupling that versioned shared library "depends:" exist to capture. Resolve the required pkg-config packages the way shared libraries are resolved, and emit "pc:openssl>=4.0.2-r1" alongside the unversioned depend. The resolution is identical for both -- ask PkgResolver who provides the artifact, pick the candidate that matches what is installed in the build environment, and constrain on its version only if it publishes a versioned "provides:" -- so determineShlibVersion() is generalized into determineProviderVersion(), taking a providerQuery. The two namespaces differ only in shape: a shared library resolves through "so:libz.so.1" but is versioned by "so-ver:libz.so.1", while a pkg-config package has just one namespace and "pc:openssl=<pkgver>" does both. This retires kaniini's TODO about capturing version relationships. Constraining on the version of the apk package the .pc file resolved against is a superset of the relationship the .pc file declares itself: "Requires: openssl >= 3.0" is satisfied by any openssl-dev we would have accepted, whereas ">=4.0.2-r1" pins the one we actually built against. Both kinds of versioned depend are governed by the existing gate, now versionedDepsEnabled(): they are the same coupling and roll out together, so "versioned-shlib-deps" (and MELANGE_VERSIONED_SHLIB_DEPENDS, and the legacy "no-versioned-shlib-deps") turn both on and off at once. The option names are unchanged for compatibility; the documentation says what they now cover. removeSelfProvidedDeps() has to strip the constraint before looking a dependency up, or a package whose .pc file requires another .pc file it ships itself -- openssl-dev's openssl.pc requiring libcrypto -- keeps a self-dependency that its own "provides:" already satisfies. Do that for any constraint rather than for so-ver: alone. The presubmit builds subversion with MELANGE_VERSIONED_SHLIB_DEPENDS=1 alongside jq, since subversion-dev is the one in the matrix that depends on pc:apr-1, pc:serf-1 and pc:sqlite3, and asserts that the versioned pc: depends came out. jq's libjq.pc requires nothing, so it keeps covering the so-ver: side only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@chainguard.dev>
xnox
force-pushed
the
so-ver-depends-opt-in
branch
from
September 5, 2026 19:35
ec679f9 to
b3f8166
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Melange Pull Request Template
Five commits, best reviewed one at a time:
pkg/build/package_test.go— a test showing bug fix: check for errors when computing the installed size #1 (this commit is intentionally red)pkg/build/package.go— the fix for bug fix: check for errors when computing the installed size #1versioned-shlib-depsoption so a package can opt inpkg/sca/sca.go— fix for bug build(deps): bump github.com/spf13/cobra from 1.3.0 to 1.4.0 #2, an inverted check that suppressed all cross-packageversioned depends
.github/workflows/wolfi-presubmit.yaml— buildjqwith the feature on, to demonstrate itBetween them, commits 2 and 4 are what make the feature actually produce anything. Before this PR,
turning the feature on emitted either nothing at all or unconstrained depends.
Bug #1: the constraint was stripped
removeSelfProvidedDeps()strips the>=versionfromso-ver:dependencies so it can look thedependency name up in the map of dependencies the package provides itself. It reassigns the loop
variable to do that, then appends the truncated value, so the constraint never reaches
.PKGINFO:An unversioned
so-ver:depend is satisfied by any package providing the shared library, so thegenerated dependency was a no-op. No existing test used
so-ver:dependencies, so nothing caughtit. The fix uses a separate variable for the lookup key; the filtering behaviour is unchanged.
Bug #2: versioned depends were never emitted at all
determineShlibVersion()only emits a versioned depend if the providing package publishes amatching versioned
provides:, which it checks by parsing the provided version. The predicatereturned
err != nil— so it accepted a provided version only when parsing failed, theopposite of the comment directly above it:
Every version in the wolfi index parses cleanly, so the check never matched and the function fell
through to returning an empty version. No versioned depend was generated for any library provided
by another package, even with the feature switched on — only libraries resolved via the
@CURRENT@path could get one, since that returns earlier.Commit 4 adds a test that runs
determineShlibVersion()against a synthetic APKINDEX shaped likethe real one (oniguruma 6.9.10-r4 providing both
so:libonig.so.5=5andso-ver:libonig.so.5=6.9.10-r4). It expects6.9.10-r4and fails without the fix; the companioncase, a provider with no versioned
provides:, still correctly yields no versioned depend.Demonstrating it in the wolfi presubmit
Commit 5 adds
jqto the presubmit matrix and builds it withMELANGE_VERSIONED_SHLIB_DEPENDS=1. jq links libonig, libc and libm, and all of the providingpackages publish versioned
so-ver:provides in the index, so the build should turn today'sinto the same list plus
so-ver:libc.so.6>=…,so-ver:libm.so.6>=…andso-ver:libonig.so.5>=…, withld-linux-x86-64.so.2staying unversioned by design.The variable is set for
jqonly; every other package builds exactly as before, since melangereads an empty value as unset. The bubblewrap runner invokes melange through
sudo, which resetsthe environment, so there it is passed via
env(1); the QEMU runner takes it from the stepenvironment directly. A new step prints the generated depends and fails the job if no
so-ver:depend was produced, so the demonstration cannot quietly become a no-op.
I tested that step's script both ways locally: it correctly fails against a real published
jq-1.7.1-r4.apk(built without the feature, noso-ver:depends) and passes against a.PKGINFOcarrying them.actionlintis clean.The new option
Versioned shlib
dependsare gated behind theMELANGE_VERSIONED_SHLIB_DEPENDSenvironmentvariable, which is per-build rather than per-package, and
no-versioned-shlib-depscan only turnthem off — an individual package had no way to switch them on. So:
It is a
*bool, so unset /true/falseare distinguishable. Precedence, in order: explicitoption, then the environment variable, then
no-versioned-shlib-deps.trueopts a package inwhile the feature flag is still off;
falseopts one out while the flag is on; an explicit valuealso wins over a stale
no-versioned-shlib-deps: truein the same file rather than the twosilently cancelling out.
While documenting it I corrected two errors in the
no-versioned-shlib-depsdocs: versioneddepends are off by default, not on, and the option governs the shared libraries a package links
against, not the ones it ships — the versioned
so-ver:providesare emitted regardless.Functional Changes
Notes: not run. Both changes are inert unless versioned shlib depends are turned on:
determineShlibVersion()returns early otherwise, so noso-ver:depend is generated andremoveSelfProvidedDeps()has nothing new to preserve.MELANGE_VERSIONED_SHLIB_DEPENDSappearsin 0 files across
stereoandenterprise-packages, and no package YAML there usesno-versioned-shlib-deps, so nothing changes for those builds until someone opts in. Theso-ver:provides side is untouched by this PR.Reviewers: if the flag is set anywhere I did not check, commit 2 makes those builds start
emitting real
so-ver:...>=constraints where they previously emitted unconstrained ones. That isthe intended effect, but it is a genuine change in generated metadata and worth confirming against
an APKINDEX before enabling it broadly.
SCA Changes
Notes: not done — verified at the unit level instead. Each commit was checked in a detached
worktree, since an uncommitted fix in the working tree would have made commit 1 look green:
commit 1 fails exactly as documented (
so-ver:libc.so.6vsso-ver:libc.so.6>=2.42-r0),commit 2 turns
pkg/buildgreen, and the tip passesgo build ./...plus all of./pkg/...and
./internal/....New tests:
TestVersionedShlibDepsEnabledcovers the whole precedence matrix (7 cases);Test_versionedShlibDepsOptionround-trips the option's tri-state throughParseConfiguration;Test_removeSelfProvidedDeps_KeepsSoVerConstraintguards the constraint.The APK-level check now happens in CI: commit 5 builds
jqwith the feature on and asserts thatso-ver:depends appear in the built package, on both the bubblewrap and QEMU runners. That isthe real end-to-end signal for this PR — if the presubmit
jqjob goes green withso-ver:linesin its output, the feature works on a real package; if the build fails resolving them, that is
worth knowing before anyone enables it more broadly.
Linter
Notes: N/A, no new linter check.
Note on the schemas
pkg/config/schema.jsonandschema.cuecarry only the newversioned-shlib-depsblock, copiedverbatim from
go generate ./internal/gen-schema(verified byte-identical). Committing the fullregeneration would have added ~145 lines of unrelated churn: the checked-in
schema.cuepredatescue v0.17's comment rewrapping, and
schema.jsonis stale against current apko (format,runtime_keyring,RuntimeKeyringEntryare missing). That drift is pre-existing and not enforcedby CI, and probably deserves its own regeneration commit.
Known limitation
Subpackages do not inherit the origin package's options —
pkgFromSub()passessub.Optionsthrough verbatim and
Options()returns a zeroPackageOption{}when it is nil — soversioned-shlib-depshas to be repeated on each subpackage that needs it. I left that alonedeliberately: making it inherit either changes inheritance for
no-provides/no-depends/no-commandstoo, or makes exactly one option inherit. If you want the narrow version, thecontainable spot is
SCABuildInterface.Options(), which can already see the origin's options.🤖 Generated with Claude Code