Skip to main content

bacnet_objects/audit/
persistence.rs

1//! Explicit, synchronous persistence for one AuditLog object.
2
3use 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
17/// Maximum configured AuditLog capacity accepted by the repository.
18pub 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/// Complete durable state for exactly one AuditLog object.
29#[derive(Clone, Debug, PartialEq)]
30pub struct AuditLogSnapshot {
31    /// AuditLog identity that owns this state.
32    pub object_identifier: ObjectIdentifier,
33    /// Nonzero two-slot snapshot generation.
34    pub generation: u64,
35    /// Persisted ring-buffer capacity.
36    pub capacity: u32,
37    /// Persisted Enable policy.
38    pub log_enable: bool,
39    /// Persisted Total_Record_Count and next sequence source.
40    pub total_record_count: u64,
41    /// Oldest-to-newest retained typed records.
42    pub records: Vec<BACnetAuditLogRecordResult>,
43    /// Bounded completed confirmed-request receipts owned by this Audit Log.
44    pub completed_receipts: Vec<CompletedAuditReceipt>,
45}
46
47/// Application-owned persistence port for one AuditLog object.
48pub trait AuditLogPersistence: Send + Sync {
49    /// Load the newest valid compatible snapshot, or `None` if neither slot exists.
50    fn load(&self, expected_object: ObjectIdentifier) -> Result<Option<AuditLogSnapshot>, Error>;
51
52    /// Durably replace one slot with a complete prospective snapshot.
53    fn commit(&self, snapshot: &AuditLogSnapshot) -> Result<(), Error>;
54}
55
56/// Two-slot, versioned and checksummed file snapshot backend.
57///
58/// Each commit fully writes and synchronizes one slot. This protects the
59/// previous valid slot from a failed commit, but does not provide multi-process
60/// coordination or stronger portable power-loss guarantees than `sync_all`.
61#[derive(Clone, Debug)]
62pub struct FileAuditLogPersistence {
63    slot_paths: [PathBuf; 2],
64}
65
66impl FileAuditLogPersistence {
67    /// Use `base_path.slot0` and `base_path.slot1` for this object's snapshots.
68    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    /// Concrete slot paths, exposed for application backup and diagnostics.
86    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    // Best effort only: the other slot remains the last known valid state.
188    // No stronger portable power-loss behavior is promised after an I/O error.
189    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    // An empty ledger contributes no projected bytes here so every valid
510    // schema-v1 snapshot remains loadable. The v2 encoder's final concrete
511    // length check still includes its four-byte zero count.
512    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(&notification.source_device),
557        recipient_size(&notification.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}