Skip to main content

mongreldb_core/
wal.rs

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