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