1use {
23 self::codec::{HEADER_LEN, decode_record, encode_frame, file_header, strip_file_header},
24 crate::{
25 event::AuditEventClass,
26 ids::{MaxSpoolBytes, RecordCount, SpoolBytes},
27 sink::{AuditError, AuditRecord},
28 },
29 krabka_units::{
30 fmt::Human as _,
31 prelude::{ByteSize, ByteSizeExt as _},
32 },
33 krabka_verified::{
34 audit::{AuditLosses, settle_loss_batch},
35 spool_append_decision,
36 },
37 std::{
38 fmt::Display,
39 fs::{self, File, OpenOptions},
40 io::{ErrorKind, Read, Seek, SeekFrom, Write},
41 num::NonZeroU64,
42 path::{Path, PathBuf},
43 sync::{Arc, Mutex, PoisonError},
44 },
45 tracing::field::Empty,
46};
47
48mod codec;
49mod resume;
50
51#[cfg(test)]
52pub(crate) use self::codec::HEADER_LEN as FILE_HEADER_LEN;
53
54#[cfg(test)]
55mod test_support;
56
57const SPOOL_FILE: &str = "audit.spool";
58const SPOOL_TMP: &str = "audit.spool.tmp";
59const LOSS_STATE_FILE: &str = "audit.losses";
60const LOSS_STATE_TMP: &str = "audit.losses.tmp";
61const LOSS_STATE_LEN: usize = 16;
62const REPLAY_OFFSET_FILE: &str = "audit.replay-offset";
63const REPLAY_OFFSET_TMP: &str = "audit.replay-offset.tmp";
64const REPLAY_POISON_FILE: &str = "audit.replay-poison";
65const REPLAY_POISON_TMP: &str = "audit.replay-poison.tmp";
66
67pub(crate) fn add_loss_state(generation: u64, count: u64, added: u64) -> (u64, u64) {
68 let generation = if count == 0 {
69 generation.saturating_add(1)
70 } else {
71 generation
72 };
73 (generation, count.saturating_add(added))
74}
75
76struct ScannedSpool {
78 records: Vec<AuditRecord>,
80 frame_lengths: Vec<u64>,
82 valid_bytes: SpoolBytes,
84}
85
86pub(crate) struct ReplayRecord {
92 pub(crate) record: AuditRecord,
93 pub(crate) frame_len: u64,
94}
95
96pub(crate) const REPLAY_BATCH_RECORDS: usize = 64;
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub(crate) enum ReplayRecovery {
103 ClearPoison,
104 RequireExplicitRecovery,
105}
106
107pub(crate) fn replay_recovery(
108 valid_frame: bool,
109 poison_offset: Option<u64>,
110 replay_offset: u64,
111) -> ReplayRecovery {
112 if valid_frame && poison_offset.is_some_and(|offset| offset < replay_offset) {
113 ReplayRecovery::ClearPoison
114 } else {
115 ReplayRecovery::RequireExplicitRecovery
116 }
117}
118
119#[derive(Debug)]
121pub(crate) struct PendingLosses {
122 state: Mutex<AuditLosses>,
123 path: Option<PathBuf>,
124}
125
126impl PendingLosses {
127 pub(crate) fn memory() -> Arc<Self> {
128 Arc::new(Self {
129 state: Mutex::new(AuditLosses::default()),
130 path: None,
131 })
132 }
133
134 fn open(dir: &Path) -> Result<Arc<Self>, AuditError> {
135 let path = dir.join(LOSS_STATE_FILE);
136 let state = if path.exists() {
137 let bytes = fs::read(&path).map_err(io)?;
138 let bytes = strip_file_header(LOSS_STATE_FILE, &bytes)?;
139 let Some((generation, count)) =
141 bytes
142 .split_first_chunk::<8>()
143 .and_then(|(generation, count)| {
144 Some((*generation, <[u8; 8]>::try_from(count).ok()?))
145 })
146 else {
147 return Err(AuditError::Io(format!(
148 "invalid audit loss state length {}",
149 bytes.len()
150 )));
151 };
152 AuditLosses {
153 generation: u64::from_be_bytes(generation),
154 count: u64::from_be_bytes(count),
155 }
156 } else {
157 let state = AuditLosses::default();
158 persist_loss_state(&path, state)?;
159 state
160 };
161 Ok(Arc::new(Self {
162 state: Mutex::new(state),
163 path: Some(path),
164 }))
165 }
166
167 pub(crate) fn add(&self, count: u64) {
168 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
169 (state.generation, state.count) = add_loss_state(state.generation, state.count, count);
170 }
171
172 #[cfg(test)]
173 pub(crate) fn count(&self) -> u64 {
174 self.state
175 .lock()
176 .unwrap_or_else(PoisonError::into_inner)
177 .count
178 }
179
180 #[cfg(test)]
181 fn state(&self) -> AuditLosses { *self.state.lock().unwrap_or_else(PoisonError::into_inner) }
182
183 pub(crate) fn snapshot(&self) -> Option<AuditLosses> {
184 let state = *self.state.lock().unwrap_or_else(PoisonError::into_inner);
185 (state.count > 0).then_some(state)
186 }
187
188 pub(crate) fn commit(&self, batch: AuditLosses) {
191 self.update_and_persist(|state| {
192 if state.generation != batch.generation {
193 return false;
194 }
195 *state = settle_loss_batch(*state, batch);
196 true
197 });
198 }
199
200 pub(crate) fn persist(&self) -> Result<(), AuditError> {
201 let state = *self.state.lock().unwrap_or_else(PoisonError::into_inner);
202 self.path
203 .as_ref()
204 .map_or(Ok(()), |path| persist_loss_state(path, state))
205 }
206
207 pub(crate) fn persist_with<T>(
208 &self,
209 write_marker: impl FnOnce(AuditLosses) -> Result<T, AuditError>,
210 ) -> Result<Option<T>, AuditError> {
211 let Some(batch) = self.snapshot() else {
212 return Ok(None);
213 };
214 self.persist()?;
215 let result = write_marker(batch)?;
216 self.commit(batch);
217 Ok(Some(result))
218 }
219
220 fn reconcile(&self, records: &[AuditRecord]) {
225 self.update_and_persist(|state| {
226 if state.count == 0 {
227 return false;
228 }
229 let Some(batch) = records
230 .iter()
231 .filter_map(loss_marker_batch)
232 .find(|batch| batch.generation == state.generation)
233 else {
234 return false;
235 };
236 *state = settle_loss_batch(*state, batch);
237 true
238 });
239 }
240
241 fn update_and_persist(&self, update: impl FnOnce(&mut AuditLosses) -> bool) {
242 let state = {
243 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
244 if !update(&mut state) {
245 return;
246 }
247 *state
248 };
249 self.persist_or_warn(state);
250 }
251
252 fn persist_or_warn(&self, state: AuditLosses) {
253 if let Some(path) = &self.path
254 && let Err(error) = persist_loss_state(path, state)
255 {
256 tracing::error!(%error, "failed to persist pending audit loss count");
257 }
258 }
259}
260
261fn loss_marker_batch(record: &AuditRecord) -> Option<AuditLosses> {
263 if record.class != AuditEventClass::RecordsLost {
264 return None;
265 }
266 let value = serde_json::from_slice::<serde_json::Value>(&record.value).ok()?;
267 Some(AuditLosses {
268 generation: value.get("loss_generation")?.as_u64()?,
269 count: value.get("records_lost")?.as_u64()?,
270 })
271}
272
273fn io<E: Display>(e: E) -> AuditError { AuditError::Io(e.to_string()) }
274
275fn persist_loss_state(path: &Path, state: AuditLosses) -> Result<(), AuditError> {
276 let mut bytes = [0_u8; LOSS_STATE_LEN];
277 bytes[..8].copy_from_slice(&state.generation.to_be_bytes());
278 bytes[8..].copy_from_slice(&state.count.to_be_bytes());
279 persist_with_header(path, LOSS_STATE_TMP, &bytes)
280}
281
282fn read_or_create_u64(path: &Path, tmp_name: &str) -> Result<u64, AuditError> {
283 match fs::read(path) {
284 Ok(bytes) => <[u8; 8]>::try_from(strip_file_header(&file_name(path), &bytes)?)
285 .map(u64::from_be_bytes)
286 .map_err(|_| AuditError::Io(format!("invalid state length for {}", path.display()))),
287 Err(error) if error.kind() == ErrorKind::NotFound => {
288 persist_u64(path, tmp_name, 0)?;
289 Ok(0)
290 }
291 Err(error) => Err(io(error)),
292 }
293}
294
295fn persist_u64(path: &Path, tmp_name: &str, value: u64) -> Result<(), AuditError> {
296 persist_with_header(path, tmp_name, &value.to_be_bytes())
297}
298
299fn file_name(path: &Path) -> String {
301 path.file_name().map_or_else(
302 || path.display().to_string(),
303 |name| name.to_string_lossy().into_owned(),
304 )
305}
306
307fn persist_with_header(path: &Path, tmp_name: &str, body: &[u8]) -> Result<(), AuditError> {
309 let mut bytes = Vec::with_capacity(HEADER_LEN + body.len());
310 bytes.extend_from_slice(&file_header());
311 bytes.extend_from_slice(body);
312 persist_bytes(path, tmp_name, &bytes)
313}
314
315fn persist_bytes(path: &Path, tmp_name: &str, bytes: &[u8]) -> Result<(), AuditError> {
316 let tmp = path.with_file_name(tmp_name);
317 let mut file = OpenOptions::new()
318 .write(true)
319 .create(true)
320 .truncate(true)
321 .open(&tmp)
322 .map_err(io)?;
323 file.write_all(bytes).map_err(io)?;
324 file.sync_all().map_err(io)?;
325 fs::rename(tmp, path).map_err(io)?;
326 sync_parent(path)
327}
328
329#[derive(Debug)]
336pub struct Spool {
337 path: PathBuf,
338 file: File,
339 max_bytes: MaxSpoolBytes,
340 bytes: SpoolBytes,
341 count: RecordCount,
342 recovered_torn_tail: bool,
343 sync_every: NonZeroU64,
344 unsynced: u64,
345 replay_offset: u64,
346 pending_losses: Arc<PendingLosses>,
347}
348
349impl Spool {
350 #[tracing::instrument(
354 level = "debug",
355 skip_all,
356 fields(dir = %dir.display(), max_size = %max_size.human(), count = Empty, bytes = Empty),
357 err
358 )]
359 pub fn open(dir: &Path, max_size: ByteSize) -> Result<Self, AuditError> {
363 Self::open_with_sync_every(dir, max_size, NonZeroU64::MIN)
364 }
365
366 #[tracing::instrument(
372 level = "debug",
373 skip_all,
374 fields(dir = %dir.display(), max_size = %max_size.human(), sync_every = sync_every.get(), count = Empty, bytes = Empty),
375 err
376 )]
377 pub fn open_with_sync_every(
381 dir: &Path,
382 max_size: ByteSize,
383 sync_every: NonZeroU64,
384 ) -> Result<Self, AuditError> {
385 fs::create_dir_all(dir).map_err(io)?;
386 let pending_losses = PendingLosses::open(dir)?;
387 let path = dir.join(SPOOL_FILE);
388 let replay_offset_path = dir.join(REPLAY_OFFSET_FILE);
389 let replay_offset = read_or_create_u64(&replay_offset_path, REPLAY_OFFSET_TMP)?;
390 if !path.exists() {
391 persist_with_header(&path, SPOOL_TMP, &[])?;
394 }
395 let file = OpenOptions::new()
396 .read(true)
397 .write(true)
398 .open(&path)
399 .map_err(io)?;
400 let mut s = Self {
401 path,
402 file,
403 max_bytes: MaxSpoolBytes(max_size.bytes_u64()),
404 bytes: SpoolBytes(0),
405 count: RecordCount(0),
406 recovered_torn_tail: false,
407 sync_every,
408 unsynced: 0,
409 replay_offset,
410 pending_losses,
411 };
412 let ScannedSpool {
413 records,
414 frame_lengths,
415 valid_bytes,
416 } = s.scan()?;
417 let physical = s.file.metadata().map_err(io)?.len();
418 s.recovered_torn_tail = header_len_u64() + valid_bytes.0 < physical;
419 if s.recovered_torn_tail {
420 s.file
421 .set_len(header_len_u64() + valid_bytes.0)
422 .map_err(io)?;
423 s.file.sync_all().map_err(io)?;
424 tracing::warn!(
425 physical,
426 valid_bytes = valid_bytes.0,
427 "audit spool: truncated torn tail frame on open"
428 );
429 }
430 s.bytes = valid_bytes;
431 let unread = s.unread_record_count(&frame_lengths, valid_bytes)?;
432 s.count = RecordCount(u64::try_from(unread).unwrap_or(u64::MAX));
433 s.reconcile_replay_poison()?;
434 s.pending_losses.reconcile(&records);
435 if s.count.0 == 0 && s.replay_offset > 0 {
436 s.truncate()?;
437 }
438 let span = tracing::Span::current();
439 span.record("count", s.count.0);
440 span.record("bytes", s.bytes.0);
441 Ok(s)
442 }
443
444 #[must_use]
445 pub fn is_empty(&self) -> bool { self.count.0 == 0 }
446
447 #[must_use]
448 pub fn count(&self) -> RecordCount { self.count }
449
450 pub(crate) fn pending_losses(&self) -> Arc<PendingLosses> { Arc::clone(&self.pending_losses) }
451
452 #[must_use]
454 pub fn size(&self) -> ByteSize { ByteSize::from_bytes(self.bytes.0) }
455
456 #[must_use]
463 pub fn recovered_torn_tail(&self) -> bool { self.recovered_torn_tail }
464
465 #[tracing::instrument(
470 level = "debug",
471 skip_all,
472 fields(class = ?record.class, value_bytes = record.value.len(), count = self.count.0),
473 err
474 )]
475 pub fn append(&mut self, record: &AuditRecord) -> Result<bool, AuditError> {
479 self.append_inner(record)
480 }
481
482 pub(crate) fn append_loss_marker(&mut self, record: &AuditRecord) -> Result<(), AuditError> {
484 if !self.append(record)? {
485 return Err(AuditError::Unavailable("audit spool is full".into()));
486 }
487 self.sync()
488 }
489
490 pub(crate) fn begin_replay(&self, record: &AuditRecord) -> Result<(), AuditError> {
491 let path = self.path.with_file_name(REPLAY_POISON_FILE);
492 let mut poison = self.replay_offset.to_be_bytes().to_vec();
493 poison.extend_from_slice(&encode_frame(record)?);
494 persist_with_header(&path, REPLAY_POISON_TMP, &poison)
495 }
496
497 pub(crate) fn commit_replay_frame(&mut self, frame_len: u64) -> Result<(), AuditError> {
498 let replay_offset = self
499 .replay_offset
500 .checked_add(frame_len)
501 .ok_or_else(|| AuditError::Io("audit replay offset overflow".into()))?;
502 persist_u64(
503 &self.path.with_file_name(REPLAY_OFFSET_FILE),
504 REPLAY_OFFSET_TMP,
505 replay_offset,
506 )?;
507 self.replay_offset = replay_offset;
508 self.count.0 = self.count.0.saturating_sub(1);
509 self.clear_replay_poison()
510 }
511
512 #[cfg(test)]
513 fn commit_replay(&mut self, record: &AuditRecord) -> Result<(), AuditError> {
514 self.commit_replay_frame(u64::try_from(encode_frame(record)?.len()).unwrap_or(u64::MAX))
515 }
516
517 pub(crate) fn abort_replay(&self) -> Result<(), AuditError> { self.clear_replay_poison() }
518
519 fn clear_replay_poison(&self) -> Result<(), AuditError> {
520 let path = self.path.with_file_name(REPLAY_POISON_FILE);
521 match fs::remove_file(&path) {
522 Ok(()) => sync_parent(&path),
523 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
524 Err(error) => Err(io(error)),
525 }
526 }
527
528 fn reconcile_replay_poison(&self) -> Result<(), AuditError> {
529 let path = self.path.with_file_name(REPLAY_POISON_FILE);
530 let bytes = match fs::read(&path) {
531 Ok(bytes) => bytes,
532 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
533 Err(error) => return Err(io(error)),
534 };
535 let bytes = strip_file_header(REPLAY_POISON_FILE, &bytes)?;
536 let poison_offset = bytes
537 .get(..8)
538 .and_then(|prefix| <[u8; 8]>::try_from(prefix).ok())
539 .map(u64::from_be_bytes);
540 let valid_frame = bytes
541 .get(8..12)
542 .and_then(|prefix| <[u8; 4]>::try_from(prefix).ok())
543 .map(u32::from_be_bytes)
544 .and_then(|len| usize::try_from(len).ok())
545 .is_some_and(|len| len.checked_add(12) == Some(bytes.len()));
546 match replay_recovery(valid_frame, poison_offset, self.replay_offset) {
547 ReplayRecovery::ClearPoison => self.clear_replay_poison(),
548 ReplayRecovery::RequireExplicitRecovery => {
549 Err(AuditError::Poisoned(path.display().to_string()))
550 }
551 }
552 }
553
554 fn append_inner(&mut self, record: &AuditRecord) -> Result<bool, AuditError> {
555 let frame = encode_frame(record)?;
556 let frame_len = u64::try_from(frame.len()).unwrap_or(u64::MAX);
557 let decision = spool_append_decision(
558 self.bytes.0,
559 frame_len,
560 self.max_bytes.0,
561 self.unsynced,
562 self.sync_every.get(),
563 );
564 if !decision.accepted {
565 return Ok(false);
566 }
567 let old_len = self.file.metadata().map_err(io)?.len();
568 self.file.seek(SeekFrom::End(0)).map_err(io)?;
569 if let Err(error) = self.file.write_all(&frame) {
570 return Err(self.rollback_append(old_len, error));
571 }
572 if decision.sync
573 && let Err(error) = self.file.sync_all()
574 {
575 return Err(self.rollback_append(old_len, error));
576 }
577 self.unsynced = decision.next_unsynced;
578 self.bytes = SpoolBytes(decision.new_bytes);
579 self.count.0 = self.count.0.saturating_add(1);
580 Ok(true)
581 }
582
583 pub fn sync(&mut self) -> Result<(), AuditError> {
590 self.file.sync_all().map_err(io)?;
591 self.unsynced = 0;
592 Ok(())
593 }
594
595 fn rollback_append<E: Display>(&mut self, old_len: u64, error: E) -> AuditError {
596 let original = error.to_string();
597 let rollback = self
598 .file
599 .set_len(old_len)
600 .and_then(|()| self.file.seek(SeekFrom::Start(old_len)).map(drop))
601 .and_then(|()| self.file.sync_all());
602 match rollback {
603 Ok(()) => AuditError::Io(original),
604 Err(rollback) => AuditError::Io(format!(
605 "{original}; failed to roll back partial append: {rollback}"
606 )),
607 }
608 }
609
610 #[tracing::instrument(
617 level = "debug",
618 skip_all,
619 fields(records = Empty, valid_bytes = Empty),
620 err
621 )]
622 fn scan(&self) -> Result<ScannedSpool, AuditError> {
623 let buf = fs::read(&self.path).map_err(io)?;
624 let mut out = Vec::new();
625 let mut frame_lengths = Vec::new();
626 let mut cur: &[u8] = strip_file_header(SPOOL_FILE, &buf)?;
627 let mut valid_bytes = SpoolBytes(0);
628 while let Some((prefix, rest)) = cur.split_first_chunk::<4>() {
629 let len = usize::try_from(u32::from_be_bytes(*prefix)).unwrap_or(0);
630 let Some((frame, tail)) = rest.split_at_checked(len) else {
631 break; };
633 match decode_record(frame) {
634 Some(rec) => {
635 out.push(rec);
636 let frame_len = u64::try_from(4 + len).unwrap_or(0);
637 frame_lengths.push(frame_len);
638 valid_bytes += SpoolBytes(frame_len);
639 }
640 None => break, }
642 cur = tail;
643 }
644 let span = tracing::Span::current();
645 span.record("records", out.len());
646 span.record("valid_bytes", valid_bytes.0);
647 Ok(ScannedSpool {
648 records: out,
649 frame_lengths,
650 valid_bytes,
651 })
652 }
653
654 fn unread_record_count(
655 &self,
656 frame_lengths: &[u64],
657 valid_bytes: SpoolBytes,
658 ) -> Result<usize, AuditError> {
659 if self.replay_offset > valid_bytes.0 {
660 if valid_bytes.0 == 0 {
661 return Ok(0);
662 }
663 return Err(AuditError::Io(format!(
664 "audit replay offset {} exceeds spool length {}",
665 self.replay_offset, valid_bytes.0
666 )));
667 }
668 let mut offset = 0_u64;
669 let mut unread = 0_usize;
670 let mut at_boundary = self.replay_offset == 0;
671 for frame_len in frame_lengths {
672 if offset == self.replay_offset {
673 at_boundary = true;
674 }
675 if at_boundary {
676 unread = unread.saturating_add(1);
677 }
678 offset = offset.saturating_add(*frame_len);
679 }
680 if offset == self.replay_offset {
681 at_boundary = true;
682 }
683 if !at_boundary {
684 return Err(AuditError::Io(format!(
685 "audit replay offset {} is not a frame boundary",
686 self.replay_offset
687 )));
688 }
689 Ok(unread)
690 }
691
692 pub(crate) fn read_replay_batch(&self) -> Result<Vec<ReplayRecord>, AuditError> {
695 let mut file = File::open(&self.path).map_err(io)?;
696 file.seek(SeekFrom::Start(
697 header_len_u64()
698 .checked_add(self.replay_offset)
699 .ok_or_else(|| AuditError::Io("audit replay seek overflow".into()))?,
700 ))
701 .map_err(io)?;
702
703 let mut records = Vec::with_capacity(
704 usize::try_from(self.count.0)
705 .unwrap_or(usize::MAX)
706 .min(REPLAY_BATCH_RECORDS),
707 );
708 for _ in 0..REPLAY_BATCH_RECORDS {
709 let mut prefix = [0_u8; 4];
710 match file.read(&mut prefix) {
711 Ok(0) => break,
712 Ok(read) => {
713 let unread = prefix.get_mut(read..).ok_or_else(|| {
714 AuditError::Io("audit spool read past the frame prefix".into())
715 })?;
716 file.read_exact(unread).map_err(io)?;
717 }
718 Err(error) => return Err(io(error)),
719 }
720 let len = usize::try_from(u32::from_be_bytes(prefix)).unwrap_or(0);
721 let mut body = vec![0_u8; len];
722 file.read_exact(&mut body).map_err(io)?;
723 let record = decode_record(&body)
724 .ok_or_else(|| AuditError::Io("corrupt audit spool frame during replay".into()))?;
725 records.push(ReplayRecord {
726 record,
727 frame_len: u64::try_from(4 + len).unwrap_or(u64::MAX),
728 });
729 }
730 Ok(records)
731 }
732
733 pub fn read_all(&self) -> Result<Vec<AuditRecord>, AuditError> {
737 let ScannedSpool {
738 records,
739 frame_lengths,
740 valid_bytes,
741 } = self.scan()?;
742 let unread = self.unread_record_count(&frame_lengths, valid_bytes)?;
743 let first_unread = records.len().saturating_sub(unread);
744 Ok(records.into_iter().skip(first_unread).collect())
745 }
746
747 #[tracing::instrument(level = "debug", skip_all, fields(count = self.count.0, bytes = self.bytes.0), err)]
749 pub fn truncate(&mut self) -> Result<(), AuditError> {
753 self.file.set_len(header_len_u64()).map_err(io)?;
755 self.bytes = SpoolBytes(0);
756 self.count = RecordCount(0);
757 self.unsynced = 0;
758 self.file
759 .seek(SeekFrom::Start(header_len_u64()))
760 .map_err(io)?;
761 self.file.sync_all().map_err(io)?;
762 persist_u64(
763 &self.path.with_file_name(REPLAY_OFFSET_FILE),
764 REPLAY_OFFSET_TMP,
765 0,
766 )?;
767 self.replay_offset = 0;
768 Ok(())
769 }
770}
771
772fn header_len_u64() -> u64 {
774 u64::try_from(HEADER_LEN).unwrap_or(u64::MAX)
776}
777
778#[cfg(unix)]
780#[cfg_attr(test, mutants::skip)]
781fn sync_parent(path: &Path) -> Result<(), AuditError> {
782 File::open(path.parent().unwrap_or_else(|| Path::new(".")))
783 .map_err(io)?
784 .sync_all()
785 .map_err(io)
786}
787
788#[cfg(not(unix))]
790#[cfg_attr(test, mutants::skip)]
791fn sync_parent(path: &Path) -> Result<(), AuditError> {
792 path.parent()
796 .unwrap_or_else(|| Path::new("."))
797 .metadata()
798 .map_err(io)
799 .map(drop)
800}
801
802#[cfg(test)]
803mod tests {
804 use {
805 super::*,
806 crate::{
807 chain::{GENESIS_HEAD, chain_hash},
808 event::{
809 AuditEndpoint, AuditEvent, AuditEventClass, AuditOutcome, AuditPrincipal,
810 PrivilegedPhase,
811 },
812 ocsf::ProductInfo,
813 spool::test_support::{
814 LossySpool, ROOMY_CAP, SeededSpool, chained_record, check_empty_file,
815 reopen_after_losses, seeded_spool, spool_with_losses,
816 },
817 test_support::TempSpool,
818 },
819 assert2::check,
820 krabka_units::prelude::ByteSize,
821 std::{
822 env,
823 fs::{self, Permissions},
824 path::Path,
825 process::{self, Command, Stdio},
826 },
827 };
828
829 fn append_torn_frame(path: &Path, partial_body: &[u8]) {
830 use std::io::Write;
831
832 let mut file = fs::OpenOptions::new().append(true).open(path).unwrap();
833 file.write_all(&100u32.to_be_bytes()).unwrap();
834 file.write_all(partial_body).unwrap();
835 }
836
837 #[test]
838 fn append_then_read_round_trips_records_with_headers() {
839 let dir = tempfile::tempdir().unwrap();
840 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
841 check!(s.is_empty());
842 let r0 = chained_record(0, &GENESIS_HEAD, b"{\"i\":0}");
843 let r1 = chained_record(1, &chain_hash(&GENESIS_HEAD, 0, b"{\"i\":0}"), b"{\"i\":1}");
844 check!((s.append(&r0).unwrap(), s.append(&r1).unwrap()) == (true, true));
845 let read = s.read_all().unwrap();
846 check!((s.count().0, s.is_empty(), read) == (2, false, vec![r0.clone(), r1.clone()]));
847 }
848
849 #[test]
853 fn privileged_action_record_round_trips_through_the_frame_format() {
854 let product = ProductInfo {
855 vendor_name: "Krabka".into(),
856 name: "krabka-broker".into(),
857 version: "0".into(),
858 };
859 let event = AuditEvent::PrivilegedAction {
860 outcome: AuditOutcome::Success,
861 phase: PrivilegedPhase::Approved,
862 action: "unclean_elect_leaders".into(),
863 target: "orders-3".into(),
864 proposal_id: "bg-7".into(),
865 principal: AuditPrincipal {
866 name: "User:bob".into(),
867 auth_method: "MTls".into(),
868 },
869 counterparties: vec![AuditPrincipal {
870 name: "User:alice".into(),
871 auth_method: "MTls".into(),
872 }],
873 approver_set_fingerprint: "f00dcafe".into(),
874 key_id: "op-1".into(),
875 signature: vec![0xde, 0xad, 0xbe, 0xef],
876 signature_verified: true,
877 signed_at_ms: 0,
878 source: AuditEndpoint {
879 ip: "10.0.0.4".into(),
880 port: 9092,
881 },
882 reason: "incident 42".into(),
883 time_ms: 10,
884 };
885 let mut rec = AuditRecord::from_event(&event, &product);
886 rec.push_chain_headers(0, &GENESIS_HEAD);
887
888 let dir = tempfile::tempdir().unwrap();
889 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
890 check!(s.append(&rec).unwrap());
891 let s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
894 check!(s.read_all().unwrap() == vec![rec.clone()]);
895 check!(rec.class == AuditEventClass::ApiActivity);
896 }
897
898 #[test]
899 fn overflow_is_rejected_not_truncated() {
900 let dir = tempfile::tempdir().unwrap();
901 let r = chained_record(0, &GENESIS_HEAD, b"0123456789");
903 let one = {
904 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
905 s.append(&r).unwrap();
906 s.size()
907 };
908 let dir2 = tempfile::tempdir().unwrap();
909 let mut s = Spool::open(dir2.path(), one).unwrap(); check!(s.append(&r).unwrap()); check!(!s.append(&r).unwrap()); check!((s.count().0, s.read_all().unwrap().len()) == (1, 1)); }
914
915 #[test]
916 fn append_rejects_size_overflow_without_writing() {
917 let dir = tempfile::tempdir().unwrap();
918 let mut spool = Spool::open(dir.path(), ByteSize::from_bytes(u64::MAX)).unwrap();
919 spool.bytes = SpoolBytes(u64::MAX);
920
921 check!(
922 !spool
923 .append(&chained_record(0, &GENESIS_HEAD, b"x"))
924 .unwrap()
925 );
926 check!((spool.bytes.0, spool.count.0) == (u64::MAX, 0));
927 check_empty_file(dir.path());
928 }
929
930 #[test]
931 fn replay_cursor_keeps_only_unacknowledged_records_after_reopen() {
932 let dir = tempfile::tempdir().unwrap();
933 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
934 let r0 = chained_record(0, &GENESIS_HEAD, b"a");
935 let r1 = chained_record(1, &GENESIS_HEAD, b"b");
936 let r2 = chained_record(2, &GENESIS_HEAD, b"c");
937 s.append(&r0).unwrap();
938 s.append(&r1).unwrap();
939 s.append(&r2).unwrap();
940 let physical_size = s.size();
941 s.begin_replay(&r0).unwrap();
942 s.commit_replay(&r0).unwrap();
943 drop(s);
944
945 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
946 check!(s.size() > ByteSize::ZERO); check!(s.size() == physical_size);
948 check!((s.count().0, s.read_all().unwrap()) == (2, vec![r1, r2]));
949 s.truncate().unwrap();
950 check!((s.is_empty(), s.read_all().unwrap().is_empty()) == (true, true));
951 }
952
953 #[test]
954 fn replay_batches_are_bounded_and_seek_past_the_committed_prefix() {
955 let dir = tempfile::tempdir().unwrap();
956 let mut spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
957 for sequence in 0..(REPLAY_BATCH_RECORDS + 3) {
958 check!(
959 spool
960 .append(&chained_record(
961 u64::try_from(sequence).unwrap(),
962 &GENESIS_HEAD,
963 &[u8::try_from(sequence).unwrap()],
964 ))
965 .unwrap()
966 );
967 }
968
969 let first = spool.read_replay_batch().unwrap();
970 check!(first.len() == REPLAY_BATCH_RECORDS);
971 for replay in first {
972 spool.commit_replay_frame(replay.frame_len).unwrap();
973 }
974 check!(spool.count().0 == 3);
975
976 let mut file = OpenOptions::new()
980 .write(true)
981 .open(dir.path().join(SPOOL_FILE))
982 .unwrap();
983 file.seek(SeekFrom::Start(header_len_u64() + 4)).unwrap();
984 file.write_all(&[0xff]).unwrap();
985
986 let remaining = spool.read_replay_batch().unwrap();
987 check!(remaining.len() == 3);
988 check!(remaining[0].record.value == vec![u8::try_from(REPLAY_BATCH_RECORDS).unwrap()]);
989 }
990
991 #[test]
995 #[ignore = "manual audit spool performance probe"]
996 fn measure_replay_batch_across_large_backlogs() {
997 use std::{io::BufWriter, time::Instant};
998
999 for backlog in [1_024_usize, 8_192] {
1000 let dir = tempfile::tempdir().unwrap();
1001 drop(Spool::open(dir.path(), ByteSize::from_bytes(u64::MAX)).unwrap());
1002 let record = chained_record(0, &GENESIS_HEAD, &vec![b'x'; 8 * 1_024]);
1003 let frame = encode_frame(&record).unwrap();
1004 let committed = backlog - REPLAY_BATCH_RECORDS;
1005 let replay_offset = u64::try_from(committed * frame.len()).unwrap();
1006
1007 let file = File::create(dir.path().join(SPOOL_FILE)).unwrap();
1008 let mut writer = BufWriter::new(file);
1009 writer.write_all(&file_header()).unwrap();
1010 for _ in 0..backlog {
1011 writer.write_all(&frame).unwrap();
1012 }
1013 writer.into_inner().unwrap().sync_all().unwrap();
1014 persist_u64(
1015 &dir.path().join(REPLAY_OFFSET_FILE),
1016 REPLAY_OFFSET_TMP,
1017 replay_offset,
1018 )
1019 .unwrap();
1020
1021 let started = Instant::now();
1022 let spool = Spool::open(dir.path(), ByteSize::from_bytes(u64::MAX)).unwrap();
1023 let recovery = started.elapsed();
1024 let started = Instant::now();
1025 let batch = spool.read_replay_batch().unwrap();
1026 let replay = started.elapsed();
1027 let replay_bytes: u64 = batch.iter().map(|record| record.frame_len).sum();
1028 let peak_kib = fs::read_to_string("/proc/self/status")
1029 .ok()
1030 .and_then(|status| {
1031 status.lines().find_map(|line| {
1032 line.strip_prefix("VmHWM:")?
1033 .split_whitespace()
1034 .next()?
1035 .parse::<u64>()
1036 .ok()
1037 })
1038 });
1039 let recovery_mib_per_second = u128::from(spool.size().bytes_u64())
1040 .saturating_mul(1_000_000_000)
1041 / recovery.as_nanos().max(1)
1042 / (1_u128 << 20);
1043 eprintln!(
1044 "backlog={backlog} spool_bytes={} recovery_us={} recovery_mib_per_second={recovery_mib_per_second} replay_records={} replay_bytes={replay_bytes} replay_us={} process_peak_kib={peak_kib:?}",
1045 spool.size().bytes_u64(),
1046 recovery.as_micros(),
1047 batch.len(),
1048 replay.as_micros(),
1049 );
1050 }
1051 }
1052
1053 #[test]
1054 fn append_accepts_record_that_exactly_fills_to_max() {
1055 let probe = tempfile::tempdir().unwrap();
1056 let r = chained_record(0, &GENESIS_HEAD, b"payload");
1057 let one = {
1058 let mut s = Spool::open(probe.path(), ROOMY_CAP).unwrap();
1059 s.append(&r).unwrap();
1060 s.size()
1061 };
1062 let dir = tempfile::tempdir().unwrap();
1066 let mut s = Spool::open(dir.path(), one * 2.0).unwrap();
1067 check!(s.append(&r).unwrap());
1068 check!(s.append(&r).unwrap());
1069 check!(s.count() == 2);
1070 }
1071
1072 #[test]
1073 fn size_reports_the_bytes_actually_on_disk() {
1074 let dir = tempfile::tempdir().unwrap();
1078 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1079 s.append(&chained_record(0, &GENESIS_HEAD, b"payload"))
1080 .unwrap();
1081 let on_disk = fs::metadata(dir.path().join(SPOOL_FILE)).unwrap().len();
1082 check!(s.size() == ByteSize::from_bytes(on_disk - header_len_u64()));
1083 }
1084
1085 #[test]
1086 fn open_heals_torn_tail_frame() {
1087 let SeededSpool {
1088 dir,
1089 record: r0,
1090 spool,
1091 } = seeded_spool(b"good");
1092 drop(spool);
1093
1094 append_torn_frame(&dir.path().join("audit.spool"), b"abc");
1096 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1098 assert2::check!((s.count().0, s.read_all().unwrap()) == (1, vec![r0.clone()]));
1099 let r1 = chained_record(1, &GENESIS_HEAD, b"more");
1100 assert2::check!(s.append(&r1).unwrap());
1101 assert2::check!(s.read_all().unwrap() == vec![r0, r1]);
1102 }
1103
1104 #[test]
1108 fn open_reports_whether_it_healed_a_torn_tail() {
1109 let SeededSpool { dir, spool, .. } = seeded_spool(b"good");
1110 drop(spool);
1111 let spool_file = dir.path().join(SPOOL_FILE);
1112
1113 let intact_len = fs::metadata(&spool_file).unwrap().len();
1114 {
1115 let intact = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1116 check!(!intact.recovered_torn_tail());
1117 }
1118 check!(fs::metadata(&spool_file).unwrap().len() == intact_len);
1119
1120 append_torn_frame(&spool_file, b"abc");
1122 let healed = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1123 check!(healed.recovered_torn_tail());
1124 check!(fs::metadata(&spool_file).unwrap().len() == intact_len);
1125 }
1126
1127 #[test]
1128 fn reopen_recovers_existing_contents() {
1129 let dir = tempfile::tempdir().unwrap();
1130 let r0 = chained_record(0, &GENESIS_HEAD, b"x");
1131 {
1132 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1133 s.append(&r0).unwrap();
1134 }
1135 let s2 = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1136 check!((s2.count().0, s2.read_all().unwrap()) == (1, vec![r0]));
1137 }
1138
1139 #[test]
1140 fn cadence_and_explicit_sync_reset_the_unsynced_counter() {
1141 let dir = tempfile::tempdir().unwrap();
1142 let mut spool =
1143 Spool::open_with_sync_every(dir.path(), ROOMY_CAP, NonZeroU64::new(2).unwrap())
1144 .unwrap();
1145 check!(
1146 spool
1147 .append(&chained_record(0, &GENESIS_HEAD, b"a"))
1148 .unwrap()
1149 );
1150 check!(spool.unsynced == 1);
1151 check!(
1152 spool
1153 .append(&chained_record(1, &GENESIS_HEAD, b"b"))
1154 .unwrap()
1155 );
1156 check!(spool.unsynced == 0);
1157 check!(
1158 spool
1159 .append(&chained_record(2, &GENESIS_HEAD, b"c"))
1160 .unwrap()
1161 );
1162 check!(spool.unsynced == 1);
1163 spool.sync().unwrap();
1164 check!(spool.unsynced == 0);
1165 }
1166
1167 #[test]
1168 fn acknowledged_record_survives_child_crash_and_torn_tail() {
1169 const CHILD_DIR: &str = "KRABKA_AUDIT_SPOOL_CRASH_DIR";
1170 if let Some(dir) = env::var_os(CHILD_DIR) {
1171 let dir = PathBuf::from(dir);
1172 let mut spool = Spool::open(&dir, ROOMY_CAP).unwrap();
1173 let record = chained_record(0, &GENESIS_HEAD, b"acknowledged");
1174 check!(spool.append(&record).unwrap());
1175 drop(spool);
1176
1177 append_torn_frame(&dir.join(SPOOL_FILE), b"torn");
1180 process::abort();
1181 }
1182
1183 let dir = tempfile::tempdir().unwrap();
1184 let status = Command::new(env::current_exe().unwrap())
1185 .arg("acknowledged_record_survives_child_crash_and_torn_tail")
1186 .env(CHILD_DIR, dir.path())
1187 .stdout(Stdio::null())
1188 .stderr(Stdio::null())
1189 .status()
1190 .unwrap();
1191 check!(!status.success());
1192
1193 let spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1194 let record = chained_record(0, &GENESIS_HEAD, b"acknowledged");
1195 check!(spool.recovered_torn_tail());
1196 check!(spool.read_all().unwrap() == vec![record]);
1197 }
1198
1199 #[test]
1200 fn pending_loss_survives_reopen_and_reconciles_a_durable_marker() {
1201 let LossySpool { dir, spool, losses } = spool_with_losses(ByteSize::from_bytes(0), 3);
1202 losses.persist().unwrap();
1203 let batch = losses.snapshot().unwrap();
1204 drop(losses);
1205 drop(spool);
1206
1207 let mut reopened = Spool::open(dir.path(), ByteSize::from_bytes(0)).unwrap();
1208 check!(reopened.pending_losses().count() == 3);
1209 let mut marker = AuditRecord::records_lost(batch.count, batch.generation);
1210 marker.push_chain_headers(0, &GENESIS_HEAD);
1211 check!(reopened.append_loss_marker(&marker).is_err());
1212 check!(reopened.size() == ByteSize::ZERO);
1213 drop(reopened);
1214
1215 let mut reopened = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1216 reopened.append_loss_marker(&marker).unwrap();
1217 drop(reopened);
1219
1220 let reopened = Spool::open(dir.path(), ByteSize::from_bytes(0)).unwrap();
1221 check!(reopened.pending_losses().count() == 0);
1222 check!(reopened.read_all().unwrap() == vec![marker]);
1223 }
1224
1225 #[test]
1226 fn uncertain_replay_poison_survives_reopen_until_record_is_removed() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let record = chained_record(0, &GENESIS_HEAD, b"uncertain");
1229 let mut spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1230 check!(spool.append(&record).unwrap());
1231 spool.begin_replay(&record).unwrap();
1232
1233 check!(matches!(
1234 Spool::open(dir.path(), ROOMY_CAP),
1235 Err(AuditError::Poisoned(_))
1236 ));
1237
1238 spool.commit_replay(&record).unwrap();
1239 drop(spool);
1240 let spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1241 check!(spool.is_empty());
1242 check!(!dir.path().join(REPLAY_POISON_FILE).exists());
1243 }
1244
1245 #[test]
1246 fn commit_and_persist_pending_losses() {
1247 let LossySpool { dir, spool, losses } = spool_with_losses(ROOMY_CAP, 5);
1248 losses.persist().unwrap();
1249 let batch = losses.snapshot().unwrap();
1250 let wrong_batch = AuditLosses {
1251 generation: batch.generation + 1,
1252 count: batch.count,
1253 };
1254 losses.commit(wrong_batch);
1255 check!(losses.count() == 5);
1256
1257 losses.commit(batch);
1258 check!(losses.count() == 0);
1259
1260 let reopened = reopen_after_losses(dir.path(), spool, losses);
1261 check!(reopened.pending_losses().count() == 0);
1262 }
1263
1264 #[test]
1265 fn reconcile_requires_matching_class_and_generation() {
1266 let LossySpool {
1267 dir: _dir,
1268 spool: _spool,
1269 losses,
1270 } = spool_with_losses(ROOMY_CAP, 4);
1271 let generation = losses.snapshot().unwrap().generation;
1272
1273 let payload =
1274 serde_json::to_vec(&serde_json::json!({ "loss_generation": generation })).unwrap();
1275 let app_event = chained_record(0, &GENESIS_HEAD, &payload);
1276 losses.reconcile(&[app_event]);
1277 check!(losses.count() == 4);
1278
1279 let wrong_gen_marker = AuditRecord::records_lost(4, generation + 1);
1280 losses.reconcile(&[wrong_gen_marker]);
1281 check!(losses.count() == 4);
1282
1283 let matching_marker = AuditRecord::records_lost(4, generation);
1284 losses.reconcile(&[matching_marker]);
1285 check!(losses.count() == 0);
1286 }
1287
1288 #[test]
1291 fn commit_carries_a_concurrent_loss_into_a_fresh_generation() {
1292 let LossySpool { dir, spool, losses } = spool_with_losses(ROOMY_CAP, 3);
1293 let batch = losses.persist_with(Ok).unwrap().unwrap();
1294 check!(
1295 losses.state()
1296 == AuditLosses {
1297 generation: batch.generation,
1298 count: 0,
1299 }
1300 );
1301
1302 losses.add(2);
1303 let batch = losses.snapshot().unwrap();
1304 losses.persist().unwrap();
1305 losses.add(1);
1306 losses.commit(batch);
1307 let carried = AuditLosses {
1308 generation: batch.generation + 1,
1309 count: 1,
1310 };
1311 check!(losses.state() == carried);
1312 check!(losses.snapshot() == Some(carried));
1313
1314 let reopened = reopen_after_losses(dir.path(), spool, losses);
1315 check!(reopened.pending_losses().state() == carried);
1316 }
1317
1318 #[test]
1322 fn reconcile_settles_only_the_markers_count() {
1323 let LossySpool {
1324 dir,
1325 mut spool,
1326 losses,
1327 } = spool_with_losses(ROOMY_CAP, 3);
1328 let batch = losses.snapshot().unwrap();
1329 losses.persist().unwrap();
1330 losses.add(1);
1331 losses.persist().unwrap();
1332 let mut marker = AuditRecord::records_lost(batch.count, batch.generation);
1333 marker.push_chain_headers(0, &GENESIS_HEAD);
1334 spool.append_loss_marker(&marker).unwrap();
1335 drop(losses);
1336 drop(spool);
1337
1338 let carried = AuditLosses {
1339 generation: batch.generation + 1,
1340 count: 1,
1341 };
1342 for _ in 0..2 {
1343 let reopened = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1344 check!(reopened.pending_losses().state() == carried);
1345 check!(reopened.read_all().unwrap() == vec![marker.clone()]);
1346 }
1347 }
1348
1349 #[cfg(unix)]
1350 fn is_root() -> bool {
1351 if let Ok(status) = fs::read_to_string("/proc/self/status") {
1352 for line in status.lines() {
1353 if let Some(rest) = line.strip_prefix("Uid:") {
1354 let fields: Vec<&str> = rest.split_whitespace().collect();
1355 if let Some(euid) = fields.get(1) {
1356 return *euid == "0";
1357 }
1358 }
1359 }
1360 }
1361 false
1362 }
1363
1364 #[test]
1365 #[cfg(unix)]
1366 fn read_or_create_u64_propagates_non_not_found_error() {
1367 use std::os::unix::fs::PermissionsExt;
1368 let dir = tempfile::tempdir().unwrap();
1369 let path = dir.path().join("forbidden");
1370 fs::write(&path, [0u8; 8]).unwrap();
1371 fs::set_permissions(&path, Permissions::from_mode(0o000)).unwrap();
1372 if is_root() || fs::File::open(&path).is_ok() {
1373 return;
1374 }
1375 let result = read_or_create_u64(&path, "tmp");
1376 check!(matches!(result, Err(AuditError::Io(_))));
1377 }
1378
1379 #[test]
1380 fn abort_replay_clears_poison_and_clear_poison_handles_absent() {
1381 let SeededSpool { dir, record, spool } = seeded_spool(b"data");
1382
1383 check!(spool.clear_replay_poison().is_ok());
1384
1385 spool.begin_replay(&record).unwrap();
1386 check!(dir.path().join(REPLAY_POISON_FILE).exists());
1387
1388 spool.abort_replay().unwrap();
1389 check!(!dir.path().join(REPLAY_POISON_FILE).exists());
1390 }
1391
1392 #[test]
1393 fn reconcile_replay_poison_detects_torn_frame() {
1394 let dir = tempfile::tempdir().unwrap();
1395 let poison_path = dir.path().join(REPLAY_POISON_FILE);
1396 let mut corrupted = file_header().to_vec();
1397 corrupted.extend_from_slice(&0u64.to_be_bytes());
1398 corrupted.extend_from_slice(&100u32.to_be_bytes());
1399 corrupted.extend_from_slice(b"abcd");
1400 fs::write(&poison_path, &corrupted).unwrap();
1401
1402 let replay_offset_path = dir.path().join(REPLAY_OFFSET_FILE);
1403 fs::write(&replay_offset_path, with_header(&100u64.to_be_bytes())).unwrap();
1404
1405 let res = Spool::open(dir.path(), ROOMY_CAP);
1406 check!(matches!(res, Err(AuditError::Poisoned(_))));
1407 }
1408
1409 #[test]
1410 fn replayed_spool_is_truncated_on_reopen() {
1411 let SeededSpool {
1412 dir,
1413 record,
1414 mut spool,
1415 } = seeded_spool(b"hello");
1416 spool.commit_replay(&record).unwrap();
1417 drop(spool);
1418
1419 let reopened = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1420 check!(reopened.size() == ByteSize::ZERO);
1421 check_empty_file(dir.path());
1422 }
1423
1424 #[test]
1425 fn unread_records_rejects_replay_offset_past_valid_bytes() {
1426 let SeededSpool { dir, spool, .. } = seeded_spool(b"valid");
1427 let valid_len = spool.size().bytes_u64();
1428 drop(spool);
1429
1430 let replay_offset_path = dir.path().join(REPLAY_OFFSET_FILE);
1431 fs::write(
1432 &replay_offset_path,
1433 with_header(&(valid_len + 100).to_be_bytes()),
1434 )
1435 .unwrap();
1436
1437 let res = Spool::open(dir.path(), ROOMY_CAP);
1438 check!(matches!(res, Err(AuditError::Io(_))));
1439 }
1440
1441 #[test]
1442 #[cfg(unix)]
1443 fn clear_replay_poison_propagates_permission_denied() {
1444 use std::os::unix::fs::PermissionsExt;
1445 let SeededSpool { dir, record, spool } = seeded_spool(b"data");
1446 spool.begin_replay(&record).unwrap();
1447
1448 fs::set_permissions(dir.path(), Permissions::from_mode(0o555)).unwrap();
1449 if is_root() || fs::write(dir.path().join("probe"), b"probe").is_ok() {
1450 let _ = fs::set_permissions(dir.path(), Permissions::from_mode(0o755));
1451 let _ = fs::remove_file(dir.path().join("probe"));
1452 return;
1453 }
1454 let res = spool.clear_replay_poison();
1455 let _ = fs::set_permissions(dir.path(), Permissions::from_mode(0o755));
1456 check!(matches!(res, Err(AuditError::Io(_))));
1457 }
1458
1459 #[test]
1460 #[cfg(unix)]
1461 fn reconcile_replay_poison_propagates_read_error() {
1462 use std::os::unix::fs::PermissionsExt;
1463 let dir = tempfile::tempdir().unwrap();
1464 let poison_path = dir.path().join(REPLAY_POISON_FILE);
1465 fs::write(&poison_path, [0u8; 16]).unwrap();
1466 fs::set_permissions(&poison_path, Permissions::from_mode(0o000)).unwrap();
1467 if is_root() || fs::File::open(&poison_path).is_ok() {
1468 let _ = fs::set_permissions(&poison_path, Permissions::from_mode(0o644));
1469 return;
1470 }
1471
1472 let res = Spool::open(dir.path(), ROOMY_CAP);
1473 let _ = fs::set_permissions(&poison_path, Permissions::from_mode(0o644));
1474 check!(matches!(res, Err(AuditError::Io(_))));
1475 }
1476
1477 fn with_header(body: &[u8]) -> Vec<u8> {
1479 let mut bytes = file_header().to_vec();
1480 bytes.extend_from_slice(body);
1481 bytes
1482 }
1483
1484 const GOLDEN_SPOOL: &[u8] = &[
1487 b'K', b'A', b'U', b'D', 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x03, 0x00, 0x00, 0x00, 0x02, b'{', b'}', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, b'k', 0x00, 0x00, 0x00, 0x02, b'v', b'1', ];
1495
1496 const FUTURE_HEADER: &[u8] = &[b'K', b'A', b'U', b'D', 0x00, 0x01];
1497 const NEGATIVE_HEADER: &[u8] = &[b'K', b'A', b'U', b'D', 0xff, 0xff];
1498
1499 fn golden_record() -> AuditRecord {
1500 AuditRecord {
1501 class: AuditEventClass::ApplicationLifecycle,
1502 value: b"{}".to_vec(),
1503 headers: vec![("k".into(), b"v1".to_vec())],
1504 }
1505 }
1506
1507 struct ExpectedSidecar {
1509 file: &'static str,
1510 want: Vec<u8>,
1511 }
1512
1513 fn spooled_golden_record() -> TempSpool {
1514 let dir = tempfile::tempdir().unwrap();
1515 let mut spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1516 check!(spool.append(&golden_record()).unwrap());
1517 TempSpool { dir, spool }
1518 }
1519
1520 #[test]
1521 fn spool_file_is_the_golden_bytes() {
1522 let TempSpool { dir, spool: _spool } = spooled_golden_record();
1523
1524 check!(fs::read(dir.path().join(SPOOL_FILE)).unwrap() == GOLDEN_SPOOL);
1525 }
1526
1527 #[test]
1528 fn golden_spool_file_reads_back_as_its_record() {
1529 let dir = tempfile::tempdir().unwrap();
1530 fs::write(dir.path().join(SPOOL_FILE), GOLDEN_SPOOL).unwrap();
1531 let spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1532
1533 check!(
1534 (spool.recovered_torn_tail(), spool.read_all().unwrap())
1535 == (false, vec![golden_record()])
1536 );
1537 }
1538
1539 #[test]
1542 fn sidecar_files_are_the_golden_bytes() {
1543 let TempSpool { dir, spool } = spooled_golden_record();
1544 spool.pending_losses().add(3);
1545 spool.pending_losses().persist().unwrap();
1546 spool.begin_replay(&golden_record()).unwrap();
1547
1548 let header: &[u8] = &[b'K', b'A', b'U', b'D', 0x00, 0x00];
1549 let cases: [ExpectedSidecar; 3] = [
1550 ExpectedSidecar {
1551 file: LOSS_STATE_FILE,
1552 want: [header, &1u64.to_be_bytes(), &3u64.to_be_bytes()].concat(),
1553 },
1554 ExpectedSidecar {
1555 file: REPLAY_OFFSET_FILE,
1556 want: [header, &0u64.to_be_bytes()].concat(),
1557 },
1558 ExpectedSidecar {
1559 file: REPLAY_POISON_FILE,
1560 want: [header, &0u64.to_be_bytes(), &GOLDEN_SPOOL[6..]].concat(),
1561 },
1562 ];
1563 for ExpectedSidecar { file, want } in cases {
1564 check!(fs::read(dir.path().join(file)).unwrap() == want, "{file}");
1565 }
1566 }
1567
1568 #[test]
1572 fn golden_sidecar_files_read_back() {
1573 let dir = tempfile::tempdir().unwrap();
1574 let header: &[u8] = &[b'K', b'A', b'U', b'D', 0x00, 0x00];
1575 let frame_len = u64::try_from(GOLDEN_SPOOL.len() - 6).unwrap();
1576 let golden_two = [GOLDEN_SPOOL, &GOLDEN_SPOOL[6..]].concat();
1577 fs::write(dir.path().join(SPOOL_FILE), &golden_two).unwrap();
1578 fs::write(
1579 dir.path().join(LOSS_STATE_FILE),
1580 [header, &1u64.to_be_bytes(), &3u64.to_be_bytes()].concat(),
1581 )
1582 .unwrap();
1583 fs::write(
1584 dir.path().join(REPLAY_OFFSET_FILE),
1585 [header, &frame_len.to_be_bytes()].concat(),
1586 )
1587 .unwrap();
1588 fs::write(
1589 dir.path().join(REPLAY_POISON_FILE),
1590 [header, &0u64.to_be_bytes(), &GOLDEN_SPOOL[6..]].concat(),
1591 )
1592 .unwrap();
1593
1594 let spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
1595 check!(
1596 (
1597 spool.pending_losses().state(),
1598 spool.replay_offset,
1599 spool.read_all().unwrap(),
1600 dir.path().join(REPLAY_POISON_FILE).exists(),
1601 ) == (
1602 AuditLosses {
1603 generation: 1,
1604 count: 3,
1605 },
1606 frame_len,
1607 vec![golden_record()],
1608 false,
1609 )
1610 );
1611 }
1612
1613 #[yare::parameterized(
1617 spool_v0_frame = { SPOOL_FILE, GOLDEN_SPOOL[6..].to_vec(), None },
1618 spool_empty = { SPOOL_FILE, Vec::new(), None },
1619 spool_short_header = { SPOOL_FILE, b"KAU".to_vec(), None },
1620 spool_future_version = {
1621 SPOOL_FILE,
1622 [FUTURE_HEADER, &GOLDEN_SPOOL[6..]].concat(),
1623 Some(1),
1624 },
1625 spool_negative_version = { SPOOL_FILE, NEGATIVE_HEADER.to_vec(), Some(-1) },
1626 losses_empty_version = { LOSS_STATE_FILE, [0u8; 16].to_vec(), None },
1627 losses_future_version = { LOSS_STATE_FILE, [FUTURE_HEADER, &[0u8; 16]].concat(), Some(1) },
1628 replay_offset_empty_version = { REPLAY_OFFSET_FILE, [0u8; 8].to_vec(), None },
1629 replay_offset_future_version = {
1630 REPLAY_OFFSET_FILE,
1631 [FUTURE_HEADER, &[0u8; 8]].concat(),
1632 Some(1),
1633 },
1634 replay_poison_empty_version = {
1635 REPLAY_POISON_FILE,
1636 [&0u64.to_be_bytes()[..], &GOLDEN_SPOOL[6..]].concat(),
1637 None,
1638 },
1639 replay_poison_future_version = {
1640 REPLAY_POISON_FILE,
1641 [FUTURE_HEADER, &0u64.to_be_bytes(), &GOLDEN_SPOOL[6..]].concat(),
1642 Some(1),
1643 },
1644 )]
1645 fn open_refuses_a_missing_or_unknown_file_header(
1646 file: &str,
1647 contents: Vec<u8>,
1648 found: Option<i16>,
1649 ) {
1650 let dir = tempfile::tempdir().unwrap();
1651 fs::write(dir.path().join(file), &contents).unwrap();
1652 let got = match Spool::open(dir.path(), ROOMY_CAP) {
1653 Err(AuditError::UnsupportedSpoolFormat { file, found }) => Some((file, found)),
1654 _ => None,
1655 };
1656 check!(
1657 got == Some((file.to_owned(), found)),
1658 "{file} {contents:x?}"
1659 );
1660 }
1661
1662 #[yare::parameterized(
1663 no_header = {
1664 None,
1665 "audit spool file audit.spool: has no version header; it predates krabka 1.0, so the audit spool directory must be emptied (the node reformatted) before this broker starts",
1666 },
1667 future_version = {
1668 Some(3),
1669 "audit spool file audit.spool: has version 3, which this build does not read",
1670 },
1671 )]
1672 fn unsupported_spool_format_names_the_file_and_the_version(found: Option<i16>, want: &str) {
1673 let error = AuditError::UnsupportedSpoolFormat {
1674 file: SPOOL_FILE.into(),
1675 found,
1676 };
1677 check!(error.to_string() == want);
1678 }
1679}