Case 143: Accidental Export (Single-Release Audit)¶
| Field | Value |
|---|---|
| Verdict | 🟢 COMPATIBLE |
| Category | Quality (Compatible) |
| Classification | Rule · audit |
| Platforms | Linux |
| Flags | Bad practice |
Detected ChangeKinds |
exported_not_public |
| Source files | catalog/cases/case143_audit_accidental_export/ |
| Rule family | audit-accidental-export |
| Subject | Export/declaration mismatches |
Category: Quality (Audit) | Verdict: 🟢 COMPATIBLE (bad practice)
Verdict and consumer impact¶
This is a single-release audit (the "G20" corpus): there is no v1/v2
pair to diff, just one build's evidence checked against itself. abicheck
reports no verdict at all ("verdict": null): a single build
has nothing to be compatible with, so the audit answers what is present,
not whether something broke. (The catalog's own 🟢 COMPATIBLE classification
above is a statement about the case, not about the command's output: nothing
here is a break today.) The audit flags an advisory ABI-hygiene finding:
debug_dump() ships with default ELF visibility (so it's in the dynamic
symbol table, and any consumer can dlsym or link against it) yet it is never
declared in a public header. Whoever maintains this library believes
debug_dump() is private and free to change or remove at will; in reality
it's already load-bearing ABI for anyone who found it in nm -D libdemo.so.
The fix belongs in this release, not after a consumer files a breakage
report against a "private" function.
What this snapshot contains¶
snapshot.abi.json is a single, hand-built AbiSnapshot (via
scripts/gen_g20_fixtures.py) representing one build of libdemo.so, with
both its ELF export table and its public-header AST baked in:
| Source in the snapshot | What it records |
|---|---|
Binary export table (L0, elf.symbols) |
_Z6renderv (render), _Z11debug_dumpv (debug_dump) — both exported with default visibility |
Public-header AST (L2, functions[].origin) |
render has origin: public_header; debug_dump has origin: export_only — it was found in the binary but never declared in any header abicheck was pointed at |
abicheck command¶
compare --no-baseline, not scan
0.6 makes this the declared spelling for a single-build audit, and
retires scan. This case was blocked on that migration until
2026-09-09; the audit now reports the finding below directly, and
tests/parity/test_no_baseline_audit_corpus_parity.py pins that it
reports at least every check scan does, counted per finding kind,
while manufacturing no comparison of its own (no verdict, no
changes[] entry).
Expected abicheck finding¶
# ABI audit: libdemo.so (no baseline)
OLD side: **declared absent** (`--no-baseline`) -- this is an audit of the candidate build alone, not a compatibility comparison. No additions, removals, or compatibility verdict are reported.
- Candidate version: `1.0`
- Acquisition state (OLD): `declared_absent`
- Evidence tiers: elf, header
## Candidate-side findings
| Finding | Symbol | Severity | State | Detail |
| --- | --- | --- | --- | --- |
| `exported_not_public` | `_Z11debug_dumpv` | potential_breaking | present in this build | Symbol '_Z11debug_dumpv' is exported by the binary but declared in no public header (declared as function 'debug_dump' in a non-public header). It is accidental ABI surface — hide it (visibility/version script) or document it. |
Minimum evidence¶
min_evidence: L2 — the binary export table alone (L0) cannot distinguish an
intentional export from an accidental one; the public-header AST (L2) is what
supplies the "documented API" side of the comparison. Only the combination of
the two lets abicheck tell them apart.
Why abicheck catches it¶
exported_not_public is a cross-source check, not a plain diff: abicheck
reconciles the ELF dynamic-symbol table against the set of declarations
reachable from the public headers it was pointed at (binary_exports and
public_header_ast, per provider_assertions). A symbol present in one set
and absent from the other is what makes the finding — neither source alone
can see it, since the export table has no notion of "public" and the header
AST has no notion of "actually shipped in this binary."
Why this matters for a real release¶
An accidental export looks free to change because nothing declares it —
until a downstream consumer discovers it via nm/dlsym, starts depending
on it, and a later "just delete this internal helper" cleanup turns into a
real func_removed break for that consumer. Catching it at audit time, on
the release that introduced it, is strictly cheaper than catching it after
someone downstream has already linked against it.
Safe redesign¶
Either mark the symbol hidden so it never reaches the export table
(__attribute__((visibility("hidden"))), or add it to the version script's
local: block), or, if it's genuinely meant to be public, declare it in an
installed header so the audit no longer flags it and the contract is
explicit.
Cross-tool comparison¶
exported_not_public is a cross-source check unique to abicheck's audit
mode — it reconciles two artifacts of the same build (export table vs.
public-header AST) rather than diffing two releases, which is outside what
abidiff/abi-compliance-checker do (they compare two ABI dumps against
each other, not a binary against its own headers).
Source files¶
snapshot.abi.json
See also: Compatibility Catalog · All COMPATIBLE cases · Category: Quality (Compatible) · Rule: Exported symbol is not public API · Subject: Export/declaration mismatches.