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