Skip to main content

mongreldb_core/
wal.rs

1//! Append-only, group-commit, torn-write-safe WAL.
2//!
3//! Sub-ms writes come from the fact that [`Wal::append`] only copies bytes into
4//! the OS file buffer (and an in-process [`BufWriter`]); it does **not** fsync.
5//! A timer- or threshold-driven [`Wal::sync`] does the `flush() + sync_all()`
6//! and bumps the epoch.
7
8use crate::epoch::Epoch;
9use crate::manifest::TtlPolicy;
10use crate::rowid::RowId;
11use crate::schema::{ColumnDef, Schema};
12use crate::{MongrelError, Result};
13use crc::{Crc, CRC_32_ISCSI};
14#[cfg(feature = "encryption")]
15use hmac::{Hmac, Mac};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest as _, Sha256};
18use std::fs::{File, OpenOptions};
19use std::io::{BufReader, BufWriter, Read, Write};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use zeroize::Zeroizing;
23
24fn unix_nanos_now() -> u64 {
25    std::time::SystemTime::now()
26        .duration_since(std::time::UNIX_EPOCH)
27        .unwrap_or_default()
28        .as_nanos() as u64
29}
30
31pub const WAL_MAGIC: [u8; 8] = *b"MONGRWAL";
32/// The only WAL format this engine reads or writes. Older segments (v3 and
33/// below) are rejected with [`MongrelError::UnsupportedStorageVersion`].
34pub(crate) const WAL_VERSION: u16 = 4;
35const HEADER_LEN: u64 = 8 + 2 + 4 + 8 + 8 + 32;
36const WAL_FRAME_AAD_DOMAIN: &[u8] = b"mongreldb/wal-frame/v4";
37const WAL_HEAD_MAGIC: [u8; 8] = *b"MONGWHED";
38const WAL_HEAD_VERSION: u16 = 1;
39const WAL_HEAD_FILENAME: &str = "wal-head-v1";
40const WAL_HEAD_AUTH_DOMAIN: &[u8] = b"mongreldb/wal-head/v1";
41const WAL_HEAD_BODY_LEN: usize = 72;
42const WAL_HEAD_LEN: usize = WAL_HEAD_BODY_LEN + 32;
43const MAX_RECOVERY_WAL_BYTES: u64 = 512 * 1024 * 1024;
44const MAX_RECOVERY_WAL_RECORDS: usize = 1_000_000;
45/// Encryption flag stored in reserved[0] of the WAL header.
46const ENC_PLAINTEXT: u8 = 0;
47const ENC_AES_GCM: u8 = 1;
48
49#[derive(Clone, Copy, Debug)]
50struct WalHead {
51    segment_no: u64,
52    durable_len: u64,
53    open_generation: u64,
54    prefix_hash: [u8; 32],
55}
56
57/// `txn_id` reserved for system records (`Flush`) that are not part of any
58/// client transaction.
59pub const SYSTEM_TXN_ID: u64 = 0;
60
61const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
62
63/// One mutation. `Put.rows` is a self-describing Arrow IPC stream (or, for tiny
64/// single-row writes, a compact row batch — both are opaque bytes to the WAL).
65/// `txn_id` groups records into a transaction; the group is sealed by a
66/// [`Op::TxnCommit`] carrying the same `txn_id`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Record {
69    pub seq: Epoch,
70    pub txn_id: u64,
71    pub op: Op,
72}
73
74/// A sorted run made durable as part of a transaction's commit (spec §7.1).
75/// Recovery links these into the table's run list at the commit epoch.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AddedRun {
78    pub table_id: u64,
79    pub run_id: u128,
80    pub row_count: u64,
81    pub level: u8,
82    pub min_row_id: u64,
83    pub max_row_id: u64,
84    pub content_hash: [u8; 32],
85}
86
87/// A schema change logged through the WAL (spec §7.1; full DDL wiring in P2.7).
88/// The schema/column payload is carried as JSON bytes because `Schema`'s
89/// internally-tagged `TypeId` is not representable under the WAL's bincode frame
90/// encoding.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum DdlOp {
93    CreateTable {
94        table_id: u64,
95        name: String,
96        schema_json: Vec<u8>,
97    },
98    DropTable {
99        table_id: u64,
100    },
101    /// Replace one existing column definition with the JSON-encoded
102    /// [`ColumnDef`] produced by native ALTER COLUMN validation.
103    AlterTable {
104        table_id: u64,
105        column_json: Vec<u8>,
106    },
107    /// Rename a live table. The catalog entry's name changes; the `table_id`,
108    /// schema, on-disk layout, and in-memory table object are all untouched
109    /// (the table is keyed by `table_id`, not name). Recovery applies this by
110    /// rewriting the catalog entry's name; it is idempotent when the
111    /// checkpoint already carries `new_name`.
112    RenameTable {
113        table_id: u64,
114        new_name: String,
115    },
116    /// Replace or clear a table's manifest-backed TTL policy.
117    SetTtl {
118        table_id: u64,
119        policy_json: Vec<u8>,
120    },
121    /// Create or update a persistent materialized-view definition. Appended at
122    /// the end of the enum to preserve earlier bincode discriminants.
123    SetMaterializedView {
124        name: String,
125        definition_json: Vec<u8>,
126    },
127    /// Full persistent RLS/masking catalog replacement.
128    SetSecurityCatalog {
129        security_json: Vec<u8>,
130    },
131    /// Create a hidden CTAS build table. Appended to preserve prior bincode
132    /// discriminants.
133    CreateBuildingTable {
134        table_id: u64,
135        build_name: String,
136        intended_name: String,
137        query_id: String,
138        created_at_unix_nanos: u64,
139        schema_json: Vec<u8>,
140    },
141    /// Atomically publish a hidden CTAS build under its intended live name.
142    PublishBuildingTable {
143        table_id: u64,
144        new_name: String,
145    },
146    /// Create a hidden replacement build while its original table stays live.
147    /// Appended to preserve prior bincode discriminants.
148    CreateRebuildingTable {
149        table_id: u64,
150        build_name: String,
151        intended_name: String,
152        query_id: String,
153        created_at_unix_nanos: u64,
154        replaces_table_id: u64,
155        schema_json: Vec<u8>,
156    },
157    /// Atomically replace a live table with its completed hidden build.
158    ReplaceBuildingTable {
159        table_id: u64,
160        replaced_table_id: u64,
161        new_name: String,
162    },
163    /// Persist SQLite-compatible application metadata. Appended to preserve
164    /// all earlier bincode discriminants.
165    SetSqlPragma {
166        key: String,
167        value: i64,
168    },
169    /// Exact post-commit catalog image. Appended after operation-specific DDL
170    /// so recovery, PITR, and logical replication preserve all derived catalog
171    /// mutations. Appended last to preserve prior bincode discriminants.
172    CatalogSnapshot {
173        catalog_json: Vec<u8>,
174    },
175    /// Remove connector state when an external-table generation is created or
176    /// dropped. Appended last to preserve prior bincode discriminants.
177    ResetExternalTableState {
178        name: String,
179        generation_epoch: u64,
180    },
181    /// One encoded [`mongreldb_log::CommandEnvelope`] proposed through the
182    /// standalone commit log (spec §9.4, FND-004). Opaque to the engine:
183    /// recovery, PITR, and CDC ignore it (every replay path wildcards unknown
184    /// DDL operations). Appended last to preserve prior bincode discriminants.
185    Command {
186        payload: Vec<u8>,
187    },
188}
189
190impl DdlOp {
191    /// Encode a schema for [`DdlOp::CreateTable`].
192    pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
193        serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
194    }
195
196    /// Decode a schema carried by [`DdlOp::CreateTable`].
197    pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
198        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
199    }
200
201    pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
202        serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
203    }
204
205    pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
206        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
207    }
208
209    pub fn encode_ttl(policy: Option<TtlPolicy>) -> Result<Vec<u8>> {
210        serde_json::to_vec(&policy).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
211    }
212
213    pub fn decode_ttl(bytes: &[u8]) -> Result<Option<TtlPolicy>> {
214        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
215    }
216
217    pub fn encode_materialized_view(
218        definition: &crate::catalog::MaterializedViewEntry,
219    ) -> Result<Vec<u8>> {
220        serde_json::to_vec(definition)
221            .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
222    }
223
224    pub fn decode_materialized_view(bytes: &[u8]) -> Result<crate::catalog::MaterializedViewEntry> {
225        serde_json::from_slice(bytes)
226            .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
227    }
228
229    pub fn encode_catalog(catalog: &crate::catalog::Catalog) -> Result<Vec<u8>> {
230        crate::catalog::encode(catalog)
231    }
232
233    pub fn decode_catalog(bytes: &[u8]) -> Result<crate::catalog::Catalog> {
234        crate::catalog::decode(bytes)
235    }
236
237    pub fn encode_security(security: &crate::security::SecurityCatalog) -> Result<Vec<u8>> {
238        serde_json::to_vec(security)
239            .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
240    }
241
242    pub fn decode_security(bytes: &[u8]) -> Result<crate::security::SecurityCatalog> {
243        serde_json::from_slice(bytes)
244            .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub enum Op {
250    Put {
251        table_id: u64,
252        rows: Vec<u8>,
253    },
254    Delete {
255        table_id: u64,
256        row_ids: Vec<RowId>,
257    },
258    TruncateTable {
259        table_id: u64,
260    },
261    /// Durable module-owned state for an external table. The payload is opaque
262    /// to the core; recovery writes the last committed payloads back under
263    /// `_vtab/<name>/state.json`.
264    ExternalTableState {
265        name: String,
266        state: Vec<u8>,
267    },
268    /// System marker (txn_id == [`SYSTEM_TXN_ID`]): everything up to
269    /// `flushed_epoch` for `table_id` is durable in a sorted run, so recovery
270    /// may skip replaying older records for that table.
271    Flush {
272        table_id: u64,
273        flushed_epoch: u64,
274    },
275    /// Seals a transaction: every earlier record with the same `txn_id` is
276    /// committed and becomes visible at `epoch`.
277    TxnCommit {
278        epoch: u64,
279        added_runs: Vec<AddedRun>,
280    },
281    /// Aborts a transaction; its staged records are discarded on recovery.
282    TxnAbort,
283    Ddl(DdlOp),
284    /// Row image captured immediately before a Delete in the same committed
285    /// transaction. Recovery ignores it; durable CDC uses it for delete/update
286    /// deltas. Appended last to preserve all earlier bincode discriminants.
287    BeforeImage {
288        table_id: u64,
289        row_id: RowId,
290        row: Vec<u8>,
291    },
292    /// Commit-timestamp ledger record written immediately before TxnCommit.
293    /// Carries the physical component of the commit's HLC timestamp (spec
294    /// §8.1, micros × 1_000) for transactions sequenced through the commit
295    /// log, and wall-clock UTC nanoseconds on the legacy single-table/DDL
296    /// append paths. PITR uses this durable ledger to map timestamp cutoffs
297    /// to commit epochs.
298    CommitTimestamp {
299        unix_nanos: u64,
300    },
301    /// Logical rows for a transaction whose normal recovery path links an
302    /// immutable spilled run. Ordinary recovery ignores this duplicate
303    /// payload; PITR and CDC use it after the source run has been compacted or
304    /// garbage-collected. Chunks stay below the WAL reader frame limit.
305    SpilledRows {
306        table_id: u64,
307        rows: Vec<u8>,
308    },
309}
310
311impl Record {
312    pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
313        Self { seq, txn_id, op }
314    }
315}
316
317/// Group-commit WAL writer. Append is O(buffer copy) and never fsyncs; callers
318/// (or a timer) drive [`Wal::sync`].
319pub struct Wal {
320    file: BufWriter<File>,
321    path: PathBuf,
322    /// Next sequence number to assign; equals `last_assigned.0 + 1`.
323    next_seq: u64,
324    unflushed_bytes: u64,
325    /// `sync()` automatically once this many bytes are buffered (0 = manual).
326    sync_byte_threshold: u64,
327    /// Optional AEAD cipher for frame-level encryption. When present, each
328    /// frame's payload is encrypted before writing.
329    cipher: Option<Box<dyn crate::encryption::Cipher>>,
330    /// Persisted segment number for this WAL segment. Forms the high 8 bytes
331    /// (big-endian) of the 12-byte AES-GCM nonce; the low 4 are the per-segment
332    /// frame counter. The WAL DEK is constant across all segments, so cross-
333    /// segment nonce uniqueness rests entirely on this number, which is drawn
334    /// from the canonical monotonically increasing segment sequence. Existing
335    /// segment paths are never truncated or reused.
336    segment_no: u64,
337    /// Per-segment frame counter. Occupies the low 4 bytes of the nonce, so
338    /// `append_record` refuses to write past `u32::MAX` frames in one segment
339    /// (that would truncate the counter and reuse a nonce under the DEK).
340    /// Segments rotate at flush long before this, so it is unreachable in
341    /// practice — but enforced rather than assumed.
342    frame_seq: u64,
343    previous_segment_hash: [u8; 32],
344    header_binding: [u8; 32],
345}
346
347impl Wal {
348    /// Create a new WAL segment. Existing paths are never replaced.
349    pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
350        let path = path.as_ref();
351        let segment_no = segment_number_from_path(path).unwrap_or(0);
352        Self::create_with_cipher(path, epoch_created, None, segment_no)
353    }
354
355    /// Create a new WAL segment with optional frame-level encryption. The
356    /// persisted `segment_no` namespaces AES-GCM nonces across segments under
357    /// the constant WAL DEK (spec §7.1, review fix #23).
358    pub fn create_with_cipher(
359        path: impl AsRef<Path>,
360        epoch_created: Epoch,
361        cipher: Option<Box<dyn crate::encryption::Cipher>>,
362        segment_no: u64,
363    ) -> Result<Self> {
364        Self::create_chained(path, epoch_created, cipher, segment_no, [0; 32])
365    }
366
367    fn create_chained(
368        path: impl AsRef<Path>,
369        epoch_created: Epoch,
370        cipher: Option<Box<dyn crate::encryption::Cipher>>,
371        segment_no: u64,
372        previous_segment_hash: [u8; 32],
373    ) -> Result<Self> {
374        let path = path.as_ref().to_path_buf();
375        let file = OpenOptions::new()
376            .create_new(true)
377            .read(true)
378            .write(true)
379            .open(&path)?;
380        let wal = Self::create_chained_from_file(
381            file,
382            path,
383            epoch_created,
384            cipher,
385            segment_no,
386            previous_segment_hash,
387        )?;
388        if let Some(parent) = wal.path.parent() {
389            crate::durable_file::sync_directory(parent)?;
390        }
391        Ok(wal)
392    }
393
394    fn create_chained_in(
395        wal_root: &crate::durable_file::DurableRoot,
396        segment_no: u64,
397        epoch_created: Epoch,
398        cipher: Option<Box<dyn crate::encryption::Cipher>>,
399        previous_segment_hash: [u8; 32],
400    ) -> Result<Self> {
401        let name = segment_filename(segment_no);
402        let file = wal_root.create_regular_new(&name)?;
403        let wal = Self::create_chained_from_file(
404            file,
405            wal_root.canonical_path().join(&name),
406            epoch_created,
407            cipher,
408            segment_no,
409            previous_segment_hash,
410        )?;
411        wal_root.sync_entry_parent(&name)?;
412        Ok(wal)
413    }
414
415    fn create_chained_from_file(
416        file: File,
417        path: PathBuf,
418        epoch_created: Epoch,
419        cipher: Option<Box<dyn crate::encryption::Cipher>>,
420        segment_no: u64,
421        previous_segment_hash: [u8; 32],
422    ) -> Result<Self> {
423        let next_seq = epoch_created
424            .0
425            .checked_add(1)
426            .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
427        let mut wal = Self {
428            file: BufWriter::with_capacity(1 << 20, file),
429            path,
430            next_seq,
431            unflushed_bytes: 0,
432            sync_byte_threshold: 64 * 1024,
433            cipher,
434            segment_no,
435            frame_seq: 0,
436            previous_segment_hash,
437            header_binding: [0; 32],
438        };
439        wal.write_header(epoch_created)?;
440        // A WAL segment is an authoritative commit prerequisite. Persist both
441        // its header and its directory entry before any caller can append a
442        // transaction that later reports a durable commit.
443        wal.sync()?;
444        Ok(wal)
445    }
446
447    /// Append a record belonging to transaction `txn_id`. Assigns the next
448    /// monotonic sequence (the first record after a WAL created at `E` gets
449    /// `E + 1`), writes it, and returns the assigned sequence. Does NOT fsync —
450    /// call [`Wal::sync`] (or rely on the byte threshold). The WAL sequence is
451    /// independent of the row commit epoch; the engine tracks commit epochs
452    /// separately.
453    pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
454        let seq = Epoch(self.next_seq);
455        let next_seq = self
456            .next_seq
457            .checked_add(1)
458            .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
459        self.append_record(&Record::new(seq, txn_id, op))?;
460        self.next_seq = next_seq;
461        Ok(seq)
462    }
463
464    /// Append a system record (txn_id == [`SYSTEM_TXN_ID`]), e.g. `Flush`.
465    pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
466        self.append_txn(SYSTEM_TXN_ID, op)
467    }
468
469    fn append_record(&mut self, record: &Record) -> Result<()> {
470        let payload = bincode::serialize(record)?;
471
472        // Encrypt the payload if a cipher is present. The nonce is prepended
473        // to the ciphertext so the reader can extract it from a single read.
474        let frame_payload = if let Some(cipher) = &self.cipher {
475            // The frame counter occupies the low 4 bytes of the nonce. Refuse to
476            // wrap it — a wrapped counter would reuse a nonce under the constant
477            // WAL DEK (catastrophic for AES-GCM). Unreachable in practice
478            // (segments rotate at flush), but enforced.
479            if self.frame_seq > u32::MAX as u64 {
480                return Err(MongrelError::Full(
481                    "wal segment frame counter exhausted (2^32); rotate the segment".into(),
482                ));
483            }
484            let nonce = self.frame_nonce();
485            let ciphertext_len = payload.len().checked_add(16).ok_or_else(|| {
486                MongrelError::InvalidArgument("wal payload length overflow".into())
487            })?;
488            let aad = wal_frame_aad(
489                &self.header_binding,
490                self.segment_no,
491                self.frame_seq as u32,
492                record.seq.0,
493                record.txn_id,
494                ciphertext_len as u64,
495            );
496            let ciphertext = cipher.encrypt_page_with_aad(&nonce, &payload, &aad)?;
497            self.frame_seq += 1;
498            ciphertext
499        } else {
500            payload
501        };
502
503        let len = frame_payload.len();
504        if len > u32::MAX as usize {
505            return Err(MongrelError::InvalidArgument(format!(
506                "wal payload too large: {len} bytes"
507            )));
508        }
509        // CRC covers seq + txn_id + (encrypted) payload.
510        let mut digest = CRC32C.digest();
511        digest.update(&record.seq.0.to_le_bytes());
512        digest.update(&record.txn_id.to_le_bytes());
513        digest.update(&frame_payload);
514        let crc_val = digest.finalize();
515
516        self.file.write_all(&(len as u32).to_le_bytes())?;
517        self.file.write_all(&crc_val.to_le_bytes())?;
518        self.file.write_all(&record.seq.0.to_le_bytes())?;
519        self.file.write_all(&record.txn_id.to_le_bytes())?;
520        self.file.write_all(&frame_payload)?;
521        self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
522        if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
523            self.sync()?;
524        }
525        Ok(())
526    }
527
528    /// Build the 12-byte AES-GCM nonce for the current frame:
529    /// `[segment_no: 8B BE][frame_seq: 4B LE]`. `segment_no` is persisted and
530    /// monotonic across segments; the counter is unique within a segment, so
531    /// nonces never repeat under the constant WAL DEK — provided
532    /// `frame_seq <= u32::MAX`, which `append_record` enforces before calling.
533    fn frame_nonce(&self) -> [u8; 12] {
534        frame_nonce_for(self.segment_no, self.frame_seq as u32)
535    }
536
537    /// Flush the buffer and fsync the file. This is the durability point.
538    pub fn sync(&mut self) -> Result<()> {
539        self.file.flush()?;
540        self.file.get_ref().sync_all()?;
541        self.unflushed_bytes = 0;
542        Ok(())
543    }
544
545    /// Pending bytes not yet fsynced.
546    #[inline]
547    pub fn unflushed_bytes(&self) -> u64 {
548        self.unflushed_bytes
549    }
550
551    /// The next sequence number this writer will assign (i.e. last assigned + 1).
552    /// Exposed so a shared-WAL group-sync can report the durable high-water mark.
553    #[inline]
554    pub fn next_seq_val(&self) -> u64 {
555        self.next_seq
556    }
557
558    /// Tune the auto-sync threshold (bytes of buffered WAL before an automatic
559    /// `fsync`). `0` disables auto-sync entirely (manual [`Wal::sync`] only) —
560    /// useful for latency benchmarks and for grouping many writes under one
561    /// explicit commit.
562    pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
563        self.sync_byte_threshold = threshold;
564    }
565
566    pub fn path(&self) -> &Path {
567        &self.path
568    }
569
570    /// Publish a fully synced staging segment under its final no-replace name.
571    /// The old segment remains authoritative until this succeeds.
572    pub(crate) fn publish_as(mut self, destination: PathBuf) -> Result<Self> {
573        self.sync()?;
574        crate::durable_file::rename(&self.path, &destination)?;
575        self.path = destination;
576        Ok(self)
577    }
578
579    fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
580        let enc_flag = if self.cipher.is_some() {
581            ENC_AES_GCM
582        } else {
583            ENC_PLAINTEXT
584        };
585        let header = encode_wal_header(
586            enc_flag,
587            epoch_created.0,
588            self.segment_no,
589            &self.previous_segment_hash,
590        );
591        self.header_binding = Sha256::digest(&header).into();
592        self.file.write_all(&header)?;
593        self.unflushed_bytes = 0;
594        Ok(())
595    }
596}
597
598impl Drop for Wal {
599    fn drop(&mut self) {
600        let _ = self.file.flush();
601    }
602}
603
604fn encode_wal_header(
605    encryption: u8,
606    epoch_created: u64,
607    segment_no: u64,
608    previous_segment_hash: &[u8; 32],
609) -> Vec<u8> {
610    let mut header = Vec::with_capacity(HEADER_LEN as usize);
611    header.extend_from_slice(&WAL_MAGIC);
612    header.extend_from_slice(&WAL_VERSION.to_le_bytes());
613    header.extend_from_slice(&[encryption, 0, 0, 0]);
614    header.extend_from_slice(&epoch_created.to_le_bytes());
615    header.extend_from_slice(&segment_no.to_le_bytes());
616    header.extend_from_slice(previous_segment_hash);
617    header
618}
619
620fn wal_frame_aad(
621    header_binding: &[u8; 32],
622    segment_no: u64,
623    frame_seq: u32,
624    record_seq: u64,
625    txn_id: u64,
626    ciphertext_len: u64,
627) -> Vec<u8> {
628    let mut aad = Vec::with_capacity(WAL_FRAME_AAD_DOMAIN.len() + 68);
629    aad.extend_from_slice(WAL_FRAME_AAD_DOMAIN);
630    aad.extend_from_slice(header_binding);
631    aad.extend_from_slice(&segment_no.to_le_bytes());
632    aad.extend_from_slice(&frame_seq.to_le_bytes());
633    aad.extend_from_slice(&record_seq.to_le_bytes());
634    aad.extend_from_slice(&txn_id.to_le_bytes());
635    aad.extend_from_slice(&ciphertext_len.to_le_bytes());
636    aad
637}
638
639fn wal_head_body(head: &WalHead, encrypted: bool) -> [u8; WAL_HEAD_BODY_LEN] {
640    let mut body = [0_u8; WAL_HEAD_BODY_LEN];
641    body[..8].copy_from_slice(&WAL_HEAD_MAGIC);
642    body[8..10].copy_from_slice(&WAL_HEAD_VERSION.to_le_bytes());
643    body[10] = if encrypted {
644        ENC_AES_GCM
645    } else {
646        ENC_PLAINTEXT
647    };
648    body[16..24].copy_from_slice(&head.segment_no.to_le_bytes());
649    body[24..32].copy_from_slice(&head.durable_len.to_le_bytes());
650    body[32..40].copy_from_slice(&head.open_generation.to_le_bytes());
651    body[40..72].copy_from_slice(&head.prefix_hash);
652    body
653}
654
655fn wal_head_auth(
656    body: &[u8; WAL_HEAD_BODY_LEN],
657    wal_dek: Option<&Zeroizing<[u8; 32]>>,
658) -> Result<[u8; 32]> {
659    if let Some(key) = wal_dek {
660        #[cfg(feature = "encryption")]
661        {
662            let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&key[..])
663                .map_err(|error| MongrelError::Encryption(error.to_string()))?;
664            mac.update(WAL_HEAD_AUTH_DOMAIN);
665            mac.update(body);
666            return Ok(mac.finalize().into_bytes().into());
667        }
668        #[cfg(not(feature = "encryption"))]
669        {
670            let _ = key;
671            return Err(MongrelError::Encryption(
672                "encryption feature disabled but a WAL DEK was supplied".into(),
673            ));
674        }
675    }
676    let mut hash = Sha256::new();
677    hash.update(WAL_HEAD_AUTH_DOMAIN);
678    hash.update(body);
679    Ok(hash.finalize().into())
680}
681
682fn encode_wal_head(
683    head: &WalHead,
684    wal_dek: Option<&Zeroizing<[u8; 32]>>,
685) -> Result<[u8; WAL_HEAD_LEN]> {
686    let body = wal_head_body(head, wal_dek.is_some());
687    let auth = wal_head_auth(&body, wal_dek)?;
688    let mut encoded = [0_u8; WAL_HEAD_LEN];
689    encoded[..WAL_HEAD_BODY_LEN].copy_from_slice(&body);
690    encoded[WAL_HEAD_BODY_LEN..].copy_from_slice(&auth);
691    Ok(encoded)
692}
693
694fn decode_wal_head(encoded: &[u8], wal_dek: Option<&Zeroizing<[u8; 32]>>) -> Result<WalHead> {
695    if encoded.len() != WAL_HEAD_LEN {
696        return Err(MongrelError::CorruptWal {
697            offset: 0,
698            reason: format!(
699                "WAL head is {} bytes, expected {WAL_HEAD_LEN}",
700                encoded.len()
701            ),
702        });
703    }
704    let body: &[u8; WAL_HEAD_BODY_LEN] =
705        encoded[..WAL_HEAD_BODY_LEN]
706            .try_into()
707            .map_err(|_| MongrelError::CorruptWal {
708                offset: 0,
709                reason: "invalid WAL head body".into(),
710            })?;
711    if body[..8] != WAL_HEAD_MAGIC {
712        return Err(MongrelError::CorruptWal {
713            offset: 0,
714            reason: "invalid WAL head magic".into(),
715        });
716    }
717    let version = u16::from_le_bytes([body[8], body[9]]);
718    if version != WAL_HEAD_VERSION {
719        return Err(MongrelError::CorruptWal {
720            offset: 8,
721            reason: format!("unsupported WAL head version {version}"),
722        });
723    }
724    let expected_mode = if wal_dek.is_some() {
725        ENC_AES_GCM
726    } else {
727        ENC_PLAINTEXT
728    };
729    if body[10] != expected_mode || body[11..16] != [0; 5] {
730        return Err(MongrelError::CorruptWal {
731            offset: 10,
732            reason: "WAL head authentication mode or reserved bytes differ".into(),
733        });
734    }
735    let expected_auth = wal_head_auth(body, wal_dek)?;
736    if encoded[WAL_HEAD_BODY_LEN..] != expected_auth {
737        return Err(MongrelError::CorruptWal {
738            offset: WAL_HEAD_BODY_LEN as u64,
739            reason: "WAL head authentication failed".into(),
740        });
741    }
742    let mut prefix_hash = [0_u8; 32];
743    prefix_hash.copy_from_slice(&body[40..72]);
744    let mut segment_no = [0_u8; 8];
745    segment_no.copy_from_slice(&body[16..24]);
746    let mut durable_len = [0_u8; 8];
747    durable_len.copy_from_slice(&body[24..32]);
748    let mut open_generation = [0_u8; 8];
749    open_generation.copy_from_slice(&body[32..40]);
750    Ok(WalHead {
751        segment_no: u64::from_le_bytes(segment_no),
752        durable_len: u64::from_le_bytes(durable_len),
753        open_generation: u64::from_le_bytes(open_generation),
754        prefix_hash,
755    })
756}
757
758fn hash_file_prefix(file: File, length: u64) -> Result<[u8; 32]> {
759    if file.metadata()?.len() < length {
760        return Err(MongrelError::CorruptWal {
761            offset: length,
762            reason: "WAL file is shorter than its durable head".into(),
763        });
764    }
765    let mut reader = BufReader::new(file).take(length);
766    let mut hash = Sha256::new();
767    std::io::copy(&mut reader, &mut HashWriter(&mut hash))?;
768    Ok(hash.finalize().into())
769}
770
771struct HashWriter<'a>(&'a mut Sha256);
772
773impl Write for HashWriter<'_> {
774    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
775        self.0.update(bytes);
776        Ok(bytes.len())
777    }
778
779    fn flush(&mut self) -> std::io::Result<()> {
780        Ok(())
781    }
782}
783
784fn hash_segment(wal_root: &crate::durable_file::DurableRoot, segment_no: u64) -> Result<[u8; 32]> {
785    let name = segment_filename(segment_no);
786    let file = wal_root.open_regular(&name)?;
787    let length = file.metadata()?.len();
788    hash_file_prefix(file, length)
789}
790
791fn read_wal_head(
792    root: &crate::durable_file::DurableRoot,
793    wal_dek: Option<&Zeroizing<[u8; 32]>>,
794) -> Result<Option<WalHead>> {
795    let relative = Path::new("_meta").join(WAL_HEAD_FILENAME);
796    match root.entry_exists(&relative) {
797        Ok(true) => {}
798        Ok(false) => return Ok(None),
799        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
800        Err(error) => return Err(error.into()),
801    }
802    let mut file = root.open_regular(&relative)?;
803    let length = file.metadata()?.len();
804    if length != WAL_HEAD_LEN as u64 {
805        return Err(MongrelError::CorruptWal {
806            offset: 0,
807            reason: format!("WAL head is {length} bytes, expected {WAL_HEAD_LEN}"),
808        });
809    }
810    let mut encoded = [0_u8; WAL_HEAD_LEN];
811    file.read_exact(&mut encoded)?;
812    decode_wal_head(&encoded, wal_dek).map(Some)
813}
814
815fn write_wal_head(
816    root: &crate::durable_file::DurableRoot,
817    wal_root: &crate::durable_file::DurableRoot,
818    segment_no: u64,
819    open_generation: u64,
820    wal_dek: Option<&Zeroizing<[u8; 32]>>,
821) -> Result<WalHead> {
822    let name = segment_filename(segment_no);
823    let file = wal_root.open_regular(&name)?;
824    let durable_len = file.metadata()?.len();
825    let head = WalHead {
826        segment_no,
827        durable_len,
828        open_generation,
829        prefix_hash: hash_file_prefix(file, durable_len)?,
830    };
831    root.create_directory_all("_meta")?;
832    root.write_atomic(
833        Path::new("_meta").join(WAL_HEAD_FILENAME),
834        &encode_wal_head(&head, wal_dek)?,
835    )?;
836    Ok(head)
837}
838
839/// Streaming reader used by recovery. Only a physically short final frame is
840/// an admissible crash-torn tail; CRC, authentication, and decode failures are
841/// corruption.
842pub struct WalReader {
843    inner: BufReader<File>,
844    pos: u64,
845    file_len: u64,
846    /// True if frames are encrypted (enc_flag in header).
847    encrypted: bool,
848    /// Optional cipher for decryption.
849    cipher: Option<Box<dyn crate::encryption::Cipher>>,
850    version: u16,
851    segment_no: u64,
852    previous_segment_hash: [u8; 32],
853    header_binding: [u8; 32],
854    frame_seq: u64,
855}
856
857impl WalReader {
858    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
859        Self::open_with_cipher(path, None)
860    }
861
862    /// Open a WAL segment for reading, optionally with a decryption cipher.
863    pub fn open_with_cipher(
864        path: impl AsRef<Path>,
865        cipher: Option<Box<dyn crate::encryption::Cipher>>,
866    ) -> Result<Self> {
867        Self::open_with_cipher_expected(path, cipher, None)
868    }
869
870    fn open_with_cipher_expected(
871        path: impl AsRef<Path>,
872        cipher: Option<Box<dyn crate::encryption::Cipher>>,
873        expected_segment_no: Option<u64>,
874    ) -> Result<Self> {
875        let path = path.as_ref();
876        let expected_segment_no = expected_segment_no.or_else(|| segment_number_from_path(path));
877        let file = crate::durable_file::open_regular_nofollow(path)?;
878        Self::open_file_with_cipher_expected(file, cipher, expected_segment_no)
879    }
880
881    fn open_file_with_cipher_expected(
882        mut file: File,
883        cipher: Option<Box<dyn crate::encryption::Cipher>>,
884        expected_segment_no: Option<u64>,
885    ) -> Result<Self> {
886        let file_len = file.metadata()?.len();
887        let mut magic = [0u8; 8];
888        file.read_exact(&mut magic)?;
889        if magic != WAL_MAGIC {
890            return Err(MongrelError::MagicMismatch {
891                what: "wal",
892                expected: WAL_MAGIC,
893                got: magic,
894            });
895        }
896        let mut version_buf = [0u8; 2];
897        file.read_exact(&mut version_buf)?;
898        let version = u16::from_le_bytes(version_buf);
899        if version != WAL_VERSION {
900            return Err(MongrelError::UnsupportedStorageVersion {
901                component: "wal",
902                found: version,
903                supported: WAL_VERSION,
904            });
905        }
906        let mut reserved = [0u8; 4];
907        file.read_exact(&mut reserved)?;
908        if !matches!(reserved[0], ENC_PLAINTEXT | ENC_AES_GCM) || reserved[1..] != [0, 0, 0] {
909            return Err(MongrelError::CorruptWal {
910                offset: 10,
911                reason: "invalid WAL header flags".into(),
912            });
913        }
914        let encrypted = reserved[0] == ENC_AES_GCM;
915        let mut epoch_buf = [0u8; 8];
916        file.read_exact(&mut epoch_buf)?;
917        let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
918        let mut previous_segment_hash = [0_u8; 32];
919        let segment_no = {
920            let mut segment = [0_u8; 8];
921            file.read_exact(&mut segment)?;
922            file.read_exact(&mut previous_segment_hash)?;
923            u64::from_le_bytes(segment)
924        };
925        if expected_segment_no.is_some_and(|expected| expected != segment_no) {
926            return Err(MongrelError::CorruptWal {
927                offset: 0,
928                reason: format!(
929                    "WAL header segment {segment_no} does not match filename segment {}",
930                    expected_segment_no.unwrap()
931                ),
932            });
933        }
934        let pos = HEADER_LEN;
935        let header_binding = Sha256::digest(encode_wal_header(
936            reserved[0],
937            _epoch_created.0,
938            segment_no,
939            &previous_segment_hash,
940        ))
941        .into();
942        if encrypted && cipher.is_none() {
943            return Err(MongrelError::Decryption(
944                "WAL is encrypted but no passphrase or key was provided. \
945                 Use Table::open_encrypted or Table::open_with_key."
946                    .into(),
947            ));
948        }
949        Ok(Self {
950            inner: BufReader::new(file),
951            pos,
952            file_len,
953            encrypted,
954            cipher,
955            version,
956            segment_no,
957            previous_segment_hash,
958            header_binding,
959            frame_seq: 0,
960        })
961    }
962
963    /// Read the next record. Returns `Ok(None)` at a clean end-of-records
964    /// (zero-length marker or EOF), and `Err(TornWrite)` for a partial record.
965    pub fn next_record(&mut self) -> Result<Option<Record>> {
966        if self.pos == self.file_len {
967            return Ok(None);
968        }
969        if self.file_len.saturating_sub(self.pos) < 4 {
970            return Err(MongrelError::TornWrite { offset: self.pos });
971        }
972        let mut len_buf = [0u8; 4];
973        match self.inner.read_exact(&mut len_buf) {
974            Ok(()) => {}
975            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
976                return Err(MongrelError::TornWrite { offset: self.pos });
977            }
978            Err(e) => return Err(e.into()),
979        }
980        let len = u32::from_le_bytes(len_buf) as usize;
981        if len == 0 {
982            return Err(MongrelError::CorruptWal {
983                offset: self.pos,
984                reason: "zero-length WAL frames are not valid".into(),
985            });
986        }
987        // A runaway length (torn header or garbage) would trigger a huge
988        // allocation; treat anything past the cap as a torn write.
989        const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
990        if len > MAX_RECORD_LEN {
991            return Err(MongrelError::CorruptWal {
992                offset: self.pos,
993                reason: format!("WAL frame length {len} exceeds limit"),
994            });
995        }
996
997        let record_start = self.pos;
998        let mut rest = vec![0u8; 4 + 8 + 8 + len];
999        match self.inner.read_exact(&mut rest) {
1000            Ok(()) => {}
1001            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1002                return Err(MongrelError::TornWrite {
1003                    offset: record_start,
1004                });
1005            }
1006            Err(e) => return Err(e.into()),
1007        }
1008        let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
1009        let seq = u64::from_le_bytes([
1010            rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
1011        ]);
1012        let txn_id = u64::from_le_bytes([
1013            rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
1014        ]);
1015        let payload = &rest[20..];
1016
1017        let mut digest = CRC32C.digest();
1018        digest.update(&seq.to_le_bytes());
1019        digest.update(&txn_id.to_le_bytes());
1020        digest.update(payload);
1021        if digest.finalize() != crc_val {
1022            return Err(MongrelError::CorruptWal {
1023                offset: record_start,
1024                reason: "crc mismatch".into(),
1025            });
1026        }
1027
1028        // Decrypt if encrypted.
1029        let plaintext = if self.encrypted {
1030            let Some(cipher) = &self.cipher else {
1031                return Err(MongrelError::Decryption(
1032                    "WAL is encrypted but no cipher was provided".into(),
1033                ));
1034            };
1035            let minimum = 16;
1036            if payload.len() < minimum {
1037                return Err(MongrelError::CorruptWal {
1038                    offset: record_start,
1039                    reason: "encrypted frame too short".into(),
1040                });
1041            }
1042            if self.frame_seq > u32::MAX as u64 {
1043                return Err(MongrelError::CorruptWal {
1044                    offset: record_start,
1045                    reason: "WAL frame counter exceeds u32".into(),
1046                });
1047            }
1048            let expected_nonce = frame_nonce_for(self.segment_no, self.frame_seq as u32);
1049            let aad = wal_frame_aad(
1050                &self.header_binding,
1051                self.segment_no,
1052                self.frame_seq as u32,
1053                seq,
1054                txn_id,
1055                payload.len() as u64,
1056            );
1057            cipher
1058                .decrypt_page_with_aad(&expected_nonce, payload, &aad)
1059                .map_err(|e| {
1060                    MongrelError::Decryption(format!(
1061                        "WAL frame decryption failed — wrong passphrase or key? ({e})"
1062                    ))
1063                })?
1064        } else {
1065            payload.to_vec()
1066        };
1067
1068        let record: Record =
1069            bincode::deserialize(&plaintext).map_err(|error| MongrelError::CorruptWal {
1070                offset: record_start,
1071                reason: format!("WAL record decode failed: {error}"),
1072            })?;
1073        if record.seq.0 != seq || record.txn_id != txn_id {
1074            return Err(MongrelError::CorruptWal {
1075                offset: record_start,
1076                reason: "WAL outer and authenticated record identity differ".into(),
1077            });
1078        }
1079        self.frame_seq += 1;
1080        self.pos += 4 + 4 + 8 + 8 + len as u64;
1081        Ok(Some(record))
1082    }
1083
1084    fn constrain_to_durable_len(&mut self, durable_len: u64) -> Result<()> {
1085        if durable_len < self.pos || durable_len > self.file_len {
1086            return Err(MongrelError::CorruptWal {
1087                offset: durable_len,
1088                reason: format!(
1089                    "WAL durable length {durable_len} is outside [{}, {}]",
1090                    self.pos, self.file_len
1091                ),
1092            });
1093        }
1094        self.file_len = durable_len;
1095        Ok(())
1096    }
1097
1098    /// Replay all cleanly-committed records. A torn tail (crash mid-append or a
1099    /// partially-flushed last frame) is treated as end-of-log and truncated —
1100    /// the valid prefix is returned. A CRC failure or short read that is
1101    /// followed by a well-formed frame is treated as **interior corruption**
1102    /// and surfaces as [`MongrelError::CorruptWal`] (spec §8.4, review fix #22).
1103    pub fn replay(&mut self) -> Result<Vec<Record>> {
1104        self.replay_with_tail_policy(true)
1105    }
1106
1107    fn replay_strict(&mut self) -> Result<Vec<Record>> {
1108        self.replay_with_tail_policy(false)
1109    }
1110
1111    fn replay_bounded(&mut self, max_records: usize, allow_torn_tail: bool) -> Result<Vec<Record>> {
1112        let mut out = Vec::new();
1113        loop {
1114            match self.next_record() {
1115                Ok(Some(record)) => {
1116                    if out.len() >= max_records {
1117                        return Err(MongrelError::ResourceLimitExceeded {
1118                            resource: "WAL recovery records",
1119                            requested: max_records.saturating_add(1),
1120                            limit: max_records,
1121                        });
1122                    }
1123                    out.push(record);
1124                }
1125                Ok(None) => break,
1126                Err(MongrelError::TornWrite { offset }) => {
1127                    if !allow_torn_tail {
1128                        return Err(MongrelError::CorruptWal {
1129                            offset,
1130                            reason: "torn tail in a non-final WAL segment".into(),
1131                        });
1132                    }
1133                    if self.valid_frame_follows()? {
1134                        return Err(MongrelError::CorruptWal {
1135                            offset,
1136                            reason: "interior torn frame followed by a valid frame".into(),
1137                        });
1138                    }
1139                    break;
1140                }
1141                Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1142                Err(error) => return Err(error),
1143            }
1144        }
1145        Ok(out)
1146    }
1147
1148    fn replay_with_tail_policy(&mut self, allow_torn_tail: bool) -> Result<Vec<Record>> {
1149        let mut out = Vec::new();
1150        loop {
1151            match self.next_record() {
1152                Ok(Some(rec)) => out.push(rec),
1153                Ok(None) => break,
1154                Err(MongrelError::TornWrite { offset }) => {
1155                    if !allow_torn_tail {
1156                        return Err(MongrelError::CorruptWal {
1157                            offset,
1158                            reason: "torn tail in a non-final WAL segment".into(),
1159                        });
1160                    }
1161                    // Partial trailing frame: clean EOF unless a valid frame
1162                    // follows it (which would mean the torn frame is interior).
1163                    if self.valid_frame_follows()? {
1164                        return Err(MongrelError::CorruptWal {
1165                            offset,
1166                            reason: "interior torn frame followed by a valid frame".into(),
1167                        });
1168                    }
1169                    break;
1170                }
1171                Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1172                Err(e) => return Err(e),
1173            }
1174        }
1175        Ok(out)
1176    }
1177
1178    /// Replay cooperatively with a hard record bound.
1179    pub fn replay_controlled(
1180        &mut self,
1181        control: &crate::ExecutionControl,
1182        max_records: usize,
1183    ) -> Result<Vec<Record>> {
1184        self.replay_controlled_with_tail_policy(control, max_records, true)
1185    }
1186
1187    fn replay_controlled_strict(
1188        &mut self,
1189        control: &crate::ExecutionControl,
1190        max_records: usize,
1191    ) -> Result<Vec<Record>> {
1192        self.replay_controlled_with_tail_policy(control, max_records, false)
1193    }
1194
1195    fn replay_controlled_with_tail_policy(
1196        &mut self,
1197        control: &crate::ExecutionControl,
1198        max_records: usize,
1199        allow_torn_tail: bool,
1200    ) -> Result<Vec<Record>> {
1201        let mut out = Vec::new();
1202        loop {
1203            if out.len() % 256 == 0 {
1204                control.checkpoint()?;
1205            }
1206            match self.next_record() {
1207                Ok(Some(record)) => {
1208                    if out.len() >= max_records {
1209                        return Err(MongrelError::ResourceLimitExceeded {
1210                            resource: "controlled WAL replay records",
1211                            requested: max_records.saturating_add(1),
1212                            limit: max_records,
1213                        });
1214                    }
1215                    out.push(record);
1216                }
1217                Ok(None) => break,
1218                Err(MongrelError::TornWrite { offset }) => {
1219                    if !allow_torn_tail {
1220                        return Err(MongrelError::CorruptWal {
1221                            offset,
1222                            reason: "torn tail in a non-final WAL segment".into(),
1223                        });
1224                    }
1225                    if self.valid_frame_follows()? {
1226                        return Err(MongrelError::CorruptWal {
1227                            offset,
1228                            reason: "interior torn frame followed by a valid frame".into(),
1229                        });
1230                    }
1231                    break;
1232                }
1233                Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1234                Err(error) => return Err(error),
1235            }
1236        }
1237        control.checkpoint()?;
1238        Ok(out)
1239    }
1240
1241    /// Probe whether a well-formed frame remains at the current read position.
1242    /// Used by [`Self::replay`] to disambiguate a trailing torn frame from
1243    /// interior corruption. The reader state is left positioned after the
1244    /// probed frame; `replay` stops after calling this regardless.
1245    fn valid_frame_follows(&mut self) -> Result<bool> {
1246        match self.next_record() {
1247            Ok(Some(_)) => Ok(true),
1248            Ok(None) => Ok(false),
1249            Err(_) => Ok(false),
1250        }
1251    }
1252
1253    /// Position the write cursor at end of file (for a reopen-and-append path,
1254    /// to be implemented alongside segment rotation).
1255    pub fn current_offset(&self) -> u64 {
1256        self.pos
1257    }
1258}
1259
1260/// Replay every record from a WAL file, stopping at the first torn/corrupt one.
1261/// Convenience wrapper around [`WalReader`].
1262pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
1263    WalReader::open(path)?.replay()
1264}
1265
1266/// Replay with an optional decryption cipher (for encrypted WAL segments).
1267pub fn replay_with_cipher(
1268    path: impl AsRef<Path>,
1269    cipher: Option<Box<dyn crate::encryption::Cipher>>,
1270) -> Result<Vec<Record>> {
1271    WalReader::open_with_cipher(path, cipher)?.replay()
1272}
1273
1274/// Build the deterministic 12-byte AES-GCM nonce for `(segment_no, frame)`:
1275/// `[segment_no: 8B BE][frame: 4B LE]`. The high 8 bytes are unique per
1276/// segment (persisted monotonic counter) and the low 4 are unique per frame
1277/// within a segment, so the pair never collides under the constant WAL DEK.
1278pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
1279    let mut n = [0u8; 12];
1280    n[..8].copy_from_slice(&segment_no.to_be_bytes());
1281    n[8..].copy_from_slice(&frame.to_le_bytes());
1282    n
1283}
1284
1285/// A WAL shared across all tables of a `Database`, multiplexing many tables'
1286/// records onto one fd (spec §7.2). Owns the active `Wal` segment plus the list
1287/// of rotated segments under `<root>/_wal/`. Appends are buffered; a single
1288/// [`SharedWal::group_sync`] is the durability point for every concurrent
1289/// writer that appended since the last sync.
1290pub struct SharedWal {
1291    root: Arc<crate::durable_file::DurableRoot>,
1292    wal_root: Arc<crate::durable_file::DurableRoot>,
1293    wal_dir: PathBuf,
1294    active: Wal,
1295    /// Monotonic segment number of the active segment (namespaces nonces).
1296    active_segment_no: u64,
1297    /// Highest sequence number reported durable by the last successful
1298    /// `group_sync`. P3's group-commit publishes only commits at or below this.
1299    durable_seq: u64,
1300    /// Highest database-open generation sealed into the durable WAL head.
1301    open_generation: u64,
1302    /// WAL DEK (constant across segments). None for plaintext. Kept so a
1303    /// `rotate` can rebuild the per-segment cipher under the same key.
1304    wal_dek: Option<Zeroizing<[u8; 32]>>,
1305    /// Count of actual fsyncs issued via [`Self::group_sync`]. With real group
1306    /// commit this is far below the commit count (one leader fsync serves many
1307    /// followers). Diagnostic / test-facing.
1308    group_sync_count: u64,
1309}
1310
1311#[derive(Default)]
1312struct ReplayTxnState {
1313    timestamp_seen: bool,
1314    terminal: bool,
1315}
1316
1317struct WalLayout {
1318    segments: Vec<u64>,
1319    head: Option<WalHead>,
1320}
1321
1322fn reader_for_segment(
1323    wal_root: &crate::durable_file::DurableRoot,
1324    segment_no: u64,
1325    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1326) -> Result<WalReader> {
1327    let cipher = match wal_dek {
1328        #[cfg(feature = "encryption")]
1329        Some(key) => Some(SharedWal::cipher_from_dek(key)?),
1330        #[cfg(not(feature = "encryption"))]
1331        Some(_) => {
1332            return Err(MongrelError::Encryption(
1333                "encryption feature disabled but a WAL DEK was supplied".into(),
1334            ));
1335        }
1336        None => None,
1337    };
1338    let file = wal_root.open_regular(segment_filename(segment_no))?;
1339    WalReader::open_file_with_cipher_expected(file, cipher, Some(segment_no))
1340}
1341
1342fn inspect_wal_layout(
1343    root: &crate::durable_file::DurableRoot,
1344    wal_root: &crate::durable_file::DurableRoot,
1345    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1346) -> Result<WalLayout> {
1347    let segments = list_segment_numbers(wal_root)?;
1348    let head = read_wal_head(root, wal_dek)?;
1349    for (index, segment_no) in segments.iter().copied().enumerate() {
1350        let reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1351        if reader.encrypted != wal_dek.is_some() {
1352            return Err(MongrelError::CorruptWal {
1353                offset: 10,
1354                reason: format!(
1355                    "WAL segment {segment_no} encryption mode differs from the database"
1356                ),
1357            });
1358        }
1359        if index != 0 {
1360            let previous_no = segments[index - 1];
1361            let expected = hash_segment(wal_root, previous_no)?;
1362            if reader.previous_segment_hash != expected {
1363                return Err(MongrelError::CorruptWal {
1364                    offset: 30,
1365                    reason: format!(
1366                        "WAL segment {segment_no} does not authenticate segment {previous_no}"
1367                    ),
1368                });
1369            }
1370        }
1371    }
1372
1373    if !segments.is_empty() && head.is_none() {
1374        return Err(MongrelError::CorruptWal {
1375            offset: 0,
1376            reason: "WAL segments exist without a durable WAL head".into(),
1377        });
1378    }
1379    if segments.is_empty() && head.is_some() {
1380        return Err(MongrelError::CorruptWal {
1381            offset: 0,
1382            reason: "a durable WAL head exists without any WAL segment".into(),
1383        });
1384    }
1385    if let Some(head) = head {
1386        let final_segment = segments
1387            .last()
1388            .copied()
1389            .ok_or_else(|| MongrelError::CorruptWal {
1390                offset: 0,
1391                reason: "durable WAL head references an empty WAL directory".into(),
1392            })?;
1393        if head.segment_no != final_segment {
1394            return Err(MongrelError::CorruptWal {
1395                offset: head.segment_no,
1396                reason: format!(
1397                    "durable WAL head references segment {}, newest retained segment is {final_segment}",
1398                    head.segment_no
1399                ),
1400            });
1401        }
1402        let file = wal_root.open_regular(segment_filename(head.segment_no))?;
1403        let actual_len = file.metadata()?.len();
1404        if head.durable_len > actual_len {
1405            return Err(MongrelError::CorruptWal {
1406                offset: head.durable_len,
1407                reason: format!(
1408                    "durable WAL head length {} exceeds segment length {actual_len}",
1409                    head.durable_len
1410                ),
1411            });
1412        }
1413        if hash_file_prefix(file, head.durable_len)? != head.prefix_hash {
1414            return Err(MongrelError::CorruptWal {
1415                offset: head.durable_len,
1416                reason: "durable WAL prefix hash differs from WAL head".into(),
1417            });
1418        }
1419    }
1420    Ok(WalLayout { segments, head })
1421}
1422
1423fn remove_unpublished_header_only_segment(
1424    root: &crate::durable_file::DurableRoot,
1425    wal_root: &crate::durable_file::DurableRoot,
1426    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1427) -> Result<bool> {
1428    let Some(head) = read_wal_head(root, wal_dek)? else {
1429        return Ok(false);
1430    };
1431    let segments = list_segment_numbers(wal_root)?;
1432    let Some(newest) = segments.last().copied() else {
1433        return Ok(false);
1434    };
1435    let Some(orphan_no) = head.segment_no.checked_add(1) else {
1436        return Ok(false);
1437    };
1438    if newest != orphan_no || !segments.contains(&head.segment_no) {
1439        return Ok(false);
1440    }
1441    let reader = reader_for_segment(wal_root, orphan_no, wal_dek)?;
1442    if reader.version != WAL_VERSION
1443        || reader.file_len != HEADER_LEN
1444        || reader.previous_segment_hash != hash_segment(wal_root, head.segment_no)?
1445    {
1446        return Ok(false);
1447    }
1448    wal_root.remove_file(segment_filename(orphan_no))?;
1449    Ok(true)
1450}
1451
1452/// One WAL segment's records as replayed from disk. Provenance is kept
1453/// through sequence validation so errors can name the offending segment;
1454/// records are flattened for recovery only after validation.
1455struct ReplayedSegment {
1456    segment_no: u64,
1457    records: Vec<Record>,
1458}
1459
1460/// Validate the v4 writer invariant: record sequence numbers are globally
1461/// contiguous across every retained segment and every session. A new session
1462/// continues at `highest durable sequence + 1`, so any gap, reset, or
1463/// duplicate is corruption.
1464fn validate_v4_sequence_continuity(segments: &[ReplayedSegment]) -> Result<()> {
1465    let mut previous: Option<(u64, u64)> = None;
1466    for segment in segments {
1467        for record in &segment.records {
1468            if let Some((previous_seq, previous_segment)) = previous {
1469                let expected =
1470                    previous_seq
1471                        .checked_add(1)
1472                        .ok_or_else(|| MongrelError::CorruptWal {
1473                            offset: record.seq.0,
1474                            reason: "WAL sequence overflows after u64::MAX".into(),
1475                        })?;
1476                if record.seq.0 != expected {
1477                    let reason = if segment.segment_no == previous_segment {
1478                        format!(
1479                            "WAL segment {} sequence {} does not follow {previous_seq}",
1480                            segment.segment_no, record.seq.0
1481                        )
1482                    } else {
1483                        format!(
1484                            "WAL segment {} begins with sequence {}, expected {expected} after segment {previous_segment}",
1485                            segment.segment_no, record.seq.0
1486                        )
1487                    };
1488                    return Err(MongrelError::CorruptWal {
1489                        offset: record.seq.0,
1490                        reason,
1491                    });
1492                }
1493            }
1494            previous = Some((record.seq.0, segment.segment_no));
1495        }
1496    }
1497    Ok(())
1498}
1499
1500fn replay_wal_layout(
1501    wal_root: &crate::durable_file::DurableRoot,
1502    layout: &WalLayout,
1503    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1504) -> Result<Vec<Record>> {
1505    let total_bytes = layout
1506        .segments
1507        .iter()
1508        .try_fold(0_u64, |total, segment_no| {
1509            let bytes = if layout
1510                .head
1511                .is_some_and(|head| head.segment_no == *segment_no)
1512            {
1513                layout.head.unwrap().durable_len
1514            } else {
1515                wal_root
1516                    .open_regular(segment_filename(*segment_no))?
1517                    .metadata()?
1518                    .len()
1519            };
1520            total
1521                .checked_add(bytes)
1522                .ok_or(MongrelError::ResourceLimitExceeded {
1523                    resource: "WAL recovery bytes",
1524                    requested: usize::MAX,
1525                    limit: MAX_RECOVERY_WAL_BYTES as usize,
1526                })
1527        })?;
1528    if total_bytes > MAX_RECOVERY_WAL_BYTES {
1529        return Err(MongrelError::ResourceLimitExceeded {
1530            resource: "WAL recovery bytes",
1531            requested: usize::try_from(total_bytes).unwrap_or(usize::MAX),
1532            limit: MAX_RECOVERY_WAL_BYTES as usize,
1533        });
1534    }
1535    let mut segments = Vec::with_capacity(layout.segments.len());
1536    let mut total_records = 0_usize;
1537    for segment_no in layout.segments.iter().copied() {
1538        let mut reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1539        if layout
1540            .head
1541            .is_some_and(|head| head.segment_no == segment_no)
1542        {
1543            reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
1544        }
1545        let remaining = MAX_RECOVERY_WAL_RECORDS.saturating_sub(total_records);
1546        // Layout validation guarantees a durable WAL head, so the head
1547        // segment is constrained to its authenticated prefix and every other
1548        // segment must parse completely: no torn tail is admissible.
1549        let records = reader.replay_bounded(remaining, false)?;
1550        total_records += records.len();
1551        segments.push(ReplayedSegment {
1552            segment_no,
1553            records,
1554        });
1555    }
1556    validate_v4_sequence_continuity(&segments)?;
1557    let records: Vec<Record> = segments
1558        .into_iter()
1559        .flat_map(|segment| segment.records)
1560        .collect();
1561    validate_shared_transaction_framing(&records)?;
1562    Ok(records)
1563}
1564
1565/// Validate transaction semantics over the flattened replay order: system
1566/// transaction usage, terminal markers, commit timestamps, and commit-epoch
1567/// uniqueness and advancement. Record sequence continuity is validated
1568/// separately with segment provenance (see `validate_v4_sequence_continuity`).
1569pub(crate) fn validate_shared_transaction_framing(records: &[Record]) -> Result<()> {
1570    let mut transactions = std::collections::HashMap::<u64, ReplayTxnState>::new();
1571    let mut commit_epochs = std::collections::HashMap::<u64, u64>::new();
1572    let mut previous_commit_epoch: Option<u64> = None;
1573    for record in records {
1574        if record.txn_id == SYSTEM_TXN_ID {
1575            if !matches!(record.op, Op::Flush { .. }) {
1576                return Err(MongrelError::CorruptWal {
1577                    offset: record.seq.0,
1578                    reason: "non-system operation uses reserved transaction id 0".into(),
1579                });
1580            }
1581            continue;
1582        }
1583        let state = transactions.entry(record.txn_id).or_default();
1584        if state.terminal {
1585            return Err(MongrelError::CorruptWal {
1586                offset: record.seq.0,
1587                reason: format!(
1588                    "transaction {} has records after its terminal marker",
1589                    record.txn_id
1590                ),
1591            });
1592        }
1593        match record.op {
1594            Op::CommitTimestamp { .. } => {
1595                if state.timestamp_seen {
1596                    return Err(MongrelError::CorruptWal {
1597                        offset: record.seq.0,
1598                        reason: format!(
1599                            "transaction {} has duplicate commit timestamps",
1600                            record.txn_id
1601                        ),
1602                    });
1603                }
1604                state.timestamp_seen = true;
1605            }
1606            Op::TxnCommit { epoch, .. } => {
1607                if epoch == 0 {
1608                    return Err(MongrelError::CorruptWal {
1609                        offset: record.seq.0,
1610                        reason: format!("transaction {} commits at epoch 0", record.txn_id),
1611                    });
1612                }
1613                if let Some(previous) = commit_epochs.insert(epoch, record.txn_id) {
1614                    return Err(MongrelError::CorruptWal {
1615                        offset: record.seq.0,
1616                        reason: format!(
1617                            "transactions {previous} and {} share commit epoch {epoch}",
1618                            record.txn_id
1619                        ),
1620                    });
1621                }
1622                if previous_commit_epoch.is_some_and(|previous| epoch <= previous) {
1623                    return Err(MongrelError::CorruptWal {
1624                        offset: record.seq.0,
1625                        reason: format!(
1626                            "commit epoch {epoch} does not advance beyond {}",
1627                            previous_commit_epoch.unwrap_or(0)
1628                        ),
1629                    });
1630                }
1631                previous_commit_epoch = Some(epoch);
1632                state.terminal = true;
1633            }
1634            Op::TxnAbort => state.terminal = true,
1635            Op::Flush { .. } => {
1636                return Err(MongrelError::CorruptWal {
1637                    offset: record.seq.0,
1638                    reason: format!(
1639                        "transaction {} contains a system flush record",
1640                        record.txn_id
1641                    ),
1642                });
1643            }
1644            _ => {}
1645        }
1646    }
1647    Ok(())
1648}
1649
1650impl SharedWal {
1651    /// Build a per-segment frame cipher from the WAL DEK (encryption feature).
1652    #[cfg(feature = "encryption")]
1653    fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
1654        Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
1655    }
1656
1657    /// Create a fresh shared WAL at `<root>/_wal/` starting at `epoch_created`.
1658    pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
1659        Self::create_with_dek(root, epoch_created, None)
1660    }
1661
1662    /// Create with optional frame-level encryption (WAL DEK).
1663    pub fn create_with_dek(
1664        root: &Path,
1665        epoch_created: Epoch,
1666        wal_dek: Option<Zeroizing<[u8; 32]>>,
1667    ) -> Result<Self> {
1668        let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1669        Self::create_with_durable_root(root, epoch_created, wal_dek)
1670    }
1671
1672    pub(crate) fn create_with_durable_root(
1673        root: Arc<crate::durable_file::DurableRoot>,
1674        epoch_created: Epoch,
1675        wal_dek: Option<Zeroizing<[u8; 32]>>,
1676    ) -> Result<Self> {
1677        let wal_root = Arc::new(root.create_directory_all_pinned("_wal")?);
1678        let wal_dir = wal_root.io_path()?;
1679        if !list_segment_numbers(&wal_root)?.is_empty() {
1680            return Err(MongrelError::CorruptWal {
1681                offset: 0,
1682                reason: "refuses to create a shared WAL over existing segments".into(),
1683            });
1684        }
1685        let cipher = match &wal_dek {
1686            #[cfg(feature = "encryption")]
1687            Some(dk) => Some(Self::cipher_from_dek(dk)?),
1688            #[cfg(not(feature = "encryption"))]
1689            Some(_) => {
1690                return Err(MongrelError::Encryption(
1691                    "encryption feature disabled but a WAL DEK was supplied".into(),
1692                ))
1693            }
1694            None => None,
1695        };
1696        let active = Wal::create_chained_in(&wal_root, 0, epoch_created, cipher, [0; 32])?;
1697        if let Err(error) = write_wal_head(&root, &wal_root, 0, 0, wal_dek.as_ref()) {
1698            drop(active);
1699            let _ = wal_root.remove_file(segment_filename(0));
1700            return Err(error);
1701        }
1702        Ok(Self {
1703            root,
1704            wal_root,
1705            wal_dir,
1706            active,
1707            active_segment_no: 0,
1708            durable_seq: epoch_created.0,
1709            open_generation: 0,
1710            wal_dek,
1711            group_sync_count: 0,
1712        })
1713    }
1714
1715    /// Open an existing shared WAL for append, preserving prior segments (which
1716    /// `replay` reads for recovery). A fresh active segment numbered one past
1717    /// the highest existing is created — old segments are NOT truncated (review
1718    /// fix #6), so a crash mid-recovery can re-replay them safely.
1719    pub fn open(
1720        root: &Path,
1721        epoch_created: Epoch,
1722        wal_dek: Option<Zeroizing<[u8; 32]>>,
1723    ) -> Result<Self> {
1724        let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1725        Self::open_durable_root(root, epoch_created, wal_dek)
1726    }
1727
1728    pub(crate) fn open_durable_root(
1729        root: Arc<crate::durable_file::DurableRoot>,
1730        epoch_created: Epoch,
1731        wal_dek: Option<Zeroizing<[u8; 32]>>,
1732    ) -> Result<Self> {
1733        Self::open_durable_root_validated(root, epoch_created, wal_dek, None)
1734    }
1735
1736    pub(crate) fn open_durable_root_validated(
1737        root: Arc<crate::durable_file::DurableRoot>,
1738        epoch_created: Epoch,
1739        wal_dek: Option<Zeroizing<[u8; 32]>>,
1740        expected_records: Option<&[Record]>,
1741    ) -> Result<Self> {
1742        let wal_root =
1743            Arc::new(
1744                root.open_directory("_wal")
1745                    .map_err(|error| MongrelError::CorruptWal {
1746                        offset: 0,
1747                        reason: format!(
1748                            "existing database WAL directory cannot be opened: {error}"
1749                        ),
1750                    })?,
1751            );
1752        let wal_dir = wal_root.io_path()?;
1753        if let Some(expected) = expected_records {
1754            let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1755            let actual = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1756            if bincode::serialize(&actual)? != bincode::serialize(expected)? {
1757                return Err(MongrelError::CorruptWal {
1758                    offset: 0,
1759                    reason: "WAL changed after recovery planning".into(),
1760                });
1761            }
1762        }
1763        remove_unpublished_header_only_segment(&root, &wal_root, wal_dek.as_ref())?;
1764        let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1765        let final_segment =
1766            layout
1767                .segments
1768                .last()
1769                .copied()
1770                .ok_or_else(|| MongrelError::CorruptWal {
1771                    offset: 0,
1772                    reason: "existing database has no WAL segments".into(),
1773                })?;
1774        let records = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1775        let open_generation = layout
1776            .head
1777            .map(|head| head.open_generation)
1778            .unwrap_or_else(|| {
1779                records
1780                    .iter()
1781                    .filter(|record| record.txn_id != SYSTEM_TXN_ID)
1782                    .map(|record| record.txn_id >> 32)
1783                    .max()
1784                    .unwrap_or(0)
1785            });
1786
1787        // Bytes beyond the authenticated head were never published durable.
1788        // Truncate that suffix before the file becomes an immutable chain
1789        // link. Layout validation guarantees a durable head exists.
1790        let durable_len = layout
1791            .head
1792            .ok_or_else(|| MongrelError::CorruptWal {
1793                offset: 0,
1794                reason: "validated WAL layout has no durable WAL head".into(),
1795            })?
1796            .durable_len;
1797        let final_name = segment_filename(final_segment);
1798        let final_file = wal_root.open_regular_read_write(&final_name)?;
1799        if final_file.metadata()?.len() != durable_len {
1800            final_file.set_len(durable_len)?;
1801            final_file.sync_all()?;
1802        }
1803        let previous_segment_hash = hash_segment(&wal_root, final_segment)?;
1804        let next_segment_no = final_segment
1805            .checked_add(1)
1806            .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1807        // The new session continues the globally contiguous v4 sequence. Only
1808        // a freshly created (record-less) WAL seeds from the creation epoch.
1809        let durable_seq = records
1810            .last()
1811            .map(|record| record.seq.0)
1812            .unwrap_or(epoch_created.0);
1813        let cipher = match &wal_dek {
1814            #[cfg(feature = "encryption")]
1815            Some(dk) => Some(Self::cipher_from_dek(dk)?),
1816            #[cfg(not(feature = "encryption"))]
1817            Some(_) => {
1818                return Err(MongrelError::Encryption(
1819                    "encryption feature disabled but a WAL DEK was supplied".into(),
1820                ))
1821            }
1822            None => None,
1823        };
1824        let mut active = Wal::create_chained_in(
1825            &wal_root,
1826            next_segment_no,
1827            epoch_created,
1828            cipher,
1829            previous_segment_hash,
1830        )?;
1831        active.next_seq = durable_seq
1832            .checked_add(1)
1833            .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
1834        if let Err(error) = write_wal_head(
1835            &root,
1836            &wal_root,
1837            next_segment_no,
1838            open_generation,
1839            wal_dek.as_ref(),
1840        ) {
1841            drop(active);
1842            let _ = wal_root.remove_file(segment_filename(next_segment_no));
1843            return Err(error);
1844        }
1845        Ok(Self {
1846            root,
1847            wal_root,
1848            wal_dir,
1849            active,
1850            active_segment_no: next_segment_no,
1851            durable_seq,
1852            open_generation,
1853            wal_dek,
1854            group_sync_count: 0,
1855        })
1856    }
1857
1858    /// The active segment's wal_dir (test/diagnostic).
1859    #[allow(dead_code)]
1860    pub fn wal_dir(&self) -> &Path {
1861        &self.wal_dir
1862    }
1863
1864    /// Append a record for `(txn_id, table_id)`. Does not fsync.
1865    /// `wal.append.before`/`wal.append.after` fault hooks bracket the append
1866    /// (spec §9.6, FND-006).
1867    pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
1868        mongreldb_fault::inject("wal.append.before").map_err(crate::commit_log::fault_as_io)?;
1869        let seq = self.active.append_txn(txn_id, op)?.0;
1870        mongreldb_fault::inject("wal.append.after").map_err(crate::commit_log::fault_as_io)?;
1871        Ok(seq)
1872    }
1873
1874    /// Append a `TxnCommit` marker sealing `txn_id` at `epoch`.
1875    pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
1876        self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
1877    }
1878
1879    pub fn append_commit_at(
1880        &mut self,
1881        txn_id: u64,
1882        epoch: Epoch,
1883        added: &[AddedRun],
1884        unix_nanos: u64,
1885    ) -> Result<u64> {
1886        self.active
1887            .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
1888        Ok(self
1889            .active
1890            .append_txn(
1891                txn_id,
1892                Op::TxnCommit {
1893                    epoch: epoch.0,
1894                    added_runs: added.to_vec(),
1895                },
1896            )?
1897            .0)
1898    }
1899
1900    /// Append a `TxnAbort` marker for `txn_id`.
1901    pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
1902        self.active.append_txn(txn_id, Op::TxnAbort)?;
1903        Ok(())
1904    }
1905
1906    /// Append a system record (txn_id == 0), e.g. `Flush`.
1907    pub fn append_system(&mut self, op: Op) -> Result<u64> {
1908        Ok(self.active.append_system(op)?.0)
1909    }
1910
1911    /// Flush + fsync the active segment and return the highest durable sequence
1912    /// number. This is the single durability point for every concurrent
1913    /// appender since the last `group_sync`. `wal.fsync.before`/`wal.fsync.after`
1914    /// fault hooks bracket the fsync (spec §9.6, FND-006).
1915    pub fn group_sync(&mut self) -> Result<u64> {
1916        mongreldb_fault::inject("wal.fsync.before").map_err(crate::commit_log::fault_as_io)?;
1917        self.active.sync()?;
1918        mongreldb_fault::inject("wal.fsync.after").map_err(crate::commit_log::fault_as_io)?;
1919        write_wal_head(
1920            &self.root,
1921            &self.wal_root,
1922            self.active_segment_no,
1923            self.open_generation,
1924            self.wal_dek.as_ref(),
1925        )?;
1926        self.group_sync_count += 1;
1927        let highest = self.active.next_seq_val().saturating_sub(1);
1928        if highest > self.durable_seq {
1929            self.durable_seq = highest;
1930        }
1931        Ok(self.durable_seq)
1932    }
1933
1934    /// Number of fsyncs issued so far (test/diagnostic — see [`group_sync`]).
1935    pub fn group_sync_count(&self) -> u64 {
1936        self.group_sync_count
1937    }
1938
1939    /// The highest sequence number reported durable by the last `group_sync`.
1940    pub fn durable_seq(&self) -> u64 {
1941        self.durable_seq
1942    }
1943
1944    pub(crate) fn seal_open_generation(&mut self, generation: u64) -> Result<()> {
1945        if generation < self.open_generation {
1946            return Err(MongrelError::CorruptWal {
1947                offset: generation,
1948                reason: format!(
1949                    "open generation {generation} precedes WAL head generation {}",
1950                    self.open_generation
1951                ),
1952            });
1953        }
1954        self.active.sync()?;
1955        let previous = self.open_generation;
1956        self.open_generation = generation;
1957        if let Err(error) = write_wal_head(
1958            &self.root,
1959            &self.wal_root,
1960            self.active_segment_no,
1961            self.open_generation,
1962            self.wal_dek.as_ref(),
1963        ) {
1964            self.open_generation = previous;
1965            return Err(error);
1966        }
1967        Ok(())
1968    }
1969
1970    /// Rotate to a fresh segment numbered `segment_no` (which namespaces nonces
1971    /// under the constant WAL DEK). The current segment must already be synced.
1972    pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
1973        let expected = self
1974            .active_segment_no
1975            .checked_add(1)
1976            .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1977        if segment_no != expected {
1978            return Err(MongrelError::InvalidArgument(format!(
1979                "WAL rotation segment {segment_no} does not immediately follow {}",
1980                self.active_segment_no
1981            )));
1982        }
1983        self.active.sync()?;
1984        write_wal_head(
1985            &self.root,
1986            &self.wal_root,
1987            self.active_segment_no,
1988            self.open_generation,
1989            self.wal_dek.as_ref(),
1990        )?;
1991        let highest = self.active.next_seq_val().saturating_sub(1);
1992        self.durable_seq = self.durable_seq.max(highest);
1993        let previous_segment_hash = hash_segment(&self.wal_root, self.active_segment_no)?;
1994        let cipher = match &self.wal_dek {
1995            #[cfg(feature = "encryption")]
1996            Some(dk) => Some(Self::cipher_from_dek(dk)?),
1997            _ => None,
1998        };
1999        let epoch = Epoch(self.durable_seq);
2000        let wal = Wal::create_chained_in(
2001            &self.wal_root,
2002            segment_no,
2003            epoch,
2004            cipher,
2005            previous_segment_hash,
2006        )?;
2007        if let Err(error) = write_wal_head(
2008            &self.root,
2009            &self.wal_root,
2010            segment_no,
2011            self.open_generation,
2012            self.wal_dek.as_ref(),
2013        ) {
2014            drop(wal);
2015            let _ = self.wal_root.remove_file(segment_filename(segment_no));
2016            return Err(error);
2017        }
2018        self.active = wal;
2019        self.active_segment_no = segment_no;
2020        Ok(())
2021    }
2022
2023    /// The active segment number.
2024    pub fn active_segment_no(&self) -> u64 {
2025        self.active_segment_no
2026    }
2027
2028    /// After every table is checkpointed into runs, publish a fresh durable
2029    /// active segment before deleting any older segment. The active file handle
2030    /// is never unlinked while it can receive later commits.
2031    pub(crate) fn reset_after_checkpoint(&mut self) -> Result<usize> {
2032        self.group_sync()?;
2033        let next_segment_no = list_segment_numbers(&self.wal_root)?
2034            .into_iter()
2035            .max()
2036            .ok_or_else(|| MongrelError::CorruptWal {
2037                offset: 0,
2038                reason: "active WAL segment disappeared before checkpoint reset".into(),
2039            })?
2040            .checked_add(1)
2041            .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
2042        self.rotate(next_segment_no)?;
2043        // Keep this explicit even though segment creation currently syncs its
2044        // header. Checkpoint safety must not depend on that constructor detail.
2045        self.group_sync()?;
2046        self.gc_segments_retain_recent(u64::MAX, 0)
2047    }
2048
2049    /// Delete rotated (non-active) WAL segments whose records are all below
2050    /// `min_retained_seq` — i.e. every record in them is already durable in a
2051    /// run and not needed by any in-flight or committed-not-flushed txn (spec
2052    /// §6.4/§16). The active segment is **never** deleted. Returns the count of
2053    /// segment files reaped.
2054    ///
2055    /// `open()` mints a fresh active segment on every reopen without truncating
2056    /// the prior ones (so a crash mid-recovery can re-replay), which means old
2057    /// segments accumulate; this is what reaps them once their data is durable.
2058    pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
2059        self.gc_segments_retain_recent(min_retained_seq, 0)
2060    }
2061
2062    pub(crate) fn has_gc_segments_retain_recent(&self, retain_recent: usize) -> Result<bool> {
2063        let rotated = list_segment_numbers(&self.wal_root)?
2064            .into_iter()
2065            .filter(|segment| *segment != self.active_segment_no)
2066            .count();
2067        Ok(rotated > retain_recent)
2068    }
2069
2070    /// [`Self::gc_segments`] while retaining the newest `retain_recent`
2071    /// rotated segments for replication followers. The active segment is
2072    /// retained separately and does not count toward this limit.
2073    pub fn gc_segments_retain_recent(
2074        &mut self,
2075        min_retained_seq: u64,
2076        retain_recent: usize,
2077    ) -> Result<usize> {
2078        self.group_sync()?;
2079        let layout = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())?;
2080        let readable = replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref())?;
2081        let segments = &layout.segments;
2082        let retained: Vec<u64> = segments
2083            .iter()
2084            .rev()
2085            .filter(|segment| **segment != self.active_segment_no)
2086            .take(retain_recent)
2087            .copied()
2088            .collect();
2089        let mut candidates = Vec::new();
2090        let mut prefix_open = true;
2091        for n in segments.iter().copied() {
2092            let mut reader = reader_for_segment(&self.wal_root, n, self.wal_dek.as_ref())?;
2093            if layout.head.is_some_and(|head| head.segment_no == n) {
2094                reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2095            }
2096            let records = reader.replay_strict()?;
2097            let below_floor = min_retained_seq == u64::MAX
2098                || records.iter().map(|record| record.seq.0).max().unwrap_or(0) < min_retained_seq;
2099            let reapable =
2100                prefix_open && n != self.active_segment_no && !retained.contains(&n) && below_floor;
2101            if reapable {
2102                candidates.push((n, records));
2103            } else {
2104                prefix_open = false;
2105            }
2106        }
2107
2108        let commits = readable
2109            .iter()
2110            .filter_map(|record| match record.op {
2111                Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2112                _ => None,
2113            })
2114            .collect::<std::collections::HashMap<_, _>>();
2115        let removed_floor = candidates
2116            .iter()
2117            .flat_map(|(_, records)| records)
2118            .filter_map(|record| commits.get(&record.txn_id).copied())
2119            .max();
2120        if let Some(epoch) = removed_floor {
2121            crate::replication::advance_replication_wal_floor_durable(&self.root, epoch)?;
2122        }
2123        for (segment_no, _) in &candidates {
2124            self.wal_root.remove_file(segment_filename(*segment_no))?;
2125        }
2126        let reaped = candidates.len();
2127        Ok(reaped)
2128    }
2129
2130    /// Verify the on-disk integrity of every WAL segment (spec §16): each
2131    /// `seg-NNNNNN.wal` file under `<root>/_wal/` must open — its header magic
2132    /// and version must parse, and for an encrypted WAL the frame cipher must
2133    /// be derivable from the WAL DEK. A segment that fails to open is corrupt
2134    /// or truncated and would break recovery. Returns one `(segment_no, error)`
2135    /// pair per failing segment. The active (in-memory) segment is trusted by
2136    /// construction and re-checked from disk like the others.
2137    pub fn verify_segments(&self) -> Vec<(u64, String)> {
2138        let result = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())
2139            .and_then(|layout| replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref()));
2140        match result {
2141            Ok(_) => Vec::new(),
2142            Err(error) => vec![(u64::MAX, error.to_string())],
2143        }
2144    }
2145
2146    /// Replay every record across all segments in `<root>/_wal/`, in segment
2147    /// order. Only the newest segment may end in a crash-torn frame; every
2148    /// older segment is immutable and therefore must validate completely.
2149    pub fn replay(root: &Path) -> Result<Vec<Record>> {
2150        Self::replay_with_dek(root, None)
2151    }
2152
2153    /// Replay with an optional WAL DEK (for encrypted segments).
2154    pub fn replay_with_dek(
2155        root: &Path,
2156        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2157    ) -> Result<Vec<Record>> {
2158        let root = crate::durable_file::DurableRoot::open(root)?;
2159        Self::replay_durable_with_dek(&root, wal_dek)
2160    }
2161
2162    pub(crate) fn replay_durable_with_dek(
2163        root: &crate::durable_file::DurableRoot,
2164        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2165    ) -> Result<Vec<Record>> {
2166        let wal_root = root.open_directory("_wal")?;
2167        let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2168        replay_wal_layout(&wal_root, &layout, wal_dek)
2169    }
2170
2171    pub(crate) fn durable_open_generation(
2172        root: &crate::durable_file::DurableRoot,
2173        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2174    ) -> Result<Option<u64>> {
2175        let wal_root = root.open_directory("_wal")?;
2176        let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2177        Ok(layout.head.map(|head| head.open_generation))
2178    }
2179
2180    /// Replay all segments cooperatively with hard total record and on-disk
2181    /// byte bounds.
2182    pub fn replay_with_dek_controlled(
2183        root: &Path,
2184        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2185        control: &crate::ExecutionControl,
2186        max_records: usize,
2187        max_bytes: usize,
2188    ) -> Result<Vec<Record>> {
2189        let root = crate::durable_file::DurableRoot::open(root)?;
2190        let wal_root = root.open_directory("_wal")?;
2191        let layout = inspect_wal_layout(&root, &wal_root, wal_dek)?;
2192        let total_bytes = layout.segments.iter().try_fold(0_usize, |total, segment| {
2193            let bytes = if layout.head.is_some_and(|head| head.segment_no == *segment) {
2194                usize::try_from(layout.head.unwrap().durable_len).unwrap_or(usize::MAX)
2195            } else {
2196                usize::try_from(
2197                    wal_root
2198                        .open_regular(segment_filename(*segment))?
2199                        .metadata()?
2200                        .len(),
2201                )
2202                .unwrap_or(usize::MAX)
2203            };
2204            Ok::<_, MongrelError>(total.saturating_add(bytes))
2205        })?;
2206        if total_bytes > max_bytes {
2207            return Err(MongrelError::ResourceLimitExceeded {
2208                resource: "controlled WAL replay bytes",
2209                requested: total_bytes,
2210                limit: max_bytes,
2211            });
2212        }
2213        let mut segments = Vec::with_capacity(layout.segments.len());
2214        let mut total_records = 0_usize;
2215        for n in layout.segments.iter().copied() {
2216            control.checkpoint()?;
2217            let remaining = max_records.saturating_sub(total_records);
2218            let mut reader = reader_for_segment(&wal_root, n, wal_dek)?;
2219            if layout.head.is_some_and(|head| head.segment_no == n) {
2220                reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2221            }
2222            // Layout validation guarantees a durable WAL head, so every
2223            // segment parses strictly: the head segment is constrained to
2224            // its authenticated prefix and no torn tail is admissible.
2225            let records = reader.replay_controlled_strict(control, remaining)?;
2226            total_records += records.len();
2227            segments.push(ReplayedSegment {
2228                segment_no: n,
2229                records,
2230            });
2231        }
2232        validate_v4_sequence_continuity(&segments)?;
2233        let out: Vec<Record> = segments
2234            .into_iter()
2235            .flat_map(|segment| segment.records)
2236            .collect();
2237        validate_shared_transaction_framing(&out)?;
2238        Ok(out)
2239    }
2240}
2241
2242fn segment_filename(segment_no: u64) -> String {
2243    format!("seg-{segment_no:06}.wal")
2244}
2245
2246fn segment_number_from_path(path: &Path) -> Option<u64> {
2247    path.file_name()
2248        .and_then(|name| name.to_str())
2249        .and_then(|name| name.strip_prefix("seg-"))
2250        .and_then(|name| name.strip_suffix(".wal"))
2251        .and_then(|number| number.parse().ok())
2252}
2253
2254/// List and validate the retained canonical segment sequence under `wal_dir`.
2255/// Garbage files are ignored, but every `seg-*` entry is authoritative and
2256/// therefore must be a regular file with its one canonical name. GC may remove
2257/// a prefix; gaps inside the retained suffix are corruption.
2258fn list_segment_numbers(wal_root: &crate::durable_file::DurableRoot) -> Result<Vec<u64>> {
2259    let mut segments = Vec::new();
2260    for fname in wal_root.list_regular_files(".")? {
2261        let s = fname.to_str().ok_or_else(|| MongrelError::CorruptWal {
2262            offset: 0,
2263            reason: "WAL directory contains a non-UTF-8 entry".into(),
2264        })?;
2265        if !s.starts_with("seg-") {
2266            continue;
2267        }
2268        let number = s
2269            .strip_prefix("seg-")
2270            .and_then(|value| value.strip_suffix(".wal"))
2271            .and_then(|value| value.parse::<u64>().ok())
2272            .ok_or_else(|| MongrelError::CorruptWal {
2273                offset: 0,
2274                reason: format!("malformed WAL segment filename {s:?}"),
2275            })?;
2276        if s != segment_filename(number) {
2277            return Err(MongrelError::CorruptWal {
2278                offset: 0,
2279                reason: format!("non-canonical WAL segment filename {s:?}"),
2280            });
2281        }
2282        segments.push(number);
2283    }
2284    segments.sort_unstable();
2285    for pair in segments.windows(2) {
2286        let expected = pair[0]
2287            .checked_add(1)
2288            .ok_or_else(|| MongrelError::CorruptWal {
2289                offset: pair[0],
2290                reason: "WAL segment namespace overflows after u64::MAX".into(),
2291            })?;
2292        if pair[1] != expected {
2293            return Err(MongrelError::CorruptWal {
2294                offset: pair[1],
2295                reason: format!(
2296                    "WAL segment {} does not immediately follow {}",
2297                    pair[1], pair[0]
2298                ),
2299            });
2300        }
2301    }
2302    Ok(segments)
2303}
2304
2305#[cfg(test)]
2306mod shared_wal_tests {
2307    use super::*;
2308    use tempfile::tempdir;
2309
2310    /// Write a file with a v3 WAL header (and no records) so tests can assert
2311    /// the format boundary: v3 is rejected as unsupported, never parsed.
2312    fn write_v3_segment(path: &Path) {
2313        let mut bytes = Vec::new();
2314        bytes.extend_from_slice(&WAL_MAGIC);
2315        bytes.extend_from_slice(&3_u16.to_le_bytes());
2316        bytes.extend_from_slice(&[ENC_PLAINTEXT, 0, 0, 0]);
2317        bytes.extend_from_slice(&0_u64.to_le_bytes());
2318        std::fs::write(path, bytes).unwrap();
2319    }
2320
2321    #[test]
2322    fn v3_segments_are_rejected_as_unsupported_not_corrupt() {
2323        let dir = tempdir().unwrap();
2324        std::fs::create_dir(dir.path().join("_wal")).unwrap();
2325        write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2326
2327        let error = SharedWal::replay(dir.path()).unwrap_err();
2328        assert!(
2329            matches!(
2330                error,
2331                MongrelError::UnsupportedStorageVersion {
2332                    component: "wal",
2333                    found: 3,
2334                    supported: 4,
2335                }
2336            ),
2337            "expected UnsupportedStorageVersion, got {error:?}"
2338        );
2339
2340        let dir = tempdir().unwrap();
2341        std::fs::create_dir(dir.path().join("_wal")).unwrap();
2342        write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2343        let error = SharedWal::open(dir.path(), Epoch(1), None)
2344            .err()
2345            .expect("v3 WAL must be rejected");
2346        assert!(matches!(
2347            error,
2348            MongrelError::UnsupportedStorageVersion {
2349                component: "wal",
2350                found: 3,
2351                supported: 4,
2352            }
2353        ));
2354    }
2355
2356    #[test]
2357    fn shared_wal_interleaves_two_tables_one_fd() {
2358        let dir = tempdir().unwrap();
2359        let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2360        w.append(
2361            1,
2362            10,
2363            Op::Put {
2364                table_id: 10,
2365                rows: vec![1],
2366            },
2367        )
2368        .unwrap();
2369        w.append(
2370            2,
2371            20,
2372            Op::Put {
2373                table_id: 20,
2374                rows: vec![2],
2375            },
2376        )
2377        .unwrap();
2378        w.append_commit(1, Epoch(1), &[]).unwrap();
2379        w.append_commit(2, Epoch(2), &[]).unwrap();
2380        let d = w.group_sync().unwrap();
2381        assert!(d >= 4);
2382        let recs = SharedWal::replay(dir.path()).unwrap();
2383        assert_eq!(
2384            recs.iter()
2385                .filter(|r| matches!(r.op, Op::Put { .. }))
2386                .count(),
2387            2
2388        );
2389        assert_eq!(
2390            recs.iter()
2391                .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
2392                .count(),
2393            2
2394        );
2395    }
2396
2397    #[test]
2398    fn controlled_shared_replay_rejects_aggregate_wal_bytes() {
2399        let dir = tempdir().unwrap();
2400        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2401        wal.append_commit(1, Epoch(1), &[]).unwrap();
2402        wal.group_sync().unwrap();
2403        let control = crate::ExecutionControl::new(None);
2404
2405        let error =
2406            SharedWal::replay_with_dek_controlled(dir.path(), None, &control, usize::MAX, 1)
2407                .unwrap_err();
2408        assert!(matches!(
2409            error,
2410            MongrelError::ResourceLimitExceeded {
2411                resource: "controlled WAL replay bytes",
2412                ..
2413            }
2414        ));
2415    }
2416
2417    #[test]
2418    fn shared_wal_gc_retains_recent_rotated_segments() {
2419        let dir = tempdir().unwrap();
2420        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2421        for segment in 0..4u64 {
2422            wal.append_commit(segment + 1, Epoch(segment + 1), &[])
2423                .unwrap();
2424            wal.group_sync().unwrap();
2425            if segment < 3 {
2426                wal.rotate(segment + 1).unwrap();
2427            }
2428        }
2429        assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
2430        let count = std::fs::read_dir(dir.path().join("_wal"))
2431            .unwrap()
2432            .flatten()
2433            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
2434            .count();
2435        assert_eq!(count, 3, "active plus two retained segments");
2436    }
2437
2438    #[test]
2439    fn shared_replay_rejects_torn_tail_in_rotated_segment() {
2440        let dir = tempdir().unwrap();
2441        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2442        wal.append_commit(1, Epoch(1), &[]).unwrap();
2443        wal.group_sync().unwrap();
2444        wal.rotate(1).unwrap();
2445        wal.append_commit(2, Epoch(2), &[]).unwrap();
2446        wal.group_sync().unwrap();
2447        drop(wal);
2448
2449        let old = dir.path().join("_wal/seg-000000.wal");
2450        let mut file = OpenOptions::new().append(true).open(old).unwrap();
2451        file.write_all(&[1, 2, 3]).unwrap();
2452        file.sync_all().unwrap();
2453
2454        assert!(matches!(
2455            SharedWal::replay(dir.path()),
2456            Err(MongrelError::CorruptWal { .. })
2457        ));
2458    }
2459
2460    #[test]
2461    fn shared_replay_rejects_records_after_commit() {
2462        let dir = tempdir().unwrap();
2463        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2464        wal.append_commit(7, Epoch(1), &[]).unwrap();
2465        wal.append(
2466            7,
2467            1,
2468            Op::Delete {
2469                table_id: 1,
2470                row_ids: vec![RowId(9)],
2471            },
2472        )
2473        .unwrap();
2474        wal.group_sync().unwrap();
2475        drop(wal);
2476
2477        assert!(matches!(
2478            SharedWal::replay(dir.path()),
2479            Err(MongrelError::CorruptWal { .. })
2480        ));
2481    }
2482
2483    #[test]
2484    fn rotate_without_explicit_group_sync_keeps_sequence_adjacent() {
2485        let dir = tempdir().unwrap();
2486        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2487        assert_eq!(
2488            wal.append_system(Op::Flush {
2489                table_id: 1,
2490                flushed_epoch: 1,
2491            })
2492            .unwrap(),
2493            1
2494        );
2495        wal.rotate(1).unwrap();
2496        assert_eq!(
2497            wal.append_system(Op::Flush {
2498                table_id: 1,
2499                flushed_epoch: 2,
2500            })
2501            .unwrap(),
2502            2
2503        );
2504        wal.group_sync().unwrap();
2505        let records = SharedWal::replay(dir.path()).unwrap();
2506        assert_eq!(
2507            records
2508                .iter()
2509                .map(|record| record.seq.0)
2510                .collect::<Vec<_>>(),
2511            vec![1, 2]
2512        );
2513    }
2514
2515    #[test]
2516    fn open_recovers_exact_header_only_segment_crash_window() {
2517        let dir = tempdir().unwrap();
2518        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2519        wal.append_commit(1, Epoch(1), &[]).unwrap();
2520        wal.group_sync().unwrap();
2521        drop(wal);
2522
2523        let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2524        let wal_root = root.open_directory("_wal").unwrap();
2525        let previous_hash = hash_segment(&wal_root, 0).unwrap();
2526        drop(Wal::create_chained_in(&wal_root, 1, Epoch(1), None, previous_hash).unwrap());
2527        assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 0);
2528
2529        let reopened = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2530        assert_eq!(reopened.active_segment_no(), 1);
2531        assert_eq!(list_segment_numbers(&wal_root).unwrap(), vec![0, 1]);
2532        assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2533    }
2534
2535    #[test]
2536    fn replay_rejects_sequence_reset_across_segments() {
2537        let dir = tempdir().unwrap();
2538        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2539        wal.append_commit(1, Epoch(1), &[]).unwrap();
2540        wal.group_sync().unwrap();
2541        wal.rotate(1).unwrap();
2542        // A session must never restart the sequence; simulate a writer bug
2543        // that does and prove replay still rejects it.
2544        wal.active.next_seq = 1;
2545        wal.append_commit(2, Epoch(2), &[]).unwrap();
2546        wal.group_sync().unwrap();
2547        drop(wal);
2548
2549        let error = SharedWal::replay(dir.path()).unwrap_err();
2550        assert!(
2551            matches!(error, MongrelError::CorruptWal { .. }),
2552            "expected CorruptWal, got {error:?}"
2553        );
2554    }
2555
2556    #[test]
2557    fn replay_rejects_backward_commit_epoch() {
2558        let dir = tempdir().unwrap();
2559        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2560        wal.append_commit(1, Epoch(2), &[]).unwrap();
2561        wal.append_commit(2, Epoch(1), &[]).unwrap();
2562        wal.group_sync().unwrap();
2563        drop(wal);
2564
2565        assert!(matches!(
2566            SharedWal::replay(dir.path()),
2567            Err(MongrelError::CorruptWal { .. })
2568        ));
2569    }
2570
2571    #[test]
2572    fn multiple_sessions_continue_one_global_sequence() {
2573        let dir = tempdir().unwrap();
2574        for session in 0..3_u64 {
2575            let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), None)
2576                .unwrap_or_else(|_| SharedWal::create(dir.path(), Epoch(session * 2)).unwrap());
2577            assert_eq!(wal.active_segment_no(), session);
2578            for i in 0..2_u64 {
2579                let txn = session * 2 + i + 1;
2580                wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2581            }
2582            wal.group_sync().unwrap();
2583            drop(wal);
2584        }
2585
2586        let records = SharedWal::replay(dir.path()).unwrap();
2587        let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2588        // Every commit writes a timestamp and a commit record: 3 sessions x 2
2589        // commits x 2 records, one contiguous namespace.
2590        assert_eq!(sequences, (1..=12).collect::<Vec<u64>>());
2591
2592        // A fourth session must continue the same namespace (recovery may
2593        // append its own system records first — contiguity is the invariant).
2594        let mut wal = SharedWal::open(dir.path(), Epoch(6), None).unwrap();
2595        assert_eq!(wal.active_segment_no(), 3);
2596        wal.append_commit(7, Epoch(7), &[]).unwrap();
2597        wal.group_sync().unwrap();
2598        drop(wal);
2599
2600        let records = SharedWal::replay(dir.path()).unwrap();
2601        let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2602        assert_eq!(
2603            sequences,
2604            (1..=sequences.len() as u64).collect::<Vec<u64>>(),
2605            "records stay globally contiguous across sessions"
2606        );
2607    }
2608
2609    #[test]
2610    fn head_and_strict_segment_names_detect_deletion_gaps_and_aliases() {
2611        let deleted = tempdir().unwrap();
2612        let wal = SharedWal::create(deleted.path(), Epoch(0)).unwrap();
2613        drop(wal);
2614        std::fs::remove_file(deleted.path().join("_wal/seg-000000.wal")).unwrap();
2615        assert!(SharedWal::replay(deleted.path()).is_err());
2616
2617        let alias = tempdir().unwrap();
2618        let wal = SharedWal::create(alias.path(), Epoch(0)).unwrap();
2619        drop(wal);
2620        std::fs::rename(
2621            alias.path().join("_wal/seg-000000.wal"),
2622            alias.path().join("_wal/seg-0.wal"),
2623        )
2624        .unwrap();
2625        assert!(SharedWal::replay(alias.path()).is_err());
2626
2627        let gap = tempdir().unwrap();
2628        let mut wal = SharedWal::create(gap.path(), Epoch(0)).unwrap();
2629        wal.rotate(1).unwrap();
2630        drop(wal);
2631        std::fs::rename(
2632            gap.path().join("_wal/seg-000001.wal"),
2633            gap.path().join("_wal/seg-000002.wal"),
2634        )
2635        .unwrap();
2636        assert!(SharedWal::replay(gap.path()).is_err());
2637    }
2638
2639    #[test]
2640    fn verify_and_gc_fail_closed_on_segment_corruption() {
2641        let dir = tempdir().unwrap();
2642        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2643        wal.append_commit(1, Epoch(1), &[]).unwrap();
2644        wal.group_sync().unwrap();
2645        wal.rotate(1).unwrap();
2646        wal.append_commit(2, Epoch(2), &[]).unwrap();
2647        wal.group_sync().unwrap();
2648
2649        let old = dir.path().join("_wal/seg-000000.wal");
2650        let mut bytes = std::fs::read(&old).unwrap();
2651        let last = bytes.len() - 1;
2652        bytes[last] ^= 0x40;
2653        std::fs::write(&old, bytes).unwrap();
2654        assert!(!wal.verify_segments().is_empty());
2655        assert!(wal.gc_segments(u64::MAX).is_err());
2656        assert!(old.exists());
2657    }
2658
2659    #[test]
2660    fn rotation_within_session_keeps_sequence_contiguous() {
2661        let dir = tempdir().unwrap();
2662        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2663        wal.append_commit(1, Epoch(1), &[]).unwrap();
2664        wal.rotate(1).unwrap();
2665        wal.append_commit(2, Epoch(2), &[]).unwrap();
2666        wal.rotate(2).unwrap();
2667        wal.append_commit(3, Epoch(3), &[]).unwrap();
2668        wal.group_sync().unwrap();
2669        drop(wal);
2670
2671        let records = SharedWal::replay(dir.path()).unwrap();
2672        let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2673        assert_eq!(
2674            sequences,
2675            (1..=sequences.len() as u64).collect::<Vec<u64>>()
2676        );
2677        assert_eq!(sequences.len(), 6, "two records per commit");
2678    }
2679
2680    #[test]
2681    fn open_truncates_garbage_beyond_the_authenticated_durable_prefix() {
2682        let dir = tempdir().unwrap();
2683        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2684        wal.append_commit(1, Epoch(1), &[]).unwrap();
2685        wal.group_sync().unwrap();
2686        drop(wal);
2687
2688        let seg = dir.path().join("_wal/seg-000000.wal");
2689        let published_len = std::fs::metadata(&seg).unwrap().len();
2690        let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2691        file.write_all(&[0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4, 5])
2692            .unwrap();
2693        file.sync_all().unwrap();
2694        drop(file);
2695
2696        // Bytes past the durable head were never published: open discards
2697        // them and keeps every durable record.
2698        let wal = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2699        drop(wal);
2700        assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2701        let records = SharedWal::replay(dir.path()).unwrap();
2702        assert_eq!(records.len(), 2);
2703        assert!(records.iter().all(|record| record.seq.0 <= 2));
2704    }
2705
2706    #[test]
2707    fn open_rejects_damage_inside_the_authenticated_prefix() {
2708        let dir = tempdir().unwrap();
2709        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2710        wal.append_commit(1, Epoch(1), &[]).unwrap();
2711        wal.group_sync().unwrap();
2712        wal.rotate(1).unwrap();
2713        wal.append_commit(2, Epoch(2), &[]).unwrap();
2714        wal.group_sync().unwrap();
2715        drop(wal);
2716
2717        // Corrupt one byte inside segment 0's record payload; segment 1's
2718        // previous-segment hash must fail to authenticate it.
2719        let seg = dir.path().join("_wal/seg-000000.wal");
2720        let mut bytes = std::fs::read(&seg).unwrap();
2721        bytes[(HEADER_LEN + 12) as usize] ^= 0x01;
2722        std::fs::write(&seg, bytes).unwrap();
2723
2724        assert!(matches!(
2725            SharedWal::replay(dir.path()),
2726            Err(MongrelError::CorruptWal { .. })
2727        ));
2728        assert!(SharedWal::open(dir.path(), Epoch(2), None).is_err());
2729    }
2730
2731    #[cfg(feature = "encryption")]
2732    #[test]
2733    fn encrypted_sessions_continue_one_global_sequence() {
2734        let dek = || Zeroizing::new([7_u8; 32]);
2735        let dir = tempdir().unwrap();
2736        for session in 0..3_u64 {
2737            let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), Some(dek()))
2738                .unwrap_or_else(|_| {
2739                    SharedWal::create_with_dek(dir.path(), Epoch(session * 2), Some(dek())).unwrap()
2740                });
2741            for i in 0..2_u64 {
2742                let txn = session * 2 + i + 1;
2743                wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2744            }
2745            wal.group_sync().unwrap();
2746            drop(wal);
2747        }
2748
2749        let records = SharedWal::replay_with_dek(dir.path(), Some(&dek())).unwrap();
2750        let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2751        assert_eq!(
2752            sequences,
2753            (1..=sequences.len() as u64).collect::<Vec<u64>>()
2754        );
2755
2756        // Unpublished bytes are discarded for encrypted segments as well.
2757        let seg = dir.path().join("_wal/seg-000002.wal");
2758        let published_len = std::fs::metadata(&seg).unwrap().len();
2759        let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2760        file.write_all(&[9, 9, 9, 9, 9, 9, 9]).unwrap();
2761        file.sync_all().unwrap();
2762        drop(file);
2763        let wal = SharedWal::open(dir.path(), Epoch(6), Some(dek())).unwrap();
2764        drop(wal);
2765        assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2766    }
2767}
2768
2769#[cfg(test)]
2770mod tests {
2771    use super::*;
2772    use tempfile::tempdir;
2773
2774    #[cfg(feature = "encryption")]
2775    fn frame_ranges(bytes: &[u8]) -> Vec<std::ops::Range<usize>> {
2776        let mut ranges = Vec::new();
2777        let mut offset = HEADER_LEN as usize;
2778        while offset < bytes.len() {
2779            let mut length = [0_u8; 4];
2780            length.copy_from_slice(&bytes[offset..offset + 4]);
2781            let end = offset + 24 + u32::from_le_bytes(length) as usize;
2782            ranges.push(offset..end);
2783            offset = end;
2784        }
2785        ranges
2786    }
2787
2788    fn recompute_frame_crc(bytes: &mut [u8], start: usize) {
2789        let mut length = [0_u8; 4];
2790        length.copy_from_slice(&bytes[start..start + 4]);
2791        let length = u32::from_le_bytes(length) as usize;
2792        let seq = &bytes[start + 8..start + 16];
2793        let txn_id = &bytes[start + 16..start + 24];
2794        let payload = &bytes[start + 24..start + 24 + length];
2795        let mut digest = CRC32C.digest();
2796        digest.update(seq);
2797        digest.update(txn_id);
2798        digest.update(payload);
2799        bytes[start + 4..start + 8].copy_from_slice(&digest.finalize().to_le_bytes());
2800    }
2801
2802    #[test]
2803    fn append_then_replay_roundtrips() {
2804        let dir = tempdir().unwrap();
2805        let path = dir.path().join("seg-000000.wal");
2806        let mut wal = Wal::create(&path, Epoch(100)).unwrap();
2807        let s1 = wal
2808            .append_txn(
2809                7,
2810                Op::Put {
2811                    table_id: 1,
2812                    rows: vec![1, 2, 3],
2813                },
2814            )
2815            .unwrap();
2816        let s2 = wal
2817            .append_txn(
2818                7,
2819                Op::Delete {
2820                    table_id: 1,
2821                    row_ids: vec![RowId(7)],
2822                },
2823            )
2824            .unwrap();
2825        assert_eq!(s1, Epoch(101));
2826        assert_eq!(s2, Epoch(102));
2827        wal.sync().unwrap();
2828
2829        let records = replay(&path).unwrap();
2830        assert_eq!(records.len(), 2);
2831        assert_eq!(records[0].seq, Epoch(101));
2832        assert_eq!(records[0].txn_id, 7);
2833        match &records[0].op {
2834            Op::Put { table_id, rows } => {
2835                assert_eq!(*table_id, 1);
2836                assert_eq!(rows, &vec![1, 2, 3]);
2837            }
2838            other => panic!("unexpected op {other:?}"),
2839        }
2840        match &records[1].op {
2841            Op::Delete { row_ids, .. } => {
2842                assert_eq!(*row_ids, vec![RowId(7)]);
2843            }
2844            other => panic!("unexpected op {other:?}"),
2845        }
2846    }
2847
2848    #[test]
2849    fn record_roundtrips_with_txn_id_and_commit_marker() {
2850        let dir = tempdir().unwrap();
2851        let path = dir.path().join("seg-000000.wal");
2852        let mut w = Wal::create(&path, Epoch(0)).unwrap();
2853        w.append_txn(
2854            7,
2855            Op::Put {
2856                table_id: 3,
2857                rows: vec![1, 2, 3],
2858            },
2859        )
2860        .unwrap();
2861        w.append_txn(
2862            7,
2863            Op::TxnCommit {
2864                epoch: 11,
2865                added_runs: vec![],
2866            },
2867        )
2868        .unwrap();
2869        w.sync().unwrap();
2870        let recs = replay(&path).unwrap();
2871        assert_eq!(recs[0].txn_id, 7);
2872        assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
2873        assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
2874        // system records carry the reserved id
2875        let system_path = dir.path().join("seg-000001.wal");
2876        let mut w2 = Wal::create(&system_path, Epoch(0)).unwrap();
2877        w2.append_system(Op::Flush {
2878            table_id: 3,
2879            flushed_epoch: 11,
2880        })
2881        .unwrap();
2882        w2.sync().unwrap();
2883        let recs = replay(&system_path).unwrap();
2884        assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
2885        assert!(matches!(recs[0].op, Op::Flush { .. }));
2886    }
2887
2888    #[test]
2889    fn catalog_snapshot_and_external_reset_roundtrip() {
2890        let dir = tempdir().unwrap();
2891        let path = dir.path().join("seg-catalog.wal");
2892        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2893        wal.append_txn(
2894            9,
2895            Op::Ddl(DdlOp::CatalogSnapshot {
2896                catalog_json: br#"{"db_epoch":7}"#.to_vec(),
2897            }),
2898        )
2899        .unwrap();
2900        wal.append_txn(
2901            9,
2902            Op::Ddl(DdlOp::ResetExternalTableState {
2903                name: "ext".into(),
2904                generation_epoch: 7,
2905            }),
2906        )
2907        .unwrap();
2908        wal.sync().unwrap();
2909
2910        let records = replay(&path).unwrap();
2911        assert!(matches!(
2912            &records[0].op,
2913            Op::Ddl(DdlOp::CatalogSnapshot { catalog_json })
2914                if catalog_json == br#"{"db_epoch":7}"#
2915        ));
2916        assert!(matches!(
2917            &records[1].op,
2918            Op::Ddl(DdlOp::ResetExternalTableState {
2919                name,
2920                generation_epoch: 7,
2921            }) if name == "ext"
2922        ));
2923    }
2924
2925    #[test]
2926    fn torn_write_is_detected() {
2927        let dir = tempdir().unwrap();
2928        let path = dir.path().join("seg-000001.wal");
2929        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2930        wal.append_txn(
2931            1,
2932            Op::Put {
2933                table_id: 1,
2934                rows: vec![0; 10],
2935            },
2936        )
2937        .unwrap();
2938        wal.sync().unwrap();
2939        drop(wal);
2940
2941        // Append a garbage partial record (simulate a crash mid-write).
2942        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
2943        // REC_LEN claims 64 bytes but we only write a handful.
2944        f.write_all(&64u32.to_le_bytes()).unwrap();
2945        f.write_all(&[0u8; 7]).unwrap();
2946        f.sync_all().unwrap();
2947        drop(f);
2948
2949        let mut reader = WalReader::open(&path).unwrap();
2950        // The first real record reads fine.
2951        assert!(reader.next_record().unwrap().is_some());
2952        // The partial record surfaces as a torn write.
2953        let err = reader.next_record().unwrap_err();
2954        assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
2955    }
2956
2957    #[test]
2958    fn crc_corruption_is_detected() {
2959        let dir = tempdir().unwrap();
2960        let path = dir.path().join("seg-000002.wal");
2961        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2962        wal.append_txn(
2963            1,
2964            Op::Put {
2965                table_id: 9,
2966                rows: vec![1, 2, 3, 4],
2967            },
2968        )
2969        .unwrap();
2970        wal.sync().unwrap();
2971        drop(wal);
2972
2973        // Flip a payload byte well past the header.
2974        let mut bytes = std::fs::read(&path).unwrap();
2975        let last = bytes.len() - 1;
2976        bytes[last] ^= 0xFF;
2977        std::fs::write(&path, bytes).unwrap();
2978
2979        let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
2980        assert!(
2981            matches!(err, MongrelError::CorruptWal { .. }),
2982            "got {err:?}"
2983        );
2984    }
2985
2986    #[test]
2987    fn trailing_torn_is_eof_but_interior_corruption_errors() {
2988        let dir = tempdir().unwrap();
2989
2990        // (a) good records then a half-written trailing frame -> replay returns
2991        //     the good prefix (torn tail = clean EOF).
2992        let path_a = dir.path().join("seg-torn.wal");
2993        let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
2994        wal.append_txn(
2995            1,
2996            Op::Put {
2997                table_id: 1,
2998                rows: vec![1],
2999            },
3000        )
3001        .unwrap();
3002        wal.append_txn(
3003            1,
3004            Op::Put {
3005                table_id: 1,
3006                rows: vec![2],
3007            },
3008        )
3009        .unwrap();
3010        wal.sync().unwrap();
3011        drop(wal);
3012        // Append a partial trailing frame (claims 64 bytes, only 7 written).
3013        let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
3014        f.write_all(&64u32.to_le_bytes()).unwrap();
3015        f.write_all(&[0u8; 7]).unwrap();
3016        f.sync_all().unwrap();
3017        drop(f);
3018        let recs = replay(&path_a).unwrap();
3019        assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
3020
3021        // (b) corrupt an INTERIOR frame's CRC and append a valid frame after ->
3022        //     replay errors (interior corruption, not a torn tail).
3023        let path_b = dir.path().join("seg-interior.wal");
3024        let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
3025        wal.append_txn(
3026            1,
3027            Op::Put {
3028                table_id: 1,
3029                rows: vec![10, 20, 30],
3030            },
3031        )
3032        .unwrap();
3033        wal.append_txn(
3034            1,
3035            Op::Put {
3036                table_id: 1,
3037                rows: vec![40],
3038            },
3039        )
3040        .unwrap();
3041        wal.sync().unwrap();
3042        drop(wal);
3043        // Flip a payload byte of the FIRST frame (interior), leaving the second
3044        // frame intact so a valid frame follows the corrupt one.
3045        let mut bytes = std::fs::read(&path_b).unwrap();
3046        let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; // past len+crc+seq+txn_id
3047        bytes[first_payload_byte] ^= 0xFF;
3048        std::fs::write(&path_b, bytes).unwrap();
3049        let err = replay(&path_b).unwrap_err();
3050        assert!(
3051            matches!(err, MongrelError::CorruptWal { .. }),
3052            "interior corruption must error, got {err:?}"
3053        );
3054
3055        // (c) a complete trailing frame with a bad CRC is corruption. Only a
3056        //     physically short final frame is an admissible crash-torn tail.
3057        let path_c = dir.path().join("seg-badtail.wal");
3058        let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
3059        wal.append_txn(
3060            1,
3061            Op::Put {
3062                table_id: 1,
3063                rows: vec![5],
3064            },
3065        )
3066        .unwrap();
3067        wal.sync().unwrap();
3068        drop(wal);
3069        let mut bytes = std::fs::read(&path_c).unwrap();
3070        let last = bytes.len() - 1;
3071        bytes[last] ^= 0xFF;
3072        std::fs::write(&path_c, bytes).unwrap();
3073        assert!(matches!(
3074            replay(&path_c),
3075            Err(MongrelError::CorruptWal { .. })
3076        ));
3077    }
3078
3079    #[test]
3080    fn byte_threshold_auto_syncs() {
3081        let dir = tempdir().unwrap();
3082        let path = dir.path().join("seg-000003.wal");
3083        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3084        wal.sync_byte_threshold = 1; // sync after every record
3085        wal.append_txn(
3086            1,
3087            Op::Put {
3088                table_id: 1,
3089                rows: vec![0; 5],
3090            },
3091        )
3092        .unwrap();
3093        assert_eq!(
3094            wal.unflushed_bytes(),
3095            0,
3096            "threshold should have auto-synced"
3097        );
3098    }
3099
3100    #[test]
3101    fn create_never_replaces_an_existing_segment() {
3102        let dir = tempdir().unwrap();
3103        let path = dir.path().join("seg-000000.wal");
3104        drop(Wal::create(&path, Epoch(0)).unwrap());
3105        let before = std::fs::read(&path).unwrap();
3106        assert!(matches!(
3107            Wal::create(&path, Epoch(0)),
3108            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists
3109        ));
3110        assert_eq!(std::fs::read(path).unwrap(), before);
3111    }
3112
3113    #[test]
3114    fn zero_length_frame_never_hides_a_wal_suffix() {
3115        let dir = tempdir().unwrap();
3116        let path = dir.path().join("seg-000000.wal");
3117        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3118        wal.append_system(Op::Flush {
3119            table_id: 1,
3120            flushed_epoch: 1,
3121        })
3122        .unwrap();
3123        wal.sync().unwrap();
3124        drop(wal);
3125        let original = std::fs::read(&path).unwrap();
3126        let mut bytes = original[..HEADER_LEN as usize].to_vec();
3127        bytes.extend_from_slice(&0_u32.to_le_bytes());
3128        bytes.extend_from_slice(&original[HEADER_LEN as usize..]);
3129        std::fs::write(&path, bytes).unwrap();
3130        assert!(matches!(
3131            replay(&path),
3132            Err(MongrelError::CorruptWal { .. })
3133        ));
3134    }
3135
3136    #[test]
3137    fn reader_rejects_outer_inner_record_identity_mismatch() {
3138        let dir = tempdir().unwrap();
3139        let path = dir.path().join("seg-000000.wal");
3140        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3141        wal.append_system(Op::Flush {
3142            table_id: 1,
3143            flushed_epoch: 1,
3144        })
3145        .unwrap();
3146        wal.sync().unwrap();
3147        drop(wal);
3148
3149        let mut bytes = std::fs::read(&path).unwrap();
3150        let start = HEADER_LEN as usize;
3151        let mut length = [0_u8; 4];
3152        length.copy_from_slice(&bytes[start..start + 4]);
3153        let length = u32::from_le_bytes(length) as usize;
3154        let payload = &bytes[start + 24..start + 24 + length];
3155        let mut record: Record = bincode::deserialize(payload).unwrap();
3156        record.seq = Epoch(99);
3157        let replacement = bincode::serialize(&record).unwrap();
3158        assert_eq!(replacement.len(), length);
3159        bytes[start + 24..start + 24 + length].copy_from_slice(&replacement);
3160        recompute_frame_crc(&mut bytes, start);
3161        std::fs::write(&path, bytes).unwrap();
3162
3163        assert!(matches!(
3164            WalReader::open(&path).unwrap().next_record(),
3165            Err(MongrelError::CorruptWal { .. })
3166        ));
3167    }
3168
3169    #[cfg(feature = "encryption")]
3170    #[test]
3171    fn encrypted_frames_reject_reorder_replay_deletion_and_cross_segment_move() {
3172        fn cipher(key: &[u8; 32]) -> Box<dyn crate::encryption::Cipher> {
3173            Box::new(crate::encryption::AesCipher::new(key).unwrap())
3174        }
3175
3176        let dir = tempdir().unwrap();
3177        let key = [0x5a; 32];
3178        let path = dir.path().join("seg-000009.wal");
3179        let mut wal = Wal::create_with_cipher(&path, Epoch(0), Some(cipher(&key)), 9).unwrap();
3180        for table_id in [1, 2] {
3181            wal.append_system(Op::Flush {
3182                table_id,
3183                flushed_epoch: table_id,
3184            })
3185            .unwrap();
3186        }
3187        wal.sync().unwrap();
3188        drop(wal);
3189        let original = std::fs::read(&path).unwrap();
3190        let ranges = frame_ranges(&original);
3191        assert_eq!(ranges.len(), 2);
3192
3193        let mut reordered = original[..HEADER_LEN as usize].to_vec();
3194        reordered.extend_from_slice(&original[ranges[1].clone()]);
3195        reordered.extend_from_slice(&original[ranges[0].clone()]);
3196        std::fs::write(&path, reordered).unwrap();
3197        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3198
3199        let mut replayed = original[..HEADER_LEN as usize].to_vec();
3200        replayed.extend_from_slice(&original[ranges[0].clone()]);
3201        replayed.extend_from_slice(&original[ranges[0].clone()]);
3202        replayed.extend_from_slice(&original[ranges[1].clone()]);
3203        std::fs::write(&path, replayed).unwrap();
3204        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3205
3206        let mut deleted = original[..HEADER_LEN as usize].to_vec();
3207        deleted.extend_from_slice(&original[ranges[1].clone()]);
3208        std::fs::write(&path, deleted).unwrap();
3209        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3210
3211        let mut outer_tampered = original.clone();
3212        outer_tampered[ranges[0].start + 8] ^= 0x01;
3213        recompute_frame_crc(&mut outer_tampered, ranges[0].start);
3214        std::fs::write(&path, outer_tampered).unwrap();
3215        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3216
3217        let other = dir.path().join("seg-000010.wal");
3218        let mut wal = Wal::create_with_cipher(&other, Epoch(0), Some(cipher(&key)), 10).unwrap();
3219        wal.append_system(Op::Flush {
3220            table_id: 3,
3221            flushed_epoch: 3,
3222        })
3223        .unwrap();
3224        wal.sync().unwrap();
3225        drop(wal);
3226        let mut moved = std::fs::read(&other).unwrap();
3227        moved.truncate(HEADER_LEN as usize);
3228        moved.extend_from_slice(&original[ranges[0].clone()]);
3229        std::fs::write(&other, moved).unwrap();
3230        assert!(replay_with_cipher(&other, Some(cipher(&key))).is_err());
3231    }
3232
3233    #[cfg(feature = "encryption")]
3234    #[test]
3235    fn wal_nonce_is_segment_deterministic() {
3236        // Two segments with different segment_no must never share a frame nonce
3237        // base, and frames within a segment never collide.
3238        assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
3239        assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
3240        // Deterministic: identical positions produce identical nonces. Segment
3241        // paths are create-new and never reused.
3242        assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
3243    }
3244}