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