Case 183: Internal ELF symbol-version node churn¶
| Field | Value |
|---|---|
| Verdict | ๐ก COMPATIBLE_WITH_RISK |
| Category | Risk |
| Platforms | Linux |
| Flags | โ |
Detected ChangeKinds |
symbol_version_node_removed |
| Source files | examples/case183_internal_version_node_churn/ |
Category: Risk | Verdict: โ ๏ธ COMPATIBLE_WITH_RISK
Verdict and consumer impact¶
foo_worker() is a private helper, versioned onto FOO_INTERNAL_1 โ a
version node whose name itself is the author's machine-readable declaration
"this is private, do not link against it directly" (the same convention as
glibc's GLIBC_PRIVATE or nettle's NETTLE_INTERNAL_* / HOGWEED_INTERNAL_*
nodes). public_api(), on the stable FOO_1.0 node, is unchanged. In v2,
foo_worker() and the entire FOO_INTERNAL_1 node are removed โ its logic
was inlined into public_api(). Any consumer that only links the public API
(the supported way to use this library) is unaffected; the risk is confined
to anyone who reached past the version boundary and linked the internal node
directly.
Old/new diff¶
| old/lib.c + old/version.map | new/lib.c + new/version.map |
|---|---|
int foo_worker(int x) { return x * 2; } on FOO_INTERNAL_1 |
(removed, inlined below) |
int public_api(int x) { return foo_worker(x) + 1; } on FOO_1.0 |
int public_api(int x) { return x * 2 + 1; } on FOO_1.0 |
abicheck command¶
gcc -shared -fPIC -g old/lib.c -o libfoo_v1.so -Wl,--version-script=old/version.map
gcc -shared -fPIC -g new/lib.c -o libfoo_v2.so -Wl,--version-script=new/version.map
abicheck compare libfoo_v1.so libfoo_v2.so
Expected abicheck finding¶
Verdict: COMPATIBLE_WITH_RISK (exit 0)
Deployment Risk Changes:
- func_removed: Public function removed: foo_worker
- symbol_version_node_removed: Version node FOO_INTERNAL_1 was entirely
removed from the version script. Symbols previously under this node:
foo_worker. Applications linked against FOO_INTERNAL_1 will get
unresolved symbol errors.
A raw symbol-table diff sees exactly what it would see for any other removed
exported function: a binding disappears. Naively that's func_removed โ
BREAKING. abicheck/diff_versioning.py's is_internal_version_node() and
demote_internal_version_node_findings() recognize that the version node
name is evidence the author never considered foo_worker@@FOO_INTERNAL_1
part of the supported ABI, and downgrade the finding to
COMPATIBLE_WITH_RISK โ conservative in one direction only: a symbol
promoted from a public node to a private one is not demoted, since that
still breaks old consumers who linked the public version.
Minimum evidence¶
min_evidence: L0 โ the ELF .gnu.version_d/.gnu.version sections plus
.dynsym bindings carry both the removed symbol and the internal-looking
version-node name; no debug info or public headers needed to make this call.
Why abicheck catches it¶
abicheck parses the version-definition section directly from each binary's
symbol table, so it sees not just that foo_worker disappeared but
which version node it was bound to in both versions. Matching that node
name against the PRIVATE/INTERNAL naming convention is what lets the
detector distinguish "an internal implementation detail moved" from "a
public API contract broke," entirely from L0 evidence.
Runtime failure demonstration¶
Severity: LOW โ no observable effect for consumers of the public API.
# Build old library + app (app only calls public_api)
gcc -shared -fPIC -g old/lib.c -o libfoo.so -Wl,--version-script=old/version.map
gcc -g app.c -L. -lfoo -Wl,-rpath,. -o app
./app
# โ public_api(5) -> 11
# Swap in new library (no recompile)
gcc -shared -fPIC -g new/lib.c -o libfoo.so -Wl,--version-script=new/version.map
./app
# โ public_api(5) -> 11 (identical โ app never linked foo_worker)
The risk only materializes for a consumer that explicitly linked
foo_worker@FOO_INTERNAL_1 โ an unsupported use in the first place โ which
would now fail with undefined symbol: foo_worker.
Safe redesign¶
If a helper genuinely needs to stay linkable across versions, put it on a
public, stable version node instead of an *_INTERNAL_*/*_PRIVATE_* one.
If it's truly private, this churn (removing/inlining it) is exactly the
safe, expected evolution the internal-node convention exists to allow.
Real-world example: this exact rule was added after a real nettle 3.6 โ
3.7 upgrade, where treating HOGWEED_INTERNAL_*/NETTLE_INTERNAL_* symbol
churn as a hard break raised abicheck's live tool-agreement score for that
release from 67% to 100%. glibc's own GLIBC_PRIVATE node follows the same
convention: symbols there are removed/changed freely across releases because
nothing outside glibc itself is expected to link against them.
Cross-tool comparison¶
A negative twin exists in the test suite:
tests/test_internal_version_node_scope.py::test_compare_public_versioned_symbol_removal_stays_breaking
verifies that the same removal on the public FOO_1.0 node โ instead of
FOO_INTERNAL_1 โ stays BREAKING; the demotion only fires when every
old-side binding for the symbol is confined to an internal/private node.
Source files¶
CMakeLists.txtapp.c
See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.