Skip to content

test/cbmc: expand CBMC coverage (RFC 0025) - #263

Draft
gburd wants to merge 15 commits into
masterfrom
test/cbmc-expand
Draft

gburd wants to merge 15 commits into
masterfrom
test/cbmc-expand

Conversation

@gburd

@gburd gburd commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

RFC 0025 — expand CBMC coverage

Branch for the CBMC coverage expansion (RFC 0025). This PR is a draft while the
phases land; each harness verifies on the real engine code and carries a
sabotage arm that must fail.

C1 (election + ack-policy decisions) — done

Four new harnesses over the replication election and the repmgr ack-policy
decisions. Each runs the real code (verbatim static copies checked by
check_copies.sh, or a faithful transcription with the source lines named
where the function is not a clean boundary), verifies with CBMC 6.9.0, and has
a -D..._SABOTAGE arm that plants a realistic bug and must fail.

Harness Target Proves
harness_cmpvote.c __rep_cmp_vote (rep_elect.c) a priority-0 non-electable vote never wins; a non-zero-priority vote present => winner has non-zero priority; same-data_gen like-priority votes are ordered by (LSN, priority, tiebreaker) and order-independent
harness_tally.c __rep_tally (rep_elect.c) each eid counted once; egen rules (ignore/update-in-place) with no count change; *countp == distinct eids, within capacity; no OOB
harness_majority.c ELECTION_MAJORITY + sub-majority warning any two majorities intersect (2*maj > n, also constructively); ack <= nsites/2 == ack < majority
harness_ackpolicy.c got_acks + __repmgr_send needed/quorum QUORUM "no election can lose it"; the 2-site strict/non-strict cases; ALL_PEERS / ONE_PEER durability; the send shortcuts are sound

Whole suite (these + the prior six) runs in ~45 s.

FINDING (documented, not a safety bug): __rep_cmp_vote is order-dependent

harness_cmpvote.c property (a), order independence, fails on the real,
unmodified code
. Two sites tallying the same VOTE1 set in different arrival
orders can pick different phase-1 winners, for two reasons: the first vote gets
a special "becomes winner outright" path (sites==1), and the comparator's
last arm (equal LSN => priority/tiebreaker) is not gated by data_gen
equality, so the relation is not even a consistent order (its comment claims a
data_gen, LSN, priority, tiebreaker total order it does not implement).

This is not a safety bug: the VOTE2 phase requires a majority
(I_HAVE_WON: votes >= nvotes) to elect, and two majorities of one group
intersect (proved in harness_majority.c), so there is no split brain. The
worst case is a rare failed-to-settle election round — a liveness cost. The
__rep_cmp_vote in lts/2026.10.7 is byte-identical, so a fix would warrant
an LTS backport. Full write-up and both CBMC counterexamples in
test/cbmc/README.md under FINDING. Flagged to the lead; no engine change
made on this branch (per the workstream rules).

Later phases (C2/C3) land in follow-up commits on this branch.

gburd and others added 6 commits November 4, 2026 09:50
The CBMC harnesses in test/cbmc cover six small functions, and their CI
workflow was never enabled.  This plans the next targets, ranked by what
a wrong answer costs:

- the replication election decisions (winner selection, tally, majority
  intersection) and the repmgr ack-policy durability decision;
- btree prefix decompression, which reads a varint before checking it
  has the bytes;
- queue record arithmetic across wrap-around;
- the log header check and the region allocator's size classes.

Each harness runs the real code, has a sabotage build that must fail,
and a verbatim copy carries a drift check against src/.
…025 C0)

The harnesses never ran in CI: the workflow was staged as
test/cbmc/cbmc.yml.workflow and never copied into .github/workflows.  It
is now .github/workflows/cbmc.yml, and runs on every PR and master push.
It has no paths filter, since a filter listing files skips changes to
files nobody remembered to add.

The okitem harness had drifted from the engine.  It carried a private,
unparenthesised copy of HPAGE_PTYPE, and run.sh's okitem(REAL) arm
expected verification to FAIL, so it kept "reproducing" the bug for
weeks after 2ca8126 fixed the real macro.  The harness now uses the
real macro and verifies.  -DHPAGE_PTYPE_SABOTAGE restores the old macro
as a sabotage arm, and that build must fail.

check_copies.sh guards the other side of the same problem.  A harness
that copies a static function proves a property of the copy, so every
"BEGIN verbatim copy of <func> from <src>" block must still match <src>,
comparing code tokens and ignoring comments.  A one-token change to the
__dd_find copy makes it fail.  run.sh runs it.
Verifies the real, verbatim __rep_cmp_vote (src/rep/rep_elect.c:1101) over 3
nondeterministic votes, with rep->sites advanced the way __rep_tally does.

Properties proved (harness, harness_likepri):
  (b) a priority-0 non-REPCTL_ELECTABLE vote never wins;
  (c) if any non-zero-priority vote is present, the winner has non-zero prio;
  (d) among like-priority votes at the SAME data_gen, the winner maximises
      (LSN, priority, tiebreaker) and is order-independent.
Sabotage arm -DCMPVOTE_SABOTAGE swaps LOG_COMPARE's order arms and fails (d).

FINDING (documented in README, not fixed here): property (a) order
independence does NOT hold. Two sites tallying the same VOTE1 set in different
orders can pick different phase-1 winners, for two reasons: the first vote gets
a special "becomes winner outright" path (sites==1), and the comparator's last
arm (equal LSN => priority/tiebreaker) is not gated by data_gen equality, so
the relation is not even a consistent order. This is NOT a safety bug: the
VOTE2 majority requirement (I_HAVE_WON: votes >= nvotes) means a candidate is
elected only if a majority independently chose it, and two majorities of one
group intersect (H3). Worst case is a rare failed-to-settle election round, a
liveness cost, not split brain. lts/2026.10.7 has identical code. Flagged to
the lead for a possible follow-up (make the comparator a total order, or fix
the comment) + LTS backport.

No engine change (test/cbmc-expand rules). CBMC 6.9.0, ~2 s per arm.
Verifies the real, verbatim __rep_tally (src/rep/rep_elect.c) over sequences of
up to 6 (eid, egen) votes into a region tally array of capacity 5 (R_ADDR and
__rep_grow_sites stubbed; asites==nsites==CAP so the no-grow path is taken and
grow_sites is asserted unreachable).

Properties proved:
  - no OOB on the tally array (reads tally[i], write tally[*countp]);
  - each eid is counted at most once (no two live slots share an eid);
  - a lower/equal egen for a seen eid returns DB_REP_IGNORE with no count
    change; a higher egen updates in place with no count change;
  - *countp equals the number of distinct eids and never exceeds capacity.

Honest bound model per the brief: the only capacity guard is the entry test
nsites > asites. With distinct eids <= CAP == asites, *countp cannot reach
asites without a grow, so tally[CAP] is never written -- the "indexed at
*countp == asites" hazard does not occur.

Sabotage -DTALLY_SABOTAGE turns the "already seen this eid" test from == to !=
(a double-count bug); it fails hard -- OOB write past the array plus every
count invariant broken.

No engine change. CBMC 6.9.0, ~1 s.
Proves the two facts the one-master-per-generation safety argument rests on,
over the real ELECTION_MAJORITY macro (verbatim from src/dbinc/rep.h:534):

  (1) 2*ELECTION_MAJORITY(n) > n for all nsites in [1,64], so any two
      majorities of the same group intersect -- also proved constructively:
      two nondeterministic subsets of N=8 sites each of size >= majority must
      share a member (CBMC finds no disjoint pair).
  (2) the sub-majority warning test in __rep_elect_int, `ack <= nsites/2`, is
      exactly equivalent to `ack < ELECTION_MAJORITY(nsites)` for all nsites
      and all ack in [0,64].

Sabotage -DMAJORITY_SABOTAGE uses n/2 (floor) for the majority; both arms fail
(disjoint even-n majorities appear, warning equivalence breaks).

Pure arithmetic, no engine struct. CBMC 6.9.0, ~1 s each.
Models got_acks (src/repmgr/repmgr_net.c:1117) and the needed/quorum
computation in __repmgr_send (the DB_REP_PERMANENT block). got_acks iterates
db_rep->sites and dereferences REPMGR_SITE, so its decision logic (the
accumulate loop + the switch (policy)) is transcribed faithfully into
got_acks_model over the exact per-site facts it reads (SITE_PRESENT,
SITE_HAS_PRIO, SITE_ELECTABLE, max_ack>=lsn), with the source lines named
inline; the needed/quorum block is copied into compute_needed_quorum, likewise
annotated. This is not a function boundary, so a verbatim #include/copy is not
possible; the transcription is marked per the brief.

Properties proved over all group shapes of <= 4 remote sites (+ master):
  - QUORUM "no election can lose it": durable => every electable-site majority
    (ELECTION_MAJORITY over master + electable peers) contains a holder (the
    master or an acked electable peer). The "every majority" quantifier is
    discharged by CBMC over a nondeterministic membership bitmap.
  - 2-site: STRICT => quorum 0 (master-only durable, the client won't
    self-elect); non-strict => the lone client must ack. Stated separately.
  - ALL_PEERS: durable => every electable peer acked (and no unknown-prio
    present site).
  - ONE_PEER: durable => some electable peer acked.
  - Shortcuts: needed==0 is durability-safe (reachable only when no electable
    peers exist, so the master is the whole majority); available<needed refuses
    and got_acks would also refuse.

Sabotage -DACKPOLICY_SABOTAGE counts non-electable acks as peers, inflating
peers_acked; QUORUM and ONE_PEER contracts then fail.

No engine change, no finding. CBMC 6.9.0; full suite (all H1-H4 + prior) ~45 s.
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (3) -- update dist/cocci/baseline.txt to lock these in.
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/btree/bt_curadj.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/crypto/mersenne/mt19937db.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/mp/mp_register.c|return (ret);

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

ABI diff vs v2026.10.8 (libabigail — authoritative)

abidiff exit bitmask: 0

Removed exported symbols (nm -D, _NNNN version suffix normalized)

None.


Advisory: libabigail/nm is the authoritative binary-ABI check; Coccinelle is complementary source-level early warning. See dist/cocci/README.md.

@gburd

gburd commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Lead on the H1 finding: Counterexample 2 may be a durability bug, not only liveness.

Arm D, cmp == 0 && (priority > w_priority || (priority == w_priority && tiebreaker > w_tiebreaker)), ignores data_gen. With equal LSNs, a vote with an older data generation replaces a winner with a newer one. Arm B does the reverse. So with two candidates the phase-1 winner is whichever one a site tallies last.

A history that could reach it, which is not yet constructed:

  1. 3 sites, QUORUM. The gen-3 master C commits T; site A acks it, so T is reported durable. A's log now ends at LSN X in gen 3.
  2. B is the old gen-2 master. It was partitioned and its log also ends at X, but with gen-2 records.
  3. C crashes. A and B hold an election and form the majority.
  4. Any site that tallies B's VOTE1 last picks B. Arm D fires when B's tiebreaker is higher. If both A and B pick B, B wins with 2/2 VOTE2s.
  5. A then syncs to B and rolls back its gen-3 records, including T, which had been reported durable.

H3 proves that two masters cannot win the same egen. It says nothing about which site wins, and choosing a site with older data is how a durable commit is lost. Equal LSNs are a coincidence, so the window is narrow, but it is not zero.

Disposition: not a confirmed bug until it reproduces against the real code. The follow-up is to drive this history in a TCL rep test with controlled message delivery, or a libcmc model of the election decision. If it reproduces, the fix is to gate arm D on like_pri && data_gen == w_datagen, and to give the first vote the same comparison path as the rest (which also removes Counterexample 1). LTS has the same code, so the fix needs a backport.

H4 note: the ack-policy harness is a transcription of a block inside __repmgr_send, not a verbatim function, so check_copies.sh does not guard it. Extract that block into a static helper that __repmgr_send calls, so the harness can include the real code. That is a small, behaviour-neutral engine change, and it can go with the follow-up.

gburd added 2 commits November 4, 2026 10:46
…FC 0025 C2)

harness_bt_decompress.c proves the memory safety of the default compressed
btree key/data decoder over every input in a stated bound. The REAL,
unmodified engine code (verbatim copy, guarded by check_copies.sh) FAILS:
__bam_defdecompress reads each varint's leading byte via
__db_decompress_count_int, and the magic byte *s at offset 0, BEFORE it
checks the compressed buffer is long enough. A corrupt or truncated
compressed item -- a trust boundary, since compressed pages come from disk --
over-reads up to one byte past compressed->size at each of the three varint
sites, and reads the first byte of a zero-length item with no check at all.

CBMC counterexample (size==0 the cheapest): *s and the count_int lookahead
read "pointer outside object bounds".

run.sh runs two arms: b1-fixed (-DB1_FIXED, the proposed bound-before-read
fix) VERIFIES; b1-decompress (the unmodified engine code) is a must-FAIL arm
that documents the live bug until fix/bt-decompress-bounds lands, mirroring
how okitem ran before 2ca8126. The whole suite stays ~26 s (cadical).

The varint helpers' arithmetic is already proved against the real source by
harness_varint.c; here they are modelled by their exact read footprint (the
count reads buf[0]; the int32 reads the leading width bytes from the real
__db_marshaled_int_size table), which is the only thing B1's bounds proof
depends on and keeps the SAT formula inside the suite budget.
The -DB1_FIXED variant's first non-dup count_int check read an uninitialised
`size`; drop it, since the top `compressed->size < 1` guard already covers the
offset-0 read, matching fix/bt-decompress-bounds exactly.  Still VERIFIES.
@gburd

gburd commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

C2 added B1 harness_bt_decompress.c (RFC 0025, pushed on this branch, 64d8257 + 010b177). It proves the memory safety of __bam_defdecompress (the default compressed-btree decoder) over every bounded input, and found a real over-read on the unmodified engine code: it reads each varint's leading byte (__db_decompress_count_int) and the magic *s at offset 0 before checking compressed->size. A corrupt/truncated compressed item — a disk trust boundary — over-reads past the buffer.

Q1/L1/E1/B2 harnesses coming to this branch next.

gburd added 4 commits November 4, 2026 11:13
harness_qam_recno.c verifies the qam.h record<->(page,index) mapping and the
circular live-window predicates over the whole u_int32_t range. MAP: recno>=1
gives an in-range index that maps back to recno. RANGE: QAM_OUTSIDE_QUEUE
agrees with an independent circular-window reference, including the wrapped
queue (cur < first). PARTITION: for recno != RECNO_OOB exactly one of
before-first / after-current / inside holds, and QAM_NOT_VALID == before ||
after. The macros are verbatim from qam.h (member access adapted to local
structs). No engine finding -- the macros agree with the reference.
-DQAM_SABOTAGE drops the 1-based "- 1" in QAM_RECNO_INDEX and must FAIL.
L1 harness_logc_hdrchk.c: the verbatim static __logc_hdrchk (check_copies-
guarded). Proves a header that passes with *eofp==0 is internally consistent
(hdr->len > hdr->size, and hdr->len <= bp_maxrec when bp_maxrec != 0), with the
__logc_set_maxrec re-read stubbed as both a no-op and a shrink. Sabotage
(L1_SABOTAGE, a separate hand-written copy, like harness_tally's) uses < for <=
and FAILs.

E1 harness_env_alloc_size.c: __env_alloc_size/DB_ALLOC_SIZE and
SET_QUEUE_FOR_SIZE (verbatim macros + DB_ALIGN). SIZE: the rounded allocation
covers len and is 8-aligned with no size_t overflow (default + DIAGNOSTIC
guard-byte). QUEUE: the size-class index stays in [0, DB_SIZE_Q_COUNT], is the
smallest class covering the chunk, and the clamp of an over-range index is a
valid subscript. Sabotage (E1_SABOTAGE) uses < for <= in the class test and
FAILs at the boundary. No engine finding for either.

Whole suite ~28 s (cadical); 6 verbatim copies match src/.
harness_bt_roundtrip.c proves decompress(compress(key,data)) == (key,data) for
the default compressed-btree codec, given the same prevKey/prevData. Both
__bam_defcompress and __bam_defdecompress are verbatim copies (check_copies-
guarded); the varint codec is the REAL db_compint.c (#include). -DB2_SABOTAGE
drops the duplicate-path magic byte on compress, so decompress misreads a
duplicate record and the round trip FAILs. No engine finding.

Bound: key/data/prev <= 4 bytes -- at the brief's 6 the #include'd codec's
full arithmetic pushes this one harness past the suite's 5-min budget; <= 4
still covers every structural path (empty/prefix/suffix/full/duplicate) and the
multi-byte varint widths are already proved by harness_varint.c. Whole suite
~4.0 min on a fast host (B2's two arms are ~110 s + ~103 s), 8 copies match.
The B1 and B2 btree-compression harnesses run the real varint codec and take
~2 min each, so the suite is a few minutes now, not under one. The 30-min job
timeout is unchanged. cadical (CBMC's built-in fast SAT backend, which run.sh
now selects) ships inside the cbmc binary, so no flake change is needed.
@gburd

gburd commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

C2/C3 complete — all five RFC 0025 targets pushed to this branch:

target harness result
B1 harness_bt_decompress.c BUG FOUND — __bam_defdecompress over-reads a truncated compressed item. b1-fixed verifies; b1-decompress (real code) is a must-FAIL arm. Engine fix: #264 (master) + #265 (LTS backport) with a guard-page test/db runner that FAILs-before / PASSes-after on any build.
B2 harness_bt_roundtrip.c round trip decompress(compress(k,d))==(k,d) verifies (real codec, key/data ≤ 4); -DB2_SABOTAGE (drop dup magic byte) FAILs.
Q1 harness_qam_recno.c queue record arithmetic MAP/RANGE/PARTITION verifies; macros agree with an independent circular-window reference (no bug); -DQAM_SABOTAGE off-by-one FAILs.
L1 harness_logc_hdrchk.c __logc_hdrchk header-consistency verifies (verbatim static, maxrec re-read stubbed no-op + shrink); -DL1_SABOTAGE (< for <=) FAILs.
E1 harness_env_alloc_size.c region size-class SIZE + QUEUE invariants verify (default + DIAGNOSTIC); -DE1_SABOTAGE FAILs at the class boundary.

Whole suite ~4 min (B1/B2 run the real varint codec; I added --sat-solver cadical and refreshed the workflow runtime note). 8 verbatim copies match src/. Only B1 was a real finding — Q1/L1/E1/B2 all confirm the code is correct. msgids 5570–5579 not used (the one engine fix needed no new DB_STR).

gburd added 2 commits November 4, 2026 12:15
…p arm

The varint model returned a value built from the bytes it read, at most
about 2^17, so the proof could not see prefix + suffix wrap.  The real
decoder returns any u32 from a 5-byte varint.  With the value nondet,
the first fix (2e99a1f) fails the proof; the fix plus e067d65
verifies.  The B1_FIXED arm is now a verbatim copy of the final fixed
function, and a new b1-wrap arm (-DB1_NO_WRAP_CHECK) must fail.
Q1 MAP bounded recno and q_root to 0x10000.  Queue has no subdatabases, so
q_root is always 1 (__qam_open sets base_pgno + 1 with base_pgno 0); with
that fixed, the round trip verifies for every recno >= 1.  E1 bounded len to
64 MB; it verifies up to SIZE_MAX - 64, where only the rounding itself must
overflow.  Both sabotage arms still fail.
@gburd

gburd commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

E1 resolved: the H1 finding (__rep_cmp_vote) is a durability bug, now fixed.

The CBMC H1 finding (order dependence; "Counterexample 2", arm D bypassing
data_gen) is confirmed a durability defect, not liveness-only, and fixed.

Reproduced against the real engine (dev box). The precondition is reachable:
two/three electable sites with an EQUAL last-commit LSN (1/16402) and DIFFERENT
data_gen (2 vs 3), carrying those values in real VOTE1 messages. A VOTE1's LSN
is the last __txn_regop (__rep_log_backup(REP_REC_COMMIT)) and its data_gen
is the highest REPLSNHIST gen (__rep_get_datagen); these are independent, so
equal LSN + different data_gen is reachable, and no invariant forbids it. On
sync a client memcmps the log bytes at its verify LSN (__rep_verify,
rep_verify.c:77), so electing a lower-data_gen site genuinely rolls back the
newer site's tail — a durable commit is lost. Full write-up (what is proven vs
argued, the mixed-version rolling-upgrade analysis, and the user upgrade note)
is in test/rep/E1-FINDINGS.md on the fix branch.

Fix (#266, fix/rep-elect-datagen; LTS backport #267): __rep_cmp_vote
rewritten as a single total order applied uniformly to every vote including
the first — electable > not; non-zero priority > zero (mixed-version ELECTABLE
preserved); then data_gen, LSN, priority, tiebreaker, and finally eid as the
always-distinct discriminator. No format/ABI/region change. rep001–rep020
and the election set pass unchanged vs master; the LTS gates (sig 0xe67a9de8,
soname, 1972 symbols) are unchanged.

CBMC harness update (for this branch). A prepared patch updates
harness_cmpvote.c so property (a) order independence and a new (e)
data_gen-maximality VERIFY on the fixed comparator, with the old comparator
as the -DCMPVOTE_SABOTAGE arm (both fail on it); run.sh and the README-H1
section are updated to match (FINDING → FIXED). The vote fields are fully nondet
in the order-independence proof; the captured real vectors live in the
deterministic test/db/run_rep_cmpvote_order.sh runner, not as the CBMC bound.
This update depends on the fix being in the same tree (check_copies.sh guards
the verbatim copy against src/rep/rep_elect.c), so it should land on this
branch only after #266 merges and this branch rebases onto it. Patch:
/scratch/libdb/e1-work/cbmc-harness-update.patch.

Pre-existing, separate: under --enable-diagnostic, rep election TCL tests
PANIC on an RFC 0010 A3 lock-order artifact in src/rep/rep_backup.c (the TCL
test transport re-enters __memp_fget under rep latches; aliases mutex id 3),
reproducing identically on plain master and lts/2026.10.7. Full BDB2077–2084
report + gdb call path handed to the lead; not part of this fix.

This branch has not been deployed

No deployments
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.

1 participant