Skip to content

Case 146: RTTI Exported for an Internal Type (Single-Release Audit)

Field Value
Verdict 🟢 COMPATIBLE
Category Quality (Compatible)
Classification Rule · audit
Platforms Linux
Flags Bad practice
Detected ChangeKinds rtti_for_internal_type
Source files catalog/cases/case146_audit_rtti_for_internal/
Rule family audit-rtti-for-internal
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 audit flags an advisory finding: _ZTI12InternalNode/_ZTV12InternalNode (the C++ typeinfo and vtable for InternalNode) are both exported from the dynamic symbol table, but InternalNode is declared only in a private header — render() is the sole function the public headers actually declare. Consumers can't name InternalNode in their own code, so they can't be handed one directly, but the exported RTTI still lets any code that obtains an InternalNode* polymorphically (e.g. via a base-class pointer returned from elsewhere in the library) run dynamic_cast/typeid against it. That couples consumer binaries to an internal class's identity without the maintainer ever intending InternalNode to be part of the API.

What this snapshot contains

snapshot.abi.json is a single, hand-built AbiSnapshot for one build of libdemo.so, with the export table and the header provenance of the type its RTTI belongs to:

Source in the snapshot What it records
Binary export table (L0, elf.symbols) _Z6renderv (render), _ZTI12InternalNode, _ZTV12InternalNode — three exported symbols
Public-header AST (L2, functions/types) only render() is declared in a public header; the InternalNode struct entry carries origin: private_header

abicheck command

abicheck compare --no-baseline snapshot.abi.json

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 |
| --- | --- | --- | --- | --- |
| `rtti_for_internal_type` | `_ZTI12InternalNode` | potential_breaking | present in this build | Symbol '_ZTI12InternalNode' exports run-time type information for type 'InternalNode', which is declared only in a private (non-installed) header. Its typeinfo leaks onto the ABI surface though consumers cannot name the type — hide the type or stop exporting its RTTI. |
| `rtti_for_internal_type` | `_ZTV12InternalNode` | potential_breaking | present in this build | Symbol '_ZTV12InternalNode' exports run-time type information for type 'InternalNode', which is declared only in a private (non-installed) header. Its typeinfo leaks onto the ABI surface though consumers cannot name the type — hide the type or stop exporting its RTTI. |

Minimum evidence

min_evidence: L2 — the export table alone (L0) sees _ZTI/_ZTV symbols but has no way to know which class they belong to is public vs. private; the public-header AST (L2) is what supplies the class's provenance, which the check demangles the RTTI symbol name against.

Why abicheck catches it

rtti_for_internal_type is a cross-source check: abicheck demangles every exported _ZTI*/_ZTV* symbol back to its class name, then looks up that class's provenance in the public-header AST (binary_exports and public_header_ast, per provider_assertions). RTTI mapped to a class whose provenance is private_header is exactly this finding — a plain export-table read has no notion of which class is public, and a plain header parse never sees the compiled RTTI symbols at all.

Why this matters for a real release

InternalNode's layout, base classes, and existence are all considered free to change since nothing declares it publicly — but as long as its RTTI is exported, any consumer binary that gets a polymorphic pointer into the library's object graph can dynamic_cast against InternalNode and observe identity/layout changes as a crash or silent misbehavior, not a compile error. Because RTTI symbols are emitted per translation unit that uses the class polymorphically, this is easy to introduce by accident (any TU that dynamic_casts or throws the type emits it) and easy to miss in review.

Safe redesign

Give InternalNode a key function defined in exactly one, internal translation unit so its vtable/RTTI aren't emitted (and, ideally, __attribute__((visibility("hidden"))) on the class) — or, if InternalNode is genuinely meant to be part of the public API, move its declaration into an installed header so the finding reflects an intentional decision instead of an accident.

Cross-tool comparison

rtti_for_internal_type is a cross-source check unique to abicheck's audit mode — it reconciles exported RTTI symbols against header provenance 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 exports against its own header provenance).


Source files

  • snapshot.abi.json

See also: Compatibility Catalog · All COMPATIBLE cases · Category: Quality (Compatible) · Rule: RTTI emitted for an internal type · Subject: Export/declaration mismatches.