Skip to content

fix: type-check membership and handle-table pages against their tree position (#141) - #158

Merged
Xof merged 1 commit into
mainfrom
fix/141-membership-type-checks
Aug 5, 2026
Merged

fix: type-check membership and handle-table pages against their tree position (#141)#158
Xof merged 1 commit into
mainfrom
fix/141-membership-type-checks

Conversation

@Xof

@Xof Xof commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #141 (I148). Stacked on #157.

The crate's radixes disagreed about a threat the repo tests everywhere else: a page whose XXH3 checksum is valid — it is non-cryptographic and publicly recomputable — but whose structure is wrong.

Module Behaviour before
freemap_tree, overflow, data_page fail closed, typed CorruptPage
membership_index, handle_table fail open

The check is positional, not merely "is this one of the membership types". expected_type_at(level) requires MembershipLeaf at level 0 and MembershipInterior above. A leaf reached one level too early has its entry words read as child pointers; an interior reached at level 0 has its child pointers read as values. Checking type against position catches both.

The sharpest instance is corruption amplification, not a bad read

free_subtree took an interior's child pointers at the depth-1 boundary and pushed them into freed with no read and no validation. A corrupt-but-checksummed pointer therefore became a page id marked reusable at commit — a live data page, or superblock slot 1, handed back to the allocator. One bad pointer turns into arbitrary overwriting later.

Validating at level 0 means leaves are now read rather than freed sight-unseen. That read is the point: a page you never read cannot be type-checked.

Two holes the adversarial review found in the first version

Both confirmed by reproduction, both closed here:

  1. insert_recursive was still unguarded at level 0, and pushed page into freed before validating anything. Same amplification on the insert path, and worse: delete at least returned CorruptPage, while insert succeeded silently — COWing a foreign page, queueing the original for reclamation, letting commit mark a live page reusable, with nothing surfacing. Reachable via a corrupt child pointer, or a corrupt packed outer-leaf value whose depth bits read as 0. The check now runs at the top of the frame, before the alloc/copy/push, matching delete_recursive.

  2. The issue names HandleTable+FLAG too, and the first version touched only membership_index. HandleTable::find_leaf descended with no check at all — only recover_depth validated. The handle table tags position with a FLAG byte rather than a distinct PageType, so both bytes are checked: buf[0] proves it is a handle-table page, buf[1] == FLAG_INTERIOR proves it belongs at this level.

One test changed meaning

iter_bounded_saturates_prefix_on_corrupt_max_depth previously asserted that walking a self-referential interior at MAX_DEPTH succeeded — the fail-open behaviour this issue is about. It now asserts CorruptPage, and still covers the property it was written for: next_base saturates in the top-level frame before the recursion that now fails, so a regression to non-saturating arithmetic still panics it.

Cost

Freeing a subtree reads its leaves as well as its interiors — O(leaves) extra cache lookups on a tag-drop, an operation already O(n). Under cache pressure those reads can surface CacheFull where the old push-blind code could not fail; that is operational, not fatal, and inherent to having a guard here at all.

Verification

721 tests pass, clippy and fmt clean. Both new tests verified non-vacuous by removing their guard.

…position

Closes #141 (I148).

The crate's three radixes disagreed about a threat the repo tests everywhere
else: a page whose XXH3 checksum is valid — it is non-cryptographic and
publicly recomputable — but whose STRUCTURE is wrong. `freemap_tree`,
`overflow` and `data_page` fail closed with a typed CorruptPage;
`membership_index` and `handle_table` failed open.

The check is positional, not merely "is this one of the membership types":
`expected_type_at(level)` requires MembershipLeaf at level 0 and
MembershipInterior above. A leaf reached one level too early has its ENTRY
words read as child pointers, and an interior reached at level 0 has its child
pointers read as values — checking the type against the position catches both.

The sharpest instance is corruption AMPLIFICATION rather than a bad read.
`free_subtree` took an interior's child pointers at the depth-1 boundary and
pushed them into `freed` with no read and no validation, so a
corrupt-but-checksummed pointer became a page id marked REUSABLE at commit —
a live data page, or superblock slot 1, handed back to the allocator. One bad
pointer turns into arbitrary overwriting later. Validating at level 0 means
leaves are now read rather than freed sight-unseen; that read is the point,
since a page you never read cannot be type-checked.

Adversarial review found two holes in the first version of this fix, both
confirmed and both closed here:

  * `insert_recursive` was still unguarded at level 0, and pushed `page` into
    `freed` BEFORE validating anything. Same amplification on the insert path,
    and worse: `delete` at least returned CorruptPage, while `insert`
    SUCCEEDED silently — COWing a foreign page, queueing the original for
    reclamation, and letting commit mark a live page reusable with nothing
    surfacing. Reachable via a corrupt child pointer or a corrupt packed
    outer-leaf value whose depth bits read as 0. The check now runs at the top
    of the frame, before the alloc/copy/push, matching `delete_recursive`.

  * The issue's fix direction names `HandleTable`+FLAG too, and the first
    version touched only membership_index. `HandleTable::find_leaf` descended
    with no check at all — only `recover_depth` validated. The handle table
    tags position with a FLAG byte rather than a distinct PageType, so both
    bytes are checked: `buf[0]` proves it is a handle-table page, `buf[1] ==
    FLAG_INTERIOR` proves it belongs at this level.

`iter_bounded_saturates_prefix_on_corrupt_max_depth` previously asserted that
walking a self-referential interior at MAX_DEPTH SUCCEEDED — the fail-open
behaviour this issue is about. It now asserts CorruptPage, and still covers
the property it was written for: `next_base` saturates in the top-level frame
before the recursion that now fails, so a regression to non-saturating
arithmetic still panics it.

Cost: freeing a subtree reads its leaves as well as its interiors — O(leaves)
extra cache lookups on a tag-drop, an operation already O(n). Under cache
pressure those reads can surface CacheFull where the old push-blind code could
not fail; that is operational, not fatal, and inherent to having a guard here.
@Xof
Xof changed the base branch from docs/125-audit-comment to main August 5, 2026 03:41
@Xof
Xof merged commit ca9b0f7 into main Aug 5, 2026
9 checks passed
@Xof
Xof deleted the fix/141-membership-type-checks branch August 5, 2026 03:46
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.

I148: free_subtree / membership descent has no positional type check — a corrupt-but-checksummed page amplifies corruption

1 participant