Skip to content

fix: refuse to create a database over an existing sub-page file - #127

Merged
Xof merged 1 commit into
mainfrom
fix/open-destroys-existing-subpage-file
Jul 29, 2026
Merged

fix: refuse to create a database over an existing sub-page file#127
Xof merged 1 commit into
mainfrom
fix/open-destroys-existing-subpage-file

Conversation

@Xof

@Xof Xof commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes the issue behind draft advisory GHSA-w3wp-8244-2jcf.

The bug

Chisel::open answered "does this file already exist?" two different ways, and they disagree for every file length in 1..8191:

predicate 71-byte file
create_if_missing gate (src/lib.rs:366) metadata(path).len() > 0 exists → gate passes
create-vs-open decision (src/lib.rs:384) io.page_count()? > 0 = len / stride absentcreate_new runs

PageIo::open uses .create(true).truncate(false), so the existing file is adopted and create_new writes superblock slots over it. Two failures at once: create_if_missing: false created a database, and an arbitrary user file under 8 KB was destroyed.

Reproduced against this tree from a downstream crate:

std::fs::write(path, b"IMPORTANT USER DATA - not a chisel database\n")?; // 71 bytes
let mut o = chisel::Options::default();
o.create_if_missing = false;
let r = chisel::Chisel::open(path, o);
// r == Ok(Chisel); file is now 16384 bytes, starts with "LSHC". Original bytes gone.

Where it came from

This is a regression from 04534c0 (the I143 fix). Before that commit a single predicate, file_exists, drove both decisions. I143 correctly moved the create-vs-open decision to a post-lock io.page_count()? > 0 to close a create-vs-open lock race, but left the create_if_missing gate on the pre-lock length. The race fix was right; it just needed both call sites.

The fix

Decide after the lock, where the true length is available. If the post-lock page count is zero, distinguish the two cases the old code conflated:

  • empty file — legitimate create target (crash between creat(2) and the first superblock write, or a bare touch). Unchanged behaviour, except that it now re-checks create_if_missing, closing the narrow race where the file is removed between the pre-lock stat and the lock.
  • non-empty but shorter than one page — cannot be a valid database, but it is somebody's data. Refuse with CorruptSuperblock, regardless of create_if_missing.

The pre-lock gate stays pre-lock, per I143's reasoning, so a refused open still does not materialize an empty file in the common case.

Supporting changes:

  • PageIo::byte_len()page_count() is len / stride and cannot distinguish "empty" from "has bytes but not a whole page". Costs a seek, so it is documented as open-path only.
  • SuperblockDefect::TooShort — so the error says why rather than overloading BadMagic (there are no bytes to have magic). The enum is already #[non_exhaustive], so this is additive for downstream matches.

Tests

Three added to tests/api_edge_cases.rs, written before the fix and confirmed failing for the right reason (both open() calls returned Ok):

  • open_refuses_to_create_over_a_sub_page_file — asserts the error and that the original bytes are untouched
  • open_with_create_if_missing_false_does_not_create_over_a_sub_page_file
  • open_still_creates_over_a_zero_length_file — pins the boundary so this fix cannot tighten into refusing empty files (this one passed before the fix, as it should)

cargo test 684 passed / 0 failed · cargo clippy --workspace -- -D warnings clean · cargo fmt --check clean.

Note for reviewers

Branched from main rather than the local development lineage, which shares no ancestry with it. The six files involved are byte-identical across the two, so the change applies cleanly either way — but it will need porting to the dev lineage separately.

Found by the clean-slate deep review of 2026-07-29 (finding PUBLIC-API-1).

`Chisel::open` decided "does this file already exist?" two different ways,
and they disagreed for every file length in 1..8191:

  * the `create_if_missing` gate used `metadata(path).len() > 0`
  * the create-vs-open decision used `io.page_count()? > 0`, which is
    `len / stride` and therefore floors any sub-page file to zero

A file in that range passed the first test (so the gate let the call
through even with `create_if_missing: false`) and failed the second (so
`create_new` ran over it). `PageIo::open` uses `.create(true)
.truncate(false)`, so the file was adopted and its contents replaced with
superblock slots. A 71-byte text file came back as a 16384-byte database
with the original bytes gone.

Both halves are fixed by deciding after the lock, where the true length is
available: if the post-lock page count is zero, distinguish an empty file
(a legitimate create target) from a short non-empty one (somebody else's
data) and refuse the latter with CorruptSuperblock. The empty-file branch
also re-checks `create_if_missing`, which closes the narrow race where the
file is removed between the pre-lock stat and the lock.

Adds `PageIo::byte_len()` for the post-lock length, and a
`SuperblockDefect::TooShort` variant so the error says why. The enum is
already `#[non_exhaustive]`, so the new variant is additive downstream.

Zero-length files still go through the create path unchanged; a regression
test pins that boundary so this fix cannot tighten into refusing them.
@github-actions

Copy link
Copy Markdown

🚦 Bench results: PR vs main

⚠️ 3 regression(s) detected across 2 scenario/mode pair(s)

Scenario Mode Δ throughput Worst Δ
ycsb-a redb-strict +16.8% p99 +30.0% ⚠️
ycsb-b chisel-mem -5.1% p95 +10.4% ⚠️
document-store chisel-mem +1.9%
document-store chisel-strict +5.3%
document-store redb-strict +14.3%
document-store sqlite-strict +0.4%
mutation-log chisel-mem +1.5%
mutation-log chisel-strict +14.0%
mutation-log redb-strict +11.3%
mutation-log sqlite-strict +7.9%
ycsb-a chisel-mem +12.2%
ycsb-a chisel-strict +36.6%
ycsb-a sqlite-strict +1.9%
ycsb-b chisel-strict +5.9%
ycsb-b redb-strict +4.7%
ycsb-b sqlite-strict -0.5%
Per-scenario detail (4 metrics × cells)

document-store

Mode Throughput p50 p95 p99
chisel-mem 27989 ops/s → 28534 ops/s (+1.9%) 5.5 µs → 5.4 µs (-2.6%) 73.4 µs → 71.5 µs (-2.5%) 381.3 µs → 367.9 µs (-3.5%)
chisel-strict 2729 ops/s → 2874 ops/s (+5.3%) 9.5 µs → 9.2 µs (-3.5%) 1.22 ms → 1.14 ms (-6.7%) 2.66 ms → 2.20 ms (-17.2%)
redb-strict 4848 ops/s → 5539 ops/s (+14.3%) 9.2 µs → 8.8 µs (-4.3%) 590.9 µs → 555.2 µs (-6.0%) 1.85 ms → 1.67 ms (-9.3%)
sqlite-strict 4891 ops/s → 4910 ops/s (+0.4%) 11.2 µs → 10.8 µs (-3.3%) 445.7 µs → 455.2 µs (+2.1%) 1.49 ms → 1.51 ms (+1.3%)

mutation-log

Mode Throughput p50 p95 p99
chisel-mem 62708 ops/s → 63633 ops/s (+1.5%) 19.7 µs → 19.6 µs (-0.6%) 23.5 µs → 22.5 µs (-4.4%) 29.7 µs → 29.7 µs (-0.2%)
chisel-strict 1366 ops/s → 1557 ops/s (+14.0%) 663.5 µs → 650.3 µs (-2.0%) 1.15 ms → 1.01 ms (-12.4%) 3.13 ms → 2.10 ms (-32.8%)
redb-strict 3412 ops/s → 3798 ops/s (+11.3%) 189.9 µs → 189.1 µs (-0.4%) 416.6 µs → 393.6 µs (-5.5%) 1.69 ms → 1.77 ms (+4.9%)
sqlite-strict 4690 ops/s → 5060 ops/s (+7.9%) 161.8 µs → 160.9 µs (-0.6%) 407.4 µs → 403.9 µs (-0.9%) 944.0 µs → 892.3 µs (-5.5%)

ycsb-a

Mode Throughput p50 p95 p99
chisel-mem 35959 ops/s → 40338 ops/s (+12.2%) 39.4 µs → 32.9 µs (-16.6%) 55.6 µs → 48.9 µs (-12.2%) 144.9 µs → 148.9 µs (+2.7%)
chisel-strict 1577 ops/s → 2155 ops/s (+36.6%) 457.0 µs → 458.0 µs (+0.2%) 1.12 ms → 1.08 ms (-3.6%) 2.26 ms → 1.87 ms (-17.5%)
redb-strict 4862 ops/s → 5679 ops/s (+16.8%) 166.5 µs → 167.7 µs (+0.7%) 252.1 µs → 234.1 µs (-7.1%) 1.20 ms → 1.55 ms (+30.0%) ⚠️
sqlite-strict 268339 ops/s → 273482 ops/s (+1.9%) 3.6 µs → 3.6 µs (-2.1%) 5.0 µs → 4.9 µs (-2.0%) 5.8 µs → 5.6 µs (-2.6%)

ycsb-b

Mode Throughput p50 p95 p99
chisel-mem 281716 ops/s → 267338 ops/s (-5.1%) ⚠️ 1.4 µs → 1.4 µs (+0.8%) 36.8 µs → 40.6 µs (+10.4%) ⚠️ 51.0 µs → 53.6 µs (+5.1%)
chisel-strict 15836 ops/s → 16767 ops/s (+5.9%) 2.5 µs → 2.5 µs (+1.5%) 490.3 µs → 490.1 µs (-0.1%) 1.06 ms → 1.10 ms (+3.9%)
redb-strict 63377 ops/s → 66376 ops/s (+4.7%) 1.9 µs → 2.0 µs (+2.6%) 167.7 µs → 167.5 µs (-0.1%) 207.5 µs → 211.0 µs (+1.7%)
sqlite-strict 321446 ops/s → 319844 ops/s (-0.5%) 3.1 µs → 3.1 µs (+0.7%) 4.3 µs → 4.3 µs (+0.0%) 5.0 µs → 5.0 µs (-0.5%)
Generated by chisel-bench-diff at 2026-07-29T19:14:01Z. Compares PR HEAD against main. Never blocks merge — signal, not gate. Thresholds: throughput 5%, p50 5%, p95 10%, p99 10%.

@Xof
Xof merged commit 7fad238 into main Jul 29, 2026
10 checks passed
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