Case 144: Private Header Leak (Single-Release Audit)¶
| Field | Value |
|---|---|
| Verdict | 🟢 COMPATIBLE |
| Category | Quality (Compatible) |
| Classification | Rule · audit |
| Platforms | Linux |
| Flags | Bad practice |
Detected ChangeKinds |
private_header_leak |
| Source files | catalog/cases/case144_audit_private_header_leak/ |
| Rule family | audit-private-header-leak |
| Subject | Export/declaration mismatches |
Category: Quality (Audit) | Verdict: 🟢 COMPATIBLE (bad practice)
Verdict and consumer impact¶
Single-release audit: one build's evidence checked against itself, no
baseline. abicheck reports no verdict at all ("verdict": null): a single build has nothing to be compatible with. (The catalog's 🟢
COMPATIBLE classification above describes the case, not the command's output:
the ABI hasn't broken.) But the audit flags an advisory finding: the public
function make_widget() returns detail::WidgetImpl*, and
detail::WidgetImpl is a type defined only in a private, non-installed
header. Consumers linking against the public headers cannot legally name
detail::WidgetImpl (they'd have to reach into a header the library never
installs), yet the pointer type is already part of the exported signature. The
maintainer never reasoned about this as public API surface, but it already is
one — the day the private header's layout changes, every caller holding a
detail::WidgetImpl* is silently exposed to a layout mismatch with no
compiler warning to catch it.
What this snapshot contains¶
snapshot.abi.json is a single, hand-built AbiSnapshot for one build of
libdemo.so, carrying both the exported function's signature and the
provenance of the type it returns:
| Source in the snapshot | What it records |
|---|---|
Binary export table (L0, elf.symbols) |
_Z11make_widgetv (make_widget) exported with default visibility |
Public-header AST (L2, functions[].return_type / types[].origin) |
make_widget returns detail::WidgetImpl *; the detail::WidgetImpl struct entry carries origin: private_header |
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 |
| --- | --- | --- | --- | --- |
| `private_header_leak` | `_Z11make_widgetv` | potential_breaking | present in this build | Public API 'make_widget' exposes type 'detail::WidgetImpl', which is declared only in a private (non-installed) header. Consumers including the public header pull in an unshipped declaration. Make the header self-contained or install the leaked header. |
Minimum evidence¶
min_evidence: L2 — the export table alone (L0) sees only that
make_widget is exported; it carries no type information at all. The
public-header AST (L2) is what supplies both the return type and that
type's provenance (public vs. private header), which is the fact the
cross-check needs.
Why abicheck catches it¶
private_header_leak is a cross-source check: abicheck walks the public
API surface reachable from the headers it was pointed at, resolves every
type referenced in a public signature, and checks that type's own
provenance. A public declaration (public_header_ast) that resolves to a
type whose provenance is private_header is exactly this finding — a single
source can't produce it, since the export table has no type information and
a plain header parse of the public header alone wouldn't know the returned
type actually lives in a different, non-installed header.
Why this matters for a real release¶
detail::WidgetImpl is undocumented, so the maintainer can freely resize or
reorder its fields believing nothing external depends on the layout. But
consumers holding a detail::WidgetImpl* from make_widget() — even if
they never dereference it and only pass it back into the library — still
depend on sizeof/alignment assumptions baked in wherever the pointer is
stored or copied. Catching the leak now, before a consumer builds tooling
around the returned pointer, is cheaper than treating a later private-header
change as a compatibility incident.
Safe redesign¶
Either install the header defining detail::WidgetImpl (promoting it to
real public API, with the compatibility obligations that implies), or hide
it behind an opaque handle — return void* or a forward-declared incomplete
type, and only operate on it through library-provided functions (PIMPL).
Cross-tool comparison¶
private_header_leak is a cross-source check unique to abicheck's audit
mode — it reconciles a public function's signature against the provenance of
the type it references within the same build, which isn't something
abidiff/abi-compliance-checker do (they diff two ABI dumps against each
other, not a binary's public surface against its own header provenance).
Source files¶
snapshot.abi.json
See also: Compatibility Catalog · All COMPATIBLE cases · Category: Quality (Compatible) · Rule: Private header leaks into the public surface · Subject: Export/declaration mismatches.