1use std::fs::{File, OpenOptions};
5use std::io::{ErrorKind, Read, Write};
6use std::path::{Path, PathBuf};
7
8use bacnet_encoding::constructed::{decode_audit_log_record, encode_audit_log_record};
9use bacnet_types::bitstring::LogStatus;
10use bacnet_types::constructed::{
11 BACnetAuditLogDatum, BACnetAuditLogRecord, BACnetAuditLogRecordResult,
12};
13use bacnet_types::error::Error;
14use bacnet_types::primitives::ObjectIdentifier;
15use bytes::BytesMut;
16
17use super::receipt::{self, CompletedAuditReceipt};
18
19mod receipt_codec;
20
21pub const MAX_AUDIT_RECORDS: u32 = 10_000;
23
24const MAGIC: &[u8; 8] = b"RBALOG01";
25const SCHEMA_VERSION_V1: u16 = 1;
26const SCHEMA_VERSION_V2: u16 = 2;
29const SCHEMA_VERSION: u16 = 3;
32const HEADER_LEN: usize = 8 + 2 + 4 + 8 + 4;
33const TRAILER_LEN: usize = 4;
34const MAX_RECORD_BYTES: usize = 1024 * 1024;
35const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
36
37#[derive(Clone, Debug, PartialEq)]
39pub struct AuditLogSnapshot {
40 pub object_identifier: ObjectIdentifier,
42 pub generation: u64,
44 pub capacity: u32,
47 pub log_enable: bool,
49 pub total_record_count: u64,
51 pub records: Vec<BACnetAuditLogRecordResult>,
53 pub completed_receipts: Vec<CompletedAuditReceipt>,
55}
56
57pub trait AuditLogPersistence: Send + Sync {
63 fn load(&self, expected_object: ObjectIdentifier) -> Result<Option<AuditLogSnapshot>, Error>;
65
66 fn commit(&self, snapshot: &AuditLogSnapshot) -> Result<(), Error>;
68}
69
70#[derive(Clone, Debug)]
78pub struct FileAuditLogPersistence {
79 slot_paths: [PathBuf; 2],
80}
81
82impl FileAuditLogPersistence {
83 pub fn new(base_path: impl AsRef<Path>) -> Result<Self, Error> {
85 let base_path = base_path.as_ref();
86 if base_path.as_os_str().is_empty() {
87 return Err(Error::OutOfRange(
88 "AuditLog persistence base path must not be empty".into(),
89 ));
90 }
91 let base = base_path.as_os_str();
92 let mut slot0 = base.to_os_string();
93 slot0.push(".slot0");
94 let mut slot1 = base.to_os_string();
95 slot1.push(".slot1");
96 Ok(Self {
97 slot_paths: [PathBuf::from(slot0), PathBuf::from(slot1)],
98 })
99 }
100
101 pub fn slot_paths(&self) -> [PathBuf; 2] {
103 self.slot_paths.clone()
104 }
105}
106
107enum SlotState {
108 Missing,
109 Recoverable(Error),
110 Fatal(Error),
111 Valid(AuditLogSnapshot),
112}
113
114enum SlotReadFailure {
115 Recoverable(Error),
116 Fatal(Error),
117}
118
119impl AuditLogPersistence for FileAuditLogPersistence {
120 fn load(&self, expected_object: ObjectIdentifier) -> Result<Option<AuditLogSnapshot>, Error> {
121 let slots = [
122 read_slot(&self.slot_paths[0], expected_object),
123 read_slot(&self.slot_paths[1], expected_object),
124 ];
125 select_newest_slot(slots)
126 }
127
128 fn commit(&self, snapshot: &AuditLogSnapshot) -> Result<(), Error> {
129 let bytes = encode_snapshot(snapshot)?;
130 let path = &self.slot_paths[(snapshot.generation % 2) as usize];
131 if let Some(parent) = path
132 .parent()
133 .filter(|parent| !parent.as_os_str().is_empty())
134 {
135 std::fs::create_dir_all(parent)?;
136 }
137
138 let (mut file, created) = match OpenOptions::new().write(true).create_new(true).open(path) {
139 Ok(file) => (file, true),
140 Err(error) if error.kind() == ErrorKind::AlreadyExists => (
141 OpenOptions::new().write(true).truncate(true).open(path)?,
142 false,
143 ),
144 Err(error) => return Err(error.into()),
145 };
146 if let Err(error) = file.write_all(&bytes) {
147 invalidate_failed_slot(&file);
148 return Err(error.into());
149 }
150 if let Err(error) = file.sync_all() {
151 invalidate_failed_slot(&file);
152 return Err(error.into());
153 }
154 if created {
158 crate::durable::sync_parent_dir(path);
159 }
160 Ok(())
161 }
162}
163
164fn select_newest_slot(slots: [SlotState; 2]) -> Result<Option<AuditLogSnapshot>, Error> {
165 let mut newest: Option<AuditLogSnapshot> = None;
166 let mut present = false;
167 let mut last_error = None;
168
169 for slot in slots {
170 match slot {
171 SlotState::Missing => {}
172 SlotState::Recoverable(error) => {
173 present = true;
174 last_error = Some(error);
175 }
176 SlotState::Fatal(error) => return Err(error),
177 SlotState::Valid(snapshot) => {
178 present = true;
179 match newest.as_ref() {
180 None => newest = Some(snapshot),
181 Some(current) if snapshot.generation > current.generation => {
182 newest = Some(snapshot);
183 }
184 Some(current) if snapshot.generation == current.generation => {
185 if encode_snapshot(&snapshot)? != encode_snapshot(current)? {
186 return Err(Error::Encoding(
187 "AuditLog slots contain divergent snapshots at the same generation"
188 .into(),
189 ));
190 }
191 }
192 Some(_) => {}
193 }
194 }
195 }
196 }
197
198 if newest.is_some() {
199 Ok(newest)
200 } else if present {
201 Err(last_error.unwrap_or_else(|| {
202 Error::Encoding("AuditLog persistence has no valid compatible slot".into())
203 }))
204 } else {
205 Ok(None)
206 }
207}
208
209fn invalidate_failed_slot(file: &File) {
210 let _ = file.set_len(0);
213 let _ = file.sync_all();
214}
215
216fn read_slot(path: &Path, expected_object: ObjectIdentifier) -> SlotState {
217 match read_slot_inner(path, expected_object) {
218 Ok(Some(snapshot)) => SlotState::Valid(snapshot),
219 Ok(None) => SlotState::Missing,
220 Err(SlotReadFailure::Recoverable(error)) => SlotState::Recoverable(error),
221 Err(SlotReadFailure::Fatal(error)) => SlotState::Fatal(error),
222 }
223}
224
225fn read_slot_inner(
226 path: &Path,
227 expected_object: ObjectIdentifier,
228) -> Result<Option<AuditLogSnapshot>, SlotReadFailure> {
229 let file = match File::open(path) {
230 Ok(file) => file,
231 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
232 Err(error) => return Err(SlotReadFailure::Fatal(error.into())),
233 };
234 let metadata = file
235 .metadata()
236 .map_err(|error| SlotReadFailure::Fatal(error.into()))?;
237 let length = usize::try_from(metadata.len()).map_err(|_| {
238 SlotReadFailure::Recoverable(Error::OutOfRange(
239 "AuditLog snapshot length exceeds usize".into(),
240 ))
241 })?;
242 if length > MAX_SNAPSHOT_BYTES {
243 return Err(SlotReadFailure::Recoverable(Error::OutOfRange(format!(
244 "AuditLog snapshot length {length} exceeds {MAX_SNAPSHOT_BYTES}"
245 ))));
246 }
247 let mut data = Vec::with_capacity(length.min(MAX_SNAPSHOT_BYTES + 1));
248 file.take((MAX_SNAPSHOT_BYTES + 1) as u64)
249 .read_to_end(&mut data)
250 .map_err(|error| SlotReadFailure::Fatal(error.into()))?;
251 if data.len() > MAX_SNAPSHOT_BYTES {
252 return Err(SlotReadFailure::Recoverable(Error::OutOfRange(format!(
253 "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
254 ))));
255 }
256 if data.len() != length {
257 return Err(SlotReadFailure::Fatal(Error::Encoding(
258 "AuditLog snapshot changed while being read".into(),
259 )));
260 }
261
262 verify_snapshot_integrity(&data).map_err(SlotReadFailure::Recoverable)?;
263 let version = u16::from_be_bytes(data[8..10].try_into().unwrap());
264 if !matches!(
265 version,
266 SCHEMA_VERSION_V1 | SCHEMA_VERSION_V2 | SCHEMA_VERSION
267 ) {
268 return Err(SlotReadFailure::Fatal(Error::Encoding(format!(
269 "AuditLog snapshot schema version {version} is unsupported"
270 ))));
271 }
272 let object_identifier =
273 ObjectIdentifier::decode(&data[10..14]).map_err(SlotReadFailure::Recoverable)?;
274 if object_identifier != expected_object {
275 return Err(SlotReadFailure::Fatal(Error::Encoding(
276 "AuditLog snapshot object identity does not match".into(),
277 )));
278 }
279 decode_verified_snapshot(&data, object_identifier, version)
280 .map(Some)
281 .map_err(SlotReadFailure::Recoverable)
282}
283
284fn verify_snapshot_integrity(data: &[u8]) -> Result<(), Error> {
285 if data.len() > MAX_SNAPSHOT_BYTES {
286 return Err(Error::OutOfRange(format!(
287 "AuditLog snapshot length {} exceeds {MAX_SNAPSHOT_BYTES}",
288 data.len()
289 )));
290 }
291 if data.len() < HEADER_LEN + TRAILER_LEN {
292 return Err(Error::Encoding("AuditLog snapshot is truncated".into()));
293 }
294 if &data[..8] != MAGIC {
295 return Err(Error::Encoding("AuditLog snapshot magic is invalid".into()));
296 }
297 let payload_len = u32::from_be_bytes(data[22..26].try_into().unwrap()) as usize;
298 let expected_len = HEADER_LEN
299 .checked_add(payload_len)
300 .and_then(|length| length.checked_add(TRAILER_LEN))
301 .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
302 if expected_len != data.len() {
303 return Err(Error::Encoding(
304 "AuditLog snapshot declared length does not match file length".into(),
305 ));
306 }
307 let checksum_start = data.len() - TRAILER_LEN;
308 let stored_checksum = u32::from_be_bytes(data[checksum_start..].try_into().unwrap());
309 if crc32(&data[..checksum_start]) != stored_checksum {
310 return Err(Error::Encoding(
311 "AuditLog snapshot checksum is invalid".into(),
312 ));
313 }
314 Ok(())
315}
316
317fn encode_snapshot(snapshot: &AuditLogSnapshot) -> Result<Vec<u8>, Error> {
318 encode_snapshot_for_version(snapshot, SCHEMA_VERSION)
319}
320
321#[cfg(test)]
322pub(super) fn encode_snapshot_v1(snapshot: &AuditLogSnapshot) -> Result<Vec<u8>, Error> {
323 if !snapshot.completed_receipts.is_empty() {
324 return Err(Error::Encoding(
325 "AuditLog schema-v1 snapshot cannot contain completed receipts".into(),
326 ));
327 }
328 encode_snapshot_for_version(snapshot, SCHEMA_VERSION_V1)
329}
330
331#[cfg(test)]
335pub(super) fn encode_snapshot_v2(snapshot: &AuditLogSnapshot) -> Result<Vec<u8>, Error> {
336 encode_snapshot_for_version(snapshot, SCHEMA_VERSION_V2)
337}
338
339fn encode_snapshot_for_version(
340 snapshot: &AuditLogSnapshot,
341 version: u16,
342) -> Result<Vec<u8>, Error> {
343 validate_snapshot(snapshot)?;
344 let mut payload = Vec::new();
345 payload.extend_from_slice(&snapshot.capacity.to_be_bytes());
346 payload.push(u8::from(snapshot.log_enable));
347 payload.extend_from_slice(&snapshot.total_record_count.to_be_bytes());
348 payload.extend_from_slice(&(snapshot.records.len() as u32).to_be_bytes());
349 for result in &snapshot.records {
350 validate_record_dynamic_size(&result.record)?;
351 let mut record = BytesMut::new();
352 encode_audit_log_record(&result.record, &mut record)?;
353 if record.len() > MAX_RECORD_BYTES {
354 return Err(Error::OutOfRange(format!(
355 "AuditLog record length {} exceeds {MAX_RECORD_BYTES}",
356 record.len()
357 )));
358 }
359 let projected = HEADER_LEN
360 .checked_add(payload.len())
361 .and_then(|length| length.checked_add(8 + 4 + record.len()))
362 .and_then(|length| length.checked_add(TRAILER_LEN))
363 .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
364 if projected > MAX_SNAPSHOT_BYTES {
365 return Err(Error::OutOfRange(format!(
366 "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
367 )));
368 }
369 payload.extend_from_slice(&result.sequence_number.to_be_bytes());
370 payload.extend_from_slice(&(record.len() as u32).to_be_bytes());
371 payload.extend_from_slice(&record);
372 }
373 if version != SCHEMA_VERSION_V1 {
374 receipt_codec::encode(&snapshot.completed_receipts, &mut payload)?;
375 }
376 let total_len = HEADER_LEN
377 .checked_add(payload.len())
378 .and_then(|length| length.checked_add(TRAILER_LEN))
379 .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
380 if total_len > MAX_SNAPSHOT_BYTES {
381 return Err(Error::OutOfRange(format!(
382 "AuditLog snapshot length {total_len} exceeds {MAX_SNAPSHOT_BYTES}"
383 )));
384 }
385
386 let mut data = Vec::with_capacity(total_len);
387 data.extend_from_slice(MAGIC);
388 data.extend_from_slice(&version.to_be_bytes());
389 data.extend_from_slice(&snapshot.object_identifier.encode());
390 data.extend_from_slice(&snapshot.generation.to_be_bytes());
391 data.extend_from_slice(&(payload.len() as u32).to_be_bytes());
392 data.extend_from_slice(&payload);
393 let checksum = crc32(&data);
394 data.extend_from_slice(&checksum.to_be_bytes());
395 Ok(data)
396}
397
398fn decode_verified_snapshot(
399 data: &[u8],
400 object_identifier: ObjectIdentifier,
401 version: u16,
402) -> Result<AuditLogSnapshot, Error> {
403 let generation = u64::from_be_bytes(data[14..22].try_into().unwrap());
404 if generation == 0 {
405 return Err(Error::Encoding(
406 "AuditLog snapshot generation must be nonzero".into(),
407 ));
408 }
409 let checksum_start = data.len() - TRAILER_LEN;
410 let payload = &data[HEADER_LEN..checksum_start];
411 let mut offset = 0;
412 let capacity = take_u32(payload, &mut offset, "capacity")?;
413 if capacity > MAX_AUDIT_RECORDS {
414 return Err(Error::OutOfRange(format!(
415 "AuditLog persisted capacity {capacity} exceeds {MAX_AUDIT_RECORDS}"
416 )));
417 }
418 let log_enable = match take(payload, &mut offset, 1, "log-enable")?[0] {
419 0 => false,
420 1 => true,
421 _ => {
422 return Err(Error::Encoding(
423 "AuditLog persisted log-enable is not Boolean".into(),
424 ));
425 }
426 };
427 let total_record_count = take_u64(payload, &mut offset, "total-record-count")?;
428 let record_count = take_u32(payload, &mut offset, "record-count")?;
429 if record_count > capacity || record_count > MAX_AUDIT_RECORDS {
430 return Err(Error::OutOfRange(format!(
431 "AuditLog persisted record count {record_count} exceeds capacity {capacity}"
432 )));
433 }
434 let mut records = Vec::with_capacity(record_count as usize);
435 for _ in 0..record_count {
436 let sequence_number = take_u64(payload, &mut offset, "sequence-number")?;
437 let record_len = take_u32(payload, &mut offset, "record-length")? as usize;
438 if record_len > MAX_RECORD_BYTES {
439 return Err(Error::OutOfRange(format!(
440 "AuditLog persisted record length {record_len} exceeds {MAX_RECORD_BYTES}"
441 )));
442 }
443 let record_data = take(payload, &mut offset, record_len, "record")?;
444 let mut record = decode_audit_log_record(record_data)?;
445 if version != SCHEMA_VERSION {
446 restore_pre_v3_log_status(&mut record);
447 }
448 records.push(BACnetAuditLogRecordResult {
449 sequence_number,
450 record,
451 });
452 }
453 let completed_receipts = if version == SCHEMA_VERSION_V1 {
454 Vec::new()
455 } else {
456 receipt_codec::decode(payload, &mut offset)?
457 };
458 if offset != payload.len() {
459 return Err(Error::Encoding(
460 "AuditLog snapshot payload has trailing bytes".into(),
461 ));
462 }
463
464 let snapshot = AuditLogSnapshot {
465 object_identifier,
466 generation,
467 capacity,
468 log_enable,
469 total_record_count,
470 records,
471 completed_receipts,
472 };
473 validate_snapshot(&snapshot)?;
474 Ok(snapshot)
475}
476
477fn restore_pre_v3_log_status(record: &mut BACnetAuditLogRecord) {
486 if let BACnetAuditLogDatum::LogStatus(status) = &mut record.datum {
487 *status = LogStatus::from_bits_truncate(status.bits().reverse_bits() >> 5);
488 }
489}
490
491pub(super) fn validate_snapshot(snapshot: &AuditLogSnapshot) -> Result<(), Error> {
492 if snapshot.generation == 0 {
493 return Err(Error::OutOfRange(
494 "AuditLog snapshot generation must be nonzero".into(),
495 ));
496 }
497 if snapshot.capacity > MAX_AUDIT_RECORDS {
498 return Err(Error::OutOfRange(format!(
499 "AuditLog capacity {} exceeds {MAX_AUDIT_RECORDS}",
500 snapshot.capacity
501 )));
502 }
503 if snapshot.records.len() > snapshot.capacity as usize {
504 return Err(Error::OutOfRange(format!(
505 "AuditLog record count {} exceeds capacity {}",
506 snapshot.records.len(),
507 snapshot.capacity
508 )));
509 }
510 receipt::validate_receipts(&snapshot.completed_receipts)?;
511 if (snapshot.total_record_count == 0 && !snapshot.records.is_empty())
514 || snapshot
515 .records
516 .last()
517 .is_some_and(|last| last.sequence_number != snapshot.total_record_count)
518 {
519 return Err(Error::Encoding(
520 "AuditLog snapshot record identities do not match Total_Record_Count".into(),
521 ));
522 }
523 for pair in snapshot.records.windows(2) {
524 let expected = if pair[0].sequence_number == u64::MAX {
525 1
526 } else {
527 pair[0].sequence_number + 1
528 };
529 if pair[0].sequence_number == 0 || pair[1].sequence_number != expected {
530 return Err(Error::Encoding(
531 "AuditLog snapshot sequence identities are not contiguous".into(),
532 ));
533 }
534 }
535 if snapshot
536 .records
537 .first()
538 .is_some_and(|first| first.sequence_number == 0)
539 {
540 return Err(Error::Encoding(
541 "AuditLog snapshot sequence identities must be nonzero".into(),
542 ));
543 }
544
545 let mut projected_len = HEADER_LEN
546 .checked_add(4 + 1 + 8 + 4)
547 .and_then(|length| length.checked_add(TRAILER_LEN))
548 .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
549 for result in &snapshot.records {
550 let encoded_len = validate_record(&result.record)?;
551 projected_len = projected_len
552 .checked_add(8 + 4 + encoded_len)
553 .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
554 if projected_len > MAX_SNAPSHOT_BYTES {
555 return Err(Error::OutOfRange(format!(
556 "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
557 )));
558 }
559 }
560 let receipt_len = if snapshot.completed_receipts.is_empty() {
564 0
565 } else {
566 receipt_codec::encoded_len(&snapshot.completed_receipts)?
567 };
568 projected_len = projected_len
569 .checked_add(receipt_len)
570 .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
571 if projected_len > MAX_SNAPSHOT_BYTES {
572 return Err(Error::OutOfRange(format!(
573 "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
574 )));
575 }
576 Ok(())
577}
578
579pub(super) fn validate_record(
580 record: &bacnet_types::constructed::BACnetAuditLogRecord,
581) -> Result<usize, Error> {
582 validate_record_dynamic_size(record)?;
583 let mut encoded = BytesMut::new();
584 encode_audit_log_record(record, &mut encoded)?;
585 if encoded.len() > MAX_RECORD_BYTES {
586 return Err(Error::OutOfRange(format!(
587 "AuditLog record length {} exceeds {MAX_RECORD_BYTES}",
588 encoded.len()
589 )));
590 }
591 Ok(encoded.len())
592}
593
594fn validate_record_dynamic_size(
595 record: &bacnet_types::constructed::BACnetAuditLogRecord,
596) -> Result<(), Error> {
597 let bacnet_types::constructed::BACnetAuditLogDatum::AuditNotification(notification) =
598 &record.datum
599 else {
600 return Ok(());
601 };
602 let recipient_size = |recipient: &bacnet_types::constructed::BACnetRecipient| match recipient {
603 bacnet_types::constructed::BACnetRecipient::Device(_) => 4,
604 bacnet_types::constructed::BACnetRecipient::Address(address) => address.mac_address.len(),
605 };
606 let lengths = [
607 recipient_size(¬ification.source_device),
608 recipient_size(¬ification.target_device),
609 notification.source_comment.as_ref().map_or(0, String::len),
610 notification.target_comment.as_ref().map_or(0, String::len),
611 notification.target_value.as_ref().map_or(0, Vec::len),
612 notification.current_value.as_ref().map_or(0, Vec::len),
613 ];
614 let dynamic = lengths.into_iter().try_fold(0usize, |sum, length| {
615 sum.checked_add(length)
616 .ok_or_else(|| Error::OutOfRange("AuditLog record length overflow".into()))
617 })?;
618 if dynamic > MAX_RECORD_BYTES {
619 return Err(Error::OutOfRange(format!(
620 "AuditLog record dynamic length {dynamic} exceeds {MAX_RECORD_BYTES}"
621 )));
622 }
623 Ok(())
624}
625
626fn take<'a>(
627 data: &'a [u8],
628 offset: &mut usize,
629 length: usize,
630 field: &str,
631) -> Result<&'a [u8], Error> {
632 let end = offset
633 .checked_add(length)
634 .ok_or_else(|| Error::OutOfRange(format!("AuditLog {field} length overflow")))?;
635 if end > data.len() {
636 return Err(Error::Encoding(format!(
637 "AuditLog snapshot {field} is truncated"
638 )));
639 }
640 let value = &data[*offset..end];
641 *offset = end;
642 Ok(value)
643}
644
645fn take_u32(data: &[u8], offset: &mut usize, field: &str) -> Result<u32, Error> {
646 Ok(u32::from_be_bytes(
647 take(data, offset, 4, field)?.try_into().unwrap(),
648 ))
649}
650
651fn take_u64(data: &[u8], offset: &mut usize, field: &str) -> Result<u64, Error> {
652 Ok(u64::from_be_bytes(
653 take(data, offset, 8, field)?.try_into().unwrap(),
654 ))
655}
656
657fn crc32(data: &[u8]) -> u32 {
658 let mut crc = !0u32;
659 for byte in data {
660 crc ^= u32::from(*byte);
661 for _ in 0..8 {
662 crc = (crc >> 1) ^ (0xedb8_8320 & (0u32.wrapping_sub(crc & 1)));
663 }
664 }
665 !crc
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use bacnet_types::enums::ObjectType;
672
673 fn snapshot() -> AuditLogSnapshot {
674 AuditLogSnapshot {
675 object_identifier: ObjectIdentifier::new(ObjectType::AUDIT_LOG, 1).unwrap(),
676 generation: 1,
677 capacity: 1,
678 log_enable: true,
679 total_record_count: 0,
680 records: Vec::new(),
681 completed_receipts: Vec::new(),
682 }
683 }
684
685 fn io_failure() -> SlotState {
686 SlotState::Fatal(Error::Transport(std::io::Error::other(
687 "injected slot read failure",
688 )))
689 }
690
691 #[test]
692 fn audit_slot_selection_never_masks_io_failure_with_valid_peer() {
693 assert!(matches!(
694 select_newest_slot([io_failure(), SlotState::Valid(snapshot())]),
695 Err(Error::Transport(_))
696 ));
697 assert!(matches!(
698 select_newest_slot([SlotState::Valid(snapshot()), io_failure()]),
699 Err(Error::Transport(_))
700 ));
701 }
702
703 #[test]
704 fn audit_slot_selection_requires_identical_same_generation_snapshots() {
705 let expected = snapshot();
706 let selected = select_newest_slot([
707 SlotState::Valid(expected.clone()),
708 SlotState::Valid(expected.clone()),
709 ])
710 .unwrap()
711 .unwrap();
712 assert_eq!(selected, expected);
713
714 let mut divergent = snapshot();
715 divergent.log_enable = false;
716 assert!(matches!(
717 select_newest_slot([
718 SlotState::Valid(snapshot()),
719 SlotState::Valid(divergent),
720 ]),
721 Err(Error::Encoding(message)) if message.contains("same generation")
722 ));
723 }
724}