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
4// Without std there is no file backend, so the snapshot format goes unused.
5#![cfg_attr(not(feature = "std"), allow(dead_code))]
6
7use crate::prelude::*;
8#[cfg(feature = "std")]
9use std::fs::{File, OpenOptions};
10#[cfg(feature = "std")]
11use std::io::{ErrorKind, Read, Write};
12#[cfg(feature = "std")]
13use std::path::{Path, PathBuf};
14
15use bacnet_encoding::constructed::{decode_audit_log_record, encode_audit_log_record};
16use bacnet_types::bitstring::LogStatus;
17use bacnet_types::constructed::{
18    BACnetAuditLogDatum, BACnetAuditLogRecord, BACnetAuditLogRecordResult,
19};
20use bacnet_types::error::Error;
21use bacnet_types::primitives::ObjectIdentifier;
22use bytes::BytesMut;
23
24use crate::durable::crc32;
25
26use super::receipt::{self, CompletedAuditReceipt};
27
28mod receipt_codec;
29
30/// Maximum configured AuditLog capacity accepted by the repository.
31pub const MAX_AUDIT_RECORDS: u32 = 10_000;
32
33const MAGIC: &[u8; 8] = b"RBALOG01";
34const SCHEMA_VERSION_V1: u16 = 1;
35/// Adds completed receipts. Like v1, it holds log-status records in the
36/// reversed bit order of releases up to 0.11.0.
37const SCHEMA_VERSION_V2: u16 = 2;
38/// Holds log-status records in the bit0-first order of every BACnet bit
39/// string.
40const SCHEMA_VERSION: u16 = 3;
41const HEADER_LEN: usize = 8 + 2 + 4 + 8 + 4;
42const TRAILER_LEN: usize = 4;
43const MAX_RECORD_BYTES: usize = 1024 * 1024;
44const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
45
46/// Complete durable state for exactly one AuditLog object.
47#[derive(Clone, Debug, PartialEq)]
48pub struct AuditLogSnapshot {
49    /// AuditLog identity that owns this state.
50    pub object_identifier: ObjectIdentifier,
51    /// Nonzero two-slot snapshot generation.
52    pub generation: u64,
53    /// Persisted ring-buffer capacity: Buffer_Size, as configured or last
54    /// written.
55    pub capacity: u32,
56    /// Persisted Enable policy.
57    pub log_enable: bool,
58    /// Persisted Total_Record_Count and next sequence source.
59    pub total_record_count: u64,
60    /// Oldest-to-newest retained typed records.
61    pub records: Vec<BACnetAuditLogRecordResult>,
62    /// Bounded completed confirmed-request receipts owned by this Audit Log.
63    pub completed_receipts: Vec<CompletedAuditReceipt>,
64}
65
66/// Application-owned persistence port for one AuditLog object.
67///
68/// The object calls it from its writer thread, one call at a time. A commit
69/// lands in storage a moment before the log serves it: see [storage leads
70/// the served state](crate::durable#storage-leads-the-served-state).
71pub trait AuditLogPersistence: Send + Sync {
72    /// Load the newest valid compatible snapshot, or `None` if neither slot exists.
73    fn load(&self, expected_object: ObjectIdentifier) -> Result<Option<AuditLogSnapshot>, Error>;
74
75    /// Durably replace one slot with a complete prospective snapshot.
76    fn commit(&self, snapshot: &AuditLogSnapshot) -> Result<(), Error>;
77}
78
79/// Two-slot, versioned and checksummed file snapshot backend.
80///
81/// Each commit fully writes and synchronizes one slot, and the first commit to
82/// a slot also synchronizes its directory (on Unix; see
83/// [`durable`](crate::durable)). This protects the previous valid slot from a
84/// failed commit, but does not provide multi-process coordination or stronger
85/// portable power-loss guarantees than `sync_all`.
86#[derive(Clone, Debug)]
87#[cfg(feature = "std")]
88pub struct FileAuditLogPersistence {
89    slot_paths: [PathBuf; 2],
90}
91
92#[cfg(feature = "std")]
93impl FileAuditLogPersistence {
94    /// Use `base_path.slot0` and `base_path.slot1` for this object's snapshots.
95    pub fn new(base_path: impl AsRef<Path>) -> Result<Self, Error> {
96        let base_path = base_path.as_ref();
97        if base_path.as_os_str().is_empty() {
98            return Err(Error::OutOfRange(
99                "AuditLog persistence base path must not be empty".into(),
100            ));
101        }
102        let base = base_path.as_os_str();
103        let mut slot0 = base.to_os_string();
104        slot0.push(".slot0");
105        let mut slot1 = base.to_os_string();
106        slot1.push(".slot1");
107        Ok(Self {
108            slot_paths: [PathBuf::from(slot0), PathBuf::from(slot1)],
109        })
110    }
111
112    /// Concrete slot paths, exposed for application backup and diagnostics.
113    pub fn slot_paths(&self) -> [PathBuf; 2] {
114        self.slot_paths.clone()
115    }
116}
117
118#[cfg(feature = "std")]
119enum SlotState {
120    Missing,
121    Recoverable(Error),
122    Fatal(Error),
123    Valid(AuditLogSnapshot),
124}
125
126#[cfg(feature = "std")]
127enum SlotReadFailure {
128    Recoverable(Error),
129    Fatal(Error),
130}
131
132#[cfg(feature = "std")]
133impl AuditLogPersistence for FileAuditLogPersistence {
134    fn load(&self, expected_object: ObjectIdentifier) -> Result<Option<AuditLogSnapshot>, Error> {
135        let slots = [
136            read_slot(&self.slot_paths[0], expected_object),
137            read_slot(&self.slot_paths[1], expected_object),
138        ];
139        select_newest_slot(slots)
140    }
141
142    fn commit(&self, snapshot: &AuditLogSnapshot) -> Result<(), Error> {
143        let bytes = encode_snapshot(snapshot)?;
144        let path = &self.slot_paths[(snapshot.generation % 2) as usize];
145        if let Some(parent) = path
146            .parent()
147            .filter(|parent| !parent.as_os_str().is_empty())
148        {
149            std::fs::create_dir_all(parent)?;
150        }
151
152        let (mut file, created) = match OpenOptions::new().write(true).create_new(true).open(path) {
153            Ok(file) => (file, true),
154            Err(error) if error.kind() == ErrorKind::AlreadyExists => (
155                OpenOptions::new().write(true).truncate(true).open(path)?,
156                false,
157            ),
158            Err(error) => return Err(error.into()),
159        };
160        if let Err(error) = file.write_all(&bytes) {
161            invalidate_failed_slot(&file);
162            return Err(error.into());
163        }
164        if let Err(error) = file.sync_all() {
165            invalidate_failed_slot(&file);
166            return Err(error.into());
167        }
168        // A slot this commit created exists only once its directory entry is
169        // durable too. The commit has landed by now, so a sync that fails is
170        // logged rather than failing it.
171        if created {
172            crate::durable::sync_parent_dir(path);
173        }
174        Ok(())
175    }
176}
177
178#[cfg(feature = "std")]
179fn select_newest_slot(slots: [SlotState; 2]) -> Result<Option<AuditLogSnapshot>, Error> {
180    let mut newest: Option<AuditLogSnapshot> = None;
181    let mut present = false;
182    let mut last_error = None;
183
184    for slot in slots {
185        match slot {
186            SlotState::Missing => {}
187            SlotState::Recoverable(error) => {
188                present = true;
189                last_error = Some(error);
190            }
191            SlotState::Fatal(error) => return Err(error),
192            SlotState::Valid(snapshot) => {
193                present = true;
194                match newest.as_ref() {
195                    None => newest = Some(snapshot),
196                    Some(current) if snapshot.generation > current.generation => {
197                        newest = Some(snapshot);
198                    }
199                    Some(current) if snapshot.generation == current.generation => {
200                        if encode_snapshot(&snapshot)? != encode_snapshot(current)? {
201                            return Err(Error::Encoding(
202                                "AuditLog slots contain divergent snapshots at the same generation"
203                                    .into(),
204                            ));
205                        }
206                    }
207                    Some(_) => {}
208                }
209            }
210        }
211    }
212
213    if newest.is_some() {
214        Ok(newest)
215    } else if present {
216        Err(last_error.unwrap_or_else(|| {
217            Error::Encoding("AuditLog persistence has no valid compatible slot".into())
218        }))
219    } else {
220        Ok(None)
221    }
222}
223
224#[cfg(feature = "std")]
225fn invalidate_failed_slot(file: &File) {
226    // Best effort only: the other slot remains the last known valid state.
227    // No stronger portable power-loss behavior is promised after an I/O error.
228    let _ = file.set_len(0);
229    let _ = file.sync_all();
230}
231
232#[cfg(feature = "std")]
233fn read_slot(path: &Path, expected_object: ObjectIdentifier) -> SlotState {
234    match read_slot_inner(path, expected_object) {
235        Ok(Some(snapshot)) => SlotState::Valid(snapshot),
236        Ok(None) => SlotState::Missing,
237        Err(SlotReadFailure::Recoverable(error)) => SlotState::Recoverable(error),
238        Err(SlotReadFailure::Fatal(error)) => SlotState::Fatal(error),
239    }
240}
241
242#[cfg(feature = "std")]
243fn read_slot_inner(
244    path: &Path,
245    expected_object: ObjectIdentifier,
246) -> Result<Option<AuditLogSnapshot>, SlotReadFailure> {
247    let file = match File::open(path) {
248        Ok(file) => file,
249        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
250        Err(error) => return Err(SlotReadFailure::Fatal(error.into())),
251    };
252    let metadata = file
253        .metadata()
254        .map_err(|error| SlotReadFailure::Fatal(error.into()))?;
255    let length = usize::try_from(metadata.len()).map_err(|_| {
256        SlotReadFailure::Recoverable(Error::OutOfRange(
257            "AuditLog snapshot length exceeds usize".into(),
258        ))
259    })?;
260    if length > MAX_SNAPSHOT_BYTES {
261        return Err(SlotReadFailure::Recoverable(Error::OutOfRange(format!(
262            "AuditLog snapshot length {length} exceeds {MAX_SNAPSHOT_BYTES}"
263        ))));
264    }
265    let mut data = Vec::with_capacity(length.min(MAX_SNAPSHOT_BYTES + 1));
266    file.take((MAX_SNAPSHOT_BYTES + 1) as u64)
267        .read_to_end(&mut data)
268        .map_err(|error| SlotReadFailure::Fatal(error.into()))?;
269    if data.len() > MAX_SNAPSHOT_BYTES {
270        return Err(SlotReadFailure::Recoverable(Error::OutOfRange(format!(
271            "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
272        ))));
273    }
274    if data.len() != length {
275        return Err(SlotReadFailure::Fatal(Error::Encoding(
276            "AuditLog snapshot changed while being read".into(),
277        )));
278    }
279
280    verify_snapshot_integrity(&data).map_err(SlotReadFailure::Recoverable)?;
281    let version = u16::from_be_bytes(data[8..10].try_into().unwrap());
282    if !matches!(
283        version,
284        SCHEMA_VERSION_V1 | SCHEMA_VERSION_V2 | SCHEMA_VERSION
285    ) {
286        return Err(SlotReadFailure::Fatal(Error::Encoding(format!(
287            "AuditLog snapshot schema version {version} is unsupported"
288        ))));
289    }
290    let object_identifier =
291        ObjectIdentifier::decode(&data[10..14]).map_err(SlotReadFailure::Recoverable)?;
292    if object_identifier != expected_object {
293        return Err(SlotReadFailure::Fatal(Error::Encoding(
294            "AuditLog snapshot object identity does not match".into(),
295        )));
296    }
297    decode_verified_snapshot(&data, object_identifier, version)
298        .map(Some)
299        .map_err(SlotReadFailure::Recoverable)
300}
301
302fn verify_snapshot_integrity(data: &[u8]) -> Result<(), Error> {
303    if data.len() > MAX_SNAPSHOT_BYTES {
304        return Err(Error::OutOfRange(format!(
305            "AuditLog snapshot length {} exceeds {MAX_SNAPSHOT_BYTES}",
306            data.len()
307        )));
308    }
309    if data.len() < HEADER_LEN + TRAILER_LEN {
310        return Err(Error::Encoding("AuditLog snapshot is truncated".into()));
311    }
312    if &data[..8] != MAGIC {
313        return Err(Error::Encoding("AuditLog snapshot magic is invalid".into()));
314    }
315    let payload_len = u32::from_be_bytes(data[22..26].try_into().unwrap()) as usize;
316    let expected_len = HEADER_LEN
317        .checked_add(payload_len)
318        .and_then(|length| length.checked_add(TRAILER_LEN))
319        .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
320    if expected_len != data.len() {
321        return Err(Error::Encoding(
322            "AuditLog snapshot declared length does not match file length".into(),
323        ));
324    }
325    let checksum_start = data.len() - TRAILER_LEN;
326    let stored_checksum = u32::from_be_bytes(data[checksum_start..].try_into().unwrap());
327    if crc32(&data[..checksum_start]) != stored_checksum {
328        return Err(Error::Encoding(
329            "AuditLog snapshot checksum is invalid".into(),
330        ));
331    }
332    Ok(())
333}
334
335fn encode_snapshot(snapshot: &AuditLogSnapshot) -> Result<Vec<u8>, Error> {
336    encode_snapshot_for_version(snapshot, SCHEMA_VERSION)
337}
338
339#[cfg(all(test, feature = "std"))]
340pub(super) fn encode_snapshot_v1(snapshot: &AuditLogSnapshot) -> Result<Vec<u8>, Error> {
341    if !snapshot.completed_receipts.is_empty() {
342        return Err(Error::Encoding(
343            "AuditLog schema-v1 snapshot cannot contain completed receipts".into(),
344        ));
345    }
346    encode_snapshot_for_version(snapshot, SCHEMA_VERSION_V1)
347}
348
349/// Encode `snapshot` under the schema-v2 header. The records are encoded as
350/// today's codec does, so a test reproduces a 0.11.0 file by handing it the
351/// log statuses that encode to the old octets.
352#[cfg(all(test, feature = "std"))]
353pub(super) fn encode_snapshot_v2(snapshot: &AuditLogSnapshot) -> Result<Vec<u8>, Error> {
354    encode_snapshot_for_version(snapshot, SCHEMA_VERSION_V2)
355}
356
357fn encode_snapshot_for_version(
358    snapshot: &AuditLogSnapshot,
359    version: u16,
360) -> Result<Vec<u8>, Error> {
361    validate_snapshot(snapshot)?;
362    let mut payload = Vec::new();
363    payload.extend_from_slice(&snapshot.capacity.to_be_bytes());
364    payload.push(u8::from(snapshot.log_enable));
365    payload.extend_from_slice(&snapshot.total_record_count.to_be_bytes());
366    payload.extend_from_slice(&(snapshot.records.len() as u32).to_be_bytes());
367    for result in &snapshot.records {
368        validate_record_dynamic_size(&result.record)?;
369        let mut record = BytesMut::new();
370        encode_audit_log_record(&result.record, &mut record)?;
371        if record.len() > MAX_RECORD_BYTES {
372            return Err(Error::OutOfRange(format!(
373                "AuditLog record length {} exceeds {MAX_RECORD_BYTES}",
374                record.len()
375            )));
376        }
377        let projected = HEADER_LEN
378            .checked_add(payload.len())
379            .and_then(|length| length.checked_add(8 + 4 + record.len()))
380            .and_then(|length| length.checked_add(TRAILER_LEN))
381            .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
382        if projected > MAX_SNAPSHOT_BYTES {
383            return Err(Error::OutOfRange(format!(
384                "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
385            )));
386        }
387        payload.extend_from_slice(&result.sequence_number.to_be_bytes());
388        payload.extend_from_slice(&(record.len() as u32).to_be_bytes());
389        payload.extend_from_slice(&record);
390    }
391    if version != SCHEMA_VERSION_V1 {
392        receipt_codec::encode(&snapshot.completed_receipts, &mut payload)?;
393    }
394    let total_len = HEADER_LEN
395        .checked_add(payload.len())
396        .and_then(|length| length.checked_add(TRAILER_LEN))
397        .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
398    if total_len > MAX_SNAPSHOT_BYTES {
399        return Err(Error::OutOfRange(format!(
400            "AuditLog snapshot length {total_len} exceeds {MAX_SNAPSHOT_BYTES}"
401        )));
402    }
403
404    let mut data = Vec::with_capacity(total_len);
405    data.extend_from_slice(MAGIC);
406    data.extend_from_slice(&version.to_be_bytes());
407    data.extend_from_slice(&snapshot.object_identifier.encode());
408    data.extend_from_slice(&snapshot.generation.to_be_bytes());
409    data.extend_from_slice(&(payload.len() as u32).to_be_bytes());
410    data.extend_from_slice(&payload);
411    let checksum = crc32(&data);
412    data.extend_from_slice(&checksum.to_be_bytes());
413    Ok(data)
414}
415
416fn decode_verified_snapshot(
417    data: &[u8],
418    object_identifier: ObjectIdentifier,
419    version: u16,
420) -> Result<AuditLogSnapshot, Error> {
421    let generation = u64::from_be_bytes(data[14..22].try_into().unwrap());
422    if generation == 0 {
423        return Err(Error::Encoding(
424            "AuditLog snapshot generation must be nonzero".into(),
425        ));
426    }
427    let checksum_start = data.len() - TRAILER_LEN;
428    let payload = &data[HEADER_LEN..checksum_start];
429    let mut offset = 0;
430    let capacity = take_u32(payload, &mut offset, "capacity")?;
431    if capacity > MAX_AUDIT_RECORDS {
432        return Err(Error::OutOfRange(format!(
433            "AuditLog persisted capacity {capacity} exceeds {MAX_AUDIT_RECORDS}"
434        )));
435    }
436    let log_enable = match take(payload, &mut offset, 1, "log-enable")?[0] {
437        0 => false,
438        1 => true,
439        _ => {
440            return Err(Error::Encoding(
441                "AuditLog persisted log-enable is not Boolean".into(),
442            ));
443        }
444    };
445    let total_record_count = take_u64(payload, &mut offset, "total-record-count")?;
446    let record_count = take_u32(payload, &mut offset, "record-count")?;
447    if record_count > capacity || record_count > MAX_AUDIT_RECORDS {
448        return Err(Error::OutOfRange(format!(
449            "AuditLog persisted record count {record_count} exceeds capacity {capacity}"
450        )));
451    }
452    let mut records = Vec::with_capacity(record_count as usize);
453    for _ in 0..record_count {
454        let sequence_number = take_u64(payload, &mut offset, "sequence-number")?;
455        let record_len = take_u32(payload, &mut offset, "record-length")? as usize;
456        if record_len > MAX_RECORD_BYTES {
457            return Err(Error::OutOfRange(format!(
458                "AuditLog persisted record length {record_len} exceeds {MAX_RECORD_BYTES}"
459            )));
460        }
461        let record_data = take(payload, &mut offset, record_len, "record")?;
462        let mut record = decode_audit_log_record(record_data)?;
463        if version != SCHEMA_VERSION {
464            restore_pre_v3_log_status(&mut record);
465        }
466        records.push(BACnetAuditLogRecordResult {
467            sequence_number,
468            record,
469        });
470    }
471    let completed_receipts = if version == SCHEMA_VERSION_V1 {
472        Vec::new()
473    } else {
474        receipt_codec::decode(payload, &mut offset)?
475    };
476    if offset != payload.len() {
477        return Err(Error::Encoding(
478            "AuditLog snapshot payload has trailing bytes".into(),
479        ));
480    }
481
482    let snapshot = AuditLogSnapshot {
483        object_identifier,
484        generation,
485        capacity,
486        log_enable,
487        total_record_count,
488        records,
489        completed_receipts,
490    };
491    validate_snapshot(&snapshot)?;
492    Ok(snapshot)
493}
494
495/// Recover the log status a v1 or v2 snapshot meant to store.
496///
497/// Up to 0.11.0 the encoder shifted the three status bits up by five, so
498/// log-disabled (bit 0) landed on the `0x20` bit that belongs to
499/// log-interrupted, and log-interrupted on `0x80`. Today's decoder reads
500/// that octet bit0-first, which yields the intended three bits in reverse
501/// order: reversing them once more restores the record. Other records are
502/// left as they are.
503fn restore_pre_v3_log_status(record: &mut BACnetAuditLogRecord) {
504    if let BACnetAuditLogDatum::LogStatus(status) = &mut record.datum {
505        *status = LogStatus::from_bits_truncate(status.bits().reverse_bits() >> 5);
506    }
507}
508
509pub(super) fn validate_snapshot(snapshot: &AuditLogSnapshot) -> Result<(), Error> {
510    if snapshot.generation == 0 {
511        return Err(Error::OutOfRange(
512            "AuditLog snapshot generation must be nonzero".into(),
513        ));
514    }
515    if snapshot.capacity > MAX_AUDIT_RECORDS {
516        return Err(Error::OutOfRange(format!(
517            "AuditLog capacity {} exceeds {MAX_AUDIT_RECORDS}",
518            snapshot.capacity
519        )));
520    }
521    if snapshot.records.len() > snapshot.capacity as usize {
522        return Err(Error::OutOfRange(format!(
523            "AuditLog record count {} exceeds capacity {}",
524            snapshot.records.len(),
525            snapshot.capacity
526        )));
527    }
528    receipt::validate_receipts(&snapshot.completed_receipts)?;
529    // A counted log may hold no records: one grown from Buffer_Size 0 keeps
530    // its count with an empty ring (#1238).
531    if (snapshot.total_record_count == 0 && !snapshot.records.is_empty())
532        || snapshot
533            .records
534            .last()
535            .is_some_and(|last| last.sequence_number != snapshot.total_record_count)
536    {
537        return Err(Error::Encoding(
538            "AuditLog snapshot record identities do not match Total_Record_Count".into(),
539        ));
540    }
541    for pair in snapshot.records.windows(2) {
542        let expected = if pair[0].sequence_number == u64::MAX {
543            1
544        } else {
545            pair[0].sequence_number + 1
546        };
547        if pair[0].sequence_number == 0 || pair[1].sequence_number != expected {
548            return Err(Error::Encoding(
549                "AuditLog snapshot sequence identities are not contiguous".into(),
550            ));
551        }
552    }
553    if snapshot
554        .records
555        .first()
556        .is_some_and(|first| first.sequence_number == 0)
557    {
558        return Err(Error::Encoding(
559            "AuditLog snapshot sequence identities must be nonzero".into(),
560        ));
561    }
562
563    let mut projected_len = HEADER_LEN
564        .checked_add(4 + 1 + 8 + 4)
565        .and_then(|length| length.checked_add(TRAILER_LEN))
566        .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
567    for result in &snapshot.records {
568        let encoded_len = validate_record(&result.record)?;
569        projected_len = projected_len
570            .checked_add(8 + 4 + encoded_len)
571            .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
572        if projected_len > MAX_SNAPSHOT_BYTES {
573            return Err(Error::OutOfRange(format!(
574                "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
575            )));
576        }
577    }
578    // An empty receipt list contributes no projected bytes here so every valid
579    // schema-v1 snapshot remains loadable. The v2 encoder's final concrete
580    // length check still includes its four-byte zero count.
581    let receipt_len = if snapshot.completed_receipts.is_empty() {
582        0
583    } else {
584        receipt_codec::encoded_len(&snapshot.completed_receipts)?
585    };
586    projected_len = projected_len
587        .checked_add(receipt_len)
588        .ok_or_else(|| Error::OutOfRange("AuditLog snapshot length overflow".into()))?;
589    if projected_len > MAX_SNAPSHOT_BYTES {
590        return Err(Error::OutOfRange(format!(
591            "AuditLog snapshot length exceeds {MAX_SNAPSHOT_BYTES}"
592        )));
593    }
594    Ok(())
595}
596
597pub(super) fn validate_record(
598    record: &bacnet_types::constructed::BACnetAuditLogRecord,
599) -> Result<usize, Error> {
600    validate_record_dynamic_size(record)?;
601    let mut encoded = BytesMut::new();
602    encode_audit_log_record(record, &mut encoded)?;
603    if encoded.len() > MAX_RECORD_BYTES {
604        return Err(Error::OutOfRange(format!(
605            "AuditLog record length {} exceeds {MAX_RECORD_BYTES}",
606            encoded.len()
607        )));
608    }
609    Ok(encoded.len())
610}
611
612fn validate_record_dynamic_size(
613    record: &bacnet_types::constructed::BACnetAuditLogRecord,
614) -> Result<(), Error> {
615    let bacnet_types::constructed::BACnetAuditLogDatum::AuditNotification(notification) =
616        &record.datum
617    else {
618        return Ok(());
619    };
620    let recipient_size = |recipient: &bacnet_types::constructed::BACnetRecipient| match recipient {
621        bacnet_types::constructed::BACnetRecipient::Device(_) => 4,
622        bacnet_types::constructed::BACnetRecipient::Address(address) => address.mac_address.len(),
623    };
624    let lengths = [
625        recipient_size(&notification.source_device),
626        recipient_size(&notification.target_device),
627        notification.source_comment.as_ref().map_or(0, String::len),
628        notification.target_comment.as_ref().map_or(0, String::len),
629        notification.target_value.as_ref().map_or(0, Vec::len),
630        notification.current_value.as_ref().map_or(0, Vec::len),
631    ];
632    let dynamic = lengths.into_iter().try_fold(0usize, |sum, length| {
633        sum.checked_add(length)
634            .ok_or_else(|| Error::OutOfRange("AuditLog record length overflow".into()))
635    })?;
636    if dynamic > MAX_RECORD_BYTES {
637        return Err(Error::OutOfRange(format!(
638            "AuditLog record dynamic length {dynamic} exceeds {MAX_RECORD_BYTES}"
639        )));
640    }
641    Ok(())
642}
643
644fn take<'a>(
645    data: &'a [u8],
646    offset: &mut usize,
647    length: usize,
648    field: &str,
649) -> Result<&'a [u8], Error> {
650    let end = offset
651        .checked_add(length)
652        .ok_or_else(|| Error::OutOfRange(format!("AuditLog {field} length overflow")))?;
653    if end > data.len() {
654        return Err(Error::Encoding(format!(
655            "AuditLog snapshot {field} is truncated"
656        )));
657    }
658    let value = &data[*offset..end];
659    *offset = end;
660    Ok(value)
661}
662
663fn take_u32(data: &[u8], offset: &mut usize, field: &str) -> Result<u32, Error> {
664    Ok(u32::from_be_bytes(
665        take(data, offset, 4, field)?.try_into().unwrap(),
666    ))
667}
668
669fn take_u64(data: &[u8], offset: &mut usize, field: &str) -> Result<u64, Error> {
670    Ok(u64::from_be_bytes(
671        take(data, offset, 8, field)?.try_into().unwrap(),
672    ))
673}
674
675#[cfg(all(test, feature = "std"))]
676mod tests {
677    use super::*;
678    use bacnet_types::enums::ObjectType;
679
680    fn snapshot() -> AuditLogSnapshot {
681        AuditLogSnapshot {
682            object_identifier: ObjectIdentifier::new(ObjectType::AUDIT_LOG, 1).unwrap(),
683            generation: 1,
684            capacity: 1,
685            log_enable: true,
686            total_record_count: 0,
687            records: Vec::new(),
688            completed_receipts: Vec::new(),
689        }
690    }
691
692    fn io_failure() -> SlotState {
693        SlotState::Fatal(Error::Transport(std::io::Error::other(
694            "injected slot read failure",
695        )))
696    }
697
698    #[test]
699    fn audit_slot_selection_never_masks_io_failure_with_valid_peer() {
700        assert!(matches!(
701            select_newest_slot([io_failure(), SlotState::Valid(snapshot())]),
702            Err(Error::Transport(_))
703        ));
704        assert!(matches!(
705            select_newest_slot([SlotState::Valid(snapshot()), io_failure()]),
706            Err(Error::Transport(_))
707        ));
708    }
709
710    #[test]
711    fn audit_slot_selection_requires_identical_same_generation_snapshots() {
712        let expected = snapshot();
713        let selected = select_newest_slot([
714            SlotState::Valid(expected.clone()),
715            SlotState::Valid(expected.clone()),
716        ])
717        .unwrap()
718        .unwrap();
719        assert_eq!(selected, expected);
720
721        let mut divergent = snapshot();
722        divergent.log_enable = false;
723        assert!(matches!(
724            select_newest_slot([
725                SlotState::Valid(snapshot()),
726                SlotState::Valid(divergent),
727            ]),
728            Err(Error::Encoding(message)) if message.contains("same generation")
729        ));
730    }
731}