1use 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#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct WalRecord {
26 pub seq: u64,
28 pub envelope: ObjectEnvelope,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum WalRecordStatus {
37 Evaluated,
39 Failed {
42 message: String,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct WalRecordOutcome {
50 pub offset: usize,
52 pub status: WalRecordStatus,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct WalReplay {
59 pub records: Vec<WalRecord>,
62 pub trailing_partial_bytes: usize,
67 pub record_outcomes: Vec<WalRecordOutcome>,
73}
74
75impl WalReplay {
76 #[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#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct WalRepair {
88 pub preserved_records: usize,
90 pub truncated_bytes: usize,
92 pub preserved_patch_ids: Vec<ObjectId>,
96}
97
98#[derive(Debug, Clone)]
100pub struct Wal {
101 path: PathBuf,
102 mutation: Option<(MutationRoot, PathBuf)>,
103 layout: Option<RepositoryLayout>,
104}
105
106impl Wal {
107 #[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 #[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 #[must_use]
131 pub fn path(&self) -> &Path {
132 &self.path
133 }
134
135 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 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 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 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 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 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 truncate_file_empty_required(root, relative)
289 }
290
291 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
361enum FrameAttempt {
363 Record {
365 record: WalRecord,
366 next_offset: usize,
367 },
368 TrailingPartial { remaining: usize },
374 Invalid { message: String },
377}
378
379fn 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 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
434fn 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#[cfg(all(test, target_os = "linux"))]
534mod tests;