Skip to main content

prikk_store/
wal.rs

1//! Write-ahead log for active patch envelopes.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use prikk_error::{PrikkError, Result};
7use prikk_hash::sha256;
8use prikk_object::{ObjectEnvelope, ObjectId, ObjectType};
9
10use crate::byte_cursor::ByteCursor;
11use crate::file_codec::{decode_envelope_file, encode_envelope_file, push_u16, push_u64};
12use crate::frame_resync::resync_to_next_magic;
13use crate::fsutil::{
14    MutationRoot, append_file_required, len_to_u64, read_file_if_exists,
15    truncate_existing_file_required, truncate_file_empty_required,
16};
17use crate::layout::RepositoryLayout;
18
19const WAL_RECORD_MAGIC: &[u8; 8] = b"PWALR001";
20const WAL_RECORD_VERSION: u16 = 1;
21const WAL_HEADER_LEN: usize = 8 + 2 + 8 + 8 + 32;
22
23/// One durable WAL record.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct WalRecord {
26    /// Monotonic WAL sequence.
27    pub seq: u64,
28    /// Exact signed object envelope stored at commit time.
29    pub envelope: ObjectEnvelope,
30}
31
32/// Outcome of attempting to decode one WAL record frame (RFC 102 Stage 2: isolate-and-continue
33/// reading). File identity here is the byte offset a frame attempt started at, not a stored id --
34/// a frame that failed to validate has no other identity to report.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum WalRecordStatus {
37    /// The frame at this offset was read and validated successfully.
38    Evaluated,
39    /// The frame at this offset failed to validate (bad magic/version, checksum mismatch, or a
40    /// malformed envelope) -- resync moved past it byte-wise to find the next candidate frame.
41    Failed {
42        /// The error this frame's own validation raised.
43        message: String,
44    },
45}
46
47/// One attempted WAL record frame's resolved outcome.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct WalRecordOutcome {
50    /// The byte offset within the WAL this frame attempt started at.
51    pub offset: usize,
52    /// How this frame's own read/validation resolved.
53    pub status: WalRecordStatus,
54}
55
56/// WAL replay result.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct WalReplay {
59    /// Valid records read from the WAL, in file order -- includes records found after a damaged
60    /// one (RFC 102 Stage 2), not merely a prefix up to the first failure.
61    pub records: Vec<WalRecord>,
62    /// Number of trailing bytes ignored as an incomplete final record -- a legitimate torn tail
63    /// from an interrupted append, unchanged in meaning from before Stage 2. Zero when the WAL's
64    /// end was reached via resync after unrecoverable corruption (see `record_outcomes`) rather
65    /// than a genuinely incomplete final frame.
66    pub trailing_partial_bytes: usize,
67    /// One outcome per attempted frame, in scan order -- both `Evaluated` and `Failed`. A frame
68    /// that failed and was then found again via resync (a false-positive magic match inside
69    /// corrupted bytes) gets its own entry too; this is deliberately not deduplicated into "one
70    /// finding per corrupted region", since each is an independently true statement about what was
71    /// attempted at that offset.
72    pub record_outcomes: Vec<WalRecordOutcome>,
73}
74
75impl WalReplay {
76    /// Return true when any attempted frame failed to validate.
77    #[must_use]
78    pub fn has_item_failure(&self) -> bool {
79        self.record_outcomes
80            .iter()
81            .any(|outcome| matches!(outcome.status, WalRecordStatus::Failed { .. }))
82    }
83}
84
85/// Result of a safe WAL tail truncation.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct WalRepair {
88    /// Number of valid records preserved after repair.
89    pub preserved_records: usize,
90    /// Number of trailing partial bytes truncated.
91    pub truncated_bytes: usize,
92    /// Patch object ids of the preserved records, in WAL order. DC-66 criterion 5: a repair against a
93    /// queue of N must say *which* authors' work survived, not just how many records — "3 records
94    /// preserved" does not answer that for N > 1 the way it unambiguously did for N = 1.
95    pub preserved_patch_ids: Vec<ObjectId>,
96}
97
98/// File-backed active-session WAL.
99#[derive(Debug, Clone)]
100pub struct Wal {
101    path: PathBuf,
102    mutation: Option<(MutationRoot, PathBuf)>,
103    layout: Option<RepositoryLayout>,
104}
105
106impl Wal {
107    /// Create a WAL handle for a path.
108    #[must_use]
109    pub fn new(path: impl Into<PathBuf>) -> Self {
110        Self {
111            path: path.into(),
112            mutation: None,
113            layout: None,
114        }
115    }
116
117    /// Create a WAL handle authorized by a validated repository layout.
118    #[must_use]
119    pub fn for_layout(layout: &RepositoryLayout) -> Self {
120        let path = layout.default_queue_wal_path();
121        let relative = PathBuf::from("active/default/queue.wal");
122        Self {
123            path,
124            mutation: Some((layout.repository_mutation_root().clone(), relative)),
125            layout: Some(layout.clone()),
126        }
127    }
128
129    /// Return the WAL path.
130    #[must_use]
131    pub fn path(&self) -> &Path {
132        &self.path
133    }
134
135    /// Append a signed patch envelope and fsync the WAL file.
136    pub fn append_patch(&self, envelope: &ObjectEnvelope) -> Result<u64> {
137        self.require_current_format()?;
138        if envelope.object_type != ObjectType::Patch {
139            return Err(PrikkError::ObjectTypeMismatch {
140                expected: ObjectType::Patch.to_string(),
141                actual: envelope.object_type.to_string(),
142            });
143        }
144        if envelope.signatures.is_empty() {
145            return Err(PrikkError::InvalidSignature(
146                "commit WAL entries must store signed patch envelopes".to_string(),
147            ));
148        }
149        envelope.validate_strict()?;
150        if envelope.schema_version != 1 {
151            return Err(PrikkError::Integrity(format!(
152                "format-2 Patch requires envelope schema 1, got {}",
153                envelope.schema_version
154            )));
155        }
156        let replay = self.replay()?;
157        if replay.trailing_partial_bytes != 0 {
158            return Err(PrikkError::Integrity(
159                "cannot append after an incomplete WAL tail".to_string(),
160            ));
161        }
162        // RFC 102 Stage 2: isolate-and-continue reading means a damaged record no longer makes
163        // `replay()` itself return `Err` -- append must refuse explicitly, since `next_seq` and
164        // "does the last record already match" below are both computed from `replay.records`,
165        // which silently omits a damaged record rather than surfacing it as an error here.
166        if replay.has_item_failure() {
167            return Err(PrikkError::Integrity(
168                "cannot append after a damaged WAL record; run doctor before appending".to_string(),
169            ));
170        }
171        let (root, relative) = self.mutation()?;
172        match replay.records.last() {
173            Some(last) if last.envelope == *envelope => {
174                append_file_required(root, relative, &[])?;
175                return Ok(last.seq);
176            }
177            _ => {}
178        }
179        let next_seq = replay.records.last().map_or(Ok(1), |last| {
180            last.seq
181                .checked_add(1)
182                .ok_or_else(|| PrikkError::MalformedData("WAL sequence overflow".to_string()))
183        })?;
184        let record = WalRecord {
185            seq: next_seq,
186            envelope: envelope.clone(),
187        };
188        let bytes = encode_record(&record)?;
189        // No `ensure_directory_required` here -- same justification as `truncate_empty_authorized`
190        // below (RFC 102 Stage 5, design-v1.md §14.8): `default_active_dir()` is in
191        // `required_repository_directories()`, permanent from `init`, and since `d8f5240` made
192        // `durable_append` strict, the WAL file must already exist for the append to succeed, so it
193        // cannot exist without its directory -- this call could not help even if the directory were
194        // somehow absent.
195        if relative.parent().is_none() {
196            return Err(PrikkError::Io(
197                "WAL path has no parent directory".to_string(),
198            ));
199        }
200        append_file_required(root, relative, &bytes)?;
201        Ok(next_seq)
202    }
203
204    /// Replay valid WAL records from the beginning.
205    pub fn replay(&self) -> Result<WalReplay> {
206        let Some(bytes) = self.read_bytes()? else {
207            return Ok(WalReplay {
208                records: Vec::new(),
209                trailing_partial_bytes: 0,
210                record_outcomes: Vec::new(),
211            });
212        };
213        let replay = decode_records(&bytes)?;
214        if let Some(layout) = &self.layout {
215            for record in &replay.records {
216                crate::format::validate_read_schema(layout.format(), &record.envelope)?;
217            }
218        }
219        Ok(replay)
220    }
221
222    /// Safely truncate an incomplete trailing WAL record, if one exists.
223    ///
224    /// This repairs only the case that FDD-02 defines as safe: valid records followed by an
225    /// incomplete final record. **RFC 102 Stage 2**: a mid-stream checksum mismatch in a complete
226    /// record no longer makes `decode_records` return `Err` -- it is now an item finding
227    /// (`WalReplay::has_item_failure`), which this function refuses on explicitly below rather than
228    /// silently truncating around. The only production caller (`doctor.rs`'s `repair_repository`)
229    /// already refuses earlier via `doctor_repository`'s own item-outcome reporting; this check is
230    /// defense in depth for this function's own contract, not the only thing enforcing it.
231    pub fn truncate_trailing_partial(&self) -> Result<WalRepair> {
232        self.require_current_format()?;
233        let Some(bytes) = self.read_bytes()? else {
234            return Ok(WalRepair {
235                preserved_records: 0,
236                truncated_bytes: 0,
237                preserved_patch_ids: Vec::new(),
238            });
239        };
240        let replay = decode_records(&bytes)?;
241        if replay.has_item_failure() {
242            return Err(PrikkError::Integrity(
243                "WAL has a damaged record; repair does not modify it".to_string(),
244            ));
245        }
246        let preserved_patch_ids: Vec<ObjectId> = replay
247            .records
248            .iter()
249            .map(|record| record.envelope.object_id())
250            .collect();
251        if replay.trailing_partial_bytes == 0 {
252            return Ok(WalRepair {
253                preserved_records: replay.records.len(),
254                truncated_bytes: 0,
255                preserved_patch_ids,
256            });
257        }
258        let current_len = u64::try_from(bytes.len())
259            .map_err(|_| PrikkError::MalformedData("WAL length does not fit u64".to_string()))?;
260        let trailing = u64::try_from(replay.trailing_partial_bytes).map_err(|_| {
261            PrikkError::MalformedData("trailing WAL byte count does not fit u64".to_string())
262        })?;
263        let repaired_len = current_len.checked_sub(trailing).ok_or_else(|| {
264            PrikkError::MalformedData("trailing WAL byte count exceeds file length".to_string())
265        })?;
266        let (root, relative) = self.mutation()?;
267        truncate_existing_file_required(root, relative, repaired_len)?;
268        Ok(WalRepair {
269            preserved_records: replay.records.len(),
270            truncated_bytes: replay.trailing_partial_bytes,
271            preserved_patch_ids,
272        })
273    }
274
275    /// Truncate the WAL after a successful publication that made all entries durable elsewhere.
276    pub fn truncate_empty(&self) -> Result<()> {
277        self.require_current_format()?;
278        self.truncate_empty_authorized()
279    }
280
281    fn truncate_empty_authorized(&self) -> Result<()> {
282        let (root, relative) = self.mutation()?;
283        // RFC 102 Stage 5, design-v1.md §14.8: no `ensure_directory_required` here -- unlike
284        // `append_patch`'s own call (a separate, not-yet-ruled-on finding from this same round, left
285        // untouched), this one is established dead rather than assumed: `default_active_dir()` is in
286        // `required_repository_directories()` (`layout.rs:389`), permanent from `init`, and
287        // `durable_truncate_to_empty` no longer creates a missing directory to paper over its absence.
288        truncate_file_empty_required(root, relative)
289    }
290
291    /// Return the next sequence number for append.
292    pub fn next_sequence(&self) -> Result<u64> {
293        let replay = self.replay()?;
294        let Some(last) = replay.records.last() else {
295            return Ok(1);
296        };
297        last.seq
298            .checked_add(1)
299            .ok_or_else(|| PrikkError::MalformedData("WAL sequence overflow".to_string()))
300    }
301
302    fn mutation(&self) -> Result<(&MutationRoot, &Path)> {
303        self.mutation
304            .as_ref()
305            .map(|(root, relative)| (root, relative.as_path()))
306            .ok_or_else(|| {
307                PrikkError::Io(
308                    "WAL mutation requires a validated repository layout capability".to_string(),
309                )
310            })
311    }
312
313    fn require_current_format(&self) -> Result<()> {
314        self.layout
315            .as_ref()
316            .ok_or_else(|| {
317                PrikkError::Io(
318                    "WAL mutation requires a validated repository layout capability".to_string(),
319                )
320            })?
321            .require_current_format()
322    }
323
324    fn read_bytes(&self) -> Result<Option<Vec<u8>>> {
325        if let Some((root, relative)) = &self.mutation {
326            read_file_if_exists(root, relative)
327        } else {
328            match fs::read(&self.path) {
329                Ok(bytes) => Ok(Some(bytes)),
330                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
331                Err(error) => Err(error.into()),
332            }
333        }
334    }
335}
336
337fn encode_record(record: &WalRecord) -> Result<Vec<u8>> {
338    let body = encode_envelope_file(&record.envelope)?;
339    frame_record(record.seq, &body)
340}
341
342#[cfg(test)]
343pub(crate) fn encode_record_for_test(record: &WalRecord) -> Result<Vec<u8>> {
344    let body = crate::file_codec::encode_envelope_file_structural(&record.envelope)?;
345    frame_record(record.seq, &body)
346}
347
348fn frame_record(sequence: u64, body: &[u8]) -> Result<Vec<u8>> {
349    let body_len = len_to_u64(body.len())?;
350    let checksum = record_checksum(sequence, body_len, body);
351    let mut out = Vec::with_capacity(WAL_HEADER_LEN + body.len());
352    out.extend_from_slice(WAL_RECORD_MAGIC);
353    push_u16(&mut out, WAL_RECORD_VERSION);
354    push_u64(&mut out, sequence);
355    push_u64(&mut out, body_len);
356    out.extend_from_slice(&checksum);
357    out.extend_from_slice(body);
358    Ok(out)
359}
360
361/// Result of attempting to parse one frame at a given offset.
362enum FrameAttempt {
363    /// A complete, checksummed, decodable record.
364    Record {
365        record: WalRecord,
366        next_offset: usize,
367    },
368    /// A structurally incomplete final frame -- too few bytes remain for a full header, or the
369    /// header parsed but its claimed body does not fully fit the remaining bytes. Legitimate torn
370    /// tail from an interrupted append; unchanged in meaning from before Stage 2. Only ever reached
371    /// when `offset` is within one frame's length of the true end of `bytes` (see the module doc on
372    /// `WalReplay::trailing_partial_bytes`), so there is nothing left to resync into either way.
373    TrailingPartial { remaining: usize },
374    /// The frame at this offset failed to validate for a reason that is not "ran out of bytes":
375    /// bad magic/version, checksum mismatch, or a malformed envelope after the checksum passed.
376    Invalid { message: String },
377}
378
379/// Attempt to parse one frame at `offset`. Never trusts a not-yet-checksum-validated header's own
380/// `body_len` for anything beyond locating where its claimed body would end -- the checksum, not
381/// the length field, is what makes a `Record` result trustworthy.
382fn parse_frame_at(bytes: &[u8], offset: usize) -> FrameAttempt {
383    let remaining = bytes.len().saturating_sub(offset);
384    if remaining < WAL_HEADER_LEN {
385        return FrameAttempt::TrailingPartial { remaining };
386    }
387    let header_end = offset + WAL_HEADER_LEN;
388    // In range by construction: `remaining >= WAL_HEADER_LEN` was just checked above -- `.get()`
389    // used anyway to satisfy `clippy::indexing_slicing`, not because this can fail.
390    let Some(header) = bytes.get(offset..header_end) else {
391        return FrameAttempt::TrailingPartial { remaining };
392    };
393    let header_values = match parse_header(header) {
394        Ok(values) => values,
395        Err(err) => {
396            return FrameAttempt::Invalid {
397                message: err.to_string(),
398            };
399        }
400    };
401    let Ok(body_len) = usize::try_from(header_values.body_len) else {
402        return FrameAttempt::Invalid {
403            message: "WAL body length does not fit usize".to_string(),
404        };
405    };
406    let Some(body_end) = header_end.checked_add(body_len) else {
407        return FrameAttempt::Invalid {
408            message: "WAL body end overflow".to_string(),
409        };
410    };
411    let Some(body) = bytes.get(header_end..body_end) else {
412        return FrameAttempt::TrailingPartial { remaining };
413    };
414    let expected = record_checksum(header_values.seq, header_values.body_len, body);
415    if expected != header_values.checksum {
416        return FrameAttempt::Invalid {
417            message: format!("WAL checksum mismatch at byte offset {offset}"),
418        };
419    }
420    match decode_envelope_file(body) {
421        Ok(envelope) => FrameAttempt::Record {
422            record: WalRecord {
423                seq: header_values.seq,
424                envelope,
425            },
426            next_offset: body_end,
427        },
428        Err(err) => FrameAttempt::Invalid {
429            message: err.to_string(),
430        },
431    }
432}
433
434/// RFC 102 Stage 2: isolate-and-continue reading. A frame that fails to validate no longer aborts
435/// replay -- its offset and error are recorded as a `Failed` outcome, and
436/// `frame_resync::resync_to_next_magic` (RFC 102 Stage 3: extracted here, reused by `refs/log.rs`
437/// and by the container read path rather than a third copy) finds the next candidate frame so every
438/// subsequent sound record is still read. Corruption is therefore confined to the records it
439/// actually damaged, matching amended constraint 5's blast-radius requirement (RFC 102 §3, §6.3a).
440/// This never returns `Err` for a decode-level problem -- only for conditions that are not about
441/// this WAL's own content, which is why the return type stays `Result` at all: none exist below,
442/// `decode_records` cannot fail, kept fallible for API stability and because `parse_header`'s errors
443/// are folded into `FrameAttempt::Invalid` rather than raised.
444fn decode_records(bytes: &[u8]) -> Result<WalReplay> {
445    let mut records = Vec::new();
446    let mut record_outcomes = Vec::new();
447    let mut offset = 0_usize;
448    loop {
449        match parse_frame_at(bytes, offset) {
450            FrameAttempt::Record {
451                record,
452                next_offset,
453            } => {
454                record_outcomes.push(WalRecordOutcome {
455                    offset,
456                    status: WalRecordStatus::Evaluated,
457                });
458                records.push(record);
459                offset = next_offset;
460            }
461            FrameAttempt::TrailingPartial { remaining } => {
462                return Ok(WalReplay {
463                    records,
464                    trailing_partial_bytes: remaining,
465                    record_outcomes,
466                });
467            }
468            FrameAttempt::Invalid { message } => {
469                record_outcomes.push(WalRecordOutcome {
470                    offset,
471                    status: WalRecordStatus::Failed { message },
472                });
473                match resync_to_next_magic(bytes, offset + 1, WAL_RECORD_MAGIC.as_slice()) {
474                    Some(next) => offset = next,
475                    None => {
476                        return Ok(WalReplay {
477                            records,
478                            trailing_partial_bytes: 0,
479                            record_outcomes,
480                        });
481                    }
482                }
483            }
484        }
485    }
486}
487
488struct WalHeader {
489    seq: u64,
490    body_len: u64,
491    checksum: [u8; 32],
492}
493
494fn parse_header(header: &[u8]) -> Result<WalHeader> {
495    let mut cursor = ByteCursor::new(header);
496    let magic = cursor.read_array::<8>()?;
497    if &magic != WAL_RECORD_MAGIC {
498        return Err(PrikkError::MalformedData(
499            "invalid WAL record magic".to_string(),
500        ));
501    }
502    let version = cursor.read_u16()?;
503    if version != WAL_RECORD_VERSION {
504        return Err(PrikkError::UnsupportedFormatVersion(u32::from(version)));
505    }
506    let seq = cursor.read_u64()?;
507    let body_len = cursor.read_u64()?;
508    let checksum = cursor.read_array::<32>()?;
509    if !cursor.is_finished() {
510        return Err(PrikkError::MalformedData(
511            "trailing bytes in WAL header".to_string(),
512        ));
513    }
514    Ok(WalHeader {
515        seq,
516        body_len,
517        checksum,
518    })
519}
520
521fn record_checksum(seq: u64, body_len: u64, body: &[u8]) -> [u8; 32] {
522    let mut preimage = Vec::with_capacity(8 + 2 + 8 + 8 + body.len());
523    preimage.extend_from_slice(WAL_RECORD_MAGIC);
524    preimage.extend_from_slice(&WAL_RECORD_VERSION.to_be_bytes());
525    preimage.extend_from_slice(&seq.to_be_bytes());
526    preimage.extend_from_slice(&body_len.to_be_bytes());
527    preimage.extend_from_slice(body);
528    sha256(&preimage)
529}
530
531// DC-71: every test here sets up its scenario via real repository mutation (RepositoryLayout::init
532// or equivalent), which is Linux-only; the module never compiles a non-Linux-meaningful test.
533#[cfg(all(test, target_os = "linux"))]
534mod tests;