Skip to content

SCA: Fix so-ver: depends losing their constraint, and allow packages to opt in - #2634

Draft
xnox wants to merge 7 commits into
chainguard-dev:mainfrom
xnox:so-ver-depends-opt-in
Draft

SCA: Fix so-ver: depends losing their constraint, and allow packages to opt in#2634
xnox wants to merge 7 commits into
chainguard-dev:mainfrom
xnox:so-ver-depends-opt-in

Conversation

@xnox

@xnox xnox commented Aug 31, 2026

Copy link
Copy Markdown
Member

Melange Pull Request Template

Five commits, best reviewed one at a time:

  1. pkg/build/package_test.go — a test showing bug fix: check for errors when computing the installed size #1 (this commit is intentionally red)
  2. pkg/build/package.go — the fix for bug fix: check for errors when computing the installed size #1
  3. config / sca / docs / schemas — a new versioned-shlib-deps option so a package can opt in
  4. pkg/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-package
    versioned depends
  5. .github/workflows/wolfi-presubmit.yaml — build jq with the feature on, to demonstrate it

Between 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 >=version from so-ver: dependencies so it can look the
dependency 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:

in:  so:libc.so.6, so-ver:libc.so.6>=2.42-r0, so:libz.so.1, so-ver:libz.so.1>=1.3.1-r6
out: so:libc.so.6, so-ver:libc.so.6,          so:libz.so.1, so-ver:libz.so.1

An unversioned so-ver: depend is satisfied by any package providing the shared library, so the
generated dependency was a no-op. No existing test used so-ver: dependencies, so nothing caught
it. 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 a
matching versioned provides:, which it checks by parsing the provided version. The predicate
returned err != nil — so it accepted a provided version only when parsing failed, the
opposite of the comment directly above it:

_, err := apk.ParseVersion(providedVersion)
// If we're able to parse the version,
// then it's a valid one.
return err != nil       // ← accepts only unparseable versions

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 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 the fix; the companion
case, a provider with no versioned provides:, still correctly yields no versioned depend.

Demonstrating it in the wolfi presubmit

Commit 5 adds jq to the presubmit matrix and builds it with
MELANGE_VERSIONED_SHLIB_DEPENDS=1. jq links libonig, libc and libm, and all of the providing
packages publish versioned so-ver: provides in the index, so the build should turn today's

depend = so:ld-linux-x86-64.so.2
depend = so:libc.so.6
depend = so:libm.so.6
depend = so:libonig.so.5

into the same list plus so-ver:libc.so.6>=…, so-ver:libm.so.6>=… and
so-ver:libonig.so.5>=…, with ld-linux-x86-64.so.2 staying unversioned by design.

The variable is set for jq only; every other package builds exactly as before, since melange
reads an empty value as unset. The bubblewrap runner invokes melange through sudo, which resets
the environment, so there it is passed via env(1); the QEMU runner takes it from the step
environment 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, no so-ver: depends) and passes against a
.PKGINFO carrying them. actionlint is clean.

The new option

Versioned shlib depends are gated behind the MELANGE_VERSIONED_SHLIB_DEPENDS environment
variable, which is per-build rather than per-package, and no-versioned-shlib-deps can only turn
them off — an individual package had no way to switch them on. So:

options:
  versioned-shlib-deps: true

It is a *bool, so unset / true / false are distinguishable. Precedence, in order: explicit
option, then the environment variable, then 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.

While documenting it I corrected two errors in the no-versioned-shlib-deps docs: 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.

Functional Changes

  • This change can build all of Wolfi without errors (describe results in notes)

Notes: not run. Both changes are inert unless versioned shlib depends are turned on:
determineShlibVersion() returns early otherwise, so no so-ver: depend is generated and
removeSelfProvidedDeps() has nothing new to preserve. MELANGE_VERSIONED_SHLIB_DEPENDS appears
in 0 files across stereo and enterprise-packages, and no package YAML there uses
no-versioned-shlib-deps, so nothing changes for those builds until someone opts in. The
so-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 is
the intended effect, but it is a genuine change in generated metadata and worth confirming against
an APKINDEX before enabling it broadly.

SCA Changes

  • Examining several representative APKs show no regression / the desired effect (details in notes)

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.6 vs so-ver:libc.so.6>=2.42-r0),
commit 2 turns pkg/build green, and the tip passes go build ./... plus all of ./pkg/...
and ./internal/....

New tests: TestVersionedShlibDepsEnabled covers the whole precedence matrix (7 cases);
Test_versionedShlibDepsOption round-trips the option's tri-state through
ParseConfiguration; Test_removeSelfProvidedDeps_KeepsSoVerConstraint guards the constraint.

The APK-level check now happens in CI: commit 5 builds jq with the feature on and asserts that
so-ver: depends appear in the built package, on both the bubblewrap and QEMU runners. That is
the real end-to-end signal for this PR — if the presubmit jq job goes green with so-ver: lines
in 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

  • The new check is clean across Wolfi
  • The new check is opt-in or a warning

Notes: N/A, no new linter check.

Note on the schemas

pkg/config/schema.json and schema.cue carry only the new versioned-shlib-deps block, copied
verbatim from go generate ./internal/gen-schema (verified byte-identical). Committing the full
regeneration would have added ~145 lines of unrelated churn: the checked-in schema.cue predates
cue v0.17's comment rewrapping, and schema.json is stale against current apko (format,
runtime_keyring, RuntimeKeyringEntry are missing). That drift is pre-existing and not enforced
by CI, and probably deserves its own regeneration commit.

Known limitation

Subpackages do not inherit the origin package's options — pkgFromSub() passes sub.Options
through verbatim and Options() returns a zero PackageOption{} when it is nil — so
versioned-shlib-deps has to be repeated on each subpackage that needs it. I left that alone
deliberately: making it inherit either changes inheritance for no-provides / no-depends /
no-commands too, or makes exactly one option inherit. If you want the narrow version, the
containable spot is SCABuildInterface.Options(), which can already see the origin's options.

🤖 Generated with Claude Code

@xnox
xnox requested review from EyeCantCU, a-crate and sergiodj August 31, 2026 13:17
@xnox
xnox marked this pull request as draft August 31, 2026 13:43
@xnox
xnox force-pushed the so-ver-depends-opt-in branch from d7e4c0f to ec679f9 Compare September 1, 2026 15:08

@a-crate a-crate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly LGTM

with ld-linux-x86-64.so.2 staying unversioned by design

I'm not sure about this.

  1. We generate so-ver provides for this today. 🔗 This contradicts this comment.
  2. 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.
  3. Tests should assert this, and I think they will catch the bug from point 1.

@xnox

xnox commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Mostly LGTM

with ld-linux-x86-64.so.2 staying unversioned by design

I'm not sure about this.

  1. We generate so-ver provides for this today. 🔗 This contradicts this comment.
  2. 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.
  3. Tests should assert this, and I think they will catch the bug from point 1.

agree!

xnox and others added 7 commits September 5, 2026 19:15
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
xnox force-pushed the so-ver-depends-opt-in branch from ec679f9 to b3f8166 Compare September 5, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants