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