Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ flowchart BT
| 2 | `page_io.rs` | Raw `pread`/`pwrite` of fixed-**stride** pages, exclusive `flock`, `fsync`, in-memory `Vec<u8>` backing. Tracks cumulative successful `fsync_calls` via `Cell<u64>`. Stride = `PAGE_SIZE` (plaintext) or `ENC_PAGE_SIZE` (encrypted), set once via `set_stride`. | The **only** module that touches the filesystem; crypto-agnostic (moves `stride`-byte blobs at `page_id * stride`). |
| 2 | `lru.rs` | O(1) intrusive doubly-linked LRU index over `u64` page ids (`FxHashMap`-backed, I77). | Replaces the O(n) `VecDeque::retain` LRU; consumed only by `page_cache`. |
| 3 | `page_cache.rs` | LRU cache over `PageIo`, dirty tracking, checksum validation on load, `PageCipher` seal/open at the I/O boundary, spillway overflow, `CacheFull`/`SpillwayFull` errors. Owns three `Cell<u64>` engine-activity counters (cache hits/misses, pages allocated) and aggregates them with `PageIo::fsync_count` into `counters()`. | Soft eviction at `max_pages`; dirty overflow spills to a sidecar (cap = `spillway_max_bytes`); `CacheFull` at strict `max_pages` when spillway disabled (`spillway_max_bytes=0`); checksums verified on disk LOAD only. |
| 3 | `spillway.rs` | Sidecar `<db>.spillway` file for dirty pages the LRU is forced to spill. Per-slot XXH3 over `page_id ‖ payload`; crypto-agnostic (plaintext 8192-byte page or sealed 8232-byte blob). | Never `fsync`ed; truncated at open/commit/rollback — its content is always discardable uncommitted state. |
| 3 | `spillway.rs` | Sidecar `<db>.spillway` file for dirty pages the LRU is forced to spill. Per-slot XXH3 over `page_id ‖ payload`; crypto-agnostic (plaintext 8192-byte page or sealed 8232-byte blob). | Never `fsync`ed; truncated at commit/rollback and re-created at open — its content is always discardable uncommitted state. Created `O_EXCL | O_NOFOLLOW` mode 0600; a pre-existing entry is unlinked only if it is a plain file this uid owns (see "File permissions" below). |
| 4 | `freemap.rs` | Single-page bitmap primitive: `allocate_first` / `mark_free` on one `[u8; PAGE_SIZE]` buffer. | Pure buffer manipulation; no cache or I/O. Composed into the multi-page tree by `freemap_tree.rs`. |
| 4 | `freemap_tree.rs` | COW radix tree of FreeMap leaves; the full multi-page freemap. | All structural COW pages sourced out-of-band (never from the bitmap); session-COW dedup (one COW per node per commit). |
| 4 | `data_page.rs` | Slotted page layout (R1): slot directory grows forward, packed value data grows backward. The directory is append-only — nothing is ever reclaimed within a page. | Slot indices are immutable for the page's lifetime; the handle table stores `(page_id, slot_index)` and relies on it. There is no intra-page compaction: reclamation is whole-page. |
Expand Down Expand Up @@ -589,6 +589,14 @@ Spillway slots carry their own per-slot XXH3 checksum over `page_id ‖ page_byt

The no-spill commit cost is **3 fsyncs**: pre-drain flush (I28) + main-pages flush + superblock. The pre-drain handles a subtle interaction in the commit protocol (see [Commit protocol](#commit-protocol) step 1).

### File permissions and the sidecar path

Both files Chisel creates are created mode **0600**. This is a contract, not an implementation detail: a caller may rely on a database it creates not being readable by other users on the host. The mode applies only at creation, so reopening an existing database never alters permissions its owner deliberately set — with one exception: a *zero-length* file adopted by the create path is tightened to 0600, because an empty file is not yet a database and a permissive empty file at the database path is more likely a squatter than a deliberate choice.

The sidecar needs more than a mode, because its path is fully derived from the database path and is therefore predictable, and because it is created lazily *mid-transaction* under cache pressure rather than at open. A local user who can create entries in the database's directory could otherwise plant `<db>.spillway` and either (a) point it at a file the database owner can write, which the old `truncate(true)` open would have zeroed, or (b) leave a world-readable regular file that Chisel would adopt — writing uncommitted user values somewhere the planter can read, since `mode` does not apply to a file the open did not create.

So the sidecar is created with `O_EXCL | O_NOFOLLOW`. A pre-existing entry is unlinked and retried **only** when it is a plain file, owned by this uid, with exactly one link — the crash-debris case the lifecycle was written for. A symlink, a foreign-owned file, or an extra hard link is treated as tampering and surfaces as a fatal `IoError`, which poisons the handle. Encryption does not substitute for either guard: the hazards are the truncate and the file's ownership, not the confidentiality of the bytes.

### Slot packing and overflow

Values up to `MAX_INLINE_VALUE` (~`PAGE_BODY_SIZE`) are stored inline in a data-page slot. Larger values get an overflow chain, and that path allocates **no data page and no slot at all**: the `HandleEntry` itself carries `HandleFlags::Overflow`, with `page_id` pointing directly at the first chain page and `slot_index = 0` (an unused placeholder, not a real slot). `HandleFlags` is a field of the handle-table entry, not of a data-page slot-directory entry — the slot directory has no flag but `SLOT_FLAG_LIVE`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ let options = Options::default()

**Fatal errors** — storage integrity is in question. Drop the handle and reopen.

`IoError`, `ChecksumMismatch`, `CorruptSuperblock`, `FileSizeMismatch`, `LockFailed`, `UnsupportedFormatVersion`, `UnsupportedPageSize`, `CorruptPage`, `InvalidPageId`, `DecryptionFailed`.
`IoError`, `ChecksumMismatch`, `CorruptSuperblock`, `FileSizeMismatch`, `LockFailed`, `UnsupportedFormatVersion`, `UnsupportedPageSize`, `InvalidFreemapDepth`, `CorruptPage`, `InvalidPageId`, `DecryptionFailed`.

`DecryptionFailed { page_id }` is fatal: an AEAD authentication failure while decrypting an already-read page means the ciphertext or session key can no longer be trusted, so it poisons the handle exactly like `ChecksumMismatch` (see the poison model below). It is distinct from the operational `InvalidEncryptionKey`, which fires at open time when the supplied key unwraps no key slot — before any data page is served.

Expand Down
24 changes: 15 additions & 9 deletions bench/src/chisel_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,21 @@ impl Engine for ChiselEngine {
}

fn delete_many(&mut self, ids: &[Identifier]) -> EngineResult<()> {
// SAFETY: Identifier and chisel::Handle are both
// #[repr(transparent)] over u64, so a slice of Identifier and a
// slice of Handle have identical layout. The borrow ends with
// this call; no aliasing concern; no 'static lifetime escapes.
// Saves the per-call Vec allocation that the safe-collect form
// would require (audit F5).
let handles: &[chisel::Handle] =
unsafe { std::slice::from_raw_parts(ids.as_ptr() as *const chisel::Handle, ids.len()) };
Ok(self.db.delete_many(handles)?)
// This used to reinterpret `&[Identifier]` as `&[chisel::Handle]`
// through `slice::from_raw_parts`, on the strength of both types being
// `#[repr(transparent)]` over u64. The layout guarantee is real, but
// nothing enforced it across the crate boundary: chisel's const
// assertions check only size and align, which a `#[repr(Rust)]
// struct Handle(u64)` also satisfies. Deleting `#[repr(transparent)]`
// would have left both crates building green while this line became UB.
//
// The transmute bought one Vec allocation per `delete_many` call,
// against an operation that performs three fsyncs. That is far below the
// floor this harness can measure, so the safe form costs nothing real
// and removes an unsafe block that no compiler check was guarding.
let handles: Vec<chisel::Handle> =
ids.iter().map(|id| chisel::Handle::from(id.0)).collect();
Ok(self.db.delete_many(&handles)?)
}

fn file_size_bytes(&self) -> EngineResult<u64> {
Expand Down
31 changes: 29 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,23 @@ pub enum ChiselError {
stored: u32,
compiled: u32,
},
// Raised at open time when the superblock's `freemap_depth` exceeds the
// depth the freemap radix can represent. Depth 5 already spans every u64
// page id, so a larger value cannot have been written by any correct
// binary — it is a corrupt or forged field.
//
// Deliberately NOT `CorruptSuperblock`: that variant means "no readable
// superblock at all" and is documented as recoverable by reopening,
// because slot selection may pick a different slot next time. Here the
// superblock parsed and validated fine apart from this one field, and
// every sibling slot carries the same rejected depth — so a reopen
// returns the same error forever. It carries the offending value for the
// same reason `UnsupportedPageSize` does: a bare "corrupt" tells an
// operator nothing about which field to look at.
InvalidFreemapDepth {
stored: u32,
max: u32,
},

// Operational — the caller supplied the wrong key material or none, or
// asked an unencrypted-only build to open an encrypted DB. The on-disk
Expand Down Expand Up @@ -223,6 +240,7 @@ impl ChiselError {
| ChiselError::CorruptPage { .. }
| ChiselError::InvalidPageId { .. }
| ChiselError::UnsupportedPageSize { .. }
| ChiselError::InvalidFreemapDepth { .. }
| ChiselError::DecryptionFailed { .. }
)
}
Expand Down Expand Up @@ -313,6 +331,10 @@ impl fmt::Display for ChiselError {
f,
"page size mismatch: file was written with {stored}-byte pages, this build uses {compiled}-byte pages"
),
ChiselError::InvalidFreemapDepth { stored, max } => write!(
f,
"superblock declares freemap depth {stored}, which exceeds the maximum {max} (corrupt or forged superblock)"
),
ChiselError::NoEncryptionKey => write!(
f,
"database is encrypted but no encryption_key was supplied"
Expand Down Expand Up @@ -571,6 +593,7 @@ mod tests {
| ChiselError::CorruptPage { .. }
| ChiselError::InvalidPageId { .. }
| ChiselError::UnsupportedPageSize { .. }
| ChiselError::InvalidFreemapDepth { .. }
| ChiselError::DecryptionFailed { .. } => true,
}
}
Expand Down Expand Up @@ -616,6 +639,7 @@ mod tests {
stored: 0,
compiled: 0,
},
ChiselError::InvalidFreemapDepth { stored: 0, max: 0 },
ChiselError::NoEncryptionKey,
ChiselError::InvalidEncryptionKey,
ChiselError::EncryptionNotSupported,
Expand All @@ -630,10 +654,13 @@ mod tests {
"is_fatal() disagrees with the documented Fatal/Operational block for {e:?}"
);
}
// Tripwire: exactly 10 variants are fatal today. If this count moves, the
// Tripwire: exactly 11 variants are fatal today. If this count moves, the
// Fatal/Operational split changed — confirm that was intentional (it is a
// breaking change for callers doing error-class matching, per the header).
assert_eq!(all.iter().filter(|e| e.is_fatal()).count(), 10);
// Last moved 10 -> 11 by InvalidFreemapDepth, which is fatal because a
// superblock field outside its representable range cannot be resolved by
// retrying: every sibling slot carries the same value.
assert_eq!(all.iter().filter(|e| e.is_fatal()).count(), 11);
}

// Phase 4: the three operational encryption errors are recoverable (the
Expand Down
Loading