Skip to main content

bacnet_objects/audit/
persistence.rs

1//! Explicit persistence for one AuditLog object. The object calls it from its
2//! writer thread, never under the database guard ([`crate::durable`]).
3
4use 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
21/// Maximum configured AuditLog capacity accepted by the repository.
22pub const MAX_AUDIT_RECORDS: u32 = 10_000;
23
24const MAGIC: &[u8; 8] = b"RBALOG01";
25const SCHEMA_VERSION_V1: u16 = 1;
26/// Adds completed receipts. Like v1, it holds log-status records in the
27/// reversed bit order of releases up to 0.11.0.
28const SCHEMA_VERSION_V2: u16 = 2;
29/// Holds log-status records in the bit0-first order of every BACnet bit
30/// string.
31const 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/// Complete durable state for exactly one AuditLog object.
38#[derive(Clone, Debug, PartialEq)]
39pub struct AuditLogSnapshot {
40    /// AuditLog identity that owns this state.
41    pub object_identifier: ObjectIdentifier,
42    /// Nonzero two-slot snapshot generation.
43    pub generation: u64,
44    /// Persisted ring-buffer capacity: Buffer_Size, as configured or last
45    /// written.
46    pub capacity: u32,
47    /// Persisted Enable policy.
48    pub log_enable: bool,
49    /// Persisted Total_Record_Count and next sequence source.
50    pub total_record_count: u64,
51    /// Oldest-to-newest retained typed records.
52    pub records: Vec<BACnetAuditLogRecordResult>,
53    /// Bounded completed confirmed-request receipts owned by this Audit Log.
54    pub completed_receipts: Vec<CompletedAuditReceipt>,
55}
56
57/// Application-owned persistence port for one AuditLog object.
58///
59/// The object calls it from its writer thread, one call at a time. A commit
60/// lands in storage a moment before the log serves it: see [storage leads
61/// the served state](crate::durable#storage-leads-the-served-state).
62pub trait AuditLogPersistence: Send + Sync {
63    /// Load the newest valid compatible snapshot, or `None` if neither slot exists.
64    fn load(&self, expected_object: ObjectIdentifier) -> Result<Option<AuditLogSnapshot>, Error>;
65
66    /// Durably replace one slot with a complete prospective snapshot.
67    fn commit(&self, snapshot: &AuditLogSnapshot) -> Result<(), Error>;
68}
69
70/// Two-slot, versioned and checksummed file snapshot backend.
71///
72/// Each commit fully writes and synchronizes one slot, and the first commit to
73/// a slot also synchronizes its directory (on Unix; see
74/// [`durable`](crate::durable)). This protects the previous valid slot from a
75/// failed commit, but does not provide multi-process coordination or stronger
76/// portable power-loss guarantees than `sync_all`.
77#[derive(Clone, Debug)]
78pub struct FileAuditLogPersistence {
79    slot_paths: [PathBuf; 2],
80}
81
82impl FileAuditLogPersistence {
83    /// Use `base_path.slot0` and `base_path.slot1` for this object's snapshots.
84    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    /// Concrete slot paths, exposed for application backup and diagnostics.
102    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        // A slot this commit created exists only once its directory entry is
155        // durable too. The commit has landed by now, so a sync that fails is
156        // logged rather than failing it.
157        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    // Best effort only: the other slot remains the last known valid state.
211    // No stronger portable power-loss behavior is promised after an I/O error.
212    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/// Encode `snapshot` under the schema-v2 header. The records are encoded as
332/// today's codec does, so a test reproduces a 0.11.0 file by handing it the
333/// log statuses that encode to the old octets.
334#[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
477/// Recover the log status a v1 or v2 snapshot meant to store.
478///
479/// Up to 0.11.0 the encoder shifted the three status bits up by five, so
480/// log-disabled (bit 0) landed on the `0x20` bit that belongs to
481/// log-interrupted, and log-interrupted on `0x80`. Today's decoder reads
482/// that octet bit0-first, which yields the intended three bits in reverse
483/// order: reversing them once more restores the record. Other records are
484/// left as they are.
485fn 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    // A counted log may hold no records: one grown from Buffer_Size 0 keeps
512    // its count with an empty ring (#1238).
513    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    // An empty receipt list contributes no projected bytes here so every valid
561    // schema-v1 snapshot remains loadable. The v2 encoder's final concrete
562    // length check still includes its four-byte zero count.
563    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(&notification.source_device),
608        recipient_size(&notification.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}