Skip to content

fix(hash): honor a custom comparator on legacy unsorted hash pages - #144

Merged
gburd merged 2 commits into
masterfrom
work/fix139
Sep 6, 2026
Merged

fix(hash): honor a custom comparator on legacy unsorted hash pages#144
gburd merged 2 commits into
masterfrom
work/fix139

Conversation

@gburd

@gburd gburd commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #139

The defect

__ham_getindex_unsorted() (src/hash/hash_page.c) is the linear search over a pre-4.6 P_HASH_UNSORTED page, a format 5.3 still reads without requiring DB->upgrade. Its inline-key (H_KEYDATA) branch had two defects, and they masked each other:

if (t->h_compare != NULL) {
        DB_INIT_DBT(pg_dbt, HKEYDATA_DATA(hk), key->size);
        if (t->h_compare(dbp, key, &pg_dbt) != 0)
                break;                  /* equal -> falls out, res untouched */
}
  1. The comparator's result was discarded. res is initialized to 1 (not-equal). On equality the if is false and control reaches the trailing break without ever assigning res = 0, so the if (res == 0) break; after the switch never fires and *match is set to 1 (not found). DB->get returned DB_NOTFOUND for a key that was present, and DB->put(DB_NOOVERWRITE) returned success and stored a second record with identical key bytes although duplicates are disabled — silently breaking key uniqueness. (This is what the report describes.)

  2. The stored-key DBT carried the wrong length. It was built with key->size, the length of the search key, not the length of the item on the page. The comparator therefore saw a truncated or over-long view of the stored key. Defect 1 was hiding this: applying only the reported one-line fix converts the false negative into a false positiveDB->get("acct") returns acct0000's data, because "acct" compares equal against a 4-byte-truncated view of the stored key. An over-long search key also made the comparator read past the stored item. Both are demonstrated in the test evidence below.

The memcmp path was always correct: it length-checks first and assigns its result.

The fix

 		case H_KEYDATA:
 			if (t->h_compare != NULL) {
-				DB_INIT_DBT(pg_dbt,
-				    HKEYDATA_DATA(hk), key->size);
-				if (t->h_compare(
-				    dbp, key, &pg_dbt) != 0)
-					break;
+				DB_INIT_DBT(pg_dbt, HKEYDATA_DATA(hk),
+				    LEN_HKEY(dbp, p, dbp->pgsize, i));
+				res = t->h_compare(dbp, key, &pg_dbt);
 			} else if (key->size ==

This is the established idiom in the same file: __ham_getindex_sorted's equivalent case does itemlen = LEN_HKEYDATA(dbp, p, dbp->pgsize, indx); ... res = t->h_compare(dbp, key, &tmp_dbt);, and the H_OFFPAGE case immediately above passes t->h_compare and &res through to __db_moff.

Audit: every comparator call site in src/hash/

Site Verdict
hash_page.c:679 __ham_getindex_unsorted, H_OFFPAGE OK. Passes &res to __db_moff, which assigns *cmpp on every path including the cmpfunc != NULL one. Only reached when tlen == key->size; that is a length pre-filter, not a substitute for the comparator, and it is conservative for equality under the documented set_h_compare contract (a comparator for an existing db must reproduce the ordering it was built with, so different-length keys the built-in call unequal cannot be equal). Covered by a positive check in the new test.
hash_page.c:693 __ham_getindex_unsorted, H_KEYDATA THE BUG. Fixed here.
hash_page.c:787 __ham_getindex_sorted, case 1 (offpage/offpage) OK. __db_coff(..., t->h_compare, &res); the short-circuit above it (koff_pgno == off_pgno → res = 0) is same-page identity, correct.
hash_page.c:795 __ham_getindex_sorted, case 2 (offpage key, on-page probe) OK. __db_moff(..., &res).
hash_page.c:810 __ham_getindex_sorted, case 3 (on-page key, offpage probe) OK. __db_moff(..., &res), then res = -res because the arguments were swapped.
hash_page.c:821 __ham_getindex_sorted, case 4 (on-page/on-page) OK, and the model for the fix. itemlen = LEN_HKEYDATA(...), then res = t->h_compare(dbp, key, &tmp_dbt) — the true stored length, result captured.
hash_page.c:882922 __ham_verify_sorted_page OK / not applicable. Returns early (line 882) when t->h_compare != NULL — sort order under a user comparator is deliberately not verified — so the three __db_coff/__db_moff calls below only ever run with cmpfunc == NULL. All three pass &res anyway.
hash.c:1548 __ham_lookup DB_GET_BOTH, off-page data OK. __db_moff(..., dbp->dup_compare, &cmp), then cmp = -cmp for the swapped arguments; cmp is tested. This is dup_compare (data), not h_compare.
hash.c:1559-1561 same, on-page data OK. cmp = ... dup_compare(...) — result assigned and tested.
hash.c:1767, hash.c:1812 __ham_overwrite sort-order guards OK by design. These are assertions, not searches: a non-zero result means the caller is corrupting the dup sort order, and the non-zero case is the one handled (__db_duperr / EINVAL). Nothing depends on recording equality; equality is the pass case.
hash_dup.c:174 OK. Reads cmp set by __ham_dsearch (below).
hash_dup.c:780,800 __ham_dsearch OK. *cmpp = func(dbp, dbt, &cur) on every iteration, and func falls back to __bam_defcmp when dup_compare is NULL, so *cmpp is always written.
hash_dup.c:281,842, hash_open.c:220-305 Not comparisonsdup_compare == NULL used as a "sorted dups?" predicate to pick a page type / set DB_AM_DUPSORT. Correct as written.
hash_verify.c:1137 __ham_dups_unsorted OK. func(...) > 0 is the whole decision (is this dup set out of order?); the boolean is the result.

Verdict: __ham_getindex_unsorted's H_KEYDATA branch was the only site that dropped a comparison result. It is also the only site that built a comparison DBT from the search key's length instead of the stored item's.

Regression test

test/db/hash_unsorted_cmp.c + test/db/run_hash_unsorted_cmp.sh.

The branch needs a legacy P_HASH_UNSORTED page and an explicit DB->set_h_compare at the same time, which is why nothing caught it: test093 sets a comparator but only over current-format sorted pages (→ __ham_getindex_sorted), and run_upgrade.sh reads legacy pages but never sets a comparator.

Fixture, built synthetically — no old library, no committed binary blob. Approach (a) from the report, using the technique test/db/run_upgrade.sh already uses for old-format fixtures. A P_HASH_UNSORTED page and a P_HASH page have identical byte layouts; P_HASH merely additionally keeps its pairs in comparison order, which is a subset of what P_HASH_UNSORTED permits. So the driver creates a current-format Hash db (512-byte pages, 20 inline keys + one 200-byte off-page key → 2 bucket pages), then rewrites each bucket page's PAGE.type byte (offset 25) from P_HASH (13) to P_HASH_UNSORTED (2) and the metadata version (offset 16) back to the 4.5.20 hash version 8. That file is exactly what __ham_getindex dispatches to __ham_getindex_unsorted. Deterministic and self-contained.

Every check runs twice, with the comparator (trigger) and without (control), so a failure is attributable to the comparator path and not to the fixture. The comparator is byte_compare, the comparison BDB itself used before 4.6 added set_h_compare — which is what the API docs require for an existing database.

Before the fix (--enable-debug):

FAIL: DB->get of a stored key: got -30988, want 0
FAIL: DB->put(DB_NOOVERWRITE) over a live key: got 0, want -30994
FAIL: record count after DB_NOOVERWRITE: got 22, want 21
FAIL: records carrying the target key: got 2, want 1
hash_unsorted_cmp: 4 check(s) FAILED
legacy fixture: HASH_UNSORTED_TESTDIR/legacy.db (2 page(s) -> P_HASH_UNSORTED, hash version 8)
  get control inline key: ret=0 (success) cmp_calls=0 equal=0
  get control off-page key: ret=0 (success) cmp_calls=0 equal=0
  get control prefix-of-stored key (acct): ret=-30988 (DB_NOTFOUND) cmp_calls=0 equal=0
  get control extends-stored key (acct0000XXXX): ret=-30988 (DB_NOTFOUND) cmp_calls=0 equal=0
  put(DB_NOOVERWRITE) control: ret=-30994 (DB_KEYEXIST) records=21 with_target_key=1
  get trigger inline key: ret=-30988 (DB_NOTFOUND) cmp_calls=10 equal=1     <-- false miss
  get trigger off-page key: ret=0 (success) cmp_calls=11 equal=1
  get trigger prefix-of-stored key (acct): ret=-30988 cmp_calls=10 equal=10 <-- 10 bogus "equal"s, swallowed
  get trigger extends-stored key (acct0000XXXX): ret=-30988 cmp_calls=10 equal=0
  put(DB_NOOVERWRITE) trigger: ret=0 (success) records=22 with_target_key=2 <-- duplicate persisted
run_hash_unsorted_cmp.sh: FAIL (rc=1)

Note equal=1 on the false miss (the comparator did report equality and it was dropped) and equal=10 on the prefix probe (defect 2, invisible only because defect 1 discarded it).

With only the reported one-line change (res = t->h_compare(...), still key->size) — this is why the fix is two changes, not one:

FAIL: DB->get of a key that is not stored: got 0, want -30988
  get control prefix-of-stored key (acct): ret=-30988 (DB_NOTFOUND) cmp_calls=0 equal=0
  get trigger prefix-of-stored key (acct): ret=0 (success) cmp_calls=1 equal=1   <-- FALSE POSITIVE

DB->get("acct") now returns acct0000's data: a wrong-record read instead of a missed read.

After the fix (both --enable-debug and release):

legacy fixture: HASH_UNSORTED_TESTDIR/legacy.db (2 page(s) -> P_HASH_UNSORTED, hash version 8)
  get control inline key: ret=0 (success) cmp_calls=0 equal=0
  get control off-page key: ret=0 (success) cmp_calls=0 equal=0
  get control prefix-of-stored key (acct): ret=-30988 (DB_NOTFOUND) cmp_calls=0 equal=0
  get control extends-stored key (acct0000XXXX): ret=-30988 (DB_NOTFOUND) cmp_calls=0 equal=0
  put(DB_NOOVERWRITE) control: ret=-30994 (DB_KEYEXIST) records=21 with_target_key=1
  get trigger inline key: ret=0 (success) cmp_calls=1 equal=1
  get trigger off-page key: ret=0 (success) cmp_calls=11 equal=1
  get trigger prefix-of-stored key (acct): ret=-30988 (DB_NOTFOUND) cmp_calls=10 equal=0
  get trigger extends-stored key (acct0000XXXX): ret=-30988 (DB_NOTFOUND) cmp_calls=10 equal=0
  put(DB_NOOVERWRITE) trigger: ret=-30994 (DB_KEYEXIST) records=21 with_target_key=1
hash_unsorted_cmp: PASS
run_hash_unsorted_cmp.sh: PASS

cmp_calls also drops 10 → 1 on the successful lookup: the search now stops at the match instead of scanning the whole page.

Validation

  • Builds clean, 0 errors: --enable-debug --enable-test --with-tcl=... and a plain release build (../dist/configure). New test passes under both.
  • Tcl, hash: test001 test003 test011 test093 (the existing set_h_compare test) — 0 failures. Plus test006 test017 test024 test025 test029 test031 test032 test038 test039 test044 test046 test048 test051 hsearch on hash — 0 failures.
  • Tcl, btree (comparator path shares __db_moff/__db_coff): test001 test093 — 0 failures.
  • Fuzz gate: test/fuzz/check-crashes.sh9/9 PASS (includes the hash OOB seed), ASan-instrumented libdb.
  • test/db/run_upgrade.sh: reaches a pre-existing failure at h_v5.db (db_verify: BDB1101 Page 0: spares array entry 1 is invalid). Confirmed pre-existing and unrelated: byte-identical output with this commit's hash_page.c reverted to master. Not touched here — the whole diff sits inside if (t->h_compare != NULL), and db_upgrade never sets a comparator, so it is inert on that path.

Registered in the coverage harness (test/coverage/run_coverage.sh, full_run3_combined.sh) and documented in test/coverage/README.md.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (2) -- update dist/cocci/baseline.txt to lock these in.
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 Sep 6, 2026

Copy link
Copy Markdown

ABI diff produced no report (build skipped or no base tag).


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

__ham_getindex_unsorted() does the linear search over a pre-4.6
P_HASH_UNSORTED page, which libdb still reads without requiring
DB->upgrade.  Its inline-key (H_KEYDATA) branch had two defects that
masked each other whenever the application configured a comparator with
DB->set_h_compare:

  1. It called the comparator only to reject a non-zero result and never
     stored a zero one, so `res` kept its initial 1 (not-equal), the
     `if (res == 0) break;` after the switch never fired, and *match was
     set to 1 (not found).  DB->get returned DB_NOTFOUND for a key that
     was present, and DB->put(DB_NOOVERWRITE) returned success and stored
     a second record with identical key bytes even though duplicates are
     disabled -- silently breaking key uniqueness.

  2. It built the stored-key DBT with key->size, the length of the SEARCH
     key, instead of the length of the item actually on the page.  The
     comparator therefore saw a truncated or over-long view of the stored
     key: a search key that merely prefixed a stored key compared equal
     (masked by defect 1 -- fixing only the dropped result turns the
     false negative into a false positive that returns another record's
     data), and an over-long search key made the comparator read past the
     stored item.

Compare against the stored key at its own length and record the result,
which is what __ham_getindex_sorted's equivalent case already does
(itemlen = LEN_HKEYDATA(...), then res = t->h_compare(...)), and what the
H_OFFPAGE case next door does by passing &res through to __db_moff.

The memcmp path was always correct: it length-checks first and assigns
its result.  Only the comparator path was affected, so reproducing this
needs a legacy page and an explicit DB->set_h_compare together -- a new
handle's h_compare is NULL, and current sorted pages take
__ham_getindex_sorted.

Fixes #139
test/db/hash_unsorted_cmp.c + run_hash_unsorted_cmp.sh cover the
__ham_getindex_unsorted() comparator branch (issue #139), which no
existing test reached: the branch needs a legacy P_HASH_UNSORTED page AND
an explicitly configured DB->set_h_compare at the same time.  test093
sets a comparator but only over current-format sorted pages (which take
__ham_getindex_sorted), and run_upgrade.sh reads legacy pages but never
sets a comparator.

The legacy fixture is manufactured with the technique run_upgrade.sh
already uses for old-format fixtures -- no old library and no committed
binary blob.  A P_HASH_UNSORTED page has the same byte layout as a P_HASH
page; P_HASH merely additionally keeps its pairs in comparison order.  So
the driver creates a current-format Hash db (512-byte pages, inline and
off-page keys), then rewrites each bucket page's PAGE.type byte from
P_HASH to P_HASH_UNSORTED and the metadata version back to the 4.5.20
hash version 8.  That is exactly the file __ham_getindex dispatches to
the unsorted path.

Checks, each run with the comparator (trigger) and without it (control)
so a failure is attributable to the comparator path rather than to the
fixture:
  * DB->get of a stored inline key succeeds and returns its value;
  * DB->get of a stored off-page key succeeds (the H_OFFPAGE branch next
    door, which passes &res to __db_moff, must stay correct);
  * DB->put(DB_NOOVERWRITE) over a live key returns DB_KEYEXIST and adds
    no record (verified by a full cursor scan);
  * a key that merely prefixes a stored key, and one that extends it, are
    both reported absent -- these catch the wrong-length stored-key DBT,
    including the false positive that a result-only fix would introduce.

Against the unfixed library the four trigger checks fail (DB->get =>
DB_NOTFOUND for a stored key; DB_NOOVERWRITE => success with 22 records,
2 carrying the target key) while every control passes.

Registered in the coverage subset (run_coverage.sh,
full_run3_combined.sh) and documented in test/coverage/README.md.
@gburd
gburd merged commit 8d60cae into master Sep 6, 2026
48 of 51 checks passed
@gburd
gburd deleted the work/fix139 branch September 6, 2026 21:52
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.

libdb 5.3.34: custom comparator equality is ignored for inline keys on P_HASH_UNSORTED pages

1 participant