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