Skip to main content

mongreldb_core/
wal.rs

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